Refs #4475. Share descriptor scopes across firmware inspection and publication. Preserve primary failures, exact cancellation reasons, and unresolved resources through finalization.
816 lines
28 KiB
TypeScript
816 lines
28 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
mkdir,
|
|
rm,
|
|
writeFile,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const fault = vi.hoisted(() => ({
|
|
selected: "",
|
|
handles: [] as FileHandle[],
|
|
blocked: new Set<FileHandle>(),
|
|
onClose: undefined as (() => Promise<void>) | undefined,
|
|
malformed: false,
|
|
onSync: undefined as (() => void) | undefined,
|
|
}));
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...original,
|
|
open: async (...args: Parameters<typeof original.open>) => {
|
|
const handle = await original.open(...args);
|
|
if (
|
|
!fault.selected ||
|
|
!String(args[0]).endsWith(fault.selected) ||
|
|
typeof args[1] !== "number"
|
|
)
|
|
return handle;
|
|
const close = handle.close.bind(handle);
|
|
const sync = handle.sync.bind(handle);
|
|
handle.sync = async () => {
|
|
await sync();
|
|
fault.onSync?.();
|
|
};
|
|
fault.handles.push(handle);
|
|
fault.blocked.add(handle);
|
|
handle.close = async () => {
|
|
await fault.onClose?.();
|
|
if (fault.blocked.has(handle))
|
|
throw new Error("controlled firmware descriptor close failure");
|
|
await close();
|
|
};
|
|
return handle;
|
|
},
|
|
};
|
|
});
|
|
// Only host/tool admission and producer execution are simulated. File IO,
|
|
// normalization, provider finalization and cleanup ownership remain production code.
|
|
vi.mock("../../src/firmware/FirmwareCommand.js", async (importOriginal) => {
|
|
const original =
|
|
await importOriginal<
|
|
typeof import("../../src/firmware/FirmwareCommand.js")
|
|
>();
|
|
return {
|
|
...original,
|
|
resolveFirmwareCommand: async () => ({
|
|
command: "/fixture/binwalk",
|
|
limiter: "/fixture/prlimit",
|
|
sha256: "0".repeat(64),
|
|
}),
|
|
runFirmwareCommand: async (
|
|
context: Parameters<typeof original.runFirmwareCommand>[0],
|
|
) => {
|
|
const version = context.args[0] === "--version";
|
|
if (!version) {
|
|
const report = context.args[context.args.indexOf("--log") + 1];
|
|
if (report === undefined)
|
|
throw new Error("Missing fixture report path");
|
|
await writeFile(
|
|
report,
|
|
fault.malformed
|
|
? "{"
|
|
: JSON.stringify([
|
|
{
|
|
Analysis: {
|
|
file_path: context.args.at(-1),
|
|
file_map: [],
|
|
extractions: {},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
const text = version ? "binwalk 3.1.0" : "";
|
|
return {
|
|
stdout: { text, bytes: Buffer.byteLength(text), truncated: false },
|
|
stderr: { text: "", bytes: 0, truncated: false },
|
|
exit_code: 0,
|
|
signal: null,
|
|
command: context.command,
|
|
args: context.args,
|
|
resource_limiter: context.limiter,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
import { FirmwareFileAccess } from "../../src/firmware/FirmwareFiles.js";
|
|
import { publishFirmwareExtraction } from "../../src/firmware/FirmwarePublication.js";
|
|
import { normalizeUnblobReport } from "../../src/firmware/FirmwareReports.js";
|
|
import { SafeOutputTree } from "../../src/artifacts/SafeOutputTree.js";
|
|
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
|
|
import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js";
|
|
import { FirmwareProvider } from "../../src/firmware/FirmwareProvider.js";
|
|
import { firmwareFailure } from "../../src/firmware/FirmwareFailures.js";
|
|
import { ArtifactFileCleanupFailure } from "../../src/artifacts/ArtifactFileOperation.js";
|
|
import { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
|
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
|
|
import { PrivateRuntimeRoot } from "../../src/process/PrivateRuntimeRoot.js";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
|
|
const providers: FirmwareProvider[] = [];
|
|
beforeEach(() => {
|
|
fault.selected = "";
|
|
fault.handles = [];
|
|
fault.blocked.clear();
|
|
fault.onClose = undefined;
|
|
fault.malformed = false;
|
|
fault.onSync = undefined;
|
|
});
|
|
afterEach(async () => {
|
|
fault.blocked.clear();
|
|
fault.onClose = undefined;
|
|
vi.restoreAllMocks();
|
|
for (const handle of fault.handles) await handle.close();
|
|
await Promise.all(providers.splice(0).map((provider) => provider.close()));
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects writerless FIFO input, hash and report through the compiled reader",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-fifo-");
|
|
const fifo = join(root, "input.fifo");
|
|
const work = join(root, "work");
|
|
await mkdir(work);
|
|
await promisify(execFile)("mkfifo", [fifo]);
|
|
const module = pathToFileURL(
|
|
join(process.cwd(), "dist/firmware/FirmwareFiles.js"),
|
|
).href;
|
|
const script = `import { FirmwareFileAccess } from ${JSON.stringify(module)};
|
|
const files = new FirmwareFileAccess();
|
|
const outcomes = [];
|
|
for (const run of [() => files.hash(process.argv[1]), () => files.report(process.argv[1], 'inspect_firmware_regions'), () => files.snapshot({ operation: 'inspect_firmware_regions', input: { path: process.argv[1] } }, process.argv[2], new AbortController().signal)]) {
|
|
try { await run(); outcomes.push('unexpected-success'); } catch (error) { outcomes.push(error._tag ?? error.name); }
|
|
}
|
|
await files.close(); console.log(JSON.stringify(outcomes));`;
|
|
const { stdout } = await promisify(execFile)(
|
|
process.execPath,
|
|
["--input-type=module", "-e", script, fifo, work],
|
|
{ timeout: 3000 },
|
|
);
|
|
expect(JSON.parse(stdout)).toEqual([
|
|
"NonRegularFileReadError",
|
|
"AnalysisOutputError",
|
|
"AnalysisInputError",
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(["hash", "report"] as const)(
|
|
"retains completed %s and exact owner across rejected retry",
|
|
async (kind) => {
|
|
const root = await createTestTempDirectory("rea-firmware-read-owner-");
|
|
const path = join(root, "selected.bin");
|
|
await writeFile(path, '{"value":"固件"}');
|
|
fault.selected = path;
|
|
const files = new FirmwareFileAccess();
|
|
const raw = await (
|
|
kind === "hash"
|
|
? files.hash(path)
|
|
: files.report(path, "inspect_firmware_regions")
|
|
).catch((cause: unknown) => cause);
|
|
expect(raw).toBeInstanceOf(ArtifactFileCleanupFailure);
|
|
if (!(raw instanceof ArtifactFileCleanupFailure))
|
|
throw new Error("Missing physical file outcome");
|
|
expect(raw.fileCleanupFailure.outcome).toEqual({
|
|
kind: "completed",
|
|
value:
|
|
kind === "hash"
|
|
? createHash("sha256").update('{"value":"固件"}').digest("hex")
|
|
: { value: "固件" },
|
|
});
|
|
const original = firmwareFailure(
|
|
raw,
|
|
"binwalk",
|
|
"inspect_firmware_regions",
|
|
);
|
|
const retry = await files
|
|
.retryCleanup()
|
|
.catch((cause: unknown) =>
|
|
firmwareFailure(cause, "binwalk", "inspect_firmware_regions"),
|
|
);
|
|
expect(retry).toBeInstanceOf(AnalysisError);
|
|
expect(fault.handles).toHaveLength(1);
|
|
const handle = fault.handles[0];
|
|
if (handle === undefined) throw new Error("Missing exact descriptor");
|
|
expect(handle.fd).toBeGreaterThanOrEqual(0);
|
|
fault.blocked.delete(handle);
|
|
await files.close();
|
|
expect(handle.fd).toBe(-1);
|
|
expect(original.cleanupIncomplete).toBe(false);
|
|
if (retry instanceof AnalysisError)
|
|
expect(retry.cleanupIncomplete).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("preserves malformed-report failure when cancellation arrives during failed close", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-primary-");
|
|
const path = join(root, "report.json");
|
|
await writeFile(path, "{");
|
|
fault.selected = path;
|
|
const controller = new AbortController();
|
|
fault.onClose = async () => controller.abort("late close cancellation");
|
|
const files = new FirmwareFileAccess();
|
|
const raw = await files
|
|
.report(path, "inspect_firmware_regions", controller.signal)
|
|
.catch((cause: unknown) => cause);
|
|
const error = firmwareFailure(raw, "binwalk", "inspect_firmware_regions");
|
|
expect(error.diagnostics?.primary_error).toMatchObject({
|
|
code: "unreadable_output",
|
|
});
|
|
fault.blocked.clear();
|
|
await files.close();
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
});
|
|
|
|
it.each([null, "caller cancellation", new Error("caller cancellation")])(
|
|
"preserves exact cancellation during final close: %s",
|
|
async (reason) => {
|
|
const root = await createTestTempDirectory("rea-firmware-final-cancel-");
|
|
const path = join(root, "report.json");
|
|
await writeFile(path, "{}");
|
|
fault.selected = path;
|
|
const controller = new AbortController();
|
|
fault.onClose = async () => {
|
|
controller.abort(reason);
|
|
fault.blocked.clear();
|
|
};
|
|
const files = new FirmwareFileAccess();
|
|
const raw = await files
|
|
.report(path, "inspect_firmware_regions", controller.signal)
|
|
.catch((cause: unknown) => cause);
|
|
const error = firmwareFailure(raw, "binwalk", "inspect_firmware_regions");
|
|
expect(error).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(error.cause).toBe(reason);
|
|
await files.close();
|
|
},
|
|
);
|
|
|
|
it.each(["descriptor-first", "workspace-first"])(
|
|
"keeps provider mixed-owner cleanup incomplete until both release: %s",
|
|
async (order) => {
|
|
const root = await createTestTempDirectory("rea-firmware-mixed-owner-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "firmware bytes");
|
|
fault.selected = "report.json";
|
|
let workspaceBlocked = true;
|
|
let workspacePath = "";
|
|
const close = PrivateRuntimeRoot.prototype.close;
|
|
vi.spyOn(PrivateRuntimeRoot.prototype, "close").mockImplementation(
|
|
async function (this: PrivateRuntimeRoot) {
|
|
workspacePath = this.path;
|
|
if (workspaceBlocked)
|
|
throw new Error("controlled workspace cleanup failure");
|
|
await close.call(this);
|
|
},
|
|
);
|
|
const provider = new FirmwareProvider({});
|
|
providers.push(provider);
|
|
const result = await provider.execute({
|
|
operation: "inspect_firmware_regions",
|
|
input: { path },
|
|
});
|
|
if (result.ok) throw new Error("Expected descriptor and workspace failure");
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
expect(result.error.cleanupResources).toContain(workspacePath);
|
|
expect(fault.handles).toHaveLength(1);
|
|
const blocked = await provider.execute({
|
|
operation: "inspect_firmware_regions",
|
|
input: { path },
|
|
});
|
|
expect(blocked).toMatchObject({
|
|
ok: false,
|
|
error: { cleanupIncomplete: true },
|
|
});
|
|
expect(fault.handles).toHaveLength(1);
|
|
if (order === "descriptor-first") fault.blocked.clear();
|
|
else workspaceBlocked = false;
|
|
const retry = await provider.close().catch((cause: unknown) => cause);
|
|
expect(retry).toBeInstanceOf(AnalysisError);
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
if (order === "workspace-first")
|
|
await expect(access(workspacePath)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
else expect(fault.handles[0]?.fd).toBe(-1);
|
|
fault.blocked.clear();
|
|
workspaceBlocked = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
if (retry instanceof AnalysisError)
|
|
expect(retry.cleanupIncomplete).toBe(false);
|
|
if (!blocked.ok) expect(blocked.error.cleanupIncomplete).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("retains completed firmware observation when cancellation arrives during workspace close", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-workspace-cancel-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "firmware bytes");
|
|
const controller = new AbortController();
|
|
const close = PrivateRuntimeRoot.prototype.close;
|
|
vi.spyOn(PrivateRuntimeRoot.prototype, "close").mockImplementation(
|
|
async function (this: PrivateRuntimeRoot) {
|
|
controller.abort(null);
|
|
await close.call(this);
|
|
},
|
|
);
|
|
const provider = new FirmwareProvider({});
|
|
providers.push(provider);
|
|
const result = await provider.execute(
|
|
{ operation: "inspect_firmware_regions", input: { path } },
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisCancelledError",
|
|
cause: null,
|
|
partialObservation: { kind: "firmware" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32").each(["snapshot", "report"] as const)(
|
|
"retains typed %s admission failure when rejecting a directory also fails close",
|
|
async (kind) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-firmware-rejected-admission-",
|
|
);
|
|
const path = join(root, "selected");
|
|
const work = join(root, "work");
|
|
await mkdir(path);
|
|
await mkdir(work);
|
|
fault.selected = path;
|
|
const files = new FirmwareFileAccess();
|
|
const raw = await (
|
|
kind === "snapshot"
|
|
? files.snapshot(
|
|
{ operation: "inspect_firmware_regions", input: { path } },
|
|
work,
|
|
new AbortController().signal,
|
|
)
|
|
: files.report(path, "inspect_firmware_regions")
|
|
).catch((cause: unknown) => cause);
|
|
const error = firmwareFailure(raw, "binwalk", "inspect_firmware_regions");
|
|
expect(error.diagnostics?.primary_error).toMatchObject({
|
|
code: kind === "snapshot" ? "invalid_request" : "unreadable_output",
|
|
});
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
fault.blocked.clear();
|
|
await files.close();
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"retains timeout during workspace finalization (close fails: %s)",
|
|
async (closeFails) => {
|
|
const root = await createTestTempDirectory("rea-firmware-final-timeout-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "firmware bytes");
|
|
const deadline = new AbortController();
|
|
vi.spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal);
|
|
let blocked = closeFails;
|
|
const close = PrivateRuntimeRoot.prototype.close;
|
|
vi.spyOn(PrivateRuntimeRoot.prototype, "close").mockImplementation(
|
|
async function (this: PrivateRuntimeRoot) {
|
|
deadline.abort(new DOMException("controlled deadline", "TimeoutError"));
|
|
if (blocked)
|
|
throw new Error("controlled final workspace close failure");
|
|
await close.call(this);
|
|
},
|
|
);
|
|
const provider = new FirmwareProvider({});
|
|
providers.push(provider);
|
|
const result = await provider.execute({
|
|
operation: "inspect_firmware_regions",
|
|
input: { path },
|
|
});
|
|
if (result.ok) throw new Error("Expected timeout");
|
|
expect(result.error.partialObservation).toMatchObject({ kind: "firmware" });
|
|
if (closeFails) {
|
|
expect(result.error.diagnostics?.primary_error).toMatchObject({
|
|
code: "provider_timeout",
|
|
});
|
|
expect(result.error.cleanupIncomplete).toBe(true);
|
|
blocked = false;
|
|
await provider.close();
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
} else
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisTimeoutError",
|
|
cause: deadline.signal.reason,
|
|
});
|
|
},
|
|
);
|
|
|
|
it("classifies a deadline before composing failed descriptor cleanup", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-file-timeout-");
|
|
const path = join(root, "report.json");
|
|
await writeFile(path, "{}");
|
|
fault.selected = path;
|
|
const deadline = new AbortController();
|
|
fault.onClose = async () =>
|
|
deadline.abort(new DOMException("controlled deadline", "TimeoutError"));
|
|
const files = new FirmwareFileAccess();
|
|
const raw = await files
|
|
.report(path, "inspect_firmware_regions", deadline.signal)
|
|
.catch((cause: unknown) => cause);
|
|
const error = firmwareFailure(
|
|
raw,
|
|
"binwalk",
|
|
"inspect_firmware_regions",
|
|
deadline.signal,
|
|
);
|
|
expect(error.diagnostics?.primary_error).toMatchObject({
|
|
code: "provider_timeout",
|
|
});
|
|
fault.blocked.clear();
|
|
await files.close();
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
});
|
|
|
|
const publication = async (
|
|
signal: AbortSignal,
|
|
deadline = new AbortController().signal,
|
|
) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-firmware-publication-lifetime-",
|
|
);
|
|
const inputPath = join(root, "input.bin");
|
|
const outputRoot = join(root, "extracted");
|
|
const sha256 = "0".repeat(64);
|
|
const normalized = normalizeUnblobReport(
|
|
[
|
|
{
|
|
task: {
|
|
path: inputPath,
|
|
depth: 0,
|
|
blob_id: "root",
|
|
is_multi_file: false,
|
|
},
|
|
reports: [
|
|
{ __typename__: "HashReport", sha256 },
|
|
{
|
|
__typename__: "StatReport",
|
|
path: inputPath,
|
|
size: 8,
|
|
is_dir: false,
|
|
is_file: true,
|
|
is_link: false,
|
|
link_target: null,
|
|
},
|
|
],
|
|
subtasks: [],
|
|
},
|
|
],
|
|
{ inputPath, outputRoot, sha256, length: 8, offset: 0, maxDepth: 3 },
|
|
);
|
|
const retained: AnalysisCleanupLease[] = [];
|
|
const workspacePath = join(root, "private");
|
|
await mkdir(workspacePath);
|
|
const context = {
|
|
files: new FirmwareFileAccess(),
|
|
workspacePath,
|
|
deadline,
|
|
outputDirectory: join(root, "published"),
|
|
entries: [],
|
|
normalized,
|
|
selection: { offset: 0, length: 8, sha256 },
|
|
maxDepth: 3,
|
|
engine: {
|
|
name: "unblob",
|
|
version: "26.6.4",
|
|
executable_path: "/fixture/unblob",
|
|
executable_sha256: sha256,
|
|
source_revision: null,
|
|
worker_count: 1 as const,
|
|
},
|
|
signal,
|
|
retainCleanup: (
|
|
_tree: SafeOutputTree,
|
|
lease: AnalysisCleanupLease,
|
|
failure: AnalysisError,
|
|
) => {
|
|
const workspace = new AnalysisCleanupLease(() =>
|
|
rm(workspacePath, { recursive: true, force: true }),
|
|
);
|
|
workspace.bindCleanupError(failure);
|
|
retained.push(
|
|
new AnalysisCleanupLease(async () => {
|
|
await lease.release();
|
|
await workspace.release();
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
return { context, retained };
|
|
};
|
|
|
|
it("preserves primitive cancellation before publication commit and rolls back the output", async () => {
|
|
const controller = new AbortController();
|
|
const fixture = await publication(controller.signal);
|
|
const create = SafeOutputTree.create;
|
|
vi.spyOn(SafeOutputTree, "create").mockImplementation(async (...args) => {
|
|
const tree = await create(...args);
|
|
controller.abort(null);
|
|
return tree;
|
|
});
|
|
const error = await publishFirmwareExtraction(fixture.context).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
expect(error).toBeInstanceOf(AnalysisCancelledError);
|
|
if (error instanceof AnalysisCancelledError) expect(error.cause).toBeNull();
|
|
await expect(access(fixture.context.outputDirectory)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
expect(fixture.retained).toHaveLength(0);
|
|
await fixture.context.files.close();
|
|
});
|
|
|
|
it.each(["caller", "deadline"])(
|
|
"preserves %s cancellation during directory sync plus failed close and rollback",
|
|
async (kind) => {
|
|
const controller = new AbortController();
|
|
const reason =
|
|
kind === "caller"
|
|
? null
|
|
: new DOMException("controlled deadline", "TimeoutError");
|
|
const fixture = await publication(
|
|
controller.signal,
|
|
kind === "deadline" ? controller.signal : undefined,
|
|
);
|
|
const create = SafeOutputTree.create;
|
|
vi.spyOn(SafeOutputTree, "create").mockImplementation(async (...args) => {
|
|
const tree = await create(...args);
|
|
fault.selected = tree.outputRoot;
|
|
fault.onSync = () => controller.abort(reason);
|
|
return tree;
|
|
});
|
|
const error = await publishFirmwareExtraction(fixture.context).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (!(error instanceof AnalysisError))
|
|
throw new Error("Expected typed tree failure");
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
expect(error.diagnostics?.primary_error).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: {
|
|
code: kind === "caller" ? "cancelled" : "provider_timeout",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const aggregate = error.cause;
|
|
if (!(aggregate instanceof AggregateError))
|
|
throw new Error("Expected primary and cleanup causes");
|
|
const primary = aggregate.errors[0] as AnalysisError;
|
|
const inner = primary.cause;
|
|
if (!(inner instanceof AggregateError))
|
|
throw new Error("Expected primary cancellation plus commit cleanup");
|
|
expect((inner.errors[0] as AnalysisError).cause).toBe(reason);
|
|
expect(fixture.retained).toHaveLength(1);
|
|
fault.blocked.clear();
|
|
await Promise.all(fixture.retained.map((lease) => lease.release()));
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
await expect(access(fixture.context.outputDirectory)).rejects.toMatchObject(
|
|
{ code: "ENOENT" },
|
|
);
|
|
await fixture.context.files.close();
|
|
},
|
|
);
|
|
|
|
it("retains a completed published observation when commit cleanup and rollback both fail", async () => {
|
|
const fixture = await publication(new AbortController().signal);
|
|
const commit = SafeOutputTree.prototype.commit;
|
|
vi.spyOn(SafeOutputTree.prototype, "commit").mockImplementation(
|
|
async function (this: SafeOutputTree, signal) {
|
|
await commit.call(this, signal);
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
new Error("controlled published commit cleanup failure"),
|
|
{
|
|
reason: "controlled pending directory close",
|
|
resources: [this.outputRoot],
|
|
},
|
|
);
|
|
},
|
|
);
|
|
const rollback = SafeOutputTree.prototype.rollback;
|
|
let blocked = true;
|
|
vi.spyOn(SafeOutputTree.prototype, "rollback").mockImplementation(
|
|
async function (this: SafeOutputTree) {
|
|
if (blocked) throw new Error("controlled rollback failure");
|
|
return rollback.call(this);
|
|
},
|
|
);
|
|
const error = await publishFirmwareExtraction(fixture.context).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (!(error instanceof AnalysisError))
|
|
throw new Error("Expected typed publication cleanup failure");
|
|
expect(error.partialObservation).toMatchObject({
|
|
kind: "firmware",
|
|
result: { output_directory: fixture.context.outputDirectory, files: [] },
|
|
});
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
blocked = false;
|
|
await Promise.all(fixture.retained.map((lease) => lease.release()));
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
await expect(
|
|
access(fixture.context.outputDirectory),
|
|
).resolves.toBeUndefined();
|
|
await fixture.context.files.close();
|
|
});
|
|
|
|
it("waits through selected-range snapshot finalization when shutdown starts during source close", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-selected-close-");
|
|
const path = join(root, "input.bin");
|
|
const work = join(root, "work");
|
|
await mkdir(work);
|
|
await writeFile(path, "firmware bytes");
|
|
fault.selected = path;
|
|
let enter!: () => void;
|
|
let release!: () => void;
|
|
const entered = new Promise<void>((resolve) => {
|
|
enter = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
fault.onClose = async () => {
|
|
fault.blocked.clear();
|
|
enter();
|
|
await gate;
|
|
};
|
|
const files = new FirmwareFileAccess();
|
|
const pending = files.snapshot(
|
|
{
|
|
operation: "extract_firmware",
|
|
input: {
|
|
path,
|
|
output_directory: join(root, "unused"),
|
|
max_depth: 3,
|
|
max_output_bytes: 1024,
|
|
max_output_files: 10,
|
|
range: { offset: 1, length: 4 },
|
|
},
|
|
},
|
|
work,
|
|
new AbortController().signal,
|
|
);
|
|
try {
|
|
await entered;
|
|
let closed = false;
|
|
const closing = files.close().then(() => {
|
|
closed = true;
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(closed).toBe(false);
|
|
release();
|
|
const snapshot = await pending;
|
|
await closing;
|
|
expect(snapshot.selection).toMatchObject({
|
|
offset: 1,
|
|
length: 4,
|
|
sha256: createHash("sha256").update("irmw").digest("hex"),
|
|
});
|
|
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
|
} finally {
|
|
release();
|
|
await pending.catch(() => {});
|
|
await files.close();
|
|
}
|
|
});
|
|
|
|
it("retains the exact publication source descriptor when its final close fails", async () => {
|
|
const fixture = await publication(new AbortController().signal);
|
|
const path = join(fixture.context.workspacePath, "source.bin");
|
|
const bytes = Buffer.from("published bytes");
|
|
await writeFile(path, bytes);
|
|
const tree = await SafeOutputTree.create(fixture.context.outputDirectory);
|
|
fault.selected = path;
|
|
const raw = await fixture.context.files
|
|
.publish(
|
|
{
|
|
path,
|
|
relativePath: "file.bin",
|
|
size: bytes.length,
|
|
kind: "file",
|
|
linkTarget: null,
|
|
},
|
|
createHash("sha256").update(bytes).digest("hex"),
|
|
tree,
|
|
fixture.context.signal,
|
|
)
|
|
.catch((cause: unknown) => cause);
|
|
expect(raw).toBeInstanceOf(ArtifactFileCleanupFailure);
|
|
const error = firmwareFailure(raw, "unblob", "extract_firmware");
|
|
expect(error.cleanupResources).toContain(path);
|
|
await tree.rollback();
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
fault.blocked.clear();
|
|
await fixture.context.files.close();
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
expect(fault.handles[0]?.fd).toBe(-1);
|
|
});
|
|
|
|
it.each(["source-first", "tree-first"])(
|
|
"keeps nested source and destination cleanup bound to their exact owners: %s",
|
|
async (order) => {
|
|
const fixture = await publication(new AbortController().signal);
|
|
const path = join(fixture.context.workspacePath, "payload.bin");
|
|
const bytes = Buffer.from("published bytes");
|
|
await writeFile(path, bytes);
|
|
const hash = fixture.context.files.hash.bind(fixture.context.files);
|
|
vi.spyOn(fixture.context.files, "hash").mockImplementation(
|
|
async (...args) => {
|
|
const digest = await hash(...args);
|
|
fault.selected = "payload.bin";
|
|
return digest;
|
|
},
|
|
);
|
|
const error = await publishFirmwareExtraction({
|
|
...fixture.context,
|
|
entries: [
|
|
{
|
|
path,
|
|
relativePath: "payload.bin",
|
|
size: bytes.length,
|
|
kind: "file",
|
|
linkTarget: null,
|
|
},
|
|
],
|
|
}).catch((cause: unknown) => cause);
|
|
if (!(error instanceof AnalysisError))
|
|
throw new Error("Expected nested file and tree cleanup failure");
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
expect(fault.handles.length).toBeGreaterThanOrEqual(2);
|
|
expect(fixture.retained).toHaveLength(1);
|
|
fault.blocked.clear();
|
|
if (order === "source-first") await fixture.context.files.close();
|
|
else await Promise.all(fixture.retained.map((lease) => lease.release()));
|
|
expect(error.cleanupIncomplete).toBe(true);
|
|
await fixture.context.files.close();
|
|
await Promise.all(fixture.retained.map((lease) => lease.release()));
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("shares one workspace cleanup attempt across concurrent shutdown callers", async () => {
|
|
const root = await createTestTempDirectory("rea-firmware-concurrent-close-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "firmware bytes");
|
|
let blocked = true;
|
|
let attempts = 0;
|
|
let enter!: () => void;
|
|
let release!: () => void;
|
|
const entered = new Promise<void>((resolve) => {
|
|
enter = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const close = PrivateRuntimeRoot.prototype.close;
|
|
vi.spyOn(PrivateRuntimeRoot.prototype, "close").mockImplementation(
|
|
async function (this: PrivateRuntimeRoot) {
|
|
attempts++;
|
|
if (blocked) throw new Error("controlled first workspace close failure");
|
|
enter();
|
|
await gate;
|
|
await close.call(this);
|
|
},
|
|
);
|
|
const provider = new FirmwareProvider({});
|
|
providers.push(provider);
|
|
const result = await provider.execute({
|
|
operation: "inspect_firmware_regions",
|
|
input: { path },
|
|
});
|
|
if (result.ok) throw new Error("Expected retained workspace cleanup");
|
|
blocked = false;
|
|
const first = provider.close();
|
|
try {
|
|
await entered;
|
|
const second = provider.close();
|
|
expect(second).toBe(first);
|
|
expect(attempts).toBe(2);
|
|
release();
|
|
await Promise.all([first, second]);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
} finally {
|
|
release();
|
|
await first;
|
|
}
|
|
});
|