char/avril

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

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

Charlotte Somrearrange libs into lib and extensions into extsf4a0f5b

main
12.8 KiB362 linesraw
1use std::io;
2use std::path::Path;
3use std::process::{Command, Output};
4
5use avril_host::environment::Environment;
6use avril_runtime::caps::{Capabilities, Category, Initial};
7
8fn run(source: &str, args: &[&str], values: &[(&str, &str)]) -> Output {
9    let dir = tempfile::tempdir().unwrap();
10    std::fs::write(dir.path().join("main.ts"), source).unwrap();
11    Command::new(env!("CARGO_BIN_EXE_avril"))
12        .current_dir(dir.path())
13        .env_clear()
14        .envs(values.iter().copied())
15        .arg("run")
16        .args(args)
17        .arg("main.ts")
18        .output()
19        .unwrap()
20}
21
22fn success(output: Output) -> String {
23    assert!(
24        output.status.success(),
25        "{}",
26        String::from_utf8_lossy(&output.stderr)
27    );
28    String::from_utf8(output.stdout).unwrap()
29}
30
31#[test]
32fn inherited_values_can_be_captured_changed_and_deleted() {
33    let output = run(
34        r#"
35        import {env} from 'avril:std';
36        console.log(env.get('TOKEN'), env.has('EMPTY'), env.get('EMPTY') === '',
37            env.get('MISSING') === undefined, env.has('MISSING'));
38        const captured = env.get('TOKEN');
39        env.delete('TOKEN');
40        console.log(captured, env.has('TOKEN'));
41        env.set('TOKEN', 'new');
42        env.set('EMPTY', '');
43        env.set('NUMBER', 42 as unknown as string);
44        env.delete('MISSING');
45        console.log(env.get('TOKEN'), env.get('EMPTY') === '', env.get('NUMBER'));
46    "#,
47        &[],
48        &[("TOKEN", "secret"), ("EMPTY", "")],
49    );
50    assert_eq!(
51        success(output),
52        "secret true true true false\nsecret false\nnew true 42\n"
53    );
54}
55
56#[test]
57fn snapshots_are_filtered_independent_and_safe_for_special_names() {
58    let output = run(
59        r#"
60        import {env} from 'avril:std';
61        const snapshot = env.toObject();
62        console.log(Object.getPrototypeOf(snapshot) === null,
63            Object.keys(snapshot).sort().join(','), snapshot.__proto__, snapshot.constructor);
64        snapshot.VISIBLE = 'snapshot';
65        delete snapshot.constructor;
66        env.set('__proto__', 'changed');
67        console.log(env.get('VISIBLE'), env.get('constructor'), snapshot.__proto__);
68    "#,
69        &[
70            "-0",
71            "--allow-env=VISIBLE",
72            "--allow-env=__proto__",
73            "--allow-env=constructor",
74        ],
75        &[
76            ("VISIBLE", "original"),
77            ("SECRET", "hidden"),
78            ("__proto__", "proto"),
79            ("constructor", "ctor"),
80        ],
81    );
82    assert_eq!(
83        success(output),
84        "true VISIBLE,__proto__,constructor proto ctor\noriginal ctor proto\n"
85    );
86}
87
88#[test]
89fn named_operations_require_permission_even_for_missing_variables() {
90    for args in [
91        vec!["-0"],
92        vec![
93            "--deny-env=SECRET",
94            "--deny-env=MISSING",
95            "--allow-env=SECRET",
96        ],
97    ] {
98        let output = run(
99            r#"
100            import {env, errors} from 'avril:std';
101            for (const name of ['SECRET', 'MISSING']) {
102                for (const action of [() => env.get(name), () => env.has(name),
103                    () => env.set(name, 'replacement'), () => env.delete(name)]) {
104                    try {action(); throw Error('Allowed');}
105                    catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
106                }
107            }
108            console.log('denied', Object.keys(env.toObject()).length);
109        "#,
110            &args,
111            &[("SECRET", "secret")],
112        );
113        assert_eq!(success(output), "denied 0\n");
114    }
115}
116
117#[test]
118fn cli_grants_are_exact_repeatable_and_do_not_split_commas() {
119    let output = run(
120        r#"
121        import {env, errors} from 'avril:std';
122        console.log(Object.keys(env.toObject()).sort().join('|'));
123        for (const name of ['FIRST_EXTRA', 'FIRST,SECOND', 'DENIED']) {
124            try {env.get(name); throw Error('Allowed');}
125            catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
126        }
127    "#,
128        &[
129            "-0",
130            "--allow-env=FIRST",
131            "--allow-env=WITH,COMMA",
132            "--allow-env=SECOND",
133            "--allow-env=DENIED",
134            "--deny-env=DENIED",
135        ],
136        &[
137            ("FIRST", "1"),
138            ("SECOND", "2"),
139            ("WITH,COMMA", "3"),
140            ("DENIED", "4"),
141            ("FIRST_EXTRA", "5"),
142        ],
143    );
144    assert_eq!(success(output), "FIRST|SECOND|WITH,COMMA\n");
145}
146
147#[test]
148fn pledges_are_atomic_monotonic_and_apply_to_retained_methods() {
149    let output = run(
150        r#"
151        import {env, pledge, errors} from 'avril:std';
152        const {get, set, delete: remove, toObject} = env;
153        const captured = get('SECRET');
154        for (const request of [{env: false, read: {deny: ['\0']}},
155            {env: {allow: ['']}}, {env: {deny: ['A=B']}}, {env: {deny: ['A\0B']}},
156            {env: {allow: 'SECRET'}}, {env: true}]) {
157            try {pledge(request as any); throw Error('Invalid pledge accepted');}
158            catch (error) {if (!(error instanceof TypeError)) throw error;}
159            if (get('SECRET') !== captured) throw Error('Partial pledge');
160        }
161        pledge({env: {allow: ['SECRET', 'PUBLIC']}});
162        pledge({env: {deny: ['SECRET']}});
163        pledge({env: {allow: ['SECRET', 'OTHER']}});
164        for (const action of [() => get('SECRET'), () => set('SECRET', 'new'),
165            () => remove('SECRET'), () => get('OTHER'), () => get('PUBLIC')]) {
166            try {action(); throw Error('Authority restored');}
167            catch (error) {if (!(error instanceof errors.PermissionDenied)) throw error;}
168        }
169        console.log(captured, Object.keys(toObject()).length);
170        pledge({env: false});
171        console.log(Object.keys(env.toObject()).length);
172    "#,
173        &[],
174        &[
175            ("SECRET", "captured"),
176            ("PUBLIC", "public"),
177            ("OTHER", "other"),
178        ],
179    );
180    assert_eq!(success(output), "captured 0\n0\n");
181}
182
183#[test]
184fn invalid_names_values_and_missing_arguments_fail_without_mutating_state() {
185    let output = run(
186        r#"
187        import {env, errors} from 'avril:std';
188        for (const name of ['', 'A=B', 'A\0B']) {
189            for (const action of [() => env.get(name), () => env.has(name),
190                () => env.set(name, 'value'), () => env.delete(name)]) {
191                try {action(); throw Error('Invalid name accepted');}
192                catch (error) {if (!(error instanceof errors.InvalidInput)) throw error;}
193            }
194        }
195        try {env.set('VALID', 'bad\0value'); throw Error('Invalid value accepted');}
196        catch (error) {if (!(error instanceof errors.InvalidInput)) throw error;}
197        for (const action of [() => (env.get as any)(), () => (env.has as any)(),
198            () => (env.set as any)('VALID'), () => (env.delete as any)(), () => env.get(Symbol() as any)]) {
199            try {action(); throw Error('Invalid arguments accepted');}
200            catch (error) {if (!(error instanceof TypeError)) throw error;}
201        }
202        console.log(env.get('VALID'));
203    "#,
204        &[],
205        &[("VALID", "original")],
206    );
207    assert_eq!(success(output), "original\n");
208    for flag in ["--allow-env=A=B", "--deny-env=A=B", "--allow-env="] {
209        let output = run("throw Error('Executed');", &[flag], &[]);
210        assert!(!output.status.success());
211        assert!(!String::from_utf8_lossy(&output.stderr).contains("Executed"));
212    }
213}
214
215#[test]
216fn captured_env_api_survives_poisoned_globals_and_prototypes() {
217    let output = run(
218        r#"
219        import {env, pledge, errors} from 'avril:std';
220        const log = console.log;
221        const objectPrototype = Object.prototype;
222        const poison = () => {throw Error('Poisoned');};
223        globalThis.String = poison; globalThis.Object = poison;
224        objectPrototype.toString = poison;
225        objectPrototype.get = poison;
226        objectPrototype.set = poison;
227        Array.prototype[Symbol.iterator] = poison;
228        env.set('KEY', 'new');
229        const snapshot = env.toObject();
230        pledge({env: {allow: ['KEY']}});
231        log(env.get('KEY'), snapshot.KEY);
232        const cases = [['KEY=bad', errors.InvalidInput], ['DENIED', errors.PermissionDenied]];
233        for (let index = 0; index < cases.length; index++) {
234            try {env.get(cases[index][0]); throw Error('Allowed');}
235            catch (error) {if (!(error instanceof cases[index][1])) throw error;}
236        }
237    "#,
238        &[],
239        &[("KEY", "old")],
240    );
241    assert_eq!(success(output), "new new\n");
242}
243
244#[test]
245fn environment_authority_is_inherited_as_a_snapshot() {
246    let cwd = Path::new(".");
247    let mut initial = Initial::new(true);
248    for key in ["A", "B", "DENIED"] {
249        initial.grant(Category::Env, false, key, cwd).unwrap();
250    }
251    initial.grant(Category::Env, true, "DENIED", cwd).unwrap();
252    let parent = Capabilities::new(initial, cwd.to_owned());
253    let child = Capabilities::inherit(parent.clone());
254    let mut env = Environment::new([("A".into(), "a".into()), ("B".into(), "b".into())]).unwrap();
255    assert_eq!(
256        env.get(&child, "DENIED").unwrap_err().kind(),
257        io::ErrorKind::PermissionDenied
258    );
259    assert!(
260        parent
261            .pledge(vec![(Category::Env, None, vec!["A".into(), "".into()])])
262            .is_err()
263    );
264    assert_eq!(env.get(&child, "A").unwrap().as_deref(), Some("a"));
265    // Later pledges narrow only the parent and future workers.
266    parent
267        .pledge(vec![(Category::Env, None, vec!["A".into()])])
268        .unwrap();
269    assert_eq!(
270        env.get(&parent, "A").unwrap_err().kind(),
271        io::ErrorKind::PermissionDenied
272    );
273    assert_eq!(env.get(&child, "A").unwrap().as_deref(), Some("a"));
274    env.set(&child, "A", "replacement".into()).unwrap();
275    env.delete(&child, "A").unwrap();
276    assert_eq!(env.to_object(&child), vec![("B".into(), "b".into())]);
277    let later = Capabilities::inherit(parent.clone());
278    assert_eq!(
279        env.get(&later, "A").unwrap_err().kind(),
280        io::ErrorKind::PermissionDenied
281    );
282    child
283        .pledge(vec![(Category::Env, Some(vec![]), vec![])])
284        .unwrap();
285    assert!(env.to_object(&child).is_empty());
286    assert_eq!(env.get(&parent, "B").unwrap().as_deref(), Some("b"));
287}
288
289#[test]
290fn mutations_do_not_change_the_host_process_environment() {
291    let original = std::env::var_os("PATH");
292    let caps = Capabilities::new(Initial::new(false), Path::new(".").to_owned());
293    let mut env = Environment::from_process();
294    env.set(&caps, "PATH", "runtime-local".into()).unwrap();
295    assert_eq!(std::env::var_os("PATH"), original);
296    env.delete(&caps, "PATH").unwrap();
297    assert_eq!(std::env::var_os("PATH"), original);
298}
299
300#[cfg(unix)]
301#[test]
302fn non_utf8_host_variables_are_not_lossily_exposed() {
303    use std::ffi::OsString;
304    use std::os::unix::ffi::OsStringExt;
305    let dir = tempfile::tempdir().unwrap();
306    std::fs::write(
307        dir.path().join("main.js"),
308        "import {env} from 'avril:std'; console.log(JSON.stringify(env.toObject()));",
309    )
310    .unwrap();
311    let output = Command::new(env!("CARGO_BIN_EXE_avril"))
312        .current_dir(dir.path())
313        .env_clear()
314        .env(OsString::from_vec(vec![b'A', 0xff]), "secret")
315        .env("BAD_VALUE", OsString::from_vec(vec![0xff]))
316        .args(["run", "main.js"])
317        .output()
318        .unwrap();
319    assert_eq!(success(output), "{}\n");
320}
321
322#[cfg(windows)]
323#[test]
324fn ascii_case_folding_is_shared_by_storage_and_permissions() {
325    let cwd = Path::new(".");
326    let mut initial = Initial::new(true);
327    initial.grant(Category::Env, false, "path", cwd).unwrap();
328    let caps = Capabilities::new(initial, cwd.to_owned());
329    let mut env = Environment::new([("Path".into(), "original".into())]).unwrap();
330    assert_eq!(env.get(&caps, "PATH").unwrap().as_deref(), Some("original"));
331    assert_eq!(
332        env.to_object(&caps),
333        vec![("Path".into(), "original".into())]
334    );
335    env.set(&caps, "pAtH", "changed".into()).unwrap();
336    assert_eq!(env.get(&caps, "path").unwrap().as_deref(), Some("changed"));
337    assert_eq!(env.to_object(&caps).len(), 1);
338    caps.pledge(vec![(Category::Env, None, vec!["PaTh".into()])])
339        .unwrap();
340    assert!(env.get(&caps, "PATH").is_err());
341    assert!(env.to_object(&caps).is_empty());
342}
343
344#[cfg(unix)]
345#[test]
346fn unix_names_are_case_sensitive_in_storage_and_permissions() {
347    let cwd = Path::new(".");
348    let mut initial = Initial::new(true);
349    initial.grant(Category::Env, false, "Path", cwd).unwrap();
350    let caps = Capabilities::new(initial, cwd.to_owned());
351    let env = Environment::new([
352        ("Path".into(), "allowed".into()),
353        ("PATH".into(), "secret".into()),
354    ])
355    .unwrap();
356    assert_eq!(env.get(&caps, "Path").unwrap().as_deref(), Some("allowed"));
357    assert!(env.get(&caps, "PATH").is_err());
358    assert_eq!(
359        env.to_object(&caps),
360        vec![("Path".into(), "allowed".into())]
361    );
362}