char/avril

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

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

Charlotte Somport fetch extension to typescriptf2c35fd

main
46.6 KiB1005 linesraw
1mod common;
2
3use std::io::{BufRead, BufReader, Read, Write};
4use std::net::{Shutdown, TcpListener, TcpStream};
5use std::process::Command;
6use std::time::Duration;
7
8struct Request {
9    method: String,
10    path: String,
11    headers: String,
12    body: Vec<u8>,
13}
14
15fn request(socket: &mut TcpStream) -> Request {
16    socket
17        .set_read_timeout(Some(Duration::from_secs(5)))
18        .unwrap();
19    let mut reader = BufReader::new(socket);
20    let mut line = String::new();
21    reader.read_line(&mut line).unwrap();
22    let mut words = line.split_whitespace();
23    let method = words.next().unwrap().to_owned();
24    let path = words.next().unwrap().to_owned();
25    let mut headers = String::new();
26    loop {
27        line.clear();
28        reader.read_line(&mut line).unwrap();
29        if line == "\r\n" {
30            break;
31        }
32        headers.push_str(&line.to_ascii_lowercase());
33    }
34    let mut body = Vec::new();
35    if headers.contains("transfer-encoding: chunked") {
36        loop {
37            line.clear();
38            reader.read_line(&mut line).unwrap();
39            let len = usize::from_str_radix(line.trim().split(';').next().unwrap(), 16).unwrap();
40            if len == 0 {
41                break;
42            }
43            let start = body.len();
44            body.resize(start + len, 0);
45            reader.read_exact(&mut body[start..]).unwrap();
46            let mut newline = [0; 2];
47            reader.read_exact(&mut newline).unwrap();
48        }
49    } else if let Some(line) = headers
50        .lines()
51        .find(|line| line.starts_with("content-length:"))
52    {
53        let len = line.split_once(':').unwrap().1.trim().parse().unwrap();
54        body.resize(len, 0);
55        reader.read_exact(&mut body).unwrap();
56    }
57    Request { method, path, headers, body }
58}
59
60fn server(
61    count: usize,
62    handler: impl Fn(Request) -> Vec<u8> + Send + 'static,
63) -> (String, std::thread::JoinHandle<()>) {
64    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
65    let url = format!("http://{}", listener.local_addr().unwrap());
66    let thread = std::thread::spawn(move || {
67        for _ in 0..count {
68            let (mut socket, _) = listener.accept().unwrap();
69            let req = request(&mut socket);
70            let _ = socket.write_all(&handler(req));
71        }
72    });
73    (url, thread)
74}
75
76#[test]
77fn fetch_ignores_guest_promise_hooks_and_internal_array_iterators() {
78    let (url, thread) = server(1, |request| {
79        assert_eq!(request.method, "POST");
80        assert_eq!(request.body, b"upload");
81        response("200 OK", "X-Test: captured\r\n", b"download")
82    });
83    let source = format!(
84        r#"
85        const then = Promise.prototype.then, originalPromise = Promise;
86        function observe(promise) {{
87            Object.defineProperty(promise, 'constructor', {{ value: undefined }});
88            const result = Reflect.apply(then, promise, [value => value, error => {{ throw error; }}]);
89            Object.defineProperty(result, 'constructor', {{ value: originalPromise }});
90            return result;
91        }}
92        const chunk = new TextEncoder().encode('upload');
93        const poison = () => {{ throw Error('Guest hook invoked'); }};
94        Object.defineProperty(Promise.prototype, 'constructor', {{ get: poison, configurable: true }});
95        Object.defineProperty(Promise, Symbol.species, {{ get: poison }});
96        Promise.prototype.then = poison;
97        Array.prototype[Symbol.iterator] = poison;
98        const body = new ReadableStream({{ start(controller) {{ controller.enqueue(chunk); controller.close(); }} }});
99        const result = await observe(fetch('{url}', {{ method: 'POST', body, duplex: 'half' }}));
100        if (result.status !== 200 || result.headers.get('x-test') !== 'captured' || await observe(result.text()) !== 'download') throw Error('Fetch behavior');
101        console.log('fetch primordials safe');
102        "#
103    );
104    assert_eq!(
105        common::run(&source, &[]).stdout,
106        b"fetch primordials safe\n"
107    );
108    thread.join().unwrap();
109}
110
111fn response(status: &str, headers: &str, bytes: &[u8]) -> Vec<u8> {
112    let mut result = format!(
113        "HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n",
114        bytes.len()
115    )
116    .into_bytes();
117    result.extend_from_slice(bytes);
118    result
119}
120
121#[test]
122fn headers_body_helpers_and_explicit_stubs() {
123    let out = common::run(
124        r#"
125        const h=new Headers([['Z',' a '],['x','1'],['X','2'],['set-cookie','a=1'],['set-cookie','b=2']]);
126        if(h.get('X')!=='1, 2'||h.getSetCookie().length!==2||[...h.keys()].join('|')!=='set-cookie|set-cookie|x|z') throw Error('Headers');
127        for(const bad of [['a b','x'],['x','a\r\nb'],['x','\u0100']]) {try {new Headers([bad]);throw Error('Accepted invalid header');}catch(e){if(!(e instanceof TypeError))throw e;}}
128        const a=new Response(new Uint8Array([0,255,3])), b=a.clone();
129        if(a.bodyUsed||b.bodyUsed||!(a.body instanceof ReadableStream))throw Error('Body flags');
130        console.log([...await a.bytes()].join(','),[...new Uint8Array(await b.arrayBuffer())].join(','));
131        try {await a.text();throw Error('Consumed body');}catch(e){if(!(e instanceof TypeError))throw e;}
132        const j=Response.json({x:42});if((await j.json()).x!==42||j.headers.get('content-type')!=='application/json')throw Error('JSON');
133        if(await new Response('\ufeff\ufeffx').text()!=='\ufeffx')throw Error('BOM');
134        const params=new URLSearchParams('q=a+b');const req=new Request('https://example.com',{method:'POST',body:params});
135        if(await req.text()!=='q=a+b')throw Error('Params');
136        if(new Response(null,{status:200.9}).status!==200)throw Error('status conversion');
137        for(const operation of [()=>fetch('data:text/plain,x'),()=>new Request('https://example.com',{cache:'reload'})]) {
138            try {await operation();throw Error('Expected stub');}catch(e){if(!e.message.includes('not yet implemented'))throw e;}
139        }
140        const controller=new AbortController(), log=[];
141        controller.signal.addEventListener('abort',()=>log.push(1));controller.signal.onabort=e=>{if(e.target!==controller.signal)throw Error('Event target');log.push(2);};controller.signal.addEventListener('abort',()=>log.push(3));controller.abort();
142        if(log.join(',')!=='1,2,3'||controller.signal.reason.name!=='AbortError')throw Error('Abort events');
143        const combined=AbortSignal.any([AbortSignal.abort('reason')]);try{combined.throwIfAborted();throw Error('Expected abort');}catch(e){if(e!=='reason')throw e;}
144        console.log('helpers');
145    "#,
146        &[],
147    );
148    assert_eq!(out.stdout, b"0,255,3 0,255,3\nhelpers\n");
149}
150
151#[test]
152fn request_response_clone_and_body_disturbance_follow_stream_state() {
153    let output = common::run(
154        r#"
155        const backing = new Uint8Array([0, 1, 2, 3]).buffer;
156        const request = new Request('https://example.com', {
157            method: 'POST', body: new DataView(backing, 1, 2),
158        });
159        new Uint8Array(backing).fill(9);
160        const requestCopy = request.clone();
161        if (request.bodyUsed || requestCopy.bodyUsed) throw Error('Clone disturbed body');
162        const [first, second] = await Promise.all([request.bytes(), requestCopy.bytes()]);
163        if (first.join() !== '1,2' || second.join() !== '1,2') throw Error('Request snapshot');
164        if (!request.bodyUsed || !requestCopy.bodyUsed) throw Error('Body consumption');
165
166        const source = new Request('https://example.com', {method:'POST', body:'source'});
167        const transferred = new Request(source);
168        if (!source.bodyUsed || await transferred.text() !== 'source') throw Error('Request transfer');
169
170        const form = new FormData();
171        form.append('field', 'before');
172        const formRequest = new Request('https://example.com', {method:'POST', body:form});
173        const formCopy = formRequest.clone();
174        form.set('field', 'after');
175        const formText = await formRequest.text(), formCopyText = await formCopy.text();
176        if (!formText.includes('before') || !formCopyText.includes('before') ||
177            formCopyText.includes('after')) throw Error('FormData snapshot');
178
179        const locked = new Response('locked'), reader = locked.body.getReader();
180        if (locked.bodyUsed) throw Error('Lock is not disturbance');
181        for (const operation of [() => locked.clone(), () => locked.text()]) {
182            try {await operation();throw Error('Locked body accepted');}
183            catch (error) {if (!(error instanceof TypeError)) throw error;}
184        }
185        await reader.cancel();
186        const disturbed = new Response('disturbed'), disturbedReader = disturbed.body.getReader();
187        await disturbedReader.read();
188        if (!disturbed.bodyUsed) throw Error('Direct stream read was not reflected');
189        try {disturbed.clone();throw Error('Disturbed body cloned');}
190        catch (error) {if (!(error instanceof TypeError)) throw error;}
191
192        const reason = {streamError:true};
193        const errored = new Response(new ReadableStream({start(controller) {controller.error(reason);}}));
194        const erroredCopy = errored.clone();
195        for (const body of [errored, erroredCopy]) {
196            try {await body.text();throw Error('Stream error was lost');}
197            catch (error) {if (error !== reason || !body.bodyUsed) throw error;}
198        }
199        console.log('clones');
200    "#,
201        &[],
202    );
203    assert_eq!(output.stdout, b"clones\n");
204}
205
206#[test]
207fn timeout_abort_rejects_pending_fetch_response_body_reads_with_reason() {
208    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
209    let url = format!("http://{}", listener.local_addr().unwrap());
210    let thread = std::thread::spawn(move || {
211        let (mut socket, _) = listener.accept().unwrap();
212        request(&mut socket);
213        socket
214            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nhello")
215            .unwrap();
216        socket
217            .set_read_timeout(Some(Duration::from_secs(5)))
218            .unwrap();
219        let mut byte = [0];
220        let _ = socket.read(&mut byte);
221    });
222    let source = r#"
223        const signal = AbortSignal.timeout(100);
224        const response = await fetch('URL', {signal});
225        const reader = response.body.getReader();
226        if ((await reader.read()).value.length !== 5) throw Error('Initial response chunk');
227        try {await reader.read();throw Error('Pending response read survived timeout');}
228        catch (error) {
229            if (error !== signal.reason || error.name !== 'TimeoutError' || error.code !== 23) throw error;
230        }
231        console.log('timeout abort');
232    "#
233    .replace("URL", &url);
234    assert_eq!(common::run(&source, &[]).stdout, b"timeout abort\n");
235    thread.join().unwrap();
236}
237
238#[test]
239fn navigator_user_agent_sets_fetch_defaults_without_overriding_explicit_headers() {
240    let (url, thread) = server(8, |req| {
241        let ua = req
242            .headers
243            .lines()
244            .find_map(|line| line.strip_prefix("user-agent:"))
245            .expect("Missing User-Agent")
246            .trim();
247        response("200 OK", "", ua.as_bytes())
248    });
249    let source = r#"
250        const url = 'URL';
251        const original = navigator.userAgent;
252        if ((await (await fetch(url)).text()) !== original.toLowerCase()) throw Error('Initial default');
253        const request = new Request(url);
254        navigator.userAgent = 'first/1.0';
255        const pending = fetch(request);
256        navigator.userAgent = 'second/2.0';
257        if ((await (await pending).text()) !== 'first/1.0') throw Error('In-flight UA changed');
258        if ((await (await fetch(request)).text()) !== 'second/2.0') throw Error('Default not updated');
259        if (request.headers.has('user-agent')) throw Error('Original request mutated');
260        if ((await (await fetch(url, {headers: {'uSeR-aGeNt': 'explicit'}})).text()) !== 'explicit') throw Error('Explicit UA lost');
261        if ((await (await fetch(url, {headers: {'User-Agent': ''}})).text()) !== '') throw Error('Explicit empty UA lost');
262        navigator.userAgent = '';
263        if ((await (await fetch(url)).text()) !== '') throw Error('Empty default lost');
264        for (const invalid of ['bad\r\ninjected: yes', 'bad\0value', '\u0100']) {
265            navigator.userAgent = invalid;
266            try { await fetch(url); throw Error('Invalid UA accepted'); }
267            catch (error) { if (!(error instanceof TypeError)) throw error; }
268        }
269        if ((await (await fetch(url, {headers: {'User-Agent': 'override'}})).text()) !== 'override') throw Error('Invalid default affected explicit UA');
270        navigator.userAgent = 'hardened/1.0';
271        Object.defineProperty(navigator, 'userAgent', {get() {throw Error('Guest getter');}});
272        Headers.prototype.get = Headers.prototype.set = () => {throw Error('Guest header method');};
273        if ((await (await fetch(url)).text()) !== 'hardened/1.0') throw Error('Internal default lost');
274        console.log('fetch UA');
275    "#
276    .replace("URL", &url);
277    assert_eq!(common::run(&source, &[]).stdout, b"fetch UA\n");
278    thread.join().unwrap();
279}
280
281#[test]
282fn worker_user_agent_defaults_are_isolate_local() {
283    let (url, thread) = server(2, |req| {
284        let ua = req
285            .headers
286            .lines()
287            .find_map(|line| line.strip_prefix("user-agent:"))
288            .unwrap()
289            .trim();
290        response("200 OK", "", ua.as_bytes())
291    });
292    let dir = tempfile::tempdir().unwrap();
293    std::fs::write(
294        dir.path().join("worker.js"),
295        r#"
296        const original = navigator.userAgent;
297        navigator.userAgent = 'worker/1.0';
298        const received = await (await fetch('URL')).text();
299        postMessage({original, current: navigator.userAgent, received});
300        close();
301    "#
302        .replace("URL", &url),
303    )
304    .unwrap();
305    std::fs::write(
306        dir.path().join("main.js"),
307        r#"
308        const original = navigator.userAgent;
309        navigator.userAgent = 'parent/1.0';
310        const worker = new Worker('./worker.js', {type: 'module'});
311        const result = await new Promise((resolve, reject) => {
312            worker.onmessage = event => resolve(event.data);
313            worker.onerror = reject;
314        });
315        if (result.original !== original || result.current !== 'worker/1.0' || result.received !== 'worker/1.0') throw Error('Worker UA');
316        if (navigator.userAgent !== 'parent/1.0' || (await (await fetch('URL')).text()) !== 'parent/1.0') throw Error('Parent UA');
317        console.log('isolated UA');
318    "#
319        .replace("URL", &url),
320    ).unwrap();
321    let output = common::wait(
322        Command::new(env!("CARGO_BIN_EXE_avril"))
323            .arg("run")
324            .arg(dir.path().join("main.js"))
325            .stdout(std::process::Stdio::piped())
326            .stderr(std::process::Stdio::piped())
327            .spawn()
328            .unwrap(),
329    );
330    assert!(
331        output.status.success(),
332        "{}",
333        String::from_utf8_lossy(&output.stderr)
334    );
335    assert_eq!(output.stdout, b"isolated UA\n");
336    thread.join().unwrap();
337}
338
339#[test]
340fn downloads_streams_bytes_and_decompresses_without_blocking_js() {
341    let (url, thread) = server(4, |req| {
342        assert!(req.headers.contains("user-agent: avril/"));
343        match req.path.as_str() {
344            "/json" => response(
345                "200 OK",
346                "Content-Type: application/json\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\n",
347                b"{\"answer\":42}",
348            ),
349            "/head" => response("204 No Content", "", b""),
350            "/gzip" => response(
351                "200 OK",
352                "Content-Encoding: gzip\r\n",
353                &[
354                    31, 139, 8, 0, 0, 0, 0, 0, 2, 3, 203, 72, 205, 201, 201, 7, 0, 134, 166, 16,
355                    54, 5, 0, 0, 0,
356                ],
357            ),
358            _ => response("200 OK", "", &[0, 128, 255, 42]),
359        }
360    });
361    let source=r#"
362        const root='URL';
363        const response=await fetch(root+'/json');if(!response.ok||response.status!==200||response.bodyUsed||response.headers.getSetCookie().length!==2)throw Error('Metadata');
364        try{response.headers.set('x','y');throw Error('Mutable headers');}catch(e){if(!(e instanceof TypeError))throw e;}
365        const copy=response.clone();if((await copy.json()).answer!==42||!(await response.bytes() instanceof Uint8Array))throw Error('JSON/bytes');
366        const bytes=[];for await(const chunk of (await fetch(root+'/binary')).body)for(const byte of chunk)bytes.push(byte);
367        if(bytes.join(',')!=='0,128,255,42')throw Error('Stream');
368        if(await(await fetch(root+'/gzip')).text()!=='hello')throw Error('Gzip');
369        const head=await fetch(root+'/head');if(head.body!==null||(await head.bytes()).length!==0)throw Error('Null body');
370        console.log('download');
371    "#.replace("URL",&url);
372    assert_eq!(common::run(&source, &[]).stdout, b"download\n");
373    thread.join().unwrap();
374}
375
376#[test]
377fn uploads_buffer_views_strings_and_backpressured_streams() {
378    let (url, thread) = server(3, |req| {
379        assert_eq!(req.method, "POST");
380        response("200 OK", "", &req.body)
381    });
382    let source=r#"
383        const root='URL';
384        const view=new DataView(new Uint8Array([9,1,2,3,8]).buffer,1,3);
385        if([...await(await fetch(root,{method:'POST',body:view})).bytes()].join(',')!=='1,2,3')throw Error('View');
386        if(await(await fetch(root,{method:'POST',body:'😀'})).text()!=='😀')throw Error('UTF8');
387        let count=0;const stream=new ReadableStream({pull(c){if(count===4)c.close();else c.enqueue(new Uint8Array([++count]));}});
388        const r=await fetch(root,{method:'POST',body:stream,duplex:'half'});if([...await r.bytes()].join(',')!=='1,2,3,4')throw Error('Upload');
389        console.log('upload');
390    "#.replace("URL",&url);
391    assert_eq!(common::run(&source, &[]).stdout, b"upload\n");
392    thread.join().unwrap();
393}
394
395#[test]
396fn redirects_check_live_permissions_and_strip_cross_origin_credentials() {
397    let (target, target_thread) = server(1, |req| {
398        assert!(!req.headers.contains("authorization:"));
399        assert!(!req.headers.contains("cookie:"));
400        response("200 OK", "", b"target")
401    });
402    let location = target.clone();
403    let (source, source_thread) = server(3, move |_| {
404        response("302 Found", &format!("Location: {location}\r\n"), b"")
405    });
406    let dir = tempfile::tempdir().unwrap();
407    let path = dir.path().join("main.js");
408    std::fs::write(&path,r#"
409        import{pledge}from'avril:std';const source='SOURCE';const target='TARGET';
410        const manual=await fetch(source,{redirect:'manual'});if(manual.status!==302||manual.redirected)throw Error('Manual');await manual.body.cancel();
411        try{await fetch(source);throw Error('Unauthorized redirect');}catch(e){if(!e.message.includes('PermissionDenied'))throw e;}
412        pledge({net:false});try{await fetch(source);throw Error('Pledge ignored');}catch(e){if(!e.message.includes('PermissionDenied'))throw e;}
413        console.log('denied');
414    "#.replace("SOURCE",&source).replace("TARGET",&target)).unwrap();
415    let host = source.strip_prefix("http://").unwrap();
416    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
417        .arg("run")
418        .args(["-0", &format!("--allow-net={host}"), path.to_str().unwrap()])
419        .output()
420        .unwrap();
421    assert!(
422        output.status.success(),
423        "{}",
424        String::from_utf8_lossy(&output.stderr)
425    );
426    assert_eq!(output.stdout, b"denied\n");
427    let script = format!(
428        "const r=await fetch('{source}',{{headers:{{authorization:'secret',cookie:'secret'}}}});console.log(await r.text(),r.redirected,r.url==='{target}/');"
429    );
430    assert_eq!(common::run(&script, &[]).stdout, b"target true true\n");
431    source_thread.join().unwrap();
432    target_thread.join().unwrap();
433}
434
435#[test]
436fn aborting_downloads_preserves_reasons_and_cancels_native_reads() {
437    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
438    let url = format!("http://{}", listener.local_addr().unwrap());
439    let thread = std::thread::spawn(move || {
440        let (mut socket, _) = listener.accept().unwrap();
441        request(&mut socket);
442        socket
443            .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nhello")
444            .unwrap();
445        socket
446            .set_read_timeout(Some(Duration::from_secs(5)))
447            .unwrap();
448        let mut byte = [0];
449        let _ = socket.read(&mut byte);
450    });
451    let source=r#"
452        const controller=new AbortController(), reason={abort:true};
453        const response=await fetch('URL',{signal:controller.signal});const reader=response.body.getReader();const first=await reader.read();if(first.value.length!==5)throw Error('Chunk');
454        const pending=reader.read();controller.abort(reason);try{await pending;throw Error('Expected abort');}catch(e){if(e!==reason)throw e;}
455        try{await fetch('URL',{signal:AbortSignal.abort(reason)});throw Error('Pre-abort');}catch(e){if(e!==reason)throw e;}
456        console.log('abort');
457    "#.replace("URL",&url);
458    assert_eq!(common::run(&source, &[]).stdout, b"abort\n");
459    thread.join().unwrap();
460}
461
462#[test]
463fn byob_and_external_consumption_track_disturbance_and_close() {
464    let (url, thread) = server(1, |_| response("200 OK", "", &[1, 2, 3, 4, 5]));
465    let source=r#"
466        const response=await fetch('URL');const reader=response.body.getReader({mode:'byob'});
467        const bytes=[];while(true){const result=await reader.read(new Uint8Array(2));if(result.done)break;for(const byte of result.value)bytes.push(byte);}
468        reader.releaseLock();if(bytes.join(',')!=='1,2,3,4,5'||!response.bodyUsed)throw Error('BYOB');
469        try{response.clone();throw Error('Cloned disturbed body');}catch(e){if(!(e instanceof TypeError))throw e;}
470        const empty=new Response('');const r=empty.body.getReader({mode:'byob'});if(!(await r.read(new Uint8Array(1))).done)throw Error('Empty BYOB');
471        console.log('byob');
472    "#.replace("URL",&url);
473    assert_eq!(common::run(&source, &[]).stdout, b"byob\n");
474    thread.join().unwrap();
475}
476
477#[test]
478fn forbidden_header_checks_and_private_fetch_bindings_survive_overrides() {
479    let (url, thread) = server(1, |req| {
480        assert!(req.headers.contains("x-test: yes"));
481        response("200 OK", "X-Value: original\r\n", b"ok")
482    });
483    let source=r#"
484        const root='URL';
485        for(const name of ['host','HOST','content-length','transfer-encoding','connection','proxy-authorization']) {
486            try{await fetch(root,{headers:{[name]:'bad'}});throw Error('Unsafe header');}catch(e){if(!(e instanceof TypeError))throw e;}
487        }
488        const poison=()=>{throw Error('Poisoned public method');};
489        Headers.prototype.append=poison;Headers.prototype.entries=poison;Headers.prototype[Symbol.iterator]=poison;
490        JSON.parse=poison;Object.getOwnPropertyDescriptor=poison;AbortSignal.any=poison;
491        const r=await fetch(root,{headers:{'X-Test':'yes'}});if(r.headers.get('x-value')!=='original'||await r.text()!=='ok')throw Error('Bindings');
492        console.log('protected');
493    "#.replace("URL",&url);
494    assert_eq!(common::run(&source, &[]).stdout, b"protected\n");
495    thread.join().unwrap();
496}
497
498#[test]
499fn aborting_a_stalled_upload_cancels_its_source() {
500    let source = r#"
501        const controller=new AbortController();let canceled=false;
502        const body=new ReadableStream({pull(){controller.abort('stop');return new Promise(()=>{});},cancel(){canceled=true;}});
503        try{await fetch('http://127.0.0.1:1',{method:'POST',body,duplex:'half',signal:controller.signal});throw Error('Expected abort');}catch(e){if(e!=='stop')throw e;}
504        if(!canceled)throw Error('Upload source not canceled');console.log('canceled');
505    "#;
506    assert_eq!(common::run(source, &[]).stdout, b"canceled\n");
507}
508
509#[test]
510fn aborted_uploads_ignore_cleanup_rejections_and_preserve_the_abort_reason() {
511    let source = r#"
512        for (const delayed of [false,true]) {
513            const controller = new AbortController(), reason = {aborted:true};
514            let canceled = 0;
515            const body = new ReadableStream({
516                pull() {controller.abort(reason); return new Promise(() => {});},
517                cancel(error) {
518                    if (error !== reason) throw Error('Wrong cancellation reason');
519                    canceled++;
520                    if (!delayed) throw Error('Cancellation failed');
521                    return new Promise((resolve,reject) => queueMicrotask(() => reject(Error('Cancellation failed'))));
522                }
523            });
524            try {
525                await fetch('http://127.0.0.1:1', {
526                    method:'POST',body,duplex:'half',signal:controller.signal
527                });
528                throw Error('Expected abort');
529            } catch (error) {if (error !== reason) throw error;}
530            await new Promise(resolve => setTimeout(resolve));
531            if (canceled !== 1) throw Error('Cancellation count');
532        }
533        console.log('cleanup');
534    "#;
535    assert_eq!(common::run(source, &[]).stdout, b"cleanup\n");
536}
537
538#[test]
539fn transferring_cached_request_bodies_cancels_the_old_stream_without_changing_bytes() {
540    let (url, thread) = server(1, |req| {
541        assert_eq!(req.method, "POST");
542        response("200 OK", "", &req.body)
543    });
544    let source = format!(
545        r#"
546        const original = new Request('{url}',{{method:'POST',body:new Uint8Array([0,128,255])}});
547        const moved = new Request(original);
548        if (!original.bodyUsed || moved.bodyUsed) throw Error('Transfer state');
549        const reader = original.body.getReader();
550        if (!(await reader.read()).done) throw Error('Original stream not canceled');
551        reader.releaseLock();
552        const response = await fetch(moved);
553        if ([...await response.bytes()].join(',') !== '0,128,255') throw Error('Transferred bytes');
554        console.log('transfer');
555        "#
556    );
557    assert_eq!(common::run(&source, &[]).stdout, b"transfer\n");
558    thread.join().unwrap();
559}
560
561#[test]
562fn early_redirect_stops_upload_without_canceling_the_response() {
563    let (target, target_thread) = server(1, |req| {
564        assert_eq!(req.method, "GET");
565        assert!(req.body.is_empty());
566        response("200 OK", "", b"redirected")
567    });
568    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
569    let source = format!("http://{}", listener.local_addr().unwrap());
570    let source_thread = std::thread::spawn(move || {
571        let (mut socket, _) = listener.accept().unwrap();
572        socket
573            .set_read_timeout(Some(Duration::from_secs(5)))
574            .unwrap();
575        let mut reader = BufReader::new(&mut socket);
576        let mut line = String::new();
577        loop {
578            line.clear();
579            reader.read_line(&mut line).unwrap();
580            if line == "\r\n" {
581                break;
582            }
583        }
584        socket
585            .write_all(&response(
586                "303 See Other",
587                &format!("Location: {target}\r\n"),
588                b"",
589            ))
590            .unwrap();
591        // Closing with unread upload bytes can reset TCP before the response arrives.
592        socket.shutdown(Shutdown::Write).unwrap();
593        let _ = std::io::copy(&mut socket, &mut std::io::sink());
594    });
595    let script = format!(
596        r#"
597        let canceled=false;const body=new ReadableStream({{pull(c){{c.enqueue(new Uint8Array(65536));}},cancel(){{canceled=true;}}}},{{highWaterMark:0}});
598        const response=await fetch('{source}',{{method:'POST',body,duplex:'half'}});
599        if(await response.text()!=='redirected'||!response.redirected)throw Error('Redirect');
600        if(!canceled)throw Error('Upload kept running');console.log('early');
601    "#
602    );
603    assert_eq!(common::run(&script, &[]).stdout, b"early\n");
604    source_thread.join().unwrap();
605    target_thread.join().unwrap();
606}
607
608#[test]
609fn blob_and_file_uploads_replay_on_redirect_and_download_as_blobs() {
610    let (url, thread) = server(2, |req| {
611        assert_eq!(req.method, "POST");
612        assert_eq!(req.body, b"upload");
613        assert!(req.headers.contains("content-type: text/plain\r\n"));
614        assert!(req.headers.contains("content-length: 6\r\n"));
615        if req.path == "/redirect" {
616            response("307 Temporary Redirect", "Location: /final\r\n", b"")
617        } else {
618            assert_eq!(req.path, "/final");
619            response(
620                "200 OK",
621                "Content-Type: Text/HTML; Charset=GBK\r\n",
622                b"download",
623            )
624        }
625    });
626    let source = format!(
627        r#"
628        const file=new File(['upload'],'upload.txt',{{type:'TEXT/PLAIN'}});
629        const result=await fetch('{url}/redirect',{{method:'POST',body:file}});
630        const blob=await result.blob();
631        if(blob.type !== 'text/html;charset=GBK' || await blob.text() !== 'download' || !result.bodyUsed || !result.redirected)throw Error('Network Blob');
632        if(await file.text() !== 'upload')throw Error('Upload mutated File');
633        console.log('blob network');
634    "#
635    );
636    let output = common::run(&source, &[]);
637    assert_eq!(output.stdout, b"blob network\n");
638    thread.join().unwrap();
639}
640
641#[test]
642fn multipart_uploads_are_snapshotted_replayable_and_parse_on_download() {
643    let first = std::sync::Arc::new(std::sync::Mutex::new(None::<Vec<u8>>));
644    let (url, thread) = server(2, move |req| {
645        assert_eq!(req.method, "POST");
646        let content_type = req
647            .headers
648            .lines()
649            .find_map(|line| line.strip_prefix("content-type: "))
650            .unwrap();
651        assert!(content_type.starts_with("multipart/form-data; boundary=----avril"));
652        assert!(
653            req.headers
654                .contains(&format!("content-length: {}\r\n", req.body.len()))
655        );
656        if req.path == "/start" {
657            *first.lock().unwrap() = Some(req.body);
658            response("307 Temporary Redirect", "Location: /finish\r\n", b"")
659        } else {
660            assert_eq!(req.path, "/finish");
661            assert_eq!(first.lock().unwrap().as_ref().unwrap(), &req.body);
662            response(
663                "200 OK",
664                &format!("Content-Type: {content_type}\r\n"),
665                &req.body,
666            )
667        }
668    });
669    let source = format!(
670        r#"
671        const f=new FormData();f.append('field','value');f.append('file',new File([new Uint8Array([0,255,3])],'data.bin',{{type:'application/octet-stream'}}));
672        const captured=new Request('{url}/start',{{method:'POST',body:f}});
673        f.set('field','changed');
674        const cleared=new Request(captured.clone(),{{headers:{{}}}});
675        if(cleared.headers.get('content-type') !== null)throw Error('Inherited body re-added Content-Type');
676        const copied=new Request(captured);
677        const result=await fetch(copied), data=await result.formData();
678        if(!result.redirected || data.get('field') !== 'value' || data.get('file').name !== 'data.bin' || (await data.get('file').bytes()).join() !== '0,255,3')throw Error('Network multipart');
679        console.log('multipart network');
680    "#
681    );
682    assert_eq!(common::run(&source, &[]).stdout, b"multipart network\n");
683    thread.join().unwrap();
684}
685
686#[test]
687fn collects_fetch_bodies_and_preserves_stream_state() {
688    let (url, thread) = server(5, |req| match req.path.as_str() {
689        "/empty" => response("200 OK", "", b""),
690        "/text" => response(
691            "200 OK",
692            "",
693            &["\u{feff}hello 😀".as_bytes(), &[0xff]].concat(),
694        ),
695        "/json" => response("200 OK", "", br#"{"answer":42}"#),
696        _ => response(
697            "200 OK",
698            "",
699            &(0..262_177).map(|i| (i % 251) as u8).collect::<Vec<_>>(),
700        ),
701    });
702    let source = r#"
703        const root='URL', controller=new AbortController();
704        const response=await fetch(root,{signal:controller.signal}), body=response.body;
705        const initialReader=body.getReader();
706        try{await response.bytes();throw Error('Accepted locked body');}catch(e){if(!(e instanceof TypeError))throw e;}
707        if(response.bodyUsed)throw Error('Lock disturbed body');initialReader.releaseLock();
708        const pending=response.bytes();
709        if(!response.bodyUsed||!body.locked)throw Error('Consumption state');
710        try{response.clone();throw Error('Cloned pending body');}catch(e){if(!(e instanceof TypeError))throw e;}
711        try{await response.text();throw Error('Consumed pending body');}catch(e){if(!(e instanceof TypeError))throw e;}
712        const bytes=await pending;
713        if(!(bytes instanceof Uint8Array)||bytes.length!==262177||body.locked)throw Error('Collection');
714        for(let i=0;i<bytes.length;i++)if(bytes[i]!==i%251)throw Error('Corrupt bytes');
715        controller.abort('after completion');
716        const reader=body.getReader();await reader.closed;
717        if(!(await reader.read()).done)throw Error('Stream not closed');reader.releaseLock();
718        const byob=body.getReader({mode:'byob'});
719        if(!(await byob.read(new Uint8Array(1))).done)throw Error('BYOB not closed');byob.releaseLock();
720        const buffer=await(await fetch(root)).arrayBuffer();
721        if(!(buffer instanceof ArrayBuffer)||buffer.byteLength!==262177)throw Error('ArrayBuffer');
722        const view=new Uint8Array(buffer);for(let i=0;i<view.length;i++)if(view[i]!==i%251)throw Error('Corrupt buffer');
723        const empty=await fetch(root+'/empty');if(!empty.body||(await empty.bytes()).length!==0)throw Error('Empty');
724        const emptyReader=empty.body.getReader();await emptyReader.closed;
725        if(!(await emptyReader.read()).done)throw Error('Empty stream not closed');emptyReader.releaseLock();
726        const original=await fetch(root+'/text'), wrapped=new Response(original.body);
727        if(await wrapped.text()!=='hello 😀�'||!original.bodyUsed||!wrapped.bodyUsed)throw Error('UTF8/wrapped body');
728        if((await(await fetch(root+'/json')).json()).answer!==42)throw Error('JSON');
729        console.log('collected');
730    "#.replace("URL", &url);
731    assert_eq!(common::run(&source, &[]).stdout, b"collected\n");
732    thread.join().unwrap();
733}
734
735#[test]
736fn collection_keeps_cloned_and_custom_streams_working() {
737    let (url, thread) = server(1, |_| response("200 OK", "", &vec![123; 131_079]));
738    let source = r#"
739        const response=await fetch('URL'), copy=response.clone();
740        const [a,b]=await Promise.all([response.bytes(),copy.bytes()]);
741        for(const bytes of [a,b]){
742            if(bytes.length!==131079)throw Error('Clone length');
743            for(const byte of bytes)if(byte!==123)throw Error('Clone data');
744        }
745        if(!response.bodyUsed||!copy.bodyUsed)throw Error('Clone disturbance');
746        a[0]=0;if(b[0]!==123)throw Error('Clone shared storage');
747        const stream=new ReadableStream({start(c){c.enqueue(new Uint8Array([1,2]));c.enqueue(new Uint8Array([3]));c.close();}});
748        if([...await new Response(stream).bytes()].join(',')!=='1,2,3')throw Error('Custom stream');
749        const invalid=new Response(new ReadableStream({start(c){c.enqueue('not bytes');c.close();}}));
750        try{await invalid.bytes();throw Error('Accepted nonbytes');}catch(e){if(!(e instanceof TypeError))throw e;}
751        const reason={custom:true}, failed=new Response(new ReadableStream({start(c){c.error(reason);}}));
752        try{await failed.text();throw Error('Accepted failed stream');}catch(e){if(e!==reason)throw e;}
753        console.log('fallback');
754    "#.replace("URL", &url);
755    assert_eq!(common::run(&source, &[]).stdout, b"fallback\n");
756    thread.join().unwrap();
757}
758
759#[test]
760fn aborting_collection_preserves_reasons_and_releases_locks() {
761    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
762    let url = format!("http://{}", listener.local_addr().unwrap());
763    let thread = std::thread::spawn(move || {
764        for _ in 0..3 {
765            let (mut socket, _) = listener.accept().unwrap();
766            request(&mut socket);
767            socket
768                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nhello")
769                .unwrap();
770            socket
771                .set_read_timeout(Some(Duration::from_secs(5)))
772                .unwrap();
773            let mut byte = [0];
774            let _ = socket.read(&mut byte);
775        }
776    });
777    let source = r#"
778        for(const method of ['bytes','text']){
779            const controller=new AbortController(), reason={method};
780            const response=await fetch('URL',{signal:controller.signal});
781            const pending=response[method]();
782            if(!response.bodyUsed||!response.body.locked)throw Error('Pending state');
783            setTimeout(()=>controller.abort(reason),0);
784            try{await pending;throw Error('Expected abort');}catch(e){if(e!==reason)throw e;}
785            if(response.body.locked||!response.bodyUsed)throw Error('Aborted state');
786            const reader=response.body.getReader();
787            if(await reader.closed.catch(e=>e)!==reason)throw Error('Closed reason');
788            try{await reader.read();throw Error('Expected stream error');}catch(e){if(e!==reason)throw e;}
789            reader.releaseLock();
790        }
791        const controller=new AbortController(), reason={before:true};
792        const response=await fetch('URL',{signal:controller.signal});controller.abort(reason);
793        try{await response.bytes();throw Error('Accepted aborted body');}catch(e){if(e!==reason)throw e;}
794        if(response.body.locked||!response.bodyUsed)throw Error('Pre-aborted state');
795        console.log('collection aborted');
796    "#.replace("URL", &url);
797    assert_eq!(common::run(&source, &[]).stdout, b"collection aborted\n");
798    thread.join().unwrap();
799}
800
801#[test]
802fn collection_rejects_truncated_responses_and_errors_the_stream() {
803    let (url, thread) = server(1, |_| {
804        b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 100000\r\n\r\nshort".to_vec()
805    });
806    let source = r#"
807        const response=await fetch('URL');let failure;
808        try{await response.bytes();throw Error('Accepted truncated body');}catch(e){if(!(e instanceof TypeError))throw e;failure=e;}
809        if(!response.bodyUsed||response.body.locked)throw Error('Failed state');
810        const reader=response.body.getReader();
811        if(await reader.closed.catch(e=>e)!==failure)throw Error('Closed error');
812        try{await reader.read();throw Error('Accepted errored stream');}catch(e){if(e!==failure)throw e;}
813        reader.releaseLock();
814        console.log('truncated');
815    "#.replace("URL", &url);
816    assert_eq!(common::run(&source, &[]).stdout, b"truncated\n");
817    thread.join().unwrap();
818}
819
820#[test]
821fn body_text_decodes_ascii_unicode_boms_and_malformed_utf8() {
822    let (url, thread) = server(12, |req| {
823        response(
824            "200 OK",
825            "Content-Type: text/plain; charset=utf-16\r\n",
826            &req.body,
827        )
828    });
829    let source = r#"
830        const root='URL';
831        const cases=[
832            [[], ''],
833            [[0,65,127], '\0A\x7f'],
834            [[239,187,191], ''],
835            [[239,187,191,239,187,191,65], '\ufeffA'],
836            [[195,169,226,130,172,240,159,152,128], 'é€😀'],
837            [[237,160,128], '���'],
838            [[240,128,128,65,255], '���A�'],
839            [[226,130], '�'],
840            [[255,254,65,0], '��A\0'],
841            [[239,187], '�'],
842        ];
843        const ascii=Array.from({length:128},(_,i)=>i);cases.push([ascii,String.fromCharCode(...ascii)]);
844        for(const [bytes,expected] of cases){
845            const input=new Uint8Array(bytes);
846            const native=await fetch(root,{method:'POST',body:input});
847            if(await native.text()!==expected||!native.bodyUsed||native.body.locked)throw Error('Native text');
848            if(await new Response(input).text()!==expected)throw Error('Memory text');
849            let i=0;const streamed=new Response(new ReadableStream({type:'bytes',pull(c){
850                if(i===bytes.length)c.close();else c.enqueue(new Uint8Array([bytes[i++]]));
851            }},{highWaterMark:0}));
852            const copy=streamed.clone();
853            const texts=await Promise.all([streamed.text(),copy.text()]);
854            if(texts.some(text=>text!==expected))throw Error('Split/cloned text');
855        }
856        const jsonBytes=new TextEncoder().encode('\ufeff{"word":"é😀"}');
857        const native=await fetch(root,{method:'POST',body:jsonBytes});
858        if((await native.json()).word!=='é😀'||(await new Response(jsonBytes).json()).word!=='é😀')throw Error('JSON text');
859        const invalid=new Response('{');
860        try{await invalid.json();throw Error('Accepted invalid JSON');}catch(e){if(!(e instanceof SyntaxError))throw e;}
861        if(!invalid.bodyUsed||invalid.body.locked)throw Error('Invalid JSON state');
862        console.log('body text');
863    "#.replace("URL", &url);
864    assert_eq!(common::run(&source, &[]).stdout, b"body text\n");
865    thread.join().unwrap();
866}
867
868#[test]
869fn native_collection_converts_blob_and_form_data_before_resolving_private_bytes() {
870    let (url, thread) = server(2, |req| {
871        if req.path == "/blob" {
872            response(
873                "200 OK",
874                "Content-Type: Text/Plain; Charset=UTF-8\r\n",
875                b"immutable",
876            )
877        } else {
878            response("200 OK","Content-Type: multipart/form-data; boundary=B\r\n",b"--B\r\nContent-Disposition: form-data; name=\"file\"; filename=\"data\"\r\n\r\nimmutable\r\n--B--\r\n")
879        }
880    });
881    let source = format!(
882        r#"
883        const retained=[], U8=Uint8Array, resolutions=new Map();
884        Object.defineProperty(Object.prototype,'then',{{configurable:true,get(){{
885            if(this instanceof U8)retained.push(this);
886            if(this instanceof Blob)resolutions.set(this,(resolutions.get(this)||0)+1);
887            const d=Object.getOwnPropertyDescriptor(this,'value');
888            if(d && d.value instanceof U8)retained.push(d.value);
889            return undefined;
890        }}}});
891        const response=await fetch('{url}/blob'), blob=await response.blob();
892        if(blob.type !== 'text/plain;charset=UTF-8')throw Error('Native MIME conversion');
893        if(resolutions.get(blob)!==1)throw Error('Blob promise resolved more than once');
894        for(const bytes of retained)if(bytes.byteLength)bytes.fill(0);
895        if(await blob.text() !== 'immutable' || !response.bodyUsed || response.body.locked)throw Error('Native Blob aliases a public chunk');
896        const form=await(await fetch('{url}/form')).formData(), file=form.get('file');
897        for(const bytes of retained)if(bytes.byteLength)bytes.fill(0);
898        if(file.name !== 'data' || await file.text() !== 'immutable')throw Error('Native FormData conversion');
899        console.log('native conversion');
900    "#
901    );
902    assert_eq!(common::run(&source, &[]).stdout, b"native conversion\n");
903    thread.join().unwrap();
904}
905
906#[test]
907fn fetch_init_dictionaries_reject_primitives_and_preserve_inherited_members() {
908    let out = common::run(
909        r#"
910        for(const init of [2,'bad',true,1n,Symbol()]){
911            for(const call of [()=>new Request('https://example.com',init),()=>new Response(null,init),()=>Response.json({},init)]){
912                try{call();throw Error('Accepted primitive dictionary');}catch(e){if(!(e instanceof TypeError))throw e;}
913            }
914        }
915        const response=Response.json({ok:true},Object.create({status:201,statusText:'Created'}));
916        if(response.status!==201||response.statusText!=='Created')throw Error('Inherited ResponseInit lost');
917        console.log('fetch dictionaries');
918    "#,
919        &[],
920    );
921    assert_eq!(out.stdout, b"fetch dictionaries\n");
922}
923
924#[test]
925fn redirect_releases_upload_writes_stalled_on_an_unread_request_body_despite_failing_cleanup() {
926    let (target, target_thread) = server(1, |_| response("200 OK", "", b"redirected"));
927    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
928    let source = format!("http://{}", listener.local_addr().unwrap());
929    let (done, finished) = std::sync::mpsc::channel::<()>();
930    let source_thread = std::thread::spawn(move || {
931        let (mut socket, _) = listener.accept().unwrap();
932        let mut reader = BufReader::new(socket.try_clone().unwrap());
933        let mut line = String::new();
934        while line != "\r\n" {
935            line.clear();
936            reader.read_line(&mut line).unwrap();
937        }
938        // Let the upload fill the socket and channel before answering.
939        std::thread::sleep(Duration::from_millis(300));
940        socket
941            .write_all(&response(
942                "303 See Other",
943                &format!("Location: {target}\r\n"),
944                b"",
945            ))
946            .unwrap();
947        // Hold the connection open without reading the body, so the upload stalls.
948        let _ = finished.recv_timeout(Duration::from_secs(5));
949    });
950    let script = format!(
951        r#"
952        let writes=0;const body=new ReadableStream({{pull(c){{writes++;c.enqueue(new Uint8Array(1<<20));}},cancel(){{throw Error('cleanup failed');}}}},{{highWaterMark:0}});
953        const response=await fetch('{source}',{{method:'POST',body,duplex:'half'}});
954        console.log(await response.text(), writes > 1);
955    "#
956    );
957    assert_eq!(common::run(&script, &[]).stdout, b"redirected true\n");
958    done.send(()).unwrap();
959    source_thread.join().unwrap();
960    target_thread.join().unwrap();
961}
962
963#[test]
964fn upload_failures_keep_native_buffer_checks_and_reentrant_abort_precedence() {
965    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
966    let url = format!("http://{}", listener.local_addr().unwrap());
967    let output = common::run(
968        &format!(
969            r#"
970        for (const [chunk, expected] of [
971            [new Uint8Array(new SharedArrayBuffer(1)), 'buffer'],
972            [new Uint8Array(new ArrayBuffer(1, {{maxByteLength: 4}})), 'resizable']
973        ]) {{
974            const body = new ReadableStream({{start(c) {{c.enqueue(chunk); c.close();}}}});
975            try {{
976                await fetch('{url}', {{method: 'POST', body, duplex: 'half'}});
977                throw Error('Accepted unsupported upload buffer');
978            }} catch (error) {{
979                if (!(error instanceof TypeError) || !error.message.toLowerCase().includes(expected)) throw error;
980            }}
981            if (body.locked) throw Error('Failed upload retained its reader');
982        }}
983        const aborter = new AbortController(), reason = {{}}, order = [];
984        const error = {{
985            get message() {{
986                order.push('message');
987                aborter.abort(reason);
988                return {{toString() {{order.push('string'); return 'upload failed';}}}};
989            }}
990        }};
991        const body = new ReadableStream({{start(c) {{c.error(error);}}}});
992        try {{
993            await fetch('{url}', {{method: 'POST', body, duplex: 'half', signal: aborter.signal}});
994            throw Error('Accepted errored upload');
995        }} catch (error) {{
996            if (error !== reason) throw Error('Lost reentrant abort reason');
997        }}
998        if (order.join('|') !== 'message|string' || body.locked) throw Error('Error conversion or reader release');
999        console.log('upload boundary and abort ordering');
1000        "#
1001        ),
1002        &[],
1003    );
1004    assert_eq!(output.stdout, b"upload boundary and abort ordering\n");
1005}