diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 06dcd69..5d8b4b1 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -96,9 +96,9 @@ describe("primitive types", () => { describe("getValueBigintWords", () => { it("reads words with null sign_bit (unsigned only)", () => { - expect(mod.bigIntFirstWord(0n)).toEqual(0); - expect(mod.bigIntFirstWord(1n)).toEqual(1); - expect(mod.bigIntFirstWord(0xdeadbeefn)).toEqual(0xdeadbeef); + expect(mod.bigIntSingleWord(0n)).toEqual(0); + expect(mod.bigIntSingleWord(1n)).toEqual(1); + expect(mod.bigIntSingleWord(0xdeadbeefn)).toEqual(0xdeadbeef); }); it("reads correct sign (non-null sign_bit path)", () => { @@ -110,6 +110,28 @@ describe("primitive types", () => { expect(mod.bigIntSign(-0xffffffffffffffffn)).toEqual(1); }); + it("rejects BigInts wider than the provided word buffer", () => { + expect(() => mod.bigIntSingleWord(2n ** 64n)).toThrow(); + expect(() => mod.bigIntSingleWord(2n ** 200n)).toThrow(); + }); + }); + + describe("toI128", () => { + it("round-trips values across the i128 range", () => { + expect(mod.bigIntToI128String(0n)).toEqual("0"); + expect(mod.bigIntToI128String(123n)).toEqual("123"); + expect(mod.bigIntToI128String(-123n)).toEqual("-123"); + expect(mod.bigIntToI128String(2n ** 127n - 1n)).toEqual((2n ** 127n - 1n).toString()); + expect(mod.bigIntToI128String(-(2n ** 127n))).toEqual((-(2n ** 127n)).toString()); + }); + + it("rejects BigInts outside the i128 range instead of crashing", () => { + expect(() => mod.bigIntToI128String(2n ** 127n)).toThrow(); + expect(() => mod.bigIntToI128String(-(2n ** 127n) - 1n)).toThrow(); + expect(() => mod.bigIntToI128String(2n ** 128n)).toThrow(); + expect(() => mod.bigIntToI128String(2n ** 200n)).toThrow(); + expect(() => mod.bigIntToI128String(-(2n ** 200n))).toThrow(); + }); }); it("tomorrow adds one day", () => { @@ -125,6 +147,25 @@ describe("primitive types", () => { }); }); +describe("value narrowing", () => { + it("narrows numbers", () => { + expect(mod.narrowToNumber(42)).toEqual(42); + }); + + it("rejects non-numbers", () => { + expect(() => mod.narrowToNumber("42")).toThrow(); + expect(() => mod.narrowToNumber({})).toThrow(); + expect(() => mod.narrowToNumber(42n)).toThrow(); + }); + + it("narrows typed arrays by exact subtype", () => { + expect(mod.narrowToUint8ArrayLen(new Uint8Array(3))).toEqual(3); + expect(() => mod.narrowToUint8ArrayLen(new Int8Array(3))).toThrow(); + expect(() => mod.narrowToUint8ArrayLen(new Uint8ClampedArray(3))).toThrow(); + expect(() => mod.narrowToUint8ArrayLen([1, 2, 3])).toThrow(); + }); +}); + // Section 4: Typed Objects describe("typed objects", () => { it("formatConfig returns formatted string", () => { @@ -235,6 +276,12 @@ describe("Counter class", () => { expect(() => getCount.call(new mod.Buffer(4))).toThrow(); }); + it("rejects constructor calls without new", () => { + expect(() => mod.Counter(5)).toThrow(TypeError); + expect(() => mod.Point()).toThrow(TypeError); + expect(() => Reflect.apply(mod.Counter, undefined, [5])).toThrow(TypeError); + }); + it("increments", () => { const c = new mod.Counter(0); c.increment(); diff --git a/examples/js_dsl/mod.zig b/examples/js_dsl/mod.zig index 9387dd9..0d13d9c 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -100,13 +100,12 @@ pub fn doubleBigInt(n: BigInt) !BigInt { return BigInt.from(val * 2); } -/// Read a BigInt's first u64 word via `getValueBigintWords` passing `null` for `sign_bit`. +/// Read a single-word BigInt as a u64 via `getValueBigintWords` passing `null` for `sign_bit`. /// -/// Throws if word_count > 1. -pub fn bigIntFirstWord(n: BigInt) !Number { +/// Throws `Overflow` if the BigInt needs more than one word. +pub fn bigIntSingleWord(n: BigInt) !Number { var words: [1]u64 = .{0}; - const got = try n.toValue().getValueBigintWords(null, &words); - if (got.len > 1) return error.BigIntTooLarge; + _ = try n.toValue().getValueBigintWords(null, &words); return Number.from(words[0]); } @@ -118,6 +117,14 @@ pub fn bigIntSign(n: BigInt) !Number { return Number.from(@as(u32, sign)); } +/// Convert a BigInt to an i128 and render it as a decimal string. +pub fn bigIntToI128String(n: BigInt) !String { + const v = try n.toI128(); + var buf: [48]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "{d}", .{v}) catch return error.FormatError; + return String.from(s); +} + /// Add one day (86400000ms) to a Date. pub fn tomorrow(d: Date) Date { const ts = d.assertTimestamp(); @@ -309,6 +316,18 @@ pub const Buffer = struct { // Section 10: Mixed DSL + N-API // ============================================================================ +/// Narrow an untyped value to a Number via the `js.Value` narrowing API. +pub fn narrowToNumber(v: Value) !Number { + return v.asNumber(); +} + +/// Narrow an untyped value to a Uint8Array and return its length. +pub fn narrowToUint8ArrayLen(v: Value) !Number { + const arr = try v.asUint8Array(); + const slice = try arr.toSlice(); + return Number.from(@as(u32, @intCast(slice.len))); +} + /// Return the JS typeof string for any value. /// Demonstrates dropping down to low-level napi to call raw N-API methods. pub fn getTypeOf(val: Value) !String { diff --git a/src/Value.zig b/src/Value.zig index 2429bcd..5912f98 100644 --- a/src/Value.zig +++ b/src/Value.zig @@ -272,16 +272,21 @@ pub fn getValueBigintUint64(self: Value, lossless: ?*bool) NapiError!u64 { /// In Ethereum's context, this is useful for big integers defined in the spec to be /// unsigned. /// +/// Returns `error.Overflow` if the BigInt needs more words than `words` can hold: +/// napi sets the out `word_count` to the *required* count, which may exceed the +/// buffer, so slicing by it unchecked would read out of bounds. +/// /// NOTE: napi's C entry takes `int*` (4-byte aligned). Casting a u1 to int* is UB, since that /// is 4-bytes aligned. We use a local `c_int` for the napi call and narrow back to `u1` for the caller. /// /// Source: https://nodejs.org/api/n-api.html#napi_get_value_bigint_words -pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) NapiError![]u64 { +pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) (NapiError || error{Overflow})![]u64 { var word_count: usize = words.len; var raw_sign: c_int = 0; try status.check( c.napi_get_value_bigint_words(self.env, self.value, &raw_sign, &word_count, words.ptr), ); + if (word_count > words.len) return error.Overflow; // napi guarantees raw_sign ∈ {0, 1} if (sign_bit) |s| s.* = @intCast(raw_sign); return words[0..word_count]; diff --git a/src/callback.zig b/src/callback.zig index e5b2453..076772f 100644 --- a/src/callback.zig +++ b/src/callback.zig @@ -40,7 +40,9 @@ pub fn wrapCallback( e.throwError(@errorName(err), "NapiError") catch {}; } else { const message: [:0]const u8 = "Native callback failed"; - e.throwError(@tagName(error_info_status), message) catch {}; + // @tagName would panic on a status the enum doesn't name. + const code = std.enums.tagName(status.Status, error_info_status) orelse "NapiError"; + e.throwError(code, message) catch {}; } } else { e.throwError(@errorName(err), @errorName(err)) catch {}; diff --git a/src/js/bigint.zig b/src/js/bigint.zig index fd38ebd..f27d0fd 100644 --- a/src/js/bigint.zig +++ b/src/js/bigint.zig @@ -38,19 +38,22 @@ pub const BigInt = struct { /// /// This function reads the BigInt as two 64-bit words and reconstructs it /// into a Zig `i128`. It handles both positive and negative BigInts. - /// Returns an error if N-API operations fail. + /// Returns `error.Overflow` if the value is outside the i128 range, and an + /// error if N-API operations fail. pub fn toI128(self: BigInt) !i128 { var sign_bit: u1 = 0; var words: [2]u64 = .{ 0, 0 }; const result = try self.val.getValueBigintWords(&sign_bit, &words); - const lo: u128 = result[0]; + const lo: u128 = if (result.len > 0) result[0] else 0; const hi: u128 = if (result.len > 1) result[1] else 0; const magnitude: u128 = (hi << 64) | lo; + const max_positive: u128 = std.math.maxInt(i128); if (sign_bit == 1) { - // Negative: negate the magnitude - if (magnitude == 0) return 0; + if (magnitude > max_positive + 1) return error.Overflow; + if (magnitude == max_positive + 1) return std.math.minInt(i128); return -@as(i128, @intCast(magnitude)); } + if (magnitude > max_positive) return error.Overflow; return @intCast(magnitude); } diff --git a/src/js/class_runtime.zig b/src/js/class_runtime.zig index edf52ef..5549f62 100644 --- a/src/js/class_runtime.zig +++ b/src/js/class_runtime.zig @@ -1,5 +1,6 @@ const std = @import("std"); const napi = @import("../napi.zig"); +const context = @import("context.zig"); pub fn typeTag(comptime T: type) napi.c.napi_type_tag { return .{ @@ -8,15 +9,17 @@ pub fn typeTag(comptime T: type) napi.c.napi_type_tag { }; } +/// Tags `object`, then wraps `native_object` into it with a finalizer. +/// +/// The wrap is done last so it is the only fallible step that transfers +/// ownership: once it succeeds N-API's finalizer owns `native_object`, and any +/// earlier failure leaves nothing wrapped, so the caller still owns and frees it. pub fn wrapTaggedObject(comptime T: type, env: napi.Env, object: napi.Value, native_object: *T) !void { const tag = typeTag(T); - try env.wrap(object, T, native_object, defaultFinalize(T), null, null); - errdefer if (env.removeWrap(T, object)) |removed| { - destroyNativeObject(T, removed); - } else |_| {}; if (!(try env.checkObjectTypeTag(object, tag))) { try env.typeTagObject(object, tag); } + try env.wrap(object, T, native_object, defaultFinalize(T), null, null); } /// Generates a deterministic 64-bit FNV-1a hash at compile-time. @@ -41,7 +44,7 @@ pub fn destroyNativeObject(comptime T: type, obj: *T) void { if (@hasDecl(T, "deinit")) { obj.deinit(); } - std.heap.c_allocator.destroy(obj); + context.allocator().destroy(obj); } pub fn defaultFinalize(comptime T: type) napi.FinalizeCallback(T) { @@ -60,17 +63,20 @@ pub fn registerClass(comptime T: type, env: napi.Env, ctor: napi.Value) !void { if (State.find(env.env) != null) return; - const entry = try std.heap.c_allocator.create(State.Entry); - errdefer std.heap.c_allocator.destroy(entry); + const entry = try context.allocator().create(State.Entry); + errdefer context.allocator().destroy(entry); entry.* = .{ .env = env.env, .ctor_ref = try env.createReference(ctor, 1), .next = State.head, }; - State.head = entry; + errdefer entry.ctor_ref.delete() catch {}; + // Link only after the cleanup hook is registered: a hook failure must not + // leave a freed entry reachable from the list head. try env.addEnvCleanupHook(State.Entry, entry, State.cleanupHook); + State.head = entry; } /// Per-thread marker set by `materializeClassInstance` to tell the generated @@ -110,7 +116,7 @@ pub fn consumeMaterialization(comptime T: type, env: napi.Env, this_arg: napi.c. pub fn materializeClassInstance(comptime T: type, env: napi.Env, instance: T, preferred_ctor: ?napi.Value) !napi.Value { const ctor = preferred_ctor orelse try getConstructor(T, env); - const obj_ptr = try std.heap.c_allocator.create(T); + const obj_ptr = try context.allocator().create(T); errdefer destroyNativeObject(T, obj_ptr); obj_ptr.* = instance; @@ -198,7 +204,7 @@ fn state(comptime T: type) type { if (current == entry) { cursor.* = current.next; current.ctor_ref.delete() catch {}; - std.heap.c_allocator.destroy(current); + context.allocator().destroy(current); return; } cursor = ¤t.next; diff --git a/src/js/value.zig b/src/js/value.zig index 73670ac..84d0aaa 100644 --- a/src/js/value.zig +++ b/src/js/value.zig @@ -245,4 +245,15 @@ pub const Value = struct { pub fn toValue(self: Value) napi.Value { return self.val; } + + fn expectType(self: Value, expected: ValueType) TypeError!void { + const actual = self.val.typeof() catch return error.TypeMismatch; + if (actual != expected) return error.TypeMismatch; + } + + fn expectTypedArrayOfType(self: Value, expected: TypedarrayType) TypeError!void { + if (!(self.val.isTypedarray() catch return error.TypeMismatch)) return error.TypeMismatch; + const info = self.val.getTypedarrayInfo() catch return error.TypeMismatch; + if (info.array_type != expected) return error.TypeMismatch; + } }; diff --git a/src/js/wrap_class.zig b/src/js/wrap_class.zig index bb20974..4d61239 100644 --- a/src/js/wrap_class.zig +++ b/src/js/wrap_class.zig @@ -360,6 +360,17 @@ pub fn wrapClass(comptime T: type) type { return null; }; + // Without `new`, `this` is the global proxy; wrapping it would + // attach native state and a finalizer to globalThis. + const new_target = e.getNewTarget(cb_info) catch { + e.throwError("", "Failed to get new.target in constructor") catch {}; + return null; + }; + if (new_target.value == null) { + e.throwTypeError("", "Class constructor cannot be invoked without 'new'") catch {}; + return null; + } + // Fast path: materializeClassInstance is creating this instance. // Skip normal init; materialize will wrap the returned JS instance // with the real native pointer after napi_new_instance returns. @@ -390,16 +401,22 @@ pub fn wrapClass(comptime T: type) type { }; } - const init_result = callInit(init_fn, args) orelse return null; - - const obj_ptr = std.heap.c_allocator.create(T) catch { + // Allocate before init so a failed allocation can't strand + // the resources init_result owns. + const obj_ptr = context.allocator().create(T) catch { e.throwError("", "Out of memory allocating native object") catch {}; return null; }; + + const init_result = callInit(init_fn, args) orelse { + context.allocator().destroy(obj_ptr); + return null; + }; obj_ptr.* = init_result; const this_val = napi.Value{ .env = raw_env, .value = this_arg }; class_runtime.wrapTaggedObject(T, e, this_val, obj_ptr) catch { + class_runtime.destroyNativeObject(T, obj_ptr); e.throwError("", "Failed to wrap native object") catch {}; return null; }; diff --git a/src/module.zig b/src/module.zig index d834556..d64ae9a 100644 --- a/src/module.zig +++ b/src/module.zig @@ -13,7 +13,9 @@ pub fn register(comptime f: fn (Env, Value) anyerror!void) void { .value = module, }; f(e, v) catch |err| { - e.throwError(@errorName(err), "Error in module registration") catch unreachable; + // throwError fails if an exception is already pending; that + // exception is the failure report, so never abort here. + e.throwError(@errorName(err), "Error in module registration") catch {}; }; return module; } diff --git a/src/status.zig b/src/status.zig index c20fc2c..58a016a 100644 --- a/src/status.zig +++ b/src/status.zig @@ -26,6 +26,8 @@ pub const Status = enum(c.napi_status) { would_deadlock = c.napi_would_deadlock, no_external_buffers_allowed = c.napi_no_external_buffers_allowed, cannot_run_js = c.napi_cannot_run_js, + // Non-exhaustive: future Node versions may add statuses. + _, }; pub const NapiError = error{ @@ -80,6 +82,7 @@ pub fn check(code: c_uint) NapiError!void { .would_deadlock => return error.WouldDeadlock, .no_external_buffers_allowed => return error.NoExternalBuffersAllowed, .cannot_run_js => return error.CannotRunJS, + _ => return error.GenericFailure, } } @@ -112,3 +115,9 @@ test "isNapiError identifies napi errors" { try std.testing.expect(isNapiError(error.GenericFailure)); try std.testing.expect(!isNapiError(error.OutOfMemory)); } + +test "check maps unknown status codes to GenericFailure" { + // Future Node versions may return statuses this enum doesn't know about; + // they must map to an error, not invalid-enum UB. + try std.testing.expectError(error.GenericFailure, check(9999)); +} diff --git a/src/value_types.zig b/src/value_types.zig index bd21a00..38efe09 100644 --- a/src/value_types.zig +++ b/src/value_types.zig @@ -34,6 +34,8 @@ pub const ValueType = enum(c.napi_valuetype) { function = c.napi_function, external = c.napi_external, bigint = c.napi_bigint, + // Non-exhaustive: future Node versions may add value types. + _, }; /// https://nodejs.org/api/n-api.html#napi_typedarray_type @@ -76,6 +78,11 @@ pub const TypedarrayType = enum(c.napi_typedarray_type) { } }; +test "elementSize covers known typedarray types" { + try @import("std").testing.expectEqual(@as(usize, 1), TypedarrayType.uint8.elementSize()); + try @import("std").testing.expectEqual(@as(usize, 8), TypedarrayType.biguint64.elementSize()); +} + /// https://nodejs.org/api/n-api.html#napi_property_attributes pub const PropertyAttributes = enum(c.napi_property_attributes) { default = c.napi_default,