streaming 7-Zip archive extractor
1/**
2 * @module
3 *
4 * locates and reads the structural pieces of a 7z archive: the 32-byte
5 * SignatureHeader at the start of the file, the StartHeader pointer it
6 * carries to the NextHeader at the tail, and the NextHeader itself.
7 *
8 * the NextHeader is often stored compressed (an EncodedHeader); when that
9 * happens the on-disk header is itself a single-folder packed stream whose
10 * coder chain must be run before {@link parseHeader} can read the result.
11 * decoding the EncodedHeader is deferred to the codec layer; this module
12 * surfaces the raw structure so callers can branch on the kind.
13 */
14
15import { crc32 } from '@mary/crc32';
16
17import { type DispatchOptions, rewrapDecodeError, runFolder } from './codec/folder.ts';
18import { SIGNATURE, SIGNATURE_HEADER_SIZE } from './constants.ts';
19import {
20 ChecksumMismatchError,
21 InvalidSignatureError,
22 MalformedArchiveError,
23 TruncatedArchiveError,
24} from './errors.ts';
25import { type ArchiveHeader, type Folder, parseHeader, parseStreamsInfo } from './header.ts';
26import { Property } from './constants.ts';
27import type { Reader } from './reader/types.ts';
28import { concat, readBytes } from './utils/buffer.ts';
29import { Cursor } from './utils/cursor.ts';
30
31// #region signature header
32
33/** the pointer carried by the StartHeader inside the signature header. */
34export interface StartHeader {
35 /** file format major version (always 0 in archives we support). */
36 versionMajor: number;
37 /** file format minor version. */
38 versionMinor: number;
39 /** offset from the end of the signature header to the NextHeader. */
40 nextHeaderOffset: number;
41 /** length of the NextHeader region. */
42 nextHeaderSize: number;
43 /** CRC32 over the NextHeader region. */
44 nextHeaderCrc: number;
45}
46
47/**
48 * reads and validates the 32-byte signature header at the start of the
49 * archive. throws {@link InvalidSignatureError} on a wrong signature and
50 * {@link ChecksumMismatchError} on a StartHeader CRC mismatch.
51 *
52 * @param reader the random-access archive source
53 * @returns the parsed StartHeader pointer to the NextHeader
54 */
55export const readSignatureHeader = async (reader: Reader): Promise<StartHeader> => {
56 if (reader.length < SIGNATURE_HEADER_SIZE) {
57 throw new TruncatedArchiveError('source is shorter than the 7z signature header');
58 }
59 const bytes = await readBytes(reader, 0, SIGNATURE_HEADER_SIZE);
60 for (let i = 0; i < SIGNATURE.length; i++) {
61 if (bytes[i] !== SIGNATURE[i]) {
62 throw new InvalidSignatureError('source does not start with a 7z signature');
63 }
64 }
65 const versionMajor = bytes[6];
66 const versionMinor = bytes[7];
67 const startHeaderCrc = new DataView(bytes.buffer, bytes.byteOffset).getUint32(8, true);
68 const startHeader = bytes.subarray(12, 32);
69 const computedCrc = crc32(startHeader);
70 if (computedCrc !== startHeaderCrc) {
71 throw new ChecksumMismatchError(
72 `StartHeader CRC32 mismatch: stored 0x${startHeaderCrc.toString(16).padStart(8, '0')}, ` +
73 `computed 0x${computedCrc.toString(16).padStart(8, '0')}`,
74 );
75 }
76 const dv = new DataView(startHeader.buffer, startHeader.byteOffset, startHeader.byteLength);
77 const offsetBig = dv.getBigUint64(0, true);
78 const sizeBig = dv.getBigUint64(8, true);
79 if (offsetBig > BigInt(Number.MAX_SAFE_INTEGER) || sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) {
80 throw new MalformedArchiveError('NextHeader offset or size exceeds safe integer range');
81 }
82 return {
83 nextHeaderCrc: dv.getUint32(16, true),
84 nextHeaderOffset: Number(offsetBig),
85 nextHeaderSize: Number(sizeBig),
86 versionMajor,
87 versionMinor,
88 };
89};
90
91// #endregion
92
93// #region next header
94
95/**
96 * raw NextHeader payload as it sits on disk: either a plain Header (parse
97 * directly with {@link parseHeader}) or an EncodedHeader (the bytes belong
98 * to a folder's packed stream and must be decoded first).
99 */
100export type NextHeaderRaw =
101 | { kind: 'header'; bytes: Uint8Array<ArrayBuffer> }
102 | {
103 kind: 'encoded';
104 folder: Folder;
105 packPos: number;
106 packSize: number;
107 unpackSize: number;
108 storedCrc: number | undefined;
109 };
110
111/**
112 * reads the NextHeader region and decides whether it is a plain Header or
113 * an EncodedHeader. the bytes are CRC32-verified against the StartHeader's
114 * `nextHeaderCrc` before further parsing.
115 *
116 * @param reader random-access archive source
117 * @param start StartHeader returned by {@link readSignatureHeader}
118 * @returns the raw NextHeader description; plain headers carry their bytes
119 * directly, encoded headers carry the indirection that locates the packed
120 * payload elsewhere in the file
121 */
122export const readNextHeader = async (
123 reader: Reader,
124 start: StartHeader,
125): Promise<NextHeaderRaw> => {
126 const absoluteOffset = SIGNATURE_HEADER_SIZE + start.nextHeaderOffset;
127 if (absoluteOffset + start.nextHeaderSize > reader.length) {
128 throw new TruncatedArchiveError(
129 `NextHeader region runs past end of file: ` +
130 `expected ${absoluteOffset + start.nextHeaderSize} bytes, got ${reader.length}`,
131 );
132 }
133 const bytes = await readBytes(reader, absoluteOffset, start.nextHeaderSize);
134 const computedCrc = crc32(bytes);
135 if (computedCrc !== start.nextHeaderCrc) {
136 throw new ChecksumMismatchError(
137 `NextHeader CRC32 mismatch: stored 0x${start.nextHeaderCrc.toString(16).padStart(8, '0')}, ` +
138 `computed 0x${computedCrc.toString(16).padStart(8, '0')}`,
139 );
140 }
141
142 const cursor = new Cursor(bytes);
143 const tag = cursor.peekU8();
144 if (tag === Property.header) {
145 return { bytes, kind: 'header' };
146 }
147 if (tag === Property.encodedHeader) {
148 cursor.readU8();
149 const info = parseStreamsInfo(cursor);
150 if (info.folders.length !== 1) {
151 throw new MalformedArchiveError(
152 `EncodedHeader must contain exactly one folder: got ${info.folders.length}`,
153 );
154 }
155 if (info.pack.packSizes.length < 1) {
156 throw new MalformedArchiveError('EncodedHeader has no packed streams');
157 }
158 const folder = info.folders[0];
159 const unpackSize = folder.unpackSizes.length > 0 ? lastUnboundUnpackSize(folder) : 0;
160 return {
161 folder,
162 kind: 'encoded',
163 packPos: info.pack.packPos,
164 packSize: info.pack.packSizes[0],
165 storedCrc: info.pack.packCrcs[0],
166 unpackSize,
167 };
168 }
169 throw new MalformedArchiveError(
170 `expected Header (0x01) or EncodedHeader (0x17): got 0x${tag.toString(16)}`,
171 );
172};
173
174const lastUnboundUnpackSize = (folder: Folder): number => {
175 for (let i = folder.unpackSizes.length - 1; i >= 0; i--) {
176 if (!folder.bindPairs.some((bp) => bp.out === i)) {
177 return folder.unpackSizes[i];
178 }
179 }
180 return 0;
181};
182
183// #endregion
184
185// #region archive
186
187/** the fully-loaded result of reading an archive's signature + NextHeader. */
188export interface ArchiveContainer {
189 start: StartHeader;
190 /**
191 * the absolute byte offset where the packed data area begins
192 * (`SIGNATURE_HEADER_SIZE` plus the header's PackInfo.packPos).
193 */
194 packBase: number;
195 header: ArchiveHeader;
196}
197
198/**
199 * reads an archive's container layer: signature, StartHeader, NextHeader.
200 * when the NextHeader is encoded (the common case), its single-folder
201 * coder chain is run through the codec layer to recover the plain header
202 * bytes before parsing.
203 *
204 * @param reader random-access archive source
205 * @returns the parsed container
206 */
207export const readContainer = async (
208 reader: Reader,
209 options: DispatchOptions = {},
210): Promise<ArchiveContainer> => {
211 const start = await readSignatureHeader(reader);
212 if (start.nextHeaderSize === 0) {
213 const header: ArchiveHeader = {
214 fileFolderIndex: [],
215 fileSubStreamIndex: [],
216 files: [],
217 folders: [],
218 pack: { packCrcs: [], packPos: 0, packSizes: [] },
219 subStreams: { crcs: [], numUnpackStreams: [], unpackSizes: [] },
220 };
221 return { header, packBase: SIGNATURE_HEADER_SIZE, start };
222 }
223 const raw = await readNextHeader(reader, start);
224 const headerBytes = raw.kind === 'header' ? raw.bytes : await decodeEncodedHeader(reader, raw, options);
225 let header: ArchiveHeader;
226 try {
227 header = parseHeader(headerBytes);
228 } catch (err) {
229 if (raw.kind === 'encoded') {
230 throw rewrapDecodeError(err, raw.folder, options);
231 }
232 throw err;
233 }
234 return { header, packBase: SIGNATURE_HEADER_SIZE + header.pack.packPos, start };
235};
236
237const decodeEncodedHeader = async (
238 reader: Reader,
239 raw: Extract<NextHeaderRaw, { kind: 'encoded' }>,
240 options: DispatchOptions,
241): Promise<Uint8Array<ArrayBuffer>> => {
242 const packedOffset = SIGNATURE_HEADER_SIZE + raw.packPos;
243 try {
244 const chunks: Uint8Array<ArrayBuffer>[] = [];
245 let total = 0;
246 for await (
247 const chunk of runFolder(
248 reader,
249 raw.folder,
250 [packedOffset],
251 [raw.packSize],
252 raw.unpackSize,
253 options,
254 )
255 ) {
256 chunks.push(chunk);
257 total += chunk.length;
258 }
259 if (total !== raw.unpackSize) {
260 throw new MalformedArchiveError(
261 `EncodedHeader output size mismatch: expected ${raw.unpackSize} bytes, got ${total}`,
262 );
263 }
264 return concat(chunks, total);
265 } catch (err) {
266 throw rewrapDecodeError(err, raw.folder, options);
267 }
268};
269
270// #endregion