char/avril

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

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

Charlotte Somsupport data urls1c8ffed

main
18.7 KiB536 linesraw
1mod common;
2#[path = "common/http.rs"]
3#[allow(dead_code)]
4mod http;
5#[path = "common/registry.rs"]
6mod registry;
7#[path = "common/lsp.rs"]
8#[allow(dead_code)]
9mod support;
10
11use base64::Engine;
12use serde_json::json;
13use std::fs;
14use std::path::Path;
15use std::process::{Command, Output, Stdio};
16
17fn data(mime: &str, source: &str) -> String {
18    format!(
19        "data:{mime};base64,{}",
20        base64::engine::general_purpose::STANDARD.encode(source)
21    )
22}
23
24fn run(root: &Path, args: &[&str]) -> Output {
25    let child = Command::new(env!("CARGO_BIN_EXE_avril"))
26        .current_dir(root)
27        .args(args)
28        .stdout(Stdio::piped())
29        .stderr(Stdio::piped())
30        .spawn()
31        .unwrap();
32    common::wait(child)
33}
34
35fn stdout(output: Output) -> String {
36    assert!(
37        output.status.success(),
38        "{}{}",
39        String::from_utf8_lossy(&output.stdout),
40        String::from_utf8_lossy(&output.stderr)
41    );
42    String::from_utf8(output.stdout).unwrap()
43}
44
45#[test]
46fn static_dynamic_and_mapped_data_imports_share_url_identity() {
47    let dir = tempfile::tempdir().unwrap();
48    let url = data(
49        "text/typescript",
50        r#"
51        export const count: number = globalThis.loads = (globalThis.loads ?? 0) + 1;
52        export const value: string = 'héllo?#%';
53        export const url = import.meta.url;
54        export const main = import.meta.main;
55        export const self = () => import(import.meta.url);
56    "#,
57    );
58    fs::write(
59        dir.path().join("avril.conf"),
60        json!({"imports": {"inline": url}}).to_string(),
61    )
62    .unwrap();
63    fs::write(dir.path().join("main.ts"), format!(r#"
64        import * as a from {url:?};
65        import * as mapped from 'inline';
66        export {{value}} from {url:?};
67        const [b, c] = await Promise.all([import({url:?}), import({upper:?})]);
68        const other = await import({fragment:?});
69        console.log(a === b, b === c, a === mapped, await a.self() === a);
70        console.log(a.count, other.count, a.value, a.main, a.url === {url:?});
71        console.log(import.meta.resolve('inline') === a.url, import.meta.resolve({upper:?}) === a.url);
72    "#, upper = url.replacen("data:", "DATA:", 1), fragment = format!("{url}#second"))).unwrap();
73    assert_eq!(
74        stdout(run(dir.path(), &["run", "main.ts"])),
75        "true true true true\n1 2 héllo?#% false true\ntrue true\n"
76    );
77    if dir.path().join("avril.lock").exists() {
78        assert!(
79            !fs::read_to_string(dir.path().join("avril.lock"))
80                .unwrap()
81                .contains("data:")
82        );
83    }
84}
85
86#[test]
87fn importer_maps_can_redirect_or_block_data_urls() {
88    let dir = tempfile::tempdir().unwrap();
89    let original = "data:text/javascript,export%20const%20value=1";
90    let mapped = "data:text/typescript,export%20const%20value%3Anumber=42";
91    let blocked = "data:text/javascript,export%20const%20value=0";
92    fs::write(
93        dir.path().join("avril.conf"),
94        json!({"imports": {original: mapped, blocked: null}}).to_string(),
95    )
96    .unwrap();
97    fs::write(
98        dir.path().join("main.ts"),
99        format!(
100            r#"
101        import {{value}} from {original:?};
102        console.log(value, import.meta.resolve({original:?}) === {mapped:?});
103        for (const action of [() => import({blocked:?}), () => import.meta.resolve({blocked:?})]) {{
104            try {{ await action(); throw Error('unexpected success'); }}
105            catch (e) {{ if (!e.message.includes('blocked by the import map')) throw e; }}
106        }}
107    "#
108        ),
109    )
110    .unwrap();
111    assert_eq!(stdout(run(dir.path(), &["run", "main.ts"])), "42 true\n");
112}
113
114#[test]
115fn data_entrypoints_decode_standard_payloads_without_io_grants() {
116    let dir = tempfile::tempdir().unwrap();
117    for url in [
118        "data:text/javascript,console.log('hello%20%F0%9F%98%80%3F%23%25%2B'%2Cimport.meta.main)",
119        "data:TEXT/JAVASCRIPT;charset=iso-8859-1,console.log('hello%20%F0%9F%98%80%3F%23%25%2B'%2Cimport.meta.main)#ignored",
120        "data:application/ecmascript,console.log('hello%20%F0%9F%98%80%3F%23%25%2B'%2Cimport.meta.main)",
121    ] {
122        assert_eq!(
123            stdout(run(dir.path(), &["run", "-0", url])),
124            "hello 😀?#%+ true\n"
125        );
126    }
127    let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode("console.log(42)");
128    let url = format!(
129        "data:application/typescript;base64,{}%20{}",
130        &encoded[..4],
131        &encoded[4..]
132    );
133    assert_eq!(stdout(run(dir.path(), &["run", "-0", &url])), "42\n");
134    let url = "data:text/javascript,console.log('literal?query')";
135    assert_eq!(
136        stdout(run(dir.path(), &["run", "-0", url])),
137        "literal?query\n"
138    );
139    let url = "data:text/typescript,const%20x%3Anumber=42%3Bconsole.log(x)%3B%2F%2Ffile.cjs";
140    assert_eq!(stdout(run(dir.path(), &["run", "-0", url])), "42\n");
141    assert!(!dir.path().join("avril.lock").exists());
142}
143
144#[test]
145fn malformed_and_unsupported_data_modules_fail_without_path_fallback() {
146    let dir = tempfile::tempdir().unwrap();
147    for (url, expected) in [
148        ("data:text/javascript", "Invalid data module URL"),
149        (
150            "data:text/javascript;base64,!!!",
151            "Invalid base64 in data module",
152        ),
153        (
154            "data:text/javascript;base64,YQ===",
155            "Invalid base64 in data module",
156        ),
157        (
158            "data:,console.log(1)",
159            "Unsupported data module MIME type: text/plain",
160        ),
161        (
162            "data:application/json,%7B%7D",
163            "Unsupported data module MIME type: application/json",
164        ),
165        ("data:text/javascript,const%20x:number=1", "SyntaxError"),
166    ] {
167        let output = run(dir.path(), &["run", "-0", url]);
168        assert!(!output.status.success(), "{url}");
169        let error = String::from_utf8_lossy(&output.stderr);
170        assert!(error.contains(expected), "{url}: {error}");
171    }
172}
173
174#[test]
175fn data_modules_have_no_relative_or_inherited_package_context() {
176    let dir = tempfile::tempdir().unwrap();
177    fs::write(dir.path().join("dep.ts"), "export const value = 42;").unwrap();
178    fs::write(dir.path().join("avril.conf"), "imports:{alias:'./dep.ts'}").unwrap();
179    let url = data(
180        "text/typescript",
181        r#"
182        for (const name of ['./dep.ts', '../dep.ts', '/dep.ts', '?query', '#fragment', 'alias']) {
183            for (const action of [() => import(name), () => import.meta.resolve(name)]) {
184                try { await action(); throw Error('unexpected success'); }
185                catch (e) {
186                    if (!/absolute import URLs|Unsupported module specifier/.test(e.message)) throw e;
187                }
188            }
189        }
190        console.log('no base');
191    "#,
192    );
193    fs::write(
194        dir.path().join("main.ts"),
195        format!("await import({url:?});"),
196    )
197    .unwrap();
198    assert_eq!(stdout(run(dir.path(), &["run", "main.ts"])), "no base\n");
199}
200
201#[test]
202fn data_module_dependencies_obey_live_execution_authority() {
203    let dir = tempfile::tempdir().unwrap();
204    let dep = dir.path().join("dep.ts");
205    fs::write(
206        &dep,
207        "export const value: number = 42; export const url = import.meta.url;",
208    )
209    .unwrap();
210    let dep = url::Url::from_file_path(dep).unwrap().to_string();
211    let url = data(
212        "text/typescript",
213        &format!(
214            r#"
215        import {{pledge}} from 'avril:std';
216        const target = {dep:?};
217        const first = await import(target);
218        console.log(first.value, import.meta.resolve(target) === first.url);
219        pledge({{jsExec: false}});
220        try {{ await import(target); throw Error('allowed cached import'); }}
221        catch (e) {{ if (!e.message.includes('PermissionDenied')) throw e; }}
222        console.log('narrowed');
223    "#
224        ),
225    );
226    let denied = run(dir.path(), &["run", "-0", &url]);
227    assert!(!denied.status.success());
228    assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied"));
229    assert_eq!(
230        stdout(run(
231            dir.path(),
232            &["run", "-0", "--allow-js-exec=dep.ts", &url]
233        )),
234        "42 true\nnarrowed\n"
235    );
236
237    let static_url = data(
238        "text/typescript",
239        &format!("import {{value}} from {dep:?}; console.log(value);"),
240    );
241    assert_eq!(
242        stdout(run(
243            dir.path(),
244            &["run", "-0", "--allow-js-exec=dep.ts", &static_url]
245        )),
246        "42\n"
247    );
248}
249
250#[test]
251fn data_modules_import_http_under_normal_authority() {
252    let server = http::Http::new();
253    server.source(
254        "dep.ts",
255        "export const value: number = 42; export const url = import.meta.url;",
256    );
257    let dir = tempfile::tempdir().unwrap();
258    let dep = server.url.join("dep.ts").unwrap().to_string();
259    let url = data(
260        "text/typescript",
261        &format!(
262            "import {{value, url}} from {dep:?}; console.log(value, import.meta.resolve({dep:?}) === url);"
263        ),
264    );
265    let denied = run(dir.path(), &["run", "-0", &url]);
266    assert!(!denied.status.success());
267    assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied"));
268    assert!(server.requests.lock().unwrap().is_empty());
269    let grant = format!("--allow-js-exec={}", server.url);
270    assert_eq!(
271        stdout(run(dir.path(), &["run", "-0", &grant, &url])),
272        "42 true\n"
273    );
274}
275
276#[test]
277fn data_modules_import_registry_packages_without_inheriting_bare_bindings() {
278    let server = registry::Registry::new();
279    let npm_inline = data("text/javascript", "export const value = 42;");
280    let npm_source = format!(
281        "export {{value}} from {npm_inline:?}; if(import.meta.resolve({npm_inline:?}) !== {npm_inline:?}) throw Error('data URL identity');"
282    );
283    server.publish_npm(
284        "inline-dep",
285        "1.0.0",
286        &[
287            (
288                "package.json",
289                r#"{"name":"inline-dep","version":"1.0.0","type":"module","exports":"./mod.js"}"#,
290            ),
291            ("mod.js", &npm_source),
292        ],
293    );
294    let inline = data("text/typescript", "export const value: number = 43;");
295    let jsr_source = format!("export {{value}} from {inline:?};");
296    server.package(
297        "@test/inline",
298        "1.0.0",
299        &[("mod.ts", &jsr_source)],
300        &[(".", "./mod.ts")],
301    );
302    let dir = tempfile::tempdir().unwrap();
303    let registry = format!("--npm-registry={}", server.url);
304    let jsr_registry = format!("--jsr-registry={}", server.url);
305    let cache = format!("--cache-dir={}", dir.path().join("cache").display());
306    let url = data(
307        "text/typescript",
308        r#"
309        import {value} from 'npm:inline-dep@1';
310        import {value as jsr} from 'jsr:@test/inline@1';
311        console.log(value, (await import(import.meta.resolve('npm:inline-dep@1'))).value, jsr);
312    "#,
313    );
314    let denied = run(dir.path(), &["run", "-0", &registry, &cache, &url]);
315    assert!(!denied.status.success());
316    assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied"));
317    assert!(server.requests.lock().unwrap().is_empty());
318    let grant = format!("--allow-js-exec={}", server.url);
319    assert_eq!(
320        stdout(run(
321            dir.path(),
322            &["run", "-0", &grant, &registry, &jsr_registry, &cache, &url]
323        )),
324        "42 42 43\n"
325    );
326}
327
328#[test]
329fn data_workers_use_typescript_and_inherit_authority() {
330    let dir = tempfile::tempdir().unwrap();
331    let dep = dir.path().join("dep.ts");
332    fs::write(&dep, "export const value: number = 42;").unwrap();
333    let dep = url::Url::from_file_path(dep).unwrap().to_string();
334    let worker = data(
335        "text/typescript",
336        &format!(
337            r#"
338        import {{value}} from {dep:?};
339        const answer: number = value;
340        postMessage([answer, import.meta.url === location.href, import.meta.main]);
341        close();
342    "#
343        ),
344    );
345    let main = data(
346        "text/typescript",
347        &format!(
348            r#"
349        const worker = new Worker({worker:?}, {{type: 'module'}});
350        const result = await new Promise((resolve, reject) => {{
351            worker.onmessage = event => resolve(event.data);
352            worker.onerror = reject;
353        }});
354        console.log(JSON.stringify(result));
355    "#
356        ),
357    );
358    assert_eq!(
359        stdout(run(
360            dir.path(),
361            &["run", "-0", "--allow-js-exec=dep.ts", &main]
362        )),
363        "[42,true,true]\n"
364    );
365    let denied = run(dir.path(), &["run", "-0", &main]);
366    assert!(!denied.status.success());
367    assert!(String::from_utf8_lossy(&denied.stderr).contains("PermissionDenied"));
368}
369
370#[test]
371fn bundling_uses_data_module_mime_types_and_preserves_source_maps() {
372    let dir = tempfile::tempdir().unwrap();
373    let dep = data(
374        "text/typescript",
375        "export const value: number = globalThis.answer ?? 42; // misleading.cjs",
376    );
377    fs::write(
378        dir.path().join("main.ts"),
379        format!("import {{value}} from {dep:?}; console.log(value);"),
380    )
381    .unwrap();
382    stdout(run(
383        dir.path(),
384        &["bundle", "main.ts", "-o", "dist/main.js", "--sourcemap"],
385    ));
386    assert_eq!(stdout(run(dir.path(), &["run", "dist/main.js"])), "42\n");
387    let map: serde_json::Value =
388        serde_json::from_slice(&fs::read(dir.path().join("dist/main.js.map")).unwrap()).unwrap();
389    assert!(
390        map["sources"].as_array().unwrap().contains(&json!(dep)),
391        "{map}"
392    );
393    let entry = data(
394        "text/typescript",
395        "const value: number = 43; console.log(value);",
396    );
397    stdout(run(dir.path(), &["bundle", &entry, "-o", "dist/entry.js"]));
398    assert_eq!(stdout(run(dir.path(), &["run", "dist/entry.js"])), "43\n");
399    let unnamed = run(dir.path(), &["bundle", &entry, "--outdir", "other"]);
400    assert!(!unnamed.status.success());
401    assert!(String::from_utf8_lossy(&unnamed.stderr).contains("explicit -o"));
402}
403
404#[test]
405fn typescript_data_errors_map_back_to_original_source_locations() {
406    let dir = tempfile::tempdir().unwrap();
407    let bad = data(
408        "text/typescript",
409        "type Erased = {\n  field: string;\n};\nconst value: number = 42;\nthrow new Error('data failure');",
410    );
411    let main = data(
412        "text/typescript",
413        &format!(
414            r#"
415        try {{ await import({bad:?}); throw Error('did not throw'); }}
416        catch (e) {{
417            if (!e.stack.includes({location:?})) throw e;
418            console.log('mapped');
419        }}
420    "#,
421            location = format!("{bad}:5:7")
422        ),
423    );
424    assert_eq!(stdout(run(dir.path(), &["run", "-0", &main])), "mapped\n");
425}
426
427#[test]
428fn editor_navigation_opens_immutable_data_module_sources() {
429    let Some(backend) = support::backend() else {
430        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
431        return;
432    };
433    let dir = tempfile::tempdir().unwrap();
434    let url = "data:text/typescript,export%20const%20value%3A42=42%3B%20%2F%2F%20%FF.cjs";
435    let other = url.replace("value", "%76alue");
436    let main = format!(
437        "import {{value}} from {url:?}; import {{value as other}} from {other:?}; value; other;"
438    );
439    fs::write(dir.path().join("main.ts"), &main).unwrap();
440    let mut server = support::Server::start(dir.path(), &backend, &["--offline"]);
441    server.initialize();
442    server.open("main.ts", &main);
443    assert!(
444        server
445            .hover("main.ts", &main, "value")
446            .contains("value: 42")
447    );
448    let links = server.definitions("main.ts", &main, "value");
449    let target = links[0]["targetUri"].as_str().unwrap();
450    assert!(target.starts_with("avril-builtin:/data/"), "{links:?}");
451    assert!(target.ends_with(".ts"), "{target}");
452    let contents = server
453        .call("avril/readDocument", json!({"uri": target}))
454        .unwrap();
455    assert_eq!(contents, "export const value:42=42; // �.cjs");
456    assert!(
457        server
458            .hover_uri(target, contents.as_str().unwrap(), "value")
459            .contains("value: 42")
460    );
461    let other_links = server.definitions("main.ts", &main, "other");
462    assert_ne!(links[0]["targetUri"], other_links[0]["targetUri"]);
463    let rename = server.call(
464        "textDocument/rename",
465        json!({
466            "textDocument": {"uri": target},
467            "position": {"line": 0, "character": 14},
468            "newName": "changed"
469        }),
470    );
471    assert!(
472        rename.is_err(),
473        "immutable data source accepted rename: {rename:?}"
474    );
475    server.close();
476}
477
478#[test]
479fn checking_data_modules_uses_mime_languages_and_reports_type_errors() {
480    let Some(backend) = support::backend() else {
481        eprintln!("skipping: AVRIL_TEST_TYPESCRIPT is not set");
482        return;
483    };
484    let dir = tempfile::tempdir().unwrap();
485    let backend = format!("--typescript={}", backend.display());
486    let ts = data(
487        "text/typescript",
488        "export const value: number = 42; // misleading.cjs",
489    );
490    let js = data(
491        "text/javascript",
492        "export const text = 'hello'; // misleading.ts",
493    );
494    let source = format!(
495        "import {{value}} from {ts:?}; import {{text}} from {js:?}; const n: number = value; const s: string = text;"
496    );
497    fs::write(dir.path().join("main.ts"), &source).unwrap();
498    stdout(run(
499        dir.path(),
500        &["check", &backend, "--offline", "main.ts"],
501    ));
502    let broken = source.replace("const n: number", "const n: string");
503    fs::write(dir.path().join("main.ts"), broken).unwrap();
504    let output = run(dir.path(), &["check", &backend, "--offline", "main.ts"]);
505    assert!(!output.status.success());
506    assert!(String::from_utf8_lossy(&output.stdout).contains("TS2322"));
507    let bad_js = data(
508        "text/javascript",
509        "// @ts-check\n/** @type {number} */ export const value = 'wrong';",
510    );
511    let output = run(dir.path(), &["check", &backend, "--offline", &bad_js]);
512    assert!(!output.status.success());
513    assert!(String::from_utf8_lossy(&output.stdout).contains("TS2322"));
514    let bad = data("text/typescript", "export const value: number = 'wrong';");
515    let output = run(dir.path(), &["check", &backend, "--offline", &bad]);
516    assert!(!output.status.success());
517    let diagnostics = String::from_utf8_lossy(&output.stdout);
518    assert!(diagnostics.contains("TS2322"), "{diagnostics}");
519    assert!(diagnostics.contains(&format!("{bad}:1:")), "{diagnostics}");
520    for (url, message) in [
521        (
522            "data:application/json,%7B%7D",
523            "Unsupported data module MIME type",
524        ),
525        (
526            "data:text/typescript;base64,!!!",
527            "Invalid base64 in data module",
528        ),
529    ] {
530        let output = run(dir.path(), &["check", &backend, "--offline", url]);
531        assert!(!output.status.success());
532        let diagnostics = String::from_utf8_lossy(&output.stdout);
533        assert!(diagnostics.contains(message), "{diagnostics}");
534        assert!(diagnostics.contains(&format!("{url}:1:")), "{diagnostics}");
535    }
536}