Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## UNRELEASED

- When a `nativeCallback` or `importCallback` function throws (synchronously or asynchronously), the `JsonnetError` rejection now carries the original JavaScript error as its `cause` property.
- Cyclic references in values returned from a native callback are now detected and reported as an exception, instead of crashing the VM with a stack overflow.
- Native callbacks now honor `toJSON()` on returned values; values that are not serializable (functions, symbols) are omitted from objects or serialized as `null` in arrays, consistent with `JSON.stringify` semantics.
- Fix: `evaluateSnippetMulti` and `evaluateFileMulti` now correctly preserve a file key named `__proto__` in the returned object (no security impact: the key was silently dropped rather than causing prototype pollution).
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"devDependencies": {
"@types/node": "20",
"glob": "^13.0.0",
"jasmine": "^6.0.0",
"jasmine": "^6.2.0",
"tsx": "^4.22.3",
"tstyche": "^7.0.0",
"typedoc": "^0.28.1",
Expand Down
49 changes: 35 additions & 14 deletions spec/binding_spec.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -351,18 +351,18 @@ describe('binding', () => {

it('propagates rejection from thenable returned by native callback', async () => {
const jsonnet = new Jsonnet();
jsonnet.nativeCallback("fail", (msg) => ({ then: (_, reject) => reject(msg) }), "msg");
jsonnet.nativeCallback("fail", (msg) => ({ then: (_, reject) => reject(new Error(msg)) }), "msg");

await expectAsync(jsonnet.evaluateSnippet(`std.native("fail")("kimagure")`))
.toBeRejectedWithError(JsonnetError, /^RUNTIME ERROR: kimagure/);
.toBeRejectedWithError(JsonnetError, /^RUNTIME ERROR:.* kimagure/);
});

it('propagates synchronous throw from .then() on promise returned by native callback', async () => {
const jsonnet = new Jsonnet();
jsonnet.nativeCallback("fail", () => ({ then: () => { throw "then threw"; } }));
jsonnet.nativeCallback("fail", () => ({ then: () => { throw new Error("then threw"); } }));

await expectAsync(jsonnet.evaluateSnippet(`std.native("fail")()`))
.toBeRejectedWithError(JsonnetError, /^RUNTIME ERROR: then threw/);
.toBeRejectedWithError(JsonnetError, /^RUNTIME ERROR:.* then threw/);
});

it('uses the native callback added most recently for the same name', async () => {
Expand All @@ -389,10 +389,15 @@ describe('binding', () => {
it('reports throwing native callback', async () => {
const jsonnet = new Jsonnet();

jsonnet.nativeCallback("fail", (msg) => { throw msg; }, "msg");
jsonnet.nativeCallback("fail", (msg) => { throw new TypeError(msg); }, "msg");
await expectAsync(jsonnet.evaluateSnippet(`std.native("fail")("kimagure")`))
.toBeRejectedWithError(JsonnetError, /^RUNTIME ERROR: kimagure/);

.toBeRejectedWithMatching(err => {
expect(err).toBeInstanceOf(JsonnetError);
expect(err.message).toMatch(/^RUNTIME ERROR:.* kimagure/);
expect(err.cause).toBeInstanceOf(TypeError);
expect(err.cause.message).toEqual("kimagure");
return true;
});
});

it('propagates error when native callback result object has a throwing ownKeys trap', async () => {
Expand Down Expand Up @@ -430,9 +435,15 @@ describe('binding', () => {
it('reports throwing async native callback', async () => {
const jsonnet = new Jsonnet();

jsonnet.nativeCallback("failAsync", async (msg) => { throw msg; }, "msg");
jsonnet.nativeCallback("failAsync", async (msg) => { throw new Error(msg); }, "msg");
await expectAsync(jsonnet.evaluateSnippet(`std.native("failAsync")("kimagure")`))
.toBeRejectedWithError(JsonnetError, /^RUNTIME ERROR: kimagure/);
.toBeRejectedWithMatching(err => {
expect(err).toBeInstanceOf(JsonnetError);
expect(err.message).toMatch(/^RUNTIME ERROR:.* kimagure/);
expect(err.cause).toBeInstanceOf(Error);
expect(err.cause.message).toEqual("kimagure");
return true;
});
});

it('reports syntax error in snippet with filename', async () => {
Expand Down Expand Up @@ -658,7 +669,7 @@ describe('binding', () => {

it('propagates synchronous throw from .then() on promise returned by import callback', async () => {
const jsonnet = new Jsonnet()
.importCallback(() => ({ then: () => { throw "then threw"; } }));
.importCallback(() => ({ then: () => { throw new Error("then threw"); } }));
await expectAsync(jsonnet.evaluateSnippet('import "x.jsonnet"'))
.toBeRejectedWithError(JsonnetError, /then threw/);
});
Expand Down Expand Up @@ -695,16 +706,26 @@ describe('binding', () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => { throw new Error(`missing: ${rel}`); });
await expectAsync(jsonnet.evaluateSnippet('import "x.jsonnet"'))
.toBeRejectedWithError(JsonnetError,
/missing: x\.jsonnet/);
.toBeRejectedWithMatching(err => {
expect(err).toBeInstanceOf(JsonnetError);
expect(err.message).toMatch(/missing: x\.jsonnet/);
expect(err.cause).toBeInstanceOf(Error);
expect(err.cause.message).toEqual('missing: x.jsonnet');
return true;
});
});

it('propagates async rejection as JsonnetError', async () => {
const jsonnet = new Jsonnet()
.importCallback(async (base, rel) => { throw new Error(`missing: ${rel}`); });
await expectAsync(jsonnet.evaluateSnippet('import "x.jsonnet"'))
.toBeRejectedWithError(JsonnetError,
/missing: x\.jsonnet/);
.toBeRejectedWithMatching(err => {
expect(err).toBeInstanceOf(JsonnetError);
expect(err.message).toMatch(/missing: x\.jsonnet/);
expect(err.cause).toBeInstanceOf(Error);
expect(err.cause.message).toEqual('missing: x.jsonnet');
return true;
});
});

it('takes precedence over addJpath', async () => {
Expand Down
12 changes: 12 additions & 0 deletions src/Callback.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
#include "Callback.hpp"

namespace nodejsonnet {

CallbackError::CallbackError(std::string const &resourceName, Napi::Error e)
: std::runtime_error{std::string("JavaScript exception throw in ") + resourceName + ": " +
e.Message()},
jsError{std::make_shared<Napi::Error>(e)} {
}

}
20 changes: 12 additions & 8 deletions src/Callback.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@

namespace nodejsonnet {

// Wrap Napi::Error in plain C++ error so it can safely pass throgh non-JS thread
struct CallbackError: std::runtime_error {
explicit CallbackError(std::string const &resourceName, Napi::Error e);
std::shared_ptr<Napi::Error> jsError;
};

template <typename Result> struct CallbackPayload {
explicit CallbackPayload(std::shared_ptr<JsonnetVm> vm): vm{std::move(vm)} {
}
Expand Down Expand Up @@ -74,12 +80,12 @@ namespace nodejsonnet {

auto const on_success = Napi::Function::New(
env,
[](Napi::CallbackInfo const &info) {
[](Napi::CallbackInfo const &info) noexcept {
auto const p = static_cast<PayloadType *>(info.Data());
try {
p->resolveResult(info[0]);
} catch(Napi::Error const &e) {
p->setError(std::make_exception_ptr(std::runtime_error(e.Message())));
p->setError(std::make_exception_ptr(CallbackError{PayloadType::resourceName, e}));
} catch(...) {
p->setError(std::current_exception());
}
Expand All @@ -88,13 +94,11 @@ namespace nodejsonnet {

auto const on_failure = Napi::Function::New(
env,
[](Napi::CallbackInfo const &info) {
[](Napi::CallbackInfo const &info) noexcept {
auto const p = static_cast<PayloadType *>(info.Data());
try {
auto const error = info[0].ToString();
p->setError(std::make_exception_ptr(std::runtime_error(error)));
} catch(Napi::Error const &e) {
p->setError(std::make_exception_ptr(std::runtime_error(e.Message())));
p->setError(std::make_exception_ptr(
CallbackError{PayloadType::resourceName, Napi::Error(info.Env(), info[0])}));
} catch(...) {
p->setError(std::current_exception());
}
Expand All @@ -103,7 +107,7 @@ namespace nodejsonnet {

result.template As<Napi::Promise>().Then(on_success, on_failure);
} catch(Napi::Error const &e) {
payload->setError(std::make_exception_ptr(std::runtime_error(e.Message())));
payload->setError(std::make_exception_ptr(CallbackError{PayloadType::resourceName, e}));
} catch(...) {
payload->setError(std::current_exception());
}
Expand Down
136 changes: 18 additions & 118 deletions src/Jsonnet.cpp
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
// SPDX-License-Identifier: MIT
#include "Jsonnet.hpp"
#include <functional>
#include <future>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "JsonValueConverter.hpp"
#include "JsonnetImportCallback.hpp"
#include "JsonnetNativeCallback.hpp"
#include "JsonnetWorker.hpp"
#include "libjsonnet.h"

namespace nodejsonnet {
Expand Down Expand Up @@ -79,81 +72,50 @@ namespace nodejsonnet {
return info.This();
}

Napi::Value Jsonnet::evaluateFile(const Napi::CallbackInfo &info) {
auto const env = info.Env();
auto filename = info[0].As<Napi::String>().Utf8Value();

auto vm = createVm(env);
auto const worker = new JsonnetWorker(
env, vm, std::make_unique<JsonnetWorker::EvaluateFileOp>(std::move(filename)));
Napi::Value Jsonnet::evaluate(Napi::Env const &env, std::unique_ptr<JsonnetWorker::Op> op) {
auto const worker = new JsonnetWorker(env, *this, std::move(op));
auto const promise = worker->Promise();
worker->Queue();
worker->Queue(); // worker is deleted when it is done
return promise;
}

Napi::Value Jsonnet::evaluateFile(const Napi::CallbackInfo &info) {
auto filename = info[0].As<Napi::String>().Utf8Value();
return evaluate(
info.Env(), std::make_unique<JsonnetWorker::EvaluateFileOp>(std::move(filename)));
}

Napi::Value Jsonnet::evaluateSnippet(const Napi::CallbackInfo &info) {
auto const env = info.Env();
auto snippet = info[0].As<Napi::String>().Utf8Value();
auto filename = info.Length() < 2 ? "(snippet)" : info[1].As<Napi::String>().Utf8Value();

auto vm = createVm(env);
auto const worker = new JsonnetWorker(env, vm,
return evaluate(info.Env(),
std::make_unique<JsonnetWorker::EvaluateSnippetOp>(std::move(snippet), std::move(filename)));
auto const promise = worker->Promise();
worker->Queue();
return promise;
}

Napi::Value Jsonnet::evaluateFileMulti(const Napi::CallbackInfo &info) {
auto const env = info.Env();
auto filename = info[0].As<Napi::String>().Utf8Value();

auto vm = createVm(env);
auto const worker = new JsonnetWorker(
env, vm, std::make_unique<JsonnetWorker::EvaluateFileMultiOp>(std::move(filename)));
auto const promise = worker->Promise();
worker->Queue();
return promise;
return evaluate(
info.Env(), std::make_unique<JsonnetWorker::EvaluateFileMultiOp>(std::move(filename)));
}

Napi::Value Jsonnet::evaluateSnippetMulti(const Napi::CallbackInfo &info) {
auto const env = info.Env();
auto snippet = info[0].As<Napi::String>().Utf8Value();
auto filename = info.Length() < 2 ? "(snippet)" : info[1].As<Napi::String>().Utf8Value();

auto vm = createVm(env);
auto const worker = new JsonnetWorker(env, vm,
std::make_unique<JsonnetWorker::EvaluateSnippetMultiOp>(
std::move(snippet), std::move(filename)));
auto const promise = worker->Promise();
worker->Queue();
return promise;
return evaluate(info.Env(), std::make_unique<JsonnetWorker::EvaluateSnippetMultiOp>(
std::move(snippet), std::move(filename)));
}

Napi::Value Jsonnet::evaluateFileStream(const Napi::CallbackInfo &info) {
auto const env = info.Env();
auto filename = info[0].As<Napi::String>().Utf8Value();

auto vm = createVm(env);
auto const worker = new JsonnetWorker(
env, vm, std::make_unique<JsonnetWorker::EvaluateFileStreamOp>(std::move(filename)));
auto const promise = worker->Promise();
worker->Queue();
return promise;
return evaluate(
info.Env(), std::make_unique<JsonnetWorker::EvaluateFileStreamOp>(std::move(filename)));
}

Napi::Value Jsonnet::evaluateSnippetStream(const Napi::CallbackInfo &info) {
auto const env = info.Env();
auto snippet = info[0].As<Napi::String>().Utf8Value();
auto filename = info.Length() < 2 ? "(snippet)" : info[1].As<Napi::String>().Utf8Value();

auto vm = createVm(env);
auto const worker = new JsonnetWorker(env, vm,
std::make_unique<JsonnetWorker::EvaluateSnippetStreamOp>(
std::move(snippet), std::move(filename)));
auto const promise = worker->Promise();
worker->Queue();
return promise;
return evaluate(info.Env(), std::make_unique<JsonnetWorker::EvaluateSnippetStreamOp>(
std::move(snippet), std::move(filename)));
}

Napi::Value Jsonnet::extString(const Napi::CallbackInfo &info) {
Expand Down Expand Up @@ -211,66 +173,4 @@ namespace nodejsonnet {
return info.This();
}

std::shared_ptr<JsonnetVm> Jsonnet::createVm(Napi::Env const &env) {
auto vm = JsonnetVm::make();

if(maxStack) {
vm->maxStack(*maxStack);
}
if(maxTrace) {
vm->maxTrace(*maxTrace);
}
if(gcMinObjects) {
vm->gcMinObjects(*gcMinObjects);
}
if(gcGrowthTrigger) {
vm->gcGrowthTrigger(*gcGrowthTrigger);
}
vm->stringOutput(stringOutput);
vm->trailingNewline(trailingNewline);

for(auto const &[name, var]: ext) {
if(var.isCode) {
vm->extCode(name, var.value);
} else {
vm->extVar(name, var.value);
}
}

for(auto const &[name, var]: tla) {
if(var.isCode) {
vm->tlaCode(name, var.value);
} else {
vm->tlaVar(name, var.value);
}
}

for(auto const &x: jpath) {
vm->jpathAdd(x);
}

for(auto const &[name, cb]: nativeCallbacks) {
auto const &fun = cb.fun;
auto const &params = cb.params;

vm->addNativeCallback(
name,
[callback = std::make_shared<JsonnetNativeCallback>(env, fun.Value())](
std::shared_ptr<JsonnetVm> vm, std::vector<JsonnetJsonValue const *> args) {
return callback->call(std::move(vm), std::move(args));
},
params);
}

if(importCallbackParam) {
vm->setImportCallback(
[callback = std::make_shared<JsonnetImportCallback>(env, importCallbackParam->fun.Value())](
std::shared_ptr<JsonnetVm> vm, std::string const &base, std::string const &rel) {
return callback->call(std::move(vm), base, rel);
});
}

return vm;
}

}
Loading
Loading