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.

121 lines · 5.7 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 deltas: Uint8Array[] = [];
55 for (let i = 1; i <= n; i++) deltas.push(refDelta(oids[i - 1]!, replace(8, content(i))));
56 const timed = async (entries: Uint8Array[]) => {
57 const p = await pack(entries);
58 const started = performance.now();
59 const objects = await parsePack(p);
60 return { ms: performance.now() - started, objects };
61 };
62 // Listed base first, every delta is resolvable when it is read: the time one pass takes on this machine.
63 const onePass = await timed([blob(content(0)), ...deltas]);
64 const newestFirst = await timed([...deltas.reverse(), blob(content(0))]);
65 // Compared on the same machine, so a slower CI host doesn't change the verdict. Pass by pass, this took minutes.
66 expect(newestFirst.ms).toBeLessThan(4 * onePass.ms + 500);
67 expect(newestFirst.objects).toHaveLength(n + 1);
68 expect(new Set(newestFirst.objects.map((o) => o.oid))).toEqual(new Set(oids));
69 }, 120_000);
70
71 it("counts what each object costs in memory, not only its content", async () => {
72 // 20,000 one-byte blobs are a 240 KB pack but cost far more than 240 KB to hold.
73 const entries: Uint8Array[] = [];
74 for (let i = 0; i < 20_000; i++) entries.push(blob(Buffer.from([i & 255, i >> 8])));
75 const p = await pack(entries);
76 await expect(parsePack(p, undefined, { maxHeldBytes: 1024 * 1024 })).rejects.toThrow(/memory/);
77 expect(await parsePack(p, undefined, { maxHeldBytes: 64 * 1024 * 1024 })).toHaveLength(20_000);
78 });
79
80 it("refuses a small pack whose objects inflate past the memory budget", async () => {
81 // 25 blobs just under the 4 MiB that is kept compressed: under 200 KB of pack, about 100 MB held.
82 const size = 4 * 1024 * 1024 - 1024;
83 const entries: Uint8Array[] = [];
84 for (let i = 0; i < 25; i++) {
85 const b = Buffer.alloc(size);
86 b[0] = i;
87 entries.push(blob(b));
88 }
89 const p = await pack(entries);
90 expect(p.length).toBeLessThan(250 * 1024);
91 await expect(parsePack(p, undefined, { largeObjectBytes: 4 * 1024 * 1024 })).rejects.toThrow(/memory/);
92 });
93
94 it("refuses a delta whose declared result can't be held, before allocating it", async () => {
95 const base = Buffer.from("base");
96 const delta = Uint8Array.from([...varint(base.length), ...varint(300 * 1024 * 1024), 4, ...base]);
97 const p = await pack([blob(base), refDelta(await hashObject("blob", base), delta)]);
98 await expect(parsePack(p)).rejects.toThrow(/pack needs more than 80 MiB of memory/);
99 });
100
101 it("counts a thin pack's bases from the repository, once each", async () => {
102 const base = Buffer.alloc(2 * 1024 * 1024, 7);
103 const oid = await hashObject("blob", base);
104 let reads = 0;
105 const external = async (o: string) => (o === oid ? (reads++, { type: "blob" as const, content: base }) : null);
106 const deltas: Uint8Array[] = [];
107 for (let i = 0; i < 3; i++) deltas.push(refDelta(oid, replace(base.length, Buffer.from(`v${i}`))));
108 const p = await pack(deltas);
109 await expect(parsePack(p, external, { maxBytes: 1024 * 1024 })).rejects.toThrow(/unpacks to more than/);
110 reads = 0;
111 expect(await parsePack(p, external, { maxBytes: 4 * 1024 * 1024 })).toHaveLength(3);
112 expect(reads).toBe(1);
113 });
114
115 it("refuses gzip data where git has zlib", async () => {
116 const content = Buffer.from("hello\n");
117 await expect(parsePack(await pack([blob(content, gzipSync)]))).rejects.toThrow(/zlib|header/);
118 const large = Buffer.alloc(64 * 1024, 1);
119 await expect(parsePack(await pack([blob(large, gzipSync)]), undefined, { largeObjectBytes: 1024 })).rejects.toThrow(/zlib|header/);
120 });
121});