Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f52e1c9
chore(deps): add fast-check and test:fuzz script
nazarhussain May 28, 2026
ba7cca7
chore(tests): scaffold fuzz_numeric addon module
nazarhussain May 28, 2026
5838156
test(fuzz): add rtNumberF64 round-trip fuzz target
nazarhussain May 28, 2026
189ed3a
test(fuzz): add Number i32/u32/i64 round-trip properties
nazarhussain May 28, 2026
0f1fa56
test(fuzz): add BigInt i64/u64 round-trip properties
nazarhussain May 28, 2026
76df5ca
test(fuzz): add lossless-flag introspection for toI64/toU64
nazarhussain May 28, 2026
6c2cfed
fix(napi): clamp getValueBigintWords return slice to buffer size
nazarhussain May 28, 2026
6f9bf72
fix(dsl): make BigInt.toI128 total over all BigInts
nazarhussain May 28, 2026
c3e12a1
feat(dsl): add BigInt.fromWords for word-level construction
nazarhussain May 28, 2026
7f7d26e
test(fuzz): add rtBigIntI128 round-trip target
nazarhussain May 28, 2026
34db241
chore: apply zig fmt reorder to @ptrCast(@alignCast)
nazarhussain May 28, 2026
3fd5552
test(fuzz): add BigInt word-level round-trip
nazarhussain May 28, 2026
13456e0
ci: add fuzz tests job
nazarhussain May 28, 2026
b3fcea4
docs(fuzz): document seeds workflow and dev loop
nazarhussain May 28, 2026
e597b91
docs(fuzz): correct seeds README — drop unimplemented __special reviver
nazarhussain May 29, 2026
9ccafd2
chore: restructure oracle code
nazarhussain May 29, 2026
5fb5841
doc: update doc reference for practically accurate doc comments.
nazarhussain May 29, 2026
e472e7f
fix: add to*LowBits interface for lossy values to keep the safety as …
nazarhussain May 29, 2026
558f7c3
test: add uint8array fuzz tests
nazarhussain May 29, 2026
82a3e1b
chore: restructure tests files
nazarhussain May 29, 2026
23674b4
chore: rename fuzz tests script
nazarhussain May 29, 2026
2e53952
Merge remote-tracking branch 'origin/main' into nh/fuzz-testing
nazarhussain Aug 12, 2026
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ jobs:
- name: Test
run: pnpm test:js

test-fuzz:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems a little strange to fuzz in CI

name: Fuzz Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-env
- name: Build
run: pnpm build
- name: Fuzz
run: pnpm test:fuzz:round_trip
env:
FUZZ_RUNS: "1000"

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ c.count; // 1 (getter, not a method call)
| `Number` | `number` | `toI32()`, `toF64()`, `assertI32()`, `from(anytype)` |
| `String` | `string` | `toSlice(buf)`, `toOwnedSlice(alloc)`, `len()`, `from([]const u8)` |
| `Boolean` | `boolean` | `toBool()`, `assertBool()`, `from(bool)` |
| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `from(anytype)` |
| `BigInt` | `bigint` | `toI64()`, `toU64()`, `toI128()`, `to*LowBits()`, `from(anytype)`, `fromWords(sign, words)` |
| `Date` | `Date` | `toTimestamp()`, `from(f64)` |
| `Array` | `Array` | `get(i)`, `getNumber(i)`, `length()`, `set(i, val)` |
| `Object(T)` | `object` | `get()`, `set(value)` — `T` fields must be DSL types |
Expand Down
11 changes: 11 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
.imports = .{.zapi},
.link_libc = true,
},
.test_fuzz_numeric = .{
.root_source_file = "tests/fuzz/mod.zig",
.imports = .{.zapi},
.link_libc = true,
},
},
.libraries = .{
.example_hello_world = .{
Expand All @@ -80,6 +85,12 @@
.linker_allow_shlib_undefined = true,
.dest_sub_path = "example_register_decls.node",
},
.test_fuzz_numeric = .{
.root_module = .test_fuzz_numeric,
.linkage = .dynamic,
.linker_allow_shlib_undefined = true,
.dest_sub_path = "test_fuzz_numeric.node",
},
},
.tests = .{
.napi = .{ .root_module = .napi },
Expand Down
3 changes: 1 addition & 2 deletions examples/js_dsl/mod.zig
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ pub fn reverseString(s: String) !String {

/// Double a BigInt value.
pub fn doubleBigInt(n: BigInt) !BigInt {
var lossless: bool = false;
const val = try n.toI64(&lossless);
const val = try n.toI64();
return BigInt.from(val * 2);
}

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"build:zig": "zig build",
"test:zig": "zig build test:zapi",
"test:js": "vitest run examples/**/*.test.ts",
"test:fuzz:round_trip": "pnpm build:zig && vitest run tests/**/*.test.ts",
"test": "pnpm test:zig && pnpm test:js",
"lint:zig": "zig fmt src",
"lint:js": "pnpm biome check",
Expand All @@ -34,6 +35,7 @@
"@biomejs/biome": "^2.4.6",
"@chainsafe/biomejs-config": "^1.0.0",
"@types/node": "^24.1.0",
"fast-check": "^4.8.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
},
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

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

7 changes: 5 additions & 2 deletions src/Value.zig
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ pub fn getValueBigintUint64(self: Value, lossless: ?*bool) NapiError!u64 {
///
/// 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.
/// buffer, so slicing by it unchecked would read out of bounds. napi still fills
/// `words` with the low-order words and `sign_bit` is set before the error is
/// returned, so callers that want truncation semantics may catch `error.Overflow`
/// and read the buffer directly.
///
/// 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.
Expand All @@ -286,9 +289,9 @@ pub fn getValueBigintWords(self: Value, sign_bit: ?*u1, words: []u64) (NapiError
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);
if (word_count > words.len) return error.Overflow;
return words[0..word_count];
}

Expand Down
2 changes: 1 addition & 1 deletion src/create_callback.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub fn createCallback(
const is_value_arg = arg_hint == .value or (arg_hint == .auto and param_type == Value);

if (arg_hint == .data) {
args[i] = @alignCast(@ptrCast(info.data));
args[i] = @ptrCast(@alignCast(info.data));
} else if (is_env_arg) {
args[i] = env;
} else if (is_value_arg) {
Expand Down
143 changes: 114 additions & 29 deletions src/js/bigint.zig
Original file line number Diff line number Diff line change
Expand Up @@ -14,78 +14,146 @@ pub const BigInt = struct {
if ((try val.typeof()) != .bigint) return error.TypeMismatch;
}

/// Attempts to convert the JavaScript BigInt to a Zig `i64`.
/// Attempts to convert the JavaScript BigInt exactly to a Zig `i64`.
///
/// If `lossless` is provided, it will be set to `true` if the BigInt can be
/// represented exactly as an `i64`, and `false` otherwise (e.g., if the
/// BigInt is too large or too small for `i64`).
/// Returns an error if the conversion fails or the `napi_env` is invalid.
pub fn toI64(self: BigInt, lossless: ?*bool) !i64 {
/// Returns `error.InvalidArg` if the value is outside the `i64` range. Use
/// `toI64LowBits` when low-64-bit conversion is intended.
pub fn toI64(self: BigInt) !i64 {
var lossless = false;
const value = try self.toI64LowBits(&lossless);
if (!lossless) return error.InvalidArg;
return value;
}

/// Converts the JavaScript BigInt to the low 64 bits as a Zig `i64`.
///
/// If `lossless` is provided, it is set to whether the returned `i64`
/// represents the original BigInt exactly. Out-of-range values still return
/// their low 64 bits, matching N-API BigInt conversion semantics, but set
/// `lossless` to false.
pub fn toI64LowBits(self: BigInt, lossless: ?*bool) !i64 {
return self.val.getValueBigintInt64(lossless);
}

/// Attempts to convert the JavaScript BigInt to a Zig `u64`.
/// Attempts to convert the JavaScript BigInt exactly to a Zig `u64`.
///
/// If `lossless` is provided, it will be set to `true` if the BigInt can be
/// represented exactly as a `u64`, and `false` otherwise.
/// Returns an error if the conversion fails (e.g., BigInt is negative) or
/// the `napi_env` is invalid.
pub fn toU64(self: BigInt, lossless: ?*bool) !u64 {
/// Returns `error.InvalidArg` if the value is negative or outside the `u64`
/// range. Use `toU64LowBits` when low-64-bit conversion is intended.
pub fn toU64(self: BigInt) !u64 {
var lossless = false;
const value = try self.toU64LowBits(&lossless);
if (!lossless) return error.InvalidArg;
return value;
}

/// Converts the JavaScript BigInt to the low 64 bits as a Zig `u64`.
///
/// If `lossless` is provided, it is set to whether the returned `u64`
/// represents the original BigInt exactly. Out-of-range or negative values
/// still return their low 64 bits, matching N-API BigInt conversion
/// semantics, but set `lossless` to false.
pub fn toU64LowBits(self: BigInt, lossless: ?*bool) !u64 {
return self.val.getValueBigintUint64(lossless);
}

/// Attempts to convert the JavaScript BigInt to a Zig `i128`.
/// Attempts to convert the JavaScript BigInt exactly to a Zig `i128`.
///
/// Returns `error.InvalidArg` if the value is outside `[-2^127, 2^127)`.
/// Use `toI128LowBits` when low-128-bit conversion is intended.
///
/// 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 `error.Overflow` if the value is outside the i128 range, and an
/// error if N-API operations fail.
/// Returns 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 = if (result.len > 0) result[0] else 0;
const hi: u128 = if (result.len > 1) result[1] else 0;
// BigInts needing more than two words are always outside the i128
// range; getValueBigintWords reports them as error.Overflow.
_ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) {
error.Overflow => return error.InvalidArg,
else => |e| return e,
};
const lo: u128 = words[0];
const hi: u128 = words[1];
const magnitude: u128 = (hi << 64) | lo;
const max_positive: u128 = std.math.maxInt(i128);
if (sign_bit == 1) {
if (magnitude > max_positive + 1) return error.Overflow;
if (magnitude > max_positive + 1) return error.InvalidArg;
if (magnitude == max_positive + 1) return std.math.minInt(i128);
return -@as(i128, @intCast(magnitude));
}
if (magnitude > max_positive) return error.Overflow;
if (magnitude > max_positive) return error.InvalidArg;
return @intCast(magnitude);
}

/// Converts the JavaScript BigInt to the low 128 bits as a Zig `i128`.
///
/// This returns `BigInt.asIntN(128, b)`, i.e. the low 128 bits
/// of the magnitude reinterpreted as a signed i128. This matches the
/// ECMAScript `BigInt.asIntN` semantics defined at
/// https://tc39.es/ecma262/#sec-bigint.asintn.
///
/// Returns an error if N-API operations fail.
pub fn toI128LowBits(self: BigInt) !i128 {
var sign_bit: u1 = 0;
// Pre-zeroed: NAPI writes only as many words as the BigInt has; unused
// words stay 0. When the value is 0n NAPI returns word_count == 0, so
// both words[0] and words[1] remain 0 — magnitude correctly becomes 0.
// When the BigInt exceeds 128 bits, getValueBigintWords returns
// error.Overflow but NAPI has still filled the two lower words, so
// swallowing the error gives BigInt.asIntN(128) truncation semantics.
var words: [2]u64 = .{ 0, 0 };
_ = self.val.getValueBigintWords(&sign_bit, &words) catch |err| switch (err) {
error.Overflow => {},
else => |e| return e,
};
const lo: u128 = words[0];
const hi: u128 = words[1];
const magnitude: u128 = (hi << 64) | lo;
if (sign_bit == 1) {
// Negative: result = -magnitude interpreted as i128.
// Use wrapping subtraction + bitcast to handle both minInt(i128)
// (magnitude == 2^127, which doesn't fit in i128 as positive) and
// out-of-range values (magnitude > 2^127, truncated to low 128 bits).
// Guard: out-of-range negatives whose low 128 bits are all zero
// (e.g. -2^128n → words [0, 0]) give magnitude == 0. asIntN(128)
// of such values is 0n; return early rather than relying on the
// `0 -% 0 == 0` coincidence below.
if (magnitude == 0) return 0;
return @bitCast(0 -% magnitude);
}
// Positive: bitcast u128 → i128 gives BigInt.asIntN(128) semantics.
// In-range values have magnitude ≤ 2^127-1 so the sign bit is clear;
// out-of-range values have the sign bit set, matching JS asIntN(128).
return @bitCast(magnitude);
}

/// Converts the JavaScript BigInt to a Zig `i64`, panicking on failure.
///
/// This is a convenience method for cases where the BigInt is guaranteed
/// to be representable as an `i64` without loss.
/// to be exactly representable as an `i64`.
pub fn assertI64(self: BigInt) i64 {
return self.toI64(null) catch @panic("BigInt.assertI64 failed");
return self.toI64() catch @panic("BigInt.assertI64 failed");
}

/// Converts the JavaScript BigInt to a Zig `u64`, panicking on failure.
///
/// This is a convenience method for cases where the BigInt is guaranteed
/// to be representable as a `u64` without loss.
/// to be exactly representable as a `u64`.
pub fn assertU64(self: BigInt) u64 {
return self.toU64(null) catch @panic("BigInt.assertU64 failed");
return self.toU64() catch @panic("BigInt.assertU64 failed");
}

/// Converts the JavaScript BigInt to a Zig `i128`, panicking on failure.
///
/// This is a convenience method for cases where the BigInt is guaranteed
/// to be representable as an `i128`.
/// to be exactly representable as an `i128`.
pub fn assertI128(self: BigInt) i128 {
return self.toI128() catch @panic("BigInt.assertI128 failed");
}

/// Creates a JavaScript `BigInt` from a Zig integer value.
///
/// Accepts `i64`, `u64`, and `comptime_int` values within this range.
/// For `i128`/`u128` values, use `fromWords` (not currently exposed but
/// available at the `napi.Env` level).
/// For `i128`/`u128` values, use `fromWords`.
///
/// Panics if N-API operations fail (e.g., invalid environment) or for Zig
/// integer types larger than `u64` that cannot be converted to `i64`/`u64`.
Expand Down Expand Up @@ -118,6 +186,23 @@ pub const BigInt = struct {
return .{ .val = val };
}

/// Creates a JavaScript `BigInt` from a sign bit and word array.
///
/// `sign_bit` is 0 for non-negative, 1 for negative. `words` is the
/// little-endian unsigned magnitude. The resulting BigInt equals
/// `(sign_bit == 1 ? -1 : 1) * sum(words[i] << (64 * i))`.
///
/// Use this for constructing BigInts whose magnitude exceeds `u64` (e.g.,
/// i128 or larger). For `i64`/`u64` values, prefer `BigInt.from`.
///
/// Panics if N-API operations fail (e.g., invalid environment).
pub fn fromWords(sign_bit: u1, words: []const u64) BigInt {
const e = context.env();
const val = e.createBigintWords(sign_bit, words) catch
@panic("BigInt.fromWords: createBigintWords failed");
return .{ .val = val };
}

/// Returns the underlying `napi.Value` representation of this JavaScript BigInt.
pub fn toValue(self: BigInt) napi.Value {
return self.val;
Expand Down
Loading
Loading