diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index 07c5433fd3..1218671e41 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -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>; +} => { + 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); diff --git a/src/domain/evm/solidityMetadata.ts b/src/domain/evm/solidityMetadata.ts index ba2e146a57..85eceb32f3 100644 --- a/src/domain/evm/solidityMetadata.ts +++ b/src/domain/evm/solidityMetadata.ts @@ -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"; diff --git a/src/evm/EvmAdmissionError.ts b/src/evm/EvmAdmissionError.ts new file mode 100644 index 0000000000..7f0993c979 --- /dev/null +++ b/src/evm/EvmAdmissionError.ts @@ -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>, + options?: ErrorOptions, + ) { + super(code, options); + this.name = "EvmAdmissionError"; + } +} diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index fd532d6aee..4f50055207 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -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>; + readonly diagnostics: Readonly>; }; 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 = { diff --git a/src/evm/EvmoleInterfaceWorker.ts b/src/evm/EvmoleInterfaceWorker.ts index 214235f7cf..96e795749b 100644 --- a/src/evm/EvmoleInterfaceWorker.ts +++ b/src/evm/EvmoleInterfaceWorker.ts @@ -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 => { 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")), ); diff --git a/src/evm/SolidityMetadata.test.ts b/src/evm/SolidityMetadata.test.ts index 8d9fade9a6..73dbb407cb 100644 --- a/src/evm/SolidityMetadata.test.ts +++ b/src/evm/SolidityMetadata.test.ts @@ -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) => diff --git a/src/evm/SolidityMetadata.ts b/src/evm/SolidityMetadata.ts index 0afd626358..e43c06c63d 100644 --- a/src/evm/SolidityMetadata.ts +++ b/src/evm/SolidityMetadata.ts @@ -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 | 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(); 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>; + }; +} => { + 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), }; } }; diff --git a/src/filesystem/OwnedFileHandle.ts b/src/filesystem/OwnedFileHandle.ts index 8c1b7ede4e..3f54ffeb08 100644 --- a/src/filesystem/OwnedFileHandle.ts +++ b/src/filesystem/OwnedFileHandle.ts @@ -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 } @@ -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); } diff --git a/src/filesystem/OwnedStagedOutput.ts b/src/filesystem/OwnedStagedOutput.ts index 6e8e65894e..e0c0319842 100644 --- a/src/filesystem/OwnedStagedOutput.ts +++ b/src/filesystem/OwnedStagedOutput.ts @@ -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 { 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, + }); } } diff --git a/src/filesystem/StagedOutputError.ts b/src/filesystem/StagedOutputError.ts new file mode 100644 index 0000000000..ed5ab193ac --- /dev/null +++ b/src/filesystem/StagedOutputError.ts @@ -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>, + options?: ErrorOptions, + ) { + super(code, options); + this.name = "StagedOutputError"; + } +} diff --git a/src/ghidra/GhidraAdmissionError.ts b/src/ghidra/GhidraAdmissionError.ts new file mode 100644 index 0000000000..ed3e4af241 --- /dev/null +++ b/src/ghidra/GhidraAdmissionError.ts @@ -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>, + options?: ErrorOptions, + ) { + super(code, options); + this.name = "GhidraAdmissionError"; + } +} diff --git a/src/ghidra/GhidraAnalysisSeeds.test.ts b/src/ghidra/GhidraAnalysisSeeds.test.ts index a76b0003c6..7cf12e02a4 100644 --- a/src/ghidra/GhidraAnalysisSeeds.test.ts +++ b/src/ghidra/GhidraAnalysisSeeds.test.ts @@ -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", }, }); }); diff --git a/src/ghidra/GhidraAnalysisSeeds.ts b/src/ghidra/GhidraAnalysisSeeds.ts index 52d56a70fa..511bf1aaba 100644 --- a/src/ghidra/GhidraAnalysisSeeds.ts +++ b/src/ghidra/GhidraAnalysisSeeds.ts @@ -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"); diff --git a/src/ghidra/GhidraFileArtifacts.ts b/src/ghidra/GhidraFileArtifacts.ts index 0e68bdbd93..2bfbb6abb9 100644 --- a/src/ghidra/GhidraFileArtifacts.ts +++ b/src/ghidra/GhidraFileArtifacts.ts @@ -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" diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 4c8e375eed..f830572f61 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -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", diff --git a/src/ghidra/GhidraInstallationPolicy.ts b/src/ghidra/GhidraInstallationPolicy.ts index ddc15219ff..f3cdba5013 100644 --- a/src/ghidra/GhidraInstallationPolicy.ts +++ b/src/ghidra/GhidraInstallationPolicy.ts @@ -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. */ diff --git a/src/ghidra/GhidraJavaLaunch.ts b/src/ghidra/GhidraJavaLaunch.ts index 14a45159da..0a3bbc9e73 100644 --- a/src/ghidra/GhidraJavaLaunch.ts +++ b/src/ghidra/GhidraJavaLaunch.ts @@ -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, diff --git a/src/ghidra/GhidraJavaOptions.ts b/src/ghidra/GhidraJavaOptions.ts index 4dfd295aa9..e53ffbe3df 100644 --- a/src/ghidra/GhidraJavaOptions.ts +++ b/src/ghidra/GhidraJavaOptions.ts @@ -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(); diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts index 8d4bb59362..e890695d21 100644 --- a/src/ghidra/GhidraJavaProbe.ts +++ b/src/ghidra/GhidraJavaProbe.ts @@ -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 = diff --git a/src/ghidra/GhidraJavaProperties.ts b/src/ghidra/GhidraJavaProperties.ts index d41b446bd1..99cf8cf157 100644 --- a/src/ghidra/GhidraJavaProperties.ts +++ b/src/ghidra/GhidraJavaProperties.ts @@ -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, string> => { +): Result, GhidraJavaPropertiesCode> => { const values = new Map(); for (const line of logicalPropertyLines(content)) { let start = 0; @@ -88,7 +97,9 @@ function* logicalPropertyLines(content: string): Generator { if (continuing) yield logical; } -const decodePropertyEscapes = (value: string): Result => { +const decodePropertyEscapes = ( + value: string, +): Result => { let result = ""; for (let index = 0; index < value.length; index += 1) { const character = value[index]; @@ -97,8 +108,7 @@ const decodePropertyEscapes = (value: string): Result => { 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 => { 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; diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 6e1c99cedd..410fc1d7b9 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -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", diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index c3e9b9feb2..2c3257777e 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -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 => { 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 => { 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 }; diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index a53ecf205c..33f3d6e532 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -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 => { + if (!(cause instanceof GhidraAdmissionError) || cause.detail === undefined) + return {}; + const detail: Record = {}; + 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, diff --git a/src/ghidra/GhidraSessionValues.test.ts b/src/ghidra/GhidraSessionValues.test.ts index ee45839c58..f3831fc3fd 100644 --- a/src/ghidra/GhidraSessionValues.test.ts +++ b/src/ghidra/GhidraSessionValues.test.ts @@ -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", + }); }); }); diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index 8bb0903c75..684330bd8d 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -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); }; diff --git a/src/ghidra/extensions/GhidraExtensions.test.ts b/src/ghidra/extensions/GhidraExtensions.test.ts index 2df197d977..02242f45bd 100644 --- a/src/ghidra/extensions/GhidraExtensions.test.ts +++ b/src/ghidra/extensions/GhidraExtensions.test.ts @@ -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", + }, }); }); }); diff --git a/src/ghidra/extensions/GhidraExtensions.ts b/src/ghidra/extensions/GhidraExtensions.ts index 890ead74c3..c7aaa74cc0 100644 --- a/src/ghidra/extensions/GhidraExtensions.ts +++ b/src/ghidra/extensions/GhidraExtensions.ts @@ -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 }); diff --git a/src/ghidra/protocol.ts b/src/ghidra/protocol.ts index 150486c3c5..89949b968b 100644 --- a/src/ghidra/protocol.ts +++ b/src/ghidra/protocol.ts @@ -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 })); } }; diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index fd2a50407f..c54ee09350 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -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 => { - 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 => { 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(); 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(); 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; }; diff --git a/src/process/LinuxResourceLimits.test.ts b/src/process/LinuxResourceLimits.test.ts index 6cf5f5787d..f80959495a 100644 --- a/src/process/LinuxResourceLimits.test.ts +++ b/src/process/LinuxResourceLimits.test.ts @@ -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( diff --git a/src/process/LinuxResourceLimits.ts b/src/process/LinuxResourceLimits.ts index d05a82d0fc..53a8a69d56 100644 --- a/src/process/LinuxResourceLimits.ts +++ b/src/process/LinuxResourceLimits.ts @@ -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")); }, ); diff --git a/src/process/OwnedWorkerRuntime.test.ts b/src/process/OwnedWorkerRuntime.test.ts index 6b9f67a223..73e400b366 100644 --- a/src/process/OwnedWorkerRuntime.test.ts +++ b/src/process/OwnedWorkerRuntime.test.ts @@ -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(); diff --git a/src/process/OwnedWorkerRuntime.ts b/src/process/OwnedWorkerRuntime.ts index 0e93602ecc..0b9f66c7ac 100644 --- a/src/process/OwnedWorkerRuntime.ts +++ b/src/process/OwnedWorkerRuntime.ts @@ -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, ): Promise { 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 { 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 { @@ -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 => { diff --git a/src/process/PendingOperations.ts b/src/process/PendingOperations.ts index bcacd18ed8..bfe7882157 100644 --- a/src/process/PendingOperations.ts +++ b/src/process/PendingOperations.ts @@ -1,3 +1,5 @@ +import { ProcessAdmissionError } from "./ProcessAdmissionError.js"; + /** Timeout and cancellation projections for one correlated operation. */ export type PendingOperationOptions = { readonly signal?: AbortSignal; @@ -36,7 +38,7 @@ export class PendingOperations { /** Register one unique operation key and await its reply or cancellation. */ wait(key: Key, options: PendingOperationOptions): Promise { 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()); diff --git a/src/process/ProcessAdmissionError.ts b/src/process/ProcessAdmissionError.ts new file mode 100644 index 0000000000..3be14df65c --- /dev/null +++ b/src/process/ProcessAdmissionError.ts @@ -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>, + options?: ErrorOptions, + ) { + super(code, options); + this.name = "ProcessAdmissionError"; + } +} diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index d47e501936..15ca968f41 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -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); diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 7bb1f1ba2d..83946af8f2 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -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, diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index c674063c66..08cf60b3ca 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -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 = { diff --git a/src/process/capture/ProcessSensitiveOutput.ts b/src/process/capture/ProcessSensitiveOutput.ts index b2a20b2332..d599d28ed5 100644 --- a/src/process/capture/ProcessSensitiveOutput.ts +++ b/src/process/capture/ProcessSensitiveOutput.ts @@ -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 { diff --git a/src/wasm/WabtAdmissionError.ts b/src/wasm/WabtAdmissionError.ts new file mode 100644 index 0000000000..4a48ad38b8 --- /dev/null +++ b/src/wasm/WabtAdmissionError.ts @@ -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>, + options?: ErrorOptions, + ) { + super(code, options); + this.name = "WabtAdmissionError"; + } +} diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 61b9da8665..6efb08d4d3 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -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", + }, }, }); }); diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 6fde4aa261..0b7abc613f 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -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( diff --git a/src/wasm/WabtComment.ts b/src/wasm/WabtComment.ts index 0224e25ba3..6170efeb69 100644 --- a/src/wasm/WabtComment.ts +++ b/src/wasm/WabtComment.ts @@ -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"); }; diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 7348621b18..27ba86a1bc 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -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 && diff --git a/src/wasm/WabtInterface.test.ts b/src/wasm/WabtInterface.test.ts index 07cb08ac69..72e3a2253f 100644 --- a/src/wasm/WabtInterface.test.ts +++ b/src/wasm/WabtInterface.test.ts @@ -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", + }), ); }); diff --git a/src/wasm/WabtInterface.ts b/src/wasm/WabtInterface.ts index 9ba9260cc4..fc45d50c31 100644 --- a/src/wasm/WabtInterface.ts +++ b/src/wasm/WabtInterface.ts @@ -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) diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index fe4f0faaeb..fdfb420ed2 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -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"]; diff --git a/src/wasm/WabtOutput.ts b/src/wasm/WabtOutput.ts index a5265c9748..40a3196b2d 100644 --- a/src/wasm/WabtOutput.ts +++ b/src/wasm/WabtOutput.ts @@ -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, diff --git a/src/wasm/WabtString.test.ts b/src/wasm/WabtString.test.ts index a0c2d7cea2..afe7edfa40 100644 --- a/src/wasm/WabtString.test.ts +++ b/src/wasm/WabtString.test.ts @@ -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", + }), ); }); diff --git a/src/wasm/WabtString.ts b/src/wasm/WabtString.ts index 715e4cb565..885e8b76ea 100644 --- a/src/wasm/WabtString.ts +++ b/src/wasm/WabtString.ts @@ -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; } diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts index cbb0a3edc5..a5305728d8 100644 --- a/src/wasm/WabtWat.ts +++ b/src/wasm/WabtWat.ts @@ -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, diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index 696afb9338..ec1e1d5655 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -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" }), }); }); diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index b7b0611aa8..1f79921ba6 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -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"); diff --git a/tests/boundary/evm/solidityMetadataWorker.test.ts b/tests/boundary/evm/solidityMetadataWorker.test.ts index cd68439df8..6b665ec0e3 100644 --- a/tests/boundary/evm/solidityMetadataWorker.test.ts +++ b/tests/boundary/evm/solidityMetadataWorker.test.ts @@ -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); } diff --git a/tests/boundary/filesystem/ghidraBridgeSources.test.ts b/tests/boundary/filesystem/ghidraBridgeSources.test.ts index 45aa07136d..dc19ac0411 100644 --- a/tests/boundary/filesystem/ghidraBridgeSources.test.ts +++ b/tests/boundary/filesystem/ghidraBridgeSources.test.ts @@ -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", }, ]; diff --git a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts index 676a8fe062..5cc068c49f 100644 --- a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts +++ b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts @@ -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); diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts index 97668eb08b..0ac7f3564b 100644 --- a/tests/boundary/filesystem/ghidraPspExtension.test.ts +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -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); diff --git a/tests/boundary/filesystem/jsonOutputOwnership.test.ts b/tests/boundary/filesystem/jsonOutputOwnership.test.ts index 8535836ef9..9bb2ab3e3f 100644 --- a/tests/boundary/filesystem/jsonOutputOwnership.test.ts +++ b/tests/boundary/filesystem/jsonOutputOwnership.test.ts @@ -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"); diff --git a/tests/boundary/mcp/wasmArtifactMcp.test.ts b/tests/boundary/mcp/wasmArtifactMcp.test.ts index eb9d5fa467..83e04f6cc0 100644 --- a/tests/boundary/mcp/wasmArtifactMcp.test.ts +++ b/tests/boundary/mcp/wasmArtifactMcp.test.ts @@ -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(); diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index 64d224a3b0..e68bc04008 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -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]); diff --git a/tests/boundary/process/providerProcess.test.ts b/tests/boundary/process/providerProcess.test.ts index 6e3b709956..6a6103f2a6 100644 --- a/tests/boundary/process/providerProcess.test.ts +++ b/tests/boundary/process/providerProcess.test.ts @@ -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", + }); }); }); diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index b402432d38..ca7d30603d 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -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); diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 4c5508813d..273fd3df95 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -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", + }), + ); }); });