streaming 7-Zip archive extractor
0

Configure Feed

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

pkg-7zip / lib / extract.ts
11 kB 385 lines
1/** 2 * @module 3 * 4 * the public extraction surface: {@link unsevenzip} walks an archive's 5 * file entries in order, yielding a {@link SevenZipEntry} for each one 6 * whose body can be read on demand. 7 * 8 * solid blocks (multiple files in one folder) share a decode cache so 9 * the folder's coder chain runs at most once across its constituent 10 * entries. 11 */ 12 13import { crc32 } from '@mary/crc32'; 14 15import { type DispatchOptions, rewrapDecodeError, runFolder } from './codec/folder.ts'; 16import { readContainer } from './container.ts'; 17import { ChecksumMismatchError, MalformedArchiveError } from './errors.ts'; 18import type { ArchiveHeader, FileEntry, Folder } from './header.ts'; 19import { concatReaders } from './reader/concat.ts'; 20import type { Reader } from './reader/types.ts'; 21import { concat, decodeUtf8From } from './utils/buffer.ts'; 22 23/** options recognised by {@link unsevenzip}. */ 24export interface UnsevenzipOptions { 25 /** password used to decrypt AES-encrypted entries. */ 26 password?: string; 27} 28 29// #region folder cache 30 31// caches the decoded output of each folder so entries that share a folder 32// (i.e. live in the same solid block) pay the decode cost once. 33class FolderCache { 34 readonly #reader: Reader; 35 readonly #header: ArchiveHeader; 36 readonly #packBase: number; 37 readonly #packOffsets: number[]; 38 readonly #pending = new Map<number, Promise<Uint8Array<ArrayBuffer>>>(); 39 readonly #options: DispatchOptions; 40 41 constructor(reader: Reader, header: ArchiveHeader, packBase: number, options: DispatchOptions) { 42 this.#reader = reader; 43 this.#header = header; 44 this.#packBase = packBase; 45 this.#options = options; 46 // running sum of pack sizes to get each pack stream's offset within 47 // the data area; folders consume pack streams in order. 48 this.#packOffsets = []; 49 let acc = 0; 50 for (const size of header.pack.packSizes) { 51 this.#packOffsets.push(acc); 52 acc += size; 53 } 54 } 55 56 get(folderIdx: number): Promise<Uint8Array<ArrayBuffer>> { 57 const cached = this.#pending.get(folderIdx); 58 if (cached !== undefined) { 59 return cached; 60 } 61 const p = this.#decode(folderIdx); 62 this.#pending.set(folderIdx, p); 63 return p; 64 } 65 66 async #decode(folderIdx: number): Promise<Uint8Array<ArrayBuffer>> { 67 const folder = this.#header.folders[folderIdx]; 68 const firstPack = this.#firstPackStreamIndex(folderIdx); 69 const packOffsets: number[] = []; 70 const packSizes: number[] = []; 71 for (let i = 0; i < folder.packedStreams.length; i++) { 72 packOffsets.push(this.#packBase + this.#packOffsets[firstPack + i]); 73 packSizes.push(this.#header.pack.packSizes[firstPack + i]); 74 } 75 const unpackSize = folderUnpackSize(folder); 76 77 try { 78 const chunks: Uint8Array<ArrayBuffer>[] = []; 79 let total = 0; 80 for await ( 81 const chunk of runFolder( 82 this.#reader, 83 folder, 84 packOffsets, 85 packSizes, 86 unpackSize, 87 this.#options, 88 ) 89 ) { 90 chunks.push(chunk); 91 total += chunk.length; 92 } 93 if (total !== unpackSize) { 94 throw new MalformedArchiveError( 95 `folder output size mismatch: folder ${folderIdx}, expected ${unpackSize} bytes, got ${total}`, 96 ); 97 } 98 const merged = concat(chunks, total); 99 100 // optional folder-level CRC 101 if (folder.crc !== undefined) { 102 const computed = crc32(merged); 103 if (computed !== folder.crc) { 104 throw new ChecksumMismatchError( 105 `folder CRC32 mismatch: folder ${folderIdx}` + 106 `, stored 0x${folder.crc.toString(16).padStart(8, '0')}` + 107 `, computed 0x${computed.toString(16).padStart(8, '0')}`, 108 ); 109 } 110 } 111 return merged; 112 } catch (err) { 113 throw rewrapDecodeError(err, folder, this.#options); 114 } 115 } 116 117 #firstPackStreamIndex(folderIdx: number): number { 118 let idx = 0; 119 for (let f = 0; f < folderIdx; f++) { 120 idx += this.#header.folders[f].packedStreams.length; 121 } 122 return idx; 123 } 124} 125 126const folderUnpackSize = (folder: Folder): number => { 127 for (let i = folder.unpackSizes.length - 1; i >= 0; i--) { 128 if (!folder.bindPairs.some((bp) => bp.out === i)) { 129 return folder.unpackSizes[i]; 130 } 131 } 132 return 0; 133}; 134 135// #endregion 136 137// #region entry 138 139/** 140 * a single file entry within a 7z archive. metadata is available immediately; 141 * content is decoded on demand when {@link body}, {@link bytes}, or one of the 142 * convenience readers is called. 143 */ 144export class SevenZipEntry { 145 readonly #file: FileEntry; 146 readonly #folderIdx: number | undefined; 147 readonly #subStreamOffset: number; 148 readonly #cache: FolderCache; 149 150 /** @ignore */ 151 constructor( 152 file: FileEntry, 153 folderIdx: number | undefined, 154 subStreamOffset: number, 155 cache: FolderCache, 156 ) { 157 this.#file = file; 158 this.#folderIdx = folderIdx; 159 this.#subStreamOffset = subStreamOffset; 160 this.#cache = cache; 161 } 162 163 /** the entry's path within the archive, forward-slash separated. */ 164 get filename(): string { 165 return this.#file.name.replaceAll('\\', '/'); 166 } 167 168 /** the uncompressed size in bytes. */ 169 get size(): number { 170 return this.#file.size; 171 } 172 173 /** whether the entry is a directory. */ 174 get isDirectory(): boolean { 175 return this.#file.isDirectory; 176 } 177 178 /** 179 * whether the entry is an anti-item — a deletion marker used by 7z's 180 * differential update format. callers that aren't applying updates can 181 * typically skip these. 182 */ 183 get isAntiItem(): boolean { 184 return this.#file.isAntiItem; 185 } 186 187 /** the stored CRC32 over the plaintext content, when present. */ 188 get crc32(): number | undefined { 189 return this.#file.crc; 190 } 191 192 /** the modification time in unix-epoch ms, or `NaN` when unset. */ 193 get mtime(): number { 194 return ntTimeToMillis(this.#file.mTime); 195 } 196 197 /** the creation time in unix-epoch ms, or `NaN` when unset. */ 198 get ctime(): number { 199 return ntTimeToMillis(this.#file.cTime); 200 } 201 202 /** the access time in unix-epoch ms, or `NaN` when unset. */ 203 get atime(): number { 204 return ntTimeToMillis(this.#file.aTime); 205 } 206 207 /** Windows file attribute bits, when stored. */ 208 get winAttributes(): number | undefined { 209 return this.#file.winAttributes; 210 } 211 212 /** 213 * a readable stream of the entry's plaintext content. each call returns 214 * a fresh stream. 215 */ 216 get body(): ReadableStream<Uint8Array> { 217 const size = this.#file.size; 218 const folderIdx = this.#folderIdx; 219 const offset = this.#subStreamOffset; 220 const cache = this.#cache; 221 const expectedCrc = this.#file.crc; 222 const name = this.filename; 223 224 return new ReadableStream({ 225 async start(controller) { 226 try { 227 if (folderIdx === undefined) { 228 // empty / directory entry — emit nothing. 229 controller.close(); 230 return; 231 } 232 const decoded = await cache.get(folderIdx); 233 const slice = decoded.subarray(offset, offset + size); 234 if (expectedCrc !== undefined) { 235 const computed = crc32(slice); 236 if (computed !== expectedCrc) { 237 controller.error( 238 new ChecksumMismatchError( 239 `entry CRC32 mismatch: ${name}` + 240 `, stored 0x${expectedCrc.toString(16).padStart(8, '0')}` + 241 `, computed 0x${computed.toString(16).padStart(8, '0')}`, 242 ), 243 ); 244 return; 245 } 246 } 247 controller.enqueue(slice); 248 controller.close(); 249 } catch (error) { 250 controller.error(error); 251 } 252 }, 253 }); 254 } 255 256 /** reads the entry as bytes. */ 257 async bytes(): Promise<Uint8Array<ArrayBuffer>> { 258 const reader = this.body.getReader(); 259 const chunks: Uint8Array[] = []; 260 let total = 0; 261 while (true) { 262 const { done, value } = await reader.read(); 263 if (done) { 264 break; 265 } 266 chunks.push(value); 267 total += value.length; 268 } 269 if (chunks.length === 1) { 270 const only = chunks[0]; 271 if ( 272 only instanceof Uint8Array && only.buffer instanceof ArrayBuffer && 273 only.byteLength === only.buffer.byteLength 274 ) { 275 return only as Uint8Array<ArrayBuffer>; 276 } 277 const fresh = new Uint8Array(only.length); 278 fresh.set(only); 279 return fresh; 280 } 281 return concat(chunks, total); 282 } 283 284 /** reads the entry as an ArrayBuffer. */ 285 async arrayBuffer(): Promise<ArrayBuffer> { 286 const bytes = await this.bytes(); 287 if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) { 288 return bytes.buffer; 289 } 290 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); 291 } 292 293 /** reads the entry as a Blob. */ 294 async blob(): Promise<Blob> { 295 return new Blob([await this.bytes()]); 296 } 297 298 /** reads the entry as UTF-8 text. */ 299 async text(): Promise<string> { 300 return decodeUtf8From(await this.bytes()); 301 } 302 303 /** reads the entry as UTF-8 text and parses it as JSON. */ 304 async json(): Promise<unknown> { 305 return JSON.parse(await this.text()); 306 } 307} 308 309// #endregion 310 311// #region unsevenzip 312 313/** 314 * extracts entries from a 7z archive. yielded entries can be read on demand. 315 * 316 * @param source a single reader, or an ordered list of readers for a 317 * multi-volume archive 318 * @param options optional extraction settings, including the decryption 319 * password 320 * @returns async generator yielding the archive's file entries 321 */ 322export async function* unsevenzip( 323 source: Reader | readonly Reader[], 324 options: UnsevenzipOptions = {}, 325): AsyncGenerator<SevenZipEntry> { 326 const dispatchOptions: DispatchOptions = { password: options.password }; 327 const reader: Reader = Array.isArray(source) 328 ? concatReaders(source as readonly Reader[]) 329 : source as Reader; 330 const container = await readContainer(reader, dispatchOptions); 331 const cache = new FolderCache(reader, container.header, container.packBase, dispatchOptions); 332 333 // substream offsets within each folder, in archive order 334 const subStreamOffsets = computeSubStreamOffsets(container.header); 335 336 for (let i = 0; i < container.header.files.length; i++) { 337 const file = container.header.files[i]; 338 const folderIdx = container.header.fileFolderIndex[i]; 339 const subStreamIdx = container.header.fileSubStreamIndex[i]; 340 let offset = 0; 341 if (folderIdx !== undefined && subStreamIdx !== undefined) { 342 offset = subStreamOffsets[folderIdx][subStreamIdx]; 343 } 344 yield new SevenZipEntry(file, folderIdx, offset, cache); 345 } 346} 347 348/** 349 * returns offsets[folderIdx][subStreamIdx] = byte offset within the 350 * folder's decoded output where that substream begins. 351 */ 352const computeSubStreamOffsets = (header: ArchiveHeader): number[][] => { 353 const out: number[][] = []; 354 let flat = 0; 355 for (let f = 0; f < header.folders.length; f++) { 356 const count = header.subStreams.numUnpackStreams[f]; 357 const offsets: number[] = []; 358 let acc = 0; 359 for (let s = 0; s < count; s++) { 360 offsets.push(acc); 361 acc += header.subStreams.unpackSizes[flat + s]; 362 } 363 out.push(offsets); 364 flat += count; 365 } 366 return out; 367}; 368 369// #endregion 370 371// #region time 372 373// NT epoch is 1601-01-01 UTC; unix epoch is 1970-01-01 UTC. delta in ms: 374const NT_TO_UNIX_DELTA_MS = 11_644_473_600_000; 375 376const ntTimeToMillis = (value: bigint | undefined): number => { 377 if (value === undefined) { 378 return Number.NaN; 379 } 380 // NT time counts 100-ns intervals; divide by 10_000 to get ms. 381 const ms = Number(value / 10000n) - NT_TO_UNIX_DELTA_MS; 382 return ms; 383}; 384 385// #endregion