Repository Public MIT
1import { gunzipSync, gzipSync, constants as zc } from "node:zlib";2import { LIMITS } from "./constants";34/**5 * Strict, dependency-free tar reader/writer.6 *7 * The reader never touches the filesystem: it turns bytes into an in-memory list of regular files.8 * Anything that is not a regular file or a directory (symlinks, hardlinks, devices, FIFOs, sparse9 * files) is rejected, as are archives that exceed the registry limits. Path safety is checked by10 * validatePackage; this module only guarantees that paths are reported exactly as stored.11 */1213export class ArchiveError extends Error {14 constructor(public code: string, message: string) {15 super(message);16 this.name = "ArchiveError";17 }18}1920export interface TarFile {21 path: string;22 mode: number;23 data: Buffer;24}2526const BLOCK = 512;2728function readOctal(buf: Buffer, start: number, len: number): number {29 const field = buf.subarray(start, start + len);30 if (field[0]! & 0x80) throw new ArchiveError("archive-unsupported-number", "base-256 numeric fields are not accepted");31 const s = field.toString("latin1").replace(/\0.*$/, "").trim();32 if (s === "") return 0;33 if (!/^[0-7]+$/.test(s)) throw new ArchiveError("archive-corrupt", "invalid octal field in tar header");34 return parseInt(s, 8);35}3637function readString(buf: Buffer, start: number, len: number): string {38 const field = buf.subarray(start, start + len);39 const end = field.indexOf(0);40 return field.subarray(0, end === -1 ? len : end).toString("utf8");41}4243function checksumOk(header: Buffer): boolean {44 const stored = readOctal(header, 148, 8);45 let sum = 0;46 for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : header[i]!;47 return sum === stored;48}4950function parsePax(data: Buffer): Record<string, string> {51 const out: Record<string, string> = {};52 let pos = 0;53 while (pos < data.length) {54 const sp = data.indexOf(32, pos);55 if (sp === -1) break;56 const len = parseInt(data.subarray(pos, sp).toString("latin1"), 10);57 if (!Number.isFinite(len) || len <= 0 || pos + len > data.length) throw new ArchiveError("archive-corrupt", "invalid pax record");58 const rec = data.subarray(sp + 1, pos + len - 1).toString("utf8");59 const eq = rec.indexOf("=");60 if (eq > 0) out[rec.slice(0, eq)] = rec.slice(eq + 1);61 pos += len;62 }63 return out;64}6566export interface ReadLimits {67 maxUnpackedBytes: number;68 maxFileBytes: number;69 maxFiles: number;70}7172export function readTar(tar: Buffer, limits: ReadLimits = LIMITS): TarFile[] {73 const files: TarFile[] = [];74 let pos = 0;75 let total = 0;76 let pendingPath: string | null = null;77 let pendingSize: number | null = null;78 let sawEnd = false;7980 while (pos + BLOCK <= tar.length) {81 const header = tar.subarray(pos, pos + BLOCK);82 pos += BLOCK;83 if (header.every((b) => b === 0)) {84 sawEnd = true;85 break;86 }87 if (!checksumOk(header)) throw new ArchiveError("archive-corrupt", "tar header checksum mismatch");88 const type = String.fromCharCode(header[156]! || 48);89 let size = readOctal(header, 124, 12);90 const mode = readOctal(header, 100, 8);91 let name = readString(header, 0, 100);92 const magic = header.subarray(257, 262).toString("latin1");93 if (magic === "ustar") {94 const prefix = readString(header, 345, 155);95 if (prefix) name = `${prefix}/${name}`;96 }97 const dataBlocks = Math.ceil(size / BLOCK) * BLOCK;98 if (pos + size > tar.length) throw new ArchiveError("archive-truncated", "tar entry exceeds archive length");99100 if (type === "x" || type === "L") {101 if (size > 64 * 1024) throw new ArchiveError("archive-limit", "extended header too large");102 const data = tar.subarray(pos, pos + size);103 if (type === "L") pendingPath = data.toString("utf8").replace(/\0+$/, "");104 else {105 const pax = parsePax(data);106 if (pax.path !== undefined) pendingPath = pax.path;107 if (pax.size !== undefined) {108 if (!/^\d+$/.test(pax.size)) throw new ArchiveError("archive-corrupt", "invalid pax size");109 pendingSize = Number(pax.size);110 }111 if (pax.linkpath !== undefined) throw new ArchiveError("archive-link", "links are not allowed in release archives");112 }113 pos += dataBlocks;114 continue;115 }116 if (type === "g") {117 pos += dataBlocks;118 continue;119 }120121 const path = pendingPath ?? name;122 if (pendingSize !== null) size = pendingSize;123 pendingPath = null;124 pendingSize = null;125126 if (type === "5") {127 if (size !== 0) throw new ArchiveError("archive-corrupt", "directory entry with data");128 continue;129 }130 if (type === "1" || type === "2") throw new ArchiveError("archive-link", `links are not allowed in release archives: ${JSON.stringify(path)}`);131 if (type !== "0" && type !== "7") throw new ArchiveError("archive-special-file", `unsupported tar entry type ${JSON.stringify(type)} for ${JSON.stringify(path)}`);132133 if (size > limits.maxFileBytes) throw new ArchiveError("archive-limit", `file exceeds ${limits.maxFileBytes} bytes: ${JSON.stringify(path)}`);134 total += size;135 if (total > limits.maxUnpackedBytes) throw new ArchiveError("archive-limit", `archive expands beyond ${limits.maxUnpackedBytes} bytes`);136 if (files.length + 1 > limits.maxFiles) throw new ArchiveError("archive-limit", `archive has more than ${limits.maxFiles} files`);137 if (pos + size > tar.length) throw new ArchiveError("archive-truncated", "tar entry exceeds archive length");138 files.push({ path, mode: mode & 0o777, data: Buffer.from(tar.subarray(pos, pos + size)) });139 pos += Math.ceil(size / BLOCK) * BLOCK;140 }141 if (!sawEnd) throw new ArchiveError("archive-truncated", "tar archive has no end-of-archive marker (truncated or corrupt)");142 return files;143}144145/** Decompress with a hard output cap, so a gzip bomb fails fast instead of exhausting memory. */146export function gunzipLimited(gz: Buffer, maxOutput: number = LIMITS.maxUnpackedBytes + 4 * 1024 * 1024): Buffer {147 if (gz.length < 18 || gz[0] !== 0x1f || gz[1] !== 0x8b) throw new ArchiveError("archive-not-gzip", "release archives must be gzip-compressed tar (.tar.gz)");148 try {149 return gunzipSync(gz, { maxOutputLength: maxOutput });150 } catch (e) {151 const code = (e as NodeJS.ErrnoException).code;152 if (code === "ERR_BUFFER_TOO_LARGE") throw new ArchiveError("archive-limit", "archive expands beyond the allowed size (possible decompression bomb)");153 throw new ArchiveError("archive-corrupt", "archive is not a valid gzip stream");154 }155}156157export function readArchive(gz: Buffer, limits: ReadLimits = LIMITS): TarFile[] {158 return readTar(gunzipLimited(gz, limits.maxUnpackedBytes + 4 * 1024 * 1024), limits);159}160161function writeOctal(buf: Buffer, value: number, start: number, len: number) {162 buf.write(value.toString(8).padStart(len - 1, "0") + "\0", start, len, "latin1");163}164165function splitPath(path: string): { name: string; prefix: string } {166 const bytes = Buffer.byteLength(path);167 if (bytes <= 100) return { name: path, prefix: "" };168 let idx = path.indexOf("/");169 while (idx !== -1) {170 const prefix = path.slice(0, idx);171 const name = path.slice(idx + 1);172 if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) return { name, prefix };173 idx = path.indexOf("/", idx + 1);174 }175 throw new ArchiveError("archive-path-too-long", `path cannot be stored in a ustar header: ${path}`);176}177178/**179 * Deterministic writer: entries sorted by path, mtime 0, uid/gid 0, modes normalised to 0644/0755.180 * Packing the same directory twice yields byte-identical archives, hence a stable SHA-256.181 */182export function writeTar(files: TarFile[]): Buffer {183 const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));184 const chunks: Buffer[] = [];185 for (const f of sorted) {186 const h = Buffer.alloc(BLOCK);187 const { name, prefix } = splitPath(f.path);188 h.write(name, 0, 100, "utf8");189 writeOctal(h, f.mode & 0o111 ? 0o755 : 0o644, 100, 8);190 writeOctal(h, 0, 108, 8);191 writeOctal(h, 0, 116, 8);192 writeOctal(h, f.data.length, 124, 12);193 writeOctal(h, 0, 136, 12);194 h.fill(32, 148, 156);195 h.write("0", 156, 1, "latin1");196 h.write("ustar\0", 257, 6, "latin1");197 h.write("00", 263, 2, "latin1");198 if (prefix) h.write(prefix, 345, 155, "utf8");199 let sum = 0;200 for (let i = 0; i < BLOCK; i++) sum += h[i]!;201 h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "latin1");202 chunks.push(h, f.data);203 const pad = (BLOCK - (f.data.length % BLOCK)) % BLOCK;204 if (pad) chunks.push(Buffer.alloc(pad));205 }206 chunks.push(Buffer.alloc(BLOCK * 2));207 return Buffer.concat(chunks);208}209210export function writeArchive(files: TarFile[]): Buffer {211 const gz = gzipSync(writeTar(files), { level: zc.Z_BEST_COMPRESSION });212 gz[9] = 3; // OS byte: fixed so archives are identical across platforms213 gz.fill(0, 4, 8); // mtime214 return gz;215}216