mod common; use common::run; #[test] fn form_data_is_an_ordered_live_collection_with_file_values() { let output = run( r#" const f=new FormData();f.append('a','1');f.append('b','2');f.append('a','3'); if(f.get('a') !== '1' || f.get('missing') !== null || f.getAll('a').join() !== '1,3' || !f.has('b'))throw Error('Collection'); const copy=f.getAll('a');copy[0]='bad'; f.set('a','new');if([...f.keys()].join() !== 'a,b' || f.get('a') !== 'new')throw Error('Set ordering'); f.delete('b');f.delete('missing'); const file=new File(['hello'],'original',{type:'Text/Plain',lastModified:123}); f.append('file',file);f.append('renamed',file,'other');f.append('blob',new Blob(['b'],{type:'x/y'})); if(f.get('file') !== file || f.get('renamed') === file || f.get('renamed').name !== 'other' || f.get('renamed').lastModified !== 123 || f.get('renamed').type !== 'text/plain')throw Error('File entries'); if(!(f.get('blob') instanceof File) || f.get('blob').name !== 'blob' || await f.get('blob').text() !== 'b')throw Error('Blob entries'); const iterator=f.entries();if(iterator[Symbol.iterator]() !== iterator || Object.prototype.toString.call(iterator) !== '[object FormData Iterator]')throw Error('Iterator'); iterator.next();f.append('late','value'); if([...iterator].at(-1)[0] !== 'late')throw Error('Live iterator'); f.append('later','v');const resumed=iterator.next();if(resumed.done || resumed.value[0] !== 'later')throw Error('Live iterator did not resume'); const live=new FormData();live.append('a','1');live.append('b','2');const seen=[]; const receiver={};live.forEach(function(value,name,self){if(this !== receiver || self !== live)throw Error('forEach');seen.push(name);if(name==='a'){self.delete('a');self.append('c','3');}},receiver); if(seen.join() !== 'a,c')throw Error('Live forEach'); if(FormData.length !== 0 || FormData.prototype.append.length !== 2 || FormData.prototype.set.length !== 2 || FormData.prototype[Symbol.iterator] !== FormData.prototype.entries)throw Error('Shape'); console.log('collection'); "#, &[], ); assert_eq!(output.stdout, b"collection\n"); } #[test] fn form_data_iterators_preserve_result_shapes_branding_and_callable_arity() { let output = run( r#" const form = new FormData(), file = new File(['payload'], 'file'); form.append('text', 'value'); form.append('file', file); for (const method of ['entries', 'keys', 'values']) { const iterator = form[method](), prototype = Object.getPrototypeOf(iterator); if (Object.hasOwn(prototype, 'constructor') || prototype.next.length !== 0 || FormData.prototype[method].length !== 0) throw Error('Iterator shape'); const first = iterator.next(), second = iterator.next(), end = iterator.next(); if (first.done || second.done || !end.done || end.value !== undefined) throw Error('Iterator results'); if (method === 'entries') { if (!Array.isArray(first.value) || first.value.length !== 2 || first.value[0] !== 'text' || first.value[1] !== 'value' || second.value[0] !== 'file' || second.value[1] !== file) throw Error('Entry pairs'); first.value[0] = 'changed'; first.value[1] = 'changed'; if (form.get('text') !== 'value') throw Error('Mutable entry alias'); } else if (method === 'keys') { if (first.value !== 'text' || second.value !== 'file') throw Error('Keys'); } else if (first.value !== 'value' || second.value !== file) throw Error('Values'); for (const receiver of [{}, form, [][Symbol.iterator]()]) { try { prototype.next.call(receiver); throw Error('Unbranded iterator'); } catch (error) { if (!(error instanceof TypeError)) throw error; } } } for (const method of ['delete', 'get', 'getAll', 'has', 'forEach']) { if (FormData.prototype[method].length !== 1) throw Error('Method arity'); } console.log('iterator contracts'); "#, &[], ); assert_eq!(output.stdout, b"iterator contracts\n"); } #[test] fn form_data_conversions_and_overloads_run_before_mutating_entries() { let output = run( r#" const f=new FormData(), log=[]; f.append({toString(){log.push('name');return 'name';}},{toString(){log.push('value');return '\ud800';}}); if(log.join() !== 'name,value' || f.get('name') !== '\ufffd')throw Error('String conversion'); for(const method of ['append','set']){ let converted=false; try {f[method]({toString(){converted=true;return 'x';}},{toString(){throw Error('Value converted');}},undefined);throw Error('Accepted string filename overload');} catch(e){if(!(e instanceof TypeError) || !converted)throw e;} f[method]('file',new Blob(['v']),undefined); if(f.get('file').name !== 'blob')throw Error('Undefined filename'); try {f[method]('bad',new Blob(),Symbol());throw Error('Symbol filename');}catch(e){if(!(e instanceof TypeError))throw e;} } f.append('key','old');f.append('key','duplicate'); f.set('key',{toString(){f.delete('key');f.append('other','x');f.append('key','side effect');return 'final';}}); if(f.getAll('key').join() !== 'final' || [...f.keys()].at(-1) !== 'key')throw Error('Set cached its indices before conversion'); for(const method of ['append','set','delete','get','getAll','has','forEach']){ try {FormData.prototype[method].call({});throw Error('Illegal receiver');}catch(e){if(!(e instanceof TypeError))throw e;} try {f[method]();throw Error('Missing argument');}catch(e){if(!(e instanceof TypeError))throw e;} } for(const operation of [()=>f.forEach(3),()=>new FormData({}),()=>new FormData(undefined,{}),()=>f.append(Symbol(),'v')]){ try {operation();throw Error('Invalid input accepted');}catch(e){if(!(e instanceof TypeError))throw e;} } console.log('conversions'); "#, &[], ); assert_eq!(output.stdout, b"conversions\n"); } #[test] fn multipart_serialization_snapshots_entries_and_normalizes_only_required_newlines() { let output = run( r#" const f=new FormData();f.append('n\r\nx\r"','v\rx\ny\r\nz'); f.append('binary',new File([new Uint8Array([0,255,13,10])],'f\r\n"\\.bin',{type:'APP/OCTET-STREAM'})); const r=new Response(f), type=r.headers.get('content-type'), boundary=type.slice('multipart/form-data; boundary='.length); if(!/^----avril[0-9a-f]{32}$/.test(boundary))throw Error('Boundary'); const second=new Response(f);if(second.headers.get('content-type')===type)throw Error('Reused boundary'); f.set('binary','mutated');f.append('late','late'); const bytes=await r.bytes(), text=new TextDecoder().decode(bytes); const expected='--'+boundary+'\r\nContent-Disposition: form-data; name="n%0D%0Ax%0D%0A%22"\r\n\r\nv\r\nx\r\ny\r\nz\r\n--'+boundary+'\r\nContent-Disposition: form-data; name="binary"; filename="f%0D%0A%22\\.bin"\r\nContent-Type: app/octet-stream\r\n\r\n'; if(!text.startsWith(expected) || !text.endsWith('\r\n--'+boundary+'--\r\n') || text.includes('mutated') || text.includes('late'))throw Error('Serialization'); const data=await new Response(bytes,{headers:{'content-type':type}}).formData(); const file=data.get('binary'); if(file.name !== 'f%0D%0A%22\\.bin' || file.type !== 'app/octet-stream' || (await file.bytes()).join() !== '0,255,13,10')throw Error('Binary roundtrip'); if(f.get('n\r\nx\r"') !== 'v\rx\ny\r\nz')throw Error('FormData mutated by normalization'); const empty=new Response(new FormData()), b=empty.headers.get('content-type').split('boundary=')[1]; if(await empty.text() !== '--'+b+'--\r\n')throw Error('Empty encoding'); console.log('encoding'); "#, &[], ); assert_eq!(output.stdout, b"encoding\n"); } #[test] fn urlencoded_form_data_percent_decodes_bytes_before_utf8_and_preserves_boms() { let output = run( r#" const header={'content-type':'application/x-www-form-urlencoded;charset=windows-1252'}; const data=await new Response(new Uint8Array([0xc2,37,56,48,61,37,69,70,37,66,66,37,66,70]),{headers:header}).formData(); if(data.get('\u0080') !== '\ufeff')throw Error('Percent decode before UTF8'); const f=await new Response('\ufefftest=\ufeff&a=1&a=2&x=a+b&bad=%ff&novalue&=empty&&',{headers:header}).formData(); if(f.get('\ufefftest') !== '\ufeff' || f.getAll('a').join() !== '1,2' || f.get('x') !== 'a b' || f.get('bad') !== '\ufffd' || f.get('novalue') !== '' || f.get('') !== 'empty')throw Error('Urlencoding'); const empty=await new Response(null,{headers:header}).formData();if([...empty].length)throw Error('Empty urlencoding'); console.log('urlencoded'); "#, &[], ); assert_eq!(output.stdout, b"urlencoded\n"); } #[test] fn multipart_parsing_handles_preambles_padding_headers_binary_and_unicode() { let output = run( r#" const raw='preamble\r\n--B \t\r\ncOnTeNt-DiSpOsItIoN : FORM-DATA; filename="f"; NAME="na\ufeffme"\r\nContent-Transfer-Encoding: base64\r\n\r\nYWJj\r\n--B\r\nContent-Disposition: form-data; name=""\r\nContent-Type: text/plain;charset=windows-1252\r\n\r\n\ufeffé\0x--By\r\n--B--\r\nepilogue'; const f=await new Response(raw,{headers:{'content-type':'multipart/form-data; boundary="B"'}}).formData(); const file=f.get('na\ufeffme'); if(!(file instanceof File) || file.name !== 'f' || file.type !== 'text/plain' || await file.text() !== 'YWJj')throw Error('File parsing'); if(f.get('') !== '\ufeffé\0x--By')throw Error('Value decode'); for(const ending of ['--B--','--B--\r\n']){ const empty=await new Response(ending,{headers:{'content-type':'multipart/form-data;boundary=B'}}).formData(); if([...empty].length)throw Error('Empty multipart'); } const duplicate='--B\r\nContent-Disposition: form-data; name="old"\r\nContent-Disposition: form-data; name="first"; name="last"; filename=""; filename*=utf-8\'\'ignored\r\nContent-Type: x/y\r\nContent-Type:\r\n\r\nvalue\r\n--B--'; const last=await new Response(duplicate,{headers:{'content-type':'multipart/form-data; boundary=B'}}).formData(); if(last.has('old') || last.get('last').name !== '' || last.get('last').type !== '')throw Error('Repeated headers and params'); console.log('parsing'); "#, &[], ); assert_eq!(output.stdout, b"parsing\n"); } #[test] fn malformed_multipart_rejects_the_whole_body_and_marks_it_used() { let output = run( r#" const valid='--B\r\nContent-Disposition: form-data; name="a"\r\n\r\nx\r\n--B--'; const cases=[['',valid],['multipart/form-data',valid],['multipart/form-data;boundary=""',valid],['multipart/form-data;boundary=B',''],['multipart/form-data;boundary=B',valid.slice(0,-5)],['multipart/form-data;boundary=B',valid.replace('form-data','attachment')],['multipart/form-data;boundary=B',valid.replace('name="a"','filename="f"')],['multipart/form-data;boundary=B',valid.replace('Content-Disposition:', 'Bad header:')],['multipart/form-data;boundary=B',valid.replace('name="a"','name="unterminated')],['multipart/form-data;boundary=B',valid.replaceAll('\r\n','\n')],['multipart/form-data;boundary=B',valid.replace('x\r\n--B--','x\r\n--Bprefix\r\n--B--')]]; for(const [type,body] of cases){ const r=new Response(body,{headers:{'content-type':type}}); try {await r.formData();throw Error('Malformed body accepted');}catch(e){if(!(e instanceof TypeError))throw e;} if(!r.bodyUsed)throw Error('Failure did not consume body'); } const partial='--B\r\nContent-Disposition: form-data; name="a"\r\n\r\nx\r\n--B\r\nBad\r\n\r\ny\r\n--B--'; try {await new Response(partial,{headers:{'content-type':'multipart/form-data;boundary=B'}}).formData();throw Error('Partial result returned');}catch(e){if(!(e instanceof TypeError))throw e;} console.log('malformed'); "#, &[], ); assert_eq!(output.stdout, b"malformed\n"); } #[test] fn form_data_consumption_respects_mime_timing_cloning_locking_and_stream_errors() { let output = run( r#" const f=new FormData();f.append('a','v');const r=new Response(f), c=r.clone(); if((await r.formData()).get('a') !== 'v' || (await c.formData()).get('a') !== 'v' || !r.bodyUsed)throw Error('Cloning'); try {await r.formData();throw Error('Body reused');}catch(e){if(!(e instanceof TypeError))throw e;} const req=new Request('https://example.com',{method:'POST',body:f});if((await req.formData()).get('a') !== 'v')throw Error('Request'); let controller;const pending=new Response(new ReadableStream({start(c){controller=c;}}),{headers:{'content-type':'text/plain'}}); const promise=pending.formData();pending.headers.set('content-type','application/x-www-form-urlencoded');controller.enqueue(new TextEncoder().encode('a=b'));controller.close(); if((await promise).get('a') !== 'b')throw Error('MIME timing'); const locked=new Response('x');const reader=locked.body.getReader(); try {await locked.formData();throw Error('Locked body');}catch(e){if(!(e instanceof TypeError))throw e;} reader.releaseLock(); const error=Error('stream failure'), broken=new Response(new ReadableStream({start(c){c.error(error);}}),{headers:{'content-type':'application/x-www-form-urlencoded'}}); try {await broken.formData();throw Error('Lost stream error');}catch(e){if(e !== error)throw e;} console.log('bodies'); "#, &[], ); assert_eq!(output.stdout, b"bodies\n"); } #[test] fn form_data_uses_private_file_state_and_bindings_under_prototype_poisoning() { let output = run( r#" const f=new FormData(), file=new File(['payload'],'actual',{type:'x/y',lastModified:123}); Object.defineProperty(file,'name',{get(){throw Error('Public file name');}}); Object.defineProperty(File.prototype,'lastModified',{get(){throw Error('Public timestamp');}}); Object.defineProperty(Blob.prototype,'type',{get(){throw Error('Public type');}}); f.append('file',file);f.set('renamed',file,'rename'); FormData.prototype.entries=null;File.prototype.bytes=null; globalThis.File=null;globalThis.Blob=null;Math.random=()=>{throw Error('Insecure boundary');}; Promise.prototype.then=function(){throw Error('Public promise adoption');}; const r=new Response(f), parsed=await r.formData(); if(parsed.get('file').name !== 'actual' || parsed.get('renamed').name !== 'rename' || await parsed.get('file').text() !== 'payload')throw Error('Private state'); console.log('private'); "#, &[], ); assert_eq!(output.stdout, b"private\n"); } #[test] fn multipart_boundary_search_handles_large_near_matches_without_losing_content() { let output = run( r#" const boundary='a'.repeat(200), value=('\r\n--'+boundary.slice(0,-1)+'!').repeat(20000); const raw='--'+boundary+'\r\nContent-Disposition: form-data; name="field"\r\n\r\n'+value+'\r\n--'+boundary+'--'; const f=await new Response(raw,{headers:{'content-type':'multipart/form-data;boundary='+boundary}}).formData(); if(f.get('field') !== value)throw Error('Boundary near-match lost bytes'); console.log('search'); "#, &[], ); assert_eq!(output.stdout, b"search\n"); } #[test] fn form_data_is_permissionless_and_private_imports_stay_private() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("main.js"); std::fs::write(&path,r#" import {pledge} from 'avril:std';pledge({net:false,read:false,jsExec:false}); const f=new FormData();f.append('a','v'); if((await new Response(f).formData()).get('a') !== 'v')throw Error('Permissionless FormData'); for(const name of ['ext:form_data/main.js','ext:form_data/form_data.js','ext:form_data/multipart.js']){ try {await import(name);throw Error('Private module escaped');}catch(e){if(!e.message.includes('private'))throw e;} } console.log('private forms'); "#).unwrap(); let child = std::process::Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .args(["-0", path.to_str().unwrap()]) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"private forms\n"); } #[test] fn form_data_set_and_delete_compact_large_duplicate_lists() { let output = run( r#" const f=new FormData();f.append('first','a'); for(let i=0;i<50000;i++)f.append('same','value'); f.append('last','b');f.set('same','new'); if([...f.keys()].join() !== 'first,same,last' || f.getAll('same').join() !== 'new')throw Error('Set compaction'); for(let i=0;i<50000;i++)f.append('same','value'); f.delete('same');if([...f.keys()].join() !== 'first,last')throw Error('Delete compaction'); console.log('compaction'); "#, &[], ); assert_eq!(output.stdout, b"compaction\n"); } #[test] fn multipart_header_blocks_decode_once_and_validate_ascii_before_case_mapping() { let output = run( r#" const header={'content-type':'multipart/form-data;boundary=B'}; const raw='--B\r\nContent-Disposition: form-data; name="a"\r\n'+('X: v\r\n').repeat(100000)+'\r\nx\r\n--B--'; if((await new Response(raw,{headers:header}).formData()).get('a') !== 'x')throw Error('Header block'); for(const extra of ['X-K: v\r\n','X-é: v\r\n']){ const bad='--B\r\nContent-Disposition: form-data; name="a"\r\n'+extra+'\r\nx\r\n--B--'; try {await new Response(bad,{headers:header}).formData();throw Error('Non-ASCII header name');}catch(e){if(!(e instanceof TypeError))throw e;} } const bad='--B\r\nContent-Disposition: form-data; name="a"; K=x\r\n\r\nx\r\n--B--'; try {await new Response(bad,{headers:header}).formData();throw Error('Non-ASCII parameter name');}catch(e){if(!(e instanceof TypeError))throw e;} console.log('headers'); "#, &[], ); assert_eq!(output.stdout, b"headers\n"); }