From 7ba05210feee64b270809e915d7df0055cb4fc94 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:11:41 +0800 Subject: [PATCH] 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 --- docs/cli.md | 5 +- src/reference/ReferenceSourceReaderEntries.ts | 23 +++++++- .../boundary/cli/referenceSourceUtf8.test.ts | 57 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 1f3246e7ff..273f8e4b87 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 09e369c68d..5998eab21a 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -377,9 +377,12 @@ const readSymlinkTarget = async ( absolute: string, path: string, ): Promise => { - 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", diff --git a/tests/boundary/cli/referenceSourceUtf8.test.ts b/tests/boundary/cli/referenceSourceUtf8.test.ts index 33af3c0209..a2451a31ac 100644 --- a/tests/boundary/cli/referenceSourceUtf8.test.ts +++ b/tests/boundary/cli/referenceSourceUtf8.test.ts @@ -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 }) => {