function pad(value: string, length: number, padWith = "\0"): string { if (value.length > length) return value.slice(0, length); return value + padWith.repeat(length - value.length); } function truncateFilename(filename: string, maxBytes = 100): string { const encoder = new TextEncoder(); // Identify extension (last '.' not at position 0) const dotIdx = filename.lastIndexOf("."); let base: string; let ext: string; if (dotIdx > 0 && dotIdx < filename.length - 1) { base = filename.slice(0, dotIdx); ext = filename.slice(dotIdx); // includes dot } else { base = filename; ext = ""; } // Whole filename fits? Done. if (encoder.encode(filename).length <= maxBytes) { return filename; } const ellipsis = "..."; const tail = ellipsis + ext; const tailBytes = encoder.encode(tail).length; // Figure out how many bytes are left for the base const baseMaxBytes = maxBytes - tailBytes; if (baseMaxBytes <= 0) { // Only enough space for truncated tail let res = ""; let count = 0; for (const ch of tail) { const chBytes = encoder.encode(ch).length; if (count + chBytes > maxBytes) break; res += ch; count += chBytes; } return res; } // Take base from left, as much as fits in baseMaxBytes let outBase = ""; let totalBytes = 0; for (const ch of Array.from(base)) { const chBytes = encoder.encode(ch).length; if (totalBytes + chBytes > baseMaxBytes) { break; } outBase += ch; totalBytes += chBytes; } return outBase + ellipsis + ext; } function octalString(n: number, length: number): string { // Reserve 1 for NUL (tar standard) const fieldLen = length - 1; let s = n.toString(8); if (s.length > fieldLen) s = "0".repeat(fieldLen); s = s.padStart(fieldLen, "0"); return `${s}\0`; } export function createTar(files: Map): Uint8Array { const blocks: Uint8Array[] = []; files.forEach((content, filename) => { const truncatedName = truncateFilename(filename); // --- HEADER --- const header = new Uint8Array(512); let idx = 0; // name (100) const nameBytes = new TextEncoder().encode(pad(truncatedName, 100)); header.set(nameBytes, idx); idx += 100; // mode (8) header.set(new TextEncoder().encode(octalString(0o644, 8)), idx); idx += 8; // uid (8) header.set(new TextEncoder().encode(octalString(0, 8)), idx); idx += 8; // gid (8) header.set(new TextEncoder().encode(octalString(0, 8)), idx); idx += 8; // size (12) header.set(new TextEncoder().encode(octalString(content.length, 12)), idx); idx += 12; // mtime (12) header.set(new TextEncoder().encode(octalString(Math.floor(Date.now() / 1000), 12)), idx); idx += 12; // chksum (8) - space-filled for now header.set(new TextEncoder().encode(" "), idx); idx += 8; // typeflag (1) - '0' for regular file header[idx++] = "0".charCodeAt(0); // linkname (100) idx += 100; // magic (6) header.set(new TextEncoder().encode("ustar\0"), idx); idx += 6; // version (2) header.set(new TextEncoder().encode("00"), idx); idx += 2; // uname (32) idx += 32; // gname (32) idx += 32; // devmajor (8) idx += 8; // devminor (8) idx += 8; // prefix (155) idx += 155; // padding (12) idx += 12; // Compute checksum: set chksum field to spaces, then sum all bytes of header let chksum = 0; for (let i = 0; i < 512; i++) { // chksum is at 148..155, treated as spaces (0x20) if (i >= 148 && i < 156) { chksum += 0x20; } else { chksum += header[i]; } } const chksumStr = pad(octalString(chksum, 7), 8, " "); // 6 digits, null, space header.set(new TextEncoder().encode(chksumStr), 148); blocks.push(header); // --- CONTENT --- blocks.push(content); // pad to 512 bytes if (content.length % 512 !== 0) { blocks.push(new Uint8Array(512 - (content.length % 512))); } }); // Tar ends with two 512-byte zero blocks blocks.push(new Uint8Array(512)); blocks.push(new Uint8Array(512)); // Flatten const total = blocks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let offset = 0; for (const b of blocks) { out.set(b, offset); offset += b.length; } return out; }