char/avril

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

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

Charlotte Somport file extension to typescriptdea05b4

main
102.7 KiB2252 linesraw
1mod common;
2
3use std::fs;
4use std::process::{Command, Stdio};
5
6fn run(source: &str, flags: &[&str]) -> (tempfile::TempDir, std::process::Output) {
7    let dir = tempfile::tempdir().unwrap();
8    fs::write(dir.path().join("main.js"), source).unwrap();
9    fs::write(dir.path().join("data"), b"abcdef").unwrap();
10    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
11        .arg("run")
12        .current_dir(dir.path())
13        .args(flags)
14        .arg("main.js")
15        .stdin(Stdio::null())
16        .stdout(Stdio::piped())
17        .stderr(Stdio::piped())
18        .spawn()
19        .unwrap();
20    let output = common::wait(child);
21    assert!(
22        output.status.success(),
23        "{}",
24        String::from_utf8_lossy(&output.stderr)
25    );
26    (dir, output)
27}
28
29#[test]
30fn error_constructors_preserve_reflected_construction() {
31    let (_, output) = run(
32        r#"
33        import {errors} from 'avril:std';
34        const Constructor = errors.NotFound;
35        Object.setPrototypeOf(Constructor, function poisoned() {throw Error('Guest superclass');});
36        class Derived extends Constructor {}
37        const error = new Derived('missing');
38        if (!(error instanceof Derived) || !(error instanceof Error) ||
39            error.name !== 'NotFound' || error.message !== 'missing') throw Error('Error identity');
40        if (Constructor.length !== 0 || Constructor.name !== 'NotFound') throw Error('Error constructor metadata');
41        if (!Object.isFrozen(errors)) throw Error('Mutable error namespace');
42        const name = Object.getOwnPropertyDescriptor(error, 'name');
43        if (!name.writable || !name.configurable || name.enumerable) throw Error('Error descriptor');
44        console.log('reflected errors');
45    "#,
46        &[],
47    );
48    assert_eq!(output.stdout, b"reflected errors\n");
49}
50
51#[test]
52fn handles_read_seek_write_truncate_and_report_metadata() {
53    let (dir, output) = run(
54        r#"
55        import {open,create,stat,FileHandle,SeekMode} from 'avril:std';
56        const file=await open('data',{read:true,write:true});
57        if(!(file instanceof FileHandle))throw Error('Handle');
58        const bytes=new Uint8Array(3);
59        if(await file.read(bytes)!==3 || bytes.join()!=='97,98,99')throw Error('Read');
60        if(await file.seek(-1,SeekMode.Current)!==2 || await file.write(new Uint8Array([88,89]))!==2)throw Error('Cursor');
61        await file.seek(-1,SeekMode.End);
62        if(await file.read(bytes)!==1 || bytes[0]!==102 || await file.read(bytes)!==null || await file.read(new Uint8Array())!==0)throw Error('EOF');
63        const a=await file.stat(),b=await stat(new URL('./data',import.meta.url));
64        if(!a.isFile || a.isDirectory || a.isSymlink || a.size!==6 || b.size!==6 || !(a.mtime instanceof Date))throw Error('Metadata');
65        await file.truncate(4);await file.syncData();await file.sync();file.close();
66        const created=await create('new');await created.write(new Uint8Array([1,2]));created.close();
67        const overwritten=await create('new');if((await overwritten.stat()).size!==0)throw Error('Truncate shorthand');overwritten[Symbol.dispose]();
68        console.log('file IO');
69    "#,
70        &[],
71    );
72    assert_eq!(output.stdout, b"file IO\n");
73    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abXY");
74    assert_eq!(fs::read(dir.path().join("new")).unwrap(), b"");
75}
76
77#[test]
78fn options_errors_modes_and_lifecycle_are_consistent() {
79    let (dir, output) = run(
80        r#"
81        import {open,create,stat,FileHandle,errors} from 'avril:std';
82        async function fails(call,C){try{await call();throw Error('Accepted invalid operation');}catch(e){if(!(e instanceof C))throw e;}}
83        await fails(()=>open('missing'),errors.NotFound);
84        await fails(()=>open('data',{}),errors.InvalidInput);
85        await fails(()=>open('data',{read:true,truncate:true}),errors.InvalidInput);
86        await fails(()=>open('data',{write:true,createNew:true}),errors.AlreadyExists);
87        await fails(()=>open('data',{read:'yes'}),TypeError);
88        await fails(()=>open('data',{write:true,mode:8192}),RangeError);
89        await fails(()=>open(new URL('https://example.test')),errors.InvalidInput);
90        await fails(()=>stat(''),errors.NotFound);
91        await fails(()=>open(),TypeError);
92        try{new FileHandle();throw Error('Constructed handle');}catch(e){if(!(e instanceof TypeError))throw e;}
93        const file=await open('data');await fails(()=>file.write(new Uint8Array([0])),errors.BadResource);
94        await fails(()=>file.read(new DataView(new ArrayBuffer(1))),TypeError);
95        await fails(()=>file.seek(2**53,0),RangeError);
96        await file.seek(Number.MAX_SAFE_INTEGER,0);
97        await fails(()=>file.seek(1,1),errors.InvalidInput);
98        if(await file.seek(0,1)!==Number.MAX_SAFE_INTEGER)throw Error('Failed seek moved cursor');
99        file.close();file[Symbol.dispose]();file[Symbol.dispose]();
100        await fails(()=>file.stat(),errors.BadResource);await fails(()=>file.read(new Uint8Array(1)),errors.BadResource);
101        try{file.close();throw Error('Closed twice');}catch(e){if(!(e instanceof errors.BadResource))throw e;}
102        const append=await open('data',{append:true});await append.seek(0,0);await append.write(new Uint8Array([33]));append.close();
103        if(!Object.isFrozen(errors))throw Error('Errors registry');
104        console.log('options');
105    "#,
106        &[],
107    );
108    assert_eq!(output.stdout, b"options\n");
109    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef!");
110}
111
112#[test]
113fn direct_writes_snapshot_input_and_stream_writes_finish_all_partial_chunks() {
114    let (dir, output) = run(
115        r#"
116        import {create,open,errors} from 'avril:std';
117        const file=await create('out'), bytes=new Uint8Array(200003);bytes.fill(7);
118        const pending=file.write(bytes);bytes.fill(9);
119        const count=await pending;if(count<=0 || count>bytes.length)throw Error('Partial count');
120        const remaining=new Uint8Array(bytes.length-count);remaining.fill(7);await file.writable.getWriter().write(remaining);file.close();
121        const check=await open('out');const all=new Uint8Array(bytes.length);let offset=0,n;
122        while((n=await check.read(all.subarray(offset)))!==null && n!==0)offset+=n;
123        if(offset!==bytes.length || all.some(x=>x!==7))throw Error('Snapshot/partial write');check.close();
124        const shared=new Uint8Array(new SharedArrayBuffer(3));shared.set([1,2,3]);
125        const f=await create('shared');if(await f.write(shared)!==3)throw Error('Shared write');f.close();
126        console.log('snapshots');
127    "#,
128        &[],
129    );
130    assert_eq!(output.stdout, b"snapshots\n");
131    assert_eq!(fs::read(dir.path().join("shared")).unwrap(), [1, 2, 3]);
132}
133
134#[test]
135fn streams_support_byob_eof_pipe_backpressure_and_automatic_close() {
136    let (dir, output) = run(
137        r#"
138        import {open,create,errors} from 'avril:std';
139        const file=await open('data');
140        if(file.readable!==file.readable)throw Error('Readable identity');
141        const reader=file.readable.getReader({mode:'byob'});let text='';
142        while(true){const step=await reader.read(new Uint8Array(2));if(step.done)break;text+=new TextDecoder().decode(step.value);}
143        if(text!=='abcdef')throw Error('BYOB');reader.releaseLock();
144        try{await file.stat();throw Error('EOF did not close');}catch(e){if(!(e instanceof errors.BadResource))throw e;}
145        const source=await open('data'),target=await create('copy');
146        if(target.writable!==target.writable)throw Error('Writable identity');
147        await source.readable.pipeTo(target.writable);
148        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;}
149        console.log('streams');
150    "#,
151        &[],
152    );
153    assert_eq!(output.stdout, b"streams\n");
154    assert_eq!(fs::read(dir.path().join("copy")).unwrap(), b"abcdef");
155}
156
157#[test]
158fn cancellation_abort_and_explicit_close_invalidate_both_streams() {
159    let (_, output) = run(
160        r#"
161        import {open,create,errors} from 'avril:std';
162        const a=await open('data');await a.readable.cancel();
163        const b=await create('out');await b.writable.abort('stop');
164        const c=await open('data',{read:true,write:true}),reader=c.readable.getReader(),writer=c.writable.getWriter();
165        const closedRead=reader.closed.catch(e=>e),closedWrite=writer.closed.catch(e=>e);c.close();
166        if(!(await closedRead instanceof errors.BadResource) || !(await closedWrite instanceof errors.BadResource))throw Error('Stream close');
167        for(const file of [a,b,c])try{await file.stat();throw Error('Unclosed handle');}catch(e){if(!(e instanceof errors.BadResource))throw e;}
168        console.log('lifecycle');
169    "#,
170        &[],
171    );
172    assert_eq!(output.stdout, b"lifecycle\n");
173}
174
175#[test]
176fn concurrent_direct_operations_share_a_serialized_cursor() {
177    let (dir, output) = run(
178        r#"
179        import {open} from 'avril:std';
180        const file=await open('data',{read:true,write:true});
181        const a=new Uint8Array(2),b=new Uint8Array(2),c=new Uint8Array(2);
182        await Promise.all([file.read(a),file.read(b),file.read(c)]);
183        if(new TextDecoder().decode(a)!=='ab'||new TextDecoder().decode(b)!=='cd'||new TextDecoder().decode(c)!=='ef')throw Error('Read order');
184        const seek=file.seek(0,0),write=file.write(new Uint8Array([90])),stat=file.stat();
185        if(await seek!==0 || await write!==1 || (await stat).size!==6)throw Error('Operation order');
186        file.close();console.log('serialized');
187    "#,
188        &[],
189    );
190    assert_eq!(output.stdout, b"serialized\n");
191    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"Zbcdef");
192}
193
194#[test]
195fn permissions_apply_to_open_and_live_operations_without_affecting_stdio() {
196    let (dir, output) = run(
197        r#"
198        import {open,create,stat,pledge,errors} from 'avril:std';
199        const file=await open('data',{read:true,write:true});
200        for(const name of ['forbidden','allowed/child']){
201            try{await create(name);throw Error('Created denied file');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
202        }
203        const allowed=await create('allowed');allowed.close();
204        pledge({read:false,write:false});
205        for(const call of [()=>file.read(new Uint8Array(1)),()=>file.write(new Uint8Array([1])),()=>file.truncate(),()=>file.stat(),()=>stat('data'),()=>create('allowed')]){
206            try{await call();throw Error('Retained authority');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
207        }
208        file.close();console.log('live caps');
209    "#,
210        &[
211            "-0",
212            "--allow-read=data",
213            "--allow-read=allowed",
214            "--allow-write=data",
215            "--allow-write=allowed",
216        ],
217    );
218    assert_eq!(output.stdout, b"live caps\n");
219    assert!(!dir.path().join("forbidden").exists());
220    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef");
221}
222
223#[test]
224fn handles_are_private_branded_and_not_cloneable() {
225    let output = common::run(
226        r#"
227        import {open,FileHandle} from 'avril:std';
228        const file=await open(new URL(import.meta.url)),read=FileHandle.prototype.read,close=FileHandle.prototype.close;
229        Object.setPrototypeOf(file,null);
230        try{structuredClone(file);throw Error('Cloned file handle');}catch(e){if(e.name!=='DataCloneError')throw e;}
231        if(await read.call(file,new Uint8Array(1))!==1)throw Error('Private brand');
232        close.call(file);
233        for(const name of ['ext:file/main.js','ext:file/errors.js']){
234            try{await import(name);throw Error('Private import');}catch(e){if(!e.message.includes('private'))throw e;}
235        }
236        if(globalThis.FileHandle!==undefined || Symbol.for('__avril.base') in globalThis)throw Error('Global exposure');
237        console.log('private handles');
238    "#,
239        &[],
240    );
241    assert_eq!(output.stdout, b"private handles\n");
242}
243
244#[cfg(unix)]
245#[test]
246fn stat_follows_links_lstat_preserves_entries_and_dangling_links_cannot_create_outside() {
247    let dir = tempfile::tempdir().unwrap();
248    fs::write(dir.path().join("data"), b"x").unwrap();
249    std::os::unix::fs::symlink("data", dir.path().join("alias")).unwrap();
250    let outside = tempfile::tempdir().unwrap();
251    std::os::unix::fs::symlink(outside.path().join("new"), dir.path().join("dangling")).unwrap();
252    fs::write(dir.path().join("main.js"),r#"
253        import {stat,lstat,create,open,errors} from 'avril:std';
254        if(!(await stat('alias')).isFile || !(await lstat('alias')).isSymlink || (await lstat('alias')).isFile)throw Error('Link metadata');
255        const file=await open('alias');file.close();
256        try{await create('dangling');throw Error('Followed dangling link');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read dangling')throw e;}
257        console.log('links');
258    "#).unwrap();
259    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
260        .arg("run")
261        .current_dir(dir.path())
262        .args(["-0", "--allow-read=./", "--allow-write=./", "main.js"])
263        .stdin(Stdio::null())
264        .stdout(Stdio::piped())
265        .stderr(Stdio::piped())
266        .spawn()
267        .unwrap();
268    let output = common::wait(child);
269    assert!(
270        output.status.success(),
271        "{}",
272        String::from_utf8_lossy(&output.stderr)
273    );
274    assert_eq!(output.stdout, b"links\n");
275    assert!(!outside.path().join("new").exists());
276}
277
278#[test]
279fn large_concurrent_batches_do_not_fail_due_to_queue_admission() {
280    let (dir, output) = run(
281        r#"
282        import {create,stat} from 'avril:std';
283        const checks=[];for(let i=0;i<600;i++)checks.push(stat('data'));
284        if((await Promise.all(checks)).some(x=>x.size!==6))throw Error('Stat batch');
285        const file=await create('out'),writes=[];
286        for(let i=0;i<600;i++)writes.push(file.write(new Uint8Array([7])));
287        if((await Promise.all(writes)).some(x=>x!==1) || (await file.stat()).size!==600)throw Error('Write batch');
288        file.close();console.log('batches');
289    "#,
290        &[],
291    );
292    assert_eq!(output.stdout, b"batches\n");
293    assert_eq!(fs::read(dir.path().join("out")).unwrap(), vec![7; 600]);
294}
295
296#[cfg(unix)]
297#[test]
298fn denied_file_requests_do_not_disclose_targets_or_resolution_errors() {
299    let dir = tempfile::tempdir().unwrap();
300    fs::create_dir(dir.path().join("secret")).unwrap();
301    fs::write(dir.path().join("secret/file"), b"private").unwrap();
302    std::os::unix::fs::symlink("secret/file", dir.path().join("alias")).unwrap();
303    std::os::unix::fs::symlink("loop", dir.path().join("loop")).unwrap();
304    fs::write(dir.path().join("main.js"),r#"
305        import {open,stat,lstat,errors} from 'avril:std';
306        for(const name of ['alias','secret/file/x','secret/missing','loop','missing/../secret/file']){
307            for(const call of [()=>open(name),()=>stat(name),()=>lstat(name)]){
308                try{await call();throw Error('Granted read');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;}
309            }
310            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;}
311        }
312        console.log('no oracle');
313    "#).unwrap();
314    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
315        .arg("run")
316        .current_dir(dir.path())
317        .args(["-0", "main.js"])
318        .stdin(Stdio::null())
319        .stdout(Stdio::piped())
320        .stderr(Stdio::piped())
321        .spawn()
322        .unwrap();
323    let output = common::wait(child);
324    assert!(
325        output.status.success(),
326        "{}",
327        String::from_utf8_lossy(&output.stderr)
328    );
329    assert_eq!(output.stdout, b"no oracle\n");
330}
331
332#[cfg(unix)]
333#[test]
334fn dangling_link_metadata_reports_missing_targets_and_dot_tails_follow_directories() {
335    let dir = tempfile::tempdir().unwrap();
336    fs::create_dir(dir.path().join("folder")).unwrap();
337    std::os::unix::fs::symlink("missing", dir.path().join("dangling")).unwrap();
338    std::os::unix::fs::symlink("folder", dir.path().join("alias")).unwrap();
339    fs::write(dir.path().join("main.js"),r#"
340        import {stat,lstat,errors} from 'avril:std';
341        if(!(await lstat('dangling')).isSymlink || !(await lstat('alias/.')).isDirectory || (await lstat('alias/.')).isSymlink)throw Error('Entry metadata');
342        for(const call of [()=>stat('dangling'),()=>stat('dangling/file'),()=>lstat('dangling/file')]){
343            try{await call();throw Error('Found missing target');}catch(e){if(!(e instanceof errors.NotFound))throw e;}
344        }
345        console.log('dangling metadata');
346    "#).unwrap();
347    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
348        .arg("run")
349        .current_dir(dir.path())
350        .arg("main.js")
351        .stdin(Stdio::null())
352        .stdout(Stdio::piped())
353        .stderr(Stdio::piped())
354        .spawn()
355        .unwrap();
356    let output = common::wait(child);
357    assert!(
358        output.status.success(),
359        "{}",
360        String::from_utf8_lossy(&output.stderr)
361    );
362    assert_eq!(output.stdout, b"dangling metadata\n");
363}
364
365#[test]
366fn file_io_does_not_use_typed_array_species_or_expose_write_snapshots() {
367    let (dir, output) = run(
368        r#"
369        import {create} from 'avril:std';
370        const file=await create('out'),input=new Uint8Array(200000);input.fill(5);
371        Object.defineProperty(Uint8Array.prototype,'constructor',{get(){throw Error('I/O consulted species');},configurable:true});
372        if(await file.write(input)!==65536)throw Error('Partial write');
373        const writer=file.writable.getWriter();await writer.write(input);await writer.close();
374        console.log('no species');
375    "#,
376        &[],
377    );
378    assert_eq!(output.stdout, b"no species\n");
379    assert_eq!(fs::read(dir.path().join("out")).unwrap(), vec![5; 265536]);
380}
381
382#[test]
383fn reads_reject_detached_and_shrunken_targets_after_pending_io() {
384    let (_, output) = run(
385        r#"
386        import {open} from 'avril:std';
387        const file=await open('data'),detached=new Uint8Array(2),first=file.read(detached);
388        structuredClone(detached.buffer,{transfer:[detached.buffer]});
389        try{await first;throw Error('Read detached buffer');}catch(e){if(!(e instanceof TypeError))throw e;}
390        if(await file.seek(0,1)!==2)throw Error('Detached read cursor');
391        const buffer=new ArrayBuffer(2,{maxByteLength:4}),second=file.read(new Uint8Array(buffer));buffer.resize(0);
392        try{await second;throw Error('Read shrunken buffer');}catch(e){if(!(e instanceof RangeError))throw e;}
393        if(await file.seek(0,1)!==4)throw Error('Shrunken read cursor');
394        file.close();console.log('buffer lifetimes');
395    "#,
396        &[],
397    );
398    assert_eq!(output.stdout, b"buffer lifetimes\n");
399}
400
401#[test]
402fn path_primitives_enumerate_copy_rename_and_remove() {
403    let (dir, output) = run(
404        r#"
405        import {mkdir,readDir,copyFile,rename,remove,stat,errors} from 'avril:std';
406        await mkdir('folder',{mode:0o700});
407        await copyFile('data',new URL('./folder/copied',import.meta.url));
408        const entries=[];for await(const entry of readDir('folder'))entries.push(entry);
409        if(entries.length!==1 || entries[0].name!=='copied' || !entries[0].isFile || entries[0].isDirectory || entries[0].isSymlink)throw Error('Entries');
410        await rename('folder/copied','folder/renamed');
411        if((await stat('folder/renamed')).size!==6)throw Error('Rename');
412        try{await remove('folder');throw Error('Removed populated directory');}catch(e){if(!(e instanceof errors.NotEmpty))throw e;}
413        await remove('folder/renamed');await remove('folder');
414        await mkdir('tree');await mkdir('tree/sub');await copyFile('data','tree/sub/data');await remove('tree',{recursive:true});
415        for(const name of ['folder','tree'])try{await stat(name);throw Error('Remaining path');}catch(e){if(!(e instanceof errors.NotFound))throw e;}
416        console.log('path ops');
417    "#,
418        &[],
419    );
420    assert_eq!(output.stdout, b"path ops\n");
421    assert!(!dir.path().join("folder").exists());
422    assert!(!dir.path().join("tree").exists());
423}
424
425#[test]
426fn denied_descendants_block_directory_mutations_before_side_effects() {
427    let (dir, output) = run(
428        r#"
429        import {mkdir,copyFile,rename,remove,stat,errors} from 'avril:std';
430        await mkdir('tree');await copyFile('data','tree/data');
431        await mkdir('source');await copyFile('data','source/data');
432        for(const call of [()=>remove('tree',{recursive:true}),()=>rename('tree','moved'),()=>rename('source','tree')]){
433            try{await call();throw Error('Moved denied subtree');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
434        }
435        if((await stat('tree/data')).size!==6 || (await stat('source/data')).size!==6)throw Error('Side effects');
436        console.log('tree denies');
437    "#,
438        &["--deny-write=tree/protected"],
439    );
440    assert_eq!(output.stdout, b"tree denies\n");
441    assert!(!dir.path().join("moved").exists());
442}
443
444#[test]
445fn listing_filters_denied_children_and_rename_cannot_launder_unread_data() {
446    let (dir, output) = run(
447        r#"
448        import {mkdir,open,readDir,rename,errors,pledge} from 'avril:std';
449        await mkdir('tree');
450        const secret=await open('tree/secret',{write:true,create:true});await secret.write(new Uint8Array([42]));secret.close();
451        pledge({read:{deny:['tree/public/future']}});
452        const publicFile=await open('tree/public',{write:true,create:true});publicFile.close();
453        const names=[];for await(const entry of readDir('tree'))names.push(entry.name);
454        if(names.join()!=='public')throw Error('Listed secret entry');
455        for(const call of [()=>rename('tree','publicTree'),()=>rename('tree/secret','visible')]){
456            try{await call();throw Error('Laundered read authority');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
457        }
458        try{await rename('tree/public','visible');throw Error('Ignored future read deny');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
459        console.log('listing privacy');
460    "#,
461        &["--deny-read=tree/secret"],
462    );
463    assert_eq!(output.stdout, b"listing privacy\n");
464    assert_eq!(fs::read(dir.path().join("tree/secret")).unwrap(), [42]);
465}
466
467#[test]
468fn exact_file_grants_copy_but_cannot_rename_potential_directory_trees() {
469    let (dir, output) = run(
470        r#"
471        import {copyFile,rename,errors} from 'avril:std';
472        await copyFile('data','copy');
473        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;}
474        console.log('exact grants');
475    "#,
476        &[
477            "-0",
478            "--allow-read=data",
479            "--allow-write=data",
480            "--allow-write=copy",
481        ],
482    );
483    assert_eq!(output.stdout, b"exact grants\n");
484    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef");
485    assert_eq!(fs::read(dir.path().join("copy")).unwrap(), b"abcdef");
486}
487
488#[test]
489fn directory_shaped_grants_support_whole_tree_rename_and_live_limits() {
490    let (dir, output) = run(
491        r#"
492        import {mkdir,create,rename,remove,pledge,errors} from 'avril:std';
493        await mkdir('source');const file=await create('source/data');await file.write(new Uint8Array([1]));file.close();
494        await rename('source','destination');
495        pledge({write:{deny:['destination/future']}});
496        try{await remove('destination',{recursive:true});throw Error('Ignored pledged descendant');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
497        console.log('tree grants');
498    "#,
499        &[
500            "-0",
501            "--allow-read=source/",
502            "--allow-write=source/",
503            "--allow-write=destination/",
504        ],
505    );
506    assert_eq!(output.stdout, b"tree grants\n");
507    assert_eq!(fs::read(dir.path().join("destination/data")).unwrap(), [1]);
508}
509
510#[test]
511fn new_path_operations_preserve_privacy_and_validate_arguments() {
512    let (_, output) = run(
513        r#"
514        import {mkdir,remove,rename,copyFile,readDir,errors} from 'avril:std';
515        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')){}}]){
516            try{await call();throw Error('Disclosed path');}catch(e){if(!(e instanceof errors.PermissionDenied)||!e.message.includes('data/x'))throw e;}
517        }
518        for(const call of [()=>mkdir('out',{mode:2**40}),()=>remove('data',{recursive:'yes'}),()=>rename('data'),()=>copyFile('data')]){
519            try{await call();throw Error('Accepted invalid arguments');}catch(e){if(!(e instanceof RangeError)&&!(e instanceof TypeError))throw e;}
520        }
521        console.log('path privacy');
522    "#,
523        &["-0"],
524    );
525    assert_eq!(output.stdout, b"path privacy\n");
526}
527
528#[cfg(unix)]
529#[test]
530fn copied_hard_links_are_not_truncated_and_link_mutations_do_not_follow_targets() {
531    let dir = tempfile::tempdir().unwrap();
532    fs::write(dir.path().join("data"), b"preserved").unwrap();
533    fs::hard_link(dir.path().join("data"), dir.path().join("hard")).unwrap();
534    let outside = tempfile::tempdir().unwrap();
535    fs::write(outside.path().join("protected"), b"outside").unwrap();
536    std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap();
537    fs::write(dir.path().join("main.js"),r#"
538        import {copyFile,rename,remove,mkdir,readDir,errors} from 'avril:std';
539        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;}
540        const entries=[];for await(const e of readDir('.'))entries.push(e);
541        if(!entries.find(e=>e.name==='link')?.isSymlink)throw Error('Link entry type');
542        await rename('link','renamedLink');await remove('renamedLink',{recursive:true});
543        await mkdir('folder',{mode:0o700});
544        console.log('inode identity');
545    "#).unwrap();
546    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
547        .arg("run")
548        .current_dir(dir.path())
549        .arg("main.js")
550        .stdin(Stdio::null())
551        .stdout(Stdio::piped())
552        .stderr(Stdio::piped())
553        .spawn()
554        .unwrap();
555    let output = common::wait(child);
556    assert!(
557        output.status.success(),
558        "{}",
559        String::from_utf8_lossy(&output.stderr)
560    );
561    assert_eq!(output.stdout, b"inode identity\n");
562    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"preserved");
563    assert_eq!(
564        fs::read(outside.path().join("protected")).unwrap(),
565        b"outside"
566    );
567    use std::os::unix::fs::PermissionsExt;
568    assert_eq!(
569        fs::metadata(dir.path().join("folder"))
570            .unwrap()
571            .permissions()
572            .mode()
573            & 0o777,
574        0o700
575    );
576}
577
578#[test]
579fn fs_conveniences_read_write_text_bytes_options_and_snapshots() {
580    let (dir, output) = run(
581        r#"
582        import {readFile,readTextFile,writeFile,writeTextFile,exists} from 'avril:fs';
583        import {errors} from 'avril:std';
584        if(await readTextFile('data')!=='abcdef' || await exists('missing') || await exists('missing/../data'))throw Error('Read/exists');
585        const bytes=new Uint8Array(200003);bytes.fill(7);
586        const pending=writeFile(new URL('./out',import.meta.url),bytes);bytes.fill(9);await pending;
587        const result=await readFile('out');if(result.length!==200003||result.some(x=>x!==7))throw Error('Snapshot');
588        await writeTextFile('text','héllo 🌍');await writeTextFile('text','!',{append:true});
589        if(await readTextFile('text')!=='héllo 🌍!')throw Error('Text/append');
590        await writeFile('out',new Uint8Array());if((await readFile('out')).length!==0)throw Error('Empty truncate');
591        for(const call of [()=>writeTextFile('text','x',{createNew:true}),()=>writeFile('missing',bytes,{create:false}),()=>writeFile('text',new DataView(new ArrayBuffer(2))),()=>writeTextFile('text',null)]){
592            try{await call();throw Error('Invalid write accepted');}catch(e){if(!(e instanceof errors.AlreadyExists)&&!(e instanceof errors.NotFound)&&!(e instanceof TypeError))throw e;}
593        }
594        console.log('fs IO');
595    "#,
596        &[],
597    );
598    assert_eq!(output.stdout, b"fs IO\n");
599    assert_eq!(
600        fs::read(dir.path().join("text")).unwrap(),
601        "héllo 🌍!".as_bytes()
602    );
603}
604
605#[test]
606fn mkdirs_copy_and_move_support_nested_trees_and_missing_parent_directories() {
607    let (dir, output) = run(
608        r#"
609        import {mkdirs,writeTextFile,copy,move,readTextFile,exists} from 'avril:fs';
610        await mkdirs('source/sub/deep');await mkdirs('source/sub/deep');
611        await writeTextFile('source/sub/deep/data','nested');
612        await copy('source','destination');
613        if(await readTextFile('destination/sub/deep/data')!=='nested')throw Error('Tree copy');
614        await copy('data','other/parents/file');
615        if(await readTextFile('other/parents/file')!=='abcdef')throw Error('File copy parents');
616        await move('destination','moved');
617        if(await exists('destination')||await readTextFile('moved/sub/deep/data')!=='nested')throw Error('Tree move');
618        console.log('fs workflows');
619    "#,
620        &[],
621    );
622    assert_eq!(output.stdout, b"fs workflows\n");
623    assert!(dir.path().join("source/sub/deep/data").exists());
624    assert!(!dir.path().join("destination").exists());
625}
626
627#[test]
628fn make_temp_dir_and_file_create_unique_namespaced_paths() {
629    let (dir, output) = run(
630        r#"
631        import {makeTempDir,makeTempFile,exists,readTextFile,writeTextFile} from 'avril:fs';
632        import {mkdir,stat,remove,errors} from 'avril:std';
633        const seen=new Set();
634        const plain=await makeTempDir();
635        if(!/^[0-9a-f]{8,16}$/.test(plain.split('/').pop()))throw Error('Hex name');
636        const named=await makeTempDir({prefix:'work-',suffix:'-tmp'});
637        if(!named.split('/').pop().startsWith('work-')||!named.endsWith('-tmp'))throw Error('Affixes');
638        const file=await makeTempFile({prefix:'out-',suffix:'.bin'});
639        if((await stat(file)).size!==0||!(await stat(file)).isFile)throw Error('Empty file');
640        await writeTextFile(file,'temp');
641        if(await readTextFile(file)!=='temp')throw Error('Writable temp file');
642        const inner=await makeTempDir({dir:plain,prefix:'inner-'});
643        if(!inner.startsWith(plain+'/'))throw Error('Custom dir spelling');
644        const viaUrl=await makeTempFile({dir:new URL(plain+'/','file://')});
645        if(!viaUrl.startsWith(plain+'/'))throw Error('URL dir');
646        for(let i=0;i<10;i++){const candidate=await makeTempFile({dir:plain});if(seen.has(candidate))throw Error('Collision');seen.add(candidate);}
647        for(const call of [()=>makeTempDir({prefix:'a/b'}),()=>makeTempFile({suffix:'x*y'}),()=>makeTempDir({prefix:'a\0b'}),()=>makeTempDir({prefix:3}),()=>makeTempDir({dir:plain+'/missing/nested'})]){
648            try{await call();throw Error('Invalid temp accepted');}catch(e){if(!(e instanceof errors.InvalidInput)&&!(e instanceof errors.NotFound)&&!(e instanceof TypeError))throw e;}
649        }
650        const dirInfo=await stat(named);
651        if(!dirInfo.isDirectory)throw Error('Directory kind');
652        if(dirInfo.mode!==null&&(dirInfo.mode&0o777)!==0o700)throw Error('Directory mode');
653        const fileInfo=await stat(file);
654        if(fileInfo.mode!==null&&(fileInfo.mode&0o777)!==0o600)throw Error('File mode');
655        await mkdir('scoped');
656        const scoped=await makeTempDir({dir:'scoped'});
657        if(!scoped.startsWith('scoped/'))throw Error('Relative dir');
658        await remove(plain,{recursive:true});await remove(named,{recursive:true});await remove(scoped,{recursive:true});await remove('scoped',{recursive:true});await remove(file);
659        if(await exists(plain)||await exists(named)||await exists(file))throw Error('Cleanup');
660        console.log('temp IO');
661    "#,
662        &[],
663    );
664    assert_eq!(output.stdout, b"temp IO\n");
665    assert!(!dir.path().join("scoped").exists());
666}
667
668#[test]
669fn fs_write_only_directory_and_exact_file_grants_work_without_reading_destinations() {
670    let (dir, output) = run(
671        r#"
672        import {mkdirs,writeTextFile,copy,readTextFile,exists} from 'avril:fs';
673        import {errors} from 'avril:std';
674        await mkdirs('out/sub');await mkdirs('out/sub');
675        await writeTextFile('out/sub/data','written');await copy('data','exact');
676        for(const call of [()=>readTextFile('out/sub/data'),()=>exists('out/sub/data'),()=>exists('ungranted/missing')]){
677            try{await call();throw Error('Read without grant');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
678        }
679        console.log('write-only fs');
680    "#,
681        &[
682            "-0",
683            "--allow-read=data",
684            "--allow-write=out/",
685            "--allow-write=exact",
686        ],
687    );
688    assert_eq!(output.stdout, b"write-only fs\n");
689    assert_eq!(fs::read(dir.path().join("exact")).unwrap(), b"abcdef");
690    assert_eq!(
691        fs::read(dir.path().join("out/sub/data")).unwrap(),
692        b"written"
693    );
694}
695
696#[test]
697fn whole_tree_copy_authorization_prevents_silent_skipping_and_partial_denied_writes() {
698    let (dir, output) = run(
699        r#"
700        import {copy,mkdirs,writeTextFile} from 'avril:fs';
701        import {errors} from 'avril:std';
702        await mkdirs('source');await writeTextFile('source/data','payload');
703        for(const call of [()=>copy('source','readDeniedCopy'),()=>copy('source','destination')]){
704            try{await call();throw Error('Copied forbidden tree');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
705        }
706        console.log('copy ceilings');
707    "#,
708        &[
709            "--deny-read=source/future",
710            "--deny-write=destination/future",
711        ],
712    );
713    assert_eq!(output.stdout, b"copy ceilings\n");
714    assert!(!dir.path().join("readDeniedCopy").exists());
715    assert!(!dir.path().join("destination").exists());
716}
717
718#[test]
719fn copying_overlapping_trees_is_rejected_before_creating_destinations() {
720    let (dir, output) = run(
721        r#"
722        import {copy,mkdirs,writeTextFile} from 'avril:fs';
723        import {errors} from 'avril:std';
724        await mkdirs('source/inner');await writeTextFile('source/data','payload');
725        for(const [from,to] of [['source','source'],['source','source/inner/new'],['source/inner','source']]){
726            try{await copy(from,to);throw Error('Copied overlapping trees');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;}
727        }
728        console.log('overlap guards');
729    "#,
730        &[],
731    );
732    assert_eq!(output.stdout, b"overlap guards\n");
733    assert!(!dir.path().join("source/inner/new").exists());
734}
735
736#[test]
737fn utility_intrinsics_and_error_classification_resist_public_prototype_tampering() {
738    let (_, output) = run(
739        r#"
740        import {writeFile,readFile,exists,writeTextFile,readTextFile} from 'avril:fs';
741        import {FileHandle,errors} from 'avril:std';
742        FileHandle.prototype.read=FileHandle.prototype.write=FileHandle.prototype[Symbol.dispose]=()=>{throw Error('Tampered method');};
743        TextEncoder.prototype.encode=TextDecoder.prototype.decode=()=>{throw Error('Tampered codec');};
744        Object.defineProperty(Uint8Array.prototype,'constructor',{get(){throw Error('Species');}});
745        Object.defineProperty(errors.NotFound,Symbol.hasInstance,{value(){return true;}});
746        await writeTextFile('text','safe');if(await readTextFile('text')!=='safe')throw Error('Codec');
747        const data=new Uint8Array(140000);data.fill(1);await writeFile('out',data);
748        if((await readFile('out')).length!==140000)throw Error('Methods');
749        if(await exists('missing'))throw Error('Missing');
750        try{await exists('denied');throw Error('Swallowed permission error');}catch(e){if(e.name!=='PermissionDenied')throw e;}
751        console.log('captured utilities');
752    "#,
753        &["--deny-read=denied"],
754    );
755    assert_eq!(output.stdout, b"captured utilities\n");
756}
757
758#[test]
759fn fs_public_module_is_frozen_and_private_sources_stay_private() {
760    let output = common::run(
761        r#"
762        import * as fs from 'avril:fs';
763        import * as std from 'avril:std';
764        for(const name of ['readFile','readTextFile','writeFile','writeTextFile','mkdirs','copy','move','exists'])
765            if(typeof fs[name]!=='function'||std[name]!==undefined)throw Error('Module split');
766        try{fs.readFile=()=>{};throw Error('FS module');}catch(e){if(!(e instanceof TypeError))throw e;}
767        if(Symbol.for('__avril.fs') in globalThis)throw Error('FS registry');
768        for(const source of ['ext:file/fs.js','ext:file/workflows.js']){
769            try{await import(source);throw Error('Private source imported');}catch(e){if(!e.message.includes('private'))throw e;}
770        }
771        console.log('fs module');
772    "#,
773        &[],
774    );
775    assert!(
776        output.status.success(),
777        "{}",
778        String::from_utf8_lossy(&output.stderr)
779    );
780    assert_eq!(output.stdout, b"fs module\n");
781}
782
783#[cfg(unix)]
784#[test]
785fn directory_copy_preserves_symlink_children_and_rejects_canonical_destination_overlap() {
786    let dir = tempfile::tempdir().unwrap();
787    fs::create_dir(dir.path().join("source")).unwrap();
788    fs::write(dir.path().join("source/data"), b"safe").unwrap();
789    std::os::unix::fs::symlink("source", dir.path().join("alias")).unwrap();
790    std::os::unix::fs::symlink("../data", dir.path().join("source/link")).unwrap();
791    fs::write(dir.path().join("main.js"),r#"
792        import {copy} from 'avril:fs';
793        import {errors,readLink,lstat} from 'avril:std';
794        try{await copy('source','alias/nested');throw Error('Canonical overlap');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;}
795        await copy('alias','copiedAlias');await copy('source','copiedTree');
796        if(!(await lstat('copiedAlias')).isSymlink||await readLink('copiedAlias')!=='source'||await readLink('copiedTree/link')!=='../data')throw Error('Link preservation');
797        console.log('symlink workflows');
798    "#).unwrap();
799    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
800        .arg("run")
801        .current_dir(dir.path())
802        .arg("main.js")
803        .stdin(Stdio::null())
804        .stdout(Stdio::piped())
805        .stderr(Stdio::piped())
806        .spawn()
807        .unwrap();
808    let output = common::wait(child);
809    assert!(
810        output.status.success(),
811        "{}",
812        String::from_utf8_lossy(&output.stderr)
813    );
814    assert_eq!(output.stdout, b"symlink workflows\n");
815    assert!(!dir.path().join("source/nested").exists());
816    assert_eq!(
817        fs::read_link(dir.path().join("copiedAlias")).unwrap(),
818        std::path::Path::new("source")
819    );
820    use std::os::unix::fs::PermissionsExt;
821    assert_eq!(
822        fs::metadata(dir.path().join("copiedTree"))
823            .unwrap()
824            .permissions()
825            .mode()
826            & 0o777,
827        fs::metadata(dir.path().join("source"))
828            .unwrap()
829            .permissions()
830            .mode()
831            & 0o777
832    );
833    assert!(fs::symlink_metadata(dir.path().join("source/link")).is_ok());
834}
835
836#[cfg(target_os = "linux")]
837#[test]
838fn cross_device_moves_copy_then_remove_and_keep_sources_on_failed_copy() {
839    use std::os::unix::fs::MetadataExt;
840    let dir = tempfile::tempdir().unwrap();
841    let Ok(other) = tempfile::tempdir_in("/dev/shm") else {
842        return;
843    };
844    if fs::metadata(dir.path()).unwrap().dev() == fs::metadata(other.path()).unwrap().dev() {
845        return;
846    }
847    fs::write(dir.path().join("file"), b"cross-device").unwrap();
848    fs::create_dir(dir.path().join("tree")).unwrap();
849    fs::write(dir.path().join("tree/data"), b"tree").unwrap();
850    fs::create_dir(dir.path().join("unsupported")).unwrap();
851    std::os::unix::fs::symlink("data", dir.path().join("tree/link")).unwrap();
852    std::os::unix::fs::symlink("file", dir.path().join("link")).unwrap();
853    let _socket =
854        std::os::unix::net::UnixListener::bind(dir.path().join("unsupported/socket")).unwrap();
855    let source = format!("{:?}", dir.path().to_str().unwrap());
856    let destination = format!("{:?}", other.path().to_str().unwrap());
857    fs::write(dir.path().join("main.js"),format!(r#"
858        import {{move,readTextFile,exists}} from 'avril:fs';
859        const source={source},destination={destination};
860        await move(source+'/file',destination+'/file');
861        await move(source+'/link',destination+'/link');
862        await move(source+'/tree',destination+'/tree');
863        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');
864        try{{await move(source+'/unsupported',destination+'/unsupported');throw Error('Moved unsupported tree');}}catch(e){{if(!e.message.includes('not yet implemented'))throw e;}}
865        console.log('cross-device');
866    "#)).unwrap();
867    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
868        .arg("run")
869        .arg(dir.path().join("main.js"))
870        .stdin(Stdio::null())
871        .stdout(Stdio::piped())
872        .stderr(Stdio::piped())
873        .spawn()
874        .unwrap();
875    let output = common::wait(child);
876    assert!(
877        output.status.success(),
878        "{}",
879        String::from_utf8_lossy(&output.stderr)
880    );
881    assert_eq!(output.stdout, b"cross-device\n");
882    assert!(fs::symlink_metadata(dir.path().join("unsupported/socket")).is_ok());
883    assert_eq!(
884        fs::read_link(other.path().join("tree/link")).unwrap(),
885        std::path::Path::new("data")
886    );
887    assert_eq!(
888        fs::read_link(other.path().join("link")).unwrap(),
889        std::path::Path::new("file")
890    );
891    assert!(fs::symlink_metadata(dir.path().join("link")).is_err());
892    assert!(other.path().join("tree/data").exists());
893}
894
895#[cfg(unix)]
896#[test]
897fn copying_read_only_directory_trees_preserves_modes_after_private_staging() {
898    use std::os::unix::fs::PermissionsExt;
899    let dir = tempfile::tempdir().unwrap();
900    fs::create_dir(dir.path().join("source")).unwrap();
901    fs::write(dir.path().join("source/data"), b"data").unwrap();
902    fs::set_permissions(dir.path().join("source"), fs::Permissions::from_mode(0o555)).unwrap();
903    fs::create_dir(dir.path().join("existing")).unwrap();
904    fs::set_permissions(
905        dir.path().join("existing"),
906        fs::Permissions::from_mode(0o777),
907    )
908    .unwrap();
909    fs::write(dir.path().join("main.js"),r#"
910        import {copy,readTextFile} from 'avril:fs';
911        await copy('source','new');await copy('source','existing');
912        if(await readTextFile('new/data')!=='data'||await readTextFile('existing/data')!=='data')throw Error('Readonly copy');
913        console.log('copied modes');
914    "#).unwrap();
915    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
916        .arg("run")
917        .current_dir(dir.path())
918        .arg("main.js")
919        .stdin(Stdio::null())
920        .stdout(Stdio::piped())
921        .stderr(Stdio::piped())
922        .spawn()
923        .unwrap();
924    let output = common::wait(child);
925    assert!(
926        output.status.success(),
927        "{}",
928        String::from_utf8_lossy(&output.stderr)
929    );
930    assert_eq!(output.stdout, b"copied modes\n");
931    for name in ["source", "new", "existing"] {
932        let path = dir.path().join(name);
933        assert_eq!(
934            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
935            0o555
936        );
937        fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
938    }
939}
940
941#[cfg(unix)]
942#[test]
943fn symbolic_link_primitives_preserve_text_resolve_paths_and_report_dangling_links() {
944    let (dir, output) = run(
945        r#"
946        import {symlink,readLink,realPath,lstat,stat,mkdir,errors} from 'avril:std';
947        import {readTextFile,exists} from 'avril:fs';
948        await mkdir('links');
949        await symlink('../data','links/relative');
950        if(await readLink(new URL('./links/relative',import.meta.url))!=='../data'||await readTextFile('links/relative')!=='abcdef')throw Error('Relative link base');
951        const target=new URL('./data',import.meta.url);await symlink(target,'absolute',{type:'file'});
952        if(await readLink('absolute')!==await realPath('data')||await realPath('links/relative')!==await realPath('data'))throw Error('Absolute canonical path');
953        if(!(await lstat('links/relative')).isSymlink||!(await stat('links/relative')).isFile)throw Error('Metadata');
954        await symlink('missing','dangling');
955        if(await readLink('dangling')!=='missing'||await exists('dangling'))throw Error('Dangling text');
956        for(const call of [()=>realPath('dangling'),()=>realPath('missing')]){
957            try{await call();throw Error('Resolved missing target');}catch(e){if(!(e instanceof errors.NotFound))throw e;}
958        }
959        await symlink('loop','loop');
960        try{await realPath('loop');throw Error('Resolved loop');}catch(e){if(!(e instanceof errors.IoError))throw e;}
961        console.log('symbolic links');
962    "#,
963        &[],
964    );
965    assert_eq!(output.stdout, b"symbolic links\n");
966    assert_eq!(
967        fs::read_link(dir.path().join("links/relative")).unwrap(),
968        std::path::Path::new("../data")
969    );
970}
971
972#[cfg(unix)]
973#[test]
974fn symlink_creation_requires_target_tree_rights_and_does_not_overwrite_entries() {
975    let (dir, output) = run(
976        r#"
977        import {mkdir,symlink,readLink,realPath,errors} from 'avril:std';
978        await mkdir('out');
979        await symlink('../data','out/allowed');
980        if(await readLink('out/allowed')!=='../data')throw Error('Stored text');
981        for(const [target,name] of [['../secret/file','out/readDenied'],['../readonly/file','out/writeDenied'],['../exact','out/exactDenied']]){
982            try{await symlink(target,name);throw Error('Planted unauthorized link');}catch(e){if(!(e instanceof errors.PermissionDenied)||!e.message.includes(name))throw e;}
983        }
984        try{await symlink('replacement','out/allowed');throw Error('Overwrote link');}catch(e){if(!(e instanceof errors.AlreadyExists)&&!(e instanceof errors.PermissionDenied))throw e;}
985        console.log('target permissions');
986    "#,
987        &[
988            "--deny-read=secret/",
989            "--deny-write=readonly/",
990            "--deny-write=exact/future",
991        ],
992    );
993    assert_eq!(output.stdout, b"target permissions\n");
994    for name in ["readDenied", "writeDenied", "exactDenied"] {
995        assert!(fs::symlink_metadata(dir.path().join("out").join(name)).is_err());
996    }
997    assert_eq!(
998        fs::read_link(dir.path().join("out/allowed")).unwrap(),
999        std::path::Path::new("../data")
1000    );
1001}
1002
1003#[cfg(unix)]
1004#[test]
1005fn scoped_symlink_targets_may_be_missing_but_must_not_hide_unresolved_aliases() {
1006    let dir = tempfile::tempdir().unwrap();
1007    fs::create_dir(dir.path().join("out")).unwrap();
1008    let outside = tempfile::tempdir().unwrap();
1009    std::os::unix::fs::symlink(
1010        outside.path().join("missing"),
1011        dir.path().join("out/hiddenAlias"),
1012    )
1013    .unwrap();
1014    fs::write(dir.path().join("main.js"),r#"
1015        import {symlink,readLink,realPath,errors,pledge} from 'avril:std';
1016        await symlink('future','out/allowed');
1017        if(await readLink('out/allowed')!=='future')throw Error('Allowed future target');
1018        for(const target of ['hiddenAlias','hiddenAlias/file','../../outside']){
1019            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;}
1020        }
1021        pledge({write:false});
1022        try{await symlink('future','out/revoked');throw Error('Retained write');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
1023        console.log('scoped links');
1024    "#).unwrap();
1025    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1026        .arg("run")
1027        .current_dir(dir.path())
1028        .args(["-0", "--allow-read=out/", "--allow-write=out/", "main.js"])
1029        .stdin(Stdio::null())
1030        .stdout(Stdio::piped())
1031        .stderr(Stdio::piped())
1032        .spawn()
1033        .unwrap();
1034    let output = common::wait(child);
1035    assert!(
1036        output.status.success(),
1037        "{}",
1038        String::from_utf8_lossy(&output.stderr)
1039    );
1040    assert_eq!(output.stdout, b"scoped links\n");
1041    assert!(fs::symlink_metadata(dir.path().join("out/denied")).is_err());
1042    assert!(!outside.path().join("missing").exists());
1043}
1044
1045#[cfg(unix)]
1046#[test]
1047fn read_link_reads_entry_data_but_real_path_requires_the_referent_and_hides_denials() {
1048    let dir = tempfile::tempdir().unwrap();
1049    fs::create_dir(dir.path().join("out")).unwrap();
1050    fs::create_dir(dir.path().join("secret")).unwrap();
1051    fs::write(dir.path().join("secret/file"), b"private").unwrap();
1052    std::os::unix::fs::symlink("../secret/file", dir.path().join("out/link")).unwrap();
1053    fs::write(dir.path().join("main.js"),r#"
1054        import {readLink,realPath,errors} from 'avril:std';
1055        if(await readLink('out/link')!=='../secret/file')throw Error('Entry read');
1056        for(const name of ['out/link','secret/file/x','secret/missing']){
1057            try{await realPath(name);throw Error('Disclosed target');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;}
1058        }
1059        try{await readLink('secret/missing');throw Error('Read unauthorized link');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
1060        console.log('link privacy');
1061    "#).unwrap();
1062    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1063        .arg("run")
1064        .current_dir(dir.path())
1065        .args(["-0", "--allow-read=out/", "main.js"])
1066        .stdin(Stdio::null())
1067        .stdout(Stdio::piped())
1068        .stderr(Stdio::piped())
1069        .spawn()
1070        .unwrap();
1071    let output = common::wait(child);
1072    assert!(
1073        output.status.success(),
1074        "{}",
1075        String::from_utf8_lossy(&output.stderr)
1076    );
1077    assert_eq!(output.stdout, b"link privacy\n");
1078}
1079
1080#[cfg(unix)]
1081#[test]
1082fn renaming_relative_links_rechecks_targets_and_scoped_directory_rebasing_is_explicitly_deferred() {
1083    let (dir, output) = run(
1084        r#"
1085        import {mkdir,symlink,rename,readLink,errors} from 'avril:std';
1086        await mkdir('safe');await mkdir('safe/deep');await mkdir('dest');
1087        await symlink('../data','safe/link');
1088        try{await rename('safe/link','safe/deep/link');throw Error('Retargeted forbidden link');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
1089        await mkdir('pack');await symlink('data','pack/link');
1090        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;}
1091        if(await readLink('safe/link')!=='../data')throw Error('Failed rename modified link');
1092        console.log('link rebasing');
1093    "#,
1094        &["--deny-write=safe/data"],
1095    );
1096    assert_eq!(output.stdout, b"link rebasing\n");
1097    assert!(!dir.path().join("dest/tree").exists());
1098}
1099
1100#[cfg(unix)]
1101#[test]
1102fn link_primitive_arguments_errors_and_private_exports_are_consistent() {
1103    let (_, output) = run(
1104        r#"
1105        import {symlink,readLink,realPath,errors} from 'avril:std';
1106        for(const call of [()=>symlink('data'),()=>symlink('data','new',{type:'junction'}),()=>readLink(),()=>realPath()]){
1107            try{await call();throw Error('Accepted invalid arguments');}catch(e){if(!(e instanceof TypeError))throw e;}
1108        }
1109        for(const call of [()=>symlink('','new'),()=>symlink('a\0b','new'),()=>readLink('data')]){
1110            try{await call();throw Error('Accepted invalid link');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;}
1111        }
1112        await symlink('missing','dangling');
1113        try{await symlink('other','dangling');throw Error('Replaced dangling link');}catch(e){if(!(e instanceof errors.AlreadyExists))throw e;}
1114        console.log('link errors');
1115    "#,
1116        &[],
1117    );
1118    assert_eq!(output.stdout, b"link errors\n");
1119}
1120
1121#[cfg(unix)]
1122#[test]
1123fn copying_links_preserves_raw_targets_or_explicitly_dereferences_them() {
1124    let (dir, output) = run(
1125        r#"
1126        import {symlink,mkdir,readLink,lstat} from 'avril:std';
1127        import {copy,readTextFile,writeTextFile} from 'avril:fs';
1128        await symlink('data','relative');await symlink(new URL('./data',import.meta.url),'absolute');await symlink('missing','dangling');
1129        for(const name of ['relative','absolute','dangling']){
1130            await copy(name,name+'Copy');
1131            if(!(await lstat(name+'Copy')).isSymlink||await readLink(name+'Copy')!==await readLink(name))throw Error('Raw preservation');
1132        }
1133        await copy('relative','fileCopy',{dereference:true});
1134        if(!(await lstat('fileCopy')).isFile||await readTextFile('fileCopy')!=='abcdef')throw Error('File dereference');
1135        await mkdir('folder');await writeTextFile('folder/data','nested');await symlink('folder','dirLink');
1136        await copy('dirLink','dirCopy',{dereference:true});
1137        if(!(await lstat('dirCopy')).isDirectory||await readTextFile('dirCopy/data')!=='nested')throw Error('Directory dereference');
1138        console.log('copy links');
1139    "#,
1140        &[],
1141    );
1142    assert_eq!(output.stdout, b"copy links\n");
1143    assert_eq!(
1144        fs::read_link(dir.path().join("danglingCopy")).unwrap(),
1145        std::path::Path::new("missing")
1146    );
1147}
1148
1149#[cfg(unix)]
1150#[test]
1151fn following_directory_links_detects_cycles_but_allows_shared_subtrees() {
1152    let (dir, output) = run(
1153        r#"
1154        import {symlink,mkdir,errors,lstat} from 'avril:std';
1155        import {copy,writeTextFile,readTextFile} from 'avril:fs';
1156        await mkdir('shared');await writeTextFile('shared/data','shared');
1157        await mkdir('source');await symlink('../shared','source/a');await symlink('../shared','source/b');
1158        await copy('source','destination',{dereference:true});
1159        if(await readTextFile('destination/a/data')!=='shared'||await readTextFile('destination/b/data')!=='shared')throw Error('DAG alias skipped');
1160        await symlink('.','source/cycle');
1161        await copy('source','preserved');
1162        if(!(await lstat('preserved/cycle')).isSymlink)throw Error('Preserved cycle');
1163        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;}
1164        console.log('cycle guards');
1165    "#,
1166        &[],
1167    );
1168    assert_eq!(output.stdout, b"cycle guards\n");
1169    assert!(!dir.path().join("followed/cycle").exists());
1170}
1171
1172#[cfg(unix)]
1173#[test]
1174fn link_copy_checks_new_target_authority_before_removing_the_destination() {
1175    let dir = tempfile::tempdir().unwrap();
1176    fs::create_dir(dir.path().join("source")).unwrap();
1177    fs::create_dir_all(dir.path().join("dest/deep")).unwrap();
1178    fs::write(dir.path().join("target"), b"target").unwrap();
1179    fs::write(dir.path().join("dest/deep/link"), b"untouched").unwrap();
1180    std::os::unix::fs::symlink("../target", dir.path().join("source/link")).unwrap();
1181    fs::write(dir.path().join("main.js"),r#"
1182        import {copy} from 'avril:fs';
1183        import {errors} from 'avril:std';
1184        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;}
1185        console.log('copy target checks');
1186    "#).unwrap();
1187    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1188        .arg("run")
1189        .current_dir(dir.path())
1190        .args(["--deny-write=dest/target", "main.js"])
1191        .stdin(Stdio::null())
1192        .stdout(Stdio::piped())
1193        .stderr(Stdio::piped())
1194        .spawn()
1195        .unwrap();
1196    let output = common::wait(child);
1197    assert!(
1198        output.status.success(),
1199        "{}",
1200        String::from_utf8_lossy(&output.stderr)
1201    );
1202    assert_eq!(output.stdout, b"copy target checks\n");
1203    assert_eq!(
1204        fs::read(dir.path().join("dest/deep/link")).unwrap(),
1205        b"untouched"
1206    );
1207}
1208
1209#[cfg(unix)]
1210#[test]
1211fn link_preservation_and_dereferencing_require_different_target_rights() {
1212    let dir = tempfile::tempdir().unwrap();
1213    fs::create_dir(dir.path().join("links")).unwrap();
1214    fs::write(dir.path().join("data"), b"read-only").unwrap();
1215    std::os::unix::fs::symlink("../data", dir.path().join("links/source")).unwrap();
1216    fs::write(dir.path().join("main.js"),r#"
1217        import {copy,readTextFile} from 'avril:fs';
1218        import {errors,lstat} from 'avril:std';
1219        try{await copy('links/source','links/preserved');throw Error('Planted read-only target link');}catch(e){if(!(e instanceof errors.PermissionDenied))throw e;}
1220        await copy('links/source','links/followed',{dereference:true});
1221        if(!(await lstat('links/followed')).isFile||await readTextFile('links/followed')!=='read-only')throw Error('Authorized read copy');
1222        console.log('follow permissions');
1223    "#).unwrap();
1224    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1225        .arg("run")
1226        .current_dir(dir.path())
1227        .args(["--deny-write=data", "main.js"])
1228        .stdin(Stdio::null())
1229        .stdout(Stdio::piped())
1230        .stderr(Stdio::piped())
1231        .spawn()
1232        .unwrap();
1233    let output = common::wait(child);
1234    assert!(
1235        output.status.success(),
1236        "{}",
1237        String::from_utf8_lossy(&output.stderr)
1238    );
1239    assert_eq!(output.stdout, b"follow permissions\n");
1240    assert!(fs::symlink_metadata(dir.path().join("links/preserved")).is_err());
1241}
1242
1243#[cfg(unix)]
1244#[test]
1245fn following_ungranted_links_does_not_disclose_canonical_targets() {
1246    let dir = tempfile::tempdir().unwrap();
1247    fs::create_dir(dir.path().join("links")).unwrap();
1248    fs::create_dir(dir.path().join("secret")).unwrap();
1249    fs::write(dir.path().join("secret/data"), b"private").unwrap();
1250    std::os::unix::fs::symlink("../secret/data", dir.path().join("links/source")).unwrap();
1251    fs::write(dir.path().join("main.js"),r#"
1252        import {copy} from 'avril:fs';
1253        import {errors} from 'avril:std';
1254        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;}
1255        console.log('follow privacy');
1256    "#).unwrap();
1257    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1258        .arg("run")
1259        .current_dir(dir.path())
1260        .args([
1261            "-0",
1262            "--allow-read=links/",
1263            "--allow-write=links/",
1264            "main.js",
1265        ])
1266        .stdin(Stdio::null())
1267        .stdout(Stdio::piped())
1268        .stderr(Stdio::piped())
1269        .spawn()
1270        .unwrap();
1271    let output = common::wait(child);
1272    assert!(
1273        output.status.success(),
1274        "{}",
1275        String::from_utf8_lossy(&output.stderr)
1276    );
1277    assert_eq!(output.stdout, b"follow privacy\n");
1278    assert!(fs::symlink_metadata(dir.path().join("links/followed")).is_err());
1279}
1280
1281#[cfg(unix)]
1282#[test]
1283fn same_inode_link_copies_fail_without_removing_the_source_or_alias() {
1284    let dir = tempfile::tempdir().unwrap();
1285    fs::write(dir.path().join("data"), b"data").unwrap();
1286    std::os::unix::fs::symlink("data", dir.path().join("link")).unwrap();
1287    fs::hard_link(dir.path().join("link"), dir.path().join("alias")).unwrap();
1288    fs::write(dir.path().join("main.js"),r#"
1289        import {copy} from 'avril:fs';
1290        import {errors,readLink} from 'avril:std';
1291        for(const name of ['link','alias']){
1292            try{await copy('link',name);throw Error('Copied same link');}catch(e){if(!(e instanceof errors.InvalidInput))throw e;}
1293            if(await readLink(name)!=='data')throw Error('Removed source link');
1294        }
1295        console.log('link inode identity');
1296    "#).unwrap();
1297    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1298        .arg("run")
1299        .current_dir(dir.path())
1300        .arg("main.js")
1301        .stdin(Stdio::null())
1302        .stdout(Stdio::piped())
1303        .stderr(Stdio::piped())
1304        .spawn()
1305        .unwrap();
1306    let output = common::wait(child);
1307    assert!(
1308        output.status.success(),
1309        "{}",
1310        String::from_utf8_lossy(&output.stderr)
1311    );
1312    assert_eq!(output.stdout, b"link inode identity\n");
1313}
1314
1315#[cfg(unix)]
1316#[test]
1317fn dereference_options_validate_and_link_destination_replacement_never_follows_old_targets() {
1318    let (dir, output) = run(
1319        r#"
1320        import {copy,readTextFile,writeTextFile} from 'avril:fs';
1321        import {symlink,readLink,mkdir,errors} from 'avril:std';
1322        await writeTextFile('oldTarget','old');await symlink('oldTarget','destination');
1323        await symlink('data','source');await copy('source','destination');
1324        if(await readLink('destination')!=='data'||await readTextFile('oldTarget')!=='old')throw Error('Followed old destination link');
1325        await mkdir('folder');
1326        try{await copy('source','folder');throw Error('Removed directory');}catch(e){if(!(e instanceof errors.IsADirectory))throw e;}
1327        try{await copy('source','invalid',{dereference:'yes'});throw Error('Accepted invalid dereference');}catch(e){if(!(e instanceof TypeError))throw e;}
1328        console.log('link overwrite');
1329    "#,
1330        &[],
1331    );
1332    assert_eq!(output.stdout, b"link overwrite\n");
1333    assert_eq!(fs::read(dir.path().join("oldTarget")).unwrap(), b"old");
1334}
1335
1336#[cfg(unix)]
1337#[test]
1338fn following_ungranted_dangling_links_masks_existence_for_all_filesystem_workflows() {
1339    let dir = tempfile::tempdir().unwrap();
1340    fs::create_dir(dir.path().join("out")).unwrap();
1341    fs::create_dir(dir.path().join("secret")).unwrap();
1342    fs::write(dir.path().join("secret/exists"), b"private").unwrap();
1343    std::os::unix::fs::symlink("../secret/exists", dir.path().join("out/a")).unwrap();
1344    std::os::unix::fs::symlink("../secret/missing", dir.path().join("out/b")).unwrap();
1345    std::os::unix::fs::symlink("b", dir.path().join("out/chain")).unwrap();
1346    fs::write(dir.path().join("main.js"),r#"
1347        import {stat,lstat,open,create,realPath,readLink,readDir,mkdir,errors} from 'avril:std';
1348        import {exists,copy} from 'avril:fs';
1349        for(const name of ['out/a','out/b','out/chain','out/a/x','out/b/x','out/chain/x']){
1350            for(const call of [()=>stat(name),()=>open(name),()=>realPath(name),()=>exists(name),()=>readDir(name).next(),()=>copy(name,'out/copied',{dereference:true})]){
1351                try{await call();throw Error('Target existence disclosed '+name+' '+call);}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;}
1352            }
1353            try{await create(name);throw Error('Write reached ungranted target');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!=='read '+name)throw e;}
1354            try{await mkdir(name);throw Error('Created directory over link');}catch(e){
1355                if(name.endsWith('/x')){if(!(e instanceof errors.PermissionDenied)||e.message!=='write '+name)throw e;}
1356                else if(!(e instanceof errors.AlreadyExists))throw e;
1357            }
1358        }
1359        if(await readLink('out/b')!=='../secret/missing'||!(await lstat('out/b')).isSymlink)throw Error('Entry permission changed');
1360        console.log('dangling target privacy');
1361    "#).unwrap();
1362    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1363        .arg("run")
1364        .current_dir(dir.path())
1365        .args(["-0", "--allow-read=out/", "--allow-write=out/", "main.js"])
1366        .stdin(Stdio::null())
1367        .stdout(Stdio::piped())
1368        .stderr(Stdio::piped())
1369        .spawn()
1370        .unwrap();
1371    let output = common::wait(child);
1372    assert!(
1373        output.status.success(),
1374        "{}",
1375        String::from_utf8_lossy(&output.stderr)
1376    );
1377    assert_eq!(output.stdout, b"dangling target privacy\n");
1378    assert!(!dir.path().join("secret/missing").exists());
1379    assert!(fs::symlink_metadata(dir.path().join("out/copied")).is_err());
1380}
1381
1382#[cfg(unix)]
1383#[test]
1384fn authorized_dangling_targets_are_not_found_and_can_be_created_through_links() {
1385    let dir = tempfile::tempdir().unwrap();
1386    fs::create_dir(dir.path().join("out")).unwrap();
1387    fs::write(dir.path().join("out/data"), b"copied").unwrap();
1388    std::os::unix::fs::symlink("missing", dir.path().join("out/link")).unwrap();
1389    std::os::unix::fs::symlink("link", dir.path().join("out/chain")).unwrap();
1390    fs::write(dir.path().join("main.js"),r#"
1391        import {stat,lstat,create,realPath,symlink,readLink,errors} from 'avril:std';
1392        import {exists,copy,mkdirs,readTextFile} from 'avril:fs';
1393        for(const name of ['out/link','out/chain','out/link/child']){
1394            if(await exists(name))throw Error('Dangling exists');
1395            for(const call of [()=>stat(name),()=>realPath(name)]){
1396                try{await call();throw Error('Resolved missing referent');}catch(e){if(!(e instanceof errors.NotFound))throw e;}
1397            }
1398        }
1399        await symlink('chain','out/newAlias');
1400        await symlink('future-dir','out/dir-link');await mkdirs('out/dir-link');
1401        if(!(await stat('out/dir-link')).isDirectory||!(await lstat('out/dir-link')).isSymlink)throw Error('Directory ensure did not follow authorized target');
1402        await copy('out/data','out/link');
1403        if(!(await lstat('out/link')).isSymlink||!(await stat('out/link')).isFile)throw Error('File copy did not follow authorized dangling destination');
1404        const file=await create('out/link');await file.write(new TextEncoder().encode('created'));file.close();
1405        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');
1406        console.log('authorized dangling targets');
1407    "#).unwrap();
1408    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1409        .arg("run")
1410        .current_dir(dir.path())
1411        .args(["-0", "--allow-read=out/", "--allow-write=out/", "main.js"])
1412        .stdin(Stdio::null())
1413        .stdout(Stdio::piped())
1414        .stderr(Stdio::piped())
1415        .spawn()
1416        .unwrap();
1417    let output = common::wait(child);
1418    assert!(
1419        output.status.success(),
1420        "{}",
1421        String::from_utf8_lossy(&output.stderr)
1422    );
1423    assert_eq!(output.stdout, b"authorized dangling targets\n");
1424    assert_eq!(
1425        fs::read(dir.path().join("out/missing")).unwrap(),
1426        b"created"
1427    );
1428}
1429
1430#[cfg(unix)]
1431#[test]
1432fn dangling_relative_targets_use_physical_alias_parents_not_lexical_dotdot() {
1433    let dir = tempfile::tempdir().unwrap();
1434    let other = tempfile::tempdir().unwrap();
1435    fs::create_dir(dir.path().join("out")).unwrap();
1436    fs::create_dir(other.path().join("inside")).unwrap();
1437    fs::create_dir(other.path().join("secret")).unwrap();
1438    std::os::unix::fs::symlink(other.path().join("inside"), dir.path().join("out/parent")).unwrap();
1439    std::os::unix::fs::symlink("../secret/missing", other.path().join("inside/link")).unwrap();
1440    fs::write(dir.path().join("main.js"),r#"
1441        import {stat,create,errors} from 'avril:std';
1442        for(const [call,label] of [[()=>stat('out/parent/link'),'read out/parent/link'],[()=>create('out/parent/link'),'read out/parent/link']]){
1443            try{await call();throw Error('Lexical alias parent used');}catch(e){if(!(e instanceof errors.PermissionDenied)||e.message!==label)throw e;}
1444        }
1445        console.log('physical dangling parents');
1446    "#).unwrap();
1447    let read = format!("--allow-read={}/", other.path().join("inside").display());
1448    let write = format!("--allow-write={}/", other.path().join("inside").display());
1449    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1450        .arg("run")
1451        .current_dir(dir.path())
1452        .args([
1453            "-0",
1454            "--allow-read=out/",
1455            "--allow-write=out/",
1456            &read,
1457            &write,
1458            "main.js",
1459        ])
1460        .stdin(Stdio::null())
1461        .stdout(Stdio::piped())
1462        .stderr(Stdio::piped())
1463        .spawn()
1464        .unwrap();
1465    let output = common::wait(child);
1466    assert!(
1467        output.status.success(),
1468        "{}",
1469        String::from_utf8_lossy(&output.stderr)
1470    );
1471    assert_eq!(output.stdout, b"physical dangling parents\n");
1472    assert!(!other.path().join("secret/missing").exists());
1473}
1474
1475#[cfg(unix)]
1476#[test]
1477fn exclusive_creation_rejects_existing_symlink_entries_even_with_missing_referents() {
1478    let (dir, output) = run(
1479        r#"
1480        import {symlink,open,readLink,errors,pledge} from 'avril:std';
1481        import {writeFile,exists} from 'avril:fs';
1482        await symlink('missing','dangling');await symlink('data','existing');
1483        pledge({read:{allow:['./']},write:{allow:['./']}});
1484        for(const name of ['dangling','existing']){
1485            for(const call of [()=>open(name,{write:true,createNew:true}),()=>writeFile(name,new Uint8Array([1]),{createNew:true})]){
1486                try{await call();throw Error('Exclusive creation followed link');}catch(e){if(!(e instanceof errors.AlreadyExists))throw e;}
1487            }
1488        }
1489        if(await exists('missing')||await readLink('dangling')!=='missing')throw Error('Exclusive creation mutated dangling target');
1490        console.log('exclusive link entries');
1491    "#,
1492        &[],
1493    );
1494    assert_eq!(output.stdout, b"exclusive link entries\n");
1495    assert!(!dir.path().join("missing").exists());
1496    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef");
1497}
1498
1499#[cfg(unix)]
1500#[test]
1501fn posix_path_ops_set_modes_times_links_and_sizes() {
1502    let (dir, output) = run(
1503        r#"
1504        import {chmod,chown,lchown,utime,lutime,link,truncate,stat,lstat,symlink,mkdir,errors} from 'avril:std';
1505        import {readFile,readTextFile} from 'avril:fs';
1506        await chmod('data', 0o600);
1507        if (((await stat('data')).mode & 0o7777) !== 0o600) throw Error('Chmod mode');
1508        await truncate('data', 3);
1509        if ((await stat('data')).size !== 3 || await readTextFile('data') !== 'abc') throw Error('Truncate shrink');
1510        await truncate('data', 6);
1511        const grown = await readFile('data');
1512        if (grown.length !== 6 || grown[0] !== 97 || grown[3] !== 0) throw Error('Truncate grow');
1513        await symlink('data', 'alias');
1514        await utime('alias', new Date(1000000000000), 1000000000);
1515        const followed = await stat('alias');
1516        if (followed.mtime.getTime() !== 1000000000000 || followed.atime.getTime() !== 1000000000000) throw Error('Utime follows links');
1517        await lutime('alias', 2000000000, 2000000000);
1518        if ((await lstat('alias')).mtime.getTime() !== 2000000000000 || (await stat('data')).mtime.getTime() !== 1000000000000) throw Error('Lutime keeps link times');
1519        const { gid } = await stat('data');
1520        await chown('data', null, gid);
1521        await chown('alias', null, gid);
1522        await lchown('alias', null, gid);
1523        await symlink('missing', 'dangling');
1524        await lchown('dangling', null, gid);
1525        await lutime('dangling', 2000000000, 2000000000);
1526        await mkdir('sub');
1527        await symlink('sub', 'dirlink');
1528        await lutime('dirlink', 2000000000, 2000000000);
1529        await lutime('dirlink/', 1000000000, 1000000000);
1530        if ((await lstat('dirlink')).mtime.getTime() !== 2000000000000 || (await stat('sub')).mtime.getTime() !== 1000000000000) throw Error('Lutime slash did not follow');
1531        await symlink('loop', 'loop');
1532        try { await lchown('loop/', null, gid); throw Error('Lchown ignored trailing slash'); } catch (e) { if (!(e instanceof errors.IoError)) throw e; }
1533        await lchown('loop', null, gid);
1534        for (const call of [()=>chown('dangling', null, gid), ()=>utime('dangling', 0, 0), ()=>chmod('dangling', 0o644)]) {
1535            try { await call(); throw Error('Followed dangling link'); } catch (e) { if (!(e instanceof errors.NotFound)) throw e; }
1536        }
1537        await link('data', 'hard');
1538        if (await readTextFile('hard') !== await readTextFile('data')) throw Error('Hard link contents');
1539        try { await link('alias', 'linkedAlias'); throw Error('Hard-linked a symlink'); } catch (e) { if (!(e instanceof errors.InvalidInput)) throw e; }
1540        for (const [call, C] of [
1541            [()=>chmod('', 0o644), errors.NotFound],
1542            [()=>chmod('data', 0o10000), RangeError],
1543            [()=>chmod('data', -1), RangeError],
1544            [()=>chmod('data', 0o644 + 0.5), RangeError],
1545            [()=>truncate('data', -1), RangeError],
1546            [()=>truncate('data', 2 ** 53), RangeError],
1547            [()=>truncate('data', 1.5), RangeError],
1548            [()=>truncate('missing', 0), errors.NotFound],
1549            [()=>link('data', 'hard'), errors.AlreadyExists],
1550            [()=>link('missing', 'new'), errors.NotFound],
1551            [()=>chown('data', null, null), errors.InvalidInput],
1552            [()=>chown('data', 1.5, null), RangeError],
1553            [()=>lchown('data', 2 ** 32, null), RangeError],
1554            [()=>utime('data', NaN, 0), TypeError],
1555            [()=>utime('data', 0, Infinity), TypeError],
1556            [()=>lutime('data', 'now', 0), TypeError],
1557            [()=>truncate(), TypeError],
1558            [()=>link('data'), TypeError],
1559            [()=>chown('data', null), TypeError],
1560        ]) {
1561            try { await call(); throw Error('Accepted invalid operation'); } catch (e) { if (!(e instanceof C)) throw e; }
1562        }
1563        console.log('posix paths');
1564    "#,
1565        &[],
1566    );
1567    assert_eq!(output.stdout, b"posix paths\n");
1568    use std::os::unix::fs::MetadataExt;
1569    assert_eq!(
1570        fs::metadata(dir.path().join("hard")).unwrap().ino(),
1571        fs::metadata(dir.path().join("data")).unwrap().ino()
1572    );
1573    assert!(!dir.path().join("linkedAlias").exists());
1574}
1575
1576#[cfg(unix)]
1577#[test]
1578fn posix_path_ops_need_write_access_and_hide_denied_targets() {
1579    let (dir, output) = run(
1580        r#"
1581        import {chmod,chown,lchown,utime,lutime,link,truncate,errors} from 'avril:std';
1582        for (const name of ['other', 'data/x']) {
1583            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)]) {
1584                try { await call(); throw Error('Granted write ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ' + name) throw e; }
1585            }
1586            try { await link(name, 'elsewhere'); throw Error('Granted read ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'read ' + name) throw e; }
1587        }
1588        console.log('posix denials');
1589    "#,
1590        &["-0", "--allow-read=data", "--allow-write=data"],
1591    );
1592    assert_eq!(output.stdout, b"posix denials\n");
1593    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef");
1594    for name in ["other", "elsewhere"] {
1595        assert!(!dir.path().join(name).exists());
1596    }
1597    let (denied, output) = run(
1598        r#"
1599        import {chmod,chown,lchown,utime,lutime,link,truncate,errors} from 'avril:std';
1600        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)]) {
1601            try { await call(); throw Error('Mutated without write'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write data') throw e; }
1602        }
1603        try { await link('data', 'copy'); throw Error('Linked without write'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write copy') throw e; }
1604        console.log('posix write gates');
1605    "#,
1606        &["-0", "--allow-read=data"],
1607    );
1608    assert_eq!(output.stdout, b"posix write gates\n");
1609    assert_eq!(fs::read(denied.path().join("data")).unwrap(), b"abcdef");
1610    assert!(!denied.path().join("copy").exists());
1611}
1612
1613#[cfg(unix)]
1614#[test]
1615fn posix_handle_ops_set_mode_owner_times_and_serialize_locks() {
1616    let (dir, output) = run(
1617        r#"
1618        import {open,pledge,errors} from 'avril:std';
1619        const file = await open('data', { read: true, write: true });
1620        await file.fchmod(0o600);
1621        if (((await file.stat()).mode & 0o7777) !== 0o600) throw Error('Fchmod mode');
1622        await file.futime(new Date(1500000000000), 1500000000);
1623        const stamped = await file.stat();
1624        if (stamped.mtime.getTime() !== 1500000000000 || stamped.atime.getTime() !== 1500000000000) throw Error('Futime times');
1625        await file.fchown(null, stamped.gid);
1626        try { await file.fchown(null, null); throw Error('Chowned nothing'); } catch (e) { if (!(e instanceof errors.InvalidInput)) throw e; }
1627        await file.flock(true);
1628        await file.funlock();
1629        await file.flock(false);
1630        await file.funlock();
1631        const other = await open('data', { read: true, write: true });
1632        await file.flock(true);
1633        let released = false;
1634        const gated = other.flock(true).then(() => { released = true; });
1635        await new Promise((resolve) => setTimeout(resolve, 300));
1636        if (released) throw Error('Exclusive lock did not block');
1637        await file.funlock();
1638        let watchdog; await Promise.race([gated, new Promise((_, reject) => { watchdog = setTimeout(() => reject(Error('Unlock did not release waiter')), 2000); })]); clearTimeout(watchdog);
1639        await other.funlock();
1640        const reader = await open('data');
1641        for (const call of [()=>reader.fchmod(0o644), ()=>reader.fchown(null, 0), ()=>reader.futime(0, 0), ()=>reader.flock(true)]) {
1642            try { await call(); throw Error('Wrote through reader'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; }
1643        }
1644        await reader.flock(false);
1645        await reader.funlock();
1646        reader.close();
1647        file.close();
1648        other.close();
1649        for (const call of [()=>file.fchmod(0o644), ()=>file.flock(false), ()=>file.funlock()]) {
1650            try { await call(); throw Error('Used closed handle'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; }
1651        }
1652        const live = await open('data', { read: true, write: true });
1653        pledge({ write: false });
1654        for (const call of [()=>live.fchmod(0o600), ()=>live.fchown(null, 0), ()=>live.futime(0, 0), ()=>live.flock(true)]) {
1655            try { await call(); throw Error('Retained write'); } catch (e) { if (!(e instanceof errors.PermissionDenied)) throw e; }
1656        }
1657        await live.funlock();
1658        live.close();
1659        console.log('posix handles');
1660    "#,
1661        &[],
1662    );
1663    assert_eq!(output.stdout, b"posix handles\n");
1664    use std::os::unix::fs::PermissionsExt;
1665    assert_eq!(
1666        fs::metadata(dir.path().join("data"))
1667            .unwrap()
1668            .permissions()
1669            .mode()
1670            & 0o777,
1671        0o600
1672    );
1673}
1674
1675#[cfg(unix)]
1676#[test]
1677fn posix_link_never_launders_symlink_sources() {
1678    let (dir, output) = run(
1679        r#"
1680        import {open,symlink,link,pledge,errors} from 'avril:std';
1681        const secret = await open('secret', { write: true, create: true });
1682        await secret.write(new Uint8Array([42]));
1683        secret.close();
1684        await symlink('secret', 'plug');
1685        pledge({ read: { deny: ['secret'] } });
1686        try {
1687            await link('plug', 'copy');
1688            throw Error('Laundered unreadable target');
1689        } catch (e) {
1690            if (!(e instanceof errors.InvalidInput) || e.message !== 'Source is a symbolic link') throw e;
1691        }
1692        console.log('posix link refusal');
1693    "#,
1694        &[],
1695    );
1696    assert_eq!(output.stdout, b"posix link refusal\n");
1697    assert_eq!(fs::read(dir.path().join("secret")).unwrap(), [42]);
1698    assert!(!dir.path().join("copy").exists());
1699}
1700
1701#[cfg(unix)]
1702#[test]
1703fn posix_saturated_ids_reject_before_lookup() {
1704    let (_dir, output) = run(
1705        r#"
1706        import {open,chown,lchown,errors} from 'avril:std';
1707        const file = await open('data', { read: true, write: true });
1708        for (const call of [
1709            () => chown('data', 4294967295, null),
1710            () => chown('data', null, 4294967295),
1711            () => chown('missing', 4294967295, 4294967295),
1712            () => lchown('data', 4294967295, null),
1713            () => file.fchown(4294967295, null),
1714            () => file.fchown(null, 4294967295),
1715        ]) {
1716            try { await call(); throw Error('Accepted saturated id'); } catch (e) { if (!(e instanceof RangeError)) throw e; }
1717        }
1718        file.close();
1719        console.log('posix id bounds');
1720    "#,
1721        &[],
1722    );
1723    assert_eq!(output.stdout, b"posix id bounds\n");
1724}
1725
1726#[cfg(unix)]
1727#[test]
1728fn posix_denied_mutations_leave_modes_and_times_untouched() {
1729    let (dir, output) = run(
1730        r#"
1731        import {chmod,chown,lchown,utime,lutime,link,truncate,stat,pledge,errors} from 'avril:std';
1732        await chmod('data', 0o640);
1733        await utime('data', 1000000000, 1000000000);
1734        pledge({ write: false });
1735        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)]) {
1736            try { await call(); throw Error('Mutated after pledge'); } catch (e) { if (!(e instanceof errors.PermissionDenied)) throw e; }
1737        }
1738        const after = await stat('data');
1739        if ((after.mode & 0o7777) !== 0o640 || after.mtime.getTime() !== 1000000000000 || after.atime.getTime() !== 1000000000000 || after.size !== 6) throw Error('Denied mutation leaked');
1740        console.log('posix denials keep state');
1741    "#,
1742        &[],
1743    );
1744    assert_eq!(output.stdout, b"posix denials keep state\n");
1745    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef");
1746    assert!(!dir.path().join("copy").exists());
1747}
1748
1749#[cfg(unix)]
1750#[test]
1751fn posix_truncate_follows_links_rejects_dirs_and_dangling() {
1752    let (dir, output) = run(
1753        r#"
1754        import {symlink,truncate,stat,mkdir,errors} from 'avril:std';
1755        await symlink('data', 'alias');
1756        await truncate('alias', 2);
1757        if ((await stat('data')).size !== 2) throw Error('Truncate did not follow link');
1758        await symlink('missing', 'dangling');
1759        try { await truncate('dangling', 0); throw Error('Truncated dangling link'); } catch (e) { if (!(e instanceof errors.NotFound)) throw e; }
1760        await mkdir('sub');
1761        try { await truncate('sub', 0); throw Error('Truncated directory'); } catch (e) { if (!(e instanceof errors.IsADirectory)) throw e; }
1762        await symlink('loop', 'loop');
1763        try { await truncate('loop/x', 0); throw Error('Truncated looped path'); } catch (e) {
1764            if (!(e instanceof errors.NotFound) || e.message !== 'Symbolic link changed or has a missing target') throw e;
1765        }
1766        console.log('posix truncate targets');
1767    "#,
1768        &[],
1769    );
1770    assert_eq!(output.stdout, b"posix truncate targets\n");
1771    assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"ab");
1772}
1773
1774#[cfg(unix)]
1775#[test]
1776fn posix_timestamps_span_seconds_and_reject_garbage() {
1777    let (_dir, output) = run(
1778        r#"
1779        import {open,utime,stat,errors} from 'avril:std';
1780        await utime('data', -0.5, 1000000000.5);
1781        const split = await stat('data');
1782        if (split.atime.getTime() !== -500 || split.mtime.getTime() !== 1000000000500) throw Error('Fractional seconds');
1783        await utime('data', 8640000000000, -62135596800);
1784        const bounds = await stat('data');
1785        if (bounds.atime.getTime() !== 8640000000000000 || bounds.mtime.getTime() !== -62135596800000) throw Error('Second bounds');
1786        await utime('data', new Date(1500000000123), 0);
1787        if ((await stat('data')).atime.getTime() !== 1500000000123) throw Error('Date split');
1788        for (const [call, C] of [
1789            [() => utime('data', 8640000000001, 0), RangeError],
1790            [() => utime('data', 8640000000000.6, 0), RangeError],
1791            [() => utime('data', -8640000000001, 0), RangeError],
1792            [() => utime('data', Infinity, 0), TypeError],
1793            [() => utime('data', 0, new Date(NaN)), TypeError],
1794        ]) {
1795            try { await call(); throw Error('Accepted invalid timestamp'); } catch (e) { if (!(e instanceof C)) throw e; }
1796        }
1797        const file = await open('data', { read: true, write: true });
1798        await file.futime(0.25, -1.75);
1799        const stamped = await file.stat();
1800        if (stamped.atime.getTime() !== 250 || stamped.mtime.getTime() !== -1750) throw Error('Handle fractions');
1801        file.close();
1802        console.log('posix timestamps');
1803    "#,
1804        &[],
1805    );
1806    assert_eq!(output.stdout, b"posix timestamps\n");
1807}
1808
1809#[cfg(unix)]
1810#[test]
1811fn posix_flock_releases_waiters_and_gates_by_mode() {
1812    let (_dir, output) = run(
1813        r#"
1814        import {open,errors} from 'avril:std';
1815        const file = await open('data', { read: true, write: true });
1816        const other = await open('data', { read: true, write: true });
1817        await file.flock(true);
1818        let released = false;
1819        const gated = other.flock(false).then(() => { released = true; });
1820        await new Promise((resolve) => setTimeout(resolve, 300));
1821        if (released) throw Error('Shared lock slipped past exclusive');
1822        await file.funlock();
1823        let watchdog;
1824        await Promise.race([gated, new Promise((_, reject) => { watchdog = setTimeout(() => reject(Error('Unlock did not release waiter')), 2000); })]);
1825        clearTimeout(watchdog);
1826        await other.funlock();
1827        await file.flock(true);
1828        const pending = other.flock(true);
1829        if ((await other.stat()).size !== 6) throw Error('Queue stalled behind lock');
1830        other.close();
1831        try { await pending; throw Error('Locked past close'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; }
1832        await file.funlock();
1833        file.close();
1834        const reader = await open('data');
1835        const writer = await open('data', { write: true });
1836        for (const call of [() => writer.flock(false), () => reader.flock(true)]) {
1837            try { await call(); throw Error('Locked past handle mode'); } catch (e) { if (!(e instanceof errors.BadResource)) throw e; }
1838        }
1839        await reader.flock(false);
1840        await reader.funlock();
1841        await writer.flock(true);
1842        await writer.funlock();
1843        reader.close();
1844        writer.close();
1845        console.log('posix lock matrix');
1846    "#,
1847        &[],
1848    );
1849    assert_eq!(output.stdout, b"posix lock matrix\n");
1850}
1851
1852#[cfg(unix)]
1853#[test]
1854fn posix_chmod_ignores_access_bits_and_follows_links() {
1855    let (dir, output) = run(
1856        r#"
1857        import {chmod,stat,symlink,mkdir,errors} from 'avril:std';
1858        await chmod('data', 0o200);
1859        await chmod('data', 0o644);
1860        if (((await stat('data')).mode & 0o7777) !== 0o644) throw Error('Chmod write-only file');
1861        await chmod('data', 0);
1862        await chmod('data', 0o644);
1863        if (((await stat('data')).mode & 0o7777) !== 0o644) throw Error('Chmod unreadable file');
1864        await mkdir('lockeddir');
1865        await chmod('lockeddir', 0);
1866        await chmod('lockeddir', 0o755);
1867        if (!((await stat('lockeddir')).isDirectory)) throw Error('Chmod locked directory');
1868        await symlink('data', 'alias');
1869        await chmod('alias', 0o600);
1870        if (((await stat('data')).mode & 0o7777) !== 0o600) throw Error('Chmod did not follow link');
1871        await symlink('missing', 'dangling');
1872        try { await chmod('dangling', 0o644); throw Error('Chmod dangling link'); } catch (e) {
1873            if (!(e instanceof errors.NotFound)) throw e;
1874        }
1875        await symlink('loop', 'loop');
1876        try { await chmod('loop/x', 0o644); throw Error('Chmod looped path'); } catch (e) {
1877            if (!(e instanceof errors.NotFound) || e.message !== 'Symbolic link changed or has a missing target') throw e;
1878        }
1879        console.log('posix chmod reach');
1880    "#,
1881        &[],
1882    );
1883    assert_eq!(output.stdout, b"posix chmod reach\n");
1884    use std::os::unix::fs::PermissionsExt;
1885    assert_eq!(
1886        fs::metadata(dir.path().join("data"))
1887            .unwrap()
1888            .permissions()
1889            .mode()
1890            & 0o7777,
1891        0o600
1892    );
1893}
1894
1895#[cfg(unix)]
1896#[test]
1897fn posix_link_needs_write_access_to_its_source() {
1898    let dir = tempfile::tempdir().unwrap();
1899    fs::write(dir.path().join("ro"), b"read-only").unwrap();
1900    fs::create_dir(dir.path().join("out")).unwrap();
1901    fs::write(
1902        dir.path().join("main.js"),
1903        r#"
1904        import {link,errors} from 'avril:std';
1905        try { await link('ro', 'out/alias'); throw Error('Linked without source write'); } catch (e) {
1906            if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ro') throw e;
1907        }
1908        console.log('posix link source gate');
1909    "#,
1910    )
1911    .unwrap();
1912    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1913        .arg("run")
1914        .current_dir(dir.path())
1915        .args(["-0", "--allow-read=.", "--allow-write=out/", "main.js"])
1916        .stdin(Stdio::null())
1917        .stdout(Stdio::piped())
1918        .stderr(Stdio::piped())
1919        .spawn()
1920        .unwrap();
1921    let output = common::wait(child);
1922    assert!(
1923        output.status.success(),
1924        "{}",
1925        String::from_utf8_lossy(&output.stderr)
1926    );
1927    assert_eq!(output.stdout, b"posix link source gate\n");
1928    assert_eq!(fs::read(dir.path().join("ro")).unwrap(), b"read-only");
1929    assert!(!dir.path().join("out/alias").exists());
1930}
1931
1932#[cfg(unix)]
1933#[test]
1934fn posix_denied_path_ops_disclose_neither_targets_nor_resolution_errors() {
1935    let dir = tempfile::tempdir().unwrap();
1936    fs::create_dir(dir.path().join("secret")).unwrap();
1937    fs::write(dir.path().join("secret/file"), b"private").unwrap();
1938    std::os::unix::fs::symlink("secret/file", dir.path().join("alias")).unwrap();
1939    std::os::unix::fs::symlink("loop", dir.path().join("loop")).unwrap();
1940    std::os::unix::fs::symlink("ungranted", dir.path().join("dangling")).unwrap();
1941    fs::write(
1942        dir.path().join("main.js"),
1943        r#"
1944        import {chmod,chown,lchown,utime,lutime,link,truncate,errors} from 'avril:std';
1945        for (const name of ['alias','secret/file/x','secret/missing','loop','missing/../secret/file','dangling']) {
1946            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)]) {
1947                try { await call(); throw Error('Granted write ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ' + name) throw e; }
1948            }
1949            try { await link(name, 'elsewhere'); throw Error('Granted read ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'read ' + name) throw e; }
1950        }
1951        console.log('no posix oracle');
1952    "#,
1953    )
1954    .unwrap();
1955    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
1956        .arg("run")
1957        .current_dir(dir.path())
1958        .args(["-0", "main.js"])
1959        .stdin(Stdio::null())
1960        .stdout(Stdio::piped())
1961        .stderr(Stdio::piped())
1962        .spawn()
1963        .unwrap();
1964    let output = common::wait(child);
1965    assert!(
1966        output.status.success(),
1967        "{}",
1968        String::from_utf8_lossy(&output.stderr)
1969    );
1970    assert_eq!(output.stdout, b"no posix oracle\n");
1971    assert_eq!(
1972        fs::read(dir.path().join("secret/file")).unwrap(),
1973        b"private"
1974    );
1975    assert!(!dir.path().join("elsewhere").exists());
1976}
1977
1978#[cfg(unix)]
1979#[test]
1980fn posix_huge_mtimes_read_as_invalid_dates_without_panicking() {
1981    let dir = tempfile::tempdir().unwrap();
1982    fs::write(dir.path().join("huge"), b"huge").unwrap();
1983    fs::File::options()
1984        .write(true)
1985        .open(dir.path().join("huge"))
1986        .unwrap()
1987        .set_modified(std::time::UNIX_EPOCH + std::time::Duration::new(20_000_000_000_000_000, 0))
1988        .unwrap();
1989    fs::write(
1990        dir.path().join("main.js"),
1991        r#"
1992        import {stat,open} from 'avril:std';
1993        const stamped = await stat('huge');
1994        if (stamped.mtime.getTime() === stamped.mtime.getTime()) throw Error('Huge mtime looked valid');
1995        const file = await open('huge');
1996        await file.stat();
1997        file.close();
1998        console.log('huge mtime safe');
1999    "#,
2000    )
2001    .unwrap();
2002    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
2003        .arg("run")
2004        .current_dir(dir.path())
2005        .arg("main.js")
2006        .stdin(Stdio::null())
2007        .stdout(Stdio::piped())
2008        .stderr(Stdio::piped())
2009        .spawn()
2010        .unwrap();
2011    let output = common::wait(child);
2012    assert!(
2013        output.status.success(),
2014        "{}",
2015        String::from_utf8_lossy(&output.stderr)
2016    );
2017    assert_eq!(output.stdout, b"huge mtime safe\n");
2018}
2019
2020#[cfg(unix)]
2021#[test]
2022fn posix_granted_dir_probes_disclose_no_escapes() {
2023    let dir = tempfile::tempdir().unwrap();
2024    fs::write(dir.path().join("secret"), b"private").unwrap();
2025    fs::create_dir(dir.path().join("out")).unwrap();
2026    std::os::unix::fs::symlink("../secret", dir.path().join("out/a")).unwrap();
2027    let before = fs::metadata(dir.path().join("secret")).unwrap();
2028    fs::write(
2029        dir.path().join("main.js"),
2030        r#"
2031        import {chmod,chown,lchown,utime,lutime,link,truncate,stat,errors} from 'avril:std';
2032        for (const name of ['out/a', 'out/../secret', 'out/a/x']) {
2033            for (const call of [()=>chmod(name, 0o644), ()=>chown(name, null, 0), ()=>utime(name, 0, 0), ()=>truncate(name, 0)]) {
2034                try { await call(); throw Error('Granted escape ' + name); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write ' + name) throw e; }
2035            }
2036        }
2037        // Link-acting ops stay on the granted link itself.
2038        const { gid } = await stat('out');
2039        await lchown('out/a', null, gid);
2040        await lutime('out/a', 0, 0);
2041        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; }
2042        try { await link('out/a', 'elsewhere'); throw Error('Granted write elsewhere'); } catch (e) { if (!(e instanceof errors.PermissionDenied) || e.message !== 'write elsewhere') throw e; }
2043        console.log('no granted-dir oracle');
2044    "#,
2045    )
2046    .unwrap();
2047    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
2048        .arg("run")
2049        .current_dir(dir.path())
2050        .args(["-0", "--allow-read=out", "--allow-write=out", "main.js"])
2051        .stdin(Stdio::null())
2052        .stdout(Stdio::piped())
2053        .stderr(Stdio::piped())
2054        .spawn()
2055        .unwrap();
2056    let output = common::wait(child);
2057    assert!(
2058        output.status.success(),
2059        "{}",
2060        String::from_utf8_lossy(&output.stderr)
2061    );
2062    assert_eq!(output.stdout, b"no granted-dir oracle\n");
2063    assert_eq!(fs::read(dir.path().join("secret")).unwrap(), b"private");
2064    let after = fs::metadata(dir.path().join("secret")).unwrap();
2065    assert_eq!(after.modified().unwrap(), before.modified().unwrap());
2066    assert!(!dir.path().join("elsewhere").exists());
2067}
2068
2069#[test]
2070fn direct_writev_writes_ordered_vectors_and_skips_empty_views() {
2071    let (dir, output) = run(
2072        r#"
2073        import {create,open,SeekMode,errors} from 'avril:std';
2074        const file=await create('out');
2075        const full=new Uint8Array([0,1,2,3,4,5,6,7,8,9]);
2076        if(await file.writev([full.subarray(0,3),new Uint8Array(0),full.subarray(3,7),full.subarray(7)])!==10)throw Error('Count');
2077        if(await file.seek(0,SeekMode.Start)!==0)throw Error('Seek');
2078        if(await file.write(new Uint8Array([9]))!==1)throw Error('Write');
2079        if(await file.writev([new Uint8Array([8]),new Uint8Array([7,6])])!==3)throw Error('Vector');
2080        if(await file.writev([])!==0||await file.writev([new Uint8Array(0),new Uint8Array(0)])!==0)throw Error('Empty');
2081        if(await file.seek(0,SeekMode.Current)!==4)throw Error('Cursor');
2082        file.close();
2083        const shared=new Uint8Array(new SharedArrayBuffer(2));shared.set([11,12]);
2084        const resizable=new ArrayBuffer(4,{maxByteLength:8}),tracked=new Uint8Array(resizable,1,2);tracked.set([13,14]);
2085        const extra=await create('extra');
2086        if(await extra.writev([shared,tracked])!==4)throw Error('Shared');
2087        extra.close();
2088        const check=await open('out'),back=new Uint8Array(10);
2089        let offset=0,n;
2090        while(offset<10&&(n=await check.read(back.subarray(offset)))!==null)offset+=n;
2091        if(offset!==10||back.join()!=='9,8,7,6,4,5,6,7,8,9')throw Error('Order '+back.join());
2092        check.close();
2093        const reread=await open('extra'),again=new Uint8Array(4);
2094        offset=0;
2095        while(offset<4&&(n=await reread.read(again.subarray(offset)))!==null)offset+=n;
2096        if(offset!==4||again.join()!=='11,12,13,14')throw Error('Buffers '+again.join());
2097        reread.close();
2098        try{await file.writev([new Uint8Array([1])]);throw Error('Closed');}catch(e){if(!(e instanceof errors.BadResource))throw e;}
2099        console.log('vectors');
2100    "#,
2101        &[],
2102    );
2103    assert_eq!(output.stdout, b"vectors\n");
2104    assert_eq!(
2105        fs::read(dir.path().join("out")).unwrap(),
2106        [9, 8, 7, 6, 4, 5, 6, 7, 8, 9]
2107    );
2108    assert_eq!(
2109        fs::read(dir.path().join("extra")).unwrap(),
2110        [11, 12, 13, 14]
2111    );
2112}
2113
2114#[test]
2115fn direct_writev_completes_large_vectors_and_snapshots_queued_bytes() {
2116    let (dir, output) = run(
2117        r#"
2118        import {create,open} from 'avril:std';
2119        const big=await create('big');
2120        const segs=[new Uint8Array(0)];
2121        for(let i=0;i<20;i++){const view=new Uint8Array(5000);view.fill(i+1);segs.push(view,new Uint8Array(0));}
2122        const writing=big.writev(segs);
2123        for(const view of segs)view.fill(99);
2124        segs[segs.length-2]=new Uint8Array([0]);
2125        if(await writing!==100000)throw Error('Total');
2126        big.close();
2127        const check=await open('big'),back=new Uint8Array(100000);
2128        let offset=0,n;
2129        while(offset<100000&&(n=await check.read(back.subarray(offset)))!==null)offset+=n;
2130        if(offset!==100000)throw Error('Length');
2131        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));
2132        check.close();
2133        const many=await create('many'),bufs=[];
2134        for(let i=0;i<3000;i++)bufs.push(new Uint8Array([i&255]));
2135        if(await many.writev(bufs)!==3000)throw Error('Small total');
2136        many.close();
2137        const reread=await open('many'),again=new Uint8Array(3000);
2138        offset=0;
2139        while(offset<3000&&(n=await reread.read(again.subarray(offset)))!==null)offset+=n;
2140        if(offset!==3000)throw Error('Small length');
2141        for(let i=0;i<3000;i++)if(again[i]!==(i&255))throw Error('Small order '+i);
2142        reread.close();
2143        const snap=await create('snap');
2144        const first=new Uint8Array(100000);first.fill(7);
2145        const one=snap.write(first),parts=[new Uint8Array([1,2]),new Uint8Array([3])];
2146        const two=snap.writev(parts);
2147        parts[0].fill(9);parts[1]=new Uint8Array([4]);parts.push(new Uint8Array([0]));
2148        const n1=await one;
2149        if(!(n1>0&&n1<=65536))throw Error('Queued count');
2150        if(await two!==3)throw Error('Vector total');
2151        snap.close();
2152        const tail=await open('snap'),end=new Uint8Array(n1+3);
2153        offset=0;
2154        while(offset<n1+3&&(n=await tail.read(end.subarray(offset)))!==null)offset+=n;
2155        if(offset!==n1+3)throw Error('Queued length');
2156        if(end[n1]!==1||end[n1+1]!==2||end[n1+2]!==3)throw Error('Snapshot');
2157        tail.close();
2158        console.log('prefixes');
2159    "#,
2160        &[],
2161    );
2162    assert_eq!(output.stdout, b"prefixes\n");
2163    assert_eq!(fs::read(dir.path().join("many")).unwrap().len(), 3000);
2164}
2165
2166#[test]
2167fn direct_writev_validates_vectors_before_writing_and_honors_close() {
2168    let (dir, output) = run(
2169        r#"
2170        import {create,open,SeekMode,errors} from 'avril:std';
2171        const file=await create('valid');
2172        async function rejects(vector){
2173            try{await file.writev(vector);}
2174            catch(e){if(e instanceof TypeError)return;throw e;}
2175            throw Error('Accepted vector');
2176        }
2177        for(const bad of ['bytes',42,null,undefined,{},new Uint8Array([1]),new Set([new Uint8Array([1])]),(function*(){yield new Uint8Array([1]);})()])await rejects(bad);
2178        for(const bad of [[new Uint8Array([1]),'x'],[[1,2]],[new DataView(new ArrayBuffer(1))],[new Uint16Array([1])],[new Uint8Array([1]),undefined],[null]])await rejects(bad);
2179        const detached=new Uint8Array([1,2]);
2180        structuredClone(detached.buffer,{transfer:[detached.buffer]});
2181        await rejects([detached]);
2182        const resizable=new ArrayBuffer(4,{maxByteLength:8}),outOfBounds=new Uint8Array(resizable,1,2);
2183        resizable.resize(0);
2184        await rejects([outOfBounds]);
2185        await rejects([new Uint8Array([5,5]),'x']);
2186        const wide=[];for(let i=0;i<20;i++)wide.push(new Uint8Array(5000));wide.push('x');
2187        await rejects(wide);
2188        for(const size of [4294967296,-1,NaN,Infinity,1.5,'1']){
2189            try{await file.writev(new Proxy([],{get:(t,p)=>p==='length'?size:new Uint8Array(0)}));throw Error('Length');}
2190            catch(e){if(!(e instanceof RangeError))throw e;}
2191        }
2192        const readOnly=await open('valid');
2193        try{await readOnly.writev([]);throw Error('Read-only');}
2194        catch(e){if(!(e instanceof errors.BadResource))throw e;}
2195        readOnly.close();
2196        if(await file.seek(0,SeekMode.Current)!==0)throw Error('Side effect');
2197        const check=await open('valid');
2198        if(await check.read(new Uint8Array(1))!==null)throw Error('Partial write');
2199        check.close();
2200        const ok=new Uint8Array([1,2]),closedVector=[new Uint8Array([1])];
2201        globalThis.Uint8Array=()=>{throw Error('Mutable intrinsic');};
2202        Array.prototype.push=()=>{throw Error('Mutable intrinsic');};
2203        if(await file.writev([ok])!==2)throw Error('Captured');
2204        file.close();
2205        for(const call of [()=>file.writev(closedVector),()=>file.writevSync(closedVector)]){
2206            try{await call();throw Error('Closed');}catch(e){if(!(e instanceof errors.BadResource))throw e;}
2207        }
2208        console.log('validated');
2209    "#,
2210        &[],
2211    );
2212    assert_eq!(output.stdout, b"validated\n");
2213    assert_eq!(fs::read(dir.path().join("valid")).unwrap(), [1, 2]);
2214}
2215
2216#[test]
2217fn direct_writev_holds_one_queue_position_against_sync_and_async() {
2218    let (dir, output) = run(
2219        r#"
2220        import {create,open,SeekMode} from 'avril:std';
2221        const file=await create('ord');
2222        const big=[];for(let i=0;i<20;i++){const view=new Uint8Array(5000);view.fill(i+1);big.push(view);}
2223        const pending=file.writev(big);
2224        if(file.seekSync(0,SeekMode.Start)!==0)throw Error('SeekSync waited');
2225        if(file.writevSync([new Uint8Array([201,202])])!==2)throw Error('Sync vector');
2226        if(await pending!==100000)throw Error('Total');
2227        file.close();
2228        const check=await open('ord'),back=new Uint8Array(100000);
2229        let offset=0,n;
2230        while(offset<100000&&(n=await check.read(back.subarray(offset)))!==null)offset+=n;
2231        if(offset!==100000)throw Error('Length');
2232        if(back[0]!==201||back[1]!==202)throw Error('Sync order');
2233        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);}
2234        check.close();
2235        const second=await create('ord2');
2236        const first=second.writev(big);
2237        if(await second.write(new Uint8Array([7,7,7]))!==3)throw Error('Write');
2238        if(await first!==100000)throw Error('First');
2239        second.close();
2240        const tail=await open('ord2'),end=new Uint8Array(100003);
2241        offset=0;
2242        while(offset<100003&&(n=await tail.read(end.subarray(offset)))!==null)offset+=n;
2243        if(offset!==100003)throw Error('Second length');
2244        if(end[100000]!==7||end[100001]!==7||end[100002]!==7)throw Error('Async order');
2245        tail.close();
2246        console.log('ordered');
2247    "#,
2248        &[],
2249    );
2250    assert_eq!(output.stdout, b"ordered\n");
2251    assert_eq!(fs::read(dir.path().join("ord2")).unwrap().len(), 100003);
2252}