import { gzipSync } from "node:zlib"; export type TarFile = { path: string; bytes: Uint8Array; executable: boolean }; // Kepr 2's strip list (ยง22.3, `source_archive.zig`): build metadata, Koh bookkeeping and lockfiles // that pin a consumer's environment. Matched at the repo root only. `flake.lock` and `build.zig.zon` // stay, because Nix needs both. const STRIPPED = new Set([ "koh.build", "kepr.build", "Cargo.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "go.sum", "LOG.md", "CONTINUITY.md", "AGENTS.md", ".kohignore", ".gitignore", ]); export function isStripped(path: string): boolean { return STRIPPED.has(path) || path === ".agent" || path.startsWith(".agent/"); } const encoder = new TextEncoder(); const BLOCK = 512; /** * A deterministic `.tar.gz` of `files` under `root/`, byte for byte what Kepr 2 wrote: one directory * entry for the root, then the files in byte order of their paths, with zero owners and times. * The one difference is the executable bit, which Kepr 3 keeps (0755) where Kepr 2 wrote 0644. */ export function buildTarball(root: string, files: TarFile[]): Uint8Array { const entries = files .filter((file) => file.path !== "" && !isStripped(file.path)) .map((file) => ({ ...file, name: encoder.encode(`${root}/${file.path}`) })) .sort((a, b) => Buffer.compare(a.name, b.name)); const blocks: Uint8Array[] = [header(encoder.encode(root), 0, "dir")]; for (const entry of entries) { blocks.push(header(entry.name, entry.bytes.length, entry.executable ? "exec" : "file")); blocks.push(entry.bytes, new Uint8Array((BLOCK - (entry.bytes.length % BLOCK)) % BLOCK)); } blocks.push(new Uint8Array(2 * BLOCK)); const gzip = gzipSync(Buffer.concat(blocks), { level: 6 }); // zlib stamps the host's OS in the gzip header (19 on macOS); Kepr 2 wrote 3 (Unix) everywhere. gzip[9] = 3; return gzip; } // A POSIX ustar header, as Kepr 2's `tarWriteHeader`: names over 99 bytes (98 for a directory, which // needs its trailing slash) split at a '/' into the 155-byte prefix field, never truncated. function header(name: Uint8Array, size: number, kind: "dir" | "file" | "exec"): Uint8Array { const h = new Uint8Array(BLOCK); const isDir = kind === "dir"; const maxName = isDir ? 98 : 99; let base = name; if (name.length > maxName) { const split = splitAt(name, maxName); if (split === null) throw new Error(`path too long for a tarball: ${new TextDecoder().decode(name)}`); h.set(name.subarray(0, split), 345); base = name.subarray(split + 1); } h.set(base); if (isDir) h[base.length] = 0x2f; const ascii = (text: string, at: number) => h.set(encoder.encode(text), at); ascii(kind === "file" ? "0000644\0" : "0000755\0", 100); ascii("0000000\0", 108); ascii("0000000\0", 116); if (!isDir) ascii(`${size.toString(8).padStart(11, "0")}\0`, 124); ascii("00000000000\0", 136); ascii(" ", 148); ascii(isDir ? "5" : "0", 156); ascii("ustar\0", 257); ascii("00", 263); const sum = h.reduce((total, byte) => total + byte, 0); ascii(`${sum.toString(8).padStart(6, "0")}\0 `, 148); return h; } // The rightmost '/' leaving a prefix of at most 154 bytes and a name of at most `maxName`. function splitAt(path: Uint8Array, maxName: number): number | null { const min = path.length - maxName - 1; for (let i = Math.min(path.length - 1, 154); i >= Math.max(min, 0); i--) { if (path[i] === 0x2f) return i; } return null; }