diff --git a/docs/doctoring/document-autosave-queue.md b/docs/doctoring/document-autosave-queue.md index 945262823..1de64a32a 100644 --- a/docs/doctoring/document-autosave-queue.md +++ b/docs/doctoring/document-autosave-queue.md @@ -133,6 +133,32 @@ hashing boundary. Deep immutability proves that the submitted graph cannot be mutated after validation; it does not prove that a caller-supplied digest was honestly derived from that graph. +## Array preflight finding — 2026-09-07 + +Status: Active PR / Proposed (#184), not protected-main implementation. + +The earlier array preflight avoided explicit key enumeration but first checked +whether each container was frozen. Frozen-state inspection also enumerates a +Proxy's keys. A packed public queue probe with a frozen, oversized sparse array +was correctly rejected without calling the host save function, yet its key trap +ran once before rejection. A costly trap could therefore run even when the +array's length already made acceptance impossible. + +The candidate keeps early frozen-state validation for objects and checks array +length, remaining value capacity, and child depth before array frozen-state +inspection. It still rejects bounded mutable arrays and preserves dense-array, +descriptor, prototype, cycle, schema, digest, and detached-snapshot checks. +No API, limit, durable-save authority, or error category changes. This avoids +unnecessary enumeration; it does not place a time limit on arbitrary Proxy code +that runs during otherwise necessary reflection. + +The two regressions in `evidenceValidationArrayPreflight.test.ts` first failed +with one key-trap invocation instead of zero for oversized and over-depth +arrays. A separate bounded-unfrozen-array case preserves the immutability gate. +Both public queue and durable session reuse this validation boundary. New-head +packed consumer verification remains required; predecessor archive verification +does not prove the candidate implementation. + ## Security and privacy considerations Revision tags are equality validators, not authorization tokens, signatures, diff --git a/src/autosave/detachedDigestResourceBoundary.test.ts b/src/autosave/detachedDigestResourceBoundary.test.ts new file mode 100644 index 000000000..187eaf116 --- /dev/null +++ b/src/autosave/detachedDigestResourceBoundary.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createDetachedAutosaveRevisionEvidence } from './evidenceValidation.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createFrozenEvidence(digestHex: string): unknown { + const documentJson = Object.freeze({ + type: 'doc', + content: Object.freeze([]), + }); + const envelope = Object.freeze({ + schemaId: 'https://inkspan.io/schemas/document-envelope/v1', + schemaVersion: 1, + documentJson, + }); + const revision = Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); + return Object.freeze({ envelope, revision }); +} + +describe('detached autosave digest resource preflight', () => { + it('rejects an impossible SHA-256 digest length before regex scanning', () => { + const regexTest = vi.spyOn(RegExp.prototype, 'test'); + + expect( + createDetachedAutosaveRevisionEvidence(createFrozenEvidence('a'.repeat(65))), + ).toBeNull(); + expect(regexTest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/autosave/evidenceValidation.ts b/src/autosave/evidenceValidation.ts index 1fb5fd711..d3d91328b 100644 --- a/src/autosave/evidenceValidation.ts +++ b/src/autosave/evidenceValidation.ts @@ -18,6 +18,10 @@ interface JsonTraversalEntry { readonly depth: number; } +type JsonContainerChildren = + | Readonly<{ kind: 'array'; length: number }> + | Readonly<{ kind: 'object'; keys: (string | symbol)[] }>; + /** Detached evidence shape returned to the public autosave queue. */ export interface DetachedDocumentAutosaveRevisionEvidence { /** Detached active-schema document envelope. */ @@ -46,23 +50,19 @@ function readExactFrozenDataRecord( expectedKeys: readonly string[], ): ExactDataRecord | null { try { - if ( - typeof value !== 'object' || - value === null || - !Object.isFrozen(value) - ) { - return null; + if (typeof value !== 'object' || value === null) return null; + + for (const expectedKey of expectedKeys) { + if (Object.getOwnPropertyDescriptor(value, expectedKey) === undefined) { + return null; + } } + + if (!Object.isFrozen(value)) return null; + const ownKeys = Reflect.ownKeys(value); - if ( - ownKeys.length !== expectedKeys.length || - ownKeys.some( - (key) => - typeof key !== 'string' || !expectedKeys.includes(key), - ) - ) { - return null; - } + if (ownKeys.length !== expectedKeys.length) return null; + const record: ExactDataRecord = {}; for (const expectedKey of expectedKeys) { const descriptor = Object.getOwnPropertyDescriptor(value, expectedKey); @@ -105,12 +105,6 @@ export function isDeeplyFrozenDocumentJson(rootValue: unknown): boolean { while (pendingEntries.length > 0) { const currentEntry = pendingEntries.pop() as JsonTraversalEntry; inspectedValueCount += 1; - if ( - inspectedValueCount > MAX_AUTOSAVE_EVIDENCE_JSON_VALUES || - currentEntry.depth > MAX_AUTOSAVE_EVIDENCE_NESTING_DEPTH - ) { - return false; - } const currentValue = currentEntry.value; if ( @@ -128,19 +122,47 @@ export function isDeeplyFrozenDocumentJson(rootValue: unknown): boolean { } if ( typeof currentValue !== 'object' || - visitedContainers.has(currentValue) || - !Object.isFrozen(currentValue) + visitedContainers.has(currentValue) ) { return false; } visitedContainers.add(currentValue); const childDepth = currentEntry.depth + 1; + let children: JsonContainerChildren; if (Array.isArray(currentValue)) { - const length = currentValue.length; + const length = Object.getOwnPropertyDescriptor( + currentValue, + 'length', + )!.value as number; + children = { kind: 'array', length }; + } else { + if (!Object.isFrozen(currentValue)) return false; + const prototype = Object.getPrototypeOf(currentValue); + if (prototype !== Object.prototype && prototype !== null) return false; + children = { kind: 'object', keys: Reflect.ownKeys(currentValue) }; + } + + const childCount = + children.kind === 'array' ? children.length : children.keys.length; + const remainingValueCapacity = + MAX_AUTOSAVE_EVIDENCE_JSON_VALUES - + inspectedValueCount - + pendingEntries.length; + if (childCount > remainingValueCapacity) return false; + if ( + childCount > 0 && + childDepth > MAX_AUTOSAVE_EVIDENCE_NESTING_DEPTH + ) { + return false; + } + + if (children.kind === 'array') { + // Frozen-state inspection also enumerates Proxy keys; preflight first. + if (!Object.isFrozen(currentValue)) return false; const ownKeys = Reflect.ownKeys(currentValue); - if (ownKeys.length !== length + 1) return false; - for (let index = 0; index < length; index += 1) { + if (ownKeys.length !== children.length + 1) return false; + for (let index = 0; index < children.length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor( currentValue, String(index), @@ -160,9 +182,7 @@ export function isDeeplyFrozenDocumentJson(rootValue: unknown): boolean { continue; } - const prototype = Object.getPrototypeOf(currentValue); - if (prototype !== Object.prototype && prototype !== null) return false; - for (const key of Reflect.ownKeys(currentValue)) { + for (const key of children.keys) { if (typeof key !== 'string') return false; const descriptor = Object.getOwnPropertyDescriptor(currentValue, key); if ( @@ -234,6 +254,7 @@ export function createDetachedAutosaveRevisionEvidence( revisionRecord === null || revisionRecord.algorithm !== 'SHA-256' || typeof revisionRecord.digestHex !== 'string' || + revisionRecord.digestHex.length !== 64 || !LOWERCASE_SHA256_DIGEST.test(revisionRecord.digestHex) || typeof revisionRecord.strongEntityTag !== 'string' || revisionRecord.strongEntityTag !== diff --git a/src/autosave/evidenceValidationArrayPreflight.test.ts b/src/autosave/evidenceValidationArrayPreflight.test.ts new file mode 100644 index 000000000..45383874a --- /dev/null +++ b/src/autosave/evidenceValidationArrayPreflight.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createDetachedAutosaveRevisionEvidence, + isDeeplyFrozenDocumentJson, +} from './evidenceValidation.js'; + +describe('autosave detached evidence array resource preflight', () => { + it('still rejects a bounded unfrozen array', () => { + expect(isDeeplyFrozenDocumentJson([null])).toBe(false); + }); + + it('rejects an impossible array length before frozen-state key enumeration', () => { + let ownKeysCalls = 0; + const oversizedArray = new Proxy(Object.freeze(new Array(1_000_001)), { + ownKeys(target) { + ownKeysCalls += 1; + return Reflect.ownKeys(target); + }, + }); + + expect(isDeeplyFrozenDocumentJson(oversizedArray)).toBe(false); + expect(ownKeysCalls).toBe(0); + }); + + it('rejects over-depth array children before frozen-state key enumeration', () => { + let ownKeysCalls = 0; + let root: readonly unknown[] = new Proxy(Object.freeze([null]), { + ownKeys(target) { + ownKeysCalls += 1; + return Reflect.ownKeys(target); + }, + }); + for (let depth = 0; depth < 128; depth += 1) root = Object.freeze([root]); + + expect(isDeeplyFrozenDocumentJson(root)).toBe(false); + expect(ownKeysCalls).toBe(0); + }); + + it('rejects an impossible array length before explicit own-key enumeration', () => { + const oversizedArray = new Array(1_000_001); + Object.freeze(oversizedArray); + + const ownKeys = vi.spyOn(Reflect, 'ownKeys'); + try { + expect(isDeeplyFrozenDocumentJson(oversizedArray)).toBe(false); + expect(ownKeys).not.toHaveBeenCalledWith(oversizedArray); + } finally { + ownKeys.mockRestore(); + } + }); + + it('does not execute array length get traps while validating frozen evidence', () => { + const target = Object.freeze([1]); + let lengthRead = false; + const proxiedArray = new Proxy(target, { + get(currentTarget, property, receiver) { + if (property === 'length') lengthRead = true; + return Reflect.get(currentTarget, property, receiver); + }, + }); + + expect(isDeeplyFrozenDocumentJson(proxiedArray)).toBe(true); + expect(lengthRead).toBe(false); + }); + + it('rejects over-depth array children before reading their descriptors', () => { + const deepestArray: readonly unknown[] = Object.freeze([null]); + let root: readonly unknown[] = deepestArray; + for (let depth = 0; depth < 128; depth += 1) { + root = Object.freeze([root]); + } + + const getOwnPropertyDescriptor = vi.spyOn( + Object, + 'getOwnPropertyDescriptor', + ); + try { + expect(isDeeplyFrozenDocumentJson(root)).toBe(false); + expect( + getOwnPropertyDescriptor.mock.calls.some( + ([value, property]) => value === deepestArray && property === '0', + ), + ).toBe(false); + } finally { + getOwnPropertyDescriptor.mockRestore(); + } + }); + + it('rejects over-depth object children before reading their descriptors', () => { + const deepestObject = Object.freeze({ child: null }); + let root: Readonly<{ child: unknown }> = deepestObject; + for (let depth = 0; depth < 128; depth += 1) { + root = Object.freeze({ child: root }); + } + + const getOwnPropertyDescriptor = vi.spyOn( + Object, + 'getOwnPropertyDescriptor', + ); + try { + expect(isDeeplyFrozenDocumentJson(root)).toBe(false); + expect( + getOwnPropertyDescriptor.mock.calls.some( + ([value, property]) => value === deepestObject && property === 'child', + ), + ).toBe(false); + } finally { + getOwnPropertyDescriptor.mockRestore(); + } + }); + + it('accepts an empty object exactly at the maximum nesting depth', () => { + const deepestObject = Object.freeze({}); + let root: Readonly> = deepestObject; + for (let depth = 0; depth < 128; depth += 1) { + root = Object.freeze({ child: root }); + } + + expect(isDeeplyFrozenDocumentJson(root)).toBe(true); + }); + + it('rejects missing record members before whole-record key enumeration', () => { + const incompleteEvidence = Object.freeze({ envelope: null }); + let ownKeysCalls = 0; + const proxiedEvidence = new Proxy(incompleteEvidence, { + ownKeys(target) { + ownKeysCalls += 1; + return Reflect.ownKeys(target); + }, + }); + + expect(createDetachedAutosaveRevisionEvidence(proxiedEvidence)).toBeNull(); + expect(ownKeysCalls).toBe(0); + }); + + it('rejects a complete unfrozen record before whole-record key enumeration', () => { + const unfrozenEvidence = { envelope: null, revision: null }; + const ownKeys = vi.spyOn(Reflect, 'ownKeys'); + try { + expect(createDetachedAutosaveRevisionEvidence(unfrozenEvidence)).toBeNull(); + expect(ownKeys).not.toHaveBeenCalledWith(unfrozenEvidence); + } finally { + ownKeys.mockRestore(); + } + }); + + it('rejects unexpected record members after required-key preflight', () => { + const evidenceWithExtraMember = Object.freeze({ + envelope: null, + revision: null, + unexpected: null, + }); + + expect( + createDetachedAutosaveRevisionEvidence(evidenceWithExtraMember), + ).toBeNull(); + }); +});