From c85b44e1b14851c54e65327a4807f79ad5cd5a61 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:34:39 +0800 Subject: [PATCH] fix(process)!: retain partial filesystem snapshot observations Keep expected readlink and follow-up lstat failures as typed entry omissions, including the observed path and errno. Retain directory metadata and sibling observations, distinguish known identity replacements, and keep cancellation and unexpected I/O failures terminal. Reuse admitted root metadata and test cancellation by descriptor state rather than a count of named lookups. Adapt the symlink race handling proposed in #4587 at fae4e25539969671c82a4d56cfb0b50761b833a3. Closes #4575 BREAKING CHANGE: Filesystem enumeration omission reasons now also include entry_unavailable and identity_changed. Clients validating the old literal reason must accept the expanded capture schema. Co-authored-by: rudrakshagarwal4200-ai --- docs/process-capture.md | 9 + .../process/processCapture.validation.test.ts | 82 ++++ src/domain/process/processCaptureCoverage.ts | 11 +- .../process/processCaptureValidation.ts | 22 +- src/process/capture/FilesystemSnapshot.ts | 135 ++++-- .../filesystemObservationRaceCli.test.ts | 166 +++++++ ...systemSnapshotFallbackCancellation.test.ts | 29 +- .../filesystemSnapshotRaces.test.ts | 433 ++++++++++++++++++ 8 files changed, 826 insertions(+), 61 deletions(-) create mode 100644 tests/boundary/process/filesystemObservationRaceCli.test.ts create mode 100644 tests/process-global/filesystemSnapshotRaces.test.ts diff --git a/docs/process-capture.md b/docs/process-capture.md index 17e69f311..69eef8fb0 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -241,6 +241,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. +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. +Successfully observed identity replacements use `identity_changed` with a null +system code. Unstable symlink targets are omitted; already observed directory +metadata remains available. The affected root stays incomplete, so omitted +entries do not prove deletion. A path missing at its initial lookup remains an +observed absence. Cancellation and unexpected I/O failures still stop capture. + Terminal frame `data` is the comparison text after the selected normalization. New captures also preserve `raw_data` when normalization changes a PTY chunk; otherwise `data` is already the original text. Older captures may lack the diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index 4e8ec81a5..4fb6333be 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -5,12 +5,94 @@ import { parseProcessCapture } from "./processCaptureParsing.js"; import { partialProcessCaptureObservationSchema } from "./processCapture.js"; import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "./processCaptureExample.js"; import { finalizationConsistencyIssue } from "./processCaptureValidation.js"; +import type { FilesystemCoverage } from "./processCaptureCoverage.js"; import { compareUnverifiedProcessCaptures as compareProcessCaptures, emptyUnverifiedProcessCapture as emptyCapture, processCaptureIssues, } from "./processCapture.fixture.js"; +const captureWithEnumerationOmission = ( + omission: FilesystemCoverage["enumeration_omissions"][number], +) => { + const capture = emptyCapture(); + const scenario = { + ...capture.manifest.scenario, + filesystem_observation_paths: ["/fixture/selected"], + }; + return { + ...capture, + manifest: { + ...capture.manifest, + scenario, + full_scenario_sha256: digestProcessCommitment(scenario), + }, + truncated: true, + filesystem_checkpoints: capture.filesystem_checkpoints.map((checkpoint) => + checkpoint.name === "after_settlement" + ? { ...checkpoint, truncated: true } + : checkpoint, + ), + truncation_details: { + ...capture.truncation_details, + filesystem_after: { + ...capture.truncation_details.filesystem_after, + enumeration_truncated: true, + enumeration_reasons: [omission.reason], + enumeration_omissions: [omission], + }, + }, + }; +}; + +it.each([ + { path: "root_0:.", reason: "entry_unavailable", system_code: "ENOENT" }, + { path: "root_0:.", reason: "identity_changed", system_code: null }, +] as const)("accepts a partial entry with $reason evidence", (omission) => { + const capture = captureWithEnumerationOmission(omission); + expect(parseProcessCapture(capture)).toEqual(capture); +}); + +it.each([ + { path: "root_0:.", reason: "entry_unavailable", system_code: null }, + { path: "root_0:.", reason: "identity_changed", system_code: "ENOENT" }, + { path: "root_0:.", reason: "directory_unavailable", system_code: "ENOENT" }, +] as const)("rejects an inconsistent omission %j", (omission) => { + expect(() => + parseProcessCapture(captureWithEnumerationOmission(omission)), + ).toThrow("enumeration_omissions"); +}); + +it("requires entry-unavailability details and matching aggregate reasons", () => { + const capture = captureWithEnumerationOmission({ + path: "root_0:.", + reason: "entry_unavailable", + system_code: "ENOENT", + }); + for (const changes of [ + { enumeration_omissions: [] }, + { enumeration_reasons: ["identity_changed"] }, + { + enumeration_omissions: [ + ...capture.truncation_details.filesystem_after.enumeration_omissions, + ...capture.truncation_details.filesystem_after.enumeration_omissions, + ], + }, + ]) + expect(() => + parseProcessCapture({ + ...capture, + truncation_details: { + ...capture.truncation_details, + filesystem_after: { + ...capture.truncation_details.filesystem_after, + ...changes, + }, + }, + }), + ).toThrow("enumeration_omissions"); +}); + it("never considers truncated captures equivalent", () => { const complete = emptyCapture(); const capture = { diff --git a/src/domain/process/processCaptureCoverage.ts b/src/domain/process/processCaptureCoverage.ts index 5a38e9240..cba5b4766 100644 --- a/src/domain/process/processCaptureCoverage.ts +++ b/src/domain/process/processCaptureCoverage.ts @@ -11,6 +11,12 @@ const byteRetentionSchema = z.strictObject({ unknown_size_frames: z.number().int().nonnegative().optional(), }); +const enumerationOmissionReasonSchema = z.enum([ + "identity_changed", + "directory_unavailable", + "entry_unavailable", +]); + /** Filesystem enumeration and whole-file digest coverage for one checkpoint. */ export const filesystemCoverageSchema = z.strictObject({ files_limit: z.number().int().nonnegative(), @@ -20,14 +26,13 @@ export const filesystemCoverageSchema = z.strictObject({ z.enum([ "files_limit", "depth_limit", - "identity_changed", - "directory_unavailable", + ...enumerationOmissionReasonSchema.options, ]), ), enumeration_omissions: z.array( z.strictObject({ path: z.string(), - reason: z.literal("directory_unavailable"), + reason: enumerationOmissionReasonSchema, system_code: z.string().nullable(), }), ), diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index 2c76007ab..f01e4bd8d 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -360,9 +360,7 @@ const validateCoverage = ( .filter((file) => file.type === "file" && file.sha256 === null) .map((file) => [file.path, file]), ); - const directories = new Set( - files.filter((file) => file.type === "directory").map(({ path }) => path), - ); + const entryTypes = new Map(files.map(({ path, type }) => [path, type])); require(coverage.hash_omissions.length === unhashedFiles.size && new Set(coverage.hash_omissions.map(({ path }) => path)).size === unhashedFiles.size && @@ -376,12 +374,22 @@ const validateCoverage = ( ), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason"); require(new Set(coverage.enumeration_omissions.map(({ path }) => path)) .size === coverage.enumeration_omissions.length && - (coverage.enumeration_omissions.length === 0 || - coverage.enumeration_reasons.includes("directory_unavailable")) && + (!coverage.enumeration_reasons.includes("entry_unavailable") || + coverage.enumeration_omissions.some( + ({ reason }) => reason === "entry_unavailable", + )) && coverage.enumeration_omissions.every( (omission) => - directories.has(omission.path) && omission.system_code !== null, - ), `truncation_details.${name}.enumeration_omissions`, "every directory enumeration omission must identify one retained directory and its system error"); + coverage.enumeration_reasons.includes(omission.reason) && + (omission.reason === "directory_unavailable" + ? entryTypes.get(omission.path) === "directory" && + omission.system_code !== null + : (entryTypes.get(omission.path) === undefined || + entryTypes.get(omission.path) === "directory") && + (omission.reason === "entry_unavailable" + ? omission.system_code !== null + : omission.system_code === null)), + ), `truncation_details.${name}.enumeration_omissions`, "every enumeration omission must identify an unavailable entry or retained directory, with a matching reason and system error only for unavailable reads"); require(coverage.hashed_bytes === files .filter((file) => file.type === "file" && file.sha256 !== null) diff --git a/src/process/capture/FilesystemSnapshot.ts b/src/process/capture/FilesystemSnapshot.ts index 60bee8fd5..78848d5c2 100644 --- a/src/process/capture/FilesystemSnapshot.ts +++ b/src/process/capture/FilesystemSnapshot.ts @@ -6,6 +6,7 @@ import type { FileHandle } from "node:fs/promises"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js"; import type { Stats } from "node:fs"; +import { err, ok, type Result } from "../../domain/result.js"; import type { FileState, ProcessFilesystemSnapshot, @@ -30,23 +31,70 @@ const hasSameFileState = (before: Stats, after: Stats): boolean => before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs; -const fileUnavailableSystemCode = (cause: unknown): string | undefined => { +const filesystemSystemCode = (cause: unknown): string | undefined => { if (!(cause instanceof Error) || !("code" in cause)) return undefined; const code = cause.code; - return typeof code === "string" && - [ - "EACCES", - "EISDIR", - "ELOOP", - "ENOTDIR", - "ENOENT", - "EPERM", - "ESTALE", - ].includes(code) + return typeof code === "string" ? code : undefined; +}; + +const unavailableSystemCodes = new Set([ + "EACCES", + "EISDIR", + "ELOOP", + "ENOTDIR", + "ENOENT", + "EPERM", + "ESTALE", +]); + +const fileUnavailableSystemCode = (cause: unknown): string | undefined => { + const code = filesystemSystemCode(cause); + return code !== undefined && unavailableSystemCodes.has(code) ? code : undefined; }; +type EntryOmission = + | { readonly reason: "entry_unavailable"; readonly system_code: string } + | { readonly reason: "identity_changed"; readonly system_code: null }; + +/** A follow-up read cannot turn previously observed metadata into absence. */ +const readEntryObservation = async ( + read: () => Promise, + signal?: AbortSignal, +): Promise> => { + signal?.throwIfAborted(); + try { + const value = await read(); + signal?.throwIfAborted(); + return ok(value); + } catch (cause: unknown) { + signal?.throwIfAborted(); + const code = filesystemSystemCode(cause); + // readlink reports EINVAL if the observed symlink was replaced by a file. + if ( + code === undefined || + (!unavailableSystemCodes.has(code) && code !== "EINVAL") + ) + throw cause; + return err({ reason: "entry_unavailable", system_code: code }); + } +}; + +const observeSymlink = async ( + path: string, + before: Stats, + signal?: AbortSignal, +): Promise> => { + const target = await readEntryObservation(() => readlink(path), signal); + if (!target.ok) return target; + const after = await readEntryObservation(() => lstat(path), signal); + if (!after.ok) return after; + return hasSameIdentity(before, after.value, "symlink") + ? target + : err({ reason: "identity_changed", system_code: null }); +}; + const lstatIfPresent = async ( path: string, previouslyObservedRoot = false, @@ -58,13 +106,9 @@ const lstatIfPresent = async ( signal?.throwIfAborted(); return stats; } catch (cause: unknown) { - if ( - cause instanceof Error && - "code" in cause && - (cause.code === "ENOENT" || - (previouslyObservedRoot && cause.code === "ENOTDIR")) - ) { - signal?.throwIfAborted(); + signal?.throwIfAborted(); + const code = filesystemSystemCode(cause); + if (code === "ENOENT" || (previouslyObservedRoot && code === "ENOTDIR")) { return undefined; } throw cause; @@ -198,11 +242,18 @@ export const snapshotRoots = async ( >(); const enumerationOmissions: FilesystemCoverage["enumeration_omissions"] = []; const hashOmissions: FilesystemCoverage["hash_omissions"] = []; + const omitEntry = (path: string, omission: EntryOmission): false => { + truncated = true; + enumerationReasons.add(omission.reason); + enumerationOmissions.push({ path, ...omission }); + return false; + }; const visit = async ( root: string, rootAlias: string, path: string, depth: number, + admitted?: Stats, ): Promise => { signal?.throwIfAborted(); if ( @@ -216,29 +267,23 @@ export const snapshotRoots = async ( enumerationReasons.add("depth_limit"); return false; } - const stats = await lstatIfPresent( - path, - previousPaths.has(`${rootAlias}:.`), - signal, - ); + const stats = + admitted ?? + (await lstatIfPresent(path, previousPaths.has(`${rootAlias}:.`), signal)); if (stats === undefined) return true; const relativePath = relative(root, path) || "."; if (stats.isSymbolicLink()) { // Keep the target as stored: lexical resolution can merge distinct targets. - const target = await readlink(path); - const afterRead = await lstat(path); - if (!hasSameIdentity(stats, afterRead, "symlink")) { - truncated = true; - enumerationReasons.add("identity_changed"); - return false; - } + const target = await observeSymlink(path, stats, signal); + if (!target.ok) + return omitEntry(`${rootAlias}:${relativePath}`, target.error); entries.push({ path: `${rootAlias}:${relativePath}`, type: "symlink", mode: stats.mode, size: stats.size, sha256: null, - symlink_target: target, + symlink_target: target.value, }); return true; } @@ -330,12 +375,18 @@ export const snapshotRoots = async ( children.push(child.name); pendingNames += 1; } - const afterRead = await lstat(path); - if (!hasSameIdentity(stats, afterRead, "directory")) { + const afterRead = await readEntryObservation(() => lstat(path), signal); + if ( + !afterRead.ok || + !hasSameIdentity(stats, afterRead.value, "directory") + ) { pendingNames -= children.length; - truncated = true; - enumerationReasons.add("identity_changed"); - return false; + return omitEntry( + `${rootAlias}:${relativePath}`, + afterRead.ok + ? { reason: "identity_changed", system_code: null } + : afterRead.error, + ); } children.sort().reverse(); while (children.length > 0) { @@ -349,14 +400,16 @@ export const snapshotRoots = async ( }; for (const [index, root] of scenario.filesystem_observation_paths.entries()) { const alias = `root_${String(index)}`; - if ( - (await lstatIfPresent(root, previousPaths.has(`${alias}:.`), signal)) === - undefined - ) { + const admitted = await lstatIfPresent( + root, + previousPaths.has(`${alias}:.`), + signal, + ); + if (admitted === undefined) { completeRoots.push(alias); continue; } - if (await visit(root, alias, root, 0)) completeRoots.push(alias); + if (await visit(root, alias, root, 0, admitted)) completeRoots.push(alias); } return { files: entries, diff --git a/tests/boundary/process/filesystemObservationRaceCli.test.ts b/tests/boundary/process/filesystemObservationRaceCli.test.ts new file mode 100644 index 000000000..8eb8de40e --- /dev/null +++ b/tests/boundary/process/filesystemObservationRaceCli.test.ts @@ -0,0 +1,166 @@ +import { lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { CAPTURE_SKIP_REASON } from "./processCaptureCapability.js"; + +const captureCliTest = cliTest.skipIf( + CAPTURE_SKIP_REASON || + (process.platform === "win32" ? "requires POSIX symlink semantics" : false), +); + +const racePreload = ` +import fs from "node:fs"; +import { resolve } from "node:path"; +import { syncBuiltinESMExports } from "node:module"; + +const cliEntry = process.env.REA_TEST_CLI_ENTRY; +if (cliEntry !== undefined && resolve(process.argv[1] ?? "") === cliEntry) { + const target = resolve(process.env.REA_TEST_SYMLINK ?? ""); + const countPath = process.env.REA_TEST_READLINK_COUNT; + const removeOnSecondRead = process.env.REA_TEST_REMOVE_ON_SECOND_READ === "1"; + let matchingReads = 0; + const originalReadlink = fs.promises.readlink; + fs.promises.readlink = async function (path, ...options) { + if (resolve(String(path)) === target) { + matchingReads += 1; + if (countPath !== undefined) fs.appendFileSync(countPath, "read\\n"); + if (removeOnSecondRead && matchingReads === 2) { + await fs.promises.unlink(path); + } + } + return originalReadlink.call(this, path, ...options); + }; + syncBuiltinESMExports(); +} +`; + +const createScenario = async (removeOnSecondRead: boolean) => { + const root = await createTestTempDirectory("rea-snapshot-race-cli-"); + const observed = join(root, "observed"); + const link = join(observed, "vanishing-link"); + const scenario = join(root, "scenario.json"); + const preload = join(root, "readlink-race-preload.mjs"); + const readlinkCount = join(root, "readlink-count.txt"); + await mkdir(observed); + await writeFile(join(observed, "sibling.txt"), "sibling evidence\n"); + await symlink("target-does-not-need-to-exist", link); + await writeFile(preload, racePreload); + await writeFile( + scenario, + JSON.stringify({ + executable: process.execPath, + arguments: ["-e", "process.stdout.write('controlled capture\\n')"], + working_directory: root, + filesystem_observation_paths: [observed], + }), + ); + return { root, link, scenario, preload, readlinkCount, removeOnSecondRead }; +}; + +captureCliTest( + "keeps a stable symlink observation complete through the public CLI", + async ({ cli }) => { + const fixture = await createScenario(false); + const result = await cli.run({ + arguments: ["capture-process", fixture.scenario, "--json"], + cwd: fixture.root, + environment: { + NODE_OPTIONS: `--import=${pathToFileURL(fixture.preload).href}`, + REA_TEST_CLI_ENTRY: resolve("scripts/rea.mjs"), + REA_TEST_SYMLINK: fixture.link, + REA_TEST_READLINK_COUNT: fixture.readlinkCount, + REA_TEST_REMOVE_ON_SECOND_READ: fixture.removeOnSecondRead ? "1" : "0", + }, + }); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(await readFile(fixture.readlinkCount, "utf8")).toBe("read\nread\n"); + expect((await lstat(fixture.link)).isSymbolicLink()).toBe(true); + expect(result.json).toMatchObject({ + normalized_result: { + truncated: false, + files_before: expect.arrayContaining([ + expect.objectContaining({ + path: "root_0:vanishing-link", + type: "symlink", + symlink_target: "target-does-not-need-to-exist", + }), + ]), + files_after: expect.arrayContaining([ + expect.objectContaining({ + path: "root_0:vanishing-link", + type: "symlink", + symlink_target: "target-does-not-need-to-exist", + }), + ]), + truncation_details: { + filesystem_after: { enumeration_truncated: false }, + }, + }, + }); + }, +); + +captureCliTest( + "retains sibling evidence and marks a vanished symlink unknown", + async ({ cli }) => { + const fixture = await createScenario(true); + const result = await cli.run({ + arguments: ["capture-process", fixture.scenario, "--json"], + cwd: fixture.root, + environment: { + NODE_OPTIONS: `--import=${pathToFileURL(fixture.preload).href}`, + REA_TEST_CLI_ENTRY: resolve("scripts/rea.mjs"), + REA_TEST_SYMLINK: fixture.link, + REA_TEST_READLINK_COUNT: fixture.readlinkCount, + REA_TEST_REMOVE_ON_SECOND_READ: "1", + }, + }); + + expect(await readFile(fixture.readlinkCount, "utf8")).toBe("read\nread\n"); + await expect(readFile(fixture.link)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + truncated: true, + files_before: expect.arrayContaining([ + expect.objectContaining({ + path: "root_0:vanishing-link", + type: "symlink", + }), + expect.objectContaining({ path: "root_0:sibling.txt", type: "file" }), + ]), + files_after: expect.arrayContaining([ + expect.objectContaining({ path: "root_0:sibling.txt", type: "file" }), + ]), + filesystem_effects: expect.arrayContaining([ + expect.objectContaining({ + path: "root_0:vanishing-link", + status: "unknown", + after: null, + }), + ]), + truncation_details: { + filesystem_after: { + enumeration_truncated: true, + enumeration_reasons: expect.arrayContaining(["entry_unavailable"]), + enumeration_omissions: [ + { + path: "root_0:vanishing-link", + reason: "entry_unavailable", + system_code: "ENOENT", + }, + ], + }, + }, + }, + }); + }, +); diff --git a/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts b/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts index cc984dceb..49e30b90a 100644 --- a/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts +++ b/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts @@ -7,8 +7,8 @@ import { afterEach, expect, it, vi } from "vitest"; const observation = vi.hoisted(() => ({ path: "", - namedReads: 0, - cancelAt: 0, + cancelAfterDescriptorInspection: false, + descriptorInspected: false, controller: undefined as AbortController | undefined, reason: undefined as Error | undefined, handles: [] as FileHandle[], @@ -30,14 +30,23 @@ vi.mock("node:fs/promises", async (importOriginal) => { ...original, open: async (...args: Parameters) => { const handle = await original.open(...args); - if (args[0] === observation.path) observation.handles.push(handle); + if (args[0] === observation.path) { + observation.handles.push(handle); + const stat = handle.stat.bind(handle); + vi.spyOn(handle, "stat").mockImplementation(async (options) => { + const stats = await stat(options); + observation.descriptorInspected = true; + return stats; + }); + } return handle; }, lstat: async (...args: Parameters) => { const stats = await original.lstat(...args); if ( args[0] === observation.path && - ++observation.namedReads === observation.cancelAt + observation.cancelAfterDescriptorInspection && + observation.descriptorInspected ) observation.controller?.abort(observation.reason); return stats; @@ -54,8 +63,8 @@ import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; afterEach(() => { observation.path = ""; - observation.namedReads = 0; - observation.cancelAt = 0; + observation.cancelAfterDescriptorInspection = false; + observation.descriptorInspected = false; observation.controller = undefined; observation.reason = undefined; observation.handles.length = 0; @@ -76,9 +85,9 @@ it.each(["hash", "snapshot"] as const)( observation.path = path; observation.controller = controller; observation.reason = reason; - // A root file snapshot first inspects presence and entry metadata, then - // reaches the same two named-file inspections as direct hashing. - observation.cancelAt = operation === "hash" ? 2 : 4; + // The final fallback inspection is the named lookup after the opened + // descriptor was inspected. Admission may perform other named lookups. + observation.cancelAfterDescriptorInspection = true; const result = operation === "hash" ? hashFile(path, expected, 1_000, controller.signal) @@ -110,7 +119,7 @@ it("keeps an unchanged fallback digest and closes its descriptor", async () => { state: "hashed", sha256: createHash("sha256").update(bytes).digest("hex"), }); - expect(observation.namedReads).toBe(2); + expect(observation.descriptorInspected).toBe(true); expect(observation.handles).toHaveLength(1); await expect(observation.handles[0]?.stat()).rejects.toMatchObject({ code: "EBADF", diff --git a/tests/process-global/filesystemSnapshotRaces.test.ts b/tests/process-global/filesystemSnapshotRaces.test.ts new file mode 100644 index 000000000..7c3df8924 --- /dev/null +++ b/tests/process-global/filesystemSnapshotRaces.test.ts @@ -0,0 +1,433 @@ +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, expect, it, vi } from "vitest"; + +const race = vi.hoisted(() => ({ + mode: "" as + | "readlink_missing" + | "lstat_missing" + | "lstat_identity_changed" + | "directory_missing" + | "readlink_eio" + | "readlink_eacces" + | "readlink_regular_replacement" + | "cancel_readlink" + | "cancel_readlink_missing" + | "cancel_directory_close" + | "", + target: "", + followup: false, + opened: false, + closeCompleted: false, + controller: undefined as AbortController | undefined, + reason: undefined as Error | undefined, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readlink: async (...args: Parameters) => { + const path = args[0]; + if (path === race.target && race.mode === "readlink_missing") { + await original.rm(path); + return original.readlink(...args); + } + if (path === race.target && race.mode === "readlink_eio") + throw Object.assign(new Error("injected unexpected readlink failure"), { + code: "EIO", + }); + if (path === race.target && race.mode === "readlink_eacces") + throw Object.assign(new Error("injected denied readlink"), { + code: "EACCES", + }); + if ( + path === race.target && + race.mode === "readlink_regular_replacement" + ) { + await original.rm(path); + await original.writeFile(path, "replacement regular file"); + } + if (path === race.target && race.mode === "cancel_readlink_missing") { + await original.rm(path); + try { + return await original.readlink(...args); + } catch (cause: unknown) { + race.controller?.abort(race.reason); + throw cause; + } + } + const target = await original.readlink(...args); + if (path === race.target) { + if (race.mode === "lstat_missing") { + race.followup = true; + await original.rm(path); + } else if (race.mode === "lstat_identity_changed") { + const replacement = `${path}.replacement`; + await original.symlink("replacement-target", replacement); + await original.rm(path); + await original.rename(replacement, path); + race.followup = true; + } else if (race.mode === "cancel_readlink") { + race.controller?.abort(race.reason); + } + } + return target; + }, + lstat: async (...args: Parameters) => { + const path = args[0]; + if ( + path === race.target && + (race.mode === "lstat_missing" || + race.mode === "lstat_identity_changed") && + race.followup + ) { + race.followup = false; + return original.lstat(...args); + } + if ( + path === race.target && + race.mode === "directory_missing" && + race.opened + ) { + race.mode = ""; + race.opened = false; + await original.rm(path, { recursive: true }); + return original.lstat(...args); + } + return original.lstat(...args); + }, + opendir: async (...args: Parameters) => { + const directory = await original.opendir(...args); + if (args[0] === race.target && race.mode === "directory_missing") + race.opened = true; + if (args[0] === race.target && race.mode === "cancel_directory_close") { + const iterator = directory[Symbol.asyncIterator](); + let closed = false; + const closeBeforeCancel = async () => { + if (closed) return; + closed = true; + try { + await directory.close(); + } catch (cause: unknown) { + if ( + !(cause instanceof Error) || + !("code" in cause) || + cause.code !== "ERR_DIR_CLOSED" + ) + throw cause; + } + race.closeCompleted = true; + race.controller?.abort(race.reason); + }; + const wrappedIterator = { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + const next = await iterator.next(); + if (next.done) await closeBeforeCancel(); + return next; + }, + async return() { + const result = await iterator.return?.(); + await closeBeforeCancel(); + return result ?? { done: true, value: undefined }; + }, + }; + return new Proxy(directory, { + get(target, property, receiver) { + if (property === Symbol.asyncIterator) return () => wrappedIterator; + return Reflect.get(target, property, receiver); + }, + }); + } + // The selected directory's next lstat is snapshotRoots' post-enumeration + // identity check. The deletion itself happens in that lstat wrapper. + return directory; + }, + }; +}); + +import { parseProcessScenario } from "../../src/domain/process/processScenario.js"; +import { classifyFilesystemEffects } from "../../src/process/capture/ProcessFilesystemEffects.js"; +import { snapshotRoots } from "../../src/process/capture/FilesystemSnapshot.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const resetRace = () => { + race.mode = ""; + race.target = ""; + race.followup = false; + race.opened = false; + race.closeCompleted = false; + race.controller = undefined; + race.reason = undefined; +}; + +afterEach(resetRace); + +const withTemporaryRoot = async ( + prefix: string, + run: (root: string) => Promise, +): Promise => { + const root = await createTestTempDirectory(prefix); + try { + return await run(root); + } finally { + resetRace(); + await rm(root, { recursive: true, force: true }); + } +}; + +const entryUnavailable = async ( + mode: + | "readlink_missing" + | "lstat_missing" + | "readlink_eacces" + | "readlink_regular_replacement", + systemCode: string, +) => { + await withTemporaryRoot("rea-snapshot-entry-race-", async (root) => { + const sibling = join(root, "a-sibling"); + const link = join(root, "z-link"); + await writeFile(sibling, "retain this sibling"); + await symlink("missing-target", link); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + const before = await snapshotRoots(scenario); + race.mode = mode; + race.target = link; + const after = await snapshotRoots(scenario); + expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling"); + expect(after.files.map(({ path }) => path)).not.toContain("root_0:z-link"); + expect(after.completeRoots).toEqual([]); + expect(after.coverage).toMatchObject({ + enumeration_truncated: true, + enumeration_reasons: ["entry_unavailable"], + enumeration_omissions: [ + { + path: "root_0:z-link", + reason: "entry_unavailable", + system_code: systemCode, + }, + ], + }); + expect( + classifyFilesystemEffects(before, after).find( + ({ path }) => path === "root_0:z-link", + ), + ).toMatchObject({ + status: "unknown", + reason: expect.stringContaining("absence remains unknown"), + }); + }); +}; + +it.skipIf(process.platform === "win32").each([ + { name: "readlink ENOENT", mode: "readlink_missing", systemCode: "ENOENT" }, + { + name: "follow-up lstat ENOENT", + mode: "lstat_missing", + systemCode: "ENOENT", + }, + { name: "readlink EACCES", mode: "readlink_eacces", systemCode: "EACCES" }, + { + name: "regular-file replacement (readlink EINVAL)", + mode: "readlink_regular_replacement", + systemCode: "EINVAL", + }, +] as const)( + "retains sibling observations after $name", + async ({ mode, systemCode }) => { + await entryUnavailable(mode, systemCode); + }, +); + +it.skipIf(process.platform === "win32")( + "records known symlink identity replacement separately from unavailable entries", + async () => + withTemporaryRoot("rea-snapshot-link-identity-", async (root) => { + const sibling = join(root, "a-sibling"); + const link = join(root, "z-link"); + await writeFile(sibling, "retain this sibling"); + await symlink("original-target", link); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + const before = await snapshotRoots(scenario); + race.mode = "lstat_identity_changed"; + race.target = link; + const after = await snapshotRoots(scenario); + expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling"); + expect(after.files.map(({ path }) => path)).not.toContain( + "root_0:z-link", + ); + expect(after.completeRoots).toEqual([]); + expect(after.coverage.enumeration_reasons).toEqual(["identity_changed"]); + expect(after.coverage.enumeration_omissions).toContainEqual({ + path: "root_0:z-link", + reason: "identity_changed", + system_code: null, + }); + expect( + classifyFilesystemEffects(before, after).find( + ({ path }) => path === "root_0:z-link", + )?.status, + ).toBe("unknown"); + }), +); + +it("retains siblings and marks child absence unknown when directory revalidation sees ENOENT", async () => + withTemporaryRoot("rea-snapshot-directory-race-", async (root) => { + const sibling = join(root, "a-sibling"); + const directory = join(root, "z-directory"); + await writeFile(sibling, "retain this sibling"); + await mkdir(directory); + await writeFile(join(directory, "child"), "child observed before race"); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + const before = await snapshotRoots(scenario); + race.mode = "directory_missing"; + race.target = directory; + const after = await snapshotRoots(scenario); + expect(after.files.map(({ path }) => path)).toContain("root_0:a-sibling"); + expect(after.files.map(({ path }) => path)).toContain("root_0:z-directory"); + expect(after.files.map(({ path }) => path)).not.toContain( + join("root_0:z-directory", "child"), + ); + expect(after.completeRoots).toEqual([]); + expect(after.coverage.enumeration_reasons).toEqual(["entry_unavailable"]); + expect(after.coverage.enumeration_omissions).toContainEqual({ + path: "root_0:z-directory", + reason: "entry_unavailable", + system_code: "ENOENT", + }); + expect( + classifyFilesystemEffects(before, after).find( + ({ path }) => path === join("root_0:z-directory", "child"), + ), + ).toMatchObject({ status: "unknown" }); + })); + +it.skipIf(process.platform === "win32")( + "propagates cancellation exactly during symlink read", + async () => + withTemporaryRoot("rea-snapshot-cancel-race-", async (root) => { + const link = join(root, "link"); + await symlink("missing-target", link); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + const controller = new AbortController(); + const reason = new Error("cancel during symlink read"); + race.mode = "cancel_readlink"; + race.target = link; + race.controller = controller; + race.reason = reason; + await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe( + reason, + ); + }), +); + +it.skipIf(process.platform === "win32")( + "prefers the exact cancellation reason over ENOENT from the pending readlink", + async () => + withTemporaryRoot("rea-snapshot-cancel-missing-", async (root) => { + const link = join(root, "link"); + await symlink("missing-target", link); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + const controller = new AbortController(); + const reason = new Error("cancel while readlink reports ENOENT"); + race.mode = "cancel_readlink_missing"; + race.target = link; + race.controller = controller; + race.reason = reason; + await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe( + reason, + ); + }), +); + +it("observes cancellation after the selected directory iterator closes", async () => + withTemporaryRoot("rea-snapshot-directory-close-cancel-", async (root) => { + const directory = join(root, "selected"); + await mkdir(directory); + await writeFile(join(directory, "child"), "observed child"); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [directory], + }); + const controller = new AbortController(); + const reason = new Error("cancel after directory iterator close"); + race.mode = "cancel_directory_close"; + race.target = directory; + race.controller = controller; + race.reason = reason; + await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe( + reason, + ); + expect(race.closeCompleted).toBe(true); + })); + +it("releases buffered names after a failed directory revalidation before visiting the next root", async () => + withTemporaryRoot("rea-snapshot-pending-names-", async (parent) => { + const firstRoot = join(parent, "first"); + const secondRoot = join(parent, "second"); + await mkdir(firstRoot); + await mkdir(secondRoot); + await writeFile(join(firstRoot, "a-child"), "first"); + await writeFile(join(firstRoot, "b-child"), "second"); + await writeFile(join(secondRoot, "child"), "retain this root"); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: parent, + filesystem_observation_paths: [firstRoot, secondRoot], + limits: { files: 4 }, + }); + race.mode = "directory_missing"; + race.target = firstRoot; + const after = await snapshotRoots(scenario); + expect(after.files.map(({ path }) => path)).toContain("root_1:child"); + expect(after.completeRoots).toEqual(["root_1"]); + expect(after.coverage.enumeration_omissions).toContainEqual({ + path: "root_0:.", + reason: "entry_unavailable", + system_code: "ENOENT", + }); + })); + +it.skipIf(process.platform === "win32")( + "propagates unexpected EIO instead of classifying it as an unavailable entry", + async () => + withTemporaryRoot("rea-snapshot-eio-control-", async (root) => { + const link = join(root, "link"); + await symlink("missing-target", link); + const scenario = parseProcessScenario({ + executable: process.execPath, + working_directory: root, + filesystem_observation_paths: [root], + }); + race.mode = "readlink_eio"; + race.target = link; + await expect(snapshotRoots(scenario)).rejects.toMatchObject({ + code: "EIO", + }); + }), +);