import { bufferUpTo, packOffset } from "../security/push-protection"; import { isAncestor } from "./history"; import { parsePack } from "./pack"; import { withStore, ZERO_SHA } from "./store"; import type { RepoRow } from "~/lib/types"; const MAX_BUFFERED_BYTES = 32 * 1024 * 1024; const MAX_INFLATED_BYTES = 64 * 1024 * 1024; const MAX_WALK = 5000; const MAX_STORE_CHECKS = 64; const PARENT_LINE = /^parent ([0-9a-f]{40})$/gm; const decoder = new TextDecoder(); export type RefUpdate = { oldSha: string; newSha: string; ref: string }; function commitParents(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]); } async function readPackParents(bytes: Uint8Array, gzip: boolean): Promise | null> { let decoded = bytes; if (gzip) { const inflated = await bufferUpTo( new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")), MAX_INFLATED_BYTES, ); if (!inflated.complete) return null; decoded = inflated.bytes; } const offset = packOffset(decoded); if (offset === null) return new Map(); const parsed = await parsePack(decoded.subarray(offset), { maxObjects: 100_000, maxObjectBytes: 16 * 1024 * 1024, maxTotalBytes: MAX_INFLATED_BYTES, }); if (parsed.truncated) return null; return new Map( parsed.objects .filter((object) => object.type === "commit") .map((object) => [object.sha, commitParents(object.content)]), ); } const FORCED = "protected branch: force pushes are not allowed"; const UNVERIFIABLE = "protected branch: gitfor could not verify this is a fast-forward; push in smaller pieces"; type Verdict = "fast_forward" | "forced" | "unverifiable"; /** Walks from `newSha` through the pushed commits, then checks the stored commits it reaches for `oldSha`. */ async function classify( repo: RepoRow, parents: Map, oldSha: string, newSha: string, ): Promise { return withStore(repo.artifacts_name, async (store) => { const queue = [newSha]; const seen = new Set(); const frontier: string[] = []; for (let index = 0; index < queue.length; index++) { if (seen.size >= MAX_WALK) return "unverifiable"; const sha = queue[index]; if (sha === oldSha) return "fast_forward"; if (seen.has(sha)) continue; seen.add(sha); const pushed = parents.get(sha); if (pushed) queue.push(...pushed); else frontier.push(sha); } if (frontier.length > MAX_STORE_CHECKS) return "unverifiable"; for (const sha of frontier) { if (!(await store.commit(sha))) return "unverifiable"; if (await isAncestor(store, oldSha, sha)) return "fast_forward"; } return "forced"; }); } /** * Finds which of `updates` are not fast-forwards, with the rejection reason for each. Reads the pushed * pack (bounded) to learn the parents of new commits, since they are not in the store yet. Returns the * body to forward, re-joined after buffering. Fails closed: an update that cannot be verified is refused, * unless `allowUnverifiable` (repository admins pushing large histories). */ export async function findForcePushes( repo: RepoRow, updates: RefUpdate[], body: ReadableStream, gzip: boolean, allowUnverifiable = false, ): Promise<{ rejected: Map; body: ReadableStream }> { const candidates = updates.filter((update) => update.oldSha !== ZERO_SHA && update.newSha !== ZERO_SHA); if (candidates.length === 0) return { rejected: new Map(), body }; const buffered = await bufferUpTo(body, MAX_BUFFERED_BYTES); const unverifiable = new Map(allowUnverifiable ? [] : candidates.map((update) => [update.ref, UNVERIFIABLE])); if (!buffered.complete) return { rejected: unverifiable, body: buffered.body }; try { const parents = await readPackParents(buffered.bytes, gzip); if (!parents) return { rejected: unverifiable, body: buffered.body }; const rejected = new Map(); for (const update of candidates) { const verdict = await classify(repo, parents, update.oldSha, update.newSha); if (verdict === "forced") rejected.set(update.ref, FORCED); else if (verdict === "unverifiable" && !allowUnverifiable) rejected.set(update.ref, UNVERIFIABLE); } return { rejected, body: buffered.body }; } catch (error) { console.warn("fast-forward check failed", error); return { rejected: unverifiable, body: buffered.body }; } }