Public: anyone can read this; the crew can change it.
Big repos always clone: transfers end on stalled progress, not a 5-minute clock, and partial clone works #4501
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/4482-big-repos-always-clone-transfers-end-on → main 2 commits fork 1 thread · 1 unresolved
9 files
changed +820
−34
README.md +4
| @@ -299,6 +299,10 @@ ssh -p 2222 localhost # the terminal UI | ||
| 299 | 299 | git clone ssh://localhost:2222/you/hello.git # git, same port |
| 300 | 300 | |
| 301 | 301 | |
| 302 | A big repository clones over either transport. To skip its large files until a | |
| 303 | checkout needs them, run `git clone --filter=blob:none <url>`. See | |
| 304 | [](docs/GIT.md). | |
| 305 | ||
| 302 | 306 | **After a GitHub migration** you choose what happens to the original: leave it |
| 303 | 307 | alone (the default), archive it, or keep it as a read-only mirror. The choice is |
| 304 | 308 | the "Afterwards" field on `/migrate`, in the terminal's migrate screen, and |
docs/GIT.md added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | # Cloning over git | |
| 2 | ||
| 3 | The forge speaks git's smart HTTP (`https://<forge>/<owner>/<name>.git`) and | |
| 4 | git over SSH (`ssh://<forge>:<port>/<owner>/<name>.git`), protocol v0 and v2, | |
| 5 | with stock git and no extension. `src/git/smart-http.ts` and | |
| 6 | `src/git/smart-ssh.ts` spawn `git upload-pack` and `git receive-pack` and pipe | |
| 7 | bytes. Who may read or write is decided before git starts (`canRead`, | |
| 8 | `canWrite`), the same way on both transports. | |
| 9 | ||
| 10 | ## A big repository | |
| 11 | ||
| 12 | A repository with large files (CAD models, datasets, media) clones in full: | |
| 13 | ||
| 14 | sh | |
| 15 | git clone https://<forge>/<owner>/<name>.git | |
| 16 | ||
| 17 | ||
| 18 | To skip the big files up front, ask for a partial clone. The commits and | |
| 19 | trees arrive at once. A file's contents are fetched the first time git needs | |
| 20 | them: at checkout, and later when you switch to a commit that has a file | |
| 21 | you have not fetched yet. | |
| 22 | ||
| 23 | sh | |
| 24 | git clone --filter=blob:none https://<forge>/<owner>/<name>.git # no file contents until needed | |
| 25 | git clone --filter=blob:limit=1m https://<forge>/<owner>/<name>.git # files over 1 MiB until needed | |
| 26 | ||
| 27 | ||
| 28 | Add `--no-checkout` to fetch nothing but history. `git checkout <branch>` | |
| 29 | then fetches only that branch's files. The same flags work over SSH. | |
| 30 | ||
| 31 | The forge runs `upload-pack` with `uploadpack.allowFilter` and | |
| 32 | `uploadpack.allowAnySHA1InWant` (as `-c` options, so server-side git still | |
| 33 | reads no global or system config). A later fetch of a missing file is an | |
| 34 | ordinary upload-pack request for an object id, and it passes the same read | |
| 35 | check as a clone. Without read access to the repository you cannot fetch any | |
| 36 | of its objects, by name or by id (`test/big-clone.test.ts`, | |
| 37 | `test/git-ssh.test.ts`). | |
| 38 | ||
| 39 | ## When a clone ends | |
| 40 | ||
| 41 | A clone ends when it stops moving, not on a clock (`src/git/progress.ts`). If | |
| 42 | fewer than 16 KiB reach the client in 30 seconds, the forge ends the | |
| 43 | transfer: git is killed and the response is cut, so a client that stopped | |
| 44 | reading frees both its transfer permit and what the forge had buffered for | |
| 45 | it. A clone that keeps moving runs for up to two hours. Git's own keep-alive, | |
| 46 | sent every 5 seconds while it is still preparing the pack, counts as moving. | |
| 47 | A push ends after ten minutes. Once its pack has arrived, the forge runs | |
| 48 | index-pack and the hooks with nothing to send, and that quiet is not a stall. | |
| 49 | ||
| 50 | Signed-in clones and anonymous ones end by the same rule. Anonymous ones | |
| 51 | still take a share of the transfer pool first (`src/http/strangers.ts`: four | |
| 52 | at once per address, half the pool for every stranger together), and a | |
| 53 | negotiation whose request body stops arriving for 30 seconds still ends. |
src/git/progress.ts added +226
| @@ -0,0 +1,226 @@ | ||
| 1 | /** | |
| 2 | * When a clone ends: on a lack of progress, not on a clock (#4482). | |
| 3 | * | |
| 4 | * A wall-clock kill is wrong both ways. It cuts off a large pack moving | |
| 5 | * steadily over a slow link, which is exactly the clone that most needs to | |
| 6 | * finish, and it lets a client that has stopped reading keep its transfer | |
| 7 | * permit until the clock runs out. So a fetch is judged by what it moved: | |
| 8 | * if fewer than `minBytes` went through in the last `windowMs`, it has | |
| 9 | * stalled and is ended. A generous absolute cap still bounds the worst case. | |
| 10 | * | |
| 11 | * One exception reads the stream, and only five bytes of it. While | |
| 12 | * pack-objects is still counting and compressing, upload-pack has no data | |
| 13 | * to send. Every `uploadpack.keepAlive` seconds it sends an empty side-band | |
| 14 | * packet (`0005\x01`) instead. That packet only reaches the meter once the | |
| 15 | * client has read what came before it, so it shows that git is working and | |
| 16 | * the client is reading. It counts as progress. | |
| 17 | */ | |
| 18 | ||
| 19 | /** What a fetch must move to count as moving. */ | |
| 20 | export interface Stall { | |
| 21 | /** The window progress is judged over. */ | |
| 22 | readonly windowMs: number; | |
| 23 | /** Fewer bytes than this through the window, and the transfer has stalled. */ | |
| 24 | readonly minBytes: number; | |
| 25 | } | |
| 26 | ||
| 27 | /** Fewer than 16 KiB in 30 s: stalled. */ | |
| 28 | export const STALL: Stall = { windowMs: 30_000, minBytes: 16 * 1024 }; | |
| 29 | ||
| 30 | /** However well it moves, a fetch ends after two hours. */ | |
| 31 | export const TRANSFER_CAP_MS = 2 * 60 * 60_000; | |
| 32 | ||
| 33 | /** The window is kept in this many slices, so a stall is caught at most a sixth of a window late. */ | |
| 34 | const SLICES = 6; | |
| 35 | ||
| 36 | /** Git's side-band keep-alive: an empty packet on band 1. */ | |
| 37 | const KEEPALIVE = [0x30, 0x30, 0x30, 0x35, 0x01]; | |
| 38 | ||
| 39 | export interface Progress { | |
| 40 | /** Bytes that moved, in either direction. */ | |
| 41 | moved(bytes: number): void; | |
| 42 | /** Stop watching: the transfer ended. Idempotent. */ | |
| 43 | stop(): void; | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * Watch a transfer and call `kill` once it stalls, or once `capMs` has | |
| 48 | * passed. The first judgement is made a whole window after the start, so a | |
| 49 | * transfer gets a full window to begin. | |
| 50 | */ | |
| 51 | export function watchProgress( | |
| 52 | stall: Stall, | |
| 53 | capMs: number, | |
| 54 | kill: () => void, | |
| 55 | ): Progress { | |
| 56 | const slices: number[] = []; | |
| 57 | let current = 0; | |
| 58 | const tick = setInterval( | |
| 59 | () => { | |
| 60 | slices.push(current); | |
| 61 | current = 0; | |
| 62 | if (slices.length > SLICES) slices.shift(); | |
| 63 | if (slices.length < SLICES) return; | |
| 64 | const moved = slices.reduce((sum, n) => sum + n, 0); | |
| 65 | if (moved < stall.minBytes) { | |
| 66 | stop(); | |
| 67 | kill(); | |
| 68 | } | |
| 69 | }, | |
| 70 | Math.max(1, Math.floor(stall.windowMs / SLICES)), | |
| 71 | ); | |
| 72 | const cap = setTimeout(() => { | |
| 73 | stop(); | |
| 74 | kill(); | |
| 75 | }, capMs); | |
| 76 | const stop = (): void => { | |
| 77 | clearInterval(tick); | |
| 78 | clearTimeout(cap); | |
| 79 | }; | |
| 80 | return { | |
| 81 | moved(bytes) { | |
| 82 | current += bytes; | |
| 83 | }, | |
| 84 | stop, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | /** A metered stream, and how to end it early. */ | |
| 89 | export interface Metered { | |
| 90 | readonly stream: ReadableStream<Uint8Array>; | |
| 91 | /** | |
| 92 | * End the stream now. What is still queued is dropped, the source is | |
| 93 | * cancelled, and the reader sees a clean end: a truncated pack, which git | |
| 94 | * reports as an early EOF. | |
| 95 | */ | |
| 96 | cut(): void; | |
| 97 | } | |
| 98 | ||
| 99 | /** The most handed to the reader at once, so the count follows the reader. */ | |
| 100 | const PIECE = 64 * 1024; | |
| 101 | ||
| 102 | /** | |
| 103 | * `stream`, counted as it is read. Bun reads a child's stdout as fast as | |
| 104 | * the child writes it, so one chunk can be most of a pack. Each chunk is | |
| 105 | * handed on in pieces, one per read, and a piece is counted when the | |
| 106 | * reader takes it. A keep-alive counts as a whole window's worth of bytes. | |
| 107 | */ | |
| 108 | export function metered( | |
| 109 | stream: ReadableStream<Uint8Array>, | |
| 110 | progress: Progress, | |
| 111 | stall: Stall, | |
| 112 | ): Metered { | |
| 113 | const reader = stream.getReader(); | |
| 114 | let chunk = new Uint8Array(0); | |
| 115 | let offset = 0; | |
| 116 | let ended = false; | |
| 117 | let controller: ReadableStreamDefaultController<Uint8Array> | undefined; | |
| 118 | const end = (): void => { | |
| 119 | if (ended) return; | |
| 120 | ended = true; | |
| 121 | progress.stop(); | |
| 122 | try { | |
| 123 | controller?.close(); | |
| 124 | } catch { | |
| 125 | // Already closed. | |
| 126 | } | |
| 127 | }; | |
| 128 | const out = new ReadableStream<Uint8Array>( | |
| 129 | { | |
| 130 | start(c) { | |
| 131 | controller = c; | |
| 132 | }, | |
| 133 | async pull(c) { | |
| 134 | if (offset >= chunk.length) { | |
| 135 | const next = await reader.read(); | |
| 136 | if (ended) return; | |
| 137 | if (next.done) return end(); | |
| 138 | // A spawn pipe's chunk may be a view over a buffer Bun reuses, | |
| 139 | // and this one is held until the reader takes it: keep a copy. | |
| 140 | chunk = next.value.slice(); | |
| 141 | offset = 0; | |
| 142 | if (isKeepAlive(chunk)) progress.moved(stall.minBytes); | |
| 143 | } | |
| 144 | const piece = chunk.subarray(offset, offset + PIECE); | |
| 145 | offset += piece.length; | |
| 146 | progress.moved(piece.length); | |
| 147 | c.enqueue(piece); | |
| 148 | }, | |
| 149 | cancel(reason) { | |
| 150 | end(); | |
| 151 | return reader.cancel(reason); | |
| 152 | }, | |
| 153 | }, | |
| 154 | { highWaterMark: 0 }, | |
| 155 | ); | |
| 156 | return { | |
| 157 | stream: out, | |
| 158 | cut: () => { | |
| 159 | end(); | |
| 160 | void reader.cancel().catch(() => {}); | |
| 161 | }, | |
| 162 | }; | |
| 163 | } | |
| 164 | ||
| 165 | /** How long a push may run before it is killed. */ | |
| 166 | export const PUSH_TIMEOUT_MS = 600_000; | |
| 167 | ||
| 168 | export interface Deadline { | |
| 169 | /** The stream to answer with: git's stdout, metered for a fetch. */ | |
| 170 | readonly stdout: ReadableStream<Uint8Array>; | |
| 171 | /** Bytes the client sent (counted only for a fetch). */ | |
| 172 | moved(bytes: number): void; | |
| 173 | /** | |
| 174 | * Git exited. A push's clock stops. A fetch's keeps running until its | |
| 175 | * stream is read to the end: Bun reads a child's stdout as fast as the | |
| 176 | * child writes it, so git can exit with most of the pack still buffered | |
| 177 | * here, and sending that buffer is still the transfer. | |
| 178 | */ | |
| 179 | exited(): void; | |
| 180 | /** The transfer is over, however it ended: stop every clock. Idempotent. */ | |
| 181 | stop(): void; | |
| 182 | } | |
| 183 | ||
| 184 | /** | |
| 185 | * The deadline a pack transfer runs under, shared by smart HTTP and SSH. | |
| 186 | * A fetch ends when it stalls, or after `timeoutMs` (default two hours) | |
| 187 | * whatever it is doing. Ending it kills git and cuts the stream, which also | |
| 188 | * frees the buffer of a client that stopped reading. A push ends after | |
| 189 | * `timeoutMs` (default ten minutes): once its pack is in, index-pack and | |
| 190 | * the hooks run with nothing to send, and that quiet is not a stall. | |
| 191 | */ | |
| 192 | export function transferDeadline( | |
| 193 | fetch: boolean, | |
| 194 | stdout: ReadableStream<Uint8Array>, | |
| 195 | options: { timeoutMs?: number; stall?: Stall }, | |
| 196 | kill: () => void, | |
| 197 | ): Deadline { | |
| 198 | if (fetch) { | |
| 199 | const stall = options.stall ?? STALL; | |
| 200 | let cut = (): void => {}; | |
| 201 | const progress = watchProgress( | |
| 202 | stall, | |
| 203 | options.timeoutMs ?? TRANSFER_CAP_MS, | |
| 204 | () => { | |
| 205 | kill(); | |
| 206 | cut(); | |
| 207 | }, | |
| 208 | ); | |
| 209 | const meter = metered(stdout, progress, stall); | |
| 210 | cut = meter.cut; | |
| 211 | return { | |
| 212 | stdout: meter.stream, | |
| 213 | moved: progress.moved, | |
| 214 | exited: () => {}, | |
| 215 | stop: progress.stop, | |
| 216 | }; | |
| 217 | } | |
| 218 | const timer = setTimeout(kill, options.timeoutMs ?? PUSH_TIMEOUT_MS); | |
| 219 | const stop = (): void => clearTimeout(timer); | |
| 220 | return { stdout, moved: () => {}, exited: stop, stop }; | |
| 221 | } | |
| 222 | ||
| 223 | function isKeepAlive(chunk: Uint8Array): boolean { | |
| 224 | if (chunk.length < KEEPALIVE.length) return false; | |
| 225 | return KEEPALIVE.every((byte, i) => chunk[i] === byte); | |
| 226 | } |
src/git/smart-http.ts +68 −17
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | readBounded, |
| 17 | 17 | } from "../http/limits.ts"; |
| 18 | 18 | import { gitEnv, transferGate } from "./env.ts"; |
| 19 | import { transferDeadline, type Stall } from "./progress.ts"; | |
| 19 | 20 | |
| 20 | 21 | export type GitService = "git-upload-pack" | "git-receive-pack"; |
| 21 | 22 | |
| @@ -30,11 +31,45 @@ function pktLine(payload: string): string { | ||
| 30 | 31 | |
| 31 | 32 | const FLUSH = "0000"; |
| 32 | 33 | |
| 34 | /** | |
| 35 | * Upload-pack serves partial clones (`git clone --filter=blob:none`) and | |
| 36 | * the blob fetches they make later, which want an object by id and not a | |
| 37 | * ref. Protocol v2 already allows any want; this lets v0 clients do the | |
| 38 | * same. Passed with `-c` so the blanked global and system config | |
| 39 | * (`gitEnv`) stay blank. A want is still only served after the repository | |
| 40 | * read check, like every other want. The keep-alive is git's default, | |
| 41 | * pinned here because `progress.ts` relies on it. | |
| 42 | */ | |
| 43 | const UPLOAD_PACK_CONFIG = [ | |
| 44 | "-c", | |
| 45 | "uploadpack.allowFilter=true", | |
| 46 | "-c", | |
| 47 | "uploadpack.allowAnySHA1InWant=true", | |
| 48 | "-c", | |
| 49 | "uploadpack.keepAlive=5", | |
| 50 | ] as const; | |
| 51 | ||
| 52 | /** The argv that runs `service`: `git [config] <subcommand> …args`. */ | |
| 53 | export function serviceCommand( | |
| 54 | service: GitService, | |
| 55 | args: readonly string[], | |
| 56 | ): string[] { | |
| 57 | // "git-upload-pack" the service is "upload-pack" the subcommand. | |
| 58 | const subcommand = service.slice(4); | |
| 59 | const config = service === "git-upload-pack" ? UPLOAD_PACK_CONFIG : []; | |
| 60 | return ["git", ...config, subcommand, ...args]; | |
| 61 | } | |
| 62 | ||
| 33 | 63 | export interface SmartHttpOptions { |
| 34 | 64 | /** Extra env for the spawned git process (pusher identity for hooks, etc.). */ |
| 35 | 65 | env?: Record<string, string>; |
| 36 | /** Kill a wedged transfer after this long. @default 600_000 */ | |
| 66 | /** | |
| 67 | * Kill the transfer after this long, however well it moves. @default | |
| 68 | * 7_200_000 for a fetch (`progress.ts`), 600_000 for a push | |
| 69 | */ | |
| 37 | 70 | timeoutMs?: number; |
| 71 | /** End a fetch that moves less than this (`progress.ts`). @default STALL */ | |
| 72 | stall?: Stall; | |
| 38 | 73 | /** |
| 39 | 74 | * End a fetch whose request body stops arriving for this long: a client |
| 40 | 75 | * that opens a negotiation and trickles it holds a transfer permit the |
| @@ -59,14 +94,14 @@ export async function advertiseRefs( | ||
| 59 | 94 | request: Request, |
| 60 | 95 | options: SmartHttpOptions = {}, |
| 61 | 96 | ): Promise<Response> { |
| 62 | const proc = await spawnService(repoDir, service, request, options, [ | |
| 97 | const { stdout } = await spawnService(repoDir, service, request, options, [ | |
| 63 | 98 | "--advertise-refs", |
| 64 | 99 | ]); |
| 65 | 100 | |
| 66 | 101 | const preamble = new TextEncoder().encode( |
| 67 | 102 | pktLine(`# service=${service}\n`) + FLUSH, |
| 68 | 103 | ); |
| 69 | const body = prepend(preamble, proc.stdout); | |
| 104 | const body = prepend(preamble, stdout); | |
| 70 | 105 | |
| 71 | 106 | return new Response(body, { |
| 72 | 107 | headers: { |
| @@ -87,12 +122,18 @@ export async function serviceRpc( | ||
| 87 | 122 | request: Request, |
| 88 | 123 | options: SmartHttpOptions = {}, |
| 89 | 124 | ): Promise<Response> { |
| 90 | const proc = await spawnService(repoDir, service, request, options, []); | |
| 125 | const { proc, stdout } = await spawnService( | |
| 126 | repoDir, | |
| 127 | service, | |
| 128 | request, | |
| 129 | options, | |
| 130 | [], | |
| 131 | ); | |
| 91 | 132 | feedStdin(proc, service, request, options.idleMs ?? FETCH_IDLE_MS).catch(() => |
| 92 | 133 | proc.kill("SIGKILL"), |
| 93 | 134 | ); |
| 94 | 135 | |
| 95 | return new Response(proc.stdout, { | |
| 136 | return new Response(stdout, { | |
| 96 | 137 | headers: { |
| 97 | 138 | "content-type": `application/x-${service}-result`, |
| 98 | 139 | "cache-control": "no-cache", |
| @@ -113,6 +154,9 @@ export async function serviceRpc( | ||
| 113 | 154 | * holds its permit across the pre-receive hook, and that hook calls back |
| 114 | 155 | * into the forge for a branch-protection check that needs plumbing permits |
| 115 | 156 | * of its own. One shared pool would deadlock the first time pushes filled it. |
| 157 | * | |
| 158 | * A fetch ends when it stops moving, a push on a clock (`progress.ts`); | |
| 159 | * `stdout` is the stream to answer with, metered for a fetch. | |
| 116 | 160 | */ |
| 117 | 161 | async function spawnService( |
| 118 | 162 | repoDir: string, |
| @@ -126,9 +170,6 @@ async function spawnService( | ||
| 126 | 170 | permit(); |
| 127 | 171 | options.release?.(); |
| 128 | 172 | }; |
| 129 | // "git-upload-pack" the service is "upload-pack" the subcommand. | |
| 130 | const subcommand = service.slice(4); | |
| 131 | ||
| 132 | 173 | const env: Record<string, string> = { ...options.env }; |
| 133 | 174 | // Forward the client's protocol request, but only within git's own |
| 134 | 175 | // grammar (colon-separated key[=value] tokens) and bounded — never an |
| @@ -140,7 +181,7 @@ async function spawnService( | ||
| 140 | 181 | } |
| 141 | 182 | |
| 142 | 183 | const spawn = () => |
| 143 | Bun.spawn(["git", subcommand, "--stateless-rpc", ...extraArgs, repoDir], { | |
| 184 | Bun.spawn(serviceCommand(service, ["--stateless-rpc", ...extraArgs, repoDir]), { | |
| 144 | 185 | env: gitEnv(env), |
| 145 | 186 | stdin: "pipe", |
| 146 | 187 | stdout: "pipe", |
| @@ -157,25 +198,35 @@ async function spawnService( | ||
| 157 | 198 | throw error; |
| 158 | 199 | } |
| 159 | 200 | |
| 160 | const timer = setTimeout( | |
| 161 | () => proc.kill("SIGKILL"), | |
| 162 | options.timeoutMs ?? 600_000, | |
| 201 | const kill = (): void => { | |
| 202 | proc.kill("SIGKILL"); | |
| 203 | }; | |
| 204 | const deadline = transferDeadline( | |
| 205 | service === "git-upload-pack", | |
| 206 | proc.stdout, | |
| 207 | options, | |
| 208 | kill, | |
| 163 | 209 | ); |
| 164 | 210 | // A transfer under way is the account's, and ends with its standing |
| 165 | 211 | // (\`http/caller.ts\`): a clone does not run on after a suspension. |
| 166 | const unhold = holdForCaller(request, () => proc.kill("SIGKILL")); | |
| 212 | const unhold = holdForCaller(request, kill); | |
| 167 | 213 | void proc.exited.finally(() => { |
| 168 | clearTimeout(timer); | |
| 214 | deadline.exited(); | |
| 169 | 215 | unhold(); |
| 170 | 216 | release(); |
| 171 | 217 | }); |
| 172 | request.signal.addEventListener("abort", () => proc.kill("SIGKILL")); | |
| 218 | // A client that hung up has nothing left to deliver or to watch. | |
| 219 | const abort = (): void => { | |
| 220 | kill(); | |
| 221 | deadline.stop(); | |
| 222 | }; | |
| 223 | request.signal.addEventListener("abort", abort); | |
| 173 | 224 | // The client may have hung up while this call was queued behind other |
| 174 | 225 | // transfers, in which case the listener above will never fire — nothing |
| 175 | 226 | // is left to abort. Check once, now that there is a process to kill. |
| 176 | if (request.signal.aborted) proc.kill("SIGKILL"); | |
| 227 | if (request.signal.aborted) abort(); | |
| 177 | 228 | |
| 178 | return proc; | |
| 229 | return { proc, stdout: deadline.stdout }; | |
| 179 | 230 | } |
| 180 | 231 | |
| 181 | 232 | /** |
src/git/smart-ssh.ts +26 −13
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | |
| 12 | 12 | import { isSafeName } from "./guard.ts"; |
| 13 | 13 | import { gitEnv } from "./env.ts"; |
| 14 | import type { GitService } from "./smart-http.ts"; | |
| 14 | import { transferDeadline, type Stall } from "./progress.ts"; | |
| 15 | import { serviceCommand, type GitService } from "./smart-http.ts"; | |
| 15 | 16 | |
| 16 | 17 | export type { GitService }; |
| 17 | 18 | |
| @@ -114,8 +115,13 @@ export interface GitChannel { | ||
| 114 | 115 | export interface GitServiceOptions { |
| 115 | 116 | /** Extra env for the spawned git process (hook identity, GIT_PROTOCOL). */ |
| 116 | 117 | env?: Record<string, string>; |
| 117 | /** Kill a wedged transfer after this long. @default 600_000 */ | |
| 118 | /** | |
| 119 | * Kill the transfer after this long, however well it moves. @default | |
| 120 | * 7_200_000 for a fetch (`progress.ts`), 600_000 for a push | |
| 121 | */ | |
| 118 | 122 | timeoutMs?: number; |
| 123 | /** End a fetch that moves less than this (`progress.ts`). @default STALL */ | |
| 124 | stall?: Stall; | |
| 119 | 125 | } |
| 120 | 126 | |
| 121 | 127 | /** |
| @@ -123,6 +129,8 @@ export interface GitServiceOptions { | ||
| 123 | 129 | * Output is written under backpressure: git's stdout can produce a pack far |
| 124 | 130 | * faster than the peer's window drains it, and awaiting `drain()` between |
| 125 | 131 | * chunks is what keeps a clone from buffering the whole pack in memory. |
| 132 | * A fetch ends when it stops moving, in either direction (`progress.ts`); | |
| 133 | * a push keeps a wall clock, as over HTTP. | |
| 126 | 134 | */ |
| 127 | 135 | export function runGitService( |
| 128 | 136 | dir: string, |
| @@ -130,20 +138,25 @@ export function runGitService( | ||
| 130 | 138 | channel: GitChannel, |
| 131 | 139 | options: GitServiceOptions = {}, |
| 132 | 140 | ): Promise<number> { |
| 133 | // "git-upload-pack" the service is "upload-pack" the subcommand. | |
| 134 | const proc = Bun.spawn(["git", service.slice(4), dir], { | |
| 141 | const proc = Bun.spawn(serviceCommand(service, [dir]), { | |
| 135 | 142 | env: gitEnv(options.env), |
| 136 | 143 | stdin: "pipe", |
| 137 | 144 | stdout: "pipe", |
| 138 | 145 | stderr: "pipe", |
| 139 | 146 | }); |
| 140 | 147 | |
| 141 | const timer = setTimeout( | |
| 142 | () => proc.kill("SIGKILL"), | |
| 143 | options.timeoutMs ?? 600_000, | |
| 148 | const kill = (): void => { | |
| 149 | proc.kill("SIGKILL"); | |
| 150 | }; | |
| 151 | const { stdout, moved, stop } = transferDeadline( | |
| 152 | service === "git-upload-pack", | |
| 153 | proc.stdout, | |
| 154 | options, | |
| 155 | kill, | |
| 144 | 156 | ); |
| 145 | 157 | |
| 146 | 158 | channel.onData((chunk) => { |
| 159 | moved(chunk.length); | |
| 147 | 160 | try { |
| 148 | 161 | proc.stdin.write(chunk); |
| 149 | 162 | void proc.stdin.flush(); |
| @@ -164,11 +177,11 @@ export function runGitService( | ||
| 164 | 177 | }); |
| 165 | 178 | // A *close* is different: the peer is gone, so every byte git still has to |
| 166 | 179 | // give has nowhere to go. Without this the child sits in `upload-pack` |
| 167 | // writing into a dead channel until the ten-minute timeout reaps it — one | |
| 168 | // idle git per disconnected clone. | |
| 180 | // writing into a dead channel until its deadline reaps it — one idle git | |
| 181 | // per disconnected clone. | |
| 169 | 182 | channel.onClose(() => { |
| 170 | clearTimeout(timer); | |
| 171 | proc.kill("SIGKILL"); | |
| 183 | stop(); | |
| 184 | kill(); | |
| 172 | 185 | }); |
| 173 | 186 | |
| 174 | 187 | const pump = async ( |
| @@ -186,11 +199,11 @@ export function runGitService( | ||
| 186 | 199 | |
| 187 | 200 | return (async () => { |
| 188 | 201 | const [, , code] = await Promise.all([ |
| 189 | pump(proc.stdout, (data) => channel.write(data)), | |
| 202 | pump(stdout, (data) => channel.write(data)), | |
| 190 | 203 | pump(proc.stderr, (data) => channel.writeStderr(data)), |
| 191 | 204 | proc.exited, |
| 192 | 205 | ]); |
| 193 | clearTimeout(timer); | |
| 206 | stop(); | |
| 194 | 207 | channel.exit(code); |
| 195 | 208 | return code; |
| 196 | 209 | })(); |
src/http/server.ts +2 −2
| @@ -1461,8 +1461,8 @@ export function knownPatterns(): string[] { | ||
| 1461 | 1461 | /** |
| 1462 | 1462 | * A transfer someone signed in makes runs as it always has; a stranger's |
| 1463 | 1463 | * takes a share of the transfer pool first (`strangers.ts`) — refused with |
| 1464 | * a 429 when its address or every stranger together has none left — and | |
| 1465 | * runs on a shorter clock. Called once access is decided, so a private | |
| 1464 | * a 429 when its address or every stranger together has none left. Either | |
| 1465 | * one ends once it stops moving (`git/progress.ts`). Called once access is decided, so a private | |
| 1466 | 1466 | * repository still answers a stranger with the credential challenge. |
| 1467 | 1467 | */ |
| 1468 | 1468 | async function asStranger( |
src/http/strangers.ts +5 −2
| @@ -37,7 +37,10 @@ export interface StrangerLimits { | ||
| 37 | 37 | * kept for pushes and signed-in clones. At least one permit. |
| 38 | 38 | */ |
| 39 | 39 | readonly transferShare: number; |
| 40 | /** A stranger's transfer is killed after this long (a signed-in one: 600 s). */ | |
| 40 | /** | |
| 41 | * A stranger's fetch is killed after this long, however well it moves. | |
| 42 | * It is killed sooner once it stalls (`git/progress.ts`), as anyone's is. | |
| 43 | */ | |
| 41 | 44 | readonly transferTimeoutMs: number; |
| 42 | 45 | /** A fetch's request body that stops arriving for this long ends the fetch. */ |
| 43 | 46 | readonly transferIdleMs: number; |
| @@ -60,7 +63,7 @@ export const STRANGER_LIMITS: StrangerLimits = { | ||
| 60 | 63 | forge: { api: 6000, git: 600, code: 60, plan: 60 }, |
| 61 | 64 | transfersPerAddress: 4, |
| 62 | 65 | transferShare: 0.5, |
| 63 | transferTimeoutMs: 300_000, | |
| 66 | transferTimeoutMs: 7_200_000, | |
| 64 | 67 | transferIdleMs: 30_000, |
| 65 | 68 | codeAtOnce: 1, |
| 66 | 69 | streamsPerAddress: 8, |
test/big-clone.test.ts added +399
| @@ -0,0 +1,399 @@ | ||
| 1 | /** | |
| 2 | * Big repositories always clone (#4482). A fetch ends once it stops moving, | |
| 3 | * not on a wall clock, so a large pack over a slow link finishes while a | |
| 4 | * client that stops reading is still cut off. Upload-pack serves partial | |
| 5 | * clones, so `git clone --filter=blob:none` skips the big blobs and a later | |
| 6 | * checkout fetches them. A fetch by object id passes the same read check as | |
| 7 | * a clone. | |
| 8 | * | |
| 9 | * Time is scaled down. The real window is 30 s and a CAD repository is | |
| 10 | * hundreds of megabytes; here the window is one second and the pack is 4 MiB | |
| 11 | * through a 1 MiB/s link, so the clone lasts several windows, longer than | |
| 12 | * the wall clock it is also run against. | |
| 13 | */ | |
| 14 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 15 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 16 | import { tmpdir } from "node:os"; | |
| 17 | import { join } from "node:path"; | |
| 18 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 19 | import { createApp, type App } from "../src/app.ts"; | |
| 20 | import { createToken } from "../src/core/tokens.ts"; | |
| 21 | import { runGit } from "../src/git/env.ts"; | |
| 22 | import { metered, watchProgress, type Stall } from "../src/git/progress.ts"; | |
| 23 | import { initBareRepo, repoDir } from "../src/git/repos.ts"; | |
| 24 | import { | |
| 25 | advertiseRefs, | |
| 26 | isGitService, | |
| 27 | serviceRpc, | |
| 28 | type SmartHttpOptions, | |
| 29 | } from "../src/git/smart-http.ts"; | |
| 30 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 31 | import { unwrap } from "../src/kernel/result.ts"; | |
| 32 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 33 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 34 | ||
| 35 | interface Run { | |
| 36 | code: number; | |
| 37 | out: string; | |
| 38 | err: string; | |
| 39 | } | |
| 40 | ||
| 41 | async function git(args: string[], cwd: string): Promise<Run> { | |
| 42 | const proc = Bun.spawn(["git", ...args], { | |
| 43 | cwd, | |
| 44 | env: gitClientEnv(), | |
| 45 | stdout: "pipe", | |
| 46 | stderr: "pipe", | |
| 47 | }); | |
| 48 | const [out, err, code] = await Promise.all([ | |
| 49 | new Response(proc.stdout).text(), | |
| 50 | new Response(proc.stderr).text(), | |
| 51 | proc.exited, | |
| 52 | ]); | |
| 53 | return { code, out: out.trim(), err: err.trim() }; | |
| 54 | } | |
| 55 | ||
| 56 | async function ok(args: string[], cwd: string): Promise<string> { | |
| 57 | const run = await git(args, cwd); | |
| 58 | if (run.code !== 0) throw new Error(`git ${args.join(" ")}: ${run.err}`); | |
| 59 | return run.out; | |
| 60 | } | |
| 61 | ||
| 62 | /** Object ids a partial clone does not have, without fetching them. */ | |
| 63 | async function missing(dir: string): Promise<string[]> { | |
| 64 | const out = await ok(["rev-list", "--objects", "--missing=print", "--all"], dir); | |
| 65 | return out | |
| 66 | .split("\n") | |
| 67 | .filter((line) => line.startsWith("?")) | |
| 68 | .map((line) => line.slice(1)); | |
| 69 | } | |
| 70 | ||
| 71 | function randomBytes(size: number): Uint8Array { | |
| 72 | const out = new Uint8Array(size); | |
| 73 | for (let at = 0; at < size; at += 65_536) | |
| 74 | crypto.getRandomValues(out.subarray(at, Math.min(size, at + 65_536))); | |
| 75 | return out; | |
| 76 | } | |
| 77 | ||
| 78 | describe("watchProgress", () => { | |
| 79 | const stall: Stall = { windowMs: 300, minBytes: 1_000 }; | |
| 80 | ||
| 81 | test("a transfer that keeps moving is never killed", async () => { | |
| 82 | let killed = false; | |
| 83 | const progress = watchProgress(stall, 60_000, () => (killed = true)); | |
| 84 | const feed = setInterval(() => progress.moved(400), 50); | |
| 85 | await Bun.sleep(1_200); | |
| 86 | clearInterval(feed); | |
| 87 | progress.stop(); | |
| 88 | expect(killed).toBe(false); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a transfer that stalls is killed within a window and a slice", async () => { | |
| 92 | let killedAt = 0; | |
| 93 | const start = Date.now(); | |
| 94 | const progress = watchProgress(stall, 60_000, () => (killedAt = Date.now())); | |
| 95 | // A trickle under the floor is a stall too. | |
| 96 | const feed = setInterval(() => progress.moved(10), 50); | |
| 97 | await Bun.sleep(1_000); | |
| 98 | clearInterval(feed); | |
| 99 | progress.stop(); | |
| 100 | expect(killedAt).toBeGreaterThan(0); | |
| 101 | expect(killedAt - start).toBeGreaterThanOrEqual(stall.windowMs - 20); | |
| 102 | expect(killedAt - start).toBeLessThan(stall.windowMs * 2 + 200); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the cap ends a transfer however well it moves", async () => { | |
| 106 | let killed = false; | |
| 107 | const progress = watchProgress(stall, 400, () => (killed = true)); | |
| 108 | const feed = setInterval(() => progress.moved(10_000), 50); | |
| 109 | await Bun.sleep(700); | |
| 110 | clearInterval(feed); | |
| 111 | progress.stop(); | |
| 112 | expect(killed).toBe(true); | |
| 113 | }); | |
| 114 | ||
| 115 | test("git's keep-alive counts as progress; other bytes count as themselves", async () => { | |
| 116 | let moved = 0; | |
| 117 | const progress = { moved: (n: number) => (moved += n), stop: () => {} }; | |
| 118 | const source = new ReadableStream<Uint8Array>({ | |
| 119 | start(controller) { | |
| 120 | controller.enqueue(new Uint8Array([0x30, 0x30, 0x30, 0x35, 0x01])); | |
| 121 | controller.enqueue(new TextEncoder().encode("0006\x01a")); | |
| 122 | controller.close(); | |
| 123 | }, | |
| 124 | }); | |
| 125 | const out = await new Response(metered(source, progress, stall).stream).arrayBuffer(); | |
| 126 | expect(out.byteLength).toBe(11); | |
| 127 | expect(moved).toBe(stall.minBytes + 11); | |
| 128 | }); | |
| 129 | }); | |
| 130 | ||
| 131 | /** Delays each chunk as a link of `bytesPerSecond` would. */ | |
| 132 | function throttled( | |
| 133 | body: ReadableStream<Uint8Array>, | |
| 134 | bytesPerSecond: number, | |
| 135 | ): ReadableStream<Uint8Array> { | |
| 136 | const reader = body.getReader(); | |
| 137 | return new ReadableStream<Uint8Array>( | |
| 138 | { | |
| 139 | async pull(controller) { | |
| 140 | const { done, value } = await reader.read(); | |
| 141 | if (done) return controller.close(); | |
| 142 | await Bun.sleep((value.length * 1_000) / bytesPerSecond); | |
| 143 | controller.enqueue(value); | |
| 144 | }, | |
| 145 | cancel: (reason) => reader.cancel(reason), | |
| 146 | }, | |
| 147 | { highWaterMark: 0 }, | |
| 148 | ); | |
| 149 | } | |
| 150 | ||
| 151 | /** | |
| 152 | * Passes `after` bytes on, then stops reading for `pauseMs`, like a client | |
| 153 | * that went away, and reads on after it. A transfer still alive then | |
| 154 | * finishes; one that was ended hands back a cut-off pack. | |
| 155 | */ | |
| 156 | function pausing( | |
| 157 | body: ReadableStream<Uint8Array>, | |
| 158 | after: number, | |
| 159 | pauseMs: number, | |
| 160 | ): ReadableStream<Uint8Array> { | |
| 161 | const reader = body.getReader(); | |
| 162 | let passed = 0; | |
| 163 | let paused = false; | |
| 164 | return new ReadableStream<Uint8Array>( | |
| 165 | { | |
| 166 | async pull(controller) { | |
| 167 | if (passed >= after && !paused) { | |
| 168 | paused = true; | |
| 169 | await Bun.sleep(pauseMs); | |
| 170 | } | |
| 171 | const { done, value } = await reader.read(); | |
| 172 | if (done) return controller.close(); | |
| 173 | passed += value.length; | |
| 174 | controller.enqueue(value); | |
| 175 | }, | |
| 176 | cancel: (reason) => reader.cancel(reason), | |
| 177 | }, | |
| 178 | { highWaterMark: 0 }, | |
| 179 | ); | |
| 180 | } | |
| 181 | ||
| 182 | describe("a big repository over smart http", () => { | |
| 183 | const stall: Stall = { windowMs: 1_000, minBytes: 16 * 1024 }; | |
| 184 | const BIG = 4 * 1024 * 1024; | |
| 185 | let root: string; | |
| 186 | let work: string; | |
| 187 | let server: ReturnType<typeof Bun.serve>; | |
| 188 | let base: string; | |
| 189 | let big: Uint8Array; | |
| 190 | /** How this test's fetch is answered: its options and its link. */ | |
| 191 | let fetchOptions: SmartHttpOptions = {}; | |
| 192 | let link = (body: ReadableStream<Uint8Array>) => body; | |
| 193 | ||
| 194 | beforeAll(async () => { | |
| 195 | root = await mkdtemp(join(tmpdir(), "forge-bigclone-")); | |
| 196 | work = await mkdtemp(join(tmpdir(), "forge-bigclone-work-")); | |
| 197 | const bare = repoDir(root, "ada", "arm"); | |
| 198 | unwrap(await initBareRepo(bare)); | |
| 199 | const seed = join(work, "seed"); | |
| 200 | await ok(["init", "-q", "-b", "main", seed], work); | |
| 201 | big = randomBytes(BIG); | |
| 202 | await Bun.write(join(seed, "base.step"), big); | |
| 203 | await Bun.write(join(seed, "README.md"), "# arm\n"); | |
| 204 | await ok(["add", "."], seed); | |
| 205 | await ok(["commit", "-q", "-m", "cad"], seed); | |
| 206 | await ok(["push", "-q", bare, "main"], seed); | |
| 207 | ||
| 208 | server = Bun.serve({ | |
| 209 | port: 0, | |
| 210 | idleTimeout: 0, | |
| 211 | async fetch(request) { | |
| 212 | const url = new URL(request.url); | |
| 213 | const dir = repoDir(root, "ada", "arm"); | |
| 214 | if (url.pathname === "/ada/arm.git/info/refs") { | |
| 215 | const service = url.searchParams.get("service"); | |
| 216 | if (!isGitService(service)) return new Response("no", { status: 400 }); | |
| 217 | return advertiseRefs(dir, service, request); | |
| 218 | } | |
| 219 | if (url.pathname === "/ada/arm.git/git-upload-pack") { | |
| 220 | const response = await serviceRpc(dir, "git-upload-pack", request, fetchOptions); | |
| 221 | return new Response(link(response.body!), { headers: response.headers }); | |
| 222 | } | |
| 223 | return new Response("not found", { status: 404 }); | |
| 224 | }, | |
| 225 | }); | |
| 226 | base = `http://127.0.0.1:${server.port}/ada/arm.git`; | |
| 227 | }); | |
| 228 | ||
| 229 | afterAll(async () => { | |
| 230 | server.stop(true); | |
| 231 | await rm(root, { recursive: true, force: true }); | |
| 232 | await rm(work, { recursive: true, force: true }); | |
| 233 | }); | |
| 234 | ||
| 235 | test("a clone through a 1 MiB/s link outlasts several windows and finishes", async () => { | |
| 236 | fetchOptions = { stall, timeoutMs: 60_000 }; | |
| 237 | link = (body) => throttled(body, 1024 * 1024); | |
| 238 | try { | |
| 239 | const start = Date.now(); | |
| 240 | const run = await git(["clone", "-q", base, "slow"], work); | |
| 241 | expect(run.err).toBe(""); | |
| 242 | expect(run.code).toBe(0); | |
| 243 | expect(Date.now() - start).toBeGreaterThan(stall.windowMs * 3); | |
| 244 | const got = new Uint8Array(await Bun.file(join(work, "slow", "base.step")).arrayBuffer()); | |
| 245 | expect(got.length).toBe(BIG); | |
| 246 | expect(Bun.hash(got)).toBe(Bun.hash(big)); | |
| 247 | } finally { | |
| 248 | fetchOptions = {}; | |
| 249 | link = (body) => body; | |
| 250 | } | |
| 251 | }, 60_000); | |
| 252 | ||
| 253 | test("the same clone under a wall clock is cut off: the clock was the bug", async () => { | |
| 254 | fetchOptions = { stall, timeoutMs: 2_000 }; | |
| 255 | link = (body) => throttled(body, 1024 * 1024); | |
| 256 | try { | |
| 257 | const run = await git(["clone", "-q", base, "clocked"], work); | |
| 258 | expect(run.code).not.toBe(0); | |
| 259 | } finally { | |
| 260 | fetchOptions = {}; | |
| 261 | link = (body) => body; | |
| 262 | } | |
| 263 | }, 60_000); | |
| 264 | ||
| 265 | test("a client that stops reading for longer than a window is cut off", async () => { | |
| 266 | fetchOptions = { stall, timeoutMs: 60_000 }; | |
| 267 | link = (body) => pausing(body, 64 * 1024, stall.windowMs * 3); | |
| 268 | try { | |
| 269 | const run = await git(["clone", "-q", base, "stalled"], work); | |
| 270 | expect(run.code).not.toBe(0); | |
| 271 | expect(run.err).toContain("early EOF"); | |
| 272 | } finally { | |
| 273 | fetchOptions = {}; | |
| 274 | link = (body) => body; | |
| 275 | } | |
| 276 | }, 60_000); | |
| 277 | ||
| 278 | test("a pause shorter than a window is not a stall", async () => { | |
| 279 | fetchOptions = { stall, timeoutMs: 60_000 }; | |
| 280 | link = (body) => pausing(body, 64 * 1024, stall.windowMs / 3); | |
| 281 | try { | |
| 282 | const run = await git(["clone", "-q", base, "paused"], work); | |
| 283 | expect(run.code).toBe(0); | |
| 284 | } finally { | |
| 285 | fetchOptions = {}; | |
| 286 | link = (body) => body; | |
| 287 | } | |
| 288 | }, 60_000); | |
| 289 | ||
| 290 | for (const version of [0, 2]) { | |
| 291 | test(`--filter=blob:limit=1m over protocol v${version} leaves the big blob behind, and checkout fetches it`, async () => { | |
| 292 | const dir = `limit-v${version}`; | |
| 293 | const run = await git( | |
| 294 | ["-c", `protocol.version=${version}`, "clone", "-q", "--no-checkout", "--filter=blob:limit=1m", base, dir], | |
| 295 | work, | |
| 296 | ); | |
| 297 | expect(run.err).not.toContain("filtering not recognized"); | |
| 298 | expect(run.code).toBe(0); | |
| 299 | const clone = join(work, dir); | |
| 300 | const bigOid = await ok(["rev-parse", "HEAD:base.step"], clone); | |
| 301 | const readmeOid = await ok(["rev-parse", "HEAD:README.md"], clone); | |
| 302 | const absent = await missing(clone); | |
| 303 | expect(absent).toContain(bigOid); | |
| 304 | expect(absent).not.toContain(readmeOid); | |
| 305 | ||
| 306 | await ok(["-c", `protocol.version=${version}`, "reset", "-q", "--hard", "HEAD"], clone); | |
| 307 | expect(await missing(clone)).toEqual([]); | |
| 308 | const got = new Uint8Array(await Bun.file(join(clone, "base.step")).arrayBuffer()); | |
| 309 | expect(Bun.hash(got)).toBe(Bun.hash(big)); | |
| 310 | }); | |
| 311 | ||
| 312 | test(`--filter=blob:none over protocol v${version} clones and checks out`, async () => { | |
| 313 | const dir = `none-v${version}`; | |
| 314 | const run = await git( | |
| 315 | ["-c", `protocol.version=${version}`, "clone", "-q", "--filter=blob:none", base, dir], | |
| 316 | work, | |
| 317 | ); | |
| 318 | expect(run.err).not.toContain("filtering not recognized"); | |
| 319 | expect(run.code).toBe(0); | |
| 320 | expect(await Bun.file(join(work, dir, "README.md")).text()).toBe("# arm\n"); | |
| 321 | expect((await Bun.file(join(work, dir, "base.step")).arrayBuffer()).byteLength).toBe(BIG); | |
| 322 | }); | |
| 323 | } | |
| 324 | }); | |
| 325 | ||
| 326 | describe("a fetch by object id is a read like any other", () => { | |
| 327 | let dataDir: string; | |
| 328 | let work: string; | |
| 329 | let app: App; | |
| 330 | let servers: Servers; | |
| 331 | let token: string; | |
| 332 | let secretOid: string; | |
| 333 | ||
| 334 | beforeAll(async () => { | |
| 335 | dataDir = await mkdtemp(join(tmpdir(), "forge-bigclone-app-")); | |
| 336 | work = await mkdtemp(join(tmpdir(), "forge-bigclone-app-work-")); | |
| 337 | app = createApp({ dataDir, port: 0 }); | |
| 338 | servers = await serve(app); | |
| 339 | const owner = await seedAdmin(app, { | |
| 340 | username: "ada", | |
| 341 | email: "[email protected]", | |
| 342 | password: "owner-passphrase", | |
| 343 | }); | |
| 344 | if (!owner.ok) throw new Error(owner.error); | |
| 345 | token = createToken(app.db, owner.value.id, "t", "ro").token; | |
| 346 | const vault = await createRepo(app, owner.value, { name: "vault" }); | |
| 347 | const open = await createRepo(app, owner.value, { name: "open", visibility: "public" }); | |
| 348 | if (!vault.ok || !open.ok) throw new Error("repo creation failed"); | |
| 349 | await commitToBranch(app, owner.value, vault.value, { | |
| 350 | message: "seed", | |
| 351 | files: [{ path: "secret", content: "the vault's secret\n" }], | |
| 352 | }); | |
| 353 | await commitToBranch(app, owner.value, open.value, { | |
| 354 | message: "seed", | |
| 355 | files: [{ path: "README.md", content: "# open\n" }], | |
| 356 | }); | |
| 357 | secretOid = unwrap( | |
| 358 | await runGit(["rev-parse", "main:secret"], { cwd: repoDir(app.reposRoot, "ada", "vault") }), | |
| 359 | ); | |
| 360 | }); | |
| 361 | ||
| 362 | afterAll(async () => { | |
| 363 | servers.stop(); | |
| 364 | app.close(); | |
| 365 | await rm(dataDir, { recursive: true, force: true }); | |
| 366 | await rm(work, { recursive: true, force: true }); | |
| 367 | }); | |
| 368 | ||
| 369 | const url = (name: string, auth = ""): string => | |
| 370 | `${servers.url.replace("://", `://${auth}`)}/ada/${name}.git`; | |
| 371 | ||
| 372 | test("a stranger cannot fetch a private repository's blob by its id", async () => { | |
| 373 | const dir = join(work, "stranger"); | |
| 374 | await ok(["init", "-q", dir], work); | |
| 375 | for (const version of [0, 2]) { | |
| 376 | const run = await git(["-c", `protocol.version=${version}`, "fetch", url("vault"), secretOid], dir); | |
| 377 | expect(run.code).not.toBe(0); | |
| 378 | } | |
| 379 | const lookup = await git(["cat-file", "-e", secretOid], dir); | |
| 380 | expect(lookup.code).not.toBe(0); | |
| 381 | }); | |
| 382 | ||
| 383 | test("a partial clone of a public repository cannot reach into a private one", async () => { | |
| 384 | const run = await git(["clone", "-q", "--filter=blob:none", url("open"), "open"], work); | |
| 385 | expect(run.code).toBe(0); | |
| 386 | const lazy = await git(["cat-file", "-p", secretOid], join(work, "open")); | |
| 387 | expect(lazy.code).not.toBe(0); | |
| 388 | expect(lazy.out).not.toContain("secret"); | |
| 389 | }); | |
| 390 | ||
| 391 | test("a reader's partial clone of the private repository fetches the blob lazily", async () => { | |
| 392 | const dir = join(work, "reader"); | |
| 393 | const auth = `ada:${token}@`; | |
| 394 | const run = await git(["clone", "-q", "--no-checkout", "--filter=blob:none", url("vault", auth), dir], work); | |
| 395 | expect(run.code).toBe(0); | |
| 396 | expect(await missing(dir)).toContain(secretOid); | |
| 397 | expect(await ok(["cat-file", "-p", secretOid], dir)).toBe("the vault's secret"); | |
| 398 | }); | |
| 399 | }); |
test/git-ssh.test.ts +37
| @@ -181,6 +181,43 @@ describe.skipIf(NO_OPENSSH)("git over ssh", () => { | ||
| 181 | 181 | expect(run.err).not.toContain("permission"); |
| 182 | 182 | }); |
| 183 | 183 | |
| 184 | test("a guest's partial clone leaves the blobs behind, and checkout fetches them (#4482)", async () => { | |
| 185 | for (const version of [0, 2]) { | |
| 186 | const dir = `guest-partial-v${version}`; | |
| 187 | const run = await git( | |
| 188 | ["-c", `protocol.version=${version}`, "clone", "--no-checkout", "--filter=blob:none", url("alice", "beacon"), dir], | |
| 189 | { cwd: work, key: strangerKey }, | |
| 190 | ); | |
| 191 | expect(run.err).not.toContain("filtering not recognized"); | |
| 192 | expect(run.code).toBe(0); | |
| 193 | const clone = join(work, dir); | |
| 194 | const readme = await git(["rev-parse", "HEAD:README.md"], { cwd: clone, key: strangerKey }); | |
| 195 | const missing = await git(["rev-list", "--objects", "--missing=print", "--all"], { | |
| 196 | cwd: clone, | |
| 197 | key: strangerKey, | |
| 198 | }); | |
| 199 | expect(missing.out.split("\n")).toContain(`?${readme.out}`); | |
| 200 | const reset = await git(["-c", `protocol.version=${version}`, "reset", "-q", "--hard", "HEAD"], { | |
| 201 | cwd: clone, | |
| 202 | key: strangerKey, | |
| 203 | }); | |
| 204 | expect(reset.code).toBe(0); | |
| 205 | expect(await readFile(join(clone, "README.md"), "utf8")).toContain("Beacon"); | |
| 206 | } | |
| 207 | }); | |
| 208 | ||
| 209 | test("a guest cannot fetch a private repo's blob by its id (#4482)", async () => { | |
| 210 | const vault = repoDir(forge.reposRoot, "alice", "vault"); | |
| 211 | const oid = await runGit(["rev-parse", "main:secret"], { cwd: vault }); | |
| 212 | if (!oid.ok) throw new Error("no secret blob"); | |
| 213 | const dir = join(work, "guest-oid"); | |
| 214 | expect((await git(["init", "-q", dir], { cwd: work, key: strangerKey })).code).toBe(0); | |
| 215 | const run = await git(["fetch", url("alice", "vault"), oid.value], { cwd: dir, key: strangerKey }); | |
| 216 | expect(run.code).not.toBe(0); | |
| 217 | expect(run.err).toContain("repository not found"); | |
| 218 | expect((await git(["cat-file", "-e", oid.value], { cwd: dir, key: strangerKey })).code).not.toBe(0); | |
| 219 | }); | |
| 220 | ||
| 184 | 221 | test("a writer pushes, and the push lands in the outbox", async () => { |
| 185 | 222 | const clone = await git(["clone", url("alice", "beacon"), "alice-beacon"], { |
| 186 | 223 | cwd: work, |
new above
offline
Low, not blocking. With any-SHA1 wants, a fork that borrows through alternates serves every object in its parent's store by id.
detachForkonly runs on delete. So if a public fork's parent later goes private, objects pushed to the parent afterwards can be fetched through the public fork by anyone who knows their oid. Protocol v2 already allowed this before this pull, so the change doesn't widen it. It is worth a follow-up: detach a fork, or refuse it, when the visibility gap opens.