Skip to content
Source
Sign inSign up
Open History to see the last commit

codec.test.ts

483 lines17.6 KB

1import { applyDelta, readDeltaHeader } from "@alchemy.run/git/Protocol/Delta";
2import {
3 bytesToHex,
4 concatBytes,
5 decodeOfsDeltaOffset,
6 decodeSizeVarint,
7 decodeTypeSize,
8 encodeCommit,
9 encodeOfsDeltaOffset,
10 encodeSizeVarint,
11 encodeTree,
12 encodeTypeSize,
13 formatIdentity,
14 hashObject,
15 hexToBytes,
16 ObjectType,
17 parseCommit,
18 parseTree,
19 treeEntryCompare,
20 utf8Decode,
21 utf8Encode,
22} from "@alchemy.run/git/Protocol/ObjectCodec";
23import {
24 decodePktLines,
25 decodePktStream,
26 errPkt,
27 flushPkt,
28 MAX_PKT_PAYLOAD,
29 pktLine,
30 pktPayloadText,
31 pktText,
32 readPktLineAt,
33} from "@alchemy.run/git/Protocol/Pkt";
34import { SIDEBAND_DATA_MAX, sidebandFrames } from "@alchemy.run/git/Protocol/Sideband";
35import { deflate, inflate, inflateEntry } from "@alchemy.run/git/Protocol/Zlib";
36/**
37 * Tier-1 pure codec tests (DESIGN.md §9): pkt-line framing, pack varints
38 * (including the OFS_DELTA +1-bias multibyte offsets), delta application
39 * (including the `size==0 ⇒ 0x10000` copy special case), tree sort rules,
40 * object hashing against real-git-computed oids, and zlib entry-boundary
41 * accounting over concatenated deflate streams.
42 *
43 * No cloud, no deploy — everything runs in-process in milliseconds.
44 */
45import { describe, expect, it } from "@alchemy.run/test";
46import * as Effect from "effect/Effect";
47import * as Result from "effect/Result";
48import * as Stream from "effect/Stream";
49​
50describe("pkt-line", { tags: ["unit", "local"] }, () => {
51 it.live("encodes data, text (LF-appended), flush, and ERR pkts", () =>
52 Effect.gen(function* () {
53 expect(utf8Decode(pktText("done"))).toBe("0009done\n");
54 // pktText must not double-append the LF
55 expect(utf8Decode(pktText("done\n"))).toBe("0009done\n");
56 expect(utf8Decode(flushPkt)).toBe("0000");
57 expect(utf8Decode(errPkt("boom"))).toBe("000dERR boom\n");
58 const data = pktLine(utf8Encode("abc"));
59 expect(utf8Decode(data)).toBe("0007abc");
60 }),
61 );
62​
63 it.live("round-trips a mixed body and strips trailing LF in payload text", () =>
64 Effect.gen(function* () {
65 const body = concatBytes([
66 pktText("want 0000000000000000000000000000000000000001 side-band-64k"),
67 flushPkt,
68 pktText("done"),
69 ]);
70 const pkts = yield* decodePktLines(body);
71 expect(pkts.length).toBe(3);
72 expect(pkts[0]!._tag).toBe("data");
73 expect(pkts[1]!._tag).toBe("flush");
74 expect(pkts[2]!._tag).toBe("data");
75 const first = pkts[0]!;
76 if (first._tag === "data") {
77 expect(pktPayloadText(first.payload)).toBe(
78 "want 0000000000000000000000000000000000000001 side-band-64k",
79 );
80 }
81 }),
82 );
83​
84 it.live("stream decoder survives pathological 1-byte chunking", () =>
85 Effect.gen(function* () {
86 const body = concatBytes([pktText("hello"), flushPkt, pktLine(utf8Encode("raw"))]);
87 const chunks: Uint8Array[] = [];
88 for (let i = 0; i < body.length; i++) {
89 chunks.push(body.subarray(i, i + 1));
90 }
91 const pkts = yield* Stream.runCollect(decodePktStream(Stream.fromArray(chunks)));
92 expect(pkts.length).toBe(3);
93 }),
94 );
95​
96 it.live("readPktLineAt reports incomplete and invalid frames", () =>
97 Effect.gen(function* () {
98 // a declared 8-byte pkt with only 6 bytes present
99 const partial = utf8Encode("0008ab");
100 expect(readPktLineAt(partial, 0)._tag).toBe("incomplete");
101 // non-hex length prefix
102 expect(readPktLineAt(utf8Encode("zzzzabcd"), 0)._tag).toBe("invalid");
103 // lengths 1-3 are reserved/invalid
104 expect(readPktLineAt(utf8Encode("0003x"), 0)._tag).toBe("invalid");
105 }),
106 );
107​
108 it.live("rejects payloads over the 65516-byte cap", () =>
109 Effect.gen(function* () {
110 const max = new Uint8Array(MAX_PKT_PAYLOAD);
111 expect(pktLine(max).length).toBe(4 + MAX_PKT_PAYLOAD);
112 expect(() => pktLine(new Uint8Array(MAX_PKT_PAYLOAD + 1))).toThrow();
113 }),
114 );
115});
116​
117describe("sideband framing", { tags: ["unit", "local"] }, () => {
118 it.live("splits payloads at the 65515-byte data cap with the band byte", () =>
119 Effect.gen(function* () {
120 const big = new Uint8Array(SIDEBAND_DATA_MAX + 10).fill(7);
121 const frames = sidebandFrames(1, big);
122 expect(frames.length).toBe(2);
123 expect(frames[0]!.length).toBe(4 + 1 + SIDEBAND_DATA_MAX);
124 expect(frames[1]!.length).toBe(4 + 1 + 10);
125 expect(frames[0]![4]).toBe(1);
126 expect(frames[1]![4]).toBe(1);
127 }),
128 );
129});
130​
131describe("pack varints", { tags: ["unit", "local"] }, () => {
132 it.live("type/size header round-trips across the continuation boundaries", () =>
133 Effect.gen(function* () {
134 for (const size of [0, 15, 16, 127, 128, 4095, 4096, 1 << 20, 2 ** 32 + 5]) {
135 for (const type of [1, 2, 3, 4, 6, 7] as const) {
136 const enc = encodeTypeSize(type, size);
137 const dec = decodeTypeSize(enc, 0);
138 expect(dec.type).toBe(type);
139 expect(dec.size).toBe(size);
140 expect(dec.next).toBe(enc.length);
141 }
142 }
143 // 4-bit sizes fit in one byte; 5 bits force a continuation
144 expect(encodeTypeSize(ObjectType.blob, 15).length).toBe(1);
145 expect(encodeTypeSize(ObjectType.blob, 16).length).toBe(2);
146 }),
147 );
148​
149 it.live("delta-payload size varint round-trips", () =>
150 Effect.gen(function* () {
151 for (const v of [0, 1, 127, 128, 16383, 16384, 2 ** 31]) {
152 const enc = encodeSizeVarint(v);
153 const dec = decodeSizeVarint(enc, 0);
154 expect(dec.value).toBe(v);
155 expect(dec.next).toBe(enc.length);
156 }
157 }),
158 );
159​
160 it.live("OFS_DELTA offsets carry the +1 bias per continuation byte", () =>
161 Effect.gen(function* () {
162 // The classic bug: without the bias, [0x80, 0x00] would decode as 0.
163 // With it, two-byte encodings cover exactly 128..16511 (128 + 2^14 - 1).
164 expect(encodeOfsDeltaOffset(127).length).toBe(1);
165 expect(encodeOfsDeltaOffset(128).length).toBe(2);
166 expect(Array.from(encodeOfsDeltaOffset(128))).toEqual([0x80, 0x00]);
167 expect(encodeOfsDeltaOffset(16511).length).toBe(2);
168 expect(encodeOfsDeltaOffset(16512).length).toBe(3);
169 for (const v of [1, 127, 128, 129, 255, 256, 16383, 16384, 16511, 16512, 2 ** 24]) {
170 const enc = encodeOfsDeltaOffset(v);
171 const dec = decodeOfsDeltaOffset(enc, 0);
172 expect(dec.value).toBe(v);
173 expect(dec.next).toBe(enc.length);
174 }
175 // known decode vectors
176 expect(decodeOfsDeltaOffset(Uint8Array.from([0x80, 0x00]), 0).value).toBe(128);
177 expect(decodeOfsDeltaOffset(Uint8Array.from([0x81, 0x00]), 0).value).toBe(256);
178 }),
179 );
180});
181​
182describe("delta application", { tags: ["unit", "local"] }, () => {
183 it.live("applies copy + insert instruction streams", () =>
184 Effect.gen(function* () {
185 const base = utf8Encode("the quick brown fox jumps over the lazy dog");
186 const delta = concatBytes([
187 encodeSizeVarint(base.length),
188 encodeSizeVarint(16 + 5 + 18),
189 Uint8Array.from([0b10010001, 4, 16]), // copy offset=4 size=16
190 Uint8Array.from([5]), // insert 5 bytes
191 utf8Encode("leaps"),
192 Uint8Array.from([0b10010001, 25, 18]), // copy offset=25 size=18
193 ]);
194 const header = readDeltaHeader(delta);
195 expect(header.baseSize).toBe(base.length);
196 expect(header.resultSize).toBe(39);
197 const out = yield* applyDelta(base, delta);
198 expect(utf8Decode(out)).toBe("quick brown fox leaps over the lazy dog");
199 }),
200 );
201​
202 it.live("copy size 0 means 0x10000 bytes", () =>
203 Effect.gen(function* () {
204 const base = new Uint8Array(0x10000 + 10);
205 for (let i = 0; i < base.length; i++) base[i] = i % 251;
206 const delta = concatBytes([
207 encodeSizeVarint(base.length),
208 encodeSizeVarint(0x10000),
209 // copy bit set, no offset bytes, no size bytes ⇒ offset 0, size 0x10000
210 Uint8Array.from([0b10000000]),
211 ]);
212 const out = yield* applyDelta(base, delta);
213 expect(out.length).toBe(0x10000);
214 expect(out[0]).toBe(base[0]);
215 expect(out[0xffff]).toBe(base[0xffff]);
216 }),
217 );
218​
219 it.live("rejects a result-size mismatch as DeltaFormatError", () =>
220 Effect.gen(function* () {
221 const base = utf8Encode("0123456789");
222 const delta = concatBytes([
223 encodeSizeVarint(base.length),
224 encodeSizeVarint(99), // declared result size never produced
225 Uint8Array.from([0b10010001, 0, 4]), // copy 4 bytes
226 ]);
227 const result = yield* Effect.result(applyDelta(base, delta));
228 expect(Result.isFailure(result)).toBe(true);
229 if (Result.isFailure(result)) {
230 expect(result.failure._tag).toBe("DeltaFormatError");
231 }
232 }),
233 );
234});
235​
236describe("tree codec", { tags: ["unit", "local"] }, () => {
237 it.live("sorts directories as name + '/' (the fsck rule)", () =>
238 Effect.gen(function* () {
239 const entries = [
240 { mode: "100644", name: "foo.txt", oid: "a".repeat(40) },
241 { mode: "40000", name: "foo", oid: "b".repeat(40) },
242 { mode: "100644", name: "foo", oid: "c".repeat(40) },
243 ];
244 const sorted = [...entries].sort(treeEntryCompare);
245 // blob "foo" < blob "foo.txt" < tree "foo" (compares as "foo/")
246 expect(sorted[0]!.oid).toBe("c".repeat(40));
247 expect(sorted[1]!.name).toBe("foo.txt");
248 expect(sorted[2]!.mode).toBe("40000");
249 }),
250 );
251​
252 it.live("encode → parse round-trips and normalizes entry order", () =>
253 Effect.gen(function* () {
254 const entries = [
255 { mode: "100644", name: "zebra", oid: "a".repeat(40) },
256 { mode: "40000", name: "dir", oid: "b".repeat(40) },
257 { mode: "100755", name: "run.sh", oid: "c".repeat(40) },
258 ];
259 const encoded = yield* encodeTree(entries);
260 const parsed = yield* parseTree(encoded);
261 expect(parsed.length).toBe(3);
262 expect(parsed.map((e) => e.name)).toEqual(["dir", "run.sh", "zebra"]);
263 expect(parsed[0]!.mode).toBe("40000");
264 }),
265 );
266});
267​
268describe("object hashing (real-git oracle oids)", { tags: ["unit", "local"] }, () => {
269 it.live("hashes blobs to the oids git computes", () =>
270 Effect.gen(function* () {
271 // git hash-object oracle values
272 expect(yield* hashObject(ObjectType.blob, utf8Encode("hello\n"))).toBe(
273 "ce013625030ba8dba906f756967f9e9ca394464a",
274 );
275 expect(yield* hashObject(ObjectType.blob, utf8Encode("hello world\n"))).toBe(
276 "3b18e512dba79e4c8300dd08aeb37f8e728b8dad",
277 );
278 // the famous empty blob / empty tree constants
279 expect(yield* hashObject(ObjectType.blob, new Uint8Array(0))).toBe(
280 "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
281 );
282 expect(yield* hashObject(ObjectType.tree, new Uint8Array(0))).toBe(
283 "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
284 );
285 }),
286 );
287​
288 it.live("hex codecs round-trip", () =>
289 Effect.gen(function* () {
290 const hex = "9e0b5a0bd293f9feda554193a204193a2c3c5d9c";
291 expect(bytesToHex(hexToBytes(hex))).toBe(hex);
292 }),
293 );
294});
295​
296describe("commit parsing", { tags: ["unit", "local"] }, () => {
297 it.live("parses tree/parents/identities/message", () =>
298 Effect.gen(function* () {
299 const tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
300 const parent = "ce013625030ba8dba906f756967f9e9ca394464a";
301 const commit = utf8Encode(
302 `tree ${tree}\n` +
303 `parent ${parent}\n` +
304 `author A U Thor <author@example.com> 1700000000 +0130\n` +
305 `committer C O Mitter <committer@example.com> 1700000100 -0500\n` +
306 `\n` +
307 `subject line\n\nbody\n`,
308 );
309 const parsed = yield* parseCommit(commit);
310 expect(parsed.tree).toBe(tree);
311 expect(parsed.parents).toEqual([parent]);
312 expect(parsed.author.email).toBe("author@example.com");
313 expect(parsed.author.when).toBe(1700000000);
314 expect(parsed.author.tz).toBe("+0130");
315 expect(parsed.committer.tz).toBe("-0500");
316 expect(parsed.message).toBe("subject line\n\nbody\n");
317 }),
318 );
319​
320 it.live("preserves gpgsig continuation lines", () =>
321 Effect.gen(function* () {
322 const tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
323 const commit = utf8Encode(
324 `tree ${tree}\n` +
325 `author A <a@b.c> 1 +0000\n` +
326 `committer A <a@b.c> 1 +0000\n` +
327 `gpgsig -----BEGIN PGP SIGNATURE-----\n` +
328 ` line2\n` +
329 ` -----END PGP SIGNATURE-----\n` +
330 `\n` +
331 `msg\n`,
332 );
333 const parsed = yield* parseCommit(commit);
334 expect(parsed.gpgsig).toContain("line2");
335 expect(parsed.gpgsig).toContain("-----END PGP SIGNATURE-----");
336 }),
337 );
338});
339​
340describe("commit encoding", { tags: ["unit", "local"] }, () => {
341 // A real git-produced commit (git 2.x, `git commit -m`), checked in as a
342 // fixture: the oid is git's own, so a matching hash proves the bytes are
343 // byte-exact.
344 const FIXTURE_OID = "805ee86374143354d5a6e5e4df894aa1d631fbb1";
345 const FIXTURE_COMMIT =
346 "tree 44caf5de156e5707051d55989b7db370a80af206\n" +
347 "parent a684c642abd0ef33ec672ce701f48f824837cd03\n" +
348 "author A U Thor <author@example.com> 1700000200 +0000\n" +
349 "committer A U Thor <author@example.com> 1700000300 +0000\n" +
350 "\n" +
351 "second commit\n" +
352 "\n" +
353 "with a body line\n";
354​
355 it.live("round-trips a real git commit byte-exactly (fixture oid)", () =>
356 Effect.gen(function* () {
357 const bytes = utf8Encode(FIXTURE_COMMIT);
358 // the fixture really is the commit git hashed
359 expect(yield* hashObject(ObjectType.commit, bytes)).toBe(FIXTURE_OID);
360 const parsed = yield* parseCommit(bytes);
361 const encoded = yield* encodeCommit({
362 tree: parsed.tree,
363 parents: parsed.parents,
364 author: parsed.author,
365 committer: parsed.committer,
366 message: parsed.message,
367 });
368 expect(utf8Decode(encoded)).toBe(FIXTURE_COMMIT);
369 expect(yield* hashObject(ObjectType.commit, encoded)).toBe(FIXTURE_OID);
370 }),
371 );
372​
373 it.live("encodes multi-parent commits and formats identities", () =>
374 Effect.gen(function* () {
375 const tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
376 const p1 = "ce013625030ba8dba906f756967f9e9ca394464a";
377 const p2 = "3b18e512dba79e4c8300dd08aeb37f8e728b8dad";
378 const identity = {
379 name: "git-service",
380 email: "git-service@localhost",
381 when: 1700000000,
382 tz: "+0000",
383 };
384 expect(formatIdentity(identity)).toBe("git-service <git-service@localhost> 1700000000 +0000");
385 const encoded = yield* encodeCommit({
386 tree,
387 parents: [p1, p2],
388 author: identity,
389 committer: identity,
390 message: "Merge pull request #1 from feature\n",
391 });
392 const parsed = yield* parseCommit(encoded);
393 expect(parsed.tree).toBe(tree);
394 expect(parsed.parents).toEqual([p1, p2]);
395 expect(parsed.author).toEqual(identity);
396 expect(parsed.message).toBe("Merge pull request #1 from feature\n");
397 }),
398 );
399​
400 it.live("rejects malformed oids and identities", () =>
401 Effect.gen(function* () {
402 const tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
403 const identity = { name: "ok", email: "ok@example.com", when: 1, tz: "+0000" };
404 const badTree = yield* Effect.result(
405 encodeCommit({
406 tree: "nope",
407 parents: [],
408 author: identity,
409 committer: identity,
410 message: "m\n",
411 }),
412 );
413 expect(Result.isFailure(badTree)).toBe(true);
414 const badName = yield* Effect.result(
415 encodeCommit({
416 tree,
417 parents: [],
418 author: { ...identity, name: "evil <injector>" },
419 committer: identity,
420 message: "m\n",
421 }),
422 );
423 expect(Result.isFailure(badName)).toBe(true);
424 const badTz = yield* Effect.result(
425 encodeCommit({
426 tree,
427 parents: [],
428 author: { ...identity, tz: "UTC" },
429 committer: identity,
430 message: "m\n",
431 }),
432 );
433 expect(Result.isFailure(badTz)).toBe(true);
434 }),
435 );
436});
437​
438describe("zlib boundary accounting", { tags: ["unit", "local"] }, () => {
439 it.live("inflateEntry reports the exact compressed span of each entry", () =>
440 Effect.gen(function* () {
441 const a = yield* deflate(utf8Encode("first object content"));
442 const b = yield* deflate(utf8Encode("second object content"));
443 const buf = concatBytes([a, b]);
444 const first = yield* inflateEntry(buf, 0);
445 expect(first.bytesConsumed).toBe(a.length);
446 expect(utf8Decode(first.content)).toBe("first object content");
447 const second = yield* inflateEntry(buf, first.bytesConsumed);
448 expect(second.bytesConsumed).toBe(b.length);
449 expect(utf8Decode(second.content)).toBe("second object content");
450 }),
451 );
452​
453 it.live("tolerates trailing junk after Z_STREAM_END", () =>
454 Effect.gen(function* () {
455 const z = yield* deflate(utf8Encode("payload"));
456 const buf = concatBytes([z, utf8Encode("TRAILING GARBAGE")]);
457 const entry = yield* inflateEntry(buf, 0);
458 expect(entry.bytesConsumed).toBe(z.length);
459 expect(utf8Decode(entry.content)).toBe("payload");
460 }),
461 );
462​
463 it.live("strict inflate round-trips deflate output", () =>
464 Effect.gen(function* () {
465 const content = utf8Encode("round trip me ".repeat(1000));
466 const z = yield* deflate(content);
467 expect(z.length).toBeLessThan(content.length);
468 const back = yield* inflate(z);
469 expect(utf8Decode(back)).toBe(utf8Decode(content));
470 }),
471 );
472​
473 it.live("strict inflate fails on a truncated stream", () =>
474 Effect.gen(function* () {
475 const z = yield* deflate(utf8Encode("payload to truncate"));
476 const result = yield* Effect.result(inflate(z.subarray(0, z.length - 3)));
477 expect(Result.isFailure(result)).toBe(true);
478 if (Result.isFailure(result)) {
479 expect(result.failure._tag).toBe("ZlibError");
480 }
481 }),
482 );
483});