import { recordArtifactsCall } from "./artifacts-health"; import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { parsePack } from "./pack"; import { concatBytes, pktLine } from "./pktline"; import { limitStream, PackTooLargeError } from "./ref-sync-protocol"; import { packFromResponse, remoteFor } from "./snapshot"; const DELIM_PKT = new TextEncoder().encode("0001"); const FLUSH = new TextEncoder().encode("0000"); /** Trees come too (Artifacts only supports `blob:none`), so caps stay well inside the 128 MB isolate. */ const MAX_GRAPH_BYTES = 24 * 1024 * 1024; const MAX_GRAPH_INFLATED_BYTES = 40 * 1024 * 1024; const MAX_GRAPH_OBJECTS = 200_000; const MAX_GRAPH_OBJECT_BYTES = 8 * 1024 * 1024; const GRAPH_TIMEOUT_MS = 25_000; const PARENT_LINE = /^parent ([0-9a-f]{40})$/gm; const decoder = new TextDecoder(); function parentsOf(content: Uint8Array): string[] { const header = decoder.decode(content.subarray(0, Math.min(content.byteLength, 64 * 1024))).split("\n\n")[0]; return [...header.matchAll(PARENT_LINE)].map((match) => match[1]); } /** * Whether `target` is an ancestor of any of `tips`, in ONE protocol v2 fetch: wants every tip, has the * target, `filter blob:none`. The pack then holds exactly the commits reachable from the tips but not from * the target, so the target is reachable iff one of those commits names it as a parent. * Artifacts' `ready`/`ACK` only report that it has the object (it answers `ready` for unrelated commits), * so they cannot be used. Returns null when the answer is unknown (no remote, error, or over budget). */ export async function reachableViaFetch( artifactsName: string, tips: string[], target: string, ): Promise { if (tips.includes(target)) return true; const wants = [...new Set(tips)]; if (wants.length === 0) return false; const remote = await remoteFor(artifactsName); if (!remote) return null; const body = concatBytes([ pktLine("command=fetch\n"), pktLine("object-format=sha1\n"), DELIM_PKT, pktLine("no-progress\n"), pktLine("filter blob:none\n"), ...wants.map((sha) => pktLine(`want ${sha}\n`)), pktLine(`have ${target}\n`), pktLine("done\n"), FLUSH, ]); let response: Response; try { // Internal git fetches are billed as pulls; count them next to the binding calls. recordArtifactsCall("git fetch: reachability"); response = await fetch(`${remote}/git-upload-pack`, { method: "POST", body, headers: { authorization: basicAuthHeader(await repoTokenSecret({ artifacts_name: artifactsName }, "read")), "git-protocol": "version=2", "content-type": "application/x-git-upload-pack-request", accept: "application/x-git-upload-pack-result", "user-agent": "git/gitfor", }, signal: AbortSignal.timeout(GRAPH_TIMEOUT_MS), }); } catch { return null; } if (!response.ok || !response.body) { await response.body?.cancel(); return null; } if (Number(response.headers.get("content-length") ?? "0") > MAX_GRAPH_BYTES) { await response.body.cancel(); return null; } try { const bytes = new Uint8Array(await new Response(limitStream(response.body, MAX_GRAPH_BYTES)).arrayBuffer()); const parsed = await parsePack(packFromResponse(bytes), { maxObjects: MAX_GRAPH_OBJECTS, maxObjectBytes: MAX_GRAPH_OBJECT_BYTES, maxTotalBytes: MAX_GRAPH_INFLATED_BYTES, }); let commits = 0; for (const object of parsed.objects) { if (object.type !== "commit") continue; commits++; if (parentsOf(object.content).includes(target)) return true; } if (parsed.truncated || parsed.unresolved.length > 0 || commits === 0) { console.warn("Reachability fetch inconclusive", { artifactsName, commits, truncated: parsed.truncated }); return null; } return false; } catch (error) { console.warn("Reachability fetch failed", { artifactsName, error: error instanceof PackTooLargeError ? "pack too large" : String(error), }); return null; } }