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");