@cldmv/gh-broker 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
@@ -1,439 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { chmodSync, closeSync, linkSync, mkdirSync, openSync, symlinkSync, writeFileSync } from "node:fs";
3
- import { execFileSync } from "node:child_process";
4
- import { indexTar, normName, readMember, MAX_TAR_MEMBERS } from "../src/verbs/ghcr/tar.ts";
5
- import { openArchive, planImage, MEDIA, MAX_LAYERS } from "../src/verbs/ghcr/archive.ts";
6
- import { tar, paxRecord, ociArchive, dockerArchive, scratchDir, removeDir, join, sha, layerTar } from "./helpers/ghcr-fixtures.mts";
7
-
8
- let root: string;
9
- let drop: string;
10
-
11
- beforeAll(() => {
12
- root = scratchDir("ghcr-archive");
13
- drop = join(root, "drop");
14
- mkdirSync(drop);
15
- });
16
- afterAll(() => removeDir(root));
17
-
18
- let n = 0;
19
- function file(buf: Buffer, dir = drop): string {
20
- const p = join(dir, `a${++n}.tar`);
21
- writeFileSync(p, buf);
22
- return p;
23
- }
24
-
25
- function withFd<T>(buf: Buffer, fn: (fd: number, size: number) => T): T {
26
- const fd = openSync(file(buf), "r");
27
- try {
28
- return fn(fd, buf.length);
29
- } finally {
30
- closeSync(fd);
31
- }
32
- }
33
-
34
- describe("indexTar", () => {
35
- it("indexes regular members and resolves symlinks and hard links inside the archive", () => {
36
- const buf = tar([
37
- { name: "./dir/", type: "5" },
38
- { name: "./dir/a.txt", data: "alpha" },
39
- { name: "dir/link", type: "2", linkname: "a.txt" },
40
- { name: "dir/hard", type: "1", linkname: "dir/a.txt" },
41
- { name: "dir/chain", type: "2", linkname: "link" },
42
- { name: "dir/dangling", type: "2", linkname: "nope" }
43
- ]);
44
- withFd(buf, (fd, size) => {
45
- const { files } = indexTar(fd, size);
46
- for (const k of ["dir/a.txt", "dir/link", "dir/hard", "dir/chain"]) expect(readMember(fd, files.get(k)!, 100, k).toString()).toBe("alpha");
47
- expect(files.has("dir/dangling")).toBe(false);
48
- expect(files.has("dir")).toBe(false);
49
- });
50
- });
51
-
52
- it("reads PAX path/size, GNU long names, ustar prefixes and base-256 sizes", () => {
53
- const long = `${"x".repeat(120)}/file`;
54
- const buf = tar([
55
- { name: "g", type: "g", data: paxRecord("comment", "global") },
56
- { name: "pax", type: "x", data: paxRecord("path", "pax/name.txt") + paxRecord("size", "3") },
57
- { name: "ignored", data: "abc" },
58
- { name: "././@LongLink", type: "L", data: `${long}\0` },
59
- { name: "short", data: "long" },
60
- { name: "file.txt", prefix: "pre/fix", data: "pfx" },
61
- { name: "b256", base256: true, data: "big" }
62
- ]);
63
- withFd(buf, (fd, size) => {
64
- const { files } = indexTar(fd, size);
65
- expect(readMember(fd, files.get("pax/name.txt")!, 10, "x").toString()).toBe("abc");
66
- expect(readMember(fd, files.get(long)!, 10, "x").toString()).toBe("long");
67
- expect(readMember(fd, files.get("pre/fix/file.txt")!, 10, "x").toString()).toBe("pfx");
68
- expect(readMember(fd, files.get("b256")!, 10, "x").toString()).toBe("big");
69
- });
70
- });
71
-
72
- it("reads a full-width 100-character name, a GNU long link name, an empty size field and a PAX record with no value", () => {
73
- const hundred = "d".repeat(100);
74
- const buf = tar([
75
- { name: hundred, data: "hundred" },
76
- { name: "././@LongLink", type: "K", data: "target-file\0" },
77
- { name: "lnk", type: "2" },
78
- { name: "tgt-holder", data: "x" },
79
- { name: "empty-size", sizeField: "" },
80
- { name: "p", type: "x", data: "6 abc\n" + paxRecord("path", "after-pax") },
81
- { name: "ignored", data: "ok" }
82
- ]);
83
- withFd(buf, (fd, size) => {
84
- const { files } = indexTar(fd, size);
85
- expect(readMember(fd, files.get(hundred)!, 10, "x").toString()).toBe("hundred");
86
- expect(files.get("empty-size")!.size).toBe(0);
87
- expect(readMember(fd, files.get("after-pax")!, 10, "x").toString()).toBe("ok");
88
- });
89
- });
90
-
91
- it("stops reading a PAX header at a record with no length separator", () => {
92
- const buf = tar([{ name: "p", type: "x", data: "nolengthhere" }, { name: "a", data: "x" }]);
93
- withFd(buf, (fd, size) => expect(indexTar(fd, size).files.has("a")).toBe(true));
94
- });
95
-
96
- it("resolves a link whose target the GNU long link name supplies", () => {
97
- const buf = tar([
98
- { name: "real", data: "payload" },
99
- { name: "././@LongLink", type: "K", data: "real\0" },
100
- { name: "alias", type: "2", linkname: "ignored" }
101
- ]);
102
- withFd(buf, (fd, size) => expect(readMember(fd, indexTar(fd, size).files.get("alias")!, 10, "x").toString()).toBe("payload"));
103
- });
104
-
105
- it("refuses a base-256 size that is not a safe integer", () => {
106
- const buf = tar([{ name: "a", data: "x", base256: true }]);
107
- buf.fill(0xff, 125, 136); // 11 bytes of 0xff after the 0x80 marker: far beyond 2^53
108
- let sum = 0;
109
- buf.fill(0x20, 148, 156);
110
- for (let i = 0; i < 512; i++) sum += buf[i]!;
111
- buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
112
- withFd(buf, (fd, size) => expect(() => indexTar(fd, size)).toThrow(/member size out of range/));
113
- });
114
-
115
- it("lets a later member replace an earlier link of the same name", () => {
116
- const buf = tar([
117
- { name: "t", data: "target" },
118
- { name: "x", type: "2", linkname: "t" },
119
- { name: "x", data: "real" }
120
- ]);
121
- withFd(buf, (fd, size) => expect(readMember(fd, indexTar(fd, size).files.get("x")!, 10, "x").toString()).toBe("real"));
122
- });
123
-
124
- it.each([
125
- ["a bad checksum", tar([{ name: "a", data: "x", badChecksum: true }]), /bad tar header checksum/],
126
- ["a non-octal checksum field", (() => { const b = tar([{ name: "a", data: "x" }]); b.write("zzzzzz\0 ", 148); return b; })(), /bad tar header checksum/],
127
- ["a non-octal size", tar([{ name: "a", data: "x", sizeField: "99999999999" }]), /bad size field/],
128
- ["a member past the end", tar([{ name: "a", data: "x", sizeField: "00000077777" }], { end: false }), /runs past the end/],
129
- ["a bad PAX record", tar([{ name: "p", type: "x", data: "zz path=x\n" }, { name: "a", data: "x" }]), /bad PAX header record/],
130
- ["a bad PAX size", tar([{ name: "p", type: "x", data: paxRecord("size", "-1") }, { name: "a", data: "x" }]), /bad PAX size/],
131
- ["a link loop", tar([{ name: "a", type: "2", linkname: "b" }, { name: "b", type: "2", linkname: "a" }]), /link loop/]
132
- ])("refuses %s", (_label, buf, re) => {
133
- withFd(buf, (fd, size) => expect(() => indexTar(fd, size)).toThrow(re));
134
- });
135
-
136
- it("refuses a truncated header and an oversized extended header", () => {
137
- const big = tar([{ name: "p", type: "x", data: Buffer.alloc(1024 * 1024 + 1, 0x41) }]);
138
- withFd(big, (fd, size) => expect(() => indexTar(fd, size)).toThrow(/extended header too large/));
139
- // The file shrank after it was stat-ed: the second header is cut short.
140
- const two = tar([{ name: "a", data: "x" }, { name: "b", data: "y" }], { end: false });
141
- withFd(two.subarray(0, 1100), (fd) => expect(() => indexTar(fd, two.length)).toThrow(/truncated at byte 1100/));
142
- });
143
-
144
- it("caps the number of members", () => {
145
- const one = tar([{ name: "a", type: "5" }], { end: false });
146
- const buf = Buffer.concat([...Array.from({ length: MAX_TAR_MEMBERS + 1 }, () => one), Buffer.alloc(1024)]);
147
- withFd(buf, (fd, size) => expect(() => indexTar(fd, size)).toThrow(/more than/));
148
- });
149
-
150
- it("refuses to read a member over the caller's limit", () => {
151
- const buf = tar([{ name: "a", data: "0123456789" }]);
152
- withFd(buf, (fd, size) => expect(() => readMember(fd, indexTar(fd, size).files.get("a")!, 5, "thing")).toThrow(/thing is 10 bytes/));
153
- });
154
-
155
- it("normalizes names", () => {
156
- expect(normName("./a/../b/")).toBe("b");
157
- expect(normName("/abs/x")).toBe("abs/x");
158
- expect(normName("../../x")).toBe("x");
159
- });
160
- });
161
-
162
- describe("openArchive", () => {
163
- const MAX = 10 * 1024 * 1024;
164
-
165
- it("opens a file in the drop dir (absolute or relative) and reports its real path", () => {
166
- const p = file(tar([{ name: "a", data: "x" }]));
167
- const a = openArchive(p, drop, MAX);
168
- expect(a.path).toBe(p);
169
- expect(a.size).toBeGreaterThan(0);
170
- a.close();
171
- a.close(); // idempotent
172
- const rel = openArchive(p.slice(drop.length + 1), drop, MAX);
173
- expect(rel.path).toBe(p);
174
- rel.close();
175
- });
176
-
177
- it("allows subdirectories of the drop dir", () => {
178
- mkdirSync(join(drop, "sub"), { recursive: true });
179
- const p = file(tar([{ name: "a", data: "x" }]), join(drop, "sub"));
180
- const a = openArchive("sub/" + p.split("/").pop(), drop, MAX);
181
- a.close();
182
- });
183
-
184
- it.each([[""], [7], [undefined], ["x\0y"], ["a".repeat(5000)]])("refuses a bad archive argument %j", (arg) => {
185
- expect(() => openArchive(arg, drop, MAX)).toThrow(/"archive" must be a file path/);
186
- });
187
-
188
- it("refuses paths outside the drop dir lexically, before opening anything", () => {
189
- expect(() => openArchive("/etc/gh-broker/secrets.json", drop, MAX)).toThrow(/must be an existing file inside the drop directory/);
190
- expect(() => openArchive("../outside.tar", drop, MAX)).toThrow(/must be an existing file inside the drop directory/);
191
- expect(() => openArchive(drop, drop, MAX)).toThrow(/must be an existing file inside the drop directory/);
192
- expect(() => openArchive("n".repeat(300), drop, MAX)).toThrow(/cannot open .* \(ENAMETOOLONG\)/);
193
- });
194
-
195
- it("refuses a missing drop dir", () => {
196
- expect(() => openArchive(join(root, "nodrop", "a.tar"), join(root, "nodrop"), MAX)).toThrow(/does not exist/);
197
- });
198
-
199
- it("refuses a symlink, even one pointing inside the drop dir", () => {
200
- const target = file(tar([{ name: "a", data: "x" }]));
201
- const link = join(drop, "link.tar");
202
- symlinkSync(target, link);
203
- expect(() => openArchive(link, drop, MAX)).toThrow(/is a symlink/);
204
- });
205
-
206
- it("refuses a file reached through a symlinked directory that leads outside", () => {
207
- const outside = join(root, "outside");
208
- mkdirSync(outside);
209
- writeFileSync(join(outside, "secret.tar"), "secret");
210
- symlinkSync(outside, join(drop, "escape"));
211
- expect(() => openArchive("escape/secret.tar", drop, MAX)).toThrow(/must be an existing file inside the drop directory/);
212
- });
213
-
214
- it("gives the same answer for every file behind a symlinked directory, so nothing outside can be probed", () => {
215
- const outside = join(root, "probe");
216
- mkdirSync(join(outside, "subdir"), { recursive: true });
217
- writeFileSync(join(outside, "present.tar"), "x");
218
- writeFileSync(join(outside, "empty.tar"), "");
219
- writeFileSync(join(outside, "private.tar"), "x", { mode: 0o000 });
220
- symlinkSync(outside, join(drop, "probe"));
221
- const answers = ["present.tar", "empty.tar", "private.tar", "subdir", "missing.tar", "missing-dir/x.tar"].map((n) => {
222
- try {
223
- openArchive(`probe/${n}`, drop, MAX);
224
- return "opened";
225
- } catch (err) {
226
- return (err as Error).message.replace(n, "<name>");
227
- }
228
- });
229
- expect(new Set(answers).size).toBe(1);
230
- expect(answers[0]).toMatch(/must be an existing file inside the drop directory/);
231
- });
232
-
233
- it("refuses a hard-linked file", () => {
234
- const outside = join(root, "hl-src.tar");
235
- writeFileSync(outside, "secret");
236
- const p = join(drop, "hl.tar");
237
- linkSync(outside, p);
238
- expect(() => openArchive(p, drop, MAX)).toThrow(/2 hard links/);
239
- });
240
-
241
- it("refuses a directory, a FIFO (without hanging), an empty file and an oversized file", () => {
242
- mkdirSync(join(drop, "isdir.tar"));
243
- expect(() => openArchive(join(drop, "isdir.tar"), drop, MAX)).toThrow(/not a regular file/);
244
- const fifo = join(drop, "fifo.tar");
245
- execFileSync("mkfifo", [fifo]);
246
- expect(() => openArchive(fifo, drop, MAX)).toThrow(/not a regular file/);
247
- const empty = join(drop, "empty.tar");
248
- writeFileSync(empty, "");
249
- expect(() => openArchive(empty, drop, MAX)).toThrow(/is empty/);
250
- const big = file(Buffer.alloc(2048));
251
- expect(() => openArchive(big, drop, 1024)).toThrow(/over the 1024-byte limit/);
252
- });
253
-
254
- it("refuses a missing file and names an unreadable one", () => {
255
- expect(() => openArchive(join(drop, "nope.tar"), drop, MAX)).toThrow(/not found/);
256
- // A missing subdirectory gets the same answer as a path outside: it could be a dangling symlink.
257
- expect(() => openArchive(join(drop, "nodir", "x.tar"), drop, MAX)).toThrow(/must be an existing file inside the drop directory/);
258
- const p = file(Buffer.from("x"));
259
- chmodSync(p, 0o000);
260
- try {
261
- if (process.getuid?.() !== 0) expect(() => openArchive(p, drop, MAX)).toThrow(/chmod g\+r/);
262
- } finally {
263
- chmodSync(p, 0o600);
264
- }
265
- });
266
-
267
- it("reports any other open error generically", () => {
268
- const name = join(drop, `${"n".repeat(300)}.tar`); // ENAMETOOLONG
269
- expect(() => openArchive(name, drop, MAX)).toThrow(/cannot open .* \(ENAMETOOLONG\)/);
270
- });
271
- });
272
-
273
- describe("planImage", () => {
274
- const plan = (buf: Buffer) => withFd(buf, (fd, size) => planImage(fd, size));
275
-
276
- it("pushes an already-compressed OCI layout verbatim, manifest included", () => {
277
- const img = ociArchive({ gzip: true, annotations: { a: "b" } });
278
- const p = plan(img.archive);
279
- expect(p.format).toBe("oci-layout");
280
- expect(p.layers.map((l) => [l.compress, l.mediaType, l.sourceDigest])).toEqual(img.layers.map((l) => [false, MEDIA.ociLayerGzip, sha(l)]));
281
- expect(p.verbatimManifest?.mediaType).toBe(MEDIA.ociManifest);
282
- expect(p.config.digest).toBe(sha(img.config));
283
- expect(p.annotations).toEqual({ a: "b" });
284
- });
285
-
286
- it("marks uncompressed OCI layers for gzip and drops the verbatim manifest", () => {
287
- const p = plan(ociArchive().archive);
288
- expect(p.layers.every((l) => l.compress && l.mediaType === MEDIA.ociLayerGzip)).toBe(true);
289
- expect(p.verbatimManifest).toBeUndefined();
290
- });
291
-
292
- it("retypes a gzip layer declared as plain tar, and keeps docker media types when correct", () => {
293
- const retyped = plan(ociArchive({ gzip: true, layerMediaType: MEDIA.ociLayer }).archive);
294
- expect(retyped.layers[0]!.mediaType).toBe(MEDIA.ociLayerGzip);
295
- expect(retyped.verbatimManifest).toBeUndefined();
296
- const docker = plan(
297
- ociArchive({ gzip: true, layerMediaType: MEDIA.dockerLayerGzip, configMediaType: MEDIA.dockerConfig, manifestMediaType: MEDIA.dockerManifest }).archive
298
- );
299
- expect(docker.layers[0]!.mediaType).toBe(MEDIA.dockerLayerGzip);
300
- expect(docker.verbatimManifest?.mediaType).toBe(MEDIA.dockerManifest);
301
- });
302
-
303
- it("detects zstd layers", () => {
304
- const zstd = Buffer.concat([Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), Buffer.from("payload")]);
305
- const p = plan(rebuildWithBlob(zstd));
306
- expect(p.layers[0]!.mediaType).toBe(MEDIA.ociLayerZstd);
307
- expect(p.layers[0]!.compress).toBe(false);
308
- });
309
-
310
- it("unwraps a nested index and skips attestation and unknown-platform entries", () => {
311
- const attest = { mediaType: MEDIA.ociManifest, digest: sha("x"), size: 1, annotations: { "vnd.docker.reference.type": "attestation-manifest" } };
312
- const unknown = { mediaType: MEDIA.ociManifest, digest: sha("y"), size: 1, platform: { os: "unknown", architecture: "unknown" } };
313
- expect(plan(ociArchive({ nested: true, extraIndexEntries: [attest, unknown] }).archive).format).toBe("oci-layout");
314
- });
315
-
316
- it("picks the one platform whose blobs were saved from a full multi-platform index (containerd store)", () => {
317
- const notSaved = { mediaType: MEDIA.ociManifest, digest: sha("arm64 manifest"), size: 1, platform: { os: "linux", architecture: "arm64" } };
318
- expect(plan(ociArchive({ nested: true, extraIndexEntries: [notSaved] }).archive).format).toBe("oci-layout");
319
- expect(plan(ociArchive({ extraIndexEntries: [notSaved, { ...notSaved, digest: "md5:x" }] }).archive).format).toBe("oci-layout");
320
- });
321
-
322
- it("refuses multi-image archives and unsupported manifests", () => {
323
- const img = ociArchive();
324
- const idx = JSON.parse(extract(img.archive, "index.json"));
325
- const both = ociArchive({ extraIndexEntries: [{ ...idx.manifests[0], platform: { os: "linux", architecture: "arm64" } }] });
326
- expect(() => plan(both.archive)).toThrow(/holds 2 images/);
327
- expect(() => plan(ociArchive({ manifestMediaType: "application/x-weird" }).archive)).toThrow(/manifest media type/);
328
- expect(() => plan(tar([{ name: "index.json", data: "{}" }]))).toThrow(/no manifests list/);
329
- expect(() => plan(tar([{ name: "index.json", data: "not json" }]))).toThrow(/index.json is not valid JSON/);
330
- });
331
-
332
- it("refuses missing, mismatched or non-sha256 blobs", () => {
333
- const img = ociArchive({ gzip: true });
334
- const cfgName = `blobs/sha256/${sha(img.config).slice(7)}`;
335
- expect(() => plan(ociArchive({ gzip: true, omit: [cfgName] }).archive)).toThrow(/image config sha256:.* is missing/);
336
- expect(() => plan(ociArchive({ gzip: true, mutateManifest: (m) => (m.config.size = 1) }).archive)).toThrow(/config size does not match/);
337
- expect(() => plan(ociArchive({ gzip: true, mutateManifest: (m) => (m.layers[0].size = 1) }).archive)).toThrow(/layer 1 size does not match/);
338
- expect(() => plan(ociArchive({ gzip: true, mutateManifest: (m) => (m.layers[0].digest = "sha512:abc") }).archive)).toThrow(/only sha256/);
339
- expect(() => plan(ociArchive({ gzip: true, mutateManifest: (m) => delete m.layers }).archive)).toThrow(/no config or layers/);
340
- expect(() => plan(ociArchive({ gzip: true, configMediaType: "application/x-cfg" }).archive)).toThrow(/config media type/);
341
- expect(() => plan(ociArchive({ gzip: true, layerMediaType: "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip" }).archive)).toThrow(/non-distributable/);
342
- expect(() => plan(ociArchive({ gzip: true, mutateManifest: (m) => (m.layers = Array.from({ length: MAX_LAYERS + 1 }, () => m.layers[0])) }).archive)).toThrow(/at most 256/);
343
- });
344
-
345
- it("refuses descriptors that carry no media type", () => {
346
- expect(() => plan(ociArchive({ mutateManifest: (m) => delete m.mediaType }).archive)).toThrow(/manifest media type "undefined" is not supported/);
347
- expect(() => plan(ociArchive({ mutateManifest: (m) => delete m.config.mediaType }).archive)).toThrow(/config media type "undefined" is not supported/);
348
- });
349
-
350
- it("refuses a nested index that holds more than one saved image", () => {
351
- const img = ociArchive();
352
- const second = { mediaType: MEDIA.ociManifest, digest: sha(img.config), size: img.config.length };
353
- expect(() => plan(ociArchive({ nested: true, extraIndexEntries: [second] }).archive)).toThrow(/archive holds 2 images/);
354
- });
355
-
356
- it("refuses a docker-archive whose layer entry is not a file name", () => {
357
- const config = Buffer.from(JSON.stringify({ rootfs: { diff_ids: [] } }));
358
- const manifest = Buffer.from(JSON.stringify([{ Config: "cfg.json", Layers: [5] }]));
359
- const buf = tar([{ name: "cfg.json", data: config }, { name: "manifest.json", data: manifest }]);
360
- expect(() => plan(buf)).toThrow(/layer 1 "5" is missing from the archive/);
361
- });
362
-
363
- it("rebuilds the manifest when a layer's declared type differs from how it will be pushed", () => {
364
- const p = plan(
365
- ociArchive({
366
- gzip: true,
367
- mutateManifest: (m) => {
368
- m.layers[0].mediaType = MEDIA.dockerLayerGzip;
369
- m.layers[1].mediaType = MEDIA.ociLayer;
370
- }
371
- }).archive
372
- );
373
- expect(p.verbatimManifest).toBeUndefined();
374
- expect(p.layers.map((l) => l.mediaType)).toEqual([MEDIA.dockerLayerGzip, MEDIA.ociLayerGzip]);
375
- });
376
-
377
- it("refuses a manifest blob whose bytes don't match its digest", () => {
378
- const img = ociArchive({ gzip: true });
379
- const idx = JSON.parse(extract(img.archive, "index.json"));
380
- const tampered = tar([
381
- { name: "index.json", data: JSON.stringify({ manifests: [{ ...idx.manifests[0] }] }) },
382
- { name: `blobs/sha256/${idx.manifests[0].digest.slice(7)}`, data: "{}" }
383
- ]);
384
- expect(() => plan(tampered)).toThrow(/image manifest does not match its digest/);
385
- });
386
-
387
- it("reads a legacy docker-archive, following its symlinked duplicate layer", () => {
388
- const img = dockerArchive({ gzipFirst: true });
389
- const p = plan(img.archive);
390
- expect(p.format).toBe("docker-archive");
391
- expect(p.config.digest).toBe(sha(img.config));
392
- expect(p.config.mediaType).toBe(MEDIA.ociConfig);
393
- expect(p.layers.map((l) => l.compress)).toEqual([false, true, true]);
394
- expect(p.layers[0]!.mediaType).toBe(MEDIA.ociLayerGzip);
395
- expect(p.layers[1]!.member).toEqual(p.layers[2]!.member);
396
- });
397
-
398
- it("refuses bad docker-archives and non-image tarballs", () => {
399
- expect(() => plan(dockerArchive({ images: 2 }).archive)).toThrow(/holds 2 images/);
400
- expect(() => plan(tar([{ name: "manifest.json", data: "{}" }]))).toThrow(/holds 0 images/);
401
- expect(() => plan(tar([{ name: "manifest.json", data: "[{}]" }]))).toThrow(/no Config or Layers/);
402
- expect(() => plan(tar([{ name: "manifest.json", data: '[{"Config":"c.json","Layers":["l.tar"]}]' }]))).toThrow(/image config "c.json" is missing/);
403
- expect(() => plan(tar([{ name: "manifest.json", data: JSON.stringify([{ Config: "c.json", Layers: Array(257).fill("l") }]) }]))).toThrow(/at most 256/);
404
- expect(() =>
405
- plan(
406
- tar([
407
- { name: "c.json", data: "{}" },
408
- { name: "manifest.json", data: '[{"Config":"c.json","Layers":["l.tar"]}]' }
409
- ])
410
- )
411
- ).toThrow(/layer 1 "l.tar" is missing/);
412
- expect(() => plan(tar([{ name: "readme", data: "hi" }]))).toThrow(/not an image archive/);
413
- });
414
- });
415
-
416
- /** Pull one member's text out of a built archive. */
417
- function extract(buf: Buffer, name: string): string {
418
- return withFd(buf, (fd, size) => readMember(fd, indexTar(fd, size).files.get(name)!, 1 << 20, name).toString());
419
- }
420
-
421
- /** A one-layer OCI archive whose only layer is `blob` (any bytes), declared as zstd. */
422
- function rebuildWithBlob(blob: Buffer): Buffer {
423
- const config = Buffer.from(JSON.stringify({ rootfs: { type: "layers", diff_ids: [sha(layerTar("z"))] } }));
424
- const manifest = Buffer.from(
425
- JSON.stringify({
426
- schemaVersion: 2,
427
- mediaType: MEDIA.ociManifest,
428
- config: { mediaType: MEDIA.ociConfig, digest: sha(config), size: config.length },
429
- layers: [{ mediaType: MEDIA.ociLayerZstd, digest: sha(blob), size: blob.length }]
430
- })
431
- );
432
- const index = JSON.stringify({ manifests: [{ mediaType: MEDIA.ociManifest, digest: sha(manifest), size: manifest.length }] });
433
- return tar([
434
- { name: "index.json", data: index },
435
- { name: `blobs/sha256/${sha(config).slice(7)}`, data: config },
436
- { name: `blobs/sha256/${sha(manifest).slice(7)}`, data: manifest },
437
- { name: `blobs/sha256/${sha(blob).slice(7)}`, data: blob }
438
- ]);
439
- }
@@ -1,96 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { parseGhcrConfig, ghcrCredRefs, imageAllowed, imageGlobToRegExp, DEFAULT_MAX_ARCHIVE_BYTES } from "../src/verbs/ghcr/config.ts";
3
-
4
- const good = (over: Record<string, unknown> = {}, owner: Record<string, unknown> = {}) => ({
5
- dropDir: "/srv/gh-broker/ghcr-drop",
6
- owners: { CLDMV: { username: "Shinrai", credential: "cred:cldmv-packages", images: ["hoot", "hoot-worker-*"], ...owner } },
7
- ...over
8
- });
9
-
10
- describe("parseGhcrConfig", () => {
11
- it("accepts a valid block, lowercases owners and applies defaults", () => {
12
- const c = parseGhcrConfig(good());
13
- expect(c).toEqual({
14
- dropDir: "/srv/gh-broker/ghcr-drop",
15
- protectReleaseTags: true,
16
- maxArchiveBytes: DEFAULT_MAX_ARCHIVE_BYTES,
17
- owners: { cldmv: { username: "Shinrai", credential: "cred:cldmv-packages", images: ["hoot", "hoot-worker-*"] } }
18
- });
19
- });
20
-
21
- it("honours protectReleaseTags: false and maxArchiveBytes, and strips a trailing slash", () => {
22
- const c = parseGhcrConfig(good({ dropDir: "/srv/drop/", protectReleaseTags: false, maxArchiveBytes: 1024 }));
23
- expect(c.dropDir).toBe("/srv/drop");
24
- expect(c.protectReleaseTags).toBe(false);
25
- expect(c.maxArchiveBytes).toBe(1024);
26
- });
27
-
28
- it.each([
29
- [null, /must be an object/],
30
- [[], /must be an object/],
31
- [{ ...good(), image: [] }, /unknown key\(s\) image/],
32
- [good({ dropDir: "relative/dir" }), /absolute path/],
33
- [good({ dropDir: 7 }), /absolute path/],
34
- [good({ dropDir: "/srv/../etc/x" }), /normalized/],
35
- [good({ dropDir: "/srv//x" }), /normalized/],
36
- [good({ dropDir: "/srv" }), /two levels deep/],
37
- [good({ dropDir: "/" }), /two levels deep|normalized/],
38
- [good({ dropDir: "/etc/gh-broker" }), /may not be under \/etc/],
39
- [good({ dropDir: "/home/repo/drop" }), /may not be under \/home/],
40
- [good({ dropDir: "/tmp/drop" }), /may not be under \/tmp/],
41
- [good({ dropDir: "/run/gh-broker" }), /may not be under \/run/],
42
- [good({ protectReleaseTags: "yes" }), /protectReleaseTags: must be a boolean/],
43
- [good({ maxArchiveBytes: 0 }), /maxArchiveBytes/],
44
- [good({ maxArchiveBytes: 1.5 }), /maxArchiveBytes/],
45
- [good({ owners: {} }), /at least one owner/],
46
- [good({ owners: [] }), /at least one owner/],
47
- [good({ owners: { "bad owner": {} } }), /not a valid GitHub owner name/],
48
- [good({ owners: { cldmv: "x" } }), /must be an object/]
49
- ])("refuses %j", (raw, re) => {
50
- expect(() => parseGhcrConfig(raw)).toThrow(re);
51
- });
52
-
53
- it.each([
54
- [{ extra: 1 }, /unknown key\(s\) extra/],
55
- [{ username: "" }, /username/],
56
- [{ username: "bad name" }, /username/],
57
- [{ credential: "cldmv-packages" }, /credential: must be a "cred:<name>" ref/],
58
- [{ credential: "cred:../x" }, /credential/],
59
- [{ credential: "cred:.." }, /credential/],
60
- [{ images: [] }, /non-empty list/],
61
- [{ images: "hoot" }, /non-empty list/],
62
- [{ images: ["Hoot"] }, /not a valid image name or glob/],
63
- [{ images: ["hoot/"] }, /not a valid image name or glob/],
64
- [{ images: [7] }, /not a valid image name or glob/],
65
- [{ images: ["a".repeat(256)] }, /not a valid image name or glob/]
66
- ])("refuses owner fields %j", (owner, re) => {
67
- expect(() => parseGhcrConfig(good({}, owner))).toThrow(re);
68
- });
69
- });
70
-
71
- describe("image globs", () => {
72
- const owner = { username: "u", credential: "cred:x", images: ["hoot", "hoot-worker-*", "tools/*.cli"] };
73
-
74
- it("matches exact names and `*` within one segment only", () => {
75
- expect(imageAllowed(owner, "hoot")).toBe(true);
76
- expect(imageAllowed(owner, "hoot-worker-")).toBe(true);
77
- expect(imageAllowed(owner, "hoot-worker-render")).toBe(true);
78
- expect(imageAllowed(owner, "tools/x.cli")).toBe(true);
79
- expect(imageAllowed(owner, "hoot-worker-a/b")).toBe(false);
80
- expect(imageAllowed(owner, "hootx")).toBe(false);
81
- expect(imageAllowed(owner, "tools/xcli")).toBe(false); // "." is literal, not a regex wildcard
82
- expect(imageAllowed(owner, "other")).toBe(false);
83
- });
84
-
85
- it("escapes regex metacharacters", () => {
86
- expect(imageGlobToRegExp("a.b-c").test("aXb-c")).toBe(false);
87
- expect(imageGlobToRegExp("a.b-c").test("a.b-c")).toBe(true);
88
- });
89
- });
90
-
91
- describe("ghcrCredRefs", () => {
92
- it("lists each owner's credential ref, or nothing without a block", () => {
93
- expect(ghcrCredRefs(undefined)).toEqual([]);
94
- expect(ghcrCredRefs(parseGhcrConfig(good()))).toEqual(["cred:cldmv-packages"]);
95
- });
96
- });