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:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* `fileExists` must not probe outside the project root (#1631).
|
||||
*
|
||||
* `resolveRelativeImport` hands this callback paths built with
|
||||
* `path.relative(projectRoot, basePath)`, which can carry `../` segments, and
|
||||
* `path.join` does not clamp — so a crafted relative import in an indexed file
|
||||
* made the resolver stat arbitrary absolute paths. Nothing outside is read (the
|
||||
* content sinks are guarded separately, #527) and no edge is produced, but the
|
||||
* probe itself is an existence oracle driven by repository content.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ReferenceResolver } from '../src/resolution';
|
||||
import type { QueryBuilder } from '../src/db/queries';
|
||||
|
||||
describe('fileExists containment (#1631)', () => {
|
||||
let sandbox: string;
|
||||
let projectRoot: string;
|
||||
|
||||
/** The resolver only needs a project root here — `fileExists` never queries. */
|
||||
const contextFor = (root: string) =>
|
||||
new ReferenceResolver(root, {} as unknown as QueryBuilder).getResolutionContext();
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-test-'));
|
||||
projectRoot = path.join(sandbox, 'proj');
|
||||
fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'src', 'a.js'), 'export const a = 1;');
|
||||
// A real file two levels above the root, as the reproduction in #1631 has.
|
||||
fs.mkdirSync(path.join(sandbox, 'outside'), { recursive: true });
|
||||
fs.writeFileSync(path.join(sandbox, 'outside', 'secret.js'), 'export const secret = 42;');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('still reports files inside the root', () => {
|
||||
expect(contextFor(projectRoot).fileExists('src/a.js')).toBe(true);
|
||||
expect(contextFor(projectRoot).fileExists('src/missing.js')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to probe a path that escapes the root, even though it exists', () => {
|
||||
const escaping = path.join('..', 'outside', 'secret.js');
|
||||
// Baseline: the target really is there — so `false` can only come from the guard.
|
||||
expect(fs.existsSync(path.join(projectRoot, escaping))).toBe(true);
|
||||
|
||||
expect(contextFor(projectRoot).fileExists(escaping)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps following an in-root symlink whose target is outside the root (#935)', () => {
|
||||
const link = path.join(projectRoot, 'vendor');
|
||||
try {
|
||||
fs.symlinkSync(path.join(sandbox, 'outside'), link, 'dir');
|
||||
} catch {
|
||||
return; // symlink creation not permitted (e.g. Windows without privilege)
|
||||
}
|
||||
// Lexically inside the root, physically outside — the indexing tier allows this.
|
||||
expect(contextFor(projectRoot).fileExists(path.join('vendor', 'secret.js'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user