fix(reference): preserve exact paths and unify symlink lookup

Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
This commit is contained in:
morluto
2026-10-11 00:03:20 +08:00
parent 5d0a883605
commit 2ca3ad2386
5 changed files with 105 additions and 61 deletions
+22 -12
View File
@@ -1,10 +1,12 @@
import { createHash } from "node:crypto";
import { posix } from "node:path";
import { logicalPathEscapesRoot } from "../domain/artifactIdentity.js";
import {
logicalPathEscapesRoot,
normalizeJoinedLogicalPath,
} from "../domain/artifactIdentity.js";
createReferenceSourcePathLookup,
isPortableAbsoluteReferenceTarget,
type ReferenceSourcePathLookup,
} from "../domain/referenceSourcePathIdentity.js";
import {
classifyReferenceSourcePath,
detectReferenceSourceLanguage,
@@ -83,11 +85,11 @@ const failedEntry = (
const resolveInternalSpecifier = (
fromPath: string,
specifier: string,
filePaths: ReadonlySet<string>,
lookupPath: ReferenceSourcePathLookup,
): { to: string; resolution: "internal" | "unresolved" } => {
if (posix.isAbsolute(specifier))
return { to: specifier, resolution: "unresolved" };
const normalized = normalizeJoinedLogicalPath(
const normalized = posix.normalize(
posix.join(posix.dirname(fromPath), specifier),
);
if (logicalPathEscapesRoot(normalized))
@@ -113,7 +115,7 @@ const resolveInternalSpecifier = (
const stem = normalized.slice(0, -posix.extname(normalized).length);
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
}
const match = candidates.find((candidate) => filePaths.has(candidate));
const match = lookupPath.resolveCandidates(candidates);
return match === undefined
? { to: normalized, resolution: "unresolved" }
: { to: match, resolution: "internal" };
@@ -121,7 +123,7 @@ const resolveInternalSpecifier = (
const appendFile = (
entry: Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>,
filePaths: ReadonlySet<string>,
lookupPath: ReferenceSourcePathLookup,
output: ParsedReferenceSourceEntries,
): void => {
const language = detectReferenceSourceLanguage(entry.path);
@@ -146,7 +148,7 @@ const appendFile = (
const resolved = resolveInternalSpecifier(
relationship.from_path,
relationship.to,
filePaths,
lookupPath,
);
output.relationships.push({
...relationship,
@@ -159,11 +161,11 @@ const appendFile = (
const appendReadEntry = (
entry: Extract<ReferenceSourceEntry, { status: "read" }>,
filePaths: ReadonlySet<string>,
lookupPath: ReferenceSourcePathLookup,
output: ParsedReferenceSourceEntries,
): void => {
if (entry.kind === "file") {
appendFile(entry, filePaths, output);
appendFile(entry, lookupPath, output);
return;
}
const classifications = classifyReferenceSourcePath(entry.path);
@@ -180,7 +182,14 @@ const appendReadEntry = (
output.entries.push({
path: entry.path,
kind: "symlink",
target: entry.target,
// The reader resolves inventory identities from its root; the persisted
// graph represents relative symlink targets from the link's directory.
target:
entry.targetState === "internal" ||
(entry.targetState === "missing" &&
!isPortableAbsoluteReferenceTarget(entry.target))
? posix.relative(posix.dirname(entry.path), entry.target) || "."
: entry.target,
target_state: entry.targetState,
classifications,
limitations: [],
@@ -199,10 +208,11 @@ export const parseReferenceSourceEntries = (
parseFailures: [],
limitations: [...read.limitations],
};
const lookupPath = createReferenceSourcePathLookup(filePaths);
for (const entry of read.entries) {
if (signal?.aborted === true) break;
if (entry.status === "failed") output.entries.push(failedEntry(entry));
else appendReadEntry(entry, filePaths, output);
else appendReadEntry(entry, lookupPath, output);
}
return output;
};
+9 -18
View File
@@ -4,7 +4,10 @@ import { z } from "zod";
import { canonicalJson } from "./comparisonSemantics.js";
import { digestCanonicalValue } from "./canonicalDigest.js";
import { normalizeJoinedLogicalPath } from "./artifactIdentity.js";
import {
createReferenceSourcePathLookup,
isPortableAbsoluteReferenceTarget,
} from "./referenceSourcePathIdentity.js";
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
@@ -82,18 +85,13 @@ const symlinkTargetSchema = z
.min(1)
.refine(
(target) =>
isPortableAbsoluteSymlinkTarget(target) ||
isPortableAbsoluteReferenceTarget(target) ||
(!target.startsWith("/") &&
!target.includes("\\") &&
posix.normalize(target) === target),
"Expected a normalized POSIX symlink target",
);
const isPortableAbsoluteSymlinkTarget = (target: string): boolean =>
target.startsWith("/") ||
/^[A-Za-z]:[\\/]/u.test(target) ||
/^\\\\[^\\/]+[\\/][^\\/]+/u.test(target);
const sourceSymlinkSchema = z.union([
z.strictObject({
...entryBaseShape,
@@ -314,18 +312,11 @@ const checkSymlinks = (
paths: ReadonlySet<string>,
context: z.RefinementCtx,
): void => {
const normalizedPathCounts = new Map<string, number>();
for (const path of paths) {
const identity = normalizeJoinedLogicalPath(path);
normalizedPathCounts.set(
identity,
(normalizedPathCounts.get(identity) ?? 0) + 1,
);
}
const lookupPath = createReferenceSourcePathLookup(paths);
for (const [index, entry] of graph.entries.entries()) {
if (entry.kind !== "symlink") continue;
if (entry.target === null) continue;
const absolute = isPortableAbsoluteSymlinkTarget(entry.target);
const absolute = isPortableAbsoluteReferenceTarget(entry.target);
if (
(entry.target_state === "external" && !absolute) ||
(entry.target_state === "internal" && absolute)
@@ -339,11 +330,11 @@ const checkSymlinks = (
const resolved = posix.normalize(
posix.join(posix.dirname(entry.path), entry.target),
);
const normalized = normalizeJoinedLogicalPath(resolved);
if (
resolved.startsWith("../") ||
resolved === ".." ||
(!paths.has(resolved) && normalizedPathCounts.get(normalized) !== 1)
// The inventory root is implicit rather than an entry with path ".".
(resolved !== "." && lookupPath.resolve(resolved) === undefined)
)
context.addIssue({
code: "custom",
+33
View File
@@ -0,0 +1,33 @@
export interface ReferenceSourcePathLookup {
readonly resolve: (path: string) => string | undefined;
readonly resolveCandidates: (paths: readonly string[]) => string | undefined;
}
/**
* Reference inventories retain filesystem spelling. Prefer that exact identity;
* canonical Unicode equivalence is a fallback only when it identifies one entry.
*/
export const createReferenceSourcePathLookup = (
paths: Iterable<string>,
): ReferenceSourcePathLookup => {
const exact = new Set(paths);
const canonical = new Map<string, string | null>();
for (const path of exact) {
const key = path.normalize("NFC");
canonical.set(key, canonical.has(key) ? null : path);
}
const equivalent = (path: string): string | undefined =>
canonical.get(path.normalize("NFC")) ?? undefined;
return {
resolve: (path) => (exact.has(path) ? path : equivalent(path)),
resolveCandidates: (candidates) =>
candidates.find((path) => exact.has(path)) ??
candidates.map(equivalent).find((path) => path !== undefined),
};
};
/** Symlink evidence can originate on a different OS from the graph reader. */
export const isPortableAbsoluteReferenceTarget = (target: string): boolean =>
target.startsWith("/") ||
/^[A-Za-z]:[\\/]/u.test(target) ||
/^\\\\[^\\/]+[\\/][^\\/]+/u.test(target);
+40 -31
View File
@@ -1,8 +1,7 @@
import type { BigIntStats } from "node:fs";
import { readdir, readlink, realpath } from "node:fs/promises";
import type { BigIntStats, Dir, Dirent } from "node:fs";
import { opendir, readlink, realpath } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
cancelled,
entryFailure,
@@ -47,20 +46,50 @@ export const traverseDirectory = async (
);
return { ok: true, value: undefined };
}
const names = await readDirectoryNames(current.path);
const directories: PendingDirectory[] = [];
let readFailure: string | undefined;
let handle: Dir | undefined;
try {
handle = await opendir(current.path);
} catch (cause: unknown) {
readFailure = directoryReadFailure(cause);
}
if (handle !== undefined) {
const iterator = handle[Symbol.asyncIterator]();
try {
for (;;) {
let child: IteratorResult<Dirent>;
try {
child = await iterator.next();
} catch (cause: unknown) {
readFailure = directoryReadFailure(cause);
break;
}
if (child.done) break;
const result = await processEntry(
state,
current,
child.value.name,
directories,
);
if (!result.ok) return result;
}
} finally {
await handle.close().catch(() => undefined);
}
}
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
if (!names.ok) {
if (readFailure !== undefined) {
state.entries.push(
entryFailure(
pathFromRoot(state.root, current.path),
"directory",
"io",
names.message,
readFailure,
),
);
return { ok: true, value: undefined };
}
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
const after = await validateDirectory(
state.root,
state.rootIdentity,
@@ -85,35 +114,15 @@ export const traverseDirectory = async (
kind: "directory",
path: pathFromRoot(state.root, current.path),
});
const directories: PendingDirectory[] = [];
for (const name of names.value) {
const result = await processEntry(state, current, name, directories);
if (!result.ok) return result;
}
directories.reverse();
state.pending.push(...directories);
return { ok: true, value: undefined };
};
const readDirectoryNames = async (
path: string,
): Promise<
| { readonly ok: true; readonly value: string[] }
| { readonly ok: false; readonly message: string }
> => {
try {
return {
ok: true,
value: (await readdir(path)).sort(compareUnicodeCodePoints),
};
} catch (cause: unknown) {
const message = filesystemFailureDetail(
cause,
"Directory could not be read",
);
if (message === undefined) throw cause;
return { ok: false, message };
}
const directoryReadFailure = (cause: unknown): string => {
const message = filesystemFailureDetail(cause, "Directory could not be read");
if (message === undefined) throw cause;
return message;
};
const readMetadata = async (
@@ -31,6 +31,7 @@ export type ReferenceSourceEntry =
readonly status: "read";
readonly kind: "symlink";
readonly path: string;
/** Internal and relative missing targets are resolved from the inventory root. */
readonly target: string;
readonly targetState: "internal" | "external" | "missing";
}