Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 8fe1f18d4aa9cc74ddbb2ceb315de08df6126bf0
parent 4a7b3697ae1cc65050d8aff4849fcb1d1033dff4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 13:12:09 -0400

common: the source mirror's gate and tree pages (sourceAudit, sourceTree, sourceManifest)

sourceAudit.ts is the gate `archilyzer source publish` runs before anything is
staged for the site: every object of a git dir in ONE `cat-file
--batch-all-objects --batch` stream (blobs and commits/tags whole, trees by
entry name), every staged file and path (a .gz decompressed, a symlink
refused), and gitleaks when it is installed (a WARNING when not). The report
names a literal `#n (x…, len L)` and masks every occurrence inside a context
window, a half-shown one included. sourceTree.ts writes one dependency-free
index.html per directory of the raw tree, URL-encoding the repo's bracketed
names and refusing a tracked index.html or a symlink. sourceManifest.ts is the
leaf contract the homepage reads.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Acommon/lib/sourceManifest.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/sourceAudit.test.ts | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/sourceAudit.ts | 628+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/sourceTree.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/sourceTree.ts | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1160 insertions(+), 0 deletions(-)

diff --git a/common/lib/sourceManifest.ts b/common/lib/sourceManifest.ts @@ -0,0 +1,93 @@ +// The published source's contract: where `archilyzer source publish` +// (common/publish/source.ts) puts things on the project site, and the shape of +// the manifest it writes LAST, beside them (public/source/manifest.json). The +// homepage's /source/ page (homepage/app/lib/source.ts) reads the same shape. +// +// A leaf: it imports only lib/project.ts (itself import-free), so the homepage +// can pull it into a server component without dragging node: modules along. +// +// The mirror is a DUMB-HTTP git repository: static files only (HEAD, +// info/refs, objects/info/packs, the packs), which `git clone` reads with no +// server-side git at all. Its directory is `archilyzer.git`, never a path +// segment named `.git` — wrangler's upload ignore list drops `**/.git` +// silently, and Cloudflare's managed rules block `/.git/` paths. + +import { PROJECT_URL } from "./project"; + +export const SOURCE_MANIFEST_VERSION = 1; + +/** The mirror's directory under /source/. */ +export const MIRROR_DIR = "archilyzer.git"; + +/** What a reader types: `git clone <CLONE_URL>`. */ +export const CLONE_URL = `${PROJECT_URL}/source/${MIRROR_DIR}`; + +/** The raw tree's generated index. */ +export const TREE_HREF = "/source/tree/"; + +/** The manifest's public path. */ +export const SOURCE_MANIFEST_HREF = "/source/manifest.json"; + +/** + * The tarball's public path — the one the Downloads page has always linked + * (homepage/app/lib/snapshot.ts SNAPSHOT_HREF). Stable by design: the date and + * commit live in snapshot.json beside it. + */ +export const TARBALL_HREF = "/downloads/archilyzer-source.tar.gz"; + +export type SourceManifest = { + version: typeof SOURCE_MANIFEST_VERSION; + generatedAt: string; + branch: "main"; + // The PRIVATE repository's main, which the mirror reflects. Its history is + // never rewritten; the mirror is generated from a fresh clone of it. + sourceCommit: string; + // The mirror's main: a different id for the same history, because paths + // were scrubbed on the way out. + mirrorHead: string; + // The mirror head's subject line (audited like every other commit). + subject: string; + // Everything else the step published: the mirror, the tree and its + // indexes, the tarball and snapshot.json (not this manifest itself). + files: number; + bytes: number; + mirror: { files: number; bytes: number; packs: number }; + // The tracked files of main, extracted; `files`/`bytes` do not count the + // generated index.html pages, `dirs` counts the directories (each has one). + tree: { files: number; dirs: number; bytes: number }; + tarball: { href: string; bytes: number; sha256: string }; + cloneUrl: string; + treeHref: string; + // What the gate checked: how many denied literals, how many git objects + // (commits among them) it read, and whether gitleaks ran. + audit: { + literals: number; + objects: number; + commits: number; + gitleaks: "clean" | "skipped"; + }; + // The tools that made it (a filter-repo upgrade may change mirrorHead). + tools: { git: string; filterRepo: string }; +}; + +const HEX40 = /^[0-9a-f]{40}$/; +const HEX64 = /^[0-9a-f]{64}$/; + +/** + * The manifest, or null when `value` is not one this code can trust: version + * 1, 40-hex commit ids, a 64-hex tarball sha. The homepage additionally + * requires the files it describes to be present (homepage/app/lib/source.ts). + */ +export function parseSourceManifest(value: unknown): SourceManifest | null { + if (!value || typeof value !== "object") return null; + const m = value as Partial<SourceManifest>; + if (m.version !== SOURCE_MANIFEST_VERSION) return null; + if (typeof m.mirrorHead !== "string" || !HEX40.test(m.mirrorHead)) return null; + if (typeof m.sourceCommit !== "string" || !HEX40.test(m.sourceCommit)) return null; + if (!m.tarball || typeof m.tarball.sha256 !== "string" || !HEX64.test(m.tarball.sha256)) { + return null; + } + if (typeof m.tarball.bytes !== "number" || typeof m.generatedAt !== "string") return null; + if (!m.mirror || !m.tree || !m.audit || typeof m.subject !== "string") return null; + return m as SourceManifest; +} diff --git a/common/publish/sourceAudit.test.ts b/common/publish/sourceAudit.test.ts @@ -0,0 +1,175 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; +import { execFileSync } from "node:child_process"; +import { + SourceRefusal, + auditBare, + auditFiles, + auditObjects, + formatAuditReport, + literalLabel, + maskLiterals, + parseDenylist, + redactHit, + runGitleaks, + scanBuffer, + treeEntryNames, + type Literal, +} from "./sourceAudit"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The source mirror's gate (sourceAudit.ts): the literal list, the byte scan, +// the redaction, the object walk over a real temp repo, the staged-file sweep +// and gitleaks' absence. Every repo is built in the OS temp dir; the git +// variables a hook or a wrapper might export are cleared first so nothing here +// can reach the real repo. +for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"]) { + delete process.env[key]; +} + +const TMP = mkdtempSync(path.join(os.tmpdir(), "source-audit-")); +after(() => rmSync(TMP, { recursive: true, force: true })); + +// The planted literal every test hunts for. Never a real name. +const PLANTED = "plantedhome"; +const lits = (...xs: string[]): Literal[] => xs.map((x) => ({ bytes: Buffer.from(x), ci: false })); + +let n = 0; +function repo(): string { + const dir = path.join(TMP, `r${n++}`); + mkdirSync(dir); + const git = (...args: string[]) => execFileSync("git", args, { cwd: dir, stdio: "pipe" }); + git("init", "-q", "-b", "main"); + git("config", "user.name", "audit test"); + git("config", "user.email", "audit@example.invalid"); + git("config", "commit.gpgsign", "false"); + return dir; +} +function commit(dir: string, files: Record<string, string>, message: string, author?: string): void { + for (const [f, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); + writeFileSync(path.join(dir, f), text); + } + const git = (...args: string[]) => execFileSync("git", args, { cwd: dir, stdio: "pipe" }); + git("add", "-A"); + git("commit", "-q", "-m", message, ...(author ? ["--author", author] : [])); +} + +test("the denylist: one literal a line, i: for any case, comments and blanks skipped, duplicates once", () => { + const list = parseDenylist(`# a comment\n\n${PLANTED}\ni:MiXeD\n ${PLANTED} \r\ni:\n`); + assert.equal(list.length, 2); + assert.deepEqual(list[0], { bytes: Buffer.from(PLANTED), ci: false }); + assert.deepEqual(list[1], { bytes: Buffer.from("mixed"), ci: true }); + // Named by position, first character and length — never by its bytes. + assert.equal(literalLabel(list, 0), `#1 (p…, len ${PLANTED.length})`); + assert.equal(literalLabel(list, 1), "#2 (m…, len 5, any case)"); +}); + +test("scanBuffer finds every occurrence; an i: literal matches any ASCII case", () => { + const buf = Buffer.from(`a ${PLANTED} b PlantedHome c mixed MIXED`); + const hits = scanBuffer(buf, [...lits(PLANTED), ...parseDenylist("i:plantedHOME\ni:mixed")]); + assert.deepEqual( + hits.map((h) => [h.lit, h.offset]), + [[0, 2], [1, 2], [1, 16], [2, 30], [2, 36]], + ); +}); + +test("redaction masks every occurrence in the window, a half-shown one included, and dots the unprintable", () => { + const list = lits(PLANTED); + // The second occurrence starts 20 bytes after the first ends: the ±24-byte + // window shows only its first four bytes, which must be masked too. + const buf = Buffer.from(`\u0001st ${PLANTED}${"-".repeat(20)}${PLANTED} tail`); + const hits = scanBuffer(buf, list); + assert.equal(hits.length, 2); + const shown = redactHit(buf, hits[0], hits); + assert.equal(shown, `.st [REDACTED]${"-".repeat(20)}[REDACTED]`); + assert.ok(!shown.includes("plan"), shown); + assert.equal(maskLiterals(`/srv/${PLANTED}/x ${PLANTED}`, list), "/srv/[REDACTED]/x [REDACTED]"); + assert.equal(maskLiterals("nothing here", list), "nothing here"); +}); + +test("a tree's entry names are what is scanned, not its binary ids", () => { + const id = Buffer.alloc(20, 0x70); // 'p' x20: would match "ppp" if ids were read + const tree = Buffer.concat([ + Buffer.from("100644 a.txt\0"), id, + Buffer.from(`40000 ${PLANTED}\0`), id, + ]); + assert.equal(treeEntryNames(tree, 20).toString("latin1"), `a.txt\0${PLANTED}\0`); + assert.equal(scanBuffer(treeEntryNames(tree, 20), lits("ppp")).length, 0); +}); + +test("the object walk finds a literal in a blob, a commit message, an author line and a tree entry name", async () => { + const dir = repo(); + commit(dir, { "README.md": "clean\n" }, "first"); + commit(dir, { "notes.txt": `path /srv/${PLANTED}/data\n` }, "a blob carries it"); + commit(dir, { "README.md": "clean 2\n" }, `the message says /srv/${PLANTED}`); + commit(dir, { "README.md": "clean 3\n" }, "an identity", `Planted <${PLANTED}@example.invalid>`); + commit(dir, { [`dir-${PLANTED}/x.txt`]: "x\n" }, "a name"); + const result = await auditObjects(path.join(dir, ".git"), lits(PLANTED)); + const kinds = result.hits.map((h) => h.kind).sort(); + // The message and the author line are one commit object each. + assert.deepEqual(kinds, ["blob", "commit", "commit", "tree"]); + assert.equal(result.commits, 5); + const objects = execFileSync("git", ["count-objects", "-v"], { cwd: dir }).toString(); + assert.equal(result.objects, Number(/^count: (\d+)/m.exec(objects)![1]), "every object was read"); + for (const h of result.hits) assert.ok(!h.context?.includes(PLANTED), h.context); + // The report names the literal by number and never prints it. + const report = formatAuditReport(result, lits(PLANTED), { + scrubFile: path.join(os.homedir(), ".config", "archilyzer", "source-scrub.txt"), + }).join("\n"); + assert.ok(!report.includes(PLANTED), report); + assert.match(report, /AUDIT REFUSED: 4 hits/); + assert.match(report, /#1 \(p…, len 11\): 1 in blob, 2 in commits, 1 in tree/); + assert.match(report, /add a rule to ~\/\.config\/archilyzer\/source-scrub\.txt or drop the file from history, then re-run\.$/); +}); + +test("a clean repo audits clean, with gitleaks skipped when it is not on PATH", async () => { + const dir = repo(); + commit(dir, { "a.txt": "nothing to see\n" }, "clean"); + const logs: string[] = []; + const result = await auditBare(path.join(dir, ".git"), lits(PLANTED), { + scratch: TMP, + onLog: (l) => logs.push(l), + signal: new AbortController().signal, + gitleaks: "gitleaks-not-installed-here", + }); + assert.equal(result.hits.length, 0); + assert.equal(result.gitleaks, "skipped"); + assert.match(logs.join("\n"), /WARNING: gitleaks-not-installed-here is not on PATH — the secret scan is skipped/); + assert.match(formatAuditReport(result, lits(PLANTED), { scrubFile: "/x" })[0], /^\[source\] audit clean: \d+ objects \(1 commit\)/); + const skipped = await runGitleaks(path.join(dir, ".git"), { + bin: "gitleaks", + scratch: TMP, + literals: [], + onLog: () => {}, + signal: new AbortController().signal, + env: { PATH: path.join(TMP, "empty-path") }, + }); + assert.equal(skipped.status, "skipped"); +}); + +test("the staged-file sweep reads contents, names and gzip'd bytes decompressed, skips packs, refuses a symlink", async () => { + const dir = path.join(TMP, "stage"); + mkdirSync(path.join(dir, "tree", `d-${PLANTED}`), { recursive: true }); + writeFileSync(path.join(dir, "tree", "ok.txt"), "fine\n"); + writeFileSync(path.join(dir, "tree", "bad.txt"), `x ${PLANTED} y\n`); + writeFileSync(path.join(dir, "tree", `d-${PLANTED}`, "f.txt"), "fine\n"); + writeFileSync(path.join(dir, "t.tar.gz"), gzipSync(Buffer.from(`inside ${PLANTED}`))); + writeFileSync(path.join(dir, "pack-1.pack"), PLANTED); // the object walk's job + const result = await auditFiles(dir, lits(PLANTED)); + assert.deepEqual(result.hits.map((h) => h.where).sort(), [ + "t.tar.gz", + "tree/bad.txt", + "tree/d-[REDACTED]", + "tree/d-[REDACTED]/f.txt", + ]); + assert.equal(result.files, 5); + symlinkSync("/etc/hostname", path.join(dir, "tree", "link")); + await assert.rejects(auditFiles(dir, lits(PLANTED)), (err) => err instanceof SourceRefusal && /symlink/.test(err.message)); +}); diff --git a/common/publish/sourceAudit.ts b/common/publish/sourceAudit.ts @@ -0,0 +1,628 @@ +// The source mirror's gate: does anything about to be published carry a +// literal the operator denied? (`archilyzer source publish`, source.ts.) +// +// Three sweeps, all of them byte searches for the SAME literal list (the +// denylist plus every scrub rule's left side — source.ts builds it): +// - auditObjects: EVERY object in a git dir, read in one +// `git cat-file --batch-all-objects --batch` stream — blobs whole, commits +// and tags whole (message AND the author/committer/tagger lines), trees by +// entry NAME. Reachable or not: a leftover of the private history in a +// pack would be published with it, so it is read with it. +// - auditFiles: every file staged beside the mirror (the raw tree, the index +// pages, the tarball decompressed, the ref files, the manifest), and every +// staged PATH. +// - runGitleaks: the secret scanner over the mirror's history, when it is +// installed (a WARNING, not a refusal, when it is not). +// +// THE REPORT NEVER PRINTS A LITERAL. A literal is named `#n (x…, len L)` — its +// position in the list, its first character, its length — and every byte of +// every occurrence of every literal inside a context window is replaced by +// `[REDACTED]`, including an occurrence the window only half covers. Paths and +// subjects printed from the repo go through the same mask. + +import { spawn } from "node:child_process"; +import { existsSync, statSync, accessSync, constants } from "node:fs"; +import { lstat, readdir, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { gunzipSync } from "node:zlib"; +import { runChildIntoLog } from "../jobs/runChild"; + +/** A refusal: the step stops, publishes nothing, and says why. */ +export class SourceRefusal extends Error { + constructor(message: string) { + super(message); + this.name = "SourceRefusal"; + } +} + +// ── literals ──────────────────────────────────────────────────────────────── + +/** + * One denied literal. `ci` literals match ASCII case-insensitively: their + * `bytes` are stored folded, and are searched for in a folded copy. + */ +export type Literal = { bytes: Buffer; ci: boolean }; + +const LOWER_A = 0x61; +const UPPER_A = 0x41; +const UPPER_Z = 0x5a; + +/** A copy of `buf` with A–Z folded to a–z (every other byte as it is). */ +export function foldAscii(buf: Uint8Array): Buffer { + const out = Buffer.from(buf); + for (let i = 0; i < out.length; i++) { + const b = out[i]; + if (b >= UPPER_A && b <= UPPER_Z) out[i] = b - UPPER_A + LOWER_A; + } + return out; +} + +/** + * The denylist file: one literal per line. `i:` in front makes it ASCII + * case-insensitive. Blank lines and lines starting with `#` are skipped; + * surrounding whitespace is trimmed. Duplicates collapse. + */ +export function parseDenylist(text: string): Literal[] { + const out: Literal[] = []; + for (const raw of text.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + if (line.startsWith("i:")) { + const lit = line.slice(2).trim(); + if (lit) out.push({ bytes: foldAscii(Buffer.from(lit, "utf8")), ci: true }); + } else { + out.push({ bytes: Buffer.from(line, "utf8"), ci: false }); + } + } + return dedupeLiterals(out); +} + +export function dedupeLiterals(list: Literal[]): Literal[] { + const seen = new Set<string>(); + const out: Literal[] = []; + for (const l of list) { + const key = `${l.ci ? "i" : "x"}:${l.bytes.toString("hex")}`; + if (seen.has(key) || l.bytes.length === 0) continue; + seen.add(key); + out.push(l); + } + return out; +} + +/** How a literal is named in a report: never its bytes. */ +export function literalLabel(literals: readonly Literal[], index: number): string { + const l = literals[index]; + const first = l.bytes.subarray(0, 4).toString("utf8").slice(0, 1); + const shown = /^[\x21-\x7e]$/.test(first) ? first : "?"; + return `#${index + 1} (${shown}…, len ${l.bytes.length}${l.ci ? ", any case" : ""})`; +} + +// ── scanning ──────────────────────────────────────────────────────────────── + +export type Hit = { lit: number; offset: number; length: number }; + +/** Every occurrence of every literal in `buf`, by offset. */ +export function scanBuffer(buf: Buffer, literals: readonly Literal[]): Hit[] { + const hits: Hit[] = []; + let folded: Buffer | null = null; + literals.forEach((l, lit) => { + let hay = buf; + if (l.ci) { + folded ??= foldAscii(buf); + hay = folded; + } + let at = hay.indexOf(l.bytes); + while (at !== -1) { + hits.push({ lit, offset: at, length: l.bytes.length }); + at = hay.indexOf(l.bytes, at + 1); + } + }); + return hits.sort((a, b) => a.offset - b.offset || a.lit - b.lit); +} + +const RADIUS = 24; + +/** + * ±24 bytes around `hit`, printable: every byte any hit covers becomes one + * `[REDACTED]` per run (so a second literal the window only half shows is + * masked too), anything outside 0x20–0x7e becomes `.`. + */ +export function redactHit(buf: Buffer, hit: Hit, allHits: readonly Hit[]): string { + const from = Math.max(0, hit.offset - RADIUS); + const to = Math.min(buf.length, hit.offset + hit.length + RADIUS); + const covered = new Uint8Array(to - from); + for (const h of allHits) { + const a = Math.max(from, h.offset); + const b = Math.min(to, h.offset + h.length); + for (let i = a; i < b; i++) covered[i - from] = 1; + } + let out = ""; + let inRun = false; + for (let i = from; i < to; i++) { + if (covered[i - from]) { + if (!inRun) out += "[REDACTED]"; + inRun = true; + continue; + } + inRun = false; + const b = buf[i]; + out += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : "."; + } + return out; +} + +/** `text` with every occurrence of every literal replaced by `[REDACTED]`. */ +export function maskLiterals(text: string, literals: readonly Literal[]): string { + const buf = Buffer.from(text, "utf8"); + const hits = scanBuffer(buf, literals); + if (hits.length === 0) return text; + // Merge overlapping occurrences into runs, then splice one mark per run. + const runs: Array<[number, number]> = []; + for (const h of hits) { + const last = runs[runs.length - 1]; + if (last && h.offset <= last[1]) last[1] = Math.max(last[1], h.offset + h.length); + else runs.push([h.offset, h.offset + h.length]); + } + const parts: Buffer[] = []; + let pos = 0; + for (const [a, b] of runs) { + parts.push(buf.subarray(pos, a), Buffer.from("[REDACTED]")); + pos = b; + } + parts.push(buf.subarray(pos)); + return Buffer.concat(parts).toString("utf8"); +} + +// ── the audit's result ────────────────────────────────────────────────────── + +export type HitKind = "blob" | "commit" | "tree" | "tag" | "file" | "gitleaks"; +const KIND_ORDER: readonly HitKind[] = ["blob", "commit", "tree", "tag", "file", "gitleaks"]; + +export type AuditHit = { + kind: HitKind; + // An object id (blob/commit/tree/tag), a path relative to the staged dir + // (file) or the finding's rule (gitleaks). + where: string; + // A literal's index, or -1 for a gitleaks finding. + lit: number; + // The redacted context (only the first CONTEXTS hits carry one). + context?: string; +}; + +export type AuditResult = { + literals: number; + objects: number; + commits: number; + files: number; + gitleaks: "clean" | "skipped" | "not run"; + hits: AuditHit[]; +}; + +// Only this many hits keep a context line: a literal every commit carries +// (the gate's own planted `Co-Authored-By`) would otherwise print thousands. +const CONTEXTS = 20; + +export function emptyAudit(literals: number): AuditResult { + return { literals, objects: 0, commits: 0, files: 0, gitleaks: "not run", hits: [] }; +} + +function record( + result: AuditResult, + kind: HitKind, + where: string, + buf: Buffer, + hits: Hit[], +): void { + for (const h of hits) { + const withContext = result.hits.length < CONTEXTS; + result.hits.push({ + kind, + where, + lit: h.lit, + ...(withContext ? { context: redactHit(buf, h, hits) } : {}), + }); + } +} + +// ── the object walk ───────────────────────────────────────────────────────── + +/** The git environment a child must not inherit: it would point git elsewhere. */ +export function cleanGitEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = { ...env }; + for (const k of Object.keys(out)) { + if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|PREFIX|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|COMMON_DIR|NAMESPACE|CEILING_DIRECTORIES|CONFIG|CONFIG_PARAMETERS|CONFIG_COUNT)$/.test(k)) { + out[k] = undefined; + } + } + return out; +} + +/** A tree object's entry names, NUL-separated (a literal never holds a NUL). */ +export function treeEntryNames(data: Buffer, hashBytes: number): Buffer { + const names: Buffer[] = []; + let i = 0; + while (i < data.length) { + const sp = data.indexOf(0x20, i); + if (sp === -1) break; + const nul = data.indexOf(0x00, sp + 1); + if (nul === -1) break; + names.push(data.subarray(sp + 1, nul), Buffer.from([0])); + i = nul + 1 + hashBytes; + } + return Buffer.concat(names); +} + +/** + * Read every object in `gitDir` once, scan it, and count. One child, one + * stream: `cat-file --batch` frames each object as `<oid> <type> <size>\n`, + * then the bytes, then `\n`. + */ +export async function auditObjects( + gitDir: string, + literals: readonly Literal[], + opts: { signal?: AbortSignal; env?: NodeJS.ProcessEnv; result?: AuditResult } = {}, +): Promise<AuditResult> { + const result = opts.result ?? emptyAudit(literals.length); + const child = spawn( + "git", + ["--git-dir", gitDir, "cat-file", "--batch-all-objects", "--unordered", "--batch"], + { stdio: ["ignore", "pipe", "pipe"], env: cleanGitEnv(opts.env), signal: opts.signal }, + ); + let stderr = ""; + child.stderr.on("data", (c: Buffer) => { + if (stderr.length < 4000) stderr += c.toString("utf8"); + }); + const exited = new Promise<number>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 1)); + }); + exited.catch(() => {}); // awaited below; a throw in the loop must not orphan it + + let state: "header" | "body" | "lf" = "header"; + let headerParts: Buffer[] = []; + let oid = ""; + let type = ""; + let body = Buffer.alloc(0); + let filled = 0; + + const onObject = () => { + result.objects++; + if (literals.length === 0) return; + if (type === "tree") { + const names = treeEntryNames(body, oid.length / 2); + const hits = scanBuffer(names, literals); + if (hits.length) record(result, "tree", oid, names, hits); + return; + } + const hits = scanBuffer(body, literals); + if (hits.length) { + record(result, type === "commit" ? "commit" : type === "tag" ? "tag" : "blob", oid, body, hits); + } + }; + + let drained = false; + try { + for await (const chunk of child.stdout as AsyncIterable<Buffer>) { + let i = 0; + // One pass per framing step; the three steps run in order inside one + // turn, so an empty object goes header -> body -> newline at once. + while (i < chunk.length) { + if (state === "header") { + const nl = chunk.indexOf(0x0a, i); + if (nl === -1) { + headerParts.push(chunk.subarray(i)); + break; + } + headerParts.push(chunk.subarray(i, nl)); + i = nl + 1; + const header = Buffer.concat(headerParts).toString("latin1"); + headerParts = []; + const [o, t, size] = header.split(" "); + if (t === "missing" || size === undefined) { + throw new SourceRefusal(`git cat-file: unexpected header "${header.slice(0, 80)}"`); + } + oid = o; + type = t; + body = Buffer.allocUnsafe(Number(size)); + filled = 0; + state = "body"; + if (type === "commit") result.commits++; + } + if (state === "body") { + const n = Math.min(body.length - filled, chunk.length - i); + chunk.copy(body, filled, i, i + n); + filled += n; + i += n; + if (filled < body.length) break; + state = "lf"; + } + if (state === "lf") { + if (i >= chunk.length) break; + i++; // the framing newline + onObject(); + state = "header"; + } + } + } + drained = true; + } finally { + // Only a loop that threw leaves a child to stop; a drained one is exiting. + if (!drained) child.kill(); + } + const code = await exited; + if (code !== 0) { + throw new SourceRefusal( + `git cat-file over ${tildify(gitDir)} exited ${code}: ${stderr.trim().split("\n")[0] ?? ""}`, + ); + } + if (state !== "header" || headerParts.length > 0) { + throw new SourceRefusal(`git cat-file over ${tildify(gitDir)} ended mid-object`); + } + return result; +} + +/** + * Where each blob sits in history (the first path it was seen at), for the + * report. Only asked for when a blob hit, so a clean audit never pays for it. + */ +async function blobPaths( + gitDir: string, + wanted: Set<string>, + env?: NodeJS.ProcessEnv, +): Promise<Map<string, string>> { + const out = new Map<string, string>(); + const child = spawn("git", ["--git-dir", gitDir, "rev-list", "--objects", "--all"], { + stdio: ["ignore", "pipe", "ignore"], + env: cleanGitEnv(env), + }); + let rest = ""; + for await (const chunk of child.stdout as AsyncIterable<Buffer>) { + const lines = (rest + chunk.toString("utf8")).split("\n"); + rest = lines.pop() ?? ""; + for (const line of lines) { + const sp = line.indexOf(" "); + if (sp === -1) continue; + const o = line.slice(0, sp); + if (wanted.has(o) && !out.has(o)) out.set(o, line.slice(sp + 1)); + } + } + await new Promise((r) => child.once("close", r)); + return out; +} + +// ── the staged files ──────────────────────────────────────────────────────── + +/** + * Every file under `dir` except `skip` (the packs, which the object walk read + * decompressed), plus every path. A `.gz` is scanned decompressed — its + * compressed bytes would never match. A symlink is a hit of its own: nothing + * staged may point outside the stage. + */ +export async function auditFiles( + dir: string, + literals: readonly Literal[], + opts: { skip?: RegExp; result?: AuditResult } = {}, +): Promise<AuditResult> { + const skip = opts.skip ?? /\.(pack|idx)$/; + const result = opts.result ?? emptyAudit(literals.length); + const walk = async (rel: string): Promise<void> => { + const abs = path.join(dir, rel); + for (const ent of await readdir(abs, { withFileTypes: true })) { + const r = rel ? `${rel}/${ent.name}` : ent.name; + const nameBuf = Buffer.from(r, "utf8"); + const nameHits = scanBuffer(nameBuf, literals); + if (nameHits.length) record(result, "file", maskLiterals(r, literals), nameBuf, nameHits); + if (ent.isSymbolicLink()) { + throw new SourceRefusal(`the stage holds a symlink (${maskLiterals(r, literals)}); nothing published may point outside it`); + } + if (ent.isDirectory()) { + await walk(r); + continue; + } + result.files++; + if (skip.test(ent.name)) continue; + let data = await readFile(path.join(abs, ent.name)); + if (ent.name.endsWith(".gz")) data = gunzipSync(data); + const hits = scanBuffer(data, literals); + if (hits.length) record(result, "file", maskLiterals(r, literals), data, hits); + } + }; + if ((await lstat(dir)).isDirectory()) await walk(""); + return result; +} + +// ── gitleaks ──────────────────────────────────────────────────────────────── + +/** A bare name resolved against PATH the way a spawn would, or null. */ +export function onPath(bin: string, envPath: string | undefined): string | null { + if (bin.includes("/")) return existsSync(bin) ? bin : null; + for (const d of (envPath ?? "").split(path.delimiter)) { + if (!d) continue; + const p = path.join(d, bin); + try { + accessSync(p, constants.X_OK); + if (statSync(p).isFile()) return p; + } catch { + /* not here */ + } + } + return null; +} + +type GitleaksFinding = { RuleID?: string; File?: string; Commit?: string }; + +/** + * gitleaks over the mirror's history. Exit 0 is clean, 3 is findings (parsed + * from its JSON report), anything else is a refusal. Not installed: "skipped", + * with a WARNING line — the literal audit still ran. + */ +export async function runGitleaks( + gitDir: string, + opts: { + bin: string; + scratch: string; + literals: readonly Literal[]; + onLog: (line: string) => void; + signal: AbortSignal; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + }, +): Promise<{ status: "clean" | "skipped"; hits: AuditHit[] }> { + const env = cleanGitEnv(opts.env ?? process.env); + if (!onPath(opts.bin, env.PATH)) { + opts.onLog( + `[source] WARNING: ${opts.bin} is not on PATH — the secret scan is skipped (the literal audit still ran). Install gitleaks to add it.`, + ); + return { status: "skipped", hits: [] }; + } + const report = path.join(opts.scratch, "gitleaks.json"); + const timeout = AbortSignal.timeout(opts.timeoutMs ?? 300_000); + const code = await runChildIntoLog( + (line) => opts.onLog(`[gitleaks] ${maskLiterals(line, opts.literals)}`), + AbortSignal.any([opts.signal, timeout]), + { + command: opts.bin, + args: [ + "git", + "--no-banner", + "--no-color", + "--redact", + "--exit-code", + "3", + "--report-format", + "json", + "--report-path", + report, + gitDir, + ], + // Its own ignore file is read from the cwd: the scratch dir has none. + cwd: opts.scratch, + env, + }, + ); + if (timeout.aborted) throw new SourceRefusal("gitleaks timed out"); + if (code === 0) return { status: "clean", hits: [] }; + if (code !== 3) throw new SourceRefusal(`gitleaks exited ${code}`); + let findings: GitleaksFinding[] = []; + try { + findings = JSON.parse(await readFile(report, "utf8")) as GitleaksFinding[]; + } catch { + throw new SourceRefusal("gitleaks reported findings but its report did not parse"); + } + return { + status: "clean", + hits: findings.map((f) => ({ + kind: "gitleaks" as const, + where: maskLiterals( + `${f.RuleID ?? "?"} in ${f.File ?? "?"} @ ${(f.Commit ?? "").slice(0, 12)}`, + opts.literals, + ), + lit: -1, + })), + }; +} + +// ── together ──────────────────────────────────────────────────────────────── + +/** + * The gate over a git dir: the object walk, then (only when it is clean — a + * refusal is already certain otherwise) gitleaks. Blob hits are given their + * path in history. + */ +export async function auditBare( + gitDir: string, + literals: readonly Literal[], + opts: { + scratch: string; + onLog: (line: string) => void; + signal: AbortSignal; + gitleaks: string | null; + env?: NodeJS.ProcessEnv; + }, +): Promise<AuditResult> { + const result = await auditObjects(gitDir, literals, { signal: opts.signal, env: opts.env }); + const blobs = new Set(result.hits.filter((h) => h.kind === "blob").map((h) => h.where)); + if (blobs.size > 0) { + const where = await blobPaths(gitDir, blobs, opts.env); + for (const h of result.hits) { + const p = h.kind === "blob" ? where.get(h.where) : undefined; + if (p) h.where = `${h.where} ${maskLiterals(p, literals)}`; + } + } + if (result.hits.length > 0) return result; + if (opts.gitleaks === null) { + result.gitleaks = "skipped"; + return result; + } + const g = await runGitleaks(gitDir, { + bin: opts.gitleaks, + scratch: opts.scratch, + literals, + onLog: opts.onLog, + signal: opts.signal, + env: opts.env, + }); + result.gitleaks = g.status; + result.hits.push(...g.hits); + return result; +} + +/** A path under the home directory as `~/…`: the home dir names the user. */ +export function tildify(p: string): string { + const home = os.homedir(); + return p === home || p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p; +} + +/** + * The report, as lines. Clean: one line of counts. Hits: the counts per + * literal and kind, the first hits with their redacted context, and what to + * do. No line carries a literal. + */ +export function formatAuditReport( + result: AuditResult, + literals: readonly Literal[], + opts: { scrubFile: string }, +): string[] { + const read = + `${result.objects.toLocaleString("en-US")} objects (${result.commits.toLocaleString("en-US")} commit${result.commits === 1 ? "" : "s"})` + + (result.files ? `, ${result.files.toLocaleString("en-US")} staged files` : "") + + ` against ${result.literals} denied literal${result.literals === 1 ? "" : "s"}; gitleaks ${result.gitleaks}`; + if (result.hits.length === 0) return [`[source] audit clean: ${read}`]; + const lines = [`[source] AUDIT REFUSED: ${result.hits.length} hit${result.hits.length === 1 ? "" : "s"} in ${read}`]; + const byLit = new Map<number, Map<HitKind, number>>(); + for (const h of result.hits) { + const m = byLit.get(h.lit) ?? new Map<HitKind, number>(); + m.set(h.kind, (m.get(h.kind) ?? 0) + 1); + byLit.set(h.lit, m); + } + for (const [lit, kinds] of [...byLit].sort((a, b) => a[0] - b[0])) { + const label = lit === -1 ? "gitleaks findings" : literalLabel(literals, lit); + const parts = KIND_ORDER.filter((k) => kinds.has(k)).map((k) => { + const n = kinds.get(k)!; + return `${n} in ${k}${n === 1 ? "" : "s"}`; + }); + lines.push(`[source] ${label}: ${parts.join(", ")}`); + } + const shown = result.hits.filter((h) => h.context !== undefined || h.kind === "gitleaks").slice(0, CONTEXTS); + for (const h of shown) { + const label = h.lit === -1 ? "" : ` ${literalLabel(literals, h.lit)}`; + lines.push( + `[source] ${h.kind} ${h.kind === "file" || h.kind === "gitleaks" ? h.where : shortWhere(h.where)}${label}` + + (h.context !== undefined ? `: ${h.context}` : ""), + ); + } + if (result.hits.length > shown.length) { + lines.push(`[source] … and ${result.hits.length - shown.length} more`); + } + lines.push( + `[source] add a rule to ${tildify(opts.scrubFile)} or drop the file from history, then re-run.`, + ); + return lines; +} + +// "<oid> <path>" → "<oid12> <path>"; a bare oid → oid12. +function shortWhere(where: string): string { + const sp = where.indexOf(" "); + return sp === -1 ? where.slice(0, 12) : `${where.slice(0, 12)}${where.slice(sp)}`; +} diff --git a/common/publish/sourceTree.test.ts b/common/publish/sourceTree.test.ts @@ -0,0 +1,86 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SourceRefusal } from "./sourceAudit"; +import { escapeHtml, hrefFor, renderTreeIndex, sortEntries, writeTreeIndexes } from "./sourceTree"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// The raw tree's directory pages (sourceTree.ts): hrefs a static host can +// resolve for the repo's real bracketed names, escaped labels, the order, +// the relative breadcrumbs, and the refusals. + +const TMP = mkdtempSync(path.join(os.tmpdir(), "source-tree-")); +after(() => rmSync(TMP, { recursive: true, force: true })); + +const META = { mirrorHead: "0123456789abcdef0123456789abcdef01234567", generatedAt: "2026-09-28T12:00:00.000Z" }; + +test("hrefs are URL-encoded, a directory's with a trailing slash — the repo's brackets included", () => { + assert.equal(hrefFor({ name: "[slug]", dir: true }), "%5Bslug%5D/"); + assert.equal(hrefFor({ name: "Archivo[wdth,wght].ttf", dir: false }), "Archivo%5Bwdth%2Cwght%5D.ttf"); + assert.equal(hrefFor({ name: "a b#c?.md", dir: false }), "a%20b%23c%3F.md"); + assert.equal(hrefFor({ name: "page.tsx", dir: false }), "page.tsx"); +}); + +test("names are escaped wherever they are printed", () => { + assert.equal(escapeHtml(`<a href="x">&'</a>`), "&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;"); + const html = renderTreeIndex({ + relDir: "x", + entries: [{ name: "<script>.ts", dir: false, bytes: 3 }], + ...META, + }); + assert.ok(!html.includes("<script>.ts"), "a name never becomes markup"); + assert.match(html, /href="%3Cscript%3E\.ts">&lt;script&gt;\.ts<\/a>/); +}); + +test("directories first, then files, each by name; sizes with raw bytes in the title", () => { + const sorted = sortEntries([ + { name: "b.ts", dir: false, bytes: 1 }, + { name: "z", dir: true, bytes: 0 }, + { name: "a.ts", dir: false, bytes: 2048 }, + { name: "B", dir: true, bytes: 0 }, + ]); + assert.deepEqual(sorted.map((e) => e.name), ["B", "z", "a.ts", "b.ts"]); + const html = renderTreeIndex({ relDir: "", entries: sorted, ...META }); + assert.match(html, /<span title="2048 bytes">2\.0 KB<\/span>/); + assert.match(html, /<meta name="robots" content="noindex">/); + assert.match(html, /main @ 0123456789ab · generated 2026-09-28T12:00:00\.000Z · <a href="\/source\/">clone<\/a>/); +}); + +test("breadcrumbs hop up relatively; the root has no `..` row", () => { + const root = renderTreeIndex({ relDir: "", entries: [], ...META }); + assert.ok(!root.includes(`href="../"`)); + assert.match(root, /<nav><strong>archilyzer<\/strong><\/nav>/); + const deep = renderTreeIndex({ relDir: "homepage/app/[slug]", entries: [], ...META }); + assert.match( + deep, + /<nav><a href="\.\.\/\.\.\/\.\.\/">archilyzer<\/a><span class="sep">\/<\/span><a href="\.\.\/\.\.\/">homepage<\/a><span class="sep">\/<\/span><a href="\.\.\/">app<\/a><span class="sep">\/<\/span><strong>\[slug\]<\/strong><\/nav>/, + ); + assert.match(deep, /<tr><td class="name"><a href="\.\.\/">\.\.<\/a>/); +}); + +test("writeTreeIndexes pages every directory and counts the tree, refusing a tracked index.html or a symlink", async () => { + const tree = path.join(TMP, "t1"); + mkdirSync(path.join(tree, "app", "[slug]"), { recursive: true }); + writeFileSync(path.join(tree, "README.md"), "hello\n"); + writeFileSync(path.join(tree, "app", "[slug]", "page.tsx"), "x"); + const totals = await writeTreeIndexes(tree, META); + assert.deepEqual(totals, { files: 2, dirs: 3, bytes: 7 }); + for (const d of ["", "app", "app/[slug]"]) assert.ok(existsSync(path.join(tree, d, "index.html")), d); + const appPage = readFileSync(path.join(tree, "app", "index.html"), "utf8"); + assert.match(appPage, /href="%5Bslug%5D\/">\[slug\]\/<\/a>/); + assert.ok(!appPage.includes(`>index.html<`), "a page does not list itself"); + + const withIndex = path.join(TMP, "t2"); + mkdirSync(path.join(withIndex, "docs"), { recursive: true }); + writeFileSync(path.join(withIndex, "docs", "index.html"), "<p>tracked</p>"); + await assert.rejects(writeTreeIndexes(withIndex, META), (e) => e instanceof SourceRefusal && /docs\/index\.html/.test(e.message)); + + const withLink = path.join(TMP, "t3"); + mkdirSync(withLink); + symlinkSync("/etc/hostname", path.join(withLink, "leak")); + await assert.rejects(writeTreeIndexes(withLink, META), (e) => e instanceof SourceRefusal && /symlink/.test(e.message)); +}); diff --git a/common/publish/sourceTree.ts b/common/publish/sourceTree.ts @@ -0,0 +1,178 @@ +// The raw tree's directory pages: one dependency-free index.html per directory +// of the extracted `main`, so /source/tree/ is browsable on a static host. +// +// The FILES under the tree are served as text/plain whatever their extension +// (homepage/public/_headers, `/source/tree/*`); only these generated pages are +// HTML. Every name is escaped for HTML and every href is URL-encoded — the tree +// holds Next's dynamic-route directories (`[slug]`) and variable fonts +// (`Archivo[wdth,wght].ttf`), whose brackets a browser would otherwise send +// raw. + +import { readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { SourceRefusal } from "./sourceAudit"; + +export type TreeEntry = { name: string; dir: boolean; bytes: number }; + +export type TreeMeta = { mirrorHead: string; generatedAt: string }; + +/** The href of one entry, relative to its directory's page. */ +export function hrefFor(entry: Pick<TreeEntry, "name" | "dir">): string { + return encodeURIComponent(entry.name) + (entry.dir ? "/" : ""); +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;") + .replace(/"/g, "&quot;") + .replace(/'/g, "&#39;"); +} + +export function formatTreeBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +/** Directories first, then files, each by name (code-unit order: stable everywhere). */ +export function sortEntries(entries: readonly TreeEntry[]): TreeEntry[] { + return [...entries].sort((a, b) => + a.dir !== b.dir ? (a.dir ? -1 : 1) : a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ); +} + +const CSS = ` +:root { color-scheme: light dark; --fg: #1d1b18; --muted: #6b665e; --bg: #faf8f4; + --rule: #e4dfd6; --link: #7a5a1c; --hover: #f1ede5; } +@media (prefers-color-scheme: dark) { + :root { --fg: #ece8e1; --muted: #a39d93; --bg: #161512; --rule: #2e2b26; + --link: #d9b46a; --hover: #221f1b; } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); + font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +main { max-width: 64rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; } +nav { font-size: 1rem; margin-bottom: 1rem; word-break: break-all; } +nav .sep { color: var(--muted); padding: 0 0.25rem; } +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +table { width: 100%; border-collapse: collapse; } +td { padding: 0.3rem 0.5rem; border-top: 1px solid var(--rule); } +tr:hover td { background: var(--hover); } +td.size { text-align: right; color: var(--muted); white-space: nowrap; width: 1%; } +td.name { word-break: break-all; } +footer { margin-top: 1.5rem; color: var(--muted); font-size: 0.8125rem; } +`.trim(); + +/** + * One directory's page. `relDir` is the directory relative to the tree root + * ("" for the root); every link on the page is relative, so it works under + * /source/tree/ on any host and from a directory's `index.html` alike. + */ +export function renderTreeIndex(opts: { + relDir: string; + entries: readonly TreeEntry[]; + mirrorHead: string; + generatedAt: string; +}): string { + const segs = opts.relDir ? opts.relDir.split("/") : []; + const depth = segs.length; + const up = (n: number) => (n === 0 ? "./" : "../".repeat(n)); + const crumbs: string[] = [ + depth === 0 + ? `<strong>archilyzer</strong>` + : `<a href="${up(depth)}">archilyzer</a>`, + ]; + segs.forEach((seg, i) => { + const last = i === depth - 1; + crumbs.push( + last + ? `<strong>${escapeHtml(seg)}</strong>` + : `<a href="${up(depth - 1 - i)}">${escapeHtml(seg)}</a>`, + ); + }); + const rows: string[] = []; + if (depth > 0) { + rows.push(`<tr><td class="name"><a href="../">..</a></td><td class="size"></td></tr>`); + } + for (const e of sortEntries(opts.entries)) { + const label = escapeHtml(e.name) + (e.dir ? "/" : ""); + const size = e.dir + ? "" + : `<span title="${e.bytes} bytes">${formatTreeBytes(e.bytes)}</span>`; + rows.push( + `<tr><td class="name"><a href="${escapeHtml(hrefFor(e))}">${label}</a></td><td class="size">${size}</td></tr>`, + ); + } + const title = depth === 0 ? "archilyzer" : `archilyzer/${opts.relDir}`; + return [ + "<!doctype html>", + `<html lang="en">`, + "<head>", + `<meta charset="utf-8">`, + `<meta name="viewport" content="width=device-width, initial-scale=1">`, + `<meta name="robots" content="noindex">`, + `<title>${escapeHtml(title)} · source</title>`, + `<style>${CSS}</style>`, + "</head>", + "<body>", + "<main>", + `<nav>${crumbs.join(`<span class="sep">/</span>`)}</nav>`, + `<table>`, + ...rows, + `</table>`, + `<footer>main @ ${escapeHtml(opts.mirrorHead.slice(0, 12))} · generated ${escapeHtml(opts.generatedAt)} · <a href="/source/">clone</a></footer>`, + "</main>", + "</body>", + "</html>", + "", + ].join("\n"); +} + +/** + * Write an index.html into every directory under `treeDir`, the root + * included. REFUSES when a directory already holds an `index.html` (a tracked + * one would be overwritten, or served in the index's place) or when anything + * is a symlink. Returns the tree's own files and bytes (not the pages) and how + * many directories got a page. + */ +export async function writeTreeIndexes( + treeDir: string, + meta: TreeMeta, +): Promise<{ files: number; dirs: number; bytes: number }> { + const totals = { files: 0, dirs: 0, bytes: 0 }; + const walk = async (rel: string): Promise<void> => { + const abs = rel ? path.join(treeDir, rel) : treeDir; + const ents = await readdir(abs, { withFileTypes: true }); + const entries: TreeEntry[] = []; + for (const ent of ents) { + const r = rel ? `${rel}/${ent.name}` : ent.name; + if (ent.isSymbolicLink()) { + throw new SourceRefusal(`the tree holds a symlink (${r}); the raw tree publishes files only`); + } + if (ent.isDirectory()) { + entries.push({ name: ent.name, dir: true, bytes: 0 }); + continue; + } + if (ent.name === "index.html") { + throw new SourceRefusal( + `the tree already has ${r}; its directory page would replace it — rename the file or publish without the raw tree`, + ); + } + const { size } = await stat(path.join(abs, ent.name)); + entries.push({ name: ent.name, dir: false, bytes: size }); + totals.files++; + totals.bytes += size; + } + await writeFile( + path.join(abs, "index.html"), + renderTreeIndex({ relDir: rel, entries, ...meta }), + ); + totals.dirs++; + for (const e of entries) if (e.dir) await walk(rel ? `${rel}/${e.name}` : e.name); + }; + await walk(""); + return totals; +}