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:
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>`), "<a href="x">&'</a>");
+ 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"><script>\.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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+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;
+}