diff --git a/.github/workflows/fuzz-image.yml b/.github/workflows/fuzz-image.yml new file mode 100644 index 0000000..303eb42 --- /dev/null +++ b/.github/workflows/fuzz-image.yml @@ -0,0 +1,175 @@ +name: Fuzz image + +# Builds Dockerfile.fuzz -- the fast-check round-trip properties in tests/fuzz -- +# and publishes it to GHCR for the ChainSafe fuzz platform. +# +# When an image is built: +# - manual run on main (Actions tab) -> build, test, publish `main` +# - manual run on any other branch -> build, test, publish `branch-` +# - pull request touching code the targets exercise -> build and test only +# Publishing is deliberately manual: the fuzz platform follows the `main` tag +# and restarts the campaign on every publish, so run this once enough good +# changes have landed, not on every commit. Pull requests still build and test +# so a broken Dockerfile.fuzz is caught before the next publish. +# +# Two jobs: `build and test` runs for every event with read-only permissions; +# `publish` runs only on a manual run, holds the only write token, and pushes +# the exact image the build job tested. +# +# Tags: `main` is what the fuzz platform runs, and only a run on main moves it. +# A run on another branch publishes `branch-` instead, for trying a branch +# on the platform before it merges. `sha-` names each build's source +# commit, also recorded as the org.opencontainers.image.revision label. +# Old versions beyond the newest 20 are deleted. + +on: + pull_request: + paths: + - "src/**" + - "tests/**" + - "build.zig" + - "build.zig.zon" + - "package.json" + - "pnpm-lock.yaml" + - "Dockerfile.fuzz" + - "Dockerfile.fuzz.dockerignore" + - ".github/workflows/fuzz-image.yml" + workflow_dispatch: + +concurrency: + group: fuzz-image-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/chainsafe/zapi-fuzz + +jobs: + build: + name: build and test + runs-on: ubuntu-latest + # Read-only: this job runs on pull requests and never pushes. + permissions: + contents: read + steps: + - name: Checkout Repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: Dockerfile.fuzz + platforms: linux/amd64 + build-args: REVISION=${{ github.sha }} + tags: zapi-fuzz:ci + load: true + # Off so the image is a single manifest: one package version per + # publish, which the version cleanup below relies on. + provenance: false + sbom: false + cache-from: type=gha,scope=fuzz-image + cache-to: type=gha,scope=fuzz-image,mode=max + + # The same restrictions the fuzz platform applies: a non-root uid, read-only + # root filesystem, no capabilities, no network, an init process. + - name: A campaign starts and reports status under the platform's restrictions + run: | + mkdir -p /tmp/fz/state /tmp/fz/artifacts /tmp/fz/failures /tmp/fz/metrics /tmp/fz/local + sudo chown -R 60001:60001 /tmp/fz + docker run -d --name smoke --user 60001:60001 --read-only --cap-drop all \ + --security-opt no-new-privileges --network none --init --pids-limit 4096 \ + -e FUZZ_PROJECT=zapi -e FUZZ_TARGET=round-trip \ + -e FUZZ_STATE=/fuzz/state -e FUZZ_ARTIFACTS=/fuzz/artifacts \ + -e FUZZ_FAILURES=/fuzz/failures -e FUZZ_METRICS=/fuzz/metrics \ + -e TMPDIR=/local \ + -v /tmp/fz/state:/fuzz/state -v /tmp/fz/artifacts:/fuzz/artifacts \ + -v /tmp/fz/failures:/fuzz/failures -v /tmp/fz/metrics:/fuzz/metrics \ + -v /tmp/fz/local:/local \ + zapi-fuzz:ci + sleep 30 + docker logs smoke + test "$(docker inspect -f '{{.State.Running}}' smoke)" = true + ! docker logs smoke 2>&1 | grep -q '^FATAL' + cat /tmp/fz/metrics/status + awk '$1 == "execs_total" && $2 > 0 { ok = 1 } END { exit !ok }' /tmp/fz/metrics/status + # Every property passes on a clean build; a failure here is a real bug. + awk '$1 == "crashes_total" && $2 == 0 { ok = 1 } END { exit !ok }' /tmp/fz/metrics/status + docker stop -t 60 smoke + test "$(docker inspect -f '{{.State.ExitCode}}' smoke)" = 0 + + # Hand the tested image itself to the publish job, so what is published is + # exactly what passed above -- no second build. + - name: Save the tested image + if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/') + run: docker save zapi-fuzz:ci -o /tmp/zapi-fuzz.tar + + - name: Upload the tested image + if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/') + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: zapi-fuzz-image + path: /tmp/zapi-fuzz.tar + retention-days: 1 + + publish: + name: publish + needs: build + # Manual runs on a branch only; see the header comment. + if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download the tested image + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: zapi-fuzz-image + path: /tmp + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push + id: push + run: | + # main moves `main`; any other branch gets its own tag, sanitized to + # Docker's tag charset and length. + if [ "$GITHUB_REF_NAME" = main ]; then + tag=main + else + tag="branch-$(printf '%s' "$GITHUB_REF_NAME" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9._-' '-' | cut -c1-100)" + fi + docker load -i /tmp/zapi-fuzz.tar + docker tag zapi-fuzz:ci "${IMAGE}:sha-${GITHUB_SHA::7}" + docker tag zapi-fuzz:ci "${IMAGE}:${tag}" + docker push "${IMAGE}:sha-${GITHUB_SHA::7}" + docker push "${IMAGE}:${tag}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "digest=$(docker image inspect -f '{{index .RepoDigests 0}}' "${IMAGE}:${tag}" | sed 's/.*@//')" >> "$GITHUB_OUTPUT" + + - name: Summary + run: | + { + echo "### Fuzz image published" + echo + echo "\`${IMAGE}:${{ steps.push.outputs.tag }}\` now points at \`${{ steps.push.outputs.digest }}\`," + echo "built from \`${GITHUB_SHA}\`. Fuzz platform targets following this tag" + echo "restart on the new image; their counters carry over." + } >> "$GITHUB_STEP_SUMMARY" + + # Without provenance or SBOM attestations, each publish is exactly one + # package version, so this keeps the newest 20 builds. + - name: Delete old image versions + uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0 + with: + package-name: zapi-fuzz + package-type: container + min-versions-to-keep: 20 diff --git a/Dockerfile.fuzz b/Dockerfile.fuzz new file mode 100644 index 0000000..b34de5e --- /dev/null +++ b/Dockerfile.fuzz @@ -0,0 +1,63 @@ +# Fuzz image for the ChainSafe fuzz platform: the fast-check round-trip properties +# in tests/fuzz/, driven by tests/fuzz/platform/run-fuzz.ts. The platform provides +# the FUZZ_* environment and the /fuzz volume. See tests/fuzz/README.md. +# +# docker build -f Dockerfile.fuzz -t zapi-fuzz . + +# --- build: Zig compiles the test_fuzz_numeric.node addon +FROM node:24-bookworm-slim AS build + +ARG ZIG_VERSION=0.16.0 +# From https://ziglang.org/download/index.json -- update together with ZIG_VERSION. +ARG ZIG_SHA256_X86_64=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00 +ARG ZIG_SHA256_AARCH64=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17 +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN case "${TARGETARCH}" in \ + amd64) arch=x86_64; sum="${ZIG_SHA256_X86_64}" ;; \ + arm64) arch=aarch64; sum="${ZIG_SHA256_AARCH64}" ;; \ + *) echo "unsupported TARGETARCH '${TARGETARCH}'" >&2; exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/zig.tar.xz \ + "https://ziglang.org/download/${ZIG_VERSION}/zig-${arch}-linux-${ZIG_VERSION}.tar.xz" \ + && echo "${sum} /tmp/zig.tar.xz" | sha256sum -c - \ + && mkdir /opt/zig && tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 \ + && rm /tmp/zig.tar.xz +ENV PATH=/opt/zig:$PATH + +WORKDIR /src +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . + +# ReleaseSafe keeps Zig's safety checks (bounds, overflow), so those bugs panic +# instead of passing silently. -Dcpu=baseline: otherwise the addon targets the +# build runner's CPU and dies with SIGILL on a host without its extensions. +RUN --mount=type=cache,target=/root/.cache/zig \ + cd tests && zig build --fork=.. --prefix ../zig-out -Doptimize=ReleaseSafe -Dcpu=baseline + +# --- runtime: node, the addon, the tests and their node_modules only +FROM node:24-bookworm-slim + +WORKDIR /opt/zapi +COPY --from=build /src/node_modules ./node_modules +COPY --from=build /src/zig-out/lib/test_fuzz_numeric.node ./zig-out/lib/ +COPY --from=build /src/package.json ./ +COPY --from=build /src/tests/fuzz ./tests/fuzz +COPY --from=build /src/tests/utils ./tests/utils +# The platform runs the container as an arbitrary non-root uid. +RUN chmod -R a+rX /opt/zapi + +ARG REVISION=unknown +ENV FUZZ_REVISION=${REVISION} +LABEL org.opencontainers.image.source="https://github.com/ChainSafe/zapi" \ + org.opencontainers.image.title="zapi-fuzz" \ + org.opencontainers.image.description="fast-check round-trip fuzz targets for zapi" \ + org.opencontainers.image.revision="${REVISION}" + +ENTRYPOINT ["node", "/opt/zapi/tests/fuzz/platform/run-fuzz.ts"] diff --git a/Dockerfile.fuzz.dockerignore b/Dockerfile.fuzz.dockerignore new file mode 100644 index 0000000..7f5b231 --- /dev/null +++ b/Dockerfile.fuzz.dockerignore @@ -0,0 +1,7 @@ +# Build context for Dockerfile.fuzz (BuildKit reads .dockerignore). +.git +**/.zig-cache +**/zig-out +**/zig-pkg +**/node_modules +lib diff --git a/package.json b/package.json index de5ff94..594d21a 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ "scripts": { "build": "pnpm build:js && pnpm build:zig", "build:js": "tsc --outDir lib", - "build:zig": "cd examples && zig build --fork=..", + "build:zig": "pnpm build:zig:examples && pnpm build:zig:tests", + "build:zig:examples": "cd examples && zig build --fork=..", + "build:zig:tests": "cd tests && zig build --fork=.. --prefix ../zig-out", "test:zig": "zig build test:zapi", - "test:js": "vitest run examples/**/*.test.ts", + "test:js": "vitest run examples/**/*.test.ts tests/unit/", + "test:fuzz:round_trip": "pnpm build:zig && vitest run tests/fuzz/", "test": "pnpm test:zig && pnpm test:js", "lint:zig": "zig fmt src", "lint:js": "pnpm biome check", @@ -34,6 +37,7 @@ "@biomejs/biome": "^2.5.8", "@chainsafe/biomejs-config": "^1.0.0", "@types/node": "^24.1.0", + "fast-check": "^4.8.0", "typescript": "^5.8.3", "vitest": "^4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f4ad11..54f4478 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@types/node': specifier: ^24.1.0 version: 24.10.10 + fast-check: + specifier: ^4.8.0 + version: 4.8.0 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -438,6 +441,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -477,6 +484,9 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -899,6 +909,10 @@ snapshots: expect-type@1.3.0: {} + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -926,6 +940,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pure-rand@8.4.0: {} + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 diff --git a/tests/build.zig b/tests/build.zig new file mode 100644 index 0000000..a165f99 --- /dev/null +++ b/tests/build.zig @@ -0,0 +1,8 @@ +const std = @import("std"); +const zbuild = @import("zbuild"); + +pub fn build(b: *std.Build) !void { + @setEvalBranchQuota(200_000); + const manifest = @import("build.zig.zon"); + _ = try zbuild.configureBuild(b, manifest, .{}); +} diff --git a/tests/build.zig.zon b/tests/build.zig.zon new file mode 100644 index 0000000..1d91232 --- /dev/null +++ b/tests/build.zig.zon @@ -0,0 +1,38 @@ +.{ + .name = .zapi_tests, + .version = "0.0.0", + .fingerprint = 0xd21dc10774fb3dc7, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "fuzz", + }, + .dependencies = .{ + // Repository builds use --fork so the tests consume the local + // checkout while preserving the URL/hash dependency boundary. + .zapi = .{ + .url = "https://github.com/ChainSafe/zapi/archive/b934988f8595e5a5fdc497b7212631004f800116.tar.gz", + .hash = "zapi-3.1.0-rIqzUfHFBADg6qo5AXJzbfHgs6LqpwgVKYal4qCG4XSW", + }, + .zbuild = .{ + .url = "git+https://github.com/chainsafe/zbuild.git#f7d5f19de09f2808e54acf599605f9e18c9bd804", + .hash = "zbuild-0.4.0-XJFav-1nAgDmfVY1nVSIIzbkcbuHZj7yMb7Fv8yvznPO", + }, + }, + .modules = .{ + .test_fuzz_numeric = .{ + .root_source_file = "fuzz/mod.zig", + .imports = .{.zapi}, + .link_libc = true, + }, + }, + .libraries = .{ + .test_fuzz_numeric = .{ + .root_module = .test_fuzz_numeric, + .linkage = .dynamic, + .linker_allow_shlib_undefined = true, + .dest_sub_path = "test_fuzz_numeric.node", + }, + }, +} diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 0000000..9c79fc1 --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,80 @@ +# fuzz + +Property-based fuzz tests for zapi's Number, BigInt, and selected binary conversion surfaces. Driven from JS through the `test_fuzz_numeric.node` addon (built by `zig build`) using vitest + fast-check. + +See `docs/superpowers/specs/2026-05-28-fuzz-testing-design.md` for design rationale. + +## Running + +```bash +# Full run (10 000 cases per property) +pnpm test:fuzz:round_trip + +# CI-equivalent (1 000 cases per property) +FUZZ_RUNS=1000 pnpm test:fuzz:round_trip + +# Single property +pnpm vitest run tests/fuzz/bigint.fuzz.test.ts -t "rtBigIntI128" +``` + +## When a property fails + +fast-check shrinks the counterexample and prints something like: + +``` +Property failed after 47 tests +Counterexample: 170141183460469231731687303715884105728n +Shrunk 14 times to: 170141183460469231731687303715884105728n +Seed: 1234567890, Path: 0:0:1 +``` + +To reproduce locally, paste the seed into a temporary `it.only`: + +```ts +it.only("repro", () => { + fc.assert( + fc.property(bigIntArbI128, (b) => ... ), + { seed: 1234567890, path: "0:0:1", numRuns: 1 }, + ); +}); +``` + +Once fixed, **persist the counterexample** so it runs forever: + +1. Create or open `tests/fuzz/seeds/.json`. +2. Append the counterexample to the JSON array. For bigints, use: + ```json + { "__bigint": "170141183460469231731687303715884105728" } + ``` + For numbers, use a JSON number directly. For `Uint8Array`, use a JSON array of byte values, e.g. `[0, 255, 1]`. NaN and ±Infinity aren't representable in JSON and are not currently supported as persisted seeds — those values are already in `edgeNumbers` and exercised on every run, so persisting them as regression cases adds nothing. If a future fuzz target needs persisted special-number seeds, extend `loadSeeds` and the relevant `revive*` helper at that time. +3. Commit with the fix. + +## Continuous fuzzing (ChainSafe fuzz platform) + +`Dockerfile.fuzz` packages these properties for the [fuzz platform](https://github.com/ChainSafe/lodestar-ansible-development/tree/main/playbooks/fuzz). `.github/workflows/fuzz-image.yml` builds and smoke-tests it on PRs. Publishing is manual: run the workflow from the Actions tab on `main` to move `ghcr.io/chainsafe/zapi-fuzz:main` (on another branch it publishes `branch-`). The platform switches the campaign to each new image. + +The entrypoint, `tests/fuzz/platform/run-fuzz.ts`, re-runs every `*.fuzz.test.ts` in a loop (`FUZZ_RUNS` cases per property, default 100 000, fresh random seed each time) until `SIGTERM`: + +- a failing property is saved once per test + shrunk counterexample to `$FUZZ_FAILURES/failure-.json` (with seed and path for replay) and logged as `NEW_FAILURE`; fuzzing continues; +- `$FUZZ_METRICS/status` is rewritten after every iteration (`execs_total` counts property cases; no `coverage`/`corpus_entries`, since fast-check is not coverage-guided); +- counters persist in `$FUZZ_STATE/stats.json`; +- a suite that fails to load, or a run with no tests, exits non-zero (an infrastructure failure). + +Run it locally as the platform does: + +```bash +docker build -f Dockerfile.fuzz -t zapi-fuzz . +mkdir -p /tmp/fz && chmod 777 /tmp/fz +docker run --rm --init --read-only --network none --cap-drop all --user 60001:60001 \ + -e TMPDIR=/fuzz/local -v /tmp/fz:/fuzz zapi-fuzz +``` + +To reproduce a saved failure, take its `seed` and `path` into the `it.only` shown above. + +## Adding a new fuzz target + +1. Add the round-trip export to `mod.zig`. +2. Add the oracle to `tests/utils/oracles.ts` and a unit test in `tests/unit/conversions.test.ts` that runs it against every entry in the relevant edge list. +3. Add the fast-check property to the relevant `*.fuzz.test.ts` file. +4. Update `tests/utils/edge.ts` if the target needs new edge values. +5. Run `pnpm test:fuzz:round_trip` and watch it pass (or find a bug). diff --git a/tests/fuzz/bigint.fuzz.test.ts b/tests/fuzz/bigint.fuzz.test.ts new file mode 100644 index 0000000..844d728 --- /dev/null +++ b/tests/fuzz/bigint.fuzz.test.ts @@ -0,0 +1,154 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { edgeBigInts } from "../utils/edge.ts"; +import { + fitsBigIntI128, + fitsBigIntI64, + fitsBigIntU64, + oracleBigIntI128, + oracleBigIntI128LowBits, + oracleLosslessI64, + oracleLosslessU64, +} from "../utils/oracles.ts"; +import { FUZZ_RUNS, loadSeeds, mod, reviveBigInt } from "../utils/support.ts"; + +const bigIntArbI64 = fc.oneof( + { arbitrary: fc.bigInt({ min: -(2n ** 65n), max: 2n ** 65n }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntI64", () => { + it("is exact in [-2^63, 2^63) and throws outside that range", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + if (fitsBigIntI64(b)) return mod.rtBigIntI64(b) === b; + + try { + mod.rtBigIntI64(b); + return false; + } catch { + return true; + } + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI64", reviveBigInt), + }, + ); + }); +}); + +describe("rtBigIntU64", () => { + it("is exact in [0, 2^64) and throws outside that range", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + if (fitsBigIntU64(b)) return mod.rtBigIntU64(b) === b; + + try { + mod.rtBigIntU64(b); + return false; + } catch { + return true; + } + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntU64", reviveBigInt), + }, + ); + }); +}); + +describe("losslessI64", () => { + it("lossless ⇔ b ∈ [-2^63, 2^63) and value matches asIntN", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + const { value, lossless } = mod.losslessI64(b); + const expected = oracleLosslessI64(b); + return value === expected.value && lossless === expected.lossless; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("losslessI64", reviveBigInt), + }, + ); + }); +}); + +describe("losslessU64", () => { + it("lossless ⇔ b ∈ [0, 2^64) and value matches asUintN", () => { + fc.assert( + fc.property(bigIntArbI64, (b) => { + const { value, lossless } = mod.losslessU64(b); + const expected = oracleLosslessU64(b); + return value === expected.value && lossless === expected.lossless; + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("losslessU64", reviveBigInt), + }, + ); + }); +}); + +const bigIntArbI128 = fc.oneof( + { arbitrary: fc.bigInt({ min: -(2n ** 129n), max: 2n ** 129n }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntI128", () => { + it("is exact in [-2^127, 2^127) and throws outside that range", () => { + fc.assert( + fc.property(bigIntArbI128, (b) => { + if (fitsBigIntI128(b)) return mod.rtBigIntI128(b) === oracleBigIntI128(b); + + try { + mod.rtBigIntI128(b); + return false; + } catch { + return true; + } + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI128", reviveBigInt), + }, + ); + }); +}); + +describe("rtBigIntI128LowBits", () => { + it("matches BigInt.asIntN(128, ·)", () => { + fc.assert( + fc.property( + bigIntArbI128, + (b) => mod.rtBigIntI128LowBits(b) === oracleBigIntI128LowBits(b), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntI128LowBits", reviveBigInt), + }, + ); + }); +}); + +const bigIntArbWords = fc.oneof( + // Keep within the addon's 64-word cap (4096 bits). + { + arbitrary: fc.bigInt({ min: -(2n ** 4000n), max: 2n ** 4000n }), + weight: 4, + }, + { arbitrary: fc.constantFrom(...edgeBigInts), weight: 1 }, +); + +describe("rtBigIntWords", () => { + it("is identity for any BigInt under the word-buffer cap", () => { + fc.assert( + fc.property(bigIntArbWords, (b) => mod.rtBigIntWords(b) === b), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtBigIntWords", reviveBigInt), + }, + ); + }); +}); diff --git a/tests/fuzz/mod.zig b/tests/fuzz/mod.zig new file mode 100644 index 0000000..415ae13 --- /dev/null +++ b/tests/fuzz/mod.zig @@ -0,0 +1,115 @@ +//! Fuzz harness addon: per-converter round-trip exports for numeric types. + +const std = @import("std"); +const js = @import("zapi").js; + +/// Round-trip JS number → f64 → JS number. Oracle: identity for all finite +/// numbers, NaN ↔ NaN, ±0 preserved. +pub fn rtNumberF64(n: js.Number) !js.Number { + return js.Number.from(try n.toF64()); +} + +/// Round-trip via ToInt32 (`value | 0` semantics). +pub fn rtNumberI32(n: js.Number) !js.Number { + return js.Number.from(try n.toI32()); +} + +/// Round-trip via ToUint32 (`value >>> 0` semantics). +pub fn rtNumberU32(n: js.Number) !js.Number { + return js.Number.from(try n.toU32()); +} + +/// Round-trip via NAPI int64. Returns a JS BigInt so the result is lossless +/// in JS (a JS number cannot represent all of i64 exactly). +pub fn rtNumberI64(n: js.Number) !js.BigInt { + return js.BigInt.from(try n.toI64()); +} + +/// Exact round-trip via i64. Throws for out-of-range inputs. +pub fn rtBigIntI64(b: js.BigInt) !js.BigInt { + return js.BigInt.from(try b.toI64()); +} + +/// Exact round-trip via u64. Throws for negative or out-of-range inputs. +pub fn rtBigIntU64(b: js.BigInt) !js.BigInt { + return js.BigInt.from(try b.toU64()); +} + +/// Returns `{ value: BigInt, lossless: Boolean }` exposing the lossy low-bits +/// conversion flag to the fuzzer. +pub fn losslessI64(b: js.BigInt) !js.Value { + var lossless: bool = false; + const v = try b.toI64LowBits(&lossless); + const value = js.BigInt.from(v); + const flag = js.Boolean.from(lossless); + const obj = try js.env().createObject(); + try obj.setNamedProperty("value", value.toValue()); + try obj.setNamedProperty("lossless", flag.toValue()); + return .{ .val = obj }; +} + +/// Returns `{ value: BigInt, lossless: Boolean }` for the u64 low-bits path. +pub fn losslessU64(b: js.BigInt) !js.Value { + var lossless: bool = false; + const v = try b.toU64LowBits(&lossless); + const value = js.BigInt.from(v); + const flag = js.Boolean.from(lossless); + const obj = try js.env().createObject(); + try obj.setNamedProperty("value", value.toValue()); + try obj.setNamedProperty("lossless", flag.toValue()); + return .{ .val = obj }; +} + +fn i128ToBigInt(v: i128) !js.BigInt { + const is_negative = v < 0; + const magnitude: u128 = if (is_negative) + @as(u128, @intCast(-(v + 1))) + 1 // safe for i128.minInt + else + @as(u128, @intCast(v)); + const words = [_]u64{ + @truncate(magnitude), + @truncate(magnitude >> 64), + }; + return js.BigInt.fromWords(if (is_negative) 1 else 0, &words); +} + +/// Round-trip JS BigInt → i128 → JS BigInt via fromWords. +/// +/// In-domain (b ∈ [-2^127, 2^127)) this is identity. Out-of-domain values +/// throw instead of silently dropping high bits. +pub fn rtBigIntI128(b: js.BigInt) !js.BigInt { + return i128ToBigInt(try b.toI128()); +} + +/// Round-trip JS BigInt → low 128 bits → JS BigInt. +/// +/// Oracle: `BigInt.asIntN(128, b)`. +pub fn rtBigIntI128LowBits(b: js.BigInt) !js.BigInt { + return i128ToBigInt(try b.toI128LowBits()); +} + +/// Round-trip via getValueBigintWords → createBigintWords (via fromWords). +/// Identity for any BigInt that fits in the addon's word buffer (64 words = +/// 4096 bits is the practical cap). +/// +/// Implementation notes: +/// - We pass a 64-word buffer to `getValueBigintWords`. The `src/Value.zig` +/// wrapper returns `error.Overflow` for BigInts needing more words, so +/// oversized BigInts throw. The 64-word cap covers all entries in +/// `edgeBigInts` (the largest, `1n << 256n`, is 5 words). +/// - Sign bit and the populated word slice are forwarded to `fromWords`. +pub fn rtBigIntWords(b: js.BigInt) !js.BigInt { + var sign_bit: u1 = 0; + var buf: [64]u64 = @splat(0); + const slice = try b.toValue().getValueBigintWords(&sign_bit, &buf); + return js.BigInt.fromWords(sign_bit, slice); +} + +/// Round-trip JS Uint8Array → []u8 → JS Uint8Array. +pub fn rtUint8Array(value: js.Uint8Array) !js.Uint8Array { + return js.Uint8Array.from(try value.toSlice()); +} + +comptime { + js.exportModule(@This(), .{}); +} diff --git a/tests/fuzz/number.fuzz.test.ts b/tests/fuzz/number.fuzz.test.ts new file mode 100644 index 0000000..bc8cba0 --- /dev/null +++ b/tests/fuzz/number.fuzz.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { edgeNumbers } from "../utils/edge.ts"; +import { + oracleNumberF64, + oracleNumberI32, + oracleNumberI64, + oracleNumberU32, +} from "../utils/oracles.ts"; +import { FUZZ_RUNS, loadSeeds, mod } from "../utils/support.ts"; + +const numberArb = fc.oneof( + { arbitrary: fc.double(), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, +); + +describe("rtNumberF64", () => { + it("round-trip is identity for all JS numbers", () => { + fc.assert( + fc.property(numberArb, (n) => { + const result = mod.rtNumberF64(n); + const expected = oracleNumberF64(n); + if (Number.isNaN(expected)) return Number.isNaN(result); + return Object.is(result, expected); + }), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberF64", (raw) => raw as number), + }, + ); + }); +}); + +const numberIntArb = fc.oneof( + { arbitrary: fc.integer({ min: -(2 ** 33), max: 2 ** 33 }), weight: 3 }, + { arbitrary: fc.double(), weight: 2 }, + { arbitrary: fc.constantFrom(...edgeNumbers), weight: 1 }, +); + +describe("rtNumberI32", () => { + it("matches ECMAScript ToInt32 (`| 0`)", () => { + fc.assert( + fc.property(numberIntArb, (n) => mod.rtNumberI32(n) === oracleNumberI32(n)), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberI32", (r) => r as number), + }, + ); + }); +}); + +describe("rtNumberU32", () => { + it("matches ECMAScript ToUint32 (`>>> 0`)", () => { + fc.assert( + fc.property(numberIntArb, (n) => mod.rtNumberU32(n) === oracleNumberU32(n)), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberU32", (r) => r as number), + }, + ); + }); +}); + +describe("rtNumberI64", () => { + it("matches NAPI int64 semantics (clamped)", () => { + fc.assert( + fc.property( + numberIntArb, + (n) => mod.rtNumberI64(n) === oracleNumberI64(n), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtNumberI64", (r) => r as number), + }, + ); + }); +}); diff --git a/tests/fuzz/platform/run-fuzz.ts b/tests/fuzz/platform/run-fuzz.ts new file mode 100644 index 0000000..a918687 --- /dev/null +++ b/tests/fuzz/platform/run-fuzz.ts @@ -0,0 +1,176 @@ +// Entrypoint of the zapi fuzz image (Dockerfile.fuzz) on the ChainSafe fuzz platform. +// +// Re-runs the fast-check properties (tests/fuzz/*.fuzz.test.ts) in a loop, each +// iteration with a fresh random seed, and fulfils the platform's container contract: +// - runs until SIGTERM; any other exit is a failure +// - a property failure is a finding, not a crash of this process: it is saved, +// deduplicated by test name + shrunk counterexample, to $FUZZ_FAILURES and +// announced with a NEW_FAILURE line +// - $FUZZ_METRICS/status is rewritten after every iteration (seconds apart) +// - cumulative counters live in $FUZZ_STATE, so they survive restarts +// - an infrastructure failure (addon fails to load, no tests ran) exits non-zero +// +// fast-check is not coverage-guided, so there is no corpus: `coverage` and +// `corpus_entries` are not reported, and `execs_total` counts property cases. +import {spawn} from "node:child_process"; +import {createHash} from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const env = (name: string, fallback: string): string => process.env[name] || fallback; + +const ROOT = path.resolve(import.meta.dirname, "../../.."); +const STATE = env("FUZZ_STATE", "/fuzz/state"); +const FAILURES = env("FUZZ_FAILURES", "/fuzz/failures"); +const METRICS = env("FUZZ_METRICS", "/fuzz/metrics"); +const TMP = env("TMPDIR", "/tmp"); +// Cases per property per iteration. Keep an iteration well under a minute: the +// status file is the platform's liveness signal and is written between iterations. +const RUNS = Number(env("FUZZ_RUNS", "100000")); +const STATS_FILE = path.join(STATE, "stats.json"); +const REPORT_FILE = path.join(TMP, "vitest-report.json"); + +type Stats = {execsTotal: number; iterations: number; lastFindTimestamp: number}; +type AssertionResult = {fullName: string; status: string; failureMessages: string[]}; +type Report = { + numTotalTests: number; + numFailedTests: number; + testResults: {name: string; message?: string; assertionResults: AssertionResult[]}[]; +}; + +for (const dir of [STATE, FAILURES, METRICS, TMP]) fs.mkdirSync(dir, {recursive: true}); + +function writeAtomic(file: string, content: string): void { + fs.writeFileSync(`${file}.tmp`, content); + fs.renameSync(`${file}.tmp`, file); +} + +function loadStats(): Stats { + try { + return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")) as Stats; + } catch { + return {execsTotal: 0, iterations: 0, lastFindTimestamp: 0}; + } +} + +const stats = loadStats(); +let execsPerSecond = 0; + +function writeStatus(): void { + const crashes = fs.readdirSync(FAILURES).filter((f) => f.startsWith("failure-")).length; + const lines = [ + `execs_total ${stats.execsTotal}`, + `execs_per_second ${execsPerSecond.toFixed(1)}`, + `crashes_total ${crashes}`, + "hangs_total 0", + ]; + if (stats.lastFindTimestamp > 0) lines.push(`last_find_timestamp_seconds ${stats.lastFindTimestamp}`); + writeAtomic(path.join(METRICS, "status"), `${lines.join("\n")}\n`); + writeAtomic(STATS_FILE, JSON.stringify(stats)); +} + +function saveFailure(test: AssertionResult): void { + const message = test.failureMessages.join("\n"); + // The seed differs every iteration; the shrunk counterexample does not. + const counterexample = /Counterexample: (.*)/.exec(message)?.[1] ?? message.split("\n")[0]; + const id = createHash("sha256").update(`${test.fullName}\0${counterexample}`).digest("hex").slice(0, 16); + const file = path.join(FAILURES, `failure-${id}.json`); + if (fs.existsSync(file)) return; + const seed = /seed: (-?\d+), path: "([^"]*)"/.exec(message); + const record = { + counterexample, + message, + path: seed?.[2] ?? null, + revision: env("FUZZ_REVISION", "unknown"), + seed: seed ? Number(seed[1]) : null, + test: test.fullName, + timestamp: new Date().toISOString(), + }; + writeAtomic(file, `${JSON.stringify(record, null, 2)}\n`); + stats.lastFindTimestamp = Math.floor(Date.now() / 1000); + console.log(`NEW_FAILURE file=failure-${id}.json test="${test.fullName}" counterexample=${counterexample}`); +} + +function fatal(reason: string, output: string): never { + console.log(`FATAL ${reason}; last output:\n${output.split("\n").slice(-30).join("\n")}`); + writeStatus(); + process.exit(1); +} + +let stopping = false; +let child: ReturnType | undefined; +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + console.log(`${signal}: stopping`); + stopping = true; + child?.kill("SIGTERM"); + }); +} + +function runIteration(): Promise<{code: number | null; output: string}> { + fs.rmSync(REPORT_FILE, {force: true}); + return new Promise((resolve) => { + child = spawn( + process.execPath, + [ + path.join(ROOT, "node_modules/vitest/vitest.mjs"), + "run", + ".fuzz.test.ts", + "--reporter=json", + `--outputFile=${REPORT_FILE}`, + ], + {cwd: ROOT, env: {...process.env, FUZZ_RUNS: String(RUNS)}, stdio: ["ignore", "pipe", "pipe"]} + ); + let output = ""; + const collect = (chunk: Buffer): void => { + output = (output + chunk.toString()).slice(-64 * 1024); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + child.on("close", (code) => resolve({code, output})); + }); +} + +console.log( + `START project=${env("FUZZ_PROJECT", "zapi")} target=${env("FUZZ_TARGET", "round-trip")} runs=${RUNS} resumed_execs=${stats.execsTotal}` +); +writeStatus(); + +while (!stopping) { + const started = Date.now(); + const {code, output} = await runIteration(); + if (stopping) break; + + let report: Report; + try { + report = JSON.parse(fs.readFileSync(REPORT_FILE, "utf8")) as Report; + } catch { + fatal(`vitest exited ${code} without a report`, output); + } + // A suite that fails to load (e.g. the addon is missing) has a message but no tests. + const suiteErrors = report.testResults + .filter((f) => f.message) + .map((f) => `${f.name}: ${f.message}`) + .join("\n"); + if (suiteErrors) fatal("a test suite failed to load", suiteErrors); + if (report.numTotalTests === 0) fatal("vitest ran no tests", output); + if (code !== 0 && report.numFailedTests === 0) fatal(`vitest exited ${code} with no failed test`, output); + + for (const file of report.testResults) { + for (const test of file.assertionResults) if (test.status === "failed") saveFailure(test); + } + + const execs = report.numTotalTests * RUNS; + stats.execsTotal += execs; + stats.iterations += 1; + execsPerSecond = execs / ((Date.now() - started) / 1000); + writeStatus(); + if (stats.iterations % 20 === 0) { + console.log( + `STATS iterations=${stats.iterations} execs_total=${stats.execsTotal} execs_per_second=${execsPerSecond.toFixed(0)}` + ); + } +} + +writeStatus(); +process.exit(0); diff --git a/tests/fuzz/seeds/.gitkeep b/tests/fuzz/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fuzz/uint8array.fuzz.test.ts b/tests/fuzz/uint8array.fuzz.test.ts new file mode 100644 index 0000000..23909c9 --- /dev/null +++ b/tests/fuzz/uint8array.fuzz.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "vitest"; +import fc from "fast-check"; +import { edgeUint8Arrays } from "../utils/edge.ts"; +import { equalUint8Array, oracleUint8Array } from "../utils/oracles.ts"; +import { + FUZZ_RUNS, + loadSeeds, + mod, + reviveUint8Array, +} from "../utils/support.ts"; + +const uint8ArrayArb = fc.oneof( + { arbitrary: fc.uint8Array({ maxLength: 4096 }), weight: 4 }, + { arbitrary: fc.constantFrom(...edgeUint8Arrays), weight: 1 }, +); + +describe("rtUint8Array", () => { + it("round-trips bytes unchanged", () => { + fc.assert( + fc.property(uint8ArrayArb, (value) => + equalUint8Array(mod.rtUint8Array(value), oracleUint8Array(value)), + ), + { + numRuns: FUZZ_RUNS, + examples: loadSeeds("rtUint8Array", reviveUint8Array), + }, + ); + }); +}); diff --git a/tests/unit/conversions.test.ts b/tests/unit/conversions.test.ts new file mode 100644 index 0000000..7556140 --- /dev/null +++ b/tests/unit/conversions.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { edgeNumbers, edgeBigInts, edgeUint8Arrays } from "../utils/edge.ts"; +import { + equalUint8Array, + fitsBigIntI128, + fitsBigIntI64, + fitsBigIntU64, + oracleBigIntI128, + oracleBigIntI128LowBits, + oracleLosslessI64, + oracleLosslessU64, + oracleNumberF64, + oracleNumberI32, + oracleNumberI64, + oracleNumberU32, + oracleUint8Array, +} from "../utils/oracles.ts"; +import { mod } from "../utils/support.ts"; + +function describeValue(v: number): string { + if (Number.isNaN(v)) return "NaN"; + if (Object.is(v, -0)) return "-0"; + return String(v); +} + +describe("oracle sanity: rtNumberF64", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + const expected = oracleNumberF64(v); + const actual = mod.rtNumberF64(v); + if (Number.isNaN(expected)) { + expect(Number.isNaN(actual)).toBe(true); + } else { + expect(Object.is(actual, expected)).toBe(true); + } + }); + } +}); + +describe("oracle sanity: rtNumberI32", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + expect(mod.rtNumberI32(v)).toBe(oracleNumberI32(v)); + }); + } +}); + +describe("oracle sanity: rtNumberU32", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + expect(mod.rtNumberU32(v)).toBe(oracleNumberU32(v)); + }); + } +}); + +describe("oracle sanity: rtNumberI64", () => { + for (const v of edgeNumbers) { + it(`agrees with oracle on ${describeValue(v)}`, () => { + expect(mod.rtNumberI64(v)).toBe(oracleNumberI64(v)); + }); + } +}); + +describe("oracle sanity: rtBigIntI64", () => { + for (const b of edgeBigInts) { + it(`is exact-or-throw on ${b}n`, () => { + if (fitsBigIntI64(b)) { + expect(mod.rtBigIntI64(b)).toBe(b); + } else { + expect(() => mod.rtBigIntI64(b)).toThrow(); + } + }); + } +}); + +describe("oracle sanity: rtBigIntU64", () => { + for (const b of edgeBigInts) { + it(`is exact-or-throw on ${b}n`, () => { + if (fitsBigIntU64(b)) { + expect(mod.rtBigIntU64(b)).toBe(b); + } else { + expect(() => mod.rtBigIntU64(b)).toThrow(); + } + }); + } +}); + +describe("oracle sanity: losslessI64", () => { + for (const b of edgeBigInts) { + it(`agrees with oracle on ${b}n`, () => { + expect(mod.losslessI64(b)).toEqual(oracleLosslessI64(b)); + }); + } +}); + +describe("oracle sanity: losslessU64", () => { + for (const b of edgeBigInts) { + it(`agrees with oracle on ${b}n`, () => { + expect(mod.losslessU64(b)).toEqual(oracleLosslessU64(b)); + }); + } +}); + +describe("oracle sanity: rtBigIntI128", () => { + for (const b of edgeBigInts) { + it(`is exact-or-throw on ${b}n`, () => { + if (fitsBigIntI128(b)) { + expect(mod.rtBigIntI128(b)).toBe(oracleBigIntI128(b)); + } else { + expect(() => mod.rtBigIntI128(b)).toThrow(); + } + }); + } +}); + +describe("oracle sanity: rtBigIntI128LowBits", () => { + for (const b of edgeBigInts) { + it(`agrees with low-bits oracle on ${b}n`, () => { + expect(mod.rtBigIntI128LowBits(b)).toBe(oracleBigIntI128LowBits(b)); + }); + } +}); + +describe("oracle sanity: rtBigIntWords", () => { + for (const b of edgeBigInts) { + it(`round-trips ${b}n`, () => { + expect(mod.rtBigIntWords(b)).toBe(b); + }); + } +}); + +describe("oracle sanity: rtUint8Array", () => { + for (const value of edgeUint8Arrays) { + it(`round-trips ${value.length} byte(s)`, () => { + expect(equalUint8Array(mod.rtUint8Array(value), oracleUint8Array(value))).toBe( + true, + ); + }); + } +}); diff --git a/tests/utils/edge.ts b/tests/utils/edge.ts new file mode 100644 index 0000000..105cb70 --- /dev/null +++ b/tests/utils/edge.ts @@ -0,0 +1,60 @@ +/** + * Hand-curated edge values that any decent numeric converter should handle. + * Mixed into fast-check generators via fc.constantFrom(...). + * + * BigInt list is added incrementally as BigInt-targeting tasks land. + */ + +export const edgeNumbers: readonly number[] = [ + 0, + -0, + NaN, + Infinity, + -Infinity, + Number.MIN_VALUE, + -Number.MIN_VALUE, + Number.MAX_SAFE_INTEGER, + -Number.MAX_SAFE_INTEGER, + 2 ** 31, + -(2 ** 31), + 2 ** 31 - 1, + -(2 ** 31) - 1, + 2 ** 32, + 2 ** 32 - 1, + 2 ** 53, + 2 ** 53 + 1, + 2 ** 63, + -(2 ** 63), +]; + +export const edgeBigInts: readonly bigint[] = [ + 0n, + 1n, + -1n, + 1n << 63n, + -(1n << 63n), + (1n << 63n) - 1n, + -((1n << 63n) - 1n), + 1n << 64n, + (1n << 64n) - 1n, + -(1n << 64n), + // i128 boundary + (1n << 127n) - 1n, + -(1n << 127n), + -((1n << 127n) - 1n), + // multi-word (beyond i128) + (1n << 128n) - 1n, + -((1n << 128n) - 1n), + 1n << 128n, + 1n << 191n, + (1n << 192n) - 1n, + 1n << 256n, +]; + +export const edgeUint8Arrays: readonly Uint8Array[] = [ + new Uint8Array([]), + new Uint8Array([0]), + new Uint8Array([255]), + new Uint8Array([0, 255, 1, 254]), + new Uint8Array(Array.from({ length: 256 }, (_, i) => i)), +]; diff --git a/tests/utils/oracles.ts b/tests/utils/oracles.ts new file mode 100644 index 0000000..7fa64c3 --- /dev/null +++ b/tests/utils/oracles.ts @@ -0,0 +1,86 @@ +/** + * Shared JS-side reference transforms for numeric fuzz targets. + * + * These are intentionally small and spec-shaped: the properties and oracle + * sanity tests should import the same functions so they cannot drift. + */ + +export const I64_MAX = (1n << 63n) - 1n; +export const I64_MIN = -(1n << 63n); +export const I128_MIN = -(1n << 127n); +export const I128_MAX = (1n << 127n) - 1n; + +export function oracleNumberF64(value: number): number { + return value; +} + +export function oracleNumberI32(value: number): number { + return value | 0; +} + +export function oracleNumberU32(value: number): number { + return value >>> 0; +} + +export function oracleNumberI64(value: number): bigint { + if (Number.isNaN(value) || !Number.isFinite(value)) return 0n; + if (value >= 2 ** 63) return I64_MAX; + if (value < -(2 ** 63)) return I64_MIN; + return BigInt(Math.trunc(value)); +} + +export function oracleBigIntI64(value: bigint): bigint { + return BigInt.asIntN(64, value); +} + +export function fitsBigIntI64(value: bigint): boolean { + return value >= I64_MIN && value <= I64_MAX; +} + +export function oracleBigIntU64(value: bigint): bigint { + return BigInt.asUintN(64, value); +} + +export function fitsBigIntU64(value: bigint): boolean { + return value >= 0n && value < 1n << 64n; +} + +export function oracleLosslessI64(value: bigint): { + value: bigint; + lossless: boolean; +} { + const lossless = fitsBigIntI64(value); + return { value: oracleBigIntI64(value), lossless }; +} + +export function oracleLosslessU64(value: bigint): { + value: bigint; + lossless: boolean; +} { + const lossless = fitsBigIntU64(value); + return { value: oracleBigIntU64(value), lossless }; +} + +export function fitsBigIntI128(value: bigint): boolean { + return value >= I128_MIN && value <= I128_MAX; +} + +export function oracleBigIntI128(value: bigint): bigint { + return value; +} + +export function oracleBigIntI128LowBits(value: bigint): bigint { + return BigInt.asIntN(128, value); +} + +export function oracleUint8Array(value: Uint8Array): Uint8Array { + return new Uint8Array(value); +} + +export function equalUint8Array(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/tests/utils/support.ts b/tests/utils/support.ts new file mode 100644 index 0000000..6d4827a --- /dev/null +++ b/tests/utils/support.ts @@ -0,0 +1,54 @@ +import { createRequire } from "node:module"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; + +export type FuzzAddon = { + rtNumberF64(n: number): number; + rtNumberI32(n: number): number; + rtNumberU32(n: number): number; + rtNumberI64(n: number): bigint; + rtBigIntI64(b: bigint): bigint; + rtBigIntU64(b: bigint): bigint; + losslessI64(b: bigint): { value: bigint; lossless: boolean }; + losslessU64(b: bigint): { value: bigint; lossless: boolean }; + rtBigIntI128(b: bigint): bigint; + rtBigIntI128LowBits(b: bigint): bigint; + rtBigIntWords(b: bigint): bigint; + rtUint8Array(value: Uint8Array): Uint8Array; +}; + +const require = createRequire(import.meta.url); + +export const mod = require("../../zig-out/lib/test_fuzz_numeric.node") as FuzzAddon; +export const FUZZ_RUNS = Number(process.env.FUZZ_RUNS ?? 10_000); + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const seedsDir = path.join(__dirname, "../fuzz/seeds"); + +/** + * Load persisted regression cases for a target. + * + * Each file under tests/fuzz/seeds/ is a JSON array of values. + * Missing file -> empty examples list; not an error. + */ +export function loadSeeds(target: string, revive: (raw: unknown) => T): T[] { + const file = path.join(seedsDir, `${target}.json`); + if (!fs.existsSync(file)) return []; + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown[]; + return raw.map(revive); +} + +export function reviveBigInt(raw: unknown): bigint { + if (typeof raw === "object" && raw !== null && "__bigint" in raw) { + return BigInt((raw as { __bigint: string }).__bigint); + } + throw new Error(`Cannot revive as bigint: ${JSON.stringify(raw)}`); +} + +export function reviveUint8Array(raw: unknown): Uint8Array { + if (Array.isArray(raw) && raw.every((v) => Number.isInteger(v))) { + return new Uint8Array(raw); + } + throw new Error(`Cannot revive as Uint8Array: ${JSON.stringify(raw)}`); +}