mod common; fn run(source: &str) -> std::process::Output { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("hello.txt"), b"0123456789").unwrap(); std::fs::write(dir.path().join("empty.bin"), b"").unwrap(); std::fs::write(dir.path().join("large.bin"), vec![42; 200_000]).unwrap(); std::fs::write( dir.path().join("pattern.bin"), (0..200_000).map(|i| (i % 251) as u8).collect::>(), ) .unwrap(); std::fs::File::create(dir.path().join("huge.bin")) .unwrap() .set_len(64 * 1024 * 1024) .unwrap(); common::run( &format!( "const root = {}; const rootURL = {};\n{source}", serde_json::to_string(dir.path().to_str().unwrap()).unwrap(), serde_json::to_string(url::Url::from_directory_path(dir.path()).unwrap().as_str()) .unwrap() ), &[], ) } #[test] fn handles_transfer_ownership_and_preserve_opened_identity() { let output = run(r#" import {serveFile, open, rename, errors, FileHandle} from 'avril:std'; const file = await open(root+'/hello.txt'); await file.seek(7, 0); const readable = file.readable, writable = file.writable; await rename(root+'/hello.txt', root+'/moved.bin'); const replacement = await open(root+'/hello.txt', {write:true, createNew:true}); await replacement.write(new TextEncoder().encode('replacement')); replacement.close(); const pending = serveFile(new Request('http://localhost/'), file); try { file.statSync(); throw Error('handle still usable'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } file[Symbol.dispose](); const response = await pending; if (!(response instanceof Response) || response.status !== 200 || response.headers.get('content-type') !== 'text/plain; charset=utf-8' || response.headers.get('content-length') !== '10' || await response.text() !== '0123456789') throw Error('opened file'); try { await readable.getReader().read(); throw Error('old readable still usable'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } try { await writable.getWriter().write(new Uint8Array([1])); throw Error('old writable still usable'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } try { await serveFile(new Request('http://localhost/'), file); throw Error('reused handle'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } const forged = Object.create(FileHandle.prototype); try { await serveFile(new Request('http://localhost/'), forged); throw Error('accepted forged handle'); } catch (error) { if (!(error instanceof TypeError)) throw error; } console.log('ownership'); "#); assert_eq!(output.stdout, b"ownership\n"); } #[test] fn retained_handles_support_concurrent_ranges_without_changing_the_cursor() { let output = run(r#" import {serveFile, open} from 'avril:std'; const file = await open(root+'/pattern.bin'); await file.seek(731, 0); const intervals = [[0,199999], [65530,180000], [130001,199999]]; const responses = await Promise.all(intervals.map(([start,end]) => serveFile(new Request('http://localhost/', {headers:{range:`bytes=${start}-${end}`}}), file, {consume:false}) )); if (await file.seek(0, 1) !== 731) throw Error('preparation changed cursor'); await Promise.all(responses.map(async (response, index) => { const [start,end] = intervals[index]; if (response.status !== 206 || response.headers.get('content-range') !== `bytes ${start}-${end}/200000`) throw Error('range metadata'); const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.length !== end-start+1 || bytes.some((x,i) => x !== (start+i)%251)) throw Error('concurrent content'); })); if (await file.seek(0, 1) !== 731) throw Error('streaming changed cursor'); const byte = new Uint8Array(1); if (await file.read(byte) !== 1 || byte[0] !== 731%251) throw Error('original read'); const pending = serveFile(new Request('http://localhost/'), file, {consume:false}); file.close(); const response = await pending; const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.length !== 200000 || bytes.some((x,i) => x !== i%251)) throw Error('original close interrupted response'); console.log('retained handles'); "#); assert_eq!(output.stdout, b"retained handles\n"); } #[test] fn handle_metadata_and_bodyless_responses_obey_ownership_options() { let output = run(r#" import {serveFile, open, errors} from 'avril:std'; const request = (headers = {}, method = 'GET') => new Request('http://localhost/', {headers, method}); const file = await open(root+'/hello.txt'); const first = await serveFile(request(), file, {consume:false, headers:{etag:'"version"', 'content-type':'application/custom'}}); if (first.headers.get('content-type') !== 'application/custom' || await first.text() !== '0123456789') throw Error('custom headers'); const cases = [ [{range:'bytes=2-3'}, 'HEAD', 200, '10'], [{'if-none-match':'"version"'}, 'GET', 304, null], [{'if-match':'"other"'}, 'GET', 412, '0'], [{range:'bytes=100-'}, 'GET', 416, '0'], [{}, 'POST', 405, '0'], ]; for (const [headers,method,status,length] of cases) { const options = {consume:false, headers:{etag:'"version"'}}; const response = await serveFile(request(headers,method), file, options); if (response.status !== status || response.body !== null || response.headers.get('content-length') !== length) throw Error('retained status ' + status); if ((await file.stat()).size !== 10) throw Error('closed retained handle'); const owned = await open(root+'/hello.txt'); const consumed = await serveFile(request(headers,method), owned, {headers:options.headers}); if (consumed.status !== status || consumed.body !== null) throw Error('consumed status ' + status); try { await owned.stat(); throw Error('did not consume bodyless handle'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } } file.close(); const empty = await open(root+'/empty.bin'); const response = await serveFile(request(), empty); if (response.status !== 200 || response.body !== null || response.headers.get('content-length') !== '0') throw Error('empty handle'); console.log('handle metadata'); "#); assert_eq!(output.stdout, b"handle metadata\n"); } #[test] fn handle_validation_cancellation_and_revocation() { let output = run(r#" import {serveFile, open, pledge, errors} from 'avril:std'; const request = () => new Request('http://localhost/'); const file = await open(root+'/hello.txt'); for (const options of [{consume:'false'}, {headers:{'bad header':'x'}}]) { try { await serveFile(request(), file, options); throw Error('accepted invalid options'); } catch (error) { if (!(error instanceof TypeError)) throw error; } await file.stat(); } const control = new AbortController(); control.abort(Error('stop')); try { await serveFile(new Request('http://localhost/', {signal:control.signal}), file); throw Error('ignored early abort'); } catch (error) { if (error.message !== 'stop') throw error; } await file.stat(); const writeOnly = await open(root+'/hello.txt', {write:true}); try { await serveFile(request(), writeOnly); throw Error('accepted write-only handle'); } catch (error) { if (!(error instanceof errors.BadResource)) throw error; } await writeOnly.stat(); writeOnly.close(); file.close(); const shared = await open(root+'/large.bin'); const canceled = await serveFile(request(), shared, {consume:false}); await canceled.body.cancel(); await shared.stat(); const controller = new AbortController(); const aborted = await serveFile(new Request('http://localhost/', {signal:controller.signal}), shared, {consume:false}); controller.abort(Error('stop')); try { await aborted.text(); throw Error('ignored abort'); } catch (error) { if (error.message !== 'stop') throw error; } await shared.stat(); const revoked = await serveFile(request(), shared, {consume:false}); pledge({read:false}); try { await revoked.text(); throw Error('read after revocation'); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } try { await serveFile(request(), shared); throw Error('acquired after revocation'); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } shared.close(); console.log('handle failures'); "#); assert_eq!(output.stdout, b"handle failures\n"); } #[test] fn handle_responses_use_the_native_http_path_and_retained_bindings() { let output = run(r#" import {serve, serveFile, open, FileHandle} from 'avril:std'; const file = await open(root+'/pattern.bin'); const poison = () => {throw Error('guest method');}; FileHandle.prototype.read = FileHandle.prototype.stat = FileHandle.prototype.seek = FileHandle.prototype.close = FileHandle.prototype[Symbol.dispose] = poison; let transferred; const server = serve({port:0}, async request => { const owned = request.url.endsWith('/owned'); const response = await serveFile(request, owned ? await open(root+'/pattern.bin') : file, {consume:owned}); transferred = response; return response; }); const url = `http://127.0.0.1:${server.addr.port}`; await Promise.all(['/shared-a','/shared-b','/owned'].map(async path => { const response = await fetch(url+path, {headers:{range:'bytes=65530-180000'}}); const bytes = new Uint8Array(await response.arrayBuffer()); if (response.status !== 206 || bytes.length !== 114471 || bytes.some((x,i) => x !== (65530+i)%251)) throw Error('wire range'); })); if (!transferred.bodyUsed) throw Error('native body not transferred'); await server.shutdown(); console.log('native handles'); "#); assert_eq!(output.stdout, b"native handles\n"); } #[test] fn metadata_head_and_conditional_requests() { let output = run(r#" import {serveFile} from 'avril:std'; const path = root + '/hello.txt'; const req = (headers = {}, method = 'GET') => new Request('http://localhost/', {headers, method}); const first = await serveFile(req(), path); const etag = first.headers.get('etag'), modified = first.headers.get('last-modified'); if (!etag || !modified || first.headers.get('content-length') !== '10' || first.headers.get('content-type') !== 'text/plain; charset=utf-8' || first.headers.get('accept-ranges') !== 'bytes' || await first.text() !== '0123456789') throw Error('metadata'); const head = await serveFile(req({range:'bytes=2-3'}, 'HEAD'), path); if (head.status !== 200 || head.body !== null || head.headers.get('content-length') !== '10' || head.headers.get('etag') !== etag) throw Error('HEAD'); const old = 'Thu, 01 Jan 1970 00:00:00 GMT'; const cases = [ [{'if-none-match':etag}, 304], [{'if-none-match':etag.slice(2)}, 304], [{'if-none-match':'"unrelated,tag", ' + etag}, 304], [{'if-none-match':'*'}, 304], [{'if-none-match':', , ' + etag + ','}, 304], [{'if-modified-since':modified}, 304], [{'if-modified-since':new Date(Date.now()+86400000).toUTCString()}, 304], [{'if-modified-since':old}, 200], [{'if-modified-since':'garbage'}, 200], [{'if-none-match':'"other"', 'if-modified-since':modified}, 200], [{'if-match':etag}, 412], [{'if-match':etag.slice(2)}, 412], [{'if-match':'*'}, 200], [{'if-unmodified-since':old}, 412], [{'if-unmodified-since':modified}, 200], [{'if-match':'*', 'if-unmodified-since':old}, 200], [{'if-match':'"other"', 'if-none-match':'*'}, 412], [{'if-none-match':etag, range:'bytes=100-'}, 304], ]; for (const [headers, status] of cases) { const response = await serveFile(req(headers), path); if (response.status !== status) throw Error(JSON.stringify(headers) + ': ' + response.status); if (status !== 200 && (response.body !== null || response.headers.get('etag') !== etag)) throw Error('conditional body'); if (status === 304 && response.headers.has('content-length')) throw Error('304 length'); await response.text(); } console.log('conditions'); "#); assert_eq!(output.stdout, b"conditions\n"); } #[test] fn byte_ranges_and_if_range() { let output = run(r#" import {serveFile} from 'avril:std'; const path = root + '/hello.txt'; const req = headers => new Request('http://localhost/', {headers}); const first = await serveFile(req({}), path); const modified = first.headers.get('last-modified'), etag = first.headers.get('etag'); await first.body.cancel(); const cases = [ ['bytes=2-4', 206, '234', 'bytes 2-4/10'], ['bytes=7-', 206, '789', 'bytes 7-9/10'], ['bytes=-3', 206, '789', 'bytes 7-9/10'], ['bytes=-100', 206, '0123456789', 'bytes 0-9/10'], ['bytes=8-1000000000000000000000000000', 206, '89', 'bytes 8-9/10'], ['bytes=-99999999999999999999999999', 206, '0123456789', 'bytes 0-9/10'], ['bytes=10-', 416, '', 'bytes */10'], ['bytes=99999999999999999999999999-', 416, '', 'bytes */10'], ['bytes=-0', 416, '', 'bytes */10'], ['bytes=4-2', 200, '0123456789', null], ['bytes=9007199254740993-9007199254740992', 200, '0123456789', null], ['bytes=-', 200, '0123456789', null], ['items=0-1', 200, '0123456789', null], ['bytes=0-1,4-5', 200, '0123456789', null], ['garbage', 200, '0123456789', null], ]; for (const [range, status, text, contentRange] of cases) { const response = await serveFile(req({range}), path); if (response.status !== status || response.headers.get('content-range') !== contentRange || response.headers.get('content-length') !== String(text.length) || await response.text() !== text) throw Error(range); } for (const [validator, status] of [[modified,206], [etag,200], [etag.slice(2),200], ['"other"',200], ['invalid',200], ['Thu, 01 Jan 1970 00:00:00 GMT',200]]) { const response = await serveFile(req({range:'bytes=1-2', 'if-range':validator}), path); if (response.status !== status || await response.text() !== (status === 206 ? '12' : '0123456789')) throw Error('If-Range ' + validator); } const strong = {headers:{etag:'"known,version"', 'content-type':'application/custom', 'cache-control':'public, max-age=30', 'content-length':'12345', 'content-range':'invalid'}}; for (const headers of [{'if-match':'"known,version"'}, {range:'bytes=1-2', 'if-range':'"known,version"'}]) { const response = await serveFile(req(headers), new URL('hello.txt', rootURL), strong); if (response.status !== (headers.range ? 206 : 200) || response.headers.get('content-type') !== 'application/custom' || response.headers.get('cache-control') !== 'public, max-age=30' || response.headers.get('content-length') !== (headers.range ? '2' : '10')) throw Error('overrides'); await response.text(); } const empty = await serveFile(req({range:'bytes=0-'}), root+'/empty.bin'); if (empty.status !== 416 || empty.headers.get('content-range') !== 'bytes */0') throw Error('empty range'); console.log('ranges'); "#); assert_eq!(output.stdout, b"ranges\n"); } #[test] fn streaming_abort_permissions_and_file_identity() { let output = run(r#" import {serveFile, open, rename, pledge, errors} from 'avril:std'; const request = () => new Request('http://localhost/'); const response = await serveFile(request(), root+'/hello.txt'); await rename(root+'/hello.txt', root+'/moved.txt'); if (await response.text() !== '0123456789') throw Error('opened identity'); const growing = await serveFile(request(), root+'/moved.txt'); const writer = await open(root+'/moved.txt', {append:true}); await writer.write(new TextEncoder().encode('extra')); writer.close(); if (await growing.text() !== '0123456789') throw Error('growth exceeded Content-Length'); const short = await serveFile(request(), root+'/moved.txt'); const shrink = await open(root+'/moved.txt', {write:true}); await shrink.truncate(2); shrink.close(); try { await short.text(); throw Error('accepted short file'); } catch (error) { if (error.message === 'accepted short file') throw error; } const control = new AbortController(); const aborted = await serveFile(new Request('http://localhost/', {signal:control.signal}), root+'/large.bin'); control.abort(Error('stop reading')); try { await aborted.text(); throw Error('ignored abort'); } catch (error) { if (error.message !== 'stop reading') throw error; } try { await serveFile(new Request('http://localhost/', {signal:control.signal}), root+'/large.bin'); throw Error('ignored early abort'); } catch (error) { if (error.message !== 'stop reading') throw error; } const canceled = await serveFile(request(), root+'/large.bin'); await canceled.body.cancel(); const revoked = await serveFile(request(), root+'/large.bin'); pledge({read:false}); try { await revoked.text(); throw Error('read after revocation'); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } try { await serveFile(request(), root+'/large.bin'); throw Error('open after revocation'); } catch (error) { if (!(error instanceof errors.PermissionDenied)) throw error; } console.log('streaming'); "#); assert_eq!(output.stdout, b"streaming\n"); } #[test] fn cloning_transforming_and_rewrapping_file_responses() { let output = run(r#" import {serve, serveFile} from 'avril:std'; const response = await serveFile(new Request('http://localhost/'), root+'/hello.txt'); const clone = response.clone(); if (await response.text() !== '0123456789' || await clone.text() !== '0123456789') throw Error('clone'); let transferred, incoming; const server = serve({port:0}, async request => { if (request.url.endsWith('/reuse')) return transferred; const response = await serveFile(request, root+'/large.bin'); if (request.url.endsWith('/rewrap')) { transferred = response; incoming = request; return new Response(response.body, {headers:response.headers}); } if (request.url.endsWith('/clone')) { const clone = response.clone(); await response.arrayBuffer(); return clone; } if (request.url.endsWith('/transform')) { return new Response(response.body.pipeThrough(new TransformStream({ transform(bytes, controller) { controller.enqueue(bytes.map(x => x + 1)); }, })), {headers:response.headers}); } response.headers.set('content-length', '1'); return response; }); const url = `http://127.0.0.1:${server.addr.port}`; for (const path of ['/rewrap', '/clone', '/transform']) { const response = await fetch(url+path); const bytes = new Uint8Array(await response.arrayBuffer()); if (response.status !== 200 || bytes.length !== 200000 || bytes.some(x => x !== (path === '/transform' ? 43 : 42))) throw Error(path); } if (!transferred.bodyUsed || incoming.signal.aborted) throw Error('transferred body lifecycle'); for (const path of ['/reuse', '/bad-length']) { const response = await fetch(url+path); if (response.status !== 500 || await response.text() !== 'Internal Server Error') throw Error('invalid ' + path); } await server.shutdown(); console.log('bodies'); "#); assert_eq!(output.stdout, b"bodies\n"); } #[test] fn revocation_and_independent_request_abort_interrupt_active_responses() { let output = run(r#" import {serve, serveFile, pledge} from 'avril:std'; let control; const server = serve({port:0}, request => { if (request.url.endsWith('/revoke')) { pledge({read:false}); return new Response('revoked'); } if (request.url.endsWith('/independent')) { control = new AbortController(); request = new Request(request.url, {signal:control.signal}); } return serveFile(request, root+'/huge.bin'); }); const url = `http://127.0.0.1:${server.addr.port}`; const independent = await fetch(url+'/independent'); const canceled = independent.body.getReader(); if ((await canceled.read()).done) throw Error('missing independent body'); control.abort(Error('stop')); let failed = false; try { while (!(await canceled.read()).done) {} } catch { failed = true; } if (!failed) throw Error('ignored independent signal'); canceled.releaseLock(); const native = await fetch(url+'/native'); const reader = native.body.getReader(); let total = (await reader.read()).value.length; if (await (await fetch(url+'/revoke')).text() !== 'revoked') throw Error('revocation'); failed = false; try { for (;;) { const next = await reader.read(); if (next.done) break; total += next.value.length; } } catch { failed = true; } if (!failed || total >= 64 * 1024 * 1024) throw Error('read after revocation'); reader.releaseLock(); await server.shutdown(); console.log('interrupted'); "#); assert_eq!(output.stdout, b"interrupted\n"); } #[test] fn retained_bindings_and_byob_streaming() { let output = run(r#" import {serveFile, FileHandle} from 'avril:std'; const request = new Request('http://localhost/', {headers:{range:'bytes=65530-65540'}}); const poison = () => {throw Error('guest method');}; FileHandle.prototype.read = FileHandle.prototype.stat = FileHandle.prototype.seek = FileHandle.prototype[Symbol.dispose] = poison; Date.prototype.toUTCString = Math.floor = Number.isSafeInteger = poison; globalThis.Date = globalThis.Headers = poison; const response = await serveFile(request, root+'/large.bin'); const reader = response.body.getReader({mode:'byob'}); let count = 0; for (;;) { const next = await reader.read(new Uint8Array(3)); if (next.done) break; for (const byte of next.value) if (byte !== 42) throw Error('BYOB content'); count += next.value.length; } if (count !== 11 || response.status !== 206) throw Error('BYOB length'); reader.releaseLock(); console.log('retained'); "#); assert_eq!(output.stdout, b"retained\n"); } #[test] fn responses_over_http_and_error_cases() { let output = run(r#" import {serve, serveFile} from 'avril:std'; const server = serve({port:0}, request => serveFile(request, root + new URL(request.url).pathname)); const url = `http://127.0.0.1:${server.addr.port}`; const full = await fetch(url+'/large.bin'); if (full.status !== 200 || (await full.arrayBuffer()).byteLength !== 200000) throw Error('large response'); const part = await fetch(url+'/large.bin', {headers:{range:'bytes=65530-65540'}}); const bytes = new Uint8Array(await part.arrayBuffer()); if (part.status !== 206 || bytes.length !== 11 || bytes.some(x => x !== 42)) throw Error('large range'); const head = await fetch(url+'/large.bin', {method:'HEAD'}); if (head.body !== null || head.headers.get('content-length') !== '200000') throw Error('wire HEAD'); const notModified = await fetch(url+'/large.bin', {headers:{'if-none-match':head.headers.get('etag')}}); if (notModified.status !== 304 || notModified.body !== null) throw Error('wire 304'); const empty = await fetch(url+'/empty.bin'); if (empty.status !== 200 || empty.headers.get('content-type') !== 'application/octet-stream' || await empty.text() !== '') throw Error('empty'); for (const path of ['/missing', '/', '/hello.txt/child']) { const response = await fetch(url+path); if (response.status !== 404 || await response.text() !== '') throw Error('missing ' + path); } const post = await fetch(url+'/hello.txt', {method:'POST'}); if (post.status !== 405 || post.headers.get('allow') !== 'GET, HEAD') throw Error('method'); await post.text(); const canceled = await fetch(url+'/large.bin'); await canceled.body.cancel(); await server.shutdown(); for (const [method, path, status] of [['GET', '/missing', 404], ['POST', '/hello.txt', 405]]) { const response = await serveFile(new Request(url, {method}), root+path, { headers:{'access-control-allow-origin':'*', 'content-length':'999', 'content-range':'invalid'}, }); if (response.status !== status || response.headers.get('access-control-allow-origin') !== '*' || response.headers.get('content-length') !== '0' || response.headers.has('content-range')) throw Error('error headers'); } console.log('http'); "#); assert_eq!(output.stdout, b"http\n"); }