/** * v0.31.2 walker hardening regression tests. * * Closes the bug class where `gbrain sync --strategy code` could hang on * repos with self-referencing symlinks (the gstack `.claude/skills/gstack * -> repo-root` dev pattern was the trigger). Pins: * * 1. Self-referencing symlink does not loop. * 2. Symlink chain through real dirs does not loop (inode-set defense). * 3. MAX_WALK_DEPTH=32 bailout is structural backstop. * 4. Strategy filter (code/markdown/auto) admits the right files. * 5. Dot-prefixed dirs (.git/.claude/.raw) and node_modules still skipped. * 6. Multimodal preservation under markdown-strategy (codex C5). * 7. Deterministic ordering — runImport's index-based resume depends on it * (codex C8). */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs'; import { basename, join, sep } from 'path'; import { tmpdir } from 'os'; import { collectSyncableFiles } from '../src/commands/import.ts'; import { withEnv } from './helpers/with-env.ts'; /** * PLATFORM NOTE: collectSyncableFiles returns absolute paths built with * join(), so on win32 they carry '\' — correct, since callers feed them to * readFileSync. Two POSIX-only idioms silently no-op there: `split('/').pop()` * returns the WHOLE path instead of a basename, and `replace(tmp, '')` leaves * a '\'-prefixed tail. Both turn the `startsWith('/.git')` style negative * assertions below into vacuous passes, so the skip-list regressions this file * exists to pin would stop being tested at all. * * Expectations therefore stay NATIVE: only the separator comes from `path`, * the directory structure in each one stays hand-written. Byte-identical to * the old POSIX literals when sep is '/'. */ let tmp: string; /** Path relative to `tmp`, native separators, leading separator preserved. */ const relToTmp = (p: string): string => p.slice(tmp.length); beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'gbrain-walker-')); }); afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); describe('collectSyncableFiles symlink + cycle hardening', () => { test('1. self-referencing symlink does not loop', async () => { await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { writeFileSync(join(tmp, 'notes.md'), '# top\n'); // Symlink "loop" inside tempdir pointing back to itself. symlinkSync(tmp, join(tmp, 'loop')); const t0 = Date.now(); const files = collectSyncableFiles(tmp, { strategy: 'markdown' }); const ms = Date.now() - t0; expect(ms).toBeLessThan(1000); // would hang if walker followed the loop expect(files).toContain(join(tmp, 'notes.md')); expect(files.every(f => !f.includes(`${sep}loop${sep}`))).toBe(true); }); }); test('2. symlink chain through real dirs does not loop', async () => { await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { // a/ contains a real subdir b/, which contains a symlink "back" -> a. // The lstat skip catches "back" before recursion. If somehow it // missed, the inode-cycle Map catches the second visit to a/. mkdirSync(join(tmp, 'a/b'), { recursive: true }); writeFileSync(join(tmp, 'a/b/leaf.md'), 'leaf\n'); symlinkSync(join(tmp, 'a'), join(tmp, 'a/b/back')); const t0 = Date.now(); const files = collectSyncableFiles(tmp, { strategy: 'markdown' }); const ms = Date.now() - t0; expect(ms).toBeLessThan(2000); expect(files).toContain(join(tmp, 'a/b/leaf.md')); }); }); test('3. max-depth bailout terminates pathological deep trees', async () => { await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { // Build a 40-level real directory tree. 32 is the default cap; the // file at the deepest level is intentionally past the bailout so we // can assert it is NOT collected (and the walker still terminates). let cur = tmp; for (let i = 0; i < 40; i++) { cur = join(cur, `d${i}`); mkdirSync(cur); } writeFileSync(join(cur, 'deep.md'), 'deep\n'); writeFileSync(join(tmp, 'shallow.md'), 'shallow\n'); const files = collectSyncableFiles(tmp, { strategy: 'markdown' }); expect(files).toContain(join(tmp, 'shallow.md')); expect(files.every(f => !f.includes(`${sep}d35${sep}`))).toBe(true); // past depth 32 }); }); test('4. strategy filter admits the right files', async () => { await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { writeFileSync(join(tmp, 'notes.md'), '# r\n'); writeFileSync(join(tmp, 'foo.ts'), '// f\n'); writeFileSync(join(tmp, 'bar.py'), '# b\n'); const code = collectSyncableFiles(tmp, { strategy: 'code' }); const markdown = collectSyncableFiles(tmp, { strategy: 'markdown' }); const auto = collectSyncableFiles(tmp, { strategy: 'auto' }); expect(code.map(f => basename(f)).sort()).toEqual(['bar.py', 'foo.ts']); expect(markdown.map(f => basename(f))).toEqual(['notes.md']); expect(auto.map(f => basename(f)).sort()).toEqual(['bar.py', 'foo.ts', 'notes.md']); }); }); test('5. dot-prefixed dirs and node_modules still skipped', async () => { await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { writeFileSync(join(tmp, 'real.md'), 'r\n'); mkdirSync(join(tmp, '.git')); writeFileSync(join(tmp, '.git/HEAD'), 'ref: refs/heads/main\n'); mkdirSync(join(tmp, '.claude/skills'), { recursive: true }); writeFileSync(join(tmp, '.claude/skills/SKILL.md'), 's\n'); mkdirSync(join(tmp, 'node_modules/foo'), { recursive: true }); writeFileSync(join(tmp, 'node_modules/foo/index.md'), 'no\n'); const files = collectSyncableFiles(tmp, { strategy: 'markdown' }); const names = files.map(f => relToTmp(f)); expect(names).toContain(`${sep}real.md`); expect(names.every(n => !n.startsWith(`${sep}.git`))).toBe(true); expect(names.every(n => !n.startsWith(`${sep}.claude`))).toBe(true); expect(names.every(n => !n.startsWith(`${sep}node_modules`))).toBe(true); }); }); test('6. multimodal preservation under markdown strategy (codex C5)', async () => { writeFileSync(join(tmp, 'r.md'), 'r\n'); writeFileSync(join(tmp, 'p.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); writeFileSync(join(tmp, 'j.jpg'), Buffer.from([0xff, 0xd8, 0xff])); // Off → markdown only. await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { const off = collectSyncableFiles(tmp, { strategy: 'markdown' }); expect(off.map(f => basename(f)).sort()).toEqual(['r.md']); }); // On → markdown + images (preserves v0.27.1 F2 collectMarkdownFiles // behavior; codex C5 carve-out). await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: 'true' }, () => { const on = collectSyncableFiles(tmp, { strategy: 'markdown' }); expect(on.map(f => basename(f)).sort()).toEqual(['j.jpg', 'p.png', 'r.md']); }); }); test('7. deterministic ordering — two walks return identical arrays (codex C8)', async () => { await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => { // runImport's checkpoint resume at import.ts:68-74 is index-based // against a sorted file list. Unstable order skips the wrong files // on resume. writeFileSync(join(tmp, 'b.md'), 'b\n'); writeFileSync(join(tmp, 'a.md'), 'a\n'); mkdirSync(join(tmp, 'sub')); writeFileSync(join(tmp, 'sub/c.md'), 'c\n'); const first = collectSyncableFiles(tmp, { strategy: 'markdown' }); const second = collectSyncableFiles(tmp, { strategy: 'markdown' }); expect(first).toEqual(second); // Sorted: a.md, b.md, sub/c.md (lexicographic on absolute paths). expect(first.map(f => relToTmp(f))).toEqual([ `${sep}a.md`, `${sep}b.md`, `${sep}sub${sep}c.md`, ]); }); }); });