Public: anyone can read this; the crew can change it.
An app's home reads its policy line under 2 ms when its tip has not moved #6166
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6147-an-app-s-home-reads-its-policy-line-unde → main 2 commits fork 2 threads · 2 unresolved
2 files
changed +141
−7
src/contract/audit.ts +39 −7
| @@ -12,9 +12,11 @@ | ||
| 12 | 12 | * audit that leaves out what it cannot read reads as a clean bill. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { readFileSync } from "node:fs"; | |
| 15 | 16 | import { readFileAt } from "../apps/checkout.ts"; |
| 16 | 17 | import type { Repo } from "../core/repos.ts"; |
| 17 | 18 | import { resolveOid } from "../git/merge.ts"; |
| 19 | import { isSafeOid, isSafeRef } from "../git/guard.ts"; | |
| 18 | 20 | import { repoDir } from "../git/repos.ts"; |
| 19 | 21 | import { |
| 20 | 22 | VERBS, |
| @@ -108,14 +110,44 @@ export async function policyBrief( | ||
| 108 | 110 | return { identities: standing.identities, lines }; |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | /** Read one repository's policy at the tip of its default branch. */ | |
| 113 | /** Most (repository, tip) pairs remembered; the oldest is dropped first. */ | |
| 114 | const CACHE_MAX = 512; | |
| 115 | ||
| 116 | /** A standing is a pure function of (directory, tip), so it is safe to keep. */ | |
| 117 | const remembered = new Map<string, Standing>(); | |
| 118 | ||
| 119 | /** | |
| 120 | * The tip of a branch without spawning git: a loose ref is one small file. | |
| 121 | * Anything else (packed, symbolic, odd) answers undefined and git is asked. | |
| 122 | */ | |
| 123 | function looseTip(dir: string, branch: string): string | undefined { | |
| 124 | if (!isSafeRef(`refs/heads/${branch}`)) return undefined; | |
| 125 | try { | |
| 126 | const text = readFileSync(`${dir}/refs/heads/${branch}`, "utf8").trim(); | |
| 127 | return isSafeOid(text) ? text : undefined; | |
| 128 | } catch { | |
| 129 | return undefined; | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Read one repository's policy at the tip of its default branch. The result | |
| 135 | * is kept by (repository, tip): a push moves the tip and so the next read | |
| 136 | * reads again; a tip that has not moved costs one small file read. | |
| 137 | */ | |
| 112 | 138 | export async function audit(reposRoot: string, repo: Repo): Promise<Audited> { |
| 113 | 139 | const dir = repoDir(reposRoot, repo.ownerName, repo.name); |
| 114 | const oid = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 140 | const oid = | |
| 141 | looseTip(dir, repo.defaultBranch) ?? | |
| 142 | (await resolveOid(dir, `refs/heads/${repo.defaultBranch}`)); | |
| 115 | 143 | if (oid === undefined) return { repo, standing: { kind: "unread" } }; |
| 116 | return { | |
| 117 | repo, | |
| 118 | oid, | |
| 119 | standing: standingOf(await readFileAt(dir, oid, "contract.json")), | |
| 120 | }; | |
| 144 | const key = `${dir}\0${oid}`; | |
| 145 | let standing = remembered.get(key); | |
| 146 | if (standing === undefined) { | |
| 147 | standing = standingOf(await readFileAt(dir, oid, "contract.json")); | |
| 148 | if (remembered.size >= CACHE_MAX) | |
| 149 | remembered.delete(remembered.keys().next().value!); | |
| 150 | remembered.set(key, standing); | |
| 151 | } | |
| 152 | return { repo, oid, standing }; | |
| 121 | 153 | } |
test/contract-audit-cache.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * `audit` keeps a repository's policy by (repository, tip): a warm read is | |
| 3 | * fast, and a push that changes forge.json is seen on the next read. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { existsSync, mkdtempSync, rmSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { repoDir } from "../src/git/repos.ts"; | |
| 12 | import { audit } from "../src/contract/audit.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { getRepo } from "../src/core/repos.ts"; | |
| 15 | ||
| 16 | let root = ""; | |
| 17 | let app: App; | |
| 18 | ||
| 19 | beforeAll(() => { | |
| 20 | root = mkdtempSync(join(tmpdir(), "forge-audit-cache-")); | |
| 21 | app = createApp({ dataDir: root, port: 0 }); | |
| 22 | }); | |
| 23 | afterAll(() => { | |
| 24 | app.close(); | |
| 25 | rmSync(root, { recursive: true, force: true }); | |
| 26 | }); | |
| 27 | ||
| 28 | const contract = (read: string) => | |
| 29 | JSON.stringify({ | |
| 30 | roles: { anon: { above: [], held: { by: "everyone" } } }, | |
| 31 | resources: { | |
| 32 | thing: { | |
| 33 | can: { create: "anon", read, update: "anon", delete: "anon", list: "anon" }, | |
| 34 | }, | |
| 35 | }, | |
| 36 | }); | |
| 37 | ||
| 38 | test("a warm audit is served from the cache and a new tip is read afresh", async () => { | |
| 39 | const u = await createUser(app.db, { | |
| 40 | origin: "seed", | |
| 41 | trusted: true, | |
| 42 | username: "alice", | |
| 43 | email: "[email protected]", | |
| 44 | password: "a-good-passphrase", | |
| 45 | adminIfFirst: true, | |
| 46 | }); | |
| 47 | if (!u.ok) throw new Error(u.error); | |
| 48 | const alice = u.value; | |
| 49 | const made = await createRepo(app, alice, { name: "cached" }); | |
| 50 | if (!made.ok) throw new Error(String(made.error)); | |
| 51 | const write = async (text: string) => { | |
| 52 | const r = await commitToBranch(app, alice, made.value, { | |
| 53 | message: "policy", | |
| 54 | files: [{ path: "contract.json", content: text }], | |
| 55 | }); | |
| 56 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 57 | }; | |
| 58 | const repo = () => getRepo(app.db, "alice", "cached")!; | |
| 59 | ||
| 60 | await write(contract("anon")); | |
| 61 | const first = await audit(app.reposRoot, repo()); | |
| 62 | expect(first.standing.kind).toBe("sound"); | |
| 63 | ||
| 64 | const warm = await audit(app.reposRoot, repo()); | |
| 65 | expect(warm.standing).toBe(first.standing); | |
outdated
This wall-clock bound will flake under CI load. The identity check on the next line already proves the cache hit, so remove this line. |
||
| 66 | ||
| 67 | await write(JSON.stringify({ roles: {}, resources: {} })); | |
| 68 | const changed = await audit(app.reposRoot, repo()); | |
| 69 | expect(changed.oid).not.toBe(first.oid); | |
| 70 | expect(changed.standing).not.toBe(first.standing); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a packed ref takes the git path, agrees with git, and a push still invalidates", async () => { | |
| 74 | const alice = getUserByName(app.db, "alice")!; | |
| 75 | const made = await createRepo(app, alice, { name: "packed" }); | |
| 76 | if (!made.ok) throw new Error(String(made.error)); | |
| 77 | const write = async (text: string) => { | |
| 78 | const r = await commitToBranch(app, alice, made.value, { | |
| 79 | message: "policy", | |
| 80 | files: [{ path: "contract.json", content: text }], | |
| 81 | }); | |
| 82 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 83 | }; | |
| 84 | const repo = () => getRepo(app.db, "alice", "packed")!; | |
| 85 | const dir = repoDir(app.reposRoot, "alice", "packed"); | |
| 86 | const git = (...args: string[]) => | |
| 87 | Bun.spawnSync(["git", "-C", dir, ...args]).stdout.toString().trim(); | |
| 88 | ||
| 89 | await write(contract("anon")); | |
| 90 | git("pack-refs", "--all"); | |
| 91 | expect(existsSync(join(dir, "refs/heads", repo().defaultBranch))).toBe(false); | |
| 92 | ||
| 93 | const packed = await audit(app.reposRoot, repo()); | |
| 94 | expect(packed.oid).toBe(git("rev-parse", `refs/heads/${repo().defaultBranch}`)); | |
| 95 | expect(packed.standing.kind).toBe("sound"); | |
| 96 | ||
| 97 | await write(JSON.stringify({ roles: {}, resources: {} })); | |
| 98 | const next = await audit(app.reposRoot, repo()); | |
| 99 | expect(next.oid).not.toBe(packed.oid); | |
| 100 | expect(next.oid).toBe(git("rev-parse", `refs/heads/${repo().defaultBranch}`)); | |
| 101 | expect(next.standing).not.toBe(packed.standing); | |
| 102 | }); |
new above
offline
Only the loose-ref path is exercised. Add a test where refs are packed (
git pack-refs --all), so theresolveOidfallback and post-push invalidation are covered.