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
29.3 KiB909 linesraw
1#[path = "common/http.rs"]
2mod http_fixture;
3#[path = "common/registry.rs"]
4mod registry_fixture;
5
6use std::sync::Arc;
7
8use avril_core::ModuleLoader;
9use avril_runtime::authority::SourceAuthority;
10use avril_runtime::caps::{Capabilities, Category, Initial, PathTarget};
11use avril_runtime::modules::ModuleGraph;
12use avril_runtime::registry::PackageStore;
13use futures::executor::block_on;
14use http_fixture::Http;
15use registry_fixture::Registry;
16
17fn caps_with(cwd: &std::path::Path, grants: &[(&str, bool, &str)]) -> Arc<Capabilities> {
18    let mut initial = Initial::new(true);
19    for (category, deny, value) in grants {
20        let category: Category = category.parse().unwrap();
21        initial.grant(category, *deny, value, cwd).unwrap();
22    }
23    Capabilities::new(initial, cwd.to_owned())
24}
25
26fn net_host_port(url: &url::Url) -> String {
27    format!(
28        "{}:{}",
29        url.host_str().unwrap(),
30        url.port_or_known_default().unwrap()
31    )
32}
33
34fn store_with(
35    options: avril_runtime::registry::Options,
36    authority: SourceAuthority,
37) -> PackageStore {
38    PackageStore::new_with_authority(registry_fixture::runtime_handle(), options, authority)
39        .unwrap()
40}
41
42fn graph_options(dir: &std::path::Path) -> avril_runtime::registry::Options {
43    avril_runtime::registry::Options {
44        jsr_registry: url::Url::parse("https://example.invalid/").unwrap(),
45        npm_registry: url::Url::parse("https://example.invalid/").unwrap(),
46        cache: dir.join("cache"),
47        lock: dir.join("avril.lock"),
48        project_root: dir.to_owned(),
49        offline: false,
50        frozen: false,
51        update: false,
52        ephemeral_lock: false,
53        vendor: None,
54    }
55}
56
57#[test]
58fn execute_keeps_redirect_without_authorizing_target() {
59    let http = Http::new();
60    let target = Http::new();
61    http.redirect("start", &target.name("nested/mod.ts"));
62    target.source("nested/mod.ts", "export const value = 7;");
63    let name = http.name("start");
64
65    let dir = tempfile::tempdir().unwrap();
66    let server = Registry::new();
67    let caps = caps_with(
68        dir.path(),
69        &[
70            ("js-exec", false, &name),
71            ("js-exec", true, target.url.as_str()),
72        ],
73    );
74    let store = store_with(server.options(dir.path()), SourceAuthority::execute(caps));
75    assert_eq!(
76        block_on(store.remote_source(&name)).unwrap(),
77        "export const value = 7;"
78    );
79}
80
81#[test]
82fn read_checks_network_for_redirect_targets() {
83    let http = Http::new();
84    let target = Http::new();
85    http.redirect("start", &target.name("nested/mod.ts"));
86    target.source("nested/mod.ts", "export const value = 7;");
87    let name = http.name("start");
88    let http_url = url::Url::parse(&name).unwrap();
89
90    // Net for the requested host only: redirect target denies the read.
91    let dir = tempfile::tempdir().unwrap();
92    let server = Registry::new();
93    let lock = dir.path().join("avril.lock").to_string_lossy().into_owned();
94    let only_first = caps_with(
95        dir.path(),
96        &[
97            ("net", false, &net_host_port(&http_url)),
98            ("read", false, &lock),
99        ],
100    );
101    let store = store_with(
102        server.options(dir.path()),
103        SourceAuthority::read(only_first),
104    );
105    assert!(
106        block_on(store.remote_source(&name))
107            .unwrap_err()
108            .to_string()
109            .contains("PermissionDenied")
110    );
111
112    assert!(target.requests.lock().unwrap().is_empty());
113
114    // Net for both hosts: the read succeeds.
115    let dir2 = tempfile::tempdir().unwrap();
116    let server2 = Registry::new();
117    let lock2 = dir2
118        .path()
119        .join("avril.lock")
120        .to_string_lossy()
121        .into_owned();
122    let both = caps_with(
123        dir2.path(),
124        &[
125            ("net", false, &net_host_port(&http_url)),
126            ("net", false, &net_host_port(&target.url)),
127            ("read", false, &lock2),
128        ],
129    );
130    let store = store_with(server2.options(dir2.path()), SourceAuthority::read(both));
131    assert_eq!(
132        block_on(store.remote_source(&name)).unwrap(),
133        "export const value = 7;"
134    );
135}
136
137#[test]
138fn execution_only_authority_never_exposes_source_in_read() {
139    let http = Http::new();
140    http.source("mod.ts", "export const value = 1;");
141    let name = http.name("mod.ts");
142    let http_url = url::Url::parse(&name).unwrap();
143
144    let dir = tempfile::tempdir().unwrap();
145    std::fs::write(dir.path().join("local.ts"), "export const value = 2;").unwrap();
146    let local = dir.path().join("local.ts").to_string_lossy().into_owned();
147
148    // js-exec without net/read: Execute allows, Read denies everything.
149    // The lock grant lets the Read store construct; fetches still deny.
150    let lock = dir.path().join("avril.lock").to_string_lossy().into_owned();
151    let caps = caps_with(
152        dir.path(),
153        &[
154            ("js-exec", false, &name),
155            ("js-exec", false, &local),
156            ("read", false, &lock),
157        ],
158    );
159    let execute = SourceAuthority::execute(caps.clone());
160    let read = SourceAuthority::read(caps);
161    assert!(execute.check_remote_module(&http_url).is_ok());
162    assert!(read.check_remote_module(&http_url).is_err());
163
164    let target = PathTarget::resolve(&local).unwrap();
165    assert!(execute.check_local(&target).is_ok());
166    assert!(read.check_local(&target).is_err());
167
168    // Store-level: Read fetch fails even though Execute would allow it.
169    let server = Registry::new();
170    let store = store_with(server.options(dir.path()), read);
171    assert!(
172        block_on(store.remote_source(&name))
173            .unwrap_err()
174            .to_string()
175            .contains("PermissionDenied")
176    );
177}
178
179#[test]
180fn read_reauthorizes_cached_access_by_final_url() {
181    let http = Http::new();
182    let target = Http::new();
183    http.redirect("start", &target.name("nested/mod.ts"));
184    target.source("nested/mod.ts", "export const value = 7;");
185    let name = http.name("start");
186    let http_url = url::Url::parse(&name).unwrap();
187
188    let dir = tempfile::tempdir().unwrap();
189    let server = Registry::new();
190    let lock = dir.path().join("avril.lock").to_string_lossy().into_owned();
191    let caps = caps_with(
192        dir.path(),
193        &[
194            ("net", false, &net_host_port(&http_url)),
195            ("net", false, &net_host_port(&target.url)),
196            ("read", false, &lock),
197        ],
198    );
199    let store = store_with(
200        server.options(dir.path()),
201        SourceAuthority::read(caps.clone()),
202    );
203    assert_eq!(
204        block_on(store.remote_source(&name)).unwrap(),
205        "export const value = 7;"
206    );
207    // Revoke the final host: the cached artifact must deny on re-read.
208    caps.pledge(vec![(
209        Category::Net,
210        None,
211        vec![net_host_port(&target.url)],
212    )])
213    .unwrap();
214    assert!(
215        block_on(store.remote_source(&name))
216            .unwrap_err()
217            .to_string()
218            .contains("PermissionDenied")
219    );
220}
221
222#[test]
223fn execute_revocation_denies_cached_remote_access() {
224    let http = Http::new();
225    http.source("mod.ts", "export const value = 1;");
226    let name = http.name("mod.ts");
227
228    let dir = tempfile::tempdir().unwrap();
229    let server = Registry::new();
230    let caps = caps_with(dir.path(), &[("js-exec", false, &name)]);
231    let store = store_with(
232        server.options(dir.path()),
233        SourceAuthority::execute(caps.clone()),
234    );
235    assert_eq!(
236        block_on(store.remote_source(&name)).unwrap(),
237        "export const value = 1;"
238    );
239    caps.pledge(vec![(Category::JsExec, None, vec![name.clone()])])
240        .unwrap();
241    assert!(
242        block_on(store.remote_source(&name))
243            .unwrap_err()
244            .to_string()
245            .contains("PermissionDenied")
246    );
247}
248
249#[test]
250fn read_local_requires_read_grant() {
251    let dir = tempfile::tempdir().unwrap();
252    std::fs::write(dir.path().join("a.ts"), "export default 1;").unwrap();
253    let path = dir.path().join("a.ts").to_string_lossy().into_owned();
254    let target = PathTarget::resolve(&path).unwrap();
255
256    let read_only = caps_with(dir.path(), &[("read", false, &path)]);
257    assert!(
258        SourceAuthority::read(read_only)
259            .check_local(&target)
260            .is_ok()
261    );
262
263    let exec_only = caps_with(dir.path(), &[("js-exec", false, &path)]);
264    assert!(
265        SourceAuthority::read(exec_only)
266            .check_local(&target)
267            .is_err()
268    );
269}
270
271#[test]
272fn read_lockfile_needs_read_authority() {
273    let http = Http::new();
274    http.source("mod.ts", "export const value = 1;");
275    let dir = tempfile::tempdir().unwrap();
276    let server = Registry::new();
277    std::fs::write(dir.path().join("avril.lock"), "not a lockfile").unwrap();
278    let caps = caps_with(dir.path(), &[("net", false, &net_host_port(&http.url))]);
279    // The malformed lock is never parsed without read authority for it.
280    let error = match PackageStore::new_with_authority(
281        registry_fixture::runtime_handle(),
282        server.options(dir.path()),
283        SourceAuthority::read(caps),
284    ) {
285        Ok(_) => panic!("lockfile without read authority must deny"),
286        Err(error) => error.to_string(),
287    };
288    assert!(error.contains("PermissionDenied"), "{error}");
289}
290
291#[test]
292fn lockfile_rereads_respect_revocation() {
293    let http = Http::new();
294    http.source("mod.ts", "export default 1;");
295    let server = Registry::new();
296    for save_first in [false, true] {
297        let dir = tempfile::tempdir().unwrap();
298        let lock = dir.path().join("avril.lock");
299        let caps = caps_with(
300            dir.path(),
301            &[
302                ("read", false, lock.to_str().unwrap()),
303                ("net", false, &net_host_port(&http.url)),
304            ],
305        );
306        let store = store_with(
307            server.options(dir.path()),
308            SourceAuthority::read(caps.clone()),
309        );
310        block_on(store.remote_source(&http.name("mod.ts"))).unwrap();
311        if save_first {
312            store.save().unwrap();
313        }
314        caps.pledge(vec![(
315            Category::Read,
316            None,
317            vec![lock.to_str().unwrap().to_owned()],
318        )])
319        .unwrap();
320        let malformed = "{ secret: 'LOCK_SECRET'";
321        std::fs::write(&lock, malformed).unwrap();
322        let error = store.save().unwrap_err().to_string();
323        assert!(error.contains("PermissionDenied"), "{error}");
324        assert!(!error.contains("LOCK_SECRET"), "{error}");
325        assert_eq!(std::fs::read_to_string(lock).unwrap(), malformed);
326    }
327}
328
329#[test]
330fn npm_and_jsr_acquisition_follow_source_mode() {
331    let server = Registry::new();
332    server.package(
333        "@test/pkg",
334        "1.0.0",
335        &[("mod.ts", "export const value = 1;")],
336        &[(".", "./mod.ts")],
337    );
338    server.publish_npm(
339        "lib",
340        "1.0.0",
341        &[
342            ("package.json", "{\"name\":\"lib\",\"version\":\"1.0.0\"}"),
343            ("index.js", "export default 1;"),
344        ],
345    );
346
347    // Read with net to the registry: tarball fetch, verified reads, and
348    // registry sources all work.
349    let dir = tempfile::tempdir().unwrap();
350    let net = net_host_port(&server.url);
351    let lock = dir.path().join("avril.lock").to_string_lossy().into_owned();
352    let caps = caps_with(dir.path(), &[("net", false, &net), ("read", false, &lock)]);
353    let store = store_with(
354        server.options(dir.path()),
355        SourceAuthority::read(caps.clone()),
356    );
357    let artifact = block_on(store.npm_acquire("npm:lib@^1")).unwrap();
358    assert_eq!(
359        artifact.read_file(dir.path(), "index.js").unwrap(),
360        b"export default 1;"
361    );
362    let module = block_on(store.resolve("jsr:@test/pkg@^1")).unwrap();
363    assert!(module.contains("/1.0.0/"));
364    assert_eq!(
365        block_on(store.source(&module)).unwrap(),
366        "export const value = 1;"
367    );
368    // Revoking the registry denies settled acquisitions on reuse.
369    caps.pledge(vec![(Category::Net, None, vec![net])]).unwrap();
370    assert!(
371        block_on(store.npm_acquire("npm:lib@^1"))
372            .unwrap_err()
373            .to_string()
374            .contains("PermissionDenied")
375    );
376    assert!(
377        block_on(store.source(&module))
378            .unwrap_err()
379            .to_string()
380            .contains("PermissionDenied")
381    );
382
383    // Execution-only grants never satisfy Read acquisition.
384    let dir2 = tempfile::tempdir().unwrap();
385    let lock2 = dir2
386        .path()
387        .join("avril.lock")
388        .to_string_lossy()
389        .into_owned();
390    let exec_caps = caps_with(
391        dir2.path(),
392        &[
393            (
394                "js-exec",
395                false,
396                &format!("{}@test/pkg/", server.url.as_str()),
397            ),
398            ("js-exec", false, &format!("{}lib/", server.url.as_str())),
399            ("read", false, &lock2),
400        ],
401    );
402    let store = store_with(
403        server.options(dir2.path()),
404        SourceAuthority::read(exec_caps),
405    );
406    assert!(
407        block_on(store.npm_acquire("npm:lib@^1"))
408            .unwrap_err()
409            .to_string()
410            .contains("PermissionDenied")
411    );
412    assert!(
413        block_on(store.resolve("jsr:@test/pkg@^1"))
414            .unwrap_err()
415            .to_string()
416            .contains("PermissionDenied")
417    );
418}
419
420#[test]
421fn nested_local_configs_follow_source_mode() {
422    let dir = tempfile::tempdir().unwrap();
423    let root = dir.path();
424    let dep = root.join("dep");
425    std::fs::create_dir_all(&dep).unwrap();
426    std::fs::write(
427        root.join("avril.conf"),
428        "deps: [{from: './dep', name: 'dep'}]",
429    )
430    .unwrap();
431    std::fs::write(
432        dep.join("avril.conf"),
433        "name: 'dep', exports: {'.': './mod.ts'}",
434    )
435    .unwrap();
436    std::fs::write(dep.join("mod.ts"), "export const value = 1;").unwrap();
437    std::fs::write(root.join("main.ts"), "import 'dep';").unwrap();
438    let main = root.join("main.ts").to_string_lossy().into_owned();
439    let dep_mod = dep.join("mod.ts").to_string_lossy().into_owned();
440    let root_str = root.to_string_lossy().into_owned();
441    let lock = root.join("avril.lock").to_string_lossy().into_owned();
442
443    // Execute resolves through both configs with exact import mapping.
444    let caps = caps_with(root, &[("js-exec", false, &root_str)]);
445    let graph = ModuleGraph::open(
446        registry_fixture::runtime_handle(),
447        SourceAuthority::execute(caps),
448        graph_options(root),
449    )
450    .unwrap();
451    assert_eq!(
452        block_on(graph.prepare(main.clone(), String::new())).unwrap(),
453        main
454    );
455    let info = graph.info(&main).unwrap();
456    assert_eq!(
457        info["modules"][&main]["imports"]["dep"],
458        serde_json::json!(dep_mod)
459    );
460    assert_eq!(
461        block_on(graph.original_source(&dep_mod)).unwrap(),
462        "export const value = 1;"
463    );
464
465    // Read with only execution grants exposes nothing.
466    let exec_only = caps_with(
467        root,
468        &[("js-exec", false, &root_str), ("read", false, &lock)],
469    );
470    let graph = ModuleGraph::open(
471        registry_fixture::runtime_handle(),
472        SourceAuthority::read(exec_only),
473        graph_options(root),
474    )
475    .unwrap();
476    assert!(
477        block_on(graph.prepare(main.clone(), String::new()))
478            .unwrap_err()
479            .to_string()
480            .contains("PermissionDenied")
481    );
482
483    // Read with read grants resolves the same nesting.
484    let read_caps = caps_with(root, &[("read", false, &root_str)]);
485    let graph = ModuleGraph::open(
486        registry_fixture::runtime_handle(),
487        SourceAuthority::read(read_caps),
488        graph_options(root),
489    )
490    .unwrap();
491    assert_eq!(
492        block_on(graph.prepare(main.clone(), String::new())).unwrap(),
493        main
494    );
495    let info = graph.info(&main).unwrap();
496    assert_eq!(
497        info["modules"][&main]["imports"]["dep"],
498        serde_json::json!(dep_mod)
499    );
500}
501
502#[test]
503fn module_graph_read_isolates_execution_only_source() {
504    let dir = tempfile::tempdir().unwrap();
505    let root = dir.path();
506    std::fs::write(root.join("avril.conf"), "").unwrap();
507    std::fs::write(root.join("main.ts"), "export const value = 1;").unwrap();
508    let main = root.join("main.ts").to_string_lossy().into_owned();
509    let lock = root.join("avril.lock").to_string_lossy().into_owned();
510
511    let caps = caps_with(root, &[("js-exec", false, &main), ("read", false, &lock)]);
512    let graph = ModuleGraph::open(
513        registry_fixture::runtime_handle(),
514        SourceAuthority::execute(caps.clone()),
515        graph_options(root),
516    )
517    .unwrap();
518    block_on(graph.prepare(main.clone(), String::new())).unwrap();
519    assert_eq!(
520        block_on(graph.original_source(&main)).unwrap(),
521        "export const value = 1;"
522    );
523
524    // Same caps in Read mode expose nothing.
525    let graph = ModuleGraph::open(
526        registry_fixture::runtime_handle(),
527        SourceAuthority::read(caps),
528        graph_options(root),
529    )
530    .unwrap();
531    assert!(
532        block_on(graph.original_source(&main))
533            .unwrap_err()
534            .to_string()
535            .contains("PermissionDenied")
536    );
537}
538
539#[test]
540fn redirected_graph_load_reauthorizes_final_host() {
541    let http = Http::new();
542    let target = Http::new();
543    http.redirect("start", &target.name("mod.ts"));
544    target.source("mod.ts", "export const value = 7;");
545    let name = http.name("start");
546
547    let dir = tempfile::tempdir().unwrap();
548    let root = dir.path();
549    let lock = root.join("avril.lock").to_string_lossy().into_owned();
550    let caps = caps_with(
551        root,
552        &[
553            ("net", false, &net_host_port(&http.url)),
554            ("net", false, &net_host_port(&target.url)),
555            ("read", false, &lock),
556        ],
557    );
558    let graph = ModuleGraph::open(
559        registry_fixture::runtime_handle(),
560        SourceAuthority::read(caps.clone()),
561        graph_options(root),
562    )
563    .unwrap();
564    block_on(graph.prepare(name.clone(), String::new())).unwrap();
565    assert_eq!(
566        block_on(graph.original_source(&name)).unwrap(),
567        "export const value = 7;"
568    );
569    // Revoking the final host denies the cached record as well as refetch.
570    caps.pledge(vec![(
571        Category::Net,
572        None,
573        vec![net_host_port(&target.url)],
574    )])
575    .unwrap();
576    assert!(
577        graph
578            .load(&name)
579            .unwrap_err()
580            .to_string()
581            .contains("PermissionDenied")
582    );
583    assert!(
584        block_on(graph.original_source(&name))
585            .unwrap_err()
586            .to_string()
587            .contains("PermissionDenied")
588    );
589}
590
591#[test]
592fn child_graph_revocation_denies_despite_parent_store() {
593    let http = Http::new();
594    http.source("mod.ts", "export const value = 1;");
595    let name = http.name("mod.ts");
596
597    let dir = tempfile::tempdir().unwrap();
598    let root = dir.path();
599    let parent = caps_with(root, &[("js-exec", false, &name)]);
600    let store = Arc::new(
601        PackageStore::new_with_authority(
602            registry_fixture::runtime_handle(),
603            graph_options(root),
604            SourceAuthority::execute(parent.clone()),
605        )
606        .unwrap(),
607    );
608    let child = Capabilities::inherit(parent.clone());
609    let graph = ModuleGraph::open_shared(SourceAuthority::execute(child.clone()), store);
610    block_on(graph.prepare(name.clone(), String::new())).unwrap();
611    assert_eq!(
612        block_on(graph.original_source(&name)).unwrap(),
613        "export const value = 1;"
614    );
615    // Revoking only the child denies the cached load.
616    child
617        .pledge(vec![(Category::JsExec, None, vec![name.clone()])])
618        .unwrap();
619    assert!(
620        graph
621            .load(&name)
622            .unwrap_err()
623            .to_string()
624            .contains("PermissionDenied")
625    );
626}
627
628#[test]
629fn nested_remote_dependency_follows_source_mode() {
630    let front = Http::new();
631    let target = Http::new();
632    // The requested child config redirects to its final host.
633    front.redirect("dep/avril.conf", &target.name("avril.conf"));
634    let child_url = front.url.join("dep/").unwrap();
635    let child_mod = target.name("mod.ts");
636    target.source(
637        "avril.conf",
638        "name: \"dep\"\nexports: {\".\": \"./mod.ts\"}",
639    );
640    target.source("mod.ts", "export const value = 1;");
641
642    let dir = tempfile::tempdir().unwrap();
643    let root = dir.path();
644    std::fs::write(
645        root.join("avril.conf"),
646        format!("deps: [{{from: \"{}\", name: 'dep'}}]", child_url.as_str()),
647    )
648    .unwrap();
649    std::fs::write(root.join("main.ts"), "import 'dep';").unwrap();
650    let main = root.join("main.ts").to_string_lossy().into_owned();
651
652    // Read with net for both hosts traverses the nesting.
653    let net_caps = caps_with(
654        root,
655        &[
656            ("net", false, &net_host_port(&front.url)),
657            ("net", false, &net_host_port(&target.url)),
658            ("read", false, root.to_str().unwrap()),
659        ],
660    );
661    let graph = ModuleGraph::open(
662        registry_fixture::runtime_handle(),
663        SourceAuthority::read(net_caps.clone()),
664        graph_options(root),
665    )
666    .unwrap();
667    assert_eq!(
668        block_on(graph.prepare(main.clone(), String::new())).unwrap(),
669        main
670    );
671    assert_eq!(
672        block_on(graph.original_source(&child_mod)).unwrap(),
673        "export const value = 1;"
674    );
675
676    let requests = (
677        front.requests.lock().unwrap().clone(),
678        target.requests.lock().unwrap().clone(),
679    );
680    // Revoking only the final host denies cached and fresh reads alike
681    // while the requested host stays granted.
682    net_caps
683        .pledge(vec![(
684            Category::Net,
685            None,
686            vec![net_host_port(&target.url)],
687        )])
688        .unwrap();
689    assert!(
690        graph
691            .load(&child_mod)
692            .unwrap_err()
693            .to_string()
694            .contains("PermissionDenied")
695    );
696    assert!(
697        block_on(graph.original_source(&child_mod))
698            .unwrap_err()
699            .to_string()
700            .contains("PermissionDenied")
701    );
702
703    assert!(
704        block_on(graph.initialize_package(&child_mod))
705            .unwrap_err()
706            .to_string()
707            .contains("PermissionDenied")
708    );
709    assert_eq!(*front.requests.lock().unwrap(), requests.0);
710    assert_eq!(*target.requests.lock().unwrap(), requests.1);
711
712    // Local reads do not authorize remote dependencies with execution-only grants.
713    let dir2 = tempfile::tempdir().unwrap();
714    let root2 = dir2.path();
715    std::fs::write(
716        root2.join("avril.conf"),
717        format!("deps: [{{from: \"{}\", name: 'dep'}}]", child_url.as_str()),
718    )
719    .unwrap();
720    std::fs::write(root2.join("main.ts"), "import 'dep';").unwrap();
721    let main2 = root2.join("main.ts").to_string_lossy().into_owned();
722    let exec_only = caps_with(
723        root2,
724        &[
725            ("js-exec", false, &front.name("dep/avril.conf")),
726            ("js-exec", false, target.url.as_str()),
727            ("read", false, root2.to_str().unwrap()),
728        ],
729    );
730    let graph = ModuleGraph::open(
731        registry_fixture::runtime_handle(),
732        SourceAuthority::read(exec_only),
733        graph_options(root2),
734    )
735    .unwrap();
736    let error = match block_on(graph.prepare(main2, String::new())) {
737        Ok(_) => panic!("execution-only authority must deny nested export"),
738        Err(error) => error.to_string(),
739    };
740    assert!(error.contains("PermissionDenied: net"), "{error}");
741    assert_eq!(*front.requests.lock().unwrap(), requests.0);
742    assert_eq!(*target.requests.lock().unwrap(), requests.1);
743}
744
745#[test]
746fn hidden_malformed_path_dependency_stays_hidden_in_read() {
747    const MARKER: &str = "SECRET_MARKER_9f3c";
748    let dir = tempfile::tempdir().unwrap();
749    let root = dir.path();
750    std::fs::write(root.join("avril.conf"), "deps: ['./secret']").unwrap();
751    std::fs::write(root.join("main.ts"), "import 'lib';").unwrap();
752    let secret = root.join("secret");
753    std::fs::create_dir_all(&secret).unwrap();
754    std::fs::write(secret.join("avril.conf"), format!(": {{{{ {MARKER} oops")).unwrap();
755    std::fs::write(secret.join("mod.ts"), "export default 1;").unwrap();
756    let main = root.join("main.ts").to_string_lossy().into_owned();
757    let root_str = root.to_string_lossy().into_owned();
758    let secret_str = secret.to_string_lossy().into_owned();
759
760    // Read sees the parent but not the hidden dependency: no marker leaks.
761    let caps = caps_with(
762        root,
763        &[("read", false, &root_str), ("read", true, &secret_str)],
764    );
765    let graph = ModuleGraph::open(
766        registry_fixture::runtime_handle(),
767        SourceAuthority::read(caps),
768        graph_options(root),
769    )
770    .unwrap();
771    let error = match block_on(graph.prepare(main.clone(), String::new())) {
772        Ok(_) => panic!("hidden dependency must deny"),
773        Err(error) => error.to_string(),
774    };
775    assert!(error.contains("PermissionDenied"), "{error}");
776    assert!(!error.contains(MARKER), "{error}");
777
778    // With the dependency granted, its declared name resolves.
779    let dir2 = tempfile::tempdir().unwrap();
780    let root2 = dir2.path();
781    std::fs::write(root2.join("avril.conf"), "deps: ['./secret']").unwrap();
782    std::fs::write(root2.join("main.ts"), "import 'lib';").unwrap();
783    let secret2 = root2.join("secret");
784    std::fs::create_dir_all(&secret2).unwrap();
785    std::fs::write(
786        secret2.join("avril.conf"),
787        "name: 'lib', exports: {'.': './mod.ts'}",
788    )
789    .unwrap();
790    std::fs::write(secret2.join("mod.ts"), "export default 1;").unwrap();
791    let main2 = root2.join("main.ts").to_string_lossy().into_owned();
792    let root2_str = root2.to_string_lossy().into_owned();
793    let caps2 = caps_with(root2, &[("read", false, &root2_str)]);
794    let graph = ModuleGraph::open(
795        registry_fixture::runtime_handle(),
796        SourceAuthority::read(caps2),
797        graph_options(root2),
798    )
799    .unwrap();
800    assert_eq!(
801        block_on(graph.prepare(main2.clone(), String::new())).unwrap(),
802        main2
803    );
804    let info = graph.info(&main2).unwrap();
805    assert!(info["modules"][&main2]["imports"]["lib"].is_string());
806}
807
808#[test]
809fn local_revocation_denies_cached_config_access() {
810    let dir = tempfile::tempdir().unwrap();
811    let root = dir.path();
812    std::fs::write(root.join("avril.conf"), "name: 'app'").unwrap();
813    std::fs::write(root.join("main.ts"), "export default 1;").unwrap();
814    let main = root.join("main.ts").to_string_lossy().into_owned();
815    let root_str = root.to_string_lossy().into_owned();
816    let conf = root.join("avril.conf").to_string_lossy().into_owned();
817
818    let caps = caps_with(root, &[("read", false, &root_str)]);
819    let graph = ModuleGraph::open(
820        registry_fixture::runtime_handle(),
821        SourceAuthority::read(caps.clone()),
822        graph_options(root),
823    )
824    .unwrap();
825    block_on(graph.prepare(main.clone(), String::new())).unwrap();
826    assert!(graph.info(&main).unwrap()["modules"][&main]["package"].is_object());
827    let src = root.join("src");
828    std::fs::create_dir(&src).unwrap();
829    let nested = src.join("main.ts");
830    let authority = SourceAuthority::read(caps.clone());
831    assert_eq!(
832        ModuleGraph::project_root(&authority, nested.to_str().unwrap()).unwrap(),
833        root
834    );
835    caps.pledge(vec![(Category::Read, None, vec![conf])])
836        .unwrap();
837    // The same graph hides the package once its config is revoked.
838    assert!(graph.info(&main).unwrap()["modules"][&main]["package"].is_null());
839    assert_eq!(
840        ModuleGraph::project_root(&authority, nested.to_str().unwrap()).unwrap(),
841        src
842    );
843}
844
845#[test]
846fn graph_factory_builds_consistent_authority() {
847    let dir = tempfile::tempdir().unwrap();
848    let root = dir.path();
849    std::fs::write(root.join("avril.conf"), "").unwrap();
850    std::fs::write(root.join("main.ts"), "export const value = 1;").unwrap();
851    let main = root.join("main.ts").to_string_lossy().into_owned();
852    let lock = root.join("avril.lock").to_string_lossy().into_owned();
853    let root_str = root.to_string_lossy().into_owned();
854
855    // Execute opens and prepares with execution grants.
856    let caps = caps_with(root, &[("js-exec", false, &root_str)]);
857    let graph = ModuleGraph::open(
858        registry_fixture::runtime_handle(),
859        SourceAuthority::execute(caps),
860        graph_options(root),
861    )
862    .unwrap();
863    assert_eq!(
864        block_on(graph.prepare(main.clone(), String::new())).unwrap(),
865        main
866    );
867
868    // Read opens and prepares with read grants.
869    let read_caps = caps_with(root, &[("read", false, &root_str)]);
870    let graph = ModuleGraph::open(
871        registry_fixture::runtime_handle(),
872        SourceAuthority::read(read_caps),
873        graph_options(root),
874    )
875    .unwrap();
876    assert_eq!(
877        block_on(graph.prepare(main.clone(), String::new())).unwrap(),
878        main
879    );
880
881    // Read construction without lock-read authority denies up front.
882    let bare = caps_with(root, &[("net", false, "example.invalid:443")]);
883    match ModuleGraph::open(
884        registry_fixture::runtime_handle(),
885        SourceAuthority::read(bare),
886        graph_options(root),
887    ) {
888        Ok(_) => panic!("lock without read authority must deny"),
889        Err(error) => assert!(error.to_string().contains("PermissionDenied"), "{error}"),
890    }
891
892    // Execution-only sources never satisfy a Read graph.
893    let exec_only = caps_with(
894        root,
895        &[("js-exec", false, &root_str), ("read", false, &lock)],
896    );
897    let graph = ModuleGraph::open(
898        registry_fixture::runtime_handle(),
899        SourceAuthority::read(exec_only),
900        graph_options(root),
901    )
902    .unwrap();
903    assert!(
904        block_on(graph.prepare(main.clone(), String::new()))
905            .unwrap_err()
906            .to_string()
907            .contains("PermissionDenied")
908    );
909}