Skip to content

Commit 086096e

Browse files
committed
Merge branch 'feature/canonical-paths' - add symlink-aware cycle detection
2 parents 4f82a83 + 0cf60ab commit 086096e

2 files changed

Lines changed: 116 additions & 10 deletions

File tree

src/imports.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect, beforeAll, afterAll } from "bun:test";
2-
import { expandImports, hasImports } from "./imports";
3-
import { mkdtemp, rm } from "node:fs/promises";
2+
import { expandImports, hasImports, toCanonicalPath } from "./imports";
3+
import { mkdtemp, rm, symlink } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66

@@ -270,3 +270,87 @@ test("expandImports handles glob patterns", async () => {
270270
expect(result).toContain("<a path=");
271271
expect(result).toContain("<b path=");
272272
});
273+
274+
// Canonical path tests
275+
test("toCanonicalPath resolves symlinks to real path", async () => {
276+
// Create a real file
277+
const realFile = join(testDir, "real-file.md");
278+
await Bun.write(realFile, "Real content");
279+
280+
// Create a symlink to it
281+
const linkFile = join(testDir, "link-to-real.md");
282+
await symlink(realFile, linkFile);
283+
284+
// Both should resolve to the same canonical path
285+
const canonicalReal = toCanonicalPath(realFile);
286+
const canonicalLink = toCanonicalPath(linkFile);
287+
288+
expect(canonicalReal).toBe(canonicalLink);
289+
});
290+
291+
test("toCanonicalPath returns original path for non-existent files", () => {
292+
const nonExistent = join(testDir, "does-not-exist.md");
293+
const result = toCanonicalPath(nonExistent);
294+
expect(result).toBe(nonExistent);
295+
});
296+
297+
test("toCanonicalPath handles regular files without symlinks", async () => {
298+
const regularFile = join(testDir, "regular.md");
299+
await Bun.write(regularFile, "Regular content");
300+
301+
const canonical = toCanonicalPath(regularFile);
302+
// For a regular file, canonical path resolves system symlinks too (e.g., /var -> /private/var on macOS)
303+
// The canonical path should end with the same relative path
304+
expect(canonical.endsWith("regular.md")).toBe(true);
305+
// And calling it twice should give the same result
306+
expect(toCanonicalPath(canonical)).toBe(canonical);
307+
});
308+
309+
test("expandImports detects circular import via symlink", async () => {
310+
// Create a file that imports itself via a symlink
311+
// File A imports symlink-to-A, which points to A -> circular!
312+
const fileA = join(testDir, "symlink-cycle-a.md");
313+
const symlinkToA = join(testDir, "symlink-to-a.md");
314+
315+
// Create the symlink first
316+
await Bun.write(fileA, "placeholder");
317+
await symlink(fileA, symlinkToA);
318+
319+
// Now update fileA to import via the symlink
320+
await Bun.write(fileA, "A imports @./symlink-to-a.md");
321+
322+
// This should detect the cycle even though paths are different
323+
const content = "@./symlink-cycle-a.md";
324+
await expect(expandImports(content, testDir)).rejects.toThrow("Circular import detected");
325+
});
326+
327+
test("expandImports detects indirect circular import via symlink", async () => {
328+
// A -> B -> symlink-to-A (which points to A)
329+
const fileA = join(testDir, "indirect-a.md");
330+
const fileB = join(testDir, "indirect-b.md");
331+
const symlinkToA = join(testDir, "indirect-link-to-a.md");
332+
333+
// Create files and symlink
334+
await Bun.write(fileA, "A imports @./indirect-b.md");
335+
await Bun.write(fileB, "B imports @./indirect-link-to-a.md");
336+
await symlink(fileA, symlinkToA);
337+
338+
// This should detect the cycle: A -> B -> symlink-to-A (= A)
339+
const content = "@./indirect-a.md";
340+
await expect(expandImports(content, testDir)).rejects.toThrow("Circular import detected");
341+
});
342+
343+
test("expandImports allows same content via different files (not symlinks)", async () => {
344+
// Two different files with the same content should NOT be a cycle
345+
const file1 = join(testDir, "same-content-1.md");
346+
const file2 = join(testDir, "same-content-2.md");
347+
348+
await Bun.write(file1, "Same content");
349+
await Bun.write(file2, "Same content");
350+
351+
const content = "@./same-content-1.md @./same-content-2.md";
352+
const result = await expandImports(content, testDir);
353+
354+
// Should work fine - not a cycle
355+
expect(result).toBe("Same content Same content");
356+
});

src/imports.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { resolve, dirname, relative, basename } from "path";
2+
import { realpathSync } from "fs";
23
import { homedir } from "os";
34
import { Glob } from "bun";
45
import ignore from "ignore";
@@ -140,6 +141,23 @@ function resolveImportPath(importPath: string, currentFileDir: string): string {
140141
return resolve(currentFileDir, expanded);
141142
}
142143

144+
/**
145+
* Resolve a path to its canonical form (resolving symlinks)
146+
* This ensures that symlinks to the same file are detected as identical
147+
* for cycle detection purposes.
148+
*
149+
* @param filePath - The path to resolve
150+
* @returns The canonical (real) path, or the original path if resolution fails
151+
*/
152+
export function toCanonicalPath(filePath: string): string {
153+
try {
154+
return realpathSync(filePath);
155+
} catch {
156+
// File might not exist yet, or other error - return original path
157+
return filePath;
158+
}
159+
}
160+
143161
/**
144162
* Check if a path contains glob characters
145163
*/
@@ -620,18 +638,21 @@ async function processFileImport(
620638
// Regular file import
621639
const resolvedPath = resolveImportPath(importPath, currentFileDir);
622640

623-
// Check for circular imports
624-
if (stack.has(resolvedPath)) {
625-
const cycle = [...stack, resolvedPath].join(" -> ");
626-
throw new Error(`Circular import detected: ${cycle}`);
627-
}
628-
629-
// Check if file exists
641+
// Check if file exists first (needed for canonical path resolution)
630642
const file = Bun.file(resolvedPath);
631643
if (!await file.exists()) {
632644
throw new Error(`Import not found: ${importPath} (resolved to ${resolvedPath})`);
633645
}
634646

647+
// Resolve to canonical path for cycle detection (handles symlinks)
648+
const canonicalPath = toCanonicalPath(resolvedPath);
649+
650+
// Check for circular imports using canonical path
651+
if (stack.has(canonicalPath)) {
652+
const cycle = [...stack, canonicalPath].join(" -> ");
653+
throw new Error(`Circular import detected: ${cycle}`);
654+
}
655+
635656
// Check file size before reading
636657
if (exceedsLimit(file.size)) {
637658
throw new FileSizeLimitError(resolvedPath, file.size);
@@ -649,8 +670,9 @@ async function processFileImport(
649670
const content = await file.text();
650671

651672
// Recursively process imports in the imported file
673+
// Use canonical path in stack for consistent cycle detection
652674
const newStack = new Set(stack);
653-
newStack.add(resolvedPath);
675+
newStack.add(canonicalPath);
654676

655677
return expandImports(content, dirname(resolvedPath), newStack, verbose);
656678
}

0 commit comments

Comments
 (0)