325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import {
|
|
chmod,
|
|
mkdir,
|
|
readFile,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it } from "vitest";
|
|
import { parseProcessScenario } from "../../domain/process/processScenario.js";
|
|
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
|
import { snapshotRoots } from "./FilesystemSnapshot.js";
|
|
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
|
|
|
|
it.each(["after", "before"] as const)(
|
|
"keeps absence unknown when the %s snapshot omits an existing file",
|
|
async (partialSide) => {
|
|
// Enumeration order is filesystem-dependent. Choose a genuinely omitted
|
|
// entry by comparing a complete snapshot with a capacity-limited one.
|
|
const root = await createTestTempDirectory("rea-fs-partial-absence-");
|
|
await writeFile(join(root, "a1.txt"), "first");
|
|
await writeFile(join(root, "a2.txt"), "second");
|
|
await writeFile(join(root, "z.txt"), "target");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
limits: { files: 2 },
|
|
});
|
|
const complete = await snapshotRoots({
|
|
...scenario,
|
|
limits: { ...scenario.limits, files: 4 },
|
|
});
|
|
const partial = await snapshotRoots(scenario);
|
|
expect(complete.completeRoots).toEqual(["root_0"]);
|
|
expect(partial.truncated).toBe(true);
|
|
const omitted = complete.files.find(
|
|
(file) => !partial.files.some(({ path }) => path === file.path),
|
|
);
|
|
if (omitted === undefined) throw new Error("Expected an omitted entry");
|
|
const before = partialSide === "before" ? partial : complete;
|
|
const after = partialSide === "after" ? partial : complete;
|
|
const effect = classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === omitted.path,
|
|
);
|
|
expect(effect?.status).toBe("unknown");
|
|
expect(effect).toMatchObject({
|
|
...(partialSide === "after"
|
|
? { before: omitted, after: null }
|
|
: { before: null, after: omitted }),
|
|
reason: expect.stringContaining("did not exhaust path enumeration"),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves proven deletion in an exhausted root while another root is partial", async () => {
|
|
const root = await createTestTempDirectory("rea-fs-multiple-roots-");
|
|
const complete = join(root, "complete");
|
|
const partial = join(root, "partial");
|
|
await mkdir(complete);
|
|
await mkdir(partial);
|
|
await writeFile(join(complete, "removed.txt"), "removed");
|
|
await writeFile(join(partial, "first.txt"), "first");
|
|
await writeFile(join(partial, "second.txt"), "second");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [complete, partial],
|
|
limits: { files: 3 },
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
await rm(join(complete, "removed.txt"));
|
|
const after = await snapshotRoots(scenario);
|
|
expect(before.truncated && after.truncated).toBe(true);
|
|
expect(
|
|
classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === "root_0:removed.txt",
|
|
)?.status,
|
|
).toBe("deleted");
|
|
});
|
|
|
|
it("distinguishes unavailable content hashes from completed path enumeration", async () => {
|
|
const root = await createTestTempDirectory("rea-fs-hash-coverage-");
|
|
await writeFile(join(root, "removed.txt"), "larger than the byte budget");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
limits: { file_bytes: 1 },
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
await rm(join(root, "removed.txt"));
|
|
const after = await snapshotRoots(scenario);
|
|
expect(before.truncated).toBe(true);
|
|
expect(
|
|
classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === "root_0:removed.txt",
|
|
),
|
|
).toMatchObject({ status: "deleted", before: { sha256: null }, after: null });
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
|
"retains unreadable-file and sibling observations in a complete snapshot",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-fs-unreadable-file-");
|
|
const unreadable = join(root, "unreadable.txt");
|
|
const readable = join(root, "readable.txt");
|
|
await writeFile(unreadable, "permission denied");
|
|
await writeFile(readable, "still observed");
|
|
await chmod(unreadable, 0);
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
|
|
try {
|
|
const snapshot = await snapshotRoots(scenario);
|
|
expect(snapshot.files.map(({ path }) => path)).toEqual([
|
|
"root_0:.",
|
|
"root_0:readable.txt",
|
|
"root_0:unreadable.txt",
|
|
]);
|
|
expect(
|
|
snapshot.files.find(({ path }) => path === "root_0:readable.txt")
|
|
?.sha256,
|
|
).not.toBeNull();
|
|
expect(
|
|
snapshot.files.find(({ path }) => path === "root_0:unreadable.txt"),
|
|
).toMatchObject({ type: "file", size: 17, sha256: null });
|
|
expect(snapshot.coverage.hash_omissions).toContainEqual({
|
|
path: "root_0:unreadable.txt",
|
|
size_bytes: 17,
|
|
remaining_budget_bytes:
|
|
scenario.limits.file_bytes - Buffer.byteLength("still observed"),
|
|
reason: "file_unavailable",
|
|
system_code: "EACCES",
|
|
});
|
|
expect(snapshot.completeRoots).toEqual(["root_0"]);
|
|
} finally {
|
|
await chmod(unreadable, 0o600);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ name: "both", beforeOmitted: true, afterOmitted: true, contents: "after!" },
|
|
{
|
|
name: "before",
|
|
beforeOmitted: true,
|
|
afterOmitted: false,
|
|
contents: "after!",
|
|
},
|
|
{
|
|
name: "after",
|
|
beforeOmitted: false,
|
|
afterOmitted: true,
|
|
contents: "after!",
|
|
},
|
|
{
|
|
name: "both for an untouched file",
|
|
beforeOmitted: true,
|
|
afterOmitted: true,
|
|
contents: "before",
|
|
},
|
|
])(
|
|
"keeps content equality unknown when $name snapshots omit its digest",
|
|
async ({ beforeOmitted, afterOmitted, contents }) => {
|
|
const root = await createTestTempDirectory("rea-fs-content-unknown-");
|
|
const budgetFile = join(root, "a.txt");
|
|
const file = join(root, "b.txt");
|
|
await writeFile(file, "before");
|
|
if (beforeOmitted) await writeFile(budgetFile, "budget");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
limits: { file_bytes: 6 },
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
if (beforeOmitted && !afterOmitted) await rm(budgetFile);
|
|
if (!beforeOmitted && afterOmitted) await writeFile(budgetFile, "budget");
|
|
if (contents !== "before") await writeFile(file, contents);
|
|
const after = await snapshotRoots(scenario);
|
|
expect(await readFile(file, "utf8")).toBe(contents);
|
|
const effect = classifyFilesystemEffects(before, after).find(
|
|
({ path }) => path === "root_0:b.txt",
|
|
);
|
|
expect(effect).toMatchObject({
|
|
status: "unknown",
|
|
before: {
|
|
type: "file",
|
|
size: 6,
|
|
sha256: beforeOmitted ? null : expect.any(String),
|
|
},
|
|
after: {
|
|
type: "file",
|
|
size: 6,
|
|
sha256: afterOmitted ? null : expect.any(String),
|
|
},
|
|
reason: expect.stringMatching(/content.*hash|hash.*content/iu),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves observed content and metadata changes without treating missing hashes as equality", async () => {
|
|
const root = await createTestTempDirectory("rea-fs-observed-changes-");
|
|
for (const name of ["changed", "stable", "unhashed", "retyped"])
|
|
await writeFile(join(root, name), "before");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
await writeFile(join(root, "changed"), "after!");
|
|
await writeFile(join(root, "unhashed"), "different size");
|
|
await rm(join(root, "retyped"));
|
|
await mkdir(join(root, "retyped"));
|
|
const after = await snapshotRoots(scenario);
|
|
const effects = classifyFilesystemEffects(before, after);
|
|
expect(effects.find(({ path }) => path === "root_0:changed")?.status).toBe(
|
|
"modified",
|
|
);
|
|
expect(effects.find(({ path }) => path === "root_0:stable")?.status).toBe(
|
|
"unchanged",
|
|
);
|
|
expect(effects.find(({ path }) => path === "root_0:retyped")?.status).toBe(
|
|
"modified",
|
|
);
|
|
const noHashes = parseProcessScenario({
|
|
...scenario,
|
|
limits: { file_bytes: 1 },
|
|
});
|
|
const unhashedBefore = await snapshotRoots(noHashes);
|
|
await writeFile(join(root, "unhashed"), "size changed again");
|
|
const unhashedAfter = await snapshotRoots(noHashes);
|
|
expect(
|
|
classifyFilesystemEffects(unhashedBefore, unhashedAfter).find(
|
|
({ path }) => path === "root_0:unhashed",
|
|
),
|
|
).toMatchObject({
|
|
status: "modified",
|
|
before: { sha256: null },
|
|
after: { sha256: null },
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves an observed permission change when file digests are omitted",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-fs-mode-change-");
|
|
const file = join(root, "file");
|
|
await writeFile(file, "before", { mode: 0o600 });
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [file],
|
|
limits: { file_bytes: 1 },
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
await chmod(file, 0o644);
|
|
const after = await snapshotRoots(scenario);
|
|
expect(classifyFilesystemEffects(before, after)).toMatchObject([
|
|
{ status: "modified", before: { sha256: null }, after: { sha256: null } },
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"compares directories and symlink targets without requiring file content hashes",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-fs-nonfile-effects-");
|
|
await mkdir(join(root, "empty"));
|
|
await writeFile(join(root, "a.txt"), "before");
|
|
await writeFile(join(root, "b.txt"), "unused");
|
|
await symlink("a.txt", join(root, "stable-link"));
|
|
await symlink("a.txt", join(root, "moved-link"));
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [root],
|
|
limits: { file_bytes: 1 },
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
await writeFile(join(root, "a.txt"), "after!");
|
|
await rm(join(root, "moved-link"));
|
|
await symlink("b.txt", join(root, "moved-link"));
|
|
const after = await snapshotRoots(scenario);
|
|
const effects = classifyFilesystemEffects(before, after);
|
|
for (const name of ["empty", "stable-link"])
|
|
expect(
|
|
effects.find(({ path }) => path === `root_0:${name}`)?.status,
|
|
).toBe("unchanged");
|
|
expect(
|
|
effects.find(({ path }) => path === "root_0:moved-link")?.status,
|
|
).toBe("modified");
|
|
},
|
|
);
|
|
|
|
it("preserves creation when the selected root was observed missing before capture", async () => {
|
|
const root = await createTestTempDirectory("rea-fs-missing-root-");
|
|
const file = join(root, "created.txt");
|
|
const scenario = parseProcessScenario({
|
|
executable: process.execPath,
|
|
working_directory: root,
|
|
filesystem_observation_paths: [file],
|
|
});
|
|
const before = await snapshotRoots(scenario);
|
|
await writeFile(file, "created");
|
|
const after = await snapshotRoots(scenario);
|
|
expect(before.files).toEqual([]);
|
|
expect(before.truncated).toBe(false);
|
|
expect(classifyFilesystemEffects(before, after)).toMatchObject([
|
|
{
|
|
path: "root_0:.",
|
|
status: "created",
|
|
before: null,
|
|
after: { type: "file" },
|
|
},
|
|
]);
|
|
});
|