zuka
zuka/test/live/wire_test.ts

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/test/live/wire_test.ts
TSwire_test.ts64.6 KBDownload
1/**
2 * Live suite: spawns the compiled binary and drives it with a real `git` CLI.
3 *
4 * This is where the wire protocol and the REST contract are actually proven — the
5 * inline Rust tests cover units, but only a real clone and push prove M1. CI sets
6 * ZUKA_LIVE_TEST; without it these skip so a plain `deno test` stays fast.
7 */
8import {
9 assert,
10 assertEquals,
11 assertRejects,
12 assertStringIncludes,
13} from "jsr:@std/assert@^1";
14
15const ENABLED = Deno.env.get("ZUKA_LIVE_TEST") === "1";
16const ROOT = new URL("../../", import.meta.url).pathname;
17
18/** Crate name, read from Cargo.toml so a rename does not break the suite. */
19const NAME = (Deno.readTextFileSync(`${ROOT}Cargo.toml`)
20 .match(/^name\s*=\s*"([^"]+)"/m)?.[1]) ?? "zuka";
21const BINARY = `${ROOT}target/release/${NAME}`;
22
23interface Server {
24 port: number;
25 sshPort: number;
26 dataDir: string;
27 workDir: string;
28 token: string;
29 /// Private key registered with the `alice` account.
30 keyPath: string;
31 stop: () => Promise<void>;
32}
33
34async function freePort(): Promise<number> {
35 const listener = Deno.listen({ port: 0 });
36 const { port } = listener.addr as Deno.NetAddr;
37 listener.close();
38 return port;
39}
40
41/** Mint a token by invoking the binary's own command, as an operator would. */
42async function mintToken(
43 dataDir: string,
44 account: string,
45 extra: string[] = ["--scopes", "admin"],
46): Promise<string> {
47 const command = new Deno.Command(BINARY, {
48 args: ["token", account, ...extra],
49 env: { ZUKA_DATA_DIR: dataDir },
50 stdout: "piped",
51 stderr: "null",
52 });
53 const { success, stdout } = await command.output();
54 assert(success, `minting a token for ${account} failed`);
55 return new TextDecoder().decode(stdout).trim();
56}
57
58/** Generate a keypair and register the public half with an account. */
59async function registerKey(
60 dataDir: string,
61 account: string,
62 path: string,
63 extra: string[] = [],
64): Promise<void> {
65 const keygen = await new Deno.Command("ssh-keygen", {
66 args: ["-t", "ed25519", "-N", "", "-f", path, "-q"],
67 stdout: "null",
68 stderr: "null",
69 }).output();
70 assert(keygen.success, "ssh-keygen failed");
71
72 const add = await new Deno.Command(BINARY, {
73 args: ["key", account, `${path}.pub`, ...extra],
74 env: { ZUKA_DATA_DIR: dataDir },
75 stdout: "null",
76 stderr: "null",
77 }).output();
78 assert(add.success, `registering a key for ${account} failed`);
79}
80
81/** Run git over SSH with a specific identity, pinned to this server. */
82function sshEnv(
83 server: Server,
84 keyPath = server.keyPath,
85): Record<string, string> {
86 return {
87 GIT_SSH_COMMAND:
88 `ssh -i ${keyPath} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null` +
89 ` -o LogLevel=ERROR -o IdentitiesOnly=yes -o BatchMode=yes`,
90 };
91}
92
93function sshUrl(server: Server, account: string, repo: string) {
94 return `ssh://git@127.0.0.1:${server.sshPort}/${account}/${repo}.git`;
95}
96
97/** Raw SSH exec, for proving what a session may NOT do. */
98async function sshExec(server: Server, command: string[]): Promise<string> {
99 const { stdout, stderr } = await new Deno.Command("ssh", {
100 args: [
101 "-i",
102 server.keyPath,
103 "-o",
104 "StrictHostKeyChecking=no",
105 "-o",
106 "UserKnownHostsFile=/dev/null",
107 "-o",
108 "LogLevel=ERROR",
109 "-o",
110 "IdentitiesOnly=yes",
111 "-o",
112 "BatchMode=yes",
113 "-p",
114 String(server.sshPort),
115 "git@127.0.0.1",
116 ...command,
117 ],
118 stdout: "piped",
119 stderr: "piped",
120 }).output();
121 const decode = new TextDecoder();
122 return decode.decode(stdout) + decode.decode(stderr);
123}
124
125async function start(extra: Record<string, string> = {}): Promise<Server> {
126 const workDir = await Deno.makeTempDir({ prefix: "zuka-live-" });
127 const dataDir = `${workDir}/data`;
128 await Deno.mkdir(dataDir, { recursive: true });
129
130 const port = await freePort();
131 const sshPort = await freePort();
132 const token = await mintToken(dataDir, "alice");
133 const keyPath = `${workDir}/id_alice`;
134 await registerKey(dataDir, "alice", keyPath);
135
136 const child = new Deno.Command(BINARY, {
137 env: {
138 ZUKA_DATA_DIR: dataDir,
139 ZUKA_BIND: `127.0.0.1:${port}`,
140 ZUKA_SSH_BIND: `127.0.0.1:${sshPort}`,
141 PATH: Deno.env.get("PATH") ?? "/usr/bin:/bin",
142 ...extra,
143 },
144 stdout: "null",
145 // The server logs two lines per request. Left undrained this fills the 64 KiB
146 // pipe and hangs it, so it is discarded rather than piped.
147 stderr: "null",
148 }).spawn();
149
150 // Poll readiness rather than sleeping — a fixed sleep is either slow or flaky.
151 const deadline = Date.now() + 10_000;
152 for (;;) {
153 try {
154 const probe = await fetch(`http://127.0.0.1:${port}/healthz`);
155 await drain(probe);
156 if (probe.ok) break;
157 } catch {
158 // not listening yet
159 }
160 if (Date.now() > deadline) throw new Error("server did not become ready");
161 await new Promise((r) => setTimeout(r, 50));
162 }
163
164 return {
165 port,
166 sshPort,
167 dataDir,
168 workDir,
169 token,
170 keyPath,
171 stop: async () => {
172 try {
173 child.kill("SIGKILL");
174 } catch {
175 // already gone
176 }
177 await child.status;
178 await Deno.remove(workDir, { recursive: true }).catch(() => {});
179 },
180 };
181}
182
183async function git(
184 args: string[],
185 cwd: string,
186 env: Record<string, string> = {},
187): Promise<{ code: number; stdout: string; stderr: string }> {
188 const command = new Deno.Command("git", {
189 args: [
190 "-c",
191 "user.email=agent@example.com",
192 "-c",
193 "user.name=Agent",
194 "-c",
195 "protocol.version=2",
196 ...args,
197 ],
198 cwd,
199 env: { ...Deno.env.toObject(), GIT_TERMINAL_PROMPT: "0", ...env },
200 stdout: "piped",
201 stderr: "piped",
202 });
203 const { code, stdout, stderr } = await command.output();
204 return {
205 code,
206 stdout: new TextDecoder().decode(stdout),
207 stderr: new TextDecoder().decode(stderr),
208 };
209}
210
211function cloneUrl(
212 server: Server,
213 account: string,
214 repo: string,
215 token = server.token,
216) {
217 return `http://x-access-token:${token}@127.0.0.1:${server.port}/${account}/${repo}.git`;
218}
219
220async function api(
221 server: Server,
222 method: string,
223 path: string,
224 options: { token?: string; body?: unknown } = {},
225): Promise<Response> {
226 const headers: Record<string, string> = {};
227 const token = options.token ?? server.token;
228 if (token) headers["authorization"] = `Bearer ${token}`;
229 if (options.body !== undefined) headers["content-type"] = "application/json";
230
231 return await fetch(`http://127.0.0.1:${server.port}${path}`, {
232 method,
233 headers,
234 body: options.body === undefined ? undefined : JSON.stringify(options.body),
235 });
236}
237
238/** Read a response fully, so no test leaves a locked or dangling stream. */
239async function drain(response: Response): Promise<string> {
240 return await response.text();
241}
242
243async function json(response: Response): Promise<
244 // deno-lint-ignore no-explicit-any
245 any
246> {
247 return JSON.parse(await drain(response));
248}
249
250async function createRepo(server: Server, name: string) {
251 const response = await api(server, "POST", "/v1/repos", { body: { name } });
252 const body = await drain(response);
253 assertEquals(response.status, 201, body);
254}
255
256/** A second server with extra configuration, for tests that need a tighter limit. */
257async function startWith(extra: Record<string, string>): Promise<Server> {
258 return await start(extra);
259}
260
261/**
262 * Run the binary as a one-shot command against a running server's data directory.
263 *
264 * This is how an operator or a systemd timer invokes maintenance, so the tests
265 * exercise the same entry point rather than an internal function.
266 */
267async function runCli(
268 server: Server,
269 args: string[],
270 extra: Record<string, string>,
271): Promise<{ code: number; stdout: string; stderr: string }> {
272 const { code, stdout, stderr } = await new Deno.Command(BINARY, {
273 args,
274 env: {
275 ZUKA_DATA_DIR: server.dataDir,
276 PATH: Deno.env.get("PATH") ?? "/usr/bin:/bin",
277 ...extra,
278 },
279 stdout: "piped",
280 stderr: "piped",
281 }).output();
282 const decode = new TextDecoder();
283 return { code, stdout: decode.decode(stdout), stderr: decode.decode(stderr) };
284}
285
286/** Loose objects in a bare repository — what accumulates without gc. */
287async function countLoose(gitDir: string): Promise<number> {
288 let count = 0;
289 for await (const bucket of Deno.readDir(`${gitDir}/objects`)) {
290 if (
291 !bucket.isDirectory || bucket.name === "pack" || bucket.name === "info"
292 ) continue;
293 for await (
294 const object of Deno.readDir(`${gitDir}/objects/${bucket.name}`)
295 ) {
296 if (object.isFile) count++;
297 }
298 }
299 return count;
300}
301
302function live(name: string, fn: (server: Server) => Promise<void>) {
303 Deno.test({
304 name,
305 ignore: !ENABLED,
306 async fn() {
307 const server = await start();
308 try {
309 await fn(server);
310 } finally {
311 await server.stop();
312 }
313 },
314 });
315}
316
317live(
318 "a repository round-trips through clone, commit, push and clone",
319 async (server) => {
320 await createRepo(server, "site");
321
322 const cloned = await git([
323 "clone",
324 cloneUrl(server, "alice", "site"),
325 "work",
326 ], server.workDir);
327 assertEquals(cloned.code, 0, cloned.stderr);
328
329 const repo = `${server.workDir}/work`;
330 await Deno.writeTextFile(`${repo}/README.md`, "hello from an agent\n");
331 assertEquals((await git(["add", "README.md"], repo)).code, 0);
332 assertEquals((await git(["commit", "-m", "add readme"], repo)).code, 0);
333
334 const pushed = await git(["push", "origin", "HEAD:refs/heads/main"], repo);
335 assertEquals(pushed.code, 0, pushed.stderr);
336
337 const verify = await git([
338 "clone",
339 cloneUrl(server, "alice", "site"),
340 "verify",
341 ], server.workDir);
342 assertEquals(verify.code, 0, verify.stderr);
343
344 const content = await Deno.readTextFile(
345 `${server.workDir}/verify/README.md`,
346 );
347 assertEquals(content, "hello from an agent\n");
348 },
349);
350
351live(
352 "the pushed commit is visible over the REST refs endpoint",
353 async (server) => {
354 await createRepo(server, "site");
355 const repo = `${server.workDir}/work`;
356 assertEquals(
357 (await git(
358 ["clone", cloneUrl(server, "alice", "site"), "work"],
359 server.workDir,
360 )).code,
361 0,
362 );
363 await Deno.writeTextFile(`${repo}/a.txt`, "a");
364 await git(["add", "."], repo);
365 await git(["commit", "-m", "a"], repo);
366 await git(["push", "origin", "HEAD:refs/heads/main"], repo);
367
368 const response = await api(server, "GET", "/v1/repos/alice/site/refs");
369 assertEquals(response.status, 200);
370 const body = await json(response);
371 assertEquals(body.items.length, 1);
372 assertEquals(body.items[0].name, "refs/heads/main");
373 assertEquals(body.items[0].type, "branch");
374 assert(
375 /^[0-9a-f]{40}$/.test(body.items[0].sha),
376 "sha must be a full object id",
377 );
378 },
379);
380
381live(
382 "an unauthenticated git request challenges so the credential helper runs",
383 async (server) => {
384 await createRepo(server, "site");
385 const response = await fetch(
386 `http://127.0.0.1:${server.port}/alice/site.git/info/refs?service=git-upload-pack`,
387 );
388 await drain(response);
389
390 assertEquals(response.status, 401);
391 const challenge = response.headers.get("www-authenticate") ?? "";
392 assertStringIncludes(challenge, "Basic realm=");
393 // git renders a JSON error body as noise at the user.
394 assert(
395 !(response.headers.get("content-type") ?? "").includes("problem+json"),
396 "the git paths must not answer with problem+json",
397 );
398 },
399);
400
401live(
402 "another account cannot read, clone or enumerate a repository",
403 async (server) => {
404 await createRepo(server, "site");
405 const bob = await mintToken(server.dataDir, "bob");
406
407 const read = await api(server, "GET", "/v1/repos/alice/site", {
408 token: bob,
409 });
410 await drain(read);
411 assertEquals(read.status, 404, "403 would confirm the repository exists");
412
413 const cloned = await git(
414 ["clone", cloneUrl(server, "alice", "site", bob), "steal"],
415 server.workDir,
416 );
417 assert(cloned.code !== 0, "a foreign account must not clone");
418
419 const list = await api(server, "GET", "/v1/repos", { token: bob });
420 assertEquals((await json(list)).items.length, 0);
421 },
422);
423
424live(
425 "names that would escape the data directory are refused",
426 async (server) => {
427 for (const name of ["../../etc", ".git", "a/b", "", "-leading", "x.lock"]) {
428 const response = await api(server, "POST", "/v1/repos", {
429 body: { name },
430 });
431 const body = await json(response);
432 assertEquals(response.status, 400, `${name} must be refused`);
433 assertStringIncludes(body.type, "/problems/invalid-name");
434 }
435 },
436);
437
438live(
439 "only the three smart-HTTP endpoints are routable under .git",
440 async (server) => {
441 await createRepo(server, "site");
442 for (
443 const path of [
444 "/alice/site.git/config",
445 "/alice/site.git/HEAD",
446 "/alice/site.git/objects/info/packs",
447 "/alice/site.git/info/refs",
448 ]
449 ) {
450 const response = await api(server, "GET", path);
451 await drain(response);
452 assertEquals(response.status, 404, `${path} must not be served`);
453 }
454 },
455);
456
457live(
458 "creating the same repository twice conflicts, including by case",
459 async (server) => {
460 await createRepo(server, "site");
461
462 const same = await api(server, "POST", "/v1/repos", {
463 body: { name: "site" },
464 });
465 assertEquals(same.status, 409);
466 assertStringIncludes((await json(same)).type, "/problems/repo-exists");
467
468 const cased = await api(server, "POST", "/v1/repos", {
469 body: { name: "Site" },
470 });
471 assertEquals(
472 cased.status,
473 409,
474 "a case variant would collide on a case-insensitive disk",
475 );
476 await drain(cased);
477 },
478);
479
480live(
481 "a deleted repository stops serving and is recoverable on disk",
482 async (server) => {
483 await createRepo(server, "site");
484
485 const deleted = await api(server, "DELETE", "/v1/repos/alice/site");
486 await drain(deleted);
487 assertEquals(deleted.status, 204);
488
489 const gone = await api(server, "GET", "/v1/repos/alice/site");
490 await drain(gone);
491 assertEquals(gone.status, 404);
492
493 const again = await api(server, "DELETE", "/v1/repos/alice/site");
494 await drain(again);
495 assertEquals(
496 again.status,
497 404,
498 "deleting an absent repo must not report success",
499 );
500
501 const graves = [...Deno.readDirSync(`${server.dataDir}/tmp/deleted`)];
502 assertEquals(
503 graves.length,
504 1,
505 "delete must move the repo aside, not remove it",
506 );
507 },
508);
509
510live("health reports mode and disk headroom", async (server) => {
511 const response = await api(server, "GET", "/healthz");
512 const body = await json(response);
513 assertEquals(response.status, 200);
514 assertEquals(body.status, "ok");
515 assertEquals(body.mode, "standalone");
516 assert(typeof body.disk_free_bytes === "number");
517});
518
519// ── SSH transport ──────────────────────────────────────────────────────────
520
521live("a repository round-trips over SSH", async (server) => {
522 await createRepo(server, "site");
523 const env = sshEnv(server);
524
525 const cloned = await git(
526 ["clone", sshUrl(server, "alice", "site"), "work"],
527 server.workDir,
528 env,
529 );
530 assertEquals(cloned.code, 0, cloned.stderr);
531
532 const repo = `${server.workDir}/work`;
533 await Deno.writeTextFile(`${repo}/README.md`, "over ssh\n");
534 await git(["add", "."], repo, env);
535 await git(["commit", "-m", "add readme"], repo, env);
536
537 const pushed = await git(
538 ["push", "origin", "HEAD:refs/heads/main"],
539 repo,
540 env,
541 );
542 assertEquals(pushed.code, 0, pushed.stderr);
543
544 // Fetching a repository that HAS content is the case that deadlocks if the
545 // client-to-stdin copy is joined on rather than pumped independently.
546 const verify = await git(
547 ["clone", sshUrl(server, "alice", "site"), "verify"],
548 server.workDir,
549 env,
550 );
551 assertEquals(verify.code, 0, verify.stderr);
552 assertEquals(
553 await Deno.readTextFile(`${server.workDir}/verify/README.md`),
554 "over ssh\n",
555 );
556});
557
558live(
559 "an SSH session cannot open a shell or run anything but git",
560 async (server) => {
561 await createRepo(server, "site");
562
563 assertStringIncludes(await sshExec(server, []), "not a shell");
564
565 for (
566 const command of [
567 ["cat /etc/passwd"],
568 ["scp -t /tmp"],
569 ["git-daemon"],
570 ]
571 ) {
572 const output = await sshExec(server, command);
573 assertStringIncludes(
574 output,
575 `${NAME}:`,
576 `${command} must be refused by us`,
577 );
578 assert(
579 !output.includes("root:"),
580 "a refused command must not have executed",
581 );
582 }
583 },
584);
585
586live(
587 "SSH refuses command injection and traversal in the repository path",
588 async (server) => {
589 await createRepo(server, "site");
590
591 for (
592 const command of [
593 "git-upload-pack '/alice/site.git'; id",
594 "git-upload-pack '/alice/site.git' && id",
595 "git-upload-pack '/alice/$(id).git'",
596 "git-upload-pack '/alice/../../etc/passwd'",
597 "git-upload-pack '//etc/shadow'",
598 "git-upload-pack '/a/b/c.git'",
599 ]
600 ) {
601 const output = await sshExec(server, [command]);
602 assertStringIncludes(output, `${NAME}:`, `${command} must be refused`);
603 assert(!output.includes("uid="), "injected command must not have run");
604 }
605 },
606);
607
608live("an unregistered SSH key cannot authenticate", async (server) => {
609 await createRepo(server, "site");
610
611 const rogue = `${server.workDir}/rogue`;
612 await new Deno.Command("ssh-keygen", {
613 args: ["-t", "ed25519", "-N", "", "-f", rogue, "-q"],
614 stdout: "null",
615 stderr: "null",
616 }).output();
617
618 const cloned = await git(
619 ["clone", sshUrl(server, "alice", "site"), "stolen"],
620 server.workDir,
621 sshEnv(server, rogue),
622 );
623 assert(cloned.code !== 0, "an unregistered key must not authenticate");
624});
625
626live("a read-only SSH key may clone but not push", async (server) => {
627 await createRepo(server, "site");
628
629 const readOnly = `${server.workDir}/id_readonly`;
630 await registerKey(server.dataDir, "alice", readOnly, ["--read-only"]);
631 const env = sshEnv(server, readOnly);
632
633 const cloned = await git(
634 ["clone", sshUrl(server, "alice", "site"), "ro"],
635 server.workDir,
636 env,
637 );
638 assertEquals(cloned.code, 0, cloned.stderr);
639
640 const repo = `${server.workDir}/ro`;
641 await Deno.writeTextFile(`${repo}/a.txt`, "a");
642 await git(["add", "."], repo, env);
643 await git(["commit", "-m", "a"], repo, env);
644
645 const pushed = await git(
646 ["push", "origin", "HEAD:refs/heads/main"],
647 repo,
648 env,
649 );
650 assert(pushed.code !== 0, "a read-only key must not push");
651});
652
653live(
654 "branch protection is enforced over SSH by the same rule",
655 async (server) => {
656 await createRepo(server, "site");
657 const env = sshEnv(server);
658 const repo = `${server.workDir}/work`;
659
660 await git(
661 ["clone", sshUrl(server, "alice", "site"), "work"],
662 server.workDir,
663 env,
664 );
665 await Deno.writeTextFile(`${repo}/a.txt`, "a");
666 await git(["add", "."], repo, env);
667 await git(["commit", "-m", "first"], repo, env);
668 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
669
670 // Unprotected: the rewrite goes through over SSH just as it does over HTTP.
671 await git(["commit", "--amend", "-m", "rewritten"], repo, env);
672 assertEquals(
673 (await git(
674 ["push", "--force", "origin", "HEAD:refs/heads/main"],
675 repo,
676 env,
677 )).code,
678 0,
679 );
680
681 await drain(
682 await api(server, "PATCH", "/v1/repos/alice/site", {
683 body: { protected_refs: ["refs/heads/main"] },
684 }),
685 );
686
687 const before = await json(
688 await api(server, "GET", "/v1/repos/alice/site/refs"),
689 );
690 await git(["commit", "--amend", "-m", "again"], repo, env);
691 const forced = await git(
692 ["push", "--force", "origin", "HEAD:refs/heads/main"],
693 repo,
694 env,
695 );
696
697 assert(forced.code !== 0, "protection must hold over SSH");
698 assertStringIncludes(forced.stderr, "protected");
699
700 const after = await json(
701 await api(server, "GET", "/v1/repos/alice/site/refs"),
702 );
703 assertEquals(after.items[0].sha, before.items[0].sha);
704 },
705);
706
707// ── Regressions ────────────────────────────────────────────────────────────
708
709live(
710 "a case-variant account cannot address another account's repository",
711 async (server) => {
712 await createRepo(server, "site");
713 const bob = await mintToken(server.dataDir, "bob");
714
715 // `Alice` folds to `alice`; bob must not reach it under either spelling.
716 for (const account of ["alice", "Alice", "ALICE"]) {
717 const response = await api(server, "GET", `/v1/repos/${account}/site`, {
718 token: bob,
719 });
720 await drain(response);
721 assertEquals(
722 response.status,
723 404,
724 `${account} must not be readable by bob`,
725 );
726 }
727
728 // The owner reaches the same single repository under any spelling.
729 const canonical = await json(
730 await api(server, "GET", "/v1/repos/Alice/SITE"),
731 );
732 assertEquals(canonical.account, "alice");
733 assertEquals(canonical.name, "site");
734 },
735);
736
737live(
738 "a repo-scoped admin token cannot delete a repository it cannot read",
739 async (server) => {
740 await createRepo(server, "site");
741 await createRepo(server, "secret");
742
743 const confined = await mintToken(server.dataDir, "alice", [
744 "--scopes",
745 "admin",
746 "--repos",
747 "site",
748 ]);
749
750 const read = await api(server, "GET", "/v1/repos/alice/secret", {
751 token: confined,
752 });
753 await drain(read);
754 assertEquals(read.status, 404);
755
756 const deleted = await api(server, "DELETE", "/v1/repos/alice/secret", {
757 token: confined,
758 });
759 await drain(deleted);
760 assertEquals(
761 deleted.status,
762 404,
763 "a token denied read must not be granted destroy",
764 );
765
766 const still = await api(server, "GET", "/v1/repos/alice/secret");
767 await drain(still);
768 assertEquals(still.status, 200, "the repository must still exist");
769 },
770);
771
772live(
773 "a duplicated service parameter is refused rather than resolved differently",
774 async (server) => {
775 await createRepo(server, "site");
776 const readOnly = await mintToken(server.dataDir, "alice", [
777 "--scopes",
778 "repo:read",
779 ]);
780
781 const single = await api(
782 server,
783 "GET",
784 "/alice/site.git/info/refs?service=git-receive-pack",
785 { token: readOnly },
786 );
787 await drain(single);
788 assertEquals(
789 single.status,
790 403,
791 "a reader must not get the push advertisement",
792 );
793
794 // This server takes the first value and the CGI takes the last, so a duplicate
795 // would let the authorization decision and the running service disagree.
796 const duplicated = await api(
797 server,
798 "GET",
799 "/alice/site.git/info/refs?service=git-upload-pack&service=git-receive-pack",
800 { token: readOnly },
801 );
802 const body = await drain(duplicated);
803 assertEquals(
804 duplicated.status,
805 404,
806 "a duplicated service parameter must not route",
807 );
808 assert(
809 !body.includes("receive-pack"),
810 "the write advertisement must not be reachable this way",
811 );
812 },
813);
814
815live("every git error is plain text, not problem+json", async (server) => {
816 await createRepo(server, "site");
817 const readOnly = await mintToken(server.dataDir, "alice", [
818 "--scopes",
819 "repo:read",
820 ]);
821
822 const cases: Array<[string, Record<string, unknown>]> = [
823 ["/alice/site.git/info/refs?service=git-receive-pack", { token: readOnly }],
824 ["/alice/absent.git/info/refs?service=git-upload-pack", {}],
825 ];
826
827 for (const [path, options] of cases) {
828 const response = await api(server, "GET", path, options);
829 const body = await drain(response);
830 assert(response.status >= 400, `${path} should have failed`);
831 assert(
832 !(response.headers.get("content-type") ?? "").includes("problem+json"),
833 `${path} answered problem+json, which git prints at the user`,
834 );
835 assert(!body.trimStart().startsWith("{"), `${path} answered JSON`);
836 }
837});
838
839live("a token expires and stops working", async (server) => {
840 await createRepo(server, "site");
841
842 // `--days 0` expires at the moment it is minted.
843 const expired = await mintToken(server.dataDir, "alice", [
844 "--scopes",
845 "admin",
846 "--days",
847 "0",
848 ]);
849
850 const response = await api(server, "GET", "/v1/repos", { token: expired });
851 await drain(response);
852 assertEquals(response.status, 401, "an expired token must not authenticate");
853});
854
855live("credential files are not world-readable", async (server) => {
856 await createRepo(server, "site");
857
858 for (const file of ["tokens.json", "ssh_keys.json", "ssh_host_ed25519_key"]) {
859 const info = await Deno.stat(`${server.dataDir}/${file}`);
860 const mode = (info.mode ?? 0) & 0o077;
861 assertEquals(mode, 0, `${file} is readable by group or other`);
862 }
863});
864
865// ── Read API ───────────────────────────────────────────────────────────────
866
867live("repository content is readable over REST", async (server) => {
868 await createRepo(server, "site");
869 const env = sshEnv(server);
870 const repo = `${server.workDir}/work`;
871
872 await git(
873 ["clone", sshUrl(server, "alice", "site"), "work"],
874 server.workDir,
875 env,
876 );
877 await Deno.mkdir(`${repo}/src`, { recursive: true });
878 await Deno.writeTextFile(`${repo}/README.md`, "# hello\n");
879 await Deno.writeTextFile(`${repo}/src/main.rs`, "fn main() {}\n");
880 await git(["add", "."], repo, env);
881 await git(["commit", "-m", "initial"], repo, env);
882 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
883
884 const tree = await json(
885 await api(server, "GET", "/v1/repos/alice/site/tree"),
886 );
887 const names = tree.items.map((e: { name: string }) => e.name).sort();
888 assertEquals(names, ["README.md", "src"]);
889
890 const nested = await json(
891 await api(server, "GET", "/v1/repos/alice/site/tree/src"),
892 );
893 assertEquals(nested.items[0].path, "src/main.rs");
894 assertEquals(nested.items[0].type, "file");
895
896 const raw = await api(server, "GET", "/v1/repos/alice/site/raw/README.md");
897 const body = await drain(raw);
898 assertEquals(body, "# hello\n");
899 assertEquals(raw.headers.get("content-type"), "application/octet-stream");
900 assertEquals(raw.headers.get("x-content-type-options"), "nosniff");
901
902 const log = await json(
903 await api(server, "GET", "/v1/repos/alice/site/commits"),
904 );
905 assertEquals(log.items.length, 1);
906 assertEquals(log.items[0].message, "initial");
907 assert(/^[0-9a-f]{40}$/.test(log.items[0].sha));
908});
909
910live(
911 "read endpoints refuse traversal and smuggled git arguments",
912 async (server) => {
913 await createRepo(server, "site");
914
915 for (
916 const path of [
917 "/v1/repos/alice/site/raw/../../../etc/passwd",
918 "/v1/repos/alice/site/tree/.git/config",
919 "/v1/repos/alice/site/commits?ref=--upload-pack%3Devil",
920 "/v1/repos/alice/site/commits/--help",
921 "/v1/repos/alice/site/commits?limit=999999",
922 ]
923 ) {
924 const response = await api(server, "GET", path);
925 await drain(response);
926 assert(
927 response.status >= 400,
928 `${path} must be refused, got ${response.status}`,
929 );
930 }
931 },
932);
933
934live(
935 "the OpenAPI document is served and matches the crate version",
936 async (server) => {
937 const response = await api(server, "GET", "/openapi.json");
938 const doc = await json(response);
939 assertEquals(response.status, 200);
940 assertEquals(doc.openapi, "3.1.0");
941 assert(
942 doc.paths["/v1/repos"],
943 "the document must describe the repo collection",
944 );
945 },
946);
947
948live(
949 "the product name is not baked into behaviour a rename would break",
950 async (server) => {
951 // Problem type URIs are built from one place and are configurable, so a rename
952 // or a self-hoster's own docs origin does not require touching call sites.
953 const response = await api(server, "POST", "/v1/repos", {
954 body: { name: "../escape" },
955 });
956 const body = await json(response);
957 assertEquals(response.status, 400);
958 assertStringIncludes(body.type, "/problems/invalid-name");
959
960 // The auth realm is derived too.
961 const challenge = await fetch(`http://127.0.0.1:${server.port}/v1/repos`);
962 await challenge.text();
963 assertStringIncludes(
964 challenge.headers.get("www-authenticate") ?? "",
965 "realm=",
966 );
967 },
968);
969
970live("repository settings are patchable", async (server) => {
971 await createRepo(server, "site");
972
973 const described = await json(
974 await api(server, "PATCH", "/v1/repos/alice/site", {
975 body: { description: "the shop" },
976 }),
977 );
978 assertEquals(described.description, "the shop");
979
980 // merge-patch: null clears, absent leaves alone.
981 const cleared = await json(
982 await api(server, "PATCH", "/v1/repos/alice/site", {
983 body: { description: null },
984 }),
985 );
986 assertEquals(cleared.description, undefined);
987 assertEquals(cleared.name, "site", "an absent field must be untouched");
988
989 // A default branch that does not exist would leave clones detached.
990 const bad = await api(server, "PATCH", "/v1/repos/alice/site", {
991 body: { default_branch: "refs/heads/nope" },
992 });
993 await drain(bad);
994 assertEquals(bad.status, 409);
995});
996
997// ── git, at full power ─────────────────────────────────────────────────────
998
999live("git archive --remote works over SSH", async (server) => {
1000 await createRepo(server, "site");
1001 const env = sshEnv(server);
1002 const repo = `${server.workDir}/work`;
1003
1004 await git(
1005 ["clone", sshUrl(server, "alice", "site"), "work"],
1006 server.workDir,
1007 env,
1008 );
1009 await Deno.writeTextFile(`${repo}/README.md`, "archived\n");
1010 await git(["add", "."], repo, env);
1011 await git(["commit", "-m", "initial"], repo, env);
1012 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1013
1014 const archived = await git(
1015 [
1016 "archive",
1017 "--remote",
1018 sshUrl(server, "alice", "site"),
1019 "--format=tar",
1020 "-o",
1021 `${server.workDir}/out.tar`,
1022 "refs/heads/main",
1023 ],
1024 server.workDir,
1025 env,
1026 );
1027 assertEquals(archived.code, 0, archived.stderr);
1028
1029 const info = await Deno.stat(`${server.workDir}/out.tar`);
1030 assert(info.size > 0, "the archive must contain something");
1031});
1032
1033live("a read-only key may archive but still may not push", async (server) => {
1034 await createRepo(server, "site");
1035 const env = sshEnv(server);
1036 const repo = `${server.workDir}/work`;
1037 await git(
1038 ["clone", sshUrl(server, "alice", "site"), "work"],
1039 server.workDir,
1040 env,
1041 );
1042 await Deno.writeTextFile(`${repo}/a.txt`, "a");
1043 await git(["add", "."], repo, env);
1044 await git(["commit", "-m", "a"], repo, env);
1045 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1046
1047 const readOnly = `${server.workDir}/id_ro_archive`;
1048 await registerKey(server.dataDir, "alice", readOnly, ["--read-only"]);
1049 const roEnv = sshEnv(server, readOnly);
1050
1051 const archived = await git(
1052 [
1053 "archive",
1054 "--remote",
1055 sshUrl(server, "alice", "site"),
1056 "--format=tar",
1057 "-o",
1058 `${server.workDir}/ro.tar`,
1059 "refs/heads/main",
1060 ],
1061 server.workDir,
1062 roEnv,
1063 );
1064 assertEquals(archived.code, 0, "archiving is a read and must be allowed");
1065});
1066
1067// ── discovery ──────────────────────────────────────────────────────────────
1068
1069/** Seed a repository with two commits and a small tree. */
1070async function seed(server: Server, name = "site") {
1071 await createRepo(server, name);
1072 const env = sshEnv(server);
1073 const repo = `${server.workDir}/${name}-work`;
1074 await git(
1075 ["clone", sshUrl(server, "alice", name), `${name}-work`],
1076 server.workDir,
1077 env,
1078 );
1079
1080 await Deno.mkdir(`${repo}/src`, { recursive: true });
1081 await Deno.writeTextFile(`${repo}/README.md`, "# project\nneedle-one\n");
1082 await Deno.writeTextFile(`${repo}/src/main.rs`, "fn main() {}\n");
1083 await git(["add", "."], repo, env);
1084 await git(["commit", "-m", "initial"], repo, env);
1085 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1086
1087 await Deno.writeTextFile(
1088 `${repo}/src/main.rs`,
1089 "fn main() {\n // needle-two\n}\n",
1090 );
1091 await git(["add", "."], repo, env);
1092 await git(["commit", "-m", "second"], repo, env);
1093 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1094
1095 return { repo, env };
1096}
1097
1098live("compare reports what changed between two points", async (server) => {
1099 await seed(server);
1100 const log = await json(
1101 await api(server, "GET", "/v1/repos/alice/site/commits"),
1102 );
1103 const [head, base] = log.items.map((c: { sha: string }) => c.sha);
1104
1105 const compared = await json(
1106 await api(
1107 server,
1108 "GET",
1109 `/v1/repos/alice/site/compare?base=${base}&head=${head}`,
1110 ),
1111 );
1112 assertEquals(compared.merge_base, base);
1113 assertEquals(compared.commits.length, 1);
1114 assertEquals(compared.changed.length, 1);
1115 assertEquals(compared.changed[0].path, "src/main.rs");
1116 assert(compared.additions > 0, "an edit should report additions");
1117});
1118
1119live(
1120 "search finds content and refuses to be used as a flag",
1121 async (server) => {
1122 await seed(server);
1123
1124 const found = await json(
1125 await api(server, "GET", "/v1/repos/alice/site/search?q=needle-two"),
1126 );
1127 assertEquals(found.items.length, 1);
1128 assertEquals(found.items[0].path, "src/main.rs");
1129
1130 const scoped = await json(
1131 await api(server, "GET", "/v1/repos/alice/site/search?q=needle&path=src"),
1132 );
1133 assert(
1134 scoped.items.every((m: { path: string }) => m.path.startsWith("src/")),
1135 );
1136
1137 const missing = await json(
1138 await api(server, "GET", "/v1/repos/alice/site/search?q=nothinghere"),
1139 );
1140 assertEquals(
1141 missing.items.length,
1142 0,
1143 "no matches is an answer, not an error",
1144 );
1145
1146 const noQuery = await api(server, "GET", "/v1/repos/alice/site/search");
1147 await drain(noQuery);
1148 assertEquals(noQuery.status, 400);
1149 },
1150);
1151
1152live("blame attributes each line to a commit", async (server) => {
1153 await seed(server);
1154 const blamed = await json(
1155 await api(server, "GET", "/v1/repos/alice/site/blame/src/main.rs"),
1156 );
1157 assertEquals(blamed.path, "src/main.rs");
1158 assert(blamed.items.length >= 3, "every line should be attributed");
1159 assert(/^[0-9a-f]{40}$/.test(blamed.items[0].sha));
1160 assertEquals(blamed.items[0].author, "Agent");
1161 assert(
1162 blamed.items.some((l: { content: string }) =>
1163 l.content.includes("needle-two")
1164 ),
1165 );
1166});
1167
1168live("a path-filtered log reports whether it was truncated", async (server) => {
1169 await seed(server);
1170 const filtered = await json(
1171 await api(server, "GET", "/v1/repos/alice/site/commits?path=src/main.rs"),
1172 );
1173 assertEquals(filtered.items.length, 2);
1174 assertEquals(filtered.truncated, false);
1175});
1176
1177// ── MCP ────────────────────────────────────────────────────────────────────
1178
1179let rpcId = 0;
1180async function rpc(
1181 server: Server,
1182 method: string,
1183 params: unknown = {},
1184 token = server.token,
1185 // deno-lint-ignore no-explicit-any
1186): Promise<any> {
1187 const response = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1188 method: "POST",
1189 headers: {
1190 authorization: `Bearer ${token}`,
1191 "content-type": "application/json",
1192 },
1193 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }),
1194 });
1195 return JSON.parse(await response.text());
1196}
1197
1198async function callTool(
1199 server: Server,
1200 name: string,
1201 args: Record<string, unknown> = {},
1202 token = server.token,
1203 // deno-lint-ignore no-explicit-any
1204): Promise<any> {
1205 return await rpc(server, "tools/call", { name, arguments: args }, token);
1206}
1207
1208live(
1209 "MCP handshake advertises tools and identifies the server",
1210 async (server) => {
1211 const init = await rpc(server, "initialize", {});
1212 assertEquals(init.jsonrpc, "2.0");
1213 assert(init.result.protocolVersion, "a protocol version is required");
1214 assert(
1215 init.result.capabilities.tools,
1216 "tools capability must be advertised",
1217 );
1218 assert(init.result.serverInfo.name);
1219
1220 // An agent must be told that writes go through git, not through a tool.
1221 assertStringIncludes(init.result.instructions, "git");
1222
1223 const listed = await rpc(server, "tools/list");
1224 const names = listed.result.tools.map((t: { name: string }) => t.name);
1225 for (
1226 const expected of [
1227 "repo_create",
1228 "key_add",
1229 "tree_list",
1230 "search",
1231 "blame",
1232 "compare",
1233 ]
1234 ) {
1235 assert(names.includes(expected), `${expected} must be offered`);
1236 }
1237 for (const banned of ["file_write", "commit_create", "push"]) {
1238 assert(!names.includes(banned), `${banned} would be a second write path`);
1239 }
1240 },
1241);
1242
1243live(
1244 "MCP handles unknown methods and notifications correctly",
1245 async (server) => {
1246 const unknown = await rpc(server, "nonsense/method");
1247 assertEquals(unknown.error.code, -32601);
1248
1249 // A notification carries no id and takes no reply.
1250 const notified = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1251 method: "POST",
1252 headers: {
1253 authorization: `Bearer ${server.token}`,
1254 "content-type": "application/json",
1255 },
1256 body: JSON.stringify({
1257 jsonrpc: "2.0",
1258 method: "notifications/initialized",
1259 }),
1260 });
1261 await notified.text();
1262 assertEquals(notified.status, 202);
1263
1264 const unauthenticated = await fetch(`http://127.0.0.1:${server.port}/mcp`, {
1265 method: "POST",
1266 headers: { "content-type": "application/json" },
1267 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
1268 });
1269 await unauthenticated.text();
1270 assertEquals(unauthenticated.status, 401);
1271 },
1272);
1273
1274live(
1275 "an agent provisions and inspects a repository entirely over MCP",
1276 async (server) => {
1277 // repo_create → key_add → git push → discovery. The whole product loop.
1278 const created = await callTool(server, "repo_create", { name: "shop" });
1279 const view = created.result.structuredContent;
1280 assertEquals(view.name, "shop");
1281 assert(view.clone_url_ssh, "the agent needs somewhere to push");
1282
1283 const keyPath = `${server.workDir}/id_mcp`;
1284 await new Deno.Command("ssh-keygen", {
1285 args: ["-t", "ed25519", "-N", "", "-f", keyPath, "-q"],
1286 stdout: "null",
1287 stderr: "null",
1288 }).output();
1289 const pub = await Deno.readTextFile(`${keyPath}.pub`);
1290
1291 const added = await callTool(server, "key_add", {
1292 title: "agent",
1293 key: pub,
1294 });
1295 assert(added.result.structuredContent.fingerprint.startsWith("SHA256:"));
1296
1297 // The key just registered must actually work for git.
1298 const env = sshEnv(server, keyPath);
1299 const repo = `${server.workDir}/shop`;
1300 assertEquals(
1301 (await git(
1302 ["clone", sshUrl(server, "alice", "shop"), "shop"],
1303 server.workDir,
1304 env,
1305 )).code,
1306 0,
1307 "a key registered over MCP must authenticate over SSH",
1308 );
1309 await Deno.writeTextFile(`${repo}/price.txt`, "10\n");
1310 await git(["add", "."], repo, env);
1311 await git(["commit", "-m", "add price"], repo, env);
1312 assertEquals(
1313 (await git(["push", "origin", "HEAD:refs/heads/main"], repo, env)).code,
1314 0,
1315 );
1316
1317 // Discovery sees what git pushed.
1318 const tree = await callTool(server, "tree_list", { repo: "shop" });
1319 const names = tree.result.structuredContent.items.map((
1320 e: { name: string },
1321 ) => e.name);
1322 assert(names.includes("price.txt"));
1323
1324 const read = await callTool(server, "file_read", {
1325 repo: "shop",
1326 path: "price.txt",
1327 });
1328 assertEquals(read.result.structuredContent.content, "10\n");
1329
1330 const refs = await callTool(server, "ref_list", { repo: "shop" });
1331 assertEquals(
1332 refs.result.structuredContent.items[0].name,
1333 "refs/heads/main",
1334 );
1335
1336 const listed = await callTool(server, "repo_list");
1337 const repos = listed.result.structuredContent.items.map((
1338 r: { name: string },
1339 ) => r.name);
1340 assert(repos.includes("shop"));
1341 },
1342);
1343
1344live(
1345 "MCP tool errors come back as tool results, not protocol failures",
1346 async (server) => {
1347 await createRepo(server, "site");
1348
1349 // A model should see the failure and adapt; a protocol error looks like a bug.
1350 const missing = await callTool(server, "file_read", {
1351 repo: "site",
1352 path: "nope.txt",
1353 });
1354 assertEquals(missing.result.isError, true);
1355 assertEquals(missing.error, undefined);
1356
1357 const badArgs = await callTool(server, "tree_list", {});
1358 assertEquals(badArgs.result.isError, true);
1359
1360 const unknownTool = await callTool(server, "not_a_tool", {});
1361 assertEquals(unknownTool.result.isError, true);
1362 },
1363);
1364
1365live("MCP enforces the same access rules as REST", async (server) => {
1366 await createRepo(server, "site");
1367 const bob = await mintToken(server.dataDir, "bob");
1368
1369 const foreign = await callTool(server, "repo_get", { repo: "site" }, bob);
1370 assertEquals(
1371 foreign.result.isError,
1372 true,
1373 "bob must not read alice's repository",
1374 );
1375
1376 const readOnly = await mintToken(server.dataDir, "alice", [
1377 "--scopes",
1378 "repo:read",
1379 ]);
1380 const denied = await callTool(
1381 server,
1382 "repo_create",
1383 { name: "nope" },
1384 readOnly,
1385 );
1386 assertEquals(denied.result.isError, true, "a read token must not create");
1387
1388 // Key management requires an unconfined admin token, as it does over REST.
1389 const confined = await mintToken(server.dataDir, "alice", [
1390 "--scopes",
1391 "admin",
1392 "--repos",
1393 "site",
1394 ]);
1395 const keys = await callTool(server, "key_list", {}, confined);
1396 assertEquals(keys.result.isError, true);
1397});
1398
1399live(
1400 "MCP discovery refuses traversal and smuggled git arguments",
1401 async (server) => {
1402 await seed(server);
1403
1404 for (
1405 const [tool, args] of [
1406 ["file_read", { repo: "site", path: "../../../etc/passwd" }],
1407 ["tree_list", { repo: "site", path: ".git" }],
1408 ["commit_log", { repo: "site", ref: "--upload-pack=evil" }],
1409 ["commit_get", { repo: "site", sha: "--help" }],
1410 ["compare", { repo: "site", base: "HEAD", head: "HEAD" }],
1411 ["blame", { repo: "site", path: ".git/config" }],
1412 ] as Array<[string, Record<string, unknown>]>
1413 ) {
1414 const result = await callTool(server, tool, args);
1415 assertEquals(
1416 result.result.isError,
1417 true,
1418 `${tool} must refuse ${JSON.stringify(args)}`,
1419 );
1420 }
1421 },
1422);
1423
1424// ── M4: limits, quotas and maintenance ─────────────────────────────────────
1425
1426live(
1427 "an over-quota account cannot create more repositories",
1428 async (server) => {
1429 // The harness server runs with the default repo cap; assert against what the
1430 // account endpoint reports rather than a number baked into the test.
1431 const account = await json(await api(server, "GET", "/v1/account"));
1432 assert(account.limits.max_repos > 0, "a repo cap must be configured");
1433 assertEquals(account.usage.repos, 0);
1434
1435 await createRepo(server, "one");
1436 const after = await json(await api(server, "GET", "/v1/account"));
1437 assertEquals(after.usage.repos, 1, "usage must track creation");
1438 },
1439);
1440
1441live(
1442 "quota enforcement is reported as a problem with limit and usage",
1443 async (server) => {
1444 // A dedicated server with a cap of one repository.
1445 const tight = await startWith({ ZUKA_MAX_REPOS_PER_ACCOUNT: "1" });
1446 try {
1447 await createRepo(tight, "first");
1448
1449 const refused = await api(tight, "POST", "/v1/repos", {
1450 body: { name: "second" },
1451 });
1452 const problem = await json(refused);
1453 assertEquals(refused.status, 413);
1454 assertStringIncludes(problem.type, "/problems/quota-exceeded");
1455 assertEquals(problem.limit, 1);
1456 assertEquals(problem.used, 1);
1457
1458 // Reads keep working: over-quota blocks writes only.
1459 const readable = await api(tight, "GET", "/v1/repos/alice/first");
1460 await drain(readable);
1461 assertEquals(readable.status, 200, "over-quota must not break reads");
1462 } finally {
1463 await tight.stop();
1464 }
1465 },
1466);
1467
1468live("a rate-limited caller is refused with Retry-After", async (_server) => {
1469 const limited = await startWith({ ZUKA_RATE_PER_MINUTE: "5" });
1470 try {
1471 // Set the repository up before spending the allowance.
1472 await createRepo(limited, "site");
1473
1474 let refused: Response | undefined;
1475 for (let i = 0; i < 20; i++) {
1476 const response = await api(limited, "GET", "/v1/account");
1477 await drain(response);
1478 if (response.status === 429) {
1479 refused = response;
1480 break;
1481 }
1482 }
1483 assert(refused, "a caller must eventually be rate limited");
1484 assert(
1485 refused.headers.get("retry-after"),
1486 "a 429 must say when to come back",
1487 );
1488
1489 // The git transport is deliberately NOT rate limited: a clone is one long
1490 // request, already bounded by the concurrency semaphore, and a per-minute
1491 // count is the wrong instrument for it.
1492 const cloned = await git(
1493 ["clone", cloneUrl(limited, "alice", "site"), "still-works"],
1494 limited.workDir,
1495 );
1496 assertEquals(
1497 cloned.code,
1498 0,
1499 `git must keep working while the API is rate limited: ${cloned.stderr}`,
1500 );
1501 } finally {
1502 await limited.stop();
1503 }
1504});
1505
1506live(
1507 "gc reclaims unreachable objects and leaves the repository intact",
1508 async (server) => {
1509 const { repo, env } = await seed(server, "churn");
1510 const gitDir = `${server.dataDir}/git/alice/churn.git`;
1511
1512 // Rewriting a branch orphans the objects the old tip referenced.
1513 await Deno.writeTextFile(
1514 `${repo}/src/main.rs`,
1515 "fn main() { /* rewritten */ }\n",
1516 );
1517 await git(["add", "."], repo, env);
1518 await git(["commit", "--amend", "-m", "rewritten"], repo, env);
1519 await git(["push", "--force", "origin", "HEAD:refs/heads/main"], repo, env);
1520
1521 const before = await countLoose(gitDir);
1522 assert(before > 0, "there should be loose objects to collect");
1523
1524 // Zero grace so the test does not have to wait two weeks.
1525 const gc = await runCli(server, ["gc"], { ZUKA_GC_PRUNE_GRACE: "now" });
1526 assertEquals(gc.code, 0, gc.stderr);
1527 assertStringIncludes(gc.stderr, "repacked");
1528
1529 const intact = await new Deno.Command("git", {
1530 args: [
1531 "--git-dir",
1532 gitDir,
1533 "fsck",
1534 "--no-progress",
1535 "--connectivity-only",
1536 ],
1537 stdout: "null",
1538 stderr: "piped",
1539 }).output();
1540 assertEquals(intact.code, 0, "gc must not corrupt the repository");
1541
1542 // The repository still serves after collection.
1543 const cloned = await git(
1544 ["clone", sshUrl(server, "alice", "churn"), "after-gc"],
1545 server.workDir,
1546 env,
1547 );
1548 assertEquals(cloned.code, 0, cloned.stderr);
1549 },
1550);
1551
1552live("gc runs safely while a clone is in flight", async (server) => {
1553 const { repo, env } = await seed(server, "busy");
1554
1555 // Make the repository big enough that the clone is still running when gc starts.
1556 // getRandomValues caps at 64 KiB per call, so fill in chunks. Incompressible so
1557 // the pack cannot be shrunk away and the clone actually takes time.
1558 const big = new Uint8Array(12 * 1024 * 1024);
1559 for (let offset = 0; offset < big.length; offset += 65536) {
1560 crypto.getRandomValues(
1561 big.subarray(offset, Math.min(offset + 65536, big.length)),
1562 );
1563 }
1564 await Deno.writeFile(`${repo}/blob.bin`, big);
1565 await git(["add", "."], repo, env);
1566 await git(["commit", "-m", "big"], repo, env);
1567 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1568
1569 const cloning = git(
1570 ["clone", sshUrl(server, "alice", "busy"), "concurrent"],
1571 server.workDir,
1572 env,
1573 );
1574 // Default grace, which is what makes this safe — not a lock.
1575 const collecting = runCli(server, ["gc"], {});
1576
1577 const [cloned, gc] = await Promise.all([cloning, collecting]);
1578 assertEquals(gc.code, 0, gc.stderr);
1579 assertEquals(
1580 cloned.code,
1581 0,
1582 `a clone must survive concurrent gc: ${cloned.stderr}`,
1583 );
1584
1585 const size = (await Deno.stat(`${server.workDir}/concurrent/blob.bin`)).size;
1586 assertEquals(size, big.length, "the cloned content must be complete");
1587});
1588
1589live("sweep reclaims deleted repositories once they expire", async (server) => {
1590 await createRepo(server, "doomed");
1591 await drain(await api(server, "DELETE", "/v1/repos/alice/doomed"));
1592
1593 const graves = () =>
1594 [...Deno.readDirSync(`${server.dataDir}/tmp/deleted`)].length;
1595 assertEquals(graves(), 1, "delete must keep the repository recoverable");
1596
1597 // Retention still running: nothing is reclaimed.
1598 const kept = await runCli(server, ["sweep"], {
1599 ZUKA_DELETED_RETENTION_DAYS: "7",
1600 });
1601 assertEquals(kept.code, 0);
1602 assertEquals(graves(), 1, "a fresh delete must stay recoverable");
1603
1604 const swept = await runCli(server, ["sweep"], {
1605 ZUKA_DELETED_RETENTION_DAYS: "0",
1606 });
1607 assertEquals(swept.code, 0, swept.stderr);
1608 assertStringIncludes(swept.stderr, "removed 1");
1609 assertEquals(graves(), 0, "an expired delete must be reclaimed");
1610});
1611
1612live("fsck finds an orphan in both directions", async (server) => {
1613 await createRepo(server, "paired");
1614 const clean = await runCli(server, ["fsck"], {});
1615 assertEquals(clean.code, 0, "a consistent store must exit zero");
1616
1617 // Metadata without a repository: what a crash mid-delete leaves.
1618 await Deno.remove(`${server.dataDir}/git/alice/paired.git`, {
1619 recursive: true,
1620 });
1621 const dangling = await runCli(server, ["fsck"], {});
1622 assertEquals(
1623 dangling.code,
1624 1,
1625 "an orphan must be reported as a non-zero exit",
1626 );
1627 assertStringIncludes(dangling.stdout, "record-without-repo");
1628
1629 // Repair removes the record, and the result is durable.
1630 const repaired = await runCli(server, ["fsck", "--repair"], {});
1631 assertEquals(repaired.code, 0);
1632 assertStringIncludes(repaired.stderr, "1 repaired");
1633 assertEquals((await runCli(server, ["fsck"], {})).code, 0);
1634
1635 // A repository without metadata: reported, never deleted.
1636 await createRepo(server, "lonely");
1637 await Deno.remove(`${server.dataDir}/meta/alice/lonely.json`);
1638 const lonely = await runCli(server, ["fsck", "--repair"], {});
1639 assertStringIncludes(lonely.stdout, "repo-without-record");
1640 assert(
1641 await Deno.stat(`${server.dataDir}/git/alice/lonely.git/HEAD`).then(() =>
1642 true
1643 ),
1644 "repair must never delete real repository bytes",
1645 );
1646});
1647
1648live(
1649 "newly created repositories do not run gc inside a push",
1650 async (server) => {
1651 await createRepo(server, "quiet");
1652 const gitDir = `${server.dataDir}/git/alice/quiet.git`;
1653
1654 for (
1655 const [key, expected] of [["gc.auto", "0"], ["receive.autogc", "false"]]
1656 ) {
1657 const value = await new Deno.Command("git", {
1658 args: ["--git-dir", gitDir, "config", "--get", key],
1659 stdout: "piped",
1660 stderr: "null",
1661 }).output();
1662 assertEquals(
1663 new TextDecoder().decode(value.stdout).trim(),
1664 expected,
1665 `${key} must be off so maintenance is scheduled, not opportunistic`,
1666 );
1667 }
1668 },
1669);
1670
1671// ── M5: CI ─────────────────────────────────────────────────────────────────
1672
1673/** Push a repository carrying a CI spec, on a server with CI enabled. */
1674async function seedCi(server: Server, name: string, spec: string) {
1675 await createRepo(server, name);
1676 const env = sshEnv(server);
1677 const repo = `${server.workDir}/${name}-ci`;
1678 await git(
1679 ["clone", sshUrl(server, "alice", name), `${name}-ci`],
1680 server.workDir,
1681 env,
1682 );
1683
1684 await Deno.writeTextFile(`${repo}/.zuka.toml`, spec);
1685 await git(["add", "."], repo, env);
1686 await git(["commit", "-m", "add ci"], repo, env);
1687 const pushed = await git(
1688 ["push", "origin", "HEAD:refs/heads/main"],
1689 repo,
1690 env,
1691 );
1692 assertEquals(pushed.code, 0, pushed.stderr);
1693 return { repo, env };
1694}
1695
1696/** Poll until a run reaches a terminal state. */
1697async function settle(server: Server, repo: string, timeoutMs = 30_000) {
1698 const deadline = Date.now() + timeoutMs;
1699 for (;;) {
1700 const body = await json(
1701 await api(server, "GET", `/v1/repos/alice/${repo}/runs`),
1702 );
1703 const run = body.items[0];
1704 if (run && !["queued", "running"].includes(run.status)) return run;
1705 if (Date.now() > deadline) {
1706 throw new Error(`run did not settle: ${JSON.stringify(body.items)}`);
1707 }
1708 await new Promise((r) => setTimeout(r, 200));
1709 }
1710}
1711
1712live(
1713 "a push triggers a run that succeeds and captures output",
1714 async (_server) => {
1715 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1716 try {
1717 await seedCi(
1718 ci,
1719 "green",
1720 '[run]\nsteps = ["echo hello-from-ci", "true"]\n',
1721 );
1722
1723 const run = await settle(ci, "green");
1724 assertEquals(run.status, "succeeded");
1725 assertEquals(run.exit_code, 0);
1726 assertEquals(run.git_ref, "refs/heads/main");
1727 assert(/^[0-9a-f]{40}$/.test(run.sha));
1728
1729 const logs = await drain(
1730 await api(ci, "GET", `/v1/repos/alice/green/runs/${run.id}/logs`),
1731 );
1732 assertStringIncludes(logs, "hello-from-ci");
1733 assertStringIncludes(logs, "all steps succeeded");
1734 } finally {
1735 await ci.stop();
1736 }
1737 },
1738);
1739
1740live(
1741 "a failing step fails the run and stops the remaining steps",
1742 async (_server) => {
1743 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1744 try {
1745 await seedCi(
1746 ci,
1747 "red",
1748 '[run]\nsteps = ["echo first", "exit 3", "echo never-runs"]\n',
1749 );
1750
1751 const run = await settle(ci, "red");
1752 assertEquals(run.status, "failed");
1753 assertEquals(run.exit_code, 3);
1754
1755 const logs = await drain(
1756 await api(ci, "GET", `/v1/repos/alice/red/runs/${run.id}/logs`),
1757 );
1758 assertStringIncludes(logs, "first");
1759 assert(!logs.includes("never-runs"), "a failed step must stop the run");
1760 } finally {
1761 await ci.stop();
1762 }
1763 },
1764);
1765
1766live("a run that exceeds its timeout is killed", async (_server) => {
1767 const ci = await startWith({
1768 ZUKA_CI_ENABLED: "1",
1769 ZUKA_CI_TIMEOUT_SECS: "2",
1770 });
1771 try {
1772 await seedCi(ci, "slow", '[run]\nsteps = ["sleep 60"]\ntimeout_secs = 2\n');
1773
1774 const run = await settle(ci, "slow", 30_000);
1775 assertEquals(run.status, "timed_out");
1776
1777 // The whole process group must be gone, not just the shell.
1778 const survivors = await new Deno.Command("pgrep", {
1779 args: ["-f", "sleep 60"],
1780 stdout: "piped",
1781 })
1782 .output();
1783 assertEquals(
1784 new TextDecoder().decode(survivors.stdout).trim(),
1785 "",
1786 "a timeout must kill the process group, not orphan it",
1787 );
1788 } finally {
1789 await ci.stop();
1790 }
1791});
1792
1793live("CI steps cannot read the server's environment", async (_server) => {
1794 const ci = await startWith({
1795 ZUKA_CI_ENABLED: "1",
1796 // A secret the service process holds. A step must not see it.
1797 ZUKA_TEST_SECRET: "hunter2",
1798 });
1799 try {
1800 await seedCi(ci, "envtest", '[run]\nsteps = ["env | sort"]\n');
1801
1802 const run = await settle(ci, "envtest");
1803 const logs = await drain(
1804 await api(ci, "GET", `/v1/repos/alice/envtest/runs/${run.id}/logs`),
1805 );
1806
1807 assert(
1808 !logs.includes("hunter2"),
1809 "the service's environment must be scrubbed",
1810 );
1811 assertStringIncludes(
1812 logs,
1813 "ZUKA_RUN_ID=",
1814 "a step should know its own run",
1815 );
1816 assertStringIncludes(logs, "PATH=", "a step needs to find its tools");
1817 } finally {
1818 await ci.stop();
1819 }
1820});
1821
1822live("a repository with no spec is a no-op, not a failure", async (_server) => {
1823 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1824 try {
1825 await createRepo(ci, "nospec");
1826 const env = sshEnv(ci);
1827 const repo = `${ci.workDir}/nospec-x`;
1828 await git(
1829 ["clone", sshUrl(ci, "alice", "nospec"), "nospec-x"],
1830 ci.workDir,
1831 env,
1832 );
1833 await Deno.writeTextFile(`${repo}/README.md`, "no ci here\n");
1834 await git(["add", "."], repo, env);
1835 await git(["commit", "-m", "no ci"], repo, env);
1836 await git(["push", "origin", "HEAD:refs/heads/main"], repo, env);
1837
1838 const run = await settle(ci, "nospec");
1839 assertEquals(run.status, "succeeded");
1840 assertEquals(run.detail, "no spec");
1841 } finally {
1842 await ci.stop();
1843 }
1844});
1845
1846live(
1847 "a spec may lower its timeout but never raise it past the host ceiling",
1848 async (_server) => {
1849 const ci = await startWith({
1850 ZUKA_CI_ENABLED: "1",
1851 ZUKA_CI_TIMEOUT_SECS: "3",
1852 });
1853 try {
1854 // The spec asks for an hour; the host allows three seconds.
1855 await seedCi(
1856 ci,
1857 "greedy",
1858 '[run]\nsteps = ["sleep 60"]\ntimeout_secs = 3600\n',
1859 );
1860
1861 const started = Date.now();
1862 const run = await settle(ci, "greedy", 30_000);
1863 assertEquals(run.status, "timed_out");
1864 assert(
1865 Date.now() - started < 25_000,
1866 "a repository must not be able to hold a runner past the host ceiling",
1867 );
1868 } finally {
1869 await ci.stop();
1870 }
1871 },
1872);
1873
1874live("runs are triggerable and cancellable over the API", async (_server) => {
1875 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1876 try {
1877 await seedCi(ci, "manual", '[run]\nsteps = ["sleep 30"]\n');
1878 await settle(ci, "manual", 40_000).catch(() => {});
1879
1880 const triggered = await api(ci, "POST", `/v1/repos/alice/manual/runs`, {
1881 body: { ref: "refs/heads/main" },
1882 });
1883 const queued = await json(triggered);
1884 assertEquals(triggered.status, 201);
1885 assertEquals(queued.status, "queued");
1886
1887 const cancelled = await json(
1888 await api(ci, "PATCH", `/v1/repos/alice/manual/runs/${queued.id}`, {
1889 body: { status: "cancelled" },
1890 }),
1891 );
1892 assertEquals(cancelled.status, "cancelled");
1893
1894 // Cancelling a finished run is a conflict, not a silent no-op.
1895 const again = await api(
1896 ci,
1897 "PATCH",
1898 `/v1/repos/alice/manual/runs/${queued.id}`,
1899 {
1900 body: { status: "cancelled" },
1901 },
1902 );
1903 await drain(again);
1904 assertEquals(again.status, 409);
1905 } finally {
1906 await ci.stop();
1907 }
1908});
1909
1910live("CI is off by default and triggering says so", async (server) => {
1911 await createRepo(server, "quiet");
1912 const health = await json(await api(server, "GET", "/healthz"));
1913 assertEquals(health.ci_enabled, false);
1914
1915 const refused = await api(server, "POST", "/v1/repos/alice/quiet/runs", {
1916 body: {},
1917 });
1918 const problem = await json(refused);
1919 assertEquals(refused.status, 409);
1920 assertStringIncludes(problem.type, "/problems/ci-disabled");
1921});
1922
1923live("an agent sees CI results over MCP", async (_server) => {
1924 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1925 try {
1926 await seedCi(ci, "mcpci", '[run]\nsteps = ["echo built-ok"]\n');
1927 await settle(ci, "mcpci");
1928
1929 const listed = await callTool(ci, "run_list", { repo: "mcpci" });
1930 const runs = listed.result.structuredContent.items;
1931 assertEquals(runs[0].status, "succeeded");
1932
1933 const logs = await callTool(ci, "run_logs", {
1934 repo: "mcpci",
1935 id: runs[0].id,
1936 });
1937 assertStringIncludes(logs.result.structuredContent.logs, "built-ok");
1938
1939 const one = await callTool(ci, "run_get", {
1940 repo: "mcpci",
1941 id: runs[0].id,
1942 });
1943 assertEquals(one.result.structuredContent.exit_code, 0);
1944 } finally {
1945 await ci.stop();
1946 }
1947});
1948
1949live("deleting a repository takes its runs with it", async (_server) => {
1950 const ci = await startWith({ ZUKA_CI_ENABLED: "1" });
1951 try {
1952 await seedCi(ci, "doomed", '[run]\nsteps = ["true"]\n');
1953 await settle(ci, "doomed");
1954
1955 await drain(await api(ci, "DELETE", "/v1/repos/alice/doomed"));
1956 const runsDir = `${ci.dataDir}/runs/alice/doomed`;
1957 await assertRejects(() => Deno.stat(runsDir));
1958 } finally {
1959 await ci.stop();
1960 }
1961});
1962
1963// ── M7: import ─────────────────────────────────────────────────────────────
1964
1965live(
1966 "import refuses every URL that could reach the local network",
1967 async (server) => {
1968 for (
1969 const url of [
1970 `http://127.0.0.1:${server.port}/alice/x.git`, // this very server
1971 "http://169.254.169.254/latest/meta-data/", // cloud metadata
1972 "http://localhost/a.git", // a name resolving to loopback
1973 "http://10.0.0.1/a.git",
1974 "http://192.168.1.1/a.git",
1975 "file:///etc/passwd",
1976 "ssh://git@github.com/a/b.git",
1977 "git://github.com/a/b.git",
1978 "https://user:token@github.com/a/b.git", // would persist creds in config
1979 "--upload-pack=evil",
1980 ]
1981 ) {
1982 const response = await api(server, "POST", "/v1/repos", {
1983 body: {
1984 name: `imp${Math.floor(Math.random() * 1e9)}`,
1985 import_url: url,
1986 },
1987 });
1988 const problem = await json(response);
1989 assertEquals(response.status, 400, `${url} must be refused`);
1990 assertStringIncludes(problem.type, "/problems/invalid-import-url");
1991 }
1992 },
1993);
1994
1995live(
1996 "a repository imports with its history and is adopted",
1997 async (_server) => {
1998 // Serve a real bare repository over plain HTTP with git's dumb protocol, so the
1999 // import exercises the actual fetch path rather than only the guards. Importing
2000 // from loopback needs the operator opt-in, which is the point of that setting.
2001 const target = await startWith({ ZUKA_IMPORT_ALLOW_PRIVATE: "1" });
2002 const serveDir = await Deno.makeTempDir({ prefix: "zuka-origin-" });
2003 let httpd: Deno.HttpServer | undefined;
2004
2005 try {
2006 // Build a repository with two commits.
2007 const work = `${serveDir}/work`;
2008 await git(["init", "-q", "--initial-branch=main", work], serveDir);
2009 await Deno.writeTextFile(`${work}/README.md`, "imported\n");
2010 await git(["add", "."], work);
2011 await git(["commit", "-m", "first"], work);
2012 await Deno.writeTextFile(`${work}/second.txt`, "two\n");
2013 await git(["add", "."], work);
2014 await git(["commit", "-m", "second"], work);
2015
2016 // Mirror it and generate the files the dumb protocol needs.
2017 const bare = `${serveDir}/origin.git`;
2018 await git(["clone", "--bare", "-q", work, bare], serveDir);
2019 await git(["update-server-info"], bare);
2020 await git(
2021 ["--git-dir", bare, "config", "http.receivepack", "false"],
2022 serveDir,
2023 );
2024
2025 const port = await freePort();
2026 httpd = Deno.serve(
2027 { port, hostname: "127.0.0.1", onListen: () => {} },
2028 async (req) => {
2029 const path = new URL(req.url).pathname.replace(/^\/origin\.git/, "");
2030 try {
2031 return new Response(await Deno.readFile(`${bare}${path}`));
2032 } catch {
2033 return new Response("not found", { status: 404 });
2034 }
2035 },
2036 );
2037
2038 const created = await api(target, "POST", "/v1/repos", {
2039 body: {
2040 name: "copy",
2041 import_url: `http://127.0.0.1:${port}/origin.git`,
2042 },
2043 });
2044 const view = await json(created);
2045 assertEquals(created.status, 201, JSON.stringify(view));
2046 assertEquals(view.name, "copy");
2047
2048 // History arrived.
2049 const log = await json(
2050 await api(target, "GET", "/v1/repos/alice/copy/commits"),
2051 );
2052 assertEquals(log.items.length, 2, "the full history must be imported");
2053 assertEquals(log.items[0].message, "second");
2054
2055 const readme = await drain(
2056 await api(target, "GET", "/v1/repos/alice/copy/raw/README.md"),
2057 );
2058 assertEquals(readme, "imported\n");
2059
2060 // Adopted: an imported repository must be indistinguishable from one we made.
2061 const gitDir = `${target.dataDir}/git/alice/copy.git`;
2062 for (
2063 const [key, expected] of [["gc.auto", "0"], [
2064 "receive.fsckObjects",
2065 "true",
2066 ]]
2067 ) {
2068 const value = await new Deno.Command("git", {
2069 args: ["--git-dir", gitDir, "config", "--get", key],
2070 stdout: "piped",
2071 stderr: "null",
2072 }).output();
2073 assertEquals(
2074 new TextDecoder().decode(value.stdout).trim(),
2075 expected,
2076 `an imported repository must carry ${key}`,
2077 );
2078 }
2079 const hook = await Deno.lstat(`${gitDir}/hooks/update`);
2080 assert(hook.isSymlink, "an imported repository must get our hooks");
2081
2082 // And it serves: clone the copy back out.
2083 const cloned = await git(
2084 ["clone", cloneUrl(target, "alice", "copy"), "roundtrip"],
2085 target.workDir,
2086 );
2087 assertEquals(cloned.code, 0, cloned.stderr);
2088 assertEquals(
2089 await Deno.readTextFile(`${target.workDir}/roundtrip/second.txt`),
2090 "two\n",
2091 );
2092 } finally {
2093 await httpd?.shutdown();
2094 await target.stop();
2095 await Deno.remove(serveDir, { recursive: true }).catch(() => {});
2096 }
2097 },
2098);
2099
2100live("import can be disabled entirely", async (_server) => {
2101 const off = await startWith({ ZUKA_IMPORT_ENABLED: "0" });
2102 try {
2103 const response = await api(off, "POST", "/v1/repos", {
2104 body: {
2105 name: "nope",
2106 import_url: "https://github.com/rust-lang/rust.git",
2107 },
2108 });
2109 const problem = await json(response);
2110 assertEquals(response.status, 409);
2111 assertStringIncludes(problem.type, "/problems/import-disabled");
2112 } finally {
2113 await off.stop();
2114 }
2115});