Skip to content
File

Blob: test/pack-indexer.scan.worker.test.ts

typescript363 lines
1import { describe, expect, it } from "vitest";
2import { env } from "cloudflare:workers";
3import { buildPack, buildAppendOnlyDelta, makeCommit, makeTree } from "./util/git-pack";
4import {
5 makeLimiter,
6 packIndexerLog as log,
7 rewritePackChecksum,
8 setSingleBytePackHeaderSize,
9} from "./util/pack-indexer.helpers";
10 
11import { scanPack } from "@/worker/git/pack/indexer";
12import { computeOid } from "@/worker/git/core/objects";
13import { bytesToHex } from "@/worker/common/hex";
14 
15describe("scanPack", () => {
16 it("indexes a pack with non-delta objects", async () => {
17 const tree = await makeTree();
18 const commit = await makeCommit(tree.oid, "initial commit");
19 const blobPayload = new TextEncoder().encode("hello world\n");
20 const blobOid = await computeOid("blob", blobPayload);
21 
22 const packBytes = await buildPack([
23 { type: "blob", payload: blobPayload },
24 { type: "tree", payload: tree.payload },
25 { type: "commit", payload: commit.payload },
26 ]);
27 
28 const packKey = "test/scan-basic.pack";
29 await env.REPO_BUCKET.put(packKey, packBytes);
30 const head = await env.REPO_BUCKET.head(packKey);
31 
32 const result = await scanPack({
33 env,
34 packKey,
35 packSize: head!.size,
36 limiter: makeLimiter(),
37 countSubrequest: () => {},
38 log,
39 });
40 
41 expect(result.objectCount).toBe(3);
42 expect(result.resolvedCount).toBe(3);
43 expect(result.refDeltaCount).toBe(0);
44 
45 for (let i = 0; i < 3; i++) {
46 expect(result.table.resolved[i]).toBe(1);
47 expect(result.table.crc32s[i]).not.toBe(0);
48 }
49 
50 const oids = [];
51 for (let i = 0; i < 3; i++) {
52 oids.push(bytesToHex(result.table.oids.subarray(i * 20, i * 20 + 20)));
53 }
54 expect(oids).toContain(blobOid);
55 expect(oids).toContain(tree.oid);
56 expect(oids).toContain(commit.oid);
57 
58 expect(result.table.types[0]).toBe(3);
59 expect(result.table.types[1]).toBe(2);
60 expect(result.table.types[2]).toBe(1);
61 expect(result.packChecksum.length).toBe(20);
62 });
63 
64 it("rejects absurd pack object counts before allocating entry tables", async () => {
65 const blobPayload = new TextEncoder().encode("guarded header count\n");
66 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
67 const mutated = await rewritePackChecksum(packBytes, (next) => {
68 const dv = new DataView(next.buffer, next.byteOffset, next.byteLength);
69 dv.setUint32(8, 1_000_000, false);
70 });
71 
72 const packKey = "test/scan-object-count-guard.pack";
73 await env.REPO_BUCKET.put(packKey, mutated);
74 const head = await env.REPO_BUCKET.head(packKey);
75 
76 await expect(
77 scanPack({
78 env,
79 packKey,
80 packSize: head!.size,
81 limiter: makeLimiter(),
82 countSubrequest: () => {},
83 log,
84 })
85 ).rejects.toThrow(/safe isolate limit/);
86 });
87 
88 it("handles packs when 1-byte range reads split the zlib header and trailer", async () => {
89 const blobPayload = new TextEncoder().encode("chunk-boundary payload\n".repeat(8));
90 const blobOid = await computeOid("blob", blobPayload);
91 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
92 
93 const packKey = "test/scan-byte-chunks.pack";
94 await env.REPO_BUCKET.put(packKey, packBytes);
95 const head = await env.REPO_BUCKET.head(packKey);
96 
97 const result = await scanPack({
98 env,
99 packKey,
100 packSize: head!.size,
101 chunkSize: 1,
102 limiter: makeLimiter(),
103 countSubrequest: () => {},
104 log,
105 });
106 
107 expect(result.objectCount).toBe(1);
108 expect(result.table.resolved[0]).toBe(1);
109 expect(bytesToHex(result.table.oids.subarray(0, 20))).toBe(blobOid);
110 expect(result.table.spanEnds[0]).toBe(head!.size - 20);
111 });
112 
113 it("rejects a base object whose pack header size disagrees with the inflated payload", async () => {
114 const blobPayload = new TextEncoder().encode("tiny");
115 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
116 const mutated = await rewritePackChecksum(packBytes, (next) => {
117 setSingleBytePackHeaderSize(next, 12, 3, blobPayload.length + 1);
118 });
119 
120 const packKey = "test/scan-bad-blob-size.pack";
121 await env.REPO_BUCKET.put(packKey, mutated);
122 const head = await env.REPO_BUCKET.head(packKey);
123 
124 await expect(
125 scanPack({
126 env,
127 packKey,
128 packSize: head!.size,
129 limiter: makeLimiter(),
130 countSubrequest: () => {},
131 log,
132 })
133 ).rejects.toThrow(/size mismatch/);
134 });
135 
136 it("rejects a delta whose pack header size disagrees with the delta result size", async () => {
137 const baseBlobPayload = new TextEncoder().encode("base");
138 const suffix = new TextEncoder().encode("xy");
139 const delta = buildAppendOnlyDelta(baseBlobPayload, suffix);
140 
141 const packBytes = await buildPack([
142 { type: "blob", payload: baseBlobPayload },
143 { type: "ofs-delta", baseIndex: 0, delta },
144 ]);
145 
146 const goodPackKey = "test/scan-good-delta-size.pack";
147 await env.REPO_BUCKET.put(goodPackKey, packBytes);
148 const goodHead = await env.REPO_BUCKET.head(goodPackKey);
149 const goodScan = await scanPack({
150 env,
151 packKey: goodPackKey,
152 packSize: goodHead!.size,
153 limiter: makeLimiter(),
154 countSubrequest: () => {},
155 log,
156 });
157 
158 const mutated = await rewritePackChecksum(packBytes, (next) => {
159 setSingleBytePackHeaderSize(next, goodScan.table.offsets[1], 6, delta.byteLength + 1);
160 });
161 
162 const badPackKey = "test/scan-bad-delta-size.pack";
163 await env.REPO_BUCKET.put(badPackKey, mutated);
164 const badHead = await env.REPO_BUCKET.head(badPackKey);
165 
166 await expect(
167 scanPack({
168 env,
169 packKey: badPackKey,
170 packSize: badHead!.size,
171 limiter: makeLimiter(),
172 countSubrequest: () => {},
173 log,
174 })
175 ).rejects.toThrow(/size mismatch/);
176 });
177 
178 it("rejects a pack with corrupted checksum", async () => {
179 const tree = await makeTree();
180 const packBytes = await buildPack([{ type: "tree", payload: tree.payload }]);
181 const corrupted = new Uint8Array(packBytes);
182 corrupted[corrupted.length - 1] ^= 0xff;
183 
184 const packKey = "test/scan-corrupt.pack";
185 await env.REPO_BUCKET.put(packKey, corrupted);
186 const head = await env.REPO_BUCKET.head(packKey);
187 
188 await expect(
189 scanPack({
190 env,
191 packKey,
192 packSize: head!.size,
193 limiter: makeLimiter(),
194 countSubrequest: () => {},
195 log,
196 })
197 ).rejects.toThrow(/checksum mismatch/);
198 });
199 
200 it("rejects reserved pack type codes during header parsing", async () => {
201 const blobPayload = new TextEncoder().encode("tiny");
202 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
203 const mutated = await rewritePackChecksum(packBytes, (next) => {
204 setSingleBytePackHeaderSize(next, 12, 5, blobPayload.length);
205 });
206 
207 const packKey = "test/scan-reserved-type.pack";
208 await env.REPO_BUCKET.put(packKey, mutated);
209 
210 await expect(
211 scanPack({
212 env,
213 packKey,
214 packSize: mutated.byteLength,
215 limiter: makeLimiter(),
216 countSubrequest: () => {},
217 log,
218 })
219 ).rejects.toThrow(/invalid reserved pack type/);
220 });
221 
222 it("rejects delta result sizes that exceed the supported 32-bit range", async () => {
223 const baseBlobPayload = new TextEncoder().encode("base\n");
224 const overflowDelta = new Uint8Array([
225 baseBlobPayload.length,
226 0x80,
227 0x80,
228 0x80,
229 0x80,
230 0x10,
231 0x00,
232 ]);
233 
234 const packBytes = await buildPack([
235 { type: "blob", payload: baseBlobPayload },
236 { type: "ofs-delta", baseIndex: 0, delta: overflowDelta },
237 ]);
238 
239 const packKey = "test/scan-overflow-delta-size.pack";
240 await env.REPO_BUCKET.put(packKey, packBytes);
241 const head = await env.REPO_BUCKET.head(packKey);
242 
243 await expect(
244 scanPack({
245 env,
246 packKey,
247 packSize: head!.size,
248 limiter: makeLimiter(),
249 countSubrequest: () => {},
250 log,
251 })
252 ).rejects.toThrow(/32-bit size range/);
253 });
254 
255 it("rejects a corrupted Adler-32 trailer even when the pack checksum is recomputed", async () => {
256 const blobPayload = new TextEncoder().encode("adler validation\n");
257 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
258 
259 const goodPackKey = "test/scan-good-adler.pack";
260 await env.REPO_BUCKET.put(goodPackKey, packBytes);
261 const goodHead = await env.REPO_BUCKET.head(goodPackKey);
262 const goodScan = await scanPack({
263 env,
264 packKey: goodPackKey,
265 packSize: goodHead!.size,
266 limiter: makeLimiter(),
267 countSubrequest: () => {},
268 log,
269 });
270 
271 const payloadStart = goodScan.table.offsets[0] + goodScan.table.headerLens[0];
272 const payloadEnd = goodScan.table.spanEnds[0];
273 const mutated = await rewritePackChecksum(packBytes, (next) => {
274 for (let i = payloadEnd - 4; i < payloadEnd; i++) {
275 next[i] ^= 0xff;
276 }
277 });
278 
279 const packKey = "test/scan-bad-adler.pack";
280 await env.REPO_BUCKET.put(packKey, mutated);
281 
282 await expect(
283 scanPack({
284 env,
285 packKey,
286 packSize: payloadEnd + 20,
287 limiter: makeLimiter(),
288 countSubrequest: () => {},
289 log,
290 })
291 ).rejects.toThrow(/Adler-32 mismatch/);
292 expect(payloadStart).toBeGreaterThan(0);
293 });
294 
295 it("rejects zlib preset-dictionary headers before inflating payload bytes", async () => {
296 const blobPayload = new TextEncoder().encode("fdict validation\n");
297 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
298 
299 const goodPackKey = "test/scan-good-fdict.pack";
300 await env.REPO_BUCKET.put(goodPackKey, packBytes);
301 const goodHead = await env.REPO_BUCKET.head(goodPackKey);
302 const goodScan = await scanPack({
303 env,
304 packKey: goodPackKey,
305 packSize: goodHead!.size,
306 limiter: makeLimiter(),
307 countSubrequest: () => {},
308 log,
309 });
310 
311 const payloadStart = goodScan.table.offsets[0] + goodScan.table.headerLens[0];
312 const mutated = await rewritePackChecksum(packBytes, (next) => {
313 const cmf = next[payloadStart];
314 const flgBase = (next[payloadStart + 1] | 0x20) & 0xe0;
315 const check = (31 - ((cmf * 256 + flgBase) % 31)) % 31;
316 next[payloadStart + 1] = flgBase | check;
317 });
318 
319 const packKey = "test/scan-fdict.pack";
320 await env.REPO_BUCKET.put(packKey, mutated);
321 
322 await expect(
323 scanPack({
324 env,
325 packKey,
326 packSize: mutated.byteLength,
327 limiter: makeLimiter(),
328 countSubrequest: () => {},
329 log,
330 })
331 ).rejects.toThrow(/preset dictionaries/);
332 });
333 
334 it("rejects undeclared bytes between the last object and the pack trailer", async () => {
335 const blobPayload = new TextEncoder().encode("trailing junk validation\n");
336 const packBytes = await buildPack([{ type: "blob", payload: blobPayload }]);
337 const junk = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
338 const mutated = new Uint8Array(packBytes.length + junk.length);
339 const trailerStart = packBytes.length - 20;
340 
341 mutated.set(packBytes.subarray(0, trailerStart), 0);
342 mutated.set(junk, trailerStart);
343 // Keep the original trailer hash in place. The scanner must reject the pack
344 // because the final object no longer ends where the trailer begins.
345 mutated.set(packBytes.subarray(trailerStart), trailerStart + junk.length);
346 
347 const packKey = "test/scan-trailing-junk.pack";
348 await env.REPO_BUCKET.put(packKey, mutated);
349 const head = await env.REPO_BUCKET.head(packKey);
350 
351 await expect(
352 scanPack({
353 env,
354 packKey,
355 packSize: head!.size,
356 limiter: makeLimiter(),
357 countSubrequest: () => {},
358 log,
359 })
360 ).rejects.toThrow(/expected indexed entries to end/);
361 });
362});