diff --git a/src/domain/browserScenarioDiffEvidence.ts b/src/domain/browserScenarioDiffEvidence.ts index d450de383a..f10f1b5d10 100644 --- a/src/domain/browserScenarioDiffEvidence.ts +++ b/src/domain/browserScenarioDiffEvidence.ts @@ -7,6 +7,7 @@ import type { import type { RetainedBrowserScenarioCapture } from "./browserScenarioCapture.js"; import { fingerprintScenarioNetworkTransactions, + ScenarioNetworkFingerprintError, scenarioNetworkTransactions, scenarioNetworkCoverageReason, } from "./browserScenarioNetworkComparison.js"; @@ -163,8 +164,7 @@ export const prepareScenarioEvents = ( for (const [index, node] of nodes.entries()) { const step = networkByStep.get(node.step_index) ?? []; const fingerprint = fingerprints[index]; - if (fingerprint === undefined) - throw new Error("Network comparison node has no fingerprint"); + if (fingerprint === undefined) throw new ScenarioNetworkFingerprintError(); step.push(fingerprint); networkByStep.set(node.step_index, step); } diff --git a/src/domain/browserScenarioNetworkComparison.ts b/src/domain/browserScenarioNetworkComparison.ts index 187cf86d8e..e94cbe4605 100644 --- a/src/domain/browserScenarioNetworkComparison.ts +++ b/src/domain/browserScenarioNetworkComparison.ts @@ -5,6 +5,17 @@ import { digestCanonicalValue } from "./canonicalDigest.js"; import { normalizeScenarioValue } from "./browserScenarioNormalization.js"; import { indexBrowserScenarioTransactions } from "./browserScenarioTransactions.js"; +/** A comparison node lost the fingerprint its producer should have stored. */ +export class ScenarioNetworkFingerprintError extends Error { + readonly _tag = "ScenarioNetworkFingerprintError"; + readonly code = "missing_fingerprint" as const; + + constructor() { + super("missing_fingerprint"); + this.name = "ScenarioNetworkFingerprintError"; + } +} + /** Numeric links are temporary local indices, never durable transaction identity. */ export const scenarioNetworkTransactionSchema = z.strictObject({ events: z.array(z.unknown()), @@ -241,8 +252,7 @@ export const fingerprintScenarioNetworkTransactions = ( } return nodes.map((_, index) => { const fingerprint = fingerprints.get(index); - if (fingerprint === undefined) - throw new Error("Network comparison node has no fingerprint"); + if (fingerprint === undefined) throw new ScenarioNetworkFingerprintError(); return fingerprint; }); }; diff --git a/src/domain/canonicalDigest.test.ts b/src/domain/canonicalDigest.test.ts index 09b67c3a69..649e8b0fdf 100644 --- a/src/domain/canonicalDigest.test.ts +++ b/src/domain/canonicalDigest.test.ts @@ -4,7 +4,10 @@ import { fc, it } from "@fast-check/vitest"; import canonicalize from "canonicalize"; import { describe, expect } from "vitest"; -import { digestCanonicalValue } from "./canonicalDigest.js"; +import { + CanonicalDigestError, + digestCanonicalValue, +} from "./canonicalDigest.js"; const legacyDigest = (value: unknown): string => { const encoded = canonicalize(value); @@ -52,8 +55,27 @@ describe("incremental canonical digest", () => { const cyclic: Record = {}; cyclic.self = cyclic; const recursiveJson = { toJSON: (): unknown => recursiveJson }; - for (const value of [NaN, Infinity, -Infinity, 1n, cyclic, recursiveJson]) - expect(() => digestCanonicalValue(value)).toThrow(); + for (const [value, code] of [ + [Number.NaN, "nan"], + [Number.POSITIVE_INFINITY, "infinity"], + [Number.NEGATIVE_INFINITY, "infinity"], + ] as const) { + expect(() => digestCanonicalValue(value)).toThrow(CanonicalDigestError); + try { + digestCanonicalValue(value); + } catch (cause) { + expect(cause).toMatchObject({ _tag: "CanonicalDigestError", code }); + } + } + for (const value of [cyclic, recursiveJson]) { + expect(() => digestCanonicalValue(value)).toThrow(CanonicalDigestError); + try { + digestCanonicalValue(value); + } catch (cause) { + expect(cause).toMatchObject({ code: "circular" }); + } + } + expect(() => digestCanonicalValue(1n)).toThrow(); }); it("preserves canonical bytes across large keys, repeated leaves and buffer flushes", () => { diff --git a/src/domain/canonicalDigest.ts b/src/domain/canonicalDigest.ts index 5e7467cc95..f14ed309a1 100644 --- a/src/domain/canonicalDigest.ts +++ b/src/domain/canonicalDigest.ts @@ -38,22 +38,30 @@ export const digestCanonicalValue = ( type Emit = (part: string) => void; +/** Canonical-JSON refusal. The code is the stable identity. */ +export class CanonicalDigestError extends Error { + readonly _tag = "CanonicalDigestError"; + + constructor(readonly code: "nan" | "infinity" | "circular") { + super(code); + this.name = "CanonicalDigestError"; + } +} + const emitCanonical = ( value: unknown, emit: Emit, ancestors: Set, ): boolean => { if (typeof value === "number" && !Number.isFinite(value)) - throw new Error( - Number.isNaN(value) ? "NaN is not allowed" : "Infinity is not allowed", - ); + throw new CanonicalDigestError(Number.isNaN(value) ? "nan" : "infinity"); if (value === null || typeof value !== "object") { const encoded = JSON.stringify(value); if (encoded === undefined) return false; emit(encoded); return true; } - if (ancestors.has(value)) throw new Error("Circular reference detected"); + if (ancestors.has(value)) throw new CanonicalDigestError("circular"); ancestors.add(value); try { if ("toJSON" in value && typeof value.toJSON === "function") { diff --git a/src/domain/canonicalJsonDigestSteps.test.ts b/src/domain/canonicalJsonDigestSteps.test.ts index a15893fdc2..c51d1a296a 100644 --- a/src/domain/canonicalJsonDigestSteps.test.ts +++ b/src/domain/canonicalJsonDigestSteps.test.ts @@ -1,7 +1,10 @@ import { fc, it } from "@fast-check/vitest"; import { describe, expect } from "vitest"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; -import { digestCanonicalValue } from "./canonicalDigest.js"; +import { + CanonicalDigestError, + digestCanonicalValue, +} from "./canonicalDigest.js"; import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; const complete = (steps: Iterator): Value => { @@ -78,7 +81,22 @@ describe("cooperative canonical JSON digest", () => { sparse.length = 2; const cycle: { [key: string]: JsonValue } = {}; cycle.self = cycle; - for (const value of [sparse, Number.NaN, cycle]) - expect(() => complete(canonicalJsonDigestSteps(value))).toThrow(); + expect(() => complete(canonicalJsonDigestSteps(sparse))).toThrow(); + expect(() => complete(canonicalJsonDigestSteps(Number.NaN))).toThrow( + CanonicalDigestError, + ); + expect(() => complete(canonicalJsonDigestSteps(cycle))).toThrow( + CanonicalDigestError, + ); + try { + complete(canonicalJsonDigestSteps(Number.NaN)); + } catch (cause) { + expect(cause).toMatchObject({ code: "nan" }); + } + try { + complete(canonicalJsonDigestSteps(cycle)); + } catch (cause) { + expect(cause).toMatchObject({ code: "circular" }); + } }); }); diff --git a/src/domain/canonicalJsonDigestSteps.ts b/src/domain/canonicalJsonDigestSteps.ts index 35cbb84913..2b7183289d 100644 --- a/src/domain/canonicalJsonDigestSteps.ts +++ b/src/domain/canonicalJsonDigestSteps.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { CanonicalDigestError } from "./canonicalDigest.js"; import type { JsonValue } from "./jsonValue.js"; type DigestFrame = @@ -137,14 +138,12 @@ export function* canonicalJsonDigestSteps( stack.push({ kind: "string", value: item, index: 0 }); } else if (item === null || typeof item !== "object") { if (typeof item === "number" && !Number.isFinite(item)) - throw new Error( - Number.isNaN(item) - ? "NaN is not allowed" - : "Infinity is not allowed", + throw new CanonicalDigestError( + Number.isNaN(item) ? "nan" : "infinity", ); emit(JSON.stringify(item)); } else { - if (ancestors.has(item)) throw new Error("Circular reference detected"); + if (ancestors.has(item)) throw new CanonicalDigestError("circular"); ancestors.add(item); if (Array.isArray(item)) { emit("["); diff --git a/src/domain/functionReferenceCompatibility.ts b/src/domain/functionReferenceCompatibility.ts index dd73548549..decdf8a098 100644 --- a/src/domain/functionReferenceCompatibility.ts +++ b/src/domain/functionReferenceCompatibility.ts @@ -17,6 +17,18 @@ interface FlowEdge { remaining: number; } +/** Residual-network invariant. The code is the stable identity. */ +class ReferenceFlowError extends Error { + readonly _tag = "ReferenceFlowError"; + + constructor( + readonly code: "endpoint_unallocated" | "reverse_edge_unallocated", + ) { + super(code); + this.name = "ReferenceFlowError"; + } +} + /** A local residual network: no recursive walk or pairwise adjacency matrix. */ class ReferenceFlow { private readonly edges: FlowEdge[][] = []; @@ -30,7 +42,7 @@ class ReferenceFlow { const forward = this.edges[from]; const reverse = this.edges[to]; if (forward === undefined || reverse === undefined) - throw new Error("Reference flow endpoint was not allocated."); + throw new ReferenceFlowError("endpoint_unallocated"); forward.push({ to, reverse: reverse.length, remaining: capacity }); reverse.push({ to: from, reverse: forward.length - 1, remaining: 0 }); } @@ -87,7 +99,7 @@ class ReferenceFlow { edge.remaining -= amount; const reverse = this.edges[edge.to]?.[edge.reverse]; if (reverse === undefined) - throw new Error("Reference flow reverse edge was not allocated."); + throw new ReferenceFlowError("reverse_edge_unallocated"); reverse.remaining += amount; } return amount; diff --git a/src/domain/managed/managedApplicationGraph.ts b/src/domain/managed/managedApplicationGraph.ts index f0def87f6e..51811b7be5 100644 --- a/src/domain/managed/managedApplicationGraph.ts +++ b/src/domain/managed/managedApplicationGraph.ts @@ -300,6 +300,17 @@ export const readManagedGraphProjection = ( const OPERATION = "project_managed_application_graph"; +/** Validated graph input still had no artifact source. */ +class ManagedGraphSourceError extends Error { + readonly _tag = "ManagedGraphSourceError"; + readonly code = "missing_source" as const; + + constructor() { + super("missing_source"); + this.name = "ManagedGraphSourceError"; + } +} + const chooseArtifact = ( parsed: ParsedManagedGraphInput, ): { readonly sha256: string; readonly path: string } => { @@ -307,8 +318,7 @@ const chooseArtifact = ( parsed.artifact?.result ?? parsed.members?.result ?? parsed.boundaries?.result; - if (result === undefined) - throw new Error("Validated managed graph projection has no source"); + if (result === undefined) throw new ManagedGraphSourceError(); return { sha256: result.artifact.sha256, path: result.artifact.path }; }; diff --git a/src/domain/propertyListOpenStep.test.ts b/src/domain/propertyListOpenStep.test.ts index ddc9ca7ad4..47d6538e59 100644 --- a/src/domain/propertyListOpenStep.test.ts +++ b/src/domain/propertyListOpenStep.test.ts @@ -4,6 +4,7 @@ import { parseOpenStep } from "plist"; import { plistNestingLimitMessage } from "./apple/plistValue.js"; import { decodeOpenStepPropertyList, + OpenStepPropertyListError, startsOpenStepPropertyList, } from "./propertyListOpenStep.js"; @@ -30,28 +31,53 @@ const syntaxFailure = (read: () => unknown): Error => { throw new Error("Expected syntax failure"); }; +const containerRefusal = ( + code: OpenStepPropertyListError["code"], + position: number, +) => ({ code, position }); + +const containerRefusals = [ + ["(}", containerRefusal("unexpected_character", 1)], + ["{)", containerRefusal("unexpected_character", 1)], + ["(@", containerRefusal("unexpected_character", 1)], + ["{key @", containerRefusal("expected_equals", 5)], + ["(x @", containerRefusal("expected_separator", 3)], + [ + "(x ", + containerRefusal("unterminated_array", 3), + containerRefusal("expected_separator", 3), + ], + ["{key ", containerRefusal("expected_equals", 5)], + ["{key = x,", containerRefusal("unexpected_character", 8)], + ["{ /* unterminated", containerRefusal("unterminated_block_comment", 2)], + ["(x, /* unterminated", containerRefusal("unterminated_block_comment", 4)], + ["{ { toString = text; } @", containerRefusal("expected_equals", 23)], +] as const; + +it.each(containerRefusals)( + "reports %s before and after a deep suffix", + (text, diagnosis, deep = diagnosis) => { + expect(syntaxFailure(() => decodeOpenStepPropertyList(text))).toMatchObject( + { _tag: "OpenStepPropertyListError", ...diagnosis }, + ); + expect( + syntaxFailure(() => decodeOpenStepPropertyList(text + "(".repeat(130))), + ).toMatchObject({ _tag: "OpenStepPropertyListError", ...deep }); + }, +); + it.each([ - "(}", - "{)", - "(@", - "{key @", - "(x @", - "(x ", - "{key ", - "{key = x,", '{ key = "unterminated', '{ key = "escape\\', "{ key = <01", - "{ /* unterminated", - "(x, /* unterminated", - "{ { toString = text; } @", "{ { toString = text; } = value; }", ])( - "preserves pinned syntax diagnosis before later deep input after %s", + "keeps the pinned scalar decoder's diagnosis for %s before a deep suffix", (prefix) => { for (const text of [prefix, prefix + "(".repeat(130)]) { const pinned = syntaxFailure(() => parseOpenStep(text)); const owned = syntaxFailure(() => decodeOpenStepPropertyList(text)); + expect(owned).not.toBeInstanceOf(OpenStepPropertyListError); expect(owned.constructor).toBe(pinned.constructor); expect(owned.message).toBe(pinned.message); } @@ -89,14 +115,16 @@ it("bounds compound keys and later siblings at their owning parse", () => { ); }); -it.each(["(x,", "{ key ="])( - "retains incomplete-value syntax for %s", - (text) => { - expect(syntaxFailure(() => decodeOpenStepPropertyList(text)).message).toBe( - syntaxFailure(() => parseOpenStep(text)).message, - ); - }, -); +it.each([ + ["(x,", "unexpected_end", 3], + ["{ key =", "unexpected_end", 7], +] as const)("reports incomplete %s as %s at %s", (text, code, position) => { + expect(syntaxFailure(() => decodeOpenStepPropertyList(text))).toMatchObject({ + _tag: "OpenStepPropertyListError", + code, + position, + }); +}); it.each(["\n", "\r\n", "\r", "\u2028", "\u2029"])( "ends OpenStep line comments at %j without dropping following values", @@ -134,5 +162,10 @@ it("does not treat nonterminating whitespace or quoted comment text as line ends ); expect(() => decodeOpenStepPropertyList("// comment without a line end"), - ).toThrow("Unexpected end of input"); + ).toThrow(OpenStepPropertyListError); + expect( + syntaxFailure(() => + decodeOpenStepPropertyList("// comment without a line end"), + ), + ).toMatchObject({ code: "unexpected_end" }); }); diff --git a/src/domain/propertyListOpenStep.ts b/src/domain/propertyListOpenStep.ts index 1ef00773ca..cd54c6c51c 100644 --- a/src/domain/propertyListOpenStep.ts +++ b/src/domain/propertyListOpenStep.ts @@ -3,6 +3,31 @@ import { parseOpenStep } from "plist"; import { MAX_PLIST_DEPTH, PlistNestingLimitError } from "./apple/plistValue.js"; import { markPlistDictionary } from "./apple/plistUid.js"; +const OPEN_STEP_SYNTAX_CODES = [ + "unterminated_block_comment", + "unexpected_end", + "unterminated_dictionary", + "expected_equals", + "unterminated_array", + "expected_separator", + "unexpected_character", +] as const; + +export type OpenStepPropertyListCode = (typeof OPEN_STEP_SYNTAX_CODES)[number]; + +/** OpenStep syntax refusal. `code` is the contract; `position` is the source offset. */ +export class OpenStepPropertyListError extends Error { + readonly _tag = "OpenStepPropertyListError"; + + constructor( + readonly code: OpenStepPropertyListCode, + readonly position?: number, + ) { + super(code); + this.name = "OpenStepPropertyListError"; + } +} + /** Locate the first value for the XML owner's existing OpenStep autodetection. */ export const startsOpenStepPropertyList = (text: string): boolean => { const cursor = skipTrivia(text, 0, false); @@ -25,7 +50,11 @@ const skipTrivia = (text: string, start: number, strict = true): number => { } else if (text.startsWith("/*", cursor)) { const end = text.indexOf("*/", cursor + 2); if (end < 0) { - if (strict) throw new Error("Unterminated block comment"); + if (strict) + throw new OpenStepPropertyListError( + "unterminated_block_comment", + cursor, + ); return text.length; } cursor = end + 2; @@ -51,7 +80,8 @@ export const decodeOpenStepPropertyList = ( }; const value = (depth: number, dictionaryKey = false): unknown => { trivia(); - if (cursor >= text.length) throw new Error("Unexpected end of input"); + if (cursor >= text.length) + throw new OpenStepPropertyListError("unexpected_end", cursor); const character = text[cursor]!; if (character === "{") { assertDepth(depth); @@ -60,7 +90,11 @@ export const decodeOpenStepPropertyList = ( markPlistDictionary(dictionary); while (true) { trivia(); - if (cursor >= text.length) throw new Error("Unterminated dictionary"); + if (cursor >= text.length) + throw new OpenStepPropertyListError( + "unterminated_dictionary", + cursor, + ); if (text[cursor] === "}") { cursor += 1; return dictionary; @@ -68,9 +102,7 @@ export const decodeOpenStepPropertyList = ( const key = value(depth + 1, true); trivia(); if (text[cursor] !== "=") - throw new Error( - `Expected '=' after key "${String(key)}" at position ${cursor}`, - ); + throw new OpenStepPropertyListError("expected_equals", cursor); cursor += 1; const entry = value(depth + 1); Object.defineProperty(dictionary, String(key), { @@ -95,13 +127,14 @@ export const decodeOpenStepPropertyList = ( while (true) { array.push(value(depth + 1)); trivia(); - if (cursor >= text.length) throw new Error("Unterminated array"); + if (cursor >= text.length) + throw new OpenStepPropertyListError("unterminated_array", cursor); if (text[cursor] === ")") { cursor += 1; return array; } if (text[cursor] !== ",") - throw new Error(`Expected ',' or ')' in array at position ${cursor}`); + throw new OpenStepPropertyListError("expected_separator", cursor); cursor += 1; trivia(); if (text[cursor] === ")") { @@ -125,9 +158,7 @@ export const decodeOpenStepPropertyList = ( while (cursor < text.length && /[a-zA-Z0-9._/$:-]/u.test(text[cursor]!)) cursor += 1; if (cursor === start) - throw new Error( - `Unexpected character '${character}' at position ${cursor}`, - ); + throw new OpenStepPropertyListError("unexpected_character", cursor); } // Scalar dictionary keys do not add source-value nesting; compound keys do. if (!dictionaryKey) assertDepth(depth); diff --git a/src/domain/webCaptureFrameComparison.ts b/src/domain/webCaptureFrameComparison.ts index e85f03e2c1..e345f35be1 100644 --- a/src/domain/webCaptureFrameComparison.ts +++ b/src/domain/webCaptureFrameComparison.ts @@ -1,6 +1,16 @@ import type { WebPageInspection } from "./browserObservationSchemas.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; +/** Frame-forest invariant. The code is the stable identity. */ +class WebCaptureFrameError extends Error { + readonly _tag = "WebCaptureFrameError"; + + constructor(readonly code: "missing_ready_frame" | "missing_parent_frame") { + super(code); + this.name = "WebCaptureFrameError"; + } +} + /** Compare complete frame forests without treating capture-local IDs as identity. */ export const fingerprintWebCaptureFrames = ( frames: WebPageInspection["frames"], @@ -20,7 +30,8 @@ export const fingerprintWebCaptureFrames = ( let examined = 0; for (let index = 0; index < ready.length; index += 1) { const frame = ready[index]; - if (frame === undefined) throw new Error("Missing ready frame"); + if (frame === undefined) + throw new WebCaptureFrameError("missing_ready_frame"); const fingerprint = digestCanonicalValue({ url: frame.url, origin: frame.origin, @@ -40,7 +51,7 @@ export const fingerprintWebCaptureFrames = ( if (count === 0) { const parent = byId.get(parentId); if (parent === undefined) - throw new Error("Missing admitted parent frame"); + throw new WebCaptureFrameError("missing_parent_frame"); ready.push(parent); } } diff --git a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts index bfd729ee76..f81c6637c0 100644 --- a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts +++ b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts @@ -2,10 +2,11 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { parse } from "plist"; +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; import { plistNestingLimitMessage } from "../../../src/domain/apple/plistValue.js"; +import type { OpenStepPropertyListCode } from "../../../src/domain/propertyListOpenStep.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; @@ -29,31 +30,34 @@ it.each([129, 3000])( }, ); -it.each(["(}", "{)", "(@", "{key @", "(x @", "(x ", "{key ", "{key = x,"])( - "preserves the pinned OpenStep syntax diagnosis before a deep suffix after %s", - async (prefix) => { - const text = prefix + "(".repeat(130); - let pinned: unknown; - try { - parse(text); - } catch (cause) { - pinned = cause; - } - if (!(pinned instanceof Error) || pinned instanceof RangeError) - throw new Error( - "Malformed control must fail in the pinned syntax decoder", - ); +it.each([ + ["(}", "unexpected_character", 1], + ["{)", "unexpected_character", 1], + ["(@", "unexpected_character", 1], + ["{key @", "expected_equals", 5], + ["(x @", "expected_separator", 3], + ["(x ", "expected_separator", 3], + ["{key ", "expected_equals", 5], + ["{key = x,", "unexpected_character", 8], +] as const)( + "reports OpenStep %s before a deep suffix as %s at %s", + async (prefix, code: OpenStepPropertyListCode, position) => { const root = await createTestTempDirectory("rea-keyed-openstep-malformed-"); - await writeFile(join(root, "Malformed.plist"), text); + await writeFile(join(root, "Malformed.plist"), prefix + "(".repeat(130)); const rejected = await inspectBundleKeyedArchive({ bundlePath: root, targetSha256: "a".repeat(64), parameters: { path: "Malformed.plist" }, platform: "linux", }).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(ArtifactReaderFailure); expect(rejected).toMatchObject({ reason: "format", - message: `Cannot decode selected Foundation keyed archive: ${pinned.message}`, + cause: { + _tag: "OpenStepPropertyListError", + code, + position, + }, }); }, );