import { concatBytes, FLUSH_PKT, packetText, parseAllPackets, pktLine } from "./pktline"; import { isValidRefName } from "./writer"; const ZERO_SHA = "0000000000000000000000000000000000000000"; const PEELED_SUFFIX = "^{}"; const PACK_MAGIC = [0x50, 0x41, 0x43, 0x4b]; const MAX_PREAMBLE_BYTES = 64 * 1024; const decoder = new TextDecoder(); export type AdvertisedRef = { name: string; sha: string }; export type RefCommand = { name: string; sha: string; oldSha?: string }; const SHA_PATTERN = /^[0-9a-f]{40}$/; /** A branch or tag whose name passes `git check-ref-format`, so a remote cannot smuggle odd names in. */ function isSyncableRef(name: string): boolean { const prefix = ["refs/heads/", "refs/tags/"].find((candidate) => name.startsWith(candidate)); return prefix !== undefined && isValidRefName(name.slice(prefix.length)); } /** Every branch and tag in a v0 upload-pack advertisement, using the tag object (not the peeled commit) for annotated tags. */ export function parseSyncableRefs(body: Uint8Array, includeAll = false, includePeeled = false): AdvertisedRef[] { const refs: AdvertisedRef[] = []; for (const packet of parseAllPackets(body)) { const line = packetText(packet); if (line === null || line.startsWith("#")) continue; const [refPart] = line.split("\0"); const [sha, name] = refPart.split(" "); if (!sha || !name || !SHA_PATTERN.test(sha) || sha === ZERO_SHA) continue; if (name.endsWith(PEELED_SUFFIX) && !(includeAll && includePeeled)) continue; if (includeAll || isSyncableRef(name)) refs.push({ name, sha }); } return refs; } /** * A stateless v0 upload-pack request. No side-band and no thin-pack, so the response is a plain, * self-contained packfile that receive-pack will accept verbatim. */ export function uploadPackRequest(wants: string[], haves: string[]): Uint8Array { const lines: Uint8Array[] = wants.map((sha, index) => pktLine(index === 0 ? `want ${sha} ofs-delta no-progress agent=gitfor\n` : `want ${sha}\n`), ); lines.push(FLUSH_PKT); for (const sha of haves) lines.push(pktLine(`have ${sha}\n`)); lines.push(pktLine("done\n")); return concatBytes(lines); } function packStart(buffer: Uint8Array): number { for (let index = 0; index + PACK_MAGIC.length <= buffer.byteLength; index++) { if (PACK_MAGIC.every((byte, offset) => buffer[index + offset] === byte)) return index; } return -1; } /** Drops the NAK/ACK pkt-lines that precede the packfile in an upload-pack response, streaming the rest. */ export async function packStream(body: ReadableStream): Promise> { const reader = body.getReader(); let buffered = new Uint8Array(0); while (true) { const { value, done } = await reader.read(); if (done) throw new Error(`Source sent no packfile: ${decoder.decode(buffered).slice(0, 200)}`); buffered = concatBytes([buffered, value]); const start = packStart(buffered); if (start !== -1) { const head = buffered.subarray(start); return new ReadableStream({ start(controller) { controller.enqueue(head); }, async pull(controller) { const next = await reader.read(); if (next.done) controller.close(); else controller.enqueue(next.value); }, cancel(reason) { return reader.cancel(reason); }, }); } if (buffered.byteLength > MAX_PREAMBLE_BYTES) throw new Error("Source response did not contain a packfile"); } } /** receive-pack body: ref-creation commands followed by the packfile stream. */ export function receivePackBody(refs: RefCommand[], pack: ReadableStream): ReadableStream { const commands = concatBytes([ ...refs.map((ref, index) => pktLine( index === 0 ? `${ref.oldSha ?? ZERO_SHA} ${ref.sha} ${ref.name}\0report-status agent=gitfor\n` : `${ref.oldSha ?? ZERO_SHA} ${ref.sha} ${ref.name}\n`, ), ), FLUSH_PKT, ]); const reader = pack.getReader(); let sentCommands = false; return new ReadableStream({ async pull(controller) { if (!sentCommands) { sentCommands = true; controller.enqueue(commands); return; } const next = await reader.read(); if (next.done) controller.close(); else controller.enqueue(next.value); }, cancel(reason) { return reader.cancel(reason); }, }); } /** Refs that `ok`'d and the first rejection, from an un-sidebanded report-status response. */ export function parseReportStatus(body: Uint8Array): { unpackOk: boolean; accepted: number; rejected: string[] } { let unpackOk = false; let accepted = 0; const rejected: string[] = []; for (const packet of parseAllPackets(body)) { const line = packetText(packet); if (line === null) continue; if (line.startsWith("unpack ")) unpackOk = line === "unpack ok"; else if (line.startsWith("ok ")) accepted++; else if (line.startsWith("ng ")) rejected.push(line.slice(3)); } return { unpackOk, accepted, rejected }; } export class PackTooLargeError extends Error {} /** Errors the stream once more than `maxBytes` pass through, so an oversized relay aborts mid-flight. */ export function limitStream(stream: ReadableStream, maxBytes: number): ReadableStream { let total = 0; return stream.pipeThrough( new TransformStream({ transform(chunk, controller) { total += chunk.byteLength; if (total > maxBytes) controller.error(new PackTooLargeError(`Pack exceeds ${maxBytes} bytes`)); else controller.enqueue(chunk); }, }), ); }