Public: anyone can read this; the crew can change it.
A test proves no code path reachable from the billing webhook removes a forge's directory #11402
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11392-a-test-proves-no-code-path-reachable-fro → main 4 commits fork 2 threads · 2 unresolved
3 files
changed +540
−1
scripts/gate.ts +14 −1
| @@ -209,6 +209,19 @@ export function strayTestRefusal(root = "."): string[] { | ||
| 209 | 209 | .map((f) => `src/${f}: a test outside test/ never runs in the gate; move it to test/`); |
| 210 | 210 | } |
| 211 | 211 | |
| 212 | /** | |
| 213 | * No call path from the billing webhook deletes a forge, an app or a child | |
| 214 | * (#11392): the walk in `src/kernel/destroy_reach.ts`, named here so it runs | |
| 215 | * in the check's first second as well as in `test/billing-never-deletes.test.ts`. | |
| 216 | */ | |
| 217 | export function billingDeletesRefusal(root = "."): string[] { | |
| 218 | const { destroyReachRefusal } = require("../src/kernel/destroy_reach.ts") as typeof import("../src/kernel/destroy_reach.ts"); | |
| 219 | // A fixture repository (the gate's own tests) holds no walk at all and has nothing to check. | |
| 220 | // A tree that has the walk but not the webhook is the real one after a move: refused, not passed. | |
| 221 | if (!existsSync(join(root, "src/kernel/destroy_reach.ts"))) return []; | |
| 222 | return destroyReachRefusal(root); | |
| 223 | } | |
| 224 | ||
| 212 | 225 | /** Review findings that repeated (#8888): see `src/kernel/review_rules.ts`. */ |
| 213 | 226 | export function reviewRefusal(root = "."): string[] { |
| 214 | 227 | // Loaded by `require` so `treeGate`, which loads this file alone, never needs it. |
| @@ -279,7 +292,7 @@ export async function directionRefusal(cwd: string = process.cwd()): Promise<str | ||
| 279 | 292 | // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so |
| 280 | 293 | // a stale exclusion fails in under a second — not after install and tsc. |
| 281 | 294 | if (import.meta.main) { |
| 282 | const refusal = [...gateRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...(await directionRefusal()), ...strayTestRefusal(), ...reviewRefusal()]; | |
| 295 | const refusal = [...gateRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...(await directionRefusal()), ...strayTestRefusal(), ...reviewRefusal(), ...billingDeletesRefusal()]; | |
| 283 | 296 | for (const line of refusal) console.error(`gate: ${line}`); |
| 284 | 297 | process.exit(refusal.length === 0 ? 0 : 2); |
| 285 | 298 | } |
src/kernel/destroy_reach.ts added +330
| @@ -0,0 +1,330 @@ | ||
| 1 | /** | |
| 2 | * What the billing webhook can reach that deletes (#11392, part of #11140: | |
| 3 | * losing someone's work is never the penalty for not paying). | |
| 4 | * | |
| 5 | * `destroyReach(root, entry)` follows calls, not whole files: from every | |
| 6 | * top-level unit (function, const, class) of the entry file, each identifier | |
| 7 | * that names a local unit or an imported one pulls that unit in, through | |
| 8 | * value imports, `import * as`, re-exports and `import()`. Whole-file | |
| 9 | * reachability would reach all of the forge through `app.ts` and say | |
| 10 | * nothing. A reachable unit that calls `rm`/`rmSync`/`unlink`/`unlinkSync`/ | |
| 11 | * `rmdir`/`rmdirSync` at all (bare, as a member such as `fs.promises.rm(`, under | |
| 12 | * an import alias, over several lines, on a path held in a variable), or names | |
| 13 | * a removal action (`SINKS`), or names the event `repo.deleted`, is a finding: | |
| 14 | * `file:line: text`, with the chain of units that led there. The path is never | |
| 15 | * judged: only a unit on `ALLOWED_REMOVALS`, with its reason, may remove. | |
| 16 | * | |
| 17 | * Approximation, stated: units are cut at column-0 declarations (a file with | |
| 18 | * an indented top-level declaration is a finding, see `layoutMisses`); a | |
| 19 | * method call through an object (`app.x.y()`) is followed by method name | |
| 20 | * within files the caller imports; a class is one unit. A removal by spawning | |
| 21 | * a process (a spawn of `rm`) is a finding too. Plain data in, plain | |
| 22 | * data out. | |
| 23 | */ | |
| 24 | ||
| 25 | import { existsSync, readFileSync, statSync } from "node:fs"; | |
| 26 | import { dirname, join, normalize } from "node:path"; | |
| 27 | ||
| 28 | /** Removal actions by name: the child-removal action and the directory deleters. */ | |
| 29 | export const SINKS: readonly string[] = ["removeForge", "removeApp", "removeEnv", "deleteRepoDir", "deleteApp", "deleteRepo"]; | |
| 30 | ||
| 31 | /** | |
| 32 | * Units the walk does not enter, each with why. A stop is not trusted by its | |
| 33 | * name: when a reachable unit refers to it, that unit must be in `from` | |
| 34 | * (`file:unit`), so a new path to it from the webhook is a finding. | |
| 35 | * - `AppsRuntime.start`: the walk matches a method call by name, so a cell's | |
| 36 | * `.start(` looks like the runtime's. The test reads `settleCell` and checks | |
| 37 | * it names the cell registry and never the runtime. | |
| 38 | * - `AppsRuntime.onEvent`: entered by the bus, not by a call. It tears an app | |
| 39 | * down only on `repo.deleted`; a reachable unit that names that event is a | |
| 40 | * finding, so the webhook appends none. Nothing may call it directly. | |
| 41 | * - `AppsRuntime.reconcileApp`: boots a forge's environments and removes those | |
| 42 | * of closed pulls. Only the units in `from` may reach it, and they act on a | |
| 43 | * forge the webhook has just made. | |
| 44 | */ | |
outdated
THEIRS gates every fs removal on the path text of the same line. A variable, a multi-line call or an aliased import slips through. Flag all reachable removals unless allow-listed with a reason. |
||
| 45 | export const STOPS: readonly { unit: string; why: string; from: readonly string[] }[] = [ | |
| 46 | { | |
| 47 | unit: "AppsRuntime.start", | |
| 48 | why: "the forge's own boot, and its prune of app data with no row; `cells.start(ledger)` in `settleCell` is `CellChildren.start`, a cell's; `startChildApps` starts the runtime of the child just made, over its own empty data", | |
| 49 | from: ["src/spawn/host.ts:settleCell", "src/spawn/child-apps.ts:startChildApps"], | |
| 50 | }, | |
| 51 | { unit: "AppsRuntime.onEvent", why: "entered by the bus; the webhook appends no repo.deleted", from: [] }, | |
| 52 | { unit: "AppsRuntime.reconcileApp", why: "boot tidy of a forge the webhook has just made, which holds no environments", from: [] }, | |
| 53 | ]; | |
| 54 | ||
| 55 | const unitsOn = (why: string, units: string[]): { unit: string; why: string }[] => units.map((unit) => ({ unit, why })); | |
| 56 | ||
| 57 | /** | |
| 58 | * Reachable units that may remove a file, each with why. A path is never | |
| 59 | * judged: a unit is here because a person read it. Most are reached because | |
| 60 | * making a forge boots it (`makeForge` -> `bootHostedChild`), and boot tidies | |
| 61 | * its own scratch. A unit that grows a second removal is still allowed here, so | |
| 62 | * read the whole unit when you add or change one; `test/billing-never-deletes | |
| 63 | * .test.ts` fails when an entry no longer removes anything (a stale reason). | |
| 64 | */ | |
| 65 | export const ALLOWED_REMOVALS: readonly { unit: string; why: string }[] = [ | |
| 66 | ...unitsOn("scratch under the checks root, per run or commit (checkouts, caches, locks, the install cache): never a forge's or an app's directory", [ | |
| 67 | "src/checks/service.ts:startChecks", | |
| 68 | "src/checks/service.ts:removeRunDir", | |
| 69 | "src/checks/cache.ts:restoreCache", | |
| 70 | "src/checks/cache.ts:keepCache", | |
| 71 | "src/checks/impact-store.ts:seedImpact", | |
| 72 | "src/checks/install.ts:prefillInstallCache", | |
| 73 | "src/checks/install.ts:holdFile", | |
| 74 | "src/checks/install_evict.ts:evictInstallCache", | |
| 75 | "src/apps/checkout.ts:exportTree", | |
| 76 | ]), | |
| 77 | { unit: "src/core/repos.ts:rowOrUndo", why: "removes the directory git has just made for the repository being created, when its row is refused" }, | |
| 78 | { unit: "src/git/write.ts:commitFiles", why: "removes its own temporary index file" }, | |
| 79 | { unit: "src/git/repos.ts:detachFork", why: "removes the alternates file of the fork just made" }, | |
| 80 | { unit: "src/git/env.ts:trustedCaBundle", why: "removes the process's own temporary CA bundle at exit" }, | |
| 81 | { unit: "src/apps/contain.ts:openCell", why: "clears the cell's socket directory before the cell opens" }, | |
| 82 | { unit: "src/apps/cgroup.ts:removeDir", why: "removes an empty cgroup directory" }, | |
| 83 | { unit: "src/spawn/own_code.ts:checkout", why: "removes a half-made export of the forge's own code, keyed by commit" }, | |
| 84 | { unit: "src/spawn/own_code.ts:recordGood", why: "removes exports of the forge's own code for other commits than the good one" }, | |
| 85 | { unit: "src/web/asset.ts:prune", why: "removes cached page scripts older than their keep time" }, | |
| 86 | ...unitsOn("the call is inside text the unit returns (a generated app's code), never run by the forge", [ | |
| 87 | "src/apps/scaffold.ts:db", | |
| 88 | "src/apps/scaffold.ts:committed", | |
| 89 | "src/apps/scaffold.ts:base", | |
| 90 | ]), | |
| 91 | ]; | |
| 92 | ||
| 93 | const REMOVERS = ["rmSync", "rmdirSync", "unlinkSync", "rm", "rmdir", "unlink"]; | |
| 94 | const FS_MODULE = /^(?:node:)?fs(?:\/promises)?$/; | |
| 95 | /** A removal spawned as a process, or a shell. */ | |
| 96 | const SPAWNED = /\b(?:Bun\.spawn(?:Sync)?\(\s*\[?\s*["'`](?:rm|unlink|rmdir|sh|bash)["'`]|Bun\.\$\s*`\s*rm\b|child_process|execSync|execFileSync)/; | |
| 97 | const EVENT = /["'`]repo\.deleted["'`]/; | |
| 98 | const METHOD = /^ {2}(?:(?:public|private|protected|static|async|override|get|set)\s+)*([A-Za-z_$#][\w$]*)\s*[(<]/; | |
| 99 | const DECL = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class|abstract\s+class)\s+([A-Za-z_$][\w$]*)/; | |
| 100 | ||
| 101 | interface Unit { file: string; name: string; start: number; text: string; methods?: Unit[] } | |
| 102 | interface Module { | |
| 103 | touches: Set<string>; | |
| 104 | units: Map<string, Unit>; | |
| 105 | imports: Map<string, { from: string; name: string }>; | |
| 106 | spaces: Map<string, string>; | |
| 107 | reexports: string[]; | |
| 108 | /** Local names that mean a file removal in this module (`import { rmSync as r } from "node:fs"`). */ | |
| 109 | removers: Set<string>; | |
| 110 | /** Every identifier in the file: a class is a candidate for a method call only where it is named. */ | |
| 111 | words: Set<string>; | |
| 112 | } | |
| 113 | ||
| 114 | const resolve = (root: string, from: string, spec: string): string | undefined => { | |
| 115 | const base = normalize(join(dirname(from), spec)); | |
| 116 | for (const c of [base, `${base}.ts`, join(base, "index.ts")]) { | |
| 117 | const full = join(root, c); | |
| 118 | if (existsSync(full) && statSync(full).isFile()) return c; | |
| 119 | } | |
| 120 | return undefined; | |
| 121 | }; | |
| 122 | ||
| 123 | /** The text with comments blanked, newlines kept, so a line number still means the same line. */ | |
| 124 | const stripComments = (text: string): string => | |
| 125 | text | |
| 126 | .replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " ")) | |
| 127 | .replace(/(?<![:"'`\\])\/\/[^\n]*/g, (c) => " ".repeat(c.length)); | |
| 128 | ||
| 129 | function parse(root: string, file: string): Module { | |
| 130 | const text = readFileSync(join(root, file), "utf8"); | |
| 131 | const lines = text.split("\n"); | |
| 132 | const units = new Map<string, Unit>(); | |
| 133 | const imports = new Map<string, { from: string; name: string }>(); | |
| 134 | const spaces = new Map<string, string>(); | |
| 135 | const reexports: string[] = []; | |
| 136 | const touches = new Set<string>(); | |
| 137 | const removers = new Set<string>(); | |
| 138 | for (const m of text.matchAll(/^(import|export)\s+(type\s+)?([^;]*?)\s+from\s+"([^"]+)"/gms)) { | |
| 139 | const clause = m[3]!.trim(); | |
| 140 | if (!m[4]!.startsWith(".")) { | |
| 141 | if (FS_MODULE.test(m[4]!)) | |
| 142 | for (const part of (clause.match(/\{([^}]*)\}/s)?.[1] ?? "").split(",")) { | |
| 143 | const [name, local] = part.trim().split(/\s+as\s+/); | |
| 144 | if (name && REMOVERS.includes(name)) removers.add((local ?? name).trim()); | |
| 145 | } | |
| 146 | continue; | |
| 147 | } | |
| 148 | const target = resolve(root, file, m[4]!); | |
| 149 | if (target === undefined) continue; | |
| 150 | touches.add(target); | |
| 151 | if (m[2] !== undefined) continue; | |
| 152 | if (m[1] === "export") reexports.push(target); | |
| 153 | const ns = clause.match(/\*\s+as\s+([\w$]+)/); | |
| 154 | if (ns) spaces.set(ns[1]!, target); | |
| 155 | for (const part of (clause.match(/\{([^}]*)\}/s)?.[1] ?? "").split(",")) { | |
| 156 | const p = part.trim().replace(/^type\s+.*/, ""); | |
| 157 | if (p === "") continue; | |
| 158 | const [name, local] = p.split(/\s+as\s+/); | |
| 159 | imports.set((local ?? name)!.trim(), { from: target, name: name!.trim() }); | |
| 160 | } | |
| 161 | const def = clause.match(/^([A-Za-z_$][\w$]*)\s*(?:,|$)/); | |
| 162 | if (def && !clause.startsWith("{")) imports.set(def[1]!, { from: target, name: "default" }); | |
| 163 | } | |
| 164 | for (const m of text.matchAll(/^export\s+\*\s+from\s+"(\.[^"]+)"/gm)) { | |
| 165 | const target = resolve(root, file, m[1]!); | |
| 166 | if (target !== undefined) { | |
| 167 | touches.add(target); | |
| 168 | reexports.push(target); | |
| 169 | } | |
| 170 | } | |
| 171 | for (const m of text.matchAll(/import\(\s*"(\.[^"]+)"\s*\)/g)) { | |
| 172 | const target = resolve(root, file, m[1]!); | |
| 173 | if (target !== undefined) { | |
| 174 | touches.add(target); | |
| 175 | reexports.push(target); | |
| 176 | } | |
| 177 | } | |
| 178 | let open: Unit | undefined; | |
| 179 | let owner: Unit | undefined; | |
| 180 | lines.forEach((line, i) => { | |
| 181 | const d = DECL.exec(line); | |
| 182 | if (d) { | |
| 183 | open = { file, name: d[1]!, start: i + 1, text: "" }; | |
| 184 | units.set(open.name, open); | |
| 185 | owner = /\bclass\s/.test(line) ? open : undefined; | |
| 186 | } | |
| 187 | if (/^(import|export\s+(type|interface)|type|interface|export\s+\{)\b/.test(line) && !d) open = owner = undefined; | |
| 188 | const meth = owner && !d ? METHOD.exec(line) : null; | |
| 189 | if (owner && meth && meth[1] !== "constructor") { | |
| 190 | open = { file, name: `${owner.name}.${meth[1]}`, start: i + 1, text: "" }; | |
| 191 | (owner.methods ??= []).push(open); | |
| 192 | } else if (owner && !d && /^\S/.test(line)) open = owner; | |
| 193 | if (open) open.text += `${line}\n`; | |
| 194 | }); | |
| 195 | return { touches, units, imports, spaces, reexports, removers, words: new Set(text.match(/[A-Za-z_$][\w$]*/g) ?? []) }; | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Lines a column-0 cut would hide: a declaration, import or export that is | |
| 200 | * indented. A file with any is not read right, and the walk says so rather | |
| 201 | * than pass over its code. | |
| 202 | */ | |
| 203 | export function layoutMisses(root: string, file: string): string[] { | |
| 204 | const out: string[] = []; | |
| 205 | stripComments(readFileSync(join(root, file), "utf8")) | |
| 206 | .split("\n") | |
| 207 | .forEach((line, i) => { | |
| 208 | if (/^[ \t]+(?:export\s+(?:default\s+)?(?:async\s+)?(?:function|const|let|class|\{)|import\s+[\w{*"])/.test(line) && !/^[ \t]*\/?\*/.test(line)) | |
| 209 | out.push(`${file}:${i + 1}: ${line.trim()}`); | |
| 210 | }); | |
| 211 | return out; | |
| 212 | } | |
| 213 | ||
| 214 | /** The units of one file by name, as the walk cut them; for the test that a parse miss is loud. */ | |
| 215 | export function unitsOf(root: string, file: string): { name: string; start: number; text: string }[] { | |
| 216 | return [...parse(root, file).units.values()].map(({ name, start, text }) => ({ name, start, text })); | |
| 217 | } | |
| 218 | ||
| 219 | /** | |
| 220 | * Every finding reachable from the units of `entry` (repo-relative, under | |
| 221 | * `root`). `allowed` are the units that may remove a file, each with why. | |
| 222 | */ | |
| 223 | export function destroyReach(root: string, entry: string, allowed: readonly { unit: string; why: string }[] = ALLOWED_REMOVALS): string[] { | |
| 224 | const mods = new Map<string, Module>(); | |
| 225 | const mod = (f: string): Module => { | |
| 226 | let m = mods.get(f); | |
| 227 | if (!m) mods.set(f, (m = parse(root, f))); | |
| 228 | return m; | |
| 229 | }; | |
| 230 | const found = (f: string, name: string, seen = new Set<string>()): Unit | undefined => { | |
| 231 | if (seen.has(f)) return undefined; | |
| 232 | seen.add(f); | |
| 233 | const m = mod(f); | |
| 234 | const u = m.units.get(name); | |
| 235 | if (u) return u; | |
| 236 | for (const r of m.reexports) { | |
| 237 | const hit = found(r, name, seen); | |
| 238 | if (hit) return hit; | |
| 239 | } | |
| 240 | return undefined; | |
| 241 | }; | |
| 242 | const out: string[] = []; | |
| 243 | const via = new Map<Unit, Unit | undefined>(); | |
| 244 | const queue: Unit[] = []; | |
| 245 | const touchesFile = (from: string, to: string): boolean => mod(from).touches.has(to); | |
| 246 | const methods = new Map<string, Unit[]>(); | |
| 247 | const wanted = new Map<string, Unit[]>(); | |
| 248 | const chainOf = (u: Unit): string => { | |
| 249 | const names: string[] = []; | |
| 250 | for (let c: Unit | undefined = u; c; c = via.get(c)) names.unshift(`${c.file}:${c.name}`); | |
| 251 | return names.join(" -> "); | |
| 252 | }; | |
| 253 | /** A method is called from a unit only where its file is the unit's or imported by it, and its class is named there. */ | |
| 254 | const candidate = (caller: Unit, method: Unit): boolean => | |
| 255 | (method.file === caller.file || touchesFile(caller.file, method.file)) && mod(caller.file).words.has(method.name.slice(0, method.name.indexOf("."))); | |
| 256 | const stopped = new Set<string>(); | |
| 257 | const add = (u: Unit | undefined, from: Unit | undefined) => { | |
| 258 | if (!u) return; | |
| 259 | const stop = from && STOPS.find((x) => x.unit === u.name); | |
| 260 | if (stop) { | |
| 261 | const key = `${from.file}:${from.name}>${u.name}`; | |
| 262 | if (!stop.from.includes(`${from.file}:${from.name}`) && !stopped.has(key)) { | |
| 263 | stopped.add(key); | |
| 264 | out.push(`${from.file}:${from.start}: reaches ${u.name}, which a webhook path may not enter from here (${stop.why}) [${chainOf(from)}]`); | |
| 265 | } | |
| 266 | return; | |
| 267 | } | |
| 268 | if (via.has(u)) return; | |
| 269 | via.set(u, from); | |
| 270 | queue.push(u); | |
| 271 | for (const m of u.methods ?? []) { | |
| 272 | const name = m.name.slice(m.name.indexOf(".") + 1); | |
| 273 | methods.set(name, [...(methods.get(name) ?? []), m]); | |
| 274 | for (const c of wanted.get(name) ?? []) if (candidate(c, m)) add(m, c); | |
| 275 | } | |
| 276 | }; | |
| 277 | const entryMod = mod(entry); | |
| 278 | if (entryMod.units.size === 0) return [`${entry}: no unit parsed, so nothing was walked`]; | |
| 279 | for (const miss of layoutMisses(root, entry)) out.push(`${miss} [indented top-level code hides from the walk]`); | |
| 280 | for (const u of entryMod.units.values()) add(u, undefined); | |
| 281 | const flagged = new Set<string>(); | |
| 282 | while (queue.length) { | |
| 283 | const u = queue.shift()!; | |
| 284 | const m = mod(u.file); | |
| 285 | if (!flagged.has(u.file)) { | |
| 286 | flagged.add(u.file); | |
| 287 | if (u.file !== entry) for (const miss of layoutMisses(root, u.file)) out.push(`${miss} [indented top-level code hides from the walk]`); | |
| 288 | } | |
| 289 | const code = stripComments(u.text); | |
| 290 | const mayRemove = allowed.some((x) => x.unit === `${u.file}:${u.name}`); | |
| 291 | const local = new Set(m.removers); | |
| 292 | for (const a of code.matchAll(/\b(rmSync|rmdirSync|unlinkSync|rm|rmdir|unlink)\s*:\s*([A-Za-z_$][\w$]*)/g)) local.add(a[2]!); | |
| 293 | for (const a of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$]+\.)*(rmSync|rmdirSync|unlinkSync|rm|rmdir|unlink)\s*[;,\n)]/g)) local.add(a[1]!); | |
| 294 | const names = [...new Set([...REMOVERS, ...local])].join("|"); | |
| 295 | const call = new RegExp(`(?<![\\w$.])(?:${names})\\s*\\(|\\.\\s*(?:${REMOVERS.join("|")})\\s*\\(|\\?\\.\\s*(?:${REMOVERS.join("|")})\\s*\\(`, "g"); | |
| 296 | const named = new RegExp(`\\b(?:${SINKS.join("|")})\\b`); | |
| 297 | const lineOf = (at: number): number => code.slice(0, at).split("\n").length - 1; | |
| 298 | const report = (at: number, why: string) => { | |
| 299 | const i = lineOf(at); | |
| 300 | out.push(`${u.file}:${u.start + i}: ${u.text.split("\n")[i]!.trim()} [${why}; ${chainOf(u)}]`); | |
| 301 | }; | |
| 302 | if (!mayRemove) for (const c of code.matchAll(call)) report(c.index!, "removes a file"); | |
| 303 | code.split("\n").forEach((line, i) => { | |
| 304 | if (named.test(line) || SPAWNED.test(line) || EVENT.test(line)) report(code.split("\n").slice(0, i).join("\n").length + (i ? 1 : 0), "names a removal"); | |
| 305 | }); | |
| 306 | for (const id of new Set(u.text.match(/[A-Za-z_$][\w$]*/g) ?? [])) { | |
| 307 | const localUnit = m.units.get(id); | |
| 308 | if (localUnit) add(localUnit, u); | |
| 309 | const imp = m.imports.get(id); | |
| 310 | if (imp) add(found(imp.from, imp.name), u); | |
| 311 | } | |
| 312 | for (const hit of u.text.matchAll(/\.#?([A-Za-z_$][\w$]*)/g)) { | |
| 313 | wanted.set(hit[1]!, [...(wanted.get(hit[1]!) ?? []), u]); | |
| 314 | for (const mm of methods.get(hit[1]!) ?? []) if (candidate(u, mm)) add(mm, u); | |
| 315 | } | |
| 316 | for (const [ns, target] of m.spaces) | |
| 317 | for (const hit of u.text.matchAll(new RegExp(`\\b${ns}\\.([\\w$]+)`, "g"))) add(found(target, hit[1]!), u); | |
| 318 | for (const target of m.reexports) if (u.text.includes("import(")) for (const x of mod(target).units.values()) add(x, u); | |
| 319 | } | |
| 320 | return out; | |
| 321 | } | |
| 322 | ||
| 323 | /** The webhook route's file: where the walk starts. */ | |
| 324 | export const WEBHOOK_ENTRY = "src/http/stripe_hook.ts"; | |
| 325 | ||
| 326 | /** Gate form: one line per finding; a missing webhook is a finding, never a pass. */ | |
| 327 | export function destroyReachRefusal(root = "."): string[] { | |
| 328 | if (!existsSync(join(root, WEBHOOK_ENTRY))) return [`${WEBHOOK_ENTRY} is not there: the walk has nothing to start from; if the webhook moved, change WEBHOOK_ENTRY in src/kernel/destroy_reach.ts`]; | |
| 329 | return destroyReach(root, WEBHOOK_ENTRY).map((f) => `the billing webhook reaches a deletion: ${f}`); | |
| 330 | } |
test/billing-never-deletes.test.ts added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | /** | |
| 2 | * #11392, part of #11140: losing someone's work is never the penalty for not | |
| 3 | * paying. No call path from the billing webhook (`POST /hooks/stripe`, | |
| 4 | * `src/http/stripe_hook.ts`, and what it uses in `src/billing/*`) removes a | |
| 5 | * forge's or an app's directory, or runs the child-removal action. The walk | |
| 6 | * is `src/kernel/destroy_reach.ts`; `bun scripts/gate.ts` runs it too. | |
| 7 | */ | |
| 8 | ||
| 9 | import { describe, expect, test } from "bun:test"; | |
| 10 | import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { readdirSync } from "node:fs"; | |
| 14 | import { billingDeletesRefusal } from "../scripts/gate.ts"; | |
| 15 | import { ALLOWED_REMOVALS, destroyReach, destroyReachRefusal, layoutMisses, STOPS, unitsOf, WEBHOOK_ENTRY } from "../src/kernel/destroy_reach.ts"; | |
| 16 | ||
| 17 | const root = new URL("..", import.meta.url).pathname; | |
| 18 | ||
| 19 | const tree = (files: Record<string, string>): string => { | |
| 20 | const dir = mkdtempSync(join(tmpdir(), "reach-")); | |
| 21 | for (const [path, text] of Object.entries(files)) { | |
| 22 | mkdirSync(join(dir, path, ".."), { recursive: true }); | |
| 23 | writeFileSync(join(dir, path), text); | |
| 24 | } | |
| 25 | return dir; | |
| 26 | }; | |
| 27 | ||
| 28 | describe("the billing webhook cannot delete", () => { | |
| 29 | test("today's tree: nothing reachable from the webhook removes a forge, an app or a child", () => { | |
| 30 | expect(destroyReach(root, WEBHOOK_ENTRY)).toEqual([]); | |
| 31 | expect(destroyReachRefusal(root)).toEqual([]); | |
| 32 | }); | |
| 33 | ||
| 34 | test("every stop and every allowance still means something, with a reason", () => { | |
| 35 | const runtime = readFileSync(join(root, "src/apps/runtime.ts"), "utf8"); | |
| 36 | for (const { unit, why, from } of STOPS) { | |
| 37 | expect(why.length).toBeGreaterThan(10); | |
| 38 | expect(runtime).toContain(unit.split(".")[1]!); | |
| 39 | // Each unit allowed to reach a stop exists in the tree. | |
| 40 | for (const f of from) expect(unitsOf(root, f.split(":")[0]!).some((u) => u.name === f.split(":")[1])).toBe(true); | |
| 41 | } | |
| 42 | // With no allowance the walk finds each allowed unit: an entry that finds nothing is stale. | |
| 43 | const found = destroyReach(root, WEBHOOK_ENTRY, []).join("\n"); | |
| 44 | for (const { unit, why } of ALLOWED_REMOVALS) { | |
| 45 | expect(why.length).toBeGreaterThan(10); | |
| 46 | expect(found).toContain(`${unit}]`); | |
| 47 | } | |
| 48 | }); | |
| 49 | ||
| 50 | test("the webhook cannot trigger the runtime's teardown: no reachable unit names repo.deleted, and the runtime's entries are entered only from the units allowed", () => { | |
| 51 | expect(destroyReach(root, WEBHOOK_ENTRY).filter((f) => f.includes("names a removal") || f.includes("reaches "))).toEqual([]); | |
| 52 | // The one name collision the walk lets through: `cells.start(` is the cell registry's. | |
| 53 | const settle = unitsOf(root, "src/spawn/host.ts").find((u) => u.name === "settleCell")!; | |
| 54 | expect(settle.text).toContain("cellChildren("); | |
| 55 | expect(settle.text).not.toMatch(/appsRuntime\(|AppsRuntime/); | |
| 56 | // And the walk does notice a webhook that appends it or boots an existing forge's apps. | |
| 57 | const dir = tree({ | |
| 58 | "src/http/stripe_hook.ts": 'export function hook(app: any) {\n app.events.append({ kind: "repo.deleted" });\n}\nexport async function boot(rt: any) {\n await rt.reconcileApp(1);\n}\nclass AppsRuntime {\n async reconcileApp(a: number) {}\n}\n', | |
| 59 | }); | |
| 60 | const bad = destroyReach(dir, WEBHOOK_ENTRY); | |
| 61 | rmSync(dir, { recursive: true, force: true }); | |
| 62 | expect(bad.some((f) => f.includes("repo.deleted"))).toBe(true); | |
| 63 | expect(bad.some((f) => f.includes("reaches AppsRuntime.reconcileApp"))).toBe(true); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a deliberate rmSync on a forge dir added to the real webhook handler is found, with its path", () => { | |
| 67 | const copy = mkdtempSync(join(tmpdir(), "reach-real-")); | |
| 68 | cpSync(join(root, "src"), join(copy, "src"), { recursive: true }); | |
| 69 | const hook = join(copy, WEBHOOK_ENTRY); | |
| 70 | writeFileSync( | |
| 71 | hook, | |
| 72 | readFileSync(hook, "utf8") + | |
| 73 | '\nexport function dropForge(app: App, bodyId: string) {\n rmSync(childDirOf(app, bodyId), { recursive: true, force: true });\n}\n', | |
| 74 | ); | |
| 75 | const found = destroyReach(copy, WEBHOOK_ENTRY); | |
| 76 | rmSync(copy, { recursive: true, force: true }); | |
| 77 | expect(found.length).toBe(1); | |
| 78 | expect(found[0]).toContain(`${WEBHOOK_ENTRY}:`); | |
| 79 | expect(found[0]).toContain("rmSync(childDirOf(app, bodyId)"); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a removal two imports away, through a re-export and a namespace, is found by chain", () => { | |
| 83 | const dir = tree({ | |
| 84 | "src/http/stripe_hook.ts": 'import { settle } from "../billing/settle.ts";\nexport function hook() {\n settle();\n}\n', | |
| 85 | "src/billing/settle.ts": 'import * as gone from "./gone.ts";\nexport function settle() {\n gone.tidy();\n}\n', | |
| 86 | "src/billing/gone.ts": 'export * from "../apps/data.ts";\n', | |
| 87 | "src/apps/data.ts": 'import { rmSync } from "node:fs";\nexport function tidy() {\n rmSync(join(appsRoot("d"), "a"), { recursive: true });\n}\n', | |
| 88 | }); | |
| 89 | const found = destroyReach(dir, WEBHOOK_ENTRY); | |
| 90 | rmSync(dir, { recursive: true, force: true }); | |
| 91 | expect(found.length).toBe(1); | |
| 92 | expect(found[0]).toContain("src/apps/data.ts:3:"); | |
| 93 | expect(found[0]).toContain("src/http/stripe_hook.ts:hook -> src/billing/settle.ts:settle -> src/apps/data.ts:tidy"); | |
| 94 | }); | |
| 95 | ||
| 96 | test("the child-removal action by name is found, as a call or a method", () => { | |
| 97 | const dir = tree({ | |
| 98 | "src/http/stripe_hook.ts": 'export async function hook(app: any) {\n await app.forges.removeForge(app, "x");\n}\n', | |
| 99 | }); | |
| 100 | const found = destroyReach(dir, WEBHOOK_ENTRY); | |
| 101 | rmSync(dir, { recursive: true, force: true }); | |
| 102 | expect(found.length).toBe(1); | |
| 103 | expect(found[0]).toContain("removeForge"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a removal nobody on the webhook's path calls is no finding; a scratch file is one unless its unit is allowed", () => { | |
| 107 | const dir = tree({ | |
| 108 | "src/http/stripe_hook.ts": 'import { scratch } from "../billing/s.ts";\nexport function hook() {\n scratch();\n}\n', | |
| 109 | "src/billing/s.ts": 'export function scratch() {\n rmSync("/tmp/x.tmp", { force: true });\n}\nexport function unrelated() {\n rmSync(childDirOf(a, b), { recursive: true });\n}\n', | |
| 110 | }); | |
| 111 | const found = destroyReach(dir, WEBHOOK_ENTRY); | |
| 112 | const allowed = destroyReach(dir, WEBHOOK_ENTRY, [{ unit: "src/billing/s.ts:scratch", why: "scratch file" }]); | |
| 113 | rmSync(dir, { recursive: true, force: true }); | |
| 114 | expect(found.length).toBe(1); | |
| 115 | expect(found[0]).toContain("s.ts:2:"); | |
| 116 | expect(allowed).toEqual([]); | |
| 117 | }); | |
| 118 | ||
| 119 | const shapes: Record<string, string> = { | |
| 120 | "a path held in a variable": 'import { rmSync } from "node:fs";\nexport function hook() {\n const d = childDirOf(app, id);\n rmSync(d, { recursive: true });\n}\n', | |
| 121 | "a bare name that says nothing": 'import { rmSync } from "node:fs";\nexport function hook(dir: string) {\n rmSync(dir);\n}\n', | |
| 122 | "a call over several lines": 'import { rmSync } from "node:fs";\nexport function hook() {\n rmSync(\n join(a, b),\n { recursive: true },\n );\n}\n', | |
| 123 | "a member of a promises namespace": 'import fs from "node:fs";\nexport async function hook(target: string) {\n await fs.promises.rm(target);\n}\n', | |
| 124 | "a namespace import": 'import * as fs from "node:fs/promises";\nexport async function hook(t: string) {\n await fs.rm(t);\n}\n', | |
| 125 | "an aliased import": 'import { rmSync as r } from "node:fs";\nexport function hook(t: string) {\n r(t, { force: true });\n}\n', | |
| 126 | "a destructured alias": 'import fs from "node:fs";\nexport function hook(t: string) {\n const { rmSync: gone } = fs;\n gone(t);\n}\n', | |
| 127 | "unlink and rmdir": 'import { unlinkSync, rmdirSync } from "fs";\nexport function hook(t: string) {\n unlinkSync(t);\n rmdirSync(t);\n}\n', | |
| 128 | }; | |
| 129 | for (const [name, text] of Object.entries(shapes)) | |
| 130 | test(`a removal is found without judging the path: ${name}`, () => { | |
| 131 | const dir = tree({ "src/http/stripe_hook.ts": text }); | |
| 132 | const found = destroyReach(dir, WEBHOOK_ENTRY); | |
| 133 | rmSync(dir, { recursive: true, force: true }); | |
| 134 | expect(found.length).toBeGreaterThan(0); | |
| 135 | expect(found[0]).toContain("removes a file"); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a call in a comment or a string-free name like `farm(` is not a removal", () => { | |
| 139 | const dir = tree({ "src/http/stripe_hook.ts": '// rmSync(x)\n/* rm(y) */\nexport function hook() {\n farm(1);\n confirm(2);\n}\n' }); | |
| 140 | const found = destroyReach(dir, WEBHOOK_ENTRY); | |
| 141 | rmSync(dir, { recursive: true, force: true }); | |
| 142 | expect(found).toEqual([]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a unit on the allow-list may remove, and only that unit", () => { | |
| 146 | const dir = tree({ | |
| 147 | "src/http/stripe_hook.ts": 'import { tidy } from "../billing/t.ts";\nexport function hook() {\n tidy();\n}\n', | |
| 148 | "src/billing/t.ts": 'import { rmSync } from "node:fs";\nexport function tidy() {\n rmSync("/x");\n}\n', | |
| 149 | }); | |
| 150 | const allowed = [{ unit: "src/billing/t.ts:tidy", why: "scratch" }]; | |
| 151 | expect(destroyReach(dir, WEBHOOK_ENTRY, allowed)).toEqual([]); | |
| 152 | expect(destroyReach(dir, WEBHOOK_ENTRY, []).length).toBe(1); | |
| 153 | rmSync(dir, { recursive: true, force: true }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a webhook that has moved is a refusal in the real tree, and the walk is skipped only in a fixture without it", () => { | |
| 157 | const moved = tree({ "src/kernel/destroy_reach.ts": "// copy\n" }); | |
| 158 | expect(destroyReachRefusal(moved)[0]).toContain(`${WEBHOOK_ENTRY} is not there`); | |
| 159 | expect(billingDeletesRefusal(moved)[0]).toContain("is not there"); | |
| 160 | rmSync(moved, { recursive: true, force: true }); | |
| 161 | const fixture = tree({ "README.md": "x" }); | |
| 162 | expect(billingDeletesRefusal(fixture)).toEqual([]); | |
| 163 | rmSync(fixture, { recursive: true, force: true }); | |
| 164 | expect(billingDeletesRefusal(root)).toEqual([]); | |
| 165 | }); | |
| 166 | ||
| 167 | test("an entry that parses to no unit, or code at an odd indent, is a finding and never a quiet pass", () => { | |
| 168 | const empty = tree({ "src/http/stripe_hook.ts": " export function hook() {\n rmSync(x);\n }\n" }); | |
| 169 | const found = destroyReach(empty, WEBHOOK_ENTRY); | |
| 170 | rmSync(empty, { recursive: true, force: true }); | |
| 171 | expect(found.length).toBeGreaterThan(0); | |
| 172 | expect(found[0]).toContain("no unit parsed"); | |
| 173 | const odd = tree({ | |
| 174 | "src/http/stripe_hook.ts": 'import { a } from "../billing/a.ts";\nexport function hook() {\n a();\n}\n', | |
| 175 | "src/billing/a.ts": 'export function a() {}\n export function hidden() {\n rmSync(x);\n }\n', | |
| 176 | }); | |
| 177 | const miss = destroyReach(odd, WEBHOOK_ENTRY); | |
| 178 | rmSync(odd, { recursive: true, force: true }); | |
| 179 | expect(miss.some((f) => f.includes("indented top-level code"))).toBe(true); | |
| 180 | }); | |
| 181 | ||
| 182 | test("every unit of the entry and of src/billing was parsed: each exported name has a non-empty unit", () => { | |
| 183 | const files = [WEBHOOK_ENTRY, ...readdirSync(join(root, "src/billing")).filter((f) => f.endsWith(".ts")).map((f) => `src/billing/${f}`)]; | |
| 184 | for (const file of files) { | |
| 185 | expect(layoutMisses(root, file)).toEqual([]); | |
| 186 | const units = unitsOf(root, file); | |
| 187 | const text = readFileSync(join(root, file), "utf8"); | |
| 188 | const exported = [...text.matchAll(/^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|const|let|class|abstract\s+class)\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]!); | |
| 189 | for (const name of exported) { | |
| 190 | const unit = units.find((u) => u.name === name); | |
| 191 | expect(unit, `${file}: ${name} was not parsed`).toBeDefined(); | |
| 192 | expect(unit!.text.trim().length).toBeGreaterThan(name.length); | |
| 193 | } | |
| 194 | } | |
| 195 | }); | |
| 196 | }); |
new above
offline
A missing webhook entry returns no findings, so renaming src/http/stripe_hook.ts silently disables the guard. Refuse in the real tree; skip only in fixtures.