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.
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 19:35:22 +08:00
1 parent 530faf43bd
commit 63b0426e40
58 files changed
+875 -186

No files matched your search

@@ -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,
+8 -1
View File
@@ -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,
[],
);
@@ -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<typeof import("../process/OwnedCommand.js")>()),
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();
}
});
+14 -1
View File
@@ -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,
@@ -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";
};
+7 -1
View File
@@ -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,
+2 -1
View File
@@ -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;
@@ -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" } },
});
});
+4 -1
View File
@@ -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
@@ -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<typeof import("./apple/BinaryPlistMaterializer.js")>();
return {
...actual,
decodeBinaryPlistText: (
...args: Parameters<typeof actual.decodeBinaryPlistText>
) => {
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,
);
});
+8 -2
View File
@@ -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 };
}
};
+3 -1
View File
@@ -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");
};
+3 -1
View File
@@ -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<string> {
): Promise<Uint8Array> {
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.
@@ -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) => {
@@ -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,
@@ -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.
+7 -1
View File
@@ -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",
@@ -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<typeof import("./BinaryPlistMaterializer.js")>();
return {
...actual,
decodeBinaryPropertyList: (
...args: Parameters<typeof actual.decodeBinaryPropertyList>
) => {
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, '<?xml version="1.0"?><plist><dict/></plist>');
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();
}
},
);
+5 -4
View File
@@ -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) &&
+10
View File
@@ -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"],
+23 -16
View File
@@ -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 },
);
};
+2 -1
View File
@@ -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<void>;
interface Interval {
@@ -89,7 +90,7 @@ export class FunctionByteCoverage {
evidenceId: string,
): Promise<void> {
if (this.#prepared)
throw new TypeError(
throw invariantViolation(
"Cannot append byte observations after fingerprinting",
);
if (read.returned_bytes === 0) return;
+2 -1
View File
@@ -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[] = [];
+4 -3
View File
@@ -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",
+12
View File
@@ -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,
);
},
);
+2 -1
View File
@@ -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;
+12 -8
View File
@@ -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", () => {
+5 -4
View File
@@ -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;
+20
View File
@@ -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);
},
);
+4 -1
View File
@@ -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;
};
+2 -1
View File
@@ -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,
+6 -2
View File
@@ -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`,
),
});
});
+49 -17
View File
@@ -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<string | undefined> => {
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<Record<string, string | undefined>>,
@@ -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,
+3 -2
View File
@@ -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<void, Il2cppMetadataForLinking> {
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 };
+2 -1
View File
@@ -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;
+4 -1
View File
@@ -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
? {
+4 -1
View File
@@ -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<void> => {
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();
};
+4 -1
View File
@@ -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 {
+4 -1
View File
@@ -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<Uint8Array> => {
if (
!Number.isSafeInteger(offset) ||
@@ -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, '<?xml version="1.0"?><plist><dict/></plist>');
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);
});
+16 -4
View File
@@ -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,
+6 -4
View File
@@ -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;
};
+4 -4
View File
@@ -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;
});
+21 -10
View File
@@ -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;
}
}
+4 -1
View File
@@ -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",
);
},
@@ -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,
);
});
+4 -1
View File
@@ -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,
+9 -2
View File
@@ -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");
+15 -3
View File
@@ -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<typeof demangleSwiftSchema>["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<DemangledSymbol>((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,
+8 -1
View File
@@ -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,
+13 -3
View File
@@ -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<LipoOutput, TypeError | z.ZodError | LipoFieldRetentionError> => {
): 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)
)
@@ -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<typeof actual.decodeBinaryPropertyList>
) => {
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);
});
+10 -11
View File
@@ -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));
}
};
+52 -18
View File
@@ -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<ConfiguredPathRefusal | undefined> => {
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");
+2 -1
View File
@@ -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<ProviderProcessSnapshot, unknown>;
try {
outcome = ok(
@@ -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();
}
},
);
@@ -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,
@@ -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");