#[path = "common/registry.rs"] mod fixture; use std::process::{Command, Output}; fn run(source: &str, files: &[(&str, &[u8])], args: &[&str], values: &[(&str, &str)]) -> Output { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join("app")).unwrap(); std::fs::write(dir.path().join("app/main.ts"), source).unwrap(); for (name, content) in files { std::fs::write(dir.path().join(name), content).unwrap(); } Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .env_clear() .envs(values.iter().copied()) .arg("run") .args(args) .arg("app/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 dotenv_syntax_supports_quotes_comments_exports_multiline_and_literal_values() { let file = concat!( "\u{feff}", r#" # ignored = comment no assignment here = ignored BASIC = basic # comment HASH=before#after EQUAL=first=second export EXPORTED = yes export EXPORTED_EMPTY= EMPTY= SPACE_EMPTY= AFTER_EMPTY=next SINGLE=' literal\n # and "quotes" ' DOUBLE="expanded\nnewline # and 'quotes'" BACKTICK=`literal\n # and "double" and 'single'` MULTI="first second" MULTI_SINGLE='first second' MULTI_BACKTICK=`first second` EMPTY_SINGLE='' EMPTY_DOUBLE="" EMPTY_BACKTICK=`` TRAILING="kept" # ignored "other" JSON={"key":"value"} REFERENCE=$BASIC ${EXPORTED} UNQUOTED_ESCAPE=literal\nnewline __proto__=ordinary UNICODE=héllo 🌸 DUPLICATE=old DUPLICATE=new LAST=no final newline"# ) .replace('\n', "\r\n"); let output = run( "import {env} from 'avril:std'; console.log(JSON.stringify(env.toObject()));", &[("settings.env", file.as_bytes())], &["--env-file=settings.env"], &[], ); let snapshot: serde_json::Value = serde_json::from_str(&success(output)).unwrap(); assert_eq!( snapshot, serde_json::json!({ "BASIC": "basic", "HASH": "before", "EQUAL": "first=second", "EXPORTED": "yes", "EXPORTED_EMPTY": "", "EMPTY": "", "SPACE_EMPTY": "", "AFTER_EMPTY": "next", "SINGLE": " literal\\n # and \"quotes\" ", "DOUBLE": "expanded\nnewline # and 'quotes'", "BACKTICK": "literal\\n # and \"double\" and 'single'", "MULTI": "first\nsecond", "MULTI_SINGLE": "first\nsecond", "MULTI_BACKTICK": "first\nsecond", "EMPTY_SINGLE": "", "EMPTY_DOUBLE": "", "EMPTY_BACKTICK": "", "TRAILING": "kept", "JSON": "{\"key\":\"value\"}", "REFERENCE": "$BASIC ${EXPORTED}", "UNQUOTED_ESCAPE": "literal\\nnewline", "__proto__": "ordinary", "UNICODE": "héllo 🌸", "DUPLICATE": "new", "LAST": "no final newline" }) ); } #[test] fn inherited_values_win_and_later_files_override_earlier_files() { let output = run( r#" import {env} from 'avril:std'; console.log(env.get('SHARED'), env.get('HOST_EMPTY') === '', env.get('FILE'), env.get('NEW')); console.log(Object.keys(env.toObject()).sort().join(',')); "#, &[ ( "first.env", b"SHARED=first\nHOST_EMPTY=first\nFILE=first\nNEW=first\n", ), ( "second.env", b"SHARED=second\nHOST_EMPTY=second\nFILE=second\nNEW=\n", ), ], &[ "-0", "--allow-env=SHARED", "--allow-env=HOST_EMPTY", "--allow-env=FILE", "--allow-env=NEW", "--env-file=first.env", "--env-file", "second.env", ], &[ ("SHARED", "inherited"), ("HOST_EMPTY", ""), ("HIDDEN", "secret"), ], ); assert_eq!( success(output), "inherited true second \nFILE,HOST_EMPTY,NEW,SHARED\n" ); } #[test] fn explicit_env_grants_and_pledges_apply_to_file_values_without_granting_read_access() { let output = run( r#" import {env, errors, pledge} from 'avril:std'; import {readTextFile} from 'avril:fs'; console.log(env.get('ALLOWED'), env.has('EMPTY'), Object.keys(env.toObject()).sort().join(',')); for (const name of ['DENIED', 'AMBIENT']) { try {env.get(name); throw Error('Allowed');} catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;} } try {await readTextFile(new URL('../settings.env', import.meta.url)); throw Error('File allowed');} catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;} pledge({env: false}); try {env.get('ALLOWED'); throw Error('Pledge ignored');} catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;} console.log(Object.keys(env.toObject()).length); "#, &[("settings.env", b"ALLOWED=value\nEMPTY=\nDENIED=secret\n")], &[ "--deny-env=DENIED", "--env-file=settings.env", "-0", "--allow-env=ALLOWED", "--allow-env=EMPTY", "--allow-env=DENIED", "--deny-read=settings.env", ], &[("AMBIENT", "secret")], ); assert_eq!(success(output), "value true ALLOWED,EMPTY\n0\n"); } #[test] fn env_files_never_grant_environment_authority() { for args in [ vec!["-0", "--env-file=settings.env"], vec!["--env-file", "settings.env", "-0"], ] { let output = run( r#" import {env, errors} from 'avril:std'; for (const name of ['FILE', 'EMPTY', 'COLLISION', 'AMBIENT']) { 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(Object.keys(env.toObject()).length); "#, &[( "settings.env", b"FILE=value\nEMPTY=\nCOLLISION=file-value\n", )], &args, &[("COLLISION", "host-secret"), ("AMBIENT", "host-secret")], ); assert_eq!(success(output), "0\n"); } } #[test] fn file_values_have_no_node_options_special_handling() { let output = run(r#" import {env} from 'avril:std'; console.log(env.get('NODE_OPTIONS'), env.get('AVRIL_CACHE_DIR'), env.get('HOME')); "#, &[("settings.env", b"NODE_OPTIONS=--not-a-real-option\nAVRIL_CACHE_DIR=/nonexistent/forbidden\nHOME=/file-home\n")], &["--env-file=settings.env"], &[]); assert_eq!( success(output), "--not-a-real-option /nonexistent/forbidden /file-home\n" ); } #[test] fn file_values_do_not_reconfigure_the_package_cache() { let server = fixture::Registry::new(); server.package( "@test/env", "1.0.0", &[("mod.ts", "export const value = 'cached';")], &[(".", "./mod.ts")], ); let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("main.ts"), "import {value} from 'jsr:@test/env@1'; console.log(value);", ) .unwrap(); std::fs::write( dir.path().join("settings.env"), "AVRIL_CACHE_DIR=file-cache\nHOME=file-home\n", ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .env_clear() .args(["run", "--env-file=settings.env"]) .arg(format!("--jsr-registry={}", server.url)) .arg("main.ts") .output() .unwrap(); assert_eq!(success(output), "cached\n"); assert!(dir.path().join(".avril-cache").is_dir()); assert!(!dir.path().join("file-cache").exists()); assert!(!dir.path().join("file-home").exists()); } #[test] fn invalid_files_fail_before_javascript_executes_without_echoing_contents() { for (files, args, expected) in [ (vec![], vec!["--env-file=missing.env"], "missing.env"), ( vec![("bad.env", &b"TOKEN=secret\0tail"[..])], vec!["--env-file=bad.env"], "NUL", ), ( vec![("bad.env", &b"TOKEN=secret\xff"[..])], vec!["--env-file=bad.env"], "UTF-8", ), ( vec![("good.env", &b"TOKEN=valid"[..])], vec!["--env-file=good.env", "--env-file=missing.env"], "missing.env", ), ] { let output = run("console.log('executed');", &files, &args, &[]); assert!(!output.status.success()); assert!(output.stdout.is_empty()); let error = String::from_utf8_lossy(&output.stderr); assert!(error.contains(expected), "{error}"); assert!(!error.contains("secret"), "{error}"); } } #[test] fn empty_comment_only_and_unclosed_quoted_files_are_handled_without_hanging() { let output = run( "import {env} from 'avril:std'; console.log(JSON.stringify(env.toObject()));", &[ ("empty.env", b""), ("comments.env", b" \t\n #comment\ninvalid\n"), ( "unclosed.env", b"QUOTE=\"not closed # literal\nNEXT=parsed\nFINAL=", ), ], &[ "--env-file=empty.env", "--env-file=comments.env", "--env-file=unclosed.env", ], &[], ); let snapshot: serde_json::Value = serde_json::from_str(&success(output)).unwrap(); assert_eq!( snapshot, serde_json::json!({"QUOTE": "\"not closed # literal", "NEXT": "parsed", "FINAL": ""}) ); } #[test] fn env_file_is_run_only_and_requires_a_nonempty_path() { for command in ["repl", "cache", "info", "update", "vendor", "add"] { let output = Command::new(env!("CARGO_BIN_EXE_avril")) .args([command, "--env-file=missing.env"]) .output() .unwrap(); assert!(!output.status.success()); let error = String::from_utf8_lossy(&output.stderr); assert!( error.contains(&format!("--env-file is not an option of `avril {command}`")), "{error}" ); } for args in [ vec!["--env-file="], vec!["--env-file", ""], vec!["--env-file", "--offline"], ] { let output = run("console.log('executed');", &[], &args, &[]); assert!(!output.status.success()); assert!(output.stdout.is_empty()); assert!(String::from_utf8_lossy(&output.stderr).contains("Expected")); } let output = Command::new(env!("CARGO_BIN_EXE_avril")) .args(["run", "--env-file"]) .output() .unwrap(); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("Expected a path")); let help = Command::new(env!("CARGO_BIN_EXE_avril")) .args(["run", "--help"]) .output() .unwrap(); assert!(success(help).contains("--env-file=PATH")); } #[test] fn env_flags_after_double_dash_are_entry_paths_not_options() { let dir = tempfile::tempdir().unwrap(); std::fs::write( dir.path().join("--env-file=missing.env"), "console.log('entry');", ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_avril")) .current_dir(dir.path()) .args(["run", "--", "--env-file=missing.env"]) .output() .unwrap(); assert_eq!(success(output), "entry\n"); } #[test] fn equals_form_accepts_dash_prefixed_paths_without_changing_zero_permissions() { let output = run( "import {env} from 'avril:std'; console.log(Object.keys(env.toObject()).join(','));", &[("-0", b"FILE=value")], &["--env-file=-0", "-0", "--allow-env=FILE"], &[("HOST", "hidden")], ); assert_eq!(success(output), "FILE\n"); }