Files
cadSet/SimpleCADAPI/scene-contract/src/zip.ts
T

279 lines
14 KiB
TypeScript

import { inflateRawSync } from "node:zlib";
import {
BASE_LIMITS,
preflightAggregateCompressionRatio,
preflightInputArchiveSize,
preflightMemberCompressionRatio,
type SceneResourceLimits,
} from "./resources.js";
export interface ArchiveInfo {
members: ReadonlyMap<string, Buffer>;
inputSize: number;
canonicalSize: number;
usedDeflate: boolean;
}
interface Entry {
name: string;
method: number;
crc32: number;
compressedSize: number;
uncompressedSize: number;
localOffset: number;
}
const memberPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}$/;
function crc32Table(): Uint32Array {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
table[index] = value >>> 0;
}
return table;
}
const crcTable = crc32Table();
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function ascii(raw: Uint8Array, context: string): string {
if (raw.some((byte) => byte > 0x7f)) throw new Error("archive member names must be ASCII");
return Buffer.from(raw).toString("ascii");
}
function validateName(name: string, seen: Set<string>): Buffer {
const encoded = Buffer.from(name, "ascii");
if (encoded.toString("ascii") !== name) throw new Error("archive member names must be ASCII");
if (!memberPattern.test(name)) throw new Error(`invalid archive member name: ${JSON.stringify(name)}`);
if (name.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`invalid archive member path segment: ${JSON.stringify(name)}`);
}
const folded = name.toLowerCase();
if (seen.has(folded)) throw new Error(`duplicate or case-colliding archive member: ${JSON.stringify(name)}`);
seen.add(folded);
return encoded;
}
export function canonicalArchiveSize(sizes: ReadonlyMap<string, number>): number {
let total = 22;
for (const [name, size] of sizes) total += 76 + 2 * Buffer.byteLength(name, "ascii") + size;
return total;
}
export function preflightArchiveMemberSizes(
sizes: ReadonlyMap<string, number>,
limits: SceneResourceLimits = BASE_LIMITS,
): number {
if (sizes.size === 0 || !sizes.has("scene.json")) throw new Error("scene package must contain scene.json");
if (sizes.size > limits.zip_members) throw new Error("archive member count exceeds resource limit");
const seen = new Set<string>();
let total = 0;
for (const [name, size] of sizes) {
validateName(name, seen);
if (!Number.isSafeInteger(size) || size < 0) throw new Error(`invalid member size for ${JSON.stringify(name)}`);
if (size > limits.one_member_bytes) throw new Error(`archive member exceeds resource limit: ${JSON.stringify(name)}`);
if (name === "scene.json" && size > limits.scene_json_bytes) throw new Error("scene.json exceeds resource limit");
if (name.startsWith("entities/") && size > limits.entity_json_bytes) throw new Error(`entity sidecar exceeds resource limit: ${JSON.stringify(name)}`);
if (name === "model/model.json" && size > limits.model_json_bytes) throw new Error("embedded model.json exceeds resource limit");
if (name === "presentation/presentation.json" && size > limits.presentation_json_bytes) throw new Error("presentation JSON exceeds resource limit");
total += size;
if (total > limits.total_uncompressed_bytes) throw new Error("total uncompressed bytes exceed resource limit");
}
const result = canonicalArchiveSize(sizes);
if (result > limits.canonical_archive_bytes) throw new Error("canonical stored archive exceeds resource limit");
if (sizes.size > 0xffff || result > 0xffffffff) throw new Error("archive would require ZIP64");
return result;
}
export function canonicalZipBytes(
input: ReadonlyMap<string, Uint8Array> | Record<string, Uint8Array>,
limits: SceneResourceLimits = BASE_LIMITS,
): Buffer {
const members = input instanceof Map ? new Map(input) : new Map(Object.entries(input));
const payloads = new Map([...members].map(([name, value]) => [name, Buffer.from(value)]));
const canonicalSize = preflightArchiveMemberSizes(new Map([...payloads].map(([name, value]) => [name, value.length])), limits);
const localParts: Buffer[] = [];
const centralParts: Buffer[] = [];
let localOffset = 0;
for (const name of [...payloads.keys()].sort((left, right) => Buffer.compare(Buffer.from(left, "ascii"), Buffer.from(right, "ascii")))) {
const nameBytes = Buffer.from(name, "ascii");
const payload = payloads.get(name)!;
const crc = crc32(payload);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(0, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0x0021, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(payload.length, 18);
local.writeUInt32LE(payload.length, 22);
local.writeUInt16LE(nameBytes.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, nameBytes, payload);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0x0314, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(0, 10);
central.writeUInt16LE(0, 12);
central.writeUInt16LE(0x0021, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(payload.length, 20);
central.writeUInt32LE(payload.length, 24);
central.writeUInt16LE(nameBytes.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0x81a40000, 38);
central.writeUInt32LE(localOffset, 42);
centralParts.push(central, nameBytes);
localOffset += local.length + nameBytes.length + payload.length;
}
const centralBytes = Buffer.concat(centralParts);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(payloads.size, 8);
eocd.writeUInt16LE(payloads.size, 10);
eocd.writeUInt32LE(centralBytes.length, 12);
eocd.writeUInt32LE(localOffset, 16);
eocd.writeUInt16LE(0, 20);
const result = Buffer.concat([...localParts, centralBytes, eocd]);
if (result.length !== canonicalSize) throw new Error("canonical ZIP size formula disagrees with encoder");
return result;
}
export function preflightZipBytes(data: Uint8Array, limits: SceneResourceLimits = BASE_LIMITS): ArchiveInfo {
preflightInputArchiveSize(data.byteLength, limits);
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (raw.length < 22) throw new Error("truncated ZIP archive");
const eocdOffset = raw.length - 22;
if (raw.readUInt32LE(eocdOffset) !== 0x06054b50 || raw.readUInt16LE(eocdOffset + 20) !== 0) {
throw new Error("ZIP EOCD must be final and have no comment");
}
const disk = raw.readUInt16LE(eocdOffset + 4);
const centralDisk = raw.readUInt16LE(eocdOffset + 6);
const diskCount = raw.readUInt16LE(eocdOffset + 8);
const totalCount = raw.readUInt16LE(eocdOffset + 10);
const centralSize = raw.readUInt32LE(eocdOffset + 12);
const centralOffset = raw.readUInt32LE(eocdOffset + 16);
if (disk !== 0 || centralDisk !== 0 || diskCount !== totalCount) throw new Error("multi-disk ZIP archives are forbidden");
if (totalCount > limits.zip_members) throw new Error("archive member count exceeds resource limit");
if (centralOffset + centralSize !== eocdOffset) throw new Error("ZIP central directory offset/size mismatch");
const entries: Entry[] = [];
const seen = new Set<string>();
let cursor = centralOffset;
for (let index = 0; index < totalCount; index += 1) {
if (cursor + 46 > eocdOffset) throw new Error("truncated ZIP central directory");
if (raw.readUInt32LE(cursor) !== 0x02014b50) throw new Error("invalid ZIP central header signature");
const madeBy = raw.readUInt16LE(cursor + 4);
const needed = raw.readUInt16LE(cursor + 6);
const flags = raw.readUInt16LE(cursor + 8);
const method = raw.readUInt16LE(cursor + 10);
const dosTime = raw.readUInt16LE(cursor + 12);
const dosDate = raw.readUInt16LE(cursor + 14);
const crc = raw.readUInt32LE(cursor + 16);
const compressedSize = raw.readUInt32LE(cursor + 20);
const uncompressedSize = raw.readUInt32LE(cursor + 24);
const nameLength = raw.readUInt16LE(cursor + 28);
const extraLength = raw.readUInt16LE(cursor + 30);
const commentLength = raw.readUInt16LE(cursor + 32);
const diskStart = raw.readUInt16LE(cursor + 34);
const internalAttributes = raw.readUInt16LE(cursor + 36);
const externalAttributes = raw.readUInt32LE(cursor + 38);
const localOffset = raw.readUInt32LE(cursor + 42);
if (madeBy !== 0x0314 || needed !== 20) throw new Error("ZIP creator/version profile mismatch");
if (flags !== 0x0800 || (method !== 0 && method !== 8)) throw new Error("unsupported ZIP flags or compression method");
if (dosTime !== 0 || dosDate !== 0x0021) throw new Error("ZIP timestamp profile mismatch");
if (extraLength !== 0 || commentLength !== 0 || diskStart !== 0 || internalAttributes !== 0) throw new Error("ZIP extra/comment/disk/attributes are forbidden");
if (externalAttributes !== 0x81a40000) throw new Error("ZIP member must be Unix regular mode 0100644");
const nameStart = cursor + 46;
const recordEnd = nameStart + nameLength + extraLength + commentLength;
if (recordEnd > eocdOffset) throw new Error("truncated ZIP central member record");
const name = ascii(raw.subarray(nameStart, nameStart + nameLength), "central name");
validateName(name, seen);
entries.push({ name, method, crc32: crc, compressedSize, uncompressedSize, localOffset });
cursor = recordEnd;
}
if (cursor !== eocdOffset) throw new Error("unexpected bytes in ZIP central directory");
const canonicalSize = preflightArchiveMemberSizes(new Map(entries.map((entry) => [entry.name, entry.uncompressedSize])), limits);
const occupied: [number, number][] = [];
const payloadRanges: { entry: Entry; start: number; end: number }[] = [];
let totalUncompressed = 0;
for (const entry of entries) {
const offset = entry.localOffset;
if (offset + 30 > centralOffset) throw new Error("truncated ZIP local header");
if (raw.readUInt32LE(offset) !== 0x04034b50) throw new Error("invalid ZIP local header signature");
const needed = raw.readUInt16LE(offset + 4);
const flags = raw.readUInt16LE(offset + 6);
const method = raw.readUInt16LE(offset + 8);
const dosTime = raw.readUInt16LE(offset + 10);
const dosDate = raw.readUInt16LE(offset + 12);
const crc = raw.readUInt32LE(offset + 14);
const compressedSize = raw.readUInt32LE(offset + 18);
const uncompressedSize = raw.readUInt32LE(offset + 22);
const nameLength = raw.readUInt16LE(offset + 26);
const extraLength = raw.readUInt16LE(offset + 28);
if (needed !== 20 || flags !== 0x0800 || method !== entry.method || dosTime !== 0 || dosDate !== 0x0021 || crc !== entry.crc32 || compressedSize !== entry.compressedSize || uncompressedSize !== entry.uncompressedSize || extraLength !== 0) {
throw new Error("ZIP central/local header mismatch");
}
const nameStart = offset + 30;
const nameEnd = nameStart + nameLength;
const payloadEnd = nameEnd + compressedSize;
if (payloadEnd > centralOffset) throw new Error("ZIP member payload crosses central directory");
if (ascii(raw.subarray(nameStart, nameEnd), "local name") !== entry.name) throw new Error("ZIP central/local member name mismatch");
occupied.push([offset, payloadEnd]);
payloadRanges.push({ entry, start: nameEnd, end: payloadEnd });
totalUncompressed += uncompressedSize;
}
occupied.sort((left, right) => left[0] - right[0]);
if (occupied.length > 0) {
if (occupied[0][0] !== 0) throw new Error("ZIP archive has a leading prefix");
for (let index = 1; index < occupied.length; index += 1) {
if (occupied[index - 1][1] !== occupied[index][0]) throw new Error("ZIP local records overlap or contain gaps");
}
if (occupied.at(-1)![1] !== centralOffset) throw new Error("ZIP bytes exist between local records and central directory");
}
preflightAggregateCompressionRatio(totalUncompressed, raw.length, limits);
const members = new Map<string, Buffer>();
for (const { entry, start, end } of payloadRanges) {
const compressed = raw.subarray(start, end);
try {
preflightMemberCompressionRatio(entry.uncompressedSize, entry.compressedSize, limits);
} catch {
throw new Error(`member compression ratio exceeds limit: ${JSON.stringify(entry.name)}`);
}
let payload: Buffer;
if (entry.method === 0) {
payload = Buffer.from(compressed);
} else {
try {
const inflated = inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize + 1, info: true }) as unknown as {
buffer: Buffer;
engine: { bytesWritten: number };
};
if (inflated.engine.bytesWritten !== compressed.length) throw new Error("trailing deflate data");
payload = Buffer.from(inflated.buffer);
} catch {
throw new Error(`invalid deflate stream: ${JSON.stringify(entry.name)}`);
}
}
if (payload.length !== entry.uncompressedSize) throw new Error(`ZIP member decoded size mismatch: ${JSON.stringify(entry.name)}`);
if (crc32(payload) !== entry.crc32) throw new Error(`ZIP member CRC mismatch: ${JSON.stringify(entry.name)}`);
members.set(entry.name, payload);
}
return { members, inputSize: raw.length, canonicalSize, usedDeflate: entries.some((entry) => entry.method === 8) };
}