diff --git a/src/scan.js b/src/scan.js index a193ea2..a6f492c 100644 --- a/src/scan.js +++ b/src/scan.js @@ -34,11 +34,30 @@ export function collectFiles(target) { if (CODE_EXT.has(e) || TEXT_EXT.has(e) || (e === "" && hasShebang(target))) out.push(target); return out; } + const seenDirs = new Set(); + const dirInode = (dir) => { + try { + const s = statSync(dir); + if (!s.isDirectory()) return null; + return `${s.dev}:${s.ino}`; + } catch { + return null; + } + }; + const walk = (dir) => { + const inode = dirInode(dir); + if (!inode || seenDirs.has(inode)) return; + seenDirs.add(inode); for (const name of readdirSync(dir)) { if (SKIP_DIR.has(name)) continue; const p = join(dir, name); - const s = statSync(p); + let s; + try { + s = statSync(p); + } catch { + continue; + } if (s.isDirectory()) walk(p); else if (s.isFile()) { const e = extname(name).toLowerCase(); diff --git a/test/skill-audit.test.js b/test/skill-audit.test.js index 17e8e91..51fa748 100644 --- a/test/skill-audit.test.js +++ b/test/skill-audit.test.js @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { basename, dirname, join, relative } from "node:path"; @@ -267,6 +267,35 @@ test("hardening: browser creds, persistence, anti-forensics, dynamic exec", () = assert.ok(scanText(py, "x.py", null).some((x) => x.rule === "SKILL-OBF-003")); }); + +test("collectFiles terminates when directory symlinks form a cycle", (t) => { + const root = mkdtempSync(join(tmpdir(), "skill-audit-symlink-cycle-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const nested = join(root, "nested"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, "SKILL.md"), "# skill\n"); + symlinkSync(root, join(nested, "loop"), "dir"); + + const start = Date.now(); + const files = collectFiles(root); + assert.ok(Date.now() - start < 2000, "collectFiles should not hang on symlink cycles"); + assert.deepEqual(files.map((file) => basename(file)).sort(), ["SKILL.md"]); +}); + +test("collectFiles follows benign directory symlinks without duplicating scans", (t) => { + const root = mkdtempSync(join(tmpdir(), "skill-audit-symlink-ok-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const real = join(root, "real"); + mkdirSync(real, { recursive: true }); + writeFileSync(join(real, "SKILL.md"), "# skill\n"); + writeFileSync(join(real, "run.sh"), "echo ok\n"); + symlinkSync(real, join(root, "alias"), "dir"); + + const files = collectFiles(root).map((file) => relative(root, file)).sort(); + assert.equal(files.length, 2); + assert.deepEqual(files.map((file) => basename(file)).sort(), ["SKILL.md", "run.sh"]); +}); + test("collectFiles skips venv and .venv directories", (t) => { const root = mkdtempSync(join(tmpdir(), "skill-audit-skip-venv-")); t.after(() => rmSync(root, { recursive: true, force: true }));