use std::fs; use std::path::Path; use std::process::{Command, Output}; fn files(root: &Path, files: &[(&str, &str)]) { for (name, source) in files { let path = root.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, source).unwrap(); } } fn run(root: &Path, entry: &str, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(root) // Options alone run the entry; a leading command selects another. .args( args.first() .is_none_or(|arg| arg.starts_with('-')) .then_some("run"), ) .args(args) .arg(entry) .output() .unwrap() } fn success(output: &Output) -> &str { assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); std::str::from_utf8(&output.stdout).unwrap() } #[test] fn renamed_scoped_and_self_exports_share_identity_with_relative_imports() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "name: '@app/demo', deps: [{from: '../math', name: 'math'}, {from: '../math', name: 'math-copy'}, {from: '../math', name: '@app/math'}]", ), ( "app/main.ts", "import * as a from 'math'; import * as b from 'math-copy'; import * as c from '../math/mod.ts'; import * as d from '@app/math'; import * as v from 'math/vector'; const name = 'math'; console.log(a === b, b === c, c === d, v === await import(name + '/vector'), a.self === v, a.count, v.count); try {await import('@original/math');} catch(e) {console.log(e.message.includes('Unsupported module specifier'));}", ), ( "math/avril.conf", "name: '@original/math', version: '0.1.0', description: 'Shared math utilities', exports: {'.': './mod.ts', './vector': './src/vector.ts'}", ), ( "math/mod.ts", "import * as self from '@original/math/vector'; export {self}; export const count = globalThis.roots = (globalThis.roots ?? 0) + 1;", ), ( "math/src/vector.ts", "export const count = globalThis.vectors = (globalThis.vectors ?? 0) + 1;", ), ], ); assert_eq!( success(&run(dir.path(), "app/main.ts", &[])), "true true true true true 1 1\ntrue\n" ); assert_eq!(success(&run(&dir.path().join("math"), "mod.ts", &[])), ""); } #[test] fn identical_declared_names_in_different_directories_keep_distinct_self_bindings() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: [{from: '../v1', name: 'math'}, {from: '../v2', name: 'math-next'}, {from: '../format', name: '@app/format'}]", ), ( "app/main.ts", "import {value as first} from 'math'; import {value as second} from 'math-next'; import {format} from '@app/format'; console.log(first, second, format);", ), ( "v1/avril.conf", "name: '@acme/math', version: '1.0.0', exports: {'.': './mod.ts', './inner': './inner.ts'}", ), ( "v2/avril.conf", "name: '@acme/math', version: '2.0.0', exports: {'.': './mod.ts', './inner': './inner.ts'}", ), ("v1/mod.ts", "export {value} from '@acme/math/inner';"), ("v2/mod.ts", "export {value} from '@acme/math/inner';"), ("v1/inner.ts", "export const value = 1;"), ("v2/inner.ts", "export const value = 2;"), ("format/avril.conf", "exports: './mod.ts'"), ("format/mod.ts", "export const format = 'unnamed';"), ], ); assert_eq!( success(&run(dir.path(), "app/main.ts", &[])), "1 2 unnamed\n" ); } #[test] fn dependencies_and_explicit_maps_are_private_to_the_owning_package() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: [{from: '../math', name: 'math'}], imports: {numeric: './app-number.ts', consumerOnly: './app-number.ts'}", ), ( "app/main.ts", "import {value, isolated} from 'math'; import {value as appValue} from 'numeric'; console.log(value, appValue, await isolated());", ), ("app/app-number.ts", "export const value = 'app';"), ( "math/avril.conf", "exports: './mod.ts', deps: ['../numeric'], imports: {helper: './helper.ts'}", ), ( "math/mod.ts", "import {value as n} from 'number-implementation'; import {suffix} from 'helper'; export const value = n + suffix; export async function isolated() {try {await import('consumerOnly'); return false;} catch(e) {return e.message.includes('Unsupported module specifier');}}", ), ("math/helper.ts", "export const suffix = '-math';"), ( "numeric/avril.conf", "name: 'number-implementation', exports: './mod.ts'", ), ("numeric/mod.ts", "export const value = 'numeric';"), ], ); assert_eq!( success(&run(dir.path(), "app/main.ts", &[])), "numeric-math app true\n" ); files( dir.path(), &[("app/main.ts", "import 'number-implementation';")], ); let output = run(dir.path(), "app/main.ts", &[]); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr) .contains("Unsupported module specifier: number-implementation") ); } #[test] fn cyclic_package_headers_and_source_graphs_do_not_scan_unused_dependencies() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: ['../a', '../b', {from: '../missing', name: 'unused'}]", ), ( "app/main.ts", "import {cycle} from '@cycle/a'; import {leafB} from '@cycle/b/leaf'; console.log(cycle(), leafB());", ), ( "a/avril.conf", "name: '@cycle/a', exports: './mod.ts', deps: ['../b']", ), ( "a/mod.ts", "import {leafB} from '@cycle/b/leaf'; export const a = 'a'; export function cycle() {return leafB();}", ), ( "b/avril.conf", "name: '@cycle/b', exports: {'./leaf': './leaf.ts'}, deps: ['../a']", ), ( "b/leaf.ts", "import {a} from '@cycle/a'; export function leafB() {return a + 'b';}", ), ], ); assert_eq!(success(&run(dir.path(), "app/main.ts", &[])), "ab ab\n"); } #[test] fn package_imports_have_no_private_file_fallback_but_relative_files_remain_available() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", "deps: [{from: '../math', name: 'math'}]"), ( "app/main.js", "import {privateSelf} from 'math'; console.log(await privateSelf());", ), ("math/avril.conf", "name: '@acme/math', exports: './mod.js'"), ( "math/mod.js", "export async function privateSelf() {try {await import('@acme/math/private.js'); return false;} catch(e) {return e.message.includes('no export');}}", ), ("math/private.js", "console.log('private relative file');"), ], ); assert_eq!(success(&run(dir.path(), "app/main.js", &[])), "true\n"); for specifier in [ "math/private.js", "math/../private.js", "math/%2e%2e/private.js", "math/", ] { files( dir.path(), &[("app/main.js", &format!("import '{specifier}';"))], ); let output = run(dir.path(), "app/main.js", &[]); assert!(!output.status.success(), "{specifier}"); assert!(String::from_utf8_lossy(&output.stderr).contains("no export")); assert!(output.stdout.is_empty()); } files( dir.path(), &[("app/main.js", "import '../math/private.js';")], ); assert_eq!( success(&run(dir.path(), "app/main.js", &[])), "private relative file\n" ); } #[test] fn declared_dependency_requires_its_own_config_and_nonempty_exports() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "avril.conf", "exports: './main.js', deps: [{from: './lib', name: 'lib'}]", ), ("main.js", "import 'lib';"), ("lib/mod.js", "throw Error('not exported');"), ], ); let output = run(dir.path(), "main.js", &[]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("must contain avril.conf")); files(dir.path(), &[("lib/avril.conf", "name: 'original'")]); let output = run(dir.path(), "main.js", &[]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("has no exports")); files( dir.path(), &[("lib/avril.conf", "exports: {'./sub': './mod.js'}")], ); let output = run(dir.path(), "main.js", &[]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("no export \".\"")); } #[test] fn export_files_and_cached_dependency_headers_obey_live_execution_caps() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", "deps: ['../lib']"), ("lib/avril.conf", "name: 'lib', exports: './mod.js'"), ("lib/mod.js", "export const value = 1;"), ], ); for denied in ["lib/avril.conf", "lib/mod.js", "lib"] { files( dir.path(), &[( "app/main.js", &format!( "import {{pledge}} from 'avril:std'; await import('lib'); pledge({{jsExec: {{deny: ['{denied}']}}}}); try {{await import('lib'); console.log('allowed');}} catch(e) {{console.log(e.message.includes('PermissionDenied'));}}" ), )], ); assert_eq!( success(&run(dir.path(), "app/main.js", &[])), "true\n", "{denied}" ); } } #[test] fn denied_dependencies_do_not_disclose_directory_or_config_existence() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", "deps: [{from: '../secret', name: 'lib'}]"), ("app/main.js", "import 'lib';"), ], ); let args = ["-0", "--allow-js-exec=app"]; let missing = run(dir.path(), "app/main.js", &args); files(dir.path(), &[("secret/avril.conf", "not: valid: syntax")]); let present = run(dir.path(), "app/main.js", &args); assert!(!missing.status.success()); assert_eq!(missing.stderr, present.stderr); assert!(String::from_utf8_lossy(&missing.stderr).contains("PermissionDenied: js-exec lib")); let args = [ "-0", "--allow-js-exec=app", "--allow-js-exec=secret", "--deny-js-exec=secret/avril.conf", ]; fs::remove_file(dir.path().join("secret/avril.conf")).unwrap(); let missing = run(dir.path(), "app/main.js", &args); files(dir.path(), &[("secret/avril.conf", "not: valid: syntax")]); let present = run(dir.path(), "app/main.js", &args); assert_eq!(missing.stderr, present.stderr); assert!(String::from_utf8_lossy(&missing.stderr).contains("PermissionDenied: js-exec lib")); } #[cfg(unix)] #[test] fn exported_symlinks_are_confined_and_directory_aliases_preserve_identity() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: [{from: '../lib', name: 'lib'}, {from: '../link', name: 'renamed'}]", ), ( "app/main.js", "import * as a from 'lib'; import * as b from 'renamed'; console.log(a === b);", ), ("lib/avril.conf", "exports: './public.js'"), ("lib/inside.js", "export const value = 1;"), ("outside.js", "console.log('escaped');"), ], ); symlink("lib", dir.path().join("link")).unwrap(); symlink("inside.js", dir.path().join("lib/public.js")).unwrap(); assert_eq!(success(&run(dir.path(), "app/main.js", &[])), "true\n"); fs::remove_file(dir.path().join("lib/public.js")).unwrap(); symlink("../outside.js", dir.path().join("lib/public.js")).unwrap(); let output = run(dir.path(), "app/main.js", &[]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("Export target escapes its package")); assert!(String::from_utf8_lossy(&output.stderr).contains("avril.conf:1:")); assert!(output.stdout.is_empty()); } #[cfg(unix)] #[test] fn dependency_config_symlinks_and_world_writable_headers_are_rejected() { use std::os::unix::fs::{PermissionsExt, symlink}; // Path-only entries read the dependency's name at startup, outside guest grants. for deps in ["[{from: '../lib', name: 'lib'}]", "['../lib']"] { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", &format!("deps: {deps}")), ("app/main.js", "import 'lib';"), ("lib/other.conf", "name: 'lib', exports: './mod.js'"), ("lib/mod.js", "console.log('untrusted');"), ], ); let config = dir.path().join("lib/avril.conf"); symlink("other.conf", &config).unwrap(); let present = run(dir.path(), "app/main.js", &[]); fs::remove_file(&config).unwrap(); symlink("missing", &config).unwrap(); let missing = run(dir.path(), "app/main.js", &[]); assert!(!present.status.success(), "{deps}"); assert_eq!(present.stderr, missing.stderr, "{deps}"); assert!(String::from_utf8_lossy(&present.stderr).contains("non-symlink")); fs::remove_file(&config).unwrap(); fs::write(&config, "name: 'lib', exports: './mod.js'").unwrap(); fs::set_permissions(&config, fs::Permissions::from_mode(0o666)).unwrap(); let output = run(dir.path(), "app/main.js", &[]); assert!(!output.status.success(), "{deps}"); assert!(String::from_utf8_lossy(&output.stderr).contains("Untrusted config")); assert!(output.stdout.is_empty()); fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).unwrap(); let lib = dir.path().join("lib"); fs::set_permissions(&lib, fs::Permissions::from_mode(0o777)).unwrap(); let output = run(dir.path(), "app/main.js", &[]); assert!(!output.status.success(), "{deps}"); assert!(String::from_utf8_lossy(&output.stderr).contains("Untrusted dependency directory")); assert!(output.stdout.is_empty()); fs::set_permissions(&lib, fs::Permissions::from_mode(0o755)).unwrap(); assert_eq!(success(&run(dir.path(), "app/main.js", &[])), "untrusted\n"); } } #[test] fn info_reports_metadata_and_per_referrer_export_bindings() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "name: '@app/demo', deps: ['../math', {from: '../math', name: 'math'}], imports: {helper: './helper.ts'}", ), ( "app/main.ts", "import {value} from 'math'; import {typed} from '@original/math'; export const result = value + typed; throw Error('must not execute');", ), ("app/helper.ts", "export const value = 'app';"), ( "math/avril.conf", "name: '@original/math', version: '0.1.0', description: 'Shared utilities', exports: {'.': './mod.ts', './types': './types.d.ts'}, imports: {helper: './helper.ts'}", ), ( "math/mod.ts", "import {value} from 'helper'; import type {Value} from '@original/math/types'; export const typed: Value = value; export {value};", ), ("math/helper.ts", "export const value = 1;"), ("math/types.d.ts", "export type Value = number;"), ], ); let output = run(dir.path(), "app/main.ts", &["info"]); let graph: serde_json::Value = serde_json::from_str(success(&output)).unwrap(); let main = &graph["modules"][dir.path().join("app/main.ts").to_str().unwrap()]; let library = &graph["modules"][dir.path().join("math/mod.ts").to_str().unwrap()]; assert_eq!( main["imports"]["math"], dir.path().join("math/mod.ts").to_str().unwrap() ); assert_eq!(main["package"]["name"], "@app/demo"); for binding in ["math", "@original/math"] { assert_eq!( main["imports"][binding], dir.path().join("math/mod.ts").to_str().unwrap() ); assert_eq!( main["package"]["dependencies"][binding], dir.path().join("math").to_str().unwrap() ); } assert_eq!(library["package"]["name"], "@original/math"); assert_eq!(library["package"]["version"], "0.1.0"); assert_eq!(library["package"]["description"], "Shared utilities"); assert_eq!( library["imports"]["helper"], dir.path().join("math/helper.ts").to_str().unwrap() ); } #[test] fn failed_dependency_headers_can_be_repaired_without_poisoning_later_imports() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "deps: [{from: './lib', name: 'lib'}]"), ( "main.js", "import {writeTextFile} from 'avril:fs'; try {await import('lib');} catch(e) {console.log(e.message.includes('avril.conf'));} await writeTextFile('./lib/avril.conf', \"exports: './mod.js'\"); console.log((await import('lib')).value);", ), ("lib/mod.js", "export const value = 42;"), ], ); assert_eq!(success(&run(dir.path(), "main.js", &[])), "true\n42\n"); files(dir.path(), &[("lib/avril.conf", "exports: false")]); assert_eq!(success(&run(dir.path(), "main.js", &[])), "true\n42\n"); } #[test] fn exported_sources_keep_their_nearest_nested_package_ownership() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: [{from: '../outer', name: 'outer'}]", ), ( "app/main.ts", "import {value, isolated} from 'outer'; console.log(value, await isolated());", ), ( "outer/avril.conf", "name: '@original/outer', exports: './inner/mod.ts', imports: {helper: './outer-helper.ts'}", ), ("outer/outer-helper.ts", "export const value = 'outer';"), ( "outer/inner/avril.conf", "name: '@original/inner', exports: {'./value': './value.ts'}", ), ( "outer/inner/mod.ts", "export {value} from '@original/inner/value'; export async function isolated() {try {await import('helper'); return false;} catch(e) {return e.message.includes('Unsupported module specifier');}}", ), ("outer/inner/value.ts", "export const value = 'inner';"), ], ); assert_eq!( success(&run(dir.path(), "app/main.ts", &[])), "inner true\n" ); } #[test] fn ordinary_absence_snapshots_require_a_fresh_loader_to_establish_a_new_package() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("avril.conf", "deps: [{from: './lib', name: 'lib'}]"), ( "main.js", "import * as relative from './lib/mod.js'; import {writeTextFile} from 'avril:fs'; await writeTextFile('./lib/avril.conf', \"exports: './mod.js'\"); try {console.log('same', (await import('lib')) === relative);} catch(e) {console.log('snapshot', e.message.includes('must contain avril.conf'));}", ), ("lib/mod.js", "export {value} from './helper.js';"), ("lib/helper.js", "export const value = 1;"), ], ); assert_eq!(success(&run(dir.path(), "main.js", &[])), "snapshot true\n"); assert_eq!(success(&run(dir.path(), "main.js", &[])), "same true\n"); } #[test] fn path_only_dependencies_bind_their_declared_names_and_overrides_do_not() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: ['../math', {from: '../math', name: 'math'}]", ), ( "app/main.ts", "import * as a from '@acme/math'; import * as b from 'math'; import * as v from '@acme/math/vector'; import * as w from 'math/vector'; console.log(a === b, v === w, a.self === v, a.value);", ), ( "math/avril.conf", "name: '@acme/math', exports: {'.': './mod.ts', './vector': './vector.ts'}", ), ( "math/mod.ts", "import * as self from '@acme/math/vector'; export {self}; export const value = 'math';", ), ("math/vector.ts", "export const vector = true;"), ], ); assert_eq!( success(&run(dir.path(), "app/main.ts", &[])), "true true true math\n" ); files( dir.path(), &[("app/avril.conf", "deps: [{from: '../math', name: 'math'}]")], ); let output = run(dir.path(), "app/main.ts", &[]); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr) .contains("Unsupported module specifier: @acme/math") ); } #[test] fn path_only_dependency_names_must_be_declared_and_unique() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/main.js", "console.log('ran');"), ("v1/avril.conf", "name: '@acme/math', exports: './mod.js'"), ("v2/avril.conf", "name: '@acme/math', exports: './mod.js'"), ("anonymous/avril.conf", "exports: './mod.js'"), ("broken/avril.conf", "name: false"), ], ); for (deps, message) in [ ("['../anonymous']", "declares no name"), ("['../missing']", "Cannot read the name of dependency"), ("['../v2/../broken']", "Expected a string"), ( "['../v1', '../v2']", "Duplicate dependency binding \"@acme/math\"", ), ] { files(dir.path(), &[("app/avril.conf", &format!("deps: {deps}"))]); let output = run(dir.path(), "app/main.js", &[]); let stderr = String::from_utf8_lossy(&output.stderr); assert!(!output.status.success(), "{deps}"); assert!(output.stdout.is_empty(), "{deps}"); assert!(stderr.contains(message), "{deps}: {stderr}"); let consumer = format!("app{}avril.conf:1:", std::path::MAIN_SEPARATOR); assert!(stderr.contains(&consumer), "{deps}: {stderr}"); } } #[test] fn root_bindings_are_read_at_startup_and_nested_bindings_when_their_config_loads() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ( "app/avril.conf", "deps: ['../lib', {from: '../mid', name: 'mid'}]", ), ( "app/main.js", "import {writeTextFile} from 'avril:fs'; await writeTextFile('../lib/avril.conf', \"name: 'renamed', exports: './mod.js'\"); await writeTextFile('../leaf/avril.conf', \"name: 'leaf', exports: './mod.js'\"); console.log((await import('original')).value, (await import('mid')).value); try {await import('renamed');} catch(e) {console.log(e.message.includes('Unsupported module specifier'));}", ), ("lib/avril.conf", "name: 'original', exports: './mod.js'"), ("lib/mod.js", "export const value = 'lib';"), ("mid/avril.conf", "exports: './mod.js', deps: ['../leaf']"), ( "mid/mod.js", "import {value as leaf} from 'leaf'; export const value = 'mid-' + leaf;", ), ("leaf/mod.js", "export const value = 'leaf';"), ], ); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .arg("run") .current_dir(dir.path().join("app")) .arg("main.js") .output() .unwrap(); assert_eq!(success(&output), "lib mid-leaf\ntrue\n"); } #[test] fn name_discovery_ignores_execution_grants_but_loading_does_not() { let dir = tempfile::tempdir().unwrap(); files( dir.path(), &[ ("app/avril.conf", "deps: ['../lib']"), ( "app/main.js", "for (const name of ['@acme/lib', 'undeclared']) {try {console.log((await import(name)).value);} catch(e) {console.log(e.message);}}", ), ("lib/avril.conf", "name: '@acme/lib', exports: './mod.js'"), ("lib/mod.js", "export const value = 'lib';"), ], ); assert_eq!( success(&run( dir.path(), "app/main.js", &["-0", "--allow-js-exec=app"] )), "PermissionDenied: js-exec @acme/lib\nUnsupported module specifier: undeclared\n" ); assert_eq!( success(&run( dir.path(), "app/main.js", &["-0", "--allow-js-exec=app", "--allow-js-exec=lib"] )), "lib\nUnsupported module specifier: undeclared\n" ); assert_eq!( success(&run( dir.path(), "app/main.js", &[ "-0", "--allow-js-exec=app", "--allow-js-exec=lib", "--deny-js-exec=lib/avril.conf" ] )), "PermissionDenied: js-exec @acme/lib\nUnsupported module specifier: undeclared\n" ); files( dir.path(), &[( "app/main.js", "import {pledge} from 'avril:std'; pledge({jsExec: {deny: ['lib/avril.conf']}}); try {await import('@acme/lib'); console.log('allowed');} catch(e) {console.log(e.message);}", )], ); assert_eq!( success(&run(dir.path(), "app/main.js", &[])), "PermissionDenied: js-exec @acme/lib\n" ); }