mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 03:15:54 +00:00
fix: report provider admission failures as typed codes (#8066)
* fix: report provider admission failures as typed codes * fix(evm): keep limiter path on admission diagnostics * fix: assert WABT and CBOR admission codes * fix: keep admission details on returned failures * fix(evm): detect repeated CBOR keys in the decoder --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
1 parent
3b647132c6
commit
317bf3fe23
63 files changed
+1021
-432
No files matched your search
@@ -44,6 +44,7 @@ import {
|
||||
} from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { OwnedStagedOutput } from "../filesystem/OwnedStagedOutput.js";
|
||||
import { StagedOutputError } from "../filesystem/StagedOutputError.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
/** Request control and its owning operation for an interruptible atomic write. */
|
||||
@@ -399,6 +400,7 @@ const writeTextPartsOwned = async (
|
||||
path: requestedPath,
|
||||
cleanup,
|
||||
published: { ...cause.outcome.value, path: requestedPath },
|
||||
...stagedOutputDiagnostics(cause.cleanupCause),
|
||||
}),
|
||||
cause.lease,
|
||||
);
|
||||
@@ -420,6 +422,12 @@ const writeTextPartsOwned = async (
|
||||
...(failure.systemCode === undefined
|
||||
? {}
|
||||
: { systemCode: failure.systemCode }),
|
||||
...stagedOutputDiagnostics(
|
||||
!cause.outcome.ok &&
|
||||
cause.outcome.error instanceof StagedOutputError
|
||||
? cause.outcome.error
|
||||
: cause.cleanupCause,
|
||||
),
|
||||
}),
|
||||
cause.lease,
|
||||
);
|
||||
@@ -445,6 +453,7 @@ const writeTextPartsOwned = async (
|
||||
...(failure.systemCode === undefined
|
||||
? {}
|
||||
: { systemCode: failure.systemCode }),
|
||||
...stagedOutputDiagnostics(cause),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -612,6 +621,15 @@ class StagedOutputCleanupFailure extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const stagedOutputDiagnostics = (
|
||||
cause: unknown,
|
||||
): {
|
||||
readonly diagnostics?: Readonly<Record<string, string | number>>;
|
||||
} => {
|
||||
if (!(cause instanceof StagedOutputError)) return {};
|
||||
return { diagnostics: { code: cause.code, ...cause.detail } };
|
||||
};
|
||||
|
||||
const failureMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message || cause.name : String(cause);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const EVM_ADMISSION_CODES = [
|
||||
"prlimit_not_absolute",
|
||||
"prlimit_not_file",
|
||||
"cleanup_incomplete",
|
||||
"cleanup_unconfirmed",
|
||||
"marker_mismatch",
|
||||
"request_path_missing",
|
||||
"tag_or_float",
|
||||
"text_bytes_unavailable",
|
||||
"collection_exceeds_bound",
|
||||
"nesting_exceeds_profile",
|
||||
"value_outside_profile",
|
||||
"repeated_map_key",
|
||||
"root_not_map",
|
||||
] as const;
|
||||
|
||||
export type EvmAdmissionCode = (typeof EVM_ADMISSION_CODES)[number];
|
||||
|
||||
/** EVM limiter, worker, or metadata admission failure. `code` is the stable identity. */
|
||||
export class EvmAdmissionError extends Error {
|
||||
readonly _tag = "EvmAdmissionError";
|
||||
|
||||
constructor(
|
||||
readonly code: EvmAdmissionCode,
|
||||
readonly detail?: Readonly<Record<string, string | number>>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, options);
|
||||
this.name = "EvmAdmissionError";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EvmAdmissionError } from "./EvmAdmissionError.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { safeParseUtf8Json } from "../domain/safeJson.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { access, readFile, stat, writeFile } from "node:fs/promises";
|
||||
@@ -110,7 +112,7 @@ export interface EvmLimiterProbeOptions {
|
||||
|
||||
type EvmLimiterSelection = {
|
||||
readonly path: string;
|
||||
readonly diagnostics: Readonly<Record<string, string>>;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
type EvmLimiterUnavailable = EvmLimiterSelection & {
|
||||
readonly reason: string;
|
||||
@@ -142,20 +144,29 @@ const selectEvmLimiter = async (
|
||||
const diagnostics = { limiter_path: path, platform, arch };
|
||||
try {
|
||||
if (!isAbsolute(path))
|
||||
throw new Error(
|
||||
"REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.",
|
||||
);
|
||||
throw new EvmAdmissionError("prlimit_not_absolute", {
|
||||
setting: "REA_EVM_PRLIMIT_COMMAND",
|
||||
});
|
||||
const fs = probes?.fs ?? { access, stat };
|
||||
await fs.access(path, constants.X_OK);
|
||||
throwIfAborted();
|
||||
if (!(await fs.stat(path)).isFile())
|
||||
throw new Error(
|
||||
"Configured prlimit must be a regular executable file, not a directory.",
|
||||
);
|
||||
throw new EvmAdmissionError("prlimit_not_file");
|
||||
throwIfAborted();
|
||||
return ok({ path, diagnostics });
|
||||
} catch (cause: unknown) {
|
||||
throwIfAborted();
|
||||
if (cause instanceof EvmAdmissionError)
|
||||
return err({
|
||||
path,
|
||||
diagnostics: {
|
||||
...diagnostics,
|
||||
code: cause.code,
|
||||
...cause.detail,
|
||||
},
|
||||
reason: cause.code,
|
||||
cause,
|
||||
});
|
||||
const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
return err({ path, diagnostics, reason, cause });
|
||||
}
|
||||
@@ -452,10 +463,13 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||
stopped.status === "incomplete" ||
|
||||
stopped.status === "not-owned"
|
||||
)
|
||||
throw new Error(
|
||||
throw new EvmAdmissionError(
|
||||
stopped.status === "incomplete"
|
||||
? stopped.reason
|
||||
: "Owned EVM worker cleanup did not confirm ownership.",
|
||||
? "cleanup_incomplete"
|
||||
: "cleanup_unconfirmed",
|
||||
stopped.status === "incomplete"
|
||||
? { reason: stopped.reason }
|
||||
: undefined,
|
||||
{ cause: stopped },
|
||||
);
|
||||
},
|
||||
@@ -511,6 +525,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||
selectedLimiter.error.reason,
|
||||
{
|
||||
cause: selectedLimiter.error.cause,
|
||||
diagnostics: selectedLimiter.error.diagnostics,
|
||||
userMessage: selectedLimiter.error.reason,
|
||||
},
|
||||
);
|
||||
@@ -813,9 +828,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||
marker = completed;
|
||||
}
|
||||
if (!marker.bytes.equals(Buffer.from("F")))
|
||||
throw new Error(
|
||||
"Private worker failure marker does not match the observed exit status.",
|
||||
);
|
||||
throw new EvmAdmissionError("marker_mismatch");
|
||||
fileFailure = { verified: true, failure: null };
|
||||
} catch (markerFailure: unknown) {
|
||||
fileFailure = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EvmAdmissionError } from "./EvmAdmissionError.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
@@ -23,7 +24,7 @@ class UnsupportedContainer extends Error {}
|
||||
const main = async (): Promise<void> => {
|
||||
const requestPath = process.argv[2];
|
||||
if (requestPath === undefined)
|
||||
throw new Error("Owned worker request path missing.");
|
||||
throw new EvmAdmissionError("request_path_missing");
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(requestPath, "utf8")),
|
||||
);
|
||||
|
||||
@@ -163,11 +163,28 @@ it("rejects repeated deeply nested container keys promptly", () => {
|
||||
inspectSolidityMetadata(bytecode("a2" + key + "01" + key + "02")),
|
||||
).toMatchObject({
|
||||
status: "not-decoded",
|
||||
diagnostic: expect.stringContaining("found repeat map key"),
|
||||
diagnostic: "repeated_map_key",
|
||||
admission: {
|
||||
code: "repeated_map_key",
|
||||
detail: { key_length: expect.any(Number) },
|
||||
},
|
||||
});
|
||||
expect(performance.now() - started).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("records the profile depth when CBOR nesting is refused", () => {
|
||||
expect(
|
||||
inspectSolidityMetadata(bytecode("a16178" + "81".repeat(65) + "00")),
|
||||
).toMatchObject({
|
||||
status: "not-decoded",
|
||||
diagnostic: "nesting_exceeds_profile",
|
||||
admission: {
|
||||
code: "nesting_exceeds_profile",
|
||||
detail: { max_depth: 64 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", "60006000f3", "000000", "00010001"])(
|
||||
"does not claim absent Solidity metadata for an unavailable trailer: %s",
|
||||
(hex) =>
|
||||
|
||||
+67
-23
@@ -1,3 +1,4 @@
|
||||
import { EvmAdmissionError } from "./EvmAdmissionError.js";
|
||||
import { decode, Tokenizer, Type, type Token } from "cborg";
|
||||
import type {
|
||||
SolidityMetadata,
|
||||
@@ -14,39 +15,52 @@ const decodeOptions = {
|
||||
allowUndefined: false,
|
||||
allowInfinity: false,
|
||||
allowNaN: false,
|
||||
rejectDuplicateMapKeys: true,
|
||||
rejectDuplicateMapKeys: false,
|
||||
retainStringBytes: true,
|
||||
} as const;
|
||||
|
||||
/** Bound recursive decoder entry before collection interpretation/allocation. */
|
||||
class BoundedMetadataTokenizer extends Tokenizer {
|
||||
readonly remaining: number[] = [];
|
||||
readonly #mapKeys: Array<Set<string> | undefined> = [];
|
||||
constructor(bytes: Uint8Array) {
|
||||
super(bytes, decodeOptions);
|
||||
}
|
||||
|
||||
override next(): Token {
|
||||
while (this.remaining.at(-1) === 0) this.remaining.pop();
|
||||
while (this.remaining.at(-1) === 0) {
|
||||
this.remaining.pop();
|
||||
this.#mapKeys.pop();
|
||||
}
|
||||
const token = super.next();
|
||||
if (
|
||||
Type.equals(token.type, Type.tag) ||
|
||||
Type.equals(token.type, Type.float)
|
||||
)
|
||||
throw new Error(
|
||||
"CBOR tags and floating-point values are outside this metadata profile.",
|
||||
);
|
||||
throw new EvmAdmissionError("tag_or_float");
|
||||
if (Type.equals(token.type, Type.string)) {
|
||||
if (token.byteValue !== undefined)
|
||||
token.value = utf8.decode(token.byteValue);
|
||||
// cborg answers the shortest-form empty text (0x60) with a shared token that has no
|
||||
// bytes; it can only mean "", which is already its value.
|
||||
else if (token.value !== "" || token.encodedLength !== 1)
|
||||
throw new Error(
|
||||
"CBOR text bytes were unavailable for strict UTF-8 validation.",
|
||||
);
|
||||
throw new EvmAdmissionError("text_bytes_unavailable");
|
||||
}
|
||||
const parent = this.remaining.length - 1;
|
||||
const mapKeys = parent >= 0 ? this.#mapKeys[parent] : undefined;
|
||||
const onKey =
|
||||
mapKeys !== undefined && parent >= 0 && this.remaining[parent]! % 2 === 0;
|
||||
if (parent >= 0) this.remaining[parent]!--;
|
||||
if (
|
||||
onKey &&
|
||||
mapKeys !== undefined &&
|
||||
!Type.equals(token.type, Type.map) &&
|
||||
!Type.equals(token.type, Type.array)
|
||||
) {
|
||||
const identity = keyIdentity(token.value);
|
||||
if (mapKeys.has(identity)) throw repeatedMapKey(identity);
|
||||
mapKeys.add(identity);
|
||||
}
|
||||
if (
|
||||
Type.equals(token.type, Type.map) ||
|
||||
Type.equals(token.type, Type.array)
|
||||
@@ -61,11 +75,14 @@ class BoundedMetadataTokenizer extends Tokenizer {
|
||||
children < 0 ||
|
||||
children > this.data.length
|
||||
)
|
||||
throw new Error(
|
||||
"Declared CBOR collection exceeds the encoded item-byte bound.",
|
||||
);
|
||||
throw new EvmAdmissionError("collection_exceeds_bound");
|
||||
if (this.remaining.length >= MAX_DEPTH)
|
||||
throw new Error("CBOR nesting exceeds the 64-level metadata profile.");
|
||||
throw new EvmAdmissionError("nesting_exceeds_profile", {
|
||||
max_depth: MAX_DEPTH,
|
||||
});
|
||||
this.#mapKeys.push(
|
||||
Type.equals(token.type, Type.map) ? new Set() : undefined,
|
||||
);
|
||||
this.remaining.push(children);
|
||||
}
|
||||
return token;
|
||||
@@ -85,9 +102,7 @@ const scalar = (value: unknown): SolidityMetadataScalar => {
|
||||
if (value instanceof Map) return { kind: "uninterpreted", container: "map" };
|
||||
if (Array.isArray(value))
|
||||
return { kind: "uninterpreted", container: "array" };
|
||||
throw new Error(
|
||||
"CBOR decoder returned a value outside the admitted profile.",
|
||||
);
|
||||
throw new EvmAdmissionError("value_outside_profile");
|
||||
};
|
||||
|
||||
/** Value identity for duplicate-key checks; map entry order is not significant. */
|
||||
@@ -101,14 +116,21 @@ const keyIdentity = (value: unknown): string => {
|
||||
return JSON.stringify(scalar(value));
|
||||
};
|
||||
|
||||
const repeatedMapKey = (identity: string): EvmAdmissionError =>
|
||||
new EvmAdmissionError(
|
||||
"repeated_map_key",
|
||||
identity.length <= 128
|
||||
? { key: identity }
|
||||
: { key_length: identity.length },
|
||||
);
|
||||
|
||||
/** Check every map boundary, even when nested values remain uninterpreted. */
|
||||
const validateMapKeys = (value: unknown): void => {
|
||||
if (value instanceof Map) {
|
||||
const seen = new Set<string>();
|
||||
for (const [key, item] of value) {
|
||||
const identity = keyIdentity(key);
|
||||
if (seen.has(identity))
|
||||
throw new Error(`CBOR decode error: found repeat map key ${identity}`);
|
||||
if (seen.has(identity)) throw repeatedMapKey(identity);
|
||||
seen.add(identity);
|
||||
validateMapKeys(key);
|
||||
validateMapKeys(item);
|
||||
@@ -118,6 +140,32 @@ const validateMapKeys = (value: unknown): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Keep the typed code and its detail; map only the decoder's duplicate-key throw. */
|
||||
const solidityFailure = (
|
||||
cause: unknown,
|
||||
): {
|
||||
readonly diagnostic: string;
|
||||
readonly admission?: {
|
||||
readonly code: string;
|
||||
readonly detail?: Readonly<Record<string, string | number>>;
|
||||
};
|
||||
} => {
|
||||
if (cause instanceof EvmAdmissionError)
|
||||
return {
|
||||
diagnostic: cause.code,
|
||||
admission: {
|
||||
code: cause.code,
|
||||
...(cause.detail === undefined ? {} : { detail: cause.detail }),
|
||||
},
|
||||
};
|
||||
return {
|
||||
diagnostic: (cause instanceof Error ? cause.message : String(cause)).slice(
|
||||
0,
|
||||
512,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse only a plausible terminal root-map candidate; never establish absence. */
|
||||
export const inspectSolidityMetadata = (
|
||||
bytes: Uint8Array,
|
||||
@@ -148,8 +196,7 @@ export const inspectSolidityMetadata = (
|
||||
...decodeOptions,
|
||||
tokenizer: new BoundedMetadataTokenizer(selected),
|
||||
});
|
||||
if (!(raw instanceof Map))
|
||||
throw new Error("Terminal CBOR candidate did not decode to a root map.");
|
||||
if (!(raw instanceof Map)) throw new EvmAdmissionError("root_not_map");
|
||||
validateMapKeys(raw);
|
||||
// cborg compares Map keys by identity, so repeated byte-string or container
|
||||
// keys (each its own object) pass its duplicate check; compare by value at
|
||||
@@ -185,10 +232,7 @@ export const inspectSolidityMetadata = (
|
||||
status: "not-decoded",
|
||||
entries: [],
|
||||
compiler_version: null,
|
||||
diagnostic: (cause instanceof Error
|
||||
? cause.message
|
||||
: String(cause)
|
||||
).slice(0, 512),
|
||||
...solidityFailure(cause),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
/** Close admission for one owned file handle. `code` is the stable identity. */
|
||||
export class OwnedFileHandleError extends Error {
|
||||
readonly _tag = "OwnedFileHandleError";
|
||||
|
||||
constructor(readonly code: "handle_invalidated") {
|
||||
super(code);
|
||||
this.name = "OwnedFileHandleError";
|
||||
}
|
||||
}
|
||||
|
||||
type CloseState =
|
||||
| { readonly kind: "open" }
|
||||
| { readonly kind: "closing"; readonly attempt: Promise<void> }
|
||||
@@ -20,9 +30,7 @@ export class OwnedFileHandle<
|
||||
if (this.#state.kind === "closing") return this.#state.attempt;
|
||||
if (this.#state.kind === "failed") return Promise.reject(this.#state.cause);
|
||||
if (this.handle.fd < 0) {
|
||||
const cause = new Error(
|
||||
"File handle was invalidated before this owner confirmed its close",
|
||||
);
|
||||
const cause = new OwnedFileHandleError("handle_invalidated");
|
||||
this.#state = { kind: "failed", cause };
|
||||
return Promise.reject(cause);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { StagedOutputError } from "./StagedOutputError.js";
|
||||
import { lstat, readdir, rmdir, unlink } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
|
||||
@@ -48,7 +49,7 @@ export class OwnedStagedOutput {
|
||||
|
||||
setFile(file: OwnedFileHandle): void {
|
||||
if (this.#file !== undefined)
|
||||
throw new Error("Staged output already owns a file handle");
|
||||
throw new StagedOutputError("handle_already_owned");
|
||||
this.#file = file;
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ export class OwnedStagedOutput {
|
||||
if (this.#file !== undefined && !this.#fileClosed)
|
||||
await this.#observeFileIdentity();
|
||||
if (this.#fileIdentity === undefined)
|
||||
throw new Error("Cannot verify staged file identity", {
|
||||
throw new StagedOutputError("identity_unverified", undefined, {
|
||||
cause: this.#identityFailure,
|
||||
});
|
||||
const stagedFile = await lstat(content);
|
||||
@@ -67,7 +68,7 @@ export class OwnedStagedOutput {
|
||||
stagedFile.isSymbolicLink() ||
|
||||
!sameIdentity(stagedFile, this.#fileIdentity)
|
||||
)
|
||||
throw new Error(`Staged file identity changed: ${content}`);
|
||||
throw new StagedOutputError("identity_changed", { path: content });
|
||||
}
|
||||
|
||||
/** Observe the descriptor's identity after its last write, then close it. */
|
||||
@@ -134,9 +135,11 @@ export class OwnedStagedOutput {
|
||||
) {
|
||||
stagedFile = undefined;
|
||||
} else if (errorCode(cause) === "ENOENT") {
|
||||
throw new Error(`Owned staged file disappeared: ${content}`, {
|
||||
cause,
|
||||
});
|
||||
throw new StagedOutputError(
|
||||
"file_disappeared",
|
||||
{ path: content },
|
||||
{ cause },
|
||||
);
|
||||
} else {
|
||||
throw cause;
|
||||
}
|
||||
@@ -148,7 +151,7 @@ export class OwnedStagedOutput {
|
||||
this.#fileIdentity === undefined ||
|
||||
!sameIdentity(stagedFile, this.#fileIdentity)
|
||||
)
|
||||
throw new Error(`Staged file identity changed: ${content}`);
|
||||
throw new StagedOutputError("identity_changed", { path: content });
|
||||
await unlink(content);
|
||||
this.#fileRemovedByOwner = true;
|
||||
}
|
||||
@@ -156,20 +159,20 @@ export class OwnedStagedOutput {
|
||||
|
||||
const remaining = await readdir(this.directory);
|
||||
if (remaining.length > 0)
|
||||
throw new Error(
|
||||
`Unexpected staging entries remain in ${this.directory}: ${remaining.join(", ")}`,
|
||||
);
|
||||
throw new StagedOutputError("unexpected_entries", {
|
||||
count: remaining.length,
|
||||
entries: remaining.join(","),
|
||||
});
|
||||
await this.#verifiedDirectory();
|
||||
await rmdir(this.directory);
|
||||
}
|
||||
|
||||
async #verifiedDirectory(): Promise<void> {
|
||||
if (this.directoryIdentity === undefined)
|
||||
throw new Error(
|
||||
`Cannot verify staging directory ownership: ${this.directory}`,
|
||||
{
|
||||
cause: this.#identityFailure,
|
||||
},
|
||||
throw new StagedOutputError(
|
||||
"directory_unverified",
|
||||
{ directory: this.directory },
|
||||
{ cause: this.#identityFailure },
|
||||
);
|
||||
const directory = await lstat(this.directory);
|
||||
if (
|
||||
@@ -177,7 +180,9 @@ export class OwnedStagedOutput {
|
||||
directory.isSymbolicLink() ||
|
||||
!sameIdentity(directory, this.directoryIdentity)
|
||||
)
|
||||
throw new Error(`Staging directory identity changed: ${this.directory}`);
|
||||
throw new StagedOutputError("directory_identity_changed", {
|
||||
directory: this.directory,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const STAGED_OUTPUT_CODES = [
|
||||
"handle_already_owned",
|
||||
"identity_unverified",
|
||||
"identity_changed",
|
||||
"file_disappeared",
|
||||
"unexpected_entries",
|
||||
"directory_unverified",
|
||||
"directory_identity_changed",
|
||||
] as const;
|
||||
|
||||
export type StagedOutputCode = (typeof STAGED_OUTPUT_CODES)[number];
|
||||
|
||||
/** Owned staged-output admission failure. `code` is the stable identity. */
|
||||
export class StagedOutputError extends Error {
|
||||
readonly _tag = "StagedOutputError";
|
||||
|
||||
constructor(
|
||||
readonly code: StagedOutputCode,
|
||||
readonly detail?: Readonly<Record<string, string | number>>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, options);
|
||||
this.name = "StagedOutputError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const GHIDRA_ADMISSION_CODES = [
|
||||
"existing_project_windows",
|
||||
"existing_project_program_name",
|
||||
"existing_project_psp",
|
||||
"existing_project_dos",
|
||||
"existing_project_raw_binary",
|
||||
"existing_project_language_override",
|
||||
"existing_project_mips_soft_float",
|
||||
"existing_project_analysis",
|
||||
"gamecube_dol_combined",
|
||||
"psp_combined",
|
||||
"psp_raw_binary",
|
||||
"mips_soft_float_scope",
|
||||
"language_override_dos",
|
||||
"language_override_raw_binary",
|
||||
"raw_binary_dos",
|
||||
"invalid_environment_entry",
|
||||
"invalid_jvm_argument",
|
||||
"unterminated_quote",
|
||||
"platform_override",
|
||||
"argument_files_exceed_bound",
|
||||
"nested_argument_file",
|
||||
"argument_file_unreadable",
|
||||
"argument_file_path_invalid",
|
||||
"argument_files_require_scope",
|
||||
"prepared_options_exceed_bound",
|
||||
"output_unobserved",
|
||||
"handshake_identity",
|
||||
"response_shape",
|
||||
"response_json",
|
||||
"seed_path_differs",
|
||||
"seed_changed",
|
||||
"canonical_path",
|
||||
"nesting_exceeds_limit",
|
||||
"entry_kind",
|
||||
"component_missing",
|
||||
"loader_jar_missing",
|
||||
"bytes_exceed_limit",
|
||||
"properties_exceed_limit",
|
||||
"component_changed",
|
||||
"component_truncated",
|
||||
"properties_invalid",
|
||||
"metadata_mismatch",
|
||||
"host_unsupported",
|
||||
"inventory_exceeds_limit",
|
||||
"not_jar",
|
||||
"unregistered",
|
||||
"path_differs",
|
||||
"digest_differs",
|
||||
] as const;
|
||||
|
||||
export type GhidraAdmissionCode = (typeof GHIDRA_ADMISSION_CODES)[number];
|
||||
|
||||
/** Ghidra launch, extension, or profile admission failure. `code` is the stable identity. */
|
||||
export class GhidraAdmissionError extends Error {
|
||||
readonly _tag = "GhidraAdmissionError";
|
||||
|
||||
constructor(
|
||||
readonly code: GhidraAdmissionCode,
|
||||
readonly detail?: Readonly<Record<string, string | number>>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, options);
|
||||
this.name = "GhidraAdmissionError";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
const GHIDRA_JAVA_PROPERTIES_CODES = [
|
||||
"trailing_escape",
|
||||
"malformed_unicode_escape",
|
||||
] as const;
|
||||
|
||||
/** Stable identity for a Java properties escape that invalidates the file. */
|
||||
export type GhidraJavaPropertiesCode =
|
||||
(typeof GHIDRA_JAVA_PROPERTIES_CODES)[number];
|
||||
|
||||
/** Parse Java Properties.load(InputStream) syntax from its ISO-8859-1 text. */
|
||||
export const parseGhidraJavaProperties = (
|
||||
content: string,
|
||||
): Result<ReadonlyMap<string, string>, string> => {
|
||||
): Result<ReadonlyMap<string, string>, GhidraJavaPropertiesCode> => {
|
||||
const values = new Map<string, string>();
|
||||
for (const line of logicalPropertyLines(content)) {
|
||||
let start = 0;
|
||||
@@ -88,7 +97,9 @@ function* logicalPropertyLines(content: string): Generator<string> {
|
||||
if (continuing) yield logical;
|
||||
}
|
||||
|
||||
const decodePropertyEscapes = (value: string): Result<string, string> => {
|
||||
const decodePropertyEscapes = (
|
||||
value: string,
|
||||
): Result<string, GhidraJavaPropertiesCode> => {
|
||||
let result = "";
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
@@ -97,8 +108,7 @@ const decodePropertyEscapes = (value: string): Result<string, string> => {
|
||||
continue;
|
||||
}
|
||||
const escaped = value[++index];
|
||||
if (escaped === undefined)
|
||||
return { ok: false, error: "Trailing escape in Java properties value" };
|
||||
if (escaped === undefined) return { ok: false, error: "trailing_escape" };
|
||||
if (escaped === "t") result += "\t";
|
||||
else if (escaped === "n") result += "\n";
|
||||
else if (escaped === "r") result += "\r";
|
||||
@@ -108,7 +118,7 @@ const decodePropertyEscapes = (value: string): Result<string, string> => {
|
||||
if (digits.length !== 4 || !/^[0-9a-f]{4}$/iu.test(digits))
|
||||
return {
|
||||
ok: false,
|
||||
error: "Malformed Unicode escape in Java properties file",
|
||||
error: "malformed_unicode_escape",
|
||||
};
|
||||
result += String.fromCharCode(Number.parseInt(digits, 16));
|
||||
index += 4;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, opendir, realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
@@ -70,17 +71,15 @@ const fingerprint = async (
|
||||
): Promise<GhidraPspExtension> => {
|
||||
assertArtifactReadActive(signal);
|
||||
if (!isAbsolute(root) || (await realpath(root)) !== root)
|
||||
throw new Error(
|
||||
"The installed Allegrex directory must be a canonical non-symlink path.",
|
||||
);
|
||||
throw new GhidraAdmissionError("canonical_path");
|
||||
const files: string[] = [];
|
||||
let entries = 1; // The root itself consumes one inventory entry.
|
||||
const visit = async (relative: string, depth: number): Promise<void> => {
|
||||
assertArtifactReadActive(signal);
|
||||
if (depth > MAX_DEPTH)
|
||||
throw new Error(
|
||||
"Allegrex extension directory nesting exceeds the inspection limit.",
|
||||
);
|
||||
throw new GhidraAdmissionError("nesting_exceeds_limit", {
|
||||
max_depth: MAX_DEPTH,
|
||||
});
|
||||
const path = join(root, relative);
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isDirectory()) {
|
||||
@@ -99,17 +98,16 @@ const fingerprint = async (
|
||||
);
|
||||
} else if (metadata.isFile()) {
|
||||
files.push(relative);
|
||||
} else
|
||||
throw new Error(
|
||||
`Allegrex extension entries must be regular files or directories: ${relative}`,
|
||||
);
|
||||
} else throw new GhidraAdmissionError("entry_kind", { relative });
|
||||
};
|
||||
await visit("", 0);
|
||||
for (const required of REQUIRED)
|
||||
if (!files.includes(required))
|
||||
throw new Error(`Missing installed Allegrex component: ${required}`);
|
||||
throw new GhidraAdmissionError("component_missing", {
|
||||
component: required,
|
||||
});
|
||||
if (!files.some((path) => path.startsWith("lib/") && path.endsWith(".jar")))
|
||||
throw new Error("The installed Allegrex extension has no loader JAR.");
|
||||
throw new GhidraAdmissionError("loader_jar_missing");
|
||||
const hash = createHash("sha256");
|
||||
let bytesRead = 0;
|
||||
let properties = Buffer.alloc(0);
|
||||
@@ -125,13 +123,13 @@ const fingerprint = async (
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
before.size > MAX_BYTES - bytesRead
|
||||
)
|
||||
throw new Error(
|
||||
"Allegrex extension bytes exceed the inspection limit.",
|
||||
);
|
||||
throw new GhidraAdmissionError("bytes_exceed_limit", {
|
||||
max_bytes: MAX_BYTES,
|
||||
});
|
||||
if (relative === "extension.properties" && before.size > 65536)
|
||||
throw new Error(
|
||||
"Allegrex extension properties exceed the inspection limit.",
|
||||
);
|
||||
throw new GhidraAdmissionError("properties_exceed_limit", {
|
||||
max_bytes: 65536,
|
||||
});
|
||||
hash.update(JSON.stringify([relative, before.size]));
|
||||
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
|
||||
let offset = 0;
|
||||
@@ -150,23 +148,19 @@ const fingerprint = async (
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
if (offset > before.size)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
throw new GhidraAdmissionError("component_changed", { relative });
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
hash.update(chunk);
|
||||
if (relative === "extension.properties")
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
if (offset !== before.size)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
throw new GhidraAdmissionError("component_truncated", { relative });
|
||||
if (
|
||||
!sameRegularFileState(before, await handle.stat()) ||
|
||||
!sameRegularFileState(before, await lstat(path))
|
||||
)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
throw new GhidraAdmissionError("component_changed", { relative });
|
||||
bytesRead += before.size;
|
||||
if (relative === "extension.properties")
|
||||
properties = Buffer.concat(chunks);
|
||||
@@ -179,18 +173,18 @@ const fingerprint = async (
|
||||
properties.toString("latin1"),
|
||||
);
|
||||
if (!parsedProperties.ok)
|
||||
throw new Error(
|
||||
`Invalid Allegrex extension properties: ${parsedProperties.error}`,
|
||||
);
|
||||
throw new GhidraAdmissionError("properties_invalid", {
|
||||
parser_code: parsedProperties.error,
|
||||
});
|
||||
const property = (name: string): string | undefined =>
|
||||
parsedProperties.value.get(name);
|
||||
if (
|
||||
property("name") !== "ghidra-allegrex" ||
|
||||
property("version") !== ghidraVersion
|
||||
)
|
||||
throw new Error(
|
||||
`Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`,
|
||||
);
|
||||
throw new GhidraAdmissionError("metadata_mismatch", {
|
||||
ghidra_version: ghidraVersion,
|
||||
});
|
||||
assertArtifactReadActive(signal);
|
||||
return {
|
||||
id: "ghidra-allegrex",
|
||||
@@ -213,9 +207,10 @@ export const resolveGhidraPspExtension = async (
|
||||
installation.platform !== "linux" ||
|
||||
installation.architecture !== "x64"
|
||||
)
|
||||
throw new Error(
|
||||
"The initial PSP profile requires a supported Linux x64 Ghidra installation.",
|
||||
);
|
||||
throw new GhidraAdmissionError("host_unsupported", {
|
||||
platform: installation.platform,
|
||||
architecture: installation.architecture,
|
||||
});
|
||||
const root = join(
|
||||
await realpath(installation.installDir),
|
||||
"Ghidra",
|
||||
@@ -241,7 +236,16 @@ const ghidraPspInspectionError = (cause: unknown): AnalysisError => {
|
||||
const cleanup =
|
||||
cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined;
|
||||
const { cause: primary } = artifactFilePrimaryFacts(cause);
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${primary instanceof Error ? primary.message : String(primary)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
const admission =
|
||||
primary instanceof GhidraAdmissionError ? primary : undefined;
|
||||
const identity =
|
||||
admission?.code ??
|
||||
(primary instanceof Error ? primary.message : String(primary));
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${identity} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
const diagnostics =
|
||||
admission === undefined
|
||||
? undefined
|
||||
: { code: admission.code, ...admission.detail };
|
||||
const error =
|
||||
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
|
||||
? new AnalysisCancelledError("resolve_analysis_profile", {
|
||||
@@ -256,6 +260,7 @@ const ghidraPspInspectionError = (cause: unknown): AnalysisError => {
|
||||
cause,
|
||||
userMessage: reason,
|
||||
cleanup,
|
||||
...(diagnostics === undefined ? {} : { diagnostics }),
|
||||
},
|
||||
);
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
@@ -282,9 +287,9 @@ const readChildren = async (
|
||||
assertArtifactReadActive(signal);
|
||||
if (child === null) break;
|
||||
if (names.length >= remainingEntries)
|
||||
throw new Error(
|
||||
"Allegrex extension inventory exceeds the inspection limit.",
|
||||
);
|
||||
throw new GhidraAdmissionError("inventory_exceeds_limit", {
|
||||
max_entries: MAX_ENTRIES,
|
||||
});
|
||||
names.push(child.name);
|
||||
}
|
||||
outcome = { kind: "completed", names };
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
import type { GhidraDiagnosticsScope } from "./GhidraDiagnostics.js";
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
import { GhidraLaunchSupportError } from "./GhidraJavaLaunch.js";
|
||||
import {
|
||||
GhidraLaunchError,
|
||||
@@ -281,6 +282,7 @@ const describeErrorCause = (
|
||||
message: message.text,
|
||||
...(message.truncated ? { message_truncated: true } : {}),
|
||||
...causeCode(cause, redact),
|
||||
...causeAdmissionDetail(cause, redact),
|
||||
...causeSignal(cause, redact),
|
||||
...causeCommandOutcome(cause, redact),
|
||||
...causeCompilerOutcome(cause, redact),
|
||||
@@ -319,6 +321,24 @@ const causeCommandOutcome = (
|
||||
};
|
||||
};
|
||||
|
||||
const causeAdmissionDetail = (
|
||||
cause: Error,
|
||||
redact: (value: string, incomplete?: boolean) => string,
|
||||
): Record<string, JsonValue> => {
|
||||
if (!(cause instanceof GhidraAdmissionError) || cause.detail === undefined)
|
||||
return {};
|
||||
const detail: Record<string, JsonValue> = {};
|
||||
for (const [key, value] of Object.entries(cause.detail)) {
|
||||
if (typeof value === "number") {
|
||||
detail[key] = value;
|
||||
continue;
|
||||
}
|
||||
const bounded = boundedUtf8Text(redact(value), MAX_CAUSE_FIELD_BYTES);
|
||||
detail[key] = bounded.text;
|
||||
}
|
||||
return { detail };
|
||||
};
|
||||
|
||||
const causeCode = (
|
||||
cause: Error,
|
||||
redact: (value: string, incomplete?: boolean) => string,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -579,10 +580,10 @@ export const createDarwinProcessRunTokenReader = (
|
||||
};
|
||||
|
||||
const compile = async (signal?: AbortSignal): Promise<string> => {
|
||||
if (closed) throw new Error("Darwin process token reader is closed");
|
||||
if (closed) throw new ProcessAdmissionError("reader_closed");
|
||||
signal?.throwIfAborted();
|
||||
while (true) {
|
||||
if (closed) throw new Error("Darwin process token reader is closed");
|
||||
if (closed) throw new ProcessAdmissionError("reader_closed");
|
||||
signal?.throwIfAborted();
|
||||
if (executable !== undefined) return executable;
|
||||
if (compilation === undefined && root !== undefined) {
|
||||
@@ -642,7 +643,7 @@ export const createDarwinProcessRunTokenReader = (
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
if (closed)
|
||||
return Promise.reject(new Error("Darwin process token reader is closed"));
|
||||
return Promise.reject(new ProcessAdmissionError("reader_closed"));
|
||||
const pending = (async () => {
|
||||
const binary = await compile(signal);
|
||||
const { stdout } = await execFileOutput(binary, arguments_, {
|
||||
@@ -670,25 +671,21 @@ export const createDarwinProcessRunTokenReader = (
|
||||
const stdout = await runHelper(pids, signal);
|
||||
const decoded = safeParseJson(stdout);
|
||||
if (!decoded.ok)
|
||||
throw new Error(
|
||||
`macOS process token helper returned invalid JSON: ${decoded.error}`,
|
||||
throw new ProcessAdmissionError(
|
||||
"token_json_invalid",
|
||||
{ parser: decoded.error },
|
||||
{ cause: decoded.cause },
|
||||
);
|
||||
const parsed = responseSchema.safeParse(decoded.value);
|
||||
if (!parsed.success)
|
||||
throw new Error(
|
||||
"macOS process token helper returned an invalid response",
|
||||
{
|
||||
cause: parsed.error,
|
||||
},
|
||||
);
|
||||
throw new ProcessAdmissionError("token_response_invalid", undefined, {
|
||||
cause: parsed.error,
|
||||
});
|
||||
const requested = new Set(processes.map(({ pid }) => pid));
|
||||
const observations = new Map<number, ProcessRunTokenObservation>();
|
||||
for (const result of parsed.data.results) {
|
||||
if (!requested.has(result.pid) || observations.has(result.pid))
|
||||
throw new Error(
|
||||
"macOS process token helper returned an unexpected PID set",
|
||||
);
|
||||
throw new ProcessAdmissionError("token_pid_set");
|
||||
observations.set(
|
||||
result.pid,
|
||||
result.state === "readable"
|
||||
@@ -701,9 +698,7 @@ export const createDarwinProcessRunTokenReader = (
|
||||
);
|
||||
}
|
||||
if (observations.size !== requested.size)
|
||||
throw new Error(
|
||||
"macOS process token helper returned an incomplete PID set",
|
||||
);
|
||||
throw new ProcessAdmissionError("token_pid_incomplete");
|
||||
return observations;
|
||||
};
|
||||
|
||||
@@ -716,25 +711,21 @@ export const createDarwinProcessRunTokenReader = (
|
||||
const stdout = await runHelper(["--identities", ...pids], signal);
|
||||
const decoded = safeParseJson(stdout);
|
||||
if (!decoded.ok)
|
||||
throw new Error(
|
||||
`macOS process identity helper returned invalid JSON: ${decoded.error}`,
|
||||
throw new ProcessAdmissionError(
|
||||
"identity_json_invalid",
|
||||
{ parser: decoded.error },
|
||||
{ cause: decoded.cause },
|
||||
);
|
||||
const parsed = identityResponseSchema.safeParse(decoded.value);
|
||||
if (!parsed.success)
|
||||
throw new Error(
|
||||
"macOS process identity helper returned an invalid response",
|
||||
{
|
||||
cause: parsed.error,
|
||||
},
|
||||
);
|
||||
throw new ProcessAdmissionError("identity_response_invalid", undefined, {
|
||||
cause: parsed.error,
|
||||
});
|
||||
const requested = new Set(processes.map(({ pid }) => pid));
|
||||
const observations = new Map<number, ProcessIdentityObservation>();
|
||||
for (const result of parsed.data.results) {
|
||||
if (!requested.has(result.pid) || observations.has(result.pid))
|
||||
throw new Error(
|
||||
"macOS process identity helper returned an unexpected PID set",
|
||||
);
|
||||
throw new ProcessAdmissionError("identity_pid_set");
|
||||
observations.set(
|
||||
result.pid,
|
||||
result.state === "readable"
|
||||
@@ -768,9 +759,7 @@ export const createDarwinProcessRunTokenReader = (
|
||||
);
|
||||
}
|
||||
if (observations.size !== requested.size)
|
||||
throw new Error(
|
||||
"macOS process identity helper returned an incomplete PID set",
|
||||
);
|
||||
throw new ProcessAdmissionError("identity_pid_incomplete");
|
||||
return observations;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
|
||||
import { PrivateRuntimeRoot } from "./PrivateRuntimeRoot.js";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
import {
|
||||
@@ -211,14 +212,14 @@ export class OwnedWorkerRuntime {
|
||||
initialize?: (supervisor: ProviderProcessSupervisor) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (this.#terminalCloseRequested || this.#closing)
|
||||
return Promise.reject(new Error("Owned worker runtime is closing"));
|
||||
return Promise.reject(new ProcessAdmissionError("runtime_closing"));
|
||||
if (this.#acquisition !== undefined) return this.#acquisition;
|
||||
if (this.#supervisor !== undefined && !this.#registry.has(this))
|
||||
return Promise.resolve();
|
||||
if (this.options.retryCleanup !== undefined && this.#registry.has(this))
|
||||
return this.#retryOwnedCleanup().then(() => {
|
||||
if (this.#terminalCloseRequested || this.#closing)
|
||||
throw new Error("Owned worker runtime is closing");
|
||||
throw new ProcessAdmissionError("runtime_closing");
|
||||
return this.start(spawnOptions, initialize);
|
||||
});
|
||||
const ownRetry = this.#registry.has(this)
|
||||
@@ -241,7 +242,7 @@ export class OwnedWorkerRuntime {
|
||||
onStdout(listener: (chunk: Buffer | string) => void): void {
|
||||
const stream = this.#supervisor?.launch.process.stdout;
|
||||
if (stream === null || stream === undefined)
|
||||
throw new Error("Owned worker runtime has no stdout stream");
|
||||
throw new ProcessAdmissionError("stdout_missing");
|
||||
stream.on("data", listener);
|
||||
this.#stdoutListeners.add({ stream, listener });
|
||||
}
|
||||
@@ -330,12 +331,12 @@ export class OwnedWorkerRuntime {
|
||||
| undefined,
|
||||
): Promise<void> {
|
||||
await this.#registry.retry(this);
|
||||
if (this.#closing) throw new Error("Owned worker runtime is closing");
|
||||
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
|
||||
this.#root ??= await PrivateRuntimeRoot.create({
|
||||
prefix: this.options.rootPrefix,
|
||||
});
|
||||
this.#cleanupEpoch ??= new OwnedWorkerRuntimeCleanupEpoch(this.#root.path);
|
||||
if (this.#closing) throw new Error("Owned worker runtime is closing");
|
||||
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
|
||||
this.#lastRunId = spawnOptions.runId;
|
||||
const spawned = await (this.options.launcher ?? spawnOwnedProviderProcess)({
|
||||
...spawnOptions,
|
||||
@@ -356,9 +357,9 @@ export class OwnedWorkerRuntime {
|
||||
);
|
||||
// A close may have begun while spawn was pending. Adopt the process first;
|
||||
// close waits for this acquisition and then stops the exact adopted owner.
|
||||
if (this.#closing) throw new Error("Owned worker runtime is closing");
|
||||
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
|
||||
await initialize?.(this.#supervisor);
|
||||
if (this.#closing) throw new Error("Owned worker runtime is closing");
|
||||
if (this.#closing) throw new ProcessAdmissionError("runtime_closing");
|
||||
}
|
||||
|
||||
async #stop(): Promise<ProviderProcessStopResult> {
|
||||
@@ -430,7 +431,9 @@ export class OwnedWorkerRuntime {
|
||||
this.#inputFailure =
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new Error("Owned worker request stream failed", { cause });
|
||||
: new ProcessAdmissionError("request_stream_failed", undefined, {
|
||||
cause,
|
||||
});
|
||||
this.options.onInputFailure?.(this.#inputFailure);
|
||||
};
|
||||
const onClose = (): void => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ProcessAdmissionError } from "./ProcessAdmissionError.js";
|
||||
|
||||
/** Timeout and cancellation projections for one correlated operation. */
|
||||
export type PendingOperationOptions<Value> = {
|
||||
readonly signal?: AbortSignal;
|
||||
@@ -36,7 +38,7 @@ export class PendingOperations<Key, Value> {
|
||||
/** Register one unique operation key and await its reply or cancellation. */
|
||||
wait(key: Key, options: PendingOperationOptions<Value>): Promise<Value> {
|
||||
if (this.#pending.has(key))
|
||||
throw new Error("Pending operation key is already registered");
|
||||
throw new ProcessAdmissionError("duplicate_key");
|
||||
if (options.signal?.aborted === true)
|
||||
return Promise.resolve(options.cancelledValue());
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const PROCESS_ADMISSION_CODES = [
|
||||
"runtime_closing",
|
||||
"stdout_missing",
|
||||
"request_stream_failed",
|
||||
"finalization_unobserved",
|
||||
"prefix_identity_lost",
|
||||
"prefix_unavailable",
|
||||
"identity_snapshot_unavailable",
|
||||
"current_process_unreported",
|
||||
"baseline_unavailable",
|
||||
"memory_limit_unsupported",
|
||||
"launcher_pid_missing",
|
||||
"duplicate_key",
|
||||
"reader_closed",
|
||||
"token_json_invalid",
|
||||
"token_response_invalid",
|
||||
"token_pid_set",
|
||||
"token_pid_incomplete",
|
||||
"identity_json_invalid",
|
||||
"identity_response_invalid",
|
||||
"identity_pid_set",
|
||||
"identity_pid_incomplete",
|
||||
"limit_unreadable",
|
||||
"limit_invalid",
|
||||
"report_exceeds_budget",
|
||||
] as const;
|
||||
|
||||
export type ProcessAdmissionCode = (typeof PROCESS_ADMISSION_CODES)[number];
|
||||
|
||||
/** Process ownership, worker, or limit admission failure. `code` is the stable identity. */
|
||||
export class ProcessAdmissionError extends Error {
|
||||
readonly _tag = "ProcessAdmissionError";
|
||||
|
||||
constructor(
|
||||
readonly code: ProcessAdmissionCode,
|
||||
readonly detail?: Readonly<Record<string, string | number>>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, options);
|
||||
this.name = "ProcessAdmissionError";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProcessAdmissionError } from "../ProcessAdmissionError.js";
|
||||
import type { IPty } from "@lydell/node-pty";
|
||||
import type { ProcessCapture } from "../../domain/process/processCaptureParsing.js";
|
||||
import type { ProcessScenario } from "../../domain/process/processScenario.js";
|
||||
@@ -846,9 +847,7 @@ const runProcessScenario = async (
|
||||
};
|
||||
if (exit.reason === "cancelled")
|
||||
throw processCaptureCancelled(signal?.reason);
|
||||
throw new Error(
|
||||
"The captured process exit was not observed because the finalization SIGKILL could not be delivered.",
|
||||
);
|
||||
throw new ProcessAdmissionError("finalization_unobserved");
|
||||
}
|
||||
if (exit.finalization !== undefined)
|
||||
observations.finalization = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProcessAdmissionError } from "../ProcessAdmissionError.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -134,7 +135,7 @@ export class ProcessSensitiveOutput {
|
||||
#prefix(index: number): Prefix {
|
||||
const value = this.prefixes[index];
|
||||
if (value === undefined)
|
||||
throw new Error("Sensitive output matcher lost its prefix identity");
|
||||
throw new ProcessAdmissionError("prefix_identity_lost");
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -342,7 +343,7 @@ export const createProcessSensitiveOutput = (
|
||||
const read = (index: number): Prefix => {
|
||||
const node = prefixes[index];
|
||||
if (node === undefined)
|
||||
throw new Error("Sensitive output prefix is unavailable");
|
||||
throw new ProcessAdmissionError("prefix_unavailable");
|
||||
return node;
|
||||
};
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const WABT_ADMISSION_CODES = [
|
||||
"incomplete_string",
|
||||
"unsupported_escape",
|
||||
"incomplete_comment",
|
||||
"malformed_form",
|
||||
"name_missing",
|
||||
"incomplete_import",
|
||||
"incomplete_export",
|
||||
"unsupported_section_header",
|
||||
"unrecognized_section_row",
|
||||
"blank_custom_name",
|
||||
"custom_header_mismatch",
|
||||
"incomplete_section_row",
|
||||
"section_ranges",
|
||||
"custom_inventory_mismatch",
|
||||
"section_coverage",
|
||||
"incomplete_name_metadata",
|
||||
"name_subsection_order",
|
||||
"incomplete_module_name",
|
||||
"unfinished_module_name",
|
||||
"unsupported_module_prefix",
|
||||
"unexpected_string",
|
||||
"unexpected_second_module",
|
||||
"unsupported_custom_name",
|
||||
"unsupported_name_payload",
|
||||
"unbalanced_output",
|
||||
"unexpected_text",
|
||||
"incomplete_output",
|
||||
"directory_not_absolute",
|
||||
"incomplete_tool_profile",
|
||||
] as const;
|
||||
|
||||
export type WabtAdmissionCode = (typeof WABT_ADMISSION_CODES)[number];
|
||||
|
||||
/** WABT output or tool-profile admission failure. `code` is the stable identity. */
|
||||
export class WabtAdmissionError extends Error {
|
||||
readonly _tag = "WabtAdmissionError";
|
||||
|
||||
constructor(
|
||||
readonly code: WabtAdmissionCode,
|
||||
readonly detail?: Readonly<Record<string, string | number>>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(code, options);
|
||||
this.name = "WabtAdmissionError";
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WabtAdmissionError } from "./WabtAdmissionError.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js";
|
||||
import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js";
|
||||
@@ -47,7 +48,7 @@ const parts = (form: string): string[] => {
|
||||
if (items[0] === "func" && items.length > 1 && !atom.startsWith("$"))
|
||||
break;
|
||||
}
|
||||
if (i <= start) throw new Error("Malformed WABT form");
|
||||
if (i <= start) throw new WabtAdmissionError("malformed_form");
|
||||
budget.reserve(64 + (i - start) * 2);
|
||||
items.push(form.slice(start, i));
|
||||
}
|
||||
@@ -91,7 +92,7 @@ const functionType = (
|
||||
};
|
||||
const name = (text: string | undefined): string => {
|
||||
if (text?.startsWith('"') !== true)
|
||||
throw new Error("WABT interface name is missing");
|
||||
throw new WabtAdmissionError("name_missing");
|
||||
return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(
|
||||
watString(text, 1).bytes,
|
||||
);
|
||||
@@ -252,7 +253,7 @@ const decodeWabtInterface = (
|
||||
const p = parts(form);
|
||||
if (p[0] !== "import") continue;
|
||||
const body = p[3];
|
||||
if (body === undefined) throw new Error("Incomplete WABT import");
|
||||
if (body === undefined) throw new WabtAdmissionError("incomplete_import");
|
||||
const observed = addDefinition(body);
|
||||
const entry: WasmInterfaceEntry = {
|
||||
direction: "import",
|
||||
@@ -280,7 +281,7 @@ const decodeWabtInterface = (
|
||||
const p = parts(form);
|
||||
if (p[0] !== "export") continue;
|
||||
const body = p[2];
|
||||
if (body === undefined) throw new Error("Incomplete WABT export");
|
||||
if (body === undefined) throw new WabtAdmissionError("incomplete_export");
|
||||
const descriptor = parts(body);
|
||||
const rawKind = descriptor[0] ?? "unknown";
|
||||
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
|
||||
|
||||
+25
-18
@@ -1,5 +1,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const refused = (code: string) =>
|
||||
expect.objectContaining({ _tag: "WabtAdmissionError", code });
|
||||
import { parseWabtOutput } from "./WabtOutput.js";
|
||||
import { parseWabtWat } from "./WabtWat.js";
|
||||
import { associateWasmGlue } from "./WasmGlueAdapter.js";
|
||||
@@ -28,10 +31,10 @@ it("corroborates a blank custom name with bounded invalid UTF-8 bytes", () => {
|
||||
valid[11] = 0x61;
|
||||
valid[12] = 0x62;
|
||||
expect(() => parseWabtOutput(dump, valid.length, [], [], valid)).toThrow(
|
||||
/not corroborated/u,
|
||||
refused("blank_custom_name"),
|
||||
);
|
||||
expect(() => parseWabtOutput(dump, bytes.length, [])).toThrow(
|
||||
/not corroborated/u,
|
||||
refused("blank_custom_name"),
|
||||
);
|
||||
const mixed = Buffer.concat([bytes, Buffer.from("00050174616263", "hex")]);
|
||||
const mixedDump = dump.replace(
|
||||
@@ -57,19 +60,21 @@ it("retains real WABT section ranges, custom section identity and original heade
|
||||
bytes: 12,
|
||||
description: '"test"',
|
||||
});
|
||||
expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(/ranges/u);
|
||||
expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(
|
||||
refused("section_ranges"),
|
||||
);
|
||||
expect(() =>
|
||||
parseWabtOutput(text.replace("size=0x0000000c", "size=0x0000000d"), 76, [
|
||||
"test",
|
||||
]),
|
||||
).toThrow(/ranges/u);
|
||||
).toThrow(refused("section_ranges"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
text.replace("\nSection Details:\n", "unexpected\nSection Details:\n"),
|
||||
76,
|
||||
["test"],
|
||||
),
|
||||
).toThrow(/Unrecognized/u);
|
||||
).toThrow(refused("unrecognized_section_row"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
text.replace(
|
||||
@@ -79,7 +84,7 @@ it("retains real WABT section ranges, custom section identity and original heade
|
||||
76,
|
||||
["test"],
|
||||
),
|
||||
).toThrow(/Unrecognized/u);
|
||||
).toThrow(refused("unrecognized_section_row"));
|
||||
});
|
||||
const dataCountModuleBytes = 0x3d;
|
||||
const unpaddedDataCountRow =
|
||||
@@ -126,14 +131,14 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
|
||||
dataCountModuleBytes,
|
||||
[],
|
||||
),
|
||||
).toThrow(/Unrecognized/u);
|
||||
).toThrow(refused("unrecognized_section_row"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
dataCountDump.replace(unpaddedDataCountRow, "Sections:"),
|
||||
dataCountModuleBytes,
|
||||
[],
|
||||
),
|
||||
).toThrow(/Unrecognized/u);
|
||||
).toThrow(refused("unrecognized_section_row"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
dataCountDump.replace(
|
||||
@@ -143,14 +148,14 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
|
||||
dataCountModuleBytes,
|
||||
[],
|
||||
),
|
||||
).toThrow(/Unrecognized/u);
|
||||
).toThrow(refused("unrecognized_section_row"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
dataCountDump.replace("(size=0x00000001)", "(size=0x00000002)"),
|
||||
dataCountModuleBytes,
|
||||
[],
|
||||
),
|
||||
).toThrow(/ranges/u);
|
||||
).toThrow(refused("section_ranges"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
dataCountDump.replace(
|
||||
@@ -160,10 +165,10 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
|
||||
dataCountModuleBytes,
|
||||
[],
|
||||
),
|
||||
).toThrow(/ranges/u);
|
||||
).toThrow(refused("section_ranges"));
|
||||
expect(() =>
|
||||
parseWabtOutput(dataCountDump, dataCountModuleBytes - 1, []),
|
||||
).toThrow(/ranges/u);
|
||||
).toThrow(refused("section_ranges"));
|
||||
expect(() =>
|
||||
parseWabtOutput(
|
||||
dataCountDump.replace(
|
||||
@@ -173,7 +178,7 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
|
||||
dataCountModuleBytes,
|
||||
[],
|
||||
),
|
||||
).toThrow(/header/u);
|
||||
).toThrow(refused("unsupported_section_header"));
|
||||
});
|
||||
it("bounds section header bytes while accepting a five-byte size LEB", () => {
|
||||
const paddedCustomDump = [
|
||||
@@ -209,7 +214,7 @@ it("bounds section header bytes while accepting a five-byte size LEB", () => {
|
||||
"",
|
||||
].join("\n");
|
||||
expect(() => parseWabtOutput(malformed, start + 1, [""])).toThrow(
|
||||
/ranges/u,
|
||||
refused("section_ranges"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -225,7 +230,7 @@ it.each([
|
||||
`\n${displayed.map((name) => `module name: <${name}>\n`).join("")}\nSections:`,
|
||||
);
|
||||
expect(() => parseWabtOutput(text, 76, ["test"], decoded)).toThrow(
|
||||
/header format/u,
|
||||
refused("unsupported_section_header"),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -284,7 +289,7 @@ it("frames custom section names at their NUL-terminated objdump display", async
|
||||
description: '"test"',
|
||||
});
|
||||
expect(() => parseWabtOutput(text, 76, ["other\0test"])).toThrow(
|
||||
/custom-section header/u,
|
||||
refused("custom_header_mismatch"),
|
||||
);
|
||||
});
|
||||
it("preserves leading U+FEFF as custom-name data, not a debug-name marker", () => {
|
||||
@@ -327,9 +332,11 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
|
||||
).exports,
|
||||
).toEqual(['(export "x" (func 0))']);
|
||||
expect(() => parseWabtWat('(module (export "unfinished"')).toThrow(
|
||||
/Incomplete/u,
|
||||
refused("incomplete_output"),
|
||||
);
|
||||
expect(() => parseWabtWat("(module) unexpected")).toThrow(
|
||||
refused("unexpected_text"),
|
||||
);
|
||||
expect(() => parseWabtWat("(module) unexpected")).toThrow(/Unexpected/u);
|
||||
});
|
||||
it("keeps exact local, ambiguous URL, computed and unparseable glue distinct", () => {
|
||||
const candidates = ["/app/module.wasm", "/other/module.wasm"];
|
||||
|
||||
+11
-14
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WabtAdmissionError } from "./WabtAdmissionError.js";
|
||||
|
||||
/** Locate a WAT string's exclusive end without decoding or copying its payload. */
|
||||
export const watStringEnd = (form: string, start: number): number => {
|
||||
for (
|
||||
@@ -9,7 +11,7 @@ export const watStringEnd = (form: string, start: number): number => {
|
||||
while (escapes > start && form[escapes - 1] === "\\") escapes--;
|
||||
if ((end - escapes) % 2 === 0) return end + 1;
|
||||
}
|
||||
throw new Error("Incomplete WABT string.");
|
||||
throw new WabtAdmissionError("incomplete_string");
|
||||
};
|
||||
|
||||
/** Decode a producer WAT string only where its bytes are interpreted. */
|
||||
@@ -45,7 +47,8 @@ export const watString = (form: string, start: number) => {
|
||||
: escape === "\\"
|
||||
? 92
|
||||
: undefined;
|
||||
if (value === undefined) throw new Error("Unsupported WABT name escape.");
|
||||
if (value === undefined)
|
||||
throw new WabtAdmissionError("unsupported_escape");
|
||||
bytes[length++] = value;
|
||||
index += 2;
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,3 +1,4 @@
|
||||
import { WabtAdmissionError } from "./WabtAdmissionError.js";
|
||||
import { parseWabtInterface } from "./WabtInterface.js";
|
||||
import { watString, watStringEnd } from "./WabtString.js";
|
||||
import { watCommentEnd } from "./WabtComment.js";
|
||||
@@ -25,24 +26,24 @@ const moduleNames = (
|
||||
value += (byte & 127) * 2 ** shift;
|
||||
if (byte < 128) return value;
|
||||
}
|
||||
throw new Error("Incomplete WABT name-section metadata.");
|
||||
throw new WabtAdmissionError("incomplete_name_metadata");
|
||||
};
|
||||
try {
|
||||
let previousType = -1;
|
||||
while (offset < bytes.length) {
|
||||
const type = uint32(bytes.length);
|
||||
if (type <= previousType)
|
||||
throw new Error("Duplicate or out-of-order WABT name subsection.");
|
||||
throw new WabtAdmissionError("name_subsection_order");
|
||||
previousType = type;
|
||||
const size = uint32(bytes.length);
|
||||
const end = offset + size;
|
||||
if (end > bytes.length)
|
||||
throw new Error("Incomplete WABT name-section metadata.");
|
||||
throw new WabtAdmissionError("incomplete_name_metadata");
|
||||
if (type === 0 && size !== 0) {
|
||||
const length = uint32(end);
|
||||
const nameEnd = offset + length;
|
||||
if (nameEnd > end)
|
||||
throw new Error("Incomplete WABT module-name metadata.");
|
||||
throw new WabtAdmissionError("incomplete_module_name");
|
||||
names.push(
|
||||
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.subarray(offset, nameEnd),
|
||||
@@ -50,7 +51,7 @@ const moduleNames = (
|
||||
);
|
||||
// WABT calls OnModuleName before rejecting trailing subsection bytes.
|
||||
if (nameEnd !== end)
|
||||
throw new Error("Unfinished WABT module-name subsection.");
|
||||
throw new WabtAdmissionError("unfinished_module_name");
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
@@ -68,7 +69,7 @@ const moduleNames = (
|
||||
/** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */
|
||||
export const parseWabtWat = (text: string) => {
|
||||
if (!/^\(module(?:\s|\))/u.test(text))
|
||||
throw new Error("Unsupported WABT WAT module prefix.");
|
||||
throw new WabtAdmissionError("unsupported_module_prefix");
|
||||
const forms: string[] = [];
|
||||
const imports: string[] = [];
|
||||
const exports: string[] = [];
|
||||
@@ -95,13 +96,12 @@ export const parseWabtWat = (text: string) => {
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
if (depth === 0)
|
||||
throw new Error("Unexpected string outside WABT WAT module.");
|
||||
if (depth === 0) throw new WabtAdmissionError("unexpected_string");
|
||||
index = watStringEnd(text, index + 1) - 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "(") {
|
||||
if (closed) throw new Error("Unexpected second WABT WAT module.");
|
||||
if (closed) throw new WabtAdmissionError("unexpected_second_module");
|
||||
depth++;
|
||||
if (depth === 2) start = index;
|
||||
} else if (char === ")") {
|
||||
@@ -118,7 +118,7 @@ export const parseWabtWat = (text: string) => {
|
||||
else if (/^\(@custom\s/u.test(form)) {
|
||||
const prefix = /^\(@custom\s+"/u.exec(form);
|
||||
if (prefix === null)
|
||||
throw new Error("Unsupported WABT custom-section name.");
|
||||
throw new WabtAdmissionError("unsupported_custom_name");
|
||||
const nameString = watString(form, prefix[0].length);
|
||||
// A WASM name is length-delimited UTF-8; leading U+FEFF is name data.
|
||||
const name = new TextDecoder("utf-8", {
|
||||
@@ -129,7 +129,7 @@ export const parseWabtWat = (text: string) => {
|
||||
if (name === "name") {
|
||||
const payloadPrefix = /^\s+"/u.exec(form.slice(nameString.end));
|
||||
if (payloadPrefix === null)
|
||||
throw new Error("Unsupported WABT name-section payload.");
|
||||
throw new WabtAdmissionError("unsupported_name_payload");
|
||||
const payload = watString(
|
||||
form,
|
||||
nameString.end + payloadPrefix[0].length,
|
||||
@@ -148,12 +148,12 @@ export const parseWabtWat = (text: string) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (--depth < 0) throw new Error("Unbalanced WABT WAT output.");
|
||||
if (--depth < 0) throw new WabtAdmissionError("unbalanced_output");
|
||||
if (depth === 0) closed = true;
|
||||
} else if (depth === 0 && char !== undefined && !/\s/u.test(char))
|
||||
throw new Error("Unexpected text outside WABT WAT module.");
|
||||
throw new WabtAdmissionError("unexpected_text");
|
||||
}
|
||||
if (depth !== 0) throw new Error("Incomplete WABT WAT output.");
|
||||
if (depth !== 0) throw new WabtAdmissionError("incomplete_output");
|
||||
return {
|
||||
imports,
|
||||
exports,
|
||||
|
||||
@@ -82,8 +82,12 @@ it("rejects a relative override and non-file without probing an alternate comman
|
||||
);
|
||||
expect(relative).toMatchObject({
|
||||
status: "unavailable",
|
||||
diagnostics: { limiter_path: "prlimit" },
|
||||
reason: expect.stringContaining("absolute executable path"),
|
||||
reason: "prlimit_not_absolute",
|
||||
diagnostics: {
|
||||
limiter_path: "prlimit",
|
||||
code: "prlimit_not_absolute",
|
||||
setting: "REA_EVM_PRLIMIT_COMMAND",
|
||||
},
|
||||
});
|
||||
|
||||
const directory = await evmLimiterAvailability(
|
||||
@@ -98,7 +102,8 @@ it("rejects a relative override and non-file without probing an alternate comman
|
||||
);
|
||||
expect(directory).toMatchObject({
|
||||
status: "unavailable",
|
||||
reason: expect.stringContaining("regular executable file"),
|
||||
reason: "prlimit_not_file",
|
||||
diagnostics: expect.objectContaining({ code: "prlimit_not_file" }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -982,7 +982,15 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])(
|
||||
category: "unsupported_provider",
|
||||
details: { provider_id: "evmole-interface" },
|
||||
});
|
||||
expect(result.error.message).toContain(limiter);
|
||||
if (scenario === "directory")
|
||||
expect(result.error).toMatchObject({
|
||||
reason: "prlimit_not_file",
|
||||
diagnostics: {
|
||||
limiter_path: limiter,
|
||||
code: "prlimit_not_file",
|
||||
},
|
||||
});
|
||||
else expect(result.error.message).toContain(limiter);
|
||||
if (ownedRoot !== undefined)
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
|
||||
|
||||
@@ -94,7 +94,17 @@ it.each([
|
||||
if (kind.startsWith("duplicate nested")) {
|
||||
expect(metadata.entries).toEqual([]);
|
||||
expect(metadata.compiler_version).toBeNull();
|
||||
expect(metadata.diagnostic).toContain("repeat map key");
|
||||
expect(metadata.diagnostic).toBe("repeated_map_key");
|
||||
expect(metadata.admission).toMatchObject({ code: "repeated_map_key" });
|
||||
if (kind === "duplicate nested text keys")
|
||||
expect(metadata.admission?.detail).toEqual({
|
||||
key: JSON.stringify({ kind: "text", value: "a" }),
|
||||
});
|
||||
else
|
||||
expect(
|
||||
metadata.admission?.detail?.key ??
|
||||
metadata.admission?.detail?.key_length,
|
||||
).toBeDefined();
|
||||
expect(metadata.bytecode_offset).toBe(1);
|
||||
expect(metadata.cbor_bytes).toBe(19);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ const admissionCases: readonly AdmissionCase[] = [
|
||||
{
|
||||
name: "malformed Unicode escape",
|
||||
properties: "application.version=12.1.4\nother=\\u00xx\n",
|
||||
error: "Malformed Unicode escape in Java properties file",
|
||||
error: "malformed_unicode_escape",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -603,7 +603,12 @@ it.each(["seed", "extension"] as const)(
|
||||
],
|
||||
},
|
||||
});
|
||||
else expect(result.error.diagnostics?.reason).toContain("not a JAR");
|
||||
else
|
||||
expect(result.error.diagnostics).toMatchObject({
|
||||
code: "not_jar",
|
||||
reason: "not_jar",
|
||||
path,
|
||||
});
|
||||
allowClose = true;
|
||||
await scope.retryCleanup();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
|
||||
@@ -241,14 +241,27 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (missing.ok) throw new Error("Missing installation was accepted");
|
||||
expect(missing.error.message).toContain("PSP_EXTENSION_UNAVAILABLE");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP extension admission codes",
|
||||
() => {
|
||||
it.each([
|
||||
["name=other\nversion=12.1.3\n", "does not match"],
|
||||
["name=ghidra-allegrex\nversion=12.1.4\n", "does not match"],
|
||||
["name=ghidra-allegrex\nversion=12.1.\\uZZZZ\n", "Unicode escape"],
|
||||
])(
|
||||
["name=other\nversion=12.1.3\n", "metadata_mismatch", undefined],
|
||||
[
|
||||
"name=ghidra-allegrex\nversion=12.1.4\n",
|
||||
"metadata_mismatch",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"name=ghidra-allegrex\nversion=12.1.\\uZZZZ\n",
|
||||
"properties_invalid",
|
||||
"malformed_unicode_escape",
|
||||
],
|
||||
] as const)(
|
||||
"rejects mismatched or malformed extension metadata %s",
|
||||
async (properties, reason) => {
|
||||
async (properties, code, parserCode) => {
|
||||
const { root } = await installation();
|
||||
await writeFile(join(root, "extension.properties"), properties);
|
||||
await expect(
|
||||
@@ -257,7 +270,14 @@ describe.skipIf(process.platform === "win32")(
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
),
|
||||
).rejects.toThrow(reason);
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: {
|
||||
code,
|
||||
...(parserCode === undefined
|
||||
? { ghidra_version: "12.1.3" }
|
||||
: { parser_code: parserCode }),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -269,7 +289,12 @@ describe.skipIf(process.platform === "win32")(
|
||||
);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("Module.manifest");
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: {
|
||||
code: "component_missing",
|
||||
component: "Module.manifest",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
|
||||
@@ -279,7 +304,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
await symlink(join(directory, "external.jar"), path);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("regular files");
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: { code: "entry_kind", relative: "lib/extension.jar" },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
|
||||
@@ -287,7 +314,12 @@ describe.skipIf(process.platform === "win32")(
|
||||
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
|
||||
await expect(
|
||||
inspectGhidraPspExtension(root, "12.1.3", new ArtifactResourceScope()),
|
||||
).rejects.toThrow("inspection limit");
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: {
|
||||
code: "bytes_exceed_limit",
|
||||
max_bytes: 64 * 1024 * 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -314,7 +346,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
"12.1.3",
|
||||
new ArtifactResourceScope(),
|
||||
),
|
||||
).rejects.toThrow("inventory exceeds the inspection limit");
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: { code: "inventory_exceeds_limit", max_entries: 1024 },
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -571,7 +605,12 @@ describe.skipIf(process.platform === "win32")(
|
||||
const languageOverride = { languageId: "x86:LE:64:default" };
|
||||
expect(() =>
|
||||
ghidraHeadlessArguments({ ...paths, psp: true, languageOverride }),
|
||||
).toThrow("PSP ELF import cannot be combined");
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "psp_combined",
|
||||
}),
|
||||
);
|
||||
expect(ghidraHeadlessArguments({ ...paths, languageOverride })).toEqual(
|
||||
expect.arrayContaining(["-processor", "x86:LE:64:default"]),
|
||||
);
|
||||
@@ -903,11 +942,14 @@ describe.skipIf(process.platform === "win32")(
|
||||
const result = await provider.resolveAnalysisProfile(target);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Changed file admitted");
|
||||
expect(result.error.message).toContain(
|
||||
change === "growth"
|
||||
? "changed during inspection"
|
||||
: "Truncated Allegrex component",
|
||||
);
|
||||
expect(result.error).toMatchObject({
|
||||
diagnostics: {
|
||||
code:
|
||||
change === "growth" ? "component_changed" : "component_truncated",
|
||||
relative:
|
||||
change === "growth" ? "Module.manifest" : "extension.properties",
|
||||
},
|
||||
});
|
||||
expect(closes).toBe(1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(provider.hasPendingOwnedResources()).toBe(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
||||
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { writeTextParts } from "../../../src/application/JsonFiles.js";
|
||||
import { StagedOutputError } from "../../../src/filesystem/StagedOutputError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const allocation = vi.hoisted(() => ({
|
||||
@@ -70,7 +71,10 @@ it("closes the descriptor and retains staged content when its final identity is
|
||||
expect(result.error.cause).toMatchObject({
|
||||
outcome: {
|
||||
ok: false,
|
||||
error: { message: "Cannot verify staged file identity" },
|
||||
error: {
|
||||
_tag: "StagedOutputError",
|
||||
code: "identity_unverified",
|
||||
},
|
||||
},
|
||||
});
|
||||
const stage = result.error.cleanupResources[0];
|
||||
@@ -457,7 +461,11 @@ it("refuses to remove unexpected files from an owned staging directory", async (
|
||||
const unexpected = join(directory, "unexpected");
|
||||
writeFileSync(unexpected, "unowned");
|
||||
try {
|
||||
await expect(owner.close()).rejects.toThrow(/Unexpected staging entries/u);
|
||||
await expect(owner.close()).rejects.toMatchObject({
|
||||
_tag: "StagedOutputError",
|
||||
code: "unexpected_entries",
|
||||
detail: { count: 1, entries: "unexpected" },
|
||||
});
|
||||
expect(await fs.readFile(unexpected, "utf8")).toBe("unowned");
|
||||
} finally {
|
||||
await fs.rm(unexpected, { force: true });
|
||||
@@ -466,6 +474,37 @@ it("refuses to remove unexpected files from an owned staging directory", async (
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unexpected staging names on the publication error", async () => {
|
||||
const root = await createTestTempDirectory("rea-staged-public-entries-");
|
||||
const destination = join(root, "out.json");
|
||||
const resources = new ArtifactResourceScope();
|
||||
const close = vi
|
||||
.spyOn(OwnedStagedOutput.prototype, "close")
|
||||
.mockImplementation(async () => {
|
||||
throw new StagedOutputError("unexpected_entries", {
|
||||
count: 1,
|
||||
entries: "unexpected",
|
||||
});
|
||||
});
|
||||
try {
|
||||
const result = await writeTextParts(["{}"], destination, false, resources);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "EvidenceFileError",
|
||||
diagnostics: {
|
||||
code: "unexpected_entries",
|
||||
count: 1,
|
||||
entries: "unexpected",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
await resources.close().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("retains cleanup when its staging directory was renamed", async () => {
|
||||
const root = await createTestTempDirectory("rea-staged-renamed-dir-");
|
||||
const directory = join(root, ".rea-write-owned");
|
||||
|
||||
@@ -141,7 +141,11 @@ it.each(["bin", "hop"])(
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
operation: "inspect_wasm_artifact",
|
||||
reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
|
||||
reason: "directory_not_absolute",
|
||||
diagnostics: {
|
||||
code: "directory_not_absolute",
|
||||
setting: "REA_WABT_BIN_DIRECTORY",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -152,7 +156,13 @@ it.each(["bin", "hop"])(
|
||||
expect(parseMcpToolError(summary)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") },
|
||||
details: {
|
||||
reason: "directory_not_absolute",
|
||||
diagnostics: {
|
||||
code: "directory_not_absolute",
|
||||
setting: "REA_WABT_BIN_DIRECTORY",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await client.ping();
|
||||
|
||||
@@ -1040,7 +1040,10 @@ it.skipIf(process.platform === "win32")(
|
||||
throw new Error("closed reader compilation unexpectedly succeeded");
|
||||
expect(outcome.cause).toMatchObject({ name: "AbortError" });
|
||||
await expect(access(state.root)).rejects.toThrow();
|
||||
await expect(reader.prepare()).rejects.toThrow(/reader is closed/u);
|
||||
await expect(reader.prepare()).rejects.toMatchObject({
|
||||
_tag: "ProcessAdmissionError",
|
||||
code: "reader_closed",
|
||||
});
|
||||
await expect(reader.close()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await Promise.allSettled([preparation]);
|
||||
|
||||
@@ -506,7 +506,10 @@ describe("provider process memory limits", () => {
|
||||
platform: "linux",
|
||||
memoryLimitBytes: 704 * 1024 * 1024,
|
||||
}),
|
||||
).rejects.toThrow("require the Windows Job Object launcher");
|
||||
).rejects.toMatchObject({
|
||||
_tag: "ProcessAdmissionError",
|
||||
code: "memory_limit_unsupported",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -452,6 +452,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
cause: { code: 3 },
|
||||
},
|
||||
});
|
||||
if (mode === "env-malformed")
|
||||
expect(cause).toMatchObject({
|
||||
cause: {
|
||||
code: "invalid_environment_entry",
|
||||
detail: { check: "separator", line: "invalid environment" },
|
||||
},
|
||||
});
|
||||
if (mode === "vm-malformed")
|
||||
expect(cause).toMatchObject({
|
||||
launch_support: expect.arrayContaining([
|
||||
@@ -474,9 +481,8 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "platform_override",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -499,9 +505,8 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "platform_override",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -578,9 +583,8 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (launched.ok)
|
||||
throw new Error("Expected an effective platform override refusal");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "platform_override",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -600,7 +604,8 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a JVM platform override failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("may not override os.arch or os.name"),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "platform_override",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -634,7 +639,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (launched.ok)
|
||||
throw new Error("Expected an unreadable argument-file failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("argument file is unreadable"),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "argument_file_unreadable",
|
||||
detail: { path: optionsFile },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -657,7 +664,8 @@ describe.skipIf(process.platform === "win32")(
|
||||
if (launched.ok)
|
||||
throw new Error("Expected a nested argument-file failure");
|
||||
expect(launched.error.cause).toMatchObject({
|
||||
message: expect.stringContaining("may not nest @ references"),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "nested_argument_file",
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -823,9 +831,8 @@ describe.skipIf(process.platform === "win32")(
|
||||
);
|
||||
if (cleanup.fileCleanupFailure.outcome.kind === "failed")
|
||||
expect(cleanup.fileCleanupFailure.outcome.cause).toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
"may not override os.arch or os.name",
|
||||
),
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "platform_override",
|
||||
});
|
||||
await resources.retryCleanup();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
|
||||
@@ -260,7 +260,12 @@ describe("Ghidra GameCube DOL loader", () => {
|
||||
sectionFilePath: "/tmp/gamecube-dol-sections.txt",
|
||||
},
|
||||
}),
|
||||
).toThrow(/cannot be combined with another loader, language override/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "gamecube_dol_combined",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,7 +351,12 @@ describe("Ghidra headless launcher", () => {
|
||||
])
|
||||
expect(() =>
|
||||
ghidraHeadlessArguments({ ...base, mipsSoftFloat: true, ...conflict }),
|
||||
).toThrow(/generic MIPS ELF/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "mips_soft_float_scope",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,7 +405,12 @@ describe("Ghidra configured import language and seeds", () => {
|
||||
dosMz: true,
|
||||
languageOverride: { languageId: "x86:LE:32:default" },
|
||||
}),
|
||||
).toThrow(/cannot be combined with a DOS import/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "language_override_dos",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("never combines a configured language with a raw-binary import", () => {
|
||||
@@ -409,7 +424,12 @@ describe("Ghidra configured import language and seeds", () => {
|
||||
baseAddr: "0x8000000",
|
||||
},
|
||||
}),
|
||||
).toThrow(/cannot be combined with a raw-binary import/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "language_override_raw_binary",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["darwin", "win32"] as const)(
|
||||
@@ -579,30 +599,41 @@ describe("Ghidra existing project", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ dosMz: true as const },
|
||||
{ dosCom: true as const },
|
||||
{
|
||||
rawBinary: {
|
||||
languageId: "ARM:LE:32:v7",
|
||||
compilerSpecId: "default",
|
||||
baseAddr: "0x8000000",
|
||||
[{ dosMz: true as const }, "existing_project_dos"],
|
||||
[{ dosCom: true as const }, "existing_project_dos"],
|
||||
[
|
||||
{
|
||||
rawBinary: {
|
||||
languageId: "ARM:LE:32:v7",
|
||||
compilerSpecId: "default",
|
||||
baseAddr: "0x8000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ languageOverride: { languageId: "x86:LE:32:default" } },
|
||||
{ psp: true as const },
|
||||
{ mipsSoftFloat: true as const },
|
||||
{ seedPath: "/tmp/runtime/seeds/seeds.tsv" },
|
||||
{ disabledAnalyzers: ["Decompiler Parameter ID"] },
|
||||
])(
|
||||
"existing_project_raw_binary",
|
||||
],
|
||||
[
|
||||
{ languageOverride: { languageId: "x86:LE:32:default" } },
|
||||
"existing_project_language_override",
|
||||
],
|
||||
[{ psp: true as const }, "existing_project_psp"],
|
||||
[{ mipsSoftFloat: true as const }, "existing_project_mips_soft_float"],
|
||||
[{ seedPath: "/tmp/runtime/seeds/seeds.tsv" }, "existing_project_analysis"],
|
||||
[
|
||||
{ disabledAnalyzers: ["Decompiler Parameter ID"] },
|
||||
"existing_project_analysis",
|
||||
],
|
||||
] as const)(
|
||||
"rejects the import-only option %o with an existing project",
|
||||
(conflict) => {
|
||||
(conflict, code) => {
|
||||
expect(() =>
|
||||
ghidraHeadlessArguments({
|
||||
...base,
|
||||
existingProject: { programName: "game.exe" },
|
||||
...conflict,
|
||||
}),
|
||||
).toThrow(/existing (Ghidra )?project|fresh import/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({ _tag: "GhidraAdmissionError", code }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -618,7 +649,12 @@ describe("Ghidra existing project", () => {
|
||||
),
|
||||
existingProject: { programName: "game.exe" },
|
||||
}),
|
||||
).toThrow(/Windows/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "existing_project_windows",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a program name for -process", () => {
|
||||
@@ -627,7 +663,12 @@ describe("Ghidra existing project", () => {
|
||||
...base,
|
||||
existingProject: { programName: " " },
|
||||
}),
|
||||
).toThrow(/program name/u);
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "GhidraAdmissionError",
|
||||
code: "existing_project_program_name",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user