Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

272 lines · 10.8 KBRaw History
1import { ByteWriter, equalBytes, fromUtf8, toHex } from "./bytes.js";
2import { applyDelta } from "./delta.js";
3import { CODE_TYPES, TYPE_CODES, hashObject, objectHeader, type GitObject, type ObjectType } from "./objects.js";
4import { Sha1, sha1 } from "./sha1.js";
5import { inflate, inflateAt, inflateEach } from "./zlib.js";
6
7const OFS_DELTA = 6;
8const REF_DELTA = 7;
9
10export 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. */
22function 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. */
29const SLICED_CHECKSUM_BYTES = 16 * 1024 * 1024;
30
31/** A delta entry waiting for its base: its instructions (inflated), freed once applied. */
32interface DeltaEntry {
33 offset: number;
34 data: Uint8Array;
35}
36
37export type ExternalLookup = (oid: string) => Promise<GitObject | null>;
38
39/** What a pack may unpack to by default (object contents and delta instructions together). */
40export 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 */
47export 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 */
54const OBJECT_OVERHEAD = 768;
55
56const 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 */
65export 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
216export 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
224function 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 */
239export 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. */
260export 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}