1/**
2 * Tier-1 pack parse/write round-trip tests (DESIGN.md §9) against the
3 * checked-in fixture packs in `test/fixtures/packs/` (generated once by the
4 * real `git` CLI — see the README there; never regenerated at test time).
5 *
6 * Covers: header/count validation, no-delta ingestion with byte-verbatim
7 * zdata (re-hash proof), OFS_DELTA and REF_DELTA resolution, thin-pack
8 * resolution against a seeded object store (and the MissingDeltaBaseError
9 * without it), trailer-checksum rejection, PackWriter → PackParser
10 * round-trips, and `git index-pack --strict` as the oracle for writer output.
11 */
12import * as BunServices from "@effect/platform-bun/BunServices";
13import { describe, expect, it } from "alchemy-test";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as Result from "effect/Result";
18import * as Stream from "effect/Stream";
19import * as ChildProcess from "effect/process/ChildProcess";
20import {
21 concatBytes,
22 decodeOfsDeltaOffset,
23 decodeTypeSize,
24 hashObject,
25 type ObjectType,
26} from "@/Git/Protocol/ObjectCodec.ts";
27import {
28 bufferRandomAccess,
29 ingestPack,
30 readPackHeader,
31 type ResolvedEntry,
32 type ThinBaseSource,
33} from "@/Git/Protocol/PackParser.ts";
34import { writePackBytes } from "@/Git/Protocol/PackWriter.ts";
35import type { ManifestEntry, ObjectSource } from "@/Git/Protocol/Store.ts";
36import { inflate, inflateEntry } from "@/Git/Protocol/Zlib.ts";
37import { makeStreamingSource } from "@/Git/Store/StreamingSource.ts";
38import * as Fiber from "effect/Fiber";
39
40// ── manifest shape (test/fixtures/packs/manifest.json) ──────────────────────
41
42interface PackManifest {
43 readonly commits: {
44 readonly c1: string;
45 readonly c2: string;
46 readonly c3: string;
47 };
48 readonly tag: {
49 readonly oid: string;
50 readonly target: string;
51 readonly name: string;
52 };
53 readonly tree1: string;
54 readonly blobs: {
55 readonly data1: string;
56 readonly data2: string;
57 readonly hello1: string;
58 };
59 readonly packs: Record<
60 string,
61 {
62 readonly count: number;
63 readonly oids: ReadonlyArray<string>;
64 readonly thin?: boolean;
65 }
66 >;
67}
68
69const fixturesDir = Effect.gen(function* () {
70 const path = yield* Path.Path;
71 return path.join(import.meta.dirname, "fixtures", "packs");
72});
73
74const readFixture = Effect.fn(function* (name: string) {
75 const fs = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77 const dir = yield* fixturesDir;
78 return yield* fs.readFile(path.join(dir, name));
79});
80
81const readManifest = Effect.gen(function* () {
82 const fs = yield* FileSystem.FileSystem;
83 const path = yield* Path.Path;
84 const dir = yield* fixturesDir;
85 const text = yield* fs.readFileString(path.join(dir, "manifest.json"));
86 return yield* Effect.try(() => JSON.parse(text) as PackManifest);
87});
88
89// ── in-memory ObjectSource fed by the ingest sink ───────────────────────────
90
91interface StoredObject {
92 readonly type: ObjectType;
93 readonly content: Uint8Array;
94 readonly zdata: Uint8Array;
95}
96
97const makeMemoryStore = () => {
98 const objects = new Map<string, StoredObject>();
99 const source: ObjectSource & ThinBaseSource = {
100 has: (oid) => Effect.sync(() => objects.has(oid)),
101 filterExisting: (oids) =>
102 Effect.sync(() => oids.filter((oid) => objects.has(oid))),
103 getMeta: (oid) =>
104 Effect.sync(() => {
105 const stored = objects.get(oid);
106 return stored === undefined
107 ? undefined
108 : {
109 oid,
110 type: stored.type,
111 size: stored.content.length,
112 zsize: stored.zdata.length,
113 location: "row" as const,
114 };
115 }),
116 readZData: (oid) =>
117 Stream.fromEffect(Effect.sync(() => objects.get(oid)!.zdata)),
118 readBase: (oid) =>
119 Effect.sync(() => {
120 const stored = objects.get(oid);
121 return stored === undefined
122 ? undefined
123 : { type: stored.type, content: stored.content };
124 }),
125 readContent: (oid) => Effect.sync(() => objects.get(oid)!.content),
126 };
127 /** Ingest sink: stage the resolved entry (zdata is already a safe copy). */
128 const sink = (entry: ResolvedEntry) =>
129 Effect.gen(function* () {
130 const content = yield* inflate(entry.zdata);
131 objects.set(entry.oid, { type: entry.type, content, zdata: entry.zdata });
132 });
133 const manifestEntries = (): ReadonlyArray<ManifestEntry> =>
134 Array.from(objects.entries(), ([oid, stored]) => ({
135 oid,
136 type: stored.type,
137 size: stored.content.length,
138 zsize: stored.zdata.length,
139 location: "row" as const,
140 }));
141 return { objects, source, sink, manifestEntries };
142};
143
144const ingestInto = Effect.fn(function* (
145 pack: Uint8Array,
146 store: ReturnType<typeof makeMemoryStore>,
147) {
148 return yield* ingestPack({
149 source: bufferRandomAccess(pack),
150 store: store.source,
151 sink: store.sink,
152 });
153});
154
155/** Scan the raw entry types of a pack without resolving anything. */
156const scanEntryTypes = Effect.fn(function* (pack: Uint8Array) {
157 const view = new DataView(pack.buffer, pack.byteOffset, pack.byteLength);
158 const count = view.getUint32(8);
159 const types: Array<number> = [];
160 let offset = 12;
161 for (let i = 0; i < count; i++) {
162 const header = decodeTypeSize(pack, offset);
163 offset = header.next;
164 if (header.type === 6) offset = decodeOfsDeltaOffset(pack, offset).next;
165 if (header.type === 7) offset += 20;
166 const entry = yield* inflateEntry(pack, offset);
167 offset += entry.bytesConsumed;
168 types.push(header.type);
169 }
170 return types;
171});
172
173const sortedOids = (oids: ReadonlyArray<string>) => [...oids].sort();
174
175const platform = <A, E, R>(
176 effect: Effect.Effect<A, E, R>,
177): Effect.Effect<A, E, Exclude<R, BunServices.BunServices>> =>
178 Effect.provide(effect, BunServices.layer) as Effect.Effect<
179 A,
180 E,
181 Exclude<R, BunServices.BunServices>
182 >;
183
184describe("fixture pack parsing", { tags: ["unit", "local"] }, () => {
185 it.live("empty pack: count 0, valid trailer, zero objects", () =>
186 Effect.gen(function* () {
187 const pack = yield* readFixture("empty.pack");
188 const header = yield* readPackHeader(bufferRandomAccess(pack));
189 expect(header.version).toBe(2);
190 expect(header.count).toBe(0);
191 const store = makeMemoryStore();
192 const summary = yield* ingestInto(pack, store);
193 expect(summary.count).toBe(0);
194 expect(summary.oids).toEqual([]);
195 }).pipe(platform),
196 );
197
198 it.live(
199 "simple pack (window=0): no deltas, byte-verbatim zdata re-hashes to the manifest oids",
200 () =>
201 Effect.gen(function* () {
202 const manifest = yield* readManifest;
203 const pack = yield* readFixture("simple.pack");
204 const types = yield* scanEntryTypes(pack);
205 expect(types).not.toContain(6);
206 expect(types).not.toContain(7);
207
208 const store = makeMemoryStore();
209 const summary = yield* ingestInto(pack, store);
210 expect(sortedOids(summary.oids)).toEqual(
211 sortedOids(manifest.packs["simple"]!.oids),
212 );
213 // the ingest stores the pack's compressed span verbatim; re-hashing the
214 // inflated content must reproduce every oid
215 for (const [oid, stored] of store.objects) {
216 expect(yield* hashObject(stored.type, stored.content)).toBe(oid);
217 }
218 // the notable objects landed with the right kinds
219 expect(store.objects.get(manifest.commits.c1)?.type).toBe(1);
220 expect(store.objects.get(manifest.tree1)?.type).toBe(2);
221 expect(store.objects.get(manifest.blobs.data1)?.type).toBe(3);
222 expect(store.objects.get(manifest.tag.oid)?.type).toBe(4);
223 }).pipe(platform),
224 );
225
226 it.live(
227 "streaming: delta bases evicted before the body ends are deferred and resolved through the fallback (DESIGN §22.6)",
228 () =>
229 Effect.gen(function* () {
230 const manifest = yield* readManifest;
231 const pack = yield* readFixture("ofs-delta.pack");
232 // Retain almost nothing, so every base is evicted before its
233 // delta arrives; a parser that blocked on evicted bytes would
234 // deadlock against backpressure here.
235 const feeder = makeStreamingSource({
236 slabBytes: 256,
237 retainBytes: 512,
238 backpressureBytes: 1024,
239 });
240 const store = makeMemoryStore();
241 const parse = yield* Effect.forkChild(
242 ingestPack({
243 source: feeder.source,
244 store: store.source,
245 sink: store.sink,
246 }),
247 );
248 for (let at = 0; at < pack.length; at += 300) {
249 yield* feeder.push(
250 pack.subarray(at, Math.min(at + 300, pack.length)),
251 );
252 }
253 feeder.setFallback(bufferRandomAccess(pack));
254 feeder.end();
255 const summary = yield* Fiber.join(parse);
256 expect(sortedOids(summary.oids)).toEqual(
257 sortedOids(manifest.packs["ofs-delta"]!.oids),
258 );
259 }).pipe(platform),
260 );
261
262 it.live(
263 "ofs-delta pack: OFS_DELTA entries resolve to the same object set",
264 () =>
265 Effect.gen(function* () {
266 const manifest = yield* readManifest;
267 const pack = yield* readFixture("ofs-delta.pack");
268 const types = yield* scanEntryTypes(pack);
269 expect(types).toContain(6);
270
271 const store = makeMemoryStore();
272 const resolvedFromDelta: Array<string> = [];
273 const summary = yield* ingestPack({
274 source: bufferRandomAccess(pack),
275 store: store.source,
276 sink: (entry) =>
277 Effect.gen(function* () {
278 if (entry.fromDelta) resolvedFromDelta.push(entry.oid);
279 yield* store.sink(entry);
280 }),
281 });
282 expect(sortedOids(summary.oids)).toEqual(
283 sortedOids(manifest.packs["ofs-delta"]!.oids),
284 );
285 expect(resolvedFromDelta.length).toBeGreaterThan(0);
286 for (const [oid, stored] of store.objects) {
287 expect(yield* hashObject(stored.type, stored.content)).toBe(oid);
288 }
289 }).pipe(platform),
290 );
291
292 it.live(
293 "ref-delta pack: REF_DELTA entries resolve to the same object set",
294 () =>
295 Effect.gen(function* () {
296 const manifest = yield* readManifest;
297 const pack = yield* readFixture("ref-delta.pack");
298 const types = yield* scanEntryTypes(pack);
299 expect(types).toContain(7);
300
301 const store = makeMemoryStore();
302 const summary = yield* ingestInto(pack, store);
303 expect(sortedOids(summary.oids)).toEqual(
304 sortedOids(manifest.packs["ref-delta"]!.oids),
305 );
306 }).pipe(platform),
307 );
308
309 it.live("thin pack: resolves REF_DELTA bases from the seeded store", () =>
310 Effect.gen(function* () {
311 const manifest = yield* readManifest;
312 const store = makeMemoryStore();
313 // seed with c1's closure (base.pack), then ingest the thin pack
314 yield* ingestInto(yield* readFixture("base.pack"), store);
315 const thin = yield* readFixture("thin.pack");
316 const types = yield* scanEntryTypes(thin);
317 expect(types).toContain(7);
318 const summary = yield* ingestInto(thin, store);
319 expect(sortedOids(summary.oids)).toEqual(
320 sortedOids(manifest.packs["thin"]!.oids),
321 );
322 // the extended blob resolved against the excluded base blob
323 expect(summary.oids).toContain(manifest.blobs.data2);
324 const data2 = store.objects.get(manifest.blobs.data2)!;
325 expect(yield* hashObject(data2.type, data2.content)).toBe(
326 manifest.blobs.data2,
327 );
328 }).pipe(platform),
329 );
330
331 it.live("thin pack without its bases fails with MissingDeltaBaseError", () =>
332 Effect.gen(function* () {
333 const thin = yield* readFixture("thin.pack");
334 const empty = makeMemoryStore();
335 const result = yield* Effect.result(ingestInto(thin, empty));
336 expect(Result.isFailure(result)).toBe(true);
337 if (Result.isFailure(result)) {
338 expect(result.failure._tag).toBe("MissingDeltaBaseError");
339 }
340 }).pipe(platform),
341 );
342
343 it.live("corrupted trailer is rejected with PackChecksumMismatch", () =>
344 Effect.gen(function* () {
345 const pack = Uint8Array.from(yield* readFixture("simple.pack"));
346 pack[pack.length - 1]! ^= 0xff;
347 const result = yield* Effect.result(ingestInto(pack, makeMemoryStore()));
348 expect(Result.isFailure(result)).toBe(true);
349 if (Result.isFailure(result)) {
350 expect(result.failure._tag).toBe("PackChecksumMismatch");
351 }
352 }).pipe(platform),
353 );
354});
355
356describe("PackWriter round-trips", { tags: ["local"] }, () => {
357 it.live(
358 "rewrite of an ingested delta pack re-parses to an identical object set",
359 () =>
360 Effect.gen(function* () {
361 const manifest = yield* readManifest;
362 const store = makeMemoryStore();
363 yield* ingestInto(yield* readFixture("ofs-delta.pack"), store);
364
365 const rewritten = concatBytes(
366 yield* Stream.runCollect(
367 writePackBytes(store.manifestEntries(), store.source),
368 ),
369 );
370 const header = yield* readPackHeader(bufferRandomAccess(rewritten));
371 expect(header.version).toBe(2);
372 expect(header.count).toBe(manifest.packs["ofs-delta"]!.count);
373
374 const reparsed = makeMemoryStore();
375 const summary = yield* ingestInto(rewritten, reparsed);
376 expect(sortedOids(summary.oids)).toEqual(
377 sortedOids(manifest.packs["ofs-delta"]!.oids),
378 );
379 for (const [oid, stored] of reparsed.objects) {
380 expect(yield* hashObject(stored.type, stored.content)).toBe(oid);
381 }
382 }).pipe(platform),
383 { tags: ["unit"] },
384 );
385
386 it.live(
387 "writer output is accepted by git index-pack --strict",
388 () =>
389 Effect.gen(function* () {
390 const fs = yield* FileSystem.FileSystem;
391 const path = yield* Path.Path;
392 const store = makeMemoryStore();
393 yield* ingestInto(yield* readFixture("simple.pack"), store);
394 const rewritten = concatBytes(
395 yield* Stream.runCollect(
396 writePackBytes(store.manifestEntries(), store.source),
397 ),
398 );
399 const dir = yield* fs.makeTempDirectory({
400 prefix: "git-service-pack-",
401 });
402 const packPath = path.join(dir, "writer-output.pack");
403 yield* fs.writeFile(packPath, rewritten);
404
405 const handle = yield* ChildProcess.make(
406 "git",
407 ["index-pack", "--strict", packPath],
408 { cwd: dir, extendEnv: true },
409 );
410 const [exitCode, stderr] = yield* Effect.all(
411 [handle.exitCode, Stream.mkString(Stream.decodeText(handle.stderr))],
412 { concurrency: 2 },
413 );
414 expect(`${exitCode}:${stderr}`).toBe(`0:${stderr}`);
415 expect(exitCode).toBe(0);
416 }).pipe(platform, Effect.timeout("30 seconds")),
417 { timeout: 60_000 },
418 );
419});