Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 41 additions & 14 deletions scripts/source.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
51 changes: 50 additions & 1 deletion test/source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
});
Loading