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
↓ kepr-1b4865e8.tar.gzatlas at this save
files changed9 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 −0package.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": {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 = mirrorsrc/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 });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 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}`);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,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");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+});