From 2f6e0506f0845ce13b438d101691d8a44e8ff740 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:49:00 +0800 Subject: [PATCH] fix: preserve process filesystem scope and admission failures --- docs/process-capture.md | 9 ++ src/domain/process/processCaptureCoverage.ts | 8 ++ .../capture/FilesystemCoverage.test.ts | 121 +++++++++++++++++- src/process/capture/FilesystemSnapshot.ts | 81 +++++++++++- .../capture/ProcessCaptureLifecycle.ts | 36 +++++- .../capture/ProcessCaptureObservation.test.ts | 71 +++++++++- .../capture/ProcessFilesystemEffects.ts | 42 ++---- src/process/capture/ProcessHarness.ts | 11 +- .../process/processPreparation.test.ts | 70 +++++++++- .../processSnapshotHandleOwnership.test.ts | 34 +++-- 10 files changed, 432 insertions(+), 51 deletions(-) diff --git a/docs/process-capture.md b/docs/process-capture.md index 4d4db94845..375cc37def 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -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. diff --git a/src/domain/process/processCaptureCoverage.ts b/src/domain/process/processCaptureCoverage.ts index da365bb403..36b3b65adc 100644 --- a/src/domain/process/processCaptureCoverage.ts +++ b/src/domain/process/processCaptureCoverage.ts @@ -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( diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index 4113e66203..6daf6572b2 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -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); + }, +); diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index abb252fc8a..1588a4f9c9 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -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, +): 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[]; + 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 => { + 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[] = []; + 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 => { + 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 => { 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}:.`, { diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 0cffa3da4b..64dffd18a5 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -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; } }; diff --git a/src/process/capture/ProcessCaptureObservation.test.ts b/src/process/capture/ProcessCaptureObservation.test.ts index 81deb057e1..b14135b652 100644 --- a/src/process/capture/ProcessCaptureObservation.test.ts +++ b/src/process/capture/ProcessCaptureObservation.test.ts @@ -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); + }, +); diff --git a/src/process/capture/ProcessFilesystemEffects.ts b/src/process/capture/ProcessFilesystemEffects.ts index af67d4cee5..e515e49820 100644 --- a/src/process/capture/ProcessFilesystemEffects.ts +++ b/src/process/capture/ProcessFilesystemEffects.ts @@ -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, - filesByPath: ReadonlyMap, + 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 { diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index 67e47eaea7..e26db834be 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -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, diff --git a/tests/boundary/process/processPreparation.test.ts b/tests/boundary/process/processPreparation.test.ts index 6cbb3630fa..153c2cba07 100644 --- a/tests/boundary/process/processPreparation.test.ts +++ b/tests/boundary/process/processPreparation.test.ts @@ -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(); + } + }, +); diff --git a/tests/boundary/process/processSnapshotHandleOwnership.test.ts b/tests/boundary/process/processSnapshotHandleOwnership.test.ts index 69571b7d1b..6c4e022fb1 100644 --- a/tests/boundary/process/processSnapshotHandleOwnership.test.ts +++ b/tests/boundary/process/processSnapshotHandleOwnership.test.ts @@ -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;