📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"use strict";
|
||||
|
||||
const zlib = require("node:zlib");
|
||||
const { cacheError } = require("./identity");
|
||||
const { collisionKey, validateRelativeAssetPath } = require("./scan");
|
||||
|
||||
const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
|
||||
maxEntries: 10000,
|
||||
maxSingleFileBytes: 32 * 1024 * 1024,
|
||||
maxExpandedTotalBytes: 160 * 1024 * 1024,
|
||||
maxCompressionRatio: 128,
|
||||
});
|
||||
|
||||
function parseOctal(buffer, field) {
|
||||
const text = buffer.toString("ascii").replace(/\0.*$/, "").trim();
|
||||
if (!/^[0-7]+$/.test(text)) throw cacheError("AAS_ARCHIVE_HEADER_INVALID", `invalid ${field}`);
|
||||
const value = Number.parseInt(text, 8);
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw cacheError("AAS_ARCHIVE_HEADER_INVALID", `unsafe ${field}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeField(buffer) {
|
||||
const end = buffer.indexOf(0);
|
||||
const bytes = end === -1 ? buffer : buffer.subarray(0, end);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw cacheError("AAS_ARCHIVE_HEADER_INVALID", "archive path is not valid UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
function safeArchivePath(value) {
|
||||
if (typeof value !== "string" || value.includes("\\") || value.startsWith("/")
|
||||
|| /^[A-Za-z]:/.test(value) || value.startsWith("//")) {
|
||||
throw cacheError("AAS_ARCHIVE_PATH_INVALID", "archive path is absolute or platform-ambiguous");
|
||||
}
|
||||
const withoutDirectorySlash = value.replace(/\/$/, "");
|
||||
for (const segment of withoutDirectorySlash.split("/")) {
|
||||
const deviceBase = segment.split(".")[0].toUpperCase();
|
||||
if (!segment || /[\u0000-\u001f<>:"|?*]/u.test(segment) || /[ .]$/.test(segment)
|
||||
|| /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CONIN\$|CONOUT\$)$/.test(deviceBase)) {
|
||||
throw cacheError("AAS_ARCHIVE_PATH_INVALID", "archive path is unsafe on a supported filesystem");
|
||||
}
|
||||
}
|
||||
try {
|
||||
return validateRelativeAssetPath(withoutDirectorySlash);
|
||||
} catch {
|
||||
throw cacheError("AAS_ARCHIVE_PATH_INVALID", "archive path contains traversal or invalid segments");
|
||||
}
|
||||
}
|
||||
|
||||
function parsePax(bytes) {
|
||||
const values = {};
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const space = bytes.indexOf(0x20, offset);
|
||||
if (space < 0) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record length is missing");
|
||||
const lengthText = bytes.subarray(offset, space).toString("ascii");
|
||||
if (!/^[1-9][0-9]*$/.test(lengthText)) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record length is invalid");
|
||||
const length = Number(lengthText);
|
||||
if (!Number.isSafeInteger(length) || length < 5 || offset + length > bytes.length || bytes[offset + length - 1] !== 0x0a) {
|
||||
throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record is truncated");
|
||||
}
|
||||
const record = bytes.subarray(space + 1, offset + length - 1).toString("utf8");
|
||||
const equals = record.indexOf("=");
|
||||
if (equals < 1) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record has no key");
|
||||
const key = record.slice(0, equals);
|
||||
const value = record.slice(equals + 1);
|
||||
if (Object.hasOwn(values, key)) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "duplicate PAX key");
|
||||
values[key] = value;
|
||||
offset += length;
|
||||
}
|
||||
if (values.linkpath !== undefined) throw cacheError("AAS_ARCHIVE_LINK_FORBIDDEN", "PAX linkpath is forbidden");
|
||||
return values;
|
||||
}
|
||||
|
||||
function assertArchiveMode(mode) {
|
||||
if ((mode & 0o7000) !== 0 || (mode & 0o022) !== 0) {
|
||||
throw cacheError("AAS_ARCHIVE_MODE_UNSAFE", "archive entry has anomalous permissions");
|
||||
}
|
||||
}
|
||||
|
||||
function parseTar(tarBytes, options = {}) {
|
||||
const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...(options.limits || {}) };
|
||||
const selected = options.selectPaths ? new Set(options.selectPaths.map(safeArchivePath)) : null;
|
||||
const entries = [];
|
||||
const seen = new Map();
|
||||
const collisionKeys = new Map();
|
||||
let offset = 0;
|
||||
let fileCount = 0;
|
||||
let expandedBytes = 0;
|
||||
let pendingPath = null;
|
||||
let zeroBlocks = 0;
|
||||
while (offset + 512 <= tarBytes.length) {
|
||||
const header = tarBytes.subarray(offset, offset + 512);
|
||||
offset += 512;
|
||||
if (header.every((byte) => byte === 0)) {
|
||||
zeroBlocks += 1;
|
||||
if (zeroBlocks === 2) break;
|
||||
continue;
|
||||
}
|
||||
zeroBlocks = 0;
|
||||
const expectedChecksum = parseOctal(header.subarray(148, 156), "checksum");
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < 512; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : header[index];
|
||||
if (checksum !== expectedChecksum) throw cacheError("AAS_ARCHIVE_CHECKSUM_INVALID", "archive header checksum mismatch");
|
||||
const size = parseOctal(header.subarray(124, 136), "size");
|
||||
const mode = parseOctal(header.subarray(100, 108), "mode");
|
||||
assertArchiveMode(mode);
|
||||
const type = String.fromCharCode(header[156] || 0);
|
||||
const name = decodeField(header.subarray(0, 100));
|
||||
const prefix = decodeField(header.subarray(345, 500));
|
||||
let archivePath = pendingPath || (prefix ? `${prefix}/${name}` : name);
|
||||
pendingPath = null;
|
||||
const paddedSize = Math.ceil(size / 512) * 512;
|
||||
if (offset + paddedSize > tarBytes.length) throw cacheError("AAS_ARCHIVE_TRUNCATED", "archive entry is truncated");
|
||||
const body = tarBytes.subarray(offset, offset + size);
|
||||
offset += paddedSize;
|
||||
if (type === "x" || type === "g") {
|
||||
if (size > 64 * 1024) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX metadata is too large");
|
||||
const pax = parsePax(body);
|
||||
if (pax.path !== undefined) pendingPath = pax.path;
|
||||
continue;
|
||||
}
|
||||
if (type === "L") {
|
||||
if (size > 4096) throw cacheError("AAS_ARCHIVE_PATH_INVALID", "GNU long path is too large");
|
||||
pendingPath = decodeField(body).replace(/\0$/, "");
|
||||
continue;
|
||||
}
|
||||
archivePath = safeArchivePath(archivePath);
|
||||
const directory = type === "5";
|
||||
const regular = type === "0" || type === "\0";
|
||||
if (!directory && !regular) throw cacheError("AAS_ARCHIVE_SPECIAL_FILE_FORBIDDEN", "archive links and special entries are forbidden");
|
||||
if (directory && size !== 0) throw cacheError("AAS_ARCHIVE_HEADER_INVALID", "directory entry has data");
|
||||
fileCount += regular ? 1 : 0;
|
||||
expandedBytes += regular ? size : 0;
|
||||
if (fileCount > limits.maxEntries) throw cacheError("AAS_ARCHIVE_ENTRY_LIMIT", "archive exceeds the file-count limit");
|
||||
if (regular && size > limits.maxSingleFileBytes) throw cacheError("AAS_ARCHIVE_FILE_LIMIT", "archive file exceeds the size limit");
|
||||
if (expandedBytes > limits.maxExpandedTotalBytes) throw cacheError("AAS_ARCHIVE_TOTAL_LIMIT", "archive exceeds the expanded-byte limit");
|
||||
const kind = directory ? "directory" : "file";
|
||||
if (seen.has(archivePath)) throw cacheError("AAS_ARCHIVE_DUPLICATE_PATH", "archive contains a duplicate path");
|
||||
const key = collisionKey(archivePath);
|
||||
if (collisionKeys.has(key)) throw cacheError("AAS_ARCHIVE_PATH_COLLISION", "archive paths collide by case or Unicode normalization");
|
||||
for (const [existingPath, existingKind] of seen) {
|
||||
if ((archivePath.startsWith(`${existingPath}/`) && existingKind === "file")
|
||||
|| (existingPath.startsWith(`${archivePath}/`) && kind === "file")) {
|
||||
throw cacheError("AAS_ARCHIVE_FILE_DIRECTORY_COLLISION", "archive file and directory paths collide");
|
||||
}
|
||||
}
|
||||
seen.set(archivePath, kind);
|
||||
collisionKeys.set(key, archivePath);
|
||||
if (regular && (!selected || selected.has(archivePath))) entries.push({ path: archivePath, mode, bytes: Buffer.from(body) });
|
||||
}
|
||||
if (zeroBlocks < 2) throw cacheError("AAS_ARCHIVE_TRUNCATED", "archive end markers are missing");
|
||||
if (pendingPath !== null) throw cacheError("AAS_ARCHIVE_TRUNCATED", "archive ended after path metadata");
|
||||
if (selected) {
|
||||
const found = new Set(entries.map((entry) => entry.path));
|
||||
const missing = [...selected].filter((entry) => !found.has(entry));
|
||||
if (missing.length) throw cacheError("AAS_ARCHIVE_ASSET_MISSING", `archive asset is missing: ${missing[0]}`);
|
||||
}
|
||||
return { entries, fileCount, expandedBytes };
|
||||
}
|
||||
|
||||
function parsePackageArchive(archiveBytes, options = {}) {
|
||||
const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...(options.limits || {}) };
|
||||
const gzip = archiveBytes[0] === 0x1f && archiveBytes[1] === 0x8b;
|
||||
let tarBytes = archiveBytes;
|
||||
if (gzip) {
|
||||
try {
|
||||
tarBytes = zlib.gunzipSync(archiveBytes, { maxOutputLength: limits.maxExpandedTotalBytes + 1024 * 1024 });
|
||||
} catch (cause) {
|
||||
throw cacheError("AAS_ARCHIVE_DECOMPRESSION_FAILED", "archive decompression failed", cause);
|
||||
}
|
||||
if (tarBytes.length > archiveBytes.length * limits.maxCompressionRatio) {
|
||||
throw cacheError("AAS_ARCHIVE_COMPRESSION_RATIO", "archive exceeds the compression-ratio limit");
|
||||
}
|
||||
}
|
||||
return parseTar(tarBytes, { ...options, limits });
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_ARCHIVE_LIMITS, parsePackageArchive, parsePax, parseTar, safeArchivePath };
|
||||
Reference in New Issue
Block a user