diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index b4421f80ae..302302ee21 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js"; import { createEvidence, parseEvidence } from "../../domain/evidence.js"; +import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js"; import { functionDossierSchema } from "../../domain/hopperValues.js"; import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js"; import { @@ -432,45 +433,284 @@ it("refuses an object-dense borrowed capture before making a full schema clone", expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed); }); -it("rejects accessor-backed inline Evidence before all view projections", () => { - const views = [ - { kind: "summary" }, - { kind: "capture" }, - { kind: "api-surface" }, - ] as const; - for (const view of views) { +it.each(["summary", "capture", "api-surface"] as const)( + "rejects inline accessors before parsing a %s view", + (kind) => { let accessed = false; const evidence = { get normalized_result() { accessed = true; - throw new Error("Evidence accessor must not be invoked"); + throw new Error("Evidence must be admitted before parsing"); }, }; - const result = inspectAnalysisView({ + const input = { source: { kind: "inline", evidence }, - view, - }); - expect(result).toMatchObject({ + view: { kind }, + }; + const parsed = inspectAnalysisViewInputSchema.parse(input); + if (parsed.source.kind !== "inline") + throw new Error("Expected inline input"); + expect(parsed.source.evidence).toBe(evidence); + expect(inspectAnalysisView(input)).toMatchObject({ ok: false, - error: { _tag: "AnalysisInputError" }, + error: { + _tag: "AnalysisInputError", + issues: [{ path: ["source", "evidence", "normalized_result"] }], + }, }); expect(accessed).toBe(false); - } - - const parent = analysisViewCaptureEvidence(); - for (const view of views) expect( inspectAnalysisView({ - source: { kind: "inline", evidence: parent }, - view, + source: { kind: "inline", evidence: analysisViewCaptureEvidence() }, + view: { kind }, }).ok, ).toBe(true); + }, +); + +it.each(["summary", "capture", "api-surface"] as const)( + "rejects nested, hidden and inherited inline accessors for %s", + (kind) => { + const parent = analysisViewCaptureEvidence(); + let accessed = false; + const getter = () => { + accessed = true; + throw new Error("Inline accessors must remain inert"); + }; + const nested = { + get value() { + return getter(); + }, + }; + const hidden = Object.defineProperty({}, "value", { get: getter }); + const array = Object.defineProperty([null], 0, { get: getter }); + const inherited: unknown = Object.create({ + get value() { + return getter(); + }, + }); + const cyclic: Record = {}; + cyclic["self"] = cyclic; + const cases: { data: unknown; path: (string | number)[] }[] = [ + { data: nested, path: ["value"] }, + { data: hidden, path: ["value"] }, + { data: array, path: [0] }, + { data: inherited, path: [] }, + { data: cyclic, path: ["self"] }, + ]; + for (const { data, path } of cases) { + // A complete envelope reaches payload parsing and Evidence authentication. + const input = { + source: { + kind: "inline" as const, + evidence: { ...parent, raw_result: data }, + }, + view: { kind }, + }; + for (const result of [ + inspectAnalysisView(input), + inspectAnalysisViewValidated( + inspectAnalysisViewInputSchema.parse(input), + ), + ]) + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [{ path: ["source", "evidence", "raw_result", ...path] }], + }, + }); + } + expect(accessed).toBe(false); + }, +); + +it.each([ + ["own map", "map", false], + ["own iterator", Symbol.iterator, false], + ["own constructor", "constructor", false], + ["inherited map", "map", true], + ["inherited iterator", Symbol.iterator, true], +] as const)( + "rejects array %s accessors without invoking them", + (_name, key, inherited) => { + const parent = analysisViewCaptureEvidence(); + let accessed = false; + const array: unknown[] = []; + const owner: object = inherited ? Object.create(Array.prototype) : array; + Object.defineProperty(owner, key, { + get() { + accessed = true; + throw new Error("Array behavior must remain inert"); + }, + }); + if (inherited) Object.setPrototypeOf(array, owner); + for (const kind of ["summary", "capture", "api-surface"] as const) { + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: { ...parent, raw_result: array } }, + view: { kind }, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + } + expect(accessed).toBe(false); + }, +); + +it("rejects callable array overrides and subclass species before parsing", () => { + const parent = analysisViewCaptureEvidence(); + let invoked = false; + const callback = () => { + invoked = true; + throw new Error("Array callbacks must remain inert"); + }; + class CustomArray extends Array { + static override get [Symbol.species](): ArrayConstructor { + return callback(); + } + } + for (const array of [ + Object.defineProperty([], "map", { value: callback }), + Object.defineProperty([], Symbol.iterator, { value: callback }), + new CustomArray(), + ]) + for (const kind of ["summary", "capture", "api-surface"] as const) { + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: { ...parent, raw_result: array } }, + view: { kind }, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + } + expect(invoked).toBe(false); +}); + +it.each(["summary", "capture", "api-surface"] as const)( + "preserves ordinary inline Evidence for %s", + (kind) => { + const parent = analysisViewCaptureEvidence(); + const before = JSON.stringify(parent); + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind }, + }); + if (!result.ok) throw result.error; + expect(parseEvidence(result.value)).toEqual(result.value); + expect(result.value.evidence_links).toEqual([parent.evidence_id]); + expect(result.value.normalized_result).toMatchObject({ + kind, + parent_evidence_id: parent.evidence_id, + }); + expect(JSON.stringify(parent)).toBe(before); + expect(Object.isFrozen(parent)).toBe(false); + }, +); + +it("rejects executable constructor values before schema plain-object checks", () => { + const parent = analysisViewCaptureEvidence(); + let invoked = false; + const constructor = () => undefined; + Object.defineProperty(constructor, "prototype", { + get() { + invoked = true; + throw new Error("Constructor metadata must remain inert"); + }, + }); + for (const kind of ["summary", "capture", "api-surface"] as const) { + expect( + inspectAnalysisView({ + source: { + kind: "inline", + evidence: { ...parent, raw_result: { constructor } }, + }, + view: { kind }, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + } + expect(invoked).toBe(false); +}); + +it("rejects inline proxy values before reflection or parsing can run traps", () => { + const parent = analysisViewCaptureEvidence(); + let invoked = false; + const trap = () => { + invoked = true; + throw new Error("Proxy behavior must remain inert"); + }; + const revoked = Proxy.revocable([], {}); + revoked.revoke(); + for (const value of [ + new Proxy({}, { getPrototypeOf: trap, ownKeys: trap, get: trap }), + new Proxy(() => undefined, { get: trap }), + revoked.proxy, + ]) + for (const kind of ["summary", "capture", "api-surface"] as const) { + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: { ...parent, raw_result: value } }, + view: { kind }, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + } + expect(invoked).toBe(false); +}); + +it("keeps ignored array metadata inert through parsing and projection", () => { + const capture = analysisViewCaptureFixture(); + const parent = createEvidence( + { + path: capture.artifact.path, + sha256: capture.artifact.sha256!, + format: "file", + }, + { id: "fixture", name: "fixture", version: "1" }, + { + operation: "inspect_web_network_capture", + parameters: { sensitive_value_count: 0 }, + result: capture, + rawResult: [], + }, + ); + if (!Array.isArray(parent.raw_result)) throw new Error("Expected raw array"); + const array = parent.raw_result; + let invoked = false; + const getter = () => { + invoked = true; + throw new Error("Ignored array metadata must remain inert"); + }; + for (let index = 0; index < 10_000; index += 1) + Object.defineProperty(array, `metadata-${index}`, { get: getter }); + Object.defineProperty(array, Symbol("metadata"), { get: getter }); + for (const kind of ["summary", "capture", "api-surface"] as const) { + const result = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind }, + }); + if (!result.ok) throw result.error; + expect(result.value.evidence_links).toEqual([parent.evidence_id]); + } + expect(invoked).toBe(false); + expect(parseEvidence(parent).raw_result).toEqual([]); }); it("keeps inline-envelope admission live alongside capture validation and grouping", () => { // Each clone alone fits; both graphs coexist while the view is projected. const capture = analysisViewObjectDenseCapture(40_000); const parent = analysisViewCaptureEvidence(capture); + for (const kind of ["summary", "capture"] as const) { + const projected = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view: { kind }, + }); + if (!projected.ok) throw projected.error; + expect(projected.value.evidence_links).toEqual([parent.evidence_id]); + } const retained = inspectAnalysisView( { source: { kind: "retained-evidence", evidence_id: parent.evidence_id }, diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index fbc434cbe2..7ee678d447 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -6,10 +6,7 @@ import type { EvidenceLookup } from "../EvidenceInputResolver.js"; import { resolveEvidenceInput } from "../EvidenceInputResolver.js"; import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisCapabilityUnavailableError, - AnalysisInputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { @@ -27,6 +24,7 @@ import { jsonValueSchema } from "../../domain/jsonValue.js"; import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js"; import { err, ok, type Result } from "../../domain/result.js"; import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js"; +import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js"; /** Authenticate a selected-view request and project already completed Evidence. */ export const inspectAnalysisView = ( @@ -58,8 +56,11 @@ export const inspectAnalysisViewValidated = ( input.view.include_query_parameter_names === true, ) : undefined; - if (input.source.kind === "inline" && accumulator !== undefined) { - const admitted = accumulator.admitInlineEvidence(input.source.evidence); + if (input.source.kind === "inline") { + const admitted = + accumulator === undefined + ? admitAnalysisViewData(input.source.evidence, true) + : accumulator.admitInlineEvidence(input.source.evidence); if (!admitted.ok) return admitted; } const resolved = resolveAnalysisViewSource(input.source, lookup); @@ -165,18 +166,6 @@ const resolveAnalysisViewSource = ( ): Result => { if (source.kind === "retained-evidence") return resolveEvidenceInput(source, lookup); - const invalidDataPath = inlineEvidenceInvalidDataPath(source.evidence); - if (invalidDataPath !== undefined) - return err( - new AnalysisInputError(INSPECT_ANALYSIS_VIEW_OPERATION, undefined, [ - { - path: ["source", "evidence", ...invalidDataPath], - reason: "invalid_type", - message: - "Inline Evidence must contain plain JSON data properties without accessors or cycles.", - }, - ]), - ); try { return ok(parseEvidence(source.evidence)); } catch (cause: unknown) { @@ -203,53 +192,6 @@ const resolveAnalysisViewSource = ( } }; -/** Reject non-JSON object behavior before Zod reads caller-owned Evidence. */ -const inlineEvidenceInvalidDataPath = ( - root: unknown, -): readonly (string | number)[] | undefined => { - const active = new Set(); - const pending: { - readonly value: unknown; - readonly path: readonly (string | number)[]; - readonly exit?: object; - }[] = [{ value: root, path: [] }]; - while (pending.length > 0) { - const current = pending.pop(); - if (current === undefined) continue; - if (current.exit !== undefined) { - active.delete(current.exit); - continue; - } - const { value, path } = current; - if (typeof value !== "object" || value === null) continue; - const isArray = Array.isArray(value); - const prototype = Object.getPrototypeOf(value); - if ( - (isArray && prototype !== Array.prototype) || - (!isArray && prototype !== Object.prototype && prototype !== null) || - active.has(value) - ) - return path; - - active.add(value); - pending.push({ value, path, exit: value }); - const keys = Object.getOwnPropertyNames(value); - for (let index = keys.length - 1; index >= 0; index -= 1) { - const key = keys[index]; - if (key === undefined) continue; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - const childPath = [ - ...path, - isArray && /^(0|[1-9]\d*)$/u.test(key) ? Number(key) : key, - ]; - if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) - return childPath; - pending.push({ value: descriptor.value, path: childPath }); - } - } - return undefined; -}; - const createAnalysisViewEvidence = ( input: InspectAnalysisViewInput, parent: Evidence, diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts index d75a23b1a9..a55cf2e15c 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { analysisViewCaptureFixture } from "../../../tests/fixtures/analysisView.js"; import type { WebNetworkCaptureRecord } from "../webNetworkCapture.js"; import { WebNetworkApiSurfaceAccumulator } from "./WebNetworkApiSurfaceAccumulator.js"; @@ -73,6 +73,43 @@ it("admits the capture schema clone and grouping against the same budget", () => expect(accumulator.page(0, 1).groups).toEqual([]); }); +it.each(["admitCaptureClone", "admitInlineEvidence"] as const)( + "%s refuses dense array expansion without enumerating its keys", + (method) => { + const array = Array.from({ length: 100_000 }, () => null); + let enumerated = false; + let inspected = 0; + const ownNames = Object.getOwnPropertyNames; + const ownDescriptor = Object.getOwnPropertyDescriptor; + const keys = vi + .spyOn(Object, "getOwnPropertyNames") + .mockImplementation((value) => { + if (value === array) enumerated = true; + return ownNames(value); + }); + const descriptors = vi + .spyOn(Object, "getOwnPropertyDescriptor") + .mockImplementation((value, key) => { + if (value === array) inspected += 1; + return ownDescriptor(value, key); + }); + try { + expect( + new WebNetworkApiSurfaceAccumulator(4096)[method](array), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError", resource: "memory" }, + }); + expect(enumerated).toBe(false); + expect(inspected).toBeGreaterThan(0); + expect(inspected).toBeLessThan(16); + } finally { + keys.mockRestore(); + descriptors.mockRestore(); + } + }, +); + it("charges non-enumerable fields that the capture schema would clone", () => { const value = Object.defineProperty({}, "records", { value: Array.from({ length: 100 }, () => ({ reported: {} })), diff --git a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts index 16f3d8efb7..4e10d46ce5 100644 --- a/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts +++ b/src/domain/analysisView/WebNetworkApiSurfaceAccumulator.ts @@ -1,8 +1,5 @@ import { z } from "zod"; -import { - AnalysisInputError, - AnalysisResourceConstraintError, -} from "../analysisErrorCore.js"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { @@ -10,7 +7,7 @@ import { parseUrlQueryParameterNames, } from "../recordedTransportUrl.js"; import { err, ok, type Result } from "../result.js"; -import { MAX_JSON_DEPTH } from "../jsonValue.js"; +import { admitAnalysisViewData } from "./analysisViewDataAdmission.js"; import { WEB_NETWORK_CAPTURE_LIMITS, type WebNetworkCaptureRecord, @@ -89,9 +86,6 @@ const ENTRY_BYTES = 128; const RECORD_SCRATCH_BYTES = 512; const SEGMENT_SCRATCH_BYTES = 512; const CAPTURE_CONTAINER_BYTES = 512; -// Covers cloned member slots and per-member validation/entries/map scratch. -// Scalar strings remain shared references, so their text is not copied here. -const CAPTURE_MEMBER_BYTES = 256; const textBytes = (length: number): number => 32 + length * 14; const segmentRule = (value: string): Rule | null => { @@ -156,87 +150,15 @@ export class WebNetworkApiSurfaceAccumulator { /** Reserve schema output and validation scratch while the borrowed parent stays live. */ admitCaptureClone(value: unknown): Result { - return this.#admitCaptureValue(value, new Set(), 0, false); + return admitAnalysisViewData(value, false, (bytes) => + this.#retain(bytes) ? ok(undefined) : this.#overBudget(), + ); } /** Admit the complete portable envelope before cloning or authenticating it. */ admitInlineEvidence(value: unknown): Result { - return this.#admitCaptureValue(value, new Set(), 0, true); - } - - #admitCaptureValue( - value: unknown, - ancestors: Set, - depth: number, - inline: boolean, - ): Result { - // Capture records add three levels before reported; inline Evidence adds one. - if ( - depth > MAX_JSON_DEPTH + (inline ? 4 : 3) || - (typeof value === "object" && value !== null && ancestors.has(value)) - ) - return this.#invalidCapture(); - if (!this.#retain(CAPTURE_MEMBER_BYTES)) return this.#overBudget(); - // Evidence authentication encodes strings and object keys for hashing. - // Keep that allowance live alongside the envelope clone and projection. - if ( - inline && - typeof value === "string" && - !this.#retain(textBytes(value.length)) - ) - return this.#overBudget(); - if (typeof value !== "object" || value === null) return ok(undefined); - if (!this.#retain(CAPTURE_CONTAINER_BYTES)) return this.#overBudget(); - ancestors.add(value); - // Walk depth-first without building a second capture graph. Member charges - // include key enumeration; aliases count each time the schema copies them. - if (Array.isArray(value)) { - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, index); - if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) - return this.#invalidCapture(); - const admitted = this.#admitCaptureValue( - descriptor.value, - ancestors, - depth + 1, - inline, - ); - if (!admitted.ok) return admitted; - } - } else { - const prototype: unknown = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) - return this.#invalidCapture(); - // Zod reads named fields even when they are non-enumerable. - for (const key of Object.getOwnPropertyNames(value)) { - if (inline && !this.#retain(textBytes(key.length))) - return this.#overBudget(); - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) - return this.#invalidCapture(); - const admitted = this.#admitCaptureValue( - descriptor.value, - ancestors, - depth + 1, - inline, - ); - if (!admitted.ok) return admitted; - } - } - ancestors.delete(value); - return ok(undefined); - } - - #invalidCapture(): Result { - return err( - new AnalysisInputError("inspect_analysis_view", undefined, [ - { - path: ["source"], - reason: "invalid_value", - message: - "Parent capture must contain bounded ordinary JSON data without cycles or accessors.", - }, - ]), + return admitAnalysisViewData(value, true, (bytes) => + this.#retain(bytes) ? ok(undefined) : this.#overBudget(), ); } diff --git a/src/domain/analysisView/analysisViewDataAdmission.ts b/src/domain/analysisView/analysisViewDataAdmission.ts new file mode 100644 index 0000000000..ad39d23418 --- /dev/null +++ b/src/domain/analysisView/analysisViewDataAdmission.ts @@ -0,0 +1,98 @@ +import { isProxy } from "node:util/types"; + +import { AnalysisInputError } from "../analysisErrorCore.js"; +import type { AnalysisError } from "../analysisErrorBase.js"; +import { MAX_JSON_DEPTH } from "../jsonValue.js"; +import { err, ok, type Result } from "../result.js"; + +// Covers cloned member slots and per-member validation/entries/map scratch. +const MEMBER_BYTES = 256; +const CONTAINER_BYTES = 512; +const textBytes = (length: number): number => 32 + length * 14; + +/** Inspect borrowed data without invoking accessors; only API-surface reserves bytes. */ +export const admitAnalysisViewData = ( + value: unknown, + inline: boolean, + reserve?: (bytes: number) => Result, +): Result => { + const ancestors = new Set(); + // A single bounded stack preserves diagnostics without per-node path copies. + const path: (string | number)[] = inline + ? ["source", "evidence"] + : ["source"]; + const invalid = (): Result => + err( + new AnalysisInputError("inspect_analysis_view", undefined, [ + { + path: [...path], + reason: "invalid_value", + message: + "Parent data must contain bounded ordinary JSON data without cycles or accessors.", + }, + ]), + ); + const visit = (item: unknown, depth: number): Result => { + // Proxy reflection and callable constructor metadata can execute caller code. + if (isProxy(item) || typeof item === "function") return invalid(); + // Capture records add three levels before reported; inline Evidence adds one. + if ( + depth > MAX_JSON_DEPTH + (inline ? 4 : 3) || + (typeof item === "object" && item !== null && ancestors.has(item)) + ) + return invalid(); + const member = reserve?.( + MEMBER_BYTES + + (inline && typeof item === "string" ? textBytes(item.length) : 0) + + (typeof item === "object" && item !== null ? CONTAINER_BYTES : 0), + ); + if (member !== undefined && !member.ok) return member; + if (typeof item !== "object" || item === null) return ok(undefined); + ancestors.add(item); + const child = (key: string | number): Result => { + path.push(key); + const descriptor = Object.getOwnPropertyDescriptor(item, key); + const admitted = + descriptor === undefined || !Object.hasOwn(descriptor, "value") + ? invalid() + : visit(descriptor.value, depth + 1); + path.pop(); + return admitted; + }; + // Walk depth-first without copying the graph. Aliases are examined each time + // the schema copies them, so byte reservations retain the same accounting. + if (Array.isArray(item)) { + // JSON validation iterates arrays and cloning calls map (which reads + // constructor/species). Reject those traversal overrides without building + // an input-sized key list. Other array metadata is omitted by JSON cloning. + if ( + Object.getPrototypeOf(item) !== Array.prototype || + Object.hasOwn(item, Symbol.iterator) || + Object.hasOwn(item, "map") || + Object.hasOwn(item, "constructor") + ) + return invalid(); + for (let index = 0; index < item.length; index += 1) { + const admitted = child(index); + if (!admitted.ok) return admitted; + } + } else { + const prototype: unknown = Object.getPrototypeOf(item); + if (prototype !== Object.prototype && prototype !== null) + return invalid(); + // Zod reads named fields even when they are non-enumerable. + for (const key of Object.getOwnPropertyNames(item)) { + // Authentication hashes strings and keys alongside the schema clone. + const keyAdmission = inline + ? reserve?.(textBytes(key.length)) + : undefined; + if (keyAdmission !== undefined && !keyAdmission.ok) return keyAdmission; + const admitted = child(key); + if (!admitted.ok) return admitted; + } + } + ancestors.delete(item); + return ok(undefined); + }; + return visit(value, 0); +};