Files
rea/tests/boundary/filesystem/processCliFileInput.test.ts
T
morluto d8c76b6296 fix(io): retain cleanup ownership and honor cancellation
Keep failed JSON read handles in the existing server resource scope and fail admission until cleanup succeeds. Thread cancellation through every process CLI input read and replace timer-driven cancellation checks with a shared first-read gate.

Classify authenticated standalone Evidence as wrong-kind input while preserving tampered-record integrity failures and actionable messages across ledger, CLI, and MCP consumers. Record unrelated CS_RESTRICT process neighbors as unverified without weakening related-process ownership checks. Update stale ADB fixtures for the actual shell protocol.

Validated focused CLI, MCP, filesystem, ownership, and error projection tests; static, docs, and duplication gates pass. The host-level CS_RESTRICT load reproduction was not run. Refs #2050, #2035, #2064.
2026-10-12 14:12:44 +08:00

285 lines
9.8 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import {
captureProcessScenarioFile,
compareProcessEvidenceFiles,
projectProcessCliError,
} from "../../../src/application/process/ProcessCli.js";
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { holdFirstFileRead } from "../../support/holdFirstFileRead.js";
describe("process CLI error projection", () => {
it("preserves local diagnostics for unexpected process failures", () => {
const projected = projectProcessCliError(
new Error("SECRET internal failure /private/path"),
);
expect(projected).toEqual({
error: "Process command failed",
category: "execution_failure",
message:
"Process command could not complete: SECRET internal failure /private/path",
});
expect(JSON.stringify(projected)).not.toContain("stack");
});
});
describe("process CLI file cancellation", () => {
it("projects pre-aborted file reads as cancellation", async () => {
const root = await createTestTempDirectory("rea-process-cli-cancel-");
const controller = new AbortController();
controller.abort();
expect(
await captureProcessScenarioFile(
join(root, "missing-scenario.json"),
process.env,
controller.signal,
),
).toMatchObject({
error: "Process command failed",
code: "cancelled",
category: "cancelled",
details: { operation: "capture_process_scenario" },
});
expect(
await compareProcessEvidenceFiles(
join(root, "missing-left.json"),
join(root, "missing-right.json"),
join(root, "missing-trace.json"),
controller.signal,
),
).toMatchObject({
code: "cancelled",
category: "cancelled",
details: { operation: "compare_process_captures" },
});
});
it.each([
{ workflow: "capture", input: "scenario" },
{ workflow: "compare", input: "left" },
{ workflow: "compare", input: "right" },
{ workflow: "compare", input: "trace specification" },
] as const)(
"stops streamed $workflow $input reads after cancellation",
async ({ workflow, input }) => {
const root = await createTestTempDirectory("rea-process-cli-stream-");
const paths = {
scenario: join(root, "scenario.json"),
left: join(root, "left.json"),
right: join(root, "right.json"),
trace: join(root, "trace.json"),
};
const evidence = JSON.stringify(processCaptureEvidence());
const scenario = JSON.stringify({
executable: process.execPath,
arguments: [],
});
const traceSpecification = JSON.stringify(validTraceSpecification());
const contentsByInput = {
scenario: input === "scenario" ? stream(scenario) : scenario,
left: input === "left" ? stream(evidence) : evidence,
right: input === "right" ? stream(evidence) : evidence,
trace:
input === "trace specification"
? stream(traceSpecification)
: traceSpecification,
};
await Promise.all([
writeFile(paths.scenario, contentsByInput.scenario),
writeFile(paths.left, contentsByInput.left),
writeFile(paths.right, contentsByInput.right),
writeFile(paths.trace, contentsByInput.trace),
]);
const targetPath =
paths[input === "trace specification" ? "trace" : input];
const contents =
contentsByInput[input === "trace specification" ? "trace" : input];
const reader = await holdFirstFileRead(targetPath);
onTestFinished(reader.release);
const controller = new AbortController();
try {
const pending =
workflow === "capture"
? captureProcessScenarioFile(
paths.scenario,
process.env,
controller.signal,
)
: compareProcessEvidenceFiles(
paths.left,
paths.right,
paths.trace,
controller.signal,
);
await reader.firstRead;
controller.abort();
reader.release();
expect(await pending).toMatchObject({
error: "Process command failed",
code: "cancelled",
category: "cancelled",
});
expect(reader.readCount).toBe(1);
expect(reader.totalReadBytes).toBeLessThan(contents.length);
} finally {
controller.abort();
reader.release();
vi.restoreAllMocks();
}
},
);
});
describe("process CLI input diagnostics", () => {
it("projects missing, malformed JSON, and invalid scenario files", async () => {
const root = await createTestTempDirectory("rea-process-cli-diagnostics-");
const malformed = join(root, "malformed.json");
const invalid = join(root, "invalid.json");
const invalidUtf8 = join(root, "invalid-utf8.json");
await writeFile(malformed, "not-json");
await writeFile(invalid, "{}");
await writeFile(
invalidUtf8,
Buffer.concat([
Buffer.from('{"executable":"'),
Buffer.from([0xff]),
Buffer.from('"}'),
]),
);
expect(
await captureProcessScenarioFile(join(root, "missing.json")),
).toEqual({
error: "Process command failed",
category: "invalid_input",
message: expect.stringContaining(
`Process input file could not be read: ${join(root, "missing.json")} (ENOENT`,
),
});
expect(await captureProcessScenarioFile(malformed)).toEqual({
error: "Process command failed",
category: "invalid_input",
message: expect.stringContaining(
`Process input file is not valid UTF-8 JSON: ${malformed}`,
),
});
expect(await captureProcessScenarioFile(invalidUtf8)).toEqual({
error: "Process command failed",
category: "invalid_input",
message: expect.stringContaining(
`Process input file is not valid UTF-8 JSON: ${invalidUtf8}`,
),
});
expect(await captureProcessScenarioFile(invalid)).toEqual({
error: "Process command failed",
code: "invalid_request",
category: "invalid_input",
message: "Analysis input is invalid. Check the arguments and try again.",
retryable: true,
remediation: { action: "Correct the listed arguments and retry." },
details: expect.objectContaining({
operation: "capture_process_scenario",
issues: expect.arrayContaining([
expect.objectContaining({ path: ["executable"] }),
]),
}),
});
});
it.each([false, true])(
"preserves the open-file owner when close fails (cancelled: %s)",
async (cancelDuringClose) => {
const root = await createTestTempDirectory("rea-process-cli-cleanup-");
const path = join(root, "scenario.json");
await writeFile(path, JSON.stringify({ executable: process.execPath }));
const controller = new AbortController();
const cleanupCause = new Error("injected file close failure");
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
if (cancelDuringClose) controller.abort();
throw cleanupCause;
});
let failure: unknown;
try {
try {
await captureProcessScenarioFile(
path,
process.env,
cancelDuringClose ? controller.signal : undefined,
);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected the retained file handle owner");
expect(failure.cleanupCause).toBe(cleanupCause);
expect(failure.owner.handle.fd).toBeGreaterThanOrEqual(0);
} finally {
close.mockRestore();
if (failure instanceof RegularFileCleanupFailure)
await failure.owner.close();
}
},
);
it("names a leading byte-order mark instead of quoting it as a token", async () => {
const path = join(
await createTestTempDirectory("rea-process-cli-diagnostics-"),
"scenario.json",
);
await writeFile(path, '\uFEFF{"executable":"/bin/echo"}');
expect(await captureProcessScenarioFile(path)).toEqual({
error: "Process command failed",
category: "invalid_input",
message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`,
});
});
});
const processCaptureEvidence = () =>
createEvidence(undefined, PROCESS_PROVIDER, {
predicateType: "rea.process-capture",
operation: "capture_process_scenario",
parameters: {},
result: EMPTY_PROCESS_CAPTURE_EXAMPLE,
confidence: "observed",
authority: "controlled-replay",
environment: {
id: "fixture",
platform: "fixture",
architecture: "fixture",
isolation: "process",
},
});
const stream = (json: string) => " ".repeat(8 * 1024 * 1024) + json;
const validTraceSpecification = () => ({
events: [
{
id: "started",
source: "lifecycle",
exact: { state: "started" },
},
{
id: "finished",
source: "lifecycle",
exact: { state: "finished" },
},
],
language: {
kind: "partial_order",
happens_before: [{ before: "started", after: "finished" }],
},
});