diff --git a/src/domain/canonicalJsonDigestSteps.test.ts b/src/domain/canonicalJsonDigestSteps.test.ts index f5346b926..8ff3382c5 100644 --- a/src/domain/canonicalJsonDigestSteps.test.ts +++ b/src/domain/canonicalJsonDigestSteps.test.ts @@ -21,6 +21,12 @@ describe("cooperative canonical JSON digest", () => { ), { payload: `${"x".repeat(8191)}𐀀${"\n\ud800".repeat(20_000)}` }, { [`${"x".repeat(8191)}𐀀${"\n".repeat(20_000)}`]: "value" }, + Object.fromEntries( + Array.from({ length: 512 }, (_, index) => [ + `escaped\n\"\\𐀀${"x".repeat(32)}${index}`, + { repeated: index }, + ]), + ), ]; it.each(values.map((value) => ({ value })))( "preserves complete canonical meaning (%#)", @@ -55,6 +61,18 @@ describe("cooperative canonical JSON digest", () => { expect(steps.next().done).toBe(true); }); + it("continues object hashing when a pending property is omitted", () => { + const value: { [key: string]: JsonValue } = { + a: "x".repeat(32_768), + b: 1, + c: { retained: true }, + }; + const steps = canonicalJsonDigestSteps(value); + expect(steps.next().done).toBe(false); + delete value.b; + expect(complete(steps)).toBe(digestCanonicalValue(value)); + }); + it("rejects sparse arrays, nonfinite numbers and cycles", () => { const sparse: JsonValue[] = []; sparse.length = 2; diff --git a/src/domain/canonicalJsonDigestSteps.ts b/src/domain/canonicalJsonDigestSteps.ts index ef4ab8841..35cbb8491 100644 --- a/src/domain/canonicalJsonDigestSteps.ts +++ b/src/domain/canonicalJsonDigestSteps.ts @@ -26,6 +26,9 @@ export function* canonicalJsonDigestSteps( const hash = createHash("sha256"); const ancestors = new Set(); const stack: DigestFrame[] = [{ kind: "value", value }]; + const encodedKeys = new Map(); + // Keep reusable key encodings within one existing digest-buffer quantum. + let remainingKeyCodeUnits = 8192; let buffered = ""; let flushed = false; const emit = (part: string): void => { @@ -69,22 +72,22 @@ export function* canonicalJsonDigestSteps( stack.push(frame); continue; } + let item: JsonValue; if (frame.kind === "array") { if (frame.index === frame.length) { emit("]"); ancestors.delete(frame.value); continue; } - const item = frame.value[frame.index]; - if (item === undefined) + const element = frame.value[frame.index]; + if (element === undefined) throw new TypeError( "Canonical JSON digest requires dense JSON arrays", ); if (frame.index++ > 0) emit(","); - stack.push(frame, { kind: "value", value: item }); - continue; - } - if (frame.kind === "object") { + stack.push(frame); + item = element; + } else if (frame.kind === "object") { if (frame.index === frame.keys.length) { emit("}"); ancestors.delete(frame.value); @@ -93,25 +96,42 @@ export function* canonicalJsonDigestSteps( const key = frame.keys[frame.index++]; if (key === undefined) throw new TypeError("Canonical JSON object changed during hashing"); - const item = frame.value[key]; - if (item === undefined) continue; + const property = frame.value[key]; + if (property === undefined) { + stack.push(frame); + continue; + } if (frame.written++ > 0) emit(","); if (key.length > 8192) { emit('"'); stack.push( frame, - { kind: "value", value: item }, + { kind: "value", value: property }, { kind: "separator" }, { kind: "string", value: key, index: 0 }, ); continue; } - emit(JSON.stringify(key)); + let encodedKey = encodedKeys.get(key); + if (encodedKey === undefined) { + encodedKey = JSON.stringify(key); + if (encodedKey.length <= remainingKeyCodeUnits) { + encodedKeys.set(key, encodedKey); + remainingKeyCodeUnits -= encodedKey.length; + } + } + emit(encodedKey); emit(":"); - stack.push(frame, { kind: "value", value: item }); - continue; + stack.push(frame); + item = property; + } else { + item = frame.value; + } + // Resume containers directly; scalar members need no temporary value frame. + if (flushed) { + flushed = false; + yield; } - const item = frame.value; if (typeof item === "string" && item.length > 8192) { emit('"'); stack.push({ kind: "string", value: item, index: 0 }); @@ -151,6 +171,7 @@ export function* canonicalJsonDigestSteps( } finally { stack.length = 0; ancestors.clear(); + encodedKeys.clear(); buffered = ""; } }