SPB Git

Repository Public MIT

tar.ts 8.7 KB 216 lines typescript

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
Files main
Menu

Theme