fix: report provider admission failures as typed codes (#8066)

* fix: report provider admission failures as typed codes

* fix(evm): keep limiter path on admission diagnostics

* fix: assert WABT and CBOR admission codes

* fix: keep admission details on returned failures

* fix(evm): detect repeated CBOR keys in the decoder

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-10-30 06:48:01 +08:00
1 parent 3b647132c6
commit 317bf3fe23
63 files changed
+1021 -432

No files matched your search

+18
View File
@@ -44,6 +44,7 @@ import {
} from "../artifacts/AnalysisCleanupLease.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
import { StagedOutputError } from "../filesystem/StagedOutputError.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Request control and its owning operation for an interruptible atomic write. */
@@ -399,6 +400,7 @@ const writeTextPartsOwned = async (
path: requestedPath,
cleanup,
published: { ...cause.outcome.value, path: requestedPath },
...stagedOutputDiagnostics(cause.cleanupCause),
}),
cause.lease,
);
@@ -420,6 +422,12 @@ const writeTextPartsOwned = async (
...(failure.systemCode === undefined
? {}
: { systemCode: failure.systemCode }),
...stagedOutputDiagnostics(
!cause.outcome.ok &&
cause.outcome.error instanceof StagedOutputError
? cause.outcome.error
: cause.cleanupCause,
),
}),
cause.lease,
);
@@ -445,6 +453,7 @@ const writeTextPartsOwned = async (
...(failure.systemCode === undefined
? {}
: { systemCode: failure.systemCode }),
...stagedOutputDiagnostics(cause),
}),
);
}
@@ -612,6 +621,15 @@ class StagedOutputCleanupFailure extends Error {
}
}
const stagedOutputDiagnostics = (
cause: unknown,
): {
readonly diagnostics?: Readonly<Record<string, string | number>>;
} => {
if (!(cause instanceof StagedOutputError)) return {};
return { diagnostics: { code: cause.code, ...cause.detail } };
};
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message || cause.name : String(cause);
+11
View File
@@ -47,6 +47,17 @@ export const solidityMetadataSchema = z
})
.nullable(),
diagnostic: z.string().max(512).nullable(),
admission: z
.strictObject({
code: z.string().min(1).max(64),
detail: z
.record(
z.string().min(1).max(64),
z.union([z.string().max(512), z.number()]),
)
.optional(),
})
.optional(),
})
.superRefine((value, context) => {
const unavailable = value.status === "unavailable";
+31
View File
@@ -0,0 +1,31 @@
const EVM_ADMISSION_CODES = [
"prlimit_not_absolute",
"prlimit_not_file",
"cleanup_incomplete",
"cleanup_unconfirmed",
"marker_mismatch",
"request_path_missing",
"tag_or_float",
"text_bytes_unavailable",
"collection_exceeds_bound",
"nesting_exceeds_profile",
"value_outside_profile",
"repeated_map_key",
"root_not_map",
] as const;
export type EvmAdmissionCode = (typeof EVM_ADMISSION_CODES)[number];
/** EVM limiter, worker, or metadata admission failure. `code` is the stable identity. */
export class EvmAdmissionError extends Error {
readonly _tag = "EvmAdmissionError";
constructor(
readonly code: EvmAdmissionCode,
readonly detail?: Readonly<Record<string, string | number>>,
options?: ErrorOptions,
) {
super(code, options);
this.name = "EvmAdmissionError";
}
}
+26 -13
View File
@@ -1,4 +1,6 @@
import { EvmAdmissionError } from "./EvmAdmissionError.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { safeParseUtf8Json } from "../domain/safeJson.js";
import { createHash, randomUUID } from "node:crypto";
import { access, readFile, stat, writeFile } from "node:fs/promises";
@@ -110,7 +112,7 @@ export interface EvmLimiterProbeOptions {
type EvmLimiterSelection = {
readonly path: string;
readonly diagnostics: Readonly<Record<string, string>>;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
};
type EvmLimiterUnavailable = EvmLimiterSelection & {
readonly reason: string;
@@ -142,20 +144,29 @@ const selectEvmLimiter = async (
const diagnostics = { limiter_path: path, platform, arch };
try {
if (!isAbsolute(path))
throw new Error(
"REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.",
);
throw new EvmAdmissionError("prlimit_not_absolute", {
setting: "REA_EVM_PRLIMIT_COMMAND",
});
const fs = probes?.fs ?? { access, stat };
await fs.access(path, constants.X_OK);
throwIfAborted();
if (!(await fs.stat(path)).isFile())
throw new Error(
"Configured prlimit must be a regular executable file, not a directory.",
);
throw new EvmAdmissionError("prlimit_not_file");
throwIfAborted();
return ok({ path, diagnostics });
} catch (cause: unknown) {
throwIfAborted();
if (cause instanceof EvmAdmissionError)
return err({
path,
diagnostics: {
...diagnostics,
code: cause.code,
...cause.detail,
},
reason: cause.code,
cause,
});
const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`;
return err({ path, diagnostics, reason, cause });
}
@@ -452,10 +463,13 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
stopped.status === "incomplete" ||
stopped.status === "not-owned"
)
throw new Error(
throw new EvmAdmissionError(
stopped.status === "incomplete"
? stopped.reason
: "Owned EVM worker cleanup did not confirm ownership.",
? "cleanup_incomplete"
: "cleanup_unconfirmed",
stopped.status === "incomplete"
? { reason: stopped.reason }
: undefined,
{ cause: stopped },
);
},
@@ -511,6 +525,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
selectedLimiter.error.reason,
{
cause: selectedLimiter.error.cause,
diagnostics: selectedLimiter.error.diagnostics,
userMessage: selectedLimiter.error.reason,
},
);
@@ -813,9 +828,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
marker = completed;
}
if (!marker.bytes.equals(Buffer.from("F")))
throw new Error(
"Private worker failure marker does not match the observed exit status.",
);
throw new EvmAdmissionError("marker_mismatch");
fileFailure = { verified: true, failure: null };
} catch (markerFailure: unknown) {
fileFailure = {
+2 -1
View File
@@ -1,3 +1,4 @@
import { EvmAdmissionError } from "./EvmAdmissionError.js";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { z } from "zod";
@@ -23,7 +24,7 @@ class UnsupportedContainer extends Error {}
const main = async (): Promise<void> => {
const requestPath = process.argv[2];
if (requestPath === undefined)
throw new Error("Owned worker request path missing.");
throw new EvmAdmissionError("request_path_missing");
const request = requestSchema.parse(
JSON.parse(await readFile(requestPath, "utf8")),
);
+18 -1
View File
@@ -163,11 +163,28 @@ it("rejects repeated deeply nested container keys promptly", () => {
inspectSolidityMetadata(bytecode("a2" + key + "01" + key + "02")),
).toMatchObject({
status: "not-decoded",
diagnostic: expect.stringContaining("found repeat map key"),
diagnostic: "repeated_map_key",
admission: {
code: "repeated_map_key",
detail: { key_length: expect.any(Number) },
},
});
expect(performance.now() - started).toBeLessThan(1000);
});
it("records the profile depth when CBOR nesting is refused", () => {
expect(
inspectSolidityMetadata(bytecode("a16178" + "81".repeat(65) + "00")),
).toMatchObject({
status: "not-decoded",
diagnostic: "nesting_exceeds_profile",
admission: {
code: "nesting_exceeds_profile",
detail: { max_depth: 64 },
},
});
});
it.each(["", "60006000f3", "000000", "00010001"])(
"does not claim absent Solidity metadata for an unavailable trailer: %s",
(hex) =>
+67 -23
View File
@@ -1,3 +1,4 @@
import { EvmAdmissionError } from "./EvmAdmissionError.js";
import { decode, Tokenizer, Type, type Token } from "cborg";
import type {
SolidityMetadata,
@@ -14,39 +15,52 @@ const decodeOptions = {
allowUndefined: false,
allowInfinity: false,
allowNaN: false,
rejectDuplicateMapKeys: true,
rejectDuplicateMapKeys: false,
retainStringBytes: true,
} as const;
/** Bound recursive decoder entry before collection interpretation/allocation. */
class BoundedMetadataTokenizer extends Tokenizer {
readonly remaining: number[] = [];
readonly #mapKeys: Array<Set<string> | undefined> = [];
constructor(bytes: Uint8Array) {
super(bytes, decodeOptions);
}
override next(): Token {
while (this.remaining.at(-1) === 0) this.remaining.pop();
while (this.remaining.at(-1) === 0) {
this.remaining.pop();
this.#mapKeys.pop();
}
const token = super.next();
if (
Type.equals(token.type, Type.tag) ||
Type.equals(token.type, Type.float)
)
throw new Error(
"CBOR tags and floating-point values are outside this metadata profile.",
);
throw new EvmAdmissionError("tag_or_float");
if (Type.equals(token.type, Type.string)) {
if (token.byteValue !== undefined)
token.value = utf8.decode(token.byteValue);
// cborg answers the shortest-form empty text (0x60) with a shared token that has no
// bytes; it can only mean "", which is already its value.
else if (token.value !== "" || token.encodedLength !== 1)
throw new Error(
"CBOR text bytes were unavailable for strict UTF-8 validation.",
);
throw new EvmAdmissionError("text_bytes_unavailable");
}
const parent = this.remaining.length - 1;
const mapKeys = parent >= 0 ? this.#mapKeys[parent] : undefined;
const onKey =
mapKeys !== undefined && parent >= 0 && this.remaining[parent]! % 2 === 0;
if (parent >= 0) this.remaining[parent]!--;
if (
onKey &&
mapKeys !== undefined &&
!Type.equals(token.type, Type.map) &&
!Type.equals(token.type, Type.array)
) {
const identity = keyIdentity(token.value);
if (mapKeys.has(identity)) throw repeatedMapKey(identity);
mapKeys.add(identity);
}
if (
Type.equals(token.type, Type.map) ||
Type.equals(token.type, Type.array)
@@ -61,11 +75,14 @@ class BoundedMetadataTokenizer extends Tokenizer {
children < 0 ||
children > this.data.length
)
throw new Error(
"Declared CBOR collection exceeds the encoded item-byte bound.",
);
throw new EvmAdmissionError("collection_exceeds_bound");
if (this.remaining.length >= MAX_DEPTH)
throw new Error("CBOR nesting exceeds the 64-level metadata profile.");
throw new EvmAdmissionError("nesting_exceeds_profile", {
max_depth: MAX_DEPTH,
});
this.#mapKeys.push(
Type.equals(token.type, Type.map) ? new Set() : undefined,
);
this.remaining.push(children);
}
return token;
@@ -85,9 +102,7 @@ const scalar = (value: unknown): SolidityMetadataScalar => {
if (value instanceof Map) return { kind: "uninterpreted", container: "map" };
if (Array.isArray(value))
return { kind: "uninterpreted", container: "array" };
throw new Error(
"CBOR decoder returned a value outside the admitted profile.",
);
throw new EvmAdmissionError("value_outside_profile");
};
/** Value identity for duplicate-key checks; map entry order is not significant. */
@@ -101,14 +116,21 @@ const keyIdentity = (value: unknown): string => {
return JSON.stringify(scalar(value));
};
const repeatedMapKey = (identity: string): EvmAdmissionError =>
new EvmAdmissionError(
"repeated_map_key",
identity.length <= 128
? { key: identity }
: { key_length: identity.length },
);
/** Check every map boundary, even when nested values remain uninterpreted. */
const validateMapKeys = (value: unknown): void => {
if (value instanceof Map) {
const seen = new Set<string>();
for (const [key, item] of value) {
const identity = keyIdentity(key);
if (seen.has(identity))
throw new Error(`CBOR decode error: found repeat map key ${identity}`);
if (seen.has(identity)) throw repeatedMapKey(identity);
seen.add(identity);
validateMapKeys(key);
validateMapKeys(item);
@@ -118,6 +140,32 @@ const validateMapKeys = (value: unknown): void => {
}
};
/** Keep the typed code and its detail; map only the decoder's duplicate-key throw. */
const solidityFailure = (
cause: unknown,
): {
readonly diagnostic: string;
readonly admission?: {
readonly code: string;
readonly detail?: Readonly<Record<string, string | number>>;
};
} => {
if (cause instanceof EvmAdmissionError)
return {
diagnostic: cause.code,
admission: {
code: cause.code,
...(cause.detail === undefined ? {} : { detail: cause.detail }),
},
};
return {
diagnostic: (cause instanceof Error ? cause.message : String(cause)).slice(
0,
512,
),
};
};
/** Parse only a plausible terminal root-map candidate; never establish absence. */
export const inspectSolidityMetadata = (
bytes: Uint8Array,
@@ -148,8 +196,7 @@ export const inspectSolidityMetadata = (
...decodeOptions,
tokenizer: new BoundedMetadataTokenizer(selected),
});
if (!(raw instanceof Map))
throw new Error("Terminal CBOR candidate did not decode to a root map.");
if (!(raw instanceof Map)) throw new EvmAdmissionError("root_not_map");
validateMapKeys(raw);
// cborg compares Map keys by identity, so repeated byte-string or container
// keys (each its own object) pass its duplicate check; compare by value at
@@ -185,10 +232,7 @@ export const inspectSolidityMetadata = (
status: "not-decoded",
entries: [],
compiler_version: null,
diagnostic: (cause instanceof Error
? cause.message
: String(cause)
).slice(0, 512),
...solidityFailure(cause),
};
}
};
+11 -3
View File
@@ -1,5 +1,15 @@
import type { FileHandle } from "node:fs/promises";
/** Close admission for one owned file handle. `code` is the stable identity. */
export class OwnedFileHandleError extends Error {
readonly _tag = "OwnedFileHandleError";
constructor(readonly code: "handle_invalidated") {
super(code);
this.name = "OwnedFileHandleError";
}
}
type CloseState =
| { readonly kind: "open" }
| { readonly kind: "closing"; readonly attempt: Promise<void> }
@@ -20,9 +30,7 @@ export class OwnedFileHandle<
if (this.#state.kind === "closing") return this.#state.attempt;
if (this.#state.kind === "failed") return Promise.reject(this.#state.cause);
if (this.handle.fd < 0) {
const cause = new Error(
"File handle was invalidated before this owner confirmed its close",
);
const cause = new OwnedFileHandleError("handle_invalidated");
this.#state = { kind: "failed", cause };
return Promise.reject(cause);
}
+21 -16
View File
@@ -1,3 +1,4 @@
import { StagedOutputError } from "./StagedOutputError.js";
import { lstat, readdir, rmdir, unlink } from "node:fs/promises";
import type { Stats } from "node:fs";
@@ -48,7 +49,7 @@ export class OwnedStagedOutput {
setFile(file: OwnedFileHandle): void {
if (this.#file !== undefined)
throw new Error("Staged output already owns a file handle");
throw new StagedOutputError("handle_already_owned");
this.#file = file;
}
@@ -58,7 +59,7 @@ export class OwnedStagedOutput {
if (this.#file !== undefined && !this.#fileClosed)
await this.#observeFileIdentity();
if (this.#fileIdentity === undefined)
throw new Error("Cannot verify staged file identity", {
throw new StagedOutputError("identity_unverified", undefined, {
cause: this.#identityFailure,
});
const stagedFile = await lstat(content);
@@ -67,7 +68,7 @@ export class OwnedStagedOutput {
stagedFile.isSymbolicLink() ||
!sameIdentity(stagedFile, this.#fileIdentity)
)
throw new Error(`Staged file identity changed: ${content}`);
throw new StagedOutputError("identity_changed", { path: content });
}
/** Observe the descriptor's identity after its last write, then close it. */
@@ -134,9 +135,11 @@ export class OwnedStagedOutput {
) {
stagedFile = undefined;
} else if (errorCode(cause) === "ENOENT") {
throw new Error(`Owned staged file disappeared: ${content}`, {
cause,
});
throw new StagedOutputError(
"file_disappeared",
{ path: content },
{ cause },
);
} else {
throw cause;
}
@@ -148,7 +151,7 @@ export class OwnedStagedOutput {
this.#fileIdentity === undefined ||
!sameIdentity(stagedFile, this.#fileIdentity)
)
throw new Error(`Staged file identity changed: ${content}`);
throw new StagedOutputError("identity_changed", { path: content });
await unlink(content);
this.#fileRemovedByOwner = true;
}
@@ -156,20 +159,20 @@ export class OwnedStagedOutput {
const remaining = await readdir(this.directory);
if (remaining.length > 0)
throw new Error(
`Unexpected staging entries remain in ${this.directory}: ${remaining.join(", ")}`,
);
throw new StagedOutputError("unexpected_entries", {
count: remaining.length,
entries: remaining.join(","),
});
await this.#verifiedDirectory();
await rmdir(this.directory);
}
async #verifiedDirectory(): Promise<void> {
if (this.directoryIdentity === undefined)
throw new Error(
`Cannot verify staging directory ownership: ${this.directory}`,
{
cause: this.#identityFailure,
},
throw new StagedOutputError(
"directory_unverified",
{ directory: this.directory },
{ cause: this.#identityFailure },
);
const directory = await lstat(this.directory);
if (
@@ -177,7 +180,9 @@ export class OwnedStagedOutput {
directory.isSymbolicLink() ||
!sameIdentity(directory, this.directoryIdentity)
)
throw new Error(`Staging directory identity changed: ${this.directory}`);
throw new StagedOutputError("directory_identity_changed", {
directory: this.directory,
});
}
}
+25
View File
@@ -0,0 +1,25 @@
const STAGED_OUTPUT_CODES = [
"handle_already_owned",
"identity_unverified",
"identity_changed",
"file_disappeared",
"unexpected_entries",
"directory_unverified",
"directory_identity_changed",
] as const;
export type StagedOutputCode = (typeof STAGED_OUTPUT_CODES)[number];
/** Owned staged-output admission failure. `code` is the stable identity. */
export class StagedOutputError extends Error {
readonly _tag = "StagedOutputError";
constructor(
readonly code: StagedOutputCode,
readonly detail?: Readonly<Record<string, string | number>>,
options?: ErrorOptions,
) {
super(code, options);
this.name = "StagedOutputError";
}
}
+66
View File
@@ -0,0 +1,66 @@
const GHIDRA_ADMISSION_CODES = [
"existing_project_windows",
"existing_project_program_name",
"existing_project_psp",
"existing_project_dos",
"existing_project_raw_binary",
"existing_project_language_override",
"existing_project_mips_soft_float",
"existing_project_analysis",
"gamecube_dol_combined",
"psp_combined",
"psp_raw_binary",
"mips_soft_float_scope",
"language_override_dos",
"language_override_raw_binary",
"raw_binary_dos",
"invalid_environment_entry",
"invalid_jvm_argument",
"unterminated_quote",
"platform_override",
"argument_files_exceed_bound",
"nested_argument_file",
"argument_file_unreadable",
"argument_file_path_invalid",
"argument_files_require_scope",
"prepared_options_exceed_bound",
"output_unobserved",
"handshake_identity",
"response_shape",
"response_json",
"seed_path_differs",
"seed_changed",
"canonical_path",
"nesting_exceeds_limit",
"entry_kind",
"component_missing",
"loader_jar_missing",
"bytes_exceed_limit",
"properties_exceed_limit",
"component_changed",
"component_truncated",
"properties_invalid",
"metadata_mismatch",
"host_unsupported",
"inventory_exceeds_limit",
"not_jar",
"unregistered",
"path_differs",
"digest_differs",
] as const;
export type GhidraAdmissionCode = (typeof GHIDRA_ADMISSION_CODES)[number];
/** Ghidra launch, extension, or profile admission failure. `code` is the stable identity. */
export class GhidraAdmissionError extends Error {
readonly _tag = "GhidraAdmissionError";
constructor(
readonly code: GhidraAdmissionCode,
readonly detail?: Readonly<Record<string, string | number>>,
options?: ErrorOptions,
) {
super(code, options);
this.name = "GhidraAdmissionError";
}
}
+3 -3
View File
@@ -135,9 +135,9 @@ describe("Ghidra seed commitment", () => {
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
reason: expect.stringMatching(
/changed after the analysis profile was committed/u,
),
code: "seed_changed",
path: resolved.value.path,
reason: "seed_changed",
},
});
});
+5 -6
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { createHash } from "node:crypto";
import { mkdir, realpath, stat } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
@@ -228,15 +229,13 @@ export const snapshotGhidraAnalysisSeeds = async (
if (
(await resolveSeedPath(commitment.configured_path)) !== commitment.path
)
throw new Error(
"Ghidra seed file resolved path differs from explicit configuration",
);
throw new GhidraAdmissionError("seed_path_differs");
const entries = await readSeeds(commitment.path, resources, signal);
const normalized = normalizedSeeds(entries);
if (sha256(normalized) !== commitment.sha256)
throw new Error(
`Ghidra seed file changed after the analysis profile was committed: ${commitment.path}`,
);
throw new GhidraAdmissionError("seed_changed", {
path: commitment.path,
});
const directory = join(runtimeRoot, "seeds");
await mkdir(directory, { mode: 0o700 });
const path = join(directory, "seeds.tsv");
+16 -6
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { open } from "node:fs/promises";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
@@ -100,7 +101,18 @@ export const ghidraFileFailure = (
return primary;
});
const { cause: primary, origin } = artifactFilePrimaryFacts(cause);
const reason = primary instanceof Error ? primary.message : String(primary);
const admission =
primary instanceof GhidraAdmissionError ? primary : undefined;
const reason =
admission?.code ??
(primary instanceof Error ? primary.message : String(primary));
const systemCode =
admission === undefined &&
primary instanceof Error &&
"code" in primary &&
typeof primary.code === "string"
? primary.code
: undefined;
const cleanup =
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
if (origin === "owned-or-classified" && primary instanceof AnalysisError) {
@@ -134,12 +146,10 @@ export const ghidraFileFailure = (
cause,
cleanup,
diagnostics: {
...admission?.detail,
...diagnostics,
...(primary instanceof Error &&
"code" in primary &&
typeof primary.code === "string"
? { system_code: primary.code }
: {}),
...(admission === undefined ? {} : { code: admission.code }),
...(systemCode === undefined ? {} : { system_code: systemCode }),
...(primary instanceof Error &&
"syscall" in primary &&
typeof primary.syscall === "string"
+1 -1
View File
@@ -917,7 +917,7 @@ describe("Ghidra malformed property admission", () => {
name: "version",
status: "failed",
code: "version_unresolved",
detail: expect.stringContaining("Malformed Unicode escape"),
detail: expect.stringContaining("malformed_unicode_escape"),
}),
expect.objectContaining({
name: "java",
+5 -2
View File
@@ -1,4 +1,7 @@
import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js";
import {
parseGhidraJavaProperties,
type GhidraJavaPropertiesCode,
} from "./GhidraJavaProperties.js";
import { ghidraConfigurationVersion } from "./GhidraReleasePolicy.js";
/** One Java major bound declared by `application.properties`. */
@@ -24,7 +27,7 @@ export interface GhidraApplicationProperties {
readonly javaMinimum: GhidraJavaBound;
readonly javaMaximum: GhidraJavaBound;
/** A malformed Java properties escape invalidates the entire file. */
readonly parseError: string | null;
readonly parseError: GhidraJavaPropertiesCode | null;
}
/** Read the version and Java bounds Ghidra publishes for one installation. */
+13 -9
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import { dirname } from "node:path";
@@ -130,9 +131,11 @@ export const ghidraJavaLaunch = async (options: {
const key =
options.platform === "win32" ? sourceKey.toUpperCase() : sourceKey;
if (separator < 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key))
throw new Error(
`Invalid Ghidra LaunchSupport environment entry: ${line}`,
);
throw new GhidraAdmissionError("invalid_environment_entry", {
check: separator < 1 ? "separator" : "key",
line: line.length <= 1024 ? line : line.slice(0, 1024),
...(line.length <= 1024 ? {} : { line_truncated: 1 }),
});
if ((environment[key] ?? "") === "")
environment[key] = line.slice(separator + 1);
}
@@ -172,12 +175,13 @@ export const ghidraJavaLaunch = async (options: {
const vmArguments = configuredArguments.stdout
.split(/\r?\n/u)
.filter((line) => line !== "");
if (
vmArguments.some(
(argument) => !argument.startsWith("-") || argument.includes("\0"),
)
)
throw new Error("Invalid Ghidra LaunchSupport JVM argument");
const invalidArgument = vmArguments.find(
(argument) => !argument.startsWith("-") || argument.includes("\0"),
);
if (invalidArgument !== undefined)
throw new GhidraAdmissionError("invalid_jvm_argument", {
check: invalidArgument.includes("\0") ? "nul" : "prefix",
});
rejectJvmPlatformOverrides(vmArguments);
return {
command,
+20 -27
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
@@ -32,19 +33,14 @@ export const jvmEnvironmentArguments = (value: string): readonly string[] => {
active = true;
}
}
if (quote !== undefined)
throw new Error(
"Invalid quoted Ghidra LaunchSupport JVM environment option",
);
if (quote !== undefined) throw new GhidraAdmissionError("unterminated_quote");
if (active) args.push(token);
return args;
};
export const rejectJvmPlatformOverrides = (args: readonly string[]): void => {
if (args.some((argument) => /^-Dos\.(?:arch|name)(?:=|$)/u.test(argument)))
throw new Error(
"Ghidra LaunchSupport may not override os.arch or os.name; the native decompiler was validated for the inspected JVM platform.",
);
throw new GhidraAdmissionError("platform_override");
};
/** Java launcher argfile grammar: quotes, escapes inside quotes, comments and line continuation are one parse. */
@@ -148,18 +144,16 @@ const readArgumentFile = (
new RegularFileOperationCancelled(reason),
});
if (data === undefined)
throw new Error(
`JDK_JAVA_OPTIONS argument files exceed the ${String(OPTIONS_BYTES)}-byte cumulative admission bound.`,
);
throw new GhidraAdmissionError("argument_files_exceed_bound", {
budget_bytes: OPTIONS_BYTES,
});
const text = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(data);
const tokens = argumentFileTokens(text);
if (tokens.some(fileReference))
throw new Error(
"Ghidra LaunchSupport JDK_JAVA_OPTIONS argument files may not nest @ references.",
);
throw new GhidraAdmissionError("nested_argument_file");
rejectJvmPlatformOverrides(tokens);
return {
text,
@@ -170,8 +164,9 @@ const readArgumentFile = (
"follow",
).catch((cause: unknown) => {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
throw new Error(
`Ghidra LaunchSupport JDK_JAVA_OPTIONS argument file is unreadable: ${path}`,
throw new GhidraAdmissionError(
"argument_file_unreadable",
{ path },
{ cause },
);
throw cause;
@@ -209,9 +204,7 @@ export const prepareJdkJavaOptions = async (
const resources = options.resources;
const directory = options.directory;
if (resources === undefined || directory === undefined)
throw new Error(
"JDK_JAVA_OPTIONS argument files require a caller-owned resource scope and runtime root.",
);
throw new GhidraAdmissionError("argument_files_require_scope");
const snapshots = new Map<
string,
{
@@ -233,9 +226,9 @@ export const prepareJdkJavaOptions = async (
let snapshot = snapshots.get(path);
if (snapshot === undefined) {
if (path === "" || path.includes("\0"))
throw new Error(
"Ghidra LaunchSupport JDK_JAVA_OPTIONS argument file is unreadable.",
);
throw new GhidraAdmissionError("argument_file_path_invalid", {
check: path === "" ? "empty" : "nul",
});
const content = await readArgumentFile(
path,
resources,
@@ -257,9 +250,9 @@ export const prepareJdkJavaOptions = async (
} else {
bytes += snapshot.bytes;
if (bytes > OPTIONS_BYTES)
throw new Error(
`JDK_JAVA_OPTIONS argument files exceed the ${String(OPTIONS_BYTES)}-byte cumulative admission bound.`,
);
throw new GhidraAdmissionError("argument_files_exceed_bound", {
budget_bytes: OPTIONS_BYTES,
});
}
replacement = `@${snapshot.path}`;
if (snapshot.disablesExpansion) expansionEnabled = false;
@@ -268,9 +261,9 @@ export const prepareJdkJavaOptions = async (
encodedBytes +=
Buffer.byteLength(encoded) + (prepared.length === 0 ? 0 : 1);
if (encodedBytes > OPTIONS_BYTES)
throw new Error(
`Prepared JDK_JAVA_OPTIONS exceeds the ${String(OPTIONS_BYTES)}-byte environment admission bound.`,
);
throw new GhidraAdmissionError("prepared_options_exceed_bound", {
budget_bytes: OPTIONS_BYTES,
});
prepared.push(encoded);
}
assertActive();
+2 -1
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { randomUUID } from "node:crypto";
@@ -184,7 +185,7 @@ const observeGhidraJava = async (
},
);
if (interpreted === undefined)
throw new Error("Java command completed without an output observation");
throw new GhidraAdmissionError("output_unobserved");
return interpreted;
} catch (cause: unknown) {
const commandFailure =
+15 -5
View File
@@ -1,9 +1,18 @@
import type { Result } from "../domain/result.js";
const GHIDRA_JAVA_PROPERTIES_CODES = [
"trailing_escape",
"malformed_unicode_escape",
] as const;
/** Stable identity for a Java properties escape that invalidates the file. */
export type GhidraJavaPropertiesCode =
(typeof GHIDRA_JAVA_PROPERTIES_CODES)[number];
/** Parse Java Properties.load(InputStream) syntax from its ISO-8859-1 text. */
export const parseGhidraJavaProperties = (
content: string,
): Result<ReadonlyMap<string, string>, string> => {
): Result<ReadonlyMap<string, string>, GhidraJavaPropertiesCode> => {
const values = new Map<string, string>();
for (const line of logicalPropertyLines(content)) {
let start = 0;
@@ -88,7 +97,9 @@ function* logicalPropertyLines(content: string): Generator<string> {
if (continuing) yield logical;
}
const decodePropertyEscapes = (value: string): Result<string, string> => {
const decodePropertyEscapes = (
value: string,
): Result<string, GhidraJavaPropertiesCode> => {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
@@ -97,8 +108,7 @@ const decodePropertyEscapes = (value: string): Result<string, string> => {
continue;
}
const escaped = value[++index];
if (escaped === undefined)
return { ok: false, error: "Trailing escape in Java properties value" };
if (escaped === undefined) return { ok: false, error: "trailing_escape" };
if (escaped === "t") result += "\t";
else if (escaped === "n") result += "\n";
else if (escaped === "r") result += "\r";
@@ -108,7 +118,7 @@ const decodePropertyEscapes = (value: string): Result<string, string> => {
if (digits.length !== 4 || !/^[0-9a-f]{4}$/iu.test(digits))
return {
ok: false,
error: "Malformed Unicode escape in Java properties file",
error: "malformed_unicode_escape",
};
result += String.fromCharCode(Number.parseInt(digits, 16));
index += 4;
+16 -45
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { copyExistingGhidraProject } from "./GhidraProjectSnapshot.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
@@ -810,41 +811,25 @@ const validateExistingProjectOptions = (
// The pre-imported project carries its own analysis; every import-time
// selection is therefore rejected instead of silently ignored.
if ((options.platform ?? process.platform) === "win32")
throw new Error(
"An existing Ghidra project cannot be opened on the Windows launch path.",
);
throw new GhidraAdmissionError("existing_project_windows");
if (existing.programName.trim() === "")
throw new Error(
"An existing Ghidra project requires a program name for -process.",
);
throw new GhidraAdmissionError("existing_project_program_name");
if (options.psp === true)
throw new Error(
"An existing Ghidra project cannot be combined with a PSP ELF import.",
);
throw new GhidraAdmissionError("existing_project_psp");
if (options.dosMz === true || options.dosCom === true)
throw new Error(
"An existing Ghidra project cannot be combined with a DOS import.",
);
throw new GhidraAdmissionError("existing_project_dos");
if (rawBinary !== undefined)
throw new Error(
"An existing Ghidra project cannot be combined with a raw-binary import.",
);
throw new GhidraAdmissionError("existing_project_raw_binary");
if (override !== undefined)
throw new Error(
"A Ghidra language override cannot be combined with an existing project.",
);
throw new GhidraAdmissionError("existing_project_language_override");
if (options.mipsSoftFloat === true)
throw new Error(
"MIPS soft-float storage applies only to a fresh import.",
);
throw new GhidraAdmissionError("existing_project_mips_soft_float");
if (
options.seedPath !== undefined ||
options.analysisSeeds !== undefined ||
(options.disabledAnalyzers?.length ?? 0) > 0
)
throw new Error(
"An existing project opens without analysis; analysis seeds and analyzer overrides do not apply.",
);
throw new GhidraAdmissionError("existing_project_analysis");
}
};
@@ -871,22 +856,16 @@ export const ghidraHeadlessArguments = (
rawBinary !== undefined ||
override !== undefined)
)
throw new Error(
"A GameCube DOL import cannot be combined with another loader, language override, or analysis lane; its language is committed in the gamecubeDol option.",
);
throw new GhidraAdmissionError("gamecube_dol_combined");
if (
options.psp === true &&
(options.dosMz === true ||
options.dosCom === true ||
override !== undefined)
)
throw new Error(
"A PSP ELF import cannot be combined with DOS or a generic Ghidra language override.",
);
throw new GhidraAdmissionError("psp_combined");
if (options.psp === true && rawBinary !== undefined)
throw new Error(
"A PSP ELF import cannot be combined with a raw-binary import.",
);
throw new GhidraAdmissionError("psp_raw_binary");
if (
options.mipsSoftFloat === true &&
(options.psp === true ||
@@ -894,27 +873,19 @@ export const ghidraHeadlessArguments = (
options.dosCom === true ||
rawBinary !== undefined)
)
throw new Error(
"MIPS soft-float storage applies only to a generic MIPS ELF import.",
);
throw new GhidraAdmissionError("mips_soft_float_scope");
if (
override !== undefined &&
(options.dosMz === true || options.dosCom === true)
)
throw new Error(
"A Ghidra language override cannot be combined with a DOS import.",
);
throw new GhidraAdmissionError("language_override_dos");
if (override !== undefined && rawBinary !== undefined)
throw new Error(
"A Ghidra language override cannot be combined with a raw-binary import.",
);
throw new GhidraAdmissionError("language_override_raw_binary");
if (
rawBinary !== undefined &&
(options.dosMz === true || options.dosCom === true)
)
throw new Error(
"A raw-binary import cannot be combined with a DOS import.",
);
throw new GhidraAdmissionError("raw_binary_dos");
return [
options.projectRoot,
existing?.projectName ?? "rea-project",
+43 -38
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { createHash } from "node:crypto";
import { lstat, opendir, realpath } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
@@ -70,17 +71,15 @@ const fingerprint = async (
): Promise<GhidraPspExtension> => {
assertArtifactReadActive(signal);
if (!isAbsolute(root) || (await realpath(root)) !== root)
throw new Error(
"The installed Allegrex directory must be a canonical non-symlink path.",
);
throw new GhidraAdmissionError("canonical_path");
const files: string[] = [];
let entries = 1; // The root itself consumes one inventory entry.
const visit = async (relative: string, depth: number): Promise<void> => {
assertArtifactReadActive(signal);
if (depth > MAX_DEPTH)
throw new Error(
"Allegrex extension directory nesting exceeds the inspection limit.",
);
throw new GhidraAdmissionError("nesting_exceeds_limit", {
max_depth: MAX_DEPTH,
});
const path = join(root, relative);
const metadata = await lstat(path);
if (metadata.isDirectory()) {
@@ -99,17 +98,16 @@ const fingerprint = async (
);
} else if (metadata.isFile()) {
files.push(relative);
} else
throw new Error(
`Allegrex extension entries must be regular files or directories: ${relative}`,
);
} else throw new GhidraAdmissionError("entry_kind", { relative });
};
await visit("", 0);
for (const required of REQUIRED)
if (!files.includes(required))
throw new Error(`Missing installed Allegrex component: ${required}`);
throw new GhidraAdmissionError("component_missing", {
component: required,
});
if (!files.some((path) => path.startsWith("lib/") && path.endsWith(".jar")))
throw new Error("The installed Allegrex extension has no loader JAR.");
throw new GhidraAdmissionError("loader_jar_missing");
const hash = createHash("sha256");
let bytesRead = 0;
let properties = Buffer.alloc(0);
@@ -125,13 +123,13 @@ const fingerprint = async (
!Number.isSafeInteger(before.size) ||
before.size > MAX_BYTES - bytesRead
)
throw new Error(
"Allegrex extension bytes exceed the inspection limit.",
);
throw new GhidraAdmissionError("bytes_exceed_limit", {
max_bytes: MAX_BYTES,
});
if (relative === "extension.properties" && before.size > 65536)
throw new Error(
"Allegrex extension properties exceed the inspection limit.",
);
throw new GhidraAdmissionError("properties_exceed_limit", {
max_bytes: 65536,
});
hash.update(JSON.stringify([relative, before.size]));
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
let offset = 0;
@@ -150,23 +148,19 @@ const fingerprint = async (
if (bytesRead === 0) break;
offset += bytesRead;
if (offset > before.size)
throw new Error(
`Allegrex component changed during inspection: ${relative}`,
);
throw new GhidraAdmissionError("component_changed", { relative });
const chunk = buffer.subarray(0, bytesRead);
hash.update(chunk);
if (relative === "extension.properties")
chunks.push(Buffer.from(chunk));
}
if (offset !== before.size)
throw new Error(`Truncated Allegrex component: ${relative}`);
throw new GhidraAdmissionError("component_truncated", { relative });
if (
!sameRegularFileState(before, await handle.stat()) ||
!sameRegularFileState(before, await lstat(path))
)
throw new Error(
`Allegrex component changed during inspection: ${relative}`,
);
throw new GhidraAdmissionError("component_changed", { relative });
bytesRead += before.size;
if (relative === "extension.properties")
properties = Buffer.concat(chunks);
@@ -179,18 +173,18 @@ const fingerprint = async (
properties.toString("latin1"),
);
if (!parsedProperties.ok)
throw new Error(
`Invalid Allegrex extension properties: ${parsedProperties.error}`,
);
throw new GhidraAdmissionError("properties_invalid", {
parser_code: parsedProperties.error,
});
const property = (name: string): string | undefined =>
parsedProperties.value.get(name);
if (
property("name") !== "ghidra-allegrex" ||
property("version") !== ghidraVersion
)
throw new Error(
`Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`,
);
throw new GhidraAdmissionError("metadata_mismatch", {
ghidra_version: ghidraVersion,
});
assertArtifactReadActive(signal);
return {
id: "ghidra-allegrex",
@@ -213,9 +207,10 @@ export const resolveGhidraPspExtension = async (
installation.platform !== "linux" ||
installation.architecture !== "x64"
)
throw new Error(
"The initial PSP profile requires a supported Linux x64 Ghidra installation.",
);
throw new GhidraAdmissionError("host_unsupported", {
platform: installation.platform,
architecture: installation.architecture,
});
const root = join(
await realpath(installation.installDir),
"Ghidra",
@@ -241,7 +236,16 @@ const ghidraPspInspectionError = (cause: unknown): AnalysisError => {
const cleanup =
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
const { cause: primary } = artifactFilePrimaryFacts(cause);
const reason = `PSP_EXTENSION_UNAVAILABLE: ${primary instanceof Error ? primary.message : String(primary)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
const admission =
primary instanceof GhidraAdmissionError ? primary : undefined;
const identity =
admission?.code ??
(primary instanceof Error ? primary.message : String(primary));
const reason = `PSP_EXTENSION_UNAVAILABLE: ${identity} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
const diagnostics =
admission === undefined
? undefined
: { code: admission.code, ...admission.detail };
const error =
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
? new AnalysisCancelledError("resolve_analysis_profile", {
@@ -256,6 +260,7 @@ const ghidraPspInspectionError = (cause: unknown): AnalysisError => {
cause,
userMessage: reason,
cleanup,
...(diagnostics === undefined ? {} : { diagnostics }),
},
);
bindArtifactFileCleanup(cause, error);
@@ -282,9 +287,9 @@ const readChildren = async (
assertArtifactReadActive(signal);
if (child === null) break;
if (names.length >= remainingEntries)
throw new Error(
"Allegrex extension inventory exceeds the inspection limit.",
);
throw new GhidraAdmissionError("inventory_exceeds_limit", {
max_entries: MAX_ENTRIES,
});
names.push(child.name);
}
outcome = { kind: "completed", names };
+20
View File
@@ -7,6 +7,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import type { GhidraDiagnosticsScope } from "./GhidraDiagnostics.js";
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js";
import {
GhidraLaunchError,
@@ -281,6 +282,7 @@ const describeErrorCause = (
message: message.text,
...(message.truncated ? { message_truncated: true } : {}),
...causeCode(cause, redact),
...causeAdmissionDetail(cause, redact),
...causeSignal(cause, redact),
...causeCommandOutcome(cause, redact),
...causeCompilerOutcome(cause, redact),
@@ -319,6 +321,24 @@ const causeCommandOutcome = (
};
};
const causeAdmissionDetail = (
cause: Error,
redact: (value: string, incomplete?: boolean) => string,
): Record<string, JsonValue> => {
if (!(cause instanceof GhidraAdmissionError) || cause.detail === undefined)
return {};
const detail: Record<string, JsonValue> = {};
for (const [key, value] of Object.entries(cause.detail)) {
if (typeof value === "number") {
detail[key] = value;
continue;
}
const bounded = boundedUtf8Text(redact(value), MAX_CAUSE_FIELD_BYTES);
detail[key] = bounded.text;
}
return { detail };
};
const causeCode = (
cause: Error,
redact: (value: string, incomplete?: boolean) => string,
+8 -6
View File
@@ -52,14 +52,16 @@ describe("Ghidra DOS import commitment", () => {
});
});
it("rejects a 32-bit interpretation even when artifact and profile hashes match", () => {
expect(rejectedHandshake(session("x86:LE:32:default")).message).toBe(
"Ghidra bridge handshake identity is invalid",
);
expect(rejectedHandshake(session("x86:LE:32:default"))).toMatchObject({
_tag: "GhidraAdmissionError",
code: "handshake_identity",
});
});
it("rejects compiler drift independently of the language", () => {
expect(rejectedHandshake(session(undefined, "windows")).message).toBe(
"Ghidra bridge handshake identity is invalid",
);
expect(rejectedHandshake(session(undefined, "windows"))).toMatchObject({
_tag: "GhidraAdmissionError",
code: "handshake_identity",
});
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { ghidraSeedReportSchema } from "./GhidraAnalysisSeeds.js";
import { ghidraDisabledAnalyzerReportSchema } from "./GhidraDisabledAnalyzers.js";
import { z } from "zod";
@@ -191,7 +192,7 @@ export const parseGhidraSessionInfo = (
parsed.data.analysis_complete === null ||
parsed.data.analysis_complete === parsed.data.analysis_timed_out)
)
return err(new Error("Ghidra bridge handshake identity is invalid"));
return err(new GhidraAdmissionError("handshake_identity"));
return ok(parsed.data);
};
+29 -15
View File
@@ -181,7 +181,12 @@ describe("optional Ghidra extension boundary", () => {
snapshotGhidraExtensions([extension], await root(), resources),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("digest differs") },
diagnostics: {
code: "digest_differs",
id: extension.id,
reason: "digest_differs",
path: extension.path,
},
});
});
it("rejects nonexistent, empty and non-JAR artifacts with useful reasons", async () => {
@@ -268,18 +273,19 @@ describe("optional Ghidra extension boundary", () => {
if (!resolved.ok)
expect(resolved.error._tag).toBe("AnalysisCancelledError");
});
it("refuses traversal identities in an imported extension profile", () => {
expect(
ghidraExtensionSchema.safeParse({
id: "../escape",
path: "/fixture.jar",
configured_path: "/fixture.jar",
sha256: "a".repeat(64),
integration_api: 1,
entry_class: "ignored",
}).success,
).toBe(false);
});
});
it("refuses traversal identities in an imported extension profile", () => {
expect(
ghidraExtensionSchema.safeParse({
id: "../escape",
path: "/fixture.jar",
configured_path: "/fixture.jar",
sha256: "a".repeat(64),
integration_api: 1,
entry_class: "ignored",
}).success,
).toBe(false);
});
describe("Ghidra extension profile and producer validation", () => {
@@ -384,7 +390,11 @@ describe("Ghidra extension profile and producer validation", () => {
),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("resolved path differs") },
diagnostics: {
code: "path_differs",
id: second.extension.id,
reason: "path_differs",
},
});
});
it("rejects unregistered descriptors before snapshot filesystem writes", async () => {
@@ -398,7 +408,11 @@ describe("Ghidra extension profile and producer validation", () => {
),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("Unregistered") },
diagnostics: {
code: "unregistered",
id: "unknown",
reason: "unregistered",
},
});
});
});
+12 -10
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "../GhidraAdmissionError.js";
import { createHash } from "node:crypto";
import { realpath, mkdir } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
@@ -86,7 +87,7 @@ const readJar = (
signal,
(bytes) => {
if (bytes.length < 4 || bytes.readUInt32LE(0) !== 0x04034b50)
throw new Error(`Ghidra extension is not a JAR: ${path}`);
throw new GhidraAdmissionError("not_jar", { path });
return bytes;
},
);
@@ -213,13 +214,13 @@ export const snapshotGhidraExtensions = async (
adapter === undefined ||
adapter.entryClass !== extension.entry_class
)
throw new Error(
`Unregistered Ghidra extension identity: ${extension.id}`,
);
throw new GhidraAdmissionError("unregistered", {
id: extension.id,
});
if ((await realpath(extension.configured_path)) !== extension.path)
throw new Error(
`Ghidra extension resolved path differs from explicit configuration: ${extension.id}`,
);
throw new GhidraAdmissionError("path_differs", {
id: extension.id,
});
}
const directory = join(runtimeRoot, "extensions");
await mkdir(directory, { mode: 0o700 });
@@ -229,9 +230,10 @@ export const snapshotGhidraExtensions = async (
if (
createHash("sha256").update(bytes).digest("hex") !== extension.sha256
)
throw new Error(
`Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`,
);
throw new GhidraAdmissionError("digest_differs", {
id: extension.id,
path: extension.path,
});
const path = join(directory, `${extension.id}.jar`);
await writeGhidraProfileArtifact(path, bytes, resources, signal);
result.push({ ...extension, path });
+3 -4
View File
@@ -1,3 +1,4 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { z } from "zod";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -44,11 +45,9 @@ export const parseGhidraResponseLine = (
const response = responseSchema.safeParse(parsed);
return response.success
? ok(response.data)
: err(new Error("Ghidra bridge response has an invalid shape"));
: err(new GhidraAdmissionError("response_shape"));
} catch (cause: unknown) {
return err(
new Error("Ghidra bridge response is not valid JSON", { cause }),
);
return err(new GhidraAdmissionError("response_json", undefined, { cause }));
}
};
+20 -31
View File
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -579,10 +580,10 @@ export const createDarwinProcessRunTokenReader = (
};
const compile = async (signal?: AbortSignal): Promise<string> => {
if (closed) throw new Error("Darwin process token reader is closed");
if (closed) throw new ProcessAdmissionError("reader_closed");
signal?.throwIfAborted();
while (true) {
if (closed) throw new Error("Darwin process token reader is closed");
if (closed) throw new ProcessAdmissionError("reader_closed");
signal?.throwIfAborted();
if (executable !== undefined) return executable;
if (compilation === undefined && root !== undefined) {
@@ -642,7 +643,7 @@ export const createDarwinProcessRunTokenReader = (
signal?: AbortSignal,
): Promise<string> => {
if (closed)
return Promise.reject(new Error("Darwin process token reader is closed"));
return Promise.reject(new ProcessAdmissionError("reader_closed"));
const pending = (async () => {
const binary = await compile(signal);
const { stdout } = await execFileOutput(binary, arguments_, {
@@ -670,25 +671,21 @@ export const createDarwinProcessRunTokenReader = (
const stdout = await runHelper(pids, signal);
const decoded = safeParseJson(stdout);
if (!decoded.ok)
throw new Error(
`macOS process token helper returned invalid JSON: ${decoded.error}`,
throw new ProcessAdmissionError(
"token_json_invalid",
{ parser: decoded.error },
{ cause: decoded.cause },
);
const parsed = responseSchema.safeParse(decoded.value);
if (!parsed.success)
throw new Error(
"macOS process token helper returned an invalid response",
{
cause: parsed.error,
},
);
throw new ProcessAdmissionError("token_response_invalid", undefined, {
cause: parsed.error,
});
const requested = new Set(processes.map(({ pid }) => pid));
const observations = new Map<number, ProcessRunTokenObservation>();
for (const result of parsed.data.results) {
if (!requested.has(result.pid) || observations.has(result.pid))
throw new Error(
"macOS process token helper returned an unexpected PID set",
);
throw new ProcessAdmissionError("token_pid_set");
observations.set(
result.pid,
result.state === "readable"
@@ -701,9 +698,7 @@ export const createDarwinProcessRunTokenReader = (
);
}
if (observations.size !== requested.size)
throw new Error(
"macOS process token helper returned an incomplete PID set",
);
throw new ProcessAdmissionError("token_pid_incomplete");
return observations;
};
@@ -716,25 +711,21 @@ export const createDarwinProcessRunTokenReader = (
const stdout = await runHelper(["--identities", ...pids], signal);
const decoded = safeParseJson(stdout);
if (!decoded.ok)
throw new Error(
`macOS process identity helper returned invalid JSON: ${decoded.error}`,
throw new ProcessAdmissionError(
"identity_json_invalid",
{ parser: decoded.error },
{ cause: decoded.cause },
);
const parsed = identityResponseSchema.safeParse(decoded.value);
if (!parsed.success)
throw new Error(
"macOS process identity helper returned an invalid response",
{
cause: parsed.error,
},
);
throw new ProcessAdmissionError("identity_response_invalid", undefined, {
cause: parsed.error,
});
const requested = new Set(processes.map(({ pid }) => pid));
const observations = new Map<number, ProcessIdentityObservation>();
for (const result of parsed.data.results) {
if (!requested.has(result.pid) || observations.has(result.pid))
throw new Error(
"macOS process identity helper returned an unexpected PID set",
);
throw new ProcessAdmissionError("identity_pid_set");
observations.set(
result.pid,
result.state === "readable"
@@ -768,9 +759,7 @@ export const createDarwinProcessRunTokenReader = (
);
}
if (observations.size !== requested.size)
throw new Error(
"macOS process identity helper returned an incomplete PID set",
);
throw new ProcessAdmissionError("identity_pid_incomplete");
return observations;
};
+8 -4
View File
@@ -176,7 +176,9 @@ it.each([
expect(physical.outcome.value).toEqual(configured);
else
expect(physical.outcome.cause).toMatchObject({
message: "Cannot establish inherited Linux limit: Max address space",
_tag: "ProcessAdmissionError",
code: "limit_unreadable",
detail: { limit: "address_space_bytes" },
});
expect(physical.owner.handle.fd).toBeGreaterThanOrEqual(0);
if (scoped)
@@ -267,9 +269,11 @@ it.each([16 * 1024, 16 * 1024 + 1])(
const prefix = report("unlimited unlimited");
await kernelReportFixture(prefix.padEnd(bytes, " "));
kernel.maximumRead = Buffer.byteLength(prefix);
await expect(readLinuxResourceLimits(configured)).rejects.toThrow(
"Inherited Linux limits exceed the report budget.",
);
await expect(readLinuxResourceLimits(configured)).rejects.toMatchObject({
_tag: "ProcessAdmissionError",
code: "report_exceeds_budget",
detail: { budget_bytes: 16 * 1024 },
});
expect(kernel.reads.length).toBeGreaterThan(1);
expect(
kernel.reads.every(
+6 -3
View File
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
import { open } from "node:fs/promises";
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
import { withArtifactFileHandle } from "../artifacts/ArtifactFileOperation.js";
@@ -57,12 +58,12 @@ export const clampLinuxResourceLimits = (
.filter((line) => line.startsWith(`${rows[key]} `));
const values = matches[0]?.slice(rows[key].length).trim().split(/\s+/u);
if (matches.length !== 1 || values === undefined || values.length !== 3)
throw new Error(`Cannot establish inherited Linux limit: ${rows[key]}`);
throw new ProcessAdmissionError("limit_unreadable", { limit: key });
let ceiling = BigInt(configured[key]);
for (const token of values.slice(0, 2)) {
if (token === "unlimited") continue;
if (!/^[0-9]+$/u.test(token))
throw new Error(`Invalid inherited Linux limit: ${rows[key]}`);
throw new ProcessAdmissionError("limit_invalid", { limit: key });
const value = BigInt(token);
if (value < ceiling) ceiling = value;
}
@@ -86,7 +87,9 @@ export const readLinuxResourceLimits = async (
const reportBudget = 16 * 1024;
const buffer = await readFileHandleRange(handle, 0, reportBudget);
if (buffer.length === reportBudget)
throw new Error("Inherited Linux limits exceed the report budget.");
throw new ProcessAdmissionError("report_exceeds_budget", {
budget_bytes: reportBudget,
});
return clampLinuxResourceLimits(configured, buffer.toString("utf8"));
},
);
+4 -3
View File
@@ -331,9 +331,10 @@ it("runs deferred delegated cleanup before registering a pending start acquisiti
expect(stopped.status).not.toBe("incomplete");
runtime.retainForCleanup();
await expect(runtime.start(startOptions())).rejects.toThrow(
"Owned worker runtime is closing",
);
await expect(runtime.start(startOptions())).rejects.toMatchObject({
_tag: "ProcessAdmissionError",
code: "runtime_closing",
});
expect(delegateTimedOut).toBe(false);
await delegatedClose;
expect(runtime.rootPath).toBeUndefined();
+11 -8
View File
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
import { PrivateRuntimeRoot } from "./PrivateRuntimeRoot.js";
import type { Readable, Writable } from "node:stream";
import {
@@ -211,14 +212,14 @@ export class OwnedWorkerRuntime {
initialize?: (supervisor: ProviderProcessSupervisor) => Promise<void>,
): Promise<void> {
if (this.#terminalCloseRequested || this.#closing)
return Promise.reject(new Error("Owned worker runtime is closing"));
return Promise.reject(new ProcessAdmissionError("runtime_closing"));
if (this.#acquisition !== undefined) return this.#acquisition;
if (this.#supervisor !== undefined && !this.#registry.has(this))
return Promise.resolve();
if (this.options.retryCleanup !== undefined && this.#registry.has(this))
return this.#retryOwnedCleanup().then(() => {
if (this.#terminalCloseRequested || this.#closing)
throw new Error("Owned worker runtime is closing");
throw new ProcessAdmissionError("runtime_closing");
return this.start(spawnOptions, initialize);
});
const ownRetry = this.#registry.has(this)
@@ -241,7 +242,7 @@ export class OwnedWorkerRuntime {
onStdout(listener: (chunk: Buffer | string) => void): void {
const stream = this.#supervisor?.launch.process.stdout;
if (stream === null || stream === undefined)
throw new Error("Owned worker runtime has no stdout stream");
throw new ProcessAdmissionError("stdout_missing");
stream.on("data", listener);
this.#stdoutListeners.add({ stream, listener });
}
@@ -330,12 +331,12 @@ export class OwnedWorkerRuntime {
| undefined,
): Promise<void> {
await this.#registry.retry(this);
if (this.#closing) throw new Error("Owned worker runtime is closing");
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
this.#root ??= await PrivateRuntimeRoot.create({
prefix: this.options.rootPrefix,
});
this.#cleanupEpoch ??= new OwnedWorkerRuntimeCleanupEpoch(this.#root.path);
if (this.#closing) throw new Error("Owned worker runtime is closing");
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
this.#lastRunId = spawnOptions.runId;
const spawned = await (this.options.launcher ?? spawnOwnedProviderProcess)({
...spawnOptions,
@@ -356,9 +357,9 @@ export class OwnedWorkerRuntime {
);
// A close may have begun while spawn was pending. Adopt the process first;
// close waits for this acquisition and then stops the exact adopted owner.
if (this.#closing) throw new Error("Owned worker runtime is closing");
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
await initialize?.(this.#supervisor);
if (this.#closing) throw new Error("Owned worker runtime is closing");
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
}
async #stop(): Promise<ProviderProcessStopResult> {
@@ -430,7 +431,9 @@ export class OwnedWorkerRuntime {
this.#inputFailure =
cause instanceof Error
? cause
: new Error("Owned worker request stream failed", { cause });
: new ProcessAdmissionError("request_stream_failed", undefined, {
cause,
});
this.options.onInputFailure?.(this.#inputFailure);
};
const onClose = (): void => {
+3 -1
View File
@@ -1,3 +1,5 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
/** Timeout and cancellation projections for one correlated operation. */
export type PendingOperationOptions<Value> = {
readonly signal?: AbortSignal;
@@ -36,7 +38,7 @@ export class PendingOperations<Key, Value> {
/** Register one unique operation key and await its reply or cancellation. */
wait(key: Key, options: PendingOperationOptions<Value>): Promise<Value> {
if (this.#pending.has(key))
throw new Error("Pending operation key is already registered");
throw new ProcessAdmissionError("duplicate_key");
if (options.signal?.aborted === true)
return Promise.resolve(options.cancelledValue());
+42
View File
@@ -0,0 +1,42 @@
const PROCESS_ADMISSION_CODES = [
"runtime_closing",
"stdout_missing",
"request_stream_failed",
"finalization_unobserved",
"prefix_identity_lost",
"prefix_unavailable",
"identity_snapshot_unavailable",
"current_process_unreported",
"baseline_unavailable",
"memory_limit_unsupported",
"launcher_pid_missing",
"duplicate_key",
"reader_closed",
"token_json_invalid",
"token_response_invalid",
"token_pid_set",
"token_pid_incomplete",
"identity_json_invalid",
"identity_response_invalid",
"identity_pid_set",
"identity_pid_incomplete",
"limit_unreadable",
"limit_invalid",
"report_exceeds_budget",
] as const;
export type ProcessAdmissionCode = (typeof PROCESS_ADMISSION_CODES)[number];
/** Process ownership, worker, or limit admission failure. `code` is the stable identity. */
export class ProcessAdmissionError extends Error {
readonly _tag = "ProcessAdmissionError";
constructor(
readonly code: ProcessAdmissionCode,
readonly detail?: Readonly<Record<string, string | number>>,
options?: ErrorOptions,
) {
super(code, options);
this.name = "ProcessAdmissionError";
}
}
+5 -4
View File
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
import { readFile, stat as statPath } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
@@ -1348,9 +1349,9 @@ export const createSystemProcessOwnershipHost = (
liveAfterRetry.some((entry) => entry.pid === pid),
);
if (stillUnresolved.length > 0)
throw new Error(
`process identity snapshot is unavailable for ${String(stillUnresolved.length)} live processes`,
);
throw new ProcessAdmissionError("identity_snapshot_unavailable", {
live_processes: stillUnresolved.length,
});
}
return processes.map(({ pid }) => {
const observation = identities.get(pid);
@@ -1369,7 +1370,7 @@ export const createSystemProcessOwnershipHost = (
try {
const processes = await listProcesses(signal);
if (!processes.some(({ pid }) => pid === process.pid))
throw new Error("ps did not report REA's current process");
throw new ProcessAdmissionError("current_process_unreported");
} catch (cause: unknown) {
if (isExpectedAbort(cause, signal)) throw cause;
throw new LinuxOwnedProcessPrerequisiteError(cause);
+5 -5
View File
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
import { spawn, type ChildProcess } from "node:child_process";
import type { EventEmitter } from "node:events";
import type { Readable, Writable } from "node:stream";
@@ -263,7 +264,7 @@ export const spawnOwnedProviderProcess = async (
if (sweepTokenOwnedProcesses) {
const capture = systemProcessOwnershipHost.captureBaseline;
if (capture === undefined)
throw new Error("Process ownership baseline is unavailable on this host");
throw new ProcessAdmissionError("baseline_unavailable");
captureBaseline = await capture(options.signal);
options.signal?.throwIfAborted();
}
@@ -306,9 +307,7 @@ export const spawnOwnedProviderProcess = async (
};
}
if (options.memoryLimitBytes !== undefined)
throw new Error(
"Owned process memory limits require the Windows Job Object launcher; no limit can be enforced on this host.",
);
throw new ProcessAdmissionError("memory_limit_unsupported");
const child = spawn(options.command, [...options.arguments], {
shell: false,
windowsHide: true,
@@ -320,7 +319,8 @@ export const spawnOwnedProviderProcess = async (
});
await waitForSpawn(child);
const pid = child.pid;
if (pid === undefined) throw new Error("Provider launcher has no process ID");
if (pid === undefined)
throw new ProcessAdmissionError("launcher_pid_missing");
const ownership: OwnedProcessGroup = {
runId: options.runId,
leaderPid: pid,
+2 -3
View File
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "../ProcessAdmissionError.js";
import type { IPty } from "@lydell/node-pty";
import type { ProcessCapture } from "../../domain/process/processCaptureParsing.js";
import type { ProcessScenario } from "../../domain/process/processScenario.js";
@@ -846,9 +847,7 @@ const runProcessScenario = async (
};
if (exit.reason === "cancelled")
throw processCaptureCancelled(signal?.reason);
throw new Error(
"The captured process exit was not observed because the finalization SIGKILL could not be delivered.",
);
throw new ProcessAdmissionError("finalization_unobserved");
}
if (exit.finalization !== undefined)
observations.finalization = {
@@ -1,3 +1,4 @@
import { ProcessAdmissionError } from "../ProcessAdmissionError.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -134,7 +135,7 @@ export class ProcessSensitiveOutput {
#prefix(index: number): Prefix {
const value = this.prefixes[index];
if (value === undefined)
throw new Error("Sensitive output matcher lost its prefix identity");
throw new ProcessAdmissionError("prefix_identity_lost");
return value;
}
@@ -342,7 +343,7 @@ export const createProcessSensitiveOutput = (
const read = (index: number): Prefix => {
const node = prefixes[index];
if (node === undefined)
throw new Error("Sensitive output prefix is unavailable");
throw new ProcessAdmissionError("prefix_unavailable");
return node;
};
try {
+47
View File
@@ -0,0 +1,47 @@
const WABT_ADMISSION_CODES = [
"incomplete_string",
"unsupported_escape",
"incomplete_comment",
"malformed_form",
"name_missing",
"incomplete_import",
"incomplete_export",
"unsupported_section_header",
"unrecognized_section_row",
"blank_custom_name",
"custom_header_mismatch",
"incomplete_section_row",
"section_ranges",
"custom_inventory_mismatch",
"section_coverage",
"incomplete_name_metadata",
"name_subsection_order",
"incomplete_module_name",
"unfinished_module_name",
"unsupported_module_prefix",
"unexpected_string",
"unexpected_second_module",
"unsupported_custom_name",
"unsupported_name_payload",
"unbalanced_output",
"unexpected_text",
"incomplete_output",
"directory_not_absolute",
"incomplete_tool_profile",
] as const;
export type WabtAdmissionCode = (typeof WABT_ADMISSION_CODES)[number];
/** WABT output or tool-profile admission failure. `code` is the stable identity. */
export class WabtAdmissionError extends Error {
readonly _tag = "WabtAdmissionError";
constructor(
readonly code: WabtAdmissionCode,
readonly detail?: Readonly<Record<string, string | number>>,
options?: ErrorOptions,
) {
super(code, options);
this.name = "WabtAdmissionError";
}
}
+6 -1
View File
@@ -436,7 +436,12 @@ it("returns actionable unavailable without creating a workspace when tools are a
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
userMessage: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
reason: "directory_not_absolute",
userMessage: "WABT configuration cannot be used",
diagnostics: {
code: "directory_not_absolute",
setting: "REA_WABT_BIN_DIRECTORY",
},
},
});
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import { randomUUID, createHash } from "node:crypto";
import { access, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
@@ -221,9 +222,9 @@ export class WabtArtifactProvider implements WasmArtifactPort {
});
const directory = environment.REA_WABT_BIN_DIRECTORY ?? "";
if (!isAbsolute(directory))
throw new Error(
"REA_WABT_BIN_DIRECTORY must be an absolute tool directory.",
);
throw new WabtAdmissionError("directory_not_absolute", {
setting: "REA_WABT_BIN_DIRECTORY",
});
const commands: {
tool: "wasm-validate" | "wasm-objdump" | "wasm2wat";
path: string;
@@ -466,7 +467,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
objdump === undefined ||
decode === undefined
)
throw new Error("Incomplete WABT tool profile.");
throw new WabtAdmissionError("incomplete_tool_profile");
const validation = await execute(validate, validate.arguments, true);
if (validation.exitCode !== 0)
throw new AnalysisInputError(
+3 -1
View File
@@ -1,3 +1,5 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
/** Skip a WAT comment without interpreting its punctuation as forms or strings. */
export const watCommentEnd = (
text: string,
@@ -24,5 +26,5 @@ export const watCommentEnd = (
index++;
}
}
throw new Error("Incomplete WABT WAT comment.");
throw new WabtAdmissionError("incomplete_comment");
};
+17 -3
View File
@@ -27,6 +27,7 @@ import {
RuntimeControlUnavailableError,
runtimeControlCapabilityError,
} from "../process/RuntimeControlUnavailableError.js";
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import {
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
MAX_WASM_COMMAND_TIMEOUT_MS,
@@ -237,18 +238,31 @@ const classifyWabtFailure = (
remediationAction: `The WABT tool allowance is ${String(WABT_LIMITS.toolBytes)} bytes. This release has no runtime override. command_timeout_ms and REA_WABT_BIN_DIRECTORY do not raise it. Select tools within the allowance. Do not repeat the unchanged call.`,
},
);
if (phase === "configuration")
if (phase === "configuration") {
const admission = cause instanceof WabtAdmissionError ? cause : undefined;
return new AnalysisCapabilityUnavailableError(
WABT_PROVIDER_IDENTITY.id,
"inspect_wasm_artifact",
reason,
admission?.code ?? reason,
{
cause,
...artifactOptions,
userMessage: reason,
...(admission === undefined
? {}
: {
diagnostics: {
code: admission.code,
...admission.detail,
},
}),
userMessage:
admission === undefined
? reason
: "WABT configuration cannot be used",
remediationAction: wabtConfigurationRecoveryAction(cause),
},
);
}
if (
phase === "artifact" &&
cause instanceof ArtifactReaderFailure &&
+10 -2
View File
@@ -217,11 +217,19 @@ it("rejects unfinished nested comments rather than projecting a truncated interf
"(module (type (func (param (; unfinished ) i32))))",
"(module) (; outer (; inner ;) still open",
])
expect(() => parseWabtWat(source)).toThrow("Incomplete WABT WAT comment");
expect(() => parseWabtWat(source)).toThrow(
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "incomplete_comment",
}),
);
});
it("rejects a second module after a CR-terminated line comment", () => {
expect(() => parseWabtWat("(module) ;; comment\r(module)")).toThrow(
"Unexpected second WABT WAT module",
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "unexpected_second_module",
}),
);
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js";
import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js";
@@ -47,7 +48,7 @@ const parts = (form: string): string[] => {
if (items[0] === "func" && items.length > 1 && !atom.startsWith("$"))
break;
}
if (i <= start) throw new Error("Malformed WABT form");
if (i <= start) throw new WabtAdmissionError("malformed_form");
budget.reserve(64 + (i - start) * 2);
items.push(form.slice(start, i));
}
@@ -91,7 +92,7 @@ const functionType = (
};
const name = (text: string | undefined): string => {
if (text?.startsWith('"') !== true)
throw new Error("WABT interface name is missing");
throw new WabtAdmissionError("name_missing");
return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(
watString(text, 1).bytes,
);
@@ -252,7 +253,7 @@ const decodeWabtInterface = (
const p = parts(form);
if (p[0] !== "import") continue;
const body = p[3];
if (body === undefined) throw new Error("Incomplete WABT import");
if (body === undefined) throw new WabtAdmissionError("incomplete_import");
const observed = addDefinition(body);
const entry: WasmInterfaceEntry = {
direction: "import",
@@ -280,7 +281,7 @@ const decodeWabtInterface = (
const p = parts(form);
if (p[0] !== "export") continue;
const body = p[2];
if (body === undefined) throw new Error("Incomplete WABT export");
if (body === undefined) throw new WabtAdmissionError("incomplete_export");
const descriptor = parts(body);
const rawKind = descriptor[0] ?? "unknown";
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
+25 -18
View File
@@ -1,5 +1,8 @@
import { readFile } from "node:fs/promises";
import { expect, it } from "vitest";
const refused = (code: string) =>
expect.objectContaining({ _tag: "WabtAdmissionError", code });
import { parseWabtOutput } from "./WabtOutput.js";
import { parseWabtWat } from "./WabtWat.js";
import { associateWasmGlue } from "./WasmGlueAdapter.js";
@@ -28,10 +31,10 @@ it("corroborates a blank custom name with bounded invalid UTF-8 bytes", () => {
valid[11] = 0x61;
valid[12] = 0x62;
expect(() => parseWabtOutput(dump, valid.length, [], [], valid)).toThrow(
/not corroborated/u,
refused("blank_custom_name"),
);
expect(() => parseWabtOutput(dump, bytes.length, [])).toThrow(
/not corroborated/u,
refused("blank_custom_name"),
);
const mixed = Buffer.concat([bytes, Buffer.from("00050174616263", "hex")]);
const mixedDump = dump.replace(
@@ -57,19 +60,21 @@ it("retains real WABT section ranges, custom section identity and original heade
bytes: 12,
description: '"test"',
});
expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(/ranges/u);
expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(
refused("section_ranges"),
);
expect(() =>
parseWabtOutput(text.replace("size=0x0000000c", "size=0x0000000d"), 76, [
"test",
]),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(
text.replace("\nSection Details:\n", "unexpected\nSection Details:\n"),
76,
["test"],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
text.replace(
@@ -79,7 +84,7 @@ it("retains real WABT section ranges, custom section identity and original heade
76,
["test"],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
});
const dataCountModuleBytes = 0x3d;
const unpaddedDataCountRow =
@@ -126,14 +131,14 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(unpaddedDataCountRow, "Sections:"),
dataCountModuleBytes,
[],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(
@@ -143,14 +148,14 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
dataCountDump.replace("(size=0x00000001)", "(size=0x00000002)"),
dataCountModuleBytes,
[],
),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(
@@ -160,10 +165,10 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(dataCountDump, dataCountModuleBytes - 1, []),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(
@@ -173,7 +178,7 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/header/u);
).toThrow(refused("unsupported_section_header"));
});
it("bounds section header bytes while accepting a five-byte size LEB", () => {
const paddedCustomDump = [
@@ -209,7 +214,7 @@ it("bounds section header bytes while accepting a five-byte size LEB", () => {
"",
].join("\n");
expect(() => parseWabtOutput(malformed, start + 1, [""])).toThrow(
/ranges/u,
refused("section_ranges"),
);
}
});
@@ -225,7 +230,7 @@ it.each([
`\n${displayed.map((name) => `module name: <${name}>\n`).join("")}\nSections:`,
);
expect(() => parseWabtOutput(text, 76, ["test"], decoded)).toThrow(
/header format/u,
refused("unsupported_section_header"),
);
},
);
@@ -284,7 +289,7 @@ it("frames custom section names at their NUL-terminated objdump display", async
description: '"test"',
});
expect(() => parseWabtOutput(text, 76, ["other\0test"])).toThrow(
/custom-section header/u,
refused("custom_header_mismatch"),
);
});
it("preserves leading U+FEFF as custom-name data, not a debug-name marker", () => {
@@ -327,9 +332,11 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
).exports,
).toEqual(['(export "x" (func 0))']);
expect(() => parseWabtWat('(module (export "unfinished"')).toThrow(
/Incomplete/u,
refused("incomplete_output"),
);
expect(() => parseWabtWat("(module) unexpected")).toThrow(
refused("unexpected_text"),
);
expect(() => parseWabtWat("(module) unexpected")).toThrow(/Unexpected/u);
});
it("keeps exact local, ambiguous URL, computed and unparseable glue distinct", () => {
const candidates = ["/app/module.wasm", "/other/module.wasm"];
+11 -14
View File
@@ -1,3 +1,5 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
/** WABT prints names with %.*s, so an embedded NUL ends their display. */
const wabtDisplayedName = (name: string | undefined): string | undefined =>
name?.split("\0", 1)[0];
@@ -17,7 +19,7 @@ export const parseWabtOutput = (
.join("");
const prefix = `${preamble}\nSections:\n\n`;
if (!text.startsWith(prefix))
throw new Error("Unsupported WABT section header format.");
throw new WabtAdmissionError("unsupported_section_header");
let offset = prefix.length;
let customIndex = 0;
const sections = [];
@@ -27,7 +29,8 @@ export const parseWabtOutput = (
/^[ \t]*(\w+) start=0x([\da-f]+) end=0x([\da-f]+) \(size=0x([\da-f]+)\) /u.exec(
text.slice(offset),
);
if (match === null) throw new Error("Unrecognized WABT section row.");
if (match === null)
throw new WabtAdmissionError("unrecognized_section_row");
offset += match[0].length;
let description: string;
if (match[1] === "Custom" && !text.startsWith('"', offset)) {
@@ -37,7 +40,7 @@ export const parseWabtOutput = (
const end = Number.parseInt(match[3] ?? "", 16);
const invalidEnd = invalidCustomNameEnd(artifact, start, end);
if (invalidEnd === undefined)
throw new Error("WABT blank custom-section name is not corroborated.");
throw new WabtAdmissionError("blank_custom_name");
invalidNameEnds.push(invalidEnd);
description = "";
// WABT does not terminate a blank-name row when another section follows.
@@ -49,14 +52,12 @@ export const parseWabtOutput = (
} else if (match[1] === "Custom") {
const name = wabtDisplayedName(customSectionNames[customIndex++]);
if (name === undefined || !text.startsWith(`"${name}"\n`, offset))
throw new Error(
"WABT custom-section header does not match decoded WAT.",
);
throw new WabtAdmissionError("custom_header_mismatch");
description = `"${name}"`;
offset += description.length + 1;
} else {
const end = text.indexOf("\n", offset);
if (end < 0) throw new Error("Incomplete WABT section row.");
if (end < 0) throw new WabtAdmissionError("incomplete_section_row");
description = text.slice(offset, end).trim();
offset = end + 1;
}
@@ -74,17 +75,13 @@ export const parseWabtOutput = (
sectionHeaderBytes < 2 ||
sectionHeaderBytes > 6
)
throw new Error("WABT section ranges do not match selected artifact.");
throw new WabtAdmissionError("section_ranges");
sections.push(section);
}
if (customIndex !== customSectionNames.length)
throw new Error(
"WABT custom-section inventory does not match decoded WAT.",
);
throw new WabtAdmissionError("custom_inventory_mismatch");
if ((sections.at(-1)?.end ?? 8) !== artifactBytes)
throw new Error(
"WABT section output does not cover the selected artifact.",
);
throw new WabtAdmissionError("section_coverage");
const separator = offset + 1;
return {
sections,
+16 -3
View File
@@ -26,8 +26,18 @@ it("frames escaped quotes and trailing backslashes without consuming the next fo
it("rejects missing terminators, including a dangling escape or escaped last quote", () => {
for (const source of ['"', '"payload', '"payload\\', '"payload\\"']) {
expect(() => watStringEnd(source, 1)).toThrow("Incomplete WABT string");
expect(() => watString(source, 1)).toThrow("Incomplete WABT string");
expect(() => watStringEnd(source, 1)).toThrow(
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "incomplete_string",
}),
);
expect(() => watString(source, 1)).toThrow(
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "incomplete_string",
}),
);
expect(() => parseWabtWat(`(module (import ${source}`)).toThrow();
}
});
@@ -53,6 +63,9 @@ it("preserves quoted WAT punctuation, nested comments, escaped names and unknown
},
]);
expect(() => parseWabtWat('(module (import "env" "\\q" (func)))')).toThrow(
"Unsupported WABT name escape",
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "unsupported_escape",
}),
);
});
+5 -2
View File
@@ -1,3 +1,5 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
/** Locate a WAT string's exclusive end without decoding or copying its payload. */
export const watStringEnd = (form: string, start: number): number => {
for (
@@ -9,7 +11,7 @@ export const watStringEnd = (form: string, start: number): number => {
while (escapes > start && form[escapes - 1] === "\\") escapes--;
if ((end - escapes) % 2 === 0) return end + 1;
}
throw new Error("Incomplete WABT string.");
throw new WabtAdmissionError("incomplete_string");
};
/** Decode a producer WAT string only where its bytes are interpreted. */
@@ -45,7 +47,8 @@ export const watString = (form: string, start: number) => {
: escape === "\\"
? 92
: undefined;
if (value === undefined) throw new Error("Unsupported WABT name escape.");
if (value === undefined)
throw new WabtAdmissionError("unsupported_escape");
bytes[length++] = value;
index += 2;
}
+14 -14
View File
@@ -1,3 +1,4 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import { parseWabtInterface } from "./WabtInterface.js";
import { watString, watStringEnd } from "./WabtString.js";
import { watCommentEnd } from "./WabtComment.js";
@@ -25,24 +26,24 @@ const moduleNames = (
value += (byte & 127) * 2 ** shift;
if (byte < 128) return value;
}
throw new Error("Incomplete WABT name-section metadata.");
throw new WabtAdmissionError("incomplete_name_metadata");
};
try {
let previousType = -1;
while (offset < bytes.length) {
const type = uint32(bytes.length);
if (type <= previousType)
throw new Error("Duplicate or out-of-order WABT name subsection.");
throw new WabtAdmissionError("name_subsection_order");
previousType = type;
const size = uint32(bytes.length);
const end = offset + size;
if (end > bytes.length)
throw new Error("Incomplete WABT name-section metadata.");
throw new WabtAdmissionError("incomplete_name_metadata");
if (type === 0 && size !== 0) {
const length = uint32(end);
const nameEnd = offset + length;
if (nameEnd > end)
throw new Error("Incomplete WABT module-name metadata.");
throw new WabtAdmissionError("incomplete_module_name");
names.push(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(offset, nameEnd),
@@ -50,7 +51,7 @@ const moduleNames = (
);
// WABT calls OnModuleName before rejecting trailing subsection bytes.
if (nameEnd !== end)
throw new Error("Unfinished WABT module-name subsection.");
throw new WabtAdmissionError("unfinished_module_name");
}
offset = end;
}
@@ -68,7 +69,7 @@ const moduleNames = (
/** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */
export const parseWabtWat = (text: string) => {
if (!/^\(module(?:\s|\))/u.test(text))
throw new Error("Unsupported WABT WAT module prefix.");
throw new WabtAdmissionError("unsupported_module_prefix");
const forms: string[] = [];
const imports: string[] = [];
const exports: string[] = [];
@@ -95,13 +96,12 @@ export const parseWabtWat = (text: string) => {
continue;
}
if (char === '"') {
if (depth === 0)
throw new Error("Unexpected string outside WABT WAT module.");
if (depth === 0) throw new WabtAdmissionError("unexpected_string");
index = watStringEnd(text, index + 1) - 1;
continue;
}
if (char === "(") {
if (closed) throw new Error("Unexpected second WABT WAT module.");
if (closed) throw new WabtAdmissionError("unexpected_second_module");
depth++;
if (depth === 2) start = index;
} else if (char === ")") {
@@ -118,7 +118,7 @@ export const parseWabtWat = (text: string) => {
else if (/^\(@custom\s/u.test(form)) {
const prefix = /^\(@custom\s+"/u.exec(form);
if (prefix === null)
throw new Error("Unsupported WABT custom-section name.");
throw new WabtAdmissionError("unsupported_custom_name");
const nameString = watString(form, prefix[0].length);
// A WASM name is length-delimited UTF-8; leading U+FEFF is name data.
const name = new TextDecoder("utf-8", {
@@ -129,7 +129,7 @@ export const parseWabtWat = (text: string) => {
if (name === "name") {
const payloadPrefix = /^\s+"/u.exec(form.slice(nameString.end));
if (payloadPrefix === null)
throw new Error("Unsupported WABT name-section payload.");
throw new WabtAdmissionError("unsupported_name_payload");
const payload = watString(
form,
nameString.end + payloadPrefix[0].length,
@@ -148,12 +148,12 @@ export const parseWabtWat = (text: string) => {
}
}
}
if (--depth < 0) throw new Error("Unbalanced WABT WAT output.");
if (--depth < 0) throw new WabtAdmissionError("unbalanced_output");
if (depth === 0) closed = true;
} else if (depth === 0 && char !== undefined && !/\s/u.test(char))
throw new Error("Unexpected text outside WABT WAT module.");
throw new WabtAdmissionError("unexpected_text");
}
if (depth !== 0) throw new Error("Incomplete WABT WAT output.");
if (depth !== 0) throw new WabtAdmissionError("incomplete_output");
return {
imports,
exports,
@@ -82,8 +82,12 @@ it("rejects a relative override and non-file without probing an alternate comman
);
expect(relative).toMatchObject({
status: "unavailable",
diagnostics: { limiter_path: "prlimit" },
reason: expect.stringContaining("absolute executable path"),
reason: "prlimit_not_absolute",
diagnostics: {
limiter_path: "prlimit",
code: "prlimit_not_absolute",
setting: "REA_EVM_PRLIMIT_COMMAND",
},
});
const directory = await evmLimiterAvailability(
@@ -98,7 +102,8 @@ it("rejects a relative override and non-file without probing an alternate comman
);
expect(directory).toMatchObject({
status: "unavailable",
reason: expect.stringContaining("regular executable file"),
reason: "prlimit_not_file",
diagnostics: expect.objectContaining({ code: "prlimit_not_file" }),
});
});
@@ -982,7 +982,15 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])(
category: "unsupported_provider",
details: { provider_id: "evmole-interface" },
});
expect(result.error.message).toContain(limiter);
if (scenario === "directory")
expect(result.error).toMatchObject({
reason: "prlimit_not_file",
diagnostics: {
limiter_path: limiter,
code: "prlimit_not_file",
},
});
else expect(result.error.message).toContain(limiter);
if (ownedRoot !== undefined)
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
@@ -94,7 +94,17 @@ it.each([
if (kind.startsWith("duplicate nested")) {
expect(metadata.entries).toEqual([]);
expect(metadata.compiler_version).toBeNull();
expect(metadata.diagnostic).toContain("repeat map key");
expect(metadata.diagnostic).toBe("repeated_map_key");
expect(metadata.admission).toMatchObject({ code: "repeated_map_key" });
if (kind === "duplicate nested text keys")
expect(metadata.admission?.detail).toEqual({
key: JSON.stringify({ kind: "text", value: "a" }),
});
else
expect(
metadata.admission?.detail?.key ??
metadata.admission?.detail?.key_length,
).toBeDefined();
expect(metadata.bytecode_offset).toBe(1);
expect(metadata.cbor_bytes).toBe(19);
}
@@ -102,7 +102,7 @@ const admissionCases: readonly AdmissionCase[] = [
{
name: "malformed Unicode escape",
properties: "application.version=12.1.4\nother=\\u00xx\n",
error: "Malformed Unicode escape in Java properties file",
error: "malformed_unicode_escape",
},
];
@@ -603,7 +603,12 @@ it.each(["seed", "extension"] as const)(
],
},
});
else expect(result.error.diagnostics?.reason).toContain("not a JAR");
else
expect(result.error.diagnostics).toMatchObject({
code: "not_jar",
reason: "not_jar",
path,
});
allowClose = true;
await scope.retryCleanup();
expect(result.error.cleanupIncomplete).toBe(false);
@@ -241,14 +241,27 @@ describe.skipIf(process.platform === "win32")(
if (missing.ok) throw new Error("Missing installation was accepted");
expect(missing.error.message).toContain("PSP_EXTENSION_UNAVAILABLE");
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP extension admission codes",
() => {
it.each([
["name=other\nversion=12.1.3\n", "does not match"],
["name=ghidra-allegrex\nversion=12.1.4\n", "does not match"],
["name=ghidra-allegrex\nversion=12.1.\\uZZZZ\n", "Unicode escape"],
])(
["name=other\nversion=12.1.3\n", "metadata_mismatch", undefined],
[
"name=ghidra-allegrex\nversion=12.1.4\n",
"metadata_mismatch",
undefined,
],
[
"name=ghidra-allegrex\nversion=12.1.\\uZZZZ\n",
"properties_invalid",
"malformed_unicode_escape",
],
] as const)(
"rejects mismatched or malformed extension metadata %s",
async (properties, reason) => {
async (properties, code, parserCode) => {
const { root } = await installation();
await writeFile(join(root, "extension.properties"), properties);
await expect(
@@ -257,7 +270,14 @@ describe.skipIf(process.platform === "win32")(
"12.1.3",
new ArtifactResourceScope(),
),
).rejects.toThrow(reason);
).rejects.toMatchObject({
diagnostics: {
code,
...(parserCode === undefined
? { ghidra_version: "12.1.3" }
: { parser_code: parserCode }),
},
});
},
);
@@ -269,7 +289,12 @@ describe.skipIf(process.platform === "win32")(
);
await expect(
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
).rejects.toThrow("Module.manifest");
).rejects.toMatchObject({
diagnostics: {
code: "component_missing",
component: "Module.manifest",
},
});
});
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
@@ -279,7 +304,9 @@ describe.skipIf(process.platform === "win32")(
await symlink(join(directory, "external.jar"), path);
await expect(
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
).rejects.toThrow("regular files");
).rejects.toMatchObject({
diagnostics: { code: "entry_kind", relative: "lib/extension.jar" },
});
});
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
@@ -287,7 +314,12 @@ describe.skipIf(process.platform === "win32")(
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
await expect(
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
).rejects.toThrow("inspection limit");
).rejects.toMatchObject({
diagnostics: {
code: "bytes_exceed_limit",
max_bytes: 64 * 1024 * 1024,
},
});
});
},
);
@@ -314,7 +346,9 @@ describe.skipIf(process.platform === "win32")(
"12.1.3",
new ArtifactResourceScope(),
),
).rejects.toThrow("inventory exceeds the inspection limit");
).rejects.toMatchObject({
diagnostics: { code: "inventory_exceeds_limit", max_entries: 1024 },
});
},
);
},
@@ -571,7 +605,12 @@ describe.skipIf(process.platform === "win32")(
const languageOverride = { languageId: "x86:LE:64:default" };
expect(() =>
ghidraHeadlessArguments({ ...paths, psp: true, languageOverride }),
).toThrow("PSP ELF import cannot be combined");
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "psp_combined",
}),
);
expect(ghidraHeadlessArguments({ ...paths, languageOverride })).toEqual(
expect.arrayContaining(["-processor", "x86:LE:64:default"]),
);
@@ -903,11 +942,14 @@ describe.skipIf(process.platform === "win32")(
const result = await provider.resolveAnalysisProfile(target);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Changed file admitted");
expect(result.error.message).toContain(
change === "growth"
? "changed during inspection"
: "Truncated Allegrex component",
);
expect(result.error).toMatchObject({
diagnostics: {
code:
change === "growth" ? "component_changed" : "component_truncated",
relative:
change === "growth" ? "Module.manifest" : "extension.properties",
},
});
expect(closes).toBe(1);
expect(result.error.cleanupIncomplete).toBe(false);
expect(provider.hasPendingOwnedResources()).toBe(false);
@@ -12,6 +12,7 @@ import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { writeTextParts } from "../../../src/application/JsonFiles.js";
import { StagedOutputError } from "../../../src/filesystem/StagedOutputError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const allocation = vi.hoisted(() => ({
@@ -70,7 +71,10 @@ it("closes the descriptor and retains staged content when its final identity is
expect(result.error.cause).toMatchObject({
outcome: {
ok: false,
error: { message: "Cannot verify staged file identity" },
error: {
_tag: "StagedOutputError",
code: "identity_unverified",
},
},
});
const stage = result.error.cleanupResources[0];
@@ -457,7 +461,11 @@ it("refuses to remove unexpected files from an owned staging directory", async (
const unexpected = join(directory, "unexpected");
writeFileSync(unexpected, "unowned");
try {
await expect(owner.close()).rejects.toThrow(/Unexpected staging entries/u);
await expect(owner.close()).rejects.toMatchObject({
_tag: "StagedOutputError",
code: "unexpected_entries",
detail: { count: 1, entries: "unexpected" },
});
expect(await fs.readFile(unexpected, "utf8")).toBe("unowned");
} finally {
await fs.rm(unexpected, { force: true });
@@ -466,6 +474,37 @@ it("refuses to remove unexpected files from an owned staging directory", async (
}
});
it("keeps unexpected staging names on the publication error", async () => {
const root = await createTestTempDirectory("rea-staged-public-entries-");
const destination = join(root, "out.json");
const resources = new ArtifactResourceScope();
const close = vi
.spyOn(OwnedStagedOutput.prototype, "close")
.mockImplementation(async () => {
throw new StagedOutputError("unexpected_entries", {
count: 1,
entries: "unexpected",
});
});
try {
const result = await writeTextParts(["{}"], destination, false, resources);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({
_tag: "EvidenceFileError",
diagnostics: {
code: "unexpected_entries",
count: 1,
entries: "unexpected",
},
});
} finally {
close.mockRestore();
await resources.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
it("retains cleanup when its staging directory was renamed", async () => {
const root = await createTestTempDirectory("rea-staged-renamed-dir-");
const directory = join(root, ".rea-write-owned");
+12 -2
View File
@@ -141,7 +141,11 @@ it.each(["bin", "hop"])(
code: "capability_unavailable",
details: {
operation: "inspect_wasm_artifact",
reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
reason: "directory_not_absolute",
diagnostics: {
code: "directory_not_absolute",
setting: "REA_WABT_BIN_DIRECTORY",
},
},
},
});
@@ -152,7 +156,13 @@ it.each(["bin", "hop"])(
expect(parseMcpToolError(summary)).toMatchObject({
error: {
code: "capability_unavailable",
details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") },
details: {
reason: "directory_not_absolute",
diagnostics: {
code: "directory_not_absolute",
setting: "REA_WABT_BIN_DIRECTORY",
},
},
},
});
await client.ping();
@@ -1040,7 +1040,10 @@ it.skipIf(process.platform === "win32")(
throw new Error("closed reader compilation unexpectedly succeeded");
expect(outcome.cause).toMatchObject({ name: "AbortError" });
await expect(access(state.root)).rejects.toThrow();
await expect(reader.prepare()).rejects.toThrow(/reader is closed/u);
await expect(reader.prepare()).rejects.toMatchObject({
_tag: "ProcessAdmissionError",
code: "reader_closed",
});
await expect(reader.close()).resolves.toBeUndefined();
} finally {
await Promise.allSettled([preparation]);
@@ -506,7 +506,10 @@ describe("provider process memory limits", () => {
platform: "linux",
memoryLimitBytes: 704 * 1024 * 1024,
}),
).rejects.toThrow("require the Windows Job Object launcher");
).rejects.toMatchObject({
_tag: "ProcessAdmissionError",
code: "memory_limit_unsupported",
});
});
});
@@ -452,6 +452,13 @@ describe.skipIf(process.platform === "win32")(
cause: { code: 3 },
},
});
if (mode === "env-malformed")
expect(cause).toMatchObject({
cause: {
code: "invalid_environment_entry",
detail: { check: "separator", line: "invalid environment" },
},
});
if (mode === "vm-malformed")
expect(cause).toMatchObject({
launch_support: expect.arrayContaining([
@@ -474,9 +481,8 @@ describe.skipIf(process.platform === "win32")(
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
_tag: "GhidraAdmissionError",
code: "platform_override",
});
},
);
@@ -499,9 +505,8 @@ describe.skipIf(process.platform === "win32")(
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
_tag: "GhidraAdmissionError",
code: "platform_override",
});
},
);
@@ -578,9 +583,8 @@ describe.skipIf(process.platform === "win32")(
if (launched.ok)
throw new Error("Expected an effective platform override refusal");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
_tag: "GhidraAdmissionError",
code: "platform_override",
});
},
);
@@ -600,7 +604,8 @@ describe.skipIf(process.platform === "win32")(
if (launched.ok)
throw new Error("Expected a JVM platform override failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining("may not override os.arch or os.name"),
_tag: "GhidraAdmissionError",
code: "platform_override",
});
});
@@ -634,7 +639,9 @@ describe.skipIf(process.platform === "win32")(
if (launched.ok)
throw new Error("Expected an unreadable argument-file failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining("argument file is unreadable"),
_tag: "GhidraAdmissionError",
code: "argument_file_unreadable",
detail: { path: optionsFile },
});
});
@@ -657,7 +664,8 @@ describe.skipIf(process.platform === "win32")(
if (launched.ok)
throw new Error("Expected a nested argument-file failure");
expect(launched.error.cause).toMatchObject({
message: expect.stringContaining("may not nest @ references"),
_tag: "GhidraAdmissionError",
code: "nested_argument_file",
});
});
},
@@ -823,9 +831,8 @@ describe.skipIf(process.platform === "win32")(
);
if (cleanup.fileCleanupFailure.outcome.kind === "failed")
expect(cleanup.fileCleanupFailure.outcome.cause).toMatchObject({
message: expect.stringContaining(
"may not override os.arch or os.name",
),
_tag: "GhidraAdmissionError",
code: "platform_override",
});
await resources.retryCleanup();
expect(resources.hasPending).toBe(false);
@@ -260,7 +260,12 @@ describe("Ghidra GameCube DOL loader", () => {
sectionFilePath: "/tmp/gamecube-dol-sections.txt",
},
}),
).toThrow(/cannot be combined with another loader, language override/u);
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "gamecube_dol_combined",
}),
);
});
});
@@ -346,7 +351,12 @@ describe("Ghidra headless launcher", () => {
])
expect(() =>
ghidraHeadlessArguments({ ...base, mipsSoftFloat: true, ...conflict }),
).toThrow(/generic MIPS ELF/u);
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "mips_soft_float_scope",
}),
);
});
});
@@ -395,7 +405,12 @@ describe("Ghidra configured import language and seeds", () => {
dosMz: true,
languageOverride: { languageId: "x86:LE:32:default" },
}),
).toThrow(/cannot be combined with a DOS import/u);
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "language_override_dos",
}),
);
});
it("never combines a configured language with a raw-binary import", () => {
@@ -409,7 +424,12 @@ describe("Ghidra configured import language and seeds", () => {
baseAddr: "0x8000000",
},
}),
).toThrow(/cannot be combined with a raw-binary import/u);
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "language_override_raw_binary",
}),
);
});
it.each(["darwin", "win32"] as const)(
@@ -579,30 +599,41 @@ describe("Ghidra existing project", () => {
});
it.each([
{ dosMz: true as const },
{ dosCom: true as const },
{
rawBinary: {
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
baseAddr: "0x8000000",
[{ dosMz: true as const }, "existing_project_dos"],
[{ dosCom: true as const }, "existing_project_dos"],
[
{
rawBinary: {
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
baseAddr: "0x8000000",
},
},
},
{ languageOverride: { languageId: "x86:LE:32:default" } },
{ psp: true as const },
{ mipsSoftFloat: true as const },
{ seedPath: "/tmp/runtime/seeds/seeds.tsv" },
{ disabledAnalyzers: ["Decompiler Parameter ID"] },
])(
"existing_project_raw_binary",
],
[
{ languageOverride: { languageId: "x86:LE:32:default" } },
"existing_project_language_override",
],
[{ psp: true as const }, "existing_project_psp"],
[{ mipsSoftFloat: true as const }, "existing_project_mips_soft_float"],
[{ seedPath: "/tmp/runtime/seeds/seeds.tsv" }, "existing_project_analysis"],
[
{ disabledAnalyzers: ["Decompiler Parameter ID"] },
"existing_project_analysis",
],
] as const)(
"rejects the import-only option %o with an existing project",
(conflict) => {
(conflict, code) => {
expect(() =>
ghidraHeadlessArguments({
...base,
existingProject: { programName: "game.exe" },
...conflict,
}),
).toThrow(/existing (Ghidra )?project|fresh import/u);
).toThrow(
expect.objectContaining({ _tag: "GhidraAdmissionError", code }),
);
},
);
@@ -618,7 +649,12 @@ describe("Ghidra existing project", () => {
),
existingProject: { programName: "game.exe" },
}),
).toThrow(/Windows/u);
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "existing_project_windows",
}),
);
});
it("requires a program name for -process", () => {
@@ -627,7 +663,12 @@ describe("Ghidra existing project", () => {
...base,
existingProject: { programName: " " },
}),
).toThrow(/program name/u);
).toThrow(
expect.objectContaining({
_tag: "GhidraAdmissionError",
code: "existing_project_program_name",
}),
);
});
});