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
34.9 KiB993 linesraw
1use std::fs;
2use std::path::Path;
3use std::process::{Command, Output};
4use std::sync::Arc;
5
6use avril_runtime::caps::{Capabilities, Category, Initial, NetGrant, PathTarget};
7
8fn policy(zero: bool, grants: &[(Category, bool, &str)], cwd: &Path) -> Arc<Capabilities> {
9    let mut initial = Initial::new(zero);
10    for (category, deny, value) in grants {
11        initial.grant(*category, *deny, value, cwd).unwrap();
12    }
13    Capabilities::new(initial, cwd.to_owned())
14}
15
16fn run(dir: &Path, args: &[&str]) -> Output {
17    Command::new(env!("CARGO_BIN_EXE_avril"))
18        .arg("run")
19        .current_dir(dir)
20        .args(args)
21        .output()
22        .unwrap()
23}
24
25#[test]
26fn network_grants_are_exact_normalized_and_port_scoped() {
27    let dir = tempfile::tempdir().unwrap();
28    let caps = policy(
29        true,
30        &[
31            (Category::Net, false, "BÜCHER.example"),
32            (Category::Net, false, "localhost:80"),
33            (Category::Net, false, "[2001:0db8::1]:443"),
34            (Category::Net, false, "[::2]"),
35            (Category::Net, false, "0x7f.1"),
36            (Category::Net, true, "bücher.example:443"),
37        ],
38        dir.path(),
39    );
40    assert!(caps.check_net("xn--bcher-kva.example.", 8080).is_ok());
41    assert!(caps.check_net("bücher.example。", 8080).is_ok());
42    assert!(caps.check_net("bücher.example", 443).is_err());
43    assert!(caps.check_net("sub.xn--bcher-kva.example", 8080).is_err());
44    assert!(caps.check_net("localhost", 80).is_ok());
45    assert!(caps.check_net("localhost", 443).is_err());
46    assert!(caps.check_net("[2001:db8::1]", 443).is_ok());
47    assert!(caps.check_net("[2001:db8::1]", 80).is_err());
48    assert!(caps.check_net("2001:db8::1", 443).is_ok());
49    assert!(caps.check_net("2001:0db8::1", 443).is_ok());
50    assert!(caps.check_net("2001:db8::1", 80).is_err());
51    assert!(caps.check_net("2001:db8::2", 443).is_err());
52    assert!(caps.check_net("[2001:db8::1]:443", 443).is_err());
53    assert!(caps.check_net("::2", 80).is_ok());
54    assert!(caps.check_net("::2", 443).is_ok());
55    assert!(caps.check_net("127.0.0.1", 1234).is_ok());
56    for grant in [
57        "",
58        "a,b",
59        "https://a",
60        "user@a",
61        "a/path",
62        "a?b",
63        "a#b",
64        "a:",
65        "a:65536",
66        "a:1x",
67        "a:-1",
68        "::1",
69        "[::1]extra",
70        "[::1]extra:80",
71        "[::1]:",
72        "[::1]:80]",
73        "a b",
74        "*.example.com",
75        "a%2eb",
76    ] {
77        assert!(NetGrant::parse(grant).is_err(), "{grant}");
78    }
79}
80
81#[test]
82fn pledges_are_atomic_monotonic_and_inherited_as_snapshots() {
83    let dir = tempfile::tempdir().unwrap();
84    let caps = policy(false, &[], dir.path());
85    assert!(
86        caps.pledge(vec![
87            (Category::Net, Some(vec![]), vec![]),
88            (Category::Read, None, vec!["\0".into()])
89        ])
90        .is_err()
91    );
92    assert!(caps.check_net("a.example", 80).is_ok());
93    let child = Capabilities::inherit(caps.clone());
94    child
95        .pledge(vec![(
96            Category::Net,
97            Some(vec!["a.example".into(), "b.example".into()]),
98            vec![],
99        )])
100        .unwrap();
101    assert!(child.check_net("a.example", 80).is_ok());
102    assert!(child.check_net("c.example", 80).is_err());
103    caps.pledge(vec![(Category::Net, None, vec!["a.example:80".into()])])
104        .unwrap();
105    assert!(caps.check_net("a.example", 80).is_err());
106    assert!(child.check_net("a.example", 80).is_ok());
107    assert!(child.check_net("a.example", 443).is_ok());
108    child
109        .pledge(vec![(
110            Category::Net,
111            Some(vec!["b.example".into(), "c.example".into()]),
112            vec![],
113        )])
114        .unwrap();
115    assert!(child.check_net("a.example", 443).is_err());
116    assert!(child.check_net("b.example", 443).is_ok());
117    assert!(child.check_net("c.example", 443).is_err());
118    caps.pledge(vec![(Category::Net, Some(vec![]), vec![])])
119        .unwrap();
120    assert!(caps.check_net("b.example", 443).is_err());
121    assert!(child.check_net("b.example", 443).is_ok());
122    let later = Capabilities::inherit(caps.clone());
123    assert!(later.check_net("b.example", 443).is_err());
124    assert!(
125        policy(true, &[], dir.path())
126            .check_net("b.example", 443)
127            .is_err()
128    );
129}
130
131#[test]
132fn read_implies_module_loading_but_execution_denials_override_it() {
133    let dir = tempfile::tempdir().unwrap();
134    fs::create_dir(dir.path().join("app")).unwrap();
135    fs::create_dir(dir.path().join("application")).unwrap();
136    for file in ["app/public.js", "app/private.js", "application/other.js"] {
137        fs::write(dir.path().join(file), "").unwrap();
138    }
139    let public = PathTarget::resolve(dir.path().join("app/public.js")).unwrap();
140    let private = PathTarget::resolve(dir.path().join("app/private.js")).unwrap();
141    let outside = PathTarget::resolve(dir.path().join("application/other.js")).unwrap();
142    let caps = policy(
143        true,
144        &[
145            (Category::Read, false, "app"),
146            (Category::JsExec, true, "app/private.js"),
147        ],
148        dir.path(),
149    );
150    assert!(caps.check_read(&private).is_ok());
151    assert!(caps.check_js_exec(&private).is_err());
152    assert!(caps.check_js_exec(&public).is_ok());
153    assert!(caps.check_js_exec(&outside).is_err());
154    caps.pledge(vec![(
155        Category::JsExec,
156        Some(vec!["application".into()]),
157        vec![],
158    )])
159    .unwrap();
160    assert!(caps.check_js_exec(&public).is_err());
161    let exec_only = policy(true, &[(Category::JsExec, false, "app")], dir.path());
162    assert!(exec_only.check_js_exec(&public).is_ok());
163    assert!(exec_only.check_read(&public).is_err());
164    let inherited_read = policy(true, &[(Category::Read, false, "app")], dir.path());
165    inherited_read
166        .pledge(vec![(Category::Read, Some(vec![]), vec![])])
167        .unwrap();
168    assert!(inherited_read.check_js_exec(&public).is_err());
169}
170
171#[test]
172fn zero_keeps_stdio_and_the_entry_but_not_its_directory() {
173    let dir = tempfile::tempdir().unwrap();
174    fs::write(
175        dir.path().join("main.js"),
176        r#"
177        console.log('stdio');
178        for (const path of ['./dep.js', '././dep.js']) {
179            try { await import(path); throw new Error('Unexpected import'); }
180            catch (e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
181        }
182        export const main = import.meta.main;
183        import(import.meta.url).then(namespace => console.log(namespace.main));
184    "#,
185    )
186    .unwrap();
187    fs::write(
188        dir.path().join("dep.js"),
189        "throw new Error('Executed denied module');",
190    )
191    .unwrap();
192    let output = run(dir.path(), &["-0", "main.js"]);
193    assert!(
194        output.status.success(),
195        "{}",
196        String::from_utf8_lossy(&output.stderr)
197    );
198    assert_eq!(output.stdout, b"stdio\ntrue\n");
199}
200
201#[test]
202fn repeated_cli_grants_preserve_commas_and_apply_denials_last() {
203    let dir = tempfile::tempdir().unwrap();
204    for folder in ["app, data", "other", "app, data/private"] {
205        fs::create_dir_all(dir.path().join(folder)).unwrap();
206    }
207    fs::write(
208        dir.path().join("app, data/one.ts"),
209        "export const one: number = 1;",
210    )
211    .unwrap();
212    fs::write(dir.path().join("other/two.js"), "export const two = 2;").unwrap();
213    fs::write(dir.path().join("app, data/private/no.js"), "export {};").unwrap();
214    fs::write(
215        dir.path().join("main.js"),
216        r#"
217        import {one} from './app, data/one.ts'; import {two} from './other/two.js';
218        try { await import('./app, data/private/no.js'); throw new Error('Unexpected import'); }
219        catch (e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
220        console.log(one + two);
221    "#,
222    )
223    .unwrap();
224    for args in [
225        vec![
226            "-0",
227            "--allow-read=app, data",
228            "--allow-js-exec=other",
229            "--deny-js-exec=app, data/private",
230            "main.js",
231        ],
232        vec![
233            "--deny-js-exec=app, data/private",
234            "main.js",
235            "--allow-js-exec=other",
236            "--allow-read=app, data",
237            "-0",
238        ],
239    ] {
240        let output = run(dir.path(), &args);
241        assert!(
242            output.status.success(),
243            "{}",
244            String::from_utf8_lossy(&output.stderr)
245        );
246        assert_eq!(output.stdout, b"3\n");
247    }
248    let output = run(dir.path(), &["-0", "main.js"]);
249    assert!(!output.status.success());
250    assert!(String::from_utf8_lossy(&output.stderr).contains("PermissionDenied: js-exec"));
251}
252
253#[test]
254fn runtime_pledges_recheck_cached_imports_and_cannot_restore_authority() {
255    let dir = tempfile::tempdir().unwrap();
256    fs::write(dir.path().join("a.js"), "export const value = 42;").unwrap();
257    fs::write(dir.path().join("b.js"), "export const value = 7;").unwrap();
258    fs::write(
259        dir.path().join("main.js"),
260        r#"
261        import { pledge } from 'avril:std';
262        const a = await import('./a.js');
263        pledge({jsExec:{deny:['./a.js']}});
264        for (const path of ['./a.js', new URL('./a.js', import.meta.url)]) {
265            try { await import(path); throw new Error('Unexpected cached import'); }
266            catch (e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
267        }
268        pledge({jsExec:{allow:['.']}});
269        console.log(a.value, (await import('./b.js')).value);
270        pledge({jsExec:false});
271        pledge({jsExec:{allow:['.']}});
272        try { await import('./b.js'); throw new Error('Restored authority'); }
273        catch (e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
274        console.log('restricted');
275    "#,
276    )
277    .unwrap();
278    let output = run(dir.path(), &["main.js"]);
279    assert!(
280        output.status.success(),
281        "{}",
282        String::from_utf8_lossy(&output.stderr)
283    );
284    assert_eq!(output.stdout, b"42 7\nrestricted\n");
285}
286
287#[test]
288fn invalid_pledges_do_not_partially_commit_and_captured_intrinsics_keep_working() {
289    let dir = tempfile::tempdir().unwrap();
290    fs::write(dir.path().join("dep.js"), "export const value = 42;").unwrap();
291    fs::write(
292        dir.path().join("main.js"),
293        r#"
294        import { pledge } from 'avril:std';
295        for (const request of [{jsExec:false,net:{deny:['a,b']}}, {jsExec:false,read:{deny:['\0']}},
296            {jsExec:false,unknown:false}, {jsExec:{allow:'not-an-array'}}, {jsExec:true}, null]) {
297            try { pledge(request); throw new Error('Expected invalid pledge'); }
298            catch(e) { if (!(e instanceof TypeError)) throw e; }
299        }
300        console.log((await import('./dep.js')).value);
301        const strings = String.prototype;
302        const poison = () => { throw new Error('Poisoned intrinsic'); };
303        globalThis.String = poison; strings.toWellFormed = poison;
304        Array.prototype.push = poison;
305        Object.defineProperty(Array.prototype,'1',{set:poison,configurable:true});
306        pledge({net:{allow:['a.example','b.example']},jsExec:{deny:['dep.js']}});
307        delete Array.prototype[1];
308        try { await import('./dep.js'); throw new Error('Unexpected import'); }
309        catch(e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
310        console.log('atomic');
311    "#,
312    )
313    .unwrap();
314    let output = run(dir.path(), &["main.js"]);
315    assert!(
316        output.status.success(),
317        "{}",
318        String::from_utf8_lossy(&output.stderr)
319    );
320    assert_eq!(output.stdout, b"42\natomic\n");
321}
322
323#[cfg(unix)]
324#[test]
325fn symlink_aliases_cannot_escape_grants_or_execution_denials() {
326    use std::os::unix::fs::symlink;
327    let dir = tempfile::tempdir().unwrap();
328    fs::create_dir(dir.path().join("app")).unwrap();
329    fs::create_dir(dir.path().join("private")).unwrap();
330    fs::write(dir.path().join("private/no.js"), "export {};").unwrap();
331    symlink("../private/no.js", dir.path().join("app/alias.js")).unwrap();
332    fs::write(
333        dir.path().join("main.js"),
334        "await import('./app/alias.js');",
335    )
336    .unwrap();
337    for args in [
338        vec!["-0", "--allow-read=app", "main.js"],
339        vec!["--deny-js-exec=private", "main.js"],
340    ] {
341        let output = run(dir.path(), &args);
342        assert!(!output.status.success());
343        assert!(String::from_utf8_lossy(&output.stderr).contains("PermissionDenied: js-exec"));
344    }
345    let output = run(
346        dir.path(),
347        &["-0", "--allow-js-exec=app/alias.js", "main.js"],
348    );
349    assert!(
350        output.status.success(),
351        "{}",
352        String::from_utf8_lossy(&output.stderr)
353    );
354}
355
356#[test]
357fn cli_rejects_malformed_grants_and_unknown_options() {
358    let dir = tempfile::tempdir().unwrap();
359    fs::write(dir.path().join("main.js"), "console.log('executed');").unwrap();
360    for flag in [
361        "--allow-net=a,b",
362        "--allow-net=host:65536",
363        "--allow-net=",
364        "--allow-net=https://example.com",
365        "--allow-read=",
366        "--allow-js-exec",
367        "--allow-unknown=x",
368        "--unknown=x",
369    ] {
370        let output = run(dir.path(), &[flag, "main.js"]);
371        assert!(!output.status.success(), "{flag}");
372        assert!(output.stdout.is_empty());
373    }
374    let output = run(
375        dir.path(),
376        &[
377            "-0",
378            "--allow-net=host1",
379            "--allow-net=host2:443",
380            "main.js",
381        ],
382    );
383    assert!(output.status.success());
384    assert_eq!(output.stdout, b"executed\n");
385}
386
387#[test]
388fn queued_import_of_an_unevaluated_cached_module_is_rechecked() {
389    let dir = tempfile::tempdir().unwrap();
390    fs::write(
391        dir.path().join("graph.js"),
392        "import './throw.js'; import './later.js';",
393    )
394    .unwrap();
395    fs::write(
396        dir.path().join("throw.js"),
397        "throw new Error('Stop this graph');",
398    )
399    .unwrap();
400    fs::write(
401        dir.path().join("later.js"),
402        "import './dep.js'; globalThis.executed = true;",
403    )
404    .unwrap();
405    fs::write(
406        dir.path().join("dep.js"),
407        "globalThis.executedDependency = true;",
408    )
409    .unwrap();
410    for (file, source) in [
411        (
412            "diamond.js",
413            "import './throw.js'; import './left.js'; import './right.js';",
414        ),
415        ("left.js", "import './shared.js';"),
416        ("right.js", "import './shared.js';"),
417        (
418            "shared.js",
419            "import './dep.js'; globalThis.executed = true;",
420        ),
421        ("cycle.js", "import './throw.js'; import './x.js';"),
422        ("x.js", "import './y.js'; globalThis.executed = true;"),
423        ("y.js", "import './x.js'; import './dep.js';"),
424    ] {
425        fs::write(dir.path().join(file), source).unwrap();
426    }
427    for (graph, parent, denied) in [
428        ("./graph.js", "./later.js", "./later.js"),
429        ("./graph.js", "./later.js", "./dep.js"),
430        ("./diamond.js", "./left.js", "./dep.js"),
431        ("./cycle.js", "./x.js", "./dep.js"),
432    ] {
433        let source = r#"
434            import { pledge } from 'avril:std';
435            await import('GRAPH').catch(() => {});
436            const pending = import('PARENT');
437            pledge({jsExec:{deny:['DENIED']}});
438            try { await pending; throw new Error('Expected queued denial'); }
439            catch(e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
440            console.log(globalThis.executed === undefined && globalThis.executedDependency === undefined);
441        "#.replace("DENIED", denied).replace("GRAPH", graph).replace("PARENT", parent);
442        fs::write(dir.path().join("main.js"), source).unwrap();
443        let output = run(dir.path(), &["main.js"]);
444        assert!(
445            output.status.success(),
446            "{}",
447            String::from_utf8_lossy(&output.stderr)
448        );
449        assert_eq!(output.stdout, b"true\n");
450    }
451}
452
453#[test]
454fn denied_imports_do_not_disclose_canonical_targets_or_resolution_errors() {
455    let dir = tempfile::tempdir().unwrap();
456    fs::write(dir.path().join("existing.js"), "export {};").unwrap();
457    #[cfg(unix)]
458    std::os::unix::fs::symlink("existing.js", dir.path().join("alias.js")).unwrap();
459    fs::write(
460        dir.path().join("main.js"),
461        r#"
462        for(const specifier of ['./existing.js', './missing.js', './alias.js']) {
463            try { await import(specifier); throw new Error('Expected denial'); }
464            catch(e) { if (e.message !== 'PermissionDenied: js-exec ' + specifier) throw e; }
465        }
466        console.log('denied');
467    "#,
468    )
469    .unwrap();
470    let output = run(dir.path(), &["-0", "main.js"]);
471    assert!(
472        output.status.success(),
473        "{}",
474        String::from_utf8_lossy(&output.stderr)
475    );
476    assert_eq!(output.stdout, b"denied\n");
477}
478
479#[test]
480fn pledge_is_sealed_and_accepts_module_relative_file_urls() {
481    let dir = tempfile::tempdir().unwrap();
482    fs::create_dir(dir.path().join("app")).unwrap();
483    fs::write(
484        dir.path().join("app/blocked.js"),
485        "throw new Error('Should not execute');",
486    )
487    .unwrap();
488    fs::write(
489        dir.path().join("app/main.js"),
490        r#"
491        import { pledge } from 'avril:std';
492        import * as std from 'avril:std';
493        if (Symbol.for('__avril.base') in globalThis) throw new Error('Leaked base');
494        try { std.pledge = () => {}; throw new Error('Replaced pledge'); }
495        catch(e) { if (!(e instanceof TypeError)) throw e; }
496        pledge({jsExec:{deny:[new URL('./blocked.js',import.meta.url)]}});
497        try { await import('./blocked.js'); throw new Error('Expected denial'); }
498        catch(e) { if (!e.message.includes('PermissionDenied: js-exec')) throw e; }
499        console.log('sealed');
500    "#,
501    )
502    .unwrap();
503    let output = run(dir.path(), &["app/main.js"]);
504    assert!(
505        output.status.success(),
506        "{}",
507        String::from_utf8_lossy(&output.stderr)
508    );
509    assert_eq!(output.stdout, b"sealed\n");
510}
511
512#[test]
513fn inherited_byte_streams_remain_available_with_zero_and_after_pledging() {
514    use std::io::Write;
515    use std::process::Stdio;
516    let dir = tempfile::tempdir().unwrap();
517    fs::write(
518        dir.path().join("main.js"),
519        r#"
520        import { pledge, stdin, stdout, stderr } from 'avril:std';
521        pledge({net:false,read:false,jsExec:false});
522        await stderr.getWriter().write(new Uint8Array([5,6]));
523        await stdin.pipeTo(stdout);
524    "#,
525    )
526    .unwrap();
527    let mut child = Command::new(env!("CARGO_BIN_EXE_avril"))
528        .arg("run")
529        .current_dir(dir.path())
530        .args(["-0", "main.js"])
531        .stdin(Stdio::piped())
532        .stdout(Stdio::piped())
533        .stderr(Stdio::piped())
534        .spawn()
535        .unwrap();
536    let input = [0, 1, 127, 128, 255];
537    child.stdin.take().unwrap().write_all(&input).unwrap();
538    let output = child.wait_with_output().unwrap();
539    assert!(
540        output.status.success(),
541        "{}",
542        String::from_utf8_lossy(&output.stderr)
543    );
544    assert_eq!(output.stdout, input);
545    assert_eq!(output.stderr, [5, 6]);
546}
547
548#[test]
549fn write_grants_are_independent_and_missing_files_are_exact() {
550    let dir = tempfile::tempdir().unwrap();
551    let target = |name: &str| PathTarget::for_creation(dir.path().join(name)).unwrap();
552    let caps = policy(
553        true,
554        &[
555            (Category::Write, false, "new.txt"),
556            (Category::Write, false, "future/"),
557            (Category::Write, true, "future/private/"),
558            (Category::Write, false, "a, b"),
559            (Category::Write, false, "second"),
560        ],
561        dir.path(),
562    );
563    for path in ["new.txt", "future", "future/file", "a, b", "second"] {
564        assert!(caps.check_write(&target(path)).is_ok(), "{path}");
565    }
566    for path in [
567        "new.txt/child",
568        "future/private",
569        "future/private/file",
570        "a",
571        "b",
572        "other",
573    ] {
574        assert!(caps.check_write(&target(path)).is_err(), "{path}");
575    }
576    assert!(caps.check_read(&target("new.txt")).is_err());
577    assert!(caps.check_js_exec(&target("new.txt")).is_err());
578    assert!(
579        policy(false, &[], dir.path())
580            .check_write(&target("anything"))
581            .is_ok()
582    );
583    assert!(
584        policy(true, &[], dir.path())
585            .check_write(&target("anything"))
586            .is_err()
587    );
588}
589
590#[test]
591fn write_pledges_are_atomic_monotonic_and_snapshot_for_children() {
592    let dir = tempfile::tempdir().unwrap();
593    let target = PathTarget::for_creation(dir.path().join("new.txt")).unwrap();
594    let caps = policy(false, &[], dir.path());
595    let child = Capabilities::inherit(caps.clone());
596    assert!(
597        caps.pledge(vec![
598            (Category::Write, Some(vec![]), vec![]),
599            (Category::Read, None, vec!["\0".into()])
600        ])
601        .is_err()
602    );
603    assert!(child.check_write(&target).is_ok());
604    caps.pledge(vec![(
605        Category::Write,
606        Some(vec!["new.txt".into()]),
607        vec![],
608    )])
609    .unwrap();
610    assert!(child.check_write(&target).is_ok());
611    child
612        .pledge(vec![(Category::Write, Some(vec![]), vec![])])
613        .unwrap();
614    child
615        .pledge(vec![(
616            Category::Write,
617            Some(vec!["new.txt".into()]),
618            vec![],
619        )])
620        .unwrap();
621    assert!(child.check_write(&target).is_err());
622    // Existing workers keep their snapshot; later ones inherit the denial.
623    let other = Capabilities::inherit(caps.clone());
624    assert!(other.check_write(&target).is_ok());
625    caps.pledge(vec![(Category::Write, None, vec!["new.txt".into()])])
626        .unwrap();
627    assert!(caps.check_write(&target).is_err());
628    assert!(other.check_write(&target).is_ok());
629    let narrowed = Capabilities::inherit(caps.clone());
630    assert!(narrowed.check_write(&target).is_err());
631}
632
633#[test]
634fn creation_targets_resolve_existing_prefixes_without_inventing_parent_traversal() {
635    let dir = tempfile::tempdir().unwrap();
636    fs::create_dir(dir.path().join("real")).unwrap();
637    let path = dir.path().join("real/missing/deeper/file");
638    assert_eq!(PathTarget::for_creation(&path).unwrap().as_path(), path);
639    assert!(PathTarget::for_creation(dir.path().join("real/missing/../file")).is_err());
640    let mut initial = Initial::new(true);
641    assert!(
642        initial
643            .grant(Category::Write, false, "real/missing/../file", dir.path())
644            .is_err()
645    );
646    #[cfg(unix)]
647    {
648        std::os::unix::fs::symlink("real", dir.path().join("alias")).unwrap();
649        assert_eq!(
650            PathTarget::for_creation(dir.path().join("alias/missing/file"))
651                .unwrap()
652                .as_path(),
653            dir.path().join("real/missing/file")
654        );
655        let caps = policy(true, &[(Category::Write, false, "alias/new")], dir.path());
656        assert!(
657            caps.check_write(&PathTarget::for_creation(dir.path().join("real/new")).unwrap())
658                .is_ok()
659        );
660    }
661}
662
663#[test]
664fn cli_and_public_pledge_accept_write_without_granting_module_execution() {
665    let dir = tempfile::tempdir().unwrap();
666    fs::write(
667        dir.path().join("dep.js"),
668        "throw Error('Unexpected execution')",
669    )
670    .unwrap();
671    fs::write(dir.path().join("main.js"),r#"
672        import {pledge} from 'avril:std';
673        pledge({write:{allow:['a, b','future/'],deny:['future/private/']}});
674        try{pledge({write:false,unknown:false});throw Error('Invalid pledge');}catch(e){if(!(e instanceof TypeError))throw e;}
675        pledge({write:false});
676        try{await import('./dep.js');throw Error('Write grants authorize execution');}catch(e){if(!e.message.includes('PermissionDenied: js-exec'))throw e;}
677        console.log('write capability');
678    "#).unwrap();
679    let output = run(
680        dir.path(),
681        &[
682            "-0",
683            "--allow-write=a, b",
684            "--allow-write=future/",
685            "--deny-write=future/private/",
686            "main.js",
687        ],
688    );
689    assert!(
690        output.status.success(),
691        "{}",
692        String::from_utf8_lossy(&output.stderr)
693    );
694    assert_eq!(output.stdout, b"write capability\n");
695}
696
697#[test]
698fn path_denials_cover_descendants_after_missing_or_file_targets_become_directories() {
699    let dir = tempfile::tempdir().unwrap();
700    fs::write(dir.path().join("file"), "").unwrap();
701    let caps = policy(
702        false,
703        &[
704            (Category::Write, true, "future"),
705            (Category::Write, true, "file"),
706            (Category::Read, true, "file"),
707        ],
708        dir.path(),
709    );
710    fs::remove_file(dir.path().join("file")).unwrap();
711    fs::create_dir(dir.path().join("file")).unwrap();
712    for path in ["future/secret", "file/secret"] {
713        let target = PathTarget::for_creation(dir.path().join(path)).unwrap();
714        assert!(caps.check_write(&target).is_err());
715    }
716    assert!(
717        caps.check_read(&PathTarget::for_creation(dir.path().join("file/secret")).unwrap())
718            .is_err()
719    );
720    let other = policy(false, &[], dir.path());
721    other
722        .pledge(vec![(Category::Write, None, vec!["missing".into()])])
723        .unwrap();
724    assert!(
725        other
726            .check_write(&PathTarget::for_creation(dir.path().join("missing/child")).unwrap())
727            .is_err()
728    );
729}
730
731#[test]
732fn remote_execution_grants_are_origin_scoped_canonical_and_monotonic() {
733    let dir = tempfile::tempdir().unwrap();
734    let mut initial = Initial::new(true);
735    initial
736        .grant(
737            Category::JsExec,
738            false,
739            "https://jsr.io/@test/pkg/",
740            dir.path(),
741        )
742        .unwrap();
743    initial
744        .grant(
745            Category::JsExec,
746            true,
747            "https://jsr.io/@test/pkg/1.0.0/blocked.ts",
748            dir.path(),
749        )
750        .unwrap();
751    let caps = Capabilities::new(initial, dir.path().to_owned());
752    let allowed = url::Url::parse("https://jsr.io/@test/pkg/1.0.0/mod.ts").unwrap();
753    caps.check_remote_exec(&allowed).unwrap();
754    caps.check_remote_acquire(&url::Url::parse("https://jsr.io/@test/pkg/").unwrap())
755        .unwrap();
756    for name in [
757        "http://jsr.io/@test/pkg/1.0.0/mod.ts",
758        "https://jsr.io:444/@test/pkg/1.0.0/mod.ts",
759        "https://jsr.io/@test/pkg-other/1.0.0/mod.ts",
760        "https://jsr.io/@test/pkg/1.0.0/blocked.ts",
761        "https://jsr.io/@test/pkg/1.0.0/blocked%2Ets",
762        "https://jsr.io/@test/pkg/1.0.0/blocked.ts/child",
763        "https://jsr.io/@test/pkg/1.0.0/%2fblocked.ts",
764    ] {
765        assert!(
766            caps.check_remote_exec(&url::Url::parse(name).unwrap())
767                .is_err(),
768            "{name}"
769        );
770    }
771    let child = Capabilities::inherit(caps.clone());
772    assert!(
773        caps.pledge(vec![(
774            Category::JsExec,
775            Some(vec!["https://jsr.io/".into(), "https://[invalid".into()]),
776            vec![]
777        )])
778        .is_err()
779    );
780    child.check_remote_exec(&allowed).unwrap();
781    caps.pledge(vec![(
782        Category::JsExec,
783        Some(vec![dir.path().to_string_lossy().into_owned()]),
784        vec![],
785    )])
786    .unwrap();
787    assert!(caps.check_remote_exec(&allowed).is_err());
788    assert!(child.check_remote_exec(&allowed).is_ok());
789    assert!(
790        caps.check_remote_acquire(&url::Url::parse("https://jsr.io/@test/pkg/").unwrap())
791            .is_err()
792    );
793    assert!(
794        child
795            .check_remote_acquire(&url::Url::parse("https://jsr.io/@test/pkg/").unwrap())
796            .is_ok()
797    );
798    caps.pledge(vec![(
799        Category::JsExec,
800        Some(vec!["https://jsr.io/".into()]),
801        vec![],
802    )])
803    .unwrap();
804    assert!(caps.check_remote_exec(&allowed).is_err());
805    assert!(child.check_remote_exec(&allowed).is_ok());
806    let later = Capabilities::inherit(caps.clone());
807    assert!(later.check_remote_exec(&allowed).is_err());
808}
809
810#[test]
811fn executable_grants_are_independent_monotonic_and_snapshot_for_workers() {
812    let dir = tempfile::tempdir().unwrap();
813    fs::write(dir.path().join("tool"), "").unwrap();
814    fs::write(dir.path().join("other"), "").unwrap();
815    let tool = PathTarget::resolve(dir.path().join("tool")).unwrap();
816    let other = PathTarget::resolve(dir.path().join("other")).unwrap();
817    let read = policy(true, &[(Category::Read, false, ".")], dir.path());
818    let js = policy(true, &[(Category::JsExec, false, ".")], dir.path());
819    assert!(read.check_run(&tool).is_err());
820    assert!(js.check_run(&tool).is_err());
821    let caps = policy(true, &[(Category::Run, false, "tool")], dir.path());
822    assert!(caps.check_run(&tool).is_ok());
823    assert!(caps.check_run(&other).is_err());
824    assert!(caps.check_read(&tool).is_err());
825    assert!(caps.check_js_exec(&tool).is_err());
826    let child = Capabilities::inherit(caps.clone());
827    assert!(child.check_run(&tool).is_ok());
828    assert!(
829        caps.pledge(vec![
830            (Category::Run, Some(vec![]), vec![]),
831            (Category::Net, None, vec!["invalid,host".into()])
832        ])
833        .is_err()
834    );
835    assert!(caps.check_run(&tool).is_ok());
836    caps.pledge(vec![(Category::Run, None, vec!["tool".into()])])
837        .unwrap();
838    assert!(caps.check_run(&tool).is_err());
839    assert!(child.check_run(&tool).is_ok());
840    caps.pledge(vec![(Category::Run, Some(vec!["tool".into()]), vec![])])
841        .unwrap();
842    assert!(caps.check_run(&tool).is_err());
843    assert!(child.check_run(&tool).is_ok());
844    let later = Capabilities::inherit(caps.clone());
845    assert!(later.check_run(&tool).is_err());
846}
847
848#[test]
849fn missing_executable_grants_are_exact_and_file_urls_work() {
850    let dir = tempfile::tempdir().unwrap();
851    let name = dir.path().join("tool");
852    let file_url = url::Url::from_file_path(&name).unwrap();
853    let caps = policy(
854        true,
855        &[(Category::Run, false, file_url.as_str())],
856        dir.path(),
857    );
858    fs::create_dir(&name).unwrap();
859    fs::write(name.join("child"), "").unwrap();
860    assert!(caps.check_run(&PathTarget::resolve(&name).unwrap()).is_ok());
861    assert!(
862        caps.check_run(&PathTarget::resolve(name.join("child")).unwrap())
863            .is_err()
864    );
865}
866
867#[test]
868fn worker_snapshots_preserve_restrictions_and_isolate_descendants() {
869    let dir = tempfile::tempdir().unwrap();
870    fs::create_dir(dir.path().join("app")).unwrap();
871    for file in ["app/one.js", "app/two.js", "tool", "other"] {
872        fs::write(dir.path().join(file), "").unwrap();
873    }
874    let one = PathTarget::resolve(dir.path().join("app/one.js")).unwrap();
875    let two = PathTarget::resolve(dir.path().join("app/two.js")).unwrap();
876    let tool = PathTarget::resolve(dir.path().join("tool")).unwrap();
877    let other = PathTarget::resolve(dir.path().join("other")).unwrap();
878    let write_target = |name: &str| PathTarget::for_creation(dir.path().join(name)).unwrap();
879    let caps = policy(
880        true,
881        &[
882            (Category::Net, false, "a.example"),
883            (Category::Net, false, "b.example"),
884            (Category::Read, false, "app"),
885            (Category::Write, false, "app"),
886            (Category::JsExec, false, "app"),
887            (Category::Env, false, "KEEP"),
888            (Category::Env, false, "DROP"),
889            (Category::Sys, false, "hostname"),
890            (Category::Run, false, "tool"),
891            (Category::Ffi, false, "tool"),
892        ],
893        dir.path(),
894    );
895    caps.pledge(vec![
896        (
897            Category::Net,
898            Some(vec!["a.example".into(), "b.example".into()]),
899            vec![],
900        ),
901        (Category::Read, Some(vec!["app".into()]), vec![]),
902        (Category::Write, Some(vec!["app".into()]), vec![]),
903    ])
904    .unwrap();
905    caps.pledge(vec![
906        (
907            Category::Net,
908            Some(vec!["a.example".into()]),
909            vec!["a.example:443".into()],
910        ),
911        (Category::Env, None, vec!["DROP".into()]),
912        (Category::Read, None, vec!["app/two.js".into()]),
913        (Category::Write, None, vec!["app/two.js".into()]),
914        (Category::JsExec, None, vec!["app/two.js".into()]),
915    ])
916    .unwrap();
917    let worker = Capabilities::inherit(caps.clone());
918    let sibling = Capabilities::inherit(caps.clone());
919    assert!(worker.check_net("a.example", 80).is_ok());
920    assert!(worker.check_net("a.example", 443).is_err());
921    assert!(worker.check_net("b.example", 80).is_err());
922    assert!(worker.check_read(&one).is_ok());
923    assert!(worker.check_read(&two).is_err());
924    assert!(worker.check_write(&write_target("app/one.js")).is_ok());
925    assert!(worker.check_write(&write_target("app/two.js")).is_err());
926    assert!(worker.check_js_exec(&one).is_ok());
927    assert!(worker.check_js_exec(&two).is_err());
928    assert!(worker.check_env("KEEP").is_ok());
929    assert!(worker.check_env("DROP").is_err());
930    assert!(worker.check_sys("hostname").is_ok());
931    assert!(worker.check_sys("uptime").is_err());
932    assert!(worker.check_run(&tool).is_ok());
933    assert!(worker.check_run(&other).is_err());
934    assert!(worker.check_ffi(&tool).is_ok());
935    assert!(worker.check_ffi(&other).is_err());
936    // Intersected allows cannot be widened back.
937    worker
938        .pledge(vec![(
939            Category::Net,
940            Some(vec!["a.example".into(), "b.example".into()]),
941            vec![],
942        )])
943        .unwrap();
944    assert!(worker.check_net("a.example", 80).is_ok());
945    assert!(worker.check_net("b.example", 80).is_err());
946    worker
947        .pledge(vec![(Category::Env, None, vec!["KEEP".into()])])
948        .unwrap();
949    assert!(worker.check_env("KEEP").is_err());
950    assert!(sibling.check_env("KEEP").is_ok());
951    assert!(caps.check_env("KEEP").is_ok());
952    let nested = Capabilities::inherit(worker.clone());
953    assert!(nested.check_net("a.example", 80).is_ok());
954    assert!(nested.check_net("b.example", 80).is_err());
955    assert!(nested.check_env("KEEP").is_err());
956    assert!(nested.check_env("DROP").is_err());
957    assert!(nested.check_read(&one).is_ok());
958    caps.pledge(vec![(Category::Net, None, vec!["a.example:80".into()])])
959        .unwrap();
960    assert!(caps.check_net("a.example", 80).is_err());
961    assert!(worker.check_net("a.example", 80).is_ok());
962    assert!(sibling.check_net("a.example", 80).is_ok());
963    assert!(nested.check_net("a.example", 80).is_ok());
964    let later = Capabilities::inherit(caps.clone());
965    assert!(later.check_net("a.example", 80).is_err());
966    assert!(later.check_net("a.example", 8443).is_ok());
967    assert!(later.check_read(&one).is_ok());
968    assert!(later.check_env("KEEP").is_ok());
969}
970
971#[test]
972fn nested_workers_snapshot_their_direct_parent() {
973    let dir = tempfile::tempdir().unwrap();
974    let caps = policy(false, &[], dir.path());
975    let worker = Capabilities::inherit(caps.clone());
976    caps.pledge(vec![(Category::Net, None, vec!["a.example:80".into()])])
977        .unwrap();
978    assert!(caps.check_net("a.example", 80).is_err());
979    // Created after the root pledge, yet keeps its direct parent's snapshot.
980    let nested = Capabilities::inherit(worker.clone());
981    assert!(worker.check_net("a.example", 80).is_ok());
982    assert!(nested.check_net("a.example", 80).is_ok());
983    // A later pledge by the direct parent leaves the existing nested worker
984    // alone but narrows subsequent ones.
985    worker
986        .pledge(vec![(Category::Net, None, vec!["a.example:443".into()])])
987        .unwrap();
988    assert!(worker.check_net("a.example", 443).is_err());
989    assert!(nested.check_net("a.example", 443).is_ok());
990    let later = Capabilities::inherit(worker.clone());
991    assert!(later.check_net("a.example", 443).is_err());
992    assert!(later.check_net("a.example", 80).is_ok());
993}