fix(native): retain completed debugger evidence through reply cleanup

This commit is contained in:
morluto committed 2026-10-26 18:12:01 +08:00
1 parent f8161ad7b3
commit c06dc2d356
3 files changed
+387 -48

No files matched your search

+124 -35
View File
@@ -2,6 +2,7 @@ import {
bindArtifactFileCleanup,
artifactFileCancellationCause,
} from "../../artifacts/ArtifactFileOperation.js";
import { safeParseUtf8Json } from "../../domain/safeJson.js";
import { access, mkdir, stat, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { randomUUID } from "node:crypto";
@@ -20,10 +21,15 @@ import {
recordedCrashDebuggerSchema,
type RecordedCrash,
} from "../../domain/native/recordedCrash.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import {
completedStableArtifact,
readStableArtifact,
} from "../../artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import {
capturedPwntoolsOutput,
pwntoolsDecoderFailure,
@@ -48,6 +54,12 @@ const replySchema = z.discriminatedUnion("ok", [
}),
]);
/** Completed debugger observations stay separate from their reply descriptor's close. */
export interface PwndbgCoreInspection {
readonly context: Extract<RecordedCrash["debugger"], { status: "available" }>;
readonly finalization: AnalysisError | undefined;
}
/** Add mapping candidates from an owned core-only debugger, using the existing snapshot. */
export const inspectPwndbgCore = async ({
environment,
@@ -74,7 +86,7 @@ export const inspectPwndbgCore = async ({
kind: "file" | "directory",
mode: number,
) => Promise<void>;
}): Promise<RecordedCrash["debugger"]> => {
}): Promise<PwndbgCoreInspection> => {
const gdb = environment.REA_PWNDBG_GDB ?? "/usr/bin/gdb";
const entry = environment.REA_PWNDBG_GDBINIT ?? "";
const venv = environment.REA_PWNDBG_VENV_PATH ?? "";
@@ -189,15 +201,33 @@ export const inspectPwndbgCore = async ({
);
output = capturedPwntoolsOutput(execution);
let reply: z.output<typeof replySchema>;
let finalization: AnalysisError | undefined;
try {
const raw = await readStableArtifact(
replyPath,
PWNTOOLS_LIMITS.outputBytes,
options?.signal,
undefined,
resources,
);
reply = replySchema.parse(JSON.parse(raw.bytes.toString("utf8")));
let raw;
try {
raw = await readStableArtifact(
replyPath,
PWNTOOLS_LIMITS.outputBytes,
options?.signal,
undefined,
resources,
);
} catch (cause: unknown) {
const completed = completedStableArtifact(cause);
if (
!(cause instanceof ArtifactReaderFailure) ||
completed === undefined
)
throw cause;
finalization = completedReplyFailure(
cause,
recordedCrashStageOutput(diagnostics, output),
);
raw = completed;
}
const decoded = safeParseUtf8Json(raw.bytes);
if (!decoded.ok) throw decoded.cause;
reply = replySchema.parse(decoded.value);
} catch (cause: unknown) {
if (
cause instanceof ArtifactReaderFailure &&
@@ -212,7 +242,7 @@ export const inspectPwndbgCore = async ({
: {}),
});
bindArtifactFileCleanup(cause, error);
throw error;
throw pwndbgContextFinalizationFailure(error, finalization);
}
const error = new AnalysisOutputError(
OPERATION,
@@ -226,38 +256,52 @@ export const inspectPwndbgCore = async ({
},
);
bindArtifactFileCleanup(cause, error);
throw error;
throw pwndbgContextFinalizationFailure(error, finalization);
}
if (!reply.ok) {
if (reply.reason === "unavailable" || reply.reason === "unsupported")
throw new AnalysisCapabilityUnavailableError(
"pwndbg-core",
OPERATION,
reply.message,
{
userMessage: reply.message,
capturedOutput: recordedCrashStageOutput(diagnostics, output),
},
throw pwndbgContextFinalizationFailure(
new AnalysisCapabilityUnavailableError(
"pwndbg-core",
OPERATION,
reply.message,
{
userMessage: reply.message,
capturedOutput: recordedCrashStageOutput(diagnostics, output),
},
),
finalization,
);
if (reply.reason === "format" || reply.reason === "output-limit")
throw new AnalysisOutputError(OPERATION, reply.message, {
capturedOutput: recordedCrashStageOutput(diagnostics, output),
});
throw new ProviderAdapterError("pwndbg-core", OPERATION, {
diagnostics: {
stage: "debugger",
reason: reply.message,
captured_output: { ...recordedCrashStageOutput(diagnostics, output) },
},
});
throw pwndbgContextFinalizationFailure(
new AnalysisOutputError(OPERATION, reply.message, {
capturedOutput: recordedCrashStageOutput(diagnostics, output),
}),
finalization,
);
throw pwndbgContextFinalizationFailure(
new ProviderAdapterError("pwndbg-core", OPERATION, {
diagnostics: {
stage: "debugger",
reason: reply.message,
captured_output: {
...recordedCrashStageOutput(diagnostics, output),
},
},
}),
finalization,
);
}
if (reply.value.status !== "available")
throw new AnalysisOutputError(
OPERATION,
"Requested debugger context returned no core evidence.",
{ capturedOutput: recordedCrashStageOutput(diagnostics, output) },
throw pwndbgContextFinalizationFailure(
new AnalysisOutputError(
OPERATION,
"Requested debugger context returned no core evidence.",
{ capturedOutput: recordedCrashStageOutput(diagnostics, output) },
),
finalization,
);
return { ...reply.value, diagnostics: output };
return { context: { ...reply.value, diagnostics: output }, finalization };
} catch (cause: unknown) {
if (cause instanceof AnalysisError && cause.cleanupIncomplete) throw cause;
const primary = pwntoolsDecoderFailure(
@@ -272,6 +316,51 @@ export const inspectPwndbgCore = async ({
}
};
const completedReplyFailure = (
cause: ArtifactReaderFailure,
capturedOutput: AnalysisCapturedOutput,
): AnalysisError => {
const cancellation =
cause.reason === "cancelled"
? new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
capturedOutput,
})
: undefined;
if (cause.cleanup === undefined) {
if (cancellation !== undefined) return cancellation;
return new AnalysisOutputError(OPERATION, cause.message, {
cause,
capturedOutput,
});
}
const cleanup = new ProviderCleanupError(
"pwndbg-core",
cause.cleanup.resources,
{ stage: "debugger_reply_descriptor_close", reason: cause.cleanup.reason },
{ operation: OPERATION, cause, capturedOutput },
);
bindArtifactFileCleanup(cause, cleanup);
return cancellation === undefined
? cleanup
: pwndbgContextFinalizationFailure(cancellation, cleanup);
};
/** A rejected/malformed context stays primary over its descriptor finalization. */
export const pwndbgContextFinalizationFailure = (
primary: AnalysisError,
finalization: AnalysisError | undefined,
): AnalysisError =>
finalization === undefined
? primary
: finalization.cleanupIncomplete
? analysisErrorWithCleanupFailure(
primary,
finalization,
OPERATION,
).bindCleanupDependencies([primary, finalization])
: primary.retainSecondaryFailure(finalization);
/** Preserve both stages' exact text and their independently observed truncation state. */
export const recordedCrashStageOutput = (
basic: AnalysisCapturedOutput,
@@ -18,6 +18,7 @@ import { validateRecordedCrashSources } from "./RecordedCrashSourceBindings.js";
import { validateRecordedCrashStructure } from "./RecordedCrashStructureBindings.js";
import {
inspectPwndbgCore,
pwndbgContextFinalizationFailure,
recordedCrashStageOutput,
} from "../pwndbg/PwndbgCoreContext.js";
import type { z } from "zod";
@@ -133,7 +134,7 @@ export class PwntoolsRecordedCrashProvider
snapshot.bytes,
);
if (!authority.ok) throw authority.error;
const context = await inspectPwndbgCore({
const completed = await inspectPwndbgCore({
environment,
architecture: value.architecture,
rootPath,
@@ -143,20 +144,25 @@ export class PwntoolsRecordedCrashProvider
runCommand,
timeoutMs,
});
const context = completed.context;
const enriched = validate(context);
if (!enriched.success)
throw new AnalysisOutputError(
"inspect_recorded_crash",
`Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`,
{
capturedOutput:
context.status === "available"
? recordedCrashStageOutput(
diagnostics,
context.diagnostics,
)
: diagnostics,
},
throw pwndbgContextFinalizationFailure(
new AnalysisOutputError(
"inspect_recorded_crash",
`Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`,
{
capturedOutput: recordedCrashStageOutput(
diagnostics,
context.diagnostics,
),
},
),
completed.finalization,
);
if (completed.finalization !== undefined)
throw completed.finalization.retainPartialObservation(
enriched.data,
);
return enriched.data;
} catch (cause: unknown) {
@@ -0,0 +1,244 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
const replyClose = vi.hoisted(() => ({
path: "",
denied: false,
onClose: undefined as (() => void) | undefined,
handles: [] as FileHandle[],
}));
vi.mock("node:fs/promises", async (original) => {
const actual = await original<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (args[0] === replyClose.path) {
replyClose.handles.push(handle);
const close = handle.close.bind(handle);
handle.close = async () => {
if (replyClose.denied) {
replyClose.onClose?.();
throw new Error("controlled debugger reply close denial");
}
await close();
replyClose.onClose?.();
};
}
return handle;
},
};
});
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import {
AnalysisError,
primaryAnalysisError,
} from "../../../src/domain/analysisErrorBase.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { recordedCrashSchema } from "../../../src/domain/native/recordedCrash.js";
import { PwntoolsRecordedCrashProvider } from "../../../src/native/pwntools/PwntoolsRecordedCrashProvider.js";
import { runOwnedCommand } from "../../../src/process/OwnedCommand.js";
import {
recordedCrashDebuggerFixture,
recordedCrashFixture,
recordedCrashFixtureBytes,
} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
import {
createTestWorkspace,
removeTestWorkspace,
} from "../../support/workspace/workspaceFixture.js";
const fixture = async (
reply = Buffer.from(
JSON.stringify({ ok: true, value: recordedCrashDebuggerFixture() }),
),
) => {
const workspace = await createTestWorkspace("rea-pwndbg-reply-finalization-");
const path = workspace.path("recorded.core");
const value = recordedCrashFixture(path);
const bytes = recordedCrashFixtureBytes(value);
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(path, bytes);
const init = workspace.path("gdbinit.py");
const venv = workspace.path("venv");
await writeFile(init, "# authored debugger fixture\n");
await mkdir(venv);
const resources = new ArtifactResourceScope();
replyClose.path = workspace.path("debugger/reply.json");
replyClose.denied = false;
replyClose.onClose = undefined;
replyClose.handles = [];
const provider = new PwntoolsRecordedCrashProvider({
REA_PWNDBG_GDB: process.execPath,
REA_PWNDBG_GDBINIT: init,
REA_PWNDBG_VENV_PATH: venv,
});
onTestFinished(async () => {
replyClose.denied = false;
replyClose.onClose = undefined;
await resources.close();
await provider.close();
await removeTestWorkspace(workspace.root);
});
const {
artifact: _artifact,
diagnostics: _diagnostics,
decoder_diagnostics: _decoderDiagnostics,
debugger: _debugger,
...payload
} = value;
const exits: (number | null | undefined)[] = [];
// Exercise the real projection and owned-process boundary on every host.
// The child authors debugger output; this does not establish GDB/pwndbg support.
const project = async (signal: AbortSignal) =>
provider.profile.project({
input: { path, include_debugger_context: true },
payload,
snapshot: { bytes, sha256 },
rootPath: workspace.root,
resources,
diagnostics: value.decoder_diagnostics,
options: { signal },
timeoutMs: 10_000,
runCommand: async (spawn) => {
const output = await runOwnedCommand(
{
...spawn,
command: process.execPath,
arguments: [
"-e",
`const fs=require('node:fs');const request=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));fs.writeFileSync(request.reply_path,Buffer.from(${JSON.stringify(reply.toString("base64"))},'base64'));process.stdout.write('authored debugger output');`,
spawn.arguments.at(-1) ?? "",
],
},
{ timeoutMs: 10_000, diagnosticBytes: 1024 * 1024 },
);
exits.push(output.exitCode);
return output;
},
});
return { project, resources, value, sha256, exits };
};
it.each([
{ cancel: false, denied: false },
{ cancel: true, denied: false },
{ cancel: false, denied: true },
{ cancel: true, denied: true },
])(
"retains authored completed debugger output through reply close (cancel=$cancel, denied=$denied)",
async ({ cancel, denied }) => {
const selected = await fixture();
replyClose.denied = denied;
const controller = new AbortController();
const reason = { stage: "completed debugger output close" };
if (cancel) replyClose.onClose = () => controller.abort(reason);
const result = await selected.project(controller.signal).then(
(value) => ({ ok: true as const, value }),
(cause: unknown) => {
if (!(cause instanceof AnalysisError)) throw cause;
return { ok: false as const, error: cause };
},
);
expect(result.ok).toBe(!cancel && !denied);
const completed = recordedCrashSchema.parse(
result.ok ? result.value : result.error.partialObservation,
);
expect(completed).toMatchObject({
artifact: { path: selected.value.artifact.path, sha256: selected.sha256 },
threads: selected.value.threads,
debugger: {
...recordedCrashDebuggerFixture(),
diagnostics: {
stdout: "authored debugger output",
stderr: "",
truncated: false,
},
},
});
expect(selected.exits).toEqual([0]);
if (!result.ok) {
if (cancel) {
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(primaryAnalysisError(result.error).cause).toBe(reason);
}
expect(result.error.cleanupIncomplete).toBe(denied);
expect(projectAnalysisError(result.error)).toMatchObject({
details: { partial_observation: completed },
});
if (denied) {
expect(result.error.cleanupResources).toEqual([replyClose.path]);
expect(replyClose.handles[0]?.fd).toBeGreaterThanOrEqual(0);
await expect(selected.resources.close()).rejects.toBeDefined();
replyClose.denied = false;
await selected.resources.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(result.error.partialObservation).toEqual(completed);
}
}
expect(replyClose.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);
it.each(["malformed JSON", "malformed UTF-8", "producer refusal"])(
"keeps %s primary alongside denied close and retains only the earlier core facts",
async (scenario) => {
const reply =
scenario === "malformed JSON"
? Buffer.from("{")
: scenario === "producer refusal"
? Buffer.from(
JSON.stringify({
ok: false,
reason: "unsupported",
message: "authored refusal",
}),
)
: Buffer.from(
JSON.stringify({
ok: true,
value: recordedCrashDebuggerFixture(),
}),
);
if (scenario === "malformed UTF-8") {
const marker = reply.indexOf("source seam");
expect(marker).toBeGreaterThanOrEqual(0);
reply[marker] = 0xff;
}
const selected = await fixture(reply);
replyClose.denied = true;
const controller = new AbortController();
const reason = { stage: "rejected debugger reply close" };
replyClose.onClose = () => controller.abort(reason);
const error: unknown = await selected
.project(controller.signal)
.catch((cause: unknown) => cause);
if (!(error instanceof AnalysisError))
throw new Error("Expected debugger reply failure");
expect(controller.signal.reason).toBe(reason);
expect(primaryAnalysisError(error)._tag).toBe(
scenario === "producer refusal"
? "AnalysisCapabilityUnavailableError"
: "AnalysisOutputError",
);
expect(error.partialObservation).toMatchObject({
artifact: { path: selected.value.artifact.path, sha256: selected.sha256 },
threads: selected.value.threads,
debugger: { status: "unavailable", requested: true },
});
expect(error.cleanupIncomplete).toBe(true);
replyClose.denied = false;
await selected.resources.close();
expect(error.cleanupIncomplete).toBe(false);
expect(error.cleanupResources).toEqual([]);
expect(replyClose.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);