From b663c79552fe10f3bf7c6f4291eebb1580f8cd31 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:23:10 +0800 Subject: [PATCH] fix(domain): replace bare parser errors with typed codes (#8057) * fix(domain): replace bare parser errors with typed codes OpenStep syntax, canonical JSON digests, managed-graph source checks, frame fingerprints, and reference-flow invariants threw Error and published the sentence. Each refusal now carries a code. Tests assert that code, including OpenStep position, instead of the previous prose. * test(domain): pin OpenStep refusal codes and positions The syntax cases accepted any code and any offset. Each container refusal now expects its code and source position, including the deep suffix that changes (x from unterminated_array to expected_separator. Scalar failures stay identical to the pinned decoder. * test(filesystem): assert OpenStep keyed-archive codes The deep-suffix keyed archive cases compared ArtifactReaderFailure message text with the pinned decoder sentence. They now require reason format and the OpenStep code and source position on the cause. --------- Co-authored-by: Cursor Agent --- src/domain/browserScenarioDiffEvidence.ts | 4 +- .../browserScenarioNetworkComparison.ts | 14 +++- src/domain/canonicalDigest.test.ts | 28 ++++++- src/domain/canonicalDigest.ts | 16 +++- src/domain/canonicalJsonDigestSteps.test.ts | 24 +++++- src/domain/canonicalJsonDigestSteps.ts | 9 +-- src/domain/functionReferenceCompatibility.ts | 16 +++- src/domain/managed/managedApplicationGraph.ts | 14 +++- src/domain/propertyListOpenStep.test.ts | 75 +++++++++++++------ src/domain/propertyListOpenStep.ts | 53 ++++++++++--- src/domain/webCaptureFrameComparison.ts | 15 +++- .../filesystem/keyedArchiveXmlNesting.test.ts | 38 +++++----- 12 files changed, 232 insertions(+), 74 deletions(-) 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, + }, }); }, );