@cldmv/gh-broker 0.8.0 → 0.9.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.
- package/README.md +40 -10
- package/config.example.json +17 -1
- package/deploy/README.md +3 -2
- package/deploy/install.mjs +5 -3
- package/dist/index.mjs +4261 -1642
- package/dist/index.mjs.map +4 -4
- package/package.json +12 -2
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- 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
|
-
});
|