mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
09b6828516
commit
3d83135987
2 files changed
+114
-69
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user