Supply a real retained resource owner to every Android verifier prerequisite check, sharing the existing Ghidra verifier cleanup scope. Match recovery checks to typed Java or archive failures and applicable remediation, preserving exact close retry authority and primary failures. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
400 lines
17 KiB
TypeScript
400 lines
17 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { chmod, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { expect, it } from "vitest";
|
|
import ts from "typescript6";
|
|
import type { inspectVerifierGhidraInstallation } from "../../../scripts/lib/ghidra-installation-inspection.mjs";
|
|
import type { validateVerifierJadxConfiguration } from "../../../scripts/lib/jadx-configuration-inspection.mjs";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
|
|
|
it("retains and retries the exact descriptor after a verifier hash close failure", async () => {
|
|
const directory = await createTestTempDirectory("rea-verifier-file-");
|
|
const path = join(directory, "input.bin");
|
|
const bytes = Buffer.from("owned verifier fixture");
|
|
await writeFile(path, bytes);
|
|
const script = `
|
|
import { ArtifactResourceScope } from ${JSON.stringify(new URL("../../../dist/artifacts/ArtifactResourceScope.js", import.meta.url).href)};
|
|
import { OwnedFileHandle } from ${JSON.stringify(new URL("../../../dist/filesystem/OwnedFileHandle.js", import.meta.url).href)};
|
|
import { hashVerifierFile } from ${JSON.stringify(new URL("../../../scripts/lib/verifier-file-resources.mjs", import.meta.url).href)};
|
|
const path = process.argv[1];
|
|
const resources = new ArtifactResourceScope();
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
let attempts = 0;
|
|
OwnedFileHandle.prototype.close = async function () {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error("fixture close failure");
|
|
return originalClose.call(this);
|
|
};
|
|
let failure;
|
|
try { await hashVerifierFile(path, resources); } catch (cause) { failure = cause; }
|
|
try {
|
|
if (failure?.outcome?.kind !== "completed" || failure.retention?.kind !== "callback_returned") throw new Error("Completed hash was not retained");
|
|
if (failure.outcome.value.sha256 !== process.env.EXPECTED_SHA256) throw new Error("Completed digest was lost");
|
|
if (!resources.hasPending) throw new Error("Failed descriptor was not retained");
|
|
} finally {
|
|
await resources.close();
|
|
}
|
|
if (attempts !== 2 || resources.hasPending) throw new Error("Exact descriptor did not close on retry");
|
|
console.log(JSON.stringify({ attempts, retained: true, retried: true }));
|
|
`;
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["--input-type=module", "-e", script, path],
|
|
{
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
EXPECTED_SHA256: createHash("sha256").update(bytes).digest("hex"),
|
|
},
|
|
},
|
|
);
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
expect(JSON.parse(result.stdout.trim())).toEqual({
|
|
attempts: 2,
|
|
retained: true,
|
|
retried: true,
|
|
});
|
|
});
|
|
|
|
it("hashes and closes a clean verifier input", async () => {
|
|
const directory = await createTestTempDirectory("rea-verifier-file-");
|
|
const path = join(directory, "input.bin");
|
|
const bytes = Buffer.from("clean verifier fixture");
|
|
await writeFile(path, bytes);
|
|
const script = `
|
|
import { ArtifactResourceScope } from ${JSON.stringify(new URL("../../../dist/artifacts/ArtifactResourceScope.js", import.meta.url).href)};
|
|
import { hashVerifierFile } from ${JSON.stringify(new URL("../../../scripts/lib/verifier-file-resources.mjs", import.meta.url).href)};
|
|
const resources = new ArtifactResourceScope();
|
|
const identity = await hashVerifierFile(process.argv[1], resources);
|
|
await resources.close();
|
|
console.log(JSON.stringify(identity));
|
|
`;
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["--input-type=module", "-e", script, path],
|
|
{
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
expect(JSON.parse(result.stdout.trim())).toEqual({
|
|
bytes: bytes.length,
|
|
sha256: createHash("sha256")
|
|
.update(await readFile(path))
|
|
.digest("hex"),
|
|
});
|
|
});
|
|
|
|
it("Ghidra verifier probes await the shared owned inspection contract", async () => {
|
|
const names = (await readdir("scripts")).filter(
|
|
(name) => name.startsWith("verify-real-ghidra") && name.endsWith(".mjs"),
|
|
);
|
|
const paths = [
|
|
...names.map((name) => `scripts/${name}`),
|
|
"scripts/lib/real-ghidra-namespace-annotations.mjs",
|
|
];
|
|
const probes: string[] = [];
|
|
const violations: { path: string; reason: string }[] = [];
|
|
for (const path of paths) {
|
|
const source = ts.createSourceFile(
|
|
path,
|
|
await readFile(path, "utf8"),
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.JS,
|
|
);
|
|
const visit = (node: ts.Node): void => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
[
|
|
"inspectGhidraInstallation",
|
|
"inspectVerifierGhidraInstallation",
|
|
].includes(node.expression.text)
|
|
) {
|
|
probes.push(path);
|
|
if (!ts.isAwaitExpression(node.parent))
|
|
violations.push({
|
|
path,
|
|
reason: "installation result was not awaited",
|
|
});
|
|
if (node.expression.text !== "inspectVerifierGhidraInstallation")
|
|
violations.push({
|
|
path,
|
|
reason: "inspection has no shared cleanup owner",
|
|
});
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(source);
|
|
}
|
|
expect(probes.length).toBeGreaterThan(0);
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it("Android verifier probes await the shared owned validation contract", async () => {
|
|
const paths = [
|
|
"scripts/verify/android/analysis.mjs",
|
|
"scripts/verify/android/windows-lifecycle.mjs",
|
|
];
|
|
const probes = new Set<string>();
|
|
const violations: { path: string; reason: string }[] = [];
|
|
for (const path of paths) {
|
|
const source = ts.createSourceFile(
|
|
path,
|
|
await readFile(path, "utf8"),
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.JS,
|
|
);
|
|
const visit = (node: ts.Node): void => {
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
ts.isIdentifier(node.expression) &&
|
|
[
|
|
"validateJadxConfiguration",
|
|
"validateVerifierJadxConfiguration",
|
|
].includes(node.expression.text)
|
|
) {
|
|
probes.add(path);
|
|
if (!ts.isAwaitExpression(node.parent))
|
|
violations.push({ path, reason: "validation was not awaited" });
|
|
if (node.expression.text !== "validateVerifierJadxConfiguration")
|
|
violations.push({ path, reason: "validation has no cleanup owner" });
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(source);
|
|
}
|
|
expect([...probes].sort()).toEqual(paths.toSorted());
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it
|
|
.skipIf(process.platform === "win32")
|
|
.each(["clean", "retry-close", "retained-close"])(
|
|
"owns Android verifier prerequisite finalization: %s",
|
|
async (mode) => {
|
|
const root = await createTestTempDirectory("rea-verifier-jadx-owner-");
|
|
const jar = join(root, "engine.jar");
|
|
await writeJadxJarInventory(jar);
|
|
const java = join(root, "java");
|
|
await writeFile(
|
|
java,
|
|
"#!/bin/sh\nprintf 'java.base@21\\njdk.compiler@21\\n'\n",
|
|
);
|
|
await chmod(java, 0o700);
|
|
const configuration = {
|
|
java,
|
|
jar,
|
|
jvmArguments: [],
|
|
} satisfies Parameters<typeof validateVerifierJadxConfiguration>[0];
|
|
const script = String.raw`
|
|
import assert from 'node:assert/strict';
|
|
import { validateVerifierJadxConfiguration } from ${JSON.stringify(new URL("../../../scripts/lib/jadx-configuration-inspection.mjs", import.meta.url).href)};
|
|
import { OwnedFileHandle } from ${JSON.stringify(new URL("../../../dist/filesystem/OwnedFileHandle.js", import.meta.url).href)};
|
|
import { ArtifactResourceRetryFailure } from ${JSON.stringify(new URL("../../../dist/artifacts/ArtifactResourceScope.js", import.meta.url).href)};
|
|
const [serializedConfiguration, mode] = process.argv.slice(1);
|
|
const configuration = JSON.parse(serializedConfiguration);
|
|
const handles = new Set();
|
|
let attempts = 0, denied = true;
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
OwnedFileHandle.prototype.close = async function () {
|
|
handles.add(this.handle); attempts++;
|
|
if ((mode === 'retry-close' && attempts === 1) || (mode === 'retained-close' && denied)) throw new Error('controlled archive close refusal');
|
|
return originalClose.call(this);
|
|
};
|
|
let failure;
|
|
try {
|
|
await validateVerifierJadxConfiguration(configuration, process.env, 'inspect_android_package');
|
|
} catch (cause) { failure = cause; }
|
|
try {
|
|
if (mode === 'clean') { assert.equal(failure, undefined); assert.equal(attempts, 1); }
|
|
else if (mode === 'retry-close') {
|
|
assert.ok(failure); assert.ok(attempts >= 2);
|
|
assert.ok(!(failure instanceof AggregateError), 'Successful retry reported unresolved cleanup');
|
|
} else {
|
|
assert.ok(failure instanceof AggregateError);
|
|
assert.equal(failure.errors.length, 2, 'Primary and cleanup failures must both survive');
|
|
const cleanup = failure.errors.find(error => error instanceof ArtifactResourceRetryFailure);
|
|
assert.ok(cleanup); assert.ok(cleanup.cleanupLeases.length > 0);
|
|
denied = false;
|
|
for (const lease of cleanup.cleanupLeases) await lease.release();
|
|
}
|
|
assert.equal(handles.size, 1);
|
|
for (const handle of handles) assert.equal(handle.fd, -1);
|
|
console.log(JSON.stringify({ mode, verified: true }));
|
|
} finally {
|
|
denied = false;
|
|
OwnedFileHandle.prototype.close = originalClose;
|
|
for (const handle of handles) await handle.close();
|
|
}
|
|
`;
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--input-type=module",
|
|
"-e",
|
|
script,
|
|
JSON.stringify(configuration),
|
|
mode,
|
|
],
|
|
{ encoding: "utf8", timeout: 15_000 },
|
|
);
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
expect(JSON.parse(result.stdout.trim())).toEqual({ mode, verified: true });
|
|
},
|
|
);
|
|
|
|
it("firmware verifier loads its current hash API before reporting unavailable prerequisites", () => {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["scripts/verify/firmware/analysis.mjs"],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
env: { PATH: process.env.PATH ?? "" },
|
|
},
|
|
);
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
process.platform === "linux"
|
|
? "requires REA_BINWALK_COMMAND"
|
|
: "requires Linux",
|
|
);
|
|
expect(result.stderr).not.toContain("does not provide an export");
|
|
});
|
|
|
|
it.each(["late-close", "retry-close", "retained-close", "opaque-cancellation"])(
|
|
"owns Ghidra verifier prerequisite finalization: %s",
|
|
async (mode) => {
|
|
const root = await createTestTempDirectory("rea-verifier-ghidra-owner-");
|
|
const options = {
|
|
environment: {},
|
|
installDir: root,
|
|
platform: process.platform,
|
|
architecture: "x64",
|
|
} satisfies Parameters<typeof inspectVerifierGhidraInstallation>[0];
|
|
const script = String.raw`
|
|
import assert from 'node:assert/strict';
|
|
import { mkdir, open, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { inspectVerifierGhidraInstallation } from ${JSON.stringify(new URL("../../../scripts/lib/ghidra-installation-inspection.mjs", import.meta.url).href)};
|
|
import { systemGhidraInstallationHost } from ${JSON.stringify(new URL("../../../dist/ghidra/GhidraInstallation.js", import.meta.url).href)};
|
|
import { SUPPORTED_GHIDRA_VERSION, SUPPORTED_GHIDRA_JAVA_MAJOR } from ${JSON.stringify(new URL("../../../dist/ghidra/GhidraReleasePolicy.js", import.meta.url).href)};
|
|
import { OwnedFileHandle } from ${JSON.stringify(new URL("../../../dist/filesystem/OwnedFileHandle.js", import.meta.url).href)};
|
|
import { RegularFileCleanupFailure } from ${JSON.stringify(new URL("../../../dist/filesystem/OwnedRegularFile.js", import.meta.url).href)};
|
|
import { ArtifactResourceRetryFailure } from ${JSON.stringify(new URL("../../../dist/artifacts/ArtifactResourceScope.js", import.meta.url).href)};
|
|
const root = process.argv[1], mode = process.argv[2], options = JSON.parse(process.argv[3]);
|
|
await mkdir(join(root, 'Ghidra'));
|
|
await writeFile(join(root, 'Ghidra', 'application.properties'), 'application.version=' + SUPPORTED_GHIDRA_VERSION + '\napplication.java.min=' + SUPPORTED_GHIDRA_JAVA_MAJOR + '\n');
|
|
const controller = new AbortController();
|
|
let foreign;
|
|
if (mode === 'opaque-cancellation') {
|
|
const path = join(root, 'foreign'); await writeFile(path, 'foreign');
|
|
foreign = new OwnedFileHandle(await open(path, 'r'));
|
|
controller.abort(new RegularFileCleanupFailure(path, foreign, { kind: 'completed', value: 'opaque' }, new Error('not this inspection owner')));
|
|
}
|
|
let entered, release;
|
|
const entering = new Promise(resolve => { entered = resolve; });
|
|
const finishing = new Promise(resolve => { release = resolve; });
|
|
const handles = new Set();
|
|
let attempts = 0, denied = true;
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
OwnedFileHandle.prototype.close = async function () {
|
|
handles.add(this.handle); attempts++;
|
|
if (mode === 'late-close' && attempts === 1) { entered(); await finishing; }
|
|
if ((mode === 'retry-close' && attempts === 1) || (mode === 'retained-close' && denied)) throw new Error('controlled inspection close refusal');
|
|
return originalClose.call(this);
|
|
};
|
|
const host = {
|
|
...systemGhidraInstallationHost(),
|
|
executable: () => true,
|
|
executableHeader: () => ({ ok: true, value: undefined }),
|
|
probeJava: async () => ({ ok: true, value: { major: SUPPORTED_GHIDRA_JAVA_MAJOR, version: '21.0.11', home: '/fixture/jdk', bits: 64, runtime: 'jdk', osArch: 'amd64', osName: options.platform === 'win32' ? 'Windows' : options.platform === 'darwin' ? 'Mac OS X' : 'Linux' } }),
|
|
};
|
|
let settled = false;
|
|
const pending = inspectVerifierGhidraInstallation({ ...options, signal: controller.signal }, host).then(
|
|
value => { settled = true; return { value }; },
|
|
error => { settled = true; return { error }; },
|
|
);
|
|
try {
|
|
if (mode === 'late-close') { await entering; assert.equal(settled, false); release(); }
|
|
const outcome = await pending;
|
|
if (mode === 'late-close') { assert.equal(outcome.value?.status, 'available'); assert.equal(handles.size, 1); }
|
|
else if (mode === 'retry-close') {
|
|
assert.ok(outcome.error); assert.equal(outcome.error.cleanupIncomplete, false);
|
|
assert.ok(attempts >= 2); assert.equal(handles.size, 1);
|
|
} else if (mode === 'retained-close') {
|
|
assert.ok(outcome.error instanceof AggregateError);
|
|
const primary = outcome.error.errors.find(error => error.cleanupIncomplete === true);
|
|
const cleanup = outcome.error.errors.find(error => error instanceof ArtifactResourceRetryFailure);
|
|
assert.ok(primary); assert.ok(cleanup); assert.ok(cleanup.cleanupLeases.length > 0);
|
|
denied = false;
|
|
for (const lease of cleanup.cleanupLeases) await lease.release();
|
|
assert.equal(primary.cleanupIncomplete, false);
|
|
assert.equal(handles.size, 1);
|
|
} else {
|
|
assert.equal(outcome.error?._tag, 'AnalysisCancelledError');
|
|
assert.equal(outcome.error.cause, controller.signal.reason);
|
|
assert.equal(handles.size, 0); assert.ok(foreign.handle.fd >= 0);
|
|
}
|
|
for (const handle of handles) assert.equal(handle.fd, -1);
|
|
console.log(JSON.stringify({ mode, verified: true }));
|
|
} finally {
|
|
denied = false; release();
|
|
await pending;
|
|
OwnedFileHandle.prototype.close = originalClose;
|
|
for (const handle of handles) await handle.close();
|
|
await foreign?.close();
|
|
}
|
|
`;
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--input-type=module",
|
|
"-e",
|
|
script,
|
|
root,
|
|
mode,
|
|
JSON.stringify(options),
|
|
],
|
|
{ encoding: "utf8", timeout: 10_000 },
|
|
);
|
|
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
|
expect(JSON.parse(result.stdout.trim())).toEqual({ mode, verified: true });
|
|
},
|
|
);
|
|
|
|
it("Ghidra verifier awaits native prerequisite rejection before creating target fixtures", async () => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-verifier-ghidra-prerequisite-",
|
|
);
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["scripts/verify-real-ghidra.mjs"],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
env: {
|
|
// Keep native process inspectors available; the selected JDK and Ghidra are absent.
|
|
PATH: process.env.PATH ?? "",
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
GHIDRA_INSTALL_DIR: join(root, "absent-installation"),
|
|
JAVA_HOME: join(root, "absent-jdk"),
|
|
},
|
|
},
|
|
);
|
|
expect(result.status, result.stderr).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
'Ghidra installation is unavailable: {"status":"unavailable"',
|
|
);
|
|
expect(result.stderr).not.toContain("TypeError");
|
|
expect(result.stderr).not.toContain("Ghidra installation is unavailable: {}");
|
|
});
|