diff --git a/scripts/source.mjs b/scripts/source.mjs index db4b0d8d3c5..31b617ab26e 100644 --- a/scripts/source.mjs +++ b/scripts/source.mjs @@ -24,11 +24,16 @@ export function profileForSlug(slug) { } export function readText(file) { - return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; + const stats = lstatOrMissing(file); + if (!stats) return ""; + assertNotSymlink(file, stats); + return fs.readFileSync(file, "utf8"); } export function readJson(file, fallback = null) { - if (!fs.existsSync(file)) return fallback; + const stats = lstatOrMissing(file); + if (!stats) return fallback; + assertNotSymlink(file, stats); try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { @@ -42,21 +47,43 @@ export function writeText(file, text) { } export function markdownFiles(dir) { - if (!fs.existsSync(dir)) return []; - return fs - .readdirSync(dir) - .filter((name) => name.endsWith(".md")) - .sort() - .map((name) => path.join(dir, name)); + return listedFiles(dir, ".md"); } export function jsonFiles(dir) { - if (!fs.existsSync(dir)) return []; - return fs - .readdirSync(dir) - .filter((name) => name.endsWith(".json")) - .sort() - .map((name) => path.join(dir, name)); + return listedFiles(dir, ".json"); +} + +function lstatOrMissing(file) { + try { + return fs.lstatSync(file); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +function assertNotSymlink(file, stats) { + if (stats.isSymbolicLink()) { + throw new Error(`[clawsweeper-state] source contains symlink: ${file}`); + } +} + +function listedFiles(dir, suffix) { + const dirStats = lstatOrMissing(dir); + if (!dirStats) return []; + assertNotSymlink(dir, dirStats); + const files = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const file = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`[clawsweeper-state] source contains symlink: ${file}`); + } + if (entry.isFile() && entry.name.endsWith(suffix)) files.push(file); + } + return files; } export function parseFrontMatter(markdown) { diff --git a/test/source.test.mjs b/test/source.test.mjs index 209bca9aff3..314ce3c3795 100644 --- a/test/source.test.mjs +++ b/test/source.test.mjs @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { readJson } from "../scripts/source.mjs"; +import { jsonFiles, markdownFiles, readJson, readText } from "../scripts/source.mjs"; test("readJson returns null fallback for missing file", () => { assert.equal(readJson("/nonexistent/path/does-not-exist.json"), null); @@ -45,3 +45,52 @@ test("readJson throws on malformed JSON without a fallback", () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test("markdownFiles and jsonFiles skip missing directories", () => { + const missing = path.join(os.tmpdir(), "clawsweeper-source-missing", "nope"); + assert.deepEqual(markdownFiles(missing), []); + assert.deepEqual(jsonFiles(missing), []); +}); + +test("markdownFiles lists regular markdown files", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-source-")); + try { + const file = path.join(dir, "1.md"); + fs.writeFileSync(file, "ok\n", "utf8"); + assert.deepEqual(markdownFiles(dir), [file]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("source readers reject a symlinked markdown file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-source-")); + const external = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-source-ext-")); + try { + const target = path.join(external, "secret.md"); + fs.writeFileSync(target, "escaped\n", "utf8"); + const link = path.join(dir, "1.md"); + fs.symlinkSync(target, link, "file"); + assert.throws(() => markdownFiles(dir), /source contains symlink/); + assert.throws(() => readText(link), /source contains symlink/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + } +}); + +test("source readers reject a symlinked json file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-source-")); + const external = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-source-ext-")); + try { + const target = path.join(external, "secret.json"); + fs.writeFileSync(target, '{"escaped":true}\n', "utf8"); + const link = path.join(dir, "run.json"); + fs.symlinkSync(target, link, "file"); + assert.throws(() => jsonFiles(dir), /source contains symlink/); + assert.throws(() => readJson(link), /source contains symlink/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + } +});