diff --git a/examples/js_dsl/mod.test.ts b/examples/js_dsl/mod.test.ts index 42db759..f5f8adf 100644 --- a/examples/js_dsl/mod.test.ts +++ b/examples/js_dsl/mod.test.ts @@ -110,6 +110,28 @@ describe("primitive types", () => { expect(mod.bigIntSign(-0xffffffffffffffffn)).toEqual(1); }); + it("rejects BigInts wider than the provided word buffer", () => { + expect(() => mod.bigIntFirstWord(2n ** 64n)).toThrow(); + expect(() => mod.bigIntFirstWord(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", () => { @@ -220,6 +261,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 99f5438..a0f9754 100644 --- a/examples/js_dsl/mod.zig +++ b/examples/js_dsl/mod.zig @@ -102,11 +102,10 @@ pub fn doubleBigInt(n: BigInt) !BigInt { /// Read a BigInt's first u64 word via `getValueBigintWords` passing `null` for `sign_bit`. /// -/// Throws if word_count > 1. +/// Throws `Overflow` if the BigInt needs more than one word. pub fn bigIntFirstWord(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 bd3b206..3f179c9 100644 --- a/src/Value.zig +++ b/src/Value.zig @@ -149,7 +149,8 @@ pub fn getTypedarrayInfo(self: Value) NapiError!TypedarrayInfo { try status.check( c.napi_get_typedarray_info(self.env, self.value, @ptrCast(&info.array_type), &info.length, @ptrCast(&data), @ptrCast(&info.arraybuffer), &info.byte_offset), ); - info.data = data[0 .. info.length * info.array_type.elementSize()]; + const elem_size = info.array_type.elementSize() orelse return error.GenericFailure; + info.data = data[0 .. info.length * elem_size]; return info; } @@ -223,16 +224,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/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..e0c468a 100644 --- a/src/js/class_runtime.zig +++ b/src/js/class_runtime.zig @@ -8,12 +8,12 @@ pub fn typeTag(comptime T: type) napi.c.napi_type_tag { }; } +/// On error the caller keeps ownership of `native_object`: the wrap is +/// detached so the finalizer cannot fire, but the object is not destroyed. 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 |_| {}; + errdefer _ = env.removeWrap(T, object) catch {}; if (!(try env.checkObjectTypeTag(object, tag))) { try env.typeTagObject(object, tag); } @@ -68,9 +68,12 @@ pub fn registerClass(comptime T: type, env: napi.Env, ctor: napi.Value) !void { .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 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..c8499f0 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. @@ -400,6 +411,7 @@ pub fn wrapClass(comptime T: type) type { 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/js/wrap_function.zig b/src/js/wrap_function.zig index fcaf689..cafabfc 100644 --- a/src/js/wrap_function.zig +++ b/src/js/wrap_function.zig @@ -75,6 +75,7 @@ fn typedArrayName(comptime array_type: napi.value_types.TypedarrayType) []const .float64 => "Float64Array", .bigint64 => "BigInt64Array", .biguint64 => "BigUint64Array", + _ => "TypedArray", }; } 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..62485ca 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 @@ -49,13 +51,18 @@ pub const TypedarrayType = enum(c.napi_typedarray_type) { float64 = c.napi_float64_array, bigint64 = c.napi_bigint64_array, biguint64 = c.napi_biguint64_array, + // Non-exhaustive: engines already ship array types napi doesn't name yet + // (e.g. Float16Array); unknown values must be representable, not UB. + _, - pub fn elementSize(self: TypedarrayType) usize { + /// Returns null for typedarray types this binding doesn't know about. + pub fn elementSize(self: TypedarrayType) ?usize { switch (self) { .int8, .uint8, .uint8_clamped => return 1, .int16, .uint16 => return 2, .int32, .uint32, .float32 => return 4, .float64, .bigint64, .biguint64 => return 8, + _ => return null, } } @@ -72,10 +79,22 @@ pub const TypedarrayType = enum(c.napi_typedarray_type) { .float64 => return f64, .bigint64 => return i64, .biguint64 => return u64, + _ => @compileError("unknown typedarray type"), } } }; +test "elementSize returns null for unknown typedarray types" { + // e.g. Float16Array on newer engines: unknown values must not be UB. + const unknown: TypedarrayType = @enumFromInt(999); + try @import("std").testing.expect(unknown.elementSize() == null); +} + +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,