@@ -0,0 +1,566 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user