Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,42 @@ 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
with:
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
# file was not produced (tsup exits 0 even when the DTS phase fails, so
# 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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/bundle/canonicalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export function canonicalize<T>(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;
}
Expand Down
52 changes: 52 additions & 0 deletions tests/canonicalize.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading