From 63b0426e409339a5efbc8ea7bbdea0b4502ec935 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 19:35:22 +0800 Subject: [PATCH] fix(errors): rethrow internal invariant violations from plist consumers and the native output projection (#11029) * fix(plist): rethrow internal invariant violations from plist consumers KeyedArchiveReader reported any non-limit decode failure as a format failure, InterfaceBuilderAnalysis listed it as an invalid archive, ReleaseExposureReader fell back to scanning raw bytes, and the native plist parser reported it as provider output. An InternalInvariantError is a REA defect, not evidence about the input, so each consumer now rethrows it. The native parser's binary path now shares its one cause projection instead of a duplicate. * fix(native): rethrow internal invariant violations from the native output projection nativeOutputFailure wrapped any producer failure as AnalysisOutputError, so a REA defect thrown while reading a plist or dispatching a command was reported as native output parsing. It now rethrows InternalInvariantError, which reaches the provider's adapter-failure path with the invariant as its cause. * fix(artifacts): rethrow internal invariant violations from the shared artifact failure mapper translateArtifactFailure turned every unrecognised cause into an ArtifactOperationError with reason io, so the keyed-archive and Interface Builder rethrows still reached users as artifact read failures. The shared mapper now rethrows InternalInvariantError, and the deferred client reports it as a ProviderAdapterError. Provider-level tests drive both operations through that client. * refactor(dotnet): report managed reader defects through invariantViolation (#11030) * refactor(dotnet): report managed reader defects through invariantViolation The managed readers threw plain TypeError and RangeError for states their callers rule out: an allowance or method page outside the input schema, a missing snapshot after identity admission, and a missing CLI header after the caller already returned for it. These are REA defects, not malformed targets, so they throw invariantViolation. The two CLI re-checks are gone: the callers pass the CLI header they already narrowed. The dotnet parser-plain-throw baseline drops from 6 to 0. * refactor(providers): refuse unusable tool paths and record marker mismatches without throwing to their own catch (#11031) * refactor(firmware): refuse unusable tool paths without throwing to its own catch resolveFirmwareCommand threw a plain TypeError for a configured engine or limiter path that is not a regular file, only to catch it in the next line and wrap it as an unavailable capability. A helper now returns why a path cannot run, and each caller builds the one refusal directly. Refusal reasons are unchanged; the directory tests now pin them exactly. The firmware parser-plain-throw baseline drops from 2 to 0. * refactor(pwndbg): refuse unusable debugger paths without throwing to its own catch inspectPwndbgCore threw plain Errors for a relative or wrong-kind configured path only to catch them and wrap them as an unavailable capability. A helper now returns why a path cannot be used, with any host error as its cause, and the loop builds the one refusal. Cancellation between host checks still propagates. Refusal reasons are unchanged. The PwndbgCoreContext parser-plain-throw baseline entry is removed. * refactor(pwntools): record a mismatched failure marker without throwing to its own catch readPwntoolsFailureEvidence threw a plain Error when the private bridge marker disagreed with the exit status, only to catch it and record its message as the marker failure. The mismatch is now recorded directly. The recorded evidence is unchanged; a unit test pins both the matching and the mismatched marker. The PwntoolsResourceLimits parser-plain-throw baseline entry is removed. * fix(android): report an unobserved Java probe as a REA defect, not a Java refusal (#11032) * fix(android): report an unobserved Java probe as a REA defect, not a Java refusal When runOwnedCommand resolved without observing the completed run, the probe threw a plain Error that its catch then classified as a Java runtime refusal, telling the caller to review their launcher. The state is a broken internal assumption, so the probe throws invariantViolation and projects it as a jadx adapter failure. The JadxJavaProbe parser-plain-throw baseline entry is removed. * refactor(native): report native provider defects through invariantViolation (#11033) * refactor(native): report native provider defects through invariantViolation Native providers threw plain Error and TypeError for states their own earlier steps rule out: a client with no target, a measured slice with no selection, a read longer than requested, a decoder command without its runtime, an LLDB root released before its children, and an IPS integer fact from another reader. These are REA defects, so they throw invariantViolation. NativeUiInterruptionControl no longer needs a throw: recording the deadline interruption returns the interruption its error is built from. Six native parser-plain-throw baseline entries drop. * refactor(native): report malformed lipo, swift-demangle and UI helper output through ParseFailure (#11034) * refactor(native): report malformed lipo and swift-demangle output through ParseFailure The lipo and swift-demangle output parsers threw plain TypeError for output that breaks their framing, and the lipo result classifier caught every TypeError as malformed output, so a host TypeError from a REA defect was reported as lipo output. Both parsers now throw malformedInput with their own format, and the lipo classifier accepts ParseFailureError instead of TypeError. Messages and the consumers' output and format failure categories are unchanged. Four native parser-plain-throw baseline entries drop. * refactor(native): report the UI helper's non-JSON output through ParseFailure The native UI helper runtime threw a plain Error when the helper's stdout was not JSON. It now throws malformedInput with its own format and the JSON failure as cause. The observation boundary still reports it as a native-macos adapter failure with the same reason. The NativeUiHelperRuntime parser-plain-throw baseline entry is removed. * refactor(verify): keep process-lifecycle owners out of the parser plain-throw guard (#11035) * refactor(verify): keep process-lifecycle owners out of the parser plain-throw guard JadxWorkspaceRoot and PwntoolsDecoder sit in parser folders but own a private helper process; their remaining plain throws report the helper's startup, handshake and cleanup outcomes, not parse faults. The guard now names them as process-lifecycle owners and leaves them out, so their five baseline entries drop without inventing parse errors for process failures. * refactor(parsers): report the remaining artifact, il2cpp, model and domain defects through invariantViolation (#11036) * refactor(parsers): report the remaining artifact, il2cpp, model and domain defects through invariantViolation Nine plain Error, TypeError and RangeError throws in parser folders reported broken internal assumptions or caller contracts (an uninitialized ZIP or DMG reader, a missing scan result, a non-byte stream chunk, an invalid traversal or marshal limit, a header count or linking fact the reader itself should have produced, a misordered Safetensors budget, an append after fingerprinting). None is an input fault, and no catch site depends on their class. They now throw invariantViolation with the same messages, and their parser-plain-throw baseline entries drop. * refactor(evidence): refuse an earlier Evidence format through ParseFailure (#11037) * refactor(evidence): refuse an earlier Evidence format through ParseFailure UnsupportedEvidenceFormatError restated the ParseFailure algebra as its own TypeError subclass. The format owner now throws unsupportedInput for the evidence-bundle format at the evidence_format field and exports the predicate its consumers use. The bundle reader's unsupported-format kind and message are unchanged. The snapshot reader surfaced only TypeError messages, so it now also surfaces the format refusal and keeps its recreate guidance; a boundary test pins that. * refactor(native): report Mach-O reader and inspection defects through invariantViolation (#11042) The Mach-O inspection plan reads the lipo, nm and dyld_info captures it just ran, the dispatch metadata reader is called only for Mach-O executables, and the command-name reader takes limits its callers derive. A missing capture, a non-Mach-O target or a malformed limit is a REA defect, not an input fault, so these six sites throw invariantViolation with their existing messages instead of TypeError. No catch site depends on their class. Their parser-plain-throw baseline entries drop. * fix(artifacts): join the Interface Builder cleanup failure to a catalog defect When catalog work failed while the concurrent Interface Builder reader failed to close, analyzeAppleAssetCatalogs built the primary through translateArtifactFailure before joining cleanup. That mapper now rethrows an InternalInvariantError, so a defect skipped the join and the reader's retained resources were lost. The defect now becomes the artifact graph provider's adapter failure at that site and joins the cleanup failure as before. --- .../verify/architecture-guard-baselines.json | 36 ----- scripts/verify/architecture-guards.mjs | 9 +- src/android/JadxJavaProbe.invariant.test.ts | 31 +++++ src/android/JadxJavaProbe.ts | 15 +- .../binary/AnalysisSnapshotFiles.ts | 4 +- src/artifacts/ArtifactOperationFailure.ts | 8 +- src/artifacts/DirectoryArtifactReader.ts | 3 +- .../MachOSliceArtifactReader.test.ts | 2 +- src/artifacts/NativeDmgArtifactReader.ts | 5 +- .../ReleaseExposureReader.invariant.test.ts | 67 +++++++++ src/artifacts/ReleaseExposureReader.ts | 10 +- src/artifacts/StreamBytes.ts | 4 +- src/artifacts/ZipArtifactReader.ts | 4 +- .../apple/AppleAssetCatalogAnalysis.test.ts | 40 ++++++ .../apple/AppleAssetCatalogAnalysis.ts | 14 +- .../apple/InterfaceBuilderAnalysis.ts | 3 + src/artifacts/apple/KeyedArchiveReader.ts | 8 +- .../apple/PlistDecodeInvariant.test.ts | 128 ++++++++++++++++++ src/domain/evidenceBundle.ts | 9 +- src/domain/evidenceFormat.test.ts | 10 ++ src/domain/evidenceFormat.ts | 39 +++--- src/domain/native/functionByteCoverage.ts | 3 +- src/domain/python/marshalUtf8.ts | 3 +- src/dotnet/ManagedArtifactInspector.ts | 7 +- src/dotnet/ManagedDecodeBudget.test.ts | 12 ++ src/dotnet/ManagedDecodeBudget.ts | 3 +- src/dotnet/ManagedMemberInspector.test.ts | 20 +-- src/dotnet/ManagedMemberInspector.ts | 9 +- src/dotnet/ManagedSnapshot.test.ts | 20 +++ src/dotnet/ManagedSnapshot.ts | 5 +- src/dotnet/ManagedStaticProvider.ts | 3 +- src/firmware/FirmwareCommand.test.ts | 8 +- src/firmware/FirmwareCommand.ts | 66 ++++++--- src/il2cpp/Il2cppMetadataFile.ts | 5 +- src/model/ModelHeaderReader.ts | 3 +- src/native/AppleDispatchMetadata.ts | 5 +- src/native/LldbCallTracer.ts | 5 +- src/native/LldbRetainedObservations.ts | 5 +- src/native/MachoCommandNames.ts | 5 +- .../NativeMacOSProvider.invariant.test.ts | 46 +++++++ src/native/NativeMacOSProvider.ts | 20 ++- src/native/NativeMachoInspection.ts | 10 +- src/native/NativeUiHelperRuntime.ts | 8 +- src/native/NativeUiInterruptionControl.ts | 31 +++-- src/native/NativeUiObservation.test.ts | 5 +- src/native/apple/AppleIpsFieldReader.test.ts | 21 +++ src/native/apple/AppleIpsFieldReader.ts | 5 +- src/native/parsers/demangle.test.ts | 11 +- src/native/parsers/demangle.ts | 18 ++- src/native/parsers/lipo.test.ts | 9 +- src/native/parsers/lipo.ts | 16 ++- src/native/parsers/plist.invariant.test.ts | 46 +++++++ src/native/parsers/plist.ts | 21 ++- src/native/pwndbg/PwndbgCoreContext.ts | 70 +++++++--- src/native/pwntools/PwntoolsDecoder.ts | 3 +- .../pwntools/PwntoolsResourceLimits.test.ts | 47 +++++++ src/native/pwntools/PwntoolsResourceLimits.ts | 15 +- .../filesystem/analysisSnapshotInput.test.ts | 23 ++++ 58 files changed, 875 insertions(+), 186 deletions(-) create mode 100644 src/android/JadxJavaProbe.invariant.test.ts create mode 100644 src/artifacts/ReleaseExposureReader.invariant.test.ts create mode 100644 src/artifacts/apple/PlistDecodeInvariant.test.ts create mode 100644 src/dotnet/ManagedSnapshot.test.ts create mode 100644 src/native/NativeMacOSProvider.invariant.test.ts create mode 100644 src/native/apple/AppleIpsFieldReader.test.ts create mode 100644 src/native/parsers/plist.invariant.test.ts create mode 100644 src/native/pwntools/PwntoolsResourceLimits.test.ts diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 2a3bdd6c2a..6f3aa88ba1 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -3,9 +3,6 @@ "src/gdb/GdbSessionManager.ts": 1, "src/process/ownedDebuggerSession.ts": 1 }, - "parser-format-failure-class": { - "src/domain/evidenceFormat.ts": 1 - }, "parser-json-parse": { "src/android/JadxProtocol.ts": 2, "src/android/JadxWorkspaceRoot.ts": 1, @@ -23,39 +20,6 @@ "src/native/parsers/plist.ts": 1, "src/rust/RustAuditableReader.ts": 1 }, - "parser-plain-throw": { - "src/android/JadxJavaProbe.ts": 1, - "src/android/JadxWorkspaceRoot.ts": 4, - "src/artifacts/DirectoryArtifactReader.ts": 1, - "src/artifacts/NativeDmgArtifactReader.ts": 1, - "src/artifacts/ReleaseExposureReader.ts": 1, - "src/artifacts/StreamBytes.ts": 1, - "src/artifacts/ZipArtifactReader.ts": 1, - "src/domain/native/functionByteCoverage.ts": 1, - "src/domain/python/marshalUtf8.ts": 1, - "src/dotnet/ManagedArtifactInspector.ts": 1, - "src/dotnet/ManagedDecodeBudget.ts": 1, - "src/dotnet/ManagedMemberInspector.ts": 2, - "src/dotnet/ManagedSnapshot.ts": 1, - "src/dotnet/ManagedStaticProvider.ts": 1, - "src/firmware/FirmwareCommand.ts": 2, - "src/il2cpp/Il2cppMetadataFile.ts": 2, - "src/model/ModelHeaderReader.ts": 1, - "src/native/AppleDispatchMetadata.ts": 1, - "src/native/LldbCallTracer.ts": 1, - "src/native/LldbRetainedObservations.ts": 1, - "src/native/MachoCommandNames.ts": 1, - "src/native/NativeMacOSProvider.ts": 2, - "src/native/NativeMachoInspection.ts": 4, - "src/native/NativeUiHelperRuntime.ts": 1, - "src/native/NativeUiInterruptionControl.ts": 1, - "src/native/apple/AppleIpsFieldReader.ts": 1, - "src/native/parsers/demangle.ts": 3, - "src/native/parsers/lipo.ts": 1, - "src/native/pwndbg/PwndbgCoreContext.ts": 2, - "src/native/pwntools/PwntoolsDecoder.ts": 2, - "src/native/pwntools/PwntoolsResourceLimits.ts": 1 - }, "raw-buffer-read": { "src/application/BinaryTargetResolver.ts": 1, "src/application/MipsElfMetadata.ts": 10, diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 152b2c1506..443ce78c9b 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -425,10 +425,17 @@ checkCounted( // An input fault is a ParseFailureError; a broken internal assumption is a // defect, not an input fault, and throws invariantViolation from // src/domain/internalInvariant so callers never read it as bad bytes. +// Process-lifecycle owners in parser folders read their private helper's +// replies through schemas; their plain throws report the helper's startup, +// handshake and cleanup outcomes, which are process failures, not parse faults. +const PROCESS_LIFECYCLE_OWNERS = [ + "src/android/JadxWorkspaceRoot.ts", + "src/native/pwntools/PwntoolsDecoder.ts", +]; checkCounted( "plain Error, TypeError or RangeError thrown by a parser (throw a ParseFailureError from src/domain/parseFailure for an input fault, or invariantViolation from src/domain/internalInvariant for a broken internal assumption)", "parser-plain-throw", - parserFiles, + parserFiles.filter((file) => !PROCESS_LIFECYCLE_OWNERS.includes(file.path)), /throw new (?:Error|TypeError|RangeError)\(/gu, [], ); diff --git a/src/android/JadxJavaProbe.invariant.test.ts b/src/android/JadxJavaProbe.invariant.test.ts new file mode 100644 index 0000000000..308c1c0665 --- /dev/null +++ b/src/android/JadxJavaProbe.invariant.test.ts @@ -0,0 +1,31 @@ +import { expect, it, vi } from "vitest"; + +import { InternalInvariantError } from "../domain/internalInvariant.js"; +import { inspectJadxJava } from "./JadxJavaProbe.js"; +import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js"; + +/** + * runOwnedCommand observes every completed run before it resolves, so a run + * that resolves unobserved is replaced here to reach the probe's invariant. + */ +vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({ + ...(await importOriginal()), + prepareOwnedCommand: async () => undefined, + runOwnedCommand: async () => undefined, +})); + +it("reports an unobserved completed Java probe as a REA defect, not a Java refusal", async () => { + const resources = createInspectionResources(); + try { + const rejected: unknown = await inspectJadxJava( + "/selected/java", + {}, + { resourceOwner: resources.owner }, + ).catch((cause: unknown) => cause); + + expect(rejected).toMatchObject({ _tag: "ProviderAdapterError" }); + expect((rejected as Error).cause).toBeInstanceOf(InternalInvariantError); + } finally { + await resources.close(); + } +}); diff --git a/src/android/JadxJavaProbe.ts b/src/android/JadxJavaProbe.ts index 33be77ef85..8431cd6b46 100644 --- a/src/android/JadxJavaProbe.ts +++ b/src/android/JadxJavaProbe.ts @@ -13,6 +13,10 @@ import { carriedReason, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { + InternalInvariantError, + invariantViolation, +} from "../domain/internalInvariant.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -145,8 +149,11 @@ export const inspectJadxJava = async ( : { launcher: options.launcher }), }, ); + // runOwnedCommand observes every completed run before it resolves. if (observed === undefined) - throw new Error("Java probe completed without an observation"); + throw invariantViolation( + "Java probe completed without an observation", + ); return observed; } catch (cause: unknown) { const command = @@ -242,6 +249,12 @@ export const inspectJadxJava = async ( }); } else if (cause instanceof AnalysisError) { primary = cause; + } else if (cause instanceof InternalInvariantError) { + // A REA defect says nothing about the selected Java runtime. + primary = new ProviderAdapterError("jadx", operation, { + cause, + ...(output === undefined ? {} : { capturedOutput: output }), + }); } else { const failure = commandProbeRefusal( java, diff --git a/src/application/binary/AnalysisSnapshotFiles.ts b/src/application/binary/AnalysisSnapshotFiles.ts index 72d674630e..5b98f65439 100644 --- a/src/application/binary/AnalysisSnapshotFiles.ts +++ b/src/application/binary/AnalysisSnapshotFiles.ts @@ -14,6 +14,7 @@ import { } from "../../domain/evidenceErrors.js"; import { err, ok, type Result } from "../../domain/result.js"; import { describeValidationFailure } from "../../domain/validationFailure.js"; +import { isUnsupportedEvidenceFormat } from "../../domain/evidenceFormat.js"; import type { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; import { AnalysisCancelledError, @@ -164,7 +165,8 @@ export const writeAnalysisSnapshot = async ( const snapshotValidationMessage = (cause: unknown): string => { if (cause instanceof z.ZodError) return `Analysis snapshot validation failed ${describeValidationFailure(cause)}`; - return cause instanceof TypeError + // The Evidence format owner's refusal carries the recreate guidance. + return cause instanceof TypeError || isUnsupportedEvidenceFormat(cause) ? cause.message : "Analysis snapshot validation failed"; }; diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index 4b4f008764..acde0efbb7 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -1,17 +1,23 @@ import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; +import { InternalInvariantError } from "../domain/internalInvariant.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { artifactFileCancellationCause, bindArtifactFileCleanup, } from "./ArtifactFileOperation.js"; -/** Translate one artifact boundary failure while retaining cleanup evidence. */ +/** + * Translate one artifact boundary failure while retaining cleanup evidence. A + * broken internal assumption is a REA defect, not an artifact read failure, so + * it is rethrown for the provider adapter to report. + */ export const translateArtifactFailure = ( operation: ArtifactOperationError["operation"], cause: unknown, ): AnalysisError => { + if (cause instanceof InternalInvariantError) throw cause; if (cause instanceof ArtifactReaderFailure) { const error = new ArtifactOperationError( operation, diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index f5dd622fea..b9cd95c079 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -35,6 +35,7 @@ import { import { AnalysisCleanupLease } from "./AnalysisCleanupLease.js"; import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js"; import { resolveArtifactSourcePath } from "./ArtifactSelectedSource.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; export type DirectoryArtifactEntry = ArtifactEntry & { readonly kind: "file" | "directory" | "symlink"; @@ -116,7 +117,7 @@ export class DirectoryArtifactReader implements ArtifactReader { maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0) ) - throw new RangeError( + throw invariantViolation( "Directory traversal maximumEntries must be a nonnegative safe integer", ); let examined = 0; diff --git a/src/artifacts/MachOSliceArtifactReader.test.ts b/src/artifacts/MachOSliceArtifactReader.test.ts index 9852872836..da04e9976d 100644 --- a/src/artifacts/MachOSliceArtifactReader.test.ts +++ b/src/artifacts/MachOSliceArtifactReader.test.ts @@ -350,7 +350,7 @@ describe("MachOSliceArtifactReader", () => { message: expect.stringMatching( /lipo.*readable architecture records.*no architectures/i, ), - cause: new TypeError("lipo output contained no architectures"), + cause: { failure: { kind: "malformed", format: "lipo-output" } }, }); }); diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index 9eb954a9f2..3ea0165ba8 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -31,6 +31,7 @@ import { type NativeDmgAttachOutput, type NativeDmgInfo, } from "./NativeDmgOwnership.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; const HDIUTIL_COMMAND_TIMEOUT_MS = 120_000; /** Conditional recovery guidance; a path match never grants attachment ownership. */ const DMG_EXISTING_ATTACHMENT_RECOVERY = @@ -594,7 +595,9 @@ export class NativeDmgArtifactReader implements ArtifactReader { async #resolveAttachOwnership(): Promise<"owned" | "none" | "unknown"> { const mountRoot = this.#mountRoot; if (mountRoot === undefined) - throw new Error("DMG mount root is unavailable for ownership discovery"); + throw invariantViolation( + "DMG mount root is unavailable for ownership discovery", + ); const ownership = resolveNativeDmgOwnership({ info: await this.#readInfo(), ...(this.#attachOutput === undefined diff --git a/src/artifacts/ReleaseExposureReader.invariant.test.ts b/src/artifacts/ReleaseExposureReader.invariant.test.ts new file mode 100644 index 0000000000..8df5ea9318 --- /dev/null +++ b/src/artifacts/ReleaseExposureReader.invariant.test.ts @@ -0,0 +1,67 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { buildBinary } from "plist"; +import { afterEach, expect, it, vi } from "vitest"; + +import { + InternalInvariantError, + invariantViolation, +} from "../domain/internalInvariant.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ReleaseExposureReader } from "./ReleaseExposureReader.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; + +/** + * No input reaches an invariant site today, so the binary plist text decoder + * is replaced with one that reports a broken internal assumption. + */ +const decoder = vi.hoisted(() => ({ broken: false })); + +vi.mock("./apple/BinaryPlistMaterializer.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + decodeBinaryPlistText: ( + ...args: Parameters + ) => { + if (decoder.broken) + throw invariantViolation("binary plist decoder copy lost its trailer"); + return actual.decodeBinaryPlistText(...args); + }, + }; +}); + +afterEach(() => { + decoder.broken = false; +}); + +const scan = async (path: string) => { + const reader = new ReleaseExposureReader(); + try { + const result = await reader.inspect({ path }, undefined, () => undefined); + if (!result.ok) throw result.error; + return result.value.text_members; + } finally { + await reader.close(); + } +}; + +it("rethrows an invariant violation instead of scanning the plist's raw bytes", async () => { + const root = await createTestTempDirectory("rea-release-exposure-invariant-"); + await writeFile( + join(root, "Info.plist"), + Buffer.from(buildBinary({ CFBundleIdentifier: "example" })), + ); + expect(await scan(root)).toEqual({ scanned: 1, skipped: 0 }); + + decoder.broken = true; + const rejected = await scan(root).catch((cause: unknown) => cause); + + // The workflow reports the defect as an adapter failure, not a scanned member. + expect(rejected).toBeInstanceOf(ProviderAdapterError); + expect((rejected as ProviderAdapterError).cause).toBeInstanceOf( + InternalInvariantError, + ); +}); diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 4d79b1dfad..8e412eeb51 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -29,6 +29,10 @@ import { type ArtifactFilePrimaryFacts, } from "./ArtifactFileOperation.js"; import { decodeBinaryPlistText } from "./apple/BinaryPlistMaterializer.js"; +import { + InternalInvariantError, + invariantViolation, +} from "../domain/internalInvariant.js"; import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js"; import { asarHeaderAdmissionRecovery, @@ -434,7 +438,7 @@ const consumeInventory = async ( } if (failed) throw failure; if (result === undefined) - throw new Error("Release exposure scan produced no result."); + throw invariantViolation("Release exposure scan produced no result."); return result; }; @@ -2234,7 +2238,9 @@ const decodeBinaryPlistMember = (bytes: Buffer): MemberText => { return decoded.complete ? decoded : { text: `${decoded.text}\n${bytes.toString("utf8")}`, complete: false }; - } catch { + } catch (cause: unknown) { + // A broken internal assumption is a REA defect, not an undecodable plist. + if (cause instanceof InternalInvariantError) throw cause; return { text: bytes.toString("utf8"), complete: false }; } }; diff --git a/src/artifacts/StreamBytes.ts b/src/artifacts/StreamBytes.ts index 9723f0650d..fcbd879645 100644 --- a/src/artifacts/StreamBytes.ts +++ b/src/artifacts/StreamBytes.ts @@ -1,7 +1,9 @@ +import { invariantViolation } from "../domain/internalInvariant.js"; + /** Parse a Node readable chunk without unchecked coercion. */ export const streamChunkToBuffer = (value: unknown): Buffer => { if (Buffer.isBuffer(value)) return value; if (value instanceof Uint8Array) return Buffer.from(value); if (typeof value === "string") return Buffer.from(value); - throw new TypeError("Readable stream emitted a non-byte chunk"); + throw invariantViolation("Readable stream emitted a non-byte chunk"); }; diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 21355d7ab8..4efd6a7e28 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -52,6 +52,7 @@ import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; import { ArtifactFileSource } from "./ArtifactFileSource.js"; import { readFileHandleRange } from "../filesystem/readFileHandleRange.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; const ZIP_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, @@ -141,7 +142,8 @@ class NodeFileReader extends Reader { ): Promise { await this.verify(); const handle = this.#handle; - if (handle === undefined) throw new Error("ZIP reader is not initialized"); + if (handle === undefined) + throw invariantViolation("ZIP reader is not initialized"); const readBytes = Math.min(length, this.size - index); // A central-directory offset can point past EOF. Type that malformed ZIP // read before a negative size reaches Buffer.alloc and becomes an I/O error. diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 6815cf6edf..9913b8b144 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -16,6 +16,11 @@ import { collectInterfaceBuilderResourceKeys, } from "./AppleAssetCatalogAnalysis.js"; import { waitFor } from "../../../tests/support/testWait.js"; +import { + InternalInvariantError, + invariantViolation, +} from "../../domain/internalInvariant.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; describe("Apple asset catalog traversal coverage", () => { it.for([false, true])( @@ -467,6 +472,41 @@ describe("Apple asset catalog and Interface Builder concurrency", () => { ); }); +it("joins the Interface Builder cleanup failure to a catalog defect", async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-defect-"); + await mkdir(join(bundle, "Contents", "Resources"), { recursive: true }); + await writeFile( + join(bundle, "Contents", "Resources", "Assets.car"), + "catalog", + ); + const retained = join(bundle, "Contents", "Resources", "Main.nib"); + const rejected = await analyzeAppleAssetCatalogs({ + environment: {}, + bundlePath: bundle, + targetSha256: "a".repeat(64), + analyzeInterfaceBuilder: ({ signal }) => + new Promise((_, reject) => { + signal?.addEventListener("abort", () => + reject( + new ArtifactReaderFailure("io", "reader close failed", { + cleanup: { reason: "close failed", resources: [retained] }, + }), + ), + ); + }), + runAssetUtil: () => + Promise.reject(invariantViolation("Catalog plan is missing")), + }).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(ProviderAdapterError); + if (!(rejected instanceof ProviderAdapterError)) + throw new Error("expected an adapter failure"); + // The defect stays the primary failure beside the reader's cleanup. + expect(rejected.cleanupPrimary).toBeInstanceOf(ProviderAdapterError); + expect(rejected.cleanupPrimary?.cause).toBeInstanceOf(InternalInvariantError); + expect(rejected.cleanupIncomplete).toBe(true); + expect(rejected.cleanupResources).toContain(retained); +}); + it.each(["not json", "{}", "[null]"])( "preserves successful assetutil output failures for %s", async (output) => { diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 29eb957429..8318cd2eed 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -16,6 +16,9 @@ import { streamChunkToBuffer } from "../StreamBytes.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; +import { InternalInvariantError } from "../../domain/internalInvariant.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ARTIFACT_GRAPH_PROVIDER } from "../../domain/investigationProviders.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { ArtifactTraversalObservation, @@ -113,7 +116,16 @@ export const analyzeAppleAssetCatalogs = async (input: { (joined.error instanceof AnalysisError && joined.error.cleanupIncomplete)) ) { - const primary = translateArtifactFailure("inspect_asset_catalog", cause); + // A defect still owns the outcome; it becomes the adapter failure the + // provider boundary would report, so the reader's cleanup can join it. + const primary = + cause instanceof InternalInvariantError + ? new ProviderAdapterError( + ARTIFACT_GRAPH_PROVIDER.id, + "inspect_asset_catalog", + { cause }, + ) + : translateArtifactFailure("inspect_asset_catalog", cause); const cleanup = translateArtifactFailure( "inspect_asset_catalog", joined.error, diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 9546f6634e..c50a229eaa 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -4,6 +4,7 @@ import { ParseFailureError, } from "../../domain/parseFailure.js"; import { TextDecoder } from "node:util"; +import { InternalInvariantError } from "../../domain/internalInvariant.js"; import { decodeBinaryPropertyList } from "./BinaryPlistMaterializer.js"; import { @@ -284,6 +285,8 @@ const decodeInterfaceBuilderBundle = async ( if (input.signal?.aborted === true) throw cause; // Read/identity failures describe source acquisition, not bad content. if (cause instanceof ArtifactReaderFailure) throw cause; + // A broken internal assumption is a REA defect, not an invalid archive. + if (cause instanceof InternalInvariantError) throw cause; if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) { // A document rejected while decoding, e.g. beyond the shared depth // limit, retains no representation; later documents keep its share. diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index c118596b14..1b41c46824 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -6,6 +6,7 @@ import { } from "../../domain/parseFailure.js"; import { basename, join } from "node:path"; import { realpath } from "node:fs/promises"; +import { InternalInvariantError } from "../../domain/internalInvariant.js"; import { readStableArtifact } from "../readStableArtifact.js"; import { projectStableArtifactFailure } from "../StableArtifactFailure.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../../domain/investigationProviders.js"; @@ -454,7 +455,12 @@ const decodeSelectedArchive = ( ...decodeKeyedArchiveBytes(bytes, input.parameters), }); } catch (cause) { - if (cause instanceof AnalysisInputError) throw cause; + // A broken internal assumption is a REA defect, not a damaged archive. + if ( + cause instanceof AnalysisInputError || + cause instanceof InternalInvariantError + ) + throw cause; if (cause instanceof UnsupportedBinaryPlistUidError) throw new AnalysisUnsupportedTargetError( "inspect_keyed_archive", diff --git a/src/artifacts/apple/PlistDecodeInvariant.test.ts b/src/artifacts/apple/PlistDecodeInvariant.test.ts new file mode 100644 index 0000000000..c991ea094b --- /dev/null +++ b/src/artifacts/apple/PlistDecodeInvariant.test.ts @@ -0,0 +1,128 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { buildBinary } from "plist"; +import { afterEach, expect, it, vi } from "vitest"; + +import { + InternalInvariantError, + invariantViolation, +} from "../../domain/internalInvariant.js"; +import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; +import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js"; +import { ArtifactProvider } from "../ArtifactProvider.js"; +import { DeferredAnalysisClient } from "../../providers/DeferredAnalysisClient.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +/** + * No input reaches an invariant site today, so the shared binary plist + * decoder is replaced with one that reports a broken internal assumption. + */ +const decoder = vi.hoisted(() => ({ broken: false })); + +vi.mock("./BinaryPlistMaterializer.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + decodeBinaryPropertyList: ( + ...args: Parameters + ) => { + if (decoder.broken) + throw invariantViolation("Binary plist source plan is missing"); + return actual.decodeBinaryPropertyList(...args); + }, + }; +}); + +afterEach(() => { + decoder.broken = false; +}); + +const archive = Buffer.from( + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "leaf"], + $top: { root: { CF$UID: 1 } }, + }), +); + +it("rethrows an invariant violation instead of reporting a damaged keyed archive", async () => { + const root = await createTestTempDirectory("rea-keyed-invariant-"); + await mkdir(join(root, "Contents", "Resources"), { recursive: true }); + await writeFile(join(root, "Contents", "Resources", "Model.plist"), archive); + const inspect = () => + inspectBundleKeyedArchive({ + bundlePath: root, + targetSha256: "a".repeat(64), + parameters: { path: "Contents/Resources/Model.plist" }, + platform: "darwin", + }); + await expect(inspect()).resolves.toBeDefined(); + + decoder.broken = true; + const rejected = await inspect().catch((cause: unknown) => cause); + + expect(rejected).toBeInstanceOf(InternalInvariantError); +}); + +it("rethrows an invariant violation instead of listing an invalid Interface Builder archive", async () => { + const root = await createTestTempDirectory("rea-ib-invariant-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle); + await writeFile(join(bundle, "Panel.nib"), archive); + const analyze = () => + analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + expect((await analyze()).documents).toHaveLength(1); + + decoder.broken = true; + const rejected = await analyze().catch((cause: unknown) => cause); + + expect(rejected).toBeInstanceOf(InternalInvariantError); +}); + +it.each([ + ["inspect_keyed_archive", { path: "Model.plist" }], + ["decode_interface_builder", {}], +] as const)( + "reports an invariant violation in %s as an adapter failure, not an artifact read", + async (operation, parameters) => { + const bundle = await createTestTempDirectory("rea-artifact-invariant-"); + const infoPlist = join(bundle, "Info.plist"); + await writeFile(infoPlist, ''); + await writeFile(join(bundle, "Model.plist"), archive); + await writeFile(join(bundle, "Panel.nib"), archive); + const provider = new ArtifactProvider({}, "darwin"); + // The deferred client is the adapter boundary every composed provider uses. + const client = new DeferredAnalysisClient( + () => + Promise.resolve( + provider.createBundleResourceClient({ + callerPath: bundle, + canonicalPath: bundle, + infoPlistPath: infoPlist, + layout: "root", + executableSha256: null, + }), + ), + provider.identity().id, + ); + try { + expect((await client.execute(operation, parameters)).ok).toBe(true); + + decoder.broken = true; + const result = await client.execute(operation, parameters); + if (result.ok) + throw new Error("Expected the defect to fail the operation"); + expect(result.error).toBeInstanceOf(ProviderAdapterError); + expect(result.error.cause).toBeInstanceOf(InternalInvariantError); + } finally { + await client.close(); + } + }, +); diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 3e9bcedef0..1609d1ab74 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -31,7 +31,8 @@ import { EVIDENCE_FORMAT_REVISION, evidenceFormatSchema, RECREATE_EVIDENCE_GUIDANCE, - UnsupportedEvidenceFormatError, + isUnsupportedEvidenceFormat, + type UnsupportedEvidenceFormat, } from "./evidenceFormat.js"; const artifactManifestBaseSchema = z.strictObject({ @@ -269,7 +270,7 @@ export const referencedEvidenceIds = ( ]; type EvidenceBundleValidationFailure = - | UnsupportedEvidenceFormatError + | UnsupportedEvidenceFormat | z.ZodError | DuplicateEvidenceRecordError | InvalidResidualUnknownGraphError @@ -297,7 +298,7 @@ const describeEvidenceBundleFailure = ( if (!(recordFailure instanceof z.ZodError)) throw recordFailure; // The format owner decides earlier bundles once; its message carries the // recreate guidance instead of per-field schema failures. - if (cause instanceof UnsupportedEvidenceFormatError) + if (isUnsupportedEvidenceFormat(cause)) return { kind: "unsupported-format", cause, message: cause.message }; return { kind: "invalid-bundle", @@ -389,7 +390,7 @@ const readBundle = ( ); } catch (cause: unknown) { if ( - !(cause instanceof UnsupportedEvidenceFormatError) && + !isUnsupportedEvidenceFormat(cause) && !(cause instanceof z.ZodError) && !(cause instanceof DuplicateEvidenceRecordError) && !(cause instanceof InvalidResidualUnknownGraphError) && diff --git a/src/domain/evidenceFormat.test.ts b/src/domain/evidenceFormat.test.ts index b5e0c4832a..23a5027043 100644 --- a/src/domain/evidenceFormat.test.ts +++ b/src/domain/evidenceFormat.test.ts @@ -10,6 +10,7 @@ import { EVIDENCE_FORMAT_REVISION, RECREATE_EVIDENCE_GUIDANCE, } from "./evidenceFormat.js"; +import { ParseFailureError } from "./parseFailure.js"; const record = createEvidence( undefined, @@ -40,6 +41,15 @@ it.each([ if (parsed.ok) throw new Error("Expected the earlier format to be refused"); expect(parsed.error.kind).toBe("unsupported-format"); expect(parsed.error.message).toContain(RECREATE_EVIDENCE_GUIDANCE); + expect(parsed.error.cause).toBeInstanceOf(ParseFailureError); + expect(parsed.error.cause).toMatchObject({ + failure: { + kind: "unsupported", + format: "evidence-bundle", + field: "evidence_format", + undecodedContent: true, + }, + }); const read = readEvidenceBundle(earlier, { operation: "import_evidence_bundle", path: ["evidence_bundle"], diff --git a/src/domain/evidenceFormat.ts b/src/domain/evidenceFormat.ts index 0a5d9e6e9d..69f4ccd4fc 100644 --- a/src/domain/evidenceFormat.ts +++ b/src/domain/evidenceFormat.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { ParseFailureError, unsupportedInput } from "./parseFailure.js"; + /** * The Evidence format revision this REA release writes and reads. * @@ -25,22 +27,19 @@ export const evidenceFormatSchema = z.literal(EVIDENCE_FORMAT_REVISION, { error: `evidence_format must be ${String(EVIDENCE_FORMAT_REVISION)}. ${RECREATE_EVIDENCE_GUIDANCE}`, }); +/** The ParseFailure format name for an Evidence bundle. */ +const EVIDENCE_BUNDLE_FORMAT = "evidence-bundle"; + /** A bundle written in a format this release does not read. */ -export class UnsupportedEvidenceFormatError extends TypeError { - constructor( - /** The declared revision, or `undefined` for an unmarked bundle. */ - readonly found: unknown, - ) { - super( - `${ - found === undefined - ? "This Evidence bundle predates format revisions" - : `Evidence bundle format ${JSON.stringify(found)} is not format ${String(EVIDENCE_FORMAT_REVISION)}` - }, which this REA release reads. ${RECREATE_EVIDENCE_GUIDANCE}`, - ); - this.name = "UnsupportedEvidenceFormatError"; - } -} +export type UnsupportedEvidenceFormat = ParseFailureError<"unsupported">; + +/** Recognize this module's refusal of a bundle from another format. */ +export const isUnsupportedEvidenceFormat = ( + cause: unknown, +): cause is UnsupportedEvidenceFormat => + cause instanceof ParseFailureError && + cause.kind === "unsupported" && + cause.failure.format === EVIDENCE_BUNDLE_FORMAT; /** * Refuse a bundle from another format before reading its shape, so an earlier @@ -58,5 +57,13 @@ export const admitEvidenceBundleFormat = (input: unknown): void => { return; const found: unknown = Reflect.get(input, "evidence_format"); if (found !== EVIDENCE_FORMAT_REVISION) - throw new UnsupportedEvidenceFormatError(found); + throw unsupportedInput( + EVIDENCE_BUNDLE_FORMAT, + `${ + found === undefined + ? "This Evidence bundle predates format revisions" + : `Evidence bundle format ${JSON.stringify(found)} is not format ${String(EVIDENCE_FORMAT_REVISION)}` + }, which this REA release reads. ${RECREATE_EVIDENCE_GUIDANCE}`, + { field: "evidence_format", undecodedContent: true }, + ); }; diff --git a/src/domain/native/functionByteCoverage.ts b/src/domain/native/functionByteCoverage.ts index 8dcce92d63..2a82d5d70f 100644 --- a/src/domain/native/functionByteCoverage.ts +++ b/src/domain/native/functionByteCoverage.ts @@ -3,6 +3,7 @@ import type { z } from "zod"; import type { functionBodySchema } from "../native/functionBody.js"; import type { nativeByteReadSchema } from "./nativeByteRead.js"; import { addressCoordinate } from "./addressCoordinate.js"; +import { invariantViolation } from "../internalInvariant.js"; export type NativeMatchingCheckpoint = (bytes?: number) => Promise; interface Interval { @@ -89,7 +90,7 @@ export class FunctionByteCoverage { evidenceId: string, ): Promise { if (this.#prepared) - throw new TypeError( + throw invariantViolation( "Cannot append byte observations after fingerprinting", ); if (read.returned_bytes === 0) return; diff --git a/src/domain/python/marshalUtf8.ts b/src/domain/python/marshalUtf8.ts index d81b648e0e..2a6e7d3a43 100644 --- a/src/domain/python/marshalUtf8.ts +++ b/src/domain/python/marshalUtf8.ts @@ -1,4 +1,5 @@ import { err, ok, type Result } from "../result.js"; +import { invariantViolation } from "../internalInvariant.js"; const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); @@ -30,7 +31,7 @@ export const decodeMarshalUtf8 = ( maximumPrefixUnits !== Infinity && (!Number.isSafeInteger(maximumPrefixUnits) || maximumPrefixUnits < 0) ) - throw new RangeError( + throw invariantViolation( "Marshal prefix limit must be a nonnegative safe integer.", ); const chunks: string[] = []; diff --git a/src/dotnet/ManagedArtifactInspector.ts b/src/dotnet/ManagedArtifactInspector.ts index 7314fdd8ae..5a1121eeca 100644 --- a/src/dotnet/ManagedArtifactInspector.ts +++ b/src/dotnet/ManagedArtifactInspector.ts @@ -160,6 +160,8 @@ const unavailableResult = ( interface RuntimeClassificationInput { readonly layout: ManagedPeLayout; + /** The layout's CLI header, which a managed classification requires. */ + readonly cli: ManagedCliHeader; readonly metadata: ManagedMetadataLayout; readonly inventory: ManagedMetadataInventory; } @@ -283,14 +285,13 @@ const managedArchitectureFor = ( const runtimeClassification = ({ layout, + cli, metadata, inventory, }: RuntimeClassificationInput): Pick< ManagedArtifactInspection["classification"], "runtime_family" | "implementation" | "managed_architecture" | "evidence" > => { - const cli = layout.cli; - if (cli === null) throw new TypeError("Managed classification requires CLI"); const { runtime_family: runtimeFamily, evidence } = runtimeFamilyFor( cli, inventory, @@ -432,7 +433,7 @@ export const inspectManagedArtifactBytes = ( classification: { status: "managed", container: "pe", - ...runtimeClassification({ layout, metadata, inventory }), + ...runtimeClassification({ layout, cli, metadata, inventory }), }, metadata: { status: issues.length === 0 ? "complete" : "partial", diff --git a/src/dotnet/ManagedDecodeBudget.test.ts b/src/dotnet/ManagedDecodeBudget.test.ts index b46f6a0a45..fc678bc6a8 100644 --- a/src/dotnet/ManagedDecodeBudget.test.ts +++ b/src/dotnet/ManagedDecodeBudget.test.ts @@ -5,6 +5,7 @@ import { ManagedDecodeBudget, } from "./ManagedDecodeBudget.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; +import { InternalInvariantError } from "../domain/internalInvariant.js"; describe("managed projection admission", () => { it("charges decoded strings by their JSON bytes", () => { @@ -92,3 +93,14 @@ describe("managed decoding bounds", () => { expect(() => budget.admitTransient(bound, null)).not.toThrow(); }); }); + +// Callers admit these allowances through their input schemas, so a value +// outside them is a REA defect rather than a malformed target. +it.each([0, -1, 1.5])( + "reports a %s representation allowance as a broken internal assumption", + (maximumBytes) => { + expect(() => new ManagedDecodeBudget(maximumBytes)).toThrow( + InternalInvariantError, + ); + }, +); diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts index fbd8e05414..36082f44b1 100644 --- a/src/dotnet/ManagedDecodeBudget.ts +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { invariantViolation } from "../domain/internalInvariant.js"; import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; @@ -34,7 +35,7 @@ export class ManagedDecodeBudget { constructor(readonly maximumBytes = DEFAULT_MANAGED_REPRESENTATION_BYTES) { if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) - throw new RangeError( + throw invariantViolation( "max_metadata_representation_bytes must be a positive safe integer", ); this.#remaining = maximumBytes; diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 452e1f22ad..875b6a2112 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; +import { InternalInvariantError } from "../domain/internalInvariant.js"; import { ManagedDecodeBudget, managedDecodeBudget, @@ -3106,14 +3107,17 @@ describe("managed member method paging", () => { expect(emptyPage.methods).toEqual([]); }); - it("rejects invalid method page bounds", () => { - const image = buildManagedPeFixture(); - expect(() => - inspectManagedMembersBytes(image, managedPeFixtureTarget(image), { - methodOffset: -1, - }), - ).toThrow(RangeError); - }); + // The input schema admits only non-negative integer pages, so any other + // page is a REA defect rather than a malformed target. + it.each([{ methodOffset: -1 }, { methodLimit: 1.5 }])( + "reports a %o method page as a broken internal assumption", + (page) => { + const image = buildManagedPeFixture(); + expect(() => + inspectManagedMembersBytes(image, managedPeFixtureTarget(image), page), + ).toThrow(InternalInvariantError); + }, + ); }); it("retains names of unselected fields without admitting their malformed signatures", () => { diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index b73bf2a7fa..6f9970f260 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -11,8 +11,10 @@ import { } from "../domain/managed/managedArtifact.js"; import { readManagedPeLayout, + type ManagedCliHeader, type ManagedPeLayout, } from "./ManagedPeReader.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; import { readManagedResourceDirectory, readManagedMetadataInventory, @@ -115,11 +117,9 @@ const unavailable = ( const readMemberLayout = ( bytes: Buffer, pe: ManagedPeLayout, + cli: ManagedCliHeader, maxRepresentationBytes?: number, ) => { - const cli = pe.cli; - if (cli === null) - throw new TypeError("Managed inventory requires CLI metadata"); const rootOffset = pe.rvaToOffset( cli.metadata.rva, cli.metadata.size, @@ -194,7 +194,7 @@ export const inspectManagedMembersBytes = ( !Number.isSafeInteger(methodLimit) || methodLimit < 0 ) - throw new RangeError( + throw invariantViolation( "methodOffset and methodLimit must be non-negative safe integers", ); const pe = readManagedPeLayout(bytes); @@ -206,6 +206,7 @@ export const inspectManagedMembersBytes = ( const { layout, memberLists } = readMemberLayout( bytes, pe, + pe.cli, maxRepresentationBytes, ); const methodTotal = layout.table(6)?.rowCount ?? 0; diff --git a/src/dotnet/ManagedSnapshot.test.ts b/src/dotnet/ManagedSnapshot.test.ts new file mode 100644 index 0000000000..e23f153fdd --- /dev/null +++ b/src/dotnet/ManagedSnapshot.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from "vitest"; + +import { InternalInvariantError } from "../domain/internalInvariant.js"; +import { hashManagedSource, readManagedSnapshot } from "./ManagedSnapshot.js"; + +// The input schema admits only positive integer snapshot allowances, so any +// other allowance is a REA defect, refused before the file is opened. +it.each([ + ["read", readManagedSnapshot], + ["hash", hashManagedSource], +] as const)( + "reports a nonpositive snapshot allowance to %s as a broken internal assumption", + async (_name, read) => { + await expect( + read("/nonexistent/rea-managed-snapshot", "inspect_managed_artifact", { + maxBytes: 0, + }), + ).rejects.toBeInstanceOf(InternalInvariantError); + }, +); diff --git a/src/dotnet/ManagedSnapshot.ts b/src/dotnet/ManagedSnapshot.ts index 44c3249b95..844129ad44 100644 --- a/src/dotnet/ManagedSnapshot.ts +++ b/src/dotnet/ManagedSnapshot.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { invariantViolation } from "../domain/internalInvariant.js"; import type { Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; @@ -148,7 +149,9 @@ export const hashManagedSource = async ( const snapshotBudget = (options: ManagedSnapshotReadOptions): number => { const maxBytes = options.maxBytes ?? DEFAULT_MANAGED_SNAPSHOT_BYTES; if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) - throw new RangeError("max_snapshot_bytes must be a positive safe integer"); + throw invariantViolation( + "max_snapshot_bytes must be a positive safe integer", + ); return maxBytes; }; diff --git a/src/dotnet/ManagedStaticProvider.ts b/src/dotnet/ManagedStaticProvider.ts index 43996e5435..e37b669cfa 100644 --- a/src/dotnet/ManagedStaticProvider.ts +++ b/src/dotnet/ManagedStaticProvider.ts @@ -7,6 +7,7 @@ import { type ExecutionOptions, type ProviderIdentity, } from "../application/AnalysisProvider.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { managedStaticCapabilities } from "./ManagedStaticProviderMetadata.js"; @@ -141,7 +142,7 @@ class ManagedStaticClient implements AnalysisClient { if (!admitted.ok) return admitted; const bytes = snapshot ?? observed.bytes; if (bytes === undefined) - throw new TypeError("Managed snapshot bytes are unavailable"); + throw invariantViolation("Managed snapshot bytes are unavailable"); this.#snapshotBytes = bytes; const result = inspectManagedOperation( operation, diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 3e3a9fd619..ac8d3c261a 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -251,7 +251,9 @@ it("rejects a directory configured as the firmware engine executable", async () ), ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", - reason: expect.stringContaining(command), + reason: expect.stringContaining( + `REA_BINWALK_COMMAND is not a readable executable regular file: ${command}: Configured path is not a regular file`, + ), }); }); @@ -295,7 +297,9 @@ it("rejects a directory configured as the resource limiter executable", async () ), ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", - reason: expect.stringContaining(limiter), + reason: expect.stringContaining( + `Resource limiter is not an executable regular file: ${limiter}: Configured path is not a regular file; provide util-linux prlimit`, + ), }); }); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index b5277869c3..67ac8f8b38 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -84,6 +84,28 @@ export const firmwareToolUnavailable = ( export const firmwareHostSupported = (platform: NodeJS.Platform): boolean => platform === "linux"; +const failureReason = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +/** + * Why a configured tool path cannot run, or undefined when `accessPath` grants + * `mode` and `statPath` is a regular file. + */ +const executableRegularFileRefusal = async ( + accessPath: string, + statPath: string, + mode: number, +): Promise => { + try { + await access(accessPath, mode); + return (await stat(statPath)).isFile() + ? undefined + : "Configured path is not a regular file"; + } catch (cause: unknown) { + return failureReason(cause); + } +}; + /** Resolve explicitly supplied executable paths; never install or search for engines. */ export const resolveFirmwareCommand = async ( environment: Readonly>, @@ -110,24 +132,29 @@ export const resolveFirmwareCommand = async ( throw firmwareToolUnavailable( engine, operation, - `${variable} is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, + `${variable} is unavailable: ${failureReason(cause)}`, ); }, ); const command = selection.invocationPath; + const engineUnavailable = (reason: string) => + firmwareToolUnavailable( + engine, + operation, + `${variable} is not a readable executable regular file: ${command}: ${reason}`, + ); + const engineRefusal = await executableRegularFileRefusal( + command, + command, + constants.R_OK | constants.X_OK, + ); + if (engineRefusal !== undefined) throw engineUnavailable(engineRefusal); let sha256: string; try { - await access(command, constants.R_OK | constants.X_OK); - if (!(await stat(command)).isFile()) - throw new TypeError("Configured path is not a regular file"); sha256 = await files.hash(selection.resolvedPath, signal); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) throw cause; - throw firmwareToolUnavailable( - engine, - operation, - `${variable} is not a readable executable regular file: ${command}: ${cause instanceof Error ? cause.message : String(cause)}`, - ); + throw engineUnavailable(failureReason(cause)); } const limiter = environment.REA_FIRMWARE_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; @@ -137,20 +164,25 @@ export const resolveFirmwareCommand = async ( operation, "REA_FIRMWARE_PRLIMIT_COMMAND must be absolute", ); + const limiterUnavailable = (reason: string) => + firmwareToolUnavailable( + engine, + operation, + `Resource limiter is not an executable regular file: ${limiter}: ${reason}; provide util-linux prlimit`, + ); let limiterSelection: ArtifactCommandPath; try { limiterSelection = await selectArtifactCommandPath(limiter, signal); - await access(limiterSelection.resolvedPath, constants.X_OK); - if (!(await stat(limiter)).isFile()) - throw new TypeError("Configured path is not a regular file"); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) throw cause; - throw firmwareToolUnavailable( - engine, - operation, - `Resource limiter is not an executable regular file: ${limiter}: ${cause instanceof Error ? cause.message : String(cause)}; provide util-linux prlimit`, - ); + throw limiterUnavailable(failureReason(cause)); } + const limiterRefusal = await executableRegularFileRefusal( + limiterSelection.resolvedPath, + limiter, + constants.X_OK, + ); + if (limiterRefusal !== undefined) throw limiterUnavailable(limiterRefusal); return { command: selection, limiter: limiterSelection, diff --git a/src/il2cpp/Il2cppMetadataFile.ts b/src/il2cpp/Il2cppMetadataFile.ts index b8687e3208..c831036660 100644 --- a/src/il2cpp/Il2cppMetadataFile.ts +++ b/src/il2cpp/Il2cppMetadataFile.ts @@ -18,6 +18,7 @@ import { readIl2cppMemberTables, type MetadataOwnerRange, } from "./Il2cppMetadataMembers.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; const NESTED_TYPE_ENTRY_BYTES = 4; @@ -215,7 +216,7 @@ const checkSections = ( /** A counted header's entry count; fixed headers record none. */ const headerCount = (section: Il2cppMetadataSection) => { if (section.count === null) - throw new TypeError(`section ${section.name} has no header count`); + throw invariantViolation(`section ${section.name} has no header count`); return section.count; }; @@ -858,7 +859,7 @@ export function* readIl2cppMetadataForLinkingSteps( ): Generator { const parsed = yield* readMetadataCoreSteps(bytes, options, true); if (parsed.link === undefined) - throw new Error( + throw invariantViolation( "Native metadata reader did not retain requested linking facts", ); return { metadata: parsed.metadata, link: parsed.link }; diff --git a/src/model/ModelHeaderReader.ts b/src/model/ModelHeaderReader.ts index 0ec2f2bca0..139753747a 100644 --- a/src/model/ModelHeaderReader.ts +++ b/src/model/ModelHeaderReader.ts @@ -7,6 +7,7 @@ import { MODEL_HEADER_BYTES, rejectModel, } from "./ModelFormat.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; /** Sequential prefix reader. Read-ahead is permitted only inside a parser-proven header extent. */ export class ModelHeaderReader { @@ -26,7 +27,7 @@ export class ModelHeaderReader { /** Safetensors declares JSON bytes separately from its fixed length prefix. */ excludeSafetensorsLengthPrefix(): void { if (this.position !== 8) - throw new TypeError( + throw invariantViolation( "Safetensors header budget must follow its eight-byte length prefix", ); this.#headerPrefixBytes = 8; diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 28a316ac4d..bede7ec1dd 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -65,6 +65,7 @@ import { createSwiftContextReader, } from "./AppleSwiftNominalTypes.js"; import { inspectObjcClassImportBindings } from "./AppleObjcClassImportBindings.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; /** Decode validated little-endian 64-bit Apple Objective-C metadata without loading target code. */ export function* decodeAppleDispatchMetadataSteps( @@ -379,7 +380,9 @@ export const inspectAppleDispatchMetadata = async ( ) => { const { signal } = options; if (target.kind !== "executable" || !isMachOFormat(target.format)) - throw new TypeError("Apple dispatch metadata requires a Mach-O target"); + throw invariantViolation( + "Apple dispatch metadata requires a Mach-O target", + ); const admission = options.maxInputBytes === undefined ? { diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index ffbffcfff1..3e04bde148 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -1,5 +1,6 @@ import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { HOST_PLATFORM } from "../platform/HostPlatform.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js"; import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js"; @@ -1334,7 +1335,9 @@ const releaseLldbRuntime = async ( owner: RetainedLldbResources, ): Promise => { if (!owner.processStopped || !owner.targetStopped || owner.files.hasPending) - throw new Error("LLDB runtime still owns unresolved child resources"); + throw invariantViolation( + "LLDB runtime still owns unresolved child resources", + ); await owner.rootLease.release(); }; diff --git a/src/native/LldbRetainedObservations.ts b/src/native/LldbRetainedObservations.ts index ec7d3376d0..1081aec41d 100644 --- a/src/native/LldbRetainedObservations.ts +++ b/src/native/LldbRetainedObservations.ts @@ -2,6 +2,7 @@ import { NATIVE_CALL_CAPTURE_DEFAULTS, type NativeCallCaptureLimits, } from "../domain/native/nativeCallCaptureLimits.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; import type { FileHandle } from "node:fs/promises"; import type { LldbReadbackFiles } from "./LldbReadbackFiles.js"; import type { Result } from "../domain/result.js"; @@ -190,7 +191,9 @@ export const readBoundedPrefix = async ( } if (count === 0) break; if (count > requestedBytes - bytesRead) - throw new Error("The bounded read returned more bytes than requested"); + throw invariantViolation( + "The bounded read returned more bytes than requested", + ); bytesRead += count; } return { diff --git a/src/native/MachoCommandNames.ts b/src/native/MachoCommandNames.ts index 772a0d89e2..aa16d4a162 100644 --- a/src/native/MachoCommandNames.ts +++ b/src/native/MachoCommandNames.ts @@ -17,6 +17,7 @@ import { ParseFailureError, truncatedInput, } from "../domain/parseFailure.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; /** loader.h defines these with LC_REQ_DYLD; the reader also accepts each code without that bit. */ const withoutRequiredByDyld = (code: number): number => code & ~LC_REQ_DYLD; @@ -202,7 +203,9 @@ export const readMachoCommandNames = async ( !Number.isSafeInteger(maximumProjectedBytes) || maximumProjectedBytes < 0 ) - throw new TypeError("Mach-O read limits must be nonnegative safe integers"); + throw invariantViolation( + "Mach-O read limits must be nonnegative safe integers", + ); const exact = async (offset: number, size: number): Promise => { if ( !Number.isSafeInteger(offset) || diff --git a/src/native/NativeMacOSProvider.invariant.test.ts b/src/native/NativeMacOSProvider.invariant.test.ts new file mode 100644 index 0000000000..36e8a947dd --- /dev/null +++ b/src/native/NativeMacOSProvider.invariant.test.ts @@ -0,0 +1,46 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, onTestFinished, vi } from "vitest"; + +import { NativeMacOSProvider } from "./NativeMacOSProvider.js"; +import { + InternalInvariantError, + invariantViolation, +} from "../domain/internalInvariant.js"; +import { NativeFixtureRunner } from "../../tests/fixtures/nativeCommands.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; + +/** + * No input reaches an invariant site today, so the command runner reports a + * broken internal assumption in place of a producer. + */ +it("reports an invariant violation as an adapter failure, not native output", async () => { + const bundle = await createTestTempDirectory("rea-native-invariant-"); + const infoPlist = join(bundle, "Info.plist"); + await writeFile(infoPlist, ''); + const runner = new NativeFixtureRunner(); + const client = new NativeMacOSProvider( + {}, + runner, + "darwin", + ).createBundleResourceClient({ + callerPath: bundle, + canonicalPath: bundle, + infoPlistPath: infoPlist, + layout: "root", + executableSha256: null, + }); + onTestFinished(async () => { + await client.close(); + }); + expect((await client.execute("inspect_plist", {})).ok).toBe(true); + + vi.spyOn(runner, "run").mockRejectedValue( + invariantViolation("Native command plan is missing"), + ); + const result = await client.execute("inspect_plist", {}); + if (result.ok) throw new Error("Expected the defect to fail inspection"); + expect(result.error._tag).toBe("ProviderAdapterError"); + expect(result.error.cause).toBeInstanceOf(InternalInvariantError); +}); diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index f8de9fad16..9b1d5356a2 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1,5 +1,6 @@ import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js"; import { withArtifactTargetIdentity } from "../artifacts/withArtifactTargetIdentity.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js"; import { plistSourceFormat, @@ -113,6 +114,7 @@ import { type NativeCommandInvocation, } from "../domain/native/nativeInspection.js"; import { err, ok, type Result } from "../domain/result.js"; +import { InternalInvariantError } from "../domain/internalInvariant.js"; import { NativeCommandFailure, NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, @@ -271,7 +273,7 @@ class NativeMacOSClient implements AnalysisClient { private get target(): BinaryTarget { if (this.activeTarget === undefined) - throw new Error("This provider client has no executable target"); + throw invariantViolation("This provider client has no executable target"); return this.activeTarget; } @@ -1441,7 +1443,9 @@ class NativeMacOSClient implements AnalysisClient { ); if (size !== null) { if (selected === undefined) - throw new TypeError("Selected Mach-O slice is unavailable"); + throw invariantViolation( + "Selected Mach-O slice is unavailable", + ); assertSelectedMachoSlice(source, selected, binary); } return binary; @@ -1705,11 +1709,19 @@ const closeNativeFileScope = async ( } }; +/** + * Project a producer failure. A broken internal assumption is a REA defect, not + * native output, so it is rethrown rather than reported as output parsing. + */ const nativeOutputFailure = ( operation: AnalysisOperation, cause: unknown, -): AnalysisError => - new AnalysisOutputError(operation, "Native output parsing failed", { cause }); +): AnalysisError => { + if (cause instanceof InternalInvariantError) throw cause; + return new AnalysisOutputError(operation, "Native output parsing failed", { + cause, + }); +}; const nativeMachoCompletedFailure = ( value: unknown, diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index df5f509ffb..71d788d751 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -58,6 +58,7 @@ import { parseDyldFileTypeRefusal, unusableNativeCommandOutput, } from "./NativeMachoCommandDiagnostics.js"; +import { invariantViolation } from "../domain/internalInvariant.js"; interface NativeMachoObservation { readonly result: JsonValue; @@ -184,7 +185,7 @@ export const inspectNativeMacho = async ( captures.push(captured.value); } const lipo = captures.find(({ tool }) => tool === "lipo"); - if (lipo === undefined) throw new TypeError("Missing lipo capture"); + if (lipo === undefined) throw invariantViolation("Missing lipo capture"); const parsedLipo = parseLipoCapture( lipo, "inspect_macho", @@ -261,7 +262,7 @@ export const inspectNativeMacho = async ( const nmIndex = captures.findIndex(({ tool }) => tool === "nm"); const dyldExports = captures.filter(({ tool }) => tool === "dyld_info")[1]; if (nmIndex < 0 || dyldExports === undefined) - throw new TypeError("Missing nm or dyld_info export capture"); + throw invariantViolation("Missing nm or dyld_info export capture"); const selected = selection !== null ? selection.architecture @@ -352,7 +353,7 @@ export const inspectNativeMacho = async ( tool === "dyld_info" && arguments_[0] === "-imports", ); if (dyldImports === undefined) - throw new TypeError("Missing dyld_info import capture"); + throw invariantViolation("Missing dyld_info import capture"); const hasChainedFixups = load.commands.some( ({ kind }) => kind === "LC_DYLD_CHAINED_FIXUPS", ); @@ -710,7 +711,8 @@ const captureLookup = (captures: readonly NativeCommandCapture[]) => (tool: string, occurrence = 0): NativeCommandCapture => { const capture = captures.filter((item) => item.tool === tool)[occurrence]; - if (capture === undefined) throw new TypeError(`Missing ${tool} capture`); + if (capture === undefined) + throw invariantViolation(`Missing ${tool} capture`); return capture; }; diff --git a/src/native/NativeUiHelperRuntime.ts b/src/native/NativeUiHelperRuntime.ts index d459a2535b..225ba1da3e 100644 --- a/src/native/NativeUiHelperRuntime.ts +++ b/src/native/NativeUiHelperRuntime.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { SWIFTC_INTERRUPT } from "../process/DarwinProcessRunTokenReader.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { safeParseJson } from "../domain/safeJson.js"; +import { malformedInput } from "../domain/parseFailure.js"; import { DEFAULT_NATIVE_UI_DEADLINE_MS } from "../domain/native/nativeDeadlines.js"; import type { NativeUiHelper } from "./NativeUiObservation.js"; import { NATIVE_UI_HELPER_MAX_BUFFER } from "./NativeUiOutputBudget.js"; @@ -108,11 +109,10 @@ export const createNativeUiHelperRuntime = ( ); const parsed = safeParseJson(output.stdout); if (!parsed.ok) - throw new Error( + throw malformedInput( + "native-ui-helper-output", `Native helper returned invalid JSON: ${parsed.error}`, - { - cause: parsed.cause, - }, + { cause: parsed.cause }, ); return parsed.value; }); diff --git a/src/native/NativeUiInterruptionControl.ts b/src/native/NativeUiInterruptionControl.ts index 113413c935..ade12fafc9 100644 --- a/src/native/NativeUiInterruptionControl.ts +++ b/src/native/NativeUiInterruptionControl.ts @@ -63,12 +63,11 @@ export class NativeUiInterruptionControl { operation: string, partialObservation?: AnalysisPartialObservation, ): AnalysisTimeoutError | AnalysisCancelledError { - if (this.#interruption === undefined) - this.#interrupt("deadline", this.#deadline.reason); - const error = this.error(operation, partialObservation); - if (error === undefined) - throw new Error("UI deadline failure requires an interruption."); - return error; + return this.#errorFor( + this.#interrupt("deadline", this.#deadline.reason), + operation, + partialObservation, + ); } error( @@ -76,7 +75,16 @@ export class NativeUiInterruptionControl { partialObservation?: AnalysisPartialObservation, ): AnalysisCancelledError | AnalysisTimeoutError | undefined { const interruption = this.#interruption; - if (interruption === undefined) return undefined; + return interruption === undefined + ? undefined + : this.#errorFor(interruption, operation, partialObservation); + } + + #errorFor( + interruption: Interruption, + operation: string, + partialObservation: AnalysisPartialObservation | undefined, + ): AnalysisCancelledError | AnalysisTimeoutError { return interruption.kind === "deadline" ? new AnalysisTimeoutError(operation, this.#deadlineMs, { cause: interruption.reason, @@ -102,9 +110,12 @@ export class NativeUiInterruptionControl { this.#deadline.removeEventListener("abort", this.#onDeadlineAbort); } - #interrupt(kind: Interruption["kind"], reason: unknown): void { - if (this.#interruption !== undefined) return; - this.#interruption = { kind, reason }; + /** Record the first interruption; a later one leaves it primary. */ + #interrupt(kind: Interruption["kind"], reason: unknown): Interruption { + if (this.#interruption !== undefined) return this.#interruption; + const interruption = { kind, reason }; + this.#interruption = interruption; this.#controller.abort(reason); + return interruption; } } diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index 90c0caf9b6..847f4f48f1 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { malformedInput } from "../domain/parseFailure.js"; import { crc32 } from "node:zlib"; import { describe, expect, it } from "vitest"; @@ -669,7 +670,9 @@ describe("native UI target and cancellation failures", () => { const result = await observeNativeUi(target, "observe_native_ui", scope, { environment: {}, invoke: async () => { - throw new Error( + // The helper runtime's own report of output that is not JSON. + throw malformedInput( + "native-ui-helper-output", "Native helper returned invalid JSON: Unexpected token", ); }, diff --git a/src/native/apple/AppleIpsFieldReader.test.ts b/src/native/apple/AppleIpsFieldReader.test.ts new file mode 100644 index 0000000000..d0c63bfb1e --- /dev/null +++ b/src/native/apple/AppleIpsFieldReader.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; + +import { InternalInvariantError } from "../../domain/internalInvariant.js"; +import { FieldReader } from "./AppleIpsFieldReader.js"; + +it("adds integers recorded by the same reader", () => { + const read = new FieldReader(1024); + expect( + read.add(read.recordedInteger("40"), read.recordedInteger("2")), + ).toEqual({ decimal: "42", hex: "0x2a" }); +}); + +// Recorded integers come only from REA's own parser, so a fact from another +// reader is a REA defect rather than a malformed crash report. +it("reports another reader's integer fact as a broken internal assumption", () => { + const read = new FieldReader(1024); + const foreign = new FieldReader(1024).recordedInteger("2"); + expect(() => read.add(read.recordedInteger("40"), foreign)).toThrow( + InternalInvariantError, + ); +}); diff --git a/src/native/apple/AppleIpsFieldReader.ts b/src/native/apple/AppleIpsFieldReader.ts index 9a3024be33..079f81f05b 100644 --- a/src/native/apple/AppleIpsFieldReader.ts +++ b/src/native/apple/AppleIpsFieldReader.ts @@ -3,6 +3,7 @@ import { type AppleIpsTypeMismatch, type RecordedInteger, } from "../../domain/native/appleIps.js"; +import { invariantViolation } from "../../domain/internalInvariant.js"; import { AppleIpsResourceFailure } from "./AppleIps.errors.js"; import type { LosslessJson } from "./losslessJson.js"; @@ -72,7 +73,9 @@ export class FieldReader { const a = this.#integers.get(left); const b = this.#integers.get(right); if (a === undefined || b === undefined) - throw new Error("IPS integer facts do not belong to this field reader"); + throw invariantViolation( + "IPS integer facts do not belong to this field reader", + ); const reserved = this.#reserve( Math.max(left.decimal.length, right.decimal.length) + 1, true, diff --git a/src/native/parsers/demangle.test.ts b/src/native/parsers/demangle.test.ts index 433dab1168..e25eb929bc 100644 --- a/src/native/parsers/demangle.test.ts +++ b/src/native/parsers/demangle.test.ts @@ -118,10 +118,17 @@ describe("parseDemangledSymbols", () => { ]); }); - it("throws TypeError when output count does not match input count", () => { + it("reports an output count that does not match the input as malformed output", () => { expect(() => parseDemangledSymbols(["sym1", "sym2"], "demangled1\n"), - ).toThrow(TypeError); + ).toThrow( + expect.objectContaining({ + failure: expect.objectContaining({ + kind: "malformed", + format: "swift-demangle-output", + }), + }), + ); expect(() => parseDemangledSymbols(["sym1"], "demangled1\ndemangled2\n"), ).toThrow("swift-demangle output count does not match input"); diff --git a/src/native/parsers/demangle.ts b/src/native/parsers/demangle.ts index 862eb5e0b8..d119fce0d4 100644 --- a/src/native/parsers/demangle.ts +++ b/src/native/parsers/demangle.ts @@ -2,6 +2,9 @@ import type { z } from "zod"; import { isSwiftMangledSymbolName } from "../../domain/swiftSymbolClassification.js"; import type { demangleSwiftSchema } from "../../domain/native/nativeInspection.js"; +import { malformedInput } from "../../domain/parseFailure.js"; + +const SWIFT_DEMANGLE_OUTPUT = "swift-demangle-output"; type DemangledSymbol = z.infer["symbols"][number]; @@ -26,20 +29,29 @@ export const parseDemangledSymbols = ( ): DemangledSymbol[] => { if (inputs.length === 0) { if (output === "" || output === "\n") return []; - throw new TypeError("swift-demangle emitted output for an empty input"); + throw malformedInput( + SWIFT_DEMANGLE_OUTPUT, + "swift-demangle emitted output for an empty input", + ); } // The command-line producer warns and skips exact `_` arguments. Other // single-line inputs each own one record, including undecoded spellings. const expectedRecords = inputs.filter((input) => input !== "_").length; const lines = output === "" ? [] : output.replace(/\n$/u, "").split("\n"); if (lines.length !== expectedRecords) - throw new TypeError("swift-demangle output count does not match input"); + throw malformedInput( + SWIFT_DEMANGLE_OUTPUT, + "swift-demangle output count does not match input", + ); let record = 0; return inputs.map((input) => { if (input === "_") return { input, output: "", status: "invalid" }; const value = lines[record++]; if (value === undefined || value.length === 0) - throw new TypeError("swift-demangle omitted an output line"); + throw malformedInput( + SWIFT_DEMANGLE_OUTPUT, + "swift-demangle omitted an output line", + ); const decoded = value !== input && value !== undecodedSpelling(input); return { input, diff --git a/src/native/parsers/lipo.test.ts b/src/native/parsers/lipo.test.ts index 8e09e321c3..9191c4615a 100644 --- a/src/native/parsers/lipo.test.ts +++ b/src/native/parsers/lipo.test.ts @@ -41,7 +41,14 @@ it("retains native command capture when shared lipo parsing rejects records", () ok: false, error: { operation: "inspect_macho", - cause: new TypeError("lipo output contained no architectures"), + cause: expect.objectContaining({ + _tag: "ParseFailureError", + failure: { + kind: "malformed", + format: "lipo-output", + detail: "lipo output contained no architectures", + }, + }), capturedOutput: { stdout: capture.stdout, stderr: capture.stderr, diff --git a/src/native/parsers/lipo.ts b/src/native/parsers/lipo.ts index 72ca871670..90215fef0c 100644 --- a/src/native/parsers/lipo.ts +++ b/src/native/parsers/lipo.ts @@ -5,6 +5,10 @@ import { AnalysisResourceConstraintError, } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { + malformedInput, + ParseFailureError, +} from "../../domain/parseFailure.js"; import { NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, type NativeCommandCapture, @@ -233,7 +237,10 @@ const parseLipoOutput = (output: string, operandPath?: string): LipoOutput => { } flush(); if (architectures.length === 0) - throw new TypeError("lipo output contained no architectures"); + throw malformedInput( + "lipo-output", + "lipo output contained no architectures", + ); return { format: "fat", declaredArchitectureCount, @@ -246,12 +253,15 @@ const parseLipoOutput = (output: string, operandPath?: string): LipoOutput => { export const parseLipoOutputResult = ( output: string, operandPath?: string, -): Result => { +): Result< + LipoOutput, + ParseFailureError | z.ZodError | LipoFieldRetentionError +> => { try { return ok(parseLipoOutput(output, operandPath)); } catch (cause: unknown) { if ( - !(cause instanceof TypeError) && + !(cause instanceof ParseFailureError) && !(cause instanceof z.ZodError) && !(cause instanceof LipoFieldRetentionError) ) diff --git a/src/native/parsers/plist.invariant.test.ts b/src/native/parsers/plist.invariant.test.ts new file mode 100644 index 0000000000..5c72dc12dc --- /dev/null +++ b/src/native/parsers/plist.invariant.test.ts @@ -0,0 +1,46 @@ +import { buildBinary } from "plist"; +import { afterEach, expect, it, vi } from "vitest"; + +import { + InternalInvariantError, + invariantViolation, +} from "../../domain/internalInvariant.js"; +import { parsePlistBinary } from "./plist.js"; + +/** + * No input reaches an invariant site today, so the shared binary plist + * decoder is replaced with one that reports a broken internal assumption. + */ +const decoder = vi.hoisted(() => ({ broken: false })); + +vi.mock( + "../../artifacts/apple/BinaryPlistMaterializer.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../artifacts/apple/BinaryPlistMaterializer.js") + >(); + return { + ...actual, + decodeBinaryPropertyList: ( + ...args: Parameters + ) => { + if (decoder.broken) + throw invariantViolation("Binary plist source plan is missing"); + return actual.decodeBinaryPropertyList(...args); + }, + }; + }, +); + +afterEach(() => { + decoder.broken = false; +}); + +it("rethrows an invariant violation instead of reporting plist output", () => { + const bytes = Buffer.from(buildBinary({ CFBundleIdentifier: "example" })); + expect(parsePlistBinary(bytes).ok).toBe(true); + + decoder.broken = true; + expect(() => parsePlistBinary(bytes)).toThrow(InternalInvariantError); +}); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 3534f1baa8..331cc633f5 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -24,6 +24,7 @@ import { } from "../../domain/apple/plistValue.js"; import { parseXmlPropertyList } from "../../domain/propertyListKeys.js"; import { malformedInput } from "../../domain/parseFailure.js"; +import { InternalInvariantError } from "../../domain/internalInvariant.js"; import { err, ok, type Result } from "../../domain/result.js"; import { @@ -129,14 +130,20 @@ const plistDecodeBudgetError = ( }, ); -const plistOutputError = (cause: unknown, reason?: string): PlistParseError => - cause instanceof PlistNestingLimitError +/** + * Project a decode failure. A broken internal assumption is a REA defect, not + * a plist fault, so it is rethrown rather than reported as plist output. + */ +const plistOutputError = (cause: unknown, reason?: string): PlistParseError => { + if (cause instanceof InternalInvariantError) throw cause; + return cause instanceof PlistNestingLimitError ? plistNestingError(cause) : new AnalysisOutputError( "inspect_plist", reason ?? (cause instanceof Error ? cause.message : String(cause)), { cause }, ); +}; /** * Read plutil JSON once, retaining exact unsafe integral literals. Optional XML @@ -411,17 +418,9 @@ export const parsePlistBinary = ( }); } catch (cause: unknown) { if (cause instanceof UnsupportedBinaryPlistUidError) return err(cause); - if (cause instanceof PlistNestingLimitError) - return err(plistNestingError(cause)); if (isPlistDecodeBudgetFailure(cause)) return err(plistDecodeBudgetError(cause, "binary")); - return err( - new AnalysisOutputError( - "inspect_plist", - cause instanceof Error ? cause.message : String(cause), - { cause }, - ), - ); + return err(plistOutputError(cause)); } }; diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index ec0bfd91c7..0ce601d26f 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -5,7 +5,7 @@ import { } from "../../artifacts/ArtifactFileOperation.js"; import { safeParseUtf8Json } from "../../domain/safeJson.js"; import { access, mkdir, stat, writeFile } from "node:fs/promises"; -import { constants } from "node:fs"; +import { constants, type Stats } from "node:fs"; import { randomUUID } from "node:crypto"; import { isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -67,6 +67,47 @@ export interface PwndbgCoreInspection { readonly finalization: AnalysisError | undefined; } +/** Why a configured debugger path cannot be used; a host error is its cause. */ +interface ConfiguredPathRefusal { + readonly reason: string; + readonly cause?: unknown; +} + +const hostPathRefusal = (cause: unknown): ConfiguredPathRefusal => ({ + reason: cause instanceof Error ? cause.message : String(cause), + cause, +}); + +/** + * Check one caller-supplied path, or return why it cannot be used. + * Cancellation between host checks propagates instead of becoming a refusal. + */ +const configuredPathRefusal = async ( + path: string, + kind: "file" | "directory", + mode: number, + checkCancelled: () => void, +): Promise => { + if (!isAbsolute(path)) + return { reason: "Expected an absolute caller-supplied path." }; + try { + await access(path, mode); + } catch (cause: unknown) { + return hostPathRefusal(cause); + } + checkCancelled(); + let information: Stats; + try { + information = await stat(path); + } catch (cause: unknown) { + return hostPathRefusal(cause); + } + checkCancelled(); + return (kind === "file" ? information.isFile() : information.isDirectory()) + ? undefined + : { reason: `Expected a ${kind}.` }; +}; + /** Add mapping candidates from an owned core-only debugger, using the existing snapshot. */ export const inspectPwndbgCore = async ({ environment, @@ -111,25 +152,18 @@ export const inspectPwndbgCore = async ({ ["REA_PWNDBG_GDBINIT", entry, "file"], ["REA_PWNDBG_VENV_PATH", venv, "directory"], ] as const) { - try { - checkCancelled(); - if (!isAbsolute(path)) - throw new Error("Expected an absolute caller-supplied path."); - const mode = key === "REA_PWNDBG_GDB" ? constants.X_OK : constants.R_OK; - await access(path, mode); - checkCancelled(); - const information = await stat(path); - checkCancelled(); - if (kind === "file" ? !information.isFile() : !information.isDirectory()) - throw new Error(`Expected a ${kind}.`); - checkCancelled(); - } catch (cause: unknown) { - if (cause instanceof AnalysisCancelledError) throw cause; + checkCancelled(); + const refusal = await configuredPathRefusal( + path, + kind, + key === "REA_PWNDBG_GDB" ? constants.X_OK : constants.R_OK, + checkCancelled, + ); + if (refusal !== undefined) throw unavailable( - `${key} is unavailable at ${path}: ${cause instanceof Error ? cause.message : String(cause)}. Configure the requested core-only context, or omit include_debugger_context for basic recorded evidence.`, - cause, + `${key} is unavailable at ${path}: ${refusal.reason}. Configure the requested core-only context, or omit include_debugger_context for basic recorded evidence.`, + refusal.cause, ); - } } checkCancelled(); const stage = join(rootPath, "debugger"); diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index e37e503f57..b82966e91e 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -1,6 +1,7 @@ import { classifyProviderFailure } from "../../providers/classifyProviderFailure.js"; import { HOST_PLATFORM } from "../../platform/HostPlatform.js"; import { snapshotEnvironment } from "../../process/snapshotEnvironment.js"; +import { invariantViolation } from "../../domain/internalInvariant.js"; import { safeParseUtf8Json } from "../../domain/safeJson.js"; import { AnalysisCleanupLease, @@ -733,7 +734,7 @@ export class PwntoolsDecoder< // A runtime is registered before either decoder stage may acquire a process. const pending = this.#pendingCleanup; if (pending === undefined) - throw new Error("Decoder command requires an owned runtime."); + throw invariantViolation("Decoder command requires an owned runtime."); let outcome: Result; try { outcome = ok( diff --git a/src/native/pwntools/PwntoolsResourceLimits.test.ts b/src/native/pwntools/PwntoolsResourceLimits.test.ts new file mode 100644 index 0000000000..65d7c73c7a --- /dev/null +++ b/src/native/pwntools/PwntoolsResourceLimits.test.ts @@ -0,0 +1,47 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +import type { ProviderProcessSnapshot } from "../../process/ProviderProcess.js"; +import { readPwntoolsFailureEvidence } from "./PwntoolsResourceLimits.js"; +import { PWNTOOLS_MEMORY_FAILURE_EXIT } from "./PwntoolsRelease.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; + +const memoryExit = new OwnedCommandFailure("process", "bridge exited", { + stdout: { text: "", bytes: 0, observedBytes: 0 }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exitCode: PWNTOOLS_MEMORY_FAILURE_EXIT, + signal: null, +} as ProviderProcessSnapshot); + +it.each([ + ["M", { resource: "memory", failure: null }], + [ + "F", + { + resource: null, + failure: + "Private bridge failure marker does not match the observed exit status.", + }, + ], +] as const)( + "classifies a %s marker beside the memory exit status", + async (written, marker) => { + const root = await createTestTempDirectory("rea-pwntools-marker-"); + await writeFile(join(root, "resource.failure"), written); + const resources = new ArtifactResourceScope(); + try { + const evidence = await readPwntoolsFailureEvidence( + memoryExit, + root, + resources, + ); + expect(evidence.marker).toEqual(marker); + } finally { + await resources.close(); + } + }, +); diff --git a/src/native/pwntools/PwntoolsResourceLimits.ts b/src/native/pwntools/PwntoolsResourceLimits.ts index fad02005a4..ebed3427b6 100644 --- a/src/native/pwntools/PwntoolsResourceLimits.ts +++ b/src/native/pwntools/PwntoolsResourceLimits.ts @@ -82,14 +82,13 @@ export const readPwntoolsFailureEvidence = async ( }); const expected = cause.snapshot.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT ? "M" : "F"; - if (!reported.bytes.equals(Buffer.from(expected, "ascii"))) - throw new Error( - "Private bridge failure marker does not match the observed exit status.", - ); - marker = { - resource: expected === "M" ? "memory" : "file-size", - failure: null, - }; + marker = reported.bytes.equals(Buffer.from(expected, "ascii")) + ? { resource: expected === "M" ? "memory" : "file-size", failure: null } + : { + resource: null, + failure: + "Private bridge failure marker does not match the observed exit status.", + }; } catch (markerFailure: unknown) { marker = { resource: null, diff --git a/tests/boundary/filesystem/analysisSnapshotInput.test.ts b/tests/boundary/filesystem/analysisSnapshotInput.test.ts index 8af4b209a1..29af35ae05 100644 --- a/tests/boundary/filesystem/analysisSnapshotInput.test.ts +++ b/tests/boundary/filesystem/analysisSnapshotInput.test.ts @@ -17,6 +17,7 @@ import { ANALYSIS_SNAPSHOT_TARGET, } from "../../../src/domain/analysisSnapshot.fixture.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { RECREATE_EVIDENCE_GUIDANCE } from "../../../src/domain/evidenceFormat.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js"; @@ -121,6 +122,28 @@ describe.each([ }); }); +it("refuses a snapshot holding an earlier-format bundle with the recreate guidance", async () => { + const root = await createTestTempDirectory("rea-snapshot-evidence-format-"); + const path = join(root, "snapshot.json"); + const { evidence_format: _current, ...unmarked } = createEvidenceBundle([]); + await writeFile( + path, + JSON.stringify({ + target: snapshotTarget(ANALYSIS_SNAPSHOT_TARGET), + binding: snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE), + entries: [], + workflow_entries: [], + evidence_bundle: unmarked, + }), + ); + const loaded = await readAnalysisSnapshot(path); + if (loaded.ok) throw new Error("Expected the earlier format to be refused"); + expect(projectAnalysisError(loaded.error)).toMatchObject({ + code: "evidence_integrity_mismatch", + details: { reason: expect.stringContaining(RECREATE_EVIDENCE_GUIDANCE) }, + }); +}); + it("retains serialization schema diagnostics without creating a snapshot file", async () => { const root = await createTestTempDirectory("rea-snapshot-write-diagnostic-"); const path = join(root, "snapshot.json");