Classify replacement EINVAL at the readlink syscall so the reader and importer retain sibling observations. Give an admitted root's enumeration failure its own typed field, use it for unresolved symlink coverage, and project it as a graph limitation instead of an invalid dot-path child entry. Keep admission, cancellation and cleanup failures distinct. Closes #5135 Closes #5136 BREAKING CHANGE: Partial reference-source reader results report failed root enumeration in rootFailure instead of a failed directory child at path ".". Historical source graphs preserve the root failure as a top-level limitation.
214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
import { expect, it, vi } from "vitest";
|
|
import type { PathLike } from "node:fs";
|
|
|
|
const race = vi.hoisted(() => ({
|
|
path: "",
|
|
beforeReadlink: undefined as (() => Promise<void>) | undefined,
|
|
actualErrno: undefined as string | undefined,
|
|
directory: "",
|
|
afterDirectoryClose: undefined as (() => Promise<void>) | undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...filesystem,
|
|
opendir: async (...args: Parameters<typeof filesystem.opendir>) => {
|
|
const directory = await filesystem.opendir(...args);
|
|
if (String(args[0]) === race.directory) {
|
|
const close = directory.close.bind(directory);
|
|
vi.spyOn(directory, "close").mockImplementation(async () => {
|
|
await close();
|
|
const hook = race.afterDirectoryClose;
|
|
race.afterDirectoryClose = undefined;
|
|
await hook?.();
|
|
});
|
|
}
|
|
return directory;
|
|
},
|
|
readlink: async (
|
|
path: PathLike,
|
|
...args: Parameters<typeof filesystem.readlink> extends [
|
|
PathLike,
|
|
...infer R,
|
|
]
|
|
? R
|
|
: never
|
|
) => {
|
|
if (String(path) === race.path && race.beforeReadlink !== undefined) {
|
|
const beforeReadlink = race.beforeReadlink;
|
|
race.beforeReadlink = undefined;
|
|
await beforeReadlink();
|
|
}
|
|
try {
|
|
return await filesystem.readlink(path, ...args);
|
|
} catch (cause: unknown) {
|
|
if (String(path) === race.path && cause instanceof Error)
|
|
race.actualErrno = Reflect.get(cause, "code");
|
|
throw cause;
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
mkdir,
|
|
readFile,
|
|
rename,
|
|
unlink,
|
|
writeFile,
|
|
symlink,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
|
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
|
import { importReferenceSource } from "../../src/application/ReferenceSourceImport.js";
|
|
import { readReferenceSource } from "../../src/reference/ReferenceSourceReader.js";
|
|
|
|
it.skipIf(process.platform === "win32").each(["reader", "import"] as const)(
|
|
"keeps sibling observations through %s when a symlink becomes a regular file before readlink",
|
|
async (operation) => {
|
|
const root = await createTestTempDirectory("rea-reference-symlink-race-");
|
|
await mkdir(join(root, "nested"));
|
|
const linkPath = join(root, "nested", "link");
|
|
const siblingPath = join(root, "sibling.txt");
|
|
await symlink("missing-target", linkPath);
|
|
await writeFile(siblingPath, "sibling remains observable");
|
|
race.path = linkPath;
|
|
race.actualErrno = undefined;
|
|
race.beforeReadlink = async () => {
|
|
await unlink(linkPath);
|
|
await writeFile(linkPath, "replacement regular file");
|
|
};
|
|
|
|
const resources = new ArtifactResourceScope();
|
|
let read: Awaited<ReturnType<typeof readReferenceSource>> | undefined;
|
|
let imported: Awaited<ReturnType<typeof importReferenceSource>> | undefined;
|
|
let thrown: unknown;
|
|
try {
|
|
if (operation === "reader")
|
|
read = await readReferenceSource(root, resources);
|
|
else
|
|
imported = await importReferenceSource(
|
|
{
|
|
root,
|
|
caller: "symlink-replacement-test",
|
|
policy: { secretPatterns: [] },
|
|
},
|
|
resources,
|
|
);
|
|
} catch (cause: unknown) {
|
|
thrown = cause;
|
|
} finally {
|
|
race.path = "";
|
|
race.beforeReadlink = undefined;
|
|
await resources.close();
|
|
}
|
|
|
|
// The hook establishes scheduling; this assertion verifies the host's real
|
|
// readlink syscall reports the replacement as EINVAL.
|
|
expect(race.actualErrno).toBe("EINVAL");
|
|
expect(thrown).toBeUndefined();
|
|
expect(read?.ok ?? imported?.ok, JSON.stringify(imported)).toBe(true);
|
|
if (imported?.ok) {
|
|
expect(imported.value.inventory_state).toBe("partial");
|
|
expect(imported.value.entries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: "nested/link",
|
|
kind: "symlink",
|
|
target: null,
|
|
target_state: "unreadable",
|
|
}),
|
|
expect.objectContaining({
|
|
path: "sibling.txt",
|
|
kind: "file",
|
|
content_state: "hashed",
|
|
sha256: expect.any(String),
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
imported.value.entries
|
|
.find(({ path }) => path === "nested/link")
|
|
?.limitations.join(" "),
|
|
).toContain("Symbolic link changed");
|
|
}
|
|
if (!read?.ok) return;
|
|
expect(read.value.rootFailure).toBeUndefined();
|
|
expect(read.value.entries.some(({ path }) => path === ".")).toBe(false);
|
|
expect(read.value.entries).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: "failed",
|
|
kind: "symlink",
|
|
path: "nested/link",
|
|
code: "changed",
|
|
}),
|
|
expect.objectContaining({
|
|
status: "read",
|
|
kind: "file",
|
|
path: "sibling.txt",
|
|
}),
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32").each(["contents", "root"] as const)(
|
|
"retains a changed %s as a graph limitation after its directory handle closes",
|
|
async (changed) => {
|
|
const parent = await createTestTempDirectory("rea-reference-root-race-");
|
|
const root = join(parent, "observed");
|
|
await mkdir(root);
|
|
const source = join(root, "source.txt");
|
|
await writeFile(source, "already observed source");
|
|
race.directory = root;
|
|
race.afterDirectoryClose = () =>
|
|
changed === "contents"
|
|
? rename(source, join(root, "renamed.txt"))
|
|
: rename(root, join(parent, "moved"));
|
|
const resources = new ArtifactResourceScope();
|
|
let imported: Awaited<ReturnType<typeof importReferenceSource>>;
|
|
try {
|
|
imported = await importReferenceSource(
|
|
{
|
|
root,
|
|
caller: "root-revalidation-test",
|
|
policy: { secretPatterns: [] },
|
|
},
|
|
resources,
|
|
);
|
|
} finally {
|
|
race.directory = "";
|
|
race.afterDirectoryClose = undefined;
|
|
await resources.close();
|
|
}
|
|
const retainedPath =
|
|
changed === "contents"
|
|
? join(root, "renamed.txt")
|
|
: join(parent, "moved", "source.txt");
|
|
expect(await readFile(retainedPath, "utf8")).toBe(
|
|
"already observed source",
|
|
);
|
|
await expect(readFile(source)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(imported.ok, JSON.stringify(imported)).toBe(true);
|
|
if (!imported.ok) return;
|
|
expect(imported.value.inventory_state).toBe("partial");
|
|
expect(imported.value.limitations).toContainEqual(
|
|
expect.stringContaining(
|
|
changed === "contents"
|
|
? "Reference root enumeration is incomplete: Reference root changed"
|
|
: "Reference root enumeration is incomplete: Directory identity could not be verified: ENOENT",
|
|
),
|
|
);
|
|
expect(imported.value.entries).toEqual([
|
|
expect.objectContaining({
|
|
path: "source.txt",
|
|
kind: "file",
|
|
content_state: "hashed",
|
|
}),
|
|
]);
|
|
},
|
|
);
|