Repository Public MIT
1import { lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs";2import { dirname, join, resolve, sep } from "node:path";3import { LIMITS } from "./constants";4import { ArchiveError, writeArchive, type TarFile } from "./tar";5import { sha256Hex, unsafePathReason } from "./validate";67/** Filesystem helpers for the CLI. Server-side ingestion never uses this module. */89const DEFAULT_IGNORES = [".git", "node_modules", ".DS_Store", "Thumbs.db", ".skillsignore", "__pycache__", ".venv", ".idea", ".vscode"];1011function compileIgnore(lines: string[]): (rel: string, isDir: boolean) => boolean {12 const pats = lines.map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));13 const res = pats.map((p) => {14 const dirOnly = p.endsWith("/");15 const body = p.replace(/^\/+|\/+$/g, "");16 const anchored = p.startsWith("/") || body.includes("/");17 const rx = body.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*");18 return { dirOnly, re: new RegExp(anchored ? `^${rx}(?:/|$)` : `(?:^|/)${rx}(?:/|$)`) };19 });20 return (rel, isDir) => res.some((r) => (r.dirOnly ? (isDir ? r.re.test(rel + "/") : r.re.test(dirname(rel) + "/")) : r.re.test(rel)));21}2223export interface CollectResult {24 files: TarFile[];25 skipped: { path: string; reason: string }[];26}2728/** Read a package directory. Symlinks are never followed: they are reported and skipped. */29export function collectDirectory(root: string): CollectResult {30 const abs = resolve(root);31 if (!existsSync(abs) || !lstatSync(abs).isDirectory()) throw new ArchiveError("pack-not-a-directory", `${root} is not a directory`);32 const ignoreFile = join(abs, ".skillsignore");33 const ignored = compileIgnore(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8").split("\n") : []);34 const files: TarFile[] = [];35 const skipped: CollectResult["skipped"] = [];36 let total = 0;37 const walk = (dir: string, rel: string) => {38 for (const name of readdirSync(dir).sort()) {39 const p = join(dir, name);40 const r = rel ? `${rel}/${name}` : name;41 const st = lstatSync(p);42 if (DEFAULT_IGNORES.includes(name)) {43 skipped.push({ path: r, reason: "ignored by default" });44 continue;45 }46 if (ignored(r, st.isDirectory())) {47 skipped.push({ path: r, reason: "matched .skillsignore" });48 continue;49 }50 if (st.isSymbolicLink()) {51 skipped.push({ path: r, reason: "symbolic link (never followed or published)" });52 continue;53 }54 if (st.isDirectory()) walk(p, r);55 else if (st.isFile()) {56 if (st.size > LIMITS.maxFileBytes) throw new ArchiveError("file-too-large", `${r} exceeds ${LIMITS.maxFileBytes} bytes`);57 total += st.size;58 if (total > LIMITS.maxUnpackedBytes) throw new ArchiveError("package-too-large", `package exceeds ${LIMITS.maxUnpackedBytes} bytes`);59 if (files.length >= LIMITS.maxFiles) throw new ArchiveError("package-too-many-files", `package has more than ${LIMITS.maxFiles} files`);60 files.push({ path: r.normalize("NFC"), mode: st.mode & 0o111 ? 0o755 : 0o644, data: readFileSync(p) });61 } else skipped.push({ path: r, reason: "not a regular file" });62 }63 };64 walk(abs, "");65 return { files, skipped };66}6768export function packFiles(files: TarFile[]): { archive: Buffer; sha256: string } {69 const archive = writeArchive(files);70 if (archive.length > LIMITS.maxArchiveBytes) throw new ArchiveError("archive-too-large", `archive exceeds ${LIMITS.maxArchiveBytes} bytes`);71 return { archive, sha256: sha256Hex(archive) };72}7374/** Write validated files under dest. Refuses any path that would land outside dest. Never executes anything. */75export function extractTo(files: TarFile[], dest: string): string[] {76 const root = resolve(dest);77 const written: string[] = [];78 for (const f of files) {79 const reason = unsafePathReason(f.path);80 if (reason) throw new ArchiveError("path-unsafe", `refusing to write ${JSON.stringify(f.path)}: ${reason}`);81 const target = resolve(root, f.path);82 if (target !== root && !target.startsWith(root + sep)) throw new ArchiveError("path-unsafe", `refusing to write outside ${root}`);83 mkdirSync(dirname(target), { recursive: true });84 const parent = lstatSync(dirname(target));85 if (parent.isSymbolicLink()) throw new ArchiveError("path-unsafe", `refusing to write through a symlink at ${dirname(target)}`);86 writeFileSync(target, f.data, { mode: f.mode & 0o111 ? 0o755 : 0o644, flag: "wx" });87 written.push(f.path);88 }89 return written;90}91