mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
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 fae4e25539.
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 <rudrakshagarwal4200@gmail.com>
This commit is contained in:
1 parent
95c8f57aa2
commit
c85b44e1b1
8 files changed
+826
-61
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <T>(
|
||||
read: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<T, EntryOmission>> => {
|
||||
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<Result<string, EntryOmission>> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<typeof original.open>) => {
|
||||
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<typeof original.lstat>) => {
|
||||
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",
|
||||
|
||||
@@ -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<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...original,
|
||||
readlink: async (...args: Parameters<typeof original.readlink>) => {
|
||||
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<typeof original.lstat>) => {
|
||||
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<typeof original.opendir>) => {
|
||||
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 <Value>(
|
||||
prefix: string,
|
||||
run: (root: string) => Promise<Value>,
|
||||
): Promise<Value> => {
|
||||
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",
|
||||
});
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user