SPB Git

Repository Public MIT

node.ts 4.5 KB 91 lines typescript

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
Files main
Menu

Theme