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.8 KiB399 linesraw
1use std::fs;
2use std::path::Path;
3use std::process::{Command, Output};
4
5use avril_runtime::import_map::ImportMap;
6use url::Url;
7
8fn run(root: &Path, entry: &str, files: &[(&str, &str)], args: &[&str]) -> Output {
9    for (name, source) in files {
10        let path = root.join(name);
11        fs::create_dir_all(path.parent().unwrap()).unwrap();
12        fs::write(path, source).unwrap();
13    }
14    Command::new(env!("CARGO_BIN_EXE_avril"))
15        .arg("run")
16        .current_dir(root)
17        .args(args)
18        .arg(entry)
19        .output()
20        .unwrap()
21}
22
23#[test]
24fn sibling_libraries_own_their_maps_without_workspace_registration() {
25    let dir = tempfile::tempdir().unwrap();
26    let output = run(
27        dir.path(),
28        "app/main.ts",
29        &[
30            ("app/avril.conf", "imports: {dep: './dep.ts'}"),
31            (
32                "app/main.ts",
33                "import {value} from 'dep'; import {value as other} from '../library/mod.ts'; console.log(value, other, (await import('../library/mod.ts')).value);",
34            ),
35            ("app/dep.ts", "export const value = 'app';"),
36            ("library/avril.conf", "imports: {dep: './dep.ts'}"),
37            (
38                "library/mod.ts",
39                "import {value as dependency} from 'dep'; export const value = dependency;",
40            ),
41            ("library/dep.ts", "export const value = 'library';"),
42        ],
43        &[],
44    );
45    assert!(
46        output.status.success(),
47        "{}",
48        String::from_utf8_lossy(&output.stderr)
49    );
50    assert_eq!(output.stdout, b"app library library\n");
51    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
52        .arg("run")
53        .current_dir(dir.path().join("library"))
54        .arg("mod.ts")
55        .output()
56        .unwrap();
57    assert!(
58        output.status.success(),
59        "{}",
60        String::from_utf8_lossy(&output.stderr)
61    );
62}
63
64#[test]
65fn nested_boundaries_do_not_inherit_consumer_aliases() {
66    let dir = tempfile::tempdir().unwrap();
67    let output = run(
68        dir.path(),
69        "main.js",
70        &[
71            ("avril.conf", "imports: {consumerOnly: './dep.js'}"),
72            ("main.js", "import './library/mod.js';"),
73            ("dep.js", "export {};"),
74            ("library/avril.conf", ""),
75            ("library/mod.js", "import 'consumerOnly';"),
76        ],
77        &[],
78    );
79    assert!(!output.status.success());
80    assert!(
81        String::from_utf8_lossy(&output.stderr)
82            .contains("Unsupported module specifier: consumerOnly")
83    );
84}
85
86#[test]
87fn aliases_and_prefixes_share_canonical_module_identity() {
88    let dir = tempfile::tempdir().unwrap();
89    let output = run(
90        dir.path(),
91        "sub/main.ts",
92        &[
93            (
94                "avril.conf",
95                "imports: {shared: './lib/shared.ts', 'lib/': './lib/'}",
96            ),
97            (
98                "sub/main.ts",
99                "import * as a from 'shared'; import * as b from 'lib/shared.ts'; import * as c from '../lib/./shared.ts'; console.log(a === b, b === c, a.count, (await import('shared')) === a);",
100            ),
101            (
102                "lib/shared.ts",
103                "export const count = globalThis.count = (globalThis.count ?? 0) + 1;",
104            ),
105        ],
106        &[],
107    );
108    assert!(
109        output.status.success(),
110        "{}",
111        String::from_utf8_lossy(&output.stderr)
112    );
113    assert_eq!(output.stdout, b"true true 1 true\n");
114}
115
116#[test]
117fn scopes_use_longest_matches_and_fall_back_to_the_package_map() {
118    let base = Url::parse("file:///project/avril.conf").unwrap();
119    let map = ImportMap::parse(r#"
120        imports: {dep: './default.ts', common: './common.ts', 'lib/': './lib/', 'lib/special/': './special/'},
121        scopes: {
122            './src/': {dep: './src.ts'},
123            './src/nested/': {dep: './nested.ts', blocked: null},
124        },
125    "#, &base).unwrap();
126    let referrer = Url::parse("file:///project/src/nested/main.ts").unwrap();
127    assert_eq!(
128        map.resolve("dep", &referrer).unwrap().as_deref(),
129        Some("file:///project/nested.ts")
130    );
131    assert_eq!(
132        map.resolve("common", &referrer).unwrap().as_deref(),
133        Some("file:///project/common.ts")
134    );
135    assert_eq!(
136        map.resolve("lib/special/mod.ts", &referrer)
137            .unwrap()
138            .as_deref(),
139        Some("file:///project/special/mod.ts")
140    );
141    assert!(
142        map.resolve("blocked", &referrer)
143            .unwrap_err()
144            .to_string()
145            .contains("blocked")
146    );
147    assert!(
148        map.resolve("lib/../outside.ts", &referrer)
149            .unwrap_err()
150            .to_string()
151            .contains("escapes")
152    );
153    assert!(map.resolve("lib/%2e%2e/outside.ts", &referrer).is_err());
154}
155
156#[test]
157fn normalizes_url_like_keys_and_does_not_recursively_resolve_aliases() {
158    let base = Url::parse("file:///project/avril.conf").unwrap();
159    let map = ImportMap::parse(
160        "imports: {'./dep.ts': './other.ts', other: './dep.ts'},",
161        &base,
162    )
163    .unwrap();
164    let referrer = Url::parse("file:///project/sub/main.ts").unwrap();
165    assert_eq!(
166        map.resolve("../dep.ts", &referrer).unwrap().as_deref(),
167        Some("file:///project/other.ts")
168    );
169    assert_eq!(
170        map.resolve("other", &referrer).unwrap().as_deref(),
171        Some("file:///project/dep.ts")
172    );
173}
174
175#[test]
176fn unknown_options_do_not_affect_import_resolution() {
177    let base = Url::parse("file:///project/avril.conf").unwrap();
178    let map = ImportMap::parse(
179        "custom: {imports: false, nested: [null, 42]},
180         imports: {dep: './dep.ts'},
181         scopes: {'./tests/': {dep: './test-dep.ts'}}",
182        &base,
183    )
184    .unwrap();
185    for (referrer, target) in [
186        ("./main.ts", "./dep.ts"),
187        ("./tests/main.ts", "./test-dep.ts"),
188    ] {
189        assert_eq!(
190            map.resolve("dep", &base.join(referrer).unwrap()).unwrap(),
191            Some(base.join(target).unwrap().to_string())
192        );
193    }
194}
195
196#[test]
197fn invalid_schema_reports_the_offending_source_location() {
198    let base = Url::parse("file:///project/avril.conf").unwrap();
199    for (source, expected) in [
200        ("imports: []", "Expected an object"),
201        ("imports: {dep: true}", "strings or null"),
202        ("imports: {dep: 'bare'}", "URLs or relative paths"),
203        ("imports: {'lib/': './lib'}", "end with '/'"),
204        ("imports: {'': './dep.ts'}", "must not be empty"),
205        (
206            "imports: {'./dep.ts': './a.ts', 'file:///project/dep.ts': './b.ts'}",
207            "Duplicate normalized",
208        ),
209    ] {
210        let error = ImportMap::parse(source, &base).unwrap_err();
211        assert!(error.message.contains(expected), "{source}: {error}");
212        assert!(source.get(error.span).is_some());
213    }
214    let dir = tempfile::tempdir().unwrap();
215    let output = run(
216        dir.path(),
217        "main.js",
218        &[
219            ("main.js", "import 'dep';"),
220            ("avril.conf", "// heading\nimports: {dep: true}"),
221        ],
222        &[],
223    );
224    assert!(!output.status.success());
225    assert!(String::from_utf8_lossy(&output.stderr).contains("avril.conf:2:16"));
226}
227
228#[test]
229fn discovery_and_mapped_imports_do_not_expand_execution_authority() {
230    let dir = tempfile::tempdir().unwrap();
231    let output = run(
232        dir.path(),
233        "app/main.js",
234        &[
235            ("avril.conf", "imports: {secret: './secret.js'}"),
236            ("secret.js", "console.log('secret');"),
237            ("app/main.js", "import 'secret';"),
238        ],
239        &["-0", "--allow-js-exec=app"],
240    );
241    assert!(!output.status.success());
242    assert!(
243        String::from_utf8_lossy(&output.stderr).contains("Unsupported module specifier: secret")
244    );
245    let output = run(
246        dir.path(),
247        "app/main.js",
248        &[("app/avril.conf", "imports: {secret: '../secret.js'}")],
249        &["-0", "--allow-js-exec=app"],
250    );
251    assert!(!output.status.success());
252    assert!(String::from_utf8_lossy(&output.stderr).contains("PermissionDenied: js-exec"));
253    assert!(output.stdout.is_empty());
254}
255
256#[test]
257fn runtime_revocation_still_applies_to_mapped_cached_modules() {
258    let dir = tempfile::tempdir().unwrap();
259    let output = run(
260        dir.path(),
261        "main.js",
262        &[
263            ("avril.conf", "imports: {dep: './dep.js'}"),
264            (
265                "main.js",
266                "import * as Avril from 'avril:std'; await import('dep'); Avril.pledge({jsExec: {deny: ['./dep.js']}}); try {await import('dep'); throw new Error('allowed');} catch (e) {console.log(e.message.includes('PermissionDenied'));}",
267            ),
268            ("dep.js", "export {};"),
269        ],
270        &[],
271    );
272    assert!(
273        output.status.success(),
274        "{}",
275        String::from_utf8_lossy(&output.stderr)
276    );
277    assert_eq!(output.stdout, b"true\n");
278}
279
280#[test]
281fn scope_file_keys_are_exact_and_colons_can_appear_in_bare_suffixes() {
282    let base = Url::parse("file:///project/avril.conf").unwrap();
283    let map = ImportMap::parse(
284        "imports: {dep: './default.ts', 'lib/': './lib/', 'lib/a:b.ts': './lib/a:b.ts'}, scopes: {'./src': {dep: './scoped.ts'}}",
285        &base,
286    )
287    .unwrap();
288    assert_eq!(
289        map.resolve("dep", &Url::parse("file:///project/src").unwrap())
290            .unwrap()
291            .as_deref(),
292        Some("file:///project/scoped.ts")
293    );
294    assert_eq!(
295        map.resolve(
296            "dep",
297            &Url::parse("file:///project/src-legacy/main.ts").unwrap()
298        )
299        .unwrap()
300        .as_deref(),
301        Some("file:///project/default.ts")
302    );
303    assert_eq!(
304        map.resolve("lib/a:b.ts", &base).unwrap().as_deref(),
305        Some("file:///project/lib/a:b.ts")
306    );
307    assert!(map.resolve("lib/..%2fsecret.ts", &base).is_err());
308}
309
310#[test]
311fn cached_maps_do_not_skip_revoked_ancestor_authority() {
312    let dir = tempfile::tempdir().unwrap();
313    let output = run(
314        dir.path(),
315        "app/main.js",
316        &[
317            ("avril.conf", "imports: {dep: './app/dep.js'}"),
318            ("app/dep.js", "export {};"),
319            (
320                "app/main.js",
321                "import {pledge} from 'avril:std'; await import('dep'); pledge({jsExec: {allow: ['./app', './avril.conf']}}); try {await import('dep'); throw new Error('allowed');} catch(e) {console.log(e.message.includes('Unsupported module specifier: dep'));}",
322            ),
323        ],
324        &[],
325    );
326    assert!(
327        output.status.success(),
328        "{}",
329        String::from_utf8_lossy(&output.stderr)
330    );
331    assert_eq!(output.stdout, b"true\n");
332}
333
334#[test]
335fn denied_config_existence_is_not_observable() {
336    let dir = tempfile::tempdir().unwrap();
337    let files = [("main.js", "import 'dep';")];
338    let args = ["-0", "--allow-js-exec=.", "--deny-js-exec=avril.conf"];
339    let missing = run(dir.path(), "main.js", &files, &args);
340    let present = run(
341        dir.path(),
342        "main.js",
343        &[("avril.conf", "not: valid: syntax")],
344        &args,
345    );
346    assert!(!missing.status.success());
347    assert_eq!(present.stderr, missing.stderr);
348}
349
350#[cfg(unix)]
351#[test]
352fn config_symlinks_are_rejected_without_probing_referents() {
353    use std::os::unix::fs::symlink;
354    let dir = tempfile::tempdir().unwrap();
355    fs::write(dir.path().join("main.js"), "import 'dep';").unwrap();
356    fs::write(dir.path().join("existing"), "").unwrap();
357    let config = dir.path().join("avril.conf");
358    symlink(dir.path().join("existing"), &config).unwrap();
359    let present = Command::new(env!("CARGO_BIN_EXE_avril"))
360        .arg("run")
361        .current_dir(dir.path())
362        .args(["-0", "--allow-js-exec=.", "main.js"])
363        .output()
364        .unwrap();
365    fs::remove_file(&config).unwrap();
366    symlink(dir.path().join("missing"), &config).unwrap();
367    let missing = Command::new(env!("CARGO_BIN_EXE_avril"))
368        .arg("run")
369        .current_dir(dir.path())
370        .args(["-0", "--allow-js-exec=.", "main.js"])
371        .output()
372        .unwrap();
373    assert!(!present.status.success());
374    assert_eq!(present.stderr, missing.stderr);
375    assert!(String::from_utf8_lossy(&present.stderr).contains("non-symlink"));
376}
377
378#[cfg(unix)]
379#[test]
380fn discovery_stops_before_shared_ancestors() {
381    use std::os::unix::fs::PermissionsExt;
382    let dir = tempfile::tempdir().unwrap();
383    let output = run(
384        dir.path(),
385        "shared/app/main.js",
386        &[
387            ("shared/avril.conf", "imports: {dep: './dep.js'}"),
388            ("shared/dep.js", "console.log('hijacked');"),
389            ("shared/app/main.js", "import 'dep';"),
390        ],
391        &[],
392    );
393    assert!(output.status.success());
394    fs::set_permissions(dir.path().join("shared"), fs::Permissions::from_mode(0o777)).unwrap();
395    let output = run(dir.path(), "shared/app/main.js", &[], &[]);
396    assert!(!output.status.success());
397    assert!(String::from_utf8_lossy(&output.stderr).contains("Unsupported module specifier"));
398    assert!(output.stdout.is_empty());
399}