Archilyzer · Source

archilyzer

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

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

common: `publishSource` — a scrubbed, dumb-HTTP git mirror, the raw tree and the tarball

A fresh `--no-local --bare --single-branch` clone of the git common dir's main,
rewritten by git-filter-repo (the home-dir rule first, then the operator's
source-scrub.txt, over file contents AND commit messages), repacked into
≤20 MB packs with info/refs and objects/info/packs; audited object by object,
then file by file; staged from an allowlist (no config, hooks, filter-repo/ or
private ids); installed link-safe with the manifest removed first and written
last. Skips when main and the rules are unchanged — the rules hash lives in
homepage/.source-publish.json, never in the published manifest, because a hash
of the denylist would confirm a guess at it. Refuses at 15,000 files / 24 MiB.
getPaths() gains ARCHILYZER_CONFIG_DIR, SOURCE_SCRUB_FILE, SOURCE_DENYLIST_FILE
and ARCHILYZER_SOURCE_SCRATCH; ENVIRONMENT.md regenerated (TRANSCRIPTS_DIR
also says umtool reads it).

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

Diffstat:
MENVIRONMENT.md | 8++++++--
Mcommon/lib/envVars.ts | 8++++++--
Mcommon/lib/paths.ts | 20++++++++++++++++++++
Acommon/publish/source.test.ts | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/source.ts | 792+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1155 insertions(+), 4 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -12,7 +12,7 @@ The one override surface for where things live and which binary runs. Every one | Variable | Default | What it does | Read by | |---|---|---|---| -| `TRANSCRIPTS_DIR` | `<repo>/transcripts` | The corpus: channels, sites, the LMDB index, job logs, the saved-video store. | common/lib/paths.ts (getPaths) | +| `TRANSCRIPTS_DIR` | `<repo>/transcripts` | The corpus: channels, sites, the LMDB index, job logs, the saved-video store. umtool reads `<it>/channels` too, when its own `CHANNELS_DIR` is unset. | common/lib/paths.ts (getPaths) | | `SAVED_VIDEOS_DIR` | `<TRANSCRIPTS_DIR>/saved-videos` | The persisted source-video store, when it should live on another disk. | common/lib/paths.ts (getPaths) | | `SITES_DIR` | `<TRANSCRIPTS_DIR>/sites` | Per-site config (`<id>/site.json`, every key in [SITE.md](SITE.md)) and the homepage's `_homepage/`. | common/lib/paths.ts (getPaths) | | `SETTINGS_FILE` | `<repo>/settings.json` | The settings file (every key in [SETTINGS.md](SETTINGS.md)). | common/lib/paths.ts (getPaths) | @@ -39,6 +39,10 @@ The one override surface for where things live and which binary runs. Every one | `GALLERY_DL_BIN` | `gallery-dl` on PATH | The X/Twitter post fetcher, for social channels. | common/lib/paths.ts (getPaths) | | `OLLAMA_URL` | `http://127.0.0.1:11434` | The local ollama server, the local digest and attribution engine. | common/lib/paths.ts (getPaths) | | `CLAUDE_BIN` | `claude` on PATH | The `claude` CLI, driving the opt-in metered digest lane. | common/lib/paths.ts (getPaths) | +| `ARCHILYZER_CONFIG_DIR` | `~/.config/archilyzer` | The operator's private config dir, outside the repo: the two inputs of `archilyzer source publish` below. Never committed. | common/lib/paths.ts (getPaths) | +| `SOURCE_SCRUB_FILE` | `<ARCHILYZER_CONFIG_DIR>/source-scrub.txt` | git-filter-repo `lhs==>rhs` rules applied to file contents AND commit messages when the source mirror is generated (`<home dir>==>/home/user` is built in and runs first). Every rule's left side is also denied. See [PUBLISH.md](PUBLISH.md). | common/lib/paths.ts (getPaths) | +| `SOURCE_DENYLIST_FILE` | `<ARCHILYZER_CONFIG_DIR>/source-denylist.txt` | Literals the published source must never contain, one per line (`i:` = any case). One hit anywhere in the mirror, the tree or the tarball refuses the publish. | common/lib/paths.ts (getPaths) | +| `ARCHILYZER_SOURCE_SCRATCH` | the OS temp dir | Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`). | common/lib/paths.ts (getPaths) | ## Runtime @@ -123,7 +127,7 @@ The publish pipeline sets these for a process it spawns. Listed so a reader know | `INSTANCE_MODE` | a site | `hub` makes the export build the hub. Set by `archilyzer build hub`. | export/app/lib/mode.ts, common/lib/archive/contract.ts | | `BUILD_ARCHIVES` | on | `0` skips archive-zip generation for one build (`--skip-archives`). | common/bin/compose-site.ts, common/bin/build-archives.ts | | `ARCHIVES_READONLY` | off | `1` inside a docker-mode build container: materialize archives, never write the shared cache. | common/bin/compose-site.ts | -| `HOMEPAGE_PUBLIC_DIR` | `<repo>/homepage/public` | Where `compose homepage` writes. | common/bin/compose-homepage.ts | +| `HOMEPAGE_PUBLIC_DIR` | `<repo>/homepage/public` | Where `compose homepage` and `source publish` write. | common/bin/compose-homepage.ts, common/publish/source.ts | ## Docker diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -53,7 +53,7 @@ const paths = (name: string, def: string, doc: string): EnvVarDecl => ({ const DECLARED: EnvVarDecl[] = [ // ── paths: getPaths() ────────────────────────────────────────────────── - paths("TRANSCRIPTS_DIR", "`<repo>/transcripts`", "The corpus: channels, sites, the LMDB index, job logs, the saved-video store."), + paths("TRANSCRIPTS_DIR", "`<repo>/transcripts`", "The corpus: channels, sites, the LMDB index, job logs, the saved-video store. umtool reads `<it>/channels` too, when its own `CHANNELS_DIR` is unset."), paths("SAVED_VIDEOS_DIR", "`<TRANSCRIPTS_DIR>/saved-videos`", "The persisted source-video store, when it should live on another disk."), paths("SITES_DIR", "`<TRANSCRIPTS_DIR>/sites`", "Per-site config (`<id>/site.json`, every key in [SITE.md](SITE.md)) and the homepage's `_homepage/`."), paths("SETTINGS_FILE", "`<repo>/settings.json`", "The settings file (every key in [SETTINGS.md](SETTINGS.md))."), @@ -80,6 +80,10 @@ const DECLARED: EnvVarDecl[] = [ paths("GALLERY_DL_BIN", "`gallery-dl` on PATH", "The X/Twitter post fetcher, for social channels."), paths("OLLAMA_URL", "`http://127.0.0.1:11434`", "The local ollama server, the local digest and attribution engine."), paths("CLAUDE_BIN", "`claude` on PATH", "The `claude` CLI, driving the opt-in metered digest lane."), + paths("ARCHILYZER_CONFIG_DIR", "`~/.config/archilyzer`", "The operator's private config dir, outside the repo: the two inputs of `archilyzer source publish` below. Never committed."), + paths("SOURCE_SCRUB_FILE", "`<ARCHILYZER_CONFIG_DIR>/source-scrub.txt`", "git-filter-repo `lhs==>rhs` rules applied to file contents AND commit messages when the source mirror is generated (`<home dir>==>/home/user` is built in and runs first). Every rule's left side is also denied. See [PUBLISH.md](PUBLISH.md)."), + paths("SOURCE_DENYLIST_FILE", "`<ARCHILYZER_CONFIG_DIR>/source-denylist.txt`", "Literals the published source must never contain, one per line (`i:` = any case). One hit anywhere in the mirror, the tree or the tarball refuses the publish."), + paths("ARCHILYZER_SOURCE_SCRATCH", "the OS temp dir", "Where `source publish` makes its scratch clone and stage (removed afterwards unless `--keep-scratch`)."), // ── runtime ──────────────────────────────────────────────────────────── { name: "WORKER_TOKEN", audience: "runtime", default: "unset (both surfaces off)", readBy: "common/lib/workerToken.ts, scripts/archilyzer-ops.mjs, mcp/src/fetchClip.ts", doc: "Bearer token for the remote-worker API and for `/api/ops/*` (`pnpm ops`, the MCP's `fetch_clip`). Set the same value on both ends." }, @@ -132,7 +136,7 @@ const DECLARED: EnvVarDecl[] = [ { name: "INSTANCE_MODE", audience: "internal", default: "a site", readBy: "export/app/lib/mode.ts, common/lib/archive/contract.ts", doc: "`hub` makes the export build the hub. Set by `archilyzer build hub`." }, { name: "BUILD_ARCHIVES", audience: "internal", default: "on", readBy: "common/bin/compose-site.ts, common/bin/build-archives.ts", doc: "`0` skips archive-zip generation for one build (`--skip-archives`)." }, { name: "ARCHIVES_READONLY", audience: "internal", default: "off", readBy: "common/bin/compose-site.ts", doc: "`1` inside a docker-mode build container: materialize archives, never write the shared cache." }, - { name: "HOMEPAGE_PUBLIC_DIR", audience: "internal", default: "`<repo>/homepage/public`", readBy: "common/bin/compose-homepage.ts", doc: "Where `compose homepage` writes." }, + { name: "HOMEPAGE_PUBLIC_DIR", audience: "internal", default: "`<repo>/homepage/public`", readBy: "common/bin/compose-homepage.ts, common/publish/source.ts", doc: "Where `compose homepage` and `source publish` write." }, // ── docker: the container's set ──────────────────────────────────────── { name: "ARCHILYZER_TRANSCRIBER", audience: "docker", default: "baked per image target (`whisper-cpp` in `runtime`)", readBy: "docker/entrypoint.sh", doc: "`whisper-cpp` or `parakeet`: which worker the first boot seeds and which model it fetches." }, diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -158,6 +158,16 @@ export type Paths = { // settings.digest.remoteEnabled). Not bundled; install it separately and point // CLAUDE_BIN at it if it isn't on PATH. claudeBin: string; + // The operator's PRIVATE config dir, outside the repo (~/.config/archilyzer + // by default). Holds the two inputs of `archilyzer source publish` + // (common/publish/source.ts), which are never committed: + // sourceScrubFile — git-filter-repo `lhs==>rhs` rules for the mirror + // sourceDenylistFile — literals the published source must never contain + configDir: string; + sourceScrubFile: string; + sourceDenylistFile: string; + // Where `source publish` makes its scratch clone (removed afterwards). + sourceScratchDir: string; }; let cached: Paths | null = null; @@ -179,6 +189,9 @@ export function getPaths(): Paths { const exportSharedDir = path.join(exportIndexDir, "shared"); const sitesDir = process.env.SITES_DIR ?? path.join(transcriptsDir, "sites"); const homepageDir = path.join(sitesDir, "_homepage"); + const configDir = + process.env.ARCHILYZER_CONFIG_DIR ?? + path.join(os.homedir(), ".config", "archilyzer"); cached = { monorepoRoot, transcriptsDir, @@ -264,6 +277,13 @@ export function getPaths(): Paths { "", ), claudeBin: process.env.CLAUDE_BIN ?? "claude", + configDir, + sourceScrubFile: + process.env.SOURCE_SCRUB_FILE ?? path.join(configDir, "source-scrub.txt"), + sourceDenylistFile: + process.env.SOURCE_DENYLIST_FILE ?? + path.join(configDir, "source-denylist.txt"), + sourceScratchDir: process.env.ARCHILYZER_SOURCE_SCRATCH ?? os.tmpdir(), }; return cached; } diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts @@ -0,0 +1,331 @@ +import { test, after, before } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { CLONE_URL, MIRROR_DIR, TARBALL_HREF, TREE_HREF } from "../lib/sourceManifest"; +import { + MAX_FILES, + MAX_FILE_BYTES, + SourceRefusal, + clearPublishedSource, + limitProblem, + loadSourceRules, + parseScrubRules, + publishSource, + resolveFilterRepo, + type SourcePublishOpts, +} from "./source"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// `archilyzer source publish` (source.ts) over temp repos. The two tests that +// rewrite history need git-filter-repo; they SKIP when resolveFilterRepo() +// cannot find one (say so in a gate: the release gate requires they RAN). +// Every repo, public dir and scratch dir is under the OS temp dir, and the git +// variables a hook or a wrapper might export are cleared first. +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-publish-")); +after(() => rmSync(TMP, { recursive: true, force: true })); + +// Planted, never real: the home dir the built-in rule scrubs, and the paths. +const HOME = "/home/not-a-real-user"; +const PLANTED = "plantedhome"; +const SECRET = "plantedsecret"; + +let filterRepoProblem: string | null = null; +before(async () => { + try { + await resolveFilterRepo({}); + } catch (err) { + filterRepoProblem = (err as Error).message; + } +}); + +let n = 0; +function dir(name: string): string { + const d = path.join(TMP, `${name}-${n++}`); + mkdirSync(d, { recursive: true }); + return d; +} + +function gitIn(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, stdio: "pipe" }).toString().trim(); +} + +// A repo with `main` of three commits: a planted path in one blob and one +// message, and the names the tree pages must encode. +function sourceRepo(extra: Record<string, string> = {}): string { + const repo = dir("src"); + gitIn(repo, "init", "-q", "-b", "main"); + gitIn(repo, "config", "user.name", "source test"); + gitIn(repo, "config", "user.email", "source@example.invalid"); + gitIn(repo, "config", "commit.gpgsign", "false"); + const put = (files: Record<string, string>, message: string) => { + for (const [f, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(repo, f)), { recursive: true }); + writeFileSync(path.join(repo, f), text); + } + gitIn(repo, "add", "-A"); + gitIn(repo, "commit", "-q", "-m", message); + }; + put( + { + "README.md": "hello\n", + "app/[slug]/page.tsx": "export default 1;\n", + "fonts/Archivo[wdth,wght].ttf": "not really a font\n", + }, + "first", + ); + put({ "notes.txt": `data lives at /srv/${PLANTED}/x\n`, ...extra }, "add notes"); + put({ "README.md": "hello again\n" }, `moved from /srv/${PLANTED}`); + return repo; +} + +function operatorFiles(scrub: string, deny: string): { scrubFile: string; denylistFile: string } { + const d = dir("config"); + writeFileSync(path.join(d, "source-scrub.txt"), scrub); + writeFileSync(path.join(d, "source-denylist.txt"), deny); + return { scrubFile: path.join(d, "source-scrub.txt"), denylistFile: path.join(d, "source-denylist.txt") }; +} + +function opts(repo: string, files: { scrubFile: string; denylistFile: string }, logs: string[], extra: Partial<SourcePublishOpts> = {}): SourcePublishOpts { + return { + paths: { monorepoRoot: TMP } as Paths, + sourceRepo: path.join(repo, ".git"), + publicDir: path.join(dir("site"), "public"), + scratchRoot: path.join(TMP, "scratch"), + gitleaks: null, + homeDir: HOME, + onLog: (l) => logs.push(l), + now: () => new Date("2026-09-28T12:00:00.000Z"), + ...files, + ...extra, + }; +} + +const sha256 = (file: string) => createHash("sha256").update(readFileSync(file)).digest("hex"); + +test("scrub rules: the home-dir rule first, comments and blanks dropped, every literal left side denied", () => { + const rules = parseScrubRules( + ["# a comment", "", `/srv/${PLANTED}==>/home/user`, " # indented comment", "literal:abc==>x", "regex:a+b==>c", "glob:*.x==>y", "bare-line", "a==>b==>c", "\r"].join("\n"), + HOME, + ); + assert.deepEqual(rules.lines, [ + `${HOME}==>/home/user`, + `/srv/${PLANTED}==>/home/user`, + "literal:abc==>x", + "regex:a+b==>c", + "glob:*.x==>y", + "bare-line", + "a==>b==>c", + ]); + // filter-repo splits at the LAST ==>; regex and glob rules deny nothing. + assert.deepEqual(rules.denied, [HOME, `/srv/${PLANTED}`, "abc", "bare-line", "a==>b"]); + // A home dir of `/`, or one that IS the replacement, gets no built-in rule. + assert.deepEqual(parseScrubRules("", "/").lines, []); + assert.deepEqual(parseScrubRules("", "/home/user").lines, []); +}); + +test("the operator's files: a missing one refuses by name; the denylist's i: and the implied left sides; the hash moves with them", async () => { + const d = dir("cfg"); + await assert.rejects( + loadSourceRules({ scrubFile: path.join(d, "nope.txt"), denylistFile: path.join(d, "deny.txt"), homeDir: HOME }), + (e) => e instanceof SourceRefusal && /no scrub rules at .*nope\.txt — create it/.test(e.message), + ); + writeFileSync(path.join(d, "scrub.txt"), `/srv/${PLANTED}==>/home/user\n`); + await assert.rejects( + loadSourceRules({ scrubFile: path.join(d, "scrub.txt"), denylistFile: path.join(d, "deny.txt"), homeDir: HOME }), + (e) => e instanceof SourceRefusal && /no denylist at .*deny\.txt/.test(e.message), + ); + writeFileSync(path.join(d, "deny.txt"), `# mine\ni:${SECRET.toUpperCase()}\n`); + const a = await loadSourceRules({ scrubFile: path.join(d, "scrub.txt"), denylistFile: path.join(d, "deny.txt"), homeDir: HOME }); + assert.deepEqual( + a.literals.map((l) => [l.bytes.toString(), l.ci]), + [[SECRET, true], [HOME, false], [`/srv/${PLANTED}`, false]], + ); + writeFileSync(path.join(d, "deny.txt"), `i:${SECRET}\nanother\n`); + const b = await loadSourceRules({ scrubFile: path.join(d, "scrub.txt"), denylistFile: path.join(d, "deny.txt"), homeDir: HOME }); + assert.notEqual(a.rulesHash, b.rulesHash, "a new literal must defeat the skip"); +}); + +test("the limits: 15,000 files and 24 MiB a file, inside Pages' 20,000 and 25 MiB", () => { + assert.equal(MAX_FILES, 15_000); + assert.equal(MAX_FILE_BYTES, 24 * 1024 * 1024); + assert.equal(limitProblem([{ rel: "a", bytes: MAX_FILE_BYTES }]), null); + assert.match(limitProblem([{ rel: "big.pack", bytes: MAX_FILE_BYTES + 1 }])!, /^big\.pack is 24\.0 MiB, over the step's limit of 24\.0 MiB/); + const many = Array.from({ length: MAX_FILES + 1 }, (_, i) => ({ rel: `f${i}`, bytes: 1 })); + assert.match(limitProblem(many)!, /^15001 files to publish, over the step's limit of 15000/); + assert.equal(limitProblem(many.slice(1)), null); +}); + +test("skip: an unchanged main with unchanged rules does nothing; a changed rule does not skip", async () => { + const repo = sourceRepo(); + const files = operatorFiles(`/srv/${PLANTED}==>/home/user\n`, ""); + const logs: string[] = []; + // filter-repo is `false`: reaching it would refuse, so a 0 proves the skip. + const o = opts(repo, files, logs, { filterRepo: ["false"] }); + const pub = o.publicDir!; + const sourceCommit = gitIn(repo, "rev-parse", "main"); + const rules = await loadSourceRules({ ...files, homeDir: HOME }); + mkdirSync(path.join(pub, "source", MIRROR_DIR, "info"), { recursive: true }); + mkdirSync(path.join(pub, "downloads"), { recursive: true }); + writeFileSync(path.join(pub, "source", MIRROR_DIR, "info", "refs"), "x\trefs/heads/main\n"); + writeFileSync(path.join(pub, "downloads", path.basename(TARBALL_HREF)), "tarball"); + writeFileSync( + path.join(pub, "source", "manifest.json"), + JSON.stringify({ + version: 1, generatedAt: "x", branch: "main", sourceCommit, mirrorHead: "a".repeat(40), subject: "s", + files: 1, bytes: 1, mirror: {}, tree: {}, tarball: { href: TARBALL_HREF, bytes: 7, sha256: "b".repeat(64) }, + audit: {}, tools: {}, + }), + ); + writeFileSync(path.join(path.dirname(pub), ".source-publish.json"), JSON.stringify({ sourceCommit, rulesHash: rules.rulesHash })); + assert.equal(await publishSource(o), 0); + assert.match(logs.join("\n"), new RegExp(`up to date at ${sourceCommit.slice(0, 12)}; skipping`)); + + writeFileSync(files.denylistFile, "a-new-literal\n"); + logs.length = 0; + assert.equal(await publishSource(o), 1, "the new rule reached the rewrite"); + assert.match(logs.join("\n"), /REFUSED: false --force --quiet exited 1/); + assert.equal(readdirSync(o.scratchRoot!).length, 0, "the scratch dir is removed"); +}); + +test("round trip: --check writes nothing; publish; a dumb clone of the mirror is main, scrubbed, from static files", async (t) => { + if (filterRepoProblem) return t.skip(`git-filter-repo unavailable: ${filterRepoProblem}`); + const repo = sourceRepo(); + const files = operatorFiles(`# the planted path\n/srv/${PLANTED}==>/home/user\n`, `i:${PLANTED}\n`); + const logs: string[] = []; + const o = opts(repo, files, logs); + const pub = o.publicDir!; + const site = path.dirname(pub); + + assert.equal(await publishSource({ ...o, check: true }), 0, logs.join("\n")); + assert.match(logs.join("\n"), /check passed — would publish main .* nothing written/); + assert.ok(!existsSync(pub), "--check wrote nothing"); + assert.ok(!existsSync(path.join(site, ".source-publish.json"))); + + logs.length = 0; + assert.equal(await publishSource(o), 0, logs.join("\n")); + assert.match(logs.join("\n"), /\[source\] audit clean: \d+ objects \(3 commits\), \d+ staged files against 3 denied literals; gitleaks skipped/); + assert.match(logs.join("\n"), /\[source\] published main [0-9a-f]{12} as [0-9a-f]{12}: \d+ files/); + const manifest = JSON.parse(readFileSync(path.join(pub, "source", "manifest.json"), "utf8")); + const sourceCommit = gitIn(repo, "rev-parse", "main"); + assert.equal(manifest.version, 1); + assert.equal(manifest.branch, "main"); + assert.equal(manifest.sourceCommit, sourceCommit); + assert.notEqual(manifest.mirrorHead, sourceCommit, "scrubbed history, different ids"); + assert.equal(manifest.subject, "moved from /home/user"); + assert.equal(manifest.cloneUrl, CLONE_URL); + assert.equal(manifest.treeHref, TREE_HREF); + assert.deepEqual(manifest.tree, { files: 4, dirs: 4, bytes: manifest.tree.bytes }); + assert.ok(manifest.mirror.packs >= 1); + assert.equal(manifest.audit.commits, 3); + assert.equal(manifest.audit.gitleaks, "skipped"); + assert.ok(!("rulesHash" in manifest), "the rules hash is never published"); + assert.ok(existsSync(path.join(site, ".source-publish.json")), "…it is kept beside public/"); + + // The mirror: the allowlist and the dumb-HTTP files. + const mirror = path.join(pub, "source", MIRROR_DIR); + for (const f of ["config", "hooks", "description", "filter-repo", "logs", "info/exclude"]) { + assert.ok(!existsSync(path.join(mirror, f)), `${f} is never published`); + } + assert.equal(readFileSync(path.join(mirror, "HEAD"), "utf8"), "ref: refs/heads/main\n"); + assert.equal(readFileSync(path.join(mirror, "info", "refs"), "utf8"), `${manifest.mirrorHead}\trefs/heads/main\n`); + assert.match(readFileSync(path.join(mirror, "objects", "info", "packs"), "utf8"), /^P pack-[0-9a-f]+\.pack$/m); + assert.ok(!existsSync(path.join(pub, "source", "index.html")), "the /source/ page keeps its route"); + + // The clone. + const clone = path.join(dir("clone"), "c"); + execFileSync("git", ["clone", "-q", `file://${mirror}`, clone], { stdio: "pipe" }); + assert.equal(gitIn(clone, "rev-parse", "HEAD"), manifest.mirrorHead); + assert.equal(gitIn(clone, "log", "-1", "--format=%s"), "moved from /home/user"); + assert.equal(readFileSync(path.join(clone, "notes.txt"), "utf8"), "data lives at /home/user/x\n"); + const revs = gitIn(clone, "rev-list", "--all").split("\n"); + assert.equal(revs.length, 3); + assert.throws( + () => execFileSync("git", ["grep", "-F", PLANTED, ...revs], { cwd: clone, stdio: "pipe" }), + (e: { status?: number }) => e.status === 1, + "no revision holds the planted path", + ); + assert.ok(!gitIn(clone, "log", "--all", "--format=%B%an%ae").includes(PLANTED)); + + // The tarball and its sidecar agree with the manifest and the bytes. + const tarball = path.join(pub, "downloads", path.basename(TARBALL_HREF)); + const snapshot = JSON.parse(readFileSync(path.join(pub, "downloads", "snapshot.json"), "utf8")); + assert.equal(snapshot.sha256, sha256(tarball)); + assert.equal(snapshot.sha256, manifest.tarball.sha256); + assert.equal(snapshot.bytes, statSync(tarball).size); + assert.equal(snapshot.commit, manifest.mirrorHead); + assert.equal(snapshot.subject, "moved from /home/user"); + + // The tree pages. + const tree = path.join(pub, "source", "tree"); + for (const d of ["", "app", "app/[slug]", "fonts"]) assert.ok(existsSync(path.join(tree, d, "index.html")), d); + assert.match(readFileSync(path.join(tree, "fonts", "index.html"), "utf8"), /href="Archivo%5Bwdth%2Cwght%5D\.ttf"/); + assert.equal(readFileSync(path.join(tree, "notes.txt"), "utf8"), "data lives at /home/user/x\n"); + assert.equal(readdirSync(o.scratchRoot!).length, 0, "the scratch dir is removed"); + + // Nothing changed: the next publish skips. + logs.length = 0; + assert.equal(await publishSource(o), 0); + assert.match(logs.join("\n"), /up to date at/); +}); + +test("a denied literal no rule removes: refused, nothing written, the report never prints it", async (t) => { + if (filterRepoProblem) return t.skip(`git-filter-repo unavailable: ${filterRepoProblem}`); + const repo = sourceRepo({ "keys.txt": `the ${SECRET} is here\n` }); + const files = operatorFiles(`/srv/${PLANTED}==>/home/user\n`, `${SECRET}\n`); + const logs: string[] = []; + const o = opts(repo, files, logs); + const downloads = path.join(o.publicDir!, "downloads"); + mkdirSync(downloads, { recursive: true }); + writeFileSync(path.join(downloads, "snapshot.json"), "yesterday's"); + const before = statSync(path.join(downloads, "snapshot.json")).mtimeMs; + assert.equal(await publishSource(o), 1); + const report = logs.join("\n"); + assert.ok(!report.includes(SECRET), report); + assert.match(report, /AUDIT REFUSED: 1 hit in \d+ objects/); + assert.match(report, /#1 \(p…, len 13\): 1 in blob/); + assert.match(report, /blob [0-9a-f]{12} keys\.txt #1 \(p…, len 13\): the \[REDACTED\] is here\./); + assert.match(report, /add a rule to .*source-scrub\.txt or drop the file from history, then re-run\.$/); + assert.ok(!existsSync(path.join(o.publicDir!, "source")), "no manifest, no mirror"); + assert.equal(readFileSync(path.join(downloads, "snapshot.json"), "utf8"), "yesterday's"); + assert.equal(statSync(path.join(downloads, "snapshot.json")).mtimeMs, before); +}); + +test("--no-source's clear: the manifest, mirror, tree, tarball and skip key go; a linked downloads/ goes as a link", async () => { + const site = dir("clear"); + const pub = path.join(site, "public"); + mkdirSync(path.join(pub, "source", MIRROR_DIR), { recursive: true }); + writeFileSync(path.join(pub, "source", "manifest.json"), "{}"); + const elsewhere = dir("elsewhere"); + writeFileSync(path.join(elsewhere, "snapshot.json"), "the other checkout's"); + symlinkSync(elsewhere, path.join(pub, "downloads")); + writeFileSync(path.join(site, ".source-publish.json"), "{}"); + const logs: string[] = []; + await clearPublishedSource({ paths: {} as Paths, publicDir: pub, onLog: (l) => logs.push(l) }); + assert.ok(!existsSync(path.join(pub, "source"))); + assert.ok(!existsSync(path.join(site, ".source-publish.json"))); + assert.equal(lstatSync(path.join(pub, "downloads"), { throwIfNoEntry: false }), undefined); + assert.equal(readFileSync(path.join(elsewhere, "snapshot.json"), "utf8"), "the other checkout's"); + assert.match(logs.join("\n"), /previously published source .* was removed/); +}); diff --git a/common/publish/source.ts b/common/publish/source.ts @@ -0,0 +1,792 @@ +// `archilyzer source publish` — the repo on the project site, read-only. +// +// The private repository is never rewritten. Every publish makes a FRESH bare +// clone of its `main`, rewrites that copy with git-filter-repo (the operator's +// scrub rules over file contents AND commit messages), repacks it for git's +// dumb-HTTP protocol, and publishes three things under homepage/public: +// +// source/archilyzer.git/ a clonable mirror: HEAD, refs, info/refs, +// objects/info/packs, the packs — static files only +// source/tree/ the tracked files of main, raw, with an +// index.html per directory (sourceTree.ts) +// downloads/ archilyzer-source.tar.gz + snapshot.json, the +// tarball the Downloads page has always offered +// source/manifest.json written LAST: what was published, from what +// +// THE GATE. Before anything is staged for the site, every object of the +// rewritten mirror, and then every staged file, is searched for every denied +// literal (sourceAudit.ts): the operator's denylist plus every scrub rule's +// left side. One hit and nothing is written; the report names the literal by +// number, never by its bytes. +// +// OPERATOR-PRIVATE INPUTS live outside the repo, in +// `${ARCHILYZER_CONFIG_DIR ?? ~/.config/archilyzer}/`: source-scrub.txt +// (git-filter-repo `lhs==>rhs` lines; `<home dir>==>/home/user` is always +// applied first) and source-denylist.txt (one literal per line, `i:` = any +// case). A missing file is a refusal naming it. Nothing here names a user. +// +// `buildHomepage` runs this between compose and `next build`, so `archilyzer +// build homepage`, the runbooks' home scripts and the editor's /sites homepage +// jobs all publish it; an unchanged main with unchanged rules skips. + +import { createHash } from "node:crypto"; +import { createReadStream, existsSync } from "node:fs"; +import { cp, lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { runChildIntoLog } from "../jobs/runChild"; +import { copyPublicFile, ownDir, writePublicFile } from "../bin/_publicFile"; +import { getPaths, type Paths } from "../lib/paths"; +import { + CLONE_URL, + MIRROR_DIR, + SOURCE_MANIFEST_VERSION, + TARBALL_HREF, + TREE_HREF, + parseSourceManifest, + type SourceManifest, +} from "../lib/sourceManifest"; +import { + SourceRefusal, + auditBare, + auditFiles, + cleanGitEnv, + dedupeLiterals, + formatAuditReport, + maskLiterals, + onPath, + parseDenylist, + tildify, + type Literal, +} from "./sourceAudit"; +import { writeTreeIndexes } from "./sourceTree"; +// Type-only: build.ts imports THIS module lazily, and must not load it (or the +// AWS SDK this would drag in) at import time. +import type { PublishOpts } from "./build"; + +export { SourceRefusal } from "./sourceAudit"; + +/** The one branch mirrored (an operator decision: no other refs, no tags). */ +export const SOURCE_BRANCH = "main"; + +/** What `pipx run` fetches when `git filter-repo` is not installed. */ +export const FILTER_REPO_PIPX_SPEC = "git-filter-repo==2.47.0"; +export const FILTER_REPO_INSTALL = "pipx install git-filter-repo"; + +/** The home-directory rule's replacement (always the first scrub rule). */ +export const HOME_REPLACEMENT = "/home/user"; + +// Cloudflare Pages allows 20,000 files per deployment and 25 MiB per file; +// the step refuses well inside both, leaving the rest of the site its room. +export const MAX_FILES = 15_000; +export const MAX_FILE_BYTES = 24 * 1024 * 1024; + +// Packs are split at this size (under the per-file cap, with room to grow). +const PACK_SIZE = "20m"; + +const TARBALL_NAME = path.basename(TARBALL_HREF); + +export type SourcePublishOpts = PublishOpts & { + // Rebuild even when main and the rules are unchanged. + force?: boolean; + // Build, audit and count everything, then write NOTHING. + check?: boolean; + // Leave the scratch dir (the rewritten bare clone, the stage) for a look. + keepScratch?: boolean; + // The repository to mirror. Default: this checkout's git COMMON dir, so a + // worktree build mirrors the primary's main. + sourceRepo?: string; + // Default: HOMEPAGE_PUBLIC_DIR, else <repo>/homepage/public. + publicDir?: string; + scrubFile?: string; + denylistFile?: string; + // The filter-repo argv; default: resolveFilterRepo(). + filterRepo?: string[] | null; + // The gitleaks binary; null skips the secret scan (tests). Default "gitleaks". + gitleaks?: string | null; + // Where the scratch dir is made. Default: paths.sourceScratchDir. + scratchRoot?: string; + // The environment the children run in (PATH decides which tools). Default: + // process.env. + env?: NodeJS.ProcessEnv; + now?: () => Date; + // The home dir the built-in rule scrubs. Default: os.homedir(). + homeDir?: string; +}; + +// ── the operator's files ──────────────────────────────────────────────────── + +export type ScrubRules = { + // replace.txt as filter-repo will read it: the built-in rule first, then + // the operator's rules in order (comments and blank lines dropped — + // filter-repo itself would treat a `#` line as a literal to replace). + lines: string[]; + // Every rule's LITERAL left side: denied, exact case, by implication. + denied: string[]; +}; + +/** + * The scrub file's text as rules. A line is `lhs==>rhs` (split at the LAST + * `==>`, as filter-repo splits it), `literal:lhs==>rhs`, `regex:…==>…` or + * `glob:…==>…`; a line with no `==>` is replaced by filter-repo's + * `***REMOVED***`. Lines whose first non-blank character is `#` are comments. + */ +export function parseScrubRules(text: string, homeDir: string): ScrubRules { + const lines: string[] = []; + // A home dir of `/` (a container user) would scrub every slash, and one + // that IS the replacement would deny the replacement itself. + if (homeDir.length > 1 && homeDir !== HOME_REPLACEMENT) { + lines.push(`${homeDir}==>${HOME_REPLACEMENT}`); + } + for (const raw of text.split("\n")) { + const line = raw.replace(/\r$/, ""); + const t = line.trim(); + if (t === "" || t.startsWith("#")) continue; + lines.push(line); + } + const denied: string[] = []; + for (const line of lines) { + const i = line.lastIndexOf("==>"); + let lhs = i === -1 ? line : line.slice(0, i); + if (lhs.startsWith("regex:") || lhs.startsWith("glob:")) continue; + if (lhs.startsWith("literal:")) lhs = lhs.slice("literal:".length); + if (lhs) denied.push(lhs); + } + return { lines, denied }; +} + +export type SourceRules = { + scrub: ScrubRules; + literals: Literal[]; + // Changes whenever a rule or a literal does: part of the skip key. Never + // published (a hash of the denylist would confirm a guess at it). + rulesHash: string; +}; + +async function readOperatorFile(file: string, what: string, how: string): Promise<string> { + try { + return await readFile(file, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new SourceRefusal(`no ${what} at ${tildify(file)} — ${how}`); + } + throw err; + } +} + +/** Both operator files, parsed, with the literal list the gate searches for. */ +export async function loadSourceRules(opts: { + scrubFile: string; + denylistFile: string; + homeDir?: string; +}): Promise<SourceRules> { + const scrubText = await readOperatorFile( + opts.scrubFile, + "scrub rules", + "create it (git-filter-repo `lhs==>rhs` lines; the home-directory rule is built in, so it may be empty) or point SOURCE_SCRUB_FILE at one", + ); + const denyText = await readOperatorFile( + opts.denylistFile, + "denylist", + "create it (one literal per line, `i:` for any case; every scrub rule's left side is denied too, so it may be empty) or point SOURCE_DENYLIST_FILE at one", + ); + const scrub = parseScrubRules(scrubText, opts.homeDir ?? os.homedir()); + const literals = dedupeLiterals([ + ...parseDenylist(denyText), + ...scrub.denied.map((d) => ({ bytes: Buffer.from(d, "utf8"), ci: false })), + ]); + const rulesHash = createHash("sha256") + .update( + JSON.stringify({ + v: SOURCE_MANIFEST_VERSION, + rules: scrub.lines, + literals: literals.map((l) => `${l.ci ? "i" : "x"}:${l.bytes.toString("hex")}`), + }), + ) + .digest("hex"); + return { scrub, literals, rulesHash }; +} + +// ── children ──────────────────────────────────────────────────────────────── + +type Ctx = { + onLog: (line: string) => void; + signal: AbortSignal; + env: NodeJS.ProcessEnv; + // Every echoed or quoted child line is masked with these once they are known. + literals: readonly Literal[]; +}; + +class Cancelled extends Error {} + +/** + * One child through runChildIntoLog, with a timeout. Returns its combined + * output. A non-zero exit or a timeout is a refusal quoting its last lines + * (masked); a cancel throws Cancelled. + */ +async function run( + ctx: Ctx, + command: string, + args: string[], + o: { cwd: string; timeoutMs: number; echo?: boolean; allowFail?: boolean }, +): Promise<{ code: number; out: string[] }> { + const out: string[] = []; + const timeout = AbortSignal.timeout(o.timeoutMs); + const code = await runChildIntoLog( + (line) => { + out.push(line); + if (o.echo) ctx.onLog(`[source] ${maskLiterals(line, ctx.literals)}`); + }, + AbortSignal.any([ctx.signal, timeout]), + { command, args, cwd: o.cwd, env: ctx.env }, + ); + if (ctx.signal.aborted) throw new Cancelled(); + // `git --git-dir <path> repack …` is named by its verb, not the path. + const what = `${command} ${(args[0] === "--git-dir" ? args.slice(2, 3) : args.slice(0, 2)).join(" ")}`; + if (timeout.aborted) { + throw new SourceRefusal(`${what} timed out after ${Math.round(o.timeoutMs / 1000)} s`); + } + if (code !== 0 && !o.allowFail) { + const tail = out.slice(-3).map((l) => maskLiterals(l, ctx.literals)).join(" / "); + throw new SourceRefusal(`${what} exited ${code}${tail ? `: ${tail}` : ""}`); + } + return { code, out }; +} + +const lastLine = (out: string[]) => (out.filter((l) => l.trim()).at(-1) ?? "").trim(); +const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; + +async function revParse(ctx: Ctx, gitDir: string, rev: string): Promise<string> { + const { out } = await run(ctx, "git", ["--git-dir", gitDir, "rev-parse", "--verify", rev], { + cwd: gitDir, + timeoutMs: 30_000, + }); + const oid = lastLine(out); + if (!OID.test(oid)) throw new SourceRefusal(`git rev-parse ${rev}: not an object id`); + return oid; +} + +export type FilterRepoChoice = { argv: string[]; label: string; version: string }; + +/** + * Which git-filter-repo runs: an installed `git filter-repo`, else `pipx run` + * of the pinned version (network on first use), else a refusal with the + * install line. + */ +export async function resolveFilterRepo(opts: { + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + onLog?: (line: string) => void; +}): Promise<FilterRepoChoice> { + const ctx: Ctx = { + onLog: opts.onLog ?? (() => {}), + signal: opts.signal ?? new AbortController().signal, + env: cleanGitEnv(opts.env ?? process.env), + literals: [], + }; + const cwd = os.tmpdir(); + const installed = await run(ctx, "git", ["filter-repo", "--version"], { + cwd, + timeoutMs: 30_000, + allowFail: true, + }); + if (installed.code === 0) { + return { argv: ["git", "filter-repo"], label: "git filter-repo", version: lastLine(installed.out) }; + } + if (!onPath("pipx", ctx.env.PATH)) { + throw new SourceRefusal( + `git-filter-repo is not installed and pipx is not on PATH — install it once: \`${FILTER_REPO_INSTALL}\` (pipx comes from your OS's packages)`, + ); + } + const argv = ["pipx", "run", "--spec", FILTER_REPO_PIPX_SPEC, "git-filter-repo"]; + const viaPipx = await run(ctx, argv[0], [...argv.slice(1), "--version"], { + cwd, + timeoutMs: 300_000, + allowFail: true, + }); + if (viaPipx.code !== 0) { + throw new SourceRefusal( + `\`pipx run --spec ${FILTER_REPO_PIPX_SPEC}\` failed (exit ${viaPipx.code}; it needs the network on first use) — install it once: \`${FILTER_REPO_INSTALL}\``, + ); + } + return { + argv, + label: `pipx run --spec ${FILTER_REPO_PIPX_SPEC} git-filter-repo`, + version: lastLine(viaPipx.out), + }; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +function terminalLog(line: string): void { + process.stdout.write(line.endsWith("\n") ? line : `${line}\n`); +} + +function sha256File(file: string): Promise<string> { + return new Promise((resolve, reject) => { + const h = createHash("sha256"); + createReadStream(file) + .on("data", (c) => h.update(c)) + .on("error", reject) + .on("end", () => resolve(h.digest("hex"))); + }); +} + +async function walkFiles(dir: string): Promise<Array<{ rel: string; bytes: number }>> { + const out: Array<{ rel: string; bytes: number }> = []; + const walk = async (rel: string) => { + for (const ent of await readdir(path.join(dir, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${ent.name}` : ent.name; + if (ent.isDirectory()) await walk(r); + else out.push({ rel: r, bytes: (await stat(path.join(dir, r))).size }); + } + }; + await walk(""); + return out; +} + +const mb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + +/** + * Why `files` may not be published on Pages, as one sentence — or null. The + * step's limits sit inside the host's: 15,000 files of its 20,000 per + * deployment (the rest of the site needs room), 24 MiB of its 25 MiB per file. + */ +export function limitProblem(files: ReadonlyArray<{ rel: string; bytes: number }>): string | null { + if (files.length > MAX_FILES) { + return `${files.length} files to publish, over the step's limit of ${MAX_FILES} (Pages allows 20,000 per deployment)`; + } + const big = files.find((f) => f.bytes > MAX_FILE_BYTES); + if (big) { + return `${big.rel} is ${mb(big.bytes)} MiB, over the step's limit of ${mb(MAX_FILE_BYTES)} MiB (Pages allows 25 MiB per file)`; + } + return null; +} + +/** Where publishSource writes, for a given checkout. */ +export function sourcePublicDir(paths: Paths, override?: string): string { + return override ?? process.env.HOMEPAGE_PUBLIC_DIR ?? path.join(paths.monorepoRoot, "homepage", "public"); +} + +// The skip key, kept BESIDE the public dir, never in it: it holds the rules +// hash, and public/ is deployed. +function statePath(publicDir: string): string { + return path.join(path.dirname(publicDir), ".source-publish.json"); +} + +type PublishState = { sourceCommit: string; rulesHash: string }; + +async function readJson(file: string): Promise<unknown> { + try { + return JSON.parse(await readFile(file, "utf8")); + } catch { + return null; + } +} + +/** The last published manifest in `publicDir`, or null. */ +export async function readPublishedManifest(publicDir: string): Promise<SourceManifest | null> { + return parseSourceManifest(await readJson(path.join(publicDir, "source", "manifest.json"))); +} + +// ── the step ──────────────────────────────────────────────────────────────── + +/** + * Publish the source (see the header). Returns 0 when published, skipped or + * checked, 1 when refused or cancelled; the refusal's reason (and the audit's + * redacted report) is in the log. Throws only on a bug or an I/O failure. + */ +export async function publishSource(opts: SourcePublishOpts = {}): Promise<number> { + const paths = opts.paths ?? getPaths(); + const onLog = opts.onLog ?? terminalLog; + const signal = opts.signal ?? new AbortController().signal; + const ctx: Ctx = { onLog, signal, env: cleanGitEnv(opts.env ?? process.env), literals: [] }; + try { + return await publish(opts, paths, ctx); + } catch (err) { + if (err instanceof Cancelled || signal.aborted) { + onLog("[source] cancelled — nothing published"); + return 1; + } + if (err instanceof SourceRefusal) { + onLog(`[source] REFUSED: ${err.message}`); + return 1; + } + throw err; + } +} + +async function publish(opts: SourcePublishOpts, paths: Paths, ctx: Ctx): Promise<number> { + const { onLog } = ctx; + const started = Date.now(); + const publicDir = sourcePublicDir(paths, opts.publicDir); + const pubSource = path.join(publicDir, "source"); + const pubDownloads = path.join(publicDir, "downloads"); + + // 1. The private main. + const sourceRepo = + opts.sourceRepo ?? + lastLine( + ( + await run(ctx, "git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { + cwd: paths.monorepoRoot, + timeoutMs: 30_000, + }) + ).out, + ); + const sourceCommit = await revParse(ctx, sourceRepo, `refs/heads/${SOURCE_BRANCH}^{commit}`); + + // 2. The operator's rules. + const rules = await loadSourceRules({ + scrubFile: opts.scrubFile ?? paths.sourceScrubFile, + denylistFile: opts.denylistFile ?? paths.sourceDenylistFile, + homeDir: opts.homeDir, + }); + ctx.literals = rules.literals; + + // 3. Nothing changed: skip. + if (!opts.force && !opts.check) { + const manifest = await readPublishedManifest(publicDir); + const state = (await readJson(statePath(publicDir))) as PublishState | null; + if ( + manifest?.sourceCommit === sourceCommit && + state?.sourceCommit === sourceCommit && + state.rulesHash === rules.rulesHash && + existsSync(path.join(pubSource, MIRROR_DIR, "info", "refs")) && + existsSync(path.join(pubDownloads, TARBALL_NAME)) + ) { + onLog(`[source] up to date at ${sourceCommit.slice(0, 12)}; skipping (--force to rebuild)`); + return 0; + } + } + if (existsSync(path.join(pubSource, "index.html"))) { + throw new SourceRefusal( + `${tildify(path.join(pubSource, "index.html"))} exists and would replace the /source/ page — remove it`, + ); + } + + // 4. The tools. + const filterRepo: FilterRepoChoice = + opts.filterRepo && opts.filterRepo.length > 0 + ? { argv: opts.filterRepo, label: opts.filterRepo.join(" "), version: "(given)" } + : await resolveFilterRepo({ env: ctx.env, signal: ctx.signal }); + const gitVersion = lastLine((await run(ctx, "git", ["--version"], { cwd: os.tmpdir(), timeoutMs: 30_000 })).out) + .replace(/^git version /, ""); + onLog(`[source] main ${sourceCommit.slice(0, 12)}; git ${gitVersion}; ${filterRepo.label} ${filterRepo.version}`); + + const scratchRoot = opts.scratchRoot ?? paths.sourceScratchDir; + await mkdir(scratchRoot, { recursive: true }); + const scratch = await mkdtemp(path.join(scratchRoot, "archilyzer-source-")); + try { + const bare = path.join(scratch, "bare"); + + // 5. A fresh bare clone of main alone. --no-local: through upload-pack, not + // a copy of the object dir (which would carry every loose leftover). + await run( + ctx, + "git", + ["clone", "--no-local", "--bare", "--single-branch", "--no-tags", "--branch", SOURCE_BRANCH, "--quiet", sourceRepo, bare], + { cwd: scratch, timeoutMs: 120_000 }, + ); + await run(ctx, "git", ["--git-dir", bare, "remote", "remove", "origin"], { cwd: bare, timeoutMs: 30_000 }); + + // 6. The rewrite. --force: filter-repo wants a fresh clone with an origin. + const replace = path.join(scratch, "replace.txt"); + await writeFile(replace, rules.scrub.lines.join("\n") + "\n"); + onLog(`[source] rewriting history (${rules.scrub.lines.length} scrub rule${rules.scrub.lines.length === 1 ? "" : "s"})…`); + await run( + ctx, + filterRepo.argv[0], + [ + ...filterRepo.argv.slice(1), + "--force", + "--quiet", + "--replace-refs", + "delete-no-add", + "--replace-text", + replace, + "--replace-message", + replace, + ], + { cwd: bare, timeoutMs: 900_000, echo: true }, + ); + // commit-map / ref-map hold the PRIVATE ids. + await rm(path.join(bare, "filter-repo"), { recursive: true, force: true }); + + // 7. Packed for dumb HTTP. + const g = (args: string[], timeoutMs = 60_000) => + run(ctx, "git", ["--git-dir", bare, ...args], { cwd: bare, timeoutMs }); + await g(["repack", "-a", "-d", "-q", `--max-pack-size=${PACK_SIZE}`], 300_000); + await g(["prune-packed"]); + await g(["pack-refs", "--all"]); + await g(["update-server-info"]); + const counts = (await g(["count-objects", "-v"])).out; + const loose = Number(/^count:\s*(\d+)/m.exec(counts.join("\n"))?.[1] ?? NaN); + if (loose !== 0) throw new SourceRefusal(`the repacked mirror still has ${loose} loose objects`); + const packsList = await readFile(path.join(bare, "objects", "info", "packs"), "utf8").catch(() => ""); + const packs = packsList.split("\n").filter((l) => l.startsWith("P ")).length; + if (packs === 0) throw new SourceRefusal("the repacked mirror lists no packs in objects/info/packs"); + const refs = (await g(["for-each-ref", "--format=%(refname)"])).out.filter((l) => l.trim()); + if (refs.length !== 1 || refs[0] !== `refs/heads/${SOURCE_BRANCH}`) { + throw new SourceRefusal(`the mirror must hold refs/heads/${SOURCE_BRANCH} alone, and holds ${refs.length} refs`); + } + const head = (await readFile(path.join(bare, "HEAD"), "utf8")).trim(); + if (head !== `ref: refs/heads/${SOURCE_BRANCH}`) { + throw new SourceRefusal(`the mirror's HEAD is not refs/heads/${SOURCE_BRANCH}`); + } + + // 8. What it became. + const mirrorHead = await revParse(ctx, bare, `refs/heads/${SOURCE_BRANCH}`); + const subject = lastLine((await g(["log", "-1", "--format=%s", `refs/heads/${SOURCE_BRANCH}`])).out); + + // 9. THE GATE, over every object. + onLog(`[source] auditing ${mirrorHead.slice(0, 12)} for ${rules.literals.length} denied literals…`); + const audit = await auditBare(bare, rules.literals, { + scratch, + onLog, + signal: ctx.signal, + gitleaks: opts.gitleaks === undefined ? "gitleaks" : opts.gitleaks, + env: ctx.env, + }); + const scrubFile = opts.scrubFile ?? paths.sourceScrubFile; + if (audit.hits.length > 0) { + for (const l of formatAuditReport(audit, rules.literals, { scrubFile })) onLog(l); + return 1; + } + + const now = opts.now?.() ?? new Date(); + const generatedAt = now.toISOString(); + const stage = path.join(scratch, "stage"); + const stageSource = path.join(stage, "source"); + const stageMirror = path.join(stageSource, MIRROR_DIR); + const stageTree = path.join(stageSource, "tree"); + const stageDownloads = path.join(stage, "downloads"); + await mkdir(stageTree, { recursive: true }); + await mkdir(stageDownloads, { recursive: true }); + + // 10. The raw tree, with its directory pages. + const treeTar = path.join(scratch, "tree.tar"); + await g(["archive", "--format=tar", "-o", treeTar, `refs/heads/${SOURCE_BRANCH}`], 120_000); + await run(ctx, "tar", ["-xf", treeTar, "-C", stageTree], { cwd: scratch, timeoutMs: 120_000 }); + await rm(treeTar, { force: true }); + const tree = await writeTreeIndexes(stageTree, { mirrorHead, generatedAt }); + + // 11. The tarball and its sidecar (the Snapshot shape the Downloads page reads). + const tarball = path.join(stageDownloads, TARBALL_NAME); + await g( + ["archive", "--format=tar.gz", "-9", "--prefix=archilyzer/", "-o", tarball, `refs/heads/${SOURCE_BRANCH}`], + 120_000, + ); + const tarBytes = (await stat(tarball)).size; + const tarSha = await sha256File(tarball); + const snapshotText = + JSON.stringify( + { generatedAt, commit: mirrorHead, subject, bytes: tarBytes, sha256: tarSha }, + null, + 2, + ) + "\n"; + await writeFile(path.join(stageDownloads, "snapshot.json"), snapshotText); + + // 12. The mirror, from an ALLOWLIST: never config (it names the clone's + // origin path), hooks/, description, logs/, filter-repo/, *.rev, *.bitmap. + // refs/heads/<branch> is written beside packed-refs so the directory is a + // git dir to git itself too (a file:// clone, `source audit`): git wants a + // refs/ directory, and an empty one would not survive the deploy. + await mkdir(path.join(stageMirror, "info"), { recursive: true }); + await mkdir(path.join(stageMirror, "objects", "info"), { recursive: true }); + await mkdir(path.join(stageMirror, "objects", "pack"), { recursive: true }); + await mkdir(path.join(stageMirror, "refs", "heads"), { recursive: true }); + for (const f of ["HEAD", "packed-refs", "info/refs", "objects/info/packs"]) { + await cp(path.join(bare, f), path.join(stageMirror, f)); + } + await writeFile(path.join(stageMirror, "refs", "heads", SOURCE_BRANCH), `${mirrorHead}\n`); + for (const f of await readdir(path.join(bare, "objects", "pack"))) { + if (/^pack-[0-9a-f]+\.(pack|idx)$/.test(f)) { + await cp(path.join(bare, "objects", "pack", f), path.join(stageMirror, "objects", "pack", f)); + } + } + const mirrorFiles = await walkFiles(stageMirror); + + // The manifest, staged with the rest so the file sweep reads it too. + const staged = await walkFiles(stage); + const manifest: SourceManifest = { + version: SOURCE_MANIFEST_VERSION, + generatedAt, + branch: SOURCE_BRANCH, + sourceCommit, + mirrorHead, + subject, + files: staged.length, + bytes: staged.reduce((n, f) => n + f.bytes, 0), + mirror: { + files: mirrorFiles.length, + bytes: mirrorFiles.reduce((n, f) => n + f.bytes, 0), + packs, + }, + tree, + tarball: { href: TARBALL_HREF, bytes: tarBytes, sha256: tarSha }, + cloneUrl: CLONE_URL, + treeHref: TREE_HREF, + audit: { + literals: rules.literals.length, + objects: audit.objects, + commits: audit.commits, + gitleaks: audit.gitleaks === "clean" ? "clean" : "skipped", + }, + tools: { git: gitVersion, filterRepo: `${filterRepo.label} ${filterRepo.version}` }, + }; + const manifestText = JSON.stringify(manifest, null, 2) + "\n"; + await writeFile(path.join(stageSource, "manifest.json"), manifestText); + + // 13. THE GATE, over every staged file and path (packs excepted: step 9 + // read their objects). + await auditFiles(stage, rules.literals, { result: audit }); + if (audit.hits.length > 0) { + for (const l of formatAuditReport(audit, rules.literals, { scrubFile })) onLog(l); + return 1; + } + for (const l of formatAuditReport(audit, rules.literals, { scrubFile })) onLog(l); + + // 14. The host's limits. + const all = await walkFiles(stage); + const tooMuch = limitProblem(all); + if (tooMuch) throw new SourceRefusal(maskLiterals(tooMuch, rules.literals)); + const totalBytes = all.reduce((n, f) => n + f.bytes, 0); + const summary = + `main ${sourceCommit.slice(0, 12)} as ${mirrorHead.slice(0, 12)}: ${all.length} files, ${mb(totalBytes)} MB ` + + `(mirror ${packs} pack${packs === 1 ? "" : "s"}, tree ${tree.dirs} dirs), tarball ${mb(tarBytes)} MB sha256 ${tarSha.slice(0, 12)}`; + + // 15. --check writes nothing. + if (opts.check) { + onLog(`[source] check passed — would publish ${summary}; nothing written (${elapsed(started)})`); + return 0; + } + + // 16. Install, link-safe. The manifest goes first and comes back last: a + // crash mid-copy leaves the page's empty state, never a manifest over a + // half-written tree. + await ownDir(pubSource); + await rm(path.join(pubSource, "manifest.json"), { force: true }); + await rm(path.join(pubSource, MIRROR_DIR), { recursive: true, force: true }); + await rm(path.join(pubSource, "tree"), { recursive: true, force: true }); + await cp(stageMirror, path.join(pubSource, MIRROR_DIR), { recursive: true }); + await cp(stageTree, path.join(pubSource, "tree"), { recursive: true }); + await ownDir(pubDownloads); + await copyPublicFile(tarball, path.join(pubDownloads, TARBALL_NAME)); + await writePublicFile(path.join(pubDownloads, "snapshot.json"), snapshotText); + const state: PublishState = { sourceCommit, rulesHash: rules.rulesHash }; + await writeFile(statePath(publicDir), JSON.stringify(state, null, 2) + "\n"); + await writePublicFile(path.join(pubSource, "manifest.json"), manifestText); + + // 17. + onLog(`[source] published ${summary} (${elapsed(started)})`); + return 0; + } finally { + if (opts.keepScratch) onLog(`[source] scratch kept at ${scratch}`); + else await rm(scratch, { recursive: true, force: true }); + } +} + +function elapsed(started: number): string { + return `${Math.round((Date.now() - started) / 1000)} s`; +} + +/** + * `build homepage --no-source`: remove what an earlier publish left (the + * manifest first, so the page never describes a half-removed tree), because + * it was audited against the rules of ITS day. The pages then show their + * empty states. Link-safe like the install: a linked directory is replaced, + * never followed. + */ +export async function clearPublishedSource( + opts: PublishOpts & { publicDir?: string } = {}, +): Promise<void> { + const paths = opts.paths ?? getPaths(); + const onLog = opts.onLog ?? terminalLog; + const publicDir = sourcePublicDir(paths, opts.publicDir); + const pubSource = path.join(publicDir, "source"); + const pubDownloads = path.join(publicDir, "downloads"); + const had = existsSync(path.join(pubSource, "manifest.json")) || existsSync(path.join(pubDownloads, TARBALL_NAME)); + await rm(path.join(pubSource, "manifest.json"), { force: true }); + await rm(statePath(publicDir), { force: true }); + // fs.rm reads the path with lstat: a linked public/source goes as a link. + await rm(pubSource, { recursive: true, force: true }); + // A linked downloads/ (a worktree's, into the primary) is dropped as a link, + // never reached through: its files are the other checkout's. + const linked = await lstat(pubDownloads).then((s) => s.isSymbolicLink(), () => false); + if (linked) { + await rm(pubDownloads, { force: true }); + } else { + await rm(path.join(pubDownloads, "snapshot.json"), { force: true }); + await rm(path.join(pubDownloads, TARBALL_NAME), { force: true }); + } + onLog( + had + ? "[notice] --no-source: the previously published source (mirror, tree, tarball) was removed — this build ships none.\n" + : "[notice] --no-source: no source published in this build.\n", + ); +} + +// ── `archilyzer source audit` ─────────────────────────────────────────────── + +/** + * The gate alone, over any git dir — by default the published mirror; the + * rollout runs it on a LIVE clone. 0 clean, 1 hits (the redacted report is + * logged) or refused. + */ +export async function auditSource( + opts: PublishOpts & { + gitDir?: string; + publicDir?: string; + scrubFile?: string; + denylistFile?: string; + gitleaks?: string | null; + scratchRoot?: string; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {}, +): Promise<number> { + const paths = opts.paths ?? getPaths(); + const onLog = opts.onLog ?? terminalLog; + const signal = opts.signal ?? new AbortController().signal; + const env = cleanGitEnv(opts.env ?? process.env); + const gitDir = path.resolve( + opts.gitDir ?? path.join(sourcePublicDir(paths, opts.publicDir), "source", MIRROR_DIR), + ); + const scrubFile = opts.scrubFile ?? paths.sourceScrubFile; + let scratch: string | null = null; + try { + const rules = await loadSourceRules({ + scrubFile, + denylistFile: opts.denylistFile ?? paths.sourceDenylistFile, + homeDir: opts.homeDir, + }); + const ctx: Ctx = { onLog, signal, env, literals: rules.literals }; + const head = await revParse(ctx, gitDir, "HEAD"); + const scratchRoot = opts.scratchRoot ?? paths.sourceScratchDir; + await mkdir(scratchRoot, { recursive: true }); + scratch = await mkdtemp(path.join(scratchRoot, "archilyzer-source-audit-")); + onLog(`[source] auditing ${tildify(gitDir)} (HEAD ${head.slice(0, 12)}) for ${rules.literals.length} denied literals…`); + const audit = await auditBare(gitDir, rules.literals, { + scratch, + onLog, + signal, + gitleaks: opts.gitleaks === undefined ? "gitleaks" : opts.gitleaks, + env, + }); + for (const l of formatAuditReport(audit, rules.literals, { scrubFile })) onLog(l); + return audit.hits.length > 0 ? 1 : 0; + } catch (err) { + if (err instanceof Cancelled || signal.aborted) { + onLog("[source] cancelled"); + return 1; + } + if (err instanceof SourceRefusal) { + onLog(`[source] REFUSED: ${err.message}`); + return 1; + } + throw err; + } finally { + if (scratch) await rm(scratch, { recursive: true, force: true }); + } +}