mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(analysis-view): unify inert inline Evidence admission
Share inline Evidence admission before authentication across analysis views. Reject executable object behavior before reflection, inspect arrays incrementally, preserve inert JSON-omitted metadata and existing budgets. Keep precise error locations with one bounded path stack. Validated base failures, 48 source tests, 71 related public-boundary tests, 27 final CLI/MCP tests and eleven calls against a genuine Chromium HAR; source identity, selected output and capacity accounting remain preserved. Co-authored-by: Zhong-fan <205188959+Zhong-fan@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
5 files changed
+409
-170
No files matched your search
@@ -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<string, unknown> = {};
|
||||
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<unknown> {
|
||||
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 },
|
||||
|
||||
@@ -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<Evidence, AnalysisError> => {
|
||||
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<object>();
|
||||
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,
|
||||
|
||||
@@ -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: {} })),
|
||||
|
||||
@@ -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<void, AnalysisError> {
|
||||
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<void, AnalysisError> {
|
||||
return this.#admitCaptureValue(value, new Set(), 0, true);
|
||||
}
|
||||
|
||||
#admitCaptureValue(
|
||||
value: unknown,
|
||||
ancestors: Set<object>,
|
||||
depth: number,
|
||||
inline: boolean,
|
||||
): Result<void, AnalysisError> {
|
||||
// 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<never, AnalysisError> {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void, AnalysisError>,
|
||||
): Result<void, AnalysisError> => {
|
||||
const ancestors = new Set<object>();
|
||||
// A single bounded stack preserves diagnostics without per-node path copies.
|
||||
const path: (string | number)[] = inline
|
||||
? ["source", "evidence"]
|
||||
: ["source"];
|
||||
const invalid = (): Result<never, AnalysisError> =>
|
||||
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<void, AnalysisError> => {
|
||||
// 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<void, AnalysisError> => {
|
||||
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);
|
||||
};
|
||||
Reference in new issue
Block a user