fix(reference): verify symlink identity around target reads

Bracket symlink target observation with the identity captured during enumeration. Report replacements and disappearing links as changed, and keep cancellation and I/O mapping at one owner boundary.

Remove duplicate target-error wrappers and unreachable external-target handling. Validation: reference reader filesystem suite passes, including deterministic replacement during observation.
This commit is contained in:
morluto committed 2026-10-12 04:33:47 +08:00
1 parent 09b6828516
commit 3d83135987
2 files changed
+114 -69

No files matched your search

+79 -68
View File
@@ -225,7 +225,13 @@ const processEntry = async (
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
if (kind === "symlink")
state.entries.push(
await describeSymlink(state.root, absolute, path, state.signal),
await readStableSymlinkTarget(
absolute,
path,
metadata.value,
state.signal,
() => readSymlinkTarget(state.root, absolute, path),
),
);
else if (kind === "directory") {
directories.push({ path: absolute });
@@ -278,86 +284,91 @@ const processFileEntry = async (
return ok(undefined);
};
const describeSymlink = async (
root: string,
/** Bracket target observation with the symlink identity captured at enumeration. */
export const readStableSymlinkTarget = async (
absolute: string,
path: string,
signal?: AbortSignal,
expected: BigIntStats,
signal: AbortSignal | undefined,
readTarget: () => Promise<ReferenceSourceEntry>,
): Promise<ReferenceSourceEntry> => {
try {
const rawTarget = await readlink(absolute);
const lexicalTarget = resolve(dirname(absolute), rawTarget);
if (!isPathWithinRoot(root, lexicalTarget))
return {
status: "read",
kind: "symlink",
path,
target: lexicalTarget,
targetState: "external",
};
try {
const canonicalTarget = await realpath(lexicalTarget);
return isPathWithinRoot(root, canonicalTarget)
? {
status: "read",
kind: "symlink",
path,
target: pathFromRoot(root, canonicalTarget),
targetState: "internal",
}
: {
status: "read",
kind: "symlink",
path,
target: canonicalTarget,
targetState: "external",
};
} catch (cause: unknown) {
if (isAborted(signal))
return entryFailure(
path,
"symlink",
"cancelled",
"Symlink inspection cancelled",
);
const code =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
if (code !== "ENOENT") {
const message = filesystemFailureDetail(
cause,
"Symbolic link target could not be resolved",
);
if (message === undefined) throw cause;
return entryFailure(path, "symlink", "io", message);
}
const missingOutsideRoot = !isPathWithinRoot(root, lexicalTarget);
return {
status: "read",
kind: "symlink",
path,
target: missingOutsideRoot
? lexicalTarget
: pathFromRoot(root, lexicalTarget),
targetState: "missing",
};
}
const before = await bigLstat(absolute);
if (isAborted(signal)) return cancelledSymlink(path);
if (!before.isSymbolicLink() || !sameFile(expected, before))
return changedSymlink(path);
const observed = await readTarget();
if (isAborted(signal)) return cancelledSymlink(path);
const after = await bigLstat(absolute);
if (isAborted(signal)) return cancelledSymlink(path);
if (!after.isSymbolicLink() || !sameFile(expected, after))
return changedSymlink(path);
return observed;
} catch (cause: unknown) {
if (isAborted(signal))
return entryFailure(
path,
"symlink",
"cancelled",
"Symlink inspection cancelled",
);
if (isAborted(signal)) return cancelledSymlink(path);
const code =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
if (code === "ENOENT" || code === "ENOTDIR") return changedSymlink(path);
const message = filesystemFailureDetail(
cause,
"Symbolic link target could not be read",
"Symbolic link target could not be read safely",
);
if (message === undefined) throw cause;
return entryFailure(path, "symlink", "io", message);
}
};
const cancelledSymlink = (path: string) =>
entryFailure(path, "symlink", "cancelled", "Symlink inspection cancelled");
const changedSymlink = (path: string) =>
entryFailure(
path,
"symlink",
"changed",
"Symbolic link changed while its target was read",
);
const readSymlinkTarget = async (
root: string,
absolute: string,
path: string,
): Promise<ReferenceSourceEntry> => {
const rawTarget = await readlink(absolute);
const lexicalTarget = resolve(dirname(absolute), rawTarget);
if (!isPathWithinRoot(root, lexicalTarget))
return {
status: "read",
kind: "symlink",
path,
target: lexicalTarget,
targetState: "external",
};
let canonicalTarget: string;
try {
canonicalTarget = await realpath(lexicalTarget);
} catch (cause: unknown) {
const code =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
if (code !== "ENOENT") throw cause;
return {
status: "read",
kind: "symlink",
path,
target: pathFromRoot(root, lexicalTarget),
targetState: "missing",
};
}
const internal = isPathWithinRoot(root, canonicalTarget);
return {
status: "read",
kind: "symlink",
path,
target: internal ? pathFromRoot(root, canonicalTarget) : canonicalTarget,
targetState: internal ? "internal" : "external",
};
};
const applyExclusion = (
shouldExclude:
| ((path: string, kind: ReferenceSourceEntryKind) => boolean)
@@ -1,10 +1,11 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { lstat, mkdir, symlink, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { readStableSymlinkTarget } from "../../../src/reference/ReferenceSourceReaderEntries.js";
import { readReferenceSource } from "../../support/referenceSourceResourceScope.js";
describe("readReferenceSource entries", () => {
@@ -89,6 +90,39 @@ describe("readReferenceSource entries", () => {
);
});
it("marks a symlink changed when its path is replaced during target observation", async () => {
const root = await createTestTempDirectory("rea-reference-symlink-race-");
const link = join(root, "source-link");
await symlink("original-target", link);
const expected = await lstat(link, { bigint: true });
const result = await readStableSymlinkTarget(
link,
"source-link",
expected,
undefined,
async () => {
await unlink(link);
await symlink("replacement-target", link);
return {
status: "read",
kind: "symlink",
path: "source-link",
target: "original-target",
targetState: "missing",
};
},
);
expect(result).toEqual({
status: "failed",
kind: "symlink",
path: "source-link",
code: "changed",
message: "Symbolic link changed while its target was read",
});
});
it("reads a source file larger than the former 16 MiB ceiling", async () => {
const root = await createTestTempDirectory("rea-reference-large-");
const size = 16 * 1024 * 1024 + 1;