Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions __tests__/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,5 +429,90 @@ describe('FileWatcher', () => {

cg.unwatch();
});

it('should auto-sync changes inside a symlinked directory (symlink to project dir, #770)', async () => {
// Set up a separate "external" project directory
const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-external-'));
const externalSrc = path.join(externalDir, 'src');
fs.mkdirSync(externalSrc);
fs.writeFileSync(path.join(externalSrc, 'external.ts'), 'export const external = 1;');

// Create a symlink inside testDir pointing to the external project
const symlinkPath = path.join(testDir, 'linked-project');
fs.symlinkSync(externalDir, symlinkPath);

cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();

const initialStats = cg.getStats();
const initialNodes = initialStats.nodeCount;

cg.watch({ debounceMs: 300 });
// Let the watcher install (and discover the symlinked dir) before writing.
await new Promise((r) => setTimeout(r, 200));

// Write to a file inside the symlinked directory — the watcher must pick it up.
const symlinkedFile = path.join(symlinkPath, 'src', 'new-in-symlink.ts');
fs.writeFileSync(symlinkedFile, 'export function newInSymlink() { return 99; }');

// Wait for auto-sync to pick it up (OS event delivery + debounce).
await waitFor(
() => {
const stats = cg.getStats();
return stats.nodeCount > initialNodes;
},
8000
);

// The new function inside the symlink should be in the graph.
const results = cg.searchNodes('newInSymlink');
expect(results.length).toBeGreaterThan(0);

cg.unwatch();

// Clean up external dir
fs.rmSync(externalDir, { recursive: true, force: true });
});

it('should not crash on symlink cycles (A→B, B→A or A→B→A)', async () => {
// Create two dirs with mutual symlinks: dirA/pointsToB → dirB, dirB/pointsToA → dirA
const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cycle-a-'));
const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cycle-b-'));
const srcA = path.join(dirA, 'src');
const srcB = path.join(dirB, 'src');
fs.mkdirSync(srcA);
fs.mkdirSync(srcB);
fs.symlinkSync(srcB, path.join(dirA, 'pointsToB'));
fs.symlinkSync(srcA, path.join(dirB, 'pointsToA'));
fs.writeFileSync(path.join(srcA, 'a.ts'), 'export const a = 1;');
fs.writeFileSync(path.join(srcB, 'b.ts'), 'export const b = 2;');

cg = CodeGraph.initSync(dirA, { config: { include: ['**/*.ts'], exclude: [] } });
await cg.indexAll();

const initialNodes = cg.getStats().nodeCount;

cg.watch({ debounceMs: 200 });
await new Promise((r) => setTimeout(r, 300));

// Write to both dirs
fs.writeFileSync(path.join(srcA, 'newA.ts'), 'export const newA = 3;');
fs.writeFileSync(path.join(srcB, 'newB.ts'), 'export const newB = 4;');

await new Promise((r) => setTimeout(r, 1000));

cg.unwatch();

// Should not have crashed, and new files should be indexed
const finalNodes = cg.getStats().nodeCount;
expect(finalNodes).toBeGreaterThan(initialNodes);
const searchResults = cg.searchNodes('newA');
expect(searchResults.length).toBeGreaterThan(0);

fs.rmSync(dirA, { recursive: true, force: true });
fs.rmSync(dirB, { recursive: true, force: true });
});
});
});
14 changes: 7 additions & 7 deletions src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,7 @@ export class ExtractionOrchestrator {
getAllFiles: () => files,
getProjectRoot: () => rootDir,
fileExists: (relativePath: string) => {
const full = validatePathWithinRoot(rootDir, relativePath);
const full = validatePathWithinRoot(rootDir, relativePath, true);
if (!full) return false;
try {
return fs.existsSync(full);
Expand All @@ -615,7 +615,7 @@ export class ExtractionOrchestrator {
}
},
readFile: (relativePath: string) => {
const full = validatePathWithinRoot(rootDir, relativePath);
const full = validatePathWithinRoot(rootDir, relativePath, true);
if (!full) return null;
try {
return fs.readFileSync(full, 'utf-8');
Expand Down Expand Up @@ -903,7 +903,7 @@ export class ExtractionOrchestrator {
const fileContents = await Promise.all(
batch.map(async (fp) => {
try {
const fullPath = validatePathWithinRoot(this.rootDir, fp);
const fullPath = validatePathWithinRoot(this.rootDir, fp, true); // allowSymlinkedDirs: watched symlinks are pre-validated
if (!fullPath) {
logWarn('Path traversal blocked in batch reader', { filePath: fp });
return { filePath: fp, content: null as string | null, stats: null as fs.Stats | null, error: new Error('Path traversal blocked') };
Expand Down Expand Up @@ -1057,7 +1057,7 @@ export class ExtractionOrchestrator {

let content: string;
try {
const fullPath = validatePathWithinRoot(this.rootDir, filePath);
const fullPath = validatePathWithinRoot(this.rootDir, filePath, true);
if (!fullPath) continue;
content = await fsp.readFile(fullPath, 'utf-8');
} catch {
Expand Down Expand Up @@ -1102,7 +1102,7 @@ export class ExtractionOrchestrator {

let fullContent: string;
try {
const fullPath = validatePathWithinRoot(this.rootDir, filePath);
const fullPath = validatePathWithinRoot(this.rootDir, filePath, true);
if (!fullPath) continue;
fullContent = await fsp.readFile(fullPath, 'utf-8');
} catch {
Expand Down Expand Up @@ -1209,7 +1209,7 @@ export class ExtractionOrchestrator {
* Index a single file
*/
async indexFile(relativePath: string): Promise<ExtractionResult> {
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
const fullPath = validatePathWithinRoot(this.rootDir, relativePath, true);

if (!fullPath) {
return {
Expand Down Expand Up @@ -1257,7 +1257,7 @@ export class ExtractionOrchestrator {
stats: fs.Stats
): Promise<ExtractionResult> {
// Prevent path traversal
const fullPath = validatePathWithinRoot(this.rootDir, relativePath);
const fullPath = validatePathWithinRoot(this.rootDir, relativePath, true);
if (!fullPath) {
logWarn('Path traversal blocked in indexFileWithContent', { relativePath });
return {
Expand Down
33 changes: 33 additions & 0 deletions src/sync/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ export class FileWatcher {
private dirCapWarned = false;
/** Test-only inert mode: started, but with no OS watcher installed. */
private inert = false;
/**
* Resolved realpath of directories already watched (or skipped). Prevents
* infinite recursion via symlink cycles — mirrors the same guard used in
* the indexer's `walk()` (extraction/index.ts).
*/
private visitedRealDirs = new Set<string>();
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Files seen by the watcher since the last successful sync — populated on
Expand Down Expand Up @@ -351,7 +357,33 @@ export class FileWatcher {
const child = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (this.shouldIgnoreDir(child)) continue;
// Track regular dirs too so a symlink pointing to an already-visited
// real path is also skipped (A→B via isDirectory, then symlink→B).
try {
this.visitedRealDirs.add(fs.realpathSync(child));
} catch { /* ignore — will be caught by watch failure below */ }
this.watchTree(child, markExisting);
} else if (entry.isSymbolicLink()) {
// Follow directory symlinks so symlinked project dirs are watched.
// Mirrors the indexer's walk() in extraction/index.ts.
try {
const realTarget = fs.realpathSync(child);
const stat = fs.statSync(realTarget);
if (stat.isDirectory()) {
if (this.visitedRealDirs.has(realTarget)) {
logDebug('Skipping already-visited directory (symlink cycle)', { dir: child, realTarget });
continue;
}
this.visitedRealDirs.add(realTarget);
if (!this.shouldIgnoreDir(child)) {
this.watchTree(child, markExisting);
}
} else if (markExisting && stat.isFile()) {
this.handleChange(normalizePath(path.relative(this.projectRoot, child)));
}
} catch {
logDebug('Skipping broken symlink in watcher', { path: child });
}
} else if (markExisting && entry.isFile()) {
this.handleChange(normalizePath(path.relative(this.projectRoot, child)));
}
Expand Down Expand Up @@ -477,6 +509,7 @@ export class FileWatcher {
}
this.dirWatchers.clear();
this.dirCapWarned = false;
this.visitedRealDirs.clear();
this.inert = false;

this.pendingFiles.clear();
Expand Down
15 changes: 14 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,19 @@ function isWithinDir(child: string, parent: string): boolean {
*
* @param projectRoot - The project root directory
* @param filePath - The (relative or absolute) file path to validate
* @param allowSymlinkedDirs - When true, skips the realpath check so that files
* inside symlinked directories (whose real target may be outside the root) are
* allowed. Use this for paths that come from the file watcher — the watcher
* already validated that symlinks are within the monitored set, so re-checking
* would incorrectly reject valid auto-sync events (#770).
* @returns The resolved absolute path (realpath when it exists), or null if it
* escapes the root
*/
export function validatePathWithinRoot(projectRoot: string, filePath: string): string | null {
export function validatePathWithinRoot(
projectRoot: string,
filePath: string,
allowSymlinkedDirs = false
): string | null {
const resolved = path.resolve(projectRoot, filePath);
const normalizedRoot = path.resolve(projectRoot);

Expand All @@ -107,6 +116,10 @@ export function validatePathWithinRoot(projectRoot: string, filePath: string): s

// 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.
// Skip this check when allowSymlinkedDirs is true (watcher-validated paths).
if (allowSymlinkedDirs) {
return resolved;
}
try {
const realRoot = fs.realpathSync(normalizedRoot);
const realResolved = fs.realpathSync(resolved);
Expand Down