streaming 7-Zip archive extractor
0

Configure Feed

Select the types of activity you want to include in your feed.

pkg-7zip / lib / container.ts
8.9 kB 270 lines
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