tarball.test.ts

11.6 KB · raw

1import { beforeAll, describe, expect, test } from "bun:test";2import { existsSync } from "node:fs";3import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import { gunzipSync } from "node:zlib";7import type { Hono } from "hono";8import { openBucket } from "../src/bucket/reader.ts";9import { type SyncTarget, syncAll, syncRepo } from "../src/bucket/sync.ts";10import { openMirror } from "../src/mirror/store.ts";11import { createApp } from "../src/server/app.ts";12import { openKeprDb } from "../src/server/db.ts";13import { buildTarball, isStripped } from "../src/tarball/tar.ts";14import { type Fixture, makeKohBucket } from "./fixture.ts";1516let fx: Fixture;17let app: Hono;18let target: SyncTarget;19let head: string;20let dataDir: string;21const get = (path: string) => app.request(path);22const bytesOf = async (path: string) => new Uint8Array(await (await get(path)).arrayBuffer());2324// Every root-level file on Kepr 2's strip list, the .agent folder, and the look-alikes that must stay.25const EXTRA: Record<string, string> = {26	"AGENTS.md": "agents\n",27	"CONTINUITY.md": "notes\n",28	"Cargo.lock": "lock\n",29	"koh.build": "build\n",30	".gitignore": "x\n",31	".agent/CONTINUITY.md": "state\n",32	".agentry/keep.txt": "kept\n",33	"flake.nix": "{ outputs = _: { }; }\n",34	"flake.lock": "{}\n",35	"build.zig.zon": ".{}\n",36	"nested/go.sum": "kept\n",37	"run.sh": "#!/bin/sh\necho run\n",38};3940beforeAll(async () => {41	fx = await makeKohBucket("tarry");42	for (const [path, text] of Object.entries(EXTRA)) {43		await mkdir(join(fx.repoDir, path, ".."), { recursive: true });44		await writeFile(join(fx.repoDir, path), text);45	}46	await chmod(join(fx.repoDir, "run.sh"), 0o755);47	const home = join(fx.repoDir, "../../home");48	const env = { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" };49	const save = Bun.spawnSync(["koh3", "save", "add build files"], { cwd: fx.repoDir, env });50	expect(save.exitCode).toBe(0);51	const log = Bun.spawnSync(["koh3", "log", "--json"], { cwd: fx.repoDir, env });52	head = JSON.parse(log.stdout.toString()).head_id;53	dataDir = await mkdtemp(join(tmpdir(), "kepr-tarball-"));54	const kepr = openKeprDb(dataDir);55	target = {56		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),57		kepr,58		mirrorDir: join(dataDir, "mirror"),59		tarballDir: join(dataDir, "tarballs"),60	};61	await syncAll(target);62	kepr.run("UPDATE repos SET public = 1");63	app = createApp(64		{65			domain: "kepr.example",66			dataDir,67			adminPasswordHash: "$argon2id$x",68			bucket: { kind: "folder", path: fx.bucketDir },69		},70		kepr,71	);72});7374function tar(args: string[], input: Uint8Array): string {75	const result = Bun.spawnSync(["tar", ...args], { stdin: input });76	expect(result.exitCode).toBe(0);77	return result.stdout.toString();78}7980describe("the tar format", () => {81	const header = (tarBytes: Uint8Array, index: number) =>82		tarBytes.subarray(index * 512, index * 512 + 512);83	const field = (h: Uint8Array, from: number, to: number) =>84		new TextDecoder().decode(h.subarray(from, to));8586	test("writes Kepr 2's headers: root directory, then files with zero owners and times", () => {87		const gz = buildTarball("r-12345678", [88			{ path: "b.txt", bytes: new TextEncoder().encode("hello source") },89			{ path: "a.sh", bytes: new Uint8Array() },90		]);91		expect([...gz.subarray(0, 10)]).toEqual([0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3]);92		const t = gunzipSync(gz);93		const [root, a, b] = [header(t, 0), header(t, 1), header(t, 2)];94		expect(field(root, 0, 12)).toBe("r-12345678/\0");95		expect(field(root, 100, 156)).toBe(96			`0000755\0${"0000000\0".repeat(2)}${"\0".repeat(12)}00000000000\0${field(root, 148, 156)}`,97		);98		expect(field(root, 156, 157)).toBe("5");99		expect(field(root, 257, 265)).toBe("ustar\u000000");100		expect(field(a, 0, 16)).toBe("r-12345678/a.sh\0");101		// Every file is 0644, executable or not, exactly as Kepr 2 wrote them (§22.3).102		expect(field(a, 100, 108)).toBe("0000644\0");103		expect(field(b, 100, 108)).toBe("0000644\0");104		expect(field(b, 124, 136)).toBe("00000000014\0");105		expect(field(b, 156, 157)).toBe("0");106		for (const h of [root, a, b]) {107			let sum = 0;108			for (const [i, byte] of h.entries()) sum += i >= 148 && i < 156 ? 32 : byte;109			expect(field(h, 148, 156)).toBe(`${sum.toString(8).padStart(6, "0")}\0 `);110		}111		expect(field(t, 3 * 512, 3 * 512 + 12)).toBe("hello source");112		expect(t.length).toBe(6 * 512);113	});114115	test("splits a long path into the ustar prefix, and refuses one that can't be split", () => {116		const long = `${"d".repeat(40)}/${"x".repeat(85)}`;117		const t = gunzipSync(buildTarball("r", [{ path: long, bytes: new Uint8Array() }]));118		const h = header(t, 1);119		expect(field(h, 345, 500).replace(/\0+$/, "")).toBe(`r/${"d".repeat(40)}`);120		expect(field(h, 0, 100).replace(/\0+$/, "")).toBe("x".repeat(85));121		expect(122			tar(["-tzf", "-"], buildTarball("r", [{ path: long, bytes: new Uint8Array() }])),123		).toContain(`r/${long}`);124		expect(() => buildTarball("r", [{ path: "y".repeat(120), bytes: new Uint8Array() }])).toThrow(125			"path too long",126		);127	});128129	test("the strip list matches the repo root only", () => {130		for (const path of [131			"koh.build",132			"kepr.build",133			"go.sum",134			"LOG.md",135			".agent",136			".agent/a/b.md",137			".gitignore",138		])139			expect(isStripped(path)).toBe(true);140		for (const path of [141			"flake.lock",142			"build.zig.zon",143			"nested/go.sum",144			"docs/AGENTS.md",145			".agentry/x",146			"log.md",147		])148			expect(isStripped(path)).toBe(false);149	});150});151152describe("the archive routes", () => {153	test("HEAD.tar.gz redirects uncached to the current save, naming it as the immutable link", async () => {154		const response = await get("/tarry/archive/HEAD.tar.gz");155		expect(response.status).toBe(302);156		expect(response.headers.get("location")).toBe(`/tarry/archive/${head}.tar.gz`);157		expect(response.headers.get("cache-control")).toBe("no-cache");158		expect(response.headers.get("link")).toBe(159			`<https://kepr.example/tarry/archive/${head}.tar.gz>; rel="immutable"`,160		);161	});162163	test("a prefix redirects to the full ID; unknown, malformed or private is a 404", async () => {164		const short = await get(`/tarry/archive/${head.slice(0, 7)}.tar.gz`);165		expect(short.status).toBe(302);166		expect(short.headers.get("location")).toBe(`/tarry/archive/${head}.tar.gz`);167		expect((await get("/tarry/archive/abcdef.tar.gz")).status).toBe(404);168		expect((await get("/tarry/archive/abc.tar.gz")).status).toBe(404);169		target.kepr.run("UPDATE repos SET public = 0");170		const hidden = [171			await get("/tarry/archive/HEAD.tar.gz"),172			await get(`/tarry/archive/${head}.tar.gz`),173		];174		target.kepr.run("UPDATE repos SET public = 1");175		expect(hidden.map((r) => r.status)).toEqual([404, 404]);176	});177178	test("serves the save's files under <repo>-<id8>/ minus the strip list, every file 0644", async () => {179		const response = await get(`/tarry/archive/${head}.tar.gz`);180		expect(response.status).toBe(200);181		expect(response.headers.get("content-type")).toBe("application/gzip");182		expect(response.headers.get("cache-control")).toBe("public, max-age=31536000");183		expect(response.headers.get("content-disposition")).toBe(184			`attachment; filename="tarry-${head.slice(0, 8)}.tar.gz"`,185		);186		const gz = new Uint8Array(await response.arrayBuffer());187		const root = `tarry-${head.slice(0, 8)}`;188		const listed = tar(["-tzf", "-"], gz).trim().split("\n");189		expect(listed).toEqual([190			`${root}/`,191			...[192				".agentry/keep.txt",193				"README.md",194				"build.zig.zon",195				"docs/guide.md",196				"docs/logo.png",197				"flake.lock",198				"flake.nix",199				"nested/go.sum",200				"run.sh",201				"secret.txt",202				"src/lib/deep/util.ts",203				"src/main.ts",204				"vendor/big.c",205			].map((path) => `${root}/${path}`),206		]);207		const out = await mkdtemp(join(tmpdir(), "kepr-untar-"));208		Bun.spawnSync(["tar", "-xzf", "-", "-C", out], { stdin: gz });209		expect(await readFile(join(out, root, "src/main.ts"), "utf8")).toContain("hello");210		expect(await readFile(join(out, root, "docs/logo.png"))).toEqual(211			await readFile(join(fx.repoDir, "docs/logo.png")),212		);213		expect(tar(["-tvzf", "-"], gz)).toMatch(/-rw-r--r-- .* tarry-[0-9a-f]{8}\/run\.sh/);214	});215216	test("is the same bytes when rebuilt, and in another process", async () => {217		const first = await bytesOf(`/tarry/archive/${head}.tar.gz`);218		const { dropTarballs } = await import("../src/tarball/cache.ts");219		dropTarballs(target.tarballDir, "tarry");220		expect(await bytesOf(`/tarry/archive/${head}.tar.gz`)).toEqual(first);221		// Every path the other process touches is a fresh temp dir, its working directory included,222		// so nothing it writes can land in the repo.223		const script = `import { cachedTarball } from "${join(import.meta.dir, "../src/tarball/cache.ts")}";224import { openMirror } from "${join(import.meta.dir, "../src/mirror/store.ts")}";225const { TARBALL_DIR, MIRROR_DIR, SAVE_ID } = process.env;226process.stdout.write(cachedTarball(TARBALL_DIR, openMirror(MIRROR_DIR, "tarry"), "tarry", SAVE_ID));`;227		const scratch = await mkdtemp(join(tmpdir(), "kepr-tb-"));228		const other = Bun.spawnSync(["bun", "-e", script], {229			cwd: scratch,230			env: {231				PATH: process.env.PATH,232				TARBALL_DIR: join(scratch, "tarballs"),233				MIRROR_DIR: target.mirrorDir,234				SAVE_ID: head,235			},236		});237		expect(other.stderr.toString()).toBe("");238		expect(new Uint8Array(other.stdout)).toEqual(first);239	});240241	test("after a strip, an old cached tarball is never served, even if its cleanup never ran", async () => {242		await get(`/tarry/archive/${head}.tar.gz`);243		const before = join(target.tarballDir, "tarry", "0", `${head}.tar.gz`);244		expect(existsSync(before)).toBe(true);245		await fx.strip("secret.txt");246		// syncRepo lands the strip without syncAll's cleanup: a crash between the two.247		await syncRepo(target.bucket, openMirror(target.mirrorDir, "tarry"), "tarry");248		expect(existsSync(before)).toBe(true);249		const listed = tar(["-tzf", "-"], await bytesOf(`/tarry/archive/${head}.tar.gz`));250		expect(listed).not.toContain("secret.txt");251		expect(listed).toContain("src/main.ts");252		expect(existsSync(join(target.tarballDir, "tarry", "1", `${head}.tar.gz`))).toBe(true);253	});254255	test("a sync that lands a strip clears the repo's old cached tarballs", async () => {256		await get(`/tarry/archive/${head}.tar.gz`);257		await fx.strip("vendor/big.c");258		await syncAll(target);259		expect(existsSync(join(target.tarballDir, "tarry"))).toBe(false);260		expect(tar(["-tzf", "-"], await bytesOf(`/tarry/archive/${head}.tar.gz`))).not.toContain(261			"big.c",262		);263	});264});265266describe("the archives panel", () => {267	test("lists HEAD and recent saves with tarball links, and nix lines where there is a flake", async () => {268		const response = await get("/tarry/archives");269		expect(response.status).toBe(200);270		expect(response.headers.get("cache-control")).toBe("public, max-age=60");271		const page = await response.text();272		const ids = [...page.matchAll(/class="history-id"[^>]*>([0-9a-f]{12})</g)].map((m) => m[1]);273		expect(ids).toEqual([head, fx.saveIds[0], fx.saveIds[2], fx.saveIds[3]]);274		expect(page).toContain('<span class="archive-head mono">HEAD</span>');275		expect(page).toContain('href="/tarry/archive/HEAD.tar.gz">↓ tarry-HEAD.tar.gz</a>');276		expect(page).toContain(277			'data-copy="nix profile add https://kepr.example/tarry/archive/HEAD.tar.gz"',278		);279		// Only HEAD has flake.nix; older saves get a tarball link but no nix line.280		expect(page.match(/nix profile add/g)?.length).toBe(2);281		const older = fx.saveIds[0] as string;282		expect(page).toContain(283			`href="/tarry/archive/${older}.tar.gz">↓ tarry-${older.slice(0, 8)}.tar.gz</a>`,284		);285		expect(page).not.toContain(fx.saveIds[1] as string);286	});287288	test("a private repo's panel is a 404", async () => {289		target.kepr.run("UPDATE repos SET public = 0");290		const status = (await get("/tarry/archives")).status;291		target.kepr.run("UPDATE repos SET public = 1");292		expect(status).toBe(404);293	});294});