fix(reference): preserve raw symlink target identity

Read link targets as bytes before strict UTF-8 admission. Retain malformed targets as unreadable with source hex; preserve literal replacement characters and valid BOM-prefixed targets.

Fixes #6765
This commit is contained in:
morluto
2026-10-24 00:11:41 +08:00
parent 51e5938776
commit 7ba05210fe
3 changed files with 81 additions and 4 deletions
+4 -1
View File
@@ -426,7 +426,10 @@ top-level limitation. That partial inventory does not establish absence among
unexamined children. POSIX directory-entry bytes that are not valid UTF-8 cannot
be represented as graph paths; the containing directory (or root) is marked
unsupported, and REA does not access the entry through a replacement-character
name. Readable siblings remain inventoried.
name. Symlink targets are read as bytes too: a target that is not valid UTF-8
is retained as an unreadable symlink with its bytes reported in the limitation,
and is never resolved through a replacement-character path. Readable siblings
remain inventoried.
JavaScript and TypeScript import parsing requires valid UTF-8. Malformed source
bytes retain their original hashes and sizes with a decoding diagnostic; REA
+20 -3
View File
@@ -377,9 +377,12 @@ const readSymlinkTarget = async (
absolute: string,
path: string,
): Promise<ReferenceSourceEntry> => {
let rawTarget: string;
let rawTarget: Buffer;
try {
rawTarget = await readlink(absolute);
// Keep the symlink's source bytes until UTF-8 validity is known. The
// default string encoding can alias an invalid target with a real U+FFFD
// sibling during the subsequent path lookup.
rawTarget = await readlink(absolute, { encoding: "buffer" });
} catch (cause: unknown) {
// POSIX readlink reports EINVAL when the observed link became a non-link.
// Keep this classification at the syscall, rather than hiding EINVAL from
@@ -388,7 +391,21 @@ const readSymlinkTarget = async (
return changedSymlink(path);
throw cause;
}
const lexicalTarget = resolve(dirname(absolute), rawTarget);
let target: string;
try {
target = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true,
}).decode(rawTarget);
} catch {
return entryFailure(
path,
"symlink",
"unsupported",
`Symlink target is not valid UTF-8 (raw target bytes: ${rawTarget.toString("hex")})`,
);
}
const lexicalTarget = resolve(dirname(absolute), target);
if (!isPathWithinRoot(root, lexicalTarget))
return {
status: "read",
@@ -133,6 +133,63 @@ cliTest.skipIf(process.platform !== "linux")(
},
);
cliTest.skipIf(process.platform === "win32")(
"keeps raw symlink target identities distinct from UTF-8 replacement and BOM handling",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-reference-link-bytes-");
const config = await createTestTempDirectory(
"rea-reference-link-bytes-config-",
);
const rawTarget = Buffer.from([0x62, 0x61, 0x64, 0xff, 0x2e, 0x74, 0x73]);
await Promise.all([
writeFile(join(root, "bad\ufffd.ts"), "export const unicode = 1;\n"),
symlink(rawTarget, join(root, "alias.ts")),
writeFile(join(root, "\ufefftarget.ts"), "export const bom = 1;\n"),
symlink("\ufefftarget.ts", join(root, "bom-alias.ts")),
]);
const result = await cli.run({
arguments: ["import-reference-source", root, "--json"],
environment: {
HOME: config,
XDG_CONFIG_HOME: config,
XDG_CACHE_HOME: config,
},
});
expect(result.exitCode, result.stderr + JSON.stringify(result.json)).toBe(
0,
);
const graph = historicalSourceGraphSchema.parse(result.json);
expect(graph.entries).toContainEqual(
expect.objectContaining({
kind: "symlink",
path: "alias.ts",
target: null,
target_state: "unreadable",
limitations: [expect.stringContaining(rawTarget.toString("hex"))],
}),
);
expect(graph.entries).toContainEqual(
expect.objectContaining({
kind: "file",
path: "bad\ufffd.ts",
content_state: "hashed",
}),
);
expect(graph.entries).toContainEqual(
expect.objectContaining({
kind: "symlink",
path: "bom-alias.ts",
target: "\ufefftarget.ts",
target_state: "internal",
}),
);
expect(
await readlink(join(root, "alias.ts"), { encoding: "buffer" }),
).toEqual(rawTarget);
},
);
cliTest.skipIf(process.platform === "win32")(
"preserves historical literal-backslash entries and their exact owning import base",
async ({ cli }) => {