fix(resolution): keep the existence probe inside the project root (#1631)

Fixes #1631.

Rebased contributor PR #1632 onto main (post-#1749). Lexical containment for `fileExists` filesystem fallback via `lexicalPathWithinRoot`; #935 in-root symlink behaviour preserved.
This commit is contained in:
Max Hsu
2026-09-08 00:32:05 -05:00
committed by GitHub
parent edcd36e5f0
commit a6f52d737a
4 changed files with 100 additions and 6 deletions
+13 -2
View File
@@ -26,6 +26,7 @@ import { loadProjectAliases, type AliasMap } from './path-aliases';
import { loadGoModule, type GoModule } from './go-module';
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
import { logDebug } from '../errors';
import { lexicalPathWithinRoot } from '../utils';
import type { ReExport } from './types';
import { LRUCache } from './lru-cache';
@@ -538,8 +539,18 @@ export class ReferenceResolver {
return true;
}
}
// Fall back to filesystem for files not yet indexed
const fullPath = path.join(this.projectRoot, filePath);
// Fall back to filesystem for files not yet indexed. `path.join` does
// not clamp, and relative-import resolution hands us paths carrying
// `../` segments, so the probe has to be contained (#1631): a path
// outside the root can never be an indexed project file, and the
// `knownFiles` check above already answered for everything that is.
// Lexical containment only: this is a per-candidate hot path, and the
// symlink half of `validatePathWithinRoot` costs two `realpathSync`
// calls per probe (~70x slower here). It would also be wrong to apply
// — indexing deliberately follows in-root symlinks whose targets live
// outside the root (#935), so only the `../` escape is refused.
const fullPath = lexicalPathWithinRoot(this.projectRoot, filePath);
if (fullPath === null) return false;
try {
return fs.existsSync(fullPath);
} catch (error) {
+21 -4
View File
@@ -80,6 +80,24 @@ function isWithinDir(child: string, parent: string): boolean {
return c === p || c.startsWith(p + path.sep);
}
/**
* The lexical half of {@link validatePathWithinRoot}, on its own.
*
* Returns the resolved absolute path when `filePath` stays inside
* `projectRoot` after `../` segments are applied, or null when it escapes.
* No filesystem access — for callers on a hot path that only need to refuse a
* lexical escape, and for which the realpath half would be both unnecessary
* and far too expensive (the existence probe in resolution's `fileExists`,
* #1631: two `realpathSync` calls per probe made it ~70x slower).
*
* This is NOT a substitute for `validatePathWithinRoot` on any path whose
* contents get served — those must keep the symlink-aware check (#527).
*/
export function lexicalPathWithinRoot(projectRoot: string, filePath: string): string | null {
const resolved = path.resolve(projectRoot, filePath);
return isWithinDir(resolved, path.resolve(projectRoot)) ? resolved : null;
}
/**
* Validate that a file path stays within the project root, resolving symlinks.
*
@@ -112,14 +130,13 @@ export function validatePathWithinRoot(
filePath: string,
options?: { allowSymlinkEscape?: boolean }
): string | null {
const resolved = path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot);
// 1. Lexical containment — cheap, catches `../` traversal. Applies even on
// the indexing read path: a crafted `../` escape is still rejected.
if (!isWithinDir(resolved, normalizedRoot)) {
const resolved = lexicalPathWithinRoot(projectRoot, filePath);
if (resolved === null) {
return null;
}
const normalizedRoot = path.resolve(projectRoot);
// 2. Symlink-aware containment — resolve symlinks on both sides and re-check,
// so an in-repo symlink whose real target escapes the root is rejected.