fix: preserve process filesystem scope and admission failures

This commit is contained in:
morluto
2026-10-24 02:49:00 +08:00
parent 5ee5e49cb4
commit 2f6e0506f0
10 changed files with 432 additions and 51 deletions
+9
View File
@@ -250,6 +250,15 @@ follow also remain unknown. Partial enumeration of one root does not erase
known effects in another, and unavailable content hashes do not by themselves
make path absence unknown.
Filesystem coverage records `unobserved_subtrees` separately from metadata
enumeration. `symlink_not_followed` identifies a link whose own metadata was
observed while its descendants were outside the snapshot. `observer_owned`
identifies the exact directory allocated for this capture's bookkeeping; it
is created before the baseline and excluded from both snapshots and file-entry
capacity. Target activity inside that excluded directory remains unobserved.
Matching filenames elsewhere are included normally. Historical captures that
omit subtree facts do not establish descendant absence under that scope.
When a follow-up read fails after an entry's metadata was observed, the
snapshot keeps unaffected observations and records an `entry_unavailable`
enumeration omission with the root-aliased path and system error code.
@@ -37,6 +37,14 @@ export const filesystemCoverageSchema = z.strictObject({
system_code: z.string().nullable(),
}),
),
unobserved_subtrees: z
.array(
z.strictObject({
path: z.string(),
reason: z.enum(["symlink_not_followed", "observer_owned"]),
}),
)
.optional(),
hash_budget_bytes: z.int().nonnegative(),
hashed_bytes: z.int().nonnegative(),
hash_omissions: z.array(
+118 -3
View File
@@ -1,6 +1,7 @@
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
symlink,
@@ -12,6 +13,9 @@ import { parseProcessScenario } from "../../domain/process/processScenario.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { snapshotRoots } from "./FilesystemSnapshot.js";
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
import { prepareProcessCapture } from "./ProcessCaptureLifecycle.js";
import { ProcessCaptureSnapshotFailure } from "./FilesystemSnapshot.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
it.each(["after", "before"] as const)(
"keeps absence unknown when the %s snapshot omits an existing file",
@@ -411,9 +415,17 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
await expect(
snapshotRoots(scenarioFor(join(root, "file", "child"))),
).rejects.toMatchObject({ message: expect.stringContaining("ENOTDIR") });
await expect(
snapshotRoots(scenarioFor(join(root, "denied", "child"))),
).rejects.toMatchObject({ message: expect.stringContaining("EACCES") });
const deniedFailure = await snapshotRoots(
scenarioFor(join(root, "denied", "child")),
).catch((cause: unknown) => cause);
if (!(deniedFailure instanceof ProcessCaptureSnapshotFailure))
throw deniedFailure;
expect(
projectAnalysisError(deniedFailure.failureForCaller()),
).toMatchObject({
code: "access_denied",
details: { path: join(root, "denied", "child"), system_code: "EACCES" },
});
// The same lookups after admission are absence and an omission.
const admission = await snapshotRoots(scenarioFor(join(root, "missing")));
expect(admission.completeRoots).toEqual(["root_0"]);
@@ -506,3 +518,106 @@ it.skipIf(process.platform === "win32")(
]);
},
);
it.skipIf(process.platform === "win32")(
"owns symlink subtree uncertainty independently of complete metadata enumeration",
async () => {
const root = await createTestTempDirectory("rea-symlink-subtree-");
const target = join(root, "target");
const link = join(root, "link");
await mkdir(target);
await symlink(target, link);
const scenario = parseProcessScenario({
executable: process.execPath,
filesystem_observation_paths: [link],
});
const before = await snapshotRoots(scenario);
await writeFile(join(target, "created.txt"), "unobserved change");
const after = await snapshotRoots(scenario);
expect(before.files).toEqual(after.files);
expect(after.coverage.enumeration_truncated).toBe(false);
expect(after.coverage.unobserved_subtrees).toEqual([
{ path: "root_0:.", reason: "symlink_not_followed" },
]);
const followed = await snapshotRoots({
...scenario,
filesystem_observation_paths: [target],
});
expect(followed.coverage.unobserved_subtrees).toEqual([]);
expect(
followed.files.some(({ path }) => path === "root_0:created.txt"),
).toBe(true);
},
);
it("allocates the observer directory before baseline and excludes it before entry accounting", async () => {
const root = await createTestTempDirectory("rea-observer-filesystem-");
const scenario = parseProcessScenario({
executable: process.execPath,
filesystem_observation_paths: [root],
limits: { files: 1 },
});
const prepared = await prepareProcessCapture(
scenario,
undefined,
snapshotRoots,
{
createTemporaryRoot: () => mkdtemp(join(root, "rea-process-")),
cleanup: (path) => rm(path, { recursive: true, force: true }),
},
);
try {
const after = await snapshotRoots(scenario, undefined, prepared.before, {
observerPaths: [prepared.temporaryRoot],
});
expect(after.files).toEqual(prepared.before.files);
expect(after.files).toHaveLength(1);
expect(after.truncated).toBe(false);
expect(after.coverage.enumeration_reasons).toEqual([]);
expect(after.coverage.unobserved_subtrees).toMatchObject([
{ reason: "observer_owned" },
]);
expect(
classifyFilesystemEffects(prepared.before, after).map(
({ status }) => status,
),
).toEqual(["unchanged"]);
} finally {
await rm(prepared.temporaryRoot, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")(
"excludes the exact observer path through parent aliases while retaining same-name target directories",
async () => {
const fixture = await createTestTempDirectory("rea-observer-alias-");
const physicalParent = join(fixture, "physical");
const root = join(physicalParent, "root");
const alias = join(fixture, "alias");
const owner = join(root, "observer-run");
await mkdir(owner, { recursive: true });
await mkdir(join(root, "other", "observer-run"), { recursive: true });
await writeFile(
join(root, "other", "observer-run", "target.txt"),
"target",
);
await symlink(physicalParent, alias);
const scenario = parseProcessScenario({
executable: process.execPath,
filesystem_observation_paths: [join(alias, "root")],
});
const snapshot = await snapshotRoots(scenario, undefined, undefined, {
observerPaths: [owner],
});
expect(snapshot.coverage.unobserved_subtrees).toEqual([
{ path: "root_0:observer-run", reason: "observer_owned" },
]);
expect(snapshot.files.map(({ path }) => path)).toEqual([
"root_0:.",
"root_0:other",
"root_0:other/observer-run",
"root_0:other/observer-run/target.txt",
]);
expect(snapshot.coverage.enumeration_truncated).toBe(false);
},
);
+77 -4
View File
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { lstat, open, opendir, readlink } from "node:fs/promises";
import { join, relative } from "node:path";
import { lstat, open, opendir, readlink, realpath } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import type { FileHandle } from "node:fs/promises";
import type { ProcessScenario } from "../../domain/process/processScenario.js";
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
@@ -26,6 +26,9 @@ import {
withProcessCaptureFile,
type ProcessCaptureCleanupRetainer,
} from "./ProcessCaptureRead.js";
import { AnalysisAccessDeniedError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
const hasSameIdentity = (
before: Stats,
@@ -118,6 +121,21 @@ type PathLookup =
readonly cause: unknown;
};
const rootAdmissionFailure = (
path: string,
lookup: Exclude<PathLookup, { readonly state: "present" }>,
): unknown =>
lookup.system_code === "EACCES" || lookup.system_code === "EPERM"
? new AnalysisAccessDeniedError(
"capture_process",
path,
lookup.system_code,
{
cause: lookup.cause,
},
)
: lookup.cause;
/**
* Look up one path without following its final component. ENOENT and ENOTDIR
* both prove that no entry exists at the path; other unavailable lookups leave
@@ -162,6 +180,7 @@ export class ProcessCaptureSnapshotFailure extends ProcessCaptureError {
readonly snapshot: ProcessFilesystemSnapshot | undefined;
readonly cleanupLeases: readonly AnalysisCleanupLease[];
readonly readFailures: readonly ProcessCaptureReadFailure<unknown>[];
readonly primaryFailure: AnalysisAccessDeniedError | undefined;
constructor(options: {
readonly cause: unknown;
@@ -230,9 +249,25 @@ export class ProcessCaptureSnapshotFailure extends ProcessCaptureError {
this.snapshot = options.snapshot;
this.cleanupLeases = cleanupLeases;
this.readFailures = readFailures;
this.primaryFailure =
options.cause instanceof AnalysisAccessDeniedError
? options.cause
: undefined;
if (cleanupLeases.length > 0)
new AnalysisCleanupLeaseGroup(this, cleanupLeases);
}
/** The snapshot owns observations; the admitted read owns its failure identity. */
failureForCaller(): AnalysisError {
if (this.primaryFailure === undefined) return this;
return this.cleanupIncomplete
? analysisErrorWithCleanupFailure(
this.primaryFailure,
this,
"capture_process",
).bindCleanupDependencies([this.primaryFailure, this])
: this.primaryFailure;
}
}
/** Hash a file only when its opened descriptor still matches the captured path state. */
@@ -341,6 +376,11 @@ export const hashFile = async (
);
};
interface SnapshotResources {
readonly cleanup?: ProcessCaptureCleanupRetainer;
readonly observerPaths?: readonly string[];
}
/**
* Capture bounded, root-aliased filesystem state without following symlinks.
*
@@ -353,8 +393,10 @@ export const snapshotRoots = async (
scenario: ProcessScenario,
signal?: AbortSignal,
admission?: ProcessFilesystemSnapshot,
retainer?: ProcessCaptureCleanupRetainer,
resources: SnapshotResources = {},
): Promise<ProcessFilesystemSnapshot> => {
const retainer = resources.cleanup;
const observerPaths = resources.observerPaths ?? [];
const entries: FileState[] = [];
const completeRoots: string[] = [];
let remainingBytes = scenario.limits.file_bytes;
@@ -366,6 +408,29 @@ export const snapshotRoots = async (
const enumerationOmissions: FilesystemCoverage["enumeration_omissions"] = [];
const hashOmissions: FilesystemCoverage["hash_omissions"] = [];
const closeFailures: ProcessCaptureReadFailure<unknown>[] = [];
const unobservedSubtrees: NonNullable<
FilesystemCoverage["unobserved_subtrees"]
> = [];
const observerRoots = new Set(
await Promise.all(observerPaths.map((path) => realpath(path))),
);
const observerNames = new Set(
[...observerRoots].map((path) => basename(path)),
);
const omitObserverPath = async (
root: string,
alias: string,
path: string,
): Promise<boolean> => {
if (!observerNames.has(basename(path))) return false;
const physical = join(await realpath(dirname(path)), basename(path));
if (!observerRoots.has(physical)) return false;
unobservedSubtrees.push({
path: `${alias}:${relative(root, path) || "."}`,
reason: "observer_owned",
});
return true;
};
const omitEntry = (path: string, omission: EntryOmission): false => {
truncated = true;
enumerationReasons.add(omission.reason);
@@ -419,6 +484,7 @@ export const snapshotRoots = async (
admitted?: Stats,
): Promise<boolean> => {
signal?.throwIfAborted();
if (await omitObserverPath(root, rootAlias, path)) return true;
if (
entries.length >= scenario.limits.files ||
depth > scenario.limits.filesystem_depth
@@ -457,6 +523,10 @@ export const snapshotRoots = async (
sha256: null,
symlink_target: target.value,
});
unobservedSubtrees.push({
path: `${rootAlias}:${relativePath}`,
reason: "symlink_not_followed",
});
return true;
}
if (stats.isFile()) {
@@ -543,6 +613,8 @@ export const snapshotRoots = async (
signal?.throwIfAborted();
const child = await ownedDirectory.read();
if (child === null) break;
if (await omitObserverPath(root, rootAlias, join(path, child.name)))
continue;
if (
children.length >= remainingEntries ||
depth >= scenario.limits.filesystem_depth
@@ -608,6 +680,7 @@ export const snapshotRoots = async (
completeRoots.length !== scenario.filesystem_observation_paths.length,
enumeration_reasons: [...enumerationReasons],
enumeration_omissions: enumerationOmissions,
unobserved_subtrees: unobservedSubtrees,
hash_budget_bytes: scenario.limits.file_bytes,
hashed_bytes: scenario.limits.file_bytes - remainingBytes,
hash_omissions: hashOmissions,
@@ -623,7 +696,7 @@ export const snapshotRoots = async (
const lookup = await lookupPath(root, signal);
if (lookup.state !== "present") {
if (admission === undefined && lookup.system_code !== "ENOENT")
throw lookup.cause;
throw rootAdmissionFailure(root, lookup);
if (lookup.state === "absent") completeRoots.push(alias);
else
omitEntry(`${alias}:.`, {
+33 -3
View File
@@ -56,6 +56,9 @@ import {
} from "./ProcessNormalization.js";
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import {
hasCaptureTruncation,
type NormalizedTerminalRetention,
@@ -463,6 +466,11 @@ const captureCoverageUnknowns = (
["before", details.filesystem_before],
["after_settlement", details.filesystem_after],
] as const) {
for (const subtree of coverage.unobserved_subtrees ?? [])
unknowns.push({
scope: "filesystem",
reason: `Filesystem ${name} subtree ${subtree.path} was outside observation: ${subtree.reason === "symlink_not_followed" ? "symbolic links are not followed" : "the exact observer-owned directory is excluded, including target activity within it"}.`,
});
if (coverage.enumeration_truncated)
unknowns.push({
scope: "filesystem",
@@ -1571,6 +1579,15 @@ export const resolveProcessResult = (
cleanupReport: cleanup,
cleanupFailure: cleanupError,
});
if (executionFailure instanceof AnalysisError) {
if (partialObservation !== undefined)
executionFailure.retainPartialObservation(partialObservation);
throw analysisErrorWithCleanupFailure(
executionFailure,
cleanupError,
"capture_process",
).bindCleanupDependencies([executionFailure, cleanupError]);
}
throw cleanupError;
}
if (executionFailure instanceof ProcessCaptureError) {
@@ -1583,6 +1600,14 @@ export const resolveProcessResult = (
});
}
if (executionFailure !== undefined) {
if (executionFailure instanceof AnalysisError) {
if (partialObservation !== undefined)
executionFailure.retainPartialObservation(partialObservation);
executionFailure.retainDiagnostics({
cleanup_report: jsonValueSchema.parse(cleanup),
});
throw executionFailure;
}
throw new ProcessCaptureError("process capture failed", {
cause: executionFailure,
reason: "capture_failed",
@@ -1649,17 +1674,22 @@ export const prepareProcessCapture = async (
let temporaryRoot: string | undefined;
const runId = randomUUID();
try {
before = await captureSnapshot(scenario, signal, undefined, resourceScope);
temporaryRoot = await host.createTemporaryRoot();
before = await captureSnapshot(scenario, signal, undefined, {
...(resourceScope === undefined ? {} : { cleanup: resourceScope }),
observerPaths: [temporaryRoot],
});
assertNotCancelled(signal);
const ownershipBaseline =
(await host.captureOwnershipBaseline?.(signal)) ?? [];
assertNotCancelled(signal);
return { temporaryRoot, runId, ownershipBaseline, before };
} catch (cause: unknown) {
let failure = cause;
if (cause instanceof ProcessCaptureSnapshotFailure) {
before = cause.snapshot;
for (const lease of cause.cleanupLeases) resourceScope?.retain(lease);
failure = cause.failureForCaller();
}
const cleanupOptions =
temporaryRoot === undefined
@@ -1690,7 +1720,7 @@ export const prepareProcessCapture = async (
: await resourceScope.release(cleanupOptions);
resolveProcessResult(
undefined,
normalizeCaptureFailure(cause, signal),
normalizeCaptureFailure(failure, signal),
cleanup,
createProcessCaptureObservationBuffer({
frames: [],
@@ -1702,7 +1732,7 @@ export const prepareProcessCapture = async (
}),
{ scenario },
);
throw cause;
throw failure;
}
};
@@ -10,7 +10,10 @@ import { readLinuxChildren } from "./LinuxProcessSampling.js";
import { TerminalRenderer } from "./TerminalRenderer.js";
import { parseProcessScenario } from "../../domain/process/processScenario.js";
import type { ProcessCapture } from "../../domain/process/processCaptureParsing.js";
import type { ProcessCaptureTruncationDetails } from "../../domain/process/processCaptureCoverage.js";
import type {
FilesystemCoverage,
ProcessCaptureTruncationDetails,
} from "../../domain/process/processCaptureCoverage.js";
import { emptyProcessCapture as emptyCapture } from "../../domain/process/processCapture.fixture.js";
const base = {
@@ -304,3 +307,69 @@ it("cancels filesystem snapshots before traversing declared roots", async () =>
controller.signal.reason,
);
});
it.each(["symlink_not_followed", "observer_owned"] as const)(
"projects %s subtree coverage into Evidence limitations and residual unknowns",
(reason) => {
const capture = emptyCapture();
const build = (coverage: FilesystemCoverage) => {
const snapshot = {
files: [],
truncated: false,
completeRoots: ["root_0"],
coverage,
};
return buildCaptureResult({
frames: [],
exit: { exitCode: 0, reason: "exited" },
samples: [],
before: snapshot,
after: snapshot,
truncationDetails: {
...truncationDetails([], [], []),
filesystem_before: coverage,
filesystem_after: coverage,
},
scenario: parseProcessScenario({
...base,
filesystem_observation_paths: ["/selected"],
}),
rootPid: 1,
samplingPartial: false,
renderedFrames: [],
interactions: [],
checkpoints: capture.filesystem_checkpoints,
settlement: {
state: capture.settlement.state,
elapsed_ms: capture.settlement.elapsed_ms,
},
manifest: capture.manifest,
eventJournal: [],
});
};
const complete = { ...emptyFilesystemCoverage(), unobserved_subtrees: [] };
expect(
build(complete).residual_unknowns.filter(
({ scope }) => scope === "filesystem",
),
).toEqual([]);
const result = build({
...complete,
unobserved_subtrees: [{ path: "root_0:.", reason }],
});
const unknowns = result.residual_unknowns.filter(
({ scope }) => scope === "filesystem",
);
expect(unknowns).toHaveLength(2);
for (const unknown of unknowns) {
expect(unknown.reason).toContain("root_0:.");
expect(unknown.reason).toContain(
reason === "symlink_not_followed"
? "symbolic links are not followed"
: "exact observer-owned directory",
);
expect(result.limitations).toContain(unknown.reason);
}
expect(result.truncated).toBe(false);
},
);
+14 -28
View File
@@ -1,5 +1,4 @@
import type {
FileState,
FilesystemCheckpoint,
ProcessFilesystemSnapshot,
} from "../../domain/process/processCapture.js";
@@ -8,23 +7,22 @@ import { sep } from "node:path";
const unobservedAbsenceReason = (
path: string,
phase: "before" | "after",
completeRoots: ReadonlySet<string>,
filesByPath: ReadonlyMap<string, FileState>,
snapshot: ProcessFilesystemSnapshot,
): string | undefined => {
const alias = path.slice(0, path.indexOf(":"));
if (!completeRoots.has(alias))
if (!snapshot.completeRoots.includes(alias))
return `The ${phase} snapshot did not exhaust path enumeration under ${alias}; absence remains unknown.`;
const rootPath = `${alias}:.`;
if (path !== rootPath && filesByPath.get(rootPath)?.type === "symlink")
return `The ${phase} snapshot did not follow the symbolic link at ${rootPath}; descendant absence remains unknown.`;
for (
let index = path.lastIndexOf(sep);
index > 0;
index = path.lastIndexOf(sep, index - 1)
) {
const parent = path.slice(0, index);
if (filesByPath.get(parent)?.type === "symlink")
return `The ${phase} snapshot did not follow the symbolic link at ${parent}; descendant absence remains unknown.`;
if (path === rootPath) return undefined;
if (snapshot.coverage.unobserved_subtrees === undefined)
return `The ${phase} snapshot has no subtree coverage facts; descendant absence remains unknown.`;
for (const subtree of snapshot.coverage.unobserved_subtrees) {
const below =
subtree.path === rootPath
? path.startsWith(`${alias}:`)
: path.startsWith(`${subtree.path}${sep}`);
if (below || (subtree.reason === "observer_owned" && path === subtree.path))
return `The ${phase} snapshot did not observe the subtree at ${subtree.path} (${subtree.reason}); descendant absence remains unknown.`;
}
return undefined;
};
@@ -36,8 +34,6 @@ export const classifyFilesystemEffects = (
): FilesystemCheckpoint["effects"] => {
const beforeByPath = new Map(before.files.map((file) => [file.path, file]));
const afterByPath = new Map(after.files.map((file) => [file.path, file]));
const beforeCompleteRoots = new Set(before.completeRoots);
const afterCompleteRoots = new Set(after.completeRoots);
return [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
.sort()
.map((path) => {
@@ -45,19 +41,9 @@ export const classifyFilesystemEffects = (
const afterFile = afterByPath.get(path) ?? null;
const reason =
beforeFile === null
? unobservedAbsenceReason(
path,
"before",
beforeCompleteRoots,
beforeByPath,
)
? unobservedAbsenceReason(path, "before", before)
: afterFile === null
? unobservedAbsenceReason(
path,
"after",
afterCompleteRoots,
afterByPath,
)
? unobservedAbsenceReason(path, "after", after)
: undefined;
if (reason !== undefined)
return {
+9 -2
View File
@@ -544,7 +544,12 @@ const completeCapture = async (options: {
scenario,
options.signal,
options.before,
options.resourceScope,
{
...(options.resourceScope === undefined
? {}
: { cleanup: options.resourceScope }),
observerPaths: [options.temporaryRoot],
},
);
} catch (cause: unknown) {
if (cause instanceof ProcessCaptureSnapshotFailure)
@@ -561,7 +566,9 @@ const completeCapture = async (options: {
reason: unavailableReason("Final filesystem snapshot", cause),
},
};
throw cause;
throw cause instanceof ProcessCaptureSnapshotFailure
? cause.failureForCaller()
: cause;
}
options.observationBuffer.filesystem_snapshots = {
...options.observationBuffer.filesystem_snapshots,
@@ -1,6 +1,13 @@
import { expect, it, vi } from "vitest";
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { AnalysisAccessDeniedError } from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { ProcessCaptureResourceScope } from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import {
ProcessCaptureSnapshotFailure,
snapshotRoots,
} from "../../../src/process/capture/FilesystemSnapshot.js";
import { prepareProcessCapture } from "../../../src/process/capture/ProcessCaptureLifecycle.js";
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js";
import { emptyProcessCapture } from "../../../src/domain/process/processCapture.fixture.js";
@@ -69,3 +76,64 @@ it("cleans the temporary root when cancellation arrives during identity capture"
expect(host.captureOwnershipBaseline).toHaveBeenCalledWith(controller.signal);
expect(cleanup).toHaveBeenCalledWith(temporaryRoot);
});
it.each([false, true])(
"preserves denied snapshot admission through temporary-root cleanup failure=%s and retry",
async (denyCleanup) => {
const path = "/selected/denied/child";
const primary = new AnalysisAccessDeniedError(
"capture_process",
path,
"EACCES",
);
const snapshot = {
files: [],
truncated: false,
completeRoots: [],
coverage: emptyFilesystemCoverage,
};
const scope = new ProcessCaptureResourceScope();
let failing = denyCleanup;
const cleanup = vi.fn(async () => {
if (failing) throw new Error("controlled temporary-root removal denial");
});
const observed = await prepareProcessCapture(
parseProcessScenario({
executable: "/bin/true",
filesystem_observation_paths: [path],
}),
undefined,
async () => {
throw new ProcessCaptureSnapshotFailure({ cause: primary, snapshot });
},
{ createTemporaryRoot: async () => "/owned/temporary-root", cleanup },
scope,
).catch((cause: unknown) => cause);
if (!(observed instanceof AnalysisError)) throw observed;
try {
expect(projectAnalysisError(observed).code).toBe(
denyCleanup ? "cleanup_incomplete" : "access_denied",
);
expect(observed.partialObservation).toMatchObject({
observations: {
filesystem_snapshots: {
before: { state: "available", value: snapshot },
},
},
});
if (denyCleanup) {
failing = false;
await scope.retryCleanup();
}
expect(observed.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(observed)).toMatchObject({
code: "access_denied",
details: { path, system_code: "EACCES", boundary: "filesystem-read" },
});
expect(cleanup).toHaveBeenCalledTimes(denyCleanup ? 2 : 1);
} finally {
failing = false;
await scope.close();
}
},
);
@@ -228,7 +228,7 @@ it("exposes a completed direct hash outcome while retaining its failed descripto
const scope = new ProcessCaptureResourceScope();
let cause: unknown;
try {
await hashFile(path, await stat(path), 1024, undefined, scope);
await hashFile(path, await stat(path), 1024, undefined, { cleanup: scope });
} catch (error: unknown) {
cause = error;
}
@@ -355,7 +355,9 @@ it("keeps a completed file hash in the partial snapshot when cancellation arrive
let cause: unknown;
try {
await scope.run(() =>
snapshotRoots(scenarioFor(root), controller.signal, undefined, scope),
snapshotRoots(scenarioFor(root), controller.signal, undefined, {
cleanup: scope,
}),
);
} catch (error: unknown) {
cause = error;
@@ -404,7 +406,9 @@ it("keeps a completed file hash when cancellation arrives during confirmed close
let cause: unknown;
try {
await scope.run(() =>
snapshotRoots(scenarioFor(root), controller.signal, undefined, scope),
snapshotRoots(scenarioFor(root), controller.signal, undefined, {
cleanup: scope,
}),
);
} catch (error: unknown) {
cause = error;
@@ -445,7 +449,9 @@ it.each([
const scope = new ProcessCaptureResourceScope();
let cause: unknown;
try {
await snapshotRoots(scenarioFor(path), undefined, undefined, scope);
await snapshotRoots(scenarioFor(path), undefined, undefined, {
cleanup: scope,
});
} catch (error: unknown) {
cause = error;
}
@@ -509,7 +515,9 @@ it("retains the full directory snapshot and exact directory owner when close fai
let cause: unknown;
try {
await scope.run(() =>
snapshotRoots(scenarioFor(root), undefined, undefined, scope),
snapshotRoots(scenarioFor(root), undefined, undefined, {
cleanup: scope,
}),
);
} catch (error: unknown) {
cause = error;
@@ -639,7 +647,9 @@ it("keeps a later file hash, blocks scope admission on the first file owner, the
let cause: unknown;
try {
await scope.run(() =>
snapshotRoots(scenarioFor(root), undefined, undefined, scope),
snapshotRoots(scenarioFor(root), undefined, undefined, {
cleanup: scope,
}),
);
} catch (error: unknown) {
cause = error;
@@ -664,7 +674,9 @@ it("keeps a later file hash, blocks scope admission on the first file owner, the
await expect(
scope.run(() =>
snapshotRoots(scenarioFor(root), undefined, undefined, scope),
snapshotRoots(scenarioFor(root), undefined, undefined, {
cleanup: scope,
}),
),
).rejects.toMatchObject({ cleanupIncomplete: true });
expect(injected.openedPaths).toEqual([first, second]);
@@ -674,7 +686,9 @@ it("keeps a later file hash, blocks scope admission on the first file owner, the
expect(failure.cleanupIncomplete).toBe(false);
await expect(
scope.run(() =>
snapshotRoots(scenarioFor(root), undefined, undefined, scope),
snapshotRoots(scenarioFor(root), undefined, undefined, {
cleanup: scope,
}),
),
).resolves.toMatchObject({ files: expect.any(Array) });
expect(injected.openedPaths).toEqual([first, second, first, second]);
@@ -728,7 +742,9 @@ it.each([false, true])(
let failure: unknown;
try {
await scope.run(() =>
snapshotRoots(scenarioFor(path), controller.signal, undefined, scope),
snapshotRoots(scenarioFor(path), controller.signal, undefined, {
cleanup: scope,
}),
);
} catch (cause: unknown) {
failure = cause;