tar.ts

3.4 KB · raw

1import { gzipSync } from "node:zlib";23export type TarFile = { path: string; bytes: Uint8Array; executable?: boolean };45// Kepr 2's strip list (§22.3, `source_archive.zig`): build metadata, Koh bookkeeping and lockfiles6// that pin a consumer's environment. Matched at the repo root only. `flake.lock` and `build.zig.zon`7// stay, because Nix needs both.8const STRIPPED = new Set([9	"koh.build",10	"kepr.build",11	"Cargo.lock",12	"package-lock.json",13	"yarn.lock",14	"pnpm-lock.yaml",15	"bun.lockb",16	"go.sum",17	"LOG.md",18	"CONTINUITY.md",19	"AGENTS.md",20	".kohignore",21	".gitignore",22]);2324export function isStripped(path: string): boolean {25	return STRIPPED.has(path) || path === ".agent" || path.startsWith(".agent/");26}2728const encoder = new TextEncoder();29const BLOCK = 512;3031/**32 * 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. Executable files are 0755 and34 * other files 0644 (§22.3). A backup passes 0o600.35 */36export function buildTarball(37	root: string,38	files: TarFile[],39	mode?: number,40): Uint8Array<ArrayBuffer> {41	const entries = files42		.filter((file) => file.path !== "" && !isStripped(file.path))43		.map((file) => ({ ...file, name: encoder.encode(`${root}/${file.path}`) }))44		.sort((a, b) => Buffer.compare(a.name, b.name));45	const blocks: Uint8Array[] = [header(encoder.encode(root), 0, true)];46	for (const entry of entries) {47		blocks.push(48			header(entry.name, entry.bytes.length, false, mode ?? (entry.executable ? 0o755 : 0o644)),49		);50		blocks.push(entry.bytes, new Uint8Array((BLOCK - (entry.bytes.length % BLOCK)) % BLOCK));51	}52	blocks.push(new Uint8Array(2 * BLOCK));53	const gzip = gzipSync(Buffer.concat(blocks), { level: 6 });54	// zlib stamps the host's OS in the gzip header (19 on macOS); Kepr 2 wrote 3 (Unix) everywhere.55	gzip[9] = 3;56	return gzip;57}5859// A POSIX ustar header, as Kepr 2's `tarWriteHeader`: names over 99 bytes (98 for a directory, which60// needs its trailing slash) split at a '/' into the 155-byte prefix field, never truncated.61function header(name: Uint8Array, size: number, isDir: boolean, mode = 0o644): Uint8Array {62	const h = new Uint8Array(BLOCK);63	const maxName = isDir ? 98 : 99;64	let base = name;65	if (name.length > maxName) {66		const split = splitAt(name, maxName);67		if (split === null)68			throw new Error(`path too long for a tarball: ${new TextDecoder().decode(name)}`);69		h.set(name.subarray(0, split), 345);70		base = name.subarray(split + 1);71	}72	h.set(base);73	if (isDir) h[base.length] = 0x2f;74	const ascii = (text: string, at: number) => h.set(encoder.encode(text), at);75	ascii(isDir ? "0000755\0" : `${mode.toString(8).padStart(7, "0")}\0`, 100);76	ascii("0000000\0", 108);77	ascii("0000000\0", 116);78	if (!isDir) ascii(`${size.toString(8).padStart(11, "0")}\0`, 124);79	ascii("00000000000\0", 136);80	ascii("        ", 148);81	ascii(isDir ? "5" : "0", 156);82	ascii("ustar\0", 257);83	ascii("00", 263);84	const sum = h.reduce((total, byte) => total + byte, 0);85	ascii(`${sum.toString(8).padStart(6, "0")}\0 `, 148);86	return h;87}8889// The rightmost '/' leaving a prefix of at most 154 bytes and a name of at most `maxName`.90function splitAt(path: Uint8Array, maxName: number): number | null {91	const min = path.length - maxName - 1;92	for (let i = Math.min(path.length - 1, 154); i >= Math.max(min, 0); i--) {93		if (path[i] === 0x2f) return i;94	}95	return null;96}