char/avril

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

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

Charlotte Somport webstorage extension to typescriptcbc8039

main
19.0 KiB512 linesraw
1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5
6fn command(project: &Path, cache: &Path) -> Command {
7    let mut command = Command::new(env!("CARGO_BIN_EXE_avril"));
8    command
9        .current_dir(project)
10        .env("AVRIL_CACHE_DIR", cache)
11        .stdin(Stdio::piped())
12        .stdout(Stdio::piped())
13        .stderr(Stdio::piped());
14    command
15}
16
17fn run(project: &Path, cache: &Path, entry: &Path) -> String {
18    let output = command(project, cache)
19        .arg("run")
20        .arg(entry)
21        .output()
22        .unwrap();
23    assert_success(&output);
24    String::from_utf8(output.stdout).unwrap()
25}
26
27fn repl(project: &Path, cache: &Path, input: &str) -> String {
28    repl_in(project, cache, input)
29}
30
31fn repl_in(directory: &Path, cache: &Path, input: &str) -> String {
32    repl_args(directory, cache, input, &["repl", "-0"])
33}
34
35fn repl_args(directory: &Path, cache: &Path, input: &str, args: &[&str]) -> String {
36    let mut child = command(directory, cache).args(args).spawn().unwrap();
37    child
38        .stdin
39        .take()
40        .unwrap()
41        .write_all(input.as_bytes())
42        .unwrap();
43    let output = child.wait_with_output().unwrap();
44    assert_success(&output);
45    String::from_utf8(output.stdout).unwrap()
46}
47
48fn assert_success(output: &Output) {
49    assert!(
50        output.status.success(),
51        "{}",
52        String::from_utf8_lossy(&output.stderr)
53    );
54}
55
56fn write(path: &Path, source: &str) {
57    fs::create_dir_all(path.parent().unwrap()).unwrap();
58    fs::write(path, source).unwrap();
59}
60
61fn file_count(directory: &Path) -> usize {
62    if !directory.exists() {
63        return 0;
64    }
65    fs::read_dir(directory)
66        .unwrap()
67        .map(|entry| {
68            let entry = entry.unwrap();
69            if entry.file_type().unwrap().is_dir() {
70                file_count(&entry.path())
71            } else {
72                1
73            }
74        })
75        .sum()
76}
77
78struct Fixture {
79    _dir: tempfile::TempDir,
80    project: PathBuf,
81    cache: PathBuf,
82}
83
84impl Fixture {
85    fn new() -> Self {
86        let dir = tempfile::tempdir().unwrap();
87        let project = dir.path().join("project");
88        let cache = dir.path().join("cache");
89        fs::create_dir_all(&project).unwrap();
90        Self { _dir: dir, project, cache }
91    }
92
93    fn run(&self, entry: &Path) -> String {
94        run(&self.project, &self.cache, entry)
95    }
96}
97
98#[test]
99fn storage_api_semantics_and_quota() {
100    let fixture = Fixture::new();
101    let entry = fixture.project.join("main.js");
102    write(
103        &entry,
104        r#"
105        if (!(localStorage instanceof Storage) ||
106            Object.prototype.toString.call(localStorage) !== '[object Storage]') throw Error('Interface');
107        try { new Storage(); throw Error('Constructor accepted'); }
108        catch (error) { if (!(error instanceof TypeError)) throw error; }
109
110        localStorage.setItem(42, { toString() { return 'value'; } });
111        if (localStorage.getItem('42') !== 'value' || localStorage.getItem(42) !== 'value') throw Error('Coercion');
112        localStorage.setItem('b', '2');
113        localStorage.setItem('a', '1');
114        if (localStorage.length !== 3 || localStorage.key(0) !== '42' ||
115            localStorage.key(1) !== 'a' || localStorage.key(3) !== null) throw Error('Enumeration');
116
117        localStorage.named = 'proxy';
118        if (localStorage.getItem('named') !== 'proxy' || localStorage.named !== 'proxy' ||
119            !('named' in localStorage) || !Object.keys(localStorage).includes('named')) throw Error('Named properties');
120        delete localStorage.named;
121        if (localStorage.getItem('named') !== null || 'named' in localStorage) throw Error('Named delete');
122
123        localStorage.setItem('length', 'stored');
124        if (typeof localStorage.length !== 'number' || localStorage.getItem('length') !== 'stored') throw Error('Built-in shadowing');
125
126        try { localStorage.setItem(Symbol(), 'x'); throw Error('Symbol accepted'); }
127        catch (error) { if (!(error instanceof TypeError)) throw error; }
128        try { structuredClone(localStorage); throw Error('Clone accepted'); }
129        catch (error) { if (error.name !== 'DataCloneError') throw error; }
130
131        localStorage.clear();
132        try {
133            localStorage.setItem('big', 'x'.repeat(10 * 1024 * 1024));
134            throw Error('Quota accepted');
135        } catch (error) {
136            if (!(error instanceof DOMException) || error.name !== 'QuotaExceededError' || error.code !== 22) throw error;
137        }
138        if (localStorage.getItem('big') !== null || localStorage.length !== 0) throw Error('Quota was not atomic');
139
140        sessionStorage.setItem('key', 'session');
141        if (sessionStorage.getItem('key') !== 'session' || sessionStorage.length !== 1) throw Error('Session storage');
142        console.log('semantics');
143        "#,
144    );
145    assert_eq!(fixture.run(&entry), "semantics\n");
146}
147
148#[test]
149fn storage_preserves_receiver_checks_coercion_and_callable_shapes() {
150    let fixture = Fixture::new();
151    let entry = fixture.project.join("main.js");
152    write(
153        &entry,
154        r#"
155        const check = (ok, message) => { if (!ok) throw Error(message); };
156        const typeError = fn => {
157            try { fn(); } catch (error) {
158                if (error instanceof TypeError) return;
159                throw error;
160            }
161            throw Error('Expected TypeError');
162        };
163        check(Storage.length === 0, 'Constructor arity');
164        for (const [name, length] of [['key', 1], ['getItem', 1], ['setItem', 2], ['removeItem', 1], ['clear', 0]]) {
165            check(Storage.prototype[name].length === length, name + ' arity');
166        }
167        const hostile = { toString() { throw Error('Coercion before validation'); } };
168        const length = Object.getOwnPropertyDescriptor(Storage.prototype, 'length').get;
169        for (const receiver of [null, undefined, 42, {}, Object.create(sessionStorage), new Proxy(sessionStorage, {})]) {
170            typeError(() => Storage.prototype.getItem.call(receiver, hostile));
171            typeError(() => Storage.prototype.setItem.call(receiver, hostile, hostile));
172            typeError(() => Storage.prototype.key.call(receiver, hostile));
173            typeError(() => Storage.prototype.removeItem.call(receiver, hostile));
174            typeError(() => Storage.prototype.clear.call(receiver));
175            typeError(() => length.call(receiver));
176        }
177        typeError(() => sessionStorage.setItem(hostile));
178        typeError(() => sessionStorage.getItem());
179        typeError(() => sessionStorage.key());
180
181        sessionStorage.setItem('a', 'value');
182        let conversions = 0;
183        check(sessionStorage.key({ valueOf() { conversions++; return 2 ** 32; } }) === 'a', 'ToUint32');
184        check(conversions === 1, 'Repeated index conversion');
185        check(sessionStorage.key(-1) === null, 'Unsigned index');
186        typeError(() => sessionStorage.key(1n));
187        typeError(() => sessionStorage.key(Symbol()));
188
189        const order = [];
190        sessionStorage.setItem(
191            { toString() { order.push('key'); return 'b'; } },
192            { toString() { order.push('value'); return 'converted'; } },
193        );
194        check(order.join(',') === 'key,value', 'Conversion order');
195        check(sessionStorage.getItem('b') === 'converted', 'Converted value');
196        const symbol = Symbol('own');
197        sessionStorage[symbol] = 42;
198        check(sessionStorage[symbol] === 42 && Reflect.ownKeys(sessionStorage).includes(symbol), 'Symbol property');
199        check(sessionStorage.length === 2, 'Symbol stored as a string key');
200        check(delete sessionStorage[symbol], 'Symbol deletion');
201        console.log('contracts');
202    "#,
203    );
204    assert_eq!(fixture.run(&entry), "contracts\n");
205}
206
207#[test]
208fn storage_uses_captured_intrinsics() {
209    let fixture = Fixture::new();
210    let entry = fixture.project.join("main.js");
211    write(
212        &entry,
213        r#"
214        const originals = [];
215        const poison = () => { throw Error('Poisoned intrinsic'); };
216        for (const [object, names] of [
217            [WeakMap.prototype, ['get', 'set']],
218            [String.prototype, ['isWellFormed']],
219            [Reflect, ['get', 'set', 'has', 'ownKeys', 'defineProperty', 'deleteProperty']],
220        ]) {
221            for (const name of names) {
222                originals.push([object, name, object[name]]);
223                object[name] = poison;
224            }
225        }
226        try {
227            sessionStorage.named = 'value';
228            if (sessionStorage.named !== 'value' || sessionStorage.getItem('named') !== 'value') throw Error('Read');
229            if (!('named' in sessionStorage) || !Object.keys(sessionStorage).includes('named')) throw Error('Keys');
230            Object.defineProperty(sessionStorage, 'defined', { value: 42, configurable: true });
231            if (sessionStorage.defined !== '42') throw Error('Define');
232            const child = Object.create(sessionStorage);
233            child.own = 'child';
234            if (child.own !== 'child' || sessionStorage.getItem('own') !== null) throw Error('Receiver');
235            const symbol = Symbol('own');
236            sessionStorage[symbol] = 42;
237            if (sessionStorage[symbol] !== 42 || !delete sessionStorage[symbol]) throw Error('Symbol');
238            delete sessionStorage.named;
239            if (sessionStorage.getItem('named') !== null) throw Error('Delete');
240            sessionStorage.clear();
241            if (sessionStorage.length !== 0) throw Error('Clear');
242        } finally {
243            for (const [object, name, value] of originals) object[name] = value;
244        }
245        console.log('captured');
246    "#,
247    );
248    assert_eq!(fixture.run(&entry), "captured\n");
249}
250
251#[test]
252fn local_storage_persists_by_config_and_entry_while_session_does_not() {
253    let fixture = Fixture::new();
254    let entry = fixture.project.join("main.js");
255    write(
256        &entry,
257        "localStorage.setItem('key', 'local'); sessionStorage.setItem('key', 'session'); console.log('stored');",
258    );
259    assert_eq!(fixture.run(&entry), "stored\n");
260
261    write(
262        &entry,
263        "console.log(localStorage.getItem('key'), sessionStorage.getItem('key'));",
264    );
265    assert_eq!(fixture.run(&entry), "local null\n");
266
267    let other = fixture.project.join("other.js");
268    write(&other, "console.log(localStorage.getItem('key'));");
269    assert_eq!(fixture.run(&other), "null\n");
270
271    // Discovering an avril.conf in an ancestor rekeys the same entry point.
272    let nested = fixture.project.join("sub/main.js");
273    write(
274        &nested,
275        "if (localStorage.getItem('key') === null) { localStorage.setItem('key', 'nested'); console.log('stored'); } else console.log(localStorage.getItem('key'));",
276    );
277    assert_eq!(fixture.run(&nested), "stored\n");
278    write(
279        &fixture.project.join("avril.conf"),
280        "name: \"storage-test\",\n",
281    );
282    write(&nested, "console.log(localStorage.getItem('key'));");
283    assert_eq!(fixture.run(&nested), "null\n");
284}
285
286#[test]
287fn untouched_storage_creates_no_files_and_repl_persists_without_capabilities() {
288    let fixture = Fixture::new();
289    let entry = fixture.project.join("main.js");
290    write(&entry, "console.log('clean');");
291    assert_eq!(fixture.run(&entry), "clean\n");
292    assert_eq!(file_count(&fixture.cache), 0);
293
294    assert_eq!(
295        repl(
296            &fixture.project,
297            &fixture.cache,
298            "localStorage.setItem('key', 'repl')\n.exit\n"
299        ),
300        "undefined\n"
301    );
302    assert_eq!(
303        repl(
304            &fixture.project,
305            &fixture.cache,
306            "localStorage.getItem('key')\n.exit\n"
307        ),
308        "\"repl\"\n"
309    );
310}
311
312#[test]
313fn proxy_brand_and_descriptor_semantics() {
314    let fixture = Fixture::new();
315    let entry = fixture.project.join("main.js");
316    write(
317        &entry,
318        r#"
319        localStorage.setItem('a', '1');
320        localStorage.setItem('b', '2');
321        if (!localStorage.hasOwnProperty('a') || localStorage.valueOf() !== localStorage) throw Error('Brand');
322        if (Storage.prototype.getItem.call(localStorage, 'a') !== '1') throw Error('Prototype receiver');
323        if (localStorage.getItem !== localStorage.getItem) throw Error('Method identity');
324        if (Object.getOwnPropertyDescriptor(localStorage, 'a').value !== '1' ||
325            Object.getOwnPropertyDescriptor(localStorage, 'a').value !== '1') throw Error('Descriptor');
326        localStorage.setItem('length', 'stored');
327        if (Object.hasOwn(localStorage, 'length') || typeof localStorage.length !== 'number') throw Error('Shadowed visibility');
328        if (delete localStorage.length !== true || localStorage.getItem('length') !== 'stored') throw Error('Shadowed delete');
329        try { localStorage.setItem('bad', '\uD800'); throw Error('Surrogate accepted'); }
330        catch (error) { if (!(error instanceof TypeError)) throw error; }
331        try { Object.defineProperty(localStorage, 'nc', { value: 'v', configurable: false }); throw Error('Define accepted'); }
332        catch (error) { if (localStorage.getItem('nc') !== null) throw error; }
333        try { Object.defineProperty(localStorage, 'acc', { get() { return 1; } }); throw Error('Accessor accepted'); }
334        catch (error) { if (localStorage.getItem('acc') !== null) throw error; }
335        try { Object.freeze(localStorage); throw Error('Freeze accepted'); }
336        catch (error) { if (!(error instanceof TypeError)) throw error; }
337        if (localStorage.getItem('a') !== '1' || !Object.keys(localStorage).includes('b')) throw Error('Freeze attempt broke storage');
338        const derived = Object.create(localStorage);
339        derived.own = 'x';
340        if (localStorage.getItem('own') !== null || derived.own !== 'x') throw Error('Receiver');
341        if (localStorage.getItem('\uD800') !== null || localStorage['\uD800'] !== undefined ||
342            ('\uD800' in localStorage) || Object.hasOwn(localStorage, '\uD800')) throw Error('Ill-formed read');
343        localStorage.removeItem('\uD800');
344        // Patching the prototype must not reroute named access or expose
345        // any hidden object, and patched methods see the public proxy.
346        const original = Storage.prototype.setItem;
347        let calls = 0, receiver = null;
348        Storage.prototype.setItem = function (key, value) { calls++; receiver = this; return original.call(this, key, value); };
349        try {
350            localStorage.named = 'via proxy';
351            if (calls !== 0 || localStorage.getItem('named') !== 'via proxy') throw Error('Named write rerouted');
352            localStorage.setItem('direct', '1');
353            if (calls !== 1 || receiver !== localStorage) throw Error('Patched receiver');
354        } finally { Storage.prototype.setItem = original; }
355        console.log('proxy');
356        "#,
357    );
358    assert_eq!(fixture.run(&entry), "proxy\n");
359}
360
361#[test]
362fn reads_do_not_modify_the_database_and_corruption_names_the_file() {
363    let fixture = Fixture::new();
364    let entry = fixture.project.join("main.js");
365    write(&entry, "localStorage.setItem('a', '1');");
366    fixture.run(&entry);
367    let database = fixture
368        .cache
369        .join("webstorage/v1")
370        .read_dir()
371        .unwrap()
372        .next()
373        .unwrap()
374        .unwrap()
375        .path();
376    let before = fs::read(&database).unwrap();
377    let modified = fs::metadata(&database).unwrap().modified().unwrap();
378    write(
379        &entry,
380        "console.log(localStorage.getItem('a'), localStorage.length, localStorage.key(0));",
381    );
382    assert_eq!(fixture.run(&entry), "1 1 a\n");
383    assert_eq!(fs::read(&database).unwrap(), before);
384    assert_eq!(
385        fs::metadata(&database).unwrap().modified().unwrap(),
386        modified
387    );
388
389    fs::write(&database, b"totally not a database").unwrap();
390    let output = command(&fixture.project, &fixture.cache)
391        .arg("run")
392        .arg(&entry)
393        .output()
394        .unwrap();
395    assert!(!output.status.success());
396    let stderr = String::from_utf8_lossy(&output.stderr);
397    assert!(stderr.contains("localStorage ("), "{stderr}");
398    assert!(
399        stderr.contains(&database.to_string_lossy().to_string()),
400        "{stderr}"
401    );
402    assert!(stderr.contains("delete the file to reset"), "{stderr}");
403}
404
405#[test]
406fn over_quota_first_write_and_info_create_no_storage_file() {
407    let fixture = Fixture::new();
408    let entry = fixture.project.join("main.js");
409    write(
410        &entry,
411        r#"
412        try { localStorage.setItem('big', 'x'.repeat(11 * 1024 * 1024)); } catch {}
413        console.log('rejected');
414        "#,
415    );
416    assert_eq!(fixture.run(&entry), "rejected\n");
417    assert_eq!(file_count(&fixture.cache), 0);
418
419    let output = command(&fixture.project, &fixture.cache)
420        .arg("info")
421        .arg(&entry)
422        .output()
423        .unwrap();
424    assert_success(&output);
425    assert!(!fixture.cache.join("webstorage").exists());
426}
427
428#[test]
429fn repl_in_a_subdirectory_shares_the_project_storage() {
430    let fixture = Fixture::new();
431    write(
432        &fixture.project.join("avril.conf"),
433        "name: \"repl-test\",\n",
434    );
435    let sub = fixture.project.join("sub");
436    fs::create_dir_all(&sub).unwrap();
437    // Unrestricted sessions, so config discovery applies; the -0 case is
438    // covered by the same-directory REPL test above.
439    assert_eq!(
440        repl_args(
441            &fixture.project,
442            &fixture.cache,
443            "localStorage.setItem('key', 'shared')\n.exit\n",
444            &["repl"]
445        ),
446        "undefined\n"
447    );
448    assert_eq!(
449        repl_args(
450            &sub,
451            &fixture.cache,
452            "localStorage.getItem('key')\n.exit\n",
453            &["repl"]
454        ),
455        "\"shared\"\n"
456    );
457}
458
459#[test]
460fn workers_have_no_storage() {
461    let fixture = Fixture::new();
462    write(
463        &fixture.project.join("worker.js"),
464        "console.log('worker', typeof localStorage, typeof sessionStorage, typeof Storage);",
465    );
466    let entry = fixture.project.join("main.js");
467    write(
468        &entry,
469        r#"
470        const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
471        await new Promise(resolve => setTimeout(resolve, 300));
472        worker.terminate();
473        console.log('main', typeof localStorage);
474        "#,
475    );
476    assert_eq!(
477        fixture.run(&entry),
478        "worker undefined undefined undefined\nmain object\n"
479    );
480}
481
482#[test]
483fn concurrent_processes_block_and_retry_instead_of_failing() {
484    let fixture = Fixture::new();
485    let entry = fixture.project.join("main.js");
486    write(
487        &entry,
488        r#"
489        for (let i = 0; i < 100; i++) {
490            localStorage.setItem('key', 'value' + i);
491            if (localStorage.getItem('key') === null) throw Error('Lost value');
492            localStorage.length;
493        }
494        console.log('done');
495        "#,
496    );
497    let first = command(&fixture.project, &fixture.cache)
498        .arg("run")
499        .arg(&entry)
500        .spawn()
501        .unwrap();
502    let second = command(&fixture.project, &fixture.cache)
503        .arg("run")
504        .arg(&entry)
505        .spawn()
506        .unwrap();
507    for child in [first, second] {
508        let output = child.wait_with_output().unwrap();
509        assert_success(&output);
510        assert_eq!(String::from_utf8(output.stdout).unwrap(), "done\n");
511    }
512}