Public: anyone can read this; the crew can change it.
forge.db and ssh/host_ed25519 leave the tree, and the tree refuses runtime state #671
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/622-forge-db-and-ssh-host-ed25519-leave-the → main 1 commit fork
10 files
changed +356
−9
.gitignore +5
| @@ -14,3 +14,8 @@ typesafe-api-key | ||
| 14 | 14 | /*.db |
| 15 | 15 | /*.db-wal |
| 16 | 16 | /*.db-shm |
| 17 | # a forge whose data directory was the checkout leaves these; boot now | |
| 18 | # refuses that, and the gate refuses a tracked key or database (#622) | |
| 19 | /forge.db* | |
| 20 | /ssh/ | |
| 21 | /*.sock |
README.md +2 −1
| @@ -81,7 +81,8 @@ convenience and exists only there: over SSH, the key decides who you are and | ||
| 81 | 81 | nothing else can. |
| 82 | 82 | |
| 83 | 83 | Configuration is environment variables, all with defaults: `FORGE_DATA` |
| 84 | (`data/`), `PORT` (`3000`), `FORGE_SSH_PORT` (`2222`; `0` turns SSH off), and | |
| 84 | (`data/`; the forge refuses to boot when it is the checkout itself or a path | |
| 85 | inside it that git does not ignore), `PORT` (`3000`), `FORGE_SSH_PORT` (`2222`; `0` turns SSH off), and | |
| 85 | 86 | `FORGE_HOST` (`localhost`, the hostname in clone URLs). Connecting origins |
| 86 | 87 | adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks), |
| 87 | 88 | `FORGE_GITHUB_CLIENT_ID` / `FORGE_GITHUB_CLIENT_SECRET`, and |
scripts/gate.ts +2
| @@ -83,6 +83,8 @@ export const GATE: readonly string[] = [ | ||
| 83 | 83 | "test/highlight.test.ts", |
| 84 | 84 | "test/check-list.test.ts", |
| 85 | 85 | "test/oauth-discovery.test.ts", |
| 86 | "test/data-home.test.ts", | |
| 87 | "test/tree.test.ts", | |
| 86 | 88 | ]; |
| 87 | 89 | |
| 88 | 90 | export const EXCLUDED: readonly Exclusion[] = [ |
scripts/tree.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * Runtime state the tree must not carry (#622): a private key or a SQLite | |
| 3 | * database committed beside the source rides into every clone and preview. | |
| 4 | * | |
| 5 | * `auditTree` names each such file, one line per file, outside `FIXTURES` | |
| 6 | * (the explicit allow-list for test fixtures). `test/tree.test.ts` runs it | |
| 7 | * over the tracked files, so the gate goes red on one. In a clone the files | |
| 8 | * are `git ls-files`; in the forge's check directory, written by | |
| 9 | * `checkout-index` with no `.git`, every file there is a tracked one, so the | |
| 10 | * directory is walked instead (`node_modules` aside, which the install made). | |
| 11 | */ | |
| 12 | ||
| 13 | import { readdirSync, readFileSync } from "node:fs"; | |
| 14 | import { join } from "node:path"; | |
| 15 | ||
| 16 | /** Tracked files allowed to look like a key or a database, each with its reason. */ | |
| 17 | export const FIXTURES: readonly { file: string; reason: string }[] = []; | |
| 18 | ||
| 19 | /** The 16-byte header every SQLite 3 database file starts with. */ | |
| 20 | const SQLITE = new TextEncoder().encode("SQLite format 3\0"); | |
| 21 | // Spelled so this source does not match itself. | |
| 22 | const PRIVATE_KEY = new RegExp(`-{5}BEGIN [A-Z0-9 ]*PRIVATE KEY-{5}`); | |
| 23 | ||
| 24 | /** What `bytes` look like, if it is something the tree refuses. */ | |
| 25 | export function stateKind(bytes: Uint8Array): string | null { | |
| 26 | if ( | |
| 27 | bytes.length >= SQLITE.length && | |
| 28 | SQLITE.every((byte, i) => bytes[i] === byte) | |
| 29 | ) | |
| 30 | return "a SQLite database"; | |
| 31 | // latin1 keeps every byte one character, so a key inside a binary still shows. | |
| 32 | if (PRIVATE_KEY.test(Buffer.from(bytes).toString("latin1"))) | |
| 33 | return "a private key"; | |
| 34 | return null; | |
| 35 | } | |
| 36 | ||
| 37 | /** One line per tracked file that is a key or a database and not a listed fixture. */ | |
| 38 | export function auditTree( | |
| 39 | files: readonly string[], | |
| 40 | read: (file: string) => Uint8Array, | |
| 41 | fixtures: readonly { file: string }[] = FIXTURES, | |
| 42 | ): string[] { | |
| 43 | const allowed = new Set(fixtures.map((f) => f.file)); | |
| 44 | const problems: string[] = []; | |
| 45 | for (const file of files) { | |
| 46 | if (allowed.has(file)) continue; | |
| 47 | const kind = stateKind(read(file)); | |
| 48 | if (kind !== null) | |
| 49 | problems.push( | |
| 50 | `${file} is ${kind}; runtime state belongs under FORGE_DATA, a fixture belongs in FIXTURES (scripts/tree.ts)`, | |
| 51 | ); | |
| 52 | } | |
| 53 | return problems; | |
| 54 | } | |
| 55 | ||
| 56 | function walk(root: string, dir = ""): string[] { | |
| 57 | const out: string[] = []; | |
| 58 | for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) { | |
| 59 | const path = dir === "" ? entry.name : `${dir}/${entry.name}`; | |
| 60 | if (entry.isDirectory()) { | |
| 61 | if (entry.name === "node_modules" || entry.name === ".git") continue; | |
| 62 | out.push(...walk(root, path)); | |
| 63 | } else if (entry.isFile()) out.push(path); | |
| 64 | } | |
| 65 | return out; | |
| 66 | } | |
| 67 | ||
| 68 | /** The repository's tracked files, relative to `root`. */ | |
| 69 | export function trackedFiles(root: string): string[] { | |
| 70 | const listed = Bun.spawnSync(["git", "ls-files", "-z"], { | |
| 71 | cwd: root, | |
| 72 | stdout: "pipe", | |
| 73 | stderr: "ignore", | |
| 74 | }); | |
| 75 | if (listed.exitCode === 0) | |
| 76 | return listed.stdout | |
| 77 | .toString() | |
| 78 | .split("\0") | |
| 79 | .filter((file) => file !== ""); | |
| 80 | return walk(root); | |
| 81 | } | |
| 82 | ||
| 83 | /** The tree's problems, read from disk; a tracked file deleted in the work tree is skipped. */ | |
| 84 | export function auditRepository(root: string): string[] { | |
| 85 | return auditTree(trackedFiles(root), (file) => { | |
| 86 | try { | |
| 87 | return readFileSync(join(root, file)); | |
| 88 | } catch { | |
| 89 | return new Uint8Array(); | |
| 90 | } | |
| 91 | }); | |
| 92 | } |
src/kernel/data_home.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * Where a forge may keep its state: anywhere but the tree it runs from. | |
| 3 | * | |
| 4 | * A forge started with its data directory set to its own checkout leaves | |
| 5 | * `forge.db`, `ssh/host_ed25519` and a socket beside the source, and the | |
| 6 | * next commit carries a private key into every clone (#622). So boot asks | |
| 7 | * git one question: is the data directory the checkout itself, or a path | |
| 8 | * inside it that `.gitignore` does not cover? Either is refused, with the | |
| 9 | * reason in one line. A forge running from no checkout (an image, a | |
| 10 | * `checkout-index` deploy) has nothing to protect and is let through. | |
| 11 | */ | |
| 12 | ||
| 13 | import { existsSync, realpathSync } from "node:fs"; | |
| 14 | import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; | |
| 15 | ||
| 16 | /** The real path of `path`, or of its nearest existing ancestor with the rest appended. */ | |
| 17 | function real(path: string): string { | |
| 18 | const missing: string[] = []; | |
| 19 | let at = resolve(path); | |
| 20 | while (!existsSync(at)) { | |
| 21 | const up = dirname(at); | |
| 22 | if (up === at) break; | |
| 23 | missing.unshift(at.slice(up.length).replace(/^[/\\]+/, "")); | |
| 24 | at = up; | |
| 25 | } | |
| 26 | return resolve(realpathSync(at), ...missing); | |
| 27 | } | |
| 28 | ||
| 29 | function git(args: string[], cwd: string): { code: number; out: string } { | |
| 30 | const run = Bun.spawnSync(["git", ...args], { | |
| 31 | cwd, | |
| 32 | stdout: "pipe", | |
| 33 | stderr: "ignore", | |
| 34 | env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" }, | |
| 35 | }); | |
| 36 | return { code: run.exitCode ?? 1, out: run.stdout.toString().trim() }; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Why `dataDir` may not hold this forge's state, in one line; `null` when | |
| 41 | * it may. `checkout` is any directory inside the forge's own source tree. | |
| 42 | */ | |
| 43 | export function dataHomeRefusal( | |
| 44 | dataDir: string, | |
| 45 | checkout: string, | |
| 46 | ): string | null { | |
| 47 | if (!existsSync(checkout)) return null; | |
| 48 | const top = git(["rev-parse", "--show-toplevel"], checkout); | |
| 49 | if (top.code !== 0 || top.out === "") return null; | |
| 50 | const root = real(top.out); | |
| 51 | const data = real(dataDir); | |
| 52 | const rel = relative(root, data); | |
| 53 | if (rel === "") | |
| 54 | return `forge: refusing to boot: the data directory ${data} is the repository checkout; set FORGE_DATA to a path outside it (#622)`; | |
| 55 | if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) | |
| 56 | return null; | |
| 57 | // The trailing slash lets a directory pattern (`data/`) match a | |
| 58 | // directory that does not exist yet. | |
| 59 | const ignored = git( | |
| 60 | ["check-ignore", "-q", "--", `${rel.split(sep).join("/")}/`], | |
| 61 | root, | |
| 62 | ); | |
| 63 | if (ignored.code === 0) return null; | |
| 64 | return `forge: refusing to boot: the data directory ${data} is inside the checkout ${root} and git does not ignore it; set FORGE_DATA outside it or list it in .gitignore (#622)`; | |
| 65 | } |
src/main.ts +14 −4
| @@ -30,6 +30,7 @@ import { graphInstagram } from "./crew/instagram.ts"; | ||
| 30 | 30 | import { startNotifier } from "./crew/notify.ts"; |
| 31 | 31 | import { startPromptRoutine } from "./crew/prompts.ts"; |
| 32 | 32 | import { pruneTokens } from "./core/tokens.ts"; |
| 33 | import { dataHomeRefusal } from "./kernel/data_home.ts"; | |
| 33 | 34 | |
| 34 | 35 | // `forge hook <name>` re-enters here from a git hook stub. It relays to the |
| 35 | 36 | // running forge over its socket and exits — it must never boot a server. |
| @@ -63,11 +64,20 @@ const canonicalUrl = hosted ? hostedAt : (process.env.FORGE_URL ?? "").trim(); | ||
| 63 | 64 | // identity headers sign people in; without it they are ignored. |
| 64 | 65 | const edgeToken = (process.env.FORGE_EDGE_TOKEN ?? "").trim(); |
| 65 | 66 | |
| 67 | const dataDir = | |
| 68 | process.env.FORGE_DATA ?? | |
| 69 | (hosted ? process.env.FORGE_APP_DATA : undefined) ?? | |
| 70 | "data"; | |
| 71 | // State never lands in the tree the forge runs from (#622): a database or a | |
| 72 | // host key there is one `git add -A` from every clone. | |
| 73 | const refusal = dataHomeRefusal(dataDir, import.meta.dir); | |
| 74 | if (refusal !== null) { | |
| 75 | console.error(refusal); | |
| 76 | process.exit(1); | |
| 77 | } | |
| 78 | ||
| 66 | 79 | const app = createApp({ |
| 67 | dataDir: | |
| 68 | process.env.FORGE_DATA ?? | |
| 69 | (hosted ? process.env.FORGE_APP_DATA : undefined) ?? | |
| 70 | "data", | |
| 80 | dataDir, | |
| 71 | 81 | port: Number(process.env.PORT ?? 3000), |
| 72 | 82 | ...(process.env.FORGE_HOST === undefined |
| 73 | 83 | ? {} |
ssh/host_ed25519 deleted −3
| @@ -1,3 +0,0 @@ | ||
| 1 | -----BEGIN PRIVATE KEY----- | |
| 2 | MC4CAQAwBQYDK2VwBCIEIJcIFQoeJ/tnUP4YRLfkgVdlhqtyfoINOdy0bmS3qYOf | |
| 3 | -----END PRIVATE KEY----- |
ssh/host_ed25519.pub deleted −1
| @@ -1,1 +0,0 @@ | ||
| 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHkZRmKOl+ECcGUt2okfxOAeGbtAPbabnnZe4K63uzd forge |
test/data-home.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * A forge keeps its state outside the tree it runs from (#622): boot refuses | |
| 3 | * a data directory that is the checkout, or inside it and not ignored, with | |
| 4 | * the reason in one line. The fixture is a throwaway git repository with | |
| 5 | * `data/` in its `.gitignore`, as this one has. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { dataHomeRefusal } from "../src/kernel/data_home.ts"; | |
| 13 | ||
| 14 | let base: string; | |
| 15 | let repo: string; | |
| 16 | ||
| 17 | beforeAll(() => { | |
| 18 | base = mkdtempSync(join(tmpdir(), "forge-data-home-")); | |
| 19 | repo = join(base, "checkout"); | |
| 20 | mkdirSync(join(repo, "src"), { recursive: true }); | |
| 21 | Bun.spawnSync(["git", "init", "-q", repo]); | |
| 22 | writeFileSync(join(repo, ".gitignore"), "data/\n*.local\n"); | |
| 23 | }); | |
| 24 | ||
| 25 | afterAll(() => rmSync(base, { recursive: true, force: true })); | |
| 26 | ||
| 27 | describe("dataHomeRefusal", () => { | |
| 28 | test("the checkout itself is refused, in one line", () => { | |
| 29 | const why = dataHomeRefusal(repo, join(repo, "src")); | |
| 30 | expect(why).toContain("is the repository checkout"); | |
| 31 | expect(why).not.toContain("\n"); | |
| 32 | }); | |
| 33 | ||
| 34 | test("a relative path that resolves to the checkout is refused", () => { | |
| 35 | const why = dataHomeRefusal(join(repo, "src", ".."), join(repo, "src")); | |
| 36 | expect(why).toContain("is the repository checkout"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a directory inside the checkout that git does not ignore is refused", () => { | |
| 40 | const why = dataHomeRefusal(join(repo, "state"), join(repo, "src")); | |
| 41 | expect(why).toContain("git does not ignore it"); | |
| 42 | expect(why).not.toContain("\n"); | |
| 43 | expect(dataHomeRefusal(join(repo, "src"), repo)).toContain( | |
| 44 | "git does not ignore it", | |
| 45 | ); | |
| 46 | }); | |
| 47 | ||
| 48 | test("an ignored directory inside the checkout is allowed, existing or not", () => { | |
| 49 | expect(dataHomeRefusal(join(repo, "data"), repo)).toBeNull(); | |
| 50 | expect(dataHomeRefusal(join(repo, "x.local"), repo)).toBeNull(); | |
| 51 | mkdirSync(join(repo, "data")); | |
| 52 | expect(dataHomeRefusal(join(repo, "data"), repo)).toBeNull(); | |
| 53 | expect(dataHomeRefusal(join(repo, "data", "deeper"), repo)).toBeNull(); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a directory outside the checkout is allowed", () => { | |
| 57 | expect(dataHomeRefusal(join(base, "elsewhere"), repo)).toBeNull(); | |
| 58 | expect(dataHomeRefusal(base, repo)).toBeNull(); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a forge running from no checkout has nothing to refuse", () => { | |
| 62 | const loose = join(base, "loose"); | |
| 63 | mkdirSync(loose); | |
| 64 | expect(dataHomeRefusal(loose, loose)).toBeNull(); | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | const root = new URL("..", import.meta.url).pathname; | |
| 69 | const inCheckout = | |
| 70 | Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { cwd: root }) | |
| 71 | .exitCode === 0; | |
| 72 | ||
| 73 | describe("boot", () => { | |
| 74 | // The forge's check runs from a `checkout-index` directory with no `.git`, | |
| 75 | // where there is no checkout to protect and main.ts would boot for real. | |
| 76 | test.skipIf(!inCheckout)( | |
| 77 | "`FORGE_DATA` set to this checkout stops main.ts before anything starts", | |
| 78 | async () => { | |
| 79 | const proc = Bun.spawn(["bun", "run", "src/main.ts"], { | |
| 80 | cwd: root, | |
| 81 | env: { | |
| 82 | PATH: process.env.PATH ?? "", | |
| 83 | HOME: process.env.HOME ?? "", | |
| 84 | FORGE_DATA: root, | |
| 85 | PORT: "0", | |
| 86 | FORGE_SSH_PORT: "0", | |
| 87 | FORGE_APPS: "0", | |
| 88 | }, | |
| 89 | stdout: "pipe", | |
| 90 | stderr: "pipe", | |
| 91 | }); | |
| 92 | const timer = setTimeout(() => proc.kill(), 20_000); | |
| 93 | const code = await proc.exited; | |
| 94 | clearTimeout(timer); | |
| 95 | const err = (await new Response(proc.stderr).text()).trim(); | |
| 96 | expect(code).toBe(1); | |
| 97 | expect(err.split("\n")).toHaveLength(1); | |
| 98 | expect(err).toContain("refusing to boot"); | |
| 99 | }, | |
| 100 | 30_000, | |
| 101 | ); | |
| 102 | }); |
test/tree.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * The tree carries no runtime state (#622): no tracked file is a private | |
| 3 | * key or a SQLite database unless `scripts/tree.ts` lists it as a fixture. | |
| 4 | */ | |
| 5 | ||
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { auditRepository, auditTree, stateKind } from "../scripts/tree.ts"; | |
| 8 | ||
| 9 | const bytes = (text: string) => new TextEncoder().encode(text); | |
| 10 | // Spelled in pieces so this file does not trip the rule it tests. | |
| 11 | const dashes = "-".repeat(5); | |
| 12 | const KEY = `${dashes}BEGIN OPENSSH PRIVATE KEY${dashes}\nb3BlbnNzaC1rZXktdjEA\n${dashes}END OPENSSH PRIVATE KEY${dashes}\n`; | |
| 13 | const DB = new Uint8Array([...bytes("SQLite format 3\0"), 16, 0, 1, 1]); | |
| 14 | ||
| 15 | describe("the repository", () => { | |
| 16 | test("no tracked file is a private key or a database", () => { | |
| 17 | expect(auditRepository(new URL("..", import.meta.url).pathname)).toEqual( | |
| 18 | [], | |
| 19 | ); | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | describe("stateKind", () => { | |
| 24 | test("a PEM or OpenSSH private key is named, wherever it sits in the file", () => { | |
| 25 | expect(stateKind(bytes(KEY))).toBe("a private key"); | |
| 26 | expect(stateKind(bytes(`const k = "${KEY.replace(/\n/g, "\\n")}";`))).toBe( | |
| 27 | "a private key", | |
| 28 | ); | |
| 29 | expect( | |
| 30 | stateKind(bytes(`${dashes}BEGIN EC PRIVATE KEY${dashes}\nMHcC\n`)), | |
| 31 | ).toBe("a private key"); | |
| 32 | }); | |
| 33 | ||
| 34 | test("a SQLite database is named by its header", () => { | |
| 35 | expect(stateKind(DB)).toBe("a SQLite database"); | |
| 36 | }); | |
| 37 | ||
| 38 | test("a public key, source that mentions the header, and short files pass", () => { | |
| 39 | expect(stateKind(bytes("ssh-ed25519 AAAAC3Nza forge\n"))).toBeNull(); | |
| 40 | expect(stateKind(bytes(`${dashes}BEGIN PUBLIC KEY${dashes}\n`))).toBeNull(); | |
| 41 | expect(stateKind(bytes('if (head === "SQLite format 3") ok();'))).toBeNull(); | |
| 42 | expect(stateKind(bytes("SQLite"))).toBeNull(); | |
| 43 | expect(stateKind(new Uint8Array())).toBeNull(); | |
| 44 | }); | |
| 45 | }); | |
| 46 | ||
| 47 | describe("auditTree", () => { | |
| 48 | const files: Record<string, Uint8Array> = { | |
| 49 | "ssh/host_ed25519": bytes(KEY), | |
| 50 | "ssh/host_ed25519.pub": bytes("ssh-ed25519 AAAA forge\n"), | |
| 51 | "forge.db": DB, | |
| 52 | "src/a.ts": bytes("export const a = 1;\n"), | |
| 53 | "test/fixtures/key.pem": bytes(KEY), | |
| 54 | }; | |
| 55 | const read = (file: string) => files[file] ?? new Uint8Array(); | |
| 56 | ||
| 57 | test("each key and database is named in one line, with where it belongs", () => { | |
| 58 | const problems = auditTree(Object.keys(files), read, [ | |
| 59 | { file: "test/fixtures/key.pem" }, | |
| 60 | ]); | |
| 61 | expect(problems).toHaveLength(2); | |
| 62 | expect(problems[0]).toStartWith("ssh/host_ed25519 is a private key;"); | |
| 63 | expect(problems[1]).toStartWith("forge.db is a SQLite database;"); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a fixture is let through only when listed", () => { | |
| 67 | expect(auditTree(["test/fixtures/key.pem"], read, [])).toHaveLength(1); | |
| 68 | expect( | |
| 69 | auditTree(["test/fixtures/key.pem"], read, [ | |
| 70 | { file: "test/fixtures/key.pem" }, | |
| 71 | ]), | |
| 72 | ).toEqual([]); | |
| 73 | }); | |
| 74 | }); |
new above
offline