1/**
2 * The hasher protocol (src/Git/Hasher/Hasher.ts): a scan result survives the
3 * binary encoding byte for byte, and the inline layer scans in-process.
4 */
5import {
6 hashObject,
7 encodeTypeSize,
8 makeSha1,
9 type Oid,
10} from "@/Git/Protocol/ObjectCodec.ts";
11import { packHeader } from "@/Git/Protocol/PackWriter.ts";
12import * as Zlib from "@/Git/Protocol/Zlib.ts";
13import {
14 decodeScanResult,
15 encodeScanResult,
16 Hasher,
17 HasherInline,
18 frame,
19 makeFrameReader,
20} from "@/Git/Hasher/Hasher.ts";
21import { describe, expect, test } from "alchemy-test";
22import { BlobStore } from "@/Git/BlobStore.ts";
23import * as Effect from "effect/Effect";
24import { makeMemoryBlobStore } from "../harness/store.ts";
25import * as Layer from "effect/Layer";
26import { concat } from "../harness/pack.ts";
27
28describe("Hasher", { tags: ["unit", "local"] }, () => {
29 test("encode/decode round-trips entries, blob references, unresolved deltas and coordinates", async () => {
30 await Effect.runPromise(
31 Effect.gen(function* () {
32 const pieces: Array<Uint8Array> = [packHeader(3)];
33 const oids: Array<Oid> = [];
34 // A blob, a tree (content returned), and another blob.
35 const contents = [
36 new Uint8Array(500),
37 new TextEncoder().encode("100644 f\\0" + "x".repeat(20)),
38 new Uint8Array(700),
39 ];
40 const types = [3, 2, 3] as const;
41 for (let i = 0; i < 3; i++) {
42 crypto.getRandomValues(contents[i]!.subarray(0, 0));
43 oids.push(yield* hashObject(types[i], contents[i]!));
44 pieces.push(
45 encodeTypeSize(types[i], contents[i]!.length),
46 yield* Zlib.deflate(contents[i]!),
47 );
48 }
49 const body = concat(pieces);
50 const sha = makeSha1();
51 sha.update(body);
52 const pack = concat([body, sha.digest()]);
53 const hasher = yield* Hasher;
54 const result = yield* hasher.hashPart(pack.subarray(12), {
55 base: 12,
56 remaining: 3,
57 maxObjectSize: 1 << 20,
58 });
59 expect(result.count).toBe(3);
60 expect(result.entries.map((e) => e.oid)).toEqual(oids);
61 expect(result.entries[1]!.content).toBeDefined();
62 expect(result.entries[0]!.content).toBeUndefined();
63 const wire = encodeScanResult({
64 ...result,
65 unresolved: [
66 { offset: 99, dataOffset: 101, span: 7, baseOffset: 42, size: 12 },
67 ],
68 });
69 const back = decodeScanResult(wire);
70 expect(back.count).toBe(3);
71 expect(back.consumedTo).toBe(result.consumedTo);
72 expect(back.unresolved).toEqual([
73 { offset: 99, dataOffset: 101, span: 7, baseOffset: 42, size: 12 },
74 ]);
75 expect(
76 back.entries.map((e) => [
77 e.oid,
78 e.type,
79 e.size,
80 e.dataOffset,
81 e.span,
82 ]),
83 ).toEqual(
84 result.entries.map((e) => [
85 e.oid,
86 e.type,
87 e.size,
88 e.dataOffset,
89 e.span,
90 ]),
91 );
92 expect(Array.from(back.entries[1]!.content!)).toEqual(
93 Array.from(result.entries[1]!.content!),
94 );
95 }).pipe(
96 Effect.provide(
97 HasherInline.pipe(
98 Layer.provide(Layer.succeed(BlobStore, makeMemoryBlobStore())),
99 ),
100 ),
101 ),
102 );
103 });
104});
105
106describe(
107 "hash route framing (DESIGN §22.9)",
108 { tags: ["unit", "local"] },
109 () => {
110 test("frames split across reads are reassembled; the part frame follows the scan", async () => {
111 const scan = frame(new TextEncoder().encode("scan-bytes"));
112 const part = frame(
113 new TextEncoder().encode(JSON.stringify({ partNumber: 3, etag: "e3" })),
114 );
115 const whole = concat([scan, part]);
116 // Deliver in awkward pieces: mid-length-prefix and mid-frame cuts.
117 const cuts = [1, 3, 7, 12, whole.length];
118 let at = 0;
119 const body = new ReadableStream<Uint8Array>({
120 pull(controller) {
121 if (at >= whole.length) return controller.close();
122 const to = cuts.find((c) => c > at) ?? whole.length;
123 controller.enqueue(whole.subarray(at, to));
124 at = to;
125 },
126 });
127 const next = makeFrameReader(body);
128 const out = await Effect.runPromise(
129 Effect.gen(function* () {
130 const first = yield* next();
131 const second = yield* next();
132 const third = yield* next();
133 return {
134 first: first && new TextDecoder().decode(first),
135 second: second && JSON.parse(new TextDecoder().decode(second)),
136 third,
137 };
138 }),
139 );
140 expect(out.first).toBe("scan-bytes");
141 expect(out.second).toEqual({ partNumber: 3, etag: "e3" });
142 expect(out.third).toBeUndefined();
143 });
144 },
145);