clank-together/src/shared/tar.js

95 lines
3.7 KiB
JavaScript
Executable file

// Minimal tar reader for the compiler package: regular files and directories, with GNU long names
// and pax path headers. Entries that would land outside the target directory are refused.
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
/** Extracts a .tar.gz into `dest`; returns the number of files written. */
export async function extractTarGz(file, dest) {
fs.mkdirSync(dest, { recursive: true });
const root = path.resolve(dest);
const gunzip = fs.createReadStream(file).pipe(zlib.createGunzip());
let buf = Buffer.alloc(0);
let entry = null; // { name, size, left, fd, type, data }
let longName = null;
let paxPath = null;
let files = 0;
const target = (name) => {
const clean = name.replace(/\\/g, '/').replace(/^\.\/+/, '');
const out = path.resolve(root, clean);
if (out !== root && !out.startsWith(root + path.sep)) throw new Error(`refusing tar entry outside the target: ${name}`);
return out;
};
const headerField = (h, off, len) => h.subarray(off, off + len).toString('utf8').replace(/\0.*$/s, '');
const octal = (h, off, len) => {
// Sizes over 8 GB use base-256 (first byte 0x80); the package never gets there.
if (h[off] & 0x80) throw new Error('tar entry too large');
return Number.parseInt(headerField(h, off, len).trim() || '0', 8);
};
const startEntry = (h) => {
const type = String.fromCharCode(h[156] || 48);
const size = octal(h, 124, 12);
const prefix = headerField(h, 345, 155);
let name = headerField(h, 0, 100);
if (prefix) name = `${prefix}/${name}`;
if (longName) (name = longName), (longName = null);
if (paxPath) (name = paxPath), (paxPath = null);
const mode = octal(h, 100, 8);
entry = { name, size, left: size, type, mode, data: type === 'L' || type === 'x' || type === 'g' ? [] : null, fd: null };
if (type === '5') fs.mkdirSync(target(name), { recursive: true });
else if (type === '0' || type === '7') {
const out = target(name);
fs.mkdirSync(path.dirname(out), { recursive: true });
entry.fd = fs.openSync(out, 'w', mode & 0o111 ? 0o755 : 0o644);
files++;
}
// Links and devices are not part of the package: skipped.
};
const endEntry = () => {
if (entry.fd !== null) fs.closeSync(entry.fd);
if (entry.type === 'L') longName = Buffer.concat(entry.data).toString('utf8').replace(/\0.*$/s, '');
if (entry.type === 'x') {
const text = Buffer.concat(entry.data).toString('utf8');
for (const m of text.matchAll(/^\d+ ([^=]+)=(.*)$/gm)) if (m[1] === 'path') paxPath = m[2];
}
entry = null;
};
for await (const chunk of gunzip) {
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
let pos = 0;
for (;;) {
if (!entry) {
if (buf.length - pos < 512) break;
const h = buf.subarray(pos, pos + 512);
pos += 512;
if (h.every((b) => b === 0)) continue; // end-of-archive blocks
startEntry(h);
if (entry.size === 0) endEntry();
continue;
}
const padded = Math.ceil(entry.size / 512) * 512;
const take = Math.min(entry.left, buf.length - pos);
if (take > 0) {
const part = buf.subarray(pos, pos + take);
if (entry.fd !== null) fs.writeSync(entry.fd, part);
else if (entry.data) entry.data.push(Buffer.from(part));
entry.left -= take;
pos += take;
}
if (entry.left > 0) break;
const pad = padded - entry.size;
if (buf.length - pos < pad) break; // the padding comes with the next chunk
pos += pad;
endEntry();
}
buf = buf.subarray(pos);
}
if (entry) throw new Error('truncated tar archive');
return files;
}