Something went wrong. Try again.
archived: streaming zip archiver/extractor jsr.io/@mary/zip
typescript jsr
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230import { unzip, zip } from '../lib/mod.ts';import { fromUint8Array } from '../lib/reader/common.ts';
/** * @module * * write-side WinZip AES coverage: zip() emits compression method 99 with the * 0x9901 extra field, frames the body as `salt | pwv | ciphertext | hmac`, * and the read side decrypts it back. each test rolls the archive through * unzip() with the same password to verify the roundtrip, plus spot-checks * specific bytes that the layout requires. */
const collect = async (it: AsyncIterable<Uint8Array>): Promise<Uint8Array<ArrayBuffer>> => { const chunks: Uint8Array[] = []; let total = 0; for await (const chunk of it) { chunks.push(chunk); total += chunk.length; } const out = new Uint8Array(new ArrayBuffer(total)); let pos = 0; for (const c of chunks) { out.set(c, pos); pos += c.length; } return out;};
const bytesEqual = (actual: Uint8Array, expected: Uint8Array): void => { if (actual.length !== expected.length) { throw new Error(`length mismatch: ${actual.length} vs ${expected.length}`); } for (let idx = 0; idx < actual.length; idx++) { if (actual[idx] !== expected[idx]) { throw new Error( `byte mismatch at ${idx}: got 0x${actual[idx].toString(16)}, want 0x${expected[idx].toString(16)}`, ); } }};
Deno.test('aes-write: stored entry roundtrips through unzip with the same password', async () => { const payload = new TextEncoder().encode('the quick brown fox jumps over the lazy dog'); const archive = await collect(zip([{ filename: 'fox.txt', data: payload, compress: false, password: 'correct horse', }]));
const opened = await unzip(fromUint8Array(archive), { password: 'correct horse' }); let saw = false; for await (const entry of opened) { if (entry.filename !== 'fox.txt') { throw new Error(`unexpected entry ${entry.filename}`); } if (!entry.isEncrypted) { throw new Error('expected isEncrypted=true'); } if (entry.compressionMethod !== 99) { throw new Error(`expected method 99 on disk, got ${entry.compressionMethod}`); } bytesEqual(await entry.bytes(), payload); saw = true; } if (!saw) { throw new Error('entry not yielded'); }});
Deno.test('aes-write: deflate entry roundtrips through unzip with the same password', async () => { // repetitive payload so deflate actually shrinks it — exercises the // compress-then-encrypt order required by the WinZip AES spec. const payload = new TextEncoder().encode('lorem ipsum dolor sit amet '.repeat(200)); const archive = await collect(zip([{ filename: 'lorem.txt', data: payload, password: 'correct horse', }]));
const opened = await unzip(fromUint8Array(archive), { password: 'correct horse' }); for await (const entry of opened) { // compressedSize is the on-disk encrypted body length, which includes // salt (16) + pwv (2) + ciphertext + HMAC (10). it must beat plain // uncompressed-with-AES (uncompressedSize + 28) since the body is // actually deflated before encryption. if (entry.compressedSize >= entry.size + 28) { throw new Error(`expected compression to shrink: csize=${entry.compressedSize}, usize=${entry.size}`); } bytesEqual(await entry.bytes(), payload); }});
Deno.test('aes-write: emits the WinZip AES 0x9901 extra in the local header', async () => { const archive = await collect(zip([{ filename: 'aes.bin', data: new Uint8Array([1, 2, 3, 4]), compress: false, password: 'pw', }]));
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); // LFH layout: fixed 30 bytes, then filename, then extras if (view.getUint32(0, true) !== 0x04034b50) { throw new Error('expected LFH signature at byte 0'); } const versionNeeded = view.getUint16(4, true); if (versionNeeded !== 51) { throw new Error(`expected version-needed 51 for AES, got ${versionNeeded}`); } const flags = view.getUint16(6, true); if ((flags & 0x0001) !== 0x0001) { throw new Error(`expected encrypted flag bit 0, got flags=0x${flags.toString(16)}`); } const method = view.getUint16(8, true); if (method !== 99) { throw new Error(`expected method 99 in LFH, got ${method}`); } const fnameLen = view.getUint16(26, true); const extraLen = view.getUint16(28, true); if (extraLen !== 11) { throw new Error(`expected 11-byte AES extra (only), got ${extraLen}`); }
const extraOffset = 30 + fnameLen; const tag = view.getUint16(extraOffset, true); if (tag !== 0x9901) { throw new Error(`expected AES extra tag 0x9901, got 0x${tag.toString(16)}`); } const aeVersion = view.getUint16(extraOffset + 4, true); if (aeVersion !== 2) { throw new Error(`expected AE-2 default, got AE-${aeVersion}`); } const vendor = view.getUint16(extraOffset + 6, true); if (vendor !== 0x4541) { throw new Error(`expected vendor 0x4541 "AE", got 0x${vendor.toString(16)}`); } const strength = view.getUint8(extraOffset + 8); if (strength !== 3) { throw new Error(`expected strength code 3 (AES-256), got ${strength}`); } const actualMethod = view.getUint16(extraOffset + 9, true); if (actualMethod !== 0) { throw new Error(`expected actualMethod 0 (stored) in 0x9901, got ${actualMethod}`); }});
Deno.test('aes-write: AE-2 zeroes the CRC field in the central record', async () => { const archive = await collect(zip([{ filename: 'tiny.txt', data: new TextEncoder().encode('hi'), compress: false, password: 'pw', }]));
// find the central directory file header (signature 0x02014b50) const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); const cdSig = 0x02014b50; let cdPos = -1; for (let pos = 0; pos + 4 <= archive.length; pos++) { if (view.getUint32(pos, true) === cdSig) { cdPos = pos; break; } } if (cdPos < 0) { throw new Error('central directory record not found'); } const crc = view.getUint32(cdPos + 16, true); if (crc !== 0) { throw new Error(`AE-2 must zero the central CRC field, got 0x${crc.toString(16)}`); }});
Deno.test('aes-write: wrong password on read surfaces InvalidPasswordError', async () => { const archive = await collect(zip([{ filename: 'x.txt', data: new TextEncoder().encode('secret'), compress: false, password: 'right', }]));
const opened = await unzip(fromUint8Array(archive), { password: 'wrong' }); let caught: unknown; for await (const entry of opened) { try { await entry.bytes(); } catch (error) { caught = error; } } if (!(caught instanceof Error) || !caught.message.includes('verification value mismatch')) { throw new Error(`expected PWV mismatch error, got ${caught}`); }});
Deno.test('aes-write: stream input is buffered and roundtrips', async () => { // stream input forces the encryption path to collect the body first // (HMAC requires full ciphertext upfront). after buffering, the entry // can use a known-size local header instead of preemptive zip64. const payload = new TextEncoder().encode('streamed encrypted payload\n'); const source = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(payload); controller.close(); }, });
const archive = await collect(zip([{ filename: 'streamed.txt', data: source, compress: false, password: 'pw', }]));
// LFH must NOT have the zip64 extra (only the 11-byte AES extra), // because the post-encryption size is known after buffering. const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); const extraLen = view.getUint16(28, true); if (extraLen !== 11) { throw new Error(`expected no zip64 LFH extra for buffered encrypted stream, got extraLen=${extraLen}`); }
const opened = await unzip(fromUint8Array(archive), { password: 'pw' }); for await (const entry of opened) { bytesEqual(await entry.bytes(), payload); }});