mod common; use common::run; #[test] fn compression_formats_round_trip_incrementally_and_match_external_fixtures() { let output = run( r#" function equal(a, b) { return a.length === b.length && a.every((value, i) => value === b[i]); } async function collect(stream) { const reader = stream.getReader(), chunks = []; let total = 0; for (;;) { const {value, done} = await reader.read(); if (done) break; if (!(value instanceof Uint8Array)) throw Error('Output chunk type'); const copy = new Uint8Array(value); chunks.push(copy); total += copy.length; } const result = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; } async function encode(format, chunks) { const stream = new CompressionStream(format), output = collect(stream.readable); const writer = stream.writable.getWriter(); for (const chunk of chunks) await writer.write(chunk); await writer.close(); return output; } async function decode(format, bytes, split = 1) { const stream = new DecompressionStream(format), output = collect(stream.readable); const writer = stream.writable.getWriter(); for (let offset = 0; offset < bytes.length; offset += split) { await writer.write(bytes.subarray(offset, Math.min(offset + split, bytes.length))); } await writer.close(); return output; } const input = new Uint8Array([...new TextEncoder().encode('whatwg compression streaming test\0'), 255]); const fixtures = { 'deflate': '789c2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00fcbc0e2e', 'deflate-raw': '2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00', 'gzip': '1f8b08000000000002ff2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f0037951d5f23000000', }; for (const format of Object.keys(fixtures)) { const bytes = Uint8Array.from(fixtures[format].match(/../g), pair => parseInt(pair, 16)); if (!equal(await decode(format, bytes), input)) throw Error('External ' + format + ' fixture'); const encoded = await encode(format, [input.subarray(0, 5), input.subarray(5, 13), input.subarray(13)]); if (!equal(await decode(format, encoded, 2), input)) throw Error('Round trip ' + format); } const brotliFixture = Uint8Array.from('213800046578706563746564206f757470757403'.match(/../g), pair => parseInt(pair, 16)); const brotliExpected = new TextEncoder().encode('expected output'); for (const format of ['brotli']) { if (!equal(await decode(format, brotliFixture), brotliExpected)) throw Error('External ' + format + ' fixture'); const encoded = await encode(format, [input.subarray(0, 7), input.subarray(7)]); if (!equal(await decode(format, encoded, 3), input)) throw Error('Round trip ' + format); const empty = await encode(format, [new Uint8Array(0)]); if ((await decode(format, empty)).length !== 0) throw Error('Empty stream ' + format); } const nativeTransform = globalThis.TransformStream, transformPrototype = nativeTransform.prototype; const readableDescriptor = Object.getOwnPropertyDescriptor(transformPrototype, 'readable'); const writableDescriptor = Object.getOwnPropertyDescriptor(transformPrototype, 'writable'); const enqueueDescriptor = Object.getOwnPropertyDescriptor(TransformStreamDefaultController.prototype, 'enqueue'); try { Object.defineProperty(transformPrototype, 'readable', {get() { throw Error('Poisoned readable getter'); }, configurable: true}); Object.defineProperty(transformPrototype, 'writable', {get() { throw Error('Poisoned writable getter'); }, configurable: true}); Object.defineProperty(TransformStreamDefaultController.prototype, 'enqueue', {value() { throw Error('Poisoned enqueue'); }, configurable: true}); globalThis.TransformStream = function() { throw Error('Poisoned constructor'); }; const poisoned = new CompressionStream('deflate'), poisonedOutput = collect(poisoned.readable); const poisonedWriter = poisoned.writable.getWriter(); await poisonedWriter.write(new Uint8Array([1, 2, 3])); await poisonedWriter.close(); if ((await decode('deflate', await poisonedOutput)).join() !== '1,2,3') throw Error('Compression used poisoned stream hooks'); } finally { Object.defineProperty(transformPrototype, 'readable', readableDescriptor); Object.defineProperty(transformPrototype, 'writable', writableDescriptor); Object.defineProperty(TransformStreamDefaultController.prototype, 'enqueue', enqueueDescriptor); globalThis.TransformStream = nativeTransform; } const empty = await encode('gzip', [new Uint8Array(0)]); if ((await decode('gzip', empty)).length !== 0) throw Error('Empty stream'); const backing = new Uint8Array([9, 1, 2, 3, 8]); const viewed = await encode('deflate', [new DataView(backing.buffer, 1, 3)]); if (!equal(await decode('deflate', viewed), new Uint8Array([1, 2, 3]))) throw Error('BufferSource view bounds'); const resizable = new ArrayBuffer(5, {maxByteLength: 10}); new Uint8Array(resizable).set([9, 1, 2, 3, 8]); const resized = await encode('gzip', [new DataView(resizable, 1, 3)]); resizable.resize(0); if (!equal(await decode('gzip', resized), new Uint8Array([1, 2, 3]))) { throw Error('Resizable BufferSource'); } const direct = await encode('gzip', [new Uint8Array([4, 5, 6]).buffer]); if (!equal(await decode('gzip', direct), new Uint8Array([4, 5, 6]))) throw Error('ArrayBuffer input'); const transferable = new CompressionStream('gzip'), sourceReadable = transferable.readable; const movedReadable = structuredClone(sourceReadable, {transfer: [sourceReadable]}); const transferredOutput = collect(movedReadable), transferredWriter = transferable.writable.getWriter(); await transferredWriter.write(input); await transferredWriter.close(); if (!equal(await decode('gzip', await transferredOutput), input)) throw Error('Transferred compression output'); console.log('compression formats'); "#, &[], ); assert_eq!(output.stdout, b"compression formats\n"); } #[test] fn compression_stream_interfaces_validate_brands_formats_and_buffer_sources() { let output = run( r#" for (const C of [CompressionStream, DecompressionStream]) { try { new C(); throw Error('Missing format'); } catch (error) { if (!(error instanceof TypeError)) throw error; } try { new C(Symbol()); throw Error('Symbol format'); } catch (error) { if (!(error instanceof TypeError)) throw error; } for (const format of ['GZIP', 'gzip ', 'bzip2', '']) { try { new C(format); throw Error('Unsupported format'); } catch (error) { if (!(error instanceof TypeError)) throw error; } } const descriptor = Object.getOwnPropertyDescriptor(C.prototype, 'readable'); if (!descriptor.get || descriptor.set || !descriptor.enumerable) throw Error('Readable descriptor'); for (const key of ['readable', 'writable']) { const get = Object.getOwnPropertyDescriptor(C.prototype, key).get; const other = C === CompressionStream ? DecompressionStream : CompressionStream; for (const receiver of [{}, new other('gzip')]) { try { get.call(receiver); throw Error('Unbranded getter'); } catch (error) { if (!(error instanceof TypeError)) throw error; } } } } for (const C of [CompressionStream, DecompressionStream]) { for (const value of ['text', 1, null, {}, new SharedArrayBuffer(4), new Uint8Array(new SharedArrayBuffer(4))]) { const stream = new C('deflate'); const reader = stream.readable.getReader(), writer = stream.writable.getWriter(); writer.closed.catch(() => {}); const read = reader.read().catch(error => error); try { await writer.write(value); throw Error('Accepted non-BufferSource'); } catch (error) { if (!(error instanceof TypeError)) throw error; } if (!(await read instanceof TypeError)) throw Error('Readable side did not error'); } } const detached = new ArrayBuffer(3); detached.transfer(); for (const value of [detached, new Uint8Array(new SharedArrayBuffer(4))]) { const stream = new CompressionStream('gzip'); const reader = stream.readable.getReader(), writer = stream.writable.getWriter(); writer.closed.catch(() => {}); const read = reader.read().catch(error => error); try { await writer.write(value); throw Error('Accepted invalid buffer'); } catch (error) { if (!(error instanceof TypeError)) throw error; } if (!(await read instanceof TypeError)) throw Error('Readable side did not error'); } const aborted = new CompressionStream('gzip'); const abortedReader = aborted.readable.getReader(), abortedWriter = aborted.writable.getWriter(); const abortedRead = abortedReader.read().catch(error => error); abortedReader.closed.catch(() => {}); await abortedWriter.abort('abort reason'); if (await abortedRead !== 'abort reason') throw Error('Abort did not error the readable side'); const canceled = new CompressionStream('gzip'); const canceledReader = canceled.readable.getReader(), canceledWriter = canceled.writable.getWriter(); canceledWriter.closed.catch(() => {}); const canceledWrite = canceledWriter.write(new Uint8Array([1])).catch(error => error); await canceledReader.cancel('cancel reason'); if (await canceledWrite !== 'cancel reason') throw Error('Cancel did not error the writable side'); console.log('compression interfaces'); "#, &[], ); assert_eq!(output.stdout, b"compression interfaces\n"); } #[test] fn compression_constructor_conversion_can_reenter_without_changing_callable_shape() { let output = run( r#" for (const C of [CompressionStream, DecompressionStream]) { let calls = 0, nested; const stream = new C({ [Symbol.toPrimitive](hint) { if (hint !== 'string') throw Error('Conversion hint'); calls++; nested = new C('deflate'); return 'gzip'; }, }); if (calls !== 1 || C.length !== 1) throw Error('Constructor conversion or arity'); for (const key of ['readable', 'writable']) { const get = Object.getOwnPropertyDescriptor(C.prototype, key).get; if (get.length !== 0 || get.call(stream) !== stream[key]) throw Error('Accessor shape'); } await stream.readable.cancel(); await nested.readable.cancel(); } console.log('compression conversion'); "#, &[], ); assert_eq!(output.stdout, b"compression conversion\n"); } #[test] fn compression_wrappers_keep_captured_reader_writer_and_controller_methods() { let output = run( r#" const hooks = [ [ReadableStream.prototype, 'getReader'], [WritableStream.prototype, 'getWriter'], [ReadableStreamDefaultReader.prototype, 'read'], [WritableStreamDefaultWriter.prototype, 'write'], [WritableStreamDefaultWriter.prototype, 'close'], [ReadableStreamDefaultController.prototype, 'enqueue'], [ReadableStreamDefaultController.prototype, 'close'], ]; const descriptors = hooks.map(([prototype, name]) => Object.getOwnPropertyDescriptor(prototype, name)); const [getReader, getWriter, read, write, close] = descriptors.map(descriptor => descriptor.value); try { for (const [prototype, name] of hooks) { Object.defineProperty(prototype, name, { value() { throw Error('Poisoned stream method: ' + name); }, configurable: true, }); } const stream = new CompressionStream('gzip'); const reader = getReader.call(stream.readable), writer = getWriter.call(stream.writable); const consumed = (async () => { const bytes = []; for (;;) { const result = await read.call(reader); if (result.done) return bytes; bytes.push(...result.value); } })(); await write.call(writer, new Uint8Array([1, 2, 3])); await close.call(writer); const bytes = await consumed; if (bytes[0] !== 31 || bytes[1] !== 139) throw Error('Missing gzip output'); } finally { for (let i = 0; i < hooks.length; i++) { Object.defineProperty(hooks[i][0], hooks[i][1], descriptors[i]); } } console.log('captured compression methods'); "#, &[], ); assert_eq!(output.stdout, b"captured compression methods\n"); } #[test] fn decompression_rejects_corruption_truncation_trailing_data_and_members() { let output = run( r#" const fixtures = { 'deflate': '789c2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00fcbc0e2e', 'deflate-raw': '2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00', 'gzip': '1f8b08000000000002ff2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f0037951d5f23000000', }; const bytesFor = format => Uint8Array.from(fixtures[format].match(/../g), x => parseInt(x, 16)); async function fails(format, bytes) { const stream = new DecompressionStream(format), writer = stream.writable.getWriter(), reader = stream.readable.getReader(); const read = (async () => { try { for (;;) if ((await reader.read()).done) throw Error('Readable side accepted corrupt input'); } catch (error) { return error; } })(); const writerClosed = writer.closed.then(() => null, error => error); const readerClosed = reader.closed.then(() => null, error => error); try { await writer.write(bytes); await writer.close(); throw Error('Writable side accepted corrupt input'); } catch (error) { if (!(error instanceof TypeError)) throw error; } const reason = await read; if (!(reason instanceof TypeError)) throw Error('Readable side did not error'); if (!(await writerClosed instanceof TypeError) || !(await readerClosed instanceof TypeError)) { throw Error('Stream endpoints did not both error'); } } for (const format of Object.keys(fixtures)) { const bytes = bytesFor(format); await fails(format, bytes.subarray(0, bytes.length - 1)); const trailing = new Uint8Array(bytes.length + 1); trailing.set(bytes); trailing[trailing.length - 1] = 1; await fails(format, trailing); } const brotli = Uint8Array.from('213800046578706563746564206f757470757403'.match(/../g), x => parseInt(x, 16)); for (const format of ['brotli']) { await fails(format, brotli.subarray(0, brotli.length - 1)); const members = new Uint8Array(brotli.length * 2); members.set(brotli); members.set(brotli, brotli.length); await fails(format, members); } const gzip = bytesFor('gzip'), corrupt = new Uint8Array(gzip); corrupt[corrupt.length - 8] ^= 1; await fails('gzip', corrupt); const members = new Uint8Array(gzip.length * 2); members.set(gzip); members.set(gzip, gzip.length); await fails('gzip', members); console.log('compression errors'); "#, &[], ); assert_eq!(output.stdout, b"compression errors\n"); } #[test] fn decompression_emits_valid_output_before_rejecting_trailing_data() { let output = run( r#" const fixtures = { 'deflate': '78 9c 4b ad 28 48 4d 2e 49 4d 51 c8 2f 2d 29 28 2d 01 00 30 ad 06 24', 'gzip': '1f 8b 08 00 00 00 00 00 00 03 4b ad 28 48 4d 2e 49 4d 51 c8 2f 2d 29 28 2d 01 00 b0 01 39 b3 0f 00 00 00', 'deflate-raw': '4b ad 28 48 4d 2e 49 4d 51 c8 2f 2d 29 28 2d 01 00', 'brotli': '21 38 00 04 65 78 70 65 63 74 65 64 20 6f 75 74 70 75 74 03', }; const expected = new TextEncoder().encode('expected output'); for (const format of Object.keys(fixtures)) { const bytes = Uint8Array.from(fixtures[format].trim().split(/\s+/), x => parseInt(x, 16)); const input = new Uint8Array(bytes.length + 1); input.set(bytes); const stream = new DecompressionStream(format); const reader = stream.readable.getReader(), writer = stream.writable.getWriter(); const write = writer.write(input).then(() => null, error => error); const first = await reader.read(); if (first.done || first.value.length !== expected.length || !first.value.every((x, i) => x === expected[i])) { throw Error(format + ' lost valid output before trailing-data error'); } const second = await reader.read().then(() => null, error => error); if (!(second instanceof TypeError)) throw Error(format + ' did not error after valid output'); if (!(await write instanceof TypeError)) throw Error(format + ' writable side did not reject'); const nextStream = new DecompressionStream(format); const nextReader = nextStream.readable.getReader(), nextWriter = nextStream.writable.getWriter(); const nextRead = nextReader.read(); await nextWriter.write(bytes); const decoded = await nextRead; if (decoded.done || decoded.value.length !== expected.length || !decoded.value.every((x, i) => x === expected[i])) { throw Error(format + ' lost valid output before later trailing data'); } const terminalRead = nextReader.read().then(() => null, error => error); const terminalWrite = nextWriter.write(new Uint8Array([0])).then(() => null, error => error); if (!(await terminalWrite instanceof TypeError) || !(await terminalRead instanceof TypeError)) { throw Error(format + ' did not reject later trailing data'); } } console.log('trailing input preserves valid output'); "#, &[], ); assert_eq!(output.stdout, b"trailing input preserves valid output\n"); } #[test] fn compression_streams_emit_large_output_before_writable_close() { let output = run( r#" const stream = new CompressionStream('deflate-raw'); const reader = stream.readable.getReader(), writer = stream.writable.getWriter(); const input = new Uint8Array(512 * 1024); let seed = 0x9e3779b9; for (let i = 0; i < input.length; i++) { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; input[i] = seed & 255; } const firstRead = reader.read(); await writer.write(input); const first = await Promise.race([ firstRead, new Promise(resolve => setTimeout(() => resolve(null), 1000)), ]); if (!first || first.done || first.value.length === 0) throw Error('Compression buffered until close'); await writer.close(); console.log('compression streaming'); "#, &[], ); assert_eq!(output.stdout, b"compression streaming\n"); } #[test] fn compression_efficiency_does_not_depend_on_small_write_boundaries() { let out = run( r#" const input=new TextEncoder().encode('hello world, '.repeat(128)); async function encode(size){ const stream=new CompressionStream('gzip'); const reader=stream.readable.getReader(),writer=stream.writable.getWriter(); const output=(async()=>{let length=0;for(;;){const result=await reader.read();if(result.done)return length;length+=result.value.length;}})(); for(let i=0;iwhole+64||small>=input.length/4)throw Error('Write boundaries degrade compression'); console.log('small chunks compress'); "#, &[], ); assert_eq!(out.stdout, b"small chunks compress\n"); }