use std::io; use std::path::Path; use std::process::{Command, Output}; use avril_host::environment::Environment; use avril_runtime::caps::{Capabilities, Category, Initial}; fn run(source: &str, args: &[&str], values: &[(&str, &str)]) -> Output { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("main.ts"), source).unwrap(); Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .env_clear() .envs(values.iter().copied()) .arg("run") .args(args) .arg("main.ts") .output() .unwrap() } fn success(output: Output) -> String { assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } #[test] fn inherited_values_can_be_captured_changed_and_deleted() { let output = run( r#" import {env} from 'avril:std'; console.log(env.get('TOKEN'), env.has('EMPTY'), env.get('EMPTY') === '', env.get('MISSING') === undefined, env.has('MISSING')); const captured = env.get('TOKEN'); env.delete('TOKEN'); console.log(captured, env.has('TOKEN')); env.set('TOKEN', 'new'); env.set('EMPTY', ''); env.set('NUMBER', 42 as unknown as string); env.delete('MISSING'); console.log(env.get('TOKEN'), env.get('EMPTY') === '', env.get('NUMBER')); "#, &[], &[("TOKEN", "secret"), ("EMPTY", "")], ); assert_eq!( success(output), "secret true true true false\nsecret false\nnew true 42\n" ); } #[test] fn snapshots_are_filtered_independent_and_safe_for_special_names() { let output = run( r#" import {env} from 'avril:std'; const snapshot = env.toObject(); console.log(Object.getPrototypeOf(snapshot) === null, Object.keys(snapshot).sort().join(','), snapshot.__proto__, snapshot.constructor); snapshot.VISIBLE = 'snapshot'; delete snapshot.constructor; env.set('__proto__', 'changed'); console.log(env.get('VISIBLE'), env.get('constructor'), snapshot.__proto__); "#, &[ "-0", "--allow-env=VISIBLE", "--allow-env=__proto__", "--allow-env=constructor", ], &[ ("VISIBLE", "original"), ("SECRET", "hidden"), ("__proto__", "proto"), ("constructor", "ctor"), ], ); assert_eq!( success(output), "true VISIBLE,__proto__,constructor proto ctor\noriginal ctor proto\n" ); } #[test] fn named_operations_require_permission_even_for_missing_variables() { for args in [ vec!["-0"], vec![ "--deny-env=SECRET", "--deny-env=MISSING", "--allow-env=SECRET", ], ] { let output = run( r#" import {env, errors} from 'avril:std'; for (const name of ['SECRET', 'MISSING']) { for (const action of [() => env.get(name), () => env.has(name), () => env.set(name, 'replacement'), () => env.delete(name)]) { try {action(); throw Error('Allowed');} catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;} } } console.log('denied', Object.keys(env.toObject()).length); "#, &args, &[("SECRET", "secret")], ); assert_eq!(success(output), "denied 0\n"); } } #[test] fn cli_grants_are_exact_repeatable_and_do_not_split_commas() { let output = run( r#" import {env, errors} from 'avril:std'; console.log(Object.keys(env.toObject()).sort().join('|')); for (const name of ['FIRST_EXTRA', 'FIRST,SECOND', 'DENIED']) { try {env.get(name); throw Error('Allowed');} catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;} } "#, &[ "-0", "--allow-env=FIRST", "--allow-env=WITH,COMMA", "--allow-env=SECOND", "--allow-env=DENIED", "--deny-env=DENIED", ], &[ ("FIRST", "1"), ("SECOND", "2"), ("WITH,COMMA", "3"), ("DENIED", "4"), ("FIRST_EXTRA", "5"), ], ); assert_eq!(success(output), "FIRST|SECOND|WITH,COMMA\n"); } #[test] fn pledges_are_atomic_monotonic_and_apply_to_retained_methods() { let output = run( r#" import {env, pledge, errors} from 'avril:std'; const {get, set, delete: remove, toObject} = env; const captured = get('SECRET'); for (const request of [{env: false, read: {deny: ['\0']}}, {env: {allow: ['']}}, {env: {deny: ['A=B']}}, {env: {deny: ['A\0B']}}, {env: {allow: 'SECRET'}}, {env: true}]) { try {pledge(request as any); throw Error('Invalid pledge accepted');} catch (error) {if (!(error instanceof TypeError)) throw error;} if (get('SECRET') !== captured) throw Error('Partial pledge'); } pledge({env: {allow: ['SECRET', 'PUBLIC']}}); pledge({env: {deny: ['SECRET']}}); pledge({env: {allow: ['SECRET', 'OTHER']}}); for (const action of [() => get('SECRET'), () => set('SECRET', 'new'), () => remove('SECRET'), () => get('OTHER'), () => get('PUBLIC')]) { try {action(); throw Error('Authority restored');} catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;} } console.log(captured, Object.keys(toObject()).length); pledge({env: false}); console.log(Object.keys(env.toObject()).length); "#, &[], &[ ("SECRET", "captured"), ("PUBLIC", "public"), ("OTHER", "other"), ], ); assert_eq!(success(output), "captured 0\n0\n"); } #[test] fn invalid_names_values_and_missing_arguments_fail_without_mutating_state() { let output = run( r#" import {env, errors} from 'avril:std'; for (const name of ['', 'A=B', 'A\0B']) { for (const action of [() => env.get(name), () => env.has(name), () => env.set(name, 'value'), () => env.delete(name)]) { try {action(); throw Error('Invalid name accepted');} catch (error) {if (!(error instanceof errors.InvalidInput)) throw error;} } } try {env.set('VALID', 'bad\0value'); throw Error('Invalid value accepted');} catch (error) {if (!(error instanceof errors.InvalidInput)) throw error;} for (const action of [() => (env.get as any)(), () => (env.has as any)(), () => (env.set as any)('VALID'), () => (env.delete as any)(), () => env.get(Symbol() as any)]) { try {action(); throw Error('Invalid arguments accepted');} catch (error) {if (!(error instanceof TypeError)) throw error;} } console.log(env.get('VALID')); "#, &[], &[("VALID", "original")], ); assert_eq!(success(output), "original\n"); for flag in ["--allow-env=A=B", "--deny-env=A=B", "--allow-env="] { let output = run("throw Error('Executed');", &[flag], &[]); assert!(!output.status.success()); assert!(!String::from_utf8_lossy(&output.stderr).contains("Executed")); } } #[test] fn captured_env_api_survives_poisoned_globals_and_prototypes() { let output = run( r#" import {env, pledge, errors} from 'avril:std'; const log = console.log; const objectPrototype = Object.prototype; const poison = () => {throw Error('Poisoned');}; globalThis.String = poison; globalThis.Object = poison; objectPrototype.toString = poison; objectPrototype.get = poison; objectPrototype.set = poison; Array.prototype[Symbol.iterator] = poison; env.set('KEY', 'new'); const snapshot = env.toObject(); pledge({env: {allow: ['KEY']}}); log(env.get('KEY'), snapshot.KEY); const cases = [['KEY=bad', errors.InvalidInput], ['DENIED', errors.PermissionDenied]]; for (let index = 0; index < cases.length; index++) { try {env.get(cases[index][0]); throw Error('Allowed');} catch (error) {if (!(error instanceof cases[index][1])) throw error;} } "#, &[], &[("KEY", "old")], ); assert_eq!(success(output), "new new\n"); } #[test] fn environment_authority_is_inherited_as_a_snapshot() { let cwd = Path::new("."); let mut initial = Initial::new(true); for key in ["A", "B", "DENIED"] { initial.grant(Category::Env, false, key, cwd).unwrap(); } initial.grant(Category::Env, true, "DENIED", cwd).unwrap(); let parent = Capabilities::new(initial, cwd.to_owned()); let child = Capabilities::inherit(parent.clone()); let mut env = Environment::new([("A".into(), "a".into()), ("B".into(), "b".into())]).unwrap(); assert_eq!( env.get(&child, "DENIED").unwrap_err().kind(), io::ErrorKind::PermissionDenied ); assert!( parent .pledge(vec![(Category::Env, None, vec!["A".into(), "".into()])]) .is_err() ); assert_eq!(env.get(&child, "A").unwrap().as_deref(), Some("a")); // Later pledges narrow only the parent and future workers. parent .pledge(vec![(Category::Env, None, vec!["A".into()])]) .unwrap(); assert_eq!( env.get(&parent, "A").unwrap_err().kind(), io::ErrorKind::PermissionDenied ); assert_eq!(env.get(&child, "A").unwrap().as_deref(), Some("a")); env.set(&child, "A", "replacement".into()).unwrap(); env.delete(&child, "A").unwrap(); assert_eq!(env.to_object(&child), vec![("B".into(), "b".into())]); let later = Capabilities::inherit(parent.clone()); assert_eq!( env.get(&later, "A").unwrap_err().kind(), io::ErrorKind::PermissionDenied ); child .pledge(vec![(Category::Env, Some(vec![]), vec![])]) .unwrap(); assert!(env.to_object(&child).is_empty()); assert_eq!(env.get(&parent, "B").unwrap().as_deref(), Some("b")); } #[test] fn mutations_do_not_change_the_host_process_environment() { let original = std::env::var_os("PATH"); let caps = Capabilities::new(Initial::new(false), Path::new(".").to_owned()); let mut env = Environment::from_process(); env.set(&caps, "PATH", "runtime-local".into()).unwrap(); assert_eq!(std::env::var_os("PATH"), original); env.delete(&caps, "PATH").unwrap(); assert_eq!(std::env::var_os("PATH"), original); } #[cfg(unix)] #[test] fn non_utf8_host_variables_are_not_lossily_exposed() { use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("main.js"), "import {env} from 'avril:std'; console.log(JSON.stringify(env.toObject()));", ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .env_clear() .env(OsString::from_vec(vec![b'A', 0xff]), "secret") .env("BAD_VALUE", OsString::from_vec(vec![0xff])) .args(["run", "main.js"]) .output() .unwrap(); assert_eq!(success(output), "{}\n"); } #[cfg(windows)] #[test] fn ascii_case_folding_is_shared_by_storage_and_permissions() { let cwd = Path::new("."); let mut initial = Initial::new(true); initial.grant(Category::Env, false, "path", cwd).unwrap(); let caps = Capabilities::new(initial, cwd.to_owned()); let mut env = Environment::new([("Path".into(), "original".into())]).unwrap(); assert_eq!(env.get(&caps, "PATH").unwrap().as_deref(), Some("original")); assert_eq!( env.to_object(&caps), vec![("Path".into(), "original".into())] ); env.set(&caps, "pAtH", "changed".into()).unwrap(); assert_eq!(env.get(&caps, "path").unwrap().as_deref(), Some("changed")); assert_eq!(env.to_object(&caps).len(), 1); caps.pledge(vec![(Category::Env, None, vec!["PaTh".into()])]) .unwrap(); assert!(env.get(&caps, "PATH").is_err()); assert!(env.to_object(&caps).is_empty()); } #[cfg(unix)] #[test] fn unix_names_are_case_sensitive_in_storage_and_permissions() { let cwd = Path::new("."); let mut initial = Initial::new(true); initial.grant(Category::Env, false, "Path", cwd).unwrap(); let caps = Capabilities::new(initial, cwd.to_owned()); let env = Environment::new([ ("Path".into(), "allowed".into()), ("PATH".into(), "secret".into()), ]) .unwrap(); assert_eq!(env.get(&caps, "Path").unwrap().as_deref(), Some("allowed")); assert!(env.get(&caps, "PATH").is_err()); assert_eq!( env.to_object(&caps), vec![("Path".into(), "allowed".into())] ); }