tar.ts
| 1 | function pad(value: string, length: number, padWith = "\0"): string { |
| 2 | if (value.length > length) return value.slice(0, length); |
| 3 | return value + padWith.repeat(length - value.length); |
| 4 | } |
| 5 | |
| 6 | function truncateFilename(filename: string, maxBytes = 100): string { |
| 7 | const encoder = new TextEncoder(); |
| 8 | |
| 9 | // Identify extension (last '.' not at position 0) |
| 10 | const dotIdx = filename.lastIndexOf("."); |
| 11 | let base: string; |
| 12 | let ext: string; |
| 13 | if (dotIdx > 0 && dotIdx < filename.length - 1) { |
| 14 | base = filename.slice(0, dotIdx); |
| 15 | ext = filename.slice(dotIdx); // includes dot |
| 16 | } else { |
| 17 | base = filename; |
| 18 | ext = ""; |
| 19 | } |
| 20 | |
| 21 | // Whole filename fits? Done. |
| 22 | if (encoder.encode(filename).length <= maxBytes) { |
| 23 | return filename; |
| 24 | } |
| 25 | |
| 26 | const ellipsis = "..."; |
| 27 | const tail = ellipsis + ext; |
| 28 | const tailBytes = encoder.encode(tail).length; |
| 29 | |
| 30 | // Figure out how many bytes are left for the base |
| 31 | const baseMaxBytes = maxBytes - tailBytes; |
| 32 | if (baseMaxBytes <= 0) { |
| 33 | // Only enough space for truncated tail |
| 34 | let res = ""; |
| 35 | let count = 0; |
| 36 | for (const ch of tail) { |
| 37 | const chBytes = encoder.encode(ch).length; |
| 38 | if (count + chBytes > maxBytes) break; |
| 39 | res += ch; |
| 40 | count += chBytes; |
| 41 | } |
| 42 | return res; |
| 43 | } |
| 44 | |
| 45 | // Take base from left, as much as fits in baseMaxBytes |
| 46 | let outBase = ""; |
| 47 | let totalBytes = 0; |
| 48 | for (const ch of Array.from(base)) { |
| 49 | const chBytes = encoder.encode(ch).length; |
| 50 | if (totalBytes + chBytes > baseMaxBytes) { |
| 51 | break; |
| 52 | } |
| 53 | outBase += ch; |
| 54 | totalBytes += chBytes; |
| 55 | } |
| 56 | |
| 57 | return outBase + ellipsis + ext; |
| 58 | } |
| 59 | |
| 60 | function octalString(n: number, length: number): string { |
| 61 | // Reserve 1 for NUL (tar standard) |
| 62 | const fieldLen = length - 1; |
| 63 | let s = n.toString(8); |
| 64 | if (s.length > fieldLen) s = "0".repeat(fieldLen); |
| 65 | s = s.padStart(fieldLen, "0"); |
| 66 | return `${s}\0`; |
| 67 | } |
| 68 | |
| 69 | export function createTar(files: Map<string, Uint8Array>): Uint8Array { |
| 70 | const blocks: Uint8Array[] = []; |
| 71 | |
| 72 | files.forEach((content, filename) => { |
| 73 | const truncatedName = truncateFilename(filename); |
| 74 | |
| 75 | // --- HEADER --- |
| 76 | const header = new Uint8Array(512); |
| 77 | let idx = 0; |
| 78 | // name (100) |
| 79 | const nameBytes = new TextEncoder().encode(pad(truncatedName, 100)); |
| 80 | header.set(nameBytes, idx); |
| 81 | idx += 100; |
| 82 | // mode (8) |
| 83 | header.set(new TextEncoder().encode(octalString(0o644, 8)), idx); |
| 84 | idx += 8; |
| 85 | // uid (8) |
| 86 | header.set(new TextEncoder().encode(octalString(0, 8)), idx); |
| 87 | idx += 8; |
| 88 | // gid (8) |
| 89 | header.set(new TextEncoder().encode(octalString(0, 8)), idx); |
| 90 | idx += 8; |
| 91 | // size (12) |
| 92 | header.set(new TextEncoder().encode(octalString(content.length, 12)), idx); |
| 93 | idx += 12; |
| 94 | // mtime (12) |
| 95 | header.set(new TextEncoder().encode(octalString(Math.floor(Date.now() / 1000), 12)), idx); |
| 96 | idx += 12; |
| 97 | // chksum (8) - space-filled for now |
| 98 | header.set(new TextEncoder().encode(" "), idx); |
| 99 | idx += 8; |
| 100 | // typeflag (1) - '0' for regular file |
| 101 | header[idx++] = "0".charCodeAt(0); |
| 102 | // linkname (100) |
| 103 | idx += 100; |
| 104 | // magic (6) |
| 105 | header.set(new TextEncoder().encode("ustar\0"), idx); |
| 106 | idx += 6; |
| 107 | // version (2) |
| 108 | header.set(new TextEncoder().encode("00"), idx); |
| 109 | idx += 2; |
| 110 | // uname (32) |
| 111 | idx += 32; |
| 112 | // gname (32) |
| 113 | idx += 32; |
| 114 | // devmajor (8) |
| 115 | idx += 8; |
| 116 | // devminor (8) |
| 117 | idx += 8; |
| 118 | // prefix (155) |
| 119 | idx += 155; |
| 120 | // padding (12) |
| 121 | idx += 12; |
| 122 | |
| 123 | // Compute checksum: set chksum field to spaces, then sum all bytes of header |
| 124 | let chksum = 0; |
| 125 | for (let i = 0; i < 512; i++) { |
| 126 | // chksum is at 148..155, treated as spaces (0x20) |
| 127 | if (i >= 148 && i < 156) { |
| 128 | chksum += 0x20; |
| 129 | } else { |
| 130 | chksum += header[i]; |
| 131 | } |
| 132 | } |
| 133 | const chksumStr = pad(octalString(chksum, 7), 8, " "); // 6 digits, null, space |
| 134 | header.set(new TextEncoder().encode(chksumStr), 148); |
| 135 | |
| 136 | blocks.push(header); |
| 137 | |
| 138 | // --- CONTENT --- |
| 139 | blocks.push(content); |
| 140 | |
| 141 | // pad to 512 bytes |
| 142 | if (content.length % 512 !== 0) { |
| 143 | blocks.push(new Uint8Array(512 - (content.length % 512))); |
| 144 | } |
| 145 | }); |
| 146 | |
| 147 | // Tar ends with two 512-byte zero blocks |
| 148 | blocks.push(new Uint8Array(512)); |
| 149 | blocks.push(new Uint8Array(512)); |
| 150 | |
| 151 | // Flatten |
| 152 | const total = blocks.reduce((a, b) => a + b.length, 0); |
| 153 | const out = new Uint8Array(total); |
| 154 | let offset = 0; |
| 155 | for (const b of blocks) { |
| 156 | out.set(b, offset); |
| 157 | offset += b.length; |
| 158 | } |
| 159 | return out; |
| 160 | } |
| 161 |