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

* fix: report provider admission failures as typed codes

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

* fix: assert WABT and CBOR admission codes

* fix: keep admission details on returned failures

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

---------

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

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
const WABT_ADMISSION_CODES = [
"incomplete_string",
"unsupported_escape",
"incomplete_comment",
"malformed_form",
"name_missing",
"incomplete_import",
"incomplete_export",
"unsupported_section_header",
"unrecognized_section_row",
"blank_custom_name",
"custom_header_mismatch",
"incomplete_section_row",
"section_ranges",
"custom_inventory_mismatch",
"section_coverage",
"incomplete_name_metadata",
"name_subsection_order",
"incomplete_module_name",
"unfinished_module_name",
"unsupported_module_prefix",
"unexpected_string",
"unexpected_second_module",
"unsupported_custom_name",
"unsupported_name_payload",
"unbalanced_output",
"unexpected_text",
"incomplete_output",
"directory_not_absolute",
"incomplete_tool_profile",
] as const;
export type WabtAdmissionCode = (typeof WABT_ADMISSION_CODES)[number];
/** WABT output or tool-profile admission failure. `code` is the stable identity. */
export class WabtAdmissionError extends Error {
readonly _tag = "WabtAdmissionError";
constructor(
readonly code: WabtAdmissionCode,
readonly detail?: Readonly<Record<string, string | number>>,
options?: ErrorOptions,
) {
super(code, options);
this.name = "WabtAdmissionError";
}
}
+6 -1
View File
@@ -436,7 +436,12 @@ it("returns actionable unavailable without creating a workspace when tools are a
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
userMessage: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
reason: "directory_not_absolute",
userMessage: "WABT configuration cannot be used",
diagnostics: {
code: "directory_not_absolute",
setting: "REA_WABT_BIN_DIRECTORY",
},
},
});
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import { randomUUID, createHash } from "node:crypto";
import { access, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
@@ -221,9 +222,9 @@ export class WabtArtifactProvider implements WasmArtifactPort {
});
const directory = environment.REA_WABT_BIN_DIRECTORY ?? "";
if (!isAbsolute(directory))
throw new Error(
"REA_WABT_BIN_DIRECTORY must be an absolute tool directory.",
);
throw new WabtAdmissionError("directory_not_absolute", {
setting: "REA_WABT_BIN_DIRECTORY",
});
const commands: {
tool: "wasm-validate" | "wasm-objdump" | "wasm2wat";
path: string;
@@ -466,7 +467,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
objdump === undefined ||
decode === undefined
)
throw new Error("Incomplete WABT tool profile.");
throw new WabtAdmissionError("incomplete_tool_profile");
const validation = await execute(validate, validate.arguments, true);
if (validation.exitCode !== 0)
throw new AnalysisInputError(
+3 -1
View File
@@ -1,3 +1,5 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
/** Skip a WAT comment without interpreting its punctuation as forms or strings. */
export const watCommentEnd = (
text: string,
@@ -24,5 +26,5 @@ export const watCommentEnd = (
index++;
}
}
throw new Error("Incomplete WABT WAT comment.");
throw new WabtAdmissionError("incomplete_comment");
};
+17 -3
View File
@@ -27,6 +27,7 @@ import {
RuntimeControlUnavailableError,
runtimeControlCapabilityError,
} from "../process/RuntimeControlUnavailableError.js";
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import {
DEFAULT_WASM_COMMAND_TIMEOUT_MS,
MAX_WASM_COMMAND_TIMEOUT_MS,
@@ -237,18 +238,31 @@ const classifyWabtFailure = (
remediationAction: `The WABT tool allowance is ${String(WABT_LIMITS.toolBytes)} bytes. This release has no runtime override. command_timeout_ms and REA_WABT_BIN_DIRECTORY do not raise it. Select tools within the allowance. Do not repeat the unchanged call.`,
},
);
if (phase === "configuration")
if (phase === "configuration") {
const admission = cause instanceof WabtAdmissionError ? cause : undefined;
return new AnalysisCapabilityUnavailableError(
WABT_PROVIDER_IDENTITY.id,
"inspect_wasm_artifact",
reason,
admission?.code ?? reason,
{
cause,
...artifactOptions,
userMessage: reason,
...(admission === undefined
? {}
: {
diagnostics: {
code: admission.code,
...admission.detail,
},
}),
userMessage:
admission === undefined
? reason
: "WABT configuration cannot be used",
remediationAction: wabtConfigurationRecoveryAction(cause),
},
);
}
if (
phase === "artifact" &&
cause instanceof ArtifactReaderFailure &&
+10 -2
View File
@@ -217,11 +217,19 @@ it("rejects unfinished nested comments rather than projecting a truncated interf
"(module (type (func (param (; unfinished ) i32))))",
"(module) (; outer (; inner ;) still open",
])
expect(() => parseWabtWat(source)).toThrow("Incomplete WABT WAT comment");
expect(() => parseWabtWat(source)).toThrow(
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "incomplete_comment",
}),
);
});
it("rejects a second module after a CR-terminated line comment", () => {
expect(() => parseWabtWat("(module) ;; comment\r(module)")).toThrow(
"Unexpected second WABT WAT module",
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "unexpected_second_module",
}),
);
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js";
import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js";
@@ -47,7 +48,7 @@ const parts = (form: string): string[] => {
if (items[0] === "func" && items.length > 1 && !atom.startsWith("$"))
break;
}
if (i <= start) throw new Error("Malformed WABT form");
if (i <= start) throw new WabtAdmissionError("malformed_form");
budget.reserve(64 + (i - start) * 2);
items.push(form.slice(start, i));
}
@@ -91,7 +92,7 @@ const functionType = (
};
const name = (text: string | undefined): string => {
if (text?.startsWith('"') !== true)
throw new Error("WABT interface name is missing");
throw new WabtAdmissionError("name_missing");
return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(
watString(text, 1).bytes,
);
@@ -252,7 +253,7 @@ const decodeWabtInterface = (
const p = parts(form);
if (p[0] !== "import") continue;
const body = p[3];
if (body === undefined) throw new Error("Incomplete WABT import");
if (body === undefined) throw new WabtAdmissionError("incomplete_import");
const observed = addDefinition(body);
const entry: WasmInterfaceEntry = {
direction: "import",
@@ -280,7 +281,7 @@ const decodeWabtInterface = (
const p = parts(form);
if (p[0] !== "export") continue;
const body = p[2];
if (body === undefined) throw new Error("Incomplete WABT export");
if (body === undefined) throw new WabtAdmissionError("incomplete_export");
const descriptor = parts(body);
const rawKind = descriptor[0] ?? "unknown";
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
+25 -18
View File
@@ -1,5 +1,8 @@
import { readFile } from "node:fs/promises";
import { expect, it } from "vitest";
const refused = (code: string) =>
expect.objectContaining({ _tag: "WabtAdmissionError", code });
import { parseWabtOutput } from "./WabtOutput.js";
import { parseWabtWat } from "./WabtWat.js";
import { associateWasmGlue } from "./WasmGlueAdapter.js";
@@ -28,10 +31,10 @@ it("corroborates a blank custom name with bounded invalid UTF-8 bytes", () => {
valid[11] = 0x61;
valid[12] = 0x62;
expect(() => parseWabtOutput(dump, valid.length, [], [], valid)).toThrow(
/not corroborated/u,
refused("blank_custom_name"),
);
expect(() => parseWabtOutput(dump, bytes.length, [])).toThrow(
/not corroborated/u,
refused("blank_custom_name"),
);
const mixed = Buffer.concat([bytes, Buffer.from("00050174616263", "hex")]);
const mixedDump = dump.replace(
@@ -57,19 +60,21 @@ it("retains real WABT section ranges, custom section identity and original heade
bytes: 12,
description: '"test"',
});
expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(/ranges/u);
expect(() => parseWabtOutput(text, 75, ["test"])).toThrow(
refused("section_ranges"),
);
expect(() =>
parseWabtOutput(text.replace("size=0x0000000c", "size=0x0000000d"), 76, [
"test",
]),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(
text.replace("\nSection Details:\n", "unexpected\nSection Details:\n"),
76,
["test"],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
text.replace(
@@ -79,7 +84,7 @@ it("retains real WABT section ranges, custom section identity and original heade
76,
["test"],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
});
const dataCountModuleBytes = 0x3d;
const unpaddedDataCountRow =
@@ -126,14 +131,14 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(unpaddedDataCountRow, "Sections:"),
dataCountModuleBytes,
[],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(
@@ -143,14 +148,14 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/Unrecognized/u);
).toThrow(refused("unrecognized_section_row"));
expect(() =>
parseWabtOutput(
dataCountDump.replace("(size=0x00000001)", "(size=0x00000002)"),
dataCountModuleBytes,
[],
),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(
@@ -160,10 +165,10 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(dataCountDump, dataCountModuleBytes - 1, []),
).toThrow(/ranges/u);
).toThrow(refused("section_ranges"));
expect(() =>
parseWabtOutput(
dataCountDump.replace(
@@ -173,7 +178,7 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad
dataCountModuleBytes,
[],
),
).toThrow(/header/u);
).toThrow(refused("unsupported_section_header"));
});
it("bounds section header bytes while accepting a five-byte size LEB", () => {
const paddedCustomDump = [
@@ -209,7 +214,7 @@ it("bounds section header bytes while accepting a five-byte size LEB", () => {
"",
].join("\n");
expect(() => parseWabtOutput(malformed, start + 1, [""])).toThrow(
/ranges/u,
refused("section_ranges"),
);
}
});
@@ -225,7 +230,7 @@ it.each([
`\n${displayed.map((name) => `module name: <${name}>\n`).join("")}\nSections:`,
);
expect(() => parseWabtOutput(text, 76, ["test"], decoded)).toThrow(
/header format/u,
refused("unsupported_section_header"),
);
},
);
@@ -284,7 +289,7 @@ it("frames custom section names at their NUL-terminated objdump display", async
description: '"test"',
});
expect(() => parseWabtOutput(text, 76, ["other\0test"])).toThrow(
/custom-section header/u,
refused("custom_header_mismatch"),
);
});
it("preserves leading U+FEFF as custom-name data, not a debug-name marker", () => {
@@ -327,9 +332,11 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
).exports,
).toEqual(['(export "x" (func 0))']);
expect(() => parseWabtWat('(module (export "unfinished"')).toThrow(
/Incomplete/u,
refused("incomplete_output"),
);
expect(() => parseWabtWat("(module) unexpected")).toThrow(
refused("unexpected_text"),
);
expect(() => parseWabtWat("(module) unexpected")).toThrow(/Unexpected/u);
});
it("keeps exact local, ambiguous URL, computed and unparseable glue distinct", () => {
const candidates = ["/app/module.wasm", "/other/module.wasm"];
+11 -14
View File
@@ -1,3 +1,5 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
/** WABT prints names with %.*s, so an embedded NUL ends their display. */
const wabtDisplayedName = (name: string | undefined): string | undefined =>
name?.split("\0", 1)[0];
@@ -17,7 +19,7 @@ export const parseWabtOutput = (
.join("");
const prefix = `${preamble}\nSections:\n\n`;
if (!text.startsWith(prefix))
throw new Error("Unsupported WABT section header format.");
throw new WabtAdmissionError("unsupported_section_header");
let offset = prefix.length;
let customIndex = 0;
const sections = [];
@@ -27,7 +29,8 @@ export const parseWabtOutput = (
/^[ \t]*(\w+) start=0x([\da-f]+) end=0x([\da-f]+) \(size=0x([\da-f]+)\) /u.exec(
text.slice(offset),
);
if (match === null) throw new Error("Unrecognized WABT section row.");
if (match === null)
throw new WabtAdmissionError("unrecognized_section_row");
offset += match[0].length;
let description: string;
if (match[1] === "Custom" && !text.startsWith('"', offset)) {
@@ -37,7 +40,7 @@ export const parseWabtOutput = (
const end = Number.parseInt(match[3] ?? "", 16);
const invalidEnd = invalidCustomNameEnd(artifact, start, end);
if (invalidEnd === undefined)
throw new Error("WABT blank custom-section name is not corroborated.");
throw new WabtAdmissionError("blank_custom_name");
invalidNameEnds.push(invalidEnd);
description = "";
// WABT does not terminate a blank-name row when another section follows.
@@ -49,14 +52,12 @@ export const parseWabtOutput = (
} else if (match[1] === "Custom") {
const name = wabtDisplayedName(customSectionNames[customIndex++]);
if (name === undefined || !text.startsWith(`"${name}"\n`, offset))
throw new Error(
"WABT custom-section header does not match decoded WAT.",
);
throw new WabtAdmissionError("custom_header_mismatch");
description = `"${name}"`;
offset += description.length + 1;
} else {
const end = text.indexOf("\n", offset);
if (end < 0) throw new Error("Incomplete WABT section row.");
if (end < 0) throw new WabtAdmissionError("incomplete_section_row");
description = text.slice(offset, end).trim();
offset = end + 1;
}
@@ -74,17 +75,13 @@ export const parseWabtOutput = (
sectionHeaderBytes < 2 ||
sectionHeaderBytes > 6
)
throw new Error("WABT section ranges do not match selected artifact.");
throw new WabtAdmissionError("section_ranges");
sections.push(section);
}
if (customIndex !== customSectionNames.length)
throw new Error(
"WABT custom-section inventory does not match decoded WAT.",
);
throw new WabtAdmissionError("custom_inventory_mismatch");
if ((sections.at(-1)?.end ?? 8) !== artifactBytes)
throw new Error(
"WABT section output does not cover the selected artifact.",
);
throw new WabtAdmissionError("section_coverage");
const separator = offset + 1;
return {
sections,
+16 -3
View File
@@ -26,8 +26,18 @@ it("frames escaped quotes and trailing backslashes without consuming the next fo
it("rejects missing terminators, including a dangling escape or escaped last quote", () => {
for (const source of ['"', '"payload', '"payload\\', '"payload\\"']) {
expect(() => watStringEnd(source, 1)).toThrow("Incomplete WABT string");
expect(() => watString(source, 1)).toThrow("Incomplete WABT string");
expect(() => watStringEnd(source, 1)).toThrow(
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "incomplete_string",
}),
);
expect(() => watString(source, 1)).toThrow(
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "incomplete_string",
}),
);
expect(() => parseWabtWat(`(module (import ${source}`)).toThrow();
}
});
@@ -53,6 +63,9 @@ it("preserves quoted WAT punctuation, nested comments, escaped names and unknown
},
]);
expect(() => parseWabtWat('(module (import "env" "\\q" (func)))')).toThrow(
"Unsupported WABT name escape",
expect.objectContaining({
_tag: "WabtAdmissionError",
code: "unsupported_escape",
}),
);
});
+5 -2
View File
@@ -1,3 +1,5 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
/** Locate a WAT string's exclusive end without decoding or copying its payload. */
export const watStringEnd = (form: string, start: number): number => {
for (
@@ -9,7 +11,7 @@ export const watStringEnd = (form: string, start: number): number => {
while (escapes > start && form[escapes - 1] === "\\") escapes--;
if ((end - escapes) % 2 === 0) return end + 1;
}
throw new Error("Incomplete WABT string.");
throw new WabtAdmissionError("incomplete_string");
};
/** Decode a producer WAT string only where its bytes are interpreted. */
@@ -45,7 +47,8 @@ export const watString = (form: string, start: number) => {
: escape === "\\"
? 92
: undefined;
if (value === undefined) throw new Error("Unsupported WABT name escape.");
if (value === undefined)
throw new WabtAdmissionError("unsupported_escape");
bytes[length++] = value;
index += 2;
}
+14 -14
View File
@@ -1,3 +1,4 @@
import { WabtAdmissionError } from "./WabtAdmissionError.js";
import { parseWabtInterface } from "./WabtInterface.js";
import { watString, watStringEnd } from "./WabtString.js";
import { watCommentEnd } from "./WabtComment.js";
@@ -25,24 +26,24 @@ const moduleNames = (
value += (byte & 127) * 2 ** shift;
if (byte < 128) return value;
}
throw new Error("Incomplete WABT name-section metadata.");
throw new WabtAdmissionError("incomplete_name_metadata");
};
try {
let previousType = -1;
while (offset < bytes.length) {
const type = uint32(bytes.length);
if (type <= previousType)
throw new Error("Duplicate or out-of-order WABT name subsection.");
throw new WabtAdmissionError("name_subsection_order");
previousType = type;
const size = uint32(bytes.length);
const end = offset + size;
if (end > bytes.length)
throw new Error("Incomplete WABT name-section metadata.");
throw new WabtAdmissionError("incomplete_name_metadata");
if (type === 0 && size !== 0) {
const length = uint32(end);
const nameEnd = offset + length;
if (nameEnd > end)
throw new Error("Incomplete WABT module-name metadata.");
throw new WabtAdmissionError("incomplete_module_name");
names.push(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(offset, nameEnd),
@@ -50,7 +51,7 @@ const moduleNames = (
);
// WABT calls OnModuleName before rejecting trailing subsection bytes.
if (nameEnd !== end)
throw new Error("Unfinished WABT module-name subsection.");
throw new WabtAdmissionError("unfinished_module_name");
}
offset = end;
}
@@ -68,7 +69,7 @@ const moduleNames = (
/** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */
export const parseWabtWat = (text: string) => {
if (!/^\(module(?:\s|\))/u.test(text))
throw new Error("Unsupported WABT WAT module prefix.");
throw new WabtAdmissionError("unsupported_module_prefix");
const forms: string[] = [];
const imports: string[] = [];
const exports: string[] = [];
@@ -95,13 +96,12 @@ export const parseWabtWat = (text: string) => {
continue;
}
if (char === '"') {
if (depth === 0)
throw new Error("Unexpected string outside WABT WAT module.");
if (depth === 0) throw new WabtAdmissionError("unexpected_string");
index = watStringEnd(text, index + 1) - 1;
continue;
}
if (char === "(") {
if (closed) throw new Error("Unexpected second WABT WAT module.");
if (closed) throw new WabtAdmissionError("unexpected_second_module");
depth++;
if (depth === 2) start = index;
} else if (char === ")") {
@@ -118,7 +118,7 @@ export const parseWabtWat = (text: string) => {
else if (/^\(@custom\s/u.test(form)) {
const prefix = /^\(@custom\s+"/u.exec(form);
if (prefix === null)
throw new Error("Unsupported WABT custom-section name.");
throw new WabtAdmissionError("unsupported_custom_name");
const nameString = watString(form, prefix[0].length);
// A WASM name is length-delimited UTF-8; leading U+FEFF is name data.
const name = new TextDecoder("utf-8", {
@@ -129,7 +129,7 @@ export const parseWabtWat = (text: string) => {
if (name === "name") {
const payloadPrefix = /^\s+"/u.exec(form.slice(nameString.end));
if (payloadPrefix === null)
throw new Error("Unsupported WABT name-section payload.");
throw new WabtAdmissionError("unsupported_name_payload");
const payload = watString(
form,
nameString.end + payloadPrefix[0].length,
@@ -148,12 +148,12 @@ export const parseWabtWat = (text: string) => {
}
}
}
if (--depth < 0) throw new Error("Unbalanced WABT WAT output.");
if (--depth < 0) throw new WabtAdmissionError("unbalanced_output");
if (depth === 0) closed = true;
} else if (depth === 0 && char !== undefined && !/\s/u.test(char))
throw new Error("Unexpected text outside WABT WAT module.");
throw new WabtAdmissionError("unexpected_text");
}
if (depth !== 0) throw new Error("Incomplete WABT WAT output.");
if (depth !== 0) throw new WabtAdmissionError("incomplete_output");
return {
imports,
exports,