mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +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
977 lines
34 KiB
TypeScript
977 lines
34 KiB
TypeScript
import { createTestProcessCaptureResourceScope } from "../../fixtures/processCaptureResourceScope.js";
|
|
import { createHash } from "node:crypto";
|
|
import { Dir, type Dirent } from "node:fs";
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
mkdtemp,
|
|
open,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { requirePermissionDenial } from "../../fixtures/permissionDenial.js";
|
|
|
|
const injected = vi.hoisted(() => ({
|
|
readlinkPath: "",
|
|
beforeReadlink: undefined as (() => Promise<void>) | undefined,
|
|
afterReadlink: undefined as (() => Promise<void>) | undefined,
|
|
afterReadlinkFailure: undefined as ((cause: unknown) => void) | undefined,
|
|
filePath: "",
|
|
fileCloseFailure: undefined as Error | undefined,
|
|
fileCloseRejectionEnabled: false,
|
|
fileCloseRejection: undefined as unknown,
|
|
fileStatFailure: undefined as Error | undefined,
|
|
openedPaths: [] as string[],
|
|
abortOnClose: undefined as (() => void) | undefined,
|
|
abortOnStat: undefined as (() => void) | undefined,
|
|
directoryPath: "",
|
|
directoryCloseFailure: undefined as Error | undefined,
|
|
fileHandle: undefined as Awaited<ReturnType<typeof open>> | undefined,
|
|
restoreFileHandle: undefined as (() => void) | undefined,
|
|
nativeFileClose: undefined as (() => Promise<void>) | undefined,
|
|
nativeFileStat: undefined as
|
|
| (() => Promise<Awaited<ReturnType<typeof stat>>>)
|
|
| undefined,
|
|
directory: undefined as Dir | undefined,
|
|
nativeDirectoryClose: undefined as
|
|
| ((callback: (error?: NodeJS.ErrnoException | null) => void) => 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);
|
|
const path = String(args[0]);
|
|
injected.openedPaths.push(path);
|
|
const statFailure = injected.fileStatFailure;
|
|
if (path !== injected.filePath) return handle;
|
|
const nativeClose = handle.close.bind(handle);
|
|
const nativeStat = handle.stat.bind(handle);
|
|
if (path === injected.filePath) {
|
|
injected.fileHandle = handle;
|
|
injected.nativeFileClose = nativeClose;
|
|
injected.nativeFileStat = nativeStat;
|
|
injected.restoreFileHandle = () => {
|
|
handle.close = nativeClose;
|
|
handle.stat = nativeStat as typeof handle.stat;
|
|
};
|
|
}
|
|
handle.close = async () => {
|
|
injected.abortOnClose?.();
|
|
if (path === injected.filePath && injected.fileCloseRejectionEnabled)
|
|
throw injected.fileCloseRejection;
|
|
const currentCloseFailure = injected.fileCloseFailure;
|
|
if (currentCloseFailure !== undefined) throw currentCloseFailure;
|
|
await nativeClose();
|
|
};
|
|
if (statFailure !== undefined)
|
|
handle.stat = async () => {
|
|
injected.abortOnStat?.();
|
|
throw statFailure;
|
|
};
|
|
return handle;
|
|
},
|
|
readlink: async (...args: Parameters<typeof original.readlink>) => {
|
|
const selected = String(args[0]) === injected.readlinkPath;
|
|
if (selected) await injected.beforeReadlink?.();
|
|
try {
|
|
return await original.readlink(...args);
|
|
} catch (cause: unknown) {
|
|
if (selected) injected.afterReadlinkFailure?.(cause);
|
|
throw cause;
|
|
} finally {
|
|
if (selected) await injected.afterReadlink?.();
|
|
}
|
|
},
|
|
opendir: async (...args: Parameters<typeof original.opendir>) => {
|
|
const directory = await original.opendir(...args);
|
|
if (args[0] === injected.directoryPath) injected.directory = directory;
|
|
return directory;
|
|
},
|
|
};
|
|
});
|
|
|
|
import { AnalysisCleanupLease } from "../../../src/artifacts/AnalysisCleanupLease.js";
|
|
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
|
|
import {
|
|
hashFile,
|
|
snapshotRoots,
|
|
ProcessCaptureSnapshotFailure,
|
|
} from "../../../src/process/capture/FilesystemSnapshot.js";
|
|
import { ProcessCaptureReadFailure } from "../../../src/process/capture/ProcessCaptureRead.js";
|
|
import {
|
|
ProcessCaptureResourceScope,
|
|
prepareProcessCapture,
|
|
} from "../../../src/process/capture/ProcessCaptureLifecycle.js";
|
|
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js";
|
|
import { classifyFilesystemEffects } from "../../../src/process/capture/ProcessFilesystemEffects.js";
|
|
|
|
const roots: string[] = [];
|
|
const trackedRoot = async (): Promise<string> => {
|
|
const root = await mkdtemp(join(tmpdir(), "rea-snapshot-handle-owner-"));
|
|
roots.push(root);
|
|
return root;
|
|
};
|
|
|
|
type DirectoryReadCallback = (
|
|
error: NodeJS.ErrnoException | null,
|
|
value: Dirent | null,
|
|
) => void;
|
|
type DirectoryCloseCallback = (error?: NodeJS.ErrnoException | null) => void;
|
|
|
|
const scenarioFor = (root: string) =>
|
|
processScenarioSchema.parse({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
|
|
const expectSnapshotFailure = (cause: unknown) => {
|
|
expect(cause).toBeInstanceOf(Error);
|
|
expect(cause).toMatchObject({
|
|
cleanupLeases: expect.any(Array),
|
|
});
|
|
const failure = cause as Error & {
|
|
cleanupIncomplete: boolean;
|
|
snapshot?: {
|
|
files: readonly { path: string; type?: string; sha256?: string | null }[];
|
|
truncated?: boolean;
|
|
completeRoots?: readonly string[];
|
|
coverage?: { enumeration_truncated?: boolean };
|
|
};
|
|
cleanupLeases: readonly AnalysisCleanupLease[];
|
|
};
|
|
expect(failure.cleanupLeases.length).toBeGreaterThan(0);
|
|
expect(failure.cleanupLeases[0]).toBeInstanceOf(AnalysisCleanupLease);
|
|
return failure;
|
|
};
|
|
|
|
afterEach(async () => {
|
|
if (injected.fileHandle !== undefined) {
|
|
injected.restoreFileHandle?.();
|
|
if (injected.fileHandle.fd >= 0 && injected.nativeFileClose !== undefined) {
|
|
try {
|
|
await injected.nativeFileClose();
|
|
} catch (cause: unknown) {
|
|
if (
|
|
injected.fileHandle.fd >= 0 ||
|
|
!(cause instanceof Error && "code" in cause && cause.code === "EBADF")
|
|
)
|
|
throw cause;
|
|
}
|
|
}
|
|
}
|
|
injected.fileHandle = undefined;
|
|
injected.restoreFileHandle = undefined;
|
|
injected.nativeFileClose = undefined;
|
|
injected.nativeFileStat = undefined;
|
|
injected.readlinkPath = "";
|
|
injected.beforeReadlink = undefined;
|
|
injected.afterReadlink = undefined;
|
|
injected.afterReadlinkFailure = undefined;
|
|
injected.filePath = "";
|
|
injected.fileCloseFailure = undefined;
|
|
injected.fileCloseRejectionEnabled = false;
|
|
injected.fileCloseRejection = undefined;
|
|
injected.fileStatFailure = undefined;
|
|
injected.openedPaths = [];
|
|
injected.abortOnClose = undefined;
|
|
injected.abortOnStat = undefined;
|
|
injected.directoryPath = "";
|
|
injected.directoryCloseFailure = undefined;
|
|
injected.directory = undefined;
|
|
const closeDirectory = injected.nativeDirectoryClose;
|
|
injected.nativeDirectoryClose = undefined;
|
|
if (closeDirectory !== undefined) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
try {
|
|
closeDirectory((error) => {
|
|
if (
|
|
error !== undefined &&
|
|
error !== null &&
|
|
error.code !== "ERR_DIR_CLOSED"
|
|
) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
} catch (cause: unknown) {
|
|
reject(cause);
|
|
}
|
|
});
|
|
}
|
|
await Promise.all(
|
|
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
it("retains a completed file hash and its exact descriptor owner when close fails", async () => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
const bytes = "retained file bytes";
|
|
await writeFile(path, bytes);
|
|
injected.filePath = path;
|
|
injected.fileCloseFailure = new Error("injected file close denial");
|
|
|
|
let cause: unknown;
|
|
try {
|
|
await snapshotRoots(scenarioFor(path));
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).not.toBe(injected.fileCloseFailure);
|
|
const failure = expectSnapshotFailure(cause);
|
|
expect(failure.cause).toBe(injected.fileCloseFailure);
|
|
expect(failure.snapshot?.files).toEqual([
|
|
expect.objectContaining({
|
|
path: "root_0:.",
|
|
type: "file",
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
}),
|
|
]);
|
|
const [lease] = failure.cleanupLeases;
|
|
expect(lease).toBeDefined();
|
|
if (lease === undefined) throw new Error("file cleanup lease missing");
|
|
await expect(injected.nativeFileStat?.()).resolves.toMatchObject({
|
|
isFile: expect.any(Function),
|
|
});
|
|
injected.fileCloseFailure = undefined;
|
|
await lease.release();
|
|
expect(lease.released).toBe(true);
|
|
await expect(injected.nativeFileStat?.()).rejects.toMatchObject({
|
|
code: "EBADF",
|
|
});
|
|
});
|
|
|
|
it("exposes a completed direct hash outcome while retaining its failed descriptor close", async () => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
const bytes = "direct hash outcome bytes";
|
|
await writeFile(path, bytes);
|
|
injected.filePath = path;
|
|
injected.fileCloseFailure = new Error("injected direct hash close denial");
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await hashFile(path, await stat(path), 1024, undefined, scope);
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).toBeInstanceOf(ProcessCaptureReadFailure);
|
|
const failure = cause as ProcessCaptureReadFailure<{
|
|
readonly state: "hashed";
|
|
readonly sha256: string;
|
|
}>;
|
|
expect(failure.outcome).toEqual({
|
|
kind: "completed",
|
|
value: {
|
|
state: "hashed",
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
},
|
|
});
|
|
expect(failure.cleanupCause).toBe(injected.fileCloseFailure);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
await expect(scope.retryCleanup()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
injected.fileCloseFailure = undefined;
|
|
await scope.retryCleanup();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await expect(injected.nativeFileStat?.()).rejects.toMatchObject({
|
|
code: "EBADF",
|
|
});
|
|
await scope.close();
|
|
});
|
|
|
|
it("preserves a primary file stat failure when descriptor close also fails", async () => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
await writeFile(path, "retained file bytes");
|
|
const primary = new Error("injected file stat failure");
|
|
injected.filePath = path;
|
|
injected.fileStatFailure = primary;
|
|
injected.fileCloseFailure = new Error("injected file close denial");
|
|
|
|
let cause: unknown;
|
|
try {
|
|
await snapshotRoots(scenarioFor(path));
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).not.toBe(injected.fileCloseFailure);
|
|
const failure = expectSnapshotFailure(cause);
|
|
expect(failure.cause).toBe(primary);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
expect(failure.snapshot).toMatchObject({
|
|
files: [],
|
|
truncated: true,
|
|
completeRoots: [],
|
|
coverage: { enumeration_truncated: true },
|
|
});
|
|
const [lease] = failure.cleanupLeases;
|
|
expect(lease).toBeDefined();
|
|
if (lease === undefined) throw new Error("file cleanup lease missing");
|
|
injected.fileCloseFailure = undefined;
|
|
await lease.release();
|
|
expect(lease.released).toBe(true);
|
|
await expect(injected.nativeFileStat?.()).rejects.toMatchObject({
|
|
code: "EBADF",
|
|
});
|
|
});
|
|
|
|
it("keeps the physical file error primary when cancellation arrives during failed close", async () => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
await writeFile(path, "primary file and close cancellation");
|
|
const primary = new Error("injected physical file stat failure");
|
|
const reason = new Error("cancel during final file close");
|
|
injected.filePath = path;
|
|
injected.fileStatFailure = primary;
|
|
injected.fileCloseFailure = new Error("injected file close denial");
|
|
const controller = new AbortController();
|
|
injected.abortOnClose = () => controller.abort(reason);
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await hashFile(path, await stat(path), 1024, controller.signal, scope);
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).toBeInstanceOf(ProcessCaptureReadFailure);
|
|
const failure = cause as ProcessCaptureReadFailure<unknown>;
|
|
expect(failure.cause).toBe(primary);
|
|
expect(failure.outcome).toMatchObject({ kind: "failed", cause: primary });
|
|
expect(failure.cleanupCause).toBe(injected.fileCloseFailure);
|
|
expect(failure.userCategory).toBeUndefined();
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
await expect(scope.retryCleanup()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
expect(failure.cause).toBe(primary);
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
|
|
injected.fileCloseFailure = undefined;
|
|
injected.abortOnClose = undefined;
|
|
await scope.retryCleanup();
|
|
expect(failure.cause).toBe(primary);
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await scope.close();
|
|
});
|
|
|
|
it("keeps a completed file hash in the partial snapshot when cancellation arrives during close", async () => {
|
|
const root = await trackedRoot();
|
|
const first = join(root, "a-first");
|
|
const second = join(root, "b-not-visited");
|
|
const firstBytes = "completed before final-close cancellation";
|
|
await writeFile(first, firstBytes);
|
|
await writeFile(second, "not visited after cancellation");
|
|
injected.filePath = first;
|
|
injected.fileCloseFailure = new Error(
|
|
"injected close failure after cancellation",
|
|
);
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel during snapshot file close");
|
|
injected.abortOnClose = () => controller.abort(reason);
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await scope.run(() =>
|
|
snapshotRoots(scenarioFor(root), controller.signal, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
);
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
|
|
const failure = cause as ProcessCaptureSnapshotFailure;
|
|
expect(failure.cause).toBe(reason);
|
|
expect(failure.userCategory).toBe("cancelled");
|
|
expect(failure.snapshot).toMatchObject({
|
|
truncated: true,
|
|
completeRoots: [],
|
|
coverage: { enumeration_truncated: true },
|
|
});
|
|
expect(failure.snapshot?.files).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "root_0:a-first",
|
|
sha256: createHash("sha256").update(firstBytes).digest("hex"),
|
|
}),
|
|
);
|
|
expect(failure.snapshot?.files).not.toContainEqual(
|
|
expect.objectContaining({ path: "root_0:b-not-visited" }),
|
|
);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
|
|
injected.fileCloseFailure = undefined;
|
|
injected.abortOnClose = undefined;
|
|
await scope.retryCleanup();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await scope.close();
|
|
});
|
|
|
|
it("keeps a completed file hash when cancellation arrives during confirmed close", async () => {
|
|
const root = await trackedRoot();
|
|
const first = join(root, "a-first");
|
|
const second = join(root, "b-not-visited");
|
|
const firstBytes = "completed before confirmed-close cancellation";
|
|
await writeFile(first, firstBytes);
|
|
await writeFile(second, "not visited after cancellation");
|
|
injected.filePath = first;
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel during confirmed snapshot file close");
|
|
injected.abortOnClose = () => controller.abort(reason);
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await scope.run(() =>
|
|
snapshotRoots(scenarioFor(root), controller.signal, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
);
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
|
|
const failure = cause as ProcessCaptureSnapshotFailure;
|
|
expect(failure.cause).toBe(reason);
|
|
expect(failure.userCategory).toBe("cancelled");
|
|
expect(failure.snapshot).toMatchObject({
|
|
truncated: true,
|
|
completeRoots: [],
|
|
coverage: { enumeration_truncated: true },
|
|
});
|
|
expect(failure.snapshot?.files).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "root_0:a-first",
|
|
sha256: createHash("sha256").update(firstBytes).digest("hex"),
|
|
}),
|
|
);
|
|
expect(failure.cleanupLeases).toEqual([]);
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await scope.close();
|
|
});
|
|
|
|
it.each([
|
|
{ label: "undefined", rejection: undefined },
|
|
{ label: "string", rejection: "injected non-Error close rejection" },
|
|
])(
|
|
"keeps cleanup incomplete for a $label descriptor close rejection",
|
|
async ({ rejection }) => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
await writeFile(path, "non-Error close rejection bytes");
|
|
injected.filePath = path;
|
|
injected.fileCloseRejectionEnabled = true;
|
|
injected.fileCloseRejection = rejection;
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await snapshotRoots(scenarioFor(path), undefined, undefined, {
|
|
cleanup: scope,
|
|
});
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
|
|
const failure = cause as ProcessCaptureSnapshotFailure;
|
|
expect(failure.readFailures[0]?.outcome.kind).toBe("completed");
|
|
expect(failure.readFailures[0]?.cleanupCause).toBe(rejection);
|
|
if (typeof rejection === "string")
|
|
expect(failure.message).toContain(rejection);
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
await expect(scope.retryCleanup()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
|
|
injected.fileCloseRejectionEnabled = false;
|
|
await scope.retryCleanup();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await scope.close();
|
|
},
|
|
);
|
|
|
|
it("retains the full directory snapshot and exact directory owner when close fails", async () => {
|
|
const root = await trackedRoot();
|
|
const child = join(root, "observed");
|
|
await mkdir(child);
|
|
await writeFile(join(child, "payload"), "complete observation");
|
|
injected.directoryPath = child;
|
|
injected.directoryCloseFailure = new Error("injected directory close denial");
|
|
const originalClose = Dir.prototype.close;
|
|
let ownedDirectory: Dir | undefined;
|
|
let nativeClose: ((callback: DirectoryCloseCallback) => void) | undefined;
|
|
function closeWithFailure(this: Dir): Promise<void>;
|
|
function closeWithFailure(this: Dir, callback: DirectoryCloseCallback): void;
|
|
function closeWithFailure(
|
|
this: Dir,
|
|
callback?: DirectoryCloseCallback,
|
|
): void | Promise<void> {
|
|
const boundClose = originalClose.bind(this);
|
|
if (this !== injected.directory) {
|
|
if (callback !== undefined) return boundClose(callback);
|
|
return new Promise<void>((resolve, reject) =>
|
|
boundClose((error) =>
|
|
error === undefined || error === null ? resolve() : reject(error),
|
|
),
|
|
);
|
|
}
|
|
ownedDirectory = this;
|
|
nativeClose ??= (done: DirectoryCloseCallback) =>
|
|
originalClose.call(this, done);
|
|
if (callback !== undefined) {
|
|
queueMicrotask(() => callback(injected.directoryCloseFailure));
|
|
return;
|
|
}
|
|
return Promise.reject(injected.directoryCloseFailure);
|
|
}
|
|
Dir.prototype.close = closeWithFailure;
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await scope.run(() =>
|
|
snapshotRoots(scenarioFor(root), undefined, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
);
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
} finally {
|
|
Dir.prototype.close = originalClose;
|
|
injected.directory = ownedDirectory;
|
|
injected.nativeDirectoryClose = nativeClose;
|
|
}
|
|
|
|
expect(cause).not.toBe(injected.directoryCloseFailure);
|
|
const failure = expectSnapshotFailure(cause);
|
|
expect(failure.cause).toBe(injected.directoryCloseFailure);
|
|
expect(failure.snapshot?.files).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ path: "root_0:observed" }),
|
|
expect.objectContaining({ path: "root_0:observed/payload" }),
|
|
]),
|
|
);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
await expect(
|
|
scope.run(async () => "unexpected replacement capture"),
|
|
).rejects.toMatchObject({ cleanupIncomplete: true });
|
|
await expect(scope.close()).rejects.toMatchObject({
|
|
cleanupIncomplete: true,
|
|
});
|
|
expect(failure.cleanupIncomplete).toBe(true);
|
|
expect(failure.cleanupLeases[0]?.released).toBe(false);
|
|
expect(ownedDirectory).toBeDefined();
|
|
});
|
|
|
|
it("preserves a primary directory read failure when final close also fails", async () => {
|
|
const root = await trackedRoot();
|
|
const selected = join(root, "selected");
|
|
await mkdir(selected);
|
|
await writeFile(
|
|
join(selected, "payload"),
|
|
"never visited after read failure",
|
|
);
|
|
injected.directoryPath = selected;
|
|
injected.directoryCloseFailure = new Error("injected directory close denial");
|
|
const primary = new Error("injected directory read failure");
|
|
const originalRead = Dir.prototype.read;
|
|
const originalClose = Dir.prototype.close;
|
|
let ownedDirectory: Dir | undefined;
|
|
let nativeClose: ((callback: DirectoryCloseCallback) => void) | undefined;
|
|
function failRead(this: Dir): Promise<Dirent | null>;
|
|
function failRead(this: Dir, callback: DirectoryReadCallback): void;
|
|
function failRead(
|
|
this: Dir,
|
|
callback?: DirectoryReadCallback,
|
|
): void | Promise<Dirent | null> {
|
|
if (this !== injected.directory) {
|
|
if (callback !== undefined) return originalRead.call(this, callback);
|
|
return new Promise<Dirent | null>((resolve, reject) =>
|
|
originalRead.call(this, (error, value) =>
|
|
error === null ? resolve(value) : reject(error),
|
|
),
|
|
);
|
|
}
|
|
if (callback !== undefined) {
|
|
queueMicrotask(() => callback(primary as NodeJS.ErrnoException, null));
|
|
return;
|
|
}
|
|
return Promise.reject(primary);
|
|
}
|
|
function closeWithFailure(this: Dir): Promise<void>;
|
|
function closeWithFailure(this: Dir, callback: DirectoryCloseCallback): void;
|
|
function closeWithFailure(
|
|
this: Dir,
|
|
callback?: DirectoryCloseCallback,
|
|
): void | Promise<void> {
|
|
const boundClose = originalClose.bind(this);
|
|
if (this !== injected.directory) {
|
|
if (callback !== undefined) return boundClose(callback);
|
|
return new Promise<void>((resolve, reject) =>
|
|
boundClose((error) =>
|
|
error === undefined || error === null ? resolve() : reject(error),
|
|
),
|
|
);
|
|
}
|
|
ownedDirectory = this;
|
|
nativeClose ??= (done: DirectoryCloseCallback) =>
|
|
originalClose.call(this, done);
|
|
if (callback !== undefined) {
|
|
queueMicrotask(() => callback(injected.directoryCloseFailure));
|
|
return;
|
|
}
|
|
return Promise.reject(injected.directoryCloseFailure);
|
|
}
|
|
Dir.prototype.read = failRead;
|
|
Dir.prototype.close = closeWithFailure;
|
|
let cause: unknown;
|
|
try {
|
|
await snapshotRoots(scenarioFor(selected));
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
} finally {
|
|
Dir.prototype.read = originalRead;
|
|
Dir.prototype.close = originalClose;
|
|
injected.directory = ownedDirectory;
|
|
injected.nativeDirectoryClose = nativeClose;
|
|
}
|
|
|
|
expect(cause).not.toBe(injected.directoryCloseFailure);
|
|
const failure = expectSnapshotFailure(cause);
|
|
expect(failure.cause).toBe(primary);
|
|
expect(failure.snapshot).toMatchObject({
|
|
files: [expect.objectContaining({ path: "root_0:.", type: "directory" })],
|
|
truncated: true,
|
|
completeRoots: [],
|
|
coverage: { enumeration_truncated: true },
|
|
});
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps a later file hash, blocks scope admission on the first file owner, then resumes after retry", async () => {
|
|
const root = await trackedRoot();
|
|
const first = join(root, "a-first");
|
|
const second = join(root, "b-second");
|
|
const firstBytes = "first completed hash";
|
|
const secondBytes = "second completed hash";
|
|
await writeFile(first, firstBytes);
|
|
await writeFile(second, secondBytes);
|
|
injected.filePath = first;
|
|
injected.fileCloseFailure = new Error("injected first file close denial");
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let cause: unknown;
|
|
try {
|
|
await scope.run(() =>
|
|
snapshotRoots(scenarioFor(root), undefined, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
);
|
|
} catch (error: unknown) {
|
|
cause = error;
|
|
}
|
|
|
|
expect(cause).not.toBe(injected.fileCloseFailure);
|
|
const failure = expectSnapshotFailure(cause);
|
|
expect(failure.snapshot?.files).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: "root_0:a-first",
|
|
sha256: createHash("sha256").update(firstBytes).digest("hex"),
|
|
}),
|
|
expect.objectContaining({
|
|
path: "root_0:b-second",
|
|
sha256: createHash("sha256").update(secondBytes).digest("hex"),
|
|
}),
|
|
]),
|
|
);
|
|
expect(failure.cleanupLeases).toHaveLength(1);
|
|
expect(injected.openedPaths).toEqual([first, second]);
|
|
|
|
await expect(
|
|
scope.run(() =>
|
|
snapshotRoots(scenarioFor(root), undefined, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
),
|
|
).rejects.toMatchObject({ cleanupIncomplete: true });
|
|
expect(injected.openedPaths).toEqual([first, second]);
|
|
|
|
injected.fileCloseFailure = undefined;
|
|
await scope.retryCleanup();
|
|
expect(failure.cleanupIncomplete).toBe(false);
|
|
await expect(
|
|
scope.run(() =>
|
|
snapshotRoots(scenarioFor(root), undefined, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
),
|
|
).resolves.toMatchObject({ files: expect.any(Array) });
|
|
expect(injected.openedPaths).toEqual([first, second, first, second]);
|
|
await scope.close();
|
|
});
|
|
|
|
it("preserves an IO failure that arrives with cancellation before descriptor cleanup", async () => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
await writeFile(path, "failed metadata with late cancellation");
|
|
const primary = new Error("physical metadata failure");
|
|
const reason = new Error("cancelled while metadata rejected");
|
|
const controller = new AbortController();
|
|
injected.filePath = path;
|
|
injected.fileStatFailure = primary;
|
|
injected.abortOnStat = () => controller.abort(reason);
|
|
let failure: unknown;
|
|
try {
|
|
await hashFile(path, await stat(path), 1024, controller.signal);
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(ProcessCaptureReadFailure);
|
|
expect(failure).toMatchObject({
|
|
cause: primary,
|
|
outcome: { kind: "failed", cause: primary },
|
|
cleanupIncomplete: false,
|
|
});
|
|
expect((failure as ProcessCaptureReadFailure).userCategory).toBeUndefined();
|
|
expect((failure as ProcessCaptureReadFailure).cancellation?.reason).toBe(
|
|
reason,
|
|
);
|
|
expect(injected.fileHandle?.fd).toBe(-1);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves complete root-file coverage after close-time cancellation (close denied: %s)",
|
|
async (denied) => {
|
|
const root = await trackedRoot();
|
|
const path = join(root, "input");
|
|
const bytes = "all selected snapshot facts completed";
|
|
await writeFile(path, bytes);
|
|
injected.filePath = path;
|
|
injected.fileCloseFailure = denied
|
|
? new Error("close denied after full root read")
|
|
: undefined;
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancelled during final root-file close");
|
|
injected.abortOnClose = () => controller.abort(reason);
|
|
const scope = new ProcessCaptureResourceScope();
|
|
let failure: unknown;
|
|
try {
|
|
await scope.run(() =>
|
|
snapshotRoots(scenarioFor(path), controller.signal, undefined, {
|
|
cleanup: scope,
|
|
}),
|
|
);
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(ProcessCaptureSnapshotFailure);
|
|
expect(failure).toMatchObject({
|
|
cause: reason,
|
|
userCategory: "cancelled",
|
|
cleanupIncomplete: denied,
|
|
snapshot: {
|
|
truncated: false,
|
|
completeRoots: ["root_0"],
|
|
coverage: { enumeration_truncated: false },
|
|
files: [
|
|
expect.objectContaining({
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
injected.abortOnClose = undefined;
|
|
injected.fileCloseFailure = undefined;
|
|
await scope.close();
|
|
expect(injected.fileHandle?.fd).toBe(-1);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32").for([
|
|
{ outcome: "readlink EINVAL", code: "EINVAL", abort: false },
|
|
{
|
|
outcome: "readlink EINVAL with later cancellation",
|
|
code: "EINVAL",
|
|
abort: true,
|
|
},
|
|
{ outcome: "readlink EACCES", code: "EACCES", abort: false },
|
|
{
|
|
outcome: "readlink EACCES with later cancellation",
|
|
code: "EACCES",
|
|
abort: true,
|
|
},
|
|
{
|
|
outcome: "successful readlink with an EACCES cancellation reason",
|
|
code: null,
|
|
abort: true,
|
|
},
|
|
] as const)(
|
|
"retains symlink observation provenance through $outcome and observer cleanup",
|
|
async ({ code, abort }, context) => {
|
|
if (code === "EACCES")
|
|
await requirePermissionDenial(context, "directory-search");
|
|
const fixture = await trackedRoot();
|
|
const selected = join(fixture, "selected");
|
|
await mkdir(selected, { mode: 0o700 });
|
|
const known = join(selected, "a-known");
|
|
const interrupted = join(selected, "b-interrupted");
|
|
await symlink(Buffer.from([0x61, 0xff]), known);
|
|
await symlink("original-target", interrupted);
|
|
const scenario = scenarioFor(selected);
|
|
const before = await snapshotRoots(scenario);
|
|
const controller = new AbortController();
|
|
const reason = Object.assign(
|
|
new Error("caller stopped symlink observation"),
|
|
{
|
|
code: "EACCES",
|
|
},
|
|
);
|
|
const genuineCancellation = code === null;
|
|
let nativeFailure: unknown;
|
|
injected.readlinkPath = interrupted;
|
|
injected.beforeReadlink = async () => {
|
|
if (genuineCancellation) {
|
|
controller.abort(reason);
|
|
} else if (code === "EACCES") {
|
|
// Enumeration and leaf metadata succeeded before search permission is removed.
|
|
await chmod(selected, 0o444);
|
|
} else {
|
|
// Produce EINVAL after symlink metadata was read.
|
|
await rm(interrupted);
|
|
await writeFile(interrupted, "replacement regular file");
|
|
}
|
|
};
|
|
injected.afterReadlink = async () => {
|
|
if (code === "EACCES") await chmod(selected, 0o700);
|
|
};
|
|
injected.afterReadlinkFailure = (cause) => {
|
|
nativeFailure = cause;
|
|
if (abort) controller.abort(reason);
|
|
};
|
|
let temporaryRoot: string | undefined;
|
|
const removed: string[] = [];
|
|
const cleanup = async (path: string) => {
|
|
await rm(path, { recursive: true, force: true });
|
|
removed.push(path);
|
|
};
|
|
const result = await prepareProcessCapture(
|
|
scenario,
|
|
controller.signal,
|
|
snapshotRoots,
|
|
{
|
|
createTemporaryRoot: async () => {
|
|
temporaryRoot = await mkdtemp(join(fixture, "observer-"));
|
|
return temporaryRoot;
|
|
},
|
|
cleanup,
|
|
},
|
|
createTestProcessCaptureResourceScope(),
|
|
).then(
|
|
(value) => ({ ok: true as const, value }),
|
|
(error: unknown) => ({ ok: false as const, error }),
|
|
);
|
|
try {
|
|
let after: typeof before;
|
|
if (abort) {
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected cancelled preparation");
|
|
expect(result.error).toBeInstanceOf(ProcessCaptureError);
|
|
if (!(result.error instanceof ProcessCaptureError)) throw result.error;
|
|
expect(result.error.cause).toBe(reason);
|
|
expect(result.error.userCategory).toBe("cancelled");
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
const partial = result.error.partialObservation;
|
|
if (partial === undefined || !("observations" in partial))
|
|
throw new Error("Expected an incomplete process capture observation");
|
|
const snapshot = partial.observations.filesystem_snapshots.before;
|
|
expect(snapshot?.state).toBe("available");
|
|
if (snapshot?.state !== "available")
|
|
throw new Error("Expected retained partial filesystem snapshot");
|
|
after = snapshot.value;
|
|
expect(removed).toEqual([temporaryRoot]);
|
|
if (temporaryRoot === undefined)
|
|
throw new Error("Observer root was not allocated");
|
|
await expect(stat(temporaryRoot)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
} else {
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
after = result.value.before;
|
|
}
|
|
if (genuineCancellation) expect(nativeFailure).toBeUndefined();
|
|
else
|
|
expect(nativeFailure).toMatchObject({
|
|
code,
|
|
syscall: "readlink",
|
|
path: interrupted,
|
|
});
|
|
expect(after.files).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "root_0:a-known",
|
|
type: "symlink",
|
|
symlink_target: null,
|
|
symlink_target_hex: "61ff",
|
|
}),
|
|
);
|
|
expect(after.files).not.toContainEqual(
|
|
expect.objectContaining({ path: "root_0:b-interrupted" }),
|
|
);
|
|
expect(after.completeRoots).toEqual([]);
|
|
expect(after.coverage.enumeration_truncated).toBe(true);
|
|
expect(after.coverage.enumeration_omissions).toContainEqual({
|
|
path: "root_0:a-known",
|
|
reason: "symlink_not_followed",
|
|
system_code: null,
|
|
});
|
|
const leafOmissions = after.coverage.enumeration_omissions.filter(
|
|
({ path }) => path === "root_0:b-interrupted",
|
|
);
|
|
expect(leafOmissions).toEqual(
|
|
genuineCancellation
|
|
? []
|
|
: [
|
|
{
|
|
path: "root_0:b-interrupted",
|
|
reason: "entry_unavailable",
|
|
system_code: code,
|
|
},
|
|
],
|
|
);
|
|
expect(
|
|
classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === "root_0:b-interrupted",
|
|
),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
before: { type: "symlink" },
|
|
after: null,
|
|
});
|
|
} finally {
|
|
if (result.ok) await cleanup(result.value.temporaryRoot);
|
|
}
|
|
expect(removed).toEqual([temporaryRoot]);
|
|
},
|
|
);
|