mod common; use std::fs; use std::process::{Command, Stdio}; fn run(source: &str, flags: &[&str]) -> (tempfile::TempDir, std::process::Output) { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.js"), source).unwrap(); fs::write(dir.path().join("data"), b"abcdef").unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(flags) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); (dir, output) } #[test] fn error_constructors_preserve_reflected_construction() { let (_, output) = run( r#" import {errors} from 'avril:std'; const Constructor = errors.NotFound; Object.setPrototypeOf(Constructor, function poisoned() {throw Error('Guest superclass');}); class Derived extends Constructor {} const error = new Derived('missing'); if (!(error instanceof Derived) || !(error instanceof Error) || error.name !== 'NotFound' || error.message !== 'missing') throw Error('Error identity'); if (Constructor.length !== 0 || Constructor.name !== 'NotFound') throw Error('Error constructor metadata'); if (!Object.isFrozen(errors)) throw Error('Mutable error namespace'); const name = Object.getOwnPropertyDescriptor(error, 'name'); if (!name.writable || !name.configurable || name.enumerable) throw Error('Error descriptor'); console.log('reflected errors'); "#, &[], ); assert_eq!(output.stdout, b"reflected errors\n"); } #[test] fn handles_read_seek_write_truncate_and_report_metadata() { let (dir, output) = run( r#" import {open,create,stat,FileHandle,SeekMode} from 'avril:std'; const file=await open('data',{read:true,write:true}); if(!(file instanceof FileHandle))throw Error('Handle'); const bytes=new Uint8Array(3); if(await file.read(bytes)!==3 || bytes.join()!=='97,98,99')throw Error('Read'); if(await file.seek(-1,SeekMode.Current)!==2 || await file.write(new Uint8Array([88,89]))!==2)throw Error('Cursor'); await file.seek(-1,SeekMode.End); if(await file.read(bytes)!==1 || bytes[0]!==102 || await file.read(bytes)!==null || await file.read(new Uint8Array())!==0)throw Error('EOF'); const a=await file.stat(),b=await stat(new URL('./data',import.meta.url)); if(!a.isFile || a.isDirectory || a.isSymlink || a.size!==6 || b.size!==6 || !(a.mtime instanceof Date))throw Error('Metadata'); await file.truncate(4);await file.syncData();await file.sync();file.close(); const created=await create('new');await created.write(new Uint8Array([1,2]));created.close(); const overwritten=await create('new');if((await overwritten.stat()).size!==0)throw Error('Truncate shorthand');overwritten[Symbol.dispose](); console.log('file IO'); "#, &[], ); assert_eq!(output.stdout, b"file IO\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abXY"); assert_eq!(fs::read(dir.path().join("new")).unwrap(), b""); } #[test] fn options_errors_modes_and_lifecycle_are_consistent() { let (dir, output) = run( r#" import {open,create,stat,FileHandle,errors} from 'avril:std'; async function fails(call,C){try{await call();throw Error('Accepted invalid operation');}catch(e){if(!(e instanceof C))throw e;}} await fails(()=>open('missing'),errors.NotFound); await fails(()=>open('data',{}),errors.InvalidInput); await fails(()=>open('data',{read:true,truncate:true}),errors.InvalidInput); await fails(()=>open('data',{write:true,createNew:true}),errors.AlreadyExists); await fails(()=>open('data',{read:'yes'}),TypeError); await fails(()=>open('data',{write:true,mode:8192}),RangeError); await fails(()=>open(new URL('https://example.test')),errors.InvalidInput); await fails(()=>stat(''),errors.NotFound); await fails(()=>open(),TypeError); try{new FileHandle();throw Error('Constructed handle');}catch(e){if(!(e instanceof TypeError))throw e;} const file=await open('data');await fails(()=>file.write(new Uint8Array([0])),errors.BadResource); await fails(()=>file.read(new DataView(new ArrayBuffer(1))),TypeError); await fails(()=>file.seek(2**53,0),RangeError); await file.seek(Number.MAX_SAFE_INTEGER,0); await fails(()=>file.seek(1,1),errors.InvalidInput); if(await file.seek(0,1)!==Number.MAX_SAFE_INTEGER)throw Error('Failed seek moved cursor'); file.close();file[Symbol.dispose]();file[Symbol.dispose](); await fails(()=>file.stat(),errors.BadResource);await fails(()=>file.read(new Uint8Array(1)),errors.BadResource); try{file.close();throw Error('Closed twice');}catch(e){if(!(e instanceof errors.BadResource))throw e;} const append=await open('data',{append:true});await append.seek(0,0);await append.write(new Uint8Array([33]));append.close(); if(!Object.isFrozen(errors))throw Error('Errors registry'); console.log('options'); "#, &[], ); assert_eq!(output.stdout, b"options\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef!"); } #[test] fn direct_writes_snapshot_input_and_stream_writes_finish_all_partial_chunks() { let (dir, output) = run( r#" import {create,open,errors} from 'avril:std'; const file=await create('out'), bytes=new Uint8Array(200003);bytes.fill(7); const pending=file.write(bytes);bytes.fill(9); const count=await pending;if(count<=0 || count>bytes.length)throw Error('Partial count'); const remaining=new Uint8Array(bytes.length-count);remaining.fill(7);await file.writable.getWriter().write(remaining);file.close(); const check=await open('out');const all=new Uint8Array(bytes.length);let offset=0,n; while((n=await check.read(all.subarray(offset)))!==null && n!==0)offset+=n; if(offset!==bytes.length || all.some(x=>x!==7))throw Error('Snapshot/partial write');check.close(); const shared=new Uint8Array(new SharedArrayBuffer(3));shared.set([1,2,3]); const f=await create('shared');if(await f.write(shared)!==3)throw Error('Shared write');f.close(); console.log('snapshots'); "#, &[], ); assert_eq!(output.stdout, b"snapshots\n"); assert_eq!(fs::read(dir.path().join("shared")).unwrap(), [1, 2, 3]); } #[test] fn streams_support_byob_eof_pipe_backpressure_and_automatic_close() { let (dir, output) = run( r#" import {open,create,errors} from 'avril:std'; const file=await open('data'); if(file.readable!==file.readable)throw Error('Readable identity'); const reader=file.readable.getReader({mode:'byob'});let text=''; while(true){const step=await reader.read(new Uint8Array(2));if(step.done)break;text+=new TextDecoder().decode(step.value);} if(text!=='abcdef')throw Error('BYOB');reader.releaseLock(); try{await file.stat();throw Error('EOF did not close');}catch(e){if(!(e instanceof errors.BadResource))throw e;} const source=await open('data'),target=await create('copy'); if(target.writable!==target.writable)throw Error('Writable identity'); await source.readable.pipeTo(target.writable); for(const f of [source,target])try{await f.stat();throw Error('Pipe did not close');}catch(e){if(!(e instanceof errors.BadResource))throw e;} console.log('streams'); "#, &[], ); assert_eq!(output.stdout, b"streams\n"); assert_eq!(fs::read(dir.path().join("copy")).unwrap(), b"abcdef"); } #[test] fn cancellation_abort_and_explicit_close_invalidate_both_streams() { let (_, output) = run( r#" import {open,create,errors} from 'avril:std'; const a=await open('data');await a.readable.cancel(); const b=await create('out');await b.writable.abort('stop'); const c=await open('data',{read:true,write:true}),reader=c.readable.getReader(),writer=c.writable.getWriter(); const closedRead=reader.closed.catch(e=>e),closedWrite=writer.closed.catch(e=>e);c.close(); if(!(await closedRead instanceof errors.BadResource) || !(await closedWrite instanceof errors.BadResource))throw Error('Stream close'); for(const file of [a,b,c])try{await file.stat();throw Error('Unclosed handle');}catch(e){if(!(e instanceof errors.BadResource))throw e;} console.log('lifecycle'); "#, &[], ); assert_eq!(output.stdout, b"lifecycle\n"); } #[test] fn concurrent_direct_operations_share_a_serialized_cursor() { let (dir, output) = run( r#" import {open} from 'avril:std'; const file=await open('data',{read:true,write:true}); const a=new Uint8Array(2),b=new Uint8Array(2),c=new Uint8Array(2); await Promise.all([file.read(a),file.read(b),file.read(c)]); if(new TextDecoder().decode(a)!=='ab'||new TextDecoder().decode(b)!=='cd'||new TextDecoder().decode(c)!=='ef')throw Error('Read order'); const seek=file.seek(0,0),write=file.write(new Uint8Array([90])),stat=file.stat(); if(await seek!==0 || await write!==1 || (await stat).size!==6)throw Error('Operation order'); file.close();console.log('serialized'); "#, &[], ); assert_eq!(output.stdout, b"serialized\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"Zbcdef"); } #[test] fn permissions_apply_to_open_and_live_operations_without_affecting_stdio() { let (dir, output) = run( r#" import {open,create,stat,pledge,errors} from 'avril:std'; const file=await open('data',{read:true,write:true}); for(const name of ['forbidden','allowed/child']){ try{await create(name);throw Error('Created denied file');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} } const allowed=await create('allowed');allowed.close(); pledge({read:false,write:false}); for(const call of [()=>file.read(new Uint8Array(1)),()=>file.write(new Uint8Array([1])),()=>file.truncate(),()=>file.stat(),()=>stat('data'),()=>create('allowed')]){ try{await call();throw Error('Retained authority');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} } file.close();console.log('live caps'); "#, &[ "-0", "--allow-read=data", "--allow-read=allowed", "--allow-write=data", "--allow-write=allowed", ], ); assert_eq!(output.stdout, b"live caps\n"); assert!(!dir.path().join("forbidden").exists()); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); } #[test] fn handles_are_private_branded_and_not_cloneable() { let output = common::run( r#" import {open,FileHandle} from 'avril:std'; const file=await open(new URL(import.meta.url)),read=FileHandle.prototype.read,close=FileHandle.prototype.close; Object.setPrototypeOf(file,null); try{structuredClone(file);throw Error('Cloned file handle');}catch(e){if(e.name!=='DataCloneError')throw e;} if(await read.call(file,new Uint8Array(1))!==1)throw Error('Private brand'); close.call(file); for(const name of ['ext:file/main.js','ext:file/errors.js']){ try{await import(name);throw Error('Private import');}catch(e){if(!e.message.includes('private'))throw e;} } if(globalThis.FileHandle!==undefined || Symbol.for('__avril.base') in globalThis)throw Error('Global exposure'); console.log('private handles'); "#, &[], ); assert_eq!(output.stdout, b"private handles\n"); } #[cfg(unix)] #[test] fn stat_follows_links_lstat_preserves_entries_and_dangling_links_cannot_create_outside() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("data"), b"x").unwrap(); std::os::unix::fs::symlink("data", dir.path().join("alias")).unwrap(); let outside = tempfile::tempdir().unwrap(); std::os::unix::fs::symlink(outside.path().join("new"), dir.path().join("dangling")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {stat,lstat,create,open,errors} from 'avril:std'; if(!(await stat('alias')).isFile || !(await lstat('alias')).isSymlink || (await lstat('alias')).isFile)throw Error('Link metadata'); const file=await open('alias');file.close(); try{await create('dangling');throw Error('Followed dangling link');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read dangling')throw e;} console.log('links'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=./", "--allow-write=./", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"links\n"); assert!(!outside.path().join("new").exists()); } #[test] fn large_concurrent_batches_do_not_fail_due_to_queue_admission() { let (dir, output) = run( r#" import {create,stat} from 'avril:std'; const checks=[];for(let i=0;i<600;i++)checks.push(stat('data')); if((await Promise.all(checks)).some(x=>x.size!==6))throw Error('Stat batch'); const file=await create('out'),writes=[]; for(let i=0;i<600;i++)writes.push(file.write(new Uint8Array([7]))); if((await Promise.all(writes)).some(x=>x!==1) || (await file.stat()).size!==600)throw Error('Write batch'); file.close();console.log('batches'); "#, &[], ); assert_eq!(output.stdout, b"batches\n"); assert_eq!(fs::read(dir.path().join("out")).unwrap(), vec![7; 600]); } #[cfg(unix)] #[test] fn denied_file_requests_do_not_disclose_targets_or_resolution_errors() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("secret")).unwrap(); fs::write(dir.path().join("secret/file"), b"private").unwrap(); std::os::unix::fs::symlink("secret/file", dir.path().join("alias")).unwrap(); std::os::unix::fs::symlink("loop", dir.path().join("loop")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {open,stat,lstat,errors} from 'avril:std'; for(const name of ['alias','secret/file/x','secret/missing','loop','missing/../secret/file']){ for(const call of [()=>open(name),()=>stat(name),()=>lstat(name)]){ try{await call();throw Error('Granted read');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;} } try{await open(name,{write:true,create:true});throw Error('Granted write');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='write '+name)throw e;} } console.log('no oracle'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"no oracle\n"); } #[cfg(unix)] #[test] fn dangling_link_metadata_reports_missing_targets_and_dot_tails_follow_directories() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("folder")).unwrap(); std::os::unix::fs::symlink("missing", dir.path().join("dangling")).unwrap(); std::os::unix::fs::symlink("folder", dir.path().join("alias")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {stat,lstat,errors} from 'avril:std'; if(!(await lstat('dangling')).isSymlink || !(await lstat('alias/.')).isDirectory || (await lstat('alias/.')).isSymlink)throw Error('Entry metadata'); for(const call of [()=>stat('dangling'),()=>stat('dangling/file'),()=>lstat('dangling/file')]){ try{await call();throw Error('Found missing target');}catch(e){if(!(e instanceof errors.NotFound))throw e;} } console.log('dangling metadata'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"dangling metadata\n"); } #[test] fn file_io_does_not_use_typed_array_species_or_expose_write_snapshots() { let (dir, output) = run( r#" import {create} from 'avril:std'; const file=await create('out'),input=new Uint8Array(200000);input.fill(5); Object.defineProperty(Uint8Array.prototype,'constructor',{get(){throw Error('I/O consulted species');},configurable:true}); if(await file.write(input)!==65536)throw Error('Partial write'); const writer=file.writable.getWriter();await writer.write(input);await writer.close(); console.log('no species'); "#, &[], ); assert_eq!(output.stdout, b"no species\n"); assert_eq!(fs::read(dir.path().join("out")).unwrap(), vec![5; 265536]); } #[test] fn reads_reject_detached_and_shrunken_targets_after_pending_io() { let (_, output) = run( r#" import {open} from 'avril:std'; const file=await open('data'),detached=new Uint8Array(2),first=file.read(detached); structuredClone(detached.buffer,{transfer:[detached.buffer]}); try{await first;throw Error('Read detached buffer');}catch(e){if(!(e instanceof TypeError))throw e;} if(await file.seek(0,1)!==2)throw Error('Detached read cursor'); const buffer=new ArrayBuffer(2,{maxByteLength:4}),second=file.read(new Uint8Array(buffer));buffer.resize(0); try{await second;throw Error('Read shrunken buffer');}catch(e){if(!(e instanceof RangeError))throw e;} if(await file.seek(0,1)!==4)throw Error('Shrunken read cursor'); file.close();console.log('buffer lifetimes'); "#, &[], ); assert_eq!(output.stdout, b"buffer lifetimes\n"); } #[test] fn path_primitives_enumerate_copy_rename_and_remove() { let (dir, output) = run( r#" import {mkdir,readDir,copyFile,rename,remove,stat,errors} from 'avril:std'; await mkdir('folder',{mode:0o700}); await copyFile('data',new URL('./folder/copied',import.meta.url)); const entries=[];for await(const entry of readDir('folder'))entries.push(entry); if(entries.length!==1 || entries[0].name!=='copied' || !entries[0].isFile || entries[0].isDirectory || entries[0].isSymlink)throw Error('Entries'); await rename('folder/copied','folder/renamed'); if((await stat('folder/renamed')).size!==6)throw Error('Rename'); try{await remove('folder');throw Error('Removed populated directory');}catch(e){if(!(e instanceof errors.NotEmpty))throw e;} await remove('folder/renamed');await remove('folder'); await mkdir('tree');await mkdir('tree/sub');await copyFile('data','tree/sub/data');await remove('tree',{recursive:true}); for(const name of ['folder','tree'])try{await stat(name);throw Error('Remaining path');}catch(e){if(!(e instanceof errors.NotFound))throw e;} console.log('path ops'); "#, &[], ); assert_eq!(output.stdout, b"path ops\n"); assert!(!dir.path().join("folder").exists()); assert!(!dir.path().join("tree").exists()); } #[test] fn denied_descendants_block_directory_mutations_before_side_effects() { let (dir, output) = run( r#" import {mkdir,copyFile,rename,remove,stat,errors} from 'avril:std'; await mkdir('tree');await copyFile('data','tree/data'); await mkdir('source');await copyFile('data','source/data'); for(const call of [()=>remove('tree',{recursive:true}),()=>rename('tree','moved'),()=>rename('source','tree')]){ try{await call();throw Error('Moved denied subtree');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} } if((await stat('tree/data')).size!==6 || (await stat('source/data')).size!==6)throw Error('Side effects'); console.log('tree denies'); "#, &["--deny-write=tree/protected"], ); assert_eq!(output.stdout, b"tree denies\n"); assert!(!dir.path().join("moved").exists()); } #[test] fn listing_filters_denied_children_and_rename_cannot_launder_unread_data() { let (dir, output) = run( r#" import {mkdir,open,readDir,rename,errors,pledge} from 'avril:std'; await mkdir('tree'); const secret=await open('tree/secret',{write:true,create:true});await secret.write(new Uint8Array([42]));secret.close(); pledge({read:{deny:['tree/public/future']}}); const publicFile=await open('tree/public',{write:true,create:true});publicFile.close(); const names=[];for await(const entry of readDir('tree'))names.push(entry.name); if(names.join()!=='public')throw Error('Listed secret entry'); for(const call of [()=>rename('tree','publicTree'),()=>rename('tree/secret','visible')]){ try{await call();throw Error('Laundered read authority');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} } try{await rename('tree/public','visible');throw Error('Ignored future read deny');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} console.log('listing privacy'); "#, &["--deny-read=tree/secret"], ); assert_eq!(output.stdout, b"listing privacy\n"); assert_eq!(fs::read(dir.path().join("tree/secret")).unwrap(), [42]); } #[test] fn exact_file_grants_copy_but_cannot_rename_potential_directory_trees() { let (dir, output) = run( r#" import {copyFile,rename,errors} from 'avril:std'; await copyFile('data','copy'); try{await rename('data','copy');throw Error('Renamed potential tree with exact grants');}catch(e){if(!(e instanceof errors.PermissionDenied)||!e.message.includes('tree'))throw e;} console.log('exact grants'); "#, &[ "-0", "--allow-read=data", "--allow-write=data", "--allow-write=copy", ], ); assert_eq!(output.stdout, b"exact grants\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); assert_eq!(fs::read(dir.path().join("copy")).unwrap(), b"abcdef"); } #[test] fn directory_shaped_grants_support_whole_tree_rename_and_live_limits() { let (dir, output) = run( r#" import {mkdir,create,rename,remove,pledge,errors} from 'avril:std'; await mkdir('source');const file=await create('source/data');await file.write(new Uint8Array([1]));file.close(); await rename('source','destination'); pledge({write:{deny:['destination/future']}}); try{await remove('destination',{recursive:true});throw Error('Ignored pledged descendant');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} console.log('tree grants'); "#, &[ "-0", "--allow-read=source/", "--allow-write=source/", "--allow-write=destination/", ], ); assert_eq!(output.stdout, b"tree grants\n"); assert_eq!(fs::read(dir.path().join("destination/data")).unwrap(), [1]); } #[test] fn new_path_operations_preserve_privacy_and_validate_arguments() { let (_, output) = run( r#" import {mkdir,remove,rename,copyFile,readDir,errors} from 'avril:std'; for(const call of [()=>mkdir('data/x'),()=>remove('data/x'),()=>rename('data/x','other'),()=>copyFile('data/x','other'),async()=>{for await(const _ of readDir('data/x')){}}]){ try{await call();throw Error('Disclosed path');}catch(e){if(!(e instanceof errors.PermissionDenied)||!e.message.includes('data/x'))throw e;} } for(const call of [()=>mkdir('out',{mode:2**40}),()=>remove('data',{recursive:'yes'}),()=>rename('data'),()=>copyFile('data')]){ try{await call();throw Error('Accepted invalid arguments');}catch(e){if(!(e instanceof RangeError)&&!(e instanceof TypeError))throw e;} } console.log('path privacy'); "#, &["-0"], ); assert_eq!(output.stdout, b"path privacy\n"); } #[cfg(unix)] #[test] fn copied_hard_links_are_not_truncated_and_link_mutations_do_not_follow_targets() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("data"), b"preserved").unwrap(); fs::hard_link(dir.path().join("data"), dir.path().join("hard")).unwrap(); let outside = tempfile::tempdir().unwrap(); fs::write(outside.path().join("protected"), b"outside").unwrap(); std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {copyFile,rename,remove,mkdir,readDir,errors} from 'avril:std'; for(const name of ['data','hard'])try{await copyFile('data',name);throw Error('Copied same inode');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;} const entries=[];for await(const e of readDir('.'))entries.push(e); if(!entries.find(e=>e.name==='link')?.isSymlink)throw Error('Link entry type'); await rename('link','renamedLink');await remove('renamedLink',{recursive:true}); await mkdir('folder',{mode:0o700}); console.log('inode identity'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"inode identity\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"preserved"); assert_eq!( fs::read(outside.path().join("protected")).unwrap(), b"outside" ); use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(dir.path().join("folder")) .unwrap() .permissions() .mode() & 0o777, 0o700 ); } #[test] fn fs_conveniences_read_write_text_bytes_options_and_snapshots() { let (dir, output) = run( r#" import {readFile,readTextFile,writeFile,writeTextFile,exists} from 'avril:fs'; import {errors} from 'avril:std'; if(await readTextFile('data')!=='abcdef' || await exists('missing') || await exists('missing/../data'))throw Error('Read/exists'); const bytes=new Uint8Array(200003);bytes.fill(7); const pending=writeFile(new URL('./out',import.meta.url),bytes);bytes.fill(9);await pending; const result=await readFile('out');if(result.length!==200003||result.some(x=>x!==7))throw Error('Snapshot'); await writeTextFile('text','héllo 🌍');await writeTextFile('text','!',{append:true}); if(await readTextFile('text')!=='héllo 🌍!')throw Error('Text/append'); await writeFile('out',new Uint8Array());if((await readFile('out')).length!==0)throw Error('Empty truncate'); for(const call of [()=>writeTextFile('text','x',{createNew:true}),()=>writeFile('missing',bytes,{create:false}),()=>writeFile('text',new DataView(new ArrayBuffer(2))),()=>writeTextFile('text',null)]){ try{await call();throw Error('Invalid write accepted');}catch(e){if(!(e instanceof errors.AlreadyExists)&&!(e instanceof errors.NotFound)&&!(e instanceof TypeError))throw e;} } console.log('fs IO'); "#, &[], ); assert_eq!(output.stdout, b"fs IO\n"); assert_eq!( fs::read(dir.path().join("text")).unwrap(), "héllo 🌍!".as_bytes() ); } #[test] fn mkdirs_copy_and_move_support_nested_trees_and_missing_parent_directories() { let (dir, output) = run( r#" import {mkdirs,writeTextFile,copy,move,readTextFile,exists} from 'avril:fs'; await mkdirs('source/sub/deep');await mkdirs('source/sub/deep'); await writeTextFile('source/sub/deep/data','nested'); await copy('source','destination'); if(await readTextFile('destination/sub/deep/data')!=='nested')throw Error('Tree copy'); await copy('data','other/parents/file'); if(await readTextFile('other/parents/file')!=='abcdef')throw Error('File copy parents'); await move('destination','moved'); if(await exists('destination')||await readTextFile('moved/sub/deep/data')!=='nested')throw Error('Tree move'); console.log('fs workflows'); "#, &[], ); assert_eq!(output.stdout, b"fs workflows\n"); assert!(dir.path().join("source/sub/deep/data").exists()); assert!(!dir.path().join("destination").exists()); } #[test] fn make_temp_dir_and_file_create_unique_namespaced_paths() { let (dir, output) = run( r#" import {makeTempDir,makeTempFile,exists,readTextFile,writeTextFile} from 'avril:fs'; import {mkdir,stat,remove,errors} from 'avril:std'; const seen=new Set(); const plain=await makeTempDir(); if(!/^[0-9a-f]{8,16}$/.test(plain.split('/').pop()))throw Error('Hex name'); const named=await makeTempDir({prefix:'work-',suffix:'-tmp'}); if(!named.split('/').pop().startsWith('work-')||!named.endsWith('-tmp'))throw Error('Affixes'); const file=await makeTempFile({prefix:'out-',suffix:'.bin'}); if((await stat(file)).size!==0||!(await stat(file)).isFile)throw Error('Empty file'); await writeTextFile(file,'temp'); if(await readTextFile(file)!=='temp')throw Error('Writable temp file'); const inner=await makeTempDir({dir:plain,prefix:'inner-'}); if(!inner.startsWith(plain+'/'))throw Error('Custom dir spelling'); const viaUrl=await makeTempFile({dir:new URL(plain+'/','file://')}); if(!viaUrl.startsWith(plain+'/'))throw Error('URL dir'); for(let i=0;i<10;i++){const candidate=await makeTempFile({dir:plain});if(seen.has(candidate))throw Error('Collision');seen.add(candidate);} for(const call of [()=>makeTempDir({prefix:'a/b'}),()=>makeTempFile({suffix:'x*y'}),()=>makeTempDir({prefix:'a\0b'}),()=>makeTempDir({prefix:3}),()=>makeTempDir({dir:plain+'/missing/nested'})]){ try{await call();throw Error('Invalid temp accepted');}catch(e){if(!(e instanceof errors.InvalidInput)&&!(e instanceof errors.NotFound)&&!(e instanceof TypeError))throw e;} } const dirInfo=await stat(named); if(!dirInfo.isDirectory)throw Error('Directory kind'); if(dirInfo.mode!==null&&(dirInfo.mode&0o777)!==0o700)throw Error('Directory mode'); const fileInfo=await stat(file); if(fileInfo.mode!==null&&(fileInfo.mode&0o777)!==0o600)throw Error('File mode'); await mkdir('scoped'); const scoped=await makeTempDir({dir:'scoped'}); if(!scoped.startsWith('scoped/'))throw Error('Relative dir'); await remove(plain,{recursive:true});await remove(named,{recursive:true});await remove(scoped,{recursive:true});await remove('scoped',{recursive:true});await remove(file); if(await exists(plain)||await exists(named)||await exists(file))throw Error('Cleanup'); console.log('temp IO'); "#, &[], ); assert_eq!(output.stdout, b"temp IO\n"); assert!(!dir.path().join("scoped").exists()); } #[test] fn fs_write_only_directory_and_exact_file_grants_work_without_reading_destinations() { let (dir, output) = run( r#" import {mkdirs,writeTextFile,copy,readTextFile,exists} from 'avril:fs'; import {errors} from 'avril:std'; await mkdirs('out/sub');await mkdirs('out/sub'); await writeTextFile('out/sub/data','written');await copy('data','exact'); for(const call of [()=>readTextFile('out/sub/data'),()=>exists('out/sub/data'),()=>exists('ungranted/missing')]){ try{await call();throw Error('Read without grant');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} } console.log('write-only fs'); "#, &[ "-0", "--allow-read=data", "--allow-write=out/", "--allow-write=exact", ], ); assert_eq!(output.stdout, b"write-only fs\n"); assert_eq!(fs::read(dir.path().join("exact")).unwrap(), b"abcdef"); assert_eq!( fs::read(dir.path().join("out/sub/data")).unwrap(), b"written" ); } #[test] fn whole_tree_copy_authorization_prevents_silent_skipping_and_partial_denied_writes() { let (dir, output) = run( r#" import {copy,mkdirs,writeTextFile} from 'avril:fs'; import {errors} from 'avril:std'; await mkdirs('source');await writeTextFile('source/data','payload'); for(const call of [()=>copy('source','readDeniedCopy'),()=>copy('source','destination')]){ try{await call();throw Error('Copied forbidden tree');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} } console.log('copy ceilings'); "#, &[ "--deny-read=source/future", "--deny-write=destination/future", ], ); assert_eq!(output.stdout, b"copy ceilings\n"); assert!(!dir.path().join("readDeniedCopy").exists()); assert!(!dir.path().join("destination").exists()); } #[test] fn copying_overlapping_trees_is_rejected_before_creating_destinations() { let (dir, output) = run( r#" import {copy,mkdirs,writeTextFile} from 'avril:fs'; import {errors} from 'avril:std'; await mkdirs('source/inner');await writeTextFile('source/data','payload'); for(const [from,to] of [['source','source'],['source','source/inner/new'],['source/inner','source']]){ try{await copy(from,to);throw Error('Copied overlapping trees');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;} } console.log('overlap guards'); "#, &[], ); assert_eq!(output.stdout, b"overlap guards\n"); assert!(!dir.path().join("source/inner/new").exists()); } #[test] fn utility_intrinsics_and_error_classification_resist_public_prototype_tampering() { let (_, output) = run( r#" import {writeFile,readFile,exists,writeTextFile,readTextFile} from 'avril:fs'; import {FileHandle,errors} from 'avril:std'; FileHandle.prototype.read=FileHandle.prototype.write=FileHandle.prototype[Symbol.dispose]=()=>{throw Error('Tampered method');}; TextEncoder.prototype.encode=TextDecoder.prototype.decode=()=>{throw Error('Tampered codec');}; Object.defineProperty(Uint8Array.prototype,'constructor',{get(){throw Error('Species');}}); Object.defineProperty(errors.NotFound,Symbol.hasInstance,{value(){return true;}}); await writeTextFile('text','safe');if(await readTextFile('text')!=='safe')throw Error('Codec'); const data=new Uint8Array(140000);data.fill(1);await writeFile('out',data); if((await readFile('out')).length!==140000)throw Error('Methods'); if(await exists('missing'))throw Error('Missing'); try{await exists('denied');throw Error('Swallowed permission error');}catch(e){if(e.name!=='PermissionDenied')throw e;} console.log('captured utilities'); "#, &["--deny-read=denied"], ); assert_eq!(output.stdout, b"captured utilities\n"); } #[test] fn fs_public_module_is_frozen_and_private_sources_stay_private() { let output = common::run( r#" import * as fs from 'avril:fs'; import * as std from 'avril:std'; for(const name of ['readFile','readTextFile','writeFile','writeTextFile','mkdirs','copy','move','exists']) if(typeof fs[name]!=='function'||std[name]!==undefined)throw Error('Module split'); try{fs.readFile=()=>{};throw Error('FS module');}catch(e){if(!(e instanceof TypeError))throw e;} if(Symbol.for('__avril.fs') in globalThis)throw Error('FS registry'); for(const source of ['ext:file/fs.js','ext:file/workflows.js']){ try{await import(source);throw Error('Private source imported');}catch(e){if(!e.message.includes('private'))throw e;} } console.log('fs module'); "#, &[], ); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"fs module\n"); } #[cfg(unix)] #[test] fn directory_copy_preserves_symlink_children_and_rejects_canonical_destination_overlap() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("source")).unwrap(); fs::write(dir.path().join("source/data"), b"safe").unwrap(); std::os::unix::fs::symlink("source", dir.path().join("alias")).unwrap(); std::os::unix::fs::symlink("../data", dir.path().join("source/link")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {copy} from 'avril:fs'; import {errors,readLink,lstat} from 'avril:std'; try{await copy('source','alias/nested');throw Error('Canonical overlap');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;} await copy('alias','copiedAlias');await copy('source','copiedTree'); if(!(await lstat('copiedAlias')).isSymlink||await readLink('copiedAlias')!=='source'||await readLink('copiedTree/link')!=='../data')throw Error('Link preservation'); console.log('symlink workflows'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"symlink workflows\n"); assert!(!dir.path().join("source/nested").exists()); assert_eq!( fs::read_link(dir.path().join("copiedAlias")).unwrap(), std::path::Path::new("source") ); use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(dir.path().join("copiedTree")) .unwrap() .permissions() .mode() & 0o777, fs::metadata(dir.path().join("source")) .unwrap() .permissions() .mode() & 0o777 ); assert!(fs::symlink_metadata(dir.path().join("source/link")).is_ok()); } #[cfg(target_os = "linux")] #[test] fn cross_device_moves_copy_then_remove_and_keep_sources_on_failed_copy() { use std::os::unix::fs::MetadataExt; let dir = tempfile::tempdir().unwrap(); let Ok(other) = tempfile::tempdir_in("/dev/shm") else { return; }; if fs::metadata(dir.path()).unwrap().dev() == fs::metadata(other.path()).unwrap().dev() { return; } fs::write(dir.path().join("file"), b"cross-device").unwrap(); fs::create_dir(dir.path().join("tree")).unwrap(); fs::write(dir.path().join("tree/data"), b"tree").unwrap(); fs::create_dir(dir.path().join("unsupported")).unwrap(); std::os::unix::fs::symlink("data", dir.path().join("tree/link")).unwrap(); std::os::unix::fs::symlink("file", dir.path().join("link")).unwrap(); let _socket = std::os::unix::net::UnixListener::bind(dir.path().join("unsupported/socket")).unwrap(); let source = format!("{:?}", dir.path().to_str().unwrap()); let destination = format!("{:?}", other.path().to_str().unwrap()); fs::write(dir.path().join("main.js"),format!(r#" import {{move,readTextFile,exists}} from 'avril:fs'; const source={source},destination={destination}; await move(source+'/file',destination+'/file'); await move(source+'/link',destination+'/link'); await move(source+'/tree',destination+'/tree'); if(await exists(source+'/file')||await exists(source+'/tree')||await readTextFile(destination+'/file')!=='cross-device'||await readTextFile(destination+'/tree/data')!=='tree')throw Error('Cross-device move'); try{{await move(source+'/unsupported',destination+'/unsupported');throw Error('Moved unsupported tree');}}catch(e){{if(!e.message.includes('not yet implemented'))throw e;}} console.log('cross-device'); "#)).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .arg(dir.path().join("main.js")) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"cross-device\n"); assert!(fs::symlink_metadata(dir.path().join("unsupported/socket")).is_ok()); assert_eq!( fs::read_link(other.path().join("tree/link")).unwrap(), std::path::Path::new("data") ); assert_eq!( fs::read_link(other.path().join("link")).unwrap(), std::path::Path::new("file") ); assert!(fs::symlink_metadata(dir.path().join("link")).is_err()); assert!(other.path().join("tree/data").exists()); } #[cfg(unix)] #[test] fn copying_read_only_directory_trees_preserves_modes_after_private_staging() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("source")).unwrap(); fs::write(dir.path().join("source/data"), b"data").unwrap(); fs::set_permissions(dir.path().join("source"), fs::Permissions::from_mode(0o555)).unwrap(); fs::create_dir(dir.path().join("existing")).unwrap(); fs::set_permissions( dir.path().join("existing"), fs::Permissions::from_mode(0o777), ) .unwrap(); fs::write(dir.path().join("main.js"),r#" import {copy,readTextFile} from 'avril:fs'; await copy('source','new');await copy('source','existing'); if(await readTextFile('new/data')!=='data'||await readTextFile('existing/data')!=='data')throw Error('Readonly copy'); console.log('copied modes'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"copied modes\n"); for name in ["source", "new", "existing"] { let path = dir.path().join(name); assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o555 ); fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } } #[cfg(unix)] #[test] fn symbolic_link_primitives_preserve_text_resolve_paths_and_report_dangling_links() { let (dir, output) = run( r#" import {symlink,readLink,realPath,lstat,stat,mkdir,errors} from 'avril:std'; import {readTextFile,exists} from 'avril:fs'; await mkdir('links'); await symlink('../data','links/relative'); if(await readLink(new URL('./links/relative',import.meta.url))!=='../data'||await readTextFile('links/relative')!=='abcdef')throw Error('Relative link base'); const target=new URL('./data',import.meta.url);await symlink(target,'absolute',{type:'file'}); if(await readLink('absolute')!==await realPath('data')||await realPath('links/relative')!==await realPath('data'))throw Error('Absolute canonical path'); if(!(await lstat('links/relative')).isSymlink||!(await stat('links/relative')).isFile)throw Error('Metadata'); await symlink('missing','dangling'); if(await readLink('dangling')!=='missing'||await exists('dangling'))throw Error('Dangling text'); for(const call of [()=>realPath('dangling'),()=>realPath('missing')]){ try{await call();throw Error('Resolved missing target');}catch(e){if(!(e instanceof errors.NotFound))throw e;} } await symlink('loop','loop'); try{await realPath('loop');throw Error('Resolved loop');}catch(e){if(!(e instanceof errors.IoError))throw e;} console.log('symbolic links'); "#, &[], ); assert_eq!(output.stdout, b"symbolic links\n"); assert_eq!( fs::read_link(dir.path().join("links/relative")).unwrap(), std::path::Path::new("../data") ); } #[cfg(unix)] #[test] fn symlink_creation_requires_target_tree_rights_and_does_not_overwrite_entries() { let (dir, output) = run( r#" import {mkdir,symlink,readLink,realPath,errors} from 'avril:std'; await mkdir('out'); await symlink('../data','out/allowed'); if(await readLink('out/allowed')!=='../data')throw Error('Stored text'); for(const [target,name] of [['../secret/file','out/readDenied'],['../readonly/file','out/writeDenied'],['../exact','out/exactDenied']]){ try{await symlink(target,name);throw Error('Planted unauthorized link');}catch(e){if(!(e instanceof errors.PermissionDenied)||!e.message.includes(name))throw e;} } try{await symlink('replacement','out/allowed');throw Error('Overwrote link');}catch(e){if(!(e instanceof errors.AlreadyExists)&&!(e instanceof errors.PermissionDenied))throw e;} console.log('target permissions'); "#, &[ "--deny-read=secret/", "--deny-write=readonly/", "--deny-write=exact/future", ], ); assert_eq!(output.stdout, b"target permissions\n"); for name in ["readDenied", "writeDenied", "exactDenied"] { assert!(fs::symlink_metadata(dir.path().join("out").join(name)).is_err()); } assert_eq!( fs::read_link(dir.path().join("out/allowed")).unwrap(), std::path::Path::new("../data") ); } #[cfg(unix)] #[test] fn scoped_symlink_targets_may_be_missing_but_must_not_hide_unresolved_aliases() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); let outside = tempfile::tempdir().unwrap(); std::os::unix::fs::symlink( outside.path().join("missing"), dir.path().join("out/hiddenAlias"), ) .unwrap(); fs::write(dir.path().join("main.js"),r#" import {symlink,readLink,realPath,errors,pledge} from 'avril:std'; await symlink('future','out/allowed'); if(await readLink('out/allowed')!=='future')throw Error('Allowed future target'); for(const target of ['hiddenAlias','hiddenAlias/file','../../outside']){ try{await symlink(target,'out/denied');throw Error('Authorized unresolved target');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='symlink target for out/denied')throw e;} } pledge({write:false}); try{await symlink('future','out/revoked');throw Error('Retained write');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} console.log('scoped links'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=out/", "--allow-write=out/", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"scoped links\n"); assert!(fs::symlink_metadata(dir.path().join("out/denied")).is_err()); assert!(!outside.path().join("missing").exists()); } #[cfg(unix)] #[test] fn read_link_reads_entry_data_but_real_path_requires_the_referent_and_hides_denials() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); fs::create_dir(dir.path().join("secret")).unwrap(); fs::write(dir.path().join("secret/file"), b"private").unwrap(); std::os::unix::fs::symlink("../secret/file", dir.path().join("out/link")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {readLink,realPath,errors} from 'avril:std'; if(await readLink('out/link')!=='../secret/file')throw Error('Entry read'); for(const name of ['out/link','secret/file/x','secret/missing']){ try{await realPath(name);throw Error('Disclosed target');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;} } try{await readLink('secret/missing');throw Error('Read unauthorized link');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} console.log('link privacy'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=out/", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"link privacy\n"); } #[cfg(unix)] #[test] fn renaming_relative_links_rechecks_targets_and_scoped_directory_rebasing_is_explicitly_deferred() { let (dir, output) = run( r#" import {mkdir,symlink,rename,readLink,errors} from 'avril:std'; await mkdir('safe');await mkdir('safe/deep');await mkdir('dest'); await symlink('../data','safe/link'); try{await rename('safe/link','safe/deep/link');throw Error('Retargeted forbidden link');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} await mkdir('pack');await symlink('data','pack/link'); try{await rename('pack','dest/tree');throw Error('Rebased scoped directory');}catch(e){if(!(e instanceof errors.NotSupported)||!e.message.includes('not yet implemented'))throw e;} if(await readLink('safe/link')!=='../data')throw Error('Failed rename modified link'); console.log('link rebasing'); "#, &["--deny-write=safe/data"], ); assert_eq!(output.stdout, b"link rebasing\n"); assert!(!dir.path().join("dest/tree").exists()); } #[cfg(unix)] #[test] fn link_primitive_arguments_errors_and_private_exports_are_consistent() { let (_, output) = run( r#" import {symlink,readLink,realPath,errors} from 'avril:std'; for(const call of [()=>symlink('data'),()=>symlink('data','new',{type:'junction'}),()=>readLink(),()=>realPath()]){ try{await call();throw Error('Accepted invalid arguments');}catch(e){if(!(e instanceof TypeError))throw e;} } for(const call of [()=>symlink('','new'),()=>symlink('a\0b','new'),()=>readLink('data')]){ try{await call();throw Error('Accepted invalid link');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;} } await symlink('missing','dangling'); try{await symlink('other','dangling');throw Error('Replaced dangling link');}catch(e){if(!(e instanceof errors.AlreadyExists))throw e;} console.log('link errors'); "#, &[], ); assert_eq!(output.stdout, b"link errors\n"); } #[cfg(unix)] #[test] fn copying_links_preserves_raw_targets_or_explicitly_dereferences_them() { let (dir, output) = run( r#" import {symlink,mkdir,readLink,lstat} from 'avril:std'; import {copy,readTextFile,writeTextFile} from 'avril:fs'; await symlink('data','relative');await symlink(new URL('./data',import.meta.url),'absolute');await symlink('missing','dangling'); for(const name of ['relative','absolute','dangling']){ await copy(name,name+'Copy'); if(!(await lstat(name+'Copy')).isSymlink||await readLink(name+'Copy')!==await readLink(name))throw Error('Raw preservation'); } await copy('relative','fileCopy',{dereference:true}); if(!(await lstat('fileCopy')).isFile||await readTextFile('fileCopy')!=='abcdef')throw Error('File dereference'); await mkdir('folder');await writeTextFile('folder/data','nested');await symlink('folder','dirLink'); await copy('dirLink','dirCopy',{dereference:true}); if(!(await lstat('dirCopy')).isDirectory||await readTextFile('dirCopy/data')!=='nested')throw Error('Directory dereference'); console.log('copy links'); "#, &[], ); assert_eq!(output.stdout, b"copy links\n"); assert_eq!( fs::read_link(dir.path().join("danglingCopy")).unwrap(), std::path::Path::new("missing") ); } #[cfg(unix)] #[test] fn following_directory_links_detects_cycles_but_allows_shared_subtrees() { let (dir, output) = run( r#" import {symlink,mkdir,errors,lstat} from 'avril:std'; import {copy,writeTextFile,readTextFile} from 'avril:fs'; await mkdir('shared');await writeTextFile('shared/data','shared'); await mkdir('source');await symlink('../shared','source/a');await symlink('../shared','source/b'); await copy('source','destination',{dereference:true}); if(await readTextFile('destination/a/data')!=='shared'||await readTextFile('destination/b/data')!=='shared')throw Error('DAG alias skipped'); await symlink('.','source/cycle'); await copy('source','preserved'); if(!(await lstat('preserved/cycle')).isSymlink)throw Error('Preserved cycle'); try{await copy('source','followed',{dereference:true});throw Error('Followed cycle forever');}catch(e){if(!(e instanceof errors.InvalidInput)||!e.message.includes('cycle'))throw e;} console.log('cycle guards'); "#, &[], ); assert_eq!(output.stdout, b"cycle guards\n"); assert!(!dir.path().join("followed/cycle").exists()); } #[cfg(unix)] #[test] fn link_copy_checks_new_target_authority_before_removing_the_destination() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("source")).unwrap(); fs::create_dir_all(dir.path().join("dest/deep")).unwrap(); fs::write(dir.path().join("target"), b"target").unwrap(); fs::write(dir.path().join("dest/deep/link"), b"untouched").unwrap(); std::os::unix::fs::symlink("../target", dir.path().join("source/link")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {copy} from 'avril:fs'; import {errors} from 'avril:std'; try{await copy('source/link','dest/deep/link');throw Error('Planted rebased link');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='symlink target for dest/deep/link')throw e;} console.log('copy target checks'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["--deny-write=dest/target", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"copy target checks\n"); assert_eq!( fs::read(dir.path().join("dest/deep/link")).unwrap(), b"untouched" ); } #[cfg(unix)] #[test] fn link_preservation_and_dereferencing_require_different_target_rights() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("links")).unwrap(); fs::write(dir.path().join("data"), b"read-only").unwrap(); std::os::unix::fs::symlink("../data", dir.path().join("links/source")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {copy,readTextFile} from 'avril:fs'; import {errors,lstat} from 'avril:std'; try{await copy('links/source','links/preserved');throw Error('Planted read-only target link');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;} await copy('links/source','links/followed',{dereference:true}); if(!(await lstat('links/followed')).isFile||await readTextFile('links/followed')!=='read-only')throw Error('Authorized read copy'); console.log('follow permissions'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["--deny-write=data", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"follow permissions\n"); assert!(fs::symlink_metadata(dir.path().join("links/preserved")).is_err()); } #[cfg(unix)] #[test] fn following_ungranted_links_does_not_disclose_canonical_targets() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("links")).unwrap(); fs::create_dir(dir.path().join("secret")).unwrap(); fs::write(dir.path().join("secret/data"), b"private").unwrap(); std::os::unix::fs::symlink("../secret/data", dir.path().join("links/source")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {copy} from 'avril:fs'; import {errors} from 'avril:std'; try{await copy('links/source','links/followed',{dereference:true});throw Error('Read secret');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read links/source')throw e;} console.log('follow privacy'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args([ "-0", "--allow-read=links/", "--allow-write=links/", "main.js", ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"follow privacy\n"); assert!(fs::symlink_metadata(dir.path().join("links/followed")).is_err()); } #[cfg(unix)] #[test] fn same_inode_link_copies_fail_without_removing_the_source_or_alias() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("data"), b"data").unwrap(); std::os::unix::fs::symlink("data", dir.path().join("link")).unwrap(); fs::hard_link(dir.path().join("link"), dir.path().join("alias")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {copy} from 'avril:fs'; import {errors,readLink} from 'avril:std'; for(const name of ['link','alias']){ try{await copy('link',name);throw Error('Copied same link');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;} if(await readLink(name)!=='data')throw Error('Removed source link'); } console.log('link inode identity'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"link inode identity\n"); } #[cfg(unix)] #[test] fn dereference_options_validate_and_link_destination_replacement_never_follows_old_targets() { let (dir, output) = run( r#" import {copy,readTextFile,writeTextFile} from 'avril:fs'; import {symlink,readLink,mkdir,errors} from 'avril:std'; await writeTextFile('oldTarget','old');await symlink('oldTarget','destination'); await symlink('data','source');await copy('source','destination'); if(await readLink('destination')!=='data'||await readTextFile('oldTarget')!=='old')throw Error('Followed old destination link'); await mkdir('folder'); try{await copy('source','folder');throw Error('Removed directory');}catch(e){if(!(e instanceof errors.IsADirectory))throw e;} try{await copy('source','invalid',{dereference:'yes'});throw Error('Accepted invalid dereference');}catch(e){if(!(e instanceof TypeError))throw e;} console.log('link overwrite'); "#, &[], ); assert_eq!(output.stdout, b"link overwrite\n"); assert_eq!(fs::read(dir.path().join("oldTarget")).unwrap(), b"old"); } #[cfg(unix)] #[test] fn following_ungranted_dangling_links_masks_existence_for_all_filesystem_workflows() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); fs::create_dir(dir.path().join("secret")).unwrap(); fs::write(dir.path().join("secret/exists"), b"private").unwrap(); std::os::unix::fs::symlink("../secret/exists", dir.path().join("out/a")).unwrap(); std::os::unix::fs::symlink("../secret/missing", dir.path().join("out/b")).unwrap(); std::os::unix::fs::symlink("b", dir.path().join("out/chain")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {stat,lstat,open,create,realPath,readLink,readDir,mkdir,errors} from 'avril:std'; import {exists,copy} from 'avril:fs'; for(const name of ['out/a','out/b','out/chain','out/a/x','out/b/x','out/chain/x']){ for(const call of [()=>stat(name),()=>open(name),()=>realPath(name),()=>exists(name),()=>readDir(name).next(),()=>copy(name,'out/copied',{dereference:true})]){ try{await call();throw Error('Target existence disclosed '+name+' '+call);}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;} } try{await create(name);throw Error('Write reached ungranted target');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;} try{await mkdir(name);throw Error('Created directory over link');}catch(e){ if(name.endsWith('/x')){if(!(e instanceof errors.PermissionDenied)||e.message!=='write '+name)throw e;} else if(!(e instanceof errors.AlreadyExists))throw e; } } if(await readLink('out/b')!=='../secret/missing'||!(await lstat('out/b')).isSymlink)throw Error('Entry permission changed'); console.log('dangling target privacy'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=out/", "--allow-write=out/", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"dangling target privacy\n"); assert!(!dir.path().join("secret/missing").exists()); assert!(fs::symlink_metadata(dir.path().join("out/copied")).is_err()); } #[cfg(unix)] #[test] fn authorized_dangling_targets_are_not_found_and_can_be_created_through_links() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); fs::write(dir.path().join("out/data"), b"copied").unwrap(); std::os::unix::fs::symlink("missing", dir.path().join("out/link")).unwrap(); std::os::unix::fs::symlink("link", dir.path().join("out/chain")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {stat,lstat,create,realPath,symlink,readLink,errors} from 'avril:std'; import {exists,copy,mkdirs,readTextFile} from 'avril:fs'; for(const name of ['out/link','out/chain','out/link/child']){ if(await exists(name))throw Error('Dangling exists'); for(const call of [()=>stat(name),()=>realPath(name)]){ try{await call();throw Error('Resolved missing referent');}catch(e){if(!(e instanceof errors.NotFound))throw e;} } } await symlink('chain','out/newAlias'); await symlink('future-dir','out/dir-link');await mkdirs('out/dir-link'); if(!(await stat('out/dir-link')).isDirectory||!(await lstat('out/dir-link')).isSymlink)throw Error('Directory ensure did not follow authorized target'); await copy('out/data','out/link'); if(!(await lstat('out/link')).isSymlink||!(await stat('out/link')).isFile)throw Error('File copy did not follow authorized dangling destination'); const file=await create('out/link');await file.write(new TextEncoder().encode('created'));file.close(); if(await readTextFile('out/chain')!=='created'||!(await stat('out/newAlias')).isFile||!(await lstat('out/link')).isSymlink||await readLink('out/link')!=='missing')throw Error('Link creation changed entry'); console.log('authorized dangling targets'); "#).unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=out/", "--allow-write=out/", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"authorized dangling targets\n"); assert_eq!( fs::read(dir.path().join("out/missing")).unwrap(), b"created" ); } #[cfg(unix)] #[test] fn dangling_relative_targets_use_physical_alias_parents_not_lexical_dotdot() { let dir = tempfile::tempdir().unwrap(); let other = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); fs::create_dir(other.path().join("inside")).unwrap(); fs::create_dir(other.path().join("secret")).unwrap(); std::os::unix::fs::symlink(other.path().join("inside"), dir.path().join("out/parent")).unwrap(); std::os::unix::fs::symlink("../secret/missing", other.path().join("inside/link")).unwrap(); fs::write(dir.path().join("main.js"),r#" import {stat,create,errors} from 'avril:std'; for(const [call,label] of [[()=>stat('out/parent/link'),'read out/parent/link'],[()=>create('out/parent/link'),'read out/parent/link']]){ try{await call();throw Error('Lexical alias parent used');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!==label)throw e;} } console.log('physical dangling parents'); "#).unwrap(); let read = format!("--allow-read={}/", other.path().join("inside").display()); let write = format!("--allow-write={}/", other.path().join("inside").display()); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args([ "-0", "--allow-read=out/", "--allow-write=out/", &read, &write, "main.js", ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"physical dangling parents\n"); assert!(!other.path().join("secret/missing").exists()); } #[cfg(unix)] #[test] fn exclusive_creation_rejects_existing_symlink_entries_even_with_missing_referents() { let (dir, output) = run( r#" import {symlink,open,readLink,errors,pledge} from 'avril:std'; import {writeFile,exists} from 'avril:fs'; await symlink('missing','dangling');await symlink('data','existing'); pledge({read:{allow:['./']},write:{allow:['./']}}); for(const name of ['dangling','existing']){ for(const call of [()=>open(name,{write:true,createNew:true}),()=>writeFile(name,new Uint8Array([1]),{createNew:true})]){ try{await call();throw Error('Exclusive creation followed link');}catch(e){if(!(e instanceof errors.AlreadyExists))throw e;} } } if(await exists('missing')||await readLink('dangling')!=='missing')throw Error('Exclusive creation mutated dangling target'); console.log('exclusive link entries'); "#, &[], ); assert_eq!(output.stdout, b"exclusive link entries\n"); assert!(!dir.path().join("missing").exists()); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); } #[cfg(unix)] #[test] fn posix_path_ops_set_modes_times_links_and_sizes() { let (dir, output) = run( r#" import {chmod,chown,lchown,utime,lutime,link,truncate,stat,lstat,symlink,mkdir,errors} from 'avril:std'; import {readFile,readTextFile} from 'avril:fs'; await chmod('data', 0o600); if (((await stat('data')).mode & 0o7777) !== 0o600) throw Error('Chmod mode'); await truncate('data', 3); if ((await stat('data')).size !== 3 || await readTextFile('data') !== 'abc') throw Error('Truncate shrink'); await truncate('data', 6); const grown = await readFile('data'); if (grown.length !== 6 || grown[0] !== 97 || grown[3] !== 0) throw Error('Truncate grow'); await symlink('data', 'alias'); await utime('alias', new Date(1000000000000), 1000000000); const followed = await stat('alias'); if (followed.mtime.getTime() !== 1000000000000 || followed.atime.getTime() !== 1000000000000) throw Error('Utime follows links'); await lutime('alias', 2000000000, 2000000000); if ((await lstat('alias')).mtime.getTime() !== 2000000000000 || (await stat('data')).mtime.getTime() !== 1000000000000) throw Error('Lutime keeps link times'); const { gid } = await stat('data'); await chown('data', null, gid); await chown('alias', null, gid); await lchown('alias', null, gid); await symlink('missing', 'dangling'); await lchown('dangling', null, gid); await lutime('dangling', 2000000000, 2000000000); await mkdir('sub'); await symlink('sub', 'dirlink'); await lutime('dirlink', 2000000000, 2000000000); await lutime('dirlink/', 1000000000, 1000000000); if ((await lstat('dirlink')).mtime.getTime() !== 2000000000000 || (await stat('sub')).mtime.getTime() !== 1000000000000) throw Error('Lutime slash did not follow'); await symlink('loop', 'loop'); try { await lchown('loop/', null, gid); throw Error('Lchown ignored trailing slash'); } catch (e) { if (!(e instanceof errors.IoError)) throw e; } await lchown('loop', null, gid); for (const call of [()=>chown('dangling', null, gid), ()=>utime('dangling', 0, 0), ()=>chmod('dangling', 0o644)]) { try { await call(); throw Error('Followed dangling link'); } catch (e) { if (!(e instanceof errors.NotFound)) throw e; } } await link('data', 'hard'); if (await readTextFile('hard') !== await readTextFile('data')) throw Error('Hard link contents'); try { await link('alias', 'linkedAlias'); throw Error('Hard-linked a symlink'); } catch (e) { if (!(e instanceof errors.InvalidInput)) throw e; } for (const [call, C] of [ [()=>chmod('', 0o644), errors.NotFound], [()=>chmod('data', 0o10000), RangeError], [()=>chmod('data', -1), RangeError], [()=>chmod('data', 0o644 + 0.5), RangeError], [()=>truncate('data', -1), RangeError], [()=>truncate('data', 2 ** 53), RangeError], [()=>truncate('data', 1.5), RangeError], [()=>truncate('missing', 0), errors.NotFound], [()=>link('data', 'hard'), errors.AlreadyExists], [()=>link('missing', 'new'), errors.NotFound], [()=>chown('data', null, null), errors.InvalidInput], [()=>chown('data', 1.5, null), RangeError], [()=>lchown('data', 2 ** 32, null), RangeError], [()=>utime('data', NaN, 0), TypeError], [()=>utime('data', 0, Infinity), TypeError], [()=>lutime('data', 'now', 0), TypeError], [()=>truncate(), TypeError], [()=>link('data'), TypeError], [()=>chown('data', null), TypeError], ]) { try { await call(); throw Error('Accepted invalid operation'); } catch (e) { if (!(e instanceof C)) throw e; } } console.log('posix paths'); "#, &[], ); assert_eq!(output.stdout, b"posix paths\n"); use std::os::unix::fs::MetadataExt; assert_eq!( fs::metadata(dir.path().join("hard")).unwrap().ino(), fs::metadata(dir.path().join("data")).unwrap().ino() ); assert!(!dir.path().join("linkedAlias").exists()); } #[cfg(unix)] #[test] fn posix_path_ops_need_write_access_and_hide_denied_targets() { let (dir, output) = run( r#" import {chmod,chown,lchown,utime,lutime,link,truncate,errors} from 'avril:std'; for (const name of ['other', 'data/x']) { for (const call of [()=>chmod(name, 0o644), ()=>chown(name, null, 0), ()=>lchown(name, null, 0), ()=>utime(name, 0, 0), ()=>lutime(name, 0, 0), ()=>link('data', name), ()=>truncate(name, 0)]) { try { await call(); throw Error('Granted write ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ' + name) throw e; } } try { await link(name, 'elsewhere'); throw Error('Granted read ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'read ' + name) throw e; } } console.log('posix denials'); "#, &["-0", "--allow-read=data", "--allow-write=data"], ); assert_eq!(output.stdout, b"posix denials\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); for name in ["other", "elsewhere"] { assert!(!dir.path().join(name).exists()); } let (denied, output) = run( r#" import {chmod,chown,lchown,utime,lutime,link,truncate,errors} from 'avril:std'; for (const call of [()=>chmod('data', 0o644), ()=>chown('data', null, 0), ()=>lchown('data', null, 0), ()=>utime('data', 0, 0), ()=>lutime('data', 0, 0), ()=>truncate('data', 6)]) { try { await call(); throw Error('Mutated without write'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write data') throw e; } } try { await link('data', 'copy'); throw Error('Linked without write'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write copy') throw e; } console.log('posix write gates'); "#, &["-0", "--allow-read=data"], ); assert_eq!(output.stdout, b"posix write gates\n"); assert_eq!(fs::read(denied.path().join("data")).unwrap(), b"abcdef"); assert!(!denied.path().join("copy").exists()); } #[cfg(unix)] #[test] fn posix_handle_ops_set_mode_owner_times_and_serialize_locks() { let (dir, output) = run( r#" import {open,pledge,errors} from 'avril:std'; const file = await open('data', { read: true, write: true }); await file.fchmod(0o600); if (((await file.stat()).mode & 0o7777) !== 0o600) throw Error('Fchmod mode'); await file.futime(new Date(1500000000000), 1500000000); const stamped = await file.stat(); if (stamped.mtime.getTime() !== 1500000000000 || stamped.atime.getTime() !== 1500000000000) throw Error('Futime times'); await file.fchown(null, stamped.gid); try { await file.fchown(null, null); throw Error('Chowned nothing'); } catch (e) { if (!(e instanceof errors.InvalidInput)) throw e; } await file.flock(true); await file.funlock(); await file.flock(false); await file.funlock(); const other = await open('data', { read: true, write: true }); await file.flock(true); let released = false; const gated = other.flock(true).then(() => { released = true; }); await new Promise((resolve) => setTimeout(resolve, 300)); if (released) throw Error('Exclusive lock did not block'); await file.funlock(); let watchdog; await Promise.race([gated, new Promise((_, reject) => { watchdog = setTimeout(() => reject(Error('Unlock did not release waiter')), 2000); })]); clearTimeout(watchdog); await other.funlock(); const reader = await open('data'); for (const call of [()=>reader.fchmod(0o644), ()=>reader.fchown(null, 0), ()=>reader.futime(0, 0), ()=>reader.flock(true)]) { try { await call(); throw Error('Wrote through reader'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; } } await reader.flock(false); await reader.funlock(); reader.close(); file.close(); other.close(); for (const call of [()=>file.fchmod(0o644), ()=>file.flock(false), ()=>file.funlock()]) { try { await call(); throw Error('Used closed handle'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; } } const live = await open('data', { read: true, write: true }); pledge({ write: false }); for (const call of [()=>live.fchmod(0o600), ()=>live.fchown(null, 0), ()=>live.futime(0, 0), ()=>live.flock(true)]) { try { await call(); throw Error('Retained write'); } catch (e) { if (!(e instanceof errors.PermissionDenied)) throw e; } } await live.funlock(); live.close(); console.log('posix handles'); "#, &[], ); assert_eq!(output.stdout, b"posix handles\n"); use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(dir.path().join("data")) .unwrap() .permissions() .mode() & 0o777, 0o600 ); } #[cfg(unix)] #[test] fn posix_link_never_launders_symlink_sources() { let (dir, output) = run( r#" import {open,symlink,link,pledge,errors} from 'avril:std'; const secret = await open('secret', { write: true, create: true }); await secret.write(new Uint8Array([42])); secret.close(); await symlink('secret', 'plug'); pledge({ read: { deny: ['secret'] } }); try { await link('plug', 'copy'); throw Error('Laundered unreadable target'); } catch (e) { if (!(e instanceof errors.InvalidInput) || e.message !== 'Source is a symbolic link') throw e; } console.log('posix link refusal'); "#, &[], ); assert_eq!(output.stdout, b"posix link refusal\n"); assert_eq!(fs::read(dir.path().join("secret")).unwrap(), [42]); assert!(!dir.path().join("copy").exists()); } #[cfg(unix)] #[test] fn posix_saturated_ids_reject_before_lookup() { let (_dir, output) = run( r#" import {open,chown,lchown,errors} from 'avril:std'; const file = await open('data', { read: true, write: true }); for (const call of [ () => chown('data', 4294967295, null), () => chown('data', null, 4294967295), () => chown('missing', 4294967295, 4294967295), () => lchown('data', 4294967295, null), () => file.fchown(4294967295, null), () => file.fchown(null, 4294967295), ]) { try { await call(); throw Error('Accepted saturated id'); } catch (e) { if (!(e instanceof RangeError)) throw e; } } file.close(); console.log('posix id bounds'); "#, &[], ); assert_eq!(output.stdout, b"posix id bounds\n"); } #[cfg(unix)] #[test] fn posix_denied_mutations_leave_modes_and_times_untouched() { let (dir, output) = run( r#" import {chmod,chown,lchown,utime,lutime,link,truncate,stat,pledge,errors} from 'avril:std'; await chmod('data', 0o640); await utime('data', 1000000000, 1000000000); pledge({ write: false }); for (const call of [() => chmod('data', 0o600), () => chown('data', null, 0), () => lchown('data', null, 0), () => utime('data', 0, 0), () => lutime('data', 0, 0), () => link('data', 'copy'), () => truncate('data', 0)]) { try { await call(); throw Error('Mutated after pledge'); } catch (e) { if (!(e instanceof errors.PermissionDenied)) throw e; } } const after = await stat('data'); if ((after.mode & 0o7777) !== 0o640 || after.mtime.getTime() !== 1000000000000 || after.atime.getTime() !== 1000000000000 || after.size !== 6) throw Error('Denied mutation leaked'); console.log('posix denials keep state'); "#, &[], ); assert_eq!(output.stdout, b"posix denials keep state\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); assert!(!dir.path().join("copy").exists()); } #[cfg(unix)] #[test] fn posix_truncate_follows_links_rejects_dirs_and_dangling() { let (dir, output) = run( r#" import {symlink,truncate,stat,mkdir,errors} from 'avril:std'; await symlink('data', 'alias'); await truncate('alias', 2); if ((await stat('data')).size !== 2) throw Error('Truncate did not follow link'); await symlink('missing', 'dangling'); try { await truncate('dangling', 0); throw Error('Truncated dangling link'); } catch (e) { if (!(e instanceof errors.NotFound)) throw e; } await mkdir('sub'); try { await truncate('sub', 0); throw Error('Truncated directory'); } catch (e) { if (!(e instanceof errors.IsADirectory)) throw e; } await symlink('loop', 'loop'); try { await truncate('loop/x', 0); throw Error('Truncated looped path'); } catch (e) { if (!(e instanceof errors.NotFound) || e.message !== 'Symbolic link changed or has a missing target') throw e; } console.log('posix truncate targets'); "#, &[], ); assert_eq!(output.stdout, b"posix truncate targets\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"ab"); } #[cfg(unix)] #[test] fn posix_timestamps_span_seconds_and_reject_garbage() { let (_dir, output) = run( r#" import {open,utime,stat,errors} from 'avril:std'; await utime('data', -0.5, 1000000000.5); const split = await stat('data'); if (split.atime.getTime() !== -500 || split.mtime.getTime() !== 1000000000500) throw Error('Fractional seconds'); await utime('data', 8640000000000, -62135596800); const bounds = await stat('data'); if (bounds.atime.getTime() !== 8640000000000000 || bounds.mtime.getTime() !== -62135596800000) throw Error('Second bounds'); await utime('data', new Date(1500000000123), 0); if ((await stat('data')).atime.getTime() !== 1500000000123) throw Error('Date split'); for (const [call, C] of [ [() => utime('data', 8640000000001, 0), RangeError], [() => utime('data', 8640000000000.6, 0), RangeError], [() => utime('data', -8640000000001, 0), RangeError], [() => utime('data', Infinity, 0), TypeError], [() => utime('data', 0, new Date(NaN)), TypeError], ]) { try { await call(); throw Error('Accepted invalid timestamp'); } catch (e) { if (!(e instanceof C)) throw e; } } const file = await open('data', { read: true, write: true }); await file.futime(0.25, -1.75); const stamped = await file.stat(); if (stamped.atime.getTime() !== 250 || stamped.mtime.getTime() !== -1750) throw Error('Handle fractions'); file.close(); console.log('posix timestamps'); "#, &[], ); assert_eq!(output.stdout, b"posix timestamps\n"); } #[cfg(unix)] #[test] fn posix_flock_releases_waiters_and_gates_by_mode() { let (_dir, output) = run( r#" import {open,errors} from 'avril:std'; const file = await open('data', { read: true, write: true }); const other = await open('data', { read: true, write: true }); await file.flock(true); let released = false; const gated = other.flock(false).then(() => { released = true; }); await new Promise((resolve) => setTimeout(resolve, 300)); if (released) throw Error('Shared lock slipped past exclusive'); await file.funlock(); let watchdog; await Promise.race([gated, new Promise((_, reject) => { watchdog = setTimeout(() => reject(Error('Unlock did not release waiter')), 2000); })]); clearTimeout(watchdog); await other.funlock(); await file.flock(true); const pending = other.flock(true); if ((await other.stat()).size !== 6) throw Error('Queue stalled behind lock'); other.close(); try { await pending; throw Error('Locked past close'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; } await file.funlock(); file.close(); const reader = await open('data'); const writer = await open('data', { write: true }); for (const call of [() => writer.flock(false), () => reader.flock(true)]) { try { await call(); throw Error('Locked past handle mode'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; } } await reader.flock(false); await reader.funlock(); await writer.flock(true); await writer.funlock(); reader.close(); writer.close(); console.log('posix lock matrix'); "#, &[], ); assert_eq!(output.stdout, b"posix lock matrix\n"); } #[cfg(unix)] #[test] fn posix_chmod_ignores_access_bits_and_follows_links() { let (dir, output) = run( r#" import {chmod,stat,symlink,mkdir,errors} from 'avril:std'; await chmod('data', 0o200); await chmod('data', 0o644); if (((await stat('data')).mode & 0o7777) !== 0o644) throw Error('Chmod write-only file'); await chmod('data', 0); await chmod('data', 0o644); if (((await stat('data')).mode & 0o7777) !== 0o644) throw Error('Chmod unreadable file'); await mkdir('lockeddir'); await chmod('lockeddir', 0); await chmod('lockeddir', 0o755); if (!((await stat('lockeddir')).isDirectory)) throw Error('Chmod locked directory'); await symlink('data', 'alias'); await chmod('alias', 0o600); if (((await stat('data')).mode & 0o7777) !== 0o600) throw Error('Chmod did not follow link'); await symlink('missing', 'dangling'); try { await chmod('dangling', 0o644); throw Error('Chmod dangling link'); } catch (e) { if (!(e instanceof errors.NotFound)) throw e; } await symlink('loop', 'loop'); try { await chmod('loop/x', 0o644); throw Error('Chmod looped path'); } catch (e) { if (!(e instanceof errors.NotFound) || e.message !== 'Symbolic link changed or has a missing target') throw e; } console.log('posix chmod reach'); "#, &[], ); assert_eq!(output.stdout, b"posix chmod reach\n"); use std::os::unix::fs::PermissionsExt; assert_eq!( fs::metadata(dir.path().join("data")) .unwrap() .permissions() .mode() & 0o7777, 0o600 ); } #[cfg(unix)] #[test] fn posix_link_needs_write_access_to_its_source() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("ro"), b"read-only").unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); fs::write( dir.path().join("main.js"), r#" import {link,errors} from 'avril:std'; try { await link('ro', 'out/alias'); throw Error('Linked without source write'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ro') throw e; } console.log('posix link source gate'); "#, ) .unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=.", "--allow-write=out/", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"posix link source gate\n"); assert_eq!(fs::read(dir.path().join("ro")).unwrap(), b"read-only"); assert!(!dir.path().join("out/alias").exists()); } #[cfg(unix)] #[test] fn posix_denied_path_ops_disclose_neither_targets_nor_resolution_errors() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("secret")).unwrap(); fs::write(dir.path().join("secret/file"), b"private").unwrap(); std::os::unix::fs::symlink("secret/file", dir.path().join("alias")).unwrap(); std::os::unix::fs::symlink("loop", dir.path().join("loop")).unwrap(); std::os::unix::fs::symlink("ungranted", dir.path().join("dangling")).unwrap(); fs::write( dir.path().join("main.js"), r#" import {chmod,chown,lchown,utime,lutime,link,truncate,errors} from 'avril:std'; for (const name of ['alias','secret/file/x','secret/missing','loop','missing/../secret/file','dangling']) { for (const call of [()=>chmod(name, 0o644), ()=>chown(name, null, 0), ()=>lchown(name, null, 0), ()=>utime(name, 0, 0), ()=>lutime(name, 0, 0), ()=>truncate(name, 0)]) { try { await call(); throw Error('Granted write ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ' + name) throw e; } } try { await link(name, 'elsewhere'); throw Error('Granted read ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'read ' + name) throw e; } } console.log('no posix oracle'); "#, ) .unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"no posix oracle\n"); assert_eq!( fs::read(dir.path().join("secret/file")).unwrap(), b"private" ); assert!(!dir.path().join("elsewhere").exists()); } #[cfg(unix)] #[test] fn posix_huge_mtimes_read_as_invalid_dates_without_panicking() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("huge"), b"huge").unwrap(); fs::File::options() .write(true) .open(dir.path().join("huge")) .unwrap() .set_modified(std::time::UNIX_EPOCH + std::time::Duration::new(20_000_000_000_000_000, 0)) .unwrap(); fs::write( dir.path().join("main.js"), r#" import {stat,open} from 'avril:std'; const stamped = await stat('huge'); if (stamped.mtime.getTime() === stamped.mtime.getTime()) throw Error('Huge mtime looked valid'); const file = await open('huge'); await file.stat(); file.close(); console.log('huge mtime safe'); "#, ) .unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .arg("main.js") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"huge mtime safe\n"); } #[cfg(unix)] #[test] fn posix_granted_dir_probes_disclose_no_escapes() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("secret"), b"private").unwrap(); fs::create_dir(dir.path().join("out")).unwrap(); std::os::unix::fs::symlink("../secret", dir.path().join("out/a")).unwrap(); let before = fs::metadata(dir.path().join("secret")).unwrap(); fs::write( dir.path().join("main.js"), r#" import {chmod,chown,lchown,utime,lutime,link,truncate,stat,errors} from 'avril:std'; for (const name of ['out/a', 'out/../secret', 'out/a/x']) { for (const call of [()=>chmod(name, 0o644), ()=>chown(name, null, 0), ()=>utime(name, 0, 0), ()=>truncate(name, 0)]) { try { await call(); throw Error('Granted escape ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ' + name) throw e; } } } // Link-acting ops stay on the granted link itself. const { gid } = await stat('out'); await lchown('out/a', null, gid); await lutime('out/a', 0, 0); try { await link('out/../secret', 'elsewhere'); throw Error('Granted read out/../secret'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'read out/../secret') throw e; } try { await link('out/a', 'elsewhere'); throw Error('Granted write elsewhere'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write elsewhere') throw e; } console.log('no granted-dir oracle'); "#, ) .unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path()) .args(["-0", "--allow-read=out", "--allow-write=out", "main.js"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stdout, b"no granted-dir oracle\n"); assert_eq!(fs::read(dir.path().join("secret")).unwrap(), b"private"); let after = fs::metadata(dir.path().join("secret")).unwrap(); assert_eq!(after.modified().unwrap(), before.modified().unwrap()); assert!(!dir.path().join("elsewhere").exists()); } #[test] fn direct_writev_writes_ordered_vectors_and_skips_empty_views() { let (dir, output) = run( r#" import {create,open,SeekMode,errors} from 'avril:std'; const file=await create('out'); const full=new Uint8Array([0,1,2,3,4,5,6,7,8,9]); if(await file.writev([full.subarray(0,3),new Uint8Array(0),full.subarray(3,7),full.subarray(7)])!==10)throw Error('Count'); if(await file.seek(0,SeekMode.Start)!==0)throw Error('Seek'); if(await file.write(new Uint8Array([9]))!==1)throw Error('Write'); if(await file.writev([new Uint8Array([8]),new Uint8Array([7,6])])!==3)throw Error('Vector'); if(await file.writev([])!==0||await file.writev([new Uint8Array(0),new Uint8Array(0)])!==0)throw Error('Empty'); if(await file.seek(0,SeekMode.Current)!==4)throw Error('Cursor'); file.close(); const shared=new Uint8Array(new SharedArrayBuffer(2));shared.set([11,12]); const resizable=new ArrayBuffer(4,{maxByteLength:8}),tracked=new Uint8Array(resizable,1,2);tracked.set([13,14]); const extra=await create('extra'); if(await extra.writev([shared,tracked])!==4)throw Error('Shared'); extra.close(); const check=await open('out'),back=new Uint8Array(10); let offset=0,n; while(offset<10&&(n=await check.read(back.subarray(offset)))!==null)offset+=n; if(offset!==10||back.join()!=='9,8,7,6,4,5,6,7,8,9')throw Error('Order '+back.join()); check.close(); const reread=await open('extra'),again=new Uint8Array(4); offset=0; while(offset<4&&(n=await reread.read(again.subarray(offset)))!==null)offset+=n; if(offset!==4||again.join()!=='11,12,13,14')throw Error('Buffers '+again.join()); reread.close(); try{await file.writev([new Uint8Array([1])]);throw Error('Closed');}catch(e){if(!(e instanceof errors.BadResource))throw e;} console.log('vectors'); "#, &[], ); assert_eq!(output.stdout, b"vectors\n"); assert_eq!( fs::read(dir.path().join("out")).unwrap(), [9, 8, 7, 6, 4, 5, 6, 7, 8, 9] ); assert_eq!( fs::read(dir.path().join("extra")).unwrap(), [11, 12, 13, 14] ); } #[test] fn direct_writev_completes_large_vectors_and_snapshots_queued_bytes() { let (dir, output) = run( r#" import {create,open} from 'avril:std'; const big=await create('big'); const segs=[new Uint8Array(0)]; for(let i=0;i<20;i++){const view=new Uint8Array(5000);view.fill(i+1);segs.push(view,new Uint8Array(0));} const writing=big.writev(segs); for(const view of segs)view.fill(99); segs[segs.length-2]=new Uint8Array([0]); if(await writing!==100000)throw Error('Total'); big.close(); const check=await open('big'),back=new Uint8Array(100000); let offset=0,n; while(offset<100000&&(n=await check.read(back.subarray(offset)))!==null)offset+=n; if(offset!==100000)throw Error('Length'); for(let i=0;i<20;i++)for(let j=0;j<5000;j++)if(back[i*5000+j]!==i+1)throw Error('Order '+(i*5000+j)); check.close(); const many=await create('many'),bufs=[]; for(let i=0;i<3000;i++)bufs.push(new Uint8Array([i&255])); if(await many.writev(bufs)!==3000)throw Error('Small total'); many.close(); const reread=await open('many'),again=new Uint8Array(3000); offset=0; while(offset<3000&&(n=await reread.read(again.subarray(offset)))!==null)offset+=n; if(offset!==3000)throw Error('Small length'); for(let i=0;i<3000;i++)if(again[i]!==(i&255))throw Error('Small order '+i); reread.close(); const snap=await create('snap'); const first=new Uint8Array(100000);first.fill(7); const one=snap.write(first),parts=[new Uint8Array([1,2]),new Uint8Array([3])]; const two=snap.writev(parts); parts[0].fill(9);parts[1]=new Uint8Array([4]);parts.push(new Uint8Array([0])); const n1=await one; if(!(n1>0&&n1<=65536))throw Error('Queued count'); if(await two!==3)throw Error('Vector total'); snap.close(); const tail=await open('snap'),end=new Uint8Array(n1+3); offset=0; while(offsetp==='length'?size:new Uint8Array(0)}));throw Error('Length');} catch(e){if(!(e instanceof RangeError))throw e;} } const readOnly=await open('valid'); try{await readOnly.writev([]);throw Error('Read-only');} catch(e){if(!(e instanceof errors.BadResource))throw e;} readOnly.close(); if(await file.seek(0,SeekMode.Current)!==0)throw Error('Side effect'); const check=await open('valid'); if(await check.read(new Uint8Array(1))!==null)throw Error('Partial write'); check.close(); const ok=new Uint8Array([1,2]),closedVector=[new Uint8Array([1])]; globalThis.Uint8Array=()=>{throw Error('Mutable intrinsic');}; Array.prototype.push=()=>{throw Error('Mutable intrinsic');}; if(await file.writev([ok])!==2)throw Error('Captured'); file.close(); for(const call of [()=>file.writev(closedVector),()=>file.writevSync(closedVector)]){ try{await call();throw Error('Closed');}catch(e){if(!(e instanceof errors.BadResource))throw e;} } console.log('validated'); "#, &[], ); assert_eq!(output.stdout, b"validated\n"); assert_eq!(fs::read(dir.path().join("valid")).unwrap(), [1, 2]); } #[test] fn direct_writev_holds_one_queue_position_against_sync_and_async() { let (dir, output) = run( r#" import {create,open,SeekMode} from 'avril:std'; const file=await create('ord'); const big=[];for(let i=0;i<20;i++){const view=new Uint8Array(5000);view.fill(i+1);big.push(view);} const pending=file.writev(big); if(file.seekSync(0,SeekMode.Start)!==0)throw Error('SeekSync waited'); if(file.writevSync([new Uint8Array([201,202])])!==2)throw Error('Sync vector'); if(await pending!==100000)throw Error('Total'); file.close(); const check=await open('ord'),back=new Uint8Array(100000); let offset=0,n; while(offset<100000&&(n=await check.read(back.subarray(offset)))!==null)offset+=n; if(offset!==100000)throw Error('Length'); if(back[0]!==201||back[1]!==202)throw Error('Sync order'); for(let i=0;i<20;i++)for(let j=0;j<5000;j++){const k=i*5000+j;if(k>1&&back[k]!==i+1)throw Error('Bytes '+k);} check.close(); const second=await create('ord2'); const first=second.writev(big); if(await second.write(new Uint8Array([7,7,7]))!==3)throw Error('Write'); if(await first!==100000)throw Error('First'); second.close(); const tail=await open('ord2'),end=new Uint8Array(100003); offset=0; while(offset<100003&&(n=await tail.read(end.subarray(offset)))!==null)offset+=n; if(offset!==100003)throw Error('Second length'); if(end[100000]!==7||end[100001]!==7||end[100002]!==7)throw Error('Async order'); tail.close(); console.log('ordered'); "#, &[], ); assert_eq!(output.stdout, b"ordered\n"); assert_eq!(fs::read(dir.path().join("ord2")).unwrap().len(), 100003); }