Files
rea/src/process/capture/FilesystemSnapshot.test.ts
T
morluto 415145d04f fix(process)!: retain per-file digest open failures
BREAKING CHANGE: Filesystem digest omission entries now require system_code; budget and changed-file omissions use null, and unavailable-file omissions preserve the operating-system error code.
2026-10-10 03:52:35 +08:00

157 lines
5.1 KiB
TypeScript

import { fstatSync } from "node:fs";
import {
mkdtemp,
lstat,
open,
readdir,
rm,
utimes,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { hashFile } from "./FilesystemSnapshot.js";
const countOpenDescriptorsFor = async (path: string): Promise<number> => {
const directory = process.platform === "linux" ? "/proc/self/fd" : "/dev/fd";
const expected = await lstat(path);
const descriptors = await readdir(directory);
const matches = await Promise.all(
descriptors.map(async (descriptor) => {
if (!/^\d+$/u.test(descriptor)) return false;
try {
const stats = fstatSync(Number(descriptor));
return stats.dev === expected.dev && stats.ino === expected.ino;
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "EBADF")
return false;
throw cause;
}
}),
);
return matches.filter(Boolean).length;
};
it("does not hash a replacement file using the earlier path metadata", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-identity-"));
const path = join(directory, "observed-file");
try {
await writeFile(path, "captured file\n");
const expected = await lstat(path);
await rm(path);
await writeFile(path, "replacement with a different identity and size\n");
await expect(hashFile(path, expected, 1_000)).resolves.toEqual({
state: "omitted",
reason: "file_changed_or_short_read",
system_code: null,
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("rejects a same-size rewrite even when its modification time is restored", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-ctime-"));
const path = join(directory, "observed-file");
try {
const fixedMtime = new Date("2020-01-02T03:04:05.000Z");
await writeFile(path, "before\n");
await utimes(path, fixedMtime, fixedMtime);
const expected = await lstat(path);
await expect
.poll(async () => {
await writeFile(path, "after!\n");
await utimes(path, fixedMtime, fixedMtime);
return (await lstat(path)).ctimeMs;
})
.not.toBe(expected.ctimeMs);
const rewritten = await lstat(path);
expect(rewritten).toMatchObject({
dev: expected.dev,
ino: expected.ino,
mode: expected.mode,
size: expected.size,
mtimeMs: expected.mtimeMs,
});
expect(rewritten.ctimeMs).not.toBe(expected.ctimeMs);
await expect(hashFile(path, expected, 1_000)).resolves.toMatchObject({
state: "omitted",
reason: "file_changed_or_short_read",
system_code: null,
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("reports a file removed between metadata observation and open", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-open-race-"));
const path = join(directory, "observed-file");
try {
await writeFile(path, "captured file\n");
const expected = await lstat(path);
await rm(path);
await expect(hashFile(path, expected, 1_000)).resolves.toEqual({
state: "omitted",
reason: "file_unavailable",
system_code: "ENOENT",
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it("rejects cancellation for an empty file before returning its digest", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-empty-cancel-"));
const path = join(directory, "observed-file");
try {
await writeFile(path, "");
const expected = await lstat(path);
const controller = new AbortController();
controller.abort();
await expect(
hashFile(path, expected, 1_000, controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")(
"closes the opened file when cancellation arrives during open",
async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-cancel-"));
const path = join(directory, "observed-file");
try {
await writeFile(path, "captured file\n");
const expected = await lstat(path);
const controller = new AbortController();
const held = await open(path, "r");
expect(await countOpenDescriptorsFor(path)).toBe(1);
await held.close();
expect(await countOpenDescriptorsFor(path)).toBe(0);
const hashing = hashFile(path, expected, 1_000, controller.signal);
// hashFile reaches `open` synchronously before its first suspension, so
// this abort is observed after the descriptor is acquired and is handled
// by the `finally` close path.
const reason = Object.assign(new Error("cancelled while opening"), {
code: "EACCES",
});
controller.abort(reason);
await expect(hashing).rejects.toBe(reason);
expect(await countOpenDescriptorsFor(path)).toBe(0);
} finally {
await rm(directory, { recursive: true, force: true });
}
},
);