Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

115 lines · 5.2 KBRaw History
1import { deflateSync, gzipSync } from "node:zlib";
2import { describe, expect, it } from "vitest";
3import { hashObject, parsePack, sha1 } from "../src/index.js";
4
5// Packs from clients must not run a repository's Durable Object out of memory (128 MB) or tie it up
6// for minutes, however small the pack is.
7
8const header = (type: number, size: number) => {
9 const out: number[] = [];
10 let c = (type << 4) | (size & 15);
11 size = Math.floor(size / 16);
12 while (size > 0) {
13 out.push(c | 0x80);
14 c = size & 0x7f;
15 size = Math.floor(size / 128);
16 }
17 out.push(c);
18 return out;
19};
20const varint = (n: number) => {
21 const out: number[] = [];
22 do {
23 let b = n % 128;
24 n = Math.floor(n / 128);
25 if (n > 0) b |= 0x80;
26 out.push(b);
27 } while (n > 0);
28 return out;
29};
30const hex = (oid: string) => Buffer.from(oid, "hex");
31
32/** A pack from entries already encoded (header, any base, zlib data). */
33async function pack(entries: Uint8Array[]): Promise<Uint8Array> {
34 const head = Buffer.alloc(12);
35 head.write("PACK");
36 head.writeUInt32BE(2, 4);
37 head.writeUInt32BE(entries.length, 8);
38 const body = Buffer.concat([head, ...entries]);
39 return new Uint8Array(Buffer.concat([body, await sha1(body)]));
40}
41const blob = (content: Uint8Array, z = deflateSync) => Buffer.concat([Buffer.from(header(3, content.length)), z(content)]);
42const refDelta = (base: string, delta: Uint8Array) => Buffer.concat([Buffer.from(header(7, delta.length)), hex(base), deflateSync(delta)]);
43/** A delta that replaces a base of `from` bytes with `to`. */
44const replace = (from: number, to: Uint8Array) => Uint8Array.from([...varint(from), ...varint(to.length), to.length, ...to]);
45
46describe("pack limits", () => {
47 it("resolves a long delta chain in one pass, whatever order the pack lists it in", async () => {
48 // 20,000 REF_DELTAs, each against the one before it, listed newest first: resolving them by
49 // repeated passes over the unresolved ones took minutes.
50 const n = 20_000;
51 const content = (i: number) => Buffer.from(String(i).padStart(8, "0"));
52 const oids = [await hashObject("blob", content(0))];
53 for (let i = 1; i <= n; i++) oids.push(await hashObject("blob", content(i)));
54 const entries: Uint8Array[] = [];
55 for (let i = n; i >= 1; i--) entries.push(refDelta(oids[i - 1]!, replace(8, content(i))));
56 entries.push(blob(content(0)));
57 const p = await pack(entries);
58 const started = Date.now();
59 const objects = await parsePack(p);
60 expect(Date.now() - started).toBeLessThan(10_000);
61 expect(objects).toHaveLength(n + 1);
62 expect(new Set(objects.map((o) => o.oid))).toEqual(new Set(oids));
63 }, 30_000);
64
65 it("counts what each object costs in memory, not only its content", async () => {
66 // 20,000 one-byte blobs are a 240 KB pack but cost far more than 240 KB to hold.
67 const entries: Uint8Array[] = [];
68 for (let i = 0; i < 20_000; i++) entries.push(blob(Buffer.from([i & 255, i >> 8])));
69 const p = await pack(entries);
70 await expect(parsePack(p, undefined, { maxHeldBytes: 1024 * 1024 })).rejects.toThrow(/memory/);
71 expect(await parsePack(p, undefined, { maxHeldBytes: 64 * 1024 * 1024 })).toHaveLength(20_000);
72 });
73
74 it("refuses a small pack whose objects inflate past the memory budget", async () => {
75 // 25 blobs just under the 4 MiB that is kept compressed: under 200 KB of pack, about 100 MB held.
76 const size = 4 * 1024 * 1024 - 1024;
77 const entries: Uint8Array[] = [];
78 for (let i = 0; i < 25; i++) {
79 const b = Buffer.alloc(size);
80 b[0] = i;
81 entries.push(blob(b));
82 }
83 const p = await pack(entries);
84 expect(p.length).toBeLessThan(250 * 1024);
85 await expect(parsePack(p, undefined, { largeObjectBytes: 4 * 1024 * 1024 })).rejects.toThrow(/memory/);
86 });
87
88 it("refuses a delta whose declared result can't be held, before allocating it", async () => {
89 const base = Buffer.from("base");
90 const delta = Uint8Array.from([...varint(base.length), ...varint(300 * 1024 * 1024), 4, ...base]);
91 const p = await pack([blob(base), refDelta(await hashObject("blob", base), delta)]);
92 await expect(parsePack(p)).rejects.toThrow(/pack needs more than 80 MiB of memory/);
93 });
94
95 it("counts a thin pack's bases from the repository, once each", async () => {
96 const base = Buffer.alloc(2 * 1024 * 1024, 7);
97 const oid = await hashObject("blob", base);
98 let reads = 0;
99 const external = async (o: string) => (o === oid ? (reads++, { type: "blob" as const, content: base }) : null);
100 const deltas: Uint8Array[] = [];
101 for (let i = 0; i < 3; i++) deltas.push(refDelta(oid, replace(base.length, Buffer.from(`v${i}`))));
102 const p = await pack(deltas);
103 await expect(parsePack(p, external, { maxBytes: 1024 * 1024 })).rejects.toThrow(/unpacks to more than/);
104 reads = 0;
105 expect(await parsePack(p, external, { maxBytes: 4 * 1024 * 1024 })).toHaveLength(3);
106 expect(reads).toBe(1);
107 });
108
109 it("refuses gzip data where git has zlib", async () => {
110 const content = Buffer.from("hello\n");
111 await expect(parsePack(await pack([blob(content, gzipSync)]))).rejects.toThrow(/zlib|header/);
112 const large = Buffer.alloc(64 * 1024, 1);
113 await expect(parsePack(await pack([blob(large, gzipSync)]), undefined, { largeObjectBytes: 1024 })).rejects.toThrow(/zlib|header/);
114 });
115});