diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 786eb8d..9cd53f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 + env: + npm_config_jobs: 2 + GOMAXPROCS: 2 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -15,10 +19,17 @@ jobs: node-version: '20' cache: npm - run: npm ci + - name: Verify bounded Vitest options + run: | + ./node_modules/.bin/vitest --help > "$RUNNER_TEMP/vitest-help.txt" + cat "$RUNNER_TEMP/vitest-help.txt" + grep -q -- '--maxWorkers' "$RUNNER_TEMP/vitest-help.txt" + grep -q -- '--minWorkers' "$RUNNER_TEMP/vitest-help.txt" + grep -Eq -- '--(fileParallelism|file-parallelism)' "$RUNNER_TEMP/vitest-help.txt" - name: Typecheck run: npm run typecheck - name: Test - run: npm test + run: npm test -- --maxWorkers=2 --minWorkers=1 --no-file-parallelism - name: Build (emits dist/index.d.ts) run: npm run build # `npm run build` runs `check:dts`, which fails the job if the declaration @@ -26,3 +37,13 @@ jobs: # this explicit assertion is what actually gates the type contract). - name: Assert declaration file shipped run: test -f dist/index.d.ts + + canonicalize-regression: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm run test:canonicalize diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f069dd6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### Fixed + +- Canonicalization now preserves an own JSON `__proto__` key as an enumerable + data property without changing the result object's ordinary prototype. + Nested cloning, sorted object keys, array order and omission of undefined + object values are preserved. Consumers storing canonical strings or hashes + for objects with this key should regenerate those values: earlier versions + could omit the key from serialized output. + +### Tests + +- Added dependency-free canonicalization regression tests, including nested + prototype-like keys, descriptors, nonmutation and JSON compatibility. + Run `npm run test:canonicalize` with Node 24 or later (native TypeScript + loading). This focused command does not change the package's runtime support + policy or replace the existing Vitest suite. diff --git a/package.json b/package.json index 857bc1d..25ca615 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test": "vitest run", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:canonicalize": "node --test --test-concurrency=1 tests/canonicalize.test.mjs" }, "keywords": [ "graph", diff --git a/src/bundle/canonicalize.ts b/src/bundle/canonicalize.ts index 078930d..9864176 100644 --- a/src/bundle/canonicalize.ts +++ b/src/bundle/canonicalize.ts @@ -46,7 +46,12 @@ export function canonicalize(value: T): T { if (v === undefined) { continue; } - result[key] = canonicalize(v); + Object.defineProperty(result, key, { + value: canonicalize(v), + enumerable: true, + writable: true, + configurable: true, + }); } return result as unknown as T; } diff --git a/tests/canonicalize.test.mjs b/tests/canonicalize.test.mjs new file mode 100644 index 0000000..228229a --- /dev/null +++ b/tests/canonicalize.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { canonicalize, canonicalStringify } from '../src/bundle/canonicalize.ts'; + +test('preserves own prototype-like JSON keys as ordinary data properties', () => { + const source = JSON.parse('{"__proto__":{"polluted":true},"constructor":3,"prototype":4,"a":1}'); + const result = canonicalize(source); + assert.equal(Object.getPrototypeOf(result), Object.prototype); + assert.equal(Object.hasOwn(Object.prototype, 'polluted'), false); + assert.deepEqual(Object.keys(result), ['__proto__', 'a', 'constructor', 'prototype']); + assert.deepEqual(Object.getOwnPropertyDescriptor(result, '__proto__'), { + value: { polluted: true }, enumerable: true, writable: true, configurable: true, + }); + assert.deepEqual(JSON.parse(canonicalStringify(source)), source); +}); + +test('clones nested objects and arrays without changing the input or array order', () => { + const source = JSON.parse('{"list":[{"__proto__":7,"z":3},2,1],"b":{"y":2,"x":1}}'); + const before = JSON.stringify(source); + Object.freeze(source.list[0]); + Object.freeze(source.list); + Object.freeze(source.b); + Object.freeze(source); + const result = canonicalize(source); + assert.equal(JSON.stringify(source), before); + assert.notEqual(result, source); + assert.notEqual(result.list, source.list); + assert.notEqual(result.list[0], source.list[0]); + assert.notEqual(result.b, source.b); + assert.deepEqual(result.list, source.list); + assert.equal(Object.getPrototypeOf(result.list[0]), Object.prototype); + assert.equal(Object.hasOwn(result.list[0], '__proto__'), true); +}); + +test('sorts object keys deterministically including own __proto__', () => { + const a = JSON.parse('{"z":3,"__proto__":null,"a":{"y":2,"x":1}}'); + const b = JSON.parse('{"a":{"x":1,"y":2},"__proto__":null,"z":3}'); + const expected = '{"__proto__":null,"a":{"x":1,"y":2},"z":3}'; + assert.equal(canonicalStringify(a), expected); + assert.equal(canonicalStringify(b), expected); +}); + +test('retains undefined omission, null, primitives and array JSON behavior', () => { + const source = { missing: undefined, present: null, list: [undefined, false, 0, ''] }; + const result = canonicalize(source); + assert.equal(Object.hasOwn(result, 'missing'), false); + assert.deepEqual(result.list, [undefined, false, 0, '']); + assert.equal(canonicalStringify(source), '{"list":[null,false,0,""],"present":null}'); + for (const value of [null, undefined, false, true, 0, 4, 'hello']) { + assert.equal(canonicalize(value), value); + } +});