Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 528da7cc31 fix(batch): report a provider's null pseudocode as unavailable, not unreadable
`procedure_pseudo_code` may return null, which a provider uses to say a
procedure has no pseudocode: Kuna does so for an import slot or a function
it could not decompile. `batch_decompile` treated null like malformed
output and reported "unreadable result, run rea doctor". A null item is now
a non-retryable capability error pointing to the batch limitations, which
carry the provider's reason. An empty string remains unreadable output.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:38:40 +00:00
42 changed files with 1351 additions and 1263 deletions

No files matched your search

+918 -79
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -138,7 +138,6 @@ export const SCOPES = {
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
"tests/boundary/process/ProcessEvidence.host.test.ts",
"tests/boundary/process/packageNpmInvocation.test.ts",
"tests/boundary/process/windowsOutputCapture.test.ts",
"tests/acceptance/webModuleCli.test.ts",
"tests/acceptance/webSourceLocationCli.test.ts",
-20
View File
@@ -1,6 +1,5 @@
import { execFile } from "node:child_process";
import { lstat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { Ajv2020 } from "ajv/dist/2020.js";
@@ -18,25 +17,6 @@ const loadMcpVerifierResults = () => import("./mcp-verifier-results.mjs");
/** Promisified child_process execution helper. */
export const exec = promisify(execFile);
/** Run npm through its JavaScript entry point so Windows command scripts are not required. */
export const execNpm = (arguments_, options = {}) => {
const environment = options.env ?? process.env;
const selectedEntry = environment.npm_execpath;
if (
selectedEntry !== undefined &&
(typeof selectedEntry !== "string" || selectedEntry.length === 0)
)
throw new Error("npm_execpath must name the selected npm CLI entry point");
const entry =
selectedEntry ??
(process.platform === "win32"
? join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js")
: undefined);
return entry === undefined
? exec("npm", arguments_, options)
: exec(process.execPath, [entry, ...arguments_], options);
};
/** Run a command and return its stdout. */
export const run = async (command, args, env) =>
(await exec(command, args, { env })).stdout;
-4
View File
@@ -1,4 +0,0 @@
// A single inspection can include native admission plus several WABT commands.
// The aggregate verifier covers seven complete fixtures and their failure paths.
export const WABT_OPERATION_TIMEOUT_MS = 4 * 60_000;
export const WABT_WORKFLOW_TIMEOUT_MS = 15 * 60_000;
+6 -4
View File
@@ -2,7 +2,7 @@ import { rm } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { exec, execNpm, json, run } from "./lib/verify-package-core.mjs";
import { exec, json, run } from "./lib/verify-package-core.mjs";
/** Install the packed CLI globally with and without optional dependencies and verify PTY degradation. */
export async function verifyPackageInstall({
@@ -11,7 +11,8 @@ export async function verifyPackageInstall({
workspace,
environment,
}) {
await execNpm(
await exec(
"npm",
[
"install",
"--global",
@@ -52,7 +53,8 @@ export async function verifyPackageInstall({
`packaged PTY backend failed without lifecycle scripts: ${JSON.stringify(processCaptureCapability)}`,
);
const noOptionalPrefix = join(workspace, "prefix-no-optional");
await execNpm(
await exec(
"npm",
[
"install",
"--global",
@@ -106,7 +108,7 @@ export async function verifyPackageInstall({
const verifyInstalledPackageCanRepack = async (prefix, environment) => {
const installedPackageRoot = join(prefix, "lib/node_modules/rea-agents");
const repackedTarball = (
await execNpm(["pack", "--silent"], {
await exec("npm", ["pack", "--silent"], {
cwd: installedPackageRoot,
env: environment,
})
+8 -4
View File
@@ -2,7 +2,7 @@ import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js";
import { exec, execNpm } from "./lib/verify-package-core.mjs";
import { exec } from "./lib/verify-package-core.mjs";
import { verifyPackedBridge } from "./verify-packed-bridge.mjs";
/** Create a tarball and assert production packaging constraints. */
@@ -12,9 +12,13 @@ export async function verifyPackagePack({ root, workspace }) {
const tarball = join(
packDirectory,
(
await execNpm(["pack", "--silent", "--pack-destination", packDirectory], {
cwd: root,
})
await exec(
"npm",
["pack", "--silent", "--pack-destination", packDirectory],
{
cwd: root,
},
)
).stdout.trim(),
);
const packedFiles = (await exec("tar", ["-tf", tarball])).stdout.split("\n");
+9 -3
View File
@@ -28,7 +28,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { execNpm } from "./lib/verify-package-core.mjs";
if (process.platform !== "win32" || process.arch !== "x64")
throw new Error(
@@ -83,10 +82,15 @@ let packageRoot = resolve(packageRootArgument ?? ".");
// package roots support an already-installed artifact without a second install.
if (packageRootArgument === undefined) {
try {
const npmEntry =
process.env.npm_execpath ??
join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js");
const packed = JSON.parse(
(
await execNpm(
await exec(
process.execPath,
[
npmEntry,
"pack",
"--json",
"--ignore-scripts",
@@ -103,8 +107,10 @@ if (packageRootArgument === undefined) {
"The package lane must produce one artifact.",
);
const prefix = join(workspace, "installed");
await execNpm(
await exec(
process.execPath,
[
npmEntry,
"install",
"--prefix",
prefix,
+4 -8
View File
@@ -2,8 +2,7 @@
import { join } from "node:path";
import { mkdir } from "node:fs/promises";
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
import { exec, execNpm } from "./lib/verify-package-core.mjs";
import { WABT_WORKFLOW_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs";
import { exec } from "./lib/verify-package-core.mjs";
import { verifyPackagePack } from "./verify-package-pack.mjs";
const root = await PrivateRuntimeRoot.create({
@@ -24,7 +23,8 @@ try {
XDG_CONFIG_HOME: join(home, ".config"),
XDG_CACHE_HOME: join(home, ".cache"),
};
await execNpm(
await exec(
"npm",
[
"install",
"--global",
@@ -49,11 +49,7 @@ try {
const verified = await exec(
process.execPath,
["scripts/verify-wasm-artifact.mjs", entrypoint, "--require-tools"],
{
env: environment,
timeout: WABT_WORKFLOW_TIMEOUT_MS,
maxBuffer: 4 * 1024 * 1024,
},
{ env: environment, timeout: 180_000, maxBuffer: 4 * 1024 * 1024 },
);
process.stdout.write(verified.stdout);
} finally {
+85 -50
View File
@@ -12,7 +12,6 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import {
inspectMipsReadelf,
expectedMipsElfProfile,
inspectLegacyMipsReadelf,
assertMipsLoadedImage,
assertMipsProbe,
@@ -121,17 +120,6 @@ async function verifySoftFloat(call, symbols, byteOrder) {
);
return { storage: boundaries };
}
function assertIncompleteMipsInspection(error) {
assert.equal(error.code, "capability_unavailable");
assert.equal(error.details.selection_reason, "target_unsupported");
const rejection = error.details.rejections.find(
(candidate) => candidate.provider_id === "ghidra",
);
assert.equal(rejection?.code, "architecture_unsupported");
assert.match(rejection.reason, /ABI declaration inspection is incomplete/u);
assert.match(rejection.reason, /section header table/u);
}
const reports = [];
let primaryFailure;
try {
@@ -487,9 +475,28 @@ try {
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.equal(snapshot.target.architecture, "mips");
const abi = independent.mips.abiFlags;
assert.deepEqual(
snapshot.binding.analysis_profile.parameters.mips_elf,
expectedMipsElfProfile(independent.mips),
{
elf_class: independent.mips.elfClass,
byte_order: independent.mips.byteOrder,
type: independent.mips.type,
flags: independent.mips.flags,
abi_flags: {
version: abi.version,
isa_level: abi.isaLevel,
isa_revision: abi.isaRevision,
gpr_size: abi.gprSize,
cpr1_size: abi.cpr1Size,
cpr2_size: abi.cpr2Size,
fp_abi: abi.fpAbi,
isa_extension: abi.isaExtension,
ases: abi.ases,
flags1: abi.flags1,
flags2: abi.flags2,
},
},
"Snapshot profile did not retain the independently inspected ELF/ABI declaration",
);
assert.equal(
@@ -610,8 +617,7 @@ try {
// PSP keeps its independent Allegrex/A-B-A checks; carved PSP is not verified.
let carved;
if (!psp) {
// A program-header ABI record survives carving, but GNU declarations in
// uncaptured section headers remain unknown: both adapters must refuse.
// Carved firmware keeps all program headers/load bytes but loses sections.
const read16 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt16LE(offset)
@@ -649,50 +655,79 @@ try {
carvedResolved.value.mips.limitations.join(" "),
/section header table/u,
);
const carvedError = requireMcpToolError(
await client.callTool(
{
name: "open_binary",
arguments: { path: carvedPath, provider_id: "ghidra" },
},
{ timeout: 60000 },
const carvedTarget = await call("open_binary", {
path: carvedPath,
provider_id: "ghidra",
});
assert.equal(carvedTarget.sha256, carvedSha256);
const carvedImage = await call("inspect_native_load_image");
assertMipsLoadedImage(
carvedImage.observations,
byteOrder,
carvedSha256,
);
const carvedProcedures = await call("list_procedures");
assert.ok(
carvedProcedures.some(
(p) =>
BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry),
),
);
assertIncompleteMipsInspection(carvedError);
let carvedCliError;
try {
await execute(
process.execPath,
[
entrypoint,
"function",
carvedPath,
independent.symbols.rea_mips_entry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 60000, maxBuffer: 1024 * 1024 },
);
} catch (cause) {
carvedCliError = cause;
}
assert.ok(
carvedCliError &&
Number.isInteger(carvedCliError.code) &&
carvedCliError.code !== 0,
"Expected an incomplete-ABI refusal, not a timeout or success",
const carvedDossier = functionDossierSchema.parse(
await call("analyze_function", {
procedure: independent.symbols.rea_mips_entry,
}),
);
assert.ok(carvedDossier.pseudocode.trim().length > 0);
const carvedSnapshotPath = join(
workspace,
`${byteOrder}-${floatAbi}-carved.snapshot.json`,
);
await call("close_binary", { snapshot_path: carvedSnapshotPath });
const carvedSnapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(carvedSnapshotPath, "utf8")),
);
assert.equal(carvedSnapshot.target.sha256, carvedSha256);
assert.ok(
carvedSnapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
item.includes("section header table"),
),
),
);
const carvedCli = await execute(
process.execPath,
[
entrypoint,
"function",
carvedPath,
independent.symbols.rea_mips_entry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout));
assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256);
assert.ok(
carvedEvidence.limitations.some((item) =>
item.includes("section header table"),
),
);
assert.equal(
functionDossierSchema.parse(carvedEvidence.normalized_result)
.procedure.address,
carvedDossier.procedure.address,
);
assertIncompleteMipsInspection(JSON.parse(carvedCliError.stdout));
assert.deepEqual(await readFile(carvedPath), carvedBytes);
carved = {
sha256: carvedSha256,
source_bytes: carvedBytes.length,
section_header_offset: sectionOffset,
status: "expected-incomplete-abi-refusal",
effective_language: carvedImage.observations.language_id,
limitations: carvedResolved.value.mips.limitations,
cli_exit: carvedCliError.code,
mcp_error: carvedError,
procedures: carvedProcedures.length,
};
}
reports.push({
+1 -4
View File
@@ -83,11 +83,8 @@ const FIXTURE_SOURCE = String.raw`
#include <stdio.h>
#include <stdlib.h>
static const char rea_kuna_utf8[] __attribute__((used)) = "_φ( °-°)/ REA unicode marker";
/* The leading NUL unit ends any UTF-16 run that bytes before this array would
start: the UTF-8 marker's last byte and NUL decode as the unit 'r', so on
layouts where it precedes this array Kuna reports "rREA wide marker". */
static const unsigned short rea_kuna_wide[] __attribute__((used)) = {
0,'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0
'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0
};
#include <string.h>
__attribute__((noinline)) unsigned rea_checksum(const unsigned char *p, size_t n) {
+3 -2
View File
@@ -1,7 +1,7 @@
import { cp, mkdir, readFile, readdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { exec, execNpm, pathExists } from "./lib/verify-package-core.mjs";
import { exec, pathExists } from "./lib/verify-package-core.mjs";
/** Exercise Skills' default root discovery without ignored build outputs. */
export async function verifyRepositorySkill({ root, workspace }) {
@@ -34,7 +34,8 @@ export async function verifyRepositorySkill({ root, workspace }) {
await mkdir(consumer, { recursive: true });
await mkdir(home, { recursive: true });
const skillsVersion = "1.7.2";
await execNpm(
await exec(
"npm",
[
"exec",
"--yes",
+5 -8
View File
@@ -24,7 +24,6 @@ import {
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { WABT_OPERATION_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs";
const directory = process.env.REA_WABT_BIN_DIRECTORY;
if (!isAbsolute(directory ?? "")) {
@@ -77,8 +76,6 @@ try {
throw cause;
}
const client = new Client({ name: "wabt-artifact-verifier", version: "1" });
const callTool = (request) =>
client.callTool(request, { timeout: WABT_OPERATION_TIMEOUT_MS });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
@@ -131,7 +128,7 @@ try {
TMP: childTempRoot,
TEMP: childTempRoot,
},
timeout: WABT_OPERATION_TIMEOUT_MS,
timeout: 120_000,
maxBuffer: 64 * 1024 * 1024,
},
)
@@ -241,7 +238,7 @@ try {
const cliValue = await inspectCli(input);
assert.equal(cliValue.category, undefined, JSON.stringify(cliValue));
const cli = parseEvidence(cliValue);
const reply = await callTool({
const reply = await client.callTool({
name: "inspect_wasm_artifact",
arguments: input,
});
@@ -335,7 +332,7 @@ try {
},
);
}
const bundleReply = await callTool({
const bundleReply = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
@@ -365,7 +362,7 @@ try {
JSON.stringify(cliError),
);
const error = requireMcpToolError(
await callTool({
await client.callTool({
name: "inspect_wasm_artifact",
arguments: invalidInput,
}),
@@ -373,7 +370,7 @@ try {
assert.equal(error.category, "invalid_input");
const partial = error.details.partial_observation;
assert.equal(partial.kind, "retained-evidence");
const failedBundleReply = await callTool({
const failedBundleReply = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
+20 -10
View File
@@ -18,10 +18,7 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import {
execNpm,
verifyCompleteToolCatalog,
} from "./lib/verify-package-core.mjs";
import { verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs";
import { verifyPackageUpdate } from "./verify-package-update.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
@@ -35,9 +32,9 @@ let report;
try {
const packed = JSON.parse(
(
await execNpm(
await npm(
["pack", "--json", "--silent", "--pack-destination", workspace],
{ cwd: root, windowsHide: true },
root,
)
).stdout,
);
@@ -66,9 +63,9 @@ try {
const tarball = join(workspace, packageResult.filename);
await access(tarball);
await execNpm(
await npm(
["install", "--no-package-lock", "--no-save", "--prefix", prefix, tarball],
{ cwd: workspace, windowsHide: true },
workspace,
);
const entry = join(
prefix,
@@ -212,9 +209,9 @@ try {
throw new Error(`Packaged uninstall did not remove ${id}`);
if (process.platform !== "win32")
await execNpm(
await npm(
["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball],
{ cwd: workspace, windowsHide: true },
workspace,
);
const update = await verifyPackageUpdate({
prefix,
@@ -244,3 +241,16 @@ try {
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), ...report })}\n`,
);
function npm(arguments_, cwd) {
const npmExecPath = process.env.npm_execpath;
return npmExecPath === undefined
? exec(process.platform === "win32" ? "npm.cmd" : "npm", arguments_, {
cwd,
windowsHide: true,
})
: exec(process.execPath, [npmExecPath, ...arguments_], {
cwd,
windowsHide: true,
});
}
+42 -27
View File
@@ -95,6 +95,46 @@ const firstAnalysisCancellation = <Value>(
* Direct callers use `execute`; adapters that own validation use
* `executeValidated` so an MCP input is parsed exactly once.
*/
const NO_PSEUDOCODE =
"The provider reports no pseudocode for this procedure; the batch limitations for procedure_pseudo_code say why.";
/** One batch item from its pseudocode observation, keeping null distinct from unreadable output. */
const batchPseudocodeItem = (
address: string,
procedure: BatchProcedureIdentity,
observed: Result<AnalysisExecution, AnalysisError>,
): BatchDecompileItem => {
const failed = (error: AnalysisError): BatchDecompileItem => ({
address,
procedure,
status: "error",
error: projectAnalysisError(error),
});
if (!observed.ok) return failed(observed.error);
const pseudocode = observed.value.result;
// The contract's null is the provider's own answer that this procedure has
// no pseudocode (an import slot, a failed body); the provider's reason is
// in the batch limitations, so it is not reported as unreadable output.
if (pseudocode === null)
return failed(
new AnalysisCapabilityUnavailableError(
observed.value.provider.id,
"procedure_pseudo_code",
NO_PSEUDOCODE,
{ userMessage: NO_PSEUDOCODE },
),
);
if (typeof pseudocode !== "string" || pseudocode.length === 0)
return failed(
new AnalysisOutputError(
"procedure_pseudo_code",
"provider returned empty pseudocode",
),
);
return { address, procedure, status: "ok", pseudocode };
};
export class EnhancedTools {
constructor(private readonly analysis: AnalysisOperationPort) {}
@@ -375,7 +415,7 @@ export class EnhancedTools {
): Promise<Result<BatchDecompileItem, AnalysisCancelledError>[]> {
return Promise.all(
ready.map(async ({ address, procedure, providerProcedure }) => {
const result = await this.#call(
const result = await this.#observe(
"procedure_pseudo_code",
{ procedure: providerProcedure },
signal,
@@ -383,32 +423,7 @@ export class EnhancedTools {
);
if (!result.ok && isAnalysisCancellation(result.error))
return err(result.error);
const item: BatchDecompileItem = !result.ok
? {
address,
procedure,
status: "error",
error: projectAnalysisError(result.error),
}
: typeof result.value !== "string" || result.value.length === 0
? {
address,
procedure,
status: "error",
error: projectAnalysisError(
new AnalysisOutputError(
"procedure_pseudo_code",
"provider returned empty pseudocode",
),
),
}
: {
address,
procedure,
status: "ok",
pseudocode: result.value,
};
return ok(item);
return ok(batchPseudocodeItem(address, procedure, result));
}),
);
}
@@ -84,8 +84,6 @@ export const analyzeInterfaceBuilderBundle = async (input: {
recordOmittedArchive(entry.path, "max_documents");
unreportedOmittedArchives += selection.unreportedOmitted;
for (const entry of selection.selected) {
// Plist representation reserved for this document before decoding.
let plistReservation = 0;
try {
const remainingInputBytes =
MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes;
@@ -134,7 +132,6 @@ export const analyzeInterfaceBuilderBundle = async (input: {
decodedReservation.estimatedBytes,
"plist representation exceeds the aggregate Interface Builder decode budget",
);
plistReservation = decodedReservation.estimatedBytes;
binaryPrototypeKeyObserved =
decodedReservation.binaryPrototypeKeyObserved;
}
@@ -168,9 +165,6 @@ export const analyzeInterfaceBuilderBundle = async (input: {
} catch (cause: unknown) {
if (input.signal?.aborted === true) throw cause;
if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) {
// A document rejected while decoding, e.g. beyond the shared depth
// limit, retains no representation; later documents keep its share.
decodeBudget.release(plistReservation);
omitted += 1;
recordOmittedArchive(entry.path, cause.reason);
continue;
@@ -706,24 +700,6 @@ const readEntry = async (
return Buffer.concat(chunks, length);
};
/**
* The shared parser bounds plist value nesting before its recursive decode and
* reports the limit as a RangeError; that is an omitted archive, not a
* malformed one.
*/
const parseXmlWithinDecodeBudget = (text: string) => {
try {
return parseXmlPropertyList(text);
} catch (cause: unknown) {
if (cause instanceof RangeError)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
cause.message,
);
throw cause;
}
};
/** Project decoded plist data and dates while retaining omitted-key coverage. */
const decodePlist = (
bytes: Buffer,
@@ -740,7 +716,7 @@ const decodePlist = (
value: parseBinary(bytes),
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
}
: parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes));
: parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes));
const projected = projectPlistValue(value);
return {
value: projected.value,
@@ -49,14 +49,6 @@ export class InterfaceBuilderDecodeBudget {
this.#remainingBytes -= bytes;
}
/** Return a reservation whose representation was rejected and not retained. */
release(bytes: number): void {
this.#remainingBytes = Math.min(
this.#initialBytes,
this.#remainingBytes + bytes,
);
}
/** Reserve an upper bound for one JSON-serialized string occurrence. */
reserveJsonStringOccurrence(value: string): void {
// JSON escaping uses at most six ASCII bytes per UTF-16 code unit, plus
@@ -125,8 +117,7 @@ const estimateXmlPlistExpansion = (
const xml = xmlText ?? decodeXmlPlistText(bytes);
const decodedSourceBytes = xml.length * 2;
let elementCount = 0;
// Value nesting is bounded by the shared plist parser, which counts plist
// value depth; raw element depth would also count the envelope and keys.
let xmlDepth = 0;
for (const token of xmlPreflightTokens(xml)) {
if (token.doctype) {
// Only a declaration outside opaque text can introduce an internal
@@ -139,6 +130,15 @@ const estimateXmlPlistExpansion = (
continue;
}
elementCount += 1;
if (token.closing) xmlDepth -= 1;
else if (!token.selfClosing) {
xmlDepth += 1;
if (xmlDepth > 128)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"XML plist nesting exceeds the aggregate Interface Builder decode budget",
);
}
if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes)
return maxBytes + 1;
}
@@ -179,7 +179,13 @@ function* xmlPreflightTokens(xml: string) {
}
if (end === xml.length) return;
cursor = end + 1;
if (doctype || element) yield { doctype, internalSubset };
if (doctype || element)
yield {
doctype,
internalSubset,
closing: xml[start + 1] === "/",
selfClosing: xml[end - 1] === "/",
};
}
}
@@ -121,43 +121,6 @@ describe("source-map artifact relationships", () => {
);
},
);
it("keeps a directory named like a source map contained by its parent", () => {
const archive = createOccurrence(entry("app", "directory"), "app", null);
archive.artifact_id = contentId("1");
const script = createOccurrence(
entry("app/main.js", "file"),
"app/main.js",
archive.occurrence_id,
);
script.artifact_id = contentId("2");
const directory = createOccurrence(
entry("app/main.js.map", "directory"),
"app/main.js.map",
archive.occurrence_id,
);
directory.artifact_id = contentId("3");
const { edges, limitations } = createArtifactEdges(contentId("4"), [
archive,
script,
directory,
]);
expect(directory).toMatchObject({
artifact_kind: "container",
artifact_format: "directory",
});
expect(
edges.find(
({ occurrence_id }) => occurrence_id === directory.occurrence_id,
),
).toMatchObject({
relation: "contains",
parent_artifact_id: contentId("1"),
});
expect(limitations).toEqual([]);
});
});
const fat = (wide = false): Buffer =>
@@ -295,9 +295,7 @@ export const createArtifactEdges = (
const edges: ArtifactEdge[] = [];
const limitations: string[] = [];
for (const occurrence of occurrences) {
// Use the occurrence's own classification: a directory named `x.map` is
// a container, not a source map of its sibling.
const isSourceMap = occurrence.artifact_kind === "source-map";
const isSourceMap = occurrence.logical_path.toLowerCase().endsWith(".map");
const sourcePath = isSourceMap
? occurrence.logical_path.slice(0, -".map".length)
: undefined;
@@ -339,10 +337,8 @@ export const createArtifactEdges = (
relation:
occurrence.entry_kind === "slice"
? ("slice-of" as const)
: isSourceMap
? mappedSource === undefined
? ("contains" as const)
: ("maps-source" as const)
: isSourceMap && mappedSource === undefined
? ("contains" as const)
: relationFor(occurrence.logical_path),
occurrence_id: occurrence.occurrence_id,
};
@@ -591,8 +587,11 @@ const classifyFatArtifact = (
return identity.state === "mach-o" ? "mach-o-universal" : "unknown";
};
/** Relation of a non-source-map occurrence to its parent. */
const relationFor = (path: string): ArtifactEdge["relation"] =>
path.toLowerCase().includes(".framework/") ? "embeds" : "contains";
const relationFor = (path: string): ArtifactEdge["relation"] => {
const lower = path.toLowerCase();
if (lower.endsWith(".map")) return "maps-source";
if (lower.includes(".framework/")) return "embeds";
return "contains";
};
const depth = (path: string): number => path.split("/").length;
@@ -156,90 +156,6 @@ describe("compiled Interface Builder graph projection", () => {
});
});
describe("keyed archive objects with unresolved classes", () => {
it("keeps an archived object whose class reference cannot be resolved", () => {
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 99 }, label: "Unresolved object" },
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
{ $class: { UID: 2 }, "NS.string": "ok" },
"plain scalar",
],
$top: { root: { UID: 3 } },
});
// The class descriptor and the scalar are not archived objects.
expect(parsed.objects).toMatchObject([
{
id: "1",
kind: "other",
class_name: null,
name: "Unresolved object",
attributes: { label: "Unresolved object", $class: { UID: 99 } },
},
{ id: "3", kind: "view", class_name: "UIView" },
]);
expect(parsed.objects[1]?.attributes).not.toHaveProperty("$class");
expect(parsed.objectCount).toBe(2);
});
it("keeps an unresolved hierarchy root's label and coverage in the graph", () => {
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "Main.nib",
archiveSha256: hash,
documentKind: "nib",
raw: {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 99 }, label: "Unresolved object" },
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
{ $class: { UID: 2 }, "NS.string": "ok" },
],
$top: { root: { UID: 1 } },
},
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
const objectNodes = result.graph.nodes.filter(
({ attributes }) => attributes.interface_builder_object_id !== undefined,
);
expect(objectNodes).toMatchObject([
{
kind: "unknown",
name: "Unresolved object",
attributes: { interface_builder_object_id: "1", class_name: null },
},
{ kind: "view", attributes: { interface_builder_object_id: "3" } },
]);
expect(result.documents[0]?.object_count).toBe(2);
expect(
result.graph.edges.some(
({ relation, to, resolution }) =>
relation === "contains" &&
to === objectNodes[0]?.id &&
resolution === "observed",
),
).toBe(true);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "objects:Main.nib",
status: "complete",
examined: 2,
omitted: 0,
}),
);
});
});
describe("Interface Builder parser bounds", () => {
it("reports connections discarded by the parser's hard ceiling", () => {
const connections = Array.from({ length: 40_001 }, (_, index) => ({
@@ -327,16 +327,11 @@ const parseKeyedArchive = (
if (index === 0) continue;
const resolved = record(resolve(raw));
const className = firstString(resolved.className);
// An archived object names its class with its own `$class` reference;
// when that reference cannot be resolved the object is still observed,
// with an unknown class. Scalars and class descriptors are not objects.
if (className === null && !Object.hasOwn(record(raw), "$class")) continue;
if (className === null) continue;
objectCount += 1;
const fields = Object.fromEntries(
Object.entries(resolved).filter(([key]) => key !== "className"),
);
// Keep the unresolvable class reference itself, e.g. a dangling UID.
if (className === null) fields.$class = record(raw).$class;
const authoredId = firstString(fields.objectID, fields["object-id"]);
const objectId =
authoredId ??
@@ -362,7 +357,6 @@ const parseKeyedArchive = (
}),
);
if (
className !== null &&
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
className,
)
+3 -2
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
@@ -18,7 +19,6 @@ import {
} from "./KunaProjectFiles.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
/** The exact producing range for one facet, including a failed C block. */
export interface KunaProjectFileSpan {
@@ -134,7 +134,8 @@ export class KunaProjectReader {
const span = index.value.code.get(address);
if (span === undefined)
return err(
kunaUnavailable(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
operation,
`Kuna's export lists no code at ${address}; import slots and data entries have no listing.`,
),
+19 -4
View File
@@ -8,7 +8,10 @@ import type {
ProviderAvailability,
ProviderTargetSupport,
} from "../application/AnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import {
createAnalysisProfile,
type AnalysisProfileCommitment,
@@ -30,7 +33,6 @@ import {
import { KUNA_COMMAND_REMEDIATION, KUNA_PROVIDER_ID } from "./KunaRelease.js";
import { KunaRunSlots } from "./KunaRunSlots.js";
import { KunaSessionClient } from "./KunaSessionClient.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
/** Architectures REA verified Kuna on: x86-64 and AArch64 ELF and Mach-O, plus ARM Thumb. */
const VERIFIED_ARCHITECTURES: ReadonlySet<string> = new Set([
@@ -167,7 +169,14 @@ export class KunaProvider implements AnalysisProviderCandidate {
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
if (!installation.ok)
return err(kunaUnavailable("open_binary", installation.error.reason));
return err(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
"open_binary",
installation.error.reason,
{ userMessage: installation.error.reason },
),
);
const { value } = installation;
return ok({
profile: createAnalysisProfile(
@@ -209,7 +218,13 @@ export class KunaProvider implements AnalysisProviderCandidate {
if (!configuration.ok)
return {
execute: async (operation) =>
err(kunaUnavailable(operation, configuration.error.reason)),
err(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
operation,
configuration.error.reason,
),
),
close: async () => ok(null),
};
return new KunaSessionClient(target, profile, {
+3 -2
View File
@@ -1,6 +1,7 @@
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -20,7 +21,6 @@ import {
} from "./KunaReferenceFiles.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
/** Charge raw JSON bytes conservatively; retained projections are smaller. */
const XREF_CACHE_BYTES = 16 * 1024 * 1024;
@@ -158,7 +158,8 @@ export class KunaReferenceQueries {
const node = graph.nodes.get(address);
if (node === undefined)
return err(
kunaUnavailable(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
operation,
`Kuna's graph has no function node at ${address}; its relationships are unknown.`,
),
+17 -4
View File
@@ -69,7 +69,6 @@ import {
KunaProjectReader,
type KunaProjectFileSpan,
} from "./KunaProjectReader.js";
import { kunaUnavailable } from "./KunaUnavailable.js";
/** How often a request waiting on the project export reports that it still waits. */
const WAIT_PROGRESS_INTERVAL_MS = 10_000;
@@ -279,7 +278,13 @@ export class KunaSessionClient implements AnalysisClient {
options: ExecutionOptions = {},
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (this.#closing)
return err(kunaUnavailable(operation, "This Kuna session is closed."));
return err(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
operation,
"This Kuna session is closed.",
),
);
return this.#own(
this.#execute(operation, parameters, {
...options,
@@ -307,7 +312,8 @@ export class KunaSessionClient implements AnalysisClient {
return err(new AnalysisCancelledError(operation));
if (operation !== "health" && !isKunaOperation(operation))
return err(
kunaUnavailable(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
operation,
"Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.",
),
@@ -1223,7 +1229,14 @@ export class KunaSessionClient implements AnalysisClient {
this.#installation ??= inspectKunaInstallation(this.#environment).then(
(inspected): Result<KunaInstallation, AnalysisError> => {
if (!inspected.ok)
return err(kunaUnavailable("health", inspected.error.reason));
return err(
new AnalysisCapabilityUnavailableError(
KUNA_PROVIDER_ID,
"health",
inspected.error.reason,
{ userMessage: inspected.error.reason },
),
);
const committed = this.#profile?.parameters["kuna_sha256"];
if (committed !== undefined && committed !== inspected.value.sha256)
return err(
-18
View File
@@ -1,18 +0,0 @@
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
/**
* A Kuna capability gap whose reason is what the caller needs to read. Without
* a user message the projection shows only "choose another analysis or
* target", which hides facts such as "no code at this address".
*/
export const kunaUnavailable = (
operation: string,
reason: string,
options: AnalysisErrorOptions = {},
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(KUNA_PROVIDER_ID, operation, reason, {
...options,
userMessage: reason,
});
@@ -850,18 +850,18 @@ describe("application workflow CLI parity", () => {
if (seed === undefined)
throw new TypeError("Expected at least one semantic relation");
const traced = await runCliWithRequestFile(
root,
const traced = await runCli([
"trace-javascript-semantics",
{
JSON.stringify({
application: analyzed.value,
query: {
seed: { kind: "semantic-node", node_id: seed },
direction: "forward-influence",
include_ambiguous_dynamic_edges: true,
},
},
);
}),
"--json",
]);
expect(traced).toMatchObject({
operation: "trace_javascript_semantics",
predicate_type: "rea.javascript-semantic-trace",
@@ -1300,18 +1300,18 @@ describe("application workflow CLI export Evidence", () => {
'export default async function make() { return { kind: "record", extra: 1 }; }',
];
const [left, right] = await analyzeComparisonSources(root, sources);
const compared = await runCliWithRequestFile(
root,
const compared = await runCli([
"compare-javascript-export-shapes",
{
JSON.stringify({
left,
right,
left_module_path: "parser.mjs",
left_export_name: "default",
right_module_path: "parser.mjs",
right_export_name: "default",
},
);
}),
"--json",
]);
expect(compared).toMatchObject({
operation: "compare_javascript_export_shapes",
normalized_result: {
@@ -1640,19 +1640,3 @@ const runCli = async (
throw cause;
}
};
/**
* Pass a request carrying complete Evidence through the CLI's file-backed JSON
* input. Serialized Evidence exceeds the Windows command-line limit when it is
* passed as one argument, so spawn fails with ENAMETOOLONG before the workflow
* starts. Inline JSON transport keeps its own coverage in the parity tests.
*/
const runCliWithRequestFile = async (
root: string,
command: string,
request: unknown,
): Promise<unknown> => {
const requestPath = join(root, "request.json");
await writeFile(requestPath, JSON.stringify(request));
return runCli([command, requestPath, "--json"]);
};
@@ -4,10 +4,9 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { buildBinary } from "plist";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js";
import { InterfaceBuilderDecodeBudget } from "../../../src/artifacts/apple/InterfaceBuilderDecodeBudget.js";
import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { xcodeIbtoolAvailable } from "../../fixtures/ibtool.js";
@@ -908,57 +907,6 @@ describe("aggregate Interface Builder archive retention budget", () => {
});
});
describe("Interface Builder XML plist depth", () => {
it("bounds XML archives by plist value depth, not element depth", async () => {
const root = await createTestTempDirectory("rea-ib-depth-test-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
// The root dict is value depth 0, so the leaf sits at `nested + 1`. The
// plist envelope and keys add XML elements but not value depth.
const document = (nested: number) =>
`<?xml version="1.0"?><plist version="1.0"><dict>${"<key>child</key><dict>".repeat(nested)}<key>leaf</key><integer>1</integer>${"</dict>".repeat(nested)}</dict></plist>`;
await writeFile(join(resources, "AtLimit.nib"), document(127));
await writeFile(join(resources, "BeyondLimit.nib"), document(128));
const release = vi.spyOn(InterfaceBuilderDecodeBudget.prototype, "release");
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
});
const released = release.mock.calls.map(([bytes]) => bytes);
release.mockRestore();
// The rejected archive returns its reservation to later documents.
expect(released).toHaveLength(1);
expect(released[0]).toBeGreaterThan(0);
expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([
"Contents/Resources/AtLimit.nib",
]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "aggregate_decode_budget_exhausted",
examined: 2,
omitted: 1,
}),
);
expect(result.limitations).toContain(
"Contents/Resources/BeyondLimit.nib: omitted because aggregate decode budget exhausted.",
);
});
it("restores released representation bytes up to the initial budget", () => {
const budget = new InterfaceBuilderDecodeBudget(100);
budget.reserve(60, "reserve");
budget.release(60);
expect(budget.remainingBytes).toBe(100);
budget.release(10);
expect(budget.remainingBytes).toBe(100);
});
});
describe("bounded Interface Builder archive decoding", () => {
it("counts malformed archives against the document limit", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
@@ -470,6 +470,47 @@ describe("enhanced MCP batches", () => {
});
});
it("reports a provider's null pseudocode as unavailable, not unreadable", async () => {
const client = await connect({
execute: async (name, arguments_) =>
name === "procedure_address"
? ok(String(arguments_.procedure))
: name === "address_name"
? ok(null)
: ok(arguments_.procedure === "0x1" ? null : ""),
});
const result = await client.callTool({
name: "batch_decompile",
arguments: { addresses: ["0x1", "0x2"] },
});
expect(result.isError).not.toBe(true);
expect(jsonResult(result)).toMatchObject({
items: [
{
address: "0x1",
status: "error",
error: {
code: "capability_unavailable",
retryable: false,
message:
"The provider reports no pseudocode for this procedure; the batch limitations for procedure_pseudo_code say why.",
},
},
// An empty string is still output no provider should produce.
{
address: "0x2",
status: "error",
error: { code: "unreadable_output" },
},
],
total: 2,
succeeded: 0,
failed: 2,
});
});
it("returns ordered typed batch failures and zero counts for empty input", async () => {
const client = await connect({
execute: async (name, arguments_) =>
@@ -1,90 +0,0 @@
import { execFile } from "node:child_process";
import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
const execute = promisify(execFile);
it("runs the caller-selected npm entry through Node without rewriting its inputs", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-npm-invocation-"));
try {
const npmEntry = join(root, "selected npm entry.mjs");
await writeFile(
npmEntry,
`process.stdout.write(JSON.stringify({ arguments: process.argv.slice(2), cwd: process.cwd(), marker: process.env.REA_NPM_TEST_MARKER }));\n`,
);
const helper = pathToFileURL(
resolve("scripts/lib/verify-package-core.mjs"),
).href;
const program = `
const { execNpm } = await import(${JSON.stringify(helper)});
const result = await execNpm(["pack", "value with spaces"], {
cwd: ${JSON.stringify(root)},
env: { ...process.env, npm_execpath: ${JSON.stringify(npmEntry)}, REA_NPM_TEST_MARKER: "caller-selected" },
});
process.stdout.write(result.stdout);
`;
const result = await execute(
process.execPath,
["--input-type=module", "--eval", program],
{ windowsHide: true },
);
expect(JSON.parse(result.stdout)).toEqual({
arguments: ["pack", "value with spaces"],
cwd: root,
marker: "caller-selected",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
const windowsIt = process.platform === "win32" ? it : it.skip;
windowsIt(
"uses the npm entry adjacent to Node when none is selected",
async () => {
const root = await mkdtemp(join(tmpdir(), "rea-adjacent-npm-"));
try {
const node = join(root, "node.exe");
const npmEntry = join(root, "node_modules/npm/bin/npm-cli.js");
await mkdir(dirname(npmEntry), { recursive: true });
await Promise.all([
copyFile(process.execPath, node),
writeFile(
npmEntry,
`process.stdout.write(JSON.stringify({ arguments: process.argv.slice(2), cwd: process.cwd() }));\n`,
),
]);
const helper = pathToFileURL(
resolve("scripts/lib/verify-package-core.mjs"),
).href;
const program = `
const { execNpm } = await import(${JSON.stringify(helper)});
const environment = { ...process.env };
delete environment.npm_execpath;
const result = await execNpm(["pack", "value with spaces"], {
cwd: ${JSON.stringify(root)},
env: environment,
});
process.stdout.write(result.stdout);
`;
const environment = { ...process.env };
delete environment.npm_execpath;
const result = await execute(
node,
["--input-type=module", "--eval", program],
{ env: environment, windowsHide: true },
);
expect(JSON.parse(result.stdout)).toEqual({
arguments: ["pack", "value with spaces"],
cwd: root,
});
} finally {
await rm(root, { recursive: true, force: true });
}
},
);
@@ -10,10 +10,6 @@ import {
rizinTestInferiorTty,
} from "./RizinTestInferiorTty.js";
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../../../src/process/ProviderProcess.js";
import type {
ProviderProcessHandle,
SpawnedOwnedProviderProcess,
@@ -28,11 +24,10 @@ interface InjectedProcessControl {
exit(signal?: NodeJS.Signals): void;
}
type RizinTestManagerOptions = NonNullable<
ConstructorParameters<typeof RizinDebugSessionManager>[1]
>;
type InjectedSpawnProcess = NonNullable<
RizinTestManagerOptions["spawnProcess"]
NonNullable<
ConstructorParameters<typeof RizinDebugSessionManager>[1]
>["spawnProcess"]
>;
export const incompleteStop: typeof stopOwnedDebuggerProcess = async (
@@ -181,47 +176,17 @@ export const createInjectedProcess = (
return { spawnProcess, control };
};
export const createRizinCoverageHarness = (
controls: {
readonly stopOwnedProcess?: (
supervisor: ProviderProcessSupervisor,
) => ReturnType<ProviderProcessSupervisor["stop"]>;
} = {},
) => {
let latestDirectory: string | undefined;
const directories = new Set<string>();
export const createRizinCoverageHarness = () => {
let directory: string | undefined;
const managers: RizinDebugSessionManager[] = [];
const ownedProcesses = new Set<ProviderProcessSupervisor>();
const spawnOwnedProcess: InjectedSpawnProcess = async (options) => {
const launched = await spawnOwnedProviderProcess(options);
// The fixture owns these fake children independently of the manager's
// intentionally uncertain debugger/inferior lifecycle.
ownedProcesses.add(
new ProviderProcessSupervisor(
{ ...launched, ownsProcessLifetime: true },
{ captureStdout: false, maxDiagnosticBytes: 0 },
),
);
return launched;
};
const createManager: typeof createRizinTestManager = (
environment = process.env,
options = {},
) => {
const manager = createRizinTestManager(environment, {
...options,
spawnProcess: options.spawnProcess ?? spawnOwnedProcess,
});
const track = (manager: RizinDebugSessionManager): void => {
managers.push(manager);
return manager;
};
const makeProvider = async (
body: string,
shebang = "#!/usr/bin/env node",
): Promise<string> => {
const directory = await mkdtemp(join(tmpdir(), "rea-rizin-coverage-"));
directories.add(directory);
latestDirectory = directory;
directory = await mkdtemp(join(tmpdir(), "rea-rizin-coverage-"));
const provider = join(directory, "fake-rizin.mjs");
await writeFile(provider, `${shebang}\n${body}\n`);
await chmod(provider, 0o700);
@@ -229,9 +194,13 @@ export const createRizinCoverageHarness = (
};
const start = async (
provider: string,
options: RizinTestManagerOptions = {},
options: ConstructorParameters<typeof RizinDebugSessionManager>[1] = {},
): Promise<{ manager: RizinDebugSessionManager; sessionId: string }> => {
const manager = createManager({ REA_RIZIN_COMMAND: provider }, options);
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: provider },
options,
);
track(manager);
const result = await manager.start({
path: "/tmp/coverage-target",
inferior_tty: await rizinTestInferiorTty(),
@@ -241,12 +210,13 @@ export const createRizinCoverageHarness = (
};
const startInjected = async (
spawnProcess: InjectedSpawnProcess,
options: RizinTestManagerOptions = {},
options: ConstructorParameters<typeof RizinDebugSessionManager>[1] = {},
) => {
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{ ...options, spawnProcess },
);
track(manager);
const started = await manager.start({
path: "/tmp/injected",
inferior_tty: await rizinTestInferiorTty(),
@@ -259,42 +229,17 @@ export const createRizinCoverageHarness = (
.splice(0)
.map((manager) => manager.closeAll().catch(() => undefined)),
);
const failures: Error[] = [];
for (const supervisor of ownedProcesses) {
const stopped =
controls.stopOwnedProcess === undefined
? await supervisor.stop()
: await controls.stopOwnedProcess(supervisor);
if (stopped.status === "incomplete") {
failures.push(
new Error(`Fake Rizin cleanup incomplete: ${stopped.reason}`),
);
} else if (supervisor.observedExit() === undefined) {
failures.push(
new Error("Fake Rizin cleanup did not observe child exit"),
);
} else {
ownedProcesses.delete(supervisor);
}
}
// Keep ownership and fixture files available when cleanup needs a retry.
if (failures.length > 0)
throw new AggregateError(failures, "Fake Rizin fixture cleanup failed");
for (const directory of directories) {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directories.delete(directory);
}
latestDirectory = undefined;
directory = undefined;
};
const missingProviderPath = (): string =>
join(latestDirectory!, "does-not-exist");
const missingProviderPath = (): string => join(directory!, "does-not-exist");
return {
cleanup,
makeProvider,
missingProviderPath,
start,
startInjected,
createManager,
spawnOwnedProcess,
track,
};
};
@@ -1,13 +1,12 @@
import { randomUUID } from "node:crypto";
import { access } from "node:fs/promises";
import { afterEach, expect, it, vi } from "vitest";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import {
createRizinTestManager,
stopFakeNoInferior,
rizinTestInferiorTty,
} from "./RizinTestInferiorTty.js";
import { rizinCommand } from "../../../src/rizin/RizinCommand.js";
import * as providerProcess from "../../../src/process/ProviderProcess.js";
import type { SpawnedOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import {
createRizinCoverageHarness,
createInjectedProcess,
@@ -21,8 +20,7 @@ const {
missingProviderPath,
start,
startInjected,
createManager,
spawnOwnedProcess,
track,
} = createRizinCoverageHarness();
afterEach(cleanup);
@@ -42,7 +40,7 @@ it.skipIf(process.platform === "win32")(
it.skipIf(process.platform === "win32")(
"rejects unsupported, pre-cancelled, and failed startup requests",
async () => {
const windows = createManager({}, { platform: "win32" });
const windows = createRizinTestManager({}, { platform: "win32" });
expect(
(
await windows.start({
@@ -63,20 +61,20 @@ it.skipIf(process.platform === "win32")(
).toBe(false);
const failed = await makeProvider("process.exit(9);");
const manager = createManager({
const manager = createRizinTestManager({
REA_RIZIN_COMMAND: failed,
});
track(manager);
const result = await manager.start({
path: "/tmp/a",
inferior_tty: await rizinTestInferiorTty(),
});
expect(result.ok).toBe(false);
const missing = createManager({
const missing = createRizinTestManager({
REA_RIZIN_COMMAND: missingProviderPath(),
});
track(missing);
const spawnFailure = await missing.start({
path: "/tmp/a",
inferior_tty: await rizinTestInferiorTty(),
@@ -89,10 +87,10 @@ it.skipIf(process.platform === "win32")(
"returns a startup timeout when Rizin exits before its initial frame",
async () => {
const provider = await makeProvider("process.exit(0);");
const manager = createManager({
const manager = createRizinTestManager({
REA_RIZIN_COMMAND: provider,
});
track(manager);
const result = await manager.start({
path: "/tmp/early-exit",
inferior_tty: await rizinTestInferiorTty(),
@@ -112,14 +110,14 @@ it.skipIf(process.platform === "win32")(
startup: () => undefined,
beforeSpawnReturns: resolveSpawned,
});
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{
spawnProcess: injected.spawnProcess,
stopProcess: async () => ({ status: "stopped" }),
},
);
track(manager);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const starting = manager.start({
@@ -141,7 +139,7 @@ it.skipIf(process.platform === "win32")(
"does not spawn Rizin when cancellation arrives during TTY validation",
async () => {
let spawned = false;
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{
spawnProcess: async () => {
@@ -166,7 +164,7 @@ it.skipIf(process.platform === "win32")(
"rejects a non-TTY path before spawning Rizin",
async () => {
let spawned = false;
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{
spawnProcess: async () => {
@@ -197,7 +195,7 @@ it.skipIf(process.platform === "win32")(
releaseSpawns = resolve;
});
const injected = createInjectedProcess();
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{
spawnProcess: async (options) => {
@@ -242,9 +240,10 @@ it.skipIf(process.platform === "win32")(
const provider = await makeProvider(
'process.stdout.write("x".repeat(16 * 1024 * 1024 + 1) + "\\0"); process.stdin.resume();',
);
const manager = createManager({
const manager = createRizinTestManager({
REA_RIZIN_COMMAND: provider,
});
track(manager);
const result = await manager.start({
path: "/tmp/oversized-startup",
@@ -265,19 +264,19 @@ it.skipIf(process.platform === "win32")(
const provider = await makeProvider(
'setTimeout(() => process.stdout.write("\\0"), 500); process.stdin.resume();',
);
const manager = createManager(
const manager = createRizinTestManager(
{
REA_RIZIN_COMMAND: provider,
},
{
spawnProcess: async (options) => {
const launched = await spawnOwnedProcess(options);
const launched = await spawnOwnedProviderProcess(options);
resolveSpawned();
return launched;
},
},
);
track(manager);
const abort = new AbortController();
const starting = manager.start(
{ path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() },
@@ -289,138 +288,39 @@ it.skipIf(process.platform === "win32")(
},
);
it
.skipIf(process.platform === "win32")
.each(["is uncertain", "reports stopped before exit"] as const)(
"cleans startup fixtures after cancellation when manager stop %s",
async (stopMode) => {
const provider = await makeProvider(
"process.stdin.resume(); setInterval(() => {}, 1000);",
);
it.skipIf(process.platform === "win32")(
"preserves startup cleanup uncertainty after cancellation",
async () => {
let resolveSpawned!: () => void;
const spawned = new Promise<void>((resolve) => {
resolveSpawned = resolve;
});
let ownedLaunch: SpawnedOwnedProviderProcess | undefined;
const nativeSpawn = providerProcess.spawnOwnedProviderProcess;
const spy = vi
.spyOn(providerProcess, "spawnOwnedProviderProcess")
.mockImplementation(async (options) => {
const launched = await nativeSpawn(options);
ownedLaunch = launched;
resolveSpawned();
return launched;
});
try {
const stopProcess: typeof incompleteStop =
stopMode === "is uncertain"
? incompleteStop
: async () => ({ status: "stopped" });
const manager = createManager(
{ REA_RIZIN_COMMAND: provider },
{ stopProcess },
);
const abort = new AbortController();
const starting = manager.start(
{
path: "/tmp/cancel-start",
inferior_tty: await rizinTestInferiorTty(),
},
abort.signal,
);
await spawned;
abort.abort();
const result = await starting;
expect(result.ok).toBe(false);
if (!result.ok && stopMode === "is uncertain")
expect(result.error.message).toContain("injected stop uncertainty");
await cleanup();
if (ownedLaunch === undefined)
throw new Error("Fixture launch was not observed");
expect(
ownedLaunch.process.exitCode !== null ||
ownedLaunch.process.signalCode !== null,
"harness returned before fixture child exit",
).toBe(true);
} finally {
spy.mockRestore();
// A failing regression must also reap its observed, directly owned child.
if (
ownedLaunch !== undefined &&
ownedLaunch.process.exitCode === null &&
ownedLaunch.process.signalCode === null
) {
const safety = new providerProcess.ProviderProcessSupervisor(
{ ...ownedLaunch, ownsProcessLifetime: true },
{ captureStdout: false, maxDiagnosticBytes: 0 },
);
expect((await safety.stop()).status).not.toBe("incomplete");
expect(safety.observedExit()).toBeDefined();
}
}
},
);
it.skipIf(process.platform === "win32")(
"retains owned fixture processes and files until a failed cleanup can complete",
async () => {
let failCleanup = true;
const fixture = createRizinCoverageHarness({
stopOwnedProcess: async (supervisor) =>
failCleanup
? { status: "incomplete", reason: "injected fixture stop failure" }
: supervisor.stop(),
});
const provider = await fixture.makeProvider(
const provider = await makeProvider(
"process.stdin.resume(); setInterval(() => {}, 1000);",
);
const launched = await fixture.spawnOwnedProcess({
command: provider,
arguments: [],
runId: randomUUID(),
stdin: "pipe",
});
try {
await expect(fixture.cleanup()).rejects.toMatchObject({
message: "Fake Rizin fixture cleanup failed",
errors: [
expect.objectContaining({
message: expect.stringContaining("injected fixture stop failure"),
}),
],
});
expect(launched.process.exitCode).toBeNull();
expect(launched.process.signalCode).toBeNull();
await access(provider);
const nextProvider = await fixture.makeProvider("process.exit(0);");
failCleanup = false;
await fixture.cleanup();
expect(
launched.process.exitCode !== null ||
launched.process.signalCode !== null,
).toBe(true);
await expect(access(provider)).rejects.toMatchObject({ code: "ENOENT" });
await expect(access(nextProvider)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
failCleanup = false;
try {
await fixture.cleanup();
} finally {
if (
launched.process.exitCode === null &&
launched.process.signalCode === null
) {
const safety = new providerProcess.ProviderProcessSupervisor(
{ ...launched, ownsProcessLifetime: true },
{ captureStdout: false, maxDiagnosticBytes: 0 },
);
expect((await safety.stop()).status).not.toBe("incomplete");
expect(safety.observedExit()).toBeDefined();
}
}
}
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: provider },
{
stopProcess: incompleteStop,
spawnProcess: async (options) => {
const launched = await spawnOwnedProviderProcess(options);
resolveSpawned();
return launched;
},
},
);
track(manager);
const abort = new AbortController();
const starting = manager.start(
{ path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() },
abort.signal,
);
await spawned;
abort.abort();
const result = await starting;
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error.message).toContain("injected stop uncertainty");
},
);
@@ -1,5 +1,9 @@
import { afterEach, expect, it } from "vitest";
import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import {
createRizinTestManager,
rizinTestInferiorTty,
} from "./RizinTestInferiorTty.js";
import {
createRizinCoverageHarness,
abortOnCheck,
@@ -7,7 +11,7 @@ import {
incompleteStop,
} from "./RizinDebugSessionCoverage.fixtures.js";
const { cleanup, makeProvider, start, startInjected, createManager } =
const { cleanup, makeProvider, start, startInjected, track } =
createRizinCoverageHarness();
afterEach(cleanup);
@@ -84,7 +88,7 @@ it.skipIf(process.platform === "win32")(
async () => {
for (const spawnError of [new Error("injected spawn failure"), "opaque"]) {
const injected = createInjectedProcess({ spawnError });
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{ spawnProcess: injected.spawnProcess },
);
@@ -98,7 +102,7 @@ it.skipIf(process.platform === "win32")(
const injected = createInjectedProcess({
startup: (control) => control.closeStdin(),
});
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{ spawnProcess: injected.spawnProcess },
);
@@ -120,7 +124,7 @@ it.skipIf(process.platform === "win32")(
beforeSpawnReturns: () => abort.abort(),
spawnError: new Error("spawn interrupted"),
});
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{ spawnProcess: injected.spawnProcess },
);
@@ -143,7 +147,7 @@ it.skipIf(process.platform === "win32")(
abort.abort();
},
});
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{ spawnProcess: injected.spawnProcess },
);
@@ -162,7 +166,7 @@ it.skipIf(process.platform === "win32")(
const injected = createInjectedProcess({
beforeSpawnReturns: () => abort.abort(),
});
const manager = createManager(
const manager = createRizinTestManager(
{ REA_RIZIN_COMMAND: "injected-rizin" },
{ spawnProcess: injected.spawnProcess },
);
@@ -1,13 +1,17 @@
import { afterEach, expect, it, vi } from "vitest";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import {
createRizinTestManager,
rizinTestInferiorTty,
} from "./RizinTestInferiorTty.js";
import {
createRizinCoverageHarness,
abortOnCheck,
createInjectedProcess,
} from "./RizinDebugSessionCoverage.fixtures.js";
const { cleanup, startInjected, createManager } = createRizinCoverageHarness();
const { cleanup, startInjected, track } = createRizinCoverageHarness();
afterEach(cleanup);
it.skipIf(process.platform === "win32")(
@@ -1,13 +1,17 @@
import { afterEach, expect, it } from "vitest";
import { evidenceSchema } from "../../../src/domain/evidence.js";
import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import {
createRizinTestManager,
rizinTestInferiorTty,
} from "./RizinTestInferiorTty.js";
import {
createRizinCoverageHarness,
createInjectedProcess,
incompleteStop,
} from "./RizinDebugSessionCoverage.fixtures.js";
const { cleanup, makeProvider, start, startInjected, createManager } =
const { cleanup, makeProvider, start, startInjected, track } =
createRizinCoverageHarness();
afterEach(cleanup);
@@ -80,41 +80,3 @@ it("reports a graph beyond REA's buffer once per session instead of rebuilding i
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
it("says why Kuna cannot answer instead of a generic unavailable message", async () => {
const { open } = await session("rea-kuna-unavailable-reasons-");
const client = open();
try {
const cases = [
{
operation: "procedure_assembly",
parameters: { procedure: "0x2000" },
message:
"Kuna's export lists no code at 0x2000; import slots and data entries have no listing.",
},
{
operation: "procedure_callers",
parameters: { procedure: "0x1004" },
message:
"Kuna's graph has no function node at 0x1004; its relationships are unknown.",
},
{
operation: "list_segments",
parameters: {},
message:
"Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.",
},
] as const;
for (const { operation, parameters, message } of cases) {
const result = await client.execute(operation, parameters);
if (result.ok) throw new Error(`${operation} unexpectedly succeeded`);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "capability_unavailable",
message,
retryable: false,
});
}
} finally {
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
+2 -17
View File
@@ -1,25 +1,10 @@
import type {
MipsAbiFlags,
MipsElfMetadata,
MipsGnuAttributes,
} from "../../../src/domain/binaryTargetTypes.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
export type MipsReadelfMetadata = MipsElfMetadata & {
readonly abiFlags: MipsAbiFlags;
readonly gnuAttributes: MipsGnuAttributes | null;
};
export function expectedMipsElfProfile(
mips: MipsReadelfMetadata,
): Readonly<Record<string, JsonValue>>;
import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js";
export type MipsFloatAbi = "hard" | "soft";
export interface MipsReadelfFacts {
mips: MipsReadelfMetadata & { readonly gnuAttributes: null };
mips: MipsElfMetadata;
symbols: Record<string, string>;
}
export function parseMipsReadelfSectionTypes(text: string): readonly string[];
export function parseMipsReadelf(
text: string,
byteOrder: string,
+1 -65
View File
@@ -23,65 +23,6 @@ const SOFT_FLOAT_SYMBOLS = [
"rea_mips_soft_caller",
];
/** Complete indexed ELF32 section types shared by the independent fixture readers. */
export function parseMipsReadelfSectionTypes(text) {
const counts = [
...text.matchAll(/^\s*Number of section headers:\s*(\d+)\s*$/gmu),
];
assert.equal(counts.length, 1, "Missing or ambiguous readelf section count");
const sectionCount = Number(counts[0][1]);
assert.ok(sectionCount > 0, "Fixture must have a section header table");
const sections = [...text.matchAll(/^[ \t]*\[[ \t]*(\d+)\][ \t]+(.+)$/gmu)];
assert.equal(
sections.length,
sectionCount,
"Incomplete readelf section table",
);
return sections.map((section, index) => {
assert.equal(Number(section[1]), index, "Ambiguous readelf section index");
const columns =
/^(?:\S+[ \t]+)?(\S+)[ \t]+[\da-f]{8}[ \t]+[\da-f]+[ \t]+[\da-f]+[ \t]+[\da-f]{2}[ \t]+(?:[A-Za-z]+[ \t]+)?\d+[ \t]+\d+[ \t]+\d+[ \t]*$/u.exec(
section[2],
);
assert.ok(columns !== null, "Malformed readelf section header");
return columns[1];
});
}
/** Independent wire-format expectation shared by generic and PSP snapshots. */
export function expectedMipsElfProfile(mips) {
const abi = mips.abiFlags;
assert.ok(abi != null, "Fixture ABI declarations must be inspected");
assert.notEqual(
mips.gnuAttributes,
undefined,
"Fixture GNU attributes must be inspected",
);
return {
elf_class: mips.elfClass,
byte_order: mips.byteOrder,
type: mips.type,
flags: mips.flags,
gnu_attributes:
mips.gnuAttributes === null
? null
: { fp_abi: mips.gnuAttributes.fpAbi, msa: mips.gnuAttributes.msa },
abi_flags: {
version: abi.version,
isa_level: abi.isaLevel,
isa_revision: abi.isaRevision,
gpr_size: abi.gprSize,
cpr1_size: abi.cpr1Size,
cpr2_size: abi.cpr2Size,
fp_abi: abi.fpAbi,
isa_extension: abi.isaExtension,
ases: abi.ases,
flags1: abi.flags1,
flags2: abi.flags2,
},
};
}
/**
* Parse only the documented GNU readelf fields needed by our tiny fixtures.
* `rel` objects carry their own symbol checks in mips-relocatable-checks.mjs.
@@ -110,10 +51,6 @@ export function parseMipsReadelf(
objectType === "rel" ? /^REL\b/u : /^EXEC\b/u,
);
assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u);
assert.ok(
!parseMipsReadelfSectionTypes(text).includes("GNU_ATTRIBUTES"),
"Fixture unexpectedly declares GNU attributes",
);
const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+),.*$/gmu, "flags"));
assert.equal(flags, 0x70001001, "Fixture ELF flags differ from build intent");
assert.equal(field(/^ISA:\s*(.+)$/gmu, "ISA"), "MIPS32r2");
@@ -186,7 +123,6 @@ export function parseMipsReadelf(
type: objectType === "rel" ? 1 : 2,
flags,
abiFlags,
gnuAttributes: null,
},
symbols,
};
@@ -208,7 +144,7 @@ export async function inspectMipsReadelf(path, byteOrder, floatAbi = "hard") {
);
const result = await execute(
command,
["-h", "-A", "-S", "-s", "-W", path],
["-h", "-A", "-s", "-W", path],
options,
);
assert.equal(
+2 -106
View File
@@ -7,7 +7,7 @@ import {
parseMipsReadelf,
} from "./mips-checks.mjs";
// GNU readelf 2.44 output for the source-owned Clang 19 / LLD 19 fixture.
// GNU readelf 2.40 output for the source-owned Clang 17 / LLD fixture.
// This tests the reader boundary, not Ghidra; the real lane rebuilds both orders.
const readelf = `ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
@@ -21,7 +21,7 @@ const readelf = `ELF Header:
Version: 0x1
Entry point address: 0x201b0
Start of program headers: 52 (bytes into file)
Start of section headers: 2624 (bytes into file)
Start of section headers: 2996 (bytes into file)
Flags: 0x70001001, noreorder, o32, mips32r2
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
@@ -30,38 +30,6 @@ const readelf = `ELF Header:
Number of section headers: 24
Section header string table index: 22
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .MIPS.abiflags MIPS_ABIFLAGS 00010118 000118 000018 18 A 0 0 8
[ 2] .reginfo MIPS_REGINFO 00010130 000130 000018 18 A 0 0 4
[ 3] .rodata PROGBITS 00010148 000148 00001e 00 A 0 0 1
[ 4] .text PROGBITS 00020170 000170 0000f0 00 AX 0 0 16
[ 5] .data PROGBITS 00030260 000260 000000 00 WA 0 0 16
[ 6] .got PROGBITS 00030260 000260 000008 00 WAp 0 0 16
[ 7] .sdata PROGBITS 00030268 000268 000004 00 WAp 0 0 4
[ 8] .bss NOBITS 00030270 00026c 000000 00 WA 0 0 16
[ 9] .mdebug.abi32 PROGBITS 00000000 00026c 000000 00 0 0 1
[10] .pdr PROGBITS 00000000 00026c 000060 00 0 0 4
[11] .debug_abbrev MIPS_DWARF 00000000 0002cc 0000b3 00 0 0 1
[12] .debug_info MIPS_DWARF 00000000 00037f 00015e 00 0 0 1
[13] .debug_str_offsets MIPS_DWARF 00000000 0004dd 000040 00 0 0 1
[14] .debug_str MIPS_DWARF 00000000 00051d 0000ef 01 MS 0 0 1
[15] .debug_addr MIPS_DWARF 00000000 00060c 000018 00 0 0 1
[16] .comment PROGBITS 00000000 000624 00003e 01 MS 0 0 1
[17] .debug_frame MIPS_DWARF 00000000 000664 00004c 00 0 0 4
[18] .debug_line MIPS_DWARF 00000000 0006b0 0000f8 00 0 0 1
[19] .debug_line_str MIPS_DWARF 00000000 0007a8 000098 01 MS 0 0 1
[20] .debug_aranges MIPS_DWARF 00000000 000840 000020 00 0 0 1
[21] .symtab SYMTAB 00000000 000860 000090 10 23 4 4
[22] .shstrtab STRTAB 00000000 0008f0 0000f0 00 0 0 1
[23] .strtab STRTAB 00000000 0009e0 00005f 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), p (processor specific)
Symbol table '.symtab' contains 9 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
@@ -86,15 +54,6 @@ ASEs:
None
FLAGS 1: 00000000
FLAGS 2: 00000000
Static GOT:
Canonical gp value: 00038250
Reserved entries:
Address Access Value
00030260 -32752(gp) 00000000
00030264 -32748(gp) 80000000
`;
const sha256 = "a".repeat(64);
const image = {
@@ -135,7 +94,6 @@ describe("MIPS real-verifier independent expectations", () => {
elfClass: 32,
byteOrder: "little",
flags: 0x70001001,
gnuAttributes: null,
abiFlags: {
isaLevel: 32,
isaRevision: 2,
@@ -271,67 +229,6 @@ describe("MIPS real-verifier independent expectations", () => {
});
});
describe("MIPS real-verifier GNU attribute coverage", () => {
it.each(["exec", "rel"] as const)(
"reports verified GNU attribute absence for both byte orders of %s fixtures",
(objectType) => {
for (const byteOrder of ["little", "big"]) {
const text = readelf
.replace("little endian", `${byteOrder} endian`)
.replace(
"EXEC (Executable file)",
objectType === "rel"
? "REL (Relocatable file)"
: "EXEC (Executable file)",
);
expect(
parseMipsReadelf(text, byteOrder, "hard", objectType).mips
.gnuAttributes,
).toBeNull();
}
},
);
it("rejects GNU attribute sections even when their name is unrelated", () => {
expect(() =>
parseMipsReadelf(
readelf.replace(
".comment PROGBITS",
".comment GNU_ATTRIBUTES",
),
"little",
),
).toThrow(/GNU attributes/u);
});
it("uses the section type rather than its name to establish absence", () => {
expect(
parseMipsReadelf(
readelf.replace(
".comment PROGBITS",
".gnu.attributes PROGBITS",
),
"little",
).mips.gnuAttributes,
).toBeNull();
});
it.each([
[
"missing table",
readelf.replace(
/Section Headers:[\s\S]*?Key to Flags:/u,
"Key to Flags:",
),
],
["missing entry", readelf.replace(/^.*\[ 5\].*\n/gmu, "")],
["duplicate index", readelf.replace("[ 5]", "[ 4]")],
["malformed entry", readelf.replace(/^.*\[ 5\].*$/gmu, " [ 5] malformed")],
])("refuses GNU attribute absence from a %s", (_name, text) => {
expect(() => parseMipsReadelf(text, "little")).toThrow(/section/u);
});
});
describe("MIPS soft-float real-verifier expectations", () => {
const softReadelf = readelf
.replace("CPR1 size: 32", "CPR1 size: 0")
@@ -352,7 +249,6 @@ describe("MIPS soft-float real-verifier expectations", () => {
it("reads the soft-float ABI record and its fixture symbols", () => {
const parsed = parseMipsReadelf(softReadelf, "little", "soft");
expect(parsed.mips.abiFlags).toMatchObject({ cpr1Size: 0, fpAbi: 3 });
expect(parsed.mips.gnuAttributes).toBeNull();
expect(parsed.symbols.rea_mips_soft_mixed).toBe("0x20380");
});
+14 -142
View File
@@ -1,9 +1,6 @@
import assert from "node:assert/strict";
import { it } from "vitest";
import { expectedMipsElfProfile } from "./mips-checks.mjs";
import { ghidraMipsProfileParameters } from "../../../src/ghidra/GhidraMipsProfile.js";
import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js";
import { parsePspReadelfMips } from "./psp-fixture.mjs";
import { parsePspReadelfAbi } from "./psp-fixture.mjs";
// GNU readelf 2.44 output from the maintained PSPDEV v20261001 fixture.
const observed = `MIPS ABI Flags Version: 0
@@ -19,42 +16,19 @@ FLAGS 1: 00000001
FLAGS 2: 00000000
`;
// Constructed section-table controls combined with the captured ABI declaration;
// this does not establish a real PSPSDK or Allegrex-provider run.
const metadata = `ELF Header:
Class: ELF32
Data: 2's complement, little endian
Type: EXEC (Executable file)
Machine: MIPS R3000
Flags: 0x10a23001, noreorder, allegrex, eabi32, mips2
Number of section headers: 3
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .MIPS.abiflags MIPS_ABIFLAGS 08800118 000118 000018 18 A 0 0 8
[ 2] .text PROGBITS 08800130 000130 000010 00 AX 0 0 16
${observed}`;
it("reads the independent PSP producer's single-float declaration", () => {
assert.deepEqual(parsePspReadelfMips(metadata), {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
gnuAttributes: null,
abiFlags: {
version: 0,
isaLevel: 2,
isaRevision: 0,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: 1,
flags2: 0,
},
assert.deepEqual(parsePspReadelfAbi(observed), {
version: 0,
isaLevel: 2,
isaRevision: 0,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: 1,
flags2: 0,
});
});
@@ -72,108 +46,6 @@ it.each([
])(
"rejects unsupported or ambiguous independent ABI output: %s",
(before, after) => {
assert.throws(() => parsePspReadelfMips(metadata.replace(before, after)));
},
);
it.each([
[
"missing table",
metadata.replace(
/Section Headers:[\s\S]*?MIPS ABI Flags/u,
"MIPS ABI Flags",
),
],
["incomplete table", metadata.replace(/^.*\[ 2\].*\n/gmu, "")],
["duplicate index", metadata.replace("[ 2]", "[ 1]")],
[
"GNU section under another name",
metadata.replace(
".text PROGBITS",
".text GNU_ATTRIBUTES",
),
],
])("refuses PSP GNU attribute absence from a %s", (_name, text) => {
assert.throws(() => parsePspReadelfMips(text), /section|GNU attributes/u);
});
it.each([
["Class: ELF32", "Class: ELF64"],
["little endian", "big endian"],
["Type: EXEC", "Type: REL"],
["Flags: 0x10a23001", "Flags: 0x70001001"],
["Class: ELF32", "Class: ELF32\n Class: ELF32"],
])("refuses contradictory or ambiguous PSP headers: %s", (before, after) => {
assert.throws(() => parsePspReadelfMips(metadata.replace(before, after)));
});
// GNU readelf 2.40 independently decoded this declaration from a constructed
// ELF32/PSP-shaped control, not a PSPSDK build or a real Allegrex import.
const gnuDeclaration = `Attribute Section: gnu
File Attributes
Tag_GNU_MIPS_ABI_FP: Hard float (single precision)
`;
const gnuMetadata = metadata.replace(
".text PROGBITS",
".text GNU_ATTRIBUTES",
);
it.each(["before", "after"])(
"retains the independent single-float GNU declaration %s the ABI dump",
(position) => {
const text =
position === "before"
? gnuMetadata.replace(observed, `${gnuDeclaration}\n${observed}`)
: `${gnuMetadata}\n${gnuDeclaration}`;
assert.deepEqual(parsePspReadelfMips(text), {
...parsePspReadelfMips(metadata),
gnuAttributes: { fpAbi: 2, msa: null },
});
},
);
it.each([
["unknown vendor", gnuDeclaration.replace("gnu", "aeabi")],
["wrong float ABI", gnuDeclaration.replace("single", "double")],
["unknown tag", `${gnuDeclaration} Tag_GNU_Unknown: 1\n`],
[
"unsupported scope",
gnuDeclaration.replace("File Attributes", "Section Attributes: 1"),
],
["duplicate declaration", `${gnuDeclaration}${gnuDeclaration}`],
])("refuses %s rather than inventing PSP GNU facts", (_name, declaration) => {
assert.throws(
() => parsePspReadelfMips(`${gnuMetadata}\n${declaration}`),
/GNU attributes/u,
);
});
it("refuses undeclared GNU attributes rather than reporting absence", () => {
assert.throws(
() => parsePspReadelfMips(`${metadata}\n${gnuDeclaration}`),
/GNU attributes/u,
);
});
it.each([
["absent", metadata],
["declared", `${gnuMetadata}\n${gnuDeclaration}`],
])(
"matches production snapshot serialization for %s GNU facts",
(_name, text) => {
const mips = parsePspReadelfMips(text);
const target = {
path: "/fixture/psp.elf",
sha256: "a".repeat(64),
kind: "executable",
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
mips,
} satisfies BinaryTarget;
assert.deepEqual(
expectedMipsElfProfile(mips),
ghidraMipsProfileParameters(target).mips_elf,
);
assert.throws(() => parsePspReadelfAbi(observed.replace(before, after)));
},
);
+2 -2
View File
@@ -1,3 +1,3 @@
import type { MipsReadelfMetadata } from "./mips-checks.mjs";
import type { MipsAbiFlags } from "../../../src/domain/binaryTargetTypes.js";
export declare function parsePspReadelfMips(text: string): MipsReadelfMetadata;
export declare function parsePspReadelfAbi(text: string): MipsAbiFlags;
+16 -56
View File
@@ -4,7 +4,6 @@ import { createHash } from "node:crypto";
import { copyFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { parseMipsReadelfSectionTypes } from "./mips-checks.mjs";
const execute = promisify(execFile);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
@@ -43,11 +42,15 @@ export async function buildPspFixture(directory, globalValue = 7) {
assert.equal(bytes.readUInt32LE(36), 0x10a23001);
const readelf = await execute(
"psp-readelf",
["-h", "-A", "-S", "-sW", path],
["-h", "-A", "-sW", path],
options,
);
assert.equal(readelf.stderr.trim(), "");
const mips = parsePspReadelfMips(readelf.stdout);
assert.match(readelf.stdout, /Class:\s+ELF32/u);
assert.match(readelf.stdout, /Data:\s+2's complement, little endian/u);
assert.match(readelf.stdout, /Type:\s+EXEC\b/u);
assert.match(readelf.stdout, /Flags:\s+0x10a23001\b/u);
const abiFlags = parsePspReadelfAbi(readelf.stdout);
const abiDump = await execute(
"psp-readelf",
["-x", ".MIPS.abiflags", path],
@@ -96,7 +99,13 @@ export async function buildPspFixture(directory, globalValue = 7) {
source_sha256: sourceDigests,
symbols,
flags: "0x10a23001",
mips,
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
abiFlags,
},
abi_record_hex_dump: abiDump.stdout,
global_value: globalValue,
versions,
@@ -106,8 +115,8 @@ export async function buildPspFixture(directory, globalValue = 7) {
};
}
/** Complete independent fields for the pinned PSPSDK fixture, not REA's parser. */
export function parsePspReadelfMips(text) {
/** Independent GNU fields for the pinned PSPSDK fixture, not REA's parser. */
export function parsePspReadelfAbi(text) {
const field = (expression, label) => {
const matches = [...text.matchAll(expression)];
assert.equal(
@@ -117,13 +126,6 @@ export function parsePspReadelfMips(text) {
);
return matches[0][1].trim();
};
assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), "ELF32");
assert.equal(
field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"),
"little",
);
assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u);
assert.match(field(/^\s*Flags:\s*(.+)$/gmu, "flags"), /^0x10a23001\b/u);
const width = (name) => {
const bits = Number(
field(new RegExp(`^${name} size:\\s*(\\d+)\\s*$`, "gmu"), name),
@@ -176,49 +178,7 @@ export function parsePspReadelfMips(text) {
},
"PSPSDK ABI declaration differs from independently inspected build intent",
);
return {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
abiFlags: abi,
gnuAttributes: parsePspGnuAttributes(text),
};
}
// The maintained PSP fixture is single-float. Retain that independently decoded
// GNU declaration when present, and refuse other tags/vendors/scopes rather
// than confusing an uninspected or unsupported declaration with absence.
function parsePspGnuAttributes(text) {
const count = parseMipsReadelfSectionTypes(text).filter(
(type) => type === "GNU_ATTRIBUTES",
).length;
const headers = [...text.matchAll(/^Attribute Section: (.+)$/gmu)];
if (count === 0) {
assert.equal(
headers.length,
0,
"GNU attributes without a declared section",
);
return null;
}
assert.equal(count, 1, "Ambiguous GNU attributes sections");
assert.equal(headers.length, 1, "Missing or ambiguous GNU attributes output");
const header = headers[0];
assert.equal(header[1].trim(), "gnu", "Unsupported GNU attributes vendor");
const tail = text.slice(header.index + header[0].length);
// GNU attributes may precede or follow the architecture-specific ABI dump.
const end = tail.search(/^MIPS ABI Flags Version:/mu);
const lines = (end < 0 ? tail : tail.slice(0, end))
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
assert.deepEqual(
lines,
["File Attributes", "Tag_GNU_MIPS_ABI_FP: Hard float (single precision)"],
"Unsupported or incomplete PSP GNU attributes declaration",
);
return { fpAbi: 2, msa: null };
return abi;
}
/** Observations from Ghidra, not an echo of the requested profile. */
@@ -69,12 +69,6 @@ describe("curated Windows test lane", () => {
"tests/boundary/process/windowsOutputCapture.test.ts",
);
});
it("keeps the adjacent npm fallback regression in the lane", async () => {
expect(await explicitPaths("windows")).toContain(
"tests/boundary/process/packageNpmInvocation.test.ts",
);
});
});
it("keeps every explicit focused CI test path executable after file moves", async () => {