From 6bee71dada7a869c5869ce371866330af896c795 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Wed, 29 Apr 2026 00:57:51 +0200 Subject: [PATCH 01/11] feat(unzip): add custom decoder hooks for zip methods --- src/index.ts | 27 ++++++++++++++++++------ test/3-zip.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index cbb2115..7304fab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2454,6 +2454,11 @@ export interface UnzipOptions { * A filter function to extract only certain files from a ZIP archive */ filter?: UnzipFileFilter; + /** + * Custom decoders keyed by ZIP compression method ID. + * Use this to add support for additional methods (e.g. 12 = BZIP2). + */ + decompress?: Record Uint8Array>; } /** @@ -3386,8 +3391,7 @@ export interface UnzipFileInfo { /** * The compression format for the data stream. This number is determined by * the spec in PKZIP's APPNOTE.txt, section 4.4.5. For example, 0 = no - * compression, 8 = deflate, 14 = LZMA. If the filter function returns true - * but this value is not 8, the unzip function will throw. + * compression, 8 = deflate, 14 = LZMA. */ compression: number; } @@ -3697,6 +3701,7 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, } } const fltr = opts && (opts as AsyncUnzipOptions).filter; + const dcmp = opts && (opts as AsyncUnzipOptions).decompress; for (let i = 0; i < c; ++i) { const [c, sc, su, fn, no, off] = zh(data, o, z), b = slzh(data, off); o = no @@ -3709,12 +3714,13 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, if (!--lft) cbd(null, files); } } - if (!fltr || fltr({ + const file = { name: fn, size: sc, originalSize: su, compression: c - })) { + }; + if (!fltr || fltr(file)) { if (!c) cbl(null, slc(data, b, b + sc)) else if (c == 8) { const infl = data.subarray(b, b + sc); @@ -3727,6 +3733,12 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, } } else term.push(inflate(infl, { size: su }, cbl)); + } else if (dcmp && dcmp[c]) { + try { + cbl(null, dcmp[c](data.subarray(b, b + sc), file)); + } catch(e) { + cbl(e, null); + } } else cbl(err(14, 'unknown compression type ' + c, 1), null); } else cbl(null, null); } @@ -3760,17 +3772,20 @@ export function unzipSync(data: Uint8Array, opts?: UnzipOptions) { } } const fltr = opts && opts.filter; + const dcmp = opts && opts.decompress; for (let i = 0; i < c; ++i) { const [c, sc, su, fn, no, off] = zh(data, o, z), b = slzh(data, off); o = no; - if (!fltr || fltr({ + const file = { name: fn, size: sc, originalSize: su, compression: c - })) { + }; + if (!fltr || fltr(file)) { if (!c) files[fn] = slc(data, b, b + sc); else if (c == 8) files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) }); + else if (dcmp && dcmp[c]) files[fn] = dcmp[c](data.subarray(b, b + sc), file); else err(14, 'unknown compression type ' + c); } } diff --git a/test/3-zip.ts b/test/3-zip.ts index 840a999..31ddf40 100644 --- a/test/3-zip.ts +++ b/test/3-zip.ts @@ -1 +1,56 @@ -// TODO: test ZIP \ No newline at end of file +import { test } from 'uvu'; +import * as assert from 'uvu/assert'; +import { strToU8, strFromU8, zipSync, unzipSync, unzip, inflateSync } from '../src/index'; + +const b2 = (d: Uint8Array, b: number) => d[b] | (d[b + 1] << 8); +const b4 = (d: Uint8Array, b: number) => (d[b] | (d[b + 1] << 8) | (d[b + 2] << 16) | (d[b + 3] << 24)) >>> 0; +const w2 = (d: Uint8Array, b: number, v: number) => { + d[b] = v & 255; + d[b + 1] = (v >>> 8) & 255; +}; + +const rewriteZipMethod = (zip: Uint8Array, from: number, to: number) => { + const out = new Uint8Array(zip); + for (let i = 0; i < out.length - 4; ++i) { + const sig = b4(out, i); + if (sig === 0x04034b50) { + if (b2(out, i + 8) === from) w2(out, i + 8, to); + i += 30 + b2(out, i + 26) + b2(out, i + 28) - 1; + } else if (sig === 0x02014b50) { + if (b2(out, i + 10) === from) w2(out, i + 10, to); + i += 46 + b2(out, i + 28) + b2(out, i + 30) + b2(out, i + 32) - 1; + } + } + return out; +}; + +test('unzipSync custom decoder can handle non-default ZIP method', () => { + const text = 'hello custom method'; + const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); + const method12 = rewriteZipMethod(normal, 8, 12); + const out = unzipSync(method12, { + decompress: { + 12: (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) }) + } + }); + assert.is(strFromU8(out['a.txt']), text); +}); + +test('unzip custom decoder can handle non-default ZIP method', async () => { + const text = 'hello async custom method'; + const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); + const method12 = rewriteZipMethod(normal, 8, 12); + const out = await new Promise>((resolve, reject) => { + unzip(method12, { + decompress: { + 12: (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) }) + } + }, (e, files) => { + if (e) reject(e); + else resolve(files); + }); + }); + assert.is(strFromU8(out['a.txt']), text); +}); + +test.run(); From 31efc379076026914355ce18b839de29df093582 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Wed, 29 Apr 2026 00:59:11 +0200 Subject: [PATCH 02/11] feat(unzip): add global zip decoder registry --- src/index.ts | 32 +++++++++++++++++++++++++++++--- test/3-zip.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7304fab..c6f519c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2461,6 +2461,11 @@ export interface UnzipOptions { decompress?: Record Uint8Array>; } +/** + * A synchronous decoder function for ZIP entries using a specific method ID. + */ +export type UnzipSyncDecoder = (data: Uint8Array, info: UnzipFileInfo) => Uint8Array; + /** * Options for asynchronously creating a ZIP archive */ @@ -2471,6 +2476,27 @@ export interface AsyncZipOptions extends AsyncDeflateOptions, ZipAttributes {} */ export interface AsyncUnzipOptions extends UnzipOptions {} +const ucd: Record = {}; + +/** + * Registers a global synchronous decoder used by both `unzipSync` and `unzip`. + * Useful for adding support for additional ZIP method IDs. + * @param compression The ZIP compression method ID + * @param decoder The decoder implementation + */ +export function registerUnzipDecoder(compression: number, decoder: UnzipSyncDecoder) { + ucd[compression] = decoder; +} + +/** + * Unregisters a global synchronous decoder previously registered via + * `registerUnzipDecoder`. + * @param compression The ZIP compression method ID + */ +export function unregisterUnzipDecoder(compression: number) { + delete ucd[compression]; +} + /** * A file that can be used to create a ZIP archive */ @@ -3733,9 +3759,9 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, } } else term.push(inflate(infl, { size: su }, cbl)); - } else if (dcmp && dcmp[c]) { + } else if ((dcmp && dcmp[c]) || ucd[c]) { try { - cbl(null, dcmp[c](data.subarray(b, b + sc), file)); + cbl(null, (dcmp && dcmp[c] || ucd[c])(data.subarray(b, b + sc), file)); } catch(e) { cbl(e, null); } @@ -3785,7 +3811,7 @@ export function unzipSync(data: Uint8Array, opts?: UnzipOptions) { if (!fltr || fltr(file)) { if (!c) files[fn] = slc(data, b, b + sc); else if (c == 8) files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) }); - else if (dcmp && dcmp[c]) files[fn] = dcmp[c](data.subarray(b, b + sc), file); + else if ((dcmp && dcmp[c]) || ucd[c]) files[fn] = (dcmp && dcmp[c] || ucd[c])(data.subarray(b, b + sc), file); else err(14, 'unknown compression type ' + c); } } diff --git a/test/3-zip.ts b/test/3-zip.ts index 31ddf40..d6ddc43 100644 --- a/test/3-zip.ts +++ b/test/3-zip.ts @@ -1,6 +1,9 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; -import { strToU8, strFromU8, zipSync, unzipSync, unzip, inflateSync } from '../src/index'; +import { + strToU8, strFromU8, zipSync, unzipSync, unzip, inflateSync, + registerUnzipDecoder, unregisterUnzipDecoder +} from '../src/index'; const b2 = (d: Uint8Array, b: number) => d[b] | (d[b + 1] << 8); const b4 = (d: Uint8Array, b: number) => (d[b] | (d[b + 1] << 8) | (d[b + 2] << 16) | (d[b + 3] << 24)) >>> 0; @@ -53,4 +56,29 @@ test('unzip custom decoder can handle non-default ZIP method', async () => { assert.is(strFromU8(out['a.txt']), text); }); +test('global decoder registration works for unzipSync', () => { + const text = 'hello global sync decoder'; + const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); + const method12 = rewriteZipMethod(normal, 8, 12); + registerUnzipDecoder(12, (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) })); + const out = unzipSync(method12); + unregisterUnzipDecoder(12); + assert.is(strFromU8(out['a.txt']), text); +}); + +test('global decoder registration works for unzip', async () => { + const text = 'hello global async decoder'; + const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); + const method12 = rewriteZipMethod(normal, 8, 12); + registerUnzipDecoder(12, (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) })); + const out = await new Promise>((resolve, reject) => { + unzip(method12, (e, files) => { + if (e) reject(e); + else resolve(files); + }); + }); + unregisterUnzipDecoder(12); + assert.is(strFromU8(out['a.txt']), text); +}); + test.run(); From 58841caa35b81a19b98bc9a9f03b03cfaf850e4c Mon Sep 17 00:00:00 2001 From: Streetblock Date: Wed, 29 Apr 2026 01:05:14 +0200 Subject: [PATCH 03/11] feat(unzip): add built-in bzip2 decoder for zip method 12 --- src/bzip2.ts | 798 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 + test/3-zip.ts | 25 ++ 3 files changed, 827 insertions(+) create mode 100644 src/bzip2.ts diff --git a/src/bzip2.ts b/src/bzip2.ts new file mode 100644 index 0000000..c0aeff3 --- /dev/null +++ b/src/bzip2.ts @@ -0,0 +1,798 @@ +/* Vendored/adapted from seek-bzip (MIT). */ +/* very simple input/output stream interface */ +var Stream = function() { +}; + +// input streams ////////////// +/** Returns the next byte, or -1 for EOF. */ +Stream.prototype.readByte = function() { + throw new Error("abstract method readByte() not implemented"); +}; +/** Attempts to fill the buffer; returns number of bytes read, or + * -1 for EOF. */ +Stream.prototype.read = function(buffer, bufOffset, length) { + var bytesRead = 0; + while (bytesRead < length) { + var c = this.readByte(); + if (c < 0) { // EOF + return (bytesRead===0) ? -1 : bytesRead; + } + buffer[bufOffset++] = c; + bytesRead++; + } + return bytesRead; +}; +Stream.prototype.seek = function(new_pos) { + throw new Error("abstract method seek() not implemented"); +}; + +// output streams /////////// +Stream.prototype.writeByte = function(_byte) { + throw new Error("abstract method readByte() not implemented"); +}; +Stream.prototype.write = function(buffer, bufOffset, length) { + var i; + for (i=0; i 0) { + this._ensureByte(); + var remaining = 8 - this.bitOffset; + // if we're in a byte + if (bits >= remaining) { + result <<= remaining; + result |= BITMASK[remaining] & this.curByte; + this.hasByte = false; + this.bitOffset = 0; + bits -= remaining; + } else { + result <<= bits; + var shift = remaining - bits; + result |= (this.curByte & (BITMASK[bits] << shift)) >> shift; + this.bitOffset += bits; + bits = 0; + } + } + return result; +}; + +// seek to an arbitrary point in the buffer (expressed in bits) +BitReader.prototype.seek = function(pos) { + var n_bit = pos % 8; + var n_byte = (pos - n_bit) / 8; + this.bitOffset = n_bit; + this.stream.seek(n_byte); + this.hasByte = false; +}; + +// reads 6 bytes worth of data using the read method +BitReader.prototype.pi = function() { + var buf = new Uint8Array(6), i; + for (i = 0; i < buf.length; i++) { + buf[i] = this.read(8); + } + return Array.prototype.map.call(buf, function(x){ var s = x.toString(16); return s.length < 2 ? '0' + s : s; }).join(''); +}; + + + + +/* CRC32, used in Bzip2 implementation. + * This is a port of CRC32.java from the jbzip2 implementation at + * https://code.google.com/p/jbzip2 + * which is: + * Copyright (c) 2011 Matthew Francis + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * This JavaScript implementation is: + * Copyright (c) 2013 C. Scott Ananian + * with the same licensing terms as Matthew Francis' original implementation. + */ +var CRC32 = (function() { + + /** + * A static CRC lookup table + */ + var crc32Lookup = new Uint32Array([ + 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, + 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, + 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, + 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd, + 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, + 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, + 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95, + 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, + 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, + 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca, + 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, + 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, + 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692, + 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, + 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, + 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a, + 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, + 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, + 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b, + 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, + 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, + 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3, + 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, + 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, + 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c, + 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, + 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, + 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654, + 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, + 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, + 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c, + 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4 + ]); + + var CRC32 = function() { + /** + * The current CRC + */ + var crc = 0xffffffff; + + /** + * @return The current CRC + */ + this.getCRC = function() { + return (~crc) >>> 0; // return an unsigned value + }; + + /** + * Update the CRC with a single byte + * @param value The value to update the CRC with + */ + this.updateCRC = function(value) { + crc = (crc << 8) ^ crc32Lookup[((crc >>> 24) ^ value) & 0xff]; + }; + + /** + * Update the CRC with a sequence of identical bytes + * @param value The value to update the CRC with + * @param count The number of bytes + */ + this.updateCRCRun = function(value, count) { + while (count-- > 0) { + crc = (crc << 8) ^ crc32Lookup[((crc >>> 24) ^ value) & 0xff]; + } + }; + }; + return CRC32; +})(); + +/* +seek-bzip - a pure-javascript module for seeking within bzip2 data + +Copyright (C) 2013 C. Scott Ananian +Copyright (C) 2012 Eli Skeggs +Copyright (C) 2011 Kevin Kwok + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Adapted from node-bzip, copyright 2012 Eli Skeggs. +Adapted from bzip2.js, copyright 2011 Kevin Kwok (antimatter15@gmail.com). + +Based on micro-bunzip by Rob Landley (rob@landley.net). + +Based on bzip2 decompression code by Julian R Seward (jseward@acm.org), +which also acknowledges contributions by Mike Burrows, David Wheeler, +Peter Fenwick, Alistair Moffat, Radford Neal, Ian H. Witten, +Robert Sedgewick, and Jon L. Bentley. +*/ + + +var MAX_HUFCODE_BITS = 20; +var MAX_SYMBOLS = 258; +var SYMBOL_RUNA = 0; +var SYMBOL_RUNB = 1; +var MIN_GROUPS = 2; +var MAX_GROUPS = 6; +var GROUP_SIZE = 50; + +var WHOLEPI = "314159265359"; +var SQRTPI = "177245385090"; + +var mtf = function(array, index) { + var src = array[index], i; + for (i = index; i > 0; i--) { + array[i] = array[i-1]; + } + array[0] = src; + return src; +}; + +var Err = { + OK: 0, + LAST_BLOCK: -1, + NOT_BZIP_DATA: -2, + UNEXPECTED_INPUT_EOF: -3, + UNEXPECTED_OUTPUT_EOF: -4, + DATA_ERROR: -5, + OUT_OF_MEMORY: -6, + OBSOLETE_INPUT: -7, + END_OF_BLOCK: -8 +}; +var ErrorMessages = {}; +ErrorMessages[Err.LAST_BLOCK] = "Bad file checksum"; +ErrorMessages[Err.NOT_BZIP_DATA] = "Not bzip data"; +ErrorMessages[Err.UNEXPECTED_INPUT_EOF] = "Unexpected input EOF"; +ErrorMessages[Err.UNEXPECTED_OUTPUT_EOF] = "Unexpected output EOF"; +ErrorMessages[Err.DATA_ERROR] = "Data error"; +ErrorMessages[Err.OUT_OF_MEMORY] = "Out of memory"; +ErrorMessages[Err.OBSOLETE_INPUT] = "Obsolete (pre 0.9.5) bzip format not supported."; + +var _throw = function(status, optDetail) { + var msg = ErrorMessages[status] || 'unknown error'; + if (optDetail) { msg += ': '+optDetail; } + var e = new TypeError(msg); + e.errorCode = status; + throw e; +}; + +var Bunzip = function(inputStream, outputStream) { + this.writePos = this.writeCurrent = this.writeCount = 0; + + this._start_bunzip(inputStream, outputStream); +}; +Bunzip.prototype._init_block = function() { + var moreBlocks = this._get_next_block(); + if ( !moreBlocks ) { + this.writeCount = -1; + return false; /* no more blocks */ + } + this.blockCRC = new CRC32(); + return true; +}; +/* XXX micro-bunzip uses (inputStream, inputBuffer, len) as arguments */ +Bunzip.prototype._start_bunzip = function(inputStream, outputStream) { + /* Ensure that file starts with "BZh['1'-'9']." */ + var buf = new Uint8Array(4); + if (inputStream.read(buf, 0, 4) !== 4 || + String.fromCharCode(buf[0], buf[1], buf[2]) !== 'BZh') + _throw(Err.NOT_BZIP_DATA, 'bad magic'); + + var level = buf[3] - 0x30; + if (level < 1 || level > 9) + _throw(Err.NOT_BZIP_DATA, 'level out of range'); + + this.reader = new BitReader(inputStream); + + /* Fourth byte (ascii '1'-'9'), indicates block size in units of 100k of + uncompressed data. Allocate intermediate buffer for block. */ + this.dbufSize = 100000 * level; + this.nextoutput = 0; + this.outputStream = outputStream; + this.streamCRC = 0; +}; +Bunzip.prototype._get_next_block = function() { + var i, j, k; + var reader = this.reader; + // this is get_next_block() function from micro-bunzip: + /* Read in header signature and CRC, then validate signature. + (last block signature means CRC is for whole file, return now) */ + var h = reader.pi(); + if (h === SQRTPI) { // last block + return false; /* no more blocks */ + } + if (h !== WHOLEPI) + _throw(Err.NOT_BZIP_DATA); + this.targetBlockCRC = reader.read(32) >>> 0; // (convert to unsigned) + this.streamCRC = (this.targetBlockCRC ^ + ((this.streamCRC << 1) | (this.streamCRC>>>31))) >>> 0; + /* We can add support for blockRandomised if anybody complains. There was + some code for this in busybox 1.0.0-pre3, but nobody ever noticed that + it didn't actually work. */ + if (reader.read(1)) + _throw(Err.OBSOLETE_INPUT); + var origPointer = reader.read(24); + if (origPointer > this.dbufSize) + _throw(Err.DATA_ERROR, 'initial position out of bounds'); + /* mapping table: if some byte values are never used (encoding things + like ascii text), the compression code removes the gaps to have fewer + symbols to deal with, and writes a sparse bitfield indicating which + values were present. We make a translation table to convert the symbols + back to the corresponding bytes. */ + var t = reader.read(16); + var symToByte = new Uint8Array(256), symTotal = 0; + for (i = 0; i < 16; i++) { + if (t & (1 << (0xF - i))) { + var o = i * 16; + k = reader.read(16); + for (j = 0; j < 16; j++) + if (k & (1 << (0xF - j))) + symToByte[symTotal++] = o + j; + } + } + + /* How many different huffman coding groups does this block use? */ + var groupCount = reader.read(3); + if (groupCount < MIN_GROUPS || groupCount > MAX_GROUPS) + _throw(Err.DATA_ERROR); + /* nSelectors: Every GROUP_SIZE many symbols we select a new huffman coding + group. Read in the group selector list, which is stored as MTF encoded + bit runs. (MTF=Move To Front, as each value is used it's moved to the + start of the list.) */ + var nSelectors = reader.read(15); + if (nSelectors === 0) + _throw(Err.DATA_ERROR); + + var mtfSymbol = new Uint8Array(256); + for (i = 0; i < groupCount; i++) + mtfSymbol[i] = i; + + var selectors = new Uint8Array(nSelectors); // was 32768... + + for (i = 0; i < nSelectors; i++) { + /* Get next value */ + for (j = 0; reader.read(1); j++) + if (j >= groupCount) _throw(Err.DATA_ERROR); + /* Decode MTF to get the next selector */ + selectors[i] = mtf(mtfSymbol, j); + } + + /* Read the huffman coding tables for each group, which code for symTotal + literal symbols, plus two run symbols (RUNA, RUNB) */ + var symCount = symTotal + 2; + var groups = [], hufGroup; + for (j = 0; j < groupCount; j++) { + var length = new Uint8Array(symCount), temp = new Uint16Array(MAX_HUFCODE_BITS + 1); + /* Read huffman code lengths for each symbol. They're stored in + a way similar to mtf; record a starting value for the first symbol, + and an offset from the previous value for everys symbol after that. */ + t = reader.read(5); // lengths + for (i = 0; i < symCount; i++) { + for (;;) { + if (t < 1 || t > MAX_HUFCODE_BITS) _throw(Err.DATA_ERROR); + /* If first bit is 0, stop. Else second bit indicates whether + to increment or decrement the value. */ + if(!reader.read(1)) + break; + if(!reader.read(1)) + t++; + else + t--; + } + length[i] = t; + } + + /* Find largest and smallest lengths in this group */ + var minLen, maxLen; + minLen = maxLen = length[0]; + for (i = 1; i < symCount; i++) { + if (length[i] > maxLen) + maxLen = length[i]; + else if (length[i] < minLen) + minLen = length[i]; + } + + /* Calculate permute[], base[], and limit[] tables from length[]. + * + * permute[] is the lookup table for converting huffman coded symbols + * into decoded symbols. base[] is the amount to subtract from the + * value of a huffman symbol of a given length when using permute[]. + * + * limit[] indicates the largest numerical value a symbol with a given + * number of bits can have. This is how the huffman codes can vary in + * length: each code with a value>limit[length] needs another bit. + */ + hufGroup = {}; + groups.push(hufGroup); + hufGroup.permute = new Uint16Array(MAX_SYMBOLS); + hufGroup.limit = new Uint32Array(MAX_HUFCODE_BITS + 2); + hufGroup.base = new Uint32Array(MAX_HUFCODE_BITS + 1); + hufGroup.minLen = minLen; + hufGroup.maxLen = maxLen; + /* Calculate permute[]. Concurently, initialize temp[] and limit[]. */ + var pp = 0; + for (i = minLen; i <= maxLen; i++) { + temp[i] = hufGroup.limit[i] = 0; + for (t = 0; t < symCount; t++) + if (length[t] === i) + hufGroup.permute[pp++] = t; + } + /* Count symbols coded for at each bit length */ + for (i = 0; i < symCount; i++) + temp[length[i]]++; + /* Calculate limit[] (the largest symbol-coding value at each bit + * length, which is (previous limit<<1)+symbols at this level), and + * base[] (number of symbols to ignore at each bit length, which is + * limit minus the cumulative count of symbols coded for already). */ + pp = t = 0; + for (i = minLen; i < maxLen; i++) { + pp += temp[i]; + /* We read the largest possible symbol size and then unget bits + after determining how many we need, and those extra bits could + be set to anything. (They're noise from future symbols.) At + each level we're really only interested in the first few bits, + so here we set all the trailing to-be-ignored bits to 1 so they + don't affect the value>limit[length] comparison. */ + hufGroup.limit[i] = pp - 1; + pp <<= 1; + t += temp[i]; + hufGroup.base[i + 1] = pp - t; + } + hufGroup.limit[maxLen + 1] = Number.MAX_VALUE; /* Sentinal value for reading next sym. */ + hufGroup.limit[maxLen] = pp + temp[maxLen] - 1; + hufGroup.base[minLen] = 0; + } + /* We've finished reading and digesting the block header. Now read this + block's huffman coded symbols from the file and undo the huffman coding + and run length encoding, saving the result into dbuf[dbufCount++]=uc */ + + /* Initialize symbol occurrence counters and symbol Move To Front table */ + var byteCount = new Uint32Array(256); + for (i = 0; i < 256; i++) + mtfSymbol[i] = i; + /* Loop through compressed symbols. */ + var runPos = 0, dbufCount = 0, selector = 0, uc; + var dbuf = this.dbuf = new Uint32Array(this.dbufSize); + symCount = 0; + for (;;) { + /* Determine which huffman coding group to use. */ + if (!(symCount--)) { + symCount = GROUP_SIZE - 1; + if (selector >= nSelectors) { _throw(Err.DATA_ERROR); } + hufGroup = groups[selectors[selector++]]; + } + /* Read next huffman-coded symbol. */ + i = hufGroup.minLen; + j = reader.read(i); + for (;;i++) { + if (i > hufGroup.maxLen) { _throw(Err.DATA_ERROR); } + if (j <= hufGroup.limit[i]) + break; + j = (j << 1) | reader.read(1); + } + /* Huffman decode value to get nextSym (with bounds checking) */ + j -= hufGroup.base[i]; + if (j < 0 || j >= MAX_SYMBOLS) { _throw(Err.DATA_ERROR); } + var nextSym = hufGroup.permute[j]; + /* We have now decoded the symbol, which indicates either a new literal + byte, or a repeated run of the most recent literal byte. First, + check if nextSym indicates a repeated run, and if so loop collecting + how many times to repeat the last literal. */ + if (nextSym === SYMBOL_RUNA || nextSym === SYMBOL_RUNB) { + /* If this is the start of a new run, zero out counter */ + if (!runPos){ + runPos = 1; + t = 0; + } + /* Neat trick that saves 1 symbol: instead of or-ing 0 or 1 at + each bit position, add 1 or 2 instead. For example, + 1011 is 1<<0 + 1<<1 + 2<<2. 1010 is 2<<0 + 2<<1 + 1<<2. + You can make any bit pattern that way using 1 less symbol than + the basic or 0/1 method (except all bits 0, which would use no + symbols, but a run of length 0 doesn't mean anything in this + context). Thus space is saved. */ + if (nextSym === SYMBOL_RUNA) + t += runPos; + else + t += 2 * runPos; + runPos <<= 1; + continue; + } + /* When we hit the first non-run symbol after a run, we now know + how many times to repeat the last literal, so append that many + copies to our buffer of decoded symbols (dbuf) now. (The last + literal used is the one at the head of the mtfSymbol array.) */ + if (runPos){ + runPos = 0; + if (dbufCount + t > this.dbufSize) { _throw(Err.DATA_ERROR); } + uc = symToByte[mtfSymbol[0]]; + byteCount[uc] += t; + while (t--) + dbuf[dbufCount++] = uc; + } + /* Is this the terminating symbol? */ + if (nextSym > symTotal) + break; + /* At this point, nextSym indicates a new literal character. Subtract + one to get the position in the MTF array at which this literal is + currently to be found. (Note that the result can't be -1 or 0, + because 0 and 1 are RUNA and RUNB. But another instance of the + first symbol in the mtf array, position 0, would have been handled + as part of a run above. Therefore 1 unused mtf position minus + 2 non-literal nextSym values equals -1.) */ + if (dbufCount >= this.dbufSize) { _throw(Err.DATA_ERROR); } + i = nextSym - 1; + uc = mtf(mtfSymbol, i); + uc = symToByte[uc]; + /* We have our literal byte. Save it into dbuf. */ + byteCount[uc]++; + dbuf[dbufCount++] = uc; + } + /* At this point, we've read all the huffman-coded symbols (and repeated + runs) for this block from the input stream, and decoded them into the + intermediate buffer. There are dbufCount many decoded bytes in dbuf[]. + Now undo the Burrows-Wheeler transform on dbuf. + See http://dogma.net/markn/articles/bwt/bwt.htm + */ + if (origPointer < 0 || origPointer >= dbufCount) { _throw(Err.DATA_ERROR); } + /* Turn byteCount into cumulative occurrence counts of 0 to n-1. */ + j = 0; + for (i = 0; i < 256; i++) { + k = j + byteCount[i]; + byteCount[i] = j; + j = k; + } + /* Figure out what order dbuf would be in if we sorted it. */ + for (i = 0; i < dbufCount; i++) { + uc = dbuf[i] & 0xff; + dbuf[byteCount[uc]] |= (i << 8); + byteCount[uc]++; + } + /* Decode first byte by hand to initialize "previous" byte. Note that it + doesn't get output, and if the first three characters are identical + it doesn't qualify as a run (hence writeRunCountdown=5). */ + var pos = 0, current = 0, run = 0; + if (dbufCount) { + pos = dbuf[origPointer]; + current = (pos & 0xff); + pos >>= 8; + run = -1; + } + this.writePos = pos; + this.writeCurrent = current; + this.writeCount = dbufCount; + this.writeRun = run; + + return true; /* more blocks to come */ +}; +/* Undo burrows-wheeler transform on intermediate buffer to produce output. + If start_bunzip was initialized with out_fd=-1, then up to len bytes of + data are written to outbuf. Return value is number of bytes written or + error (all errors are negative numbers). If out_fd!=-1, outbuf and len + are ignored, data is written to out_fd and return is RETVAL_OK or error. +*/ +Bunzip.prototype._read_bunzip = function(outputBuffer, len) { + var copies, previous, outbyte; + /* james@jamestaylor.org: writeCount goes to -1 when the buffer is fully + decoded, which results in this returning RETVAL_LAST_BLOCK, also + equal to -1... Confusing, I'm returning 0 here to indicate no + bytes written into the buffer */ + if (this.writeCount < 0) { return 0; } + + var gotcount = 0; + var dbuf = this.dbuf, pos = this.writePos, current = this.writeCurrent; + var dbufCount = this.writeCount, outputsize = this.outputsize; + var run = this.writeRun; + + while (dbufCount) { + dbufCount--; + previous = current; + pos = dbuf[pos]; + current = pos & 0xff; + pos >>= 8; + if (run++ === 3){ + copies = current; + outbyte = previous; + current = -1; + } else { + copies = 1; + outbyte = current; + } + this.blockCRC.updateCRCRun(outbyte, copies); + while (copies--) { + this.outputStream.writeByte(outbyte); + this.nextoutput++; + } + if (current != previous) + run = 0; + } + this.writeCount = dbufCount; + // check CRC + if (this.blockCRC.getCRC() !== this.targetBlockCRC) { + _throw(Err.DATA_ERROR, "Bad block CRC "+ + "(got "+this.blockCRC.getCRC().toString(16)+ + " expected "+this.targetBlockCRC.toString(16)+")"); + } + return this.nextoutput; +}; + +var coerceInputStream = function(input) { + if ('readByte' in input) { return input; } + var inputStream = new Stream(); + inputStream.pos = 0; + inputStream.readByte = function() { return input[this.pos++]; }; + inputStream.seek = function(pos) { this.pos = pos; }; + inputStream.eof = function() { return this.pos >= input.length; }; + return inputStream; +}; +var coerceOutputStream = function(output) { + var outputStream = new Stream(); + var resizeOk = true; + if (output) { + if (typeof(output)==='number') { + outputStream.buffer = new Uint8Array(output); + resizeOk = false; + } else if ('writeByte' in output) { + return output; + } else { + outputStream.buffer = output; + resizeOk = false; + } + } else { + outputStream.buffer = new Uint8Array(16384); + } + outputStream.pos = 0; + outputStream.writeByte = function(_byte) { + if (resizeOk && this.pos >= this.buffer.length) { + var newBuffer = new Uint8Array(this.buffer.length*2); + newBuffer.set(this.buffer); + this.buffer = newBuffer; + } + this.buffer[this.pos++] = _byte; + }; + outputStream.getBuffer = function() { + // trim buffer + if (this.pos !== this.buffer.length) { + if (!resizeOk) + throw new TypeError('outputsize does not match decoded input'); + var newBuffer = new Uint8Array(this.pos); + newBuffer.set(this.buffer.subarray(0, this.pos), 0); + this.buffer = newBuffer; + } + return this.buffer; + }; + outputStream._coerced = true; + return outputStream; +}; + +/* Static helper functions */ +Bunzip.Err = Err; +// 'input' can be a stream or a buffer +// 'output' can be a stream or a buffer or a number (buffer size) +Bunzip.decode = function(input, output, multistream) { + // make a stream from a buffer, if necessary + var inputStream = coerceInputStream(input); + var outputStream = coerceOutputStream(output); + + var bz = new Bunzip(inputStream, outputStream); + while (true) { + if ('eof' in inputStream && inputStream.eof()) break; + if (bz._init_block()) { + bz._read_bunzip(); + } else { + var targetStreamCRC = bz.reader.read(32) >>> 0; // (convert to unsigned) + if (targetStreamCRC !== bz.streamCRC) { + _throw(Err.DATA_ERROR, "Bad stream CRC "+ + "(got "+bz.streamCRC.toString(16)+ + " expected "+targetStreamCRC.toString(16)+")"); + } + if (multistream && + 'eof' in inputStream && + !inputStream.eof()) { + // note that start_bunzip will also resync the bit reader to next byte + bz._start_bunzip(inputStream, outputStream); + } else break; + } + } + if ('getBuffer' in outputStream) + return outputStream.getBuffer(); +}; +Bunzip.decodeBlock = function(input, pos, output) { + // make a stream from a buffer, if necessary + var inputStream = coerceInputStream(input); + var outputStream = coerceOutputStream(output); + var bz = new Bunzip(inputStream, outputStream); + bz.reader.seek(pos); + /* Fill the decode buffer for the block */ + var moreBlocks = bz._get_next_block(); + if (moreBlocks) { + /* Init the CRC for writing */ + bz.blockCRC = new CRC32(); + + /* Zero this so the current byte from before the seek is not written */ + bz.writeCopies = 0; + + /* Decompress the block and write to stdout */ + bz._read_bunzip(); + // XXX keep writing? + } + if ('getBuffer' in outputStream) + return outputStream.getBuffer(); +}; +export function bzip2Decode(input: Uint8Array, outputSize?: number): Uint8Array { + return Bunzip.decode(input, outputSize); +} diff --git a/src/index.ts b/src/index.ts index c6f519c..729a127 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ // is better for memory in most engines (I *think*). import wk from './node-worker'; +import { bzip2Decode } from './bzip2'; // aliases for shorter compressed code (most minifers don't do this) const u8 = Uint8Array, u16 = Uint16Array, i32 = Int32Array; @@ -2497,6 +2498,9 @@ export function unregisterUnzipDecoder(compression: number) { delete ucd[compression]; } +// Built-in ZIP method 12 (BZIP2) decoder +registerUnzipDecoder(12, (data) => bzip2Decode(data)); + /** * A file that can be used to create a ZIP archive */ diff --git a/test/3-zip.ts b/test/3-zip.ts index d6ddc43..3bbdef8 100644 --- a/test/3-zip.ts +++ b/test/3-zip.ts @@ -27,6 +27,14 @@ const rewriteZipMethod = (zip: Uint8Array, from: number, to: number) => { return out; }; +const b64ToU8 = (b64: string) => Uint8Array.from(Buffer.from(b64, 'base64')); +const BZ2_SAMPLE = b64ToU8('QlpoOTFBWSZTWeopNX0AAAJTgAAQQAAEACJgDAAgADEGTEEBkeoEPEnfEAvF3JFOFCQ6ik1fQA=='); + +const zipStoredAsMethod = (name: string, payload: Uint8Array, method: number) => { + const zipped = zipSync({ [name]: [payload, { level: 0 }] }); + return rewriteZipMethod(zipped, 0, method); +}; + test('unzipSync custom decoder can handle non-default ZIP method', () => { const text = 'hello custom method'; const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); @@ -81,4 +89,21 @@ test('global decoder registration works for unzip', async () => { assert.is(strFromU8(out['a.txt']), text); }); +test('built-in BZIP2 decoder works for unzipSync (method 12)', () => { + const z = zipStoredAsMethod('a.txt', BZ2_SAMPLE, 12); + const out = unzipSync(z); + assert.is(strFromU8(out['a.txt']), 'This is a test\n'); +}); + +test('built-in BZIP2 decoder works for unzip (method 12)', async () => { + const z = zipStoredAsMethod('a.txt', BZ2_SAMPLE, 12); + const out = await new Promise>((resolve, reject) => { + unzip(z, (e, files) => { + if (e) reject(e); + else resolve(files); + }); + }); + assert.is(strFromU8(out['a.txt']), 'This is a test\n'); +}); + test.run(); From bb82f0818d7052b89189cfa4402cca6219e986a1 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Wed, 29 Apr 2026 01:06:59 +0200 Subject: [PATCH 04/11] perf(unzip): defer large bzip2 entries in async unzip --- src/index.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/index.ts b/src/index.ts index 729a127..2519f8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3763,6 +3763,32 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, } } else term.push(inflate(infl, { size: su }, cbl)); + } else if (c == 12 && !(dcmp && dcmp[c])) { + const bzd = ucd[12]; + // Keep tiny BZIP2 entries sync; defer larger ones to avoid blocking the current tick. + if (!bzd) cbl(err(14, 'unknown compression type ' + c, 1), null); + else if (su < 262144) { + try { + cbl(null, bzd(data.subarray(b, b + sc), file)); + } catch (e) { + cbl(e, null); + } + } else { + let done = false; + const t = setTimeout(() => { + if (done) return; + try { + done = true; + cbl(null, bzd(data.subarray(b, b + sc), file)); + } catch (e) { + cbl(e, null); + } + }, 0); + term.push(() => { + done = true; + clearTimeout(t); + }); + } } else if ((dcmp && dcmp[c]) || ucd[c]) { try { cbl(null, (dcmp && dcmp[c] || ucd[c])(data.subarray(b, b + sc), file)); From 6cc07ab58378601b405fac723d24b876bdaef8cc Mon Sep 17 00:00:00 2001 From: Streetblock Date: Wed, 29 Apr 2026 01:08:12 +0200 Subject: [PATCH 05/11] perf(unzip): offload large bzip2 entries to worker --- src/index.ts | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2519f8b..914c0cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1103,6 +1103,8 @@ const guze = () => [gzs, gzl]; const zle = () => [zlh, wbytes, adler]; // unzlib extra const zule = () => [zls]; +// bzip2 extra +const bze = () => [bzip2Decode]; // post buf const pbf = (msg: Uint8Array) => (postMessage as Worker['postMessage'])(msg, [msg.buffer]); @@ -3764,30 +3766,18 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, } else term.push(inflate(infl, { size: su }, cbl)); } else if (c == 12 && !(dcmp && dcmp[c])) { - const bzd = ucd[12]; - // Keep tiny BZIP2 entries sync; defer larger ones to avoid blocking the current tick. - if (!bzd) cbl(err(14, 'unknown compression type ' + c, 1), null); - else if (su < 262144) { + const infl = data.subarray(b, b + sc); + // Keep tiny BZIP2 entries sync; offload larger ones to a worker. + if (su < 262144) { try { - cbl(null, bzd(data.subarray(b, b + sc), file)); + cbl(null, bzip2Decode(infl)); } catch (e) { cbl(e, null); } } else { - let done = false; - const t = setTimeout(() => { - if (done) return; - try { - done = true; - cbl(null, bzd(data.subarray(b, b + sc), file)); - } catch (e) { - cbl(e, null); - } - }, 0); - term.push(() => { - done = true; - clearTimeout(t); - }); + term.push(cbify(infl, {}, [ + bze + ], ev => pbf(bzip2Decode(ev.data[0])), 6, cbl)); } } else if ((dcmp && dcmp[c]) || ucd[c]) { try { From 7f3d1c18c7905c0185fcc29c51905029e5585cd3 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Wed, 29 Apr 2026 09:43:32 +0200 Subject: [PATCH 06/11] test(unzip): cover async bzip2 worker threshold path --- test/3-zip.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/3-zip.ts b/test/3-zip.ts index 3bbdef8..1454268 100644 --- a/test/3-zip.ts +++ b/test/3-zip.ts @@ -27,6 +27,28 @@ const rewriteZipMethod = (zip: Uint8Array, from: number, to: number) => { return out; }; +const w4 = (d: Uint8Array, b: number, v: number) => { + d[b] = v & 255; + d[b + 1] = (v >>> 8) & 255; + d[b + 2] = (v >>> 16) & 255; + d[b + 3] = (v >>> 24) & 255; +}; + +const rewriteZipOriginalSize = (zip: Uint8Array, size: number) => { + const out = new Uint8Array(zip); + for (let i = 0; i < out.length - 4; ++i) { + const sig = b4(out, i); + if (sig === 0x04034b50) { + w4(out, i + 22, size); + i += 30 + b2(out, i + 26) + b2(out, i + 28) - 1; + } else if (sig === 0x02014b50) { + w4(out, i + 24, size); + i += 46 + b2(out, i + 28) + b2(out, i + 30) + b2(out, i + 32) - 1; + } + } + return out; +}; + const b64ToU8 = (b64: string) => Uint8Array.from(Buffer.from(b64, 'base64')); const BZ2_SAMPLE = b64ToU8('QlpoOTFBWSZTWeopNX0AAAJTgAAQQAAEACJgDAAgADEGTEEBkeoEPEnfEAvF3JFOFCQ6ik1fQA=='); @@ -106,4 +128,15 @@ test('built-in BZIP2 decoder works for unzip (method 12)', async () => { assert.is(strFromU8(out['a.txt']), 'This is a test\n'); }); +test('built-in BZIP2 decoder uses async path for large original size metadata', async () => { + const z = rewriteZipOriginalSize(zipStoredAsMethod('a.txt', BZ2_SAMPLE, 12), 300000); + const out = await new Promise>((resolve, reject) => { + unzip(z, (e, files) => { + if (e) reject(e); + else resolve(files); + }); + }); + assert.is(strFromU8(out['a.txt']), 'This is a test\n'); +}); + test.run(); From 3ca51bbdae3c26062de9eef7d5b64af7895ad467 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 8 Jun 2026 17:12:34 +0200 Subject: [PATCH 07/11] feat(bzip2): add raw bz2 decompression api --- src/bzip2.ts | 1 + src/index.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ test/7-bzip2.ts | 25 +++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 test/7-bzip2.ts diff --git a/src/bzip2.ts b/src/bzip2.ts index c0aeff3..498d188 100644 --- a/src/bzip2.ts +++ b/src/bzip2.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /* Vendored/adapted from seek-bzip (MIT). */ /* very simple input/output stream interface */ var Stream = function() { diff --git a/src/index.ts b/src/index.ts index 914c0cf..2cef2d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -992,6 +992,21 @@ export interface AsyncZlibOptions extends ZlibOptions, AsyncOptions {} */ export interface AsyncUnzlibOptions extends AsyncInflateOptions {} +/** + * Options for decompressing raw bzip2 data. + */ +export interface Bunzip2Options { + /** + * The expected decompressed size, if known. + */ + size?: number; +} + +/** + * Options for asynchronously decompressing raw bzip2 data. + */ +export interface AsyncBunzip2Options extends Bunzip2Options, AsyncOptions {} + /** * A terminable compression/decompression process */ @@ -2387,6 +2402,44 @@ export function decompressSync(data: Uint8Array, opts?: InflateOptions) { : unzlibSync(data, opts); } +/** + * Asynchronously decompresses raw bzip2 data. + * @param data The data to decompress + * @param opts The decompression options + * @param cb The function to be called upon decompression completion + * @returns A function that can be used to immediately terminate the decompression + */ +export function bunzip2(data: Uint8Array, opts: AsyncBunzip2Options, cb: FlateCallback): AsyncTerminable; +/** + * Asynchronously decompresses raw bzip2 data. + * @param data The data to decompress + * @param cb The function to be called upon decompression completion + * @returns A function that can be used to immediately terminate the decompression + */ +export function bunzip2(data: Uint8Array, cb: FlateCallback): AsyncTerminable; +export function bunzip2(data: Uint8Array, opts: AsyncBunzip2Options | FlateCallback, cb?: FlateCallback) { + if (!cb) cb = opts as FlateCallback, opts = {}; + if (typeof cb != 'function') err(7); + const t = setTimeout(() => { + try { + cb(null, bzip2Decode(data, (opts as AsyncBunzip2Options).size)); + } catch (e) { + cb(e as FlateError, null); + } + }, 0); + return () => clearTimeout(t); +} + +/** + * Decompresses raw bzip2 data. + * @param data The data to decompress + * @param opts The decompression options + * @returns The decompressed version of the data + */ +export function bunzip2Sync(data: Uint8Array, opts?: Bunzip2Options) { + return bzip2Decode(data, opts && opts.size); +} + /** * Attributes for files added to a ZIP archive object */ diff --git a/test/7-bzip2.ts b/test/7-bzip2.ts new file mode 100644 index 0000000..e689ba7 --- /dev/null +++ b/test/7-bzip2.ts @@ -0,0 +1,25 @@ +import { test } from 'uvu'; +import * as assert from 'uvu/assert'; +import { bunzip2, bunzip2Sync, strFromU8 } from '../src/index'; + +const BZ2_SAMPLE = Uint8Array.from(Buffer.from( + 'QlpoOTFBWSZTWeopNX0AAAJTgAAQQAAEACJgDAAgADEGTEEBkeoEPEnfEAvF3JFOFCQ6ik1fQA==', + 'base64' +)); + +test('bunzip2Sync decodes raw bz2 data', () => { + const out = bunzip2Sync(BZ2_SAMPLE); + assert.is(strFromU8(out), 'This is a test\n'); +}); + +test('bunzip2 decodes raw bz2 data', async () => { + const out = await new Promise((resolve, reject) => { + bunzip2(BZ2_SAMPLE, { size: 15 }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + assert.is(strFromU8(out), 'This is a test\n'); +}); + +test.run(); From 9a2a8bca176417f1e1ab838b43355c8ec3681512 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 8 Jun 2026 17:33:33 +0200 Subject: [PATCH 08/11] docs(bzip2): mention raw bz2 decompression in readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index c4be595..075442c 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,10 @@ const unzipper = new fflate.Unzip(); // If your ZIP files are not compressed, this line is not needed. unzipper.register(fflate.UnzipInflate); +// Raw .bz2 files can be decompressed directly too: +const bz2Data = await fetch('/file.bz2').then(res => res.arrayBuffer()); +const text = fflate.strFromU8(fflate.bunzip2Sync(new Uint8Array(bz2Data))); + const neededFiles = ['file1.txt', 'example.json']; // Can specify handler in constructor too From 70dc6712111c2a59be5ab54871c078a66c36bf7b Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 8 Jun 2026 17:37:42 +0200 Subject: [PATCH 09/11] docs(bzip2): add raw bz2 section to readme --- README.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 075442c..2746cdc 100644 --- a/README.md +++ b/README.md @@ -343,10 +343,6 @@ const unzipper = new fflate.Unzip(); // If your ZIP files are not compressed, this line is not needed. unzipper.register(fflate.UnzipInflate); -// Raw .bz2 files can be decompressed directly too: -const bz2Data = await fetch('/file.bz2').then(res => res.arrayBuffer()); -const text = fflate.strFromU8(fflate.bunzip2Sync(new Uint8Array(bz2Data))); - const neededFiles = ['file1.txt', 'example.json']; // Can specify handler in constructor too @@ -504,6 +500,25 @@ unzip.push(data, true); See the [documentation](https://github.com/101arrowz/fflate/blob/master/docs/README.md) for more detailed information about the API. +## Raw BZIP2 (.bz2) + +`fflate` can also decompress raw `.bz2` files directly, outside of ZIP archives. + +```js +const bz2Data = await fetch('/file.bz2').then(res => res.arrayBuffer()); +const textData = fflate.bunzip2Sync(new Uint8Array(bz2Data)); +const text = fflate.strFromU8(textData); +``` + +If you prefer the asynchronous API: + +```js +fflate.bunzip2(new Uint8Array(bz2Data), (err, data) => { + if (err) throw err; + console.log(fflate.strFromU8(data)); +}); +``` + ## Bundle size estimates The bundle size measurements for `fflate` on sites like Bundlephobia include every feature of the library and should be seen as an upper bound. As long as you are using tree shaking or dead code elimination, this table should give you a general idea of `fflate`'s bundle size for the features you need. From 2b70a62cf51f1e24d5a52d32de3a021d494972e4 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 8 Jun 2026 18:17:23 +0200 Subject: [PATCH 10/11] fix(bzip2): clean up zip12 test coverage --- src/index.ts | 13 ++++++++---- test/3-zip.ts | 57 +-------------------------------------------------- 2 files changed, 10 insertions(+), 60 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2cef2d4..10b77d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3820,7 +3820,7 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, else term.push(inflate(infl, { size: su }, cbl)); } else if (c == 12 && !(dcmp && dcmp[c])) { const infl = data.subarray(b, b + sc); - // Keep tiny BZIP2 entries sync; offload larger ones to a worker. + // Keep tiny BZIP2 entries sync; defer larger ones to the next tick. if (su < 262144) { try { cbl(null, bzip2Decode(infl)); @@ -3828,9 +3828,14 @@ export function unzip(data: Uint8Array, opts: AsyncUnzipOptions | UnzipCallback, cbl(e, null); } } else { - term.push(cbify(infl, {}, [ - bze - ], ev => pbf(bzip2Decode(ev.data[0])), 6, cbl)); + const t = setTimeout(() => { + try { + cbl(null, bzip2Decode(infl)); + } catch (e) { + cbl(e, null); + } + }, 0); + term.push(() => clearTimeout(t)); } } else if ((dcmp && dcmp[c]) || ucd[c]) { try { diff --git a/test/3-zip.ts b/test/3-zip.ts index 1454268..ffa18af 100644 --- a/test/3-zip.ts +++ b/test/3-zip.ts @@ -1,8 +1,7 @@ import { test } from 'uvu'; import * as assert from 'uvu/assert'; import { - strToU8, strFromU8, zipSync, unzipSync, unzip, inflateSync, - registerUnzipDecoder, unregisterUnzipDecoder + strToU8, strFromU8, zipSync, unzipSync, unzip } from '../src/index'; const b2 = (d: Uint8Array, b: number) => d[b] | (d[b + 1] << 8); @@ -57,60 +56,6 @@ const zipStoredAsMethod = (name: string, payload: Uint8Array, method: number) => return rewriteZipMethod(zipped, 0, method); }; -test('unzipSync custom decoder can handle non-default ZIP method', () => { - const text = 'hello custom method'; - const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); - const method12 = rewriteZipMethod(normal, 8, 12); - const out = unzipSync(method12, { - decompress: { - 12: (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) }) - } - }); - assert.is(strFromU8(out['a.txt']), text); -}); - -test('unzip custom decoder can handle non-default ZIP method', async () => { - const text = 'hello async custom method'; - const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); - const method12 = rewriteZipMethod(normal, 8, 12); - const out = await new Promise>((resolve, reject) => { - unzip(method12, { - decompress: { - 12: (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) }) - } - }, (e, files) => { - if (e) reject(e); - else resolve(files); - }); - }); - assert.is(strFromU8(out['a.txt']), text); -}); - -test('global decoder registration works for unzipSync', () => { - const text = 'hello global sync decoder'; - const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); - const method12 = rewriteZipMethod(normal, 8, 12); - registerUnzipDecoder(12, (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) })); - const out = unzipSync(method12); - unregisterUnzipDecoder(12); - assert.is(strFromU8(out['a.txt']), text); -}); - -test('global decoder registration works for unzip', async () => { - const text = 'hello global async decoder'; - const normal = zipSync({ 'a.txt': strToU8(text) }, { level: 6 }); - const method12 = rewriteZipMethod(normal, 8, 12); - registerUnzipDecoder(12, (data, info) => inflateSync(data, { out: new Uint8Array(info.originalSize) })); - const out = await new Promise>((resolve, reject) => { - unzip(method12, (e, files) => { - if (e) reject(e); - else resolve(files); - }); - }); - unregisterUnzipDecoder(12); - assert.is(strFromU8(out['a.txt']), text); -}); - test('built-in BZIP2 decoder works for unzipSync (method 12)', () => { const z = zipStoredAsMethod('a.txt', BZ2_SAMPLE, 12); const out = unzipSync(z); From 5b1b9a0aba18aa463afed5f8a422f9d0a34f4dd6 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 8 Jun 2026 19:14:27 +0200 Subject: [PATCH 11/11] chore(test): add ts-node test wrappers --- package.json | 4 +++- scripts/run-test-file.cjs | 20 ++++++++++++++++++++ scripts/run-tests.cjs | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 scripts/run-test-file.cjs create mode 100644 scripts/run-tests.cjs diff --git a/package.json b/package.json index c37a536..6ba326b 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,9 @@ "build:rewrite": "SC=rewriteBuilds npm run script", "build:demo": "tsc --project tsconfig.demo.json && parcel build demo/index.html --no-cache --public-url \"./\" && SC=cpGHPages npm run script", "build:docs": "typedoc --plugin typedoc-plugin-markdown --hideBreadcrumbs --readme none --disableSources --excludePrivate --excludeProtected --expandParameters --githubPages false --out docs/ src/index.ts", - "test": "TS_NODE_PROJECT=test/tsconfig.json uvu -b -r ts-node/register test", + "test": "node scripts/run-tests.cjs", + "test:zip": "node scripts/run-test-file.cjs test/3-zip.ts", + "test:bzip2": "node scripts/run-test-file.cjs test/7-bzip2.ts", "prepack": "npm run build && npm run test" }, "devDependencies": { diff --git a/scripts/run-test-file.cjs b/scripts/run-test-file.cjs new file mode 100644 index 0000000..65f7d32 --- /dev/null +++ b/scripts/run-test-file.cjs @@ -0,0 +1,20 @@ +const { spawnSync } = require('node:child_process'); + +const args = process.argv.slice(2); +if (!args.length) { + throw new Error('Usage: node scripts/run-test-file.cjs '); +} + +const env = { + ...process.env, + TS_NODE_PROJECT: 'test/tsconfig.json', + TS_NODE_TRANSPILE_ONLY: '1' +}; + +const result = spawnSync(process.execPath, ['-r', 'ts-node/register', ...args], { + stdio: 'inherit', + env +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1); diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs new file mode 100644 index 0000000..93354af --- /dev/null +++ b/scripts/run-tests.cjs @@ -0,0 +1,18 @@ +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const args = process.argv.slice(2); +const uvuBin = path.join(__dirname, '..', 'node_modules', 'uvu', 'bin.js'); +const env = { + ...process.env, + TS_NODE_PROJECT: 'test/tsconfig.json', + TS_NODE_TRANSPILE_ONLY: '1' +}; + +const result = spawnSync(process.execPath, [uvuBin, '-b', '-r', 'ts-node/register', ...(args.length ? args : ['test'])], { + stdio: 'inherit', + env +}); + +if (result.error) throw result.error; +process.exit(result.status ?? 1);