Public: anyone can read this; the crew can change it.
A pull opened on the Forgejo mirror reaches trunk as a real pull with its author, and the mirror stays a plain copy #9039
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9011-a-pull-opened-on-the-forgejo-mirror-reac → main 3 commits fork
16 files
changed +949
−28
docs/ORIGINS.md +25
| @@ -792,6 +792,31 @@ rights on GitHub: without them the migration still succeeds and says the | ||
| 792 | 792 | repository was not archived, so you can do it there yourself. Only repositories |
| 793 | 793 | read through a connected origin have an original to change; a pasted URL does not. |
| 794 | 794 | |
| 795 | **Pulls opened on the mirror come here** (#9011, `src/origins/mirror-pulls.ts`). | |
| 796 | Someone who finds the mirror and opens a pull there is not lost: each open | |
| 797 | pull on the mirror is fetched (`refs/pull/<n>/head`, vetted and pinned like the | |
| 798 | mirror push) into `mirror-pull/<n>` here and opened as a pull on the default | |
| 799 | branch (or the mirror pull's base, when it exists here), under the account | |
| 800 | that connected the origin. It carries its author's name (`origin_author`) and | |
| 801 | a link back (`origin_html_url`), so it is a guest pull: no secrets, no crew, | |
| 802 | until a person approves it — unless that author is a trusted source on the | |
| 803 | mirror's host (`pull_source`; `guestWaits` asks `foldsIn`, so lapsed trust | |
| 804 | waits again). The author and link are written with the pull's first row, so | |
| 805 | nothing that hears `pull.opened` ever sees it as an ordinary pull. The fetch | |
| 806 | is capped at 200 MiB of new objects (`src/git/capped-fetch.ts`: it lands in a | |
| 807 | scratch repository borrowing trunk's objects, and only a fetch under the cap | |
| 808 | is moved in). One comment on the mirror pull says where it went. A head that | |
| 809 | moves on the mirror moves the pull here while it is open, and clears any | |
| 810 | guest approval: the head a person approved is not the one that would run. The mirror is never pushed to except the trunk refs the | |
| 811 | mirror push already sends; the comment and a webhook are the only writes. It | |
| 812 | runs by event — on boot the mirror is asked once for a webhook on its pull | |
| 813 | events (`watch`, Forgejo's `X-Forgejo-Signature` is accepted on | |
| 814 | `/api/v1/repos/:owner/:name/origin-hook`, signed with the repository's derived | |
| 815 | secret) — and by a sweep every five minutes for anything missed. Both key on | |
| 816 | the mirror pull's URL and run one at a time per repository, so they never | |
| 817 | make two copies of one pull. `test/mirror-pulls.test.ts`, against the fake | |
| 818 | Forgejo. | |
| 819 | ||
| 795 | 820 | **Migrate from the terminal.** Press `M` on the dashboard (or pick "Migrate a |
| 796 | 821 | repository" in the palette, `ctrl+k`). The list is your connected origins' |
| 797 | 822 | repositories, private ones marked; `/` filters it and `u` pastes a URL instead. |
src/actions/pulls.ts +3
| @@ -122,6 +122,8 @@ export interface OpenPullInput { | ||
| 122 | 122 | revertsIssueId?: string; |
| 123 | 123 | /** The variant group the pull joins (`actions/tell.ts`); absent for an ordinary pull. */ |
| 124 | 124 | variantGroup?: string; |
| 125 | /** Who opened it on another forge, and where; native repos only (`CreatePullInput.origin`). */ | |
| 126 | origin?: { author: string; htmlUrl: string }; | |
| 125 | 127 | /** |
| 126 | 128 | * A person's deliberate yes to fixing a sealed report from a branch that |
| 127 | 129 | * is already public (this repository, or a public fork). Without it such |
| @@ -297,6 +299,7 @@ export async function openPull( | ||
| 297 | 299 | : {}), |
| 298 | 300 | ...(input.revertsIssueId ? { revertsIssueId: input.revertsIssueId } : {}), |
| 299 | 301 | ...(input.variantGroup ? { variantGroup: input.variantGroup } : {}), |
| 302 | ...(input.origin ? { origin: input.origin } : {}), | |
| 300 | 303 | }, |
| 301 | 304 | ); |
| 302 | 305 | if (!created.ok) return created; |
src/core/pulls.ts +12
| @@ -181,6 +181,12 @@ export interface CreatePullInput { | ||
| 181 | 181 | revertsIssueId?: string; |
| 182 | 182 | /** The variant group the pull joins; absent for an ordinary pull. */ |
| 183 | 183 | variantGroup?: string; |
| 184 | /** | |
| 185 | * Who opened it on another forge, and where (#9011): written with the | |
| 186 | * issue row, so the pull is a guest (`origins/guest.ts`) from its first | |
| 187 | * moment and nothing can start it with secrets before it is marked. | |
| 188 | */ | |
| 189 | origin?: { author: string; htmlUrl: string }; | |
| 184 | 190 | } |
| 185 | 191 | |
| 186 | 192 | /** |
| @@ -292,6 +298,12 @@ export async function createPull( | ||
| 292 | 298 | sealed, |
| 293 | 299 | }); |
| 294 | 300 | if (!issue.ok) return err(issue.error); |
| 301 | if (input.origin !== undefined) | |
| 302 | db.query("UPDATE issue SET origin_author = ?1, origin_html_url = ?2 WHERE id = ?3").run( | |
| 303 | input.origin.author, | |
| 304 | input.origin.htmlUrl, | |
| 305 | issue.value.id, | |
| 306 | ); | |
| 295 | 307 | |
| 296 | 308 | const headRepoId = input.headRepoId ?? input.repoId; |
| 297 | 309 | db.query( |
src/git/capped-fetch.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * A fetch of a head nobody here vetted (a read-only mirror's pull, #9011), | |
| 3 | * capped in bytes. git's fetch has no `maxInputSize` of its own, so it runs | |
| 4 | * into a throwaway bare repository beside the real one that borrows the | |
| 5 | * real one's objects (`objects/info/alternates`): only what is new lands | |
| 6 | * there. A watchdog kills the fetch once that passes the cap, and only a | |
| 7 | * fetch that finished under it has its objects moved into the repository | |
| 8 | * and is named by `ref`. Anything else leaves the repository as it was. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { runGitCapture } from "./env.ts"; | |
| 14 | import { isSafeOid } from "./guard.ts"; | |
| 15 | import { resolveOid } from "./merge.ts"; | |
| 16 | ||
| 17 | export interface CappedFetch { | |
| 18 | /** The repository fetched into. */ | |
| 19 | readonly dir: string; | |
| 20 | readonly url: string; | |
| 21 | /** The remote ref, e.g. `refs/pull/3/head`. */ | |
| 22 | readonly from: string; | |
| 23 | /** The local ref it lands at, once it is under the cap. */ | |
| 24 | readonly ref: string; | |
| 25 | readonly maxBytes: number; | |
| 26 | readonly timeoutMs: number; | |
| 27 | /** The vetted, pinned env for the remote (`vettedEnv`). */ | |
| 28 | readonly env: Record<string, string>; | |
| 29 | } | |
| 30 | ||
| 31 | export type CappedFetchResult = | |
| 32 | | { readonly ok: true; readonly oid: string } | |
| 33 | | { readonly ok: false; readonly reason: "too_large" | "failed" }; | |
| 34 | ||
| 35 | const WATCH_MS = 100; | |
| 36 | ||
| 37 | /** Bytes under `root`, every file. */ | |
| 38 | async function bytesUnder(root: string): Promise<number> { | |
| 39 | let total = 0; | |
| 40 | for (const entry of await readdir(root, { recursive: true, withFileTypes: true }).catch(() => [])) { | |
| 41 | if (!entry.isFile()) continue; | |
| 42 | const size = await stat(join(entry.parentPath, entry.name)).then((s) => s.size, () => 0); | |
| 43 | total += size; | |
| 44 | } | |
| 45 | return total; | |
| 46 | } | |
| 47 | ||
| 48 | /** Move every object file from `from` into `to`, keeping its path; one already there stays. */ | |
| 49 | async function migrate(from: string, to: string): Promise<void> { | |
| 50 | const entries = (await readdir(from, { recursive: true, withFileTypes: true })).filter( | |
| 51 | (e) => !join(e.parentPath, e.name).startsWith(join(from, "info")), | |
| 52 | ); | |
| 53 | // Packs: the index after its pack, so no reader sees an index without one. | |
| 54 | const files = entries | |
| 55 | .filter((e) => e.isFile()) | |
| 56 | .map((e) => join(e.parentPath, e.name).slice(from.length + 1)) | |
| 57 | .sort((a, b) => Number(a.endsWith(".idx")) - Number(b.endsWith(".idx"))); | |
| 58 | for (const rel of files) { | |
| 59 | const target = join(to, rel); | |
| 60 | await mkdir(join(target, ".."), { recursive: true }); | |
| 61 | if (await stat(target).then(() => true, () => false)) continue; | |
| 62 | await rename(join(from, rel), target); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | export async function cappedFetch(input: CappedFetch): Promise<CappedFetchResult> { | |
| 67 | const objects = join(input.dir, "objects"); | |
| 68 | const scratch = await mkdtemp(join(input.dir, "incoming-")); | |
| 69 | const fresh = join(scratch, "objects"); | |
| 70 | const abort = new AbortController(); | |
| 71 | let tooLarge = false; | |
| 72 | const newBytes = async (): Promise<number> => | |
| 73 | (await bytesUnder(fresh)) - (await bytesUnder(join(fresh, "info"))); | |
| 74 | const watch = setInterval(() => { | |
| 75 | void newBytes().then((n) => { | |
| 76 | if (n > input.maxBytes && !abort.signal.aborted) { | |
| 77 | tooLarge = true; | |
| 78 | abort.abort(); | |
| 79 | } | |
| 80 | }); | |
| 81 | }, WATCH_MS); | |
| 82 | try { | |
| 83 | const made = await runGitCapture(["init", "--quiet", "--bare", scratch]); | |
| 84 | if (made.code !== 0) return { ok: false, reason: "failed" }; | |
| 85 | await mkdir(join(fresh, "info"), { recursive: true }); | |
| 86 | await Bun.write(join(fresh, "info", "alternates"), `${objects}\n`); | |
| 87 | const fetched = await runGitCapture( | |
| 88 | [ | |
| 89 | "fetch", | |
| 90 | "--no-tags", | |
| 91 | "--no-write-fetch-head", | |
| 92 | "--no-recurse-submodules", | |
| 93 | "--quiet", | |
| 94 | "--", | |
| 95 | input.url, | |
| 96 | `+${input.from}:refs/incoming`, | |
| 97 | ], | |
| 98 | { cwd: scratch, env: input.env, timeoutMs: input.timeoutMs, signal: abort.signal }, | |
| 99 | ); | |
| 100 | clearInterval(watch); | |
| 101 | if (tooLarge || (await newBytes()) > input.maxBytes) return { ok: false, reason: "too_large" }; | |
| 102 | if (fetched.code !== 0) return { ok: false, reason: "failed" }; | |
| 103 | const oid = await resolveOid(scratch, "refs/incoming"); | |
| 104 | if (oid === undefined || !isSafeOid(oid)) return { ok: false, reason: "failed" }; | |
| 105 | await migrate(fresh, objects); | |
| 106 | const named = await runGitCapture(["update-ref", input.ref, oid], { cwd: input.dir }); | |
| 107 | return named.code === 0 ? { ok: true, oid } : { ok: false, reason: "failed" }; | |
| 108 | } finally { | |
| 109 | clearInterval(watch); | |
| 110 | await rm(scratch, { recursive: true, force: true }); | |
| 111 | } | |
| 112 | } |
src/http/api.ts +7 −2
| @@ -55,7 +55,8 @@ import { isProbe } from "../kernel/probe.ts"; | ||
| 55 | 55 | import { PRIVATE_ADDRESS_REFUSED } from "../kernel/address_words.ts"; |
| 56 | 56 | import { forgeState, PARENT_HEADER } from "../spawn/clients.ts"; |
| 57 | 57 | import { familyView } from "../spawn/family.ts"; |
| 58 | import { HOOK_BODY_MAX, HOOK_SIGNATURE_HEADER, receiveOriginHook } from "../origins/hook.ts"; | |
| 58 | import { HOOK_BODY_MAX, receiveOriginHook, signatureOf } from "../origins/hook.ts"; | |
| 59 | import { receiveMirrorHook } from "../origins/mirror-pulls.ts"; | |
| 59 | 60 | import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; |
| 60 | 61 | import { takeOrTell } from "../actions/follow.ts"; |
| 61 | 62 | import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts"; |
| @@ -3296,7 +3297,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3296 | 3297 | const bytes = await readBounded(ctx.request, HOOK_BODY_MAX); |
| 3297 | 3298 | if (!repo || bytes === "too_large") return notFound(); |
| 3298 | 3299 | const raw = new TextDecoder().decode(bytes); |
| 3299 | const kept = await receiveOriginHook(ctx.app, repo, raw, ctx.request.headers.get(HOOK_SIGNATURE_HEADER)); | |
| 3300 | const signature = signatureOf(ctx.request.headers); | |
| 3301 | // The origin this repository mirrors, or the mirror it pushes to (#9011). | |
| 3302 | const kept = | |
| 3303 | (await receiveOriginHook(ctx.app, repo, raw, signature)) || | |
| 3304 | (await receiveMirrorHook(ctx.app, repo, raw, signature)); | |
| 3300 | 3305 | return kept ? json({ ok: true }) : notFound(); |
| 3301 | 3306 | }); |
| 3302 | 3307 |
src/main.ts +4
| @@ -50,6 +50,7 @@ import { RED_HOLD_MS } from "./checks/red_main.ts"; | ||
| 50 | 50 | import { stopQueue } from "./checks/queue.ts"; |
| 51 | 51 | import { startLooks } from "./looks/service.ts"; |
| 52 | 52 | import { startMirrorOut } from "./origins/mirror-out.ts"; |
| 53 | import { startMirrorPulls } from "./origins/mirror-pulls.ts"; | |
| 53 | 54 | import { startProbes } from "./contract/probes.ts"; |
| 54 | 55 | import { startNightly } from "./nightly/service.ts"; |
| 55 | 56 | import { startSnapshots } from "./snapshot/service.ts"; |
| @@ -443,6 +444,8 @@ const checks = | ||
| 443 | 444 | }); |
| 444 | 445 | // Migrated repositories kept as a read-only mirror push to their source. |
| 445 | 446 | const mirrorOut = startMirrorOut(app); |
| 447 | // …and take the pulls opened on that mirror as pulls here (#9011). | |
| 448 | const mirrorPulls = startMirrorPulls(app); | |
| 446 | 449 | // The look: a picture of every environment that comes up green, taken |
| 447 | 450 | // through this forge's own listener. |
| 448 | 451 | const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined; |
| @@ -859,6 +862,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 859 | 862 | // longer ends it: a person's stop cuts checks off (queued again) at once. |
| 860 | 863 | checks?.stop({ drain: signal === "SIGTERM" }), |
| 861 | 864 | mirrorOut(), |
| 865 | mirrorPulls(), | |
| 862 | 866 | looks?.stop(), |
| 863 | 867 | probes?.stop(), |
| 864 | 868 | nightly?.stop(), |
src/origins/guest.ts +20 −4
| @@ -5,14 +5,17 @@ | ||
| 5 | 5 | * repository's secrets, and the crew does not review it, until a person — |
| 6 | 6 | * never an agent — who can write the repository approves it once. The |
| 7 | 7 | * approval is for the pull, not a head: a later push by the same author |
| 8 | * runs with the secrets too. Whether a head's code may run at all is still | |
| 9 | * `core/pull_trust.ts`'s question, asked per head. Pure data. | |
| 8 | * runs with the secrets too — except a head moved on a read-only mirror, | |
| 9 | * which clears it (`forgetGuestApproval`, #9011). Whether a head's code | |
| 10 | * may run at all is still `core/pull_trust.ts`'s question, asked per head. | |
| 11 | * Pure data. | |
| 10 | 12 | */ |
| 11 | 13 | |
| 12 | 14 | import type { Database } from "bun:sqlite"; |
| 13 | 15 | import { getIssueById } from "../core/issues.ts"; |
| 14 | 16 | import type { Pull } from "../core/pulls.ts"; |
| 15 | 17 | import { canWrite, type Repo } from "../core/repos.ts"; |
| 18 | import { foldsIn } from "../core/trusted_sources.ts"; | |
| 16 | 19 | import type { User } from "../core/users.ts"; |
| 17 | 20 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | 21 | |
| @@ -37,9 +40,13 @@ export function guestApproval( | ||
| 37 | 40 | : { approvedBy: row.approved_by, approvedAt: row.approved_at }; |
| 38 | 41 | } |
| 39 | 42 | |
| 40 | /** A guest nobody has approved yet: no secrets, no crew. */ | |
| 43 | /** | |
| 44 | * A guest nobody has approved yet: no secrets, no crew. A guest whose | |
| 45 | * account is a trusted source on its forge (`pull_source`, #9011) does not | |
| 46 | * wait: trust is that approval, for as long as it lasts. | |
| 47 | */ | |
| 41 | 48 | export function guestWaits(db: Database, pull: Pull): boolean { |
| 42 | return isGuestPull(db, pull) && guestApproval(db, pull.id) === undefined; | |
| 49 | return isGuestPull(db, pull) && guestApproval(db, pull.id) === undefined && !foldsIn(db, pull.id); | |
| 43 | 50 | } |
| 44 | 51 | |
| 45 | 52 | /** Whether `user` may approve a guest on `repo`: a person who can write it. */ |
| @@ -65,3 +72,12 @@ export function approveGuest( | ||
| 65 | 72 | .run(pull.id, user.id).changes; |
| 66 | 73 | return ok(changed === 0 ? "already" : "approved"); |
| 67 | 74 | } |
| 75 | ||
| 76 | /** | |
| 77 | * Clear the guest `pull`'s approval: its head moved somewhere no one here | |
| 78 | * pushes (a read-only mirror's pull, #9011), so the head a person approved | |
| 79 | * is not the one that would run. It waits again until someone looks. | |
| 80 | */ | |
| 81 | export function forgetGuestApproval(db: Database, pullId: string): void { | |
| 82 | db.query("DELETE FROM guest_approval WHERE pull_id = ?1").run(pullId); | |
| 83 | } |
src/origins/hook-secret.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * What an origin's webhook delivery is signed with and sent to, shared by | |
| 3 | * the receiver (`hook.ts`) and the mirror's watcher (`mirror-pulls.ts`) so | |
| 4 | * neither imports the other. The secret is derived from the forge's key and | |
| 5 | * the repository, so nothing is stored. | |
| 6 | */ | |
| 7 | ||
| 8 | import { createHmac } from "node:crypto"; | |
| 9 | import type { App } from "../app.ts"; | |
| 10 | import type { Repo } from "../core/repos.ts"; | |
| 11 | import { loadKey } from "../kernel/secret.ts"; | |
| 12 | ||
| 13 | export const HOOK_SIGNATURE_HEADER = "x-forge-signature-256"; | |
| 14 | ||
| 15 | /** The secret the origin signs this mirror's deliveries with. */ | |
| 16 | export function hookSecret(app: App, repo: Repo): string { | |
| 17 | return createHmac("sha256", loadKey(app.config.dataDir)) | |
| 18 | .update(`origin-hook:${repo.id}`) | |
| 19 | .digest("hex"); | |
| 20 | } | |
| 21 | ||
| 22 | /** Where the origin delivers: https only, because the origin refuses the rest. */ | |
| 23 | export function hookUrl(app: App, repo: Repo): string | undefined { | |
| 24 | const base = app.config.url; | |
| 25 | if (base === undefined || !/^https:\/\//i.test(base)) return undefined; | |
| 26 | return `${base.replace(/\/+$/, "")}/api/v1/repos/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}/origin-hook`; | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * The signature a delivery carries, as `sha256=<hex>`: ours, or the bare hex | |
| 31 | * Forgejo and Gitea send from a mirror's webhook. | |
| 32 | */ | |
| 33 | export function signatureOf(headers: Headers): string | null { | |
| 34 | const ours = headers.get(HOOK_SIGNATURE_HEADER); | |
| 35 | if (ours !== null) return ours; | |
| 36 | const hex = headers.get("x-forgejo-signature") ?? headers.get("x-gitea-signature"); | |
| 37 | return hex !== null && /^[0-9a-f]{64}$/i.test(hex) ? `sha256=${hex.toLowerCase()}` : null; | |
| 38 | } |
src/origins/hook.ts +3 −17
| @@ -9,33 +9,19 @@ | ||
| 9 | 9 | * derived from the forge's key and the repository, so nothing is stored. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { createHmac } from "node:crypto"; | |
| 13 | 12 | import type { App } from "../app.ts"; |
| 14 | 13 | import { verifySignature } from "../core/webhooks.ts"; |
| 15 | 14 | import { originForRepo } from "../core/origins.ts"; |
| 16 | 15 | import type { Repo } from "../core/repos.ts"; |
| 17 | import { loadKey } from "../kernel/secret.ts"; | |
| 18 | 16 | import { bustOrigin } from "../view/cache.ts"; |
| 19 | 17 | import { providerForOrigin } from "./credential.ts"; |
| 18 | import { HOOK_SIGNATURE_HEADER, hookSecret, hookUrl, signatureOf } from "./hook-secret.ts"; | |
| 20 | 19 | import { refreshOriginPull } from "./pull-check.ts"; |
| 21 | 20 | |
| 21 | export { HOOK_SIGNATURE_HEADER, hookSecret, hookUrl, signatureOf }; | |
| 22 | ||
| 22 | 23 | /** The most a delivery may say; it names a pull and little else. */ |
| 23 | 24 | export const HOOK_BODY_MAX = 256 * 1024; |
| 24 | export const HOOK_SIGNATURE_HEADER = "x-forge-signature-256"; | |
| 25 | ||
| 26 | /** The secret the origin signs this mirror's deliveries with. */ | |
| 27 | export function hookSecret(app: App, repo: Repo): string { | |
| 28 | return createHmac("sha256", loadKey(app.config.dataDir)) | |
| 29 | .update(`origin-hook:${repo.id}`) | |
| 30 | .digest("hex"); | |
| 31 | } | |
| 32 | ||
| 33 | /** Where the origin delivers: https only, because the origin refuses the rest. */ | |
| 34 | export function hookUrl(app: App, repo: Repo): string | undefined { | |
| 35 | const base = app.config.url; | |
| 36 | if (base === undefined || !/^https:\/\//i.test(base)) return undefined; | |
| 37 | return `${base.replace(/\/+$/, "")}/api/v1/repos/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}/origin-hook`; | |
| 38 | } | |
| 39 | 25 | |
| 40 | 26 | /** The pull number a delivery names: ours, Gitea's or GitHub's shape. */ |
| 41 | 27 | export function numberIn(body: unknown): number | undefined { |
src/origins/mirror-pulls.ts added +341
| @@ -0,0 +1,341 @@ | ||
| 1 | /** | |
| 2 | * Mirror pulls in (#9011): a repository that pushes to a read-only mirror | |
| 3 | * (`mirror-out.ts`) takes the pulls people open *there* as pulls here. The | |
| 4 | * mirror stays a plain copy: nothing is pushed to it but trunk's own refs — | |
| 5 | * here we only read its open pulls, fetch each head into | |
| 6 | * `refs/heads/mirror-pull/<n>`, and leave one comment on the mirror pull | |
| 7 | * saying where it went. | |
| 8 | * | |
| 9 | * A pull brought in names its author (`origin_author`) and links back | |
| 10 | * (`origin_html_url`), so it is a guest pull (`guest.ts`): no secrets, no | |
| 11 | * crew, until a person approves it — unless its author is a trusted source | |
| 12 | * on the mirror's host, which `pull_source` records. | |
| 13 | * | |
| 14 | * It runs by event (the mirror's webhook, `hook.ts`) and by a sweep for | |
| 15 | * anything missed. Both key on the mirror pull's URL, and one run at a time | |
| 16 | * per repository, so the two never make two copies of one pull. | |
| 17 | */ | |
| 18 | ||
| 19 | import type { App } from "../app.ts"; | |
| 20 | import { openPull } from "../actions/pulls.ts"; | |
| 21 | import { getMirrorTarget, type MirrorTarget } from "../core/migrate_mirror.ts"; | |
| 22 | import { getOriginById } from "../core/origins.ts"; | |
| 23 | import { getPull, retargetPull } from "../core/pulls.ts"; | |
| 24 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 25 | import { cleanAccount, cleanHost, recordPullSource } from "../core/trusted_sources.ts"; | |
| 26 | import { getUserById } from "../core/users.ts"; | |
| 27 | import { cappedFetch } from "../git/capped-fetch.ts"; | |
| 28 | import { resolveOid, writeRef } from "../git/merge.ts"; | |
| 29 | import { REMOTE_PROTOCOLS } from "../git/remote-env.ts"; | |
| 30 | import { repoDir } from "../git/repos.ts"; | |
| 31 | import { createLog } from "../kernel/log.ts"; | |
| 32 | import { providerForOrigin } from "./credential.ts"; | |
| 33 | import { forgetGuestApproval } from "./guest.ts"; | |
| 34 | import { vettedEnv } from "./git-mirror.ts"; | |
| 35 | import { hookSecret, hookUrl } from "./hook-secret.ts"; | |
| 36 | import { verifySignature } from "../core/webhooks.ts"; | |
| 37 | import type { Provider, RemoteIssue } from "./types.ts"; | |
| 38 | ||
| 39 | const log = createLog("mirror-pulls"); | |
| 40 | ||
| 41 | /** How often every mirror is swept for a pull its webhook did not announce. */ | |
| 42 | export const MIRROR_PULLS_EVERY_MS = 5 * 60_000; | |
| 43 | /** Pages of open pulls read per sweep; a mirror with more is read next time. */ | |
| 44 | const MAX_PAGES = 5; | |
| 45 | const FETCH_TIMEOUT_MS = 120_000; | |
| 46 | /** The most a mirror pull's head may bring that trunk does not already hold. */ | |
| 47 | export const MAX_FETCH_BYTES = 200 * 1024 * 1024; | |
| 48 | const MAX_TITLE = 200; | |
| 49 | const MAX_BODY = 60_000; | |
| 50 | ||
| 51 | /** The branch here that carries mirror pull `n`'s head. */ | |
| 52 | export const mirrorPullBranch = (n: number): string => `mirror-pull/${n}`; | |
| 53 | ||
| 54 | /** Fetch the mirror's `refs/pull/<n>/head` into `ref`; the oid it landed at. */ | |
| 55 | export type FetchHead = ( | |
| 56 | app: App, | |
| 57 | repo: Repo, | |
| 58 | target: MirrorTarget, | |
| 59 | provider: Provider, | |
| 60 | number: number, | |
| 61 | ref: string, | |
| 62 | ) => Promise<string | undefined>; | |
| 63 | ||
| 64 | export interface MirrorPullsDeps { | |
| 65 | readonly provider?: Provider; | |
| 66 | readonly fetchHead?: FetchHead; | |
| 67 | } | |
| 68 | ||
| 69 | export interface MirrorPullsReport { | |
| 70 | /** Local numbers of the pulls opened this run. */ | |
| 71 | readonly opened: number[]; | |
| 72 | /** Local numbers of the pulls whose head moved on the mirror. */ | |
| 73 | readonly moved: number[]; | |
| 74 | readonly error: string; | |
| 75 | } | |
| 76 | ||
| 77 | const running = new Map<string, Promise<MirrorPullsReport>>(); | |
| 78 | ||
| 79 | /** | |
| 80 | * Bring `repo`'s mirror's open pulls in. A second caller while one runs | |
| 81 | * waits for it and then runs again, so an event heard mid-run is not lost. | |
| 82 | */ | |
| 83 | export async function bringMirrorPulls( | |
| 84 | app: App, | |
| 85 | repo: Repo, | |
| 86 | deps: MirrorPullsDeps = {}, | |
| 87 | ): Promise<MirrorPullsReport> { | |
| 88 | const before = running.get(repo.id); | |
| 89 | const run = (before ?? Promise.resolve()) | |
| 90 | .catch(() => undefined) | |
| 91 | .then(() => bringOnce(app, repo, deps)); | |
| 92 | running.set(repo.id, run); | |
| 93 | try { | |
| 94 | return await run; | |
| 95 | } finally { | |
| 96 | if (running.get(repo.id) === run) running.delete(repo.id); | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | async function bringOnce(app: App, repo: Repo, deps: MirrorPullsDeps): Promise<MirrorPullsReport> { | |
| 101 | const opened: number[] = []; | |
| 102 | const moved: number[] = []; | |
| 103 | const done = (error = ""): MirrorPullsReport => ({ opened, moved, error }); | |
| 104 | const target = getMirrorTarget(app.db, repo.id); | |
| 105 | if (!target) return done(); | |
| 106 | const origin = getOriginById(app.db, target.originId); | |
| 107 | if (!origin) return done("the mirror's origin was disconnected"); | |
| 108 | const opener = getUserById(app.db, origin.userId); | |
| 109 | if (!opener) return done("the mirror's origin has no account here"); | |
| 110 | let provider = deps.provider; | |
| 111 | if (provider === undefined) { | |
| 112 | const made = providerForOrigin(app, origin); | |
| 113 | if (!made.ok) return done("the mirror's origin needs to be reconnected"); | |
| 114 | provider = made.value; | |
| 115 | } | |
| 116 | const host = cleanHost(origin.webUrl) ?? ""; | |
| 117 | const fetchHead = deps.fetchHead ?? fetchFromMirror; | |
| 118 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 119 | ||
| 120 | let cursor: string | undefined; | |
| 121 | for (let page = 0; page < MAX_PAGES; page++) { | |
| 122 | const listed = await provider.listIssues(target.fullName, { | |
| 123 | kind: "pull", | |
| 124 | state: "open", | |
| 125 | ...(cursor === undefined ? {} : { cursor }), | |
| 126 | }); | |
| 127 | if (!listed.ok) return done(`the mirror's pulls could not be read: ${listed.error.message}`); | |
| 128 | for (const remote of listed.value.items) { | |
| 129 | if (remote.pull === undefined || !isMirrorPullUrl(remote.htmlUrl)) continue; | |
| 130 | try { | |
| 131 | await bringOne(app, repo, { target, provider, fetchHead, dir, host, opener, opened, moved }, remote); | |
| 132 | } catch (error) { | |
| 133 | log.error("mirror pull not brought", { repo: repo.id, number: remote.number, error: String(error) }); | |
| 134 | } | |
| 135 | } | |
| 136 | if (listed.value.next === null) break; | |
| 137 | cursor = listed.value.next; | |
| 138 | } | |
| 139 | return done(); | |
| 140 | } | |
| 141 | ||
| 142 | interface Run { | |
| 143 | readonly target: MirrorTarget; | |
| 144 | readonly provider: Provider; | |
| 145 | readonly fetchHead: FetchHead; | |
| 146 | readonly dir: string; | |
| 147 | readonly host: string; | |
| 148 | readonly opener: NonNullable<ReturnType<typeof getUserById>>; | |
| 149 | readonly opened: number[]; | |
| 150 | readonly moved: number[]; | |
| 151 | } | |
| 152 | ||
| 153 | /** A link back is an http(s) URL and nothing else, because a page renders it. */ | |
| 154 | const isMirrorPullUrl = (url: string): boolean => { | |
| 155 | const parsed = URL.parse(url); | |
| 156 | return parsed !== null && (parsed.protocol === "https:" || parsed.protocol === "http:") && url.length <= 500; | |
| 157 | }; | |
| 158 | ||
| 159 | /** The number of the pull here that a mirror pull became, by its URL. */ | |
| 160 | export function broughtFrom(app: App, repoId: string, url: string): number | undefined { | |
| 161 | return ( | |
| 162 | app.db | |
| 163 | .query<{ number: number }, [string, string]>( | |
| 164 | "SELECT number FROM issue WHERE repo_id = ?1 AND kind = 'pull' AND origin_html_url = ?2", | |
| 165 | ) | |
| 166 | .get(repoId, url)?.number ?? undefined | |
| 167 | ); | |
| 168 | } | |
| 169 | ||
| 170 | async function bringOne(app: App, repo: Repo, run: Run, remote: RemoteIssue): Promise<void> { | |
| 171 | const number = remote.number; | |
| 172 | const branch = mirrorPullBranch(number); | |
| 173 | const known = broughtFrom(app, repo.id, remote.htmlUrl); | |
| 174 | if (known !== undefined) { | |
| 175 | // Already here: follow a head that moved, while the pull is open here. | |
| 176 | const pull = getPull(app.db, repo.id, known); | |
| 177 | if (!pull || pull.state !== "open" || pull.mergedAt !== null) return; | |
| 178 | if (pull.headOid === remote.pull?.headOid) return; | |
| 179 | const oid = await run.fetchHead(app, repo, run.target, run.provider, number, `refs/heads/${branch}`); | |
| 180 | if (oid === undefined || oid === pull.headOid) return; | |
| 181 | // Nobody here pushed this head: an approval given to the last one does | |
| 182 | // not carry over to it. | |
| 183 | forgetGuestApproval(app.db, pull.id); | |
| 184 | retargetPull(app.db, pull.id, branch, oid); | |
| 185 | await writeRef(run.dir, `refs/pull/${pull.number}/head`, oid); | |
| 186 | run.moved.push(pull.number); | |
| 187 | return; | |
| 188 | } | |
| 189 | ||
| 190 | const oid = await run.fetchHead(app, repo, run.target, run.provider, number, `refs/heads/${branch}`); | |
| 191 | if (oid === undefined) return; | |
| 192 | // A run that raced this one may have brought it while we fetched. | |
| 193 | if (broughtFrom(app, repo.id, remote.htmlUrl) !== undefined) return; | |
| 194 | const base = remote.pull?.baseBranch ?? ""; | |
| 195 | const baseBranch = | |
| 196 | base !== "" && (await resolveOid(run.dir, `refs/heads/${base}`)) !== undefined ? base : repo.defaultBranch; | |
| 197 | const author = oneLine(remote.author, 100) || "someone"; | |
| 198 | const title = oneLine(remote.title, MAX_TITLE) || `${run.target.fullName}#${number}`; | |
| 199 | const body = `From @${author} on ${remote.htmlUrl}\n\n${remote.body}`.slice(0, MAX_BODY); | |
| 200 | // A guest from its first row: the origin is written with the pull, never after. | |
| 201 | const made = await openPull(app, run.opener, repo, { | |
| 202 | title, | |
| 203 | body, | |
| 204 | baseBranch, | |
| 205 | headBranch: branch, | |
| 206 | origin: { author, htmlUrl: remote.htmlUrl }, | |
| 207 | }); | |
| 208 | if (!made.ok) { | |
| 209 | log.warn("mirror pull not opened", { repo: repo.id, number, error: String(made.error) }); | |
| 210 | return; | |
| 211 | } | |
| 212 | const pull = made.value; | |
| 213 | const account = cleanAccount(author); | |
| 214 | if (run.host !== "" && account !== undefined) | |
| 215 | recordPullSource(app.db, { pullId: pull.id, host: run.host, account, via: [] }); | |
| 216 | run.opened.push(pull.number); | |
| 217 | ||
| 218 | const here = pullUrl(app, repo, pull.number); | |
| 219 | const said = await run.provider.createComment( | |
| 220 | run.target.fullName, | |
| 221 | number, | |
| 222 | `This pull continues on ${here ?? `${repo.ownerName}/${repo.name}#${pull.number}`}, where it is reviewed and landed. This repository is a mirror; what lands there comes back here.`, | |
| 223 | ); | |
| 224 | if (!said.ok) log.warn("mirror pull comment failed", { repo: repo.id, number, error: said.error.message }); | |
| 225 | } | |
| 226 | ||
| 227 | function pullUrl(app: App, repo: Repo, number: number): string | undefined { | |
| 228 | const base = app.config.url; | |
| 229 | if (base === undefined || !/^https?:\/\//i.test(base)) return undefined; | |
| 230 | return `${base.replace(/\/+$/, "")}/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}/pulls/${number}`; | |
| 231 | } | |
| 232 | ||
| 233 | const oneLine = (text: string, max: number): string => | |
| 234 | text.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max); | |
| 235 | ||
| 236 | /** | |
| 237 | * The real fetch: the mirror's clone URL, vetted and pinned, with the | |
| 238 | * origin's credential, and capped in bytes (`capped-fetch.ts`). | |
| 239 | */ | |
| 240 | const fetchFromMirror: FetchHead = async (app, repo, target, provider, number, ref) => { | |
| 241 | const authEnv = await provider.gitAuthEnv(target.cloneUrl); | |
| 242 | const pinned = await vettedEnv(target.cloneUrl, authEnv, REMOTE_PROTOCOLS); | |
| 243 | if (!pinned.ok) return undefined; | |
| 244 | const fetched = await cappedFetch({ | |
| 245 | dir: repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 246 | url: target.cloneUrl, | |
| 247 | from: `refs/pull/${number}/head`, | |
| 248 | ref, | |
| 249 | maxBytes: MAX_FETCH_BYTES, | |
| 250 | timeoutMs: FETCH_TIMEOUT_MS, | |
| 251 | env: pinned.value, | |
| 252 | }); | |
| 253 | if (!fetched.ok) log.warn("mirror pull head not fetched", { repo: repo.id, number, reason: fetched.reason }); | |
| 254 | return fetched.ok ? fetched.oid : undefined; | |
| 255 | }; | |
| 256 | ||
| 257 | /** | |
| 258 | * A delivery from the read-only mirror this repository pushes to: someone | |
| 259 | * opened or moved a pull there. Believed for "look again" only — the mirror's | |
| 260 | * open pulls are read and brought in (below), not answered from | |
| 261 | * the body. The origin is not kept waiting for the fetch. | |
| 262 | */ | |
| 263 | export async function receiveMirrorHook( | |
| 264 | app: App, | |
| 265 | repo: Repo, | |
| 266 | raw: string, | |
| 267 | signature: string | null, | |
| 268 | deps: MirrorPullsDeps = {}, | |
| 269 | ): Promise<boolean> { | |
| 270 | if (signature === null || !getMirrorTarget(app.db, repo.id)) return false; | |
| 271 | if (!(await verifySignature(hookSecret(app, repo), raw, signature))) return false; | |
| 272 | void bringMirrorPulls(app, repo, deps).catch((error: unknown) => | |
| 273 | app.log.error("mirror pulls failed", { repoId: repo.id, error: String(error) }), | |
| 274 | ); | |
| 275 | return true; | |
| 276 | } | |
| 277 | ||
| 278 | /** | |
| 279 | * Ask the mirror this repository pushes to for its pull events, once. | |
| 280 | * Best effort: the sweep still finds what a missing hook would have said. | |
| 281 | */ | |
| 282 | export async function watchMirror(app: App, repo: Repo, provider: Provider): Promise<void> { | |
| 283 | const target = getMirrorTarget(app.db, repo.id); | |
| 284 | const url = hookUrl(app, repo); | |
| 285 | if (!target || url === undefined || provider.watch === undefined) return; | |
| 286 | await provider.watch(target.fullName, { url, secret: hookSecret(app, repo) }); | |
| 287 | } | |
| 288 | ||
| 289 | /** Every repository that pushes to a mirror. */ | |
| 290 | function mirroredRepos(app: App): Repo[] { | |
| 291 | return app.db | |
| 292 | .query<{ repo_id: string }, []>("SELECT repo_id FROM migrate_mirror") | |
| 293 | .all() | |
| 294 | .map((r) => getRepoById(app.db, r.repo_id)) | |
| 295 | .filter((r): r is Repo => r !== undefined); | |
| 296 | } | |
| 297 | ||
| 298 | /** One sweep over every mirror. Never throws. */ | |
| 299 | export async function sweepMirrorPulls(app: App, deps: MirrorPullsDeps = {}): Promise<void> { | |
| 300 | for (const repo of mirroredRepos(app)) { | |
| 301 | try { | |
| 302 | const report = await bringMirrorPulls(app, repo, deps); | |
| 303 | if (report.error !== "") log.warn("mirror pulls", { repo: repo.id, error: report.error }); | |
| 304 | } catch (error) { | |
| 305 | log.error("mirror pulls sweep failed", { repo: repo.id, error: String(error) }); | |
| 306 | } | |
| 307 | } | |
| 308 | } | |
| 309 | ||
| 310 | /** Ask each mirror for its webhook, once per boot. Never throws. */ | |
| 311 | async function watchAll(app: App): Promise<void> { | |
| 312 | for (const repo of mirroredRepos(app)) { | |
| 313 | const target = getMirrorTarget(app.db, repo.id); | |
| 314 | const origin = target === undefined ? undefined : getOriginById(app.db, target.originId); | |
| 315 | const provider = origin === undefined ? undefined : providerForOrigin(app, origin); | |
| 316 | if (!provider?.ok) continue; | |
| 317 | try { | |
| 318 | await watchMirror(app, repo, provider.value); | |
| 319 | } catch (error) { | |
| 320 | log.warn("mirror webhook not set", { repo: repo.id, error: String(error) }); | |
| 321 | } | |
| 322 | } | |
| 323 | } | |
| 324 | ||
| 325 | /** The sweep on a clock, once on boot. Returns the stop. */ | |
| 326 | export function startMirrorPulls(app: App): () => void { | |
| 327 | let busy = false; | |
| 328 | let first = true; | |
| 329 | const tick = (): void => { | |
| 330 | if (busy) return; | |
| 331 | busy = true; | |
| 332 | const watching = first ? watchAll(app) : Promise.resolve(); | |
| 333 | first = false; | |
| 334 | void watching.then(() => sweepMirrorPulls(app)).finally(() => { | |
| 335 | busy = false; | |
| 336 | }); | |
| 337 | }; | |
| 338 | const timer = setInterval(tick, MIRROR_PULLS_EVERY_MS); | |
| 339 | tick(); | |
| 340 | return () => clearInterval(timer); | |
| 341 | } |
src/origins/providers/forge.ts +1 −1
| @@ -182,7 +182,7 @@ export class ForgeProvider extends ForgejoProvider { | ||
| 182 | 182 | return succeed(states); |
| 183 | 183 | } |
| 184 | 184 | |
| 185 | async watch(fullName: string, hook: { url: string; secret: string }): Promise<ProviderResult<void>> { | |
| 185 | override async watch(fullName: string, hook: { url: string; secret: string }): Promise<ProviderResult<void>> { | |
| 186 | 186 | const base = this.repoPath(fullName); |
| 187 | 187 | if (base === undefined) return fail("not_found", `not a repository name: ${fullName}`); |
| 188 | 188 | const listed = await this.api.call({ path: `${base}/hooks` }); |
src/origins/providers/forgejo.ts +26
| @@ -729,6 +729,32 @@ export class ForgejoProvider implements Provider { | ||
| 729 | 729 | return succeed(toComment(result.value.data)); |
| 730 | 730 | } |
| 731 | 731 | |
| 732 | /** | |
| 733 | * A webhook on the repository for its pull events (#9011), signed with | |
| 734 | * `secret`; Forgejo and Gitea sign as `X-Forgejo-Signature` / | |
| 735 | * `X-Gitea-Signature`, bare hex. One already pointing at `url` is kept. | |
| 736 | */ | |
| 737 | async watch(fullName: string, hook: { url: string; secret: string }): Promise<ProviderResult<void>> { | |
| 738 | const named = splitFullName(fullName); | |
| 739 | if (!named) return fail("not_found", `not a repository name: ${fullName}`); | |
| 740 | const path = `/repos/${seg(named.owner)}/${seg(named.name)}/hooks`; | |
| 741 | const listed = await this.api.call({ path }); | |
| 742 | if (!listed.ok) return listed; | |
| 743 | if (asArray(listed.value.data).some((row) => asString(field(field(row, "config"), "url")) === hook.url)) | |
| 744 | return succeed(undefined); | |
| 745 | const made = await this.api.call({ | |
| 746 | path, | |
| 747 | method: "POST", | |
| 748 | body: { | |
| 749 | type: "gitea", | |
| 750 | active: true, | |
| 751 | events: ["pull_request"], | |
| 752 | config: { url: hook.url, content_type: "json", secret: hook.secret }, | |
| 753 | }, | |
| 754 | }); | |
| 755 | return made.ok ? succeed(undefined) : made; | |
| 756 | } | |
| 757 | ||
| 732 | 758 | async setState( |
| 733 | 759 | fullName: string, |
| 734 | 760 | number: number, |
src/web/pages/pull-guest.ts +2 −4
| @@ -11,8 +11,7 @@ import type { Pull } from "../../core/pulls.ts"; | ||
| 11 | 11 | import type { Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { approveGuestPull } from "../../actions/pulls.ts"; |
| 13 | 13 | import { |
| 14 | guestApproval, | |
| 15 | isGuestPull, | |
| 14 | guestWaits, | |
| 16 | 15 | mayApproveGuest, |
| 17 | 16 | } from "../../origins/guest.ts"; |
| 18 | 17 | import { repoBase, signInFirst } from "../chrome.ts"; |
| @@ -29,8 +28,7 @@ export const GUEST_APPROVED = "Approved as a guest: its preview starts again wit | ||
| 29 | 28 | /** The guest notice; empty for a pull from this forge, or one already approved. */ |
| 30 | 29 | export function guestCard(ctx: Ctx, repo: Repo, pull: Pull, action: string): Html { |
| 31 | 30 | if (pull.state !== "open" || pull.mergedAt !== null) return html``; |
| 32 | if (!isGuestPull(ctx.app.db, pull)) return html``; | |
| 33 | if (guestApproval(ctx.app.db, pull.id) !== undefined) return html``; | |
| 31 | if (!guestWaits(ctx.app.db, pull)) return html``; | |
| 34 | 32 | const writer = mayApproveGuest(ctx.app.db, repo, ctx.auth.user); |
| 35 | 33 | return card( |
| 36 | 34 | html`<p>${GUEST_WAITS}</p> |
test/capped-fetch.test.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * A head fetched from somewhere nobody vetted is capped in bytes (#9011): | |
| 3 | * over the cap, the repository is as it was — no ref, no objects, no | |
| 4 | * scratch left behind; under it, the head is named and its objects are the | |
| 5 | * repository's own. Real git, over file://. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { randomBytes } from "node:crypto"; | |
| 10 | import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { cappedFetch } from "../src/git/capped-fetch.ts"; | |
| 14 | import { runGitCapture } from "../src/git/env.ts"; | |
| 15 | import { resolveOid } from "../src/git/merge.ts"; | |
| 16 | ||
| 17 | const git = async (cwd: string, ...args: string[]): Promise<string> => { | |
| 18 | const env = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }; | |
| 19 | const run = await runGitCapture(args, { cwd, env }); | |
| 20 | if (run.code !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr}`); | |
| 21 | return run.stdout.trim(); | |
| 22 | }; | |
| 23 | ||
| 24 | describe("capped fetch", () => { | |
| 25 | let root: string; | |
| 26 | let work: string; | |
| 27 | let dest: string; | |
| 28 | let head: string; | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | root = await mkdtemp(join(tmpdir(), "forge-capped-fetch-")); | |
| 32 | work = join(root, "work"); | |
| 33 | dest = join(root, "dest.git"); | |
| 34 | await git(root, "init", "--quiet", "-b", "main", work); | |
| 35 | await writeFile(join(work, "README.md"), "hi\n"); | |
| 36 | await git(work, "add", "."); | |
| 37 | await git(work, "commit", "--quiet", "-m", "start"); | |
| 38 | await git(root, "clone", "--quiet", "--bare", work, dest); | |
| 39 | // The pull: half a megabyte nothing can compress. | |
| 40 | await writeFile(join(work, "big.bin"), randomBytes(512 * 1024)); | |
| 41 | await git(work, "add", "."); | |
| 42 | await git(work, "commit", "--quiet", "-m", "big"); | |
| 43 | head = await git(work, "rev-parse", "HEAD"); | |
| 44 | await git(work, "update-ref", "refs/pull/1/head", head); | |
| 45 | }); | |
| 46 | ||
| 47 | afterAll(async () => { | |
| 48 | await rm(root, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | const fetch = (maxBytes: number) => | |
| 52 | cappedFetch({ | |
| 53 | dir: dest, | |
| 54 | url: `file://${work}`, | |
| 55 | from: "refs/pull/1/head", | |
| 56 | ref: "refs/heads/mirror-pull/1", | |
| 57 | maxBytes, | |
| 58 | timeoutMs: 30_000, | |
| 59 | env: {}, | |
| 60 | }); | |
| 61 | ||
| 62 | test("over the cap, the repository is as it was", async () => { | |
| 63 | expect(await fetch(64 * 1024)).toEqual({ ok: false, reason: "too_large" }); | |
| 64 | expect(await resolveOid(dest, "refs/heads/mirror-pull/1")).toBeUndefined(); | |
| 65 | expect((await runGitCapture(["cat-file", "-e", head], { cwd: dest })).code).not.toBe(0); | |
| 66 | expect((await readdir(dest)).filter((n) => n.startsWith("incoming-"))).toEqual([]); | |
| 67 | }); | |
| 68 | ||
| 69 | test("under the cap, the head is named and its objects are the repository's", async () => { | |
| 70 | expect(await fetch(8 * 1024 * 1024)).toEqual({ ok: true, oid: head }); | |
| 71 | expect(await resolveOid(dest, "refs/heads/mirror-pull/1")).toBe(head); | |
| 72 | expect((await runGitCapture(["cat-file", "-e", `${head}:big.bin`], { cwd: dest })).code).toBe(0); | |
| 73 | expect((await runGitCapture(["fsck", "--no-dangling"], { cwd: dest })).code).toBe(0); | |
| 74 | expect((await readdir(dest)).filter((n) => n.startsWith("incoming-"))).toEqual([]); | |
| 75 | }); | |
| 76 | }); |
test/fake-origin.ts +15
| @@ -199,6 +199,8 @@ export interface FakeState { | ||
| 199 | 199 | conditionalHits: number; |
| 200 | 200 | /** Refuse the next write with this message. */ |
| 201 | 201 | refuseWrite: string; |
| 202 | /** Forgejo webhooks as created, keyed `owner/name`. */ | |
| 203 | hooks: Map<string, Record<string, unknown>[]>; | |
| 202 | 204 | /** |
| 203 | 205 | * How many `getRepo` calls a freshly forked repository 404s for before it |
| 204 | 206 | * appears. GitHub creates a fork asynchronously and answers 202 with a |
| @@ -272,6 +274,7 @@ export function newFakeState( | ||
| 272 | 274 | etagsEnabled: false, |
| 273 | 275 | conditionalHits: 0, |
| 274 | 276 | refuseWrite: "", |
| 277 | hooks: new Map(), | |
| 275 | 278 | forkDelayReads: 0, |
| 276 | 279 | pendingForks: new Map(), |
| 277 | 280 | refuseFork: "", |
| @@ -495,6 +498,18 @@ function route( | ||
| 495 | 498 | return jsonResponse({ message: "Not Found" }, 404); |
| 496 | 499 | } |
| 497 | 500 | |
| 501 | // Forgejo's webhooks: listed, and made with a `config` holding url and secret. | |
| 502 | if (tail === "/hooks" && state.kind !== "github") { | |
| 503 | const hooks = state.hooks.get(fullName) ?? []; | |
| 504 | if (method === "GET") return jsonResponse(hooks); | |
| 505 | if (method === "POST") { | |
| 506 | if (state.refuseWrite !== "") return refuse(state); | |
| 507 | const made = { id: state.nextId++, ...(parse(bodyText) as Record<string, unknown>) }; | |
| 508 | state.hooks.set(fullName, [...hooks, made]); | |
| 509 | return jsonResponse(made, 201); | |
| 510 | } | |
| 511 | } | |
| 512 | ||
| 498 | 513 | if (tail === "" && method === "PATCH") { |
| 499 | 514 | if (repo.admin === false) |
| 500 | 515 | return jsonResponse({ message: "Must have admin rights to Repository." }, 403); |
test/mirror-pulls.test.ts added +264
| @@ -0,0 +1,264 @@ | ||
| 1 | /** | |
| 2 | * A pull opened on the read-only Forgejo mirror becomes a pull here (#9011): | |
| 3 | * its title, its author's name and a link back; a guest unless the author is | |
| 4 | * a trusted source; one pull however often the event and the sweep fire; a | |
| 5 | * head that moves there moves here. The mirror gets one comment and its | |
| 6 | * webhook, never a push. Against the fake Forgejo; the git fetch is the one | |
| 7 | * seam, standing in for `refs/pull/<n>/head`. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { createHmac } from "node:crypto"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { getIssue } from "../src/core/issues.ts"; | |
| 18 | import { setMirrorTarget } from "../src/core/migrate_mirror.ts"; | |
| 19 | import { createOrigin } from "../src/core/origins.ts"; | |
| 20 | import { getPull } from "../src/core/pulls.ts"; | |
| 21 | import type { Repo } from "../src/core/repos.ts"; | |
| 22 | import { trustSource, untrust } from "../src/core/trusted_sources.ts"; | |
| 23 | import { createUser, type User } from "../src/core/users.ts"; | |
| 24 | import { resolveOid } from "../src/git/merge.ts"; | |
| 25 | import { repoDir } from "../src/git/repos.ts"; | |
| 26 | import { approveGuest, guestWaits } from "../src/origins/guest.ts"; | |
| 27 | import { hookSecret, receiveOriginHook, signatureOf } from "../src/origins/hook.ts"; | |
| 28 | import { bringMirrorPulls, broughtFrom, receiveMirrorHook, watchMirror, type FetchHead } from "../src/origins/mirror-pulls.ts"; | |
| 29 | import { ForgejoProvider } from "../src/origins/providers/forgejo.ts"; | |
| 30 | import { staticCredential } from "../src/origins/types.ts"; | |
| 31 | import { newFakeState, serveFakeForge, type FakeForge, type FakeState } from "./fake-origin.ts"; | |
| 32 | import { quietLogs } from "./helpers/globals.ts"; | |
| 33 | ||
| 34 | quietLogs(); | |
| 35 | ||
| 36 | const TOKEN = "tok-mirror-1234"; | |
| 37 | ||
| 38 | describe("pulls opened on the mirror come here", () => { | |
| 39 | let dataDir: string; | |
| 40 | let app: App; | |
| 41 | let alice: User; | |
| 42 | let repo: Repo; | |
| 43 | let fake: FakeForge; | |
| 44 | let state: FakeState; | |
| 45 | let fetches = 0; | |
| 46 | ||
| 47 | // The mirror's `refs/pull/<n>/head`: a commit on the branch the pull rides | |
| 48 | // here, a new one only when the mirror's head sha moved. | |
| 49 | const served = new Map<number, string>(); | |
| 50 | const fetchHead: FetchHead = async (a, r, _t, _p, number, ref) => { | |
| 51 | const dir = repoDir(a.reposRoot, r.ownerName, r.name); | |
| 52 | const sha = state.issues.get("trevato-apps/forge")?.find((i) => i.number === number)?.pull?.headSha ?? ""; | |
| 53 | if (served.get(number) === sha) return resolveOid(dir, ref); | |
| 54 | served.set(number, sha); | |
| 55 | fetches++; | |
| 56 | const branch = ref.replace(/^refs\/heads\//, ""); | |
| 57 | const made = await commitToBranch(a, alice, r, { | |
| 58 | branch, | |
| 59 | message: `Mirror pull ${number}, fetch ${fetches}`, | |
| 60 | files: [{ path: `m${number}-${fetches}.md`, content: "from the mirror\n" }], | |
| 61 | }); | |
| 62 | if (!made.ok) return undefined; | |
| 63 | return resolveOid(dir, ref); | |
| 64 | }; | |
| 65 | const provider = () => | |
| 66 | new ForgejoProvider( | |
| 67 | staticCredential({ kind: "forgejo", apiUrl: fake.apiUrl, webUrl: fake.url, token: TOKEN }), | |
| 68 | ); | |
| 69 | const bring = () => bringMirrorPulls(app, repo, { provider: provider(), fetchHead }); | |
| 70 | const openOnMirror = (number: number, user: string, headSha = `${number}`.padStart(40, "a")) => { | |
| 71 | const all = state.issues.get("trevato-apps/forge") ?? []; | |
| 72 | all.push({ | |
| 73 | id: 500 + number, | |
| 74 | number, | |
| 75 | title: `Fix ${number}`, | |
| 76 | body: "It was broken.", | |
| 77 | user, | |
| 78 | pull: { base: "main", head: `fix-${number}`, headRepo: `${user}/forge`, headSha }, | |
| 79 | }); | |
| 80 | state.issues.set("trevato-apps/forge", all); | |
| 81 | }; | |
| 82 | const broughtCount = () => | |
| 83 | app.db | |
| 84 | .query<{ n: number }, [string]>( | |
| 85 | "SELECT count(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'pull' AND origin_html_url IS NOT NULL", | |
| 86 | ) | |
| 87 | .get(repo.id)!.n; | |
| 88 | ||
| 89 | beforeAll(async () => { | |
| 90 | dataDir = await mkdtemp(join(tmpdir(), "forge-mirror-pulls-")); | |
| 91 | app = createApp({ dataDir, port: 0, url: "https://forge.test" }); | |
| 92 | state = newFakeState("forgejo", "trevato", TOKEN); | |
| 93 | state.repos.push({ id: 1, fullName: "trevato-apps/forge", cloneUrl: "http://127.0.0.1/none.git", admin: true }); | |
| 94 | fake = serveFakeForge(state); | |
| 95 | const made = await createUser(app.db, { | |
| 96 | origin: "seed", | |
| 97 | trusted: true, | |
| 98 | username: "alice", | |
| 99 | email: "[email protected]", | |
| 100 | password: "a-good-passphrase", | |
| 101 | adminIfFirst: true, | |
| 102 | }); | |
| 103 | if (!made.ok) throw new Error(made.error); | |
| 104 | alice = made.value; | |
| 105 | const r = await createRepo(app, alice, { name: "forge", visibility: "public" }); | |
| 106 | if (!r.ok) throw new Error(String(r.error)); | |
| 107 | repo = r.value; | |
| 108 | const seeded = await commitToBranch(app, alice, repo, { | |
| 109 | message: "Start", | |
| 110 | files: [{ path: "README.md", content: "forge\n" }], | |
| 111 | }); | |
| 112 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 113 | const origin = createOrigin(app.db, dataDir, { | |
| 114 | userId: alice.id, | |
| 115 | kind: "forgejo", | |
| 116 | apiUrl: fake.apiUrl, | |
| 117 | webUrl: fake.url, | |
| 118 | login: "trevato", | |
| 119 | token: TOKEN, | |
| 120 | }); | |
| 121 | if (!origin.ok) throw new Error(origin.error); | |
| 122 | setMirrorTarget(app.db, { | |
| 123 | repoId: repo.id, | |
| 124 | originId: origin.value.id, | |
| 125 | fullName: "trevato-apps/forge", | |
| 126 | cloneUrl: `${fake.url}/trevato-apps/forge.git`, | |
| 127 | tips: {}, | |
| 128 | }); | |
| 129 | }, 60_000); | |
| 130 | ||
| 131 | afterAll(async () => { | |
| 132 | fake.stop(); | |
| 133 | app.close(); | |
| 134 | await rm(dataDir, { recursive: true, force: true }); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a mirror pull becomes a guest pull with its author and a link back, and the mirror is told where", async () => { | |
| 138 | openOnMirror(1, "tdobbertin-espo"); | |
| 139 | // Whatever starts a check or a preview hears `pull.opened`: by then the | |
| 140 | // pull must already be a guest, or it could start with secrets. | |
| 141 | const seen: Array<string | null> = []; | |
| 142 | const stop = app.bus.subscribe((event) => { | |
| 143 | if (event.kind !== "pull.opened" || event.repoId !== repo.id) return; | |
| 144 | const number = (event.payload as { number: number }).number; | |
| 145 | const issue = getIssue(app.db, repo.id, number)!; | |
| 146 | seen.push(issue.originAuthor); | |
| 147 | expect(guestWaits(app.db, getPull(app.db, repo.id, number)!)).toBe(true); | |
| 148 | }); | |
| 149 | const report = await bring(); | |
| 150 | stop(); | |
| 151 | expect(seen).toEqual(["tdobbertin-espo"]); | |
| 152 | expect(report.error).toBe(""); | |
| 153 | expect(report.opened.length).toBe(1); | |
| 154 | const pull = getPull(app.db, repo.id, report.opened[0]!)!; | |
| 155 | expect(pull.title).toBe("Fix 1"); | |
| 156 | expect(pull.headBranch).toBe("mirror-pull/1"); | |
| 157 | expect(pull.baseBranch).toBe("main"); | |
| 158 | const issue = getIssue(app.db, repo.id, pull.number)!; | |
| 159 | expect(issue.originAuthor).toBe("tdobbertin-espo"); | |
| 160 | expect(issue.originUrl).toBe(`${fake.url}/1`); | |
| 161 | expect(issue.body).toContain(`From @tdobbertin-espo on ${fake.url}/1`); | |
| 162 | expect(guestWaits(app.db, pull)).toBe(true); | |
| 163 | ||
| 164 | const said = state.comments.get("trevato-apps/forge") ?? []; | |
| 165 | expect(said.length).toBe(1); | |
| 166 | expect(said[0]!.issueNumber).toBe(1); | |
| 167 | expect(said[0]!.body).toContain(`https://forge.test/alice/forge/pulls/${pull.number}`); | |
| 168 | // The only write the mirror saw is that comment. | |
| 169 | const writes = state.requests.filter((q) => q.method !== "GET"); | |
| 170 | expect(writes.map((q) => `${q.method} ${q.path}`)).toEqual([ | |
| 171 | "POST /api/v1/repos/trevato-apps/forge/issues/1/comments", | |
| 172 | ]); | |
| 173 | }); | |
| 174 | ||
| 175 | test("the event and the sweep firing together still make one pull", async () => { | |
| 176 | openOnMirror(2, "someone"); | |
| 177 | const before = broughtCount(); | |
| 178 | const [a, b] = await Promise.all([bring(), bring()]); | |
| 179 | expect(a.opened.length + b.opened.length).toBe(1); | |
| 180 | expect(broughtCount()).toBe(before + 1); | |
| 181 | await bring(); | |
| 182 | expect(broughtCount()).toBe(before + 1); | |
| 183 | expect((state.comments.get("trevato-apps/forge") ?? []).filter((c) => c.issueNumber === 2).length).toBe(1); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a trusted source's mirror pull does not wait as a guest", async () => { | |
| 187 | const host = new URL(fake.url).host; | |
| 188 | const until = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10); | |
| 189 | const trusted = trustSource(app.db, { host, account: "friend", until, setBy: alice.id }); | |
| 190 | expect(typeof trusted).toBe("object"); | |
| 191 | openOnMirror(3, "friend"); | |
| 192 | const report = await bring(); | |
| 193 | const pull = getPull(app.db, repo.id, report.opened[0]!)!; | |
| 194 | expect(getIssue(app.db, repo.id, pull.number)!.originAuthor).toBe("friend"); | |
| 195 | expect(guestWaits(app.db, pull)).toBe(false); | |
| 196 | ||
| 197 | // Trust that lapses (or is taken back) makes it wait again. | |
| 198 | app.db | |
| 199 | .query("UPDATE trusted_source SET until = ?1 WHERE host = ?2 AND account = 'friend'") | |
| 200 | .run(new Date(Date.now() - 60_000).toISOString(), host); | |
| 201 | expect(guestWaits(app.db, pull)).toBe(true); | |
| 202 | const renewed = trustSource(app.db, { host, account: "friend", until, setBy: alice.id }); | |
| 203 | expect(guestWaits(app.db, pull)).toBe(false); | |
| 204 | if (typeof renewed === "object") untrust(app.db, renewed.id); | |
| 205 | expect(guestWaits(app.db, pull)).toBe(true); | |
| 206 | }); | |
| 207 | ||
| 208 | test("an untrusted guest still waits while someone else is trusted", async () => { | |
| 209 | const pull = getPull(app.db, repo.id, broughtFrom(app, repo.id, `${fake.url}/1`)!)!; | |
| 210 | expect(guestWaits(app.db, pull)).toBe(true); | |
| 211 | }); | |
| 212 | ||
| 213 | test("a head that moves on the mirror moves the pull here", async () => { | |
| 214 | const mirror = state.issues.get("trevato-apps/forge")!.find((i) => i.number === 1)!; | |
| 215 | const number = app.db | |
| 216 | .query<{ number: number }, [string]>("SELECT number FROM issue WHERE origin_html_url = ?1") | |
| 217 | .get(`${fake.url}/1`)!.number; | |
| 218 | const approved = getPull(app.db, repo.id, number)!; | |
| 219 | const was = approved.headOid; | |
| 220 | expect(approveGuest(app.db, repo, approved, alice).ok).toBe(true); | |
| 221 | expect(guestWaits(app.db, approved)).toBe(false); | |
| 222 | mirror.pull = { ...mirror.pull!, headSha: "b".repeat(40) }; | |
| 223 | const report = await bring(); | |
| 224 | expect(report.moved).toEqual([number]); | |
| 225 | const now = getPull(app.db, repo.id, number)!; | |
| 226 | // The head a person approved is not the one that would run now. | |
| 227 | expect(guestWaits(app.db, now)).toBe(true); | |
| 228 | expect(now.headOid).not.toBe(was); | |
| 229 | expect(await resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), `refs/pull/${number}/head`)).toBe( | |
| 230 | now.headOid, | |
| 231 | ); | |
| 232 | }); | |
| 233 | ||
| 234 | test("the mirror's webhook is set once, and its signed delivery is believed", async () => { | |
| 235 | await watchMirror(app, repo, provider()); | |
| 236 | await watchMirror(app, repo, provider()); | |
| 237 | const hooks = state.hooks.get("trevato-apps/forge") ?? []; | |
| 238 | expect(hooks.length).toBe(1); | |
| 239 | const config = hooks[0]!["config"] as Record<string, string>; | |
| 240 | expect(config["url"]).toBe("https://forge.test/api/v1/repos/alice/forge/origin-hook"); | |
| 241 | expect(config["secret"]).toBe(hookSecret(app, repo)); | |
| 242 | ||
| 243 | const raw = JSON.stringify({ action: "opened", number: 9 }); | |
| 244 | const mac = createHmac("sha256", hookSecret(app, repo)).update(raw).digest("hex"); | |
| 245 | const good = signatureOf(new Headers({ "x-forgejo-signature": mac })); | |
| 246 | expect(good).toBe(`sha256=${mac}`); | |
| 247 | // Not an origin's delivery: this repository mirrors nothing. | |
| 248 | expect(await receiveOriginHook(app, repo, raw, good)).toBe(false); | |
| 249 | const bad = signatureOf(new Headers({ "x-forgejo-signature": "0".repeat(64) })); | |
| 250 | expect(await receiveMirrorHook(app, repo, raw, bad, { provider: provider(), fetchHead })).toBe(false); | |
| 251 | expect(await receiveMirrorHook(app, repo, raw, null, { provider: provider(), fetchHead })).toBe(false); | |
| 252 | expect(signatureOf(new Headers({ "x-forgejo-signature": "not hex" }))).toBeNull(); | |
| 253 | ||
| 254 | // A signed delivery brings the pull it announced. | |
| 255 | openOnMirror(9, "hooked"); | |
| 256 | const before = broughtCount(); | |
| 257 | expect(await receiveMirrorHook(app, repo, raw, good, { provider: provider(), fetchHead })).toBe(true); | |
| 258 | // Runs on one repository queue: this one waits for the delivery's, and finds nothing left. | |
| 259 | const after = await bring(); | |
| 260 | expect(after.opened).toEqual([]); | |
| 261 | expect(broughtCount()).toBe(before + 1); | |
| 262 | expect(getIssue(app.db, repo.id, broughtFrom(app, repo.id, `${fake.url}/9`)!)!.originAuthor).toBe("hooked"); | |
| 263 | }); | |
| 264 | }); |
new above
offline