import { ByteCursor, checkedOffsetAdd } from "@add-ideas/toolbox-helpers"; import { crc32, uint32Hex } from "./checksums"; import { ArchivePolicyError } from "./errors"; import { ARCHIVE_LIMITS, throwIfAborted } from "./limits"; import { assessArchivePath } from "./paths"; import type { ArchiveDocument, ArchiveEntryRecord, ArchiveIssue, } from "./types"; const SIGNATURE = new Uint8Array([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]); const NID = Object.freeze({ end: 0x00, header: 0x01, archiveProperties: 0x02, additionalStreamsInfo: 0x03, mainStreamsInfo: 0x04, filesInfo: 0x05, packInfo: 0x06, unpackInfo: 0x07, subStreamsInfo: 0x08, size: 0x09, crc: 0x0a, folder: 0x0b, codersUnpackSize: 0x0c, numUnpackStream: 0x0d, emptyStream: 0x0e, emptyFile: 0x0f, anti: 0x10, name: 0x11, encodedHeader: 0x17, }); interface SevenZipMetadata { entries: ArchiveEntryRecord[]; parsed: boolean; } export async function inspectSevenZip( file: File, signal?: AbortSignal, ): Promise { throwIfAborted(signal); if (file.size < 32) malformed("7z start header is truncated."); const start = await readExact(file, 0, 32, signal); for (const [index, byte] of SIGNATURE.entries()) { if (start[index] !== byte) malformed("7z signature is invalid."); } const view = new DataView(start.buffer, start.byteOffset, start.byteLength); const expectedStartCrc = view.getUint32(8, true); const actualStartCrc = crc32(start.subarray(12, 32)); if (expectedStartCrc !== actualStartCrc) { malformed("7z start-header CRC-32 does not match.", "7Z_START_CRC"); } const nextOffset = safeBigUint( view.getBigUint64(12, true), "7z next-header offset", ); const nextSize = safeBigUint( view.getBigUint64(20, true), "7z next-header size", ); if (nextSize > ARCHIVE_LIMITS.maxStructuralHeaderBytes) { throw new ArchivePolicyError( "7Z_HEADER_LIMIT", `7z next header exceeds the ${ARCHIVE_LIMITS.maxStructuralHeaderBytes.toLocaleString()}-byte structural-inspection limit.`, ); } const nextStart = checkedArchiveOffset( 32, nextOffset, file.size, "7z next-header offset", ); checkedArchiveOffset(nextStart, nextSize, file.size, "7z next-header range"); const next = await readExact(file, nextStart, nextSize, signal); const expectedNextCrc = view.getUint32(28, true); const actualNextCrc = crc32(next); if (expectedNextCrc !== actualNextCrc) { malformed("7z next-header CRC-32 does not match.", "7Z_NEXT_CRC"); } let kind: "empty" | "plain" | "encoded" | "unknown" = "empty"; let metadata: SevenZipMetadata = { entries: [], parsed: false }; const issues: ArchiveIssue[] = []; if (next.length) { if (next[0] === NID.header) { kind = "plain"; metadata = parsePlainHeader(next); issues.push({ code: "SEVEN_ZIP_STRUCTURAL_ONLY", message: "7z plain metadata was inventoried, but packed streams are not decoded or extracted.", severity: "info", }); } else if (next[0] === NID.encodedHeader) { kind = "encoded"; issues.push({ code: "SEVEN_ZIP_ENCODED_HEADER", message: "The 7z next header is encoded (compressed or encrypted); structural inspection stops before metadata decoding.", severity: "warning", }); } else { kind = "unknown"; malformed( `Unsupported 7z next-header marker 0x${next[0]!.toString(16).padStart(2, "0")}.`, ); } } const entries = markCollisions(metadata.entries); return { name: file.name, format: "7z", source: file, sourceBytes: file.size, entries, issues, expandedBytes: entries.reduce( (sum, entry) => sum + (entry.sizeKnown === false ? 0 : entry.size), 0, ), compressedBytes: file.size, zip64: false, structural: { parser: "7z", signature: "37 7A BC AF 27 1C", version: `${start[6]}.${start[7]}`, headerCrcs: { verified: 2, failed: 0 }, nextHeader: { offset: nextStart, size: nextSize, crc32: uint32Hex(expectedNextCrc), kind, metadataParsed: metadata.parsed, }, notes: [ kind === "plain" ? "Plain FilesInfo names and empty-file/directory markers were parsed when present." : kind === "encoded" ? "Encoded headers are not decoded and may conceal filenames or encryption." : "The archive has no next-header payload.", "7z packed streams, codec chains, passwords and extraction are unsupported.", ], }, }; } function parsePlainHeader(bytes: Uint8Array): SevenZipMetadata { try { const cursor = new ByteCursor(bytes, { maximumBytes: ARCHIVE_LIMITS.maxStructuralHeaderBytes, }); expectNid(cursor, NID.header, "7z Header"); let entries: ArchiveEntryRecord[] = []; let filesSeen = false; while (!cursor.done) { const nid = cursor.readUint8(); if (nid === NID.end) { if (!cursor.done) malformed("7z plain header has trailing bytes after End."); return { entries, parsed: filesSeen }; } if (nid === NID.archiveProperties) skipArchiveProperties(cursor); else if (nid === NID.additionalStreamsInfo || nid === NID.mainStreamsInfo) skipStreamsInfo(cursor); else if (nid === NID.filesInfo) { if (filesSeen) malformed("7z header contains duplicate FilesInfo sections."); entries = parseFilesInfo(cursor); filesSeen = true; } else malformed(`Unsupported 7z Header property 0x${hexByte(nid)}.`); } malformed("7z plain header is missing its End marker."); } catch (error) { if (error instanceof ArchivePolicyError) throw error; malformed( error instanceof Error ? `Malformed 7z plain header: ${error.message}` : "Malformed 7z plain header.", ); } } function skipArchiveProperties(cursor: ByteCursor): void { while (true) { const nid = cursor.readUint8(); if (nid === NID.end) return; cursor.skip(readSevenZipUint(cursor, "archive property size")); } } interface FolderState { outputStreams: number; crcDefined: boolean; } function skipStreamsInfo(cursor: ByteCursor): void { let folders: FolderState[] = []; while (true) { const nid = cursor.readUint8(); if (nid === NID.end) return; if (nid === NID.packInfo) skipPackInfo(cursor); else if (nid === NID.unpackInfo) folders = skipUnpackInfo(cursor); else if (nid === NID.subStreamsInfo) skipSubStreamsInfo(cursor, folders); else malformed(`Unsupported 7z StreamsInfo property 0x${hexByte(nid)}.`); } } function skipPackInfo(cursor: ByteCursor): void { readSevenZipUint(cursor, "pack position"); const streams = boundedCount( readSevenZipUint(cursor, "pack stream count"), "pack streams", ); while (true) { const nid = cursor.readUint8(); if (nid === NID.end) return; if (nid === NID.size) { for (let index = 0; index < streams; index += 1) readSevenZipUint(cursor, "packed stream size"); } else if (nid === NID.crc) readDigestSet(cursor, streams); else malformed(`Unsupported 7z PackInfo property 0x${hexByte(nid)}.`); } } function skipUnpackInfo(cursor: ByteCursor): FolderState[] { expectNid(cursor, NID.folder, "7z Folder"); const count = boundedCount( readSevenZipUint(cursor, "folder count"), "folders", ); if (cursor.readUint8() !== 0) malformed("External 7z folder metadata is unsupported."); const folders: FolderState[] = []; for (let index = 0; index < count; index += 1) { folders.push({ outputStreams: skipFolder(cursor), crcDefined: false }); } expectNid(cursor, NID.codersUnpackSize, "7z CodersUnpackSize"); for (const folder of folders) { for (let index = 0; index < folder.outputStreams; index += 1) readSevenZipUint(cursor, "coder unpack size"); } let nid = cursor.readUint8(); if (nid === NID.crc) { const defined = readDigestSet(cursor, count); folders.forEach( (folder, index) => (folder.crcDefined = defined[index] ?? false), ); nid = cursor.readUint8(); } if (nid !== NID.end) malformed("7z UnpackInfo is missing its End marker."); return folders; } function skipFolder(cursor: ByteCursor): number { const coderCount = boundedCount( readSevenZipUint(cursor, "coder count"), "coders", 1024, ); if (!coderCount) malformed("7z folder has no coders."); let inputs = 0; let outputs = 0; for (let index = 0; index < coderCount; index += 1) { const flags = cursor.readUint8(); if (flags & 0x80) malformed("Alternative 7z coder methods are unsupported."); const idSize = flags & 0x0f; if (!idSize) malformed("7z coder method ID is empty."); cursor.skip(idSize); const complex = Boolean(flags & 0x10); const coderInputs = complex ? readSevenZipUint(cursor, "coder inputs") : 1; const coderOutputs = complex ? readSevenZipUint(cursor, "coder outputs") : 1; inputs = checkedSmallAdd(inputs, coderInputs, "coder input streams"); outputs = checkedSmallAdd(outputs, coderOutputs, "coder output streams"); if (flags & 0x20) cursor.skip(readSevenZipUint(cursor, "coder property size")); } if (!outputs || inputs < outputs - 1) malformed("Invalid 7z folder stream graph."); const bindPairs = outputs - 1; for (let index = 0; index < bindPairs; index += 1) { readSevenZipUint(cursor, "bind input index"); readSevenZipUint(cursor, "bind output index"); } const packedStreams = inputs - bindPairs; if (packedStreams > 1) { for (let index = 0; index < packedStreams; index += 1) readSevenZipUint(cursor, "packed stream index"); } return outputs; } function skipSubStreamsInfo(cursor: ByteCursor, folders: FolderState[]): void { let counts = folders.map(() => 1); let nid = cursor.readUint8(); if (nid === NID.numUnpackStream) { counts = folders.map(() => boundedCount(readSevenZipUint(cursor, "substream count"), "substreams"), ); nid = cursor.readUint8(); } if (nid === NID.size) { for (const count of counts) { for (let index = 1; index < count; index += 1) readSevenZipUint(cursor, "substream size"); } nid = cursor.readUint8(); } if (nid === NID.crc) { const digestCount = counts.reduce( (sum, count, index) => checkedSmallAdd( sum, count === 1 && folders[index]?.crcDefined ? 0 : count, "substream digests", ), 0, ); readDigestSet(cursor, digestCount); nid = cursor.readUint8(); } if (nid !== NID.end) malformed("7z SubStreamsInfo is missing its End marker."); } function readDigestSet(cursor: ByteCursor, count: number): boolean[] { const allDefined = cursor.readUint8() !== 0; const defined = allDefined ? Array(count).fill(true) : readBitSet(cursor, count); for (const present of defined) if (present) cursor.skip(4); return defined; } function parseFilesInfo(cursor: ByteCursor): ArchiveEntryRecord[] { const count = boundedCount(readSevenZipUint(cursor, "file count"), "files"); let names: string[] | undefined; let emptyStreams = Array(count).fill(false); let emptyFiles: boolean[] = []; while (true) { const property = cursor.readUint8(); if (property === NID.end) break; const size = readSevenZipUint(cursor, "FilesInfo property size"); const propertyCursor = cursor.subcursor(size); if (property === NID.name) names = parseNames(propertyCursor, count); else if (property === NID.emptyStream) emptyStreams = readBitSet(propertyCursor, count); else if (property === NID.emptyFile) emptyFiles = readBitSet( propertyCursor, emptyStreams.filter(Boolean).length, ); else if (property === NID.anti) readBitSet(propertyCursor, emptyStreams.filter(Boolean).length); else propertyCursor.skip(propertyCursor.remaining); if (!propertyCursor.done) malformed( `7z FilesInfo property 0x${hexByte(property)} has trailing bytes.`, ); } if (!names) { return Array.from({ length: count }, (_, index) => createEntry(index, `unnamed-${index + 1}`, "other", false), ); } let emptyIndex = 0; return names.map((name, index) => { const isEmptyStream = emptyStreams[index] ?? false; const isFile = !isEmptyStream || (emptyFiles[emptyIndex] ?? false); if (isEmptyStream) emptyIndex += 1; return createEntry( index, name, isFile ? "file" : "directory", isEmptyStream, ); }); } function parseNames(cursor: ByteCursor, count: number): string[] { if (cursor.readUint8() !== 0) malformed("External 7z filename metadata is unsupported."); if (cursor.remaining % 2) malformed("7z UTF-16 filename data has an odd byte length."); let decoded: string; try { decoded = new TextDecoder("utf-16le", { fatal: true }).decode( cursor.readBytes(cursor.remaining), ); } catch { malformed("7z filename metadata is not valid UTF-16LE."); } if (!decoded.endsWith("\0")) malformed("7z filename metadata lacks a terminator."); const names = decoded.slice(0, -1).split("\0"); if (names.length !== count) malformed("7z filename count does not match FilesInfo."); return names; } function createEntry( index: number, rawName: string, kind: ArchiveEntryRecord["kind"], sizeKnown: boolean, ): ArchiveEntryRecord { const rawPath = kind === "directory" && !rawName.endsWith("/") ? `${rawName}/` : rawName; const assessed = assessArchivePath(rawPath); const issues = assessed.issues.map((issue) => ({ ...issue, entryId: String(index), })); issues.push({ code: "SEVEN_ZIP_EXTRACTION_UNSUPPORTED", message: "7z entries are inventory-only; packed-stream decoding and extraction are unsupported.", severity: "error", entryId: String(index), }); return { id: String(index), sourceIndex: index, rawPath, path: assessed.normalized || rawPath, collisionKey: assessed.collisionKey, kind, size: 0, sizeKnown, compression: "7z (not decoded)", extractable: false, issues, }; } function markCollisions(entries: ArchiveEntryRecord[]): ArchiveEntryRecord[] { const groups = new Map(); for (const entry of entries) { const group = groups.get(entry.collisionKey) ?? []; group.push(entry); groups.set(entry.collisionKey, group); } for (const group of groups.values()) { if (group.length < 2) continue; for (const entry of group) { entry.issues.push({ code: "DUPLICATE_PATH", message: "Path collides with another entry after safe normalization.", severity: "error", entryId: entry.id, }); } } return entries; } function readBitSet(cursor: ByteCursor, count: number): boolean[] { const result: boolean[] = []; let current = 0; let mask = 0; for (let index = 0; index < count; index += 1) { if (!mask) { current = cursor.readUint8(); mask = 0x80; } result.push(Boolean(current & mask)); mask >>= 1; } return result; } function readSevenZipUint(cursor: ByteCursor, label: string): number { const first = cursor.readUint8(); let mask = 0x80; let value = 0n; for (let index = 0; index < 8; index += 1) { if ((first & mask) === 0) { value |= BigInt(first & (mask - 1)) << BigInt(index * 8); return safeBigUint(value, label); } value |= BigInt(cursor.readUint8()) << BigInt(index * 8); mask >>= 1; } return safeBigUint(value, label); } function expectNid(cursor: ByteCursor, expected: number, label: string): void { const actual = cursor.readUint8(); if (actual !== expected) malformed(`${label} marker is invalid (found 0x${hexByte(actual)}).`); } function boundedCount( value: number, label: string, maximum: number = ARCHIVE_LIMITS.maxEntries, ): number { if (value > maximum) throw new ArchivePolicyError( "7Z_COUNT_LIMIT", `7z ${label} exceed the bounded limit of ${maximum.toLocaleString()}.`, ); return value; } function checkedSmallAdd(left: number, right: number, label: string): number { const result = checkedOffsetAdd( left, right, ARCHIVE_LIMITS.maxEntries * 16, label, ); return result; } function checkedArchiveOffset( base: number, length: number, maximum: number, label: string, ): number { try { return checkedOffsetAdd(base, length, maximum, label); } catch { malformed(`${label} points outside the archive.`); } } async function readExact( file: Blob, offset: number, length: number, signal?: AbortSignal, ): Promise { throwIfAborted(signal); const bytes = new Uint8Array( await file.slice(offset, offset + length).arrayBuffer(), ); throwIfAborted(signal); if (bytes.byteLength !== length) malformed("7z header is truncated."); return bytes; } function safeBigUint(value: bigint, label: string): number { if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new ArchivePolicyError( "7Z_OFFSET_LIMIT", `${label} exceeds safe browser offset arithmetic.`, ); return Number(value); } function hexByte(value: number): string { return value.toString(16).padStart(2, "0"); } function malformed(message: string, code = "MALFORMED_7Z"): never { throw new ArchivePolicyError(code, message); }