mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix: preserve process filesystem scope and admission failures
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}:.`, {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user