char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somport compression extension to typescriptec742cf

main
21.2 KiB398 linesraw
1mod common;
2
3use common::run;
4
5#[test]
6fn compression_formats_round_trip_incrementally_and_match_external_fixtures() {
7    let output = run(
8        r#"
9        function equal(a, b) { return a.length === b.length && a.every((value, i) => value === b[i]); }
10        async function collect(stream) {
11            const reader = stream.getReader(), chunks = [];
12            let total = 0;
13            for (;;) {
14                const {value, done} = await reader.read();
15                if (done) break;
16                if (!(value instanceof Uint8Array)) throw Error('Output chunk type');
17                const copy = new Uint8Array(value);
18                chunks.push(copy);
19                total += copy.length;
20            }
21            const result = new Uint8Array(total);
22            let offset = 0;
23            for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; }
24            return result;
25        }
26        async function encode(format, chunks) {
27            const stream = new CompressionStream(format), output = collect(stream.readable);
28            const writer = stream.writable.getWriter();
29            for (const chunk of chunks) await writer.write(chunk);
30            await writer.close();
31            return output;
32        }
33        async function decode(format, bytes, split = 1) {
34            const stream = new DecompressionStream(format), output = collect(stream.readable);
35            const writer = stream.writable.getWriter();
36            for (let offset = 0; offset < bytes.length; offset += split) {
37                await writer.write(bytes.subarray(offset, Math.min(offset + split, bytes.length)));
38            }
39            await writer.close();
40            return output;
41        }
42        const input = new Uint8Array([...new TextEncoder().encode('whatwg compression streaming test\0'), 255]);
43        const fixtures = {
44            'deflate': '789c2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00fcbc0e2e',
45            'deflate-raw': '2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00',
46            'gzip': '1f8b08000000000002ff2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f0037951d5f23000000',
47        };
48        for (const format of Object.keys(fixtures)) {
49            const bytes = Uint8Array.from(fixtures[format].match(/../g), pair => parseInt(pair, 16));
50            if (!equal(await decode(format, bytes), input)) throw Error('External ' + format + ' fixture');
51            const encoded = await encode(format, [input.subarray(0, 5), input.subarray(5, 13), input.subarray(13)]);
52            if (!equal(await decode(format, encoded, 2), input)) throw Error('Round trip ' + format);
53        }
54        const brotliFixture = Uint8Array.from('213800046578706563746564206f757470757403'.match(/../g), pair => parseInt(pair, 16));
55        const brotliExpected = new TextEncoder().encode('expected output');
56        for (const format of ['brotli']) {
57            if (!equal(await decode(format, brotliFixture), brotliExpected)) throw Error('External ' + format + ' fixture');
58            const encoded = await encode(format, [input.subarray(0, 7), input.subarray(7)]);
59            if (!equal(await decode(format, encoded, 3), input)) throw Error('Round trip ' + format);
60            const empty = await encode(format, [new Uint8Array(0)]);
61            if ((await decode(format, empty)).length !== 0) throw Error('Empty stream ' + format);
62        }
63        const nativeTransform = globalThis.TransformStream, transformPrototype = nativeTransform.prototype;
64        const readableDescriptor = Object.getOwnPropertyDescriptor(transformPrototype, 'readable');
65        const writableDescriptor = Object.getOwnPropertyDescriptor(transformPrototype, 'writable');
66        const enqueueDescriptor = Object.getOwnPropertyDescriptor(TransformStreamDefaultController.prototype, 'enqueue');
67        try {
68            Object.defineProperty(transformPrototype, 'readable', {get() { throw Error('Poisoned readable getter'); }, configurable: true});
69            Object.defineProperty(transformPrototype, 'writable', {get() { throw Error('Poisoned writable getter'); }, configurable: true});
70            Object.defineProperty(TransformStreamDefaultController.prototype, 'enqueue', {value() { throw Error('Poisoned enqueue'); }, configurable: true});
71            globalThis.TransformStream = function() { throw Error('Poisoned constructor'); };
72            const poisoned = new CompressionStream('deflate'), poisonedOutput = collect(poisoned.readable);
73            const poisonedWriter = poisoned.writable.getWriter();
74            await poisonedWriter.write(new Uint8Array([1, 2, 3]));
75            await poisonedWriter.close();
76            if ((await decode('deflate', await poisonedOutput)).join() !== '1,2,3') throw Error('Compression used poisoned stream hooks');
77        } finally {
78            Object.defineProperty(transformPrototype, 'readable', readableDescriptor);
79            Object.defineProperty(transformPrototype, 'writable', writableDescriptor);
80            Object.defineProperty(TransformStreamDefaultController.prototype, 'enqueue', enqueueDescriptor);
81            globalThis.TransformStream = nativeTransform;
82        }
83        const empty = await encode('gzip', [new Uint8Array(0)]);
84        if ((await decode('gzip', empty)).length !== 0) throw Error('Empty stream');
85        const backing = new Uint8Array([9, 1, 2, 3, 8]);
86        const viewed = await encode('deflate', [new DataView(backing.buffer, 1, 3)]);
87        if (!equal(await decode('deflate', viewed), new Uint8Array([1, 2, 3]))) throw Error('BufferSource view bounds');
88        const resizable = new ArrayBuffer(5, {maxByteLength: 10});
89        new Uint8Array(resizable).set([9, 1, 2, 3, 8]);
90        const resized = await encode('gzip', [new DataView(resizable, 1, 3)]);
91        resizable.resize(0);
92        if (!equal(await decode('gzip', resized), new Uint8Array([1, 2, 3]))) {
93            throw Error('Resizable BufferSource');
94        }
95        const direct = await encode('gzip', [new Uint8Array([4, 5, 6]).buffer]);
96        if (!equal(await decode('gzip', direct), new Uint8Array([4, 5, 6]))) throw Error('ArrayBuffer input');
97        const transferable = new CompressionStream('gzip'), sourceReadable = transferable.readable;
98        const movedReadable = structuredClone(sourceReadable, {transfer: [sourceReadable]});
99        const transferredOutput = collect(movedReadable), transferredWriter = transferable.writable.getWriter();
100        await transferredWriter.write(input);
101        await transferredWriter.close();
102        if (!equal(await decode('gzip', await transferredOutput), input)) throw Error('Transferred compression output');
103        console.log('compression formats');
104        "#,
105        &[],
106    );
107    assert_eq!(output.stdout, b"compression formats\n");
108}
109
110#[test]
111fn compression_stream_interfaces_validate_brands_formats_and_buffer_sources() {
112    let output = run(
113        r#"
114        for (const C of [CompressionStream, DecompressionStream]) {
115            try { new C(); throw Error('Missing format'); } catch (error) { if (!(error instanceof TypeError)) throw error; }
116            try { new C(Symbol()); throw Error('Symbol format'); } catch (error) { if (!(error instanceof TypeError)) throw error; }
117            for (const format of ['GZIP', 'gzip ', 'bzip2', '']) {
118                try { new C(format); throw Error('Unsupported format'); } catch (error) { if (!(error instanceof TypeError)) throw error; }
119            }
120            const descriptor = Object.getOwnPropertyDescriptor(C.prototype, 'readable');
121            if (!descriptor.get || descriptor.set || !descriptor.enumerable) throw Error('Readable descriptor');
122            for (const key of ['readable', 'writable']) {
123                const get = Object.getOwnPropertyDescriptor(C.prototype, key).get;
124                const other = C === CompressionStream ? DecompressionStream : CompressionStream;
125                for (const receiver of [{}, new other('gzip')]) {
126                    try { get.call(receiver); throw Error('Unbranded getter'); }
127                    catch (error) { if (!(error instanceof TypeError)) throw error; }
128                }
129            }
130        }
131        for (const C of [CompressionStream, DecompressionStream]) {
132            for (const value of ['text', 1, null, {}, new SharedArrayBuffer(4), new Uint8Array(new SharedArrayBuffer(4))]) {
133                const stream = new C('deflate');
134                const reader = stream.readable.getReader(), writer = stream.writable.getWriter();
135                writer.closed.catch(() => {});
136                const read = reader.read().catch(error => error);
137                try { await writer.write(value); throw Error('Accepted non-BufferSource'); }
138                catch (error) { if (!(error instanceof TypeError)) throw error; }
139                if (!(await read instanceof TypeError)) throw Error('Readable side did not error');
140            }
141        }
142        const detached = new ArrayBuffer(3); detached.transfer();
143        for (const value of [detached, new Uint8Array(new SharedArrayBuffer(4))]) {
144            const stream = new CompressionStream('gzip');
145            const reader = stream.readable.getReader(), writer = stream.writable.getWriter();
146            writer.closed.catch(() => {});
147            const read = reader.read().catch(error => error);
148            try { await writer.write(value); throw Error('Accepted invalid buffer'); }
149            catch (error) { if (!(error instanceof TypeError)) throw error; }
150            if (!(await read instanceof TypeError)) throw Error('Readable side did not error');
151        }
152        const aborted = new CompressionStream('gzip');
153        const abortedReader = aborted.readable.getReader(), abortedWriter = aborted.writable.getWriter();
154        const abortedRead = abortedReader.read().catch(error => error);
155        abortedReader.closed.catch(() => {});
156        await abortedWriter.abort('abort reason');
157        if (await abortedRead !== 'abort reason') throw Error('Abort did not error the readable side');
158        const canceled = new CompressionStream('gzip');
159        const canceledReader = canceled.readable.getReader(), canceledWriter = canceled.writable.getWriter();
160        canceledWriter.closed.catch(() => {});
161        const canceledWrite = canceledWriter.write(new Uint8Array([1])).catch(error => error);
162        await canceledReader.cancel('cancel reason');
163        if (await canceledWrite !== 'cancel reason') throw Error('Cancel did not error the writable side');
164        console.log('compression interfaces');
165        "#,
166        &[],
167    );
168    assert_eq!(output.stdout, b"compression interfaces\n");
169}
170
171#[test]
172fn compression_constructor_conversion_can_reenter_without_changing_callable_shape() {
173    let output = run(
174        r#"
175        for (const C of [CompressionStream, DecompressionStream]) {
176            let calls = 0, nested;
177            const stream = new C({
178                [Symbol.toPrimitive](hint) {
179                    if (hint !== 'string') throw Error('Conversion hint');
180                    calls++;
181                    nested = new C('deflate');
182                    return 'gzip';
183                },
184            });
185            if (calls !== 1 || C.length !== 1) throw Error('Constructor conversion or arity');
186            for (const key of ['readable', 'writable']) {
187                const get = Object.getOwnPropertyDescriptor(C.prototype, key).get;
188                if (get.length !== 0 || get.call(stream) !== stream[key]) throw Error('Accessor shape');
189            }
190            await stream.readable.cancel();
191            await nested.readable.cancel();
192        }
193        console.log('compression conversion');
194        "#,
195        &[],
196    );
197    assert_eq!(output.stdout, b"compression conversion\n");
198}
199
200#[test]
201fn compression_wrappers_keep_captured_reader_writer_and_controller_methods() {
202    let output = run(
203        r#"
204        const hooks = [
205            [ReadableStream.prototype, 'getReader'],
206            [WritableStream.prototype, 'getWriter'],
207            [ReadableStreamDefaultReader.prototype, 'read'],
208            [WritableStreamDefaultWriter.prototype, 'write'],
209            [WritableStreamDefaultWriter.prototype, 'close'],
210            [ReadableStreamDefaultController.prototype, 'enqueue'],
211            [ReadableStreamDefaultController.prototype, 'close'],
212        ];
213        const descriptors = hooks.map(([prototype, name]) => Object.getOwnPropertyDescriptor(prototype, name));
214        const [getReader, getWriter, read, write, close] = descriptors.map(descriptor => descriptor.value);
215        try {
216            for (const [prototype, name] of hooks) {
217                Object.defineProperty(prototype, name, {
218                    value() { throw Error('Poisoned stream method: ' + name); },
219                    configurable: true,
220                });
221            }
222            const stream = new CompressionStream('gzip');
223            const reader = getReader.call(stream.readable), writer = getWriter.call(stream.writable);
224            const consumed = (async () => {
225                const bytes = [];
226                for (;;) {
227                    const result = await read.call(reader);
228                    if (result.done) return bytes;
229                    bytes.push(...result.value);
230                }
231            })();
232            await write.call(writer, new Uint8Array([1, 2, 3]));
233            await close.call(writer);
234            const bytes = await consumed;
235            if (bytes[0] !== 31 || bytes[1] !== 139) throw Error('Missing gzip output');
236        } finally {
237            for (let i = 0; i < hooks.length; i++) {
238                Object.defineProperty(hooks[i][0], hooks[i][1], descriptors[i]);
239            }
240        }
241        console.log('captured compression methods');
242        "#,
243        &[],
244    );
245    assert_eq!(output.stdout, b"captured compression methods\n");
246}
247
248#[test]
249fn decompression_rejects_corruption_truncation_trailing_data_and_members() {
250    let output = run(
251        r#"
252        const fixtures = {
253            'deflate': '789c2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00fcbc0e2e',
254            'deflate-raw': '2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f00',
255            'gzip': '1f8b08000000000002ff2bcf482c294f5748cecf2d284a2d2ececccf53282e294a4dcccdcc4b5728492d2e61f80f0037951d5f23000000',
256        };
257        const bytesFor = format => Uint8Array.from(fixtures[format].match(/../g), x => parseInt(x, 16));
258        async function fails(format, bytes) {
259            const stream = new DecompressionStream(format), writer = stream.writable.getWriter(), reader = stream.readable.getReader();
260            const read = (async () => {
261                try {
262                    for (;;) if ((await reader.read()).done) throw Error('Readable side accepted corrupt input');
263                } catch (error) { return error; }
264            })();
265            const writerClosed = writer.closed.then(() => null, error => error);
266            const readerClosed = reader.closed.then(() => null, error => error);
267            try {
268                await writer.write(bytes);
269                await writer.close();
270                throw Error('Writable side accepted corrupt input');
271            } catch (error) {
272                if (!(error instanceof TypeError)) throw error;
273            }
274            const reason = await read;
275            if (!(reason instanceof TypeError)) throw Error('Readable side did not error');
276            if (!(await writerClosed instanceof TypeError) || !(await readerClosed instanceof TypeError)) {
277                throw Error('Stream endpoints did not both error');
278            }
279        }
280        for (const format of Object.keys(fixtures)) {
281            const bytes = bytesFor(format);
282            await fails(format, bytes.subarray(0, bytes.length - 1));
283            const trailing = new Uint8Array(bytes.length + 1); trailing.set(bytes); trailing[trailing.length - 1] = 1;
284            await fails(format, trailing);
285        }
286        const brotli = Uint8Array.from('213800046578706563746564206f757470757403'.match(/../g), x => parseInt(x, 16));
287        for (const format of ['brotli']) {
288            await fails(format, brotli.subarray(0, brotli.length - 1));
289            const members = new Uint8Array(brotli.length * 2); members.set(brotli); members.set(brotli, brotli.length);
290            await fails(format, members);
291        }
292        const gzip = bytesFor('gzip'), corrupt = new Uint8Array(gzip);
293        corrupt[corrupt.length - 8] ^= 1;
294        await fails('gzip', corrupt);
295        const members = new Uint8Array(gzip.length * 2); members.set(gzip); members.set(gzip, gzip.length);
296        await fails('gzip', members);
297        console.log('compression errors');
298        "#,
299        &[],
300    );
301    assert_eq!(output.stdout, b"compression errors\n");
302}
303
304#[test]
305fn decompression_emits_valid_output_before_rejecting_trailing_data() {
306    let output = run(
307        r#"
308        const fixtures = {
309            'deflate': '78 9c 4b ad 28 48 4d 2e 49 4d 51 c8 2f 2d 29 28 2d 01 00 30 ad 06 24',
310            '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',
311            'deflate-raw': '4b ad 28 48 4d 2e 49 4d 51 c8 2f 2d 29 28 2d 01 00',
312            'brotli': '21 38 00 04 65 78 70 65 63 74 65 64 20 6f 75 74 70 75 74 03',
313        };
314        const expected = new TextEncoder().encode('expected output');
315        for (const format of Object.keys(fixtures)) {
316            const bytes = Uint8Array.from(fixtures[format].trim().split(/\s+/), x => parseInt(x, 16));
317            const input = new Uint8Array(bytes.length + 1);
318            input.set(bytes);
319            const stream = new DecompressionStream(format);
320            const reader = stream.readable.getReader(), writer = stream.writable.getWriter();
321            const write = writer.write(input).then(() => null, error => error);
322            const first = await reader.read();
323            if (first.done || first.value.length !== expected.length || !first.value.every((x, i) => x === expected[i])) {
324                throw Error(format + ' lost valid output before trailing-data error');
325            }
326            const second = await reader.read().then(() => null, error => error);
327            if (!(second instanceof TypeError)) throw Error(format + ' did not error after valid output');
328            if (!(await write instanceof TypeError)) throw Error(format + ' writable side did not reject');
329
330            const nextStream = new DecompressionStream(format);
331            const nextReader = nextStream.readable.getReader(), nextWriter = nextStream.writable.getWriter();
332            const nextRead = nextReader.read();
333            await nextWriter.write(bytes);
334            const decoded = await nextRead;
335            if (decoded.done || decoded.value.length !== expected.length || !decoded.value.every((x, i) => x === expected[i])) {
336                throw Error(format + ' lost valid output before later trailing data');
337            }
338            const terminalRead = nextReader.read().then(() => null, error => error);
339            const terminalWrite = nextWriter.write(new Uint8Array([0])).then(() => null, error => error);
340            if (!(await terminalWrite instanceof TypeError) || !(await terminalRead instanceof TypeError)) {
341                throw Error(format + ' did not reject later trailing data');
342            }
343        }
344        console.log('trailing input preserves valid output');
345        "#,
346        &[],
347    );
348    assert_eq!(output.stdout, b"trailing input preserves valid output\n");
349}
350
351#[test]
352fn compression_streams_emit_large_output_before_writable_close() {
353    let output = run(
354        r#"
355        const stream = new CompressionStream('deflate-raw');
356        const reader = stream.readable.getReader(), writer = stream.writable.getWriter();
357        const input = new Uint8Array(512 * 1024);
358        let seed = 0x9e3779b9;
359        for (let i = 0; i < input.length; i++) {
360            seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
361            input[i] = seed & 255;
362        }
363        const firstRead = reader.read();
364        await writer.write(input);
365        const first = await Promise.race([
366            firstRead,
367            new Promise(resolve => setTimeout(() => resolve(null), 1000)),
368        ]);
369        if (!first || first.done || first.value.length === 0) throw Error('Compression buffered until close');
370        await writer.close();
371        console.log('compression streaming');
372        "#,
373        &[],
374    );
375    assert_eq!(output.stdout, b"compression streaming\n");
376}
377
378#[test]
379fn compression_efficiency_does_not_depend_on_small_write_boundaries() {
380    let out = run(
381        r#"
382        const input=new TextEncoder().encode('hello world, '.repeat(128));
383        async function encode(size){
384            const stream=new CompressionStream('gzip');
385            const reader=stream.readable.getReader(),writer=stream.writable.getWriter();
386            const output=(async()=>{let length=0;for(;;){const result=await reader.read();if(result.done)return length;length+=result.value.length;}})();
387            for(let i=0;i<input.length;i+=size)await writer.write(input.subarray(i,i+size));
388            await writer.close();
389            return output;
390        }
391        const whole=await encode(input.length),small=await encode(1);
392        if(small>whole+64||small>=input.length/4)throw Error('Write boundaries degrade compression');
393        console.log('small chunks compress');
394    "#,
395        &[],
396    );
397    assert_eq!(out.stdout, b"small chunks compress\n");
398}