mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
* fix(process): preserve producer-owned inspection cancellation Prototype the explicit inspection origin and retained file/batch owner protocol. Downstream adapter migration and lease binding remain required before integration. * fix(process): type inspection ownership handoffs precisely * fix(process): retain plain inspection preparation cancellation * fix(process): retain explicit inspection cleanup and cancellation facts * fix(process): require explicit inspection facts and cleanup scope * refactor(process): remove redundant cleanup forwarding wrapper * fix(firmware): retain inspection cancellation and cleanup ownership * fix(wakaru): retain typed inspection outcomes and caller scope * fix(ghidra): preserve inspection facts and launch cleanup scope * fix(providers): preserve typed process inspection outcomes * refactor(process): remove obsolete inspection cancellation re-export * fix(native): preserve inspection preparation cleanup and cancellation * fix(registration): retain typed inspection cleanup during Git discovery * fix(process): preserve inspection deadline origin in adapter handoff * fix(process): consume the canonical inspection selector in binding * fix(native): preserve inspection deadlines and cleanup retry completion * test(process): narrow the retained Darwin preparation owner * fix(providers): preserve preparation ownership and timeout policy Narrow preparation facts through their canonical owner, preserve actual cleanup capabilities and recorded cancellation, and keep enclosing command deadlines authoritative. Retain raw inspection records through Java refusal and configuration projections without changing availability fallback policy. * refactor(process): use one inspection failure type owner * fix(providers): retain recorded secondary failures when rebinding * fix(ghidra): preserve normalized inspection facts through public projection * fix(process): normalize native process listing cancellation * docs(development): clarify failure provenance and cleanup verification * fix(process): preserve direct inspection cancellation facts * fix(process): retain inspection owners through termination * fix(process): preserve command inspection cleanup provenance * fix(desktop): preserve inspection facts after cleanup retry * refactor(process)!: migrate desktop capture with inspection producers * fix(desktop)!: preserve capture failure origins and cleanup owners (#10115) * fix(desktop): preserve capture failure origins and cleanup * fix(desktop): type keyring cleanup cancellation * fix(kuna)!: retain copy cleanup before retiring workspaces (#10114) * fix(kuna): own copy cancellation and descriptor cleanup * fix(kuna): close copy descriptors before workspace removal * test(kuna): type copy lifecycle gates for project library * docs(kuna): explain retained copy-file cleanup and staging ownership * docs(kuna): separate cache publication, integrity and cleanup guidance * fix(desktop): retain sampling inspection owners before release * fix(process): preserve capture file read cancellation origin * fix(process): retain capture inspection cancellation and cleanup owners * fix(process): retain sampling inspection failures in capture scope * fix(process): retain inspection failures through mandatory cleanup * fix(process): distinguish sampler shutdown from capture cancellation * fix(browser): retain startup EOF before later cancellation * fix(process): bind capture inspection cleanup to the owning run * test(process): migrate cancellation fixtures to typed owners Record explicit preparation and command cancellation facts, retain exact opaque reasons and physical causes, and guard the mocked version probe from native preparation. Keep the live Wakaru command control while adding a recording equivalent for bounded source verification. * fix(process): own sampling reads and settle inspection peers * fix(errors): retain authored recovery through cleanup composition * fix(process): retain all sampling inspection leaves for final cleanup * test(browser): report recording cleanup signal outcome * fix(process): keep inspection error context out of implicit JSON * fix(desktop): retain normalized sampling cleanup ownership * test(process): assert tagged startup cancellation
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { createTestArtifactResourceScope } from "../fixtures/artifactResourceScope.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { access, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const fault = vi.hoisted(() => ({
|
|
failure: new Error("controlled manifest failure"),
|
|
failManifest: true,
|
|
}));
|
|
vi.mock("write-file-atomic", async (importOriginal) => {
|
|
const actual = await importOriginal<{
|
|
default: typeof import("write-file-atomic");
|
|
}>();
|
|
return {
|
|
default: async (...args: Parameters<typeof actual.default>) => {
|
|
if (
|
|
fault.failManifest &&
|
|
typeof args[0] === "string" &&
|
|
args[0].endsWith("/ownership.json")
|
|
)
|
|
throw fault.failure;
|
|
return actual.default(...args);
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
GhidraHeadlessLauncher,
|
|
GhidraLaunchError,
|
|
type GhidraLaunchSession,
|
|
} from "../../src/ghidra/GhidraLauncher.js";
|
|
import { bindGhidraSessionFailure } from "../../src/ghidra/GhidraSessionError.js";
|
|
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
|
|
import { GhidraClient } from "../../src/ghidra/GhidraClient.js";
|
|
import {
|
|
spawnOwnedProviderProcess,
|
|
type SpawnedOwnedProviderProcess,
|
|
} from "../../src/process/ProviderProcess.js";
|
|
import {
|
|
cleanupOwnedProcessGroup,
|
|
type ProcessCleanupFailure,
|
|
} from "../../src/process/ProcessOwnership.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { ghidraFixturePath } from "../boundary/providers/ghidra/ghidraClient.fixture.js";
|
|
|
|
const owners: SpawnedOwnedProviderProcess[] = [];
|
|
const clients: GhidraClient[] = [];
|
|
let denyCleanup = true;
|
|
beforeEach(() => {
|
|
denyCleanup = true;
|
|
fault.failManifest = true;
|
|
});
|
|
afterEach(async () => {
|
|
denyCleanup = false;
|
|
for (const client of clients.splice(0)) await client.close();
|
|
for (const owner of owners.splice(0))
|
|
expect(await cleanupOwnedProcessGroup(owner.ownership)).toMatchObject({
|
|
cleaned: true,
|
|
});
|
|
});
|
|
|
|
const incomplete: ProcessCleanupFailure = {
|
|
cleaned: false,
|
|
reason: "mixed fixture outcomes",
|
|
failures: [
|
|
{
|
|
pid: 123,
|
|
reason: "process-identity-unavailable",
|
|
diagnostic: "controlled identity uncertainty",
|
|
},
|
|
],
|
|
unverified: [{ pid: 456, diagnostic: "controlled observation unavailable" }],
|
|
};
|
|
const fixture = async (
|
|
rollback: () => Promise<ProcessCleanupFailure>,
|
|
afterSpawn?: () => void,
|
|
) => {
|
|
const root = await createTestTempDirectory("rea-launch-rollback-");
|
|
const launcher = new GhidraHeadlessLauncher({
|
|
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
|
|
environment: {},
|
|
platform: process.platform,
|
|
analyzeHeadlessPath: join(root, "support", "analyzeHeadless"),
|
|
javaHome: join(root, "jdk"),
|
|
bridgeScriptPath: join(root, "ReaGhidraBridge.java"),
|
|
javaLaunch: async () => ({
|
|
command: process.execPath,
|
|
arguments: ["-e", "setInterval(()=>{},1000)"],
|
|
environment: {},
|
|
}),
|
|
spawnProcess: async (options) => {
|
|
const spawned = await spawnOwnedProviderProcess(options);
|
|
owners.push(spawned);
|
|
afterSpawn?.();
|
|
return {
|
|
...spawned,
|
|
cleanup: () =>
|
|
denyCleanup
|
|
? rollback()
|
|
: cleanupOwnedProcessGroup(spawned.ownership),
|
|
};
|
|
},
|
|
});
|
|
const session: GhidraLaunchSession = {
|
|
runtimeRoot: root,
|
|
transport: "unix-socket",
|
|
endpointPath: join(root, "bridge.sock"),
|
|
token: "fixture-token",
|
|
runId: randomUUID(),
|
|
targetPath: join(root, "unused-target"),
|
|
targetSha256: "a".repeat(64),
|
|
providerVersion: "test",
|
|
profileDigest: "b".repeat(64),
|
|
};
|
|
return { launcher, session };
|
|
};
|
|
|
|
it
|
|
.skipIf(process.platform === "win32")
|
|
.each([undefined, new Error("rollback rejected")] as const)(
|
|
"retains the process owner after rollback rejects with %s",
|
|
async (rejection) => {
|
|
const { launcher, session } = await fixture(async () => {
|
|
throw rejection;
|
|
});
|
|
const result = await launcher.launch(session, {
|
|
resourceScope: createTestArtifactResourceScope(),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok || !(result.error instanceof GhidraLaunchError))
|
|
throw new Error("Expected launch failure");
|
|
expect(result.error.partialLaunch).toBeDefined();
|
|
expect(result.error).toMatchObject({
|
|
rollbackFailure: { kind: "rejected", cause: rejection },
|
|
});
|
|
expect(owners[0]?.process.exitCode).toBeNull();
|
|
denyCleanup = false;
|
|
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
|
|
cleaned: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains structured mixed-member rollback evidence through session projection",
|
|
async () => {
|
|
const { launcher, session } = await fixture(async () => incomplete);
|
|
const result = await launcher.launch(session, {
|
|
resourceScope: createTestArtifactResourceScope(),
|
|
});
|
|
if (result.ok || !(result.error instanceof GhidraLaunchError))
|
|
throw new Error("Expected launch failure");
|
|
expect(result.error.partialLaunch).toBeDefined();
|
|
expect(result.error).toMatchObject({
|
|
rollbackFailure: { kind: "incomplete", result: incomplete },
|
|
});
|
|
const projected = bindGhidraSessionFailure(() => ({}))(
|
|
"start",
|
|
result.error.message,
|
|
result.error,
|
|
);
|
|
expect(projected.diagnostics).toMatchObject({
|
|
failure_cause: {
|
|
rollback_failure: { kind: "incomplete", result: incomplete },
|
|
},
|
|
});
|
|
denyCleanup = false;
|
|
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
|
|
cleaned: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"client adopts a rejected rollback owner and retries before removing its runtime",
|
|
async () => {
|
|
const { launcher } = await fixture(async () => {
|
|
throw undefined;
|
|
});
|
|
const target = await readFile(ghidraFixturePath);
|
|
let runtimeRoot: string | undefined;
|
|
const client = new GhidraClient({
|
|
launcher: {
|
|
launch: (session, options) => {
|
|
runtimeRoot = session.runtimeRoot;
|
|
return launcher.launch(session, options);
|
|
},
|
|
},
|
|
targetPath: ghidraFixturePath,
|
|
targetSha256: createHash("sha256").update(target).digest("hex"),
|
|
providerVersion: "test",
|
|
profileDigest: "b".repeat(64),
|
|
startupTimeoutMs: 5000,
|
|
});
|
|
clients.push(client);
|
|
const result = await client.start();
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } },
|
|
});
|
|
if (result.ok) throw new Error("Expected failed startup");
|
|
expect(owners[0]?.process.exitCode).toBeNull();
|
|
if (runtimeRoot === undefined)
|
|
throw new Error("Expected admitted runtime root");
|
|
await expect(access(runtimeRoot)).resolves.toBeUndefined();
|
|
denyCleanup = false;
|
|
expect(await client.close()).toMatchObject({ ok: true });
|
|
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
|
|
await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains synchronous rollback rejection and the earlier failure during cancellation",
|
|
async () => {
|
|
const controller = new AbortController();
|
|
const { launcher, session } = await fixture(() => {
|
|
controller.abort(null);
|
|
throw undefined;
|
|
});
|
|
const result = await launcher.launch(session, {
|
|
resourceScope: createTestArtifactResourceScope(),
|
|
signal: controller.signal,
|
|
});
|
|
if (result.ok || !(result.error instanceof GhidraLaunchError))
|
|
throw new Error("Expected launch failure");
|
|
expect(result.error.rollbackFailure).toEqual({
|
|
kind: "rejected",
|
|
cause: undefined,
|
|
});
|
|
expect(result.error.cause).toBeInstanceOf(AggregateError);
|
|
if (!(result.error.cause instanceof AggregateError))
|
|
throw new Error("Expected composite failure");
|
|
const primary: unknown = result.error.cause.errors[0];
|
|
expect(primary).toBeInstanceOf(GhidraLaunchError);
|
|
if (!(primary instanceof GhidraLaunchError)) throw primary;
|
|
expect(primary.cause).toBe(fault.failure);
|
|
expect(result.error.cause.errors[1]).toBeUndefined();
|
|
expect(result.error.partialLaunch).toBeDefined();
|
|
denyCleanup = false;
|
|
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
|
|
cleaned: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("bounds and redacts rollback evidence with explicit truncation", () => {
|
|
const value = "sensitive-transport-token";
|
|
const result: ProcessCleanupFailure = {
|
|
cleaned: false,
|
|
reason: value + "x".repeat(4096),
|
|
failures: [
|
|
{
|
|
pid: 1,
|
|
reason: "run-token-mismatch",
|
|
diagnostic: value + "y".repeat(4096),
|
|
},
|
|
],
|
|
unverified: [{ pid: 2, diagnostic: value + "z".repeat(4096) }],
|
|
};
|
|
const launch = new GhidraLaunchError("primary", {
|
|
rollbackFailure: { kind: "incomplete", result },
|
|
});
|
|
const projected = bindGhidraSessionFailure(
|
|
() => ({}),
|
|
(text) => text.replaceAll(value, "[REDACTED]"),
|
|
)("start", "primary", launch);
|
|
expect(JSON.stringify(projected.diagnostics)).not.toContain(value);
|
|
expect(projected.diagnostics).toMatchObject({
|
|
failure_cause: {
|
|
rollback_failure: {
|
|
result: {
|
|
reason_truncated: true,
|
|
failures: [{ diagnostic_truncated: true }],
|
|
unverified: [{ diagnostic_truncated: true }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("bounds aggregate member evidence and retains complete producer facts", () => {
|
|
const rows = Array.from({ length: 500 }, (_, pid) => ({
|
|
pid,
|
|
diagnostic: "\u0001".repeat(2000),
|
|
}));
|
|
const result: ProcessCleanupFailure = {
|
|
cleaned: false,
|
|
reason: "many controlled members",
|
|
failures: rows.map((row) => ({
|
|
...row,
|
|
reason: "process-identity-unavailable" as const,
|
|
})),
|
|
unverified: rows,
|
|
};
|
|
const launch = new GhidraLaunchError("primary", {
|
|
rollbackFailure: { kind: "incomplete", result },
|
|
});
|
|
const projected = bindGhidraSessionFailure(() => ({}))(
|
|
"start",
|
|
"primary",
|
|
launch,
|
|
);
|
|
const cause = projected.diagnostics["failure_cause"] as {
|
|
rollback_failure: {
|
|
result: {
|
|
failures: unknown[];
|
|
unverified: unknown[];
|
|
failures_omitted?: number;
|
|
unverified_omitted?: number;
|
|
};
|
|
};
|
|
};
|
|
const output = cause.rollback_failure.result;
|
|
expect(output.failures.length + (output.failures_omitted ?? 0)).toBe(500);
|
|
expect(output.unverified.length + (output.unverified_omitted ?? 0)).toBe(500);
|
|
expect(Buffer.byteLength(JSON.stringify(projected.diagnostics))).toBeLessThan(
|
|
10 * 1024,
|
|
);
|
|
expect(launch.rollbackFailure).toEqual({ kind: "incomplete", result });
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each([null, "stop after spawn"])(
|
|
"retains producer cancellation and exact process cleanup through failed rollback %j",
|
|
async (reason) => {
|
|
fault.failManifest = false;
|
|
const controller = new AbortController();
|
|
const { launcher, session } = await fixture(
|
|
async () => incomplete,
|
|
() => controller.abort(reason),
|
|
);
|
|
const result = await launcher.launch(session, {
|
|
resourceScope: createTestArtifactResourceScope(),
|
|
signal: controller.signal,
|
|
});
|
|
if (result.ok || !(result.error instanceof GhidraLaunchError))
|
|
throw new Error("Expected incomplete rollback");
|
|
expect(result.error.primaryFailure).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(result.error.primaryFailure?.cause).toBe(reason);
|
|
expect(result.error.rollbackFailure).toEqual({
|
|
kind: "incomplete",
|
|
result: incomplete,
|
|
});
|
|
expect(result.error.partialLaunch).toBeDefined();
|
|
denyCleanup = false;
|
|
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
|
|
cleaned: true,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"client preserves an earlier manifest failure when the final caller cancels during rollback",
|
|
async () => {
|
|
const controller = new AbortController();
|
|
const { launcher } = await fixture(async () => {
|
|
controller.abort(null);
|
|
return incomplete;
|
|
});
|
|
const target = await readFile(ghidraFixturePath);
|
|
const client = new GhidraClient({
|
|
launcher,
|
|
targetPath: ghidraFixturePath,
|
|
targetSha256: createHash("sha256").update(target).digest("hex"),
|
|
providerVersion: "test",
|
|
profileDigest: "b".repeat(64),
|
|
startupTimeoutMs: 5000,
|
|
});
|
|
clients.push(client);
|
|
const result = await client.start(controller.signal);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } },
|
|
});
|
|
if (result.ok) throw new Error("Expected original startup failure");
|
|
expect(JSON.stringify(result.error.diagnostics)).toContain(
|
|
fault.failure.message,
|
|
);
|
|
denyCleanup = false;
|
|
expect(await client.close()).toMatchObject({ ok: true });
|
|
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
|
|
},
|
|
);
|