gent-cli 14.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,812 @@
1
+ /**
2
+ * ============================================================================
3
+ * Packfile - pack and pack-index reader/writer
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Read the packs Git produces (locally after `git gc`, and over the wire)
8
+ * and write the packs Gent sends. Without this, a repository Git has packed
9
+ * looks empty to Gent.
10
+ *
11
+ * READ:
12
+ * pack v2/v3, index v2 (including 8-byte large offsets and the CRC table),
13
+ * normal objects, OFS_DELTA and REF_DELTA with full copy/insert decoding.
14
+ * Delta chains are bounded (depth and inflated size) and every decoded
15
+ * object is bounds-checked; corruption is an error, never a silent miss.
16
+ *
17
+ * WRITE:
18
+ * Full, undeltified objects only. That trades transfer size for an encoder
19
+ * small enough to be obviously correct. Delta *decoding* stays mandatory
20
+ * because Git will send deltas regardless.
21
+ *
22
+ * THIN PACKS:
23
+ * A pack received over the wire may delta against objects it does not
24
+ * contain. resolveThinPack() completes those against the repository before
25
+ * anything is published.
26
+ *
27
+ * See docs/git-compat/format-contract.md section 9.
28
+ * ============================================================================
29
+ */
30
+
31
+ const fs = require('fs').promises;
32
+ const path = require('path');
33
+ const zlib = require('zlib');
34
+ const crypto = require('crypto');
35
+
36
+ const {
37
+ OID_RAW_LENGTH,
38
+ OBJECT_TYPES,
39
+ isObjectId,
40
+ assertObjectId,
41
+ hashObject,
42
+ frameObject,
43
+ MalformedObjectError
44
+ } = require('./git-objects');
45
+
46
+ const PACK_SIGNATURE = 0x5041434b; // 'PACK'
47
+ const IDX_SIGNATURE = 0xff744f63; // '\377tOc'
48
+ const SUPPORTED_PACK_VERSIONS = new Set([2, 3]);
49
+
50
+ const OBJ_COMMIT = 1;
51
+ const OBJ_TREE = 2;
52
+ const OBJ_BLOB = 3;
53
+ const OBJ_TAG = 4;
54
+ const OBJ_OFS_DELTA = 6;
55
+ const OBJ_REF_DELTA = 7;
56
+
57
+ const TYPE_BY_CODE = { 1: 'commit', 2: 'tree', 3: 'blob', 4: 'tag' };
58
+ const CODE_BY_TYPE = { commit: 1, tree: 2, blob: 3, tag: 4 };
59
+
60
+ const MAX_DELTA_DEPTH = 50;
61
+ const MAX_OBJECT_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB inflated, per object
62
+
63
+ class PackError extends Error {
64
+ constructor(message, details) {
65
+ super(message);
66
+ this.name = 'PackError';
67
+ this.code = 'GENT_BAD_PACK';
68
+ Object.assign(this, details || {});
69
+ }
70
+ }
71
+
72
+ // ─── zlib helpers ────────────────────────────────────────
73
+
74
+ /**
75
+ * Inflate the zlib stream starting at `offset`, reporting how many input
76
+ * bytes it consumed so the caller can find the next object.
77
+ *
78
+ * @param {Buffer} buffer
79
+ * @param {Number} offset
80
+ * @param {Number} [expectedSize] - declared inflated size, enforced when given
81
+ * @returns {Promise<{data: Buffer, consumed: Number}>}
82
+ */
83
+ function inflateAt(buffer, offset, expectedSize) {
84
+ return new Promise((resolve, reject) => {
85
+ const stream = zlib.createInflate();
86
+ const chunks = [];
87
+ let total = 0;
88
+
89
+ stream.on('data', (chunk) => {
90
+ total += chunk.length;
91
+ if (total > MAX_OBJECT_BYTES) {
92
+ stream.destroy();
93
+ reject(new PackError(`object at offset ${offset} inflates past the ${MAX_OBJECT_BYTES}-byte limit`));
94
+ return;
95
+ }
96
+ chunks.push(chunk);
97
+ });
98
+ stream.on('error', (error) => reject(new PackError(`corrupt deflate stream at offset ${offset}: ${error.message}`)));
99
+ stream.on('end', () => {
100
+ const data = Buffer.concat(chunks, total);
101
+ if (expectedSize !== undefined && data.length !== expectedSize) {
102
+ reject(new PackError(`object at offset ${offset} declares ${expectedSize} bytes but inflates to ${data.length}`));
103
+ return;
104
+ }
105
+ resolve({ data, consumed: stream.bytesWritten });
106
+ });
107
+
108
+ stream.end(buffer.subarray(offset));
109
+ });
110
+ }
111
+
112
+ // ─── varints ─────────────────────────────────────────────
113
+
114
+ /**
115
+ * Pack object header: type in bits 4-6 of the first byte, size in
116
+ * little-endian 7-bit groups.
117
+ * @param {Buffer} buffer
118
+ * @param {Number} offset
119
+ * @returns {{type: Number, size: Number, offset: Number}}
120
+ */
121
+ function readObjectHeader(buffer, offset) {
122
+ let byte = buffer[offset++];
123
+ if (byte === undefined) throw new PackError('pack ends inside an object header');
124
+
125
+ const type = (byte >> 4) & 0x7;
126
+ let size = byte & 0x0f;
127
+ let shift = 4;
128
+
129
+ while (byte & 0x80) {
130
+ byte = buffer[offset++];
131
+ if (byte === undefined) throw new PackError('pack ends inside an object size');
132
+ size += (byte & 0x7f) * 2 ** shift;
133
+ shift += 7;
134
+ if (shift > 63) throw new PackError('object size varint is absurdly long');
135
+ }
136
+ return { type, size, offset };
137
+ }
138
+
139
+ /**
140
+ * OFS_DELTA's negative-offset encoding.
141
+ * @param {Buffer} buffer
142
+ * @param {Number} offset
143
+ * @returns {{distance: Number, offset: Number}}
144
+ */
145
+ function readOffsetDelta(buffer, offset) {
146
+ let byte = buffer[offset++];
147
+ if (byte === undefined) throw new PackError('pack ends inside a delta offset');
148
+ let distance = byte & 0x7f;
149
+
150
+ while (byte & 0x80) {
151
+ byte = buffer[offset++];
152
+ if (byte === undefined) throw new PackError('pack ends inside a delta offset');
153
+ distance = (distance + 1) * 128 + (byte & 0x7f);
154
+ }
155
+ return { distance, offset };
156
+ }
157
+
158
+ /**
159
+ * Little-endian 7-bit varint used for delta sizes.
160
+ * @param {Buffer} buffer
161
+ * @param {Number} offset
162
+ * @returns {{value: Number, offset: Number}}
163
+ */
164
+ function readDeltaSize(buffer, offset) {
165
+ let value = 0;
166
+ let shift = 0;
167
+ let byte;
168
+ do {
169
+ byte = buffer[offset++];
170
+ if (byte === undefined) throw new PackError('delta ends inside a size varint');
171
+ value |= (byte & 0x7f) << shift;
172
+ shift += 7;
173
+ } while (byte & 0x80);
174
+ return { value: value >>> 0, offset };
175
+ }
176
+
177
+ /**
178
+ * Apply a delta to its base.
179
+ * @param {Buffer} base
180
+ * @param {Buffer} delta
181
+ * @returns {Buffer}
182
+ */
183
+ function applyDelta(base, delta) {
184
+ let offset = 0;
185
+
186
+ const sourceSize = readDeltaSize(delta, offset);
187
+ offset = sourceSize.offset;
188
+ if (sourceSize.value !== base.length) {
189
+ throw new PackError(`delta expects a ${sourceSize.value}-byte base but the base is ${base.length} bytes`);
190
+ }
191
+
192
+ const targetSize = readDeltaSize(delta, offset);
193
+ offset = targetSize.offset;
194
+ if (targetSize.value > MAX_OBJECT_BYTES) {
195
+ throw new PackError(`delta target size ${targetSize.value} exceeds the ${MAX_OBJECT_BYTES}-byte limit`);
196
+ }
197
+
198
+ const out = Buffer.allocUnsafe(targetSize.value);
199
+ let written = 0;
200
+
201
+ while (offset < delta.length) {
202
+ const command = delta[offset++];
203
+
204
+ if (command & 0x80) {
205
+ let copyOffset = 0;
206
+ let copySize = 0;
207
+ if (command & 0x01) copyOffset |= delta[offset++];
208
+ if (command & 0x02) copyOffset |= delta[offset++] << 8;
209
+ if (command & 0x04) copyOffset |= delta[offset++] << 16;
210
+ if (command & 0x08) copyOffset |= delta[offset++] * 0x1000000;
211
+ if (command & 0x10) copySize |= delta[offset++];
212
+ if (command & 0x20) copySize |= delta[offset++] << 8;
213
+ if (command & 0x40) copySize |= delta[offset++] << 16;
214
+ if (copySize === 0) copySize = 0x10000;
215
+
216
+ if (copyOffset + copySize > base.length) {
217
+ throw new PackError(`delta copies ${copySize} bytes at ${copyOffset}, past the end of a ${base.length}-byte base`);
218
+ }
219
+ if (written + copySize > out.length) {
220
+ throw new PackError('delta produces more bytes than it declared');
221
+ }
222
+ base.copy(out, written, copyOffset, copyOffset + copySize);
223
+ written += copySize;
224
+ continue;
225
+ }
226
+
227
+ if (command === 0) throw new PackError('delta contains the reserved 0x00 instruction');
228
+
229
+ if (offset + command > delta.length) throw new PackError('delta insert runs past the end of the delta');
230
+ if (written + command > out.length) throw new PackError('delta produces more bytes than it declared');
231
+ delta.copy(out, written, offset, offset + command);
232
+ written += command;
233
+ offset += command;
234
+ }
235
+
236
+ if (written !== out.length) {
237
+ throw new PackError(`delta produced ${written} bytes but declared ${out.length}`);
238
+ }
239
+ return out;
240
+ }
241
+
242
+ // ─── pack index (.idx v2) ────────────────────────────────
243
+
244
+ class PackIndex {
245
+ /**
246
+ * @param {Buffer} buffer
247
+ * @param {String} filePath
248
+ */
249
+ constructor(buffer, filePath) {
250
+ this.filePath = filePath;
251
+
252
+ if (buffer.length < 8 + 256 * 4 + 2 * OID_RAW_LENGTH) throw new PackError(`${filePath} is too short to be a pack index`);
253
+ if (buffer.readUInt32BE(0) !== IDX_SIGNATURE) throw new PackError(`${filePath} is not a version 2 pack index`);
254
+ if (buffer.readUInt32BE(4) !== 2) throw new PackError(`${filePath} has pack index version ${buffer.readUInt32BE(4)}; only 2 is supported`);
255
+
256
+ this.buffer = buffer;
257
+ this.count = buffer.readUInt32BE(8 + 255 * 4);
258
+
259
+ this.fanoutOffset = 8;
260
+ this.oidsOffset = this.fanoutOffset + 256 * 4;
261
+ this.crcOffset = this.oidsOffset + this.count * OID_RAW_LENGTH;
262
+ this.offsetsOffset = this.crcOffset + this.count * 4;
263
+ this.largeOffsetsOffset = this.offsetsOffset + this.count * 4;
264
+
265
+ const trailerStart = buffer.length - 2 * OID_RAW_LENGTH;
266
+ if (this.largeOffsetsOffset > trailerStart) throw new PackError(`${filePath} is truncated`);
267
+ this.packChecksum = buffer.toString('hex', trailerStart, trailerStart + OID_RAW_LENGTH);
268
+ }
269
+
270
+ /**
271
+ * @param {String} filePath
272
+ * @returns {Promise<PackIndex>}
273
+ */
274
+ static async open(filePath) {
275
+ return new PackIndex(await fs.readFile(filePath), filePath);
276
+ }
277
+
278
+ /**
279
+ * @param {Number} position
280
+ * @returns {String}
281
+ */
282
+ oidAt(position) {
283
+ const start = this.oidsOffset + position * OID_RAW_LENGTH;
284
+ return this.buffer.toString('hex', start, start + OID_RAW_LENGTH);
285
+ }
286
+
287
+ /**
288
+ * Binary search within the fanout bucket.
289
+ * @param {String} oid
290
+ * @returns {Number} pack offset, or -1
291
+ */
292
+ find(oid) {
293
+ if (!isObjectId(oid)) return -1;
294
+
295
+ const firstByte = Number.parseInt(oid.slice(0, 2), 16);
296
+ let low = firstByte === 0 ? 0 : this.buffer.readUInt32BE(this.fanoutOffset + (firstByte - 1) * 4);
297
+ let high = this.buffer.readUInt32BE(this.fanoutOffset + firstByte * 4);
298
+
299
+ while (low < high) {
300
+ const middle = (low + high) >>> 1;
301
+ const candidate = this.oidAt(middle);
302
+ if (candidate === oid) return this.offsetAt(middle);
303
+ if (candidate < oid) low = middle + 1;
304
+ else high = middle;
305
+ }
306
+ return -1;
307
+ }
308
+
309
+ /**
310
+ * @param {Number} position
311
+ * @returns {Number}
312
+ */
313
+ offsetAt(position) {
314
+ const raw = this.buffer.readUInt32BE(this.offsetsOffset + position * 4);
315
+ if ((raw & 0x80000000) === 0) return raw;
316
+
317
+ const largeIndex = raw & 0x7fffffff;
318
+ const at = this.largeOffsetsOffset + largeIndex * 8;
319
+ const value = this.buffer.readBigUInt64BE(at);
320
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new PackError('pack offset exceeds the safe integer range');
321
+ return Number(value);
322
+ }
323
+
324
+ /**
325
+ * @param {Number} position
326
+ * @returns {Number}
327
+ */
328
+ crcAt(position) {
329
+ return this.buffer.readUInt32BE(this.crcOffset + position * 4);
330
+ }
331
+
332
+ /**
333
+ * @returns {Array<String>}
334
+ */
335
+ oids() {
336
+ const all = [];
337
+ for (let i = 0; i < this.count; i++) all.push(this.oidAt(i));
338
+ return all;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Build a version 2 pack index.
344
+ * @param {Array<{oid: String, offset: Number, crc: Number}>} entries
345
+ * @param {String} packChecksum - hex
346
+ * @returns {Buffer}
347
+ */
348
+ function buildPackIndex(entries, packChecksum) {
349
+ const sorted = [...entries].sort((a, b) => (a.oid < b.oid ? -1 : a.oid > b.oid ? 1 : 0));
350
+
351
+ const fanout = Buffer.alloc(256 * 4);
352
+ let cursor = 0;
353
+ for (let bucket = 0; bucket < 256; bucket++) {
354
+ while (cursor < sorted.length && Number.parseInt(sorted[cursor].oid.slice(0, 2), 16) === bucket) cursor++;
355
+ fanout.writeUInt32BE(cursor, bucket * 4);
356
+ }
357
+
358
+ const oids = Buffer.concat(sorted.map(e => Buffer.from(e.oid, 'hex')));
359
+
360
+ const crcs = Buffer.alloc(sorted.length * 4);
361
+ sorted.forEach((entry, i) => crcs.writeUInt32BE(entry.crc >>> 0, i * 4));
362
+
363
+ const large = [];
364
+ const offsets = Buffer.alloc(sorted.length * 4);
365
+ sorted.forEach((entry, i) => {
366
+ if (entry.offset < 0x80000000) {
367
+ offsets.writeUInt32BE(entry.offset, i * 4);
368
+ } else {
369
+ offsets.writeUInt32BE(0x80000000 | large.length, i * 4);
370
+ large.push(entry.offset);
371
+ }
372
+ });
373
+
374
+ const largeBuffer = Buffer.alloc(large.length * 8);
375
+ large.forEach((value, i) => largeBuffer.writeBigUInt64BE(BigInt(value), i * 8));
376
+
377
+ const header = Buffer.alloc(8);
378
+ header.writeUInt32BE(IDX_SIGNATURE, 0);
379
+ header.writeUInt32BE(2, 4);
380
+
381
+ const body = Buffer.concat([
382
+ header, fanout, oids, crcs, offsets, largeBuffer, Buffer.from(packChecksum, 'hex')
383
+ ]);
384
+ return Buffer.concat([body, crypto.createHash('sha256').update(body).digest()]);
385
+ }
386
+
387
+ // ─── pack file ───────────────────────────────────────────
388
+
389
+ class PackFile {
390
+ /**
391
+ * @param {String} packPath
392
+ * @param {Buffer} buffer
393
+ * @param {PackIndex|null} index
394
+ */
395
+ constructor(packPath, buffer, index) {
396
+ this.packPath = packPath;
397
+ this.buffer = buffer;
398
+ this.index = index;
399
+ this.cache = new Map(); // offset -> {type, payload}
400
+
401
+ if (buffer.length < 12 + OID_RAW_LENGTH) throw new PackError(`${packPath} is too short to be a pack`);
402
+ if (buffer.readUInt32BE(0) !== PACK_SIGNATURE) throw new PackError(`${packPath} does not start with PACK`);
403
+
404
+ this.version = buffer.readUInt32BE(4);
405
+ if (!SUPPORTED_PACK_VERSIONS.has(this.version)) {
406
+ throw new PackError(`${packPath} is pack version ${this.version}; Gent reads 2 and 3`);
407
+ }
408
+ this.objectCount = buffer.readUInt32BE(8);
409
+ this.checksum = buffer.toString('hex', buffer.length - OID_RAW_LENGTH);
410
+ }
411
+
412
+ /**
413
+ * @param {String} packPath
414
+ * @returns {Promise<PackFile>}
415
+ */
416
+ static async open(packPath) {
417
+ const buffer = await fs.readFile(packPath);
418
+ const idxPath = packPath.replace(/\.pack$/, '.idx');
419
+ const index = await PackIndex.open(idxPath).catch((error) => {
420
+ if (error.code === 'ENOENT') return null;
421
+ throw error;
422
+ });
423
+ return new PackFile(packPath, buffer, index);
424
+ }
425
+
426
+ /**
427
+ * Verify the trailing checksum. Not done on every read — that would mean
428
+ * hashing the whole pack per object — but required before publishing an
429
+ * incoming pack.
430
+ * @returns {Boolean}
431
+ */
432
+ verifyChecksum() {
433
+ const body = this.buffer.subarray(0, this.buffer.length - OID_RAW_LENGTH);
434
+ return crypto.createHash('sha256').update(body).digest('hex') === this.checksum;
435
+ }
436
+
437
+ /**
438
+ * @param {String} oid
439
+ * @returns {Boolean}
440
+ */
441
+ has(oid) {
442
+ return Boolean(this.index) && this.index.find(oid) >= 0;
443
+ }
444
+
445
+ /**
446
+ * @param {String} oid
447
+ * @returns {Promise<{type: String, size: Number, payload: Buffer}|null>}
448
+ */
449
+ async read(oid) {
450
+ if (!this.index) return null;
451
+ const offset = this.index.find(oid);
452
+ if (offset < 0) return null;
453
+ return this.readAt(offset);
454
+ }
455
+
456
+ /**
457
+ * @param {Number} offset
458
+ * @param {Number} [depth]
459
+ * @returns {Promise<{type: String, size: Number, payload: Buffer}>}
460
+ */
461
+ async readAt(offset, depth = 0) {
462
+ if (depth > MAX_DELTA_DEPTH) {
463
+ throw new PackError(`delta chain in ${this.packPath} is deeper than ${MAX_DELTA_DEPTH}`);
464
+ }
465
+ const cached = this.cache.get(offset);
466
+ if (cached) return cached;
467
+
468
+ if (offset < 12 || offset >= this.buffer.length - OID_RAW_LENGTH) {
469
+ throw new PackError(`offset ${offset} is outside ${this.packPath}`);
470
+ }
471
+
472
+ const header = readObjectHeader(this.buffer, offset);
473
+ let cursor = header.offset;
474
+ let baseOffset = null;
475
+ let baseOid = null;
476
+
477
+ if (header.type === OBJ_OFS_DELTA) {
478
+ const delta = readOffsetDelta(this.buffer, cursor);
479
+ cursor = delta.offset;
480
+ baseOffset = offset - delta.distance;
481
+ if (baseOffset < 12 || baseOffset >= offset) {
482
+ throw new PackError(`OFS_DELTA at ${offset} points outside the pack (base ${baseOffset})`);
483
+ }
484
+ } else if (header.type === OBJ_REF_DELTA) {
485
+ baseOid = this.buffer.toString('hex', cursor, cursor + OID_RAW_LENGTH);
486
+ cursor += OID_RAW_LENGTH;
487
+ } else if (!TYPE_BY_CODE[header.type]) {
488
+ throw new PackError(`unknown pack object type ${header.type} at offset ${offset}`);
489
+ }
490
+
491
+ const { data } = await inflateAt(this.buffer, cursor, header.size);
492
+
493
+ let result;
494
+ if (baseOffset !== null) {
495
+ const base = await this.readAt(baseOffset, depth + 1);
496
+ result = { type: base.type, payload: applyDelta(base.payload, data) };
497
+ } else if (baseOid !== null) {
498
+ const base = await this._resolveExternalBase(baseOid, depth);
499
+ result = { type: base.type, payload: applyDelta(base.payload, data) };
500
+ } else {
501
+ result = { type: TYPE_BY_CODE[header.type], payload: data };
502
+ }
503
+
504
+ result.size = result.payload.length;
505
+ this.cache.set(offset, result);
506
+ return result;
507
+ }
508
+
509
+ /**
510
+ * REF_DELTA bases usually live in the same pack; a thin pack's do not.
511
+ * @param {String} baseOid
512
+ * @param {Number} depth
513
+ */
514
+ async _resolveExternalBase(baseOid, depth) {
515
+ if (this.index) {
516
+ const offset = this.index.find(baseOid);
517
+ if (offset >= 0) return this.readAt(offset, depth + 1);
518
+ }
519
+ if (this.externalBaseResolver) {
520
+ const base = await this.externalBaseResolver(baseOid);
521
+ if (base) return base;
522
+ }
523
+ throw new PackError(`REF_DELTA base ${baseOid} is not in ${this.packPath} and could not be resolved`, { oid: baseOid });
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Every pack in <objects>/pack, presented to ObjectStore as one backend.
529
+ * Rescans on a miss so a pack written by a concurrent `git gc` is picked up.
530
+ */
531
+ class PackStore {
532
+ /**
533
+ * @param {String} objectsDir
534
+ */
535
+ constructor(objectsDir) {
536
+ this.packDir = path.join(objectsDir, 'pack');
537
+ this.packs = new Map(); // path -> PackFile
538
+ this.scanned = false;
539
+ }
540
+
541
+ /**
542
+ * @param {Boolean} [force]
543
+ * @returns {Promise<void>}
544
+ */
545
+ async scan(force = false) {
546
+ if (this.scanned && !force) return;
547
+ this.scanned = true;
548
+
549
+ let names;
550
+ try {
551
+ names = await fs.readdir(this.packDir);
552
+ } catch (error) {
553
+ if (error.code === 'ENOENT') { this.packs.clear(); return; }
554
+ throw error;
555
+ }
556
+
557
+ const present = new Set();
558
+ for (const name of names) {
559
+ if (!name.endsWith('.pack')) continue;
560
+ const packPath = path.join(this.packDir, name);
561
+ present.add(packPath);
562
+ if (this.packs.has(packPath)) continue;
563
+ try {
564
+ this.packs.set(packPath, await PackFile.open(packPath));
565
+ } catch (error) {
566
+ if (error.code === 'ENOENT') continue; // repack raced us
567
+ throw error;
568
+ }
569
+ }
570
+ for (const packPath of [...this.packs.keys()]) {
571
+ if (!present.has(packPath)) this.packs.delete(packPath);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * @param {String} oid
577
+ * @returns {Promise<Boolean>}
578
+ */
579
+ async has(oid) {
580
+ await this.scan();
581
+ for (const pack of this.packs.values()) if (pack.has(oid)) return true;
582
+
583
+ await this.scan(true); // a repack may have moved it
584
+ for (const pack of this.packs.values()) if (pack.has(oid)) return true;
585
+ return false;
586
+ }
587
+
588
+ /**
589
+ * @param {String} oid
590
+ * @returns {Promise<{type, size, payload}|null>}
591
+ */
592
+ async read(oid) {
593
+ await this.scan();
594
+ for (const pack of this.packs.values()) {
595
+ const found = await pack.read(oid);
596
+ if (found) return found;
597
+ }
598
+
599
+ await this.scan(true);
600
+ for (const pack of this.packs.values()) {
601
+ const found = await pack.read(oid);
602
+ if (found) return found;
603
+ }
604
+ return null;
605
+ }
606
+
607
+ /**
608
+ * @returns {Promise<Array<String>>}
609
+ */
610
+ async listAll() {
611
+ await this.scan(true);
612
+ const all = [];
613
+ for (const pack of this.packs.values()) {
614
+ if (pack.index) all.push(...pack.index.oids());
615
+ }
616
+ return all;
617
+ }
618
+ }
619
+
620
+ // ─── writing ─────────────────────────────────────────────
621
+
622
+ /**
623
+ * Encode a pack object header.
624
+ * @param {Number} typeCode
625
+ * @param {Number} size
626
+ * @returns {Buffer}
627
+ */
628
+ function encodeObjectHeader(typeCode, size) {
629
+ const bytes = [];
630
+ let remaining = size;
631
+ let first = (typeCode << 4) | (remaining & 0x0f);
632
+ remaining = Math.floor(remaining / 16);
633
+
634
+ while (remaining > 0) {
635
+ bytes.push(first | 0x80);
636
+ first = remaining & 0x7f;
637
+ remaining = Math.floor(remaining / 128);
638
+ }
639
+ bytes.push(first);
640
+ return Buffer.from(bytes);
641
+ }
642
+
643
+ /**
644
+ * Write a pack of full (undeltified) objects.
645
+ *
646
+ * @param {Array<{oid: String, type: String, payload: Buffer}>} objects
647
+ * @returns {{pack: Buffer, entries: Array<{oid, offset, crc}>, checksum: String}}
648
+ */
649
+ function buildPack(objects) {
650
+ const header = Buffer.alloc(12);
651
+ header.writeUInt32BE(PACK_SIGNATURE, 0);
652
+ header.writeUInt32BE(2, 4);
653
+ header.writeUInt32BE(objects.length, 8);
654
+
655
+ const parts = [header];
656
+ const entries = [];
657
+ let offset = header.length;
658
+
659
+ for (const object of objects) {
660
+ if (!CODE_BY_TYPE[object.type]) throw new PackError(`cannot pack object type '${object.type}'`);
661
+
662
+ const objectHeader = encodeObjectHeader(CODE_BY_TYPE[object.type], object.payload.length);
663
+ const compressed = zlib.deflateSync(object.payload);
664
+ const record = Buffer.concat([objectHeader, compressed]);
665
+
666
+ entries.push({ oid: object.oid, offset, crc: zlib.crc32(record) });
667
+ parts.push(record);
668
+ offset += record.length;
669
+ }
670
+
671
+ const body = Buffer.concat(parts);
672
+ const checksum = crypto.createHash('sha256').update(body).digest();
673
+ return {
674
+ pack: Buffer.concat([body, checksum]),
675
+ entries,
676
+ checksum: checksum.toString('hex')
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Parse a pack that has no index yet — an incoming push or fetch. Objects are
682
+ * decoded in order; REF_DELTA bases outside the pack go through `resolveBase`.
683
+ *
684
+ * @param {Buffer} buffer
685
+ * @param {Object} [options]
686
+ * @param {(oid: String) => Promise<{type, payload}|null>} [options.resolveBase]
687
+ * @param {Number} [options.maxObjects]
688
+ * @returns {Promise<Array<{oid, type, payload}>>}
689
+ */
690
+ async function readPackStream(buffer, options = {}) {
691
+ if (buffer.length < 12 + OID_RAW_LENGTH) throw new PackError('pack is too short');
692
+ if (buffer.readUInt32BE(0) !== PACK_SIGNATURE) throw new PackError('pack does not start with PACK');
693
+
694
+ const version = buffer.readUInt32BE(4);
695
+ if (!SUPPORTED_PACK_VERSIONS.has(version)) throw new PackError(`pack version ${version} is not supported`);
696
+
697
+ const count = buffer.readUInt32BE(8);
698
+ if (options.maxObjects !== undefined && count > options.maxObjects) {
699
+ throw new PackError(`pack declares ${count} objects, over the ${options.maxObjects} limit`);
700
+ }
701
+
702
+ const body = buffer.subarray(0, buffer.length - OID_RAW_LENGTH);
703
+ const declared = buffer.toString('hex', buffer.length - OID_RAW_LENGTH);
704
+ const actual = crypto.createHash('sha256').update(body).digest('hex');
705
+ if (declared !== actual) {
706
+ throw new PackError(`pack checksum mismatch: trailer says ${declared.slice(0, 12)}, contents hash to ${actual.slice(0, 12)}`);
707
+ }
708
+
709
+ const maxBytes = options.maxBytes || 128 * 1024 * 1024;
710
+ if (buffer.length > maxBytes) throw new PackError('pack exceeds transfer limit');
711
+ const records = [];
712
+ let offset = 12, total = 0;
713
+ for (let i = 0; i < count; i++) {
714
+ const start = offset;
715
+ const header = readObjectHeader(body, offset);
716
+ offset = header.offset;
717
+ let baseOffset = null, baseOid = null;
718
+ if (header.type === OBJ_OFS_DELTA) {
719
+ const delta = readOffsetDelta(body, offset);
720
+ offset = delta.offset;
721
+ baseOffset = start - delta.distance;
722
+ if (baseOffset < 12 || baseOffset >= start) throw new PackError('invalid offset delta base');
723
+ } else if (header.type === OBJ_REF_DELTA) {
724
+ if (offset + OID_RAW_LENGTH > body.length) throw new PackError('truncated ref delta');
725
+ baseOid = body.toString('hex', offset, offset + OID_RAW_LENGTH);
726
+ offset += OID_RAW_LENGTH;
727
+ } else if (!TYPE_BY_CODE[header.type]) {
728
+ throw new PackError(`unknown pack object type ${header.type}`);
729
+ }
730
+ const { data, consumed } = await inflateAt(body, offset, header.size);
731
+ offset += consumed;
732
+ total += data.length;
733
+ if (total > maxBytes) throw new PackError('inflated pack exceeds transfer limit');
734
+ records.push({ start, header, baseOffset, baseOid, data });
735
+ }
736
+ if (offset !== body.length) throw new PackError('pack contains trailing data');
737
+ const byOffset = new Map(), byOid = new Map(), results = [];
738
+ let pending = records;
739
+ for (let pass = 0; pass <= MAX_DELTA_DEPTH; pass++) {
740
+ const remaining = [];
741
+ for (const record of pending) {
742
+ const { start, header, baseOffset, baseOid, data } = record;
743
+ let resolved, depth = 0;
744
+ if (baseOffset !== null || baseOid !== null) {
745
+ let base = baseOffset !== null ? byOffset.get(baseOffset) : byOid.get(baseOid);
746
+ if (!base && baseOid && options.resolveBase) base = await options.resolveBase(baseOid);
747
+ if (!base) { remaining.push(record); continue; }
748
+ depth = (base.depth || 0) + 1;
749
+ if (depth > MAX_DELTA_DEPTH) throw new PackError('delta chain exceeds depth limit');
750
+ resolved = { type: base.type, payload: applyDelta(base.payload, data) };
751
+ total += resolved.payload.length;
752
+ if (total > maxBytes) throw new PackError('resolved pack exceeds transfer limit');
753
+ } else resolved = { type: TYPE_BY_CODE[header.type], payload: data };
754
+ if (!OBJECT_TYPES.includes(resolved.type)) throw new PackError('invalid resolved type');
755
+ resolved.oid = hashObject(resolved.type, resolved.payload);
756
+ resolved.depth = depth;
757
+ byOffset.set(start, resolved); byOid.set(resolved.oid, resolved); results.push(resolved);
758
+ }
759
+ if (!remaining.length) return results.map(({ depth, ...item }) => item);
760
+ if (remaining.length === pending.length) throw new PackError('missing or cyclic delta base');
761
+ pending = remaining;
762
+ }
763
+ throw new PackError('delta chain exceeds resolution limit');
764
+ }
765
+
766
+ /**
767
+ * Write a pack and its index into <objects>/pack, publishing both atomically.
768
+ * @param {String} objectsDir
769
+ * @param {Array<{oid, type, payload}>} objects
770
+ * @returns {Promise<{packPath: String, idxPath: String, checksum: String}>}
771
+ */
772
+ async function writePackToStore(objectsDir, objects) {
773
+ const { pack, entries, checksum } = buildPack(objects);
774
+ const idx = buildPackIndex(entries, checksum);
775
+
776
+ const packDir = path.join(objectsDir, 'pack');
777
+ await fs.mkdir(packDir, { recursive: true });
778
+
779
+ const base = path.join(packDir, `pack-${checksum}`);
780
+ const tmpPack = `${base}.pack.tmp`;
781
+ const tmpIdx = `${base}.idx.tmp`;
782
+
783
+ await fs.writeFile(tmpPack, pack);
784
+ await fs.writeFile(tmpIdx, idx);
785
+ // Index last: a .pack without a .idx is invisible, the reverse is corrupt.
786
+ await fs.rename(tmpPack, `${base}.pack`);
787
+ await fs.rename(tmpIdx, `${base}.idx`);
788
+
789
+ return { packPath: `${base}.pack`, idxPath: `${base}.idx`, checksum };
790
+ }
791
+
792
+ module.exports = {
793
+ PackError,
794
+ PackFile,
795
+ PackIndex,
796
+ PackStore,
797
+ buildPack,
798
+ buildPackIndex,
799
+ readPackStream,
800
+ writePackToStore,
801
+ applyDelta,
802
+ inflateAt,
803
+ encodeObjectHeader,
804
+ MAX_DELTA_DEPTH,
805
+ MAX_OBJECT_BYTES,
806
+ OBJ_COMMIT,
807
+ OBJ_TREE,
808
+ OBJ_BLOB,
809
+ OBJ_TAG,
810
+ OBJ_OFS_DELTA,
811
+ OBJ_REF_DELTA
812
+ };