char/avril

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

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

Charlotte Somsnapshot: embed shared main and worker startup in the CLI688e96e

main
12.1 KiB362 linesraw
1#[path = "common/registry.rs"]
2mod fixture;
3
4use std::process::{Command, Output};
5
6fn run(source: &str, files: &[(&str, &[u8])], args: &[&str], values: &[(&str, &str)]) -> Output {
7    let dir = tempfile::tempdir().unwrap();
8    std::fs::create_dir(dir.path().join("app")).unwrap();
9    std::fs::write(dir.path().join("app/main.ts"), source).unwrap();
10    for (name, content) in files {
11        std::fs::write(dir.path().join(name), content).unwrap();
12    }
13    Command::new(env!("CARGO_BIN_EXE_avril"))
14        .current_dir(dir.path())
15        .env_clear()
16        .envs(values.iter().copied())
17        .arg("run")
18        .args(args)
19        .arg("app/main.ts")
20        .output()
21        .unwrap()
22}
23
24fn success(output: Output) -> String {
25    assert!(
26        output.status.success(),
27        "{}",
28        String::from_utf8_lossy(&output.stderr)
29    );
30    String::from_utf8(output.stdout).unwrap()
31}
32
33#[test]
34fn dotenv_syntax_supports_quotes_comments_exports_multiline_and_literal_values() {
35    let file = concat!(
36        "\u{feff}",
37        r#"
38        # ignored = comment
39        no assignment here
40        = ignored
41        BASIC = basic # comment
42        HASH=before#after
43        EQUAL=first=second
44        export   EXPORTED = yes
45        export EXPORTED_EMPTY=
46        EMPTY=
47        SPACE_EMPTY=    
48        AFTER_EMPTY=next
49        SINGLE='  literal\n # and "quotes"  '
50        DOUBLE="expanded\nnewline # and 'quotes'"
51        BACKTICK=`literal\n # and "double" and 'single'`
52        MULTI="first
53second"
54        MULTI_SINGLE='first
55second'
56        MULTI_BACKTICK=`first
57second`
58        EMPTY_SINGLE=''
59        EMPTY_DOUBLE=""
60        EMPTY_BACKTICK=``
61        TRAILING="kept" # ignored "other"
62        JSON={"key":"value"}
63        REFERENCE=$BASIC ${EXPORTED}
64        UNQUOTED_ESCAPE=literal\nnewline
65        __proto__=ordinary
66        UNICODE=héllo 🌸
67        DUPLICATE=old
68        DUPLICATE=new
69        LAST=no final newline"#
70    )
71    .replace('\n', "\r\n");
72    let output = run(
73        "import {env} from 'avril:std'; console.log(JSON.stringify(env.toObject()));",
74        &[("settings.env", file.as_bytes())],
75        &["--env-file=settings.env"],
76        &[],
77    );
78    let snapshot: serde_json::Value = serde_json::from_str(&success(output)).unwrap();
79    assert_eq!(
80        snapshot,
81        serde_json::json!({
82            "BASIC": "basic", "HASH": "before", "EQUAL": "first=second", "EXPORTED": "yes",
83            "EXPORTED_EMPTY": "", "EMPTY": "", "SPACE_EMPTY": "", "AFTER_EMPTY": "next",
84            "SINGLE": "  literal\\n # and \"quotes\"  ",
85            "DOUBLE": "expanded\nnewline # and 'quotes'",
86            "BACKTICK": "literal\\n # and \"double\" and 'single'",
87            "MULTI": "first\nsecond", "MULTI_SINGLE": "first\nsecond", "MULTI_BACKTICK": "first\nsecond",
88            "EMPTY_SINGLE": "", "EMPTY_DOUBLE": "", "EMPTY_BACKTICK": "",
89            "TRAILING": "kept", "JSON": "{\"key\":\"value\"}", "REFERENCE": "$BASIC ${EXPORTED}",
90            "UNQUOTED_ESCAPE": "literal\\nnewline", "__proto__": "ordinary", "UNICODE": "héllo 🌸",
91            "DUPLICATE": "new", "LAST": "no final newline"
92        })
93    );
94}
95
96#[test]
97fn inherited_values_win_and_later_files_override_earlier_files() {
98    let output = run(
99        r#"
100        import {env} from 'avril:std';
101        console.log(env.get('SHARED'), env.get('HOST_EMPTY') === '', env.get('FILE'), env.get('NEW'));
102        console.log(Object.keys(env.toObject()).sort().join(','));
103    "#,
104        &[
105            (
106                "first.env",
107                b"SHARED=first\nHOST_EMPTY=first\nFILE=first\nNEW=first\n",
108            ),
109            (
110                "second.env",
111                b"SHARED=second\nHOST_EMPTY=second\nFILE=second\nNEW=\n",
112            ),
113        ],
114        &[
115            "-0",
116            "--allow-env=SHARED",
117            "--allow-env=HOST_EMPTY",
118            "--allow-env=FILE",
119            "--allow-env=NEW",
120            "--env-file=first.env",
121            "--env-file",
122            "second.env",
123        ],
124        &[
125            ("SHARED", "inherited"),
126            ("HOST_EMPTY", ""),
127            ("HIDDEN", "secret"),
128        ],
129    );
130    assert_eq!(
131        success(output),
132        "inherited true second \nFILE,HOST_EMPTY,NEW,SHARED\n"
133    );
134}
135
136#[test]
137fn explicit_env_grants_and_pledges_apply_to_file_values_without_granting_read_access() {
138    let output = run(
139        r#"
140        import {env, errors, pledge} from 'avril:std';
141        import {readTextFile} from 'avril:fs';
142        console.log(env.get('ALLOWED'), env.has('EMPTY'), Object.keys(env.toObject()).sort().join(','));
143        for (const name of ['DENIED', 'AMBIENT']) {
144            try {env.get(name); throw Error('Allowed');}
145            catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
146        }
147        try {await readTextFile(new URL('../settings.env', import.meta.url)); throw Error('File allowed');}
148        catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
149        pledge({env: false});
150        try {env.get('ALLOWED'); throw Error('Pledge ignored');}
151        catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
152        console.log(Object.keys(env.toObject()).length);
153    "#,
154        &[("settings.env", b"ALLOWED=value\nEMPTY=\nDENIED=secret\n")],
155        &[
156            "--deny-env=DENIED",
157            "--env-file=settings.env",
158            "-0",
159            "--allow-env=ALLOWED",
160            "--allow-env=EMPTY",
161            "--allow-env=DENIED",
162            "--deny-read=settings.env",
163        ],
164        &[("AMBIENT", "secret")],
165    );
166    assert_eq!(success(output), "value true ALLOWED,EMPTY\n0\n");
167}
168
169#[test]
170fn env_files_never_grant_environment_authority() {
171    for args in [
172        vec!["-0", "--env-file=settings.env"],
173        vec!["--env-file", "settings.env", "-0"],
174    ] {
175        let output = run(
176            r#"
177            import {env, errors} from 'avril:std';
178            for (const name of ['FILE', 'EMPTY', 'COLLISION', 'AMBIENT']) {
179                for (const action of [() => env.get(name), () => env.has(name),
180                    () => env.set(name, 'replacement'), () => env.delete(name)]) {
181                    try {action(); throw Error('Allowed');}
182                    catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
183                }
184            }
185            console.log(Object.keys(env.toObject()).length);
186        "#,
187            &[(
188                "settings.env",
189                b"FILE=value\nEMPTY=\nCOLLISION=file-value\n",
190            )],
191            &args,
192            &[("COLLISION", "host-secret"), ("AMBIENT", "host-secret")],
193        );
194        assert_eq!(success(output), "0\n");
195    }
196}
197
198#[test]
199fn file_values_have_no_node_options_special_handling() {
200    let output = run(r#"
201        import {env} from 'avril:std';
202        console.log(env.get('NODE_OPTIONS'), env.get('AVRIL_CACHE_DIR'), env.get('HOME'));
203    "#, &[("settings.env", b"NODE_OPTIONS=--not-a-real-option\nAVRIL_CACHE_DIR=/nonexistent/forbidden\nHOME=/file-home\n")],
204        &["--env-file=settings.env"], &[]);
205    assert_eq!(
206        success(output),
207        "--not-a-real-option /nonexistent/forbidden /file-home\n"
208    );
209}
210
211#[test]
212fn file_values_do_not_reconfigure_the_package_cache() {
213    let server = fixture::Registry::new();
214    server.package(
215        "@test/env",
216        "1.0.0",
217        &[("mod.ts", "export const value = 'cached';")],
218        &[(".", "./mod.ts")],
219    );
220    let dir = tempfile::tempdir().unwrap();
221    std::fs::write(
222        dir.path().join("main.ts"),
223        "import {value} from 'jsr:@test/env@1'; console.log(value);",
224    )
225    .unwrap();
226    std::fs::write(
227        dir.path().join("settings.env"),
228        "AVRIL_CACHE_DIR=file-cache\nHOME=file-home\n",
229    )
230    .unwrap();
231    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
232        .current_dir(dir.path())
233        .env_clear()
234        .args(["run", "--env-file=settings.env"])
235        .arg(format!("--jsr-registry={}", server.url))
236        .arg("main.ts")
237        .output()
238        .unwrap();
239    assert_eq!(success(output), "cached\n");
240    assert!(dir.path().join(".avril-cache").is_dir());
241    assert!(!dir.path().join("file-cache").exists());
242    assert!(!dir.path().join("file-home").exists());
243}
244
245#[test]
246fn invalid_files_fail_before_javascript_executes_without_echoing_contents() {
247    for (files, args, expected) in [
248        (vec![], vec!["--env-file=missing.env"], "missing.env"),
249        (
250            vec![("bad.env", &b"TOKEN=secret\0tail"[..])],
251            vec!["--env-file=bad.env"],
252            "NUL",
253        ),
254        (
255            vec![("bad.env", &b"TOKEN=secret\xff"[..])],
256            vec!["--env-file=bad.env"],
257            "UTF-8",
258        ),
259        (
260            vec![("good.env", &b"TOKEN=valid"[..])],
261            vec!["--env-file=good.env", "--env-file=missing.env"],
262            "missing.env",
263        ),
264    ] {
265        let output = run("console.log('executed');", &files, &args, &[]);
266        assert!(!output.status.success());
267        assert!(output.stdout.is_empty());
268        let error = String::from_utf8_lossy(&output.stderr);
269        assert!(error.contains(expected), "{error}");
270        assert!(!error.contains("secret"), "{error}");
271    }
272}
273
274#[test]
275fn empty_comment_only_and_unclosed_quoted_files_are_handled_without_hanging() {
276    let output = run(
277        "import {env} from 'avril:std'; console.log(JSON.stringify(env.toObject()));",
278        &[
279            ("empty.env", b""),
280            ("comments.env", b" \t\n #comment\ninvalid\n"),
281            (
282                "unclosed.env",
283                b"QUOTE=\"not closed # literal\nNEXT=parsed\nFINAL=",
284            ),
285        ],
286        &[
287            "--env-file=empty.env",
288            "--env-file=comments.env",
289            "--env-file=unclosed.env",
290        ],
291        &[],
292    );
293    let snapshot: serde_json::Value = serde_json::from_str(&success(output)).unwrap();
294    assert_eq!(
295        snapshot,
296        serde_json::json!({"QUOTE": "\"not closed # literal", "NEXT": "parsed", "FINAL": ""})
297    );
298}
299
300#[test]
301fn env_file_is_run_only_and_requires_a_nonempty_path() {
302    for command in ["repl", "cache", "info", "update", "vendor", "add"] {
303        let output = Command::new(env!("CARGO_BIN_EXE_avril"))
304            .args([command, "--env-file=missing.env"])
305            .output()
306            .unwrap();
307        assert!(!output.status.success());
308        let error = String::from_utf8_lossy(&output.stderr);
309        assert!(
310            error.contains(&format!("--env-file is not an option of `avril {command}`")),
311            "{error}"
312        );
313    }
314    for args in [
315        vec!["--env-file="],
316        vec!["--env-file", ""],
317        vec!["--env-file", "--offline"],
318    ] {
319        let output = run("console.log('executed');", &[], &args, &[]);
320        assert!(!output.status.success());
321        assert!(output.stdout.is_empty());
322        assert!(String::from_utf8_lossy(&output.stderr).contains("Expected"));
323    }
324    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
325        .args(["run", "--env-file"])
326        .output()
327        .unwrap();
328    assert!(!output.status.success());
329    assert!(String::from_utf8_lossy(&output.stderr).contains("Expected a path"));
330    let help = Command::new(env!("CARGO_BIN_EXE_avril"))
331        .args(["run", "--help"])
332        .output()
333        .unwrap();
334    assert!(success(help).contains("--env-file=PATH"));
335}
336
337#[test]
338fn env_flags_after_double_dash_are_entry_paths_not_options() {
339    let dir = tempfile::tempdir().unwrap();
340    std::fs::write(
341        dir.path().join("--env-file=missing.env"),
342        "console.log('entry');",
343    )
344    .unwrap();
345    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
346        .current_dir(dir.path())
347        .args(["run", "--", "--env-file=missing.env"])
348        .output()
349        .unwrap();
350    assert_eq!(success(output), "entry\n");
351}
352
353#[test]
354fn equals_form_accepts_dash_prefixed_paths_without_changing_zero_permissions() {
355    let output = run(
356        "import {env} from 'avril:std'; console.log(Object.keys(env.toObject()).join(','));",
357        &[("-0", b"FILE=value")],
358        &["--env-file=-0", "-0", "--allow-env=FILE"],
359        &[("HOST", "hidden")],
360    );
361    assert_eq!(success(output), "FILE\n");
362}