streaming 7-Zip archive extractor
0

Configure Feed

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

feat: surface InvalidPasswordError on wrong-password failures

wrong AES keys decrypt the pack stream to garbage, which up to now bubbled
up as MalformedArchiveError (LZMA range decoder) or as a header parse
error on encrypted EncodedHeaders. wrap folder decode, encoded-header
decode, and the subsequent parseHeader call: when the affected folder
includes an AES coder and the caller supplied a password, swap the error
for InvalidPasswordError so a retry-with-different-password flow is
catchable.

+147 -55
+47 -1
lib/codec/folder.ts
··· 7 7 * produce exactly one output stream. 8 8 */ 9 9 10 - import { UnsupportedFeatureError } from '../errors.ts'; 10 + import { 11 + InvalidPasswordError, 12 + MissingPasswordError, 13 + SevenZipError, 14 + UnsupportedFeatureError, 15 + } from '../errors.ts'; 11 16 import type { Coder, Folder } from '../header.ts'; 12 17 import type { Reader } from '../reader/types.ts'; 13 18 import { Method } from '../constants.ts'; ··· 46 51 47 52 /** the numeric method id this layer recognises. */ 48 53 export const coderMethod = (coder: Coder): number => methodIdToNumber(coder.methodId); 54 + 55 + /** true when the folder's chain includes an AES coder. */ 56 + export const folderIsEncrypted = (folder: Folder): boolean => 57 + folder.coders.some((c) => coderMethod(c) === Method.aes256Sha256); 58 + 59 + /** 60 + * promotes a decode-time error into {@link InvalidPasswordError} when the 61 + * caller supplied a password and the folder is AES-encrypted. wrong keys 62 + * decrypt the pack stream to garbage, which then fails downstream (usually 63 + * LZMA's range decoder) as `MalformedArchiveError`; without this remapping 64 + * the caller can't distinguish a wrong password from a genuinely corrupt 65 + * encrypted archive. 66 + * 67 + * the original error type is preserved for the un-encrypted case so plain 68 + * corruption still surfaces correctly. 69 + * 70 + * @param err the original decode error 71 + * @param folder the folder whose decode threw 72 + * @param options the dispatch options the decode ran with 73 + * @returns the error to throw — either `err` unchanged or a fresh 74 + * {@link InvalidPasswordError} 75 + */ 76 + export const rewrapDecodeError = ( 77 + err: unknown, 78 + folder: Folder, 79 + options: DispatchOptions, 80 + ): unknown => { 81 + if ( 82 + options.password === undefined || 83 + !(err instanceof SevenZipError) || 84 + err instanceof InvalidPasswordError || 85 + err instanceof MissingPasswordError || 86 + !folderIsEncrypted(folder) 87 + ) { 88 + return err; 89 + } 90 + return new InvalidPasswordError( 91 + `decode failed after AES decryption — password is likely incorrect ` + 92 + `(${err.name}: ${err.message})`, 93 + ); 94 + }; 49 95 50 96 /** runtime options forwarded from the public iterator to the codec layer. */ 51 97 export interface DispatchOptions {
+34 -22
lib/container.ts
··· 14 14 15 15 import { crc32 } from '@mary/crc32'; 16 16 17 - import { type DispatchOptions, runFolder } from './codec/folder.ts'; 17 + import { type DispatchOptions, rewrapDecodeError, runFolder } from './codec/folder.ts'; 18 18 import { SIGNATURE, SIGNATURE_HEADER_SIZE } from './constants.ts'; 19 19 import { 20 20 ChecksumMismatchError, ··· 222 222 } 223 223 const raw = await readNextHeader(reader, start); 224 224 const headerBytes = raw.kind === 'header' ? raw.bytes : await decodeEncodedHeader(reader, raw, options); 225 - const header = parseHeader(headerBytes); 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 + } 226 234 return { header, packBase: SIGNATURE_HEADER_SIZE + header.pack.packPos, start }; 227 235 }; 228 236 ··· 232 240 options: DispatchOptions, 233 241 ): Promise<Uint8Array<ArrayBuffer>> => { 234 242 const packedOffset = SIGNATURE_HEADER_SIZE + raw.packPos; 235 - const chunks: Uint8Array<ArrayBuffer>[] = []; 236 - let total = 0; 237 - for await ( 238 - const chunk of runFolder( 239 - reader, 240 - raw.folder, 241 - [packedOffset], 242 - [raw.packSize], 243 - raw.unpackSize, 244 - options, 245 - ) 246 - ) { 247 - chunks.push(chunk); 248 - total += chunk.length; 249 - } 250 - if (total !== raw.unpackSize) { 251 - throw new MalformedArchiveError( 252 - `EncodedHeader output size mismatch: expected ${raw.unpackSize} bytes, got ${total}`, 253 - ); 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); 254 267 } 255 - return concat(chunks, total); 256 268 }; 257 269 258 270 // #endregion
+36 -32
lib/extract.ts
··· 12 12 13 13 import { crc32 } from '@mary/crc32'; 14 14 15 - import { type DispatchOptions, runFolder } from './codec/folder.ts'; 15 + import { type DispatchOptions, rewrapDecodeError, runFolder } from './codec/folder.ts'; 16 16 import { readContainer } from './container.ts'; 17 17 import { ChecksumMismatchError, MalformedArchiveError } from './errors.ts'; 18 18 import type { ArchiveHeader, FileEntry, Folder } from './header.ts'; ··· 74 74 } 75 75 const unpackSize = folderUnpackSize(folder); 76 76 77 - const chunks: Uint8Array<ArrayBuffer>[] = []; 78 - let total = 0; 79 - for await ( 80 - const chunk of runFolder( 81 - this.#reader, 82 - folder, 83 - packOffsets, 84 - packSizes, 85 - unpackSize, 86 - this.#options, 87 - ) 88 - ) { 89 - chunks.push(chunk); 90 - total += chunk.length; 91 - } 92 - if (total !== unpackSize) { 93 - throw new MalformedArchiveError( 94 - `folder output size mismatch: folder ${folderIdx}, expected ${unpackSize} bytes, got ${total}`, 95 - ); 96 - } 97 - const merged = concat(chunks, total); 98 - 99 - // optional folder-level CRC 100 - if (folder.crc !== undefined) { 101 - const computed = crc32(merged); 102 - if (computed !== folder.crc) { 103 - throw new ChecksumMismatchError( 104 - `folder CRC32 mismatch: folder ${folderIdx}` + 105 - `, stored 0x${folder.crc.toString(16).padStart(8, '0')}` + 106 - `, computed 0x${computed.toString(16).padStart(8, '0')}`, 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}`, 107 96 ); 108 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); 109 114 } 110 - return merged; 111 115 } 112 116 113 117 #firstPackStreamIndex(folderIdx: number): number {
+30
tests/errors.test.ts
··· 2 2 3 3 import { 4 4 ChecksumMismatchError, 5 + InvalidPasswordError, 5 6 InvalidSignatureError, 6 7 MissingPasswordError, 7 8 TruncatedArchiveError, ··· 63 64 Deno.test('unsevenzip: filename_encryption without password throws MissingPasswordError', async () => { 64 65 const reader = fromUint8Array(load(PY7ZR, 'filename_encryption.7z')); 65 66 await assertRejects(() => drain(reader), MissingPasswordError); 67 + }); 68 + 69 + // #endregion 70 + 71 + // #region wrong password 72 + 73 + Deno.test('unsevenzip: wrong password on encrypted entry throws InvalidPasswordError', async () => { 74 + const reader = fromUint8Array(load(LIBARCHIVE, 'test_read_format_7zip_encryption.7z')); 75 + await assertRejects(() => drain(reader, 'wrong'), InvalidPasswordError); 76 + }); 77 + 78 + Deno.test('unsevenzip: wrong password on encrypted header throws InvalidPasswordError', async () => { 79 + const reader = fromUint8Array(load(LIBARCHIVE, 'test_read_format_7zip_encryption_header.7z')); 80 + await assertRejects(() => drain(reader, 'wrong'), InvalidPasswordError); 81 + }); 82 + 83 + Deno.test('unsevenzip: wrong password on partially-encrypted archive throws InvalidPasswordError', async () => { 84 + const reader = fromUint8Array(load(LIBARCHIVE, 'test_read_format_7zip_encryption_partially.7z')); 85 + await assertRejects(() => drain(reader, 'wrong'), InvalidPasswordError); 86 + }); 87 + 88 + Deno.test('unsevenzip: wrong password on bla.encrypted throws InvalidPasswordError', async () => { 89 + const reader = fromUint8Array(load(COMMONS_COMPRESS, 'bla.encrypted.7z')); 90 + await assertRejects(() => drain(reader, 'wrong'), InvalidPasswordError); 91 + }); 92 + 93 + Deno.test('unsevenzip: wrong password on filename_encryption throws InvalidPasswordError', async () => { 94 + const reader = fromUint8Array(load(PY7ZR, 'filename_encryption.7z')); 95 + await assertRejects(() => drain(reader, 'wrong'), InvalidPasswordError); 66 96 }); 67 97 68 98 // #endregion