char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
c013baf
main
1mod common; 2 3use common:: run; 4 5# [ test ] 6fn form_data_is_an_ordered_live_collection_with_file_values () { 7let output =run ( 8r#" 9const f=new FormData();f.append('a','1');f.append('b','2');f.append('a','3'); 10if(f.get('a') !== '1' || f.get('missing') !== null || f.getAll('a').join() !== '1,3' || !f.has('b'))throw Error('Collection'); 11const copy=f.getAll('a');copy[0]='bad'; 12f.set('a','new');if([...f.keys()].join() !== 'a,b' || f.get('a') !== 'new')throw Error('Set ordering'); 13f.delete('b');f.delete('missing'); 14const file=new File(['hello'],'original',{type:'Text/Plain',lastModified:123}); 15f.append('file',file);f.append('renamed',file,'other');f.append('blob',new Blob(['b'],{type:'x/y'})); 16if(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'); 17if(!(f.get('blob') instanceof File) || f.get('blob').name !== 'blob' || await f.get('blob').text() !== 'b')throw Error('Blob entries'); 18const iterator=f.entries();if(iterator[Symbol.iterator]() !== iterator || Object.prototype.toString.call(iterator) !== '[object FormData Iterator]')throw Error('Iterator'); 19iterator.next();f.append('late','value'); 20if([...iterator].at(-1)[0] !== 'late')throw Error('Live iterator'); 21f.append('later','v');const resumed=iterator.next();if(resumed.done || resumed.value[0] !== 'later')throw Error('Live iterator did not resume'); 22const live=new FormData();live.append('a','1');live.append('b','2');const seen=[]; 23const 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); 24if(seen.join() !== 'a,c')throw Error('Live forEach'); 25if(FormData.length !== 0 || FormData.prototype.append.length !== 2 || FormData.prototype.set.length !== 2 || FormData.prototype[Symbol.iterator] !== FormData.prototype.entries)throw Error('Shape'); 26console.log('collection'); 27"# , 28& [], 29); 30assert_eq! ( output. stdout , b"collection\n" ); 31} 32 33# [ test ] 34fn form_data_iterators_preserve_result_shapes_branding_and_callable_arity () { 35let output =run ( 36r#" 37const form = new FormData(), file = new File(['payload'], 'file'); 38form.append('text', 'value'); form.append('file', file); 39for (const method of ['entries', 'keys', 'values']) { 40const iterator = form[method](), prototype = Object.getPrototypeOf(iterator); 41if (Object.hasOwn(prototype, 'constructor') || prototype.next.length !== 0 || 42FormData.prototype[method].length !== 0) throw Error('Iterator shape'); 43const first = iterator.next(), second = iterator.next(), end = iterator.next(); 44if (first.done || second.done || !end.done || end.value !== undefined) throw Error('Iterator results'); 45if (method === 'entries') { 46if (!Array.isArray(first.value) || first.value.length !== 2 || 47first.value[0] !== 'text' || first.value[1] !== 'value' || 48second.value[0] !== 'file' || second.value[1] !== file) throw Error('Entry pairs'); 49first.value[0] = 'changed'; first.value[1] = 'changed'; 50if (form.get('text') !== 'value') throw Error('Mutable entry alias'); 51} else if (method === 'keys') { 52if (first.value !== 'text' || second.value !== 'file') throw Error('Keys'); 53} else if (first.value !== 'value' || second.value !== file) throw Error('Values'); 54for (const receiver of [{}, form, [][Symbol.iterator]()]) { 55try { prototype.next.call(receiver); throw Error('Unbranded iterator'); } 56catch (error) { if (!(error instanceof TypeError)) throw error; } 57} 58} 59for (const method of ['delete', 'get', 'getAll', 'has', 'forEach']) { 60if (FormData.prototype[method].length !== 1) throw Error('Method arity'); 61} 62console.log('iterator contracts'); 63"# , 64& [], 65); 66assert_eq! ( output. stdout , b"iterator contracts\n" ); 67} 68 69# [ test ] 70fn form_data_conversions_and_overloads_run_before_mutating_entries () { 71let output =run ( 72r#" 73const f=new FormData(), log=[]; 74f.append({toString(){log.push('name');return 'name';}},{toString(){log.push('value');return '\ud800';}}); 75if(log.join() !== 'name,value' || f.get('name') !== '\ufffd')throw Error('String conversion'); 76for(const method of ['append','set']){ 77let converted=false; 78try {f[method]({toString(){converted=true;return 'x';}},{toString(){throw Error('Value converted');}},undefined);throw Error('Accepted string filename overload');} 79catch(e){if(!(e instanceof TypeError) || !converted)throw e;} 80f[method]('file',new Blob(['v']),undefined); 81if(f.get('file').name !== 'blob')throw Error('Undefined filename'); 82try {f[method]('bad',new Blob(),Symbol());throw Error('Symbol filename');}catch(e){if(!(e instanceof TypeError))throw e;} 83} 84f.append('key','old');f.append('key','duplicate'); 85f.set('key',{toString(){f.delete('key');f.append('other','x');f.append('key','side effect');return 'final';}}); 86if(f.getAll('key').join() !== 'final' || [...f.keys()].at(-1) !== 'key')throw Error('Set cached its indices before conversion'); 87for(const method of ['append','set','delete','get','getAll','has','forEach']){ 88try {FormData.prototype[method].call({});throw Error('Illegal receiver');}catch(e){if(!(e instanceof TypeError))throw e;} 89try {f[method]();throw Error('Missing argument');}catch(e){if(!(e instanceof TypeError))throw e;} 90} 91for(const operation of [()=>f.forEach(3),()=>new FormData({}),()=>new FormData(undefined,{}),()=>f.append(Symbol(),'v')]){ 92try {operation();throw Error('Invalid input accepted');}catch(e){if(!(e instanceof TypeError))throw e;} 93} 94console.log('conversions'); 95"# , 96& [], 97); 98assert_eq! ( output. stdout , b"conversions\n" ); 99} 100 101# [ test ] 102fn multipart_serialization_snapshots_entries_and_normalizes_only_required_newlines () { 103let output =run ( 104r#" 105const f=new FormData();f.append('n\r\nx\r"','v\rx\ny\r\nz'); 106f.append('binary',new File([new Uint8Array([0,255,13,10])],'f\r\n"\\.bin',{type:'APP/OCTET-STREAM'})); 107const r=new Response(f), type=r.headers.get('content-type'), boundary=type.slice('multipart/form-data; boundary='.length); 108if(!/^----avril[0-9a-f]{32}$/.test(boundary))throw Error('Boundary'); 109const second=new Response(f);if(second.headers.get('content-type')===type)throw Error('Reused boundary'); 110f.set('binary','mutated');f.append('late','late'); 111const bytes=await r.bytes(), text=new TextDecoder().decode(bytes); 112const 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'; 113if(!text.startsWith(expected) || !text.endsWith('\r\n--'+boundary+'--\r\n') || text.includes('mutated') || text.includes('late'))throw Error('Serialization'); 114const data=await new Response(bytes,{headers:{'content-type':type}}).formData(); 115const file=data.get('binary'); 116if(file.name !== 'f%0D%0A%22\\.bin' || file.type !== 'app/octet-stream' || (await file.bytes()).join() !== '0,255,13,10')throw Error('Binary roundtrip'); 117if(f.get('n\r\nx\r"') !== 'v\rx\ny\r\nz')throw Error('FormData mutated by normalization'); 118const empty=new Response(new FormData()), b=empty.headers.get('content-type').split('boundary=')[1]; 119if(await empty.text() !== '--'+b+'--\r\n')throw Error('Empty encoding'); 120console.log('encoding'); 121"# , 122& [], 123); 124assert_eq! ( output. stdout , b"encoding\n" ); 125} 126 127# [ test ] 128fn urlencoded_form_data_percent_decodes_bytes_before_utf8_and_preserves_boms () { 129let output =run ( 130r#" 131const header={'content-type':'application/x-www-form-urlencoded;charset=windows-1252'}; 132const data=await new Response(new Uint8Array([0xc2,37,56,48,61,37,69,70,37,66,66,37,66,70]),{headers:header}).formData(); 133if(data.get('\u0080') !== '\ufeff')throw Error('Percent decode before UTF8'); 134const f=await new Response('\ufefftest=\ufeff&a=1&a=2&x=a+b&bad=%ff&novalue&=empty&&',{headers:header}).formData(); 135if(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'); 136const empty=await new Response(null,{headers:header}).formData();if([...empty].length)throw Error('Empty urlencoding'); 137console.log('urlencoded'); 138"# , 139& [], 140); 141assert_eq! ( output. stdout , b"urlencoded\n" ); 142} 143 144# [ test ] 145fn multipart_parsing_handles_preambles_padding_headers_binary_and_unicode () { 146let output =run ( 147r#" 148const 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'; 149const f=await new Response(raw,{headers:{'content-type':'multipart/form-data; boundary="B"'}}).formData(); 150const file=f.get('na\ufeffme'); 151if(!(file instanceof File) || file.name !== 'f' || file.type !== 'text/plain' || await file.text() !== 'YWJj')throw Error('File parsing'); 152if(f.get('') !== '\ufeffé\0x--By')throw Error('Value decode'); 153for(const ending of ['--B--','--B--\r\n']){ 154const empty=await new Response(ending,{headers:{'content-type':'multipart/form-data;boundary=B'}}).formData(); 155if([...empty].length)throw Error('Empty multipart'); 156} 157const 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--'; 158const last=await new Response(duplicate,{headers:{'content-type':'multipart/form-data; boundary=B'}}).formData(); 159if(last.has('old') || last.get('last').name !== '' || last.get('last').type !== '')throw Error('Repeated headers and params'); 160console.log('parsing'); 161"# , 162& [], 163); 164assert_eq! ( output. stdout , b"parsing\n" ); 165} 166 167# [ test ] 168fn malformed_multipart_rejects_the_whole_body_and_marks_it_used () { 169let output =run ( 170r#" 171const valid='--B\r\nContent-Disposition: form-data; name="a"\r\n\r\nx\r\n--B--'; 172const 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--')]]; 173for(const [type,body] of cases){ 174const r=new Response(body,{headers:{'content-type':type}}); 175try {await r.formData();throw Error('Malformed body accepted');}catch(e){if(!(e instanceof TypeError))throw e;} 176if(!r.bodyUsed)throw Error('Failure did not consume body'); 177} 178const 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--'; 179try {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;} 180console.log('malformed'); 181"# , 182& [], 183); 184assert_eq! ( output. stdout , b"malformed\n" ); 185} 186 187# [ test ] 188fn form_data_consumption_respects_mime_timing_cloning_locking_and_stream_errors () { 189let output =run ( 190r#" 191const f=new FormData();f.append('a','v');const r=new Response(f), c=r.clone(); 192if((await r.formData()).get('a') !== 'v' || (await c.formData()).get('a') !== 'v' || !r.bodyUsed)throw Error('Cloning'); 193try {await r.formData();throw Error('Body reused');}catch(e){if(!(e instanceof TypeError))throw e;} 194const req=new Request('https://example.com',{method:'POST',body:f});if((await req.formData()).get('a') !== 'v')throw Error('Request'); 195let controller;const pending=new Response(new ReadableStream({start(c){controller=c;}}),{headers:{'content-type':'text/plain'}}); 196const promise=pending.formData();pending.headers.set('content-type','application/x-www-form-urlencoded');controller.enqueue(new TextEncoder().encode('a=b'));controller.close(); 197if((await promise).get('a') !== 'b')throw Error('MIME timing'); 198const locked=new Response('x');const reader=locked.body.getReader(); 199try {await locked.formData();throw Error('Locked body');}catch(e){if(!(e instanceof TypeError))throw e;} 200reader.releaseLock(); 201const error=Error('stream failure'), broken=new Response(new ReadableStream({start(c){c.error(error);}}),{headers:{'content-type':'application/x-www-form-urlencoded'}}); 202try {await broken.formData();throw Error('Lost stream error');}catch(e){if(e !== error)throw e;} 203console.log('bodies'); 204"# , 205& [], 206); 207assert_eq! ( output. stdout , b"bodies\n" ); 208} 209 210# [ test ] 211fn form_data_uses_private_file_state_and_bindings_under_prototype_poisoning () { 212let output =run ( 213r#" 214const f=new FormData(), file=new File(['payload'],'actual',{type:'x/y',lastModified:123}); 215Object.defineProperty(file,'name',{get(){throw Error('Public file name');}}); 216Object.defineProperty(File.prototype,'lastModified',{get(){throw Error('Public timestamp');}}); 217Object.defineProperty(Blob.prototype,'type',{get(){throw Error('Public type');}}); 218f.append('file',file);f.set('renamed',file,'rename'); 219FormData.prototype.entries=null;File.prototype.bytes=null; 220globalThis.File=null;globalThis.Blob=null;Math.random=()=>{throw Error('Insecure boundary');}; 221Promise.prototype.then=function(){throw Error('Public promise adoption');}; 222const r=new Response(f), parsed=await r.formData(); 223if(parsed.get('file').name !== 'actual' || parsed.get('renamed').name !== 'rename' || await parsed.get('file').text() !== 'payload')throw Error('Private state'); 224console.log('private'); 225"# , 226& [], 227); 228assert_eq! ( output. stdout , b"private\n" ); 229} 230 231# [ test ] 232fn multipart_boundary_search_handles_large_near_matches_without_losing_content () { 233let output =run ( 234r#" 235const boundary='a'.repeat(200), value=('\r\n--'+boundary.slice(0,-1)+'!').repeat(20000); 236const raw='--'+boundary+'\r\nContent-Disposition: form-data; name="field"\r\n\r\n'+value+'\r\n--'+boundary+'--'; 237const f=await new Response(raw,{headers:{'content-type':'multipart/form-data;boundary='+boundary}}).formData(); 238if(f.get('field') !== value)throw Error('Boundary near-match lost bytes'); 239console.log('search'); 240"# , 241& [], 242); 243assert_eq! ( output. stdout , b"search\n" ); 244} 245 246# [ test ] 247fn form_data_is_permissionless_and_private_imports_stay_private () { 248let dir = tempfile:: tempdir (). unwrap (); 249let path = dir. path (). join ( "main.js" ); 250 std:: fs:: write ( & path, r#" 251import {pledge} from 'avril:std';pledge({net:false,read:false,jsExec:false}); 252const f=new FormData();f.append('a','v'); 253if((await new Response(f).formData()).get('a') !== 'v')throw Error('Permissionless FormData'); 254for(const name of ['ext:form_data/main.js','ext:form_data/form_data.js','ext:form_data/multipart.js']){ 255try {await import(name);throw Error('Private module escaped');}catch(e){if(!e.message.includes('private'))throw e;} 256} 257console.log('private forms'); 258"# ). unwrap (); 259let child = std:: process:: Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 260. arg ( "run" ) 261. args ([ "-0" , path. to_str (). unwrap ()]) 262. stdout ( std:: process:: Stdio :: piped ()) 263. stderr ( std:: process:: Stdio :: piped ()) 264. spawn () 265. unwrap (); 266let output = common:: wait ( child); 267assert! ( 268 output. status . success (), 269"{}" , 270String :: from_utf8_lossy ( & output. stderr ) 271); 272assert_eq! ( output. stdout , b"private forms\n" ); 273} 274 275# [ test ] 276fn form_data_set_and_delete_compact_large_duplicate_lists () { 277let output =run ( 278r#" 279const f=new FormData();f.append('first','a'); 280for(let i=0;i<50000;i++)f.append('same','value'); 281f.append('last','b');f.set('same','new'); 282if([...f.keys()].join() !== 'first,same,last' || f.getAll('same').join() !== 'new')throw Error('Set compaction'); 283for(let i=0;i<50000;i++)f.append('same','value'); 284f.delete('same');if([...f.keys()].join() !== 'first,last')throw Error('Delete compaction'); 285console.log('compaction'); 286"# , 287& [], 288); 289assert_eq! ( output. stdout , b"compaction\n" ); 290} 291 292# [ test ] 293fn multipart_header_blocks_decode_once_and_validate_ascii_before_case_mapping () { 294let output =run ( 295r#" 296const header={'content-type':'multipart/form-data;boundary=B'}; 297const raw='--B\r\nContent-Disposition: form-data; name="a"\r\n'+('X: v\r\n').repeat(100000)+'\r\nx\r\n--B--'; 298if((await new Response(raw,{headers:header}).formData()).get('a') !== 'x')throw Error('Header block'); 299for(const extra of ['X-K: v\r\n','X-é: v\r\n']){ 300const bad='--B\r\nContent-Disposition: form-data; name="a"\r\n'+extra+'\r\nx\r\n--B--'; 301try {await new Response(bad,{headers:header}).formData();throw Error('Non-ASCII header name');}catch(e){if(!(e instanceof TypeError))throw e;} 302} 303const bad='--B\r\nContent-Disposition: form-data; name="a"; K=x\r\n\r\nx\r\n--B--'; 304try {await new Response(bad,{headers:header}).formData();throw Error('Non-ASCII parameter name');}catch(e){if(!(e instanceof TypeError))throw e;} 305console.log('headers'); 306"# , 307& [], 308); 309assert_eq! ( output. stdout , b"headers\n" ); 310}