Files
codegraph/__tests__/security.test.ts
T
Colby McHenryandClaude Opus 5 16e17495f4 feat(extraction): content-based generated-file detection (CG-5, #1500)
`isGeneratedFile` was path-only, but Go's own convention is a CONTENT
marker (`// Code generated by <tool>. DO NOT EDIT.`), not a filename one.
A Go monorepo with generated CRUD in ordinarily-named files sitting beside
hand-written use-cases was therefore invisible to every generated-file
down-rank in the codebase — that is #1500.

Measured on kubernetes/client-go (2,453 Go files): the canonical banner
appears in 2,001 of them, the path check flags 0, the new content check
flags exactly those 2,001 — no false positives, no misses.

Design: decide at INDEX time (content is already in memory for parsing),
persist on `files.generated`, read from the DB. Explore never reads file
headers per request.

- `hasGeneratedHeader(content)` recognizes the standard banners — Go's,
  protoc's, `@generated`, `<auto-generated>`, Thrift, OpenAPI Generator,
  FlatBuffers, bindgen, ANTLR. Precision-first and fenced three ways: an
  8KB/60-line header window, a comment-line requirement (leader or open
  block comment), and markers tight enough that prose can't trip them. A
  generator's own source, holding the banner as a string constant in its
  body, is not flagged; neither is this module itself (pinned by test).
- `isGeneratedFile(path)` is unchanged — cheap, sync, still the fallback.
- Schema v9 adds `files.generated` + a PARTIAL index. DDL only, no
  backfill: the flag derives from content the migration cannot see, so
  rows stay 0 until a re-index and every reader unions the flag with the
  path check — an un-migrated index keeps pre-#1500 behavior rather than
  regressing. Re-index required; noted in the CHANGELOG.
- `generatedPredicateFor(paths)` gives ranking a bounded probe + O(1)
  lookups. Bounded, not cached: no invalidation, so a ranking call can
  never serve a verdict the last sync already replaced. Wired into explore
  ranking, findSymbolMatches, findAllSymbols, search (MCP + CLI), the
  context formatter, and the dominant-file/route-file hygiene filters.

Cost (acceptance bar was no measurable index-time regression): a single
unanchored `/generat/i` test over the header rejects ~every hand-written
file before any line splitting. 4.6 µs/file on client-go (worst case —
82% generated). End-to-end `codegraph init` on client-go, n=3 alternating
arms: 5.73s median with detection vs 5.76s path-only baseline; the arms
cross over between runs, so the difference is inside run-to-run noise.

Scope note: generated status remains a stable TIEBREAK at equal score,
exactly where it was. Making it a strong negative signal is CG-10, which
this unblocks by making the signal correct and available.

Two pre-existing tests hard-coded schema version 8; both now track
CURRENT_SCHEMA_VERSION (or the migration table) so future migrations
don't require editing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:13:59 -05:00

692 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Security Tests
*
* Tests for P0/P1 security fixes:
* - FileLock (cross-process locking)
* - Path traversal prevention
* - MCP input validation
* - Atomic writes
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { FileLock, validateProjectPath, validatePathWithinRoot } from '../src/utils';
import CodeGraph from '../src/index';
import { ToolHandler, tools } from '../src/mcp/tools';
import { scanDirectory, isSourceFile } from '../src/extraction';
import { DatabaseConnection, getDatabasePath } from '../src/db';
import { QueryBuilder } from '../src/db/queries';
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-security-test-'));
}
function cleanupTempDir(dir: string): void {
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe('FileLock', () => {
let tempDir: string;
let lockPath: string;
beforeEach(() => {
tempDir = createTempDir();
lockPath = path.join(tempDir, 'test.lock');
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should acquire and release a lock', () => {
const lock = new FileLock(lockPath);
lock.acquire();
expect(fs.existsSync(lockPath)).toBe(true);
const content = fs.readFileSync(lockPath, 'utf-8').trim();
expect(parseInt(content, 10)).toBe(process.pid);
lock.release();
expect(fs.existsSync(lockPath)).toBe(false);
});
it('should prevent double acquisition within same process', () => {
const lock1 = new FileLock(lockPath);
const lock2 = new FileLock(lockPath);
lock1.acquire();
// Second lock should fail because our PID is alive
expect(() => lock2.acquire()).toThrow(/locked by another process/);
lock1.release();
});
it('should detect and remove stale locks from dead processes', () => {
// Write a lock file with a PID that doesn't exist
// PID 99999999 is extremely unlikely to be a real process
fs.writeFileSync(lockPath, '99999999');
const lock = new FileLock(lockPath);
// Should succeed because the PID is dead
expect(() => lock.acquire()).not.toThrow();
lock.release();
});
it('should execute function with withLock', () => {
const lock = new FileLock(lockPath);
const result = lock.withLock(() => {
expect(fs.existsSync(lockPath)).toBe(true);
return 42;
});
expect(result).toBe(42);
expect(fs.existsSync(lockPath)).toBe(false);
});
it('should release lock even if function throws', () => {
const lock = new FileLock(lockPath);
expect(() => {
lock.withLock(() => {
throw new Error('test error');
});
}).toThrow('test error');
expect(fs.existsSync(lockPath)).toBe(false);
});
it('should execute async function with withLockAsync', async () => {
const lock = new FileLock(lockPath);
const result = await lock.withLockAsync(async () => {
expect(fs.existsSync(lockPath)).toBe(true);
return 'async-result';
});
expect(result).toBe('async-result');
expect(fs.existsSync(lockPath)).toBe(false);
});
it('should release lock even if async function throws', async () => {
const lock = new FileLock(lockPath);
await expect(
lock.withLockAsync(async () => {
throw new Error('async error');
})
).rejects.toThrow('async error');
expect(fs.existsSync(lockPath)).toBe(false);
});
it('release should be idempotent', () => {
const lock = new FileLock(lockPath);
lock.acquire();
lock.release();
// Second release should not throw
expect(() => lock.release()).not.toThrow();
});
});
describe('Path Traversal Prevention', () => {
let testDir: string;
let cg: CodeGraph;
beforeEach(async () => {
testDir = createTempDir();
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(
path.join(srcDir, 'hello.ts'),
`export function hello(): string { return "hi"; }\n`
);
cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
});
afterEach(() => {
if (cg) cg.close();
cleanupTempDir(testDir);
});
it('should read code for valid nodes within project', async () => {
const nodes = cg.getNodesByKind('function');
const hello = nodes.find((n) => n.name === 'hello');
expect(hello).toBeDefined();
const code = await cg.getCode(hello!.id);
expect(code).toContain('hello');
});
it('should return null for non-existent node', async () => {
const code = await cg.getCode('does-not-exist');
expect(code).toBeNull();
});
});
describe('Symlink escape prevention (#527)', () => {
// An in-repo symlink whose logical path is inside the project root but whose
// REAL target escapes the root must never be served. validatePathWithinRoot
// is the chokepoint both content-serving read sinks go through (codegraph_node
// includeCode + codegraph_explore source rendering), so it must resolve
// symlinks, not just compare strings. realpathSync the roots so the test's own
// expectations don't trip over /tmp -> /private/tmp on macOS.
let root: string;
let outside: string;
beforeEach(() => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-root-')));
outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-outside-')));
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(path.join(root, 'src', 'in.ts'), 'export const x = 1;\n');
fs.mkdirSync(path.join(outside, 'pkg'));
fs.writeFileSync(path.join(outside, 'pkg', 'secret.txt'), 'TOP-SECRET\n');
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
});
// Symlink creation needs privileges on Windows; skip gracefully if it fails.
const link = (linkPath: string, target: string): boolean => {
try { fs.symlinkSync(target, linkPath); return true; } catch { return false; }
};
it('allows a real file inside the root (and realpaths consistently)', () => {
expect(validatePathWithinRoot(root, 'src/in.ts')).not.toBeNull();
});
it('allows a not-yet-existing path inside the root (ENOENT — files about to be written)', () => {
expect(validatePathWithinRoot(root, 'src/will-write.ts')).not.toBeNull();
});
it('rejects a lexical ../ traversal out of the root', () => {
expect(validatePathWithinRoot(root, `../${path.basename(outside)}/pkg/secret.txt`)).toBeNull();
});
it('rejects an in-repo symlink to an out-of-root FILE', () => {
if (!link(path.join(root, 'escape'), path.join(outside, 'pkg', 'secret.txt'))) return;
expect(validatePathWithinRoot(root, 'escape')).toBeNull();
});
it('rejects a path that escapes through an in-repo symlink to an out-of-root DIR', () => {
if (!link(path.join(root, 'escapedir'), path.join(outside, 'pkg'))) return;
expect(validatePathWithinRoot(root, 'escapedir/secret.txt')).toBeNull();
});
it('still allows an in-repo symlink that stays WITHIN the root (no over-blocking)', () => {
if (!link(path.join(root, 'src', 'inlink.ts'), path.join(root, 'src', 'in.ts'))) return;
expect(validatePathWithinRoot(root, 'src/inlink.ts')).not.toBeNull();
});
// The INDEXING read path opts into following in-root symlinks the directory
// walk already descended into — discovery and the reader must agree, or files
// reached via an in-root symlink-to-outside fail to index (#935). The lexical
// `../` guard is NOT waived, and content-serving sinks never pass the flag.
it('allowSymlinkEscape follows an in-repo symlink to an out-of-root FILE (indexing read)', () => {
if (!link(path.join(root, 'escape'), path.join(outside, 'pkg', 'secret.txt'))) return;
expect(validatePathWithinRoot(root, 'escape', { allowSymlinkEscape: true })).not.toBeNull();
});
it('allowSymlinkEscape follows a path through an in-repo out-of-root DIR symlink (indexing read)', () => {
if (!link(path.join(root, 'escapedir'), path.join(outside, 'pkg'))) return;
expect(validatePathWithinRoot(root, 'escapedir/secret.txt', { allowSymlinkEscape: true })).not.toBeNull();
});
it('allowSymlinkEscape STILL rejects a lexical ../ traversal (guard not waived)', () => {
expect(
validatePathWithinRoot(root, `../${path.basename(outside)}/pkg/secret.txt`, { allowSymlinkEscape: true })
).toBeNull();
});
it('end-to-end: getCode never serves an out-of-root file reached via a dir symlink', async () => {
fs.writeFileSync(path.join(outside, 'pkg', 'leak.ts'),
'export function leaked() { return "LEAKED-ZZZ-9"; }\n');
if (!link(path.join(root, 'vendored'), path.join(outside, 'pkg'))) return;
const cg = CodeGraph.initSync(root, { config: { include: ['**/*.ts'], exclude: [] } });
try {
await cg.indexAll();
// Whether or not extraction followed the dir symlink, NO node may ever
// yield the out-of-root content through getCode.
for (const n of cg.getNodesByKind('function')) {
const code = await cg.getCode(n.id);
expect(code ?? '').not.toContain('LEAKED-ZZZ-9');
}
} finally {
cg.close();
}
});
it('end-to-end (#935): indexes source reached through an in-root dir symlink to outside', async () => {
// The Dota custom-game layout symlinks `game/` and `content/` into an SDK
// tree outside the repo. Before #935 the batch reader's strict symlink-escape
// guard blocked every file under them, so nothing indexed — even though the
// directory walk deliberately followed the symlink to enumerate them. The
// reader now agrees with discovery: the file indexes.
fs.writeFileSync(path.join(outside, 'pkg', 'vendored.ts'),
'export function vendoredHelper() { return "LEAKED-ZZZ-9"; }\n');
if (!link(path.join(root, 'game'), path.join(outside, 'pkg'))) return;
const cg = CodeGraph.initSync(root, { config: { include: ['**/*.ts'], exclude: [] } });
try {
await cg.indexAll();
// The symlinked-in file is now part of the graph...
const names = cg.getNodesByKind('function').map((n) => n.name);
expect(names).toContain('vendoredHelper');
// ...but its out-of-root contents are STILL never served (the #527 sink
// stays strict — indexing relaxes only the read path, not getCode).
for (const n of cg.getNodesByKind('function')) {
expect((await cg.getCode(n.id)) ?? '').not.toContain('LEAKED-ZZZ-9');
}
} finally {
cg.close();
}
});
});
describe('validateProjectPath — sensitive directory blocking', () => {
// POSIX-only: on Windows '/etc' resolves to C:\etc (non-existent), not a
// sensitive dir — the Windows case is covered by the win32-gated test below.
it.runIf(process.platform !== 'win32')('blocks POSIX system directories (exact match)', () => {
expect(validateProjectPath('/')).toMatch(/sensitive system directory/i);
expect(validateProjectPath('/etc')).toMatch(/sensitive system directory/i);
});
it('allows a normal, existing directory', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-validate-'));
try {
expect(validateProjectPath(dir)).toBeNull();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// SENSITIVE_PATHS stores the Windows entries lowercase and validateProjectPath
// matches via resolved.toLowerCase(), so 'C:\\Windows' and 'c:\\windows' are
// both blocked. path.resolve is platform-specific, so this only runs on Windows.
it.runIf(process.platform === 'win32')(
'blocks Windows system directories regardless of case',
() => {
expect(validateProjectPath('C:\\Windows')).toMatch(/sensitive system directory/i);
expect(validateProjectPath('c:\\windows')).toMatch(/sensitive system directory/i);
expect(validateProjectPath('C:\\WINDOWS\\System32')).toMatch(/sensitive system directory/i);
}
);
});
describe('MCP Input Validation', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
beforeEach(async () => {
testDir = createTempDir();
const srcDir = path.join(testDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(
path.join(srcDir, 'example.ts'),
`export function exampleFunc(): void {}\nexport class ExampleClass {}\n`
);
cg = CodeGraph.initSync(testDir, {
config: { include: ['**/*.ts'], exclude: [] },
});
await cg.indexAll();
handler = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.close();
cleanupTempDir(testDir);
});
it('should reject non-string query in codegraph_search', async () => {
const result = await handler.execute('codegraph_search', { query: null });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('non-empty string');
});
it('should reject empty string query in codegraph_search', async () => {
const result = await handler.execute('codegraph_search', { query: '' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('non-empty string');
});
it('should accept valid query in codegraph_search', async () => {
const result = await handler.execute('codegraph_search', { query: 'example' });
expect(result.isError).toBeFalsy();
});
it('should clamp limit to valid range in codegraph_search', async () => {
// Extremely large limit should still work (clamped to 100)
const result = await handler.execute('codegraph_search', { query: 'example', limit: 999999 });
expect(result.isError).toBeFalsy();
});
it('should reject non-string symbol in codegraph_callers', async () => {
const result = await handler.execute('codegraph_callers', { symbol: 123 });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('non-empty string');
});
it('should reject non-string query in codegraph_explore', async () => {
const result = await handler.execute('codegraph_explore', { query: undefined });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('non-empty string');
});
it('should truncate oversized tool output', async () => {
// Force a huge result set through codegraph_search; the response must be
// truncated with the sentinel rather than flooding the agent's context.
const many = Array.from({ length: 3000 }, (_, i) => ({
node: {
id: `n${i}`,
name: `symbol_${i}_${'x'.repeat(40)}`,
kind: 'function',
filePath: `src/very/deep/path/file_${i}.ts`,
startLine: 1,
endLine: 2,
language: 'typescript',
},
score: 1,
}));
const fakeCg = {
searchNodes: () => many,
// Search down-ranks generated files, and since #1500 that verdict comes
// from the index (path convention content banner) rather than the
// filename alone. No database here — none of these paths is generated.
generatedFilePredicate: () => () => false,
};
const fakeHandler = new ToolHandler(fakeCg as unknown as CodeGraph);
const result = await fakeHandler.execute('codegraph_search', { query: 'x' });
expect(result.isError).toBeFalsy();
expect(result.content[0].text).toContain('... (output truncated)');
});
it('should reject non-string symbol in codegraph_impact', async () => {
const result = await handler.execute('codegraph_impact', { symbol: [] });
expect(result.isError).toBe(true);
});
it('should reject non-string symbol in codegraph_node', async () => {
const result = await handler.execute('codegraph_node', { symbol: false });
expect(result.isError).toBe(true);
});
it('should reject non-string symbol in codegraph_callees', async () => {
const result = await handler.execute('codegraph_callees', { symbol: {} });
expect(result.isError).toBe(true);
});
it('should handle NaN limit gracefully', async () => {
const result = await handler.execute('codegraph_search', { query: 'example', limit: 'abc' });
expect(result.isError).toBeFalsy();
});
it('should handle negative limit gracefully', async () => {
const result = await handler.execute('codegraph_search', { query: 'example', limit: -5 });
expect(result.isError).toBeFalsy();
});
// #230: getCodeGraph must reject a sensitive system directory passed as
// projectPath before opening it. The error surfaces through execute()'s
// catch as an isError result. /etc is sensitive on POSIX; C:\Windows on
// Windows (path.resolve is platform-specific, so each case is gated).
it.runIf(process.platform !== 'win32')(
'rejects a sensitive POSIX projectPath (/etc) via the MCP handler',
async () => {
const result = await handler.execute('codegraph_search', {
query: 'example',
projectPath: '/etc',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/sensitive system directory/i);
}
);
it.runIf(process.platform === 'win32')(
'rejects a sensitive Windows projectPath (C:\\Windows) via the MCP handler',
async () => {
const result = await handler.execute('codegraph_search', {
query: 'example',
projectPath: 'C:\\Windows',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toMatch(/sensitive system directory/i);
}
);
});
describe('Atomic Writes', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should not leave temp files on success', () => {
// We test this indirectly through the config-writer module
// by checking that no .tmp files remain after writing
const configDir = path.join(tempDir, '.claude');
fs.mkdirSync(configDir, { recursive: true });
const testFile = path.join(configDir, 'test.json');
// Simulate what atomicWriteFileSync does
const tmpPath = testFile + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, '{"test": true}');
fs.renameSync(tmpPath, testFile);
expect(fs.existsSync(testFile)).toBe(true);
expect(fs.existsSync(tmpPath)).toBe(false);
const content = JSON.parse(fs.readFileSync(testFile, 'utf-8'));
expect(content.test).toBe(true);
});
});
describe('Source file detection (isSourceFile)', () => {
it('selects files by supported extension', () => {
expect(isSourceFile('src/index.ts')).toBe(true);
expect(isSourceFile('src/deep/nested/file.ts')).toBe(true);
expect(isSourceFile('src/component.tsx')).toBe(true);
expect(isSourceFile('lib/util.js')).toBe(true);
expect(isSourceFile('src/main.py')).toBe(true);
});
it('rejects unsupported extensions and extensionless files', () => {
expect(isSourceFile('src/component.css')).toBe(false);
expect(isSourceFile('README.md')).toBe(false);
expect(isSourceFile('Makefile')).toBe(false);
expect(isSourceFile('.gitignore')).toBe(false);
});
it('matches regardless of leading dot directories', () => {
expect(isSourceFile('.hidden/index.ts')).toBe(true);
});
});
describe('JSON.parse Error Boundaries in DB', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should not crash when node has malformed JSON in decorators column', () => {
const dbPath = path.join(tempDir, 'test.db');
const db = DatabaseConnection.initialize(dbPath);
const queries = new QueryBuilder(db.getDb());
// Insert a node with malformed JSON in the decorators column
db.getDb().prepare(`
INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, decorators, is_exported, is_async, is_static, is_abstract, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'test-node-1', 'function', 'myFunc', 'myFunc', 'test.ts', 'typescript',
1, 5, 0, 0,
'{not valid json!!!}', // malformed decorators
0, 0, 0, 0, Date.now()
);
// Should not throw - should return node with undefined decorators
const node = queries.getNodeById('test-node-1');
expect(node).not.toBeNull();
expect(node!.name).toBe('myFunc');
expect(node!.decorators).toBeUndefined();
db.close();
});
it('should not crash when edge has malformed JSON in metadata column', () => {
const dbPath = path.join(tempDir, 'test.db');
const db = DatabaseConnection.initialize(dbPath);
const queries = new QueryBuilder(db.getDb());
// Insert two nodes first
const insertNode = db.getDb().prepare(`
INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, is_exported, is_async, is_static, is_abstract, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertNode.run('node-a', 'function', 'funcA', 'funcA', 'a.ts', 'typescript', 1, 5, 0, 0, 0, 0, 0, 0, Date.now());
insertNode.run('node-b', 'function', 'funcB', 'funcB', 'b.ts', 'typescript', 1, 5, 0, 0, 0, 0, 0, 0, Date.now());
// Insert edge with malformed metadata
db.getDb().prepare(`
INSERT INTO edges (source, target, kind, metadata)
VALUES (?, ?, ?, ?)
`).run('node-a', 'node-b', 'calls', 'broken json {{{');
// Should not throw - should return edge with undefined metadata
const edges = queries.getOutgoingEdges('node-a');
expect(edges.length).toBe(1);
expect(edges[0].source).toBe('node-a');
expect(edges[0].target).toBe('node-b');
expect(edges[0].metadata).toBeUndefined();
db.close();
});
it('should not crash when file record has malformed JSON in errors column', () => {
const dbPath = path.join(tempDir, 'test.db');
const db = DatabaseConnection.initialize(dbPath);
const queries = new QueryBuilder(db.getDb());
// Insert a file with malformed errors JSON
db.getDb().prepare(`
INSERT INTO files (path, content_hash, language, size, modified_at, indexed_at, node_count, errors)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run('test.ts', 'abc123', 'typescript', 100, Date.now(), Date.now(), 5, 'not-an-array');
// Should not throw - should return file with undefined errors
const file = queries.getFileByPath('test.ts');
expect(file).not.toBeNull();
expect(file!.path).toBe('test.ts');
expect(file!.errors).toBeUndefined();
db.close();
});
});
describe('Symlink Cycle Detection', () => {
let tempDir: string;
beforeEach(() => {
tempDir = createTempDir();
});
afterEach(() => {
cleanupTempDir(tempDir);
});
it('should handle symlink cycle without infinite loop', () => {
// Create directory structure with a symlink cycle
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(path.join(srcDir, 'index.ts'), 'export const x = 1;\n');
// Create a symlink from src/loop -> tempDir (parent directory)
try {
fs.symlinkSync(tempDir, path.join(srcDir, 'loop'), 'dir');
} catch {
// Skip test if symlinks not supported (e.g., Windows without admin)
return;
}
// This should complete without hanging
const files = scanDirectory(tempDir);
// Should find the real file but not loop infinitely
expect(files).toContain('src/index.ts');
// Should not find duplicates via the symlink path
const indexFiles = files.filter(f => f.endsWith('index.ts'));
expect(indexFiles.length).toBe(1);
});
it('should follow valid symlinks to directories', () => {
// Create source directory with a file
const realDir = path.join(tempDir, 'real');
fs.mkdirSync(realDir);
fs.writeFileSync(path.join(realDir, 'hello.ts'), 'export function hello() {}\n');
// Create a symlink to realDir
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
try {
fs.symlinkSync(realDir, path.join(srcDir, 'linked'), 'dir');
} catch {
return;
}
const files = scanDirectory(tempDir);
// Should find files from both the real dir and via the symlink
// But deduplicate since they resolve to the same real path
expect(files.some(f => f.includes('hello.ts'))).toBe(true);
});
it('should skip broken symlinks gracefully', () => {
const srcDir = path.join(tempDir, 'src');
fs.mkdirSync(srcDir);
fs.writeFileSync(path.join(srcDir, 'valid.ts'), 'export const y = 2;\n');
try {
fs.symlinkSync('/nonexistent/path', path.join(srcDir, 'broken'), 'dir');
} catch {
return;
}
// Should not throw
const files = scanDirectory(tempDir);
expect(files).toContain('src/valid.ts');
});
});