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
↓ kepr-9445b343.tar.gzatlas at this save
files changed10 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 −2package.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": {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 }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 );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") };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 });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));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 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");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 () => {