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 { createHash } from "node:crypto"; 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 OwnedProviderProcessSpawnOptions, type SpawnedOwnedProviderProcess, } from "../../../src/process/ProviderProcess.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { stopProviderProcessFixture, waitForProviderProcessReady, } from "../../fixtures/providerProcess.js"; import { recordedCrashFixture, recordedCrashFileMappingFixture, 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; }, }); const spawnDebuggerFixture = ( spawn: OwnedProviderProcessSpawnOptions, scenario: string, ): Promise => { const script = scenario === "process-failure" ? "process.stderr.write('debugger failed',()=>{process.exitCode=9;})" : scenario.startsWith("cancelled") ? "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)" : "process.stdout.write('debugger stage diagnostic')"; const exec = scenario.startsWith("cancelled-exec"); return spawnOwnedProviderProcess({ ...spawn, command: exec ? "/bin/sh" : process.execPath, arguments: exec ? [ "-c", scenario === "cancelled-exec-foreign" ? 'REA_PROCESS_RUN_ID=source-owned-foreign exec "$1" -e "$2"' : 'exec "$1" -e "$2"', "recorded-debugger-launcher", process.execPath, script, ] : ["-e", script], }); }; const expectForeignDebuggerPreserved = async ( child: SpawnedOwnedProviderProcess["process"] | undefined, ): Promise => { if (child === undefined || child.pid === undefined) throw new Error("Expected an owned fixture PID"); const environment = await readFile(`/proc/${child.pid}/environ`); expect( environment.includes( Buffer.from("REA_PROCESS_RUN_ID=source-owned-foreign\0"), ), ).toBe(true); expect(child.exitCode).toBeNull(); expect(child.signalCode).toBeNull(); }; it .runIf(host) .each([ "success", "success-aarch64", "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 architecture = scenario.endsWith("aarch64") ? "aarch64-little-endian" : "x86_64-little-endian"; const { path, value, bytes } = await fixture((path) => { const value = recordedCrashSignalFixture(path, architecture); if (scenario === "wrong-owner-display") { 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 = []; } return value; }); 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" || scenario === "success-aarch64") { if (!result.ok) throw result.error; expect(result.value.architecture).toBe(architecture); 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", "unsafe-mapping", "success", "process-failure", "invalid-context", "cancelled", "cancelled-exec", "cancelled-exec-foreign", "cleanup-failure", "decoder-cleanup-failure", ] as const)( "owns optional context from the same snapshot: %s", async (scenario) => { const { path, value, bytes, root } = await fixture( scenario === "unsafe-mapping" ? (path) => recordedCrashFileMappingFixture( [Buffer.from("../../outside-owned-root")], path, ) : recordedCrashFixture, ); const controller = new AbortController(); let owned = ""; let snapshotPath = ""; let cleanupAllowed = false; let launches = 0; let debuggerProcess: SpawnedOwnedProviderProcess["process"] | undefined; 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 spawnDebuggerFixture(spawn, scenario); debuggerProcess = launched.process; if (scenario.startsWith("cancelled")) { await waitForProviderProcessReady(launched.process); setImmediate(() => controller.abort()); } return scenario === "cleanup-failure" ? withCleanupGate(launched, () => cleanupAllowed) : launched; }, ); onTestFinished(async () => { cleanupAllowed = true; // The test retains its creation handle even when the provider must refuse // an exec'd worker with a foreign token, or a regression leaves it live. if (debuggerProcess !== undefined) await stopProviderProcessFixture(debuggerProcess); 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, value); if (scenario === "unsafe-mapping") { expect(launches).toBe(1); await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" }); snapshotPath = ""; await expectBasicCoreFallback(provider, path, value.notes); expect(launches).toBe(2); } if (scenario === "cancelled-exec-foreign") await expectForeignDebuggerPreserved(debuggerProcess); } if (scenario === "cancelled" || scenario === "cancelled-exec") expectDebuggerStopped(debuggerProcess); expect(await readFile(path)).toEqual(bytes); if ( scenario === "cleanup-failure" || scenario === "decoder-cleanup-failure" || scenario === "cancelled-exec-foreign" ) { 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; if ( scenario === "cancelled-exec-foreign" && debuggerProcess !== undefined ) await stopProviderProcessFixture(debuggerProcess); await provider.close(); } await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" }); }, ); const expectDebuggerStopped = ( child: SpawnedOwnedProviderProcess["process"] | undefined, ): void => { if (child === undefined) throw new Error("Expected an owned debugger process"); expect(child.exitCode !== null || child.signalCode !== null).toBe(true); }; const expectBasicCoreFallback = async ( provider: PwntoolsRecordedCrashProvider, path: string, notes: RecordedCrash["notes"], ) => { const basic = await provider.inspect({ path, include_debugger_context: false, }); if (!basic.ok) throw basic.error; expect(basic.value.notes).toEqual(notes); expect(basic.value.debugger).toEqual({ status: "not-requested" }); }; const expectOptionalContextFailure = ( scenario: string, error: AnalysisError, value: RecordedCrash, ) => { const projected = projectAnalysisError(error); if (scenario === "unsafe-mapping") expect(error).toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", partialObservation: { notes: value.notes, debugger: { status: "unavailable", requested: true }, }, }); if (scenario === "cancelled-exec-foreign") expect(projected.code).toBe("cleanup_incomplete"); if (scenario === "unconfigured" || scenario === "unsafe-mapping") 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.startsWith("cancelled") || scenario === "cleanup-failure" ) { const serialized = JSON.stringify(projected); expect(serialized).toContain("basic stage diagnostic"); expect(serialized).toContain( scenario.startsWith("cancelled") ? "ready" : "debugger stage diagnostic", ); expect(error.partialObservation).toMatchObject({ artifact: { ...value.artifact, sha256: createHash("sha256") .update(recordedCrashFixtureBytes(value)) .digest("hex"), }, threads: value.threads, notes: value.notes, }); expect(error._tag).toBe( scenario === "invalid-context" ? "AnalysisOutputError" : scenario.startsWith("cancelled") && scenario !== "cancelled-exec-foreign" ? "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", }, }, }); }; it.runIf(host)( "reports a non-default debugger deadline when pwndbg times out", async () => { const { path, value, bytes, root } = await fixture(); const venv = join(root, "venv"); const plugin = join(root, "gdbinit.py"); await mkdir(venv); await writeFile(plugin, ""); let stage = 0; const provider = new PwntoolsRecordedCrashProvider( { REA_PWNTOOLS_PYTHON: process.execPath, REA_PWNDBG_GDB: process.execPath, REA_PWNDBG_GDBINIT: plugin, REA_PWNDBG_VENV_PATH: venv, }, async (spawn) => { stage += 1; const request = requestSchema.parse( JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")), ); if (stage === 1) { 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, }), ); return spawnOwnedProviderProcess({ ...spawn, command: process.execPath, arguments: ["-e", "process.stdout.write('basic stage diagnostic')"], }); } await new Promise((resolve) => { if (spawn.signal?.aborted === true) { resolve(); return; } spawn.signal?.addEventListener("abort", () => resolve(), { once: true, }); }); return spawnOwnedProviderProcess({ ...spawn, command: process.execPath, arguments: ["-e", "process.exit(0)"], }); }, ); onTestFinished(() => provider.close()); const result = await provider.inspect({ path, include_debugger_context: true, pwntools_timeout_ms: 1_500, }); expect(await readFile(path)).toEqual(bytes); if (result.ok) throw new Error("expected debugger timeout"); const projected = projectAnalysisError(result.error); expect(projected.code).toBe("provider_timeout"); expect(projected.message).toContain("of 1500 ms"); expect(projected.remediation.action).toContain("above 1500"); expect(projected.remediation.action).not.toContain("above 30000"); }, 10_000, ); const fixture = async ( createReport: (path: string) => RecordedCrash = recordedCrashFixture, ) => { const workspace = await createTestWorkspace("rea-recorded-core-seam-"); onTestFinished(() => removeTestWorkspace(workspace.root)); const path = join(workspace.root, "recorded.core"); const value = createReport(path); 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" }, })); };