9445b343df7f

make the frozen Koh 2 route a permanent 404, keep executable bits in tarballs and confine the sandbox to raw files and uploaded assets, with the pin moved to the koh3 they were verified against (v3.0.56)

time
2026-09-27 14:17 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
kepr 3 implementation
parent
cf30e2643e1a
10 files package.json+1 −1src/pages/files.ts+0 −1src/releases/routes.ts+4 −14src/server/app.ts+5 −2src/tarball/cache.ts+1 −1src/tarball/tar.ts+7 −5tests/contract.test.ts+3 −14tests/koh-version.txt+1 −1tests/pages.test.ts+12 −6tests/tarball.test.ts+2 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.55",3+	"version": "3.0.56",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/pages/files.ts+0 −1
@@ -54,7 +54,6 @@5454 		cacheFor(c, found.at !== null);5555 		const type = contentType(found.path, found.bytes);5656 		c.header("Content-Type", type);57-		if (type === "image/svg+xml") c.header("Content-Security-Policy", "sandbox");5857 		return c.body(found.bytes);5958 	});6059 }

view file

src/releases/routes.ts+4 −14
@@ -6,14 +6,7 @@66 import { headId, repoStats, saveByPrefix } from "../mirror/queries.ts";77 import { notFound, openPublicRepo, type PageContext, type PublicRepo } from "../pages/layout.ts";88 import { readmeAtHead, readmeIntro } from "../pages/repo.ts";9-import {10-	checksumsTxt,11-	historyJson,12-	legacyReleaseJson,13-	metaJson,14-	releaseJson,15-	updateInfoJson,16-} from "./feeds.ts";9+import { checksumsTxt, historyJson, metaJson, releaseJson, updateInfoJson } from "./feeds.ts";1710 import { countDownload, type Release, releaseContentType, visibleReleases } from "./releases.ts";1811 1912 /**@@ -67,12 +60,9 @@6760 		return f.releases[0] ?? send(c, 404, NO_RELEASES);6861 	};6962 70-	app.get("/api/koh/releases/latest", (c) => {71-		const f = feed(FROZEN_KOH2.repo);72-		const release = f?.releases.find((r) => r.version === FROZEN_KOH2.version);73-		if (!release) return send(c, 404, '{"error":"no_releases","message":"no releases available"}');74-		return send(c, 200, legacyReleaseJson(ctx.domain, FROZEN_KOH2.repo, release));75-	});63+	app.get("/api/koh/releases/latest", (c) =>64+		send(c, 404, '{"error":"no_releases","message":"no releases available"}'),65+	);7666 	app.get("/api/koh/releases/:version/:platform", (c) =>7767 		downloadAt(c, FROZEN_KOH2.repo, c.req.param("version"), c.req.param("platform")),7868 	);

view file

src/server/app.ts+5 −2
@@ -39,7 +39,11 @@3939 		config.bucket.kind === "s3" ? ["'self'", new URL(config.bucket.endpoint).origin] : ["'self'"];4040 	app.use("*", async (c, next) => {4141 		await next();42-		if (c.req.path.endsWith(".svg")) c.header("Content-Security-Policy", "sandbox");42+		const path = c.req.path;43+		if (/^\/[^/]+\/raw\/.+/.test(path) || path.startsWith("/static/assets/")) {44+			const policy = c.res.headers.get("Content-Security-Policy");45+			c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox");46+		}4347 	});4448 	app.use(4549 		secureHeaders({@@ -70,7 +74,6 @@7074 		);7175 		if (!(await file.exists())) return c.notFound();7276 		c.header("Cache-Control", "public, max-age=3600");73-		if (file.type === "image/svg+xml") c.header("Content-Security-Policy", "sandbox");7477 		return c.body(await file.bytes(), 200, { "Content-Type": file.type });7578 	});7679 	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };

view file

src/tarball/cache.ts+1 −1
@@ -26,7 +26,7 @@2626 	const files = filesAt(db, saveId).map((file) => {2727 		const bytes = objectBytes(db, file.hash);2828 		if (!bytes) throw new Error(`${repo}: object ${file.hash} is missing from the mirror`);29-		return { path: file.path, bytes };29+		return { path: file.path, bytes, executable: file.executable };3030 	});3131 	const tarball = buildTarball(`${repo}-${saveId.slice(0, 8)}`, files);3232 	mkdirSync(dir, { recursive: true });

view file

src/tarball/tar.ts+7 −5
@@ -1,6 +1,6 @@11 import { gzipSync } from "node:zlib";22 3-export type TarFile = { path: string; bytes: Uint8Array };3+export type TarFile = { path: string; bytes: Uint8Array; executable?: boolean };44 55 // Kepr 2's strip list (§22.3, `source_archive.zig`): build metadata, Koh bookkeeping and lockfiles66 // that pin a consumer's environment. Matched at the repo root only. `flake.lock` and `build.zig.zon`@@ -30,13 +30,13 @@3030 3131 /**3232  * A deterministic `.tar.gz` of `files` under `root/`, byte for byte what Kepr 2 wrote: one directory33- * entry for the root, then the files in byte order of their paths, all 0644, with zero owners and34- * times (§22.3: the headers match Kepr 2's, so an executable bit is not kept). A backup passes 0o600.33+ * entry for the root, then the files in byte order of their paths. Executable files are 0755 and34+ * other files 0644 (§22.3). A backup passes 0o600.3535  */3636 export function buildTarball(3737 	root: string,3838 	files: TarFile[],39-	mode = 0o644,39+	mode?: number,4040 ): Uint8Array<ArrayBuffer> {4141 	const entries = files4242 		.filter((file) => file.path !== "" && !isStripped(file.path))@@ -44,7 +44,9 @@4444 		.sort((a, b) => Buffer.compare(a.name, b.name));4545 	const blocks: Uint8Array[] = [header(encoder.encode(root), 0, true)];4646 	for (const entry of entries) {47-		blocks.push(header(entry.name, entry.bytes.length, false, mode));47+		blocks.push(48+			header(entry.name, entry.bytes.length, false, mode ?? (entry.executable ? 0o755 : 0o644)),49+		);4850 		blocks.push(entry.bytes, new Uint8Array((BLOCK - (entry.bytes.length % BLOCK)) % BLOCK));4951 	}5052 	blocks.push(new Uint8Array(2 * BLOCK));

view file

tests/contract.test.ts+3 −14
@@ -295,21 +295,10 @@295295 296296 	// Kepr 2 listed platforms by build time; Kepr 3 lists them by name, as the generic shape always297297 	// did. Koh 2 reads them by key, so the object is compared as parsed, and every field order exactly.298-	test("serves Kepr 2's legacy shape for 2.1.16, never the newest release", async () => {298+	test("always returns the frozen Koh 2 no-releases body", async () => {299299 		const res = await get("/api/koh/releases/latest");300-		const rec = await recorded("/api/koh/releases/latest");301-		expect(res.status).toBe(200);302-		expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);303-		type Legacy = { platforms: Record<string, { provenance: object }> };304-		const ours = (await res.json()) as Legacy;305-		const theirs = JSON.parse(rec.body.replaceAll("2.1.0", FROZEN)) as Legacy;306-		expect(ours).toEqual(theirs);307-		expect(Object.keys(ours)).toEqual(Object.keys(theirs));308-		for (const [platform, entry] of Object.entries(theirs.platforms)) {309-			const mine = ours.platforms[platform] as { provenance: object };310-			expect(Object.keys(mine)).toEqual(Object.keys(entry));311-			expect(Object.keys(mine.provenance)).toEqual(Object.keys(entry.provenance));312-		}300+		expect(res.status).toBe(404);301+		expect(await res.text()).toBe('{"error":"no_releases","message":"no releases available"}');313302 		expect(await (await get("/koh/releases/latest/version.txt")).text()).toBe("3.0.0");314303 	});315304 

view file

tests/koh-version.txt+1 −1
@@ -1 +1 @@1-3.0.881+3.0.89

view file

tests/pages.test.ts+12 −6
@@ -1,6 +1,6 @@11 import { Database } from "bun:sqlite";22 import { beforeAll, describe, expect, test } from "bun:test";3-import { mkdtemp, writeFile } from "node:fs/promises";3+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";44 import { tmpdir } from "node:os";55 import { join } from "node:path";66 import type { Hono } from "hono";@@ -50,8 +50,10 @@5050 		Bun.spawnSync(["koh3", "log", "--json"], { cwd: tail.repoDir, env: tailEnv }).stdout.toString(),5151 	).saves.map((save: { id: string }) => save.id);5252 	const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));53-	mirrorDir = join(dataDir, "mirror");53+	await mkdir(join(dataDir, "assets"));54+	await writeFile(join(dataDir, "assets", "plain.txt"), "asset\n");5455 	const kepr = openKeprDb(dataDir);56+	mirrorDir = join(dataDir, "mirror");5557 	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {5658 		const bucket = openBucket({ kind: "folder", path: bucketDir });5759 		await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });@@ -413,13 +415,17 @@413415 	});414416 });415417 416-test("static assets are served and SVG responses are sandboxed", async () => {418+test("static assets and raw files carry route-based sandboxing", async () => {417419 	const css = await get("/static/css/kepr.css");418420 	expect(css.status).toBe(200);419421 	expect(css.headers.get("content-type")).toContain("text/css");420-	const svg = await get("/favicon.svg");421-	expect(svg.headers.get("content-type")).toBe("image/svg+xml");422-	expect(svg.headers.get("content-security-policy")).toBe("sandbox");422+	const raw = await get("/demo/raw/README.md");423+	expect(raw.headers.get("content-security-policy")).toContain("sandbox");424+	const asset = await get("/static/assets/plain.txt");425+	expect(asset.headers.get("content-security-policy")).toContain("sandbox");426+	const favicon = await get("/favicon.svg");427+	expect(favicon.headers.get("content-type")).toBe("image/svg+xml");428+	expect(favicon.headers.get("content-security-policy")).not.toContain("sandbox");423429 	expect((await get("/static/../package.json")).status).toBe(404);424430 	const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";425431 	expect(csp).toContain("style-src 'self' https://fonts.bunny.net");

view file

tests/tarball.test.ts+2 −2
@@ -190,7 +190,7 @@190190 		expect(hidden.map((r) => r.status)).toEqual([404, 404]);191191 	});192192 193-	test("serves the save's files under <repo>-<id8>/ minus the strip list, every file 0644", async () => {193+	test("serves the save's files under <repo>-<id8>/ minus the strip list", async () => {194194 		const response = await get(`/tarry/archive/${head}.tar.gz`);195195 		expect(response.status).toBe(200);196196 		expect(response.headers.get("content-type")).toBe("application/gzip");@@ -225,7 +225,7 @@225225 		expect(await readFile(join(out, root, "docs/logo.png"))).toEqual(226226 			await readFile(join(fx.repoDir, "docs/logo.png")),227227 		);228-		expect(tar(["-tvzf", "-"], gz)).toMatch(/-rw-r--r-- .* tarry-[0-9a-f]{8}\/run\.sh/);228+		expect(tar(["-tvzf", "-"], gz)).toMatch(/-rwxr-xr-x .* tarry-[0-9a-f]{8}\/run\.sh/);229229 	});230230 231231 	test("is the same bytes when rebuilt, and in another process", async () => {

view file