char/avril

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

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

Charlotte Somport FormData and multipart extension to typescriptc013baf

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