mod common; use std::fs; use std::process::{Command, Stdio}; fn run( source: &str, flags: &[&str], fixtures: &[(&str, &str)], ) -> (tempfile::TempDir, std::process::Output) { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("main.js"), source).unwrap(); fs::write(dir.path().join("data"), b"abcdef").unwrap(); for (name, contents) in fixtures { let path = dir.path().join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, contents).unwrap(); } let child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .args(flags) .arg("main.js") .current_dir(dir.path()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); (dir, output) } #[test] fn mixed_operations_share_handles_and_cursor_without_running_js() { let (dir, output) = run( r#" import { open as openAsync, FileHandle } from 'avril:std'; import { open, create, SeekMode, stat, FileHandle as SyncHandle } from 'avril:std/sync'; import { readTextFile } from 'avril:fs/sync'; if (FileHandle !== SyncHandle) throw Error('Separate handle families'); const file = open('data', {read: true, write: true}); if (!(file instanceof FileHandle)) throw Error('Wrong handle type'); const a = new Uint8Array(2), b = new Uint8Array(2); let ran = false; queueMicrotask(() => ran = true); const first = file.read(a); if (file.readSync(b) !== 2 || new TextDecoder().decode(b) !== 'cd') throw Error('Read order'); if (ran) throw Error('Sync read ran JavaScript'); const seek = file.seek(0, SeekMode.Start); if (file.writeSync(new Uint8Array([88])) !== 1) throw Error('Write'); if (file.seekSync(-1, SeekMode.End) !== 5 || file.readSync(b) !== 1 || b[0] !== 102) throw Error('Seek'); if (file.readSync(b) !== null || file.readSync(new Uint8Array()) !== 0) throw Error('EOF'); if (await first !== 2 || new TextDecoder().decode(a) !== 'ab' || await seek !== 0) throw Error('Async completion'); file.truncateSync(3); file.syncDataSync(); file.syncSync(); if (file.statSync().size !== 3 || stat(new URL('./data', import.meta.url)).size !== 3) throw Error('Stat'); file.close(); const reopened = await openAsync('data'); if (reopened.readSync(b) !== 2 || b[0] !== 88) throw Error('Async-opened handle'); reopened[Symbol.dispose](); const out = create('out'); const writes = []; for (let i = 0; i < 100; i++) writes.push(out.write(new Uint8Array([65 + i % 26]))); out.seekSync(0, 0); const all = new Uint8Array(100); if (out.readSync(all) !== 100 || all.some((v, i) => v !== 65 + i % 26)) throw Error('Queued writes'); if ((await Promise.all(writes)).some(n => n !== 1)) throw Error('Write completions'); out.close(); if (readTextFile('data') !== 'Xbc') throw Error('Persisted contents'); console.log('mixed IO'); "#, &[], &[], ); assert_eq!(output.stdout, b"mixed IO\n"); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"Xbc"); } #[test] fn synchronous_paths_and_workflows_preserve_contents() { let (dir, _) = run( r#" import { mkdir, readDir, copyFile, rename, remove, stat, lstat, realPath, truncate } from 'avril:std/sync'; import { mkdirs, copy, move, exists, readFile, readTextFile, writeFile, writeTextFile } from 'avril:fs/sync'; mkdir('tree'); mkdirs('tree/deep/nested'); mkdirs('tree/deep/nested'); const bytes = new Uint8Array(200003); for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251; writeFile('tree/deep/nested/bytes', bytes); const actual = readFile(new URL('./tree/deep/nested/bytes', import.meta.url)); if (actual.length !== bytes.length || actual.some((v, i) => v !== bytes[i])) throw Error('Partial transfer'); writeTextFile('tree/text', 'héllo 🌷'); writeTextFile('tree/text', '!', {append: true}); writeFile('empty', new Uint8Array()); if (readFile('empty').length !== 0 || readTextFile('tree/text') !== 'héllo 🌷!') throw Error('Text/empty'); const entries = [...readDir('tree')].sort((a, b) => a.name.localeCompare(b.name)); if (entries.length !== 2 || entries[0].name !== 'deep' || !entries[0].isDirectory || !entries[1].isFile) throw Error('Directory entries'); copy('tree', 'copies/tree'); move('copies/tree', 'moved'); if (exists('copies/tree') || !exists('moved/deep/nested/bytes')) throw Error('Move'); copyFile('moved/text', 'copied'); rename('copied', 'renamed'); truncate('renamed', 2); if (stat('renamed').size !== 2 || !lstat('renamed').isFile) throw Error('Path metadata'); if (!realPath('renamed').endsWith('renamed') || exists('missing')) throw Error('Resolution'); remove('tree', {recursive: true}); remove('empty'); if (exists('tree')) throw Error('Remove'); "#, &[], &[], ); assert_eq!(fs::read(dir.path().join("renamed")).unwrap(), b"h\xc3"); assert_eq!( fs::read_to_string(dir.path().join("moved/text")).unwrap(), "héllo 🌷!" ); assert_eq!( fs::metadata(dir.path().join("moved/deep/nested/bytes")) .unwrap() .len(), 200003 ); } #[test] fn synchronous_temp_paths_match_async_behavior() { run( r#" import { makeTempDir, makeTempFile, exists } from 'avril:fs/sync'; import { stat, remove, errors } from 'avril:std/sync'; const plain = makeTempDir(); if (!/^[0-9a-f]{8,16}$/.test(plain.split('/').pop())) throw Error('Hex name'); const named = makeTempDir({ prefix: 'sync-', suffix: '.d' }); if (!named.split('/').pop().startsWith('sync-') || !named.endsWith('.d')) throw Error('Affixes'); const file = makeTempFile({ dir: plain }); if (!file.startsWith(plain + '/') || stat(file).size !== 0 || !stat(file).isFile) throw Error('Temp file'); const seen = new Set(); for (let i = 0; i < 10; i++) { const candidate = makeTempFile({ dir: plain }); if (seen.has(candidate)) throw Error('Collision'); seen.add(candidate); } try { makeTempDir({ suffix: 'a|b' }); throw Error('Accepted characters'); } catch (e) { if (!(e instanceof errors.InvalidInput)) throw e; } try { makeTempFile({ dir: plain + '/missing' }); throw Error('Accepted parents'); } catch (e) { if (!(e instanceof errors.NotFound)) throw e; } const dirInfo = stat(named); if (!dirInfo.isDirectory) throw Error('Directory kind'); if (dirInfo.mode !== null && (dirInfo.mode & 0o777) !== 0o700) throw Error('Directory mode'); remove(plain, { recursive: true }); remove(named, { recursive: true }); if (exists(plain) || exists(named)) throw Error('Cleanup'); "#, &[], &[], ); } #[test] fn failures_throw_synchronously_and_handles_keep_their_brand_and_lifecycle() { run( r#" import { open, create, stat, mkdir, remove, errors, FileHandle } from 'avril:std/sync'; import { readFile, writeFile, writeTextFile, mkdirs, copy, exists } from 'avril:fs/sync'; function fails(call, C) { try { call(); } catch (e) { if (e instanceof C) return; throw e; } throw Error('Expected synchronous ' + C.name); } fails(() => open('missing'), errors.NotFound); fails(() => open(), TypeError); fails(() => open('data', {}), errors.InvalidInput); fails(() => open('data', {read: true, truncate: true}), errors.InvalidInput); fails(() => open('data', {read: 'yes'}), TypeError); fails(() => open('data', {write: true, mode: 8192}), RangeError); fails(() => open(new URL('https://example.test')), errors.InvalidInput); fails(() => open('.'), errors.NotSupported); fails(() => stat(''), errors.NotFound); fails(() => mkdir('directory', {mode: -1}), RangeError); fails(() => mkdirs('directory', {mode: -1}), RangeError); fails(() => remove('data', {recursive: 1}), TypeError); fails(() => copy('data', 'copy', {dereference: 1}), TypeError); fails(() => writeTextFile('new', 123), TypeError); fails(() => writeFile('new', new Uint8Array(), {create: false}), errors.NotFound); fails(() => writeFile('data', new Uint8Array(), {createNew: true}), errors.AlreadyExists); fails(() => readFile(), TypeError); if (exists('new') || exists('directory') || exists('copy')) throw Error('Validation had side effects'); const file = open('data'); fails(() => file.writeSync(new Uint8Array([1])), errors.BadResource); fails(() => file.readSync(new DataView(new ArrayBuffer(1))), TypeError); fails(() => file.seekSync(2 ** 53, 0), RangeError); fails(() => FileHandle.prototype.readSync.call({}, new Uint8Array(1)), TypeError); fails(() => structuredClone(file), DOMException); const detached = new Uint8Array(1); structuredClone(detached.buffer, {transfer: [detached.buffer]}); fails(() => file.readSync(detached), TypeError); const read = FileHandle.prototype.readSync, close = FileHandle.prototype.close; Object.setPrototypeOf(file, null); if (read.call(file, new Uint8Array(1)) !== 1) throw Error('Private brand'); close.call(file); fails(() => read.call(file, new Uint8Array(1)), errors.BadResource); const out = create('out'); const shared = new Uint8Array(new SharedArrayBuffer(3)); shared.set([1, 2, 3]); if (out.writeSync(shared) !== 3) throw Error('Shared buffer'); out[Symbol.dispose](); out[Symbol.dispose](); fails(() => out.statSync(), errors.BadResource); fails(() => out.close(), errors.BadResource); "#, &[], &[], ); } #[test] fn synchronous_io_obeys_scoped_and_live_capabilities() { let (dir, _) = run( r#" import { pledge } from 'avril:std'; import { open, stat, readDir, errors } from 'avril:std/sync'; import { readTextFile, writeTextFile, copy, exists } from 'avril:fs/sync'; function denied(call) { try { call(); } catch (e) { if (e instanceof errors.PermissionDenied) return; throw e; } throw Error('Retained authority'); } if (readTextFile('data') !== 'abcdef') throw Error('Read grant'); writeTextFile('out', 'allowed'); denied(() => writeTextFile('forbidden', 'no')); denied(() => readTextFile('allowed/secret')); denied(() => exists('allowed/secret')); const entries = [...readDir('allowed')]; if (entries.length !== 1 || entries[0].name !== 'public') throw Error('Listing leaked denied child'); denied(() => copy('allowed', 'target')); const file = open('data', {read: true, write: true}); pledge({read: false, write: false}); for (const call of [ () => file.readSync(new Uint8Array(1)), () => file.writeSync(new Uint8Array([1])), () => file.statSync(), () => file.truncateSync(), () => file.syncSync(), () => stat('data'), () => readTextFile('data'), () => writeTextFile('out', 'no'), ]) denied(call); file.close(); "#, &[ "-0", "--allow-read=data", "--allow-write=data", "--allow-write=out", "--allow-read=allowed/", "--deny-read=allowed/secret", "--allow-write=target/", ], &[("allowed/public", "yes"), ("allowed/secret", "no")], ); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); assert_eq!(fs::read(dir.path().join("out")).unwrap(), b"allowed"); assert!(!dir.path().join("forbidden").exists()); assert!(!dir.path().join("target").exists()); } #[test] fn streams_and_async_callbacks_can_use_sync_handles() { let (dir, _) = run( r#" import { open, create } from 'avril:std/sync'; import { readTextFile, writeTextFile } from 'avril:fs/sync'; const source = open('data'), target = create('streamed'); await source.readable.pipeTo(target.writable); await new Promise(resolve => setTimeout(() => { writeTextFile('callback', readTextFile('streamed')); resolve(); }, 0)); "#, &[], &[], ); assert_eq!(fs::read(dir.path().join("callback")).unwrap(), b"abcdef"); } #[test] fn workers_can_use_sync_io_from_their_event_loop() { let (dir, _) = run( r#" const worker = new Worker(new URL('./worker.js', import.meta.url), {type: 'module'}); const answer = new Promise((resolve, reject) => { worker.onmessage = ({data}) => resolve(data); worker.onerror = reject; }); worker.postMessage('go'); if (await answer !== 'abcdef!') throw Error('Worker IO'); worker.terminate(); "#, &[], &[( "worker.js", r#" import { readTextFile, writeTextFile } from 'avril:fs/sync'; onmessage = () => { writeTextFile('worker-out', readTextFile('data') + '!'); postMessage(readTextFile('worker-out')); }; "#, )], ); assert_eq!(fs::read(dir.path().join("worker-out")).unwrap(), b"abcdef!"); } #[test] fn sync_modules_keep_helpers_private_and_capture_intrinsics() { run( r#" import * as std from 'avril:std/sync'; import * as fs from 'avril:fs/sync'; import { pledge } from 'avril:std'; if ('path' in std || 'openArgs' in std || 'writeOptions' in fs) throw Error('Private exports'); for (const name of ['ext:file/sync.js', 'ext:file/fs_sync.js']) { try { await import(name); throw Error('Private import succeeded'); } catch (e) { if (!e.message.includes('private')) throw e; } } const log = console.log; const fail = () => { throw Error('Mutable intrinsic'); }; std.FileHandle.prototype.readSync = fail; std.FileHandle.prototype.writeSync = fail; Uint8Array.prototype.set = fail; TextEncoder.prototype.encode = fail; TextDecoder.prototype.decode = fail; globalThis.Uint8Array = fail; Object.prototype.toJSON = fail; fs.writeTextFile('out', fs.readTextFile('data')); if (fs.readTextFile('out') !== 'abcdef') throw Error('Contents'); pledge({read: false, write: false, jsExec: false}); if ((await import('avril:fs/sync')).readFile !== fs.readFile) throw Error('Lost builtin'); log('intrinsics'); "#, &[], &[], ); } #[cfg(unix)] #[test] fn sync_posix_metadata_links_and_permissions_match_async_handles() { run( r#" import { open, stat, lstat, chmod, chown, lchown, utime, lutime, link, symlink, readLink, realPath, errors } from 'avril:std/sync'; import { copy, readTextFile, mkdirs, exists } from 'avril:fs/sync'; const file = open('data', {read: true, write: true}); const initial = file.statSync(); file.fchmodSync(0o640); file.fchownSync(initial.uid, initial.gid); file.futimeSync(new Date(1234567890123), 1234567890.125); let info = stat('data'); if ((info.mode & 0o777) !== 0o640 || +info.mtime !== 1234567890125 || +info.atime !== 1234567890123) throw Error('Handle metadata'); chmod('data', 0o600); chown('data', initial.uid, null); utime('data', 1234567000, new Date(1234567890456)); if ((stat('data').mode & 0o777) !== 0o600 || +stat('data').mtime !== 1234567890456) throw Error('Path metadata'); link('data', 'hard'); symlink('data', 'soft'); if (!lstat('soft').isSymlink || !stat('soft').isFile || readLink('soft') !== 'data' || realPath('soft') !== realPath('data')) throw Error('Links'); lchown('soft', initial.uid, initial.gid); lutime('soft', 1234500000, 1234500000); if (+lstat('soft').mtime !== 1234500000000 || +stat('data').mtime !== 1234567890456) throw Error('Link times'); copy('soft', 'preserved'); copy('soft', 'followed', {dereference: true}); if (!lstat('preserved').isSymlink || lstat('followed').isSymlink || readTextFile('followed') !== 'abcdef') throw Error('Copy links'); mkdirs('tree'); symlink('.', 'tree/cycle'); try { copy('tree', 'cycle-copy', {dereference: true}); throw Error('Accepted cycle'); } catch (e) { if (!(e instanceof errors.InvalidInput)) throw e; } file.flockSync(); file.funlockSync(); file.flockSync(false); await file.funlock(); file.close(); if (!exists('hard')) throw Error('Hard link'); "#, &[], &[], ); } #[cfg(unix)] #[test] fn synchronous_calls_do_not_follow_ungranted_symlink_targets() { let (dir, _) = run( r#" import { pledge } from 'avril:std'; import { mkdir, symlink, open, stat, truncate, errors } from 'avril:std/sync'; import { readFile, writeTextFile, copy, exists } from 'avril:fs/sync'; mkdir('allowed'); symlink('../data', 'allowed/alias'); pledge({read: {allow: ['allowed/']}, write: {allow: ['allowed/', 'out']}}); for (const call of [ () => open('allowed/alias'), () => stat('allowed/alias'), () => truncate('allowed/alias'), () => readFile('allowed/alias'), () => exists('allowed/alias'), () => writeTextFile('allowed/alias', 'no'), () => copy('allowed/alias', 'out', {dereference: true}), ]) { try { call(); throw Error('Followed denied target'); } catch (e) { if (!(e instanceof errors.PermissionDenied)) throw e; } } "#, &[], &[], ); assert_eq!(fs::read(dir.path().join("data")).unwrap(), b"abcdef"); assert!(!dir.path().join("out").exists()); } #[cfg(unix)] #[test] fn terminating_a_worker_interrupts_a_synchronous_lock_wait() { run( r#" import { open, create } from 'avril:std/sync'; const file = open('data', {read: true, write: true}), lifetime = create('lifetime'); file.flockSync(); const worker = new Worker(new URL('./worker.js', import.meta.url), {type: 'module'}); const ready = new Promise((resolve, reject) => { worker.onmessage = resolve; worker.onerror = reject; }); worker.postMessage('go'); await ready; await new Promise(resolve => setTimeout(resolve, 30)); worker.terminate(); lifetime.flockSync(); lifetime.funlockSync(); lifetime.close(); file.funlockSync(); file.close(); "#, &[], &[( "worker.js", r#" import { open } from 'avril:std/sync'; onmessage = () => { const file = open('data', {read: true, write: true}); const lifetime = open('lifetime', {read: true, write: true}); lifetime.flockSync(); postMessage('waiting'); file.flockSync(); throw Error('Acquired locked file'); }; "#, )], ); } #[cfg(unix)] #[test] fn synchronous_lock_waits_for_another_isolate_without_pumping_js() { run( r#" import { open } from 'avril:std/sync'; const worker = new Worker(new URL('./worker.js', import.meta.url), {type: 'module'}); await new Promise((resolve, reject) => { worker.onmessage = resolve; worker.onerror = reject; }); const file = open('data', {read: true, write: true}); let callback = false; queueMicrotask(() => callback = true); worker.postMessage('release'); file.flockSync(); if (callback) throw Error('Lock wait pumped JS'); file.funlockSync(); file.close(); worker.terminate(); "#, &[], &[( "worker.js", r#" import { open } from 'avril:std/sync'; const file = open('data', {read: true, write: true}); file.flockSync(); onmessage = () => setTimeout(() => { file.funlockSync(); file.close(); }, 50); postMessage('locked'); "#, )], ); } #[test] fn repl_and_test_callbacks_support_sync_io() { use std::io::Write; let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("data"), "hello").unwrap(); fs::write( dir.path().join("io_test.ts"), r#" import { test } from 'avril:test'; import { open, type FileInfo } from 'avril:std/sync'; import { readTextFile, writeTextFile } from 'avril:fs/sync'; test('sync IO after await', async () => { await new Promise(resolve => setTimeout(resolve, 0)); const file = open('data'); const info: FileInfo = file.statSync(); if (info.size !== 5) throw Error('Metadata'); file.close(); writeTextFile('test-out', readTextFile('data')); }); "#, ) .unwrap(); let child = Command::new(env!("CARGO_BIN_EXE_avril")) .args(["test", "io_test.ts"]) .current_dir(dir.path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(fs::read(dir.path().join("test-out")).unwrap(), b"hello"); let mut child = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("repl") .current_dir(dir.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); child.stdin.take().unwrap().write_all(b"import { readTextFile, writeTextFile } from 'avril:fs/sync'\nawait new Promise(resolve => setTimeout(() => { writeTextFile('repl-out', readTextFile('data')); resolve('done'); }, 0))\n.exit\n").unwrap(); let output = common::wait(child); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!(fs::read(dir.path().join("repl-out")).unwrap(), b"hello"); } #[cfg(unix)] #[test] fn sync_opened_handles_can_be_lent_to_subprocesses() { let (dir, _) = run( r#" import { runSync } from 'avril:std'; import { open, create } from 'avril:std/sync'; const input = open('data'), output = create('out'); input.seekSync(2, 0); runSync('/bin/sh', ['-c', 'cat'], {stdin: {handle: input}, stdout: {handle: output}}); if (output.statSync().size !== 4) throw Error('Borrowed handle'); input.close(); output.close(); "#, &[], &[], ); assert_eq!(fs::read(dir.path().join("out")).unwrap(), b"cdef"); } #[test] fn sync_writev_shares_the_queue_orders_with_async_and_validates() { let (dir, output) = run( r#" import { open, create, SeekMode, errors, FileHandle } from 'avril:std/sync'; import { open as openAsync } from 'avril:std'; function fails(call, C) { try { call(); } catch (e) { if (e instanceof C) return; throw e; } throw Error('Expected synchronous ' + C.name); } const file = create('out'); const pending = file.write(new Uint8Array([1,2,3])); if (file.writevSync([new Uint8Array([4]), new Uint8Array(0), new Uint8Array([5,6])]) !== 3) throw Error('Sync count'); if (await pending !== 3) throw Error('Async count'); if (file.writevSync([]) !== 0) throw Error('Empty'); if (file.seekSync(0, SeekMode.Start) !== 0) throw Error('Seek'); const back = new Uint8Array(6); if (file.readSync(back) !== 6 || back.join() !== '1,2,3,4,5,6') throw Error('Order ' + back.join()); const wide = create('wide'), segs = [new Uint8Array(0)]; for (let i = 0; i < 20; i++) { const view = new Uint8Array(5000); view.fill(i + 1); segs.push(view); } segs.push(new Uint8Array(0)); if (wide.writevSync(segs) !== 100000) throw Error('Wide'); wide.close(); const view = new Uint8Array([7,8]); if (file.writevSync([view.subarray(1)]) !== 1) throw Error('Subarray'); view.fill(0); const shared = new Uint8Array(new SharedArrayBuffer(1)); shared[0] = 9; if (file.writevSync([shared]) !== 1) throw Error('Shared'); fails(() => file.writevSync('bytes'), TypeError); fails(() => file.writevSync([new Uint8Array([1]), null]), TypeError); fails(() => file.writevSync(new Uint8Array([1])), TypeError); fails(() => FileHandle.prototype.writevSync.call({}, [new Uint8Array([1])]), TypeError); const detached = new Uint8Array([1]); structuredClone(detached.buffer, {transfer: [detached.buffer]}); fails(() => file.writevSync([detached]), TypeError); file.close(); fails(() => file.writevSync([new Uint8Array([1])]), errors.BadResource); const check = await openAsync('out'); const all = new Uint8Array(8); let offset = 0, n; while (offset < 8 && (n = await check.read(all.subarray(offset))) !== null) offset += n; if (offset !== 8 || all.join() !== '1,2,3,4,5,6,8,9') throw Error('Bytes ' + all.join()); check.close(); console.log('sync-vectors'); "#, &[], &[], ); assert_eq!(output.stdout, b"sync-vectors\n"); let wide = fs::read(dir.path().join("wide")).unwrap(); let expected: Vec = (1..=20).flat_map(|value| vec![value; 5000]).collect(); assert_eq!(wide, expected); assert_eq!( fs::read(dir.path().join("out")).unwrap(), [1, 2, 3, 4, 5, 6, 8, 9] ); }