| 1 | import { ByteWriter, equalBytes, fromUtf8, toHex } from "./bytes.js"; |
| 2 | import { applyDelta } from "./delta.js"; |
| 3 | import { CODE_TYPES, TYPE_CODES, hashObject, objectHeader, type GitObject, type ObjectType } from "./objects.js"; |
| 4 | import { Sha1, sha1 } from "./sha1.js"; |
| 5 | import { inflate, inflateAt, inflateEach } from "./zlib.js"; |
| 6 | |
| 7 | const OFS_DELTA = 6; |
| 8 | const REF_DELTA = 7; |
| 9 | |
| 10 | export interface PackedObject extends GitObject { |
| 11 | oid: string; |
| 12 | /** |
| 13 | * Set on a large blob parsed with `largeObjectBytes`: its zlib data as it sits in the pack (a view, |
| 14 | * not a copy) and its size. Its `content` is then inflated afresh on every read, so callers that |
| 15 | * can work from `deflated` should. |
| 16 | */ |
| 17 | deflated?: Uint8Array; |
| 18 | size?: number; |
| 19 | } |
| 20 | |
| 21 | /** A large blob kept compressed: `content` inflates it when something needs the bytes. */ |
| 22 | function largeBlob(oid: string, size: number, deflated: Uint8Array): PackedObject { |
| 23 | const obj = { oid, type: "blob" as const, size, deflated } as PackedObject; |
| 24 | Object.defineProperty(obj, "content", { enumerable: true, get: () => inflate(deflated) }); |
| 25 | return obj; |
| 26 | } |
| 27 | |
| 28 | /** Packs bigger than this get their checksum computed in slices, so the pack is never copied for hashing. */ |
| 29 | const SLICED_CHECKSUM_BYTES = 16 * 1024 * 1024; |
| 30 | |
| 31 | /** A delta entry waiting for its base: its instructions (inflated), freed once applied. */ |
| 32 | interface DeltaEntry { |
| 33 | offset: number; |
| 34 | data: Uint8Array; |
| 35 | } |
| 36 | |
| 37 | export type ExternalLookup = (oid: string) => Promise<GitObject | null>; |
| 38 | |
| 39 | /** What a pack may unpack to by default (object contents and delta instructions together). */ |
| 40 | export const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; |
| 41 | |
| 42 | /** |
| 43 | * What unpacking a pack may hold in memory by default: the contents it returns (large blobs kept |
| 44 | * compressed aside), delta instructions until they are applied, a base while its deltas are applied, |
| 45 | * and OBJECT_OVERHEAD for each object. A repository's Durable Object has 128 MB, the pack included. |
| 46 | */ |
| 47 | export const MAX_HELD_BYTES = 80 * 1024 * 1024; |
| 48 | |
| 49 | /** |
| 50 | * Roughly what one object costs to hold beyond its content: the object and its oid, the Uint8Array |
| 51 | * and its buffer, and its entries in the indexes below. Measured at about 740 bytes in Node for |
| 52 | * 100,000 three-byte blobs. |
| 53 | */ |
| 54 | const OBJECT_OVERHEAD = 768; |
| 55 | |
| 56 | const EMPTY = new Uint8Array(0); |
| 57 | |
| 58 | /** |
| 59 | * Parse a packfile and fully resolve every object (including deltas). |
| 60 | * `external` resolves REF_DELTA bases that are not inside the pack (thin packs). |
| 61 | * Packs come from clients, so unpacking is bounded: each object stops inflating at its |
| 62 | * declared size, the whole pack may unpack to at most `maxBytes`, and what it holds in memory |
| 63 | * may come to at most `maxHeldBytes`. Deltas are resolved in one pass from their bases. |
| 64 | */ |
| 65 | export async function parsePack( |
| 66 | pack: Uint8Array, |
| 67 | external?: ExternalLookup, |
| 68 | opts: { maxBytes?: number; largeObjectBytes?: number; maxHeldBytes?: number } = {}, |
| 69 | ): Promise<PackedObject[]> { |
| 70 | if (pack.length < 32 || fromUtf8(pack.subarray(0, 4)) !== "PACK") throw new Error("not a packfile"); |
| 71 | const view = new DataView(pack.buffer, pack.byteOffset, pack.byteLength); |
| 72 | const version = view.getUint32(4); |
| 73 | if (version !== 2 && version !== 3) throw new Error(`unsupported pack version ${version}`); |
| 74 | const count = view.getUint32(8); |
| 75 | |
| 76 | const trailer = pack.subarray(pack.length - 20); |
| 77 | let digest: Uint8Array; |
| 78 | if (pack.length > SLICED_CHECKSUM_BYTES) { |
| 79 | const h = new Sha1(); |
| 80 | for (let i = 0; i < pack.length - 20; i += 1024 * 1024) h.update(pack.subarray(i, Math.min(i + 1024 * 1024, pack.length - 20))); |
| 81 | digest = h.digest(); |
| 82 | } else digest = await sha1(pack.subarray(0, pack.length - 20)); |
| 83 | if (!equalBytes(trailer, digest)) throw new Error("pack checksum mismatch"); |
| 84 | |
| 85 | const maxBytes = opts.maxBytes ?? MAX_UNPACKED_BYTES; |
| 86 | const maxHeld = opts.maxHeldBytes ?? MAX_HELD_BYTES; |
| 87 | const large = opts.largeObjectBytes ?? Infinity; |
| 88 | let unpacked = 0; |
| 89 | let held = 0; |
| 90 | const work = (n: number) => { |
| 91 | if (unpacked + n > maxBytes) throw new Error(`pack unpacks to more than ${maxBytes} bytes`); |
| 92 | unpacked += n; |
| 93 | }; |
| 94 | const hold = (n: number) => { |
| 95 | if (held + n > maxHeld) throw new Error(`pack needs more than ${Math.floor(maxHeld / (1024 * 1024))} MiB of memory to unpack; split it into smaller pushes`); |
| 96 | held += n; |
| 97 | }; |
| 98 | |
| 99 | const results: PackedObject[] = []; |
| 100 | /** Each result's offset in the pack, to find the OFS_DELTAs against it. */ |
| 101 | const offsets: number[] = []; |
| 102 | const childrenByOffset = new Map<number, DeltaEntry[]>(); |
| 103 | const childrenByOid = new Map<string, DeltaEntry[]>(); |
| 104 | const waitFor = <K>(m: Map<K, DeltaEntry[]>, k: K, d: DeltaEntry) => { |
| 105 | const list = m.get(k); |
| 106 | if (list) list.push(d); |
| 107 | else m.set(k, [d]); |
| 108 | }; |
| 109 | const add = (offset: number, obj: PackedObject) => { |
| 110 | results.push(obj); |
| 111 | offsets.push(offset); |
| 112 | }; |
| 113 | |
| 114 | let pos = 12; |
| 115 | for (let n = 0; n < count; n++) { |
| 116 | const offset = pos; |
| 117 | let c = pack[pos++]!; |
| 118 | const typeCode = (c >> 4) & 7; |
| 119 | let size = c & 15; |
| 120 | let shift = 4; |
| 121 | while (c & 0x80) { |
| 122 | c = pack[pos++]!; |
| 123 | size += (c & 0x7f) * 2 ** shift; |
| 124 | shift += 7; |
| 125 | } |
| 126 | let baseOffset: number | undefined; |
| 127 | let baseOid: string | undefined; |
| 128 | if (typeCode === OFS_DELTA) { |
| 129 | c = pack[pos++]!; |
| 130 | let ofs = c & 0x7f; |
| 131 | while (c & 0x80) { |
| 132 | c = pack[pos++]!; |
| 133 | ofs = (ofs + 1) * 128 + (c & 0x7f); |
| 134 | } |
| 135 | baseOffset = offset - ofs; |
| 136 | } else if (typeCode === REF_DELTA) { |
| 137 | baseOid = toHex(pack.subarray(pos, pos + 20)); |
| 138 | pos += 20; |
| 139 | } else if (!CODE_TYPES[typeCode]) { |
| 140 | throw new Error(`bad object type ${typeCode} at ${offset}`); |
| 141 | } |
| 142 | if (typeCode === TYPE_CODES.blob && size >= large) { |
| 143 | // Hash it as it inflates and keep only the compressed bytes, so memory never holds it whole. |
| 144 | work(size); |
| 145 | hold(OBJECT_OVERHEAD); |
| 146 | const h = new Sha1().update(objectHeader("blob", size)); |
| 147 | const { consumed, produced } = inflateEach(pack, pos, (chunk) => h.update(chunk), size); |
| 148 | if (produced !== size) throw new Error(`size mismatch at ${offset}: ${produced} != ${size}`); |
| 149 | add(offset, largeBlob(toHex(h.digest()), size, pack.subarray(pos, pos + consumed))); |
| 150 | pos += consumed; |
| 151 | continue; |
| 152 | } |
| 153 | // Reserved at the declared size before inflating, so a declared size the budget can't hold is refused up front. |
| 154 | work(size); |
| 155 | hold(size + OBJECT_OVERHEAD); |
| 156 | const { out, consumed } = inflateAt(pack, pos, size); |
| 157 | if (out.length !== size) throw new Error(`size mismatch at ${offset}: ${out.length} != ${size}`); |
| 158 | pos += consumed; |
| 159 | if (baseOffset !== undefined) waitFor(childrenByOffset, baseOffset, { offset, data: out }); |
| 160 | else if (baseOid !== undefined) waitFor(childrenByOid, baseOid, { offset, data: out }); |
| 161 | else { |
| 162 | const type = CODE_TYPES[typeCode]!; |
| 163 | add(offset, { oid: await hashObject(type, out), type, content: out }); |
| 164 | } |
| 165 | } |
| 166 | if (pos !== pack.length - 20) throw new Error("trailing garbage in pack"); |
| 167 | |
| 168 | /** Applies every delta waiting for `base` (in the pack at `offset`, or -1 when it came from the repository). */ |
| 169 | const resolveDeltasOf = async (base: GitObject & { oid: string; deflated?: Uint8Array; size?: number }, offset: number) => { |
| 170 | const byOffset = offset >= 0 ? childrenByOffset.get(offset) : undefined; |
| 171 | const byOid = childrenByOid.get(base.oid); |
| 172 | if (!byOffset && !byOid) return; |
| 173 | if (byOffset) childrenByOffset.delete(offset); |
| 174 | if (byOid) childrenByOid.delete(base.oid); |
| 175 | // A large blob kept compressed is inflated once for all its deltas, and held while they are applied. |
| 176 | const inflated = base.deflated ? (base.size ?? 0) : 0; |
| 177 | if (inflated) { |
| 178 | work(inflated); |
| 179 | hold(inflated); |
| 180 | } |
| 181 | const content = base.content; |
| 182 | for (const d of [...(byOffset ?? []), ...(byOid ?? [])]) { |
| 183 | const result = applyDelta(content, d.data, Math.min(maxBytes - unpacked, maxHeld - held + d.data.length)); |
| 184 | work(result.length); |
| 185 | held -= d.data.length; |
| 186 | hold(result.length); |
| 187 | d.data = EMPTY; |
| 188 | add(d.offset, { oid: await hashObject(base.type, result), type: base.type, content: result }); |
| 189 | } |
| 190 | held -= inflated; |
| 191 | }; |
| 192 | // Each object, as it is added, has its deltas applied: one pass, whatever order the pack lists them in. |
| 193 | let next = 0; |
| 194 | const drain = async () => { |
| 195 | for (; next < results.length; next++) await resolveDeltasOf(results[next]!, offsets[next]!); |
| 196 | }; |
| 197 | await drain(); |
| 198 | // REF_DELTA bases that aren't in the pack (thin packs) come from the repository, each read once. |
| 199 | if (external) { |
| 200 | for (const oid of [...childrenByOid.keys()]) { |
| 201 | if (!childrenByOid.has(oid)) continue; |
| 202 | const base = await external(oid); |
| 203 | if (!base) continue; |
| 204 | work(base.content.length); |
| 205 | hold(base.content.length); |
| 206 | await resolveDeltasOf({ oid, ...base }, -1); |
| 207 | held -= base.content.length; |
| 208 | await drain(); |
| 209 | } |
| 210 | } |
| 211 | const unresolved = [...childrenByOid.keys(), ...[...childrenByOffset.keys()].map((o) => `@${o}`)]; |
| 212 | if (unresolved.length) throw new Error(`unresolvable delta base(s): ${unresolved.join(", ")}`); |
| 213 | return results; |
| 214 | } |
| 215 | |
| 216 | export interface PackEntryInput { |
| 217 | type: ObjectType; |
| 218 | /** Uncompressed size of the object content. */ |
| 219 | size: number; |
| 220 | /** zlib-deflated object content. */ |
| 221 | deflated: Uint8Array; |
| 222 | } |
| 223 | |
| 224 | function writeEntryHeader(w: ByteWriter, typeCode: number, size: number) { |
| 225 | let c = (typeCode << 4) | (size & 15); |
| 226 | size = Math.floor(size / 16); |
| 227 | while (size > 0) { |
| 228 | w.byte(c | 0x80); |
| 229 | c = size & 0x7f; |
| 230 | size = Math.floor(size / 128); |
| 231 | } |
| 232 | w.byte(c); |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * The same packfile as `writePack`, produced piece by piece as `entries` yields them, so a clone of a |
| 237 | * large repository never holds the whole pack: only the entry being written. |
| 238 | */ |
| 239 | export async function* packStream(count: number, entries: AsyncIterable<PackEntryInput>): AsyncGenerator<Uint8Array> { |
| 240 | const hash = new Sha1(); |
| 241 | const head = new ByteWriter(); |
| 242 | head.write(new TextEncoder().encode("PACK")); |
| 243 | head.uint32(2); |
| 244 | head.uint32(count); |
| 245 | let written = 0; |
| 246 | const out = (b: Uint8Array) => (hash.update(b), b); |
| 247 | yield out(head.bytes()); |
| 248 | for await (const e of entries) { |
| 249 | if (++written > count) throw new Error(`pack has more than the ${count} objects it announced`); |
| 250 | const h = new ByteWriter(); |
| 251 | writeEntryHeader(h, TYPE_CODES[e.type], e.size); |
| 252 | yield out(h.bytes()); |
| 253 | yield out(e.deflated); |
| 254 | } |
| 255 | if (written !== count) throw new Error(`pack announced ${count} objects but has ${written}`); |
| 256 | yield hash.digest(); |
| 257 | } |
| 258 | |
| 259 | /** Build a version 2 packfile of undeltified objects from pre-deflated content. */ |
| 260 | export async function writePack(entries: readonly PackEntryInput[]): Promise<Uint8Array> { |
| 261 | const w = new ByteWriter(); |
| 262 | w.write(new TextEncoder().encode("PACK")); |
| 263 | w.uint32(2); |
| 264 | w.uint32(entries.length); |
| 265 | for (const e of entries) { |
| 266 | writeEntryHeader(w, TYPE_CODES[e.type], e.size); |
| 267 | w.write(e.deflated); |
| 268 | } |
| 269 | const body = w.bytes(); |
| 270 | w.write(await sha1(body)); |
| 271 | return w.bytes(); |
| 272 | } |