203 lines
7.8 KiB
TypeScript
203 lines
7.8 KiB
TypeScript
import { gunzipSync, gzipSync } from "node:zlib";
|
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { dirname, isAbsolute, join, posix } from "node:path";
|
|
|
|
export type ArchiveEntry = {
|
|
path: string;
|
|
type: "file" | "directory";
|
|
mode: number;
|
|
data: Buffer;
|
|
};
|
|
|
|
const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
|
|
|
|
function octal(buffer: Buffer, start: number, length: number): number {
|
|
const value = buffer.subarray(start, start + length).toString("ascii").replace(/\0.*$/, "").trim();
|
|
if (!value) return 0;
|
|
if (!/^[0-7]+$/.test(value)) throw new Error(`invalid tar octal field "${value}"`);
|
|
return Number.parseInt(value, 8);
|
|
}
|
|
|
|
function safePath(value: string): string {
|
|
const slashPath = value.replaceAll("\\", "/");
|
|
const normalized = posix.normalize(slashPath);
|
|
if (
|
|
!value ||
|
|
value.includes("\0") ||
|
|
slashPath.split("/").some((segment) => segment === "." || segment === "..") ||
|
|
isAbsolute(value) ||
|
|
/^[A-Za-z]:\//.test(normalized) ||
|
|
normalized.startsWith("//") ||
|
|
normalized === ".." ||
|
|
normalized.startsWith("../") ||
|
|
normalized.startsWith("/")
|
|
) {
|
|
throw new Error(`unsafe archive path: ${JSON.stringify(value)}`);
|
|
}
|
|
return normalized.replace(/\/$/, "");
|
|
}
|
|
|
|
export function readTarGz(
|
|
path: string,
|
|
options: { maxBytes?: number } = {},
|
|
): ArchiveEntry[] {
|
|
const maxBytes = options.maxBytes ?? MAX_ARCHIVE_BYTES;
|
|
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_ARCHIVE_BYTES) {
|
|
throw new Error("archive byte limit must be a positive integer no greater than 1 GiB");
|
|
}
|
|
if (statSync(path).size > maxBytes) {
|
|
throw new Error("compressed archive exceeds the archive byte limit");
|
|
}
|
|
let tar: Buffer;
|
|
try {
|
|
tar = gunzipSync(readFileSync(path), { maxOutputLength: maxBytes });
|
|
} catch (error) {
|
|
if (
|
|
error instanceof RangeError ||
|
|
(error as NodeJS.ErrnoException).code === "ERR_BUFFER_TOO_LARGE"
|
|
) {
|
|
throw new Error("expanded archive exceeds the extraction byte limit");
|
|
}
|
|
throw error;
|
|
}
|
|
const entries: ArchiveEntry[] = [];
|
|
const seen = new Set<string>();
|
|
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
const header = tar.subarray(offset, offset + 512);
|
|
if (header.every((byte) => byte === 0)) break;
|
|
const stored = octal(header, 148, 8);
|
|
const checksumHeader = Buffer.from(header);
|
|
checksumHeader.fill(0x20, 148, 156);
|
|
const actual = checksumHeader.reduce((sum, byte) => sum + byte, 0);
|
|
if (stored !== actual) throw new Error(`tar header checksum mismatch at byte ${offset}`);
|
|
const name = header.subarray(0, 100).toString("utf-8").replace(/\0.*$/, "");
|
|
const prefix = header.subarray(345, 500).toString("utf-8").replace(/\0.*$/, "");
|
|
const entryPath = safePath(prefix ? `${prefix}/${name}` : name);
|
|
if (seen.has(entryPath)) throw new Error(`duplicate archive destination: ${entryPath}`);
|
|
seen.add(entryPath);
|
|
const size = octal(header, 124, 12);
|
|
const mode = octal(header, 100, 8) || 0o644;
|
|
const typeFlag = String.fromCharCode(header[156] || 0);
|
|
if (!["\0", "0", "5"].includes(typeFlag)) {
|
|
throw new Error(`archive entry ${entryPath} has unsupported link/special type ${typeFlag}`);
|
|
}
|
|
const dataStart = offset + 512;
|
|
const dataEnd = dataStart + size;
|
|
if (dataEnd > tar.length) throw new Error(`truncated archive entry: ${entryPath}`);
|
|
if (typeFlag === "5" && size !== 0) {
|
|
throw new Error(`archive directory ${entryPath} has unexpected file data`);
|
|
}
|
|
entries.push({
|
|
path: entryPath,
|
|
type: typeFlag === "5" ? "directory" : "file",
|
|
mode,
|
|
data: Buffer.from(tar.subarray(dataStart, dataEnd)),
|
|
});
|
|
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
}
|
|
const types = new Map(entries.map((entry) => [entry.path, entry.type]));
|
|
for (const entry of entries) {
|
|
const parts = entry.path.split("/");
|
|
for (let index = 1; index < parts.length; index++) {
|
|
const ancestor = parts.slice(0, index).join("/");
|
|
if (types.get(ancestor) === "file") {
|
|
throw new Error(`archive file ${ancestor} is an ancestor of ${entry.path}`);
|
|
}
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
export function extractTarGz(
|
|
path: string,
|
|
destination: string,
|
|
options: { reservedTopLevelNames?: readonly string[] } = {},
|
|
): void {
|
|
const entries = readTarGz(path);
|
|
const reserved = new Set(
|
|
(options.reservedTopLevelNames ?? []).map((name) => name.toLowerCase()),
|
|
);
|
|
for (const entry of entries) {
|
|
const topLevel = entry.path.split("/", 1)[0].toLowerCase();
|
|
if (reserved.has(topLevel)) {
|
|
throw new Error(`archive entry uses reserved top-level name: ${entry.path}`);
|
|
}
|
|
}
|
|
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
for (const entry of entries.filter((item) => item.type === "directory")) {
|
|
mkdirSync(join(destination, entry.path), { recursive: true, mode: entry.mode & 0o777 });
|
|
}
|
|
for (const entry of entries.filter((item) => item.type === "file")) {
|
|
const target = join(destination, entry.path);
|
|
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
writeFileSync(target, entry.data, { mode: entry.mode & 0o777 });
|
|
}
|
|
}
|
|
|
|
function tarString(header: Buffer, offset: number, length: number, value: string): void {
|
|
const bytes = Buffer.from(value);
|
|
if (bytes.length > length) throw new Error(`tar field is too long: ${value}`);
|
|
bytes.copy(header, offset);
|
|
}
|
|
|
|
function tarOctal(header: Buffer, offset: number, length: number, value: number): void {
|
|
tarString(header, offset, length, value.toString(8).padStart(length - 1, "0"));
|
|
}
|
|
|
|
function tarPath(path: string): { name: string; prefix?: string } {
|
|
if (Buffer.byteLength(path) <= 100) return { name: path };
|
|
for (let index = path.lastIndexOf("/"); index > 0; index = path.lastIndexOf("/", index - 1)) {
|
|
const prefix = path.slice(0, index);
|
|
const name = path.slice(index + 1);
|
|
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) {
|
|
return { name, prefix };
|
|
}
|
|
}
|
|
throw new Error(`tar path is too long for ustar: ${path}`);
|
|
}
|
|
|
|
export function createTarGz(entries: readonly ArchiveEntry[]): Buffer {
|
|
const chunks: Buffer[] = [];
|
|
const seen = new Set<string>();
|
|
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
|
|
const types = new Map<string, ArchiveEntry["type"]>();
|
|
for (const source of sorted) {
|
|
const path = safePath(source.path);
|
|
if (source.type === "directory" && source.data.length !== 0) {
|
|
throw new Error(`archive directory ${path} has unexpected file data`);
|
|
}
|
|
if (seen.has(path)) throw new Error(`duplicate archive destination: ${path}`);
|
|
seen.add(path);
|
|
const parts = path.split("/");
|
|
for (let index = 1; index < parts.length; index++) {
|
|
const ancestor = parts.slice(0, index).join("/");
|
|
if (types.get(ancestor) === "file") {
|
|
throw new Error(`archive file ${ancestor} is an ancestor of ${path}`);
|
|
}
|
|
}
|
|
types.set(path, source.type);
|
|
const header = Buffer.alloc(512);
|
|
const fields = tarPath(path);
|
|
tarString(header, 0, 100, fields.name);
|
|
if (fields.prefix) tarString(header, 345, 155, fields.prefix);
|
|
tarOctal(header, 100, 8, source.mode & 0o777);
|
|
tarOctal(header, 108, 8, 0);
|
|
tarOctal(header, 116, 8, 0);
|
|
tarOctal(header, 124, 12, source.type === "file" ? source.data.length : 0);
|
|
tarOctal(header, 136, 12, 0);
|
|
header.fill(0x20, 148, 156);
|
|
header[156] = source.type === "directory" ? "5".charCodeAt(0) : "0".charCodeAt(0);
|
|
tarString(header, 257, 6, "ustar");
|
|
tarString(header, 263, 2, "00");
|
|
tarOctal(header, 148, 8, header.reduce((sum, byte) => sum + byte, 0));
|
|
chunks.push(header);
|
|
if (source.type === "file") {
|
|
chunks.push(source.data);
|
|
const padding = (512 - (source.data.length % 512)) % 512;
|
|
if (padding) chunks.push(Buffer.alloc(padding));
|
|
}
|
|
}
|
|
chunks.push(Buffer.alloc(1024));
|
|
return gzipSync(Buffer.concat(chunks));
|
|
}
|