mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
* fix(native): retain failed pwntools process and runtime cleanup Keep failed worker/debugger cleanup owners and their private files until verified process shutdown and successful directory removal. Retry cleanup before another inspection and expose shutdown through the shared services, CLI and MCP server. Serialize acquisition without delaying cancellation of queued callers, and preserve primary diagnostics when CLI cleanup fails. The original decoder reproduction left a live owned Node child with its runtime deleted and no retained cleanup owner; the regression now verifies retention, admission blocking, retries and eventual process/file cleanup. Validation: 10 lifecycle/public CLI/MCP tests, 20 service tests, test discovery 814/814, build:cached and full check including architecture guards. Actual POSIX process/filesystem boundaries used injected Node workers on macOS; real Linux pwntools/GDB provider parsing was not exercised. * test(native): verify retained recorded-crash cleanup lifecycle * fix(native): finalize diagnostic cleanup before publishing results * fix(native): retire decoder stages before reading runtime evidence --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
|
import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
|
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it, onTestFinished } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
createTestWorkspace,
|
|
removeTestWorkspace,
|
|
} from "../../support/workspace/workspaceFixture.js";
|
|
import { PwntoolsRecordedCrashProvider } from "../../../src/native/pwntools/PwntoolsRecordedCrashProvider.js";
|
|
import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js";
|
|
import {
|
|
spawnOwnedProviderProcess,
|
|
type SpawnedOwnedProviderProcess,
|
|
} from "../../../src/process/ProviderProcess.js";
|
|
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
|
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
|
|
import {
|
|
recordedCrashFixture,
|
|
recordedCrashDebuggerFixture,
|
|
recordedCrashFixtureBytes,
|
|
recordedCrashSignalFixture,
|
|
} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
|
|
const requestSchema = z.object({
|
|
snapshot_path: z.string(),
|
|
reply_path: z.string(),
|
|
});
|
|
const host = process.platform === "linux" && process.arch === "x64";
|
|
|
|
const withCleanupGate = (
|
|
launched: SpawnedOwnedProviderProcess,
|
|
cleanupAllowed: () => boolean,
|
|
): SpawnedOwnedProviderProcess => ({
|
|
...launched,
|
|
cleanup: async () => {
|
|
const result = await cleanupOwnedProcessGroup(launched.ownership);
|
|
if (!cleanupAllowed())
|
|
throw new Error("source-owned post-cleanup reporting failure");
|
|
return result;
|
|
},
|
|
});
|
|
|
|
it
|
|
.runIf(host)
|
|
.each([
|
|
"success",
|
|
"wrong-register",
|
|
"wrong-register-source",
|
|
"missing-thread",
|
|
"duplicate-thread",
|
|
"missing-signal",
|
|
"duplicate-signal",
|
|
"missing-register",
|
|
"duplicated-physical-thread",
|
|
"duplicated-physical-signal",
|
|
"omitted-physical-note",
|
|
"hidden-segment",
|
|
"wrong-header-range",
|
|
"wrong-ei-version-zero",
|
|
"wrong-ei-version-two",
|
|
"wrong-e-version-zero",
|
|
"wrong-e-version-two",
|
|
"wrong-note-kind",
|
|
"wrong-owner-display",
|
|
"wrong-pid",
|
|
"wrong-current-signal",
|
|
"wrong-signal-number",
|
|
"wrong-signal-code",
|
|
"wrong-signal-errno",
|
|
"wrong-signal-address",
|
|
"wrong-raw-note",
|
|
"malformed-range",
|
|
] as const)(
|
|
"validates recorded source bytes and cleanup: %s",
|
|
async (scenario) => {
|
|
const { path, value, bytes } = await fixture(
|
|
true,
|
|
scenario === "wrong-owner-display",
|
|
);
|
|
if (scenario.startsWith("wrong-ei-version-"))
|
|
bytes[6] = Number(scenario.at(-1));
|
|
if (scenario.startsWith("wrong-e-version-"))
|
|
bytes.writeUInt32LE(Number(scenario.at(-1)), 20);
|
|
if (scenario.startsWith("wrong-e")) await writeFile(path, bytes);
|
|
let owned = "";
|
|
const provider = new PwntoolsRecordedCrashProvider(
|
|
{ REA_PWNTOOLS_PYTHON: process.execPath },
|
|
async (spawn) => {
|
|
owned = spawn.cwd ?? "";
|
|
const request = requestSchema.parse(
|
|
JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")),
|
|
);
|
|
expect(await readFile(request.snapshot_path)).toEqual(bytes);
|
|
const payload = structuredClone(value);
|
|
alterRecordedCrashReply(payload, scenario);
|
|
const {
|
|
artifact: _artifact,
|
|
diagnostics: _diagnostics,
|
|
debugger: _debugger,
|
|
decoder_diagnostics: _decoderDiagnostics,
|
|
...raw
|
|
} = payload;
|
|
await writeFile(
|
|
request.reply_path,
|
|
JSON.stringify({
|
|
ok: true,
|
|
profile: PWNTOOLS_PROVIDER_IDENTITY.version,
|
|
value: raw,
|
|
}),
|
|
);
|
|
return spawnOwnedProviderProcess({
|
|
...spawn,
|
|
command: process.execPath,
|
|
arguments: ["-e", "process.stdout.write('recorded warning')"],
|
|
});
|
|
},
|
|
);
|
|
const result = await provider.inspect({
|
|
path,
|
|
include_debugger_context: false,
|
|
});
|
|
if (scenario === "success") {
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.threads).toEqual(value.threads);
|
|
expect(result.value.diagnostics.stdout).toBe("recorded warning");
|
|
} else
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisOutputError",
|
|
capturedOutput: { stdout: "recorded warning" },
|
|
},
|
|
});
|
|
expect(await readFile(path)).toEqual(bytes);
|
|
await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
it
|
|
.runIf(host)
|
|
.each([
|
|
"unconfigured",
|
|
"success",
|
|
"process-failure",
|
|
"invalid-context",
|
|
"cancelled",
|
|
"cleanup-failure",
|
|
"decoder-cleanup-failure",
|
|
] as const)(
|
|
"owns optional context from the same snapshot: %s",
|
|
async (scenario) => {
|
|
const { path, value, bytes, root } = await fixture();
|
|
const controller = new AbortController();
|
|
let owned = "";
|
|
let snapshotPath = "";
|
|
let cleanupAllowed = false;
|
|
let launches = 0;
|
|
const venv = join(root, "venv");
|
|
const plugin = join(root, "gdbinit.py");
|
|
await mkdir(venv);
|
|
await writeFile(plugin, "");
|
|
const provider = new PwntoolsRecordedCrashProvider(
|
|
{
|
|
REA_PWNTOOLS_PYTHON: process.execPath,
|
|
...(scenario === "unconfigured"
|
|
? {}
|
|
: {
|
|
REA_PWNDBG_GDB: process.execPath,
|
|
REA_PWNDBG_GDBINIT: plugin,
|
|
REA_PWNDBG_VENV_PATH: venv,
|
|
}),
|
|
},
|
|
async (spawn) => {
|
|
launches++;
|
|
const request = requestSchema.parse(
|
|
JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")),
|
|
);
|
|
expect(await readFile(request.snapshot_path)).toEqual(bytes);
|
|
if (snapshotPath === "") {
|
|
snapshotPath = request.snapshot_path;
|
|
owned = spawn.cwd ?? "";
|
|
const {
|
|
artifact: _artifact,
|
|
diagnostics: _diagnostics,
|
|
debugger: _debugger,
|
|
decoder_diagnostics: _decoderDiagnostics,
|
|
...raw
|
|
} = value;
|
|
await writeFile(
|
|
request.reply_path,
|
|
JSON.stringify({
|
|
ok: true,
|
|
profile: PWNTOOLS_PROVIDER_IDENTITY.version,
|
|
value: raw,
|
|
}),
|
|
);
|
|
const launched = await spawnOwnedProviderProcess({
|
|
...spawn,
|
|
command: process.execPath,
|
|
arguments: ["-e", "process.stdout.write('basic stage diagnostic')"],
|
|
});
|
|
return scenario === "decoder-cleanup-failure"
|
|
? withCleanupGate(launched, () => cleanupAllowed)
|
|
: launched;
|
|
}
|
|
expect(request.snapshot_path).toBe(snapshotPath);
|
|
const context = recordedCrashDebuggerFixture();
|
|
if (scenario === "invalid-context")
|
|
context.maps = context.maps.map((map) => ({ ...map, end: "0x0" }));
|
|
await writeFile(
|
|
request.reply_path,
|
|
JSON.stringify({ ok: true, value: context }),
|
|
);
|
|
const launched = await spawnOwnedProviderProcess({
|
|
...spawn,
|
|
command: process.execPath,
|
|
arguments: [
|
|
"-e",
|
|
scenario === "process-failure"
|
|
? "process.stderr.write('debugger failed',()=>{process.exitCode=9;})"
|
|
: scenario === "cancelled"
|
|
? "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)"
|
|
: "process.stdout.write('debugger stage diagnostic')",
|
|
],
|
|
});
|
|
if (scenario === "cancelled") {
|
|
await waitForProviderProcessReady(launched.process);
|
|
setImmediate(() => controller.abort());
|
|
}
|
|
return scenario === "cleanup-failure"
|
|
? withCleanupGate(launched, () => cleanupAllowed)
|
|
: launched;
|
|
},
|
|
);
|
|
onTestFinished(async () => {
|
|
cleanupAllowed = true;
|
|
await provider.close();
|
|
});
|
|
const result = await provider.inspect(
|
|
{
|
|
path,
|
|
include_debugger_context: true,
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (scenario === "success") {
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.debugger).toMatchObject({
|
|
status: "available",
|
|
maps: [{ permissions: null }],
|
|
diagnostics: { stdout: "debugger stage diagnostic" },
|
|
});
|
|
} else {
|
|
if (result.ok) throw new Error("Expected optional debugger failure");
|
|
expectOptionalContextFailure(scenario, result.error);
|
|
}
|
|
expect(await readFile(path)).toEqual(bytes);
|
|
if (
|
|
scenario === "cleanup-failure" ||
|
|
scenario === "decoder-cleanup-failure"
|
|
) {
|
|
await expect(access(owned)).resolves.toBeUndefined();
|
|
expect(await readFile(snapshotPath)).toEqual(bytes);
|
|
const refused = await provider.inspect({
|
|
path,
|
|
include_debugger_context: true,
|
|
});
|
|
if (refused.ok)
|
|
throw new Error("Unreleased ownership must block a launch");
|
|
expect(refused.error.cleanupResources).toContain(owned);
|
|
expect(launches).toBe(scenario === "decoder-cleanup-failure" ? 1 : 2);
|
|
await expect(provider.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
await expect(access(owned)).resolves.toBeUndefined();
|
|
cleanupAllowed = true;
|
|
await provider.close();
|
|
}
|
|
await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
const expectOptionalContextFailure = (
|
|
scenario: string,
|
|
error: AnalysisError,
|
|
) => {
|
|
const projected = projectAnalysisError(error);
|
|
if (scenario === "unconfigured")
|
|
expect(projected).toMatchObject({
|
|
details: { captured_output: { stdout: "basic stage diagnostic" } },
|
|
});
|
|
else if (scenario === "decoder-cleanup-failure")
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: { captured_output: { stdout: "basic stage diagnostic" } },
|
|
},
|
|
});
|
|
else if (
|
|
scenario === "invalid-context" ||
|
|
scenario === "cancelled" ||
|
|
scenario === "cleanup-failure"
|
|
) {
|
|
const serialized = JSON.stringify(projected);
|
|
expect(serialized).toContain("basic stage diagnostic");
|
|
expect(serialized).toContain(
|
|
scenario === "cancelled" ? "ready" : "debugger stage diagnostic",
|
|
);
|
|
expect(error._tag).toBe(
|
|
scenario === "invalid-context"
|
|
? "AnalysisOutputError"
|
|
: scenario === "cancelled"
|
|
? "AnalysisCancelledError"
|
|
: "ProviderAdapterError",
|
|
);
|
|
if (scenario === "cleanup-failure" || scenario === "invalid-context") {
|
|
if (scenario === "cleanup-failure")
|
|
expect(error).toBeInstanceOf(ProviderCleanupError);
|
|
expect(projected).toMatchObject({
|
|
details: {
|
|
partial_observation: {
|
|
debugger: { status: "unavailable", requested: true },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
} else
|
|
expect(projected).toMatchObject({
|
|
category: "execution_failure",
|
|
details: {
|
|
diagnostics: {
|
|
preceding_output: { stdout: "basic stage diagnostic" },
|
|
stderr: "debugger failed",
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
const fixture = async (includeSignal = false, opaqueOwner = false) => {
|
|
const workspace = await createTestWorkspace("rea-recorded-core-seam-");
|
|
onTestFinished(() => removeTestWorkspace(workspace.root));
|
|
const path = join(workspace.root, "recorded.core");
|
|
const value = includeSignal
|
|
? recordedCrashSignalFixture(path)
|
|
: recordedCrashFixture(path);
|
|
if (opaqueOwner) {
|
|
value.notes = value.notes.map((note) =>
|
|
note.index === 0
|
|
? {
|
|
...note,
|
|
owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"),
|
|
owner_display: "EVIL",
|
|
}
|
|
: note,
|
|
);
|
|
value.threads = [];
|
|
}
|
|
const bytes = recordedCrashFixtureBytes(value);
|
|
await writeFile(path, bytes);
|
|
return { path, value, bytes, root: workspace.root };
|
|
};
|
|
|
|
const alterRecordedCrashReply = (
|
|
payload: RecordedCrash,
|
|
scenario: string,
|
|
): void => {
|
|
if (scenario === "wrong-owner-display")
|
|
payload.notes = payload.notes.map((note) =>
|
|
note.index === 0 ? { ...note, owner_display: "FORGED" } : note,
|
|
);
|
|
if (
|
|
scenario === "duplicated-physical-thread" ||
|
|
scenario === "duplicated-physical-signal"
|
|
) {
|
|
const noteIndex = scenario === "duplicated-physical-thread" ? 0 : 1;
|
|
const note = payload.notes[noteIndex];
|
|
if (note === undefined) throw new Error("missing fixture note");
|
|
payload.notes.push({ ...note, index: 2 });
|
|
if (noteIndex === 0)
|
|
payload.threads.push(
|
|
...payload.threads.map((thread) => ({
|
|
...thread,
|
|
note_index: 2,
|
|
})),
|
|
);
|
|
else
|
|
payload.signals.push(
|
|
...payload.signals.map((signal) => ({
|
|
...signal,
|
|
note_index: 2,
|
|
})),
|
|
);
|
|
}
|
|
if (scenario === "omitted-physical-note") {
|
|
payload.notes = payload.notes.filter((note) => note.index === 0);
|
|
payload.signals = [];
|
|
}
|
|
if (scenario === "hidden-segment") {
|
|
payload.segments = [];
|
|
payload.notes = [];
|
|
payload.threads = [];
|
|
payload.signals = [];
|
|
}
|
|
if (scenario === "wrong-header-range")
|
|
payload.segments = payload.segments.map((segment) => ({
|
|
...segment,
|
|
header_location: { offset: "0x0", bytes: "0x38" },
|
|
}));
|
|
if (scenario === "wrong-note-kind") {
|
|
payload.notes = payload.notes.map((note) =>
|
|
note.index === 0 ? { ...note, type: "NT_AUXV" } : note,
|
|
);
|
|
payload.threads = [];
|
|
}
|
|
if (scenario === "wrong-register-source")
|
|
payload.threads = payload.threads.map((thread) => ({
|
|
...thread,
|
|
registers: thread.registers.map((register) =>
|
|
register.name === "rdi"
|
|
? {
|
|
...register,
|
|
value: "0x0",
|
|
location: { offset: "0x104", bytes: "0x8" },
|
|
}
|
|
: register,
|
|
),
|
|
}));
|
|
if (scenario === "missing-thread") payload.threads = [];
|
|
if (scenario === "duplicate-thread")
|
|
payload.threads = [...payload.threads, ...payload.threads];
|
|
if (scenario === "missing-signal") payload.signals = [];
|
|
if (scenario === "duplicate-signal")
|
|
payload.signals = [...payload.signals, ...payload.signals];
|
|
if (scenario === "missing-register")
|
|
payload.threads = payload.threads.map((thread) => ({
|
|
...thread,
|
|
registers: thread.registers.slice(1),
|
|
}));
|
|
if (scenario === "wrong-register")
|
|
payload.threads = payload.threads.map((t) => ({
|
|
...t,
|
|
registers: t.registers.map((r) => ({ ...r, value: "0x1" })),
|
|
}));
|
|
if (scenario === "wrong-pid")
|
|
payload.threads = payload.threads.map((thread) => ({
|
|
...thread,
|
|
historical_pid: -2,
|
|
}));
|
|
if (scenario === "wrong-current-signal")
|
|
payload.threads = payload.threads.map((thread) => ({
|
|
...thread,
|
|
recorded_current_signal: -1,
|
|
}));
|
|
if (scenario.startsWith("wrong-signal"))
|
|
payload.signals = payload.signals.map((signal) => ({
|
|
...signal,
|
|
...(scenario === "wrong-signal-number"
|
|
? {
|
|
number: 10,
|
|
fault_address: null,
|
|
fault_address_meaning: "unknown" as const,
|
|
}
|
|
: scenario === "wrong-signal-code"
|
|
? { code: 2 }
|
|
: scenario === "wrong-signal-errno"
|
|
? { errno: 0 }
|
|
: { fault_address: "0x1" }),
|
|
}));
|
|
if (scenario === "wrong-raw-note")
|
|
payload.notes = payload.notes.map((n) => ({
|
|
...n,
|
|
descriptor_bytes_base64: Buffer.alloc(336, 1).toString("base64"),
|
|
}));
|
|
if (scenario === "malformed-range")
|
|
payload.notes = payload.notes.map((n) => ({
|
|
...n,
|
|
descriptor_location: { offset: "0x200", bytes: "0x150" },
|
|
}));
|
|
};
|