Files
archive-tools/src/archive/sevenzip.ts
T
zemion fe578f46bd
Verify / verify (push) Canceled after 0s
Release Archive Tools 0.2.0
2026-09-02 09:34:59 +02:00

567 lines
17 KiB
TypeScript

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<ArchiveDocument> {
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<boolean>(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<boolean>(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<string, ArchiveEntryRecord[]>();
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<Uint8Array> {
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);
}