1b4865e81200

prove public steal end to end: list the saves and objects a clone walks, and prune the redemption limit by value (v3.0.65)

time
2026-09-27 16:01 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
kepr connect and public steal
parent
4c969e0a75af
9 files package.json+1 −1src/mirror/remote.ts+23 −0src/server/connect.ts+4 −2tests/connect.test.ts+4 −0tests/fake-s3.ts+58 −0tests/fixture.ts+3 −3tests/koh-version.txt+1 −1tests/kohremote.test.ts+9 −0tests/kohsteal.test.ts+242 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.64",3+	"version": "3.0.65",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/mirror/remote.ts+23 −0
@@ -132,6 +132,29 @@132132 		}),133133 	);134134 135+	// Koh lists `<repo>/saves` and `<repo>/objects` as well as its events: a clone walks the chain136+	// it is given here, and fetches every object those saves name. Both lists follow the mirror, so137+	// neither is cached.138+	app.get("/:repo/koh/saves", (c) =>139+		serve(c, c.req.param("repo"), (mirror) => {140+			const ids = mirror141+				.query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")142+				.all()143+				.map((row) => row.id);144+			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);145+		}),146+	);147+148+	app.get("/:repo/koh/objects", (c) =>149+		serve(c, c.req.param("repo"), (mirror) => {150+			const hashes = mirror151+				.query<{ hash: string }, []>("SELECT hash FROM objects ORDER BY hash")152+				.all()153+				.map((row) => row.hash);154+			return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);155+		}),156+	);157+135158 	app.get("/:repo/koh/events", (c) =>136159 		serve(c, c.req.param("repo"), (mirror) => {137160 			const ids = mirror

view file

src/server/connect.ts+4 −2
@@ -15,7 +15,7 @@1515 // caller learns nothing but "no code".1616 const NO_CODE = '{"error":"no_code","message":"no code"}';1717 // Failed codes, oldest first, in memory: a restart forgets them, but an attacker can't cause one.18-const attempts: { at: number; address: string }[] = [];18+let attempts: { at: number; address: string }[] = [];1919 // Addresses are keyed hashes even in memory, as issue filings store them (§23).2020 const attemptKey = randomBytes(32);2121 const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");@@ -104,7 +104,9 @@104104 		if (!write || !bucket) return c.notFound();105105 		c.header("Cache-Control", "no-store");106106 		const now = Date.now();107-		while (attempts[0] && attempts[0].at <= now - ATTEMPT_WINDOW_MS) attempts.shift();107+		// By value, not by position: a clock that stepped backwards outside this process (a test's108+		// fake timer, an NTP correction) would leave the oldest entry in the middle of the list.109+		attempts = attempts.filter((attempt) => attempt.at > now - ATTEMPT_WINDOW_MS);108110 		const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex");109111 		const used = attempts.filter((attempt) => attempt.address === address).length;110112 		attempts.push({ at: now, address });

view file

tests/connect.test.ts+4 −0
@@ -55,6 +55,10 @@5555 	const body = await page.text();5656 	const match = /class="mono code">([^<]+)/.exec(body);5757 	const code = match?.[1]?.trim() ?? "";58+	if (code === "") {59+		// A page without a code is the interesting failure: say what it said instead.60+		throw new Error(`no code on the connect page (${page.status}): ${body.slice(0, 300)}`);61+	}5862 	return { page, body, code };5963 }6064 

view file

tests/fake-s3.ts+58 −0
@@ -0,0 +1,58 @@1+/**2+ * A bucket in memory, served over HTTP for the joint public-steal test: koh3's own S3 backend and3+ * Bun's S3Client both talk to it, so Kepr and the two koh3 machines share one bucket.4+ *5+ * It runs as its own process because the test drives koh3 with `Bun.spawnSync`, which would block6+ * the event loop this server needs. It prints its endpoint on the first line of stdout.7+ *8+ * Only what the two clients use is implemented: GET, HEAD, PUT (with `If-None-Match: *` for the9+ * one conditional write that matters, `head`), DELETE, and ListObjectsV2. Signatures are ignored10+ * rather than verified — the one way this is easier than a real bucket.11+ */12+const BUCKET = "kohbucket";13+const objects = new Map<string, Uint8Array>();14+15+async function handle(request: Request): Promise<Response> {16+	const url = new URL(request.url);17+	const [, bucket = "", ...rest] = url.pathname.split("/");18+	if (bucket !== BUCKET) return new Response("", { status: 404 });19+	if (url.searchParams.get("list-type") === "2") {20+		const prefix = url.searchParams.get("prefix") ?? "";21+		const keys = [...objects.keys()].filter((key) => key.startsWith(prefix)).sort();22+		const contents = keys23+			.map(24+				(key) =>25+					`<Contents><Key>${key}</Key><Size>${objects.get(key)?.length ?? 0}</Size></Contents>`,26+			)27+			.join("");28+		return new Response(29+			`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><IsTruncated>false</IsTruncated>${contents}</ListBucketResult>`,30+			{ headers: { "content-type": "application/xml" } },31+		);32+	}33+	const key = rest.join("/");34+	if (request.method === "PUT") {35+		const body = new Uint8Array(await request.arrayBuffer());36+		if (request.headers.get("if-none-match") === "*" && objects.has(key)) {37+			return new Response("", { status: 412 });38+		}39+		objects.set(key, body);40+		return new Response("", { status: 200, headers: { etag: `"${body.length}"` } });41+	}42+	if (request.method === "GET" || request.method === "HEAD") {43+		const body = objects.get(key);44+		if (!body) return new Response("", { status: 404 });45+		return new Response(request.method === "HEAD" ? null : body, {46+			status: 200,47+			headers: { etag: `"${body.length}"`, "content-length": String(body.length) },48+		});49+	}50+	if (request.method === "DELETE") {51+		objects.delete(key);52+		return new Response("", { status: 204 });53+	}54+	return new Response("", { status: 404 });55+}56+57+const server = Bun.serve({ port: 0, fetch: handle });58+console.log(`http://127.0.0.1:${server.port}`);

view file

tests/fixture.ts+3 −3
@@ -105,7 +105,7 @@105105 		repo,106106 		repoDir,107107 		saveIds: log.saves.map((save) => save.id),108-		strip: (path) => strip(repoDir, home, path),108+		strip: (path) => stripPath(repoDir, home, path),109109 	};110110 }111111 @@ -123,8 +123,8 @@123123 	return { PATH: process.env.PATH, HOME: home, NO_COLOR: "1" };124124 }125125 126-// `koh strip` insists on a terminal and a typed confirmation, so it runs under a Bun PTY.127-async function strip(cwd: string, home: string, path: string): Promise<void> {126+/** `koh strip` insists on a terminal and a typed confirmation, so it runs under a Bun PTY. */127+export async function stripPath(cwd: string, home: string, path: string): Promise<void> {128128 	let output = "";129129 	const proc = Bun.spawn(["koh3", "strip", path], {130130 		cwd,

view file

tests/koh-version.txt+1 −1
@@ -1 +1 @@1-3.0.941+3.0.97

view file

tests/kohremote.test.ts+9 −0
@@ -78,6 +78,15 @@7878 		expect((await inst.app.request(`/${REPO}/saves/${checkpoint}`)).status).toBe(404);7979 	});8080 81+	test("the save and object prefixes list what a clone walks", async () => {82+		const saves = await get("saves");83+		expect(saves.headers.get("cache-control")).toBe("no-cache");84+		expect((await saves.text()).trim().split("\n")).toContain(head);85+		const save = await record(head);86+		const objects = await (await get("objects")).text();87+		expect(objects.trim().split("\n")).toContain(save.files[0].hash);88+	});89+8190 	test("an object is the stored zlib bytes and hashes back to its key", async () => {8291 		const save = await record(head);8392 		const file = save.files.find((entry: { path: string }) => entry.path === "src/main.ts");

view file

tests/kohsteal.test.ts+242 −0
@@ -0,0 +1,242 @@1+/**2+ * The joint public-steal contract (addendum A2, item 8d): one machine publishes a repo to a bucket,3+ * Kepr serves it read-only over HTTP in Koh's own layout, a second `koh3` steals it — and later,4+ * once that machine has signed in, offers its work back to the bucket.5+ *6+ * There is no S3 on a test host, so the bucket is a fake one served here; koh3 is run as the real7+ * installed binary (`koh3` on PATH, the same one this suite is pinned to). The account of what the8+ * fake bucket accepts is in its own class: signatures are ignored rather than verified, which is9+ * the one way this test is easier than the real thing.10+ */11+import { describe, expect, test } from "bun:test";12+import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";13+import { tmpdir } from "node:os";14+import { join } from "node:path";15+import type { Hono } from "hono";16+import { openBucket } from "../src/bucket/reader.ts";17+import { syncAll } from "../src/bucket/sync.ts";18+import { createApp } from "../src/server/app.ts";19+import { openKeprDb } from "../src/server/db.ts";20+import { Browser, PASSWORD } from "./browser.ts";21+import { stripPath } from "./fixture.ts";22+23+const REPO = "shipped";24+const PRIVATE_REPO = "kept";25+const BUCKET = "kohbucket";26+const ACCESS = "fake-access";27+const SECRET = "fake-secret";28+/** The command deadline: two koh3 runs talk to a local server, so this is generous. */29+const RUN = 60_000;30+31+/**32+ * The fake bucket runs as its own process: the koh3 calls below are synchronous (`koh3` blocks the33+ * event loop), so an in-process server would never answer them. `tests/fake-s3.ts` prints its34+ * endpoint on stdout.35+ */36+async function startBucket(): Promise<{ endpoint: string; stop: () => void }> {37+	const proc = Bun.spawn(["bun", "run", join(import.meta.dir, "fake-s3.ts")], {38+		stdout: "pipe",39+		stderr: "inherit",40+	});41+	const reader = proc.stdout.getReader();42+	const first = await reader.read();43+	const endpoint =44+		new TextDecoder()45+			.decode(first.value ?? new Uint8Array())46+			.trim()47+			.split("\n")[0] ?? "";48+	reader.releaseLock();49+	expect(endpoint).toStartWith("http://127.0.0.1:");50+	return { endpoint, stop: () => proc.kill() };51+}52+53+type Ran = { code: number; out: string; err: string };54+55+/** The real koh3, with a throwaway HOME so no test ever touches the operator's config. */56+async function koh3(57+	cwd: string,58+	home: string,59+	args: string[],60+	env: Record<string, string> = {},61+): Promise<Ran> {62+	const proc = Bun.spawn(["koh3", ...args], {63+		cwd,64+		env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", ...env },65+		stdout: "pipe",66+		stderr: "pipe",67+	});68+	const [out, err, code] = await Promise.all([69+		new Response(proc.stdout).text(),70+		new Response(proc.stderr).text(),71+		proc.exited,72+	]);73+	return { code, out, err };74+}75+76+async function line(repo: string, home: string): Promise<unknown> {77+	return JSON.parse((await koh3(repo, home, ["view", "--json"])).out).saves.map(78+		(save: { id: string; message: string }) => [save.id, save.message],79+	);80+}81+82+describe.serial("stealing a public repo from Kepr over HTTP", () => {83+	test(84+		"a clone comes over the wire, stays read-only, and follows its source",85+		async () => {86+			const root = await mkdtemp(join(tmpdir(), "kepr-steal-"));87+			const bucketServer = await startBucket();88+			const endpoint = bucketServer.endpoint;89+			const keys = { KOH_ACCESS_KEY: ACCESS, KOH_SECRET_KEY: SECRET };90+			try {91+				// The owner: a small repo in a bucket, offered on every save.92+				const ownerHome = join(root, "owner-home");93+				const ownerDir = join(root, "owner");94+				const repoDir = join(ownerDir, REPO);95+				const keptDir = join(ownerDir, PRIVATE_REPO);96+				await mkdir(ownerHome, { recursive: true });97+				await mkdir(repoDir, { recursive: true });98+				await mkdir(keptDir, { recursive: true });99+				expect((await koh3(repoDir, ownerHome, ["init"], keys)).code).toBe(0);100+				expect(101+					(await koh3(repoDir, ownerHome, ["remote", "s3", endpoint, BUCKET, "fr-par"], keys)).code,102+				).toBe(0);103+				await writeFile(join(repoDir, "README.md"), "# shipped\n\nA repo to steal.\n");104+				await writeFile(join(repoDir, "src.v"), "fn main() {}\n");105+				expect((await koh3(repoDir, ownerHome, ["save", "first save"])).code).toBe(0);106+				await writeFile(join(repoDir, "README.md"), "# shipped\n\nA repo to steal, edited.\n");107+				expect((await koh3(repoDir, ownerHome, ["save", "second save"])).code).toBe(0);108+				// A second repo, never made public on Kepr: only a signed-in machine may have it.109+				expect((await koh3(keptDir, ownerHome, ["init"], keys)).code).toBe(0);110+				expect(111+					(await koh3(keptDir, ownerHome, ["remote", "s3", endpoint, BUCKET, "fr-par"], keys)).code,112+				).toBe(0);113+				await writeFile(join(keptDir, "secret.txt"), "kept in the bucket\n");114+				expect((await koh3(keptDir, ownerHome, ["save", "a private repo"])).code).toBe(0);115+116+				// Kepr, reading that bucket: the shipped repo is public, the kept one is not.117+				const dataDir = await mkdtemp(join(tmpdir(), "kepr-steal-kepr-"));118+				const kepr = openKeprDb(dataDir);119+				const bucket = {120+					kind: "s3" as const,121+					endpoint,122+					name: BUCKET,123+					region: "fr-par",124+					accessKeyId: ACCESS,125+					secretAccessKey: SECRET,126+				};127+				/** Kepr syncs on its own loop in production; here each new save is synced by hand. */128+				const sync = () =>129+					syncAll({130+						bucket: openBucket(bucket),131+						kepr,132+						mirrorDir: join(dataDir, "mirror"),133+						tarballDir: join(dataDir, "tarballs"),134+					});135+				await sync();136+				kepr.query("UPDATE repos SET public = 1 WHERE name = ?").run(REPO);137+				let app: Hono;138+				const server = Bun.serve({139+					port: 0,140+					fetch: (request) => app.fetch(request),141+				});142+				app = createApp(143+					{144+						domain: `127.0.0.1:${server.port}`,145+						dataDir,146+						adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),147+						bucket: { ...bucket, write: { accessKeyId: ACCESS, secretAccessKey: SECRET } },148+					},149+					kepr,150+				);151+				const host = `http://127.0.0.1:${server.port}`;152+153+				// The second machine: nothing but a HOME and the address it was told.154+				const thiefHome = join(root, "thief-home");155+				const thiefDir = join(root, "thief");156+				await mkdir(thiefHome, { recursive: true });157+				await mkdir(thiefDir, { recursive: true });158+				const stolen = await koh3(thiefDir, thiefHome, ["steal", `${host}/${REPO}`]);159+				expect(stolen.code, stolen.err + stolen.out).toBe(0);160+				const clone = join(thiefDir, REPO);161+				expect(await readFile(join(clone, "README.md"), "utf8")).toContain("edited");162+				expect((await koh3(clone, thiefHome, ["doctor"])).code).toBe(0);163+				// Save for save, the clone is the line the owner published.164+				expect(await line(clone, thiefHome)).toEqual(await line(repoDir, ownerHome));165+				// The source is remembered, and shown above the machine's remote.166+				expect((await koh3(clone, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);167+				// And it cannot be offered: it has no bucket of its own.168+				const refused = await koh3(clone, thiefHome, ["offer"]);169+				expect(refused.code).toBe(1);170+				expect(refused.err + refused.out).toContain("which is read-only");171+172+				// A later public save arrives on a bare `koh steal`.173+				await writeFile(join(repoDir, "README.md"), "# shipped\n\nA third save.\n");174+				expect((await koh3(repoDir, ownerHome, ["save", "third save"])).code).toBe(0);175+				await sync();176+				expect((await koh3(clone, thiefHome, ["steal"])).code).toBe(0);177+				expect(await readFile(join(clone, "README.md"), "utf8")).toContain("A third save");178+179+				// A strip on the source side reaches the mirror, and is honoured: history no longer180+				// holds the path, so a fresh clone never sees the file at all. On the machine that181+				// already had it, the file stays as work it never saved — a strip is not a delete.182+				await stripPath(repoDir, ownerHome, "src.v");183+				await sync();184+				expect((await koh3(clone, thiefHome, ["steal"])).code).toBe(0);185+				expect((await koh3(clone, thiefHome, ["doctor"])).code).toBe(0);186+				expect((await koh3(clone, thiefHome, ["status"])).out).toContain("src.v");187+				const fresh = join(root, "thief-again");188+				await mkdir(fresh, { recursive: true });189+				expect((await koh3(fresh, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);190+				expect(await Bun.file(join(fresh, REPO, "src.v")).exists()).toBe(false);191+192+				// Signing in: a code from the connect page, read the way `koh` reads it.193+				// This Kepr's domain is the loopback address it is served on, so the same-origin194+				// checks want that origin, not the Browser's default.195+				const origin = `https://127.0.0.1:${server.port}`;196+				const browser = new Browser(app);197+				const signInPage = await browser.get("/sign-in");198+				const form = /name="form" value="([^"]+)"/.exec(await signInPage.text())?.[1] ?? "";199+				const signed = await browser.request("/sign-in", {200+					method: "POST",201+					headers: { origin },202+					body: new URLSearchParams({ form, password: PASSWORD }),203+				});204+				expect(signed.status).toBe(303);205+				const machine = "thief-machine";206+				expect((await browser.get(`/connect?machine=${machine}`)).status).toBe(200);207+				const shown = await browser.post("/connect", { machine }, origin);208+				const code = /class="mono code">([^<]+)</.exec(await shown.text())?.[1]?.trim() ?? "";209+				expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);210+				const signedIn = await koh3(thiefDir, thiefHome, ["remote", host, "--code", code]);211+				expect(signedIn.code, signedIn.err + signedIn.out).toBe(0);212+213+				// The next steal drops the source: the repo is this machine's own work now. The file214+				// the strip left behind is unsaved work, cleared here as `revert` would.215+				expect((await koh3(clone, thiefHome, ["revert", "--yes"])).code).toBe(0);216+				const upgraded = await koh3(clone, thiefHome, ["steal"]);217+				expect(upgraded.err, upgraded.err + upgraded.out).toBe("");218+				expect(upgraded.code).toBe(0);219+				expect(upgraded.out + upgraded.err).toContain("now syncs with your bucket");220+				await writeFile(join(clone, "README.md"), "# shipped\n\nThe thief's edit.\n");221+				expect((await koh3(clone, thiefHome, ["save", "from the thief"])).code).toBe(0);222+				// The owner steals it back from the bucket; the stripped file is unsaved work there223+				// too, so it goes first.224+				expect((await koh3(repoDir, ownerHome, ["revert", "--yes"])).code).toBe(0);225+				expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);226+				expect(await readFile(join(repoDir, "README.md"), "utf8")).toContain("The thief's edit");227+228+				// A repo the public side never serves clones from the bucket once signed in.229+				const second = join(root, "thief-two");230+				await mkdir(second, { recursive: true });231+				const kept = await koh3(second, thiefHome, ["steal", `${host}/${PRIVATE_REPO}`]);232+				expect(kept.code, kept.err + kept.out).toBe(0);233+				expect(await readFile(join(second, PRIVATE_REPO, "secret.txt"), "utf8")).toContain("kept");234+				// And it is a normal repo: the machine's own remote is untouched by any of this.235+				expect((await koh3(second, thiefHome, ["remote"])).out).toContain(`s3 ${endpoint}`);236+			} finally {237+				bucketServer.stop();238+			}239+		},240+		RUN,241+	);242+});

view file