567 lines
17 KiB
TypeScript
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);
|
|
}
|