use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; fn command(project: &Path, cache: &Path) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .current_dir(project) .env("AVRIL_CACHE_DIR", cache) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); command } fn run(project: &Path, cache: &Path, entry: &Path) -> String { let output = command(project, cache) .arg("run") .arg(entry) .output() .unwrap(); assert_success(&output); String::from_utf8(output.stdout).unwrap() } fn repl(project: &Path, cache: &Path, input: &str) -> String { repl_in(project, cache, input) } fn repl_in(directory: &Path, cache: &Path, input: &str) -> String { repl_args(directory, cache, input, &["repl", "-0"]) } fn repl_args(directory: &Path, cache: &Path, input: &str, args: &[&str]) -> String { let mut child = command(directory, cache).args(args).spawn().unwrap(); child .stdin .take() .unwrap() .write_all(input.as_bytes()) .unwrap(); let output = child.wait_with_output().unwrap(); assert_success(&output); String::from_utf8(output.stdout).unwrap() } fn assert_success(output: &Output) { assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } fn write(path: &Path, source: &str) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, source).unwrap(); } fn file_count(directory: &Path) -> usize { if !directory.exists() { return 0; } fs::read_dir(directory) .unwrap() .map(|entry| { let entry = entry.unwrap(); if entry.file_type().unwrap().is_dir() { file_count(&entry.path()) } else { 1 } }) .sum() } struct Fixture { _dir: tempfile::TempDir, project: PathBuf, cache: PathBuf, } impl Fixture { fn new() -> Self { let dir = tempfile::tempdir().unwrap(); let project = dir.path().join("project"); let cache = dir.path().join("cache"); fs::create_dir_all(&project).unwrap(); Self { _dir: dir, project, cache } } fn run(&self, entry: &Path) -> String { run(&self.project, &self.cache, entry) } } #[test] fn storage_api_semantics_and_quota() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, r#" if (!(localStorage instanceof Storage) || Object.prototype.toString.call(localStorage) !== '[object Storage]') throw Error('Interface'); try { new Storage(); throw Error('Constructor accepted'); } catch (error) { if (!(error instanceof TypeError)) throw error; } localStorage.setItem(42, { toString() { return 'value'; } }); if (localStorage.getItem('42') !== 'value' || localStorage.getItem(42) !== 'value') throw Error('Coercion'); localStorage.setItem('b', '2'); localStorage.setItem('a', '1'); if (localStorage.length !== 3 || localStorage.key(0) !== '42' || localStorage.key(1) !== 'a' || localStorage.key(3) !== null) throw Error('Enumeration'); localStorage.named = 'proxy'; if (localStorage.getItem('named') !== 'proxy' || localStorage.named !== 'proxy' || !('named' in localStorage) || !Object.keys(localStorage).includes('named')) throw Error('Named properties'); delete localStorage.named; if (localStorage.getItem('named') !== null || 'named' in localStorage) throw Error('Named delete'); localStorage.setItem('length', 'stored'); if (typeof localStorage.length !== 'number' || localStorage.getItem('length') !== 'stored') throw Error('Built-in shadowing'); try { localStorage.setItem(Symbol(), 'x'); throw Error('Symbol accepted'); } catch (error) { if (!(error instanceof TypeError)) throw error; } try { structuredClone(localStorage); throw Error('Clone accepted'); } catch (error) { if (error.name !== 'DataCloneError') throw error; } localStorage.clear(); try { localStorage.setItem('big', 'x'.repeat(10 * 1024 * 1024)); throw Error('Quota accepted'); } catch (error) { if (!(error instanceof DOMException) || error.name !== 'QuotaExceededError' || error.code !== 22) throw error; } if (localStorage.getItem('big') !== null || localStorage.length !== 0) throw Error('Quota was not atomic'); sessionStorage.setItem('key', 'session'); if (sessionStorage.getItem('key') !== 'session' || sessionStorage.length !== 1) throw Error('Session storage'); console.log('semantics'); "#, ); assert_eq!(fixture.run(&entry), "semantics\n"); } #[test] fn storage_preserves_receiver_checks_coercion_and_callable_shapes() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, r#" const check = (ok, message) => { if (!ok) throw Error(message); }; const typeError = fn => { try { fn(); } catch (error) { if (error instanceof TypeError) return; throw error; } throw Error('Expected TypeError'); }; check(Storage.length === 0, 'Constructor arity'); for (const [name, length] of [['key', 1], ['getItem', 1], ['setItem', 2], ['removeItem', 1], ['clear', 0]]) { check(Storage.prototype[name].length === length, name + ' arity'); } const hostile = { toString() { throw Error('Coercion before validation'); } }; const length = Object.getOwnPropertyDescriptor(Storage.prototype, 'length').get; for (const receiver of [null, undefined, 42, {}, Object.create(sessionStorage), new Proxy(sessionStorage, {})]) { typeError(() => Storage.prototype.getItem.call(receiver, hostile)); typeError(() => Storage.prototype.setItem.call(receiver, hostile, hostile)); typeError(() => Storage.prototype.key.call(receiver, hostile)); typeError(() => Storage.prototype.removeItem.call(receiver, hostile)); typeError(() => Storage.prototype.clear.call(receiver)); typeError(() => length.call(receiver)); } typeError(() => sessionStorage.setItem(hostile)); typeError(() => sessionStorage.getItem()); typeError(() => sessionStorage.key()); sessionStorage.setItem('a', 'value'); let conversions = 0; check(sessionStorage.key({ valueOf() { conversions++; return 2 ** 32; } }) === 'a', 'ToUint32'); check(conversions === 1, 'Repeated index conversion'); check(sessionStorage.key(-1) === null, 'Unsigned index'); typeError(() => sessionStorage.key(1n)); typeError(() => sessionStorage.key(Symbol())); const order = []; sessionStorage.setItem( { toString() { order.push('key'); return 'b'; } }, { toString() { order.push('value'); return 'converted'; } }, ); check(order.join(',') === 'key,value', 'Conversion order'); check(sessionStorage.getItem('b') === 'converted', 'Converted value'); const symbol = Symbol('own'); sessionStorage[symbol] = 42; check(sessionStorage[symbol] === 42 && Reflect.ownKeys(sessionStorage).includes(symbol), 'Symbol property'); check(sessionStorage.length === 2, 'Symbol stored as a string key'); check(delete sessionStorage[symbol], 'Symbol deletion'); console.log('contracts'); "#, ); assert_eq!(fixture.run(&entry), "contracts\n"); } #[test] fn storage_uses_captured_intrinsics() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, r#" const originals = []; const poison = () => { throw Error('Poisoned intrinsic'); }; for (const [object, names] of [ [WeakMap.prototype, ['get', 'set']], [String.prototype, ['isWellFormed']], [Reflect, ['get', 'set', 'has', 'ownKeys', 'defineProperty', 'deleteProperty']], ]) { for (const name of names) { originals.push([object, name, object[name]]); object[name] = poison; } } try { sessionStorage.named = 'value'; if (sessionStorage.named !== 'value' || sessionStorage.getItem('named') !== 'value') throw Error('Read'); if (!('named' in sessionStorage) || !Object.keys(sessionStorage).includes('named')) throw Error('Keys'); Object.defineProperty(sessionStorage, 'defined', { value: 42, configurable: true }); if (sessionStorage.defined !== '42') throw Error('Define'); const child = Object.create(sessionStorage); child.own = 'child'; if (child.own !== 'child' || sessionStorage.getItem('own') !== null) throw Error('Receiver'); const symbol = Symbol('own'); sessionStorage[symbol] = 42; if (sessionStorage[symbol] !== 42 || !delete sessionStorage[symbol]) throw Error('Symbol'); delete sessionStorage.named; if (sessionStorage.getItem('named') !== null) throw Error('Delete'); sessionStorage.clear(); if (sessionStorage.length !== 0) throw Error('Clear'); } finally { for (const [object, name, value] of originals) object[name] = value; } console.log('captured'); "#, ); assert_eq!(fixture.run(&entry), "captured\n"); } #[test] fn local_storage_persists_by_config_and_entry_while_session_does_not() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, "localStorage.setItem('key', 'local'); sessionStorage.setItem('key', 'session'); console.log('stored');", ); assert_eq!(fixture.run(&entry), "stored\n"); write( &entry, "console.log(localStorage.getItem('key'), sessionStorage.getItem('key'));", ); assert_eq!(fixture.run(&entry), "local null\n"); let other = fixture.project.join("other.js"); write(&other, "console.log(localStorage.getItem('key'));"); assert_eq!(fixture.run(&other), "null\n"); // Discovering an avril.conf in an ancestor rekeys the same entry point. let nested = fixture.project.join("sub/main.js"); write( &nested, "if (localStorage.getItem('key') === null) { localStorage.setItem('key', 'nested'); console.log('stored'); } else console.log(localStorage.getItem('key'));", ); assert_eq!(fixture.run(&nested), "stored\n"); write( &fixture.project.join("avril.conf"), "name: \"storage-test\",\n", ); write(&nested, "console.log(localStorage.getItem('key'));"); assert_eq!(fixture.run(&nested), "null\n"); } #[test] fn untouched_storage_creates_no_files_and_repl_persists_without_capabilities() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write(&entry, "console.log('clean');"); assert_eq!(fixture.run(&entry), "clean\n"); assert_eq!(file_count(&fixture.cache), 0); assert_eq!( repl( &fixture.project, &fixture.cache, "localStorage.setItem('key', 'repl')\n.exit\n" ), "undefined\n" ); assert_eq!( repl( &fixture.project, &fixture.cache, "localStorage.getItem('key')\n.exit\n" ), "\"repl\"\n" ); } #[test] fn proxy_brand_and_descriptor_semantics() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, r#" localStorage.setItem('a', '1'); localStorage.setItem('b', '2'); if (!localStorage.hasOwnProperty('a') || localStorage.valueOf() !== localStorage) throw Error('Brand'); if (Storage.prototype.getItem.call(localStorage, 'a') !== '1') throw Error('Prototype receiver'); if (localStorage.getItem !== localStorage.getItem) throw Error('Method identity'); if (Object.getOwnPropertyDescriptor(localStorage, 'a').value !== '1' || Object.getOwnPropertyDescriptor(localStorage, 'a').value !== '1') throw Error('Descriptor'); localStorage.setItem('length', 'stored'); if (Object.hasOwn(localStorage, 'length') || typeof localStorage.length !== 'number') throw Error('Shadowed visibility'); if (delete localStorage.length !== true || localStorage.getItem('length') !== 'stored') throw Error('Shadowed delete'); try { localStorage.setItem('bad', '\uD800'); throw Error('Surrogate accepted'); } catch (error) { if (!(error instanceof TypeError)) throw error; } try { Object.defineProperty(localStorage, 'nc', { value: 'v', configurable: false }); throw Error('Define accepted'); } catch (error) { if (localStorage.getItem('nc') !== null) throw error; } try { Object.defineProperty(localStorage, 'acc', { get() { return 1; } }); throw Error('Accessor accepted'); } catch (error) { if (localStorage.getItem('acc') !== null) throw error; } try { Object.freeze(localStorage); throw Error('Freeze accepted'); } catch (error) { if (!(error instanceof TypeError)) throw error; } if (localStorage.getItem('a') !== '1' || !Object.keys(localStorage).includes('b')) throw Error('Freeze attempt broke storage'); const derived = Object.create(localStorage); derived.own = 'x'; if (localStorage.getItem('own') !== null || derived.own !== 'x') throw Error('Receiver'); if (localStorage.getItem('\uD800') !== null || localStorage['\uD800'] !== undefined || ('\uD800' in localStorage) || Object.hasOwn(localStorage, '\uD800')) throw Error('Ill-formed read'); localStorage.removeItem('\uD800'); // Patching the prototype must not reroute named access or expose // any hidden object, and patched methods see the public proxy. const original = Storage.prototype.setItem; let calls = 0, receiver = null; Storage.prototype.setItem = function (key, value) { calls++; receiver = this; return original.call(this, key, value); }; try { localStorage.named = 'via proxy'; if (calls !== 0 || localStorage.getItem('named') !== 'via proxy') throw Error('Named write rerouted'); localStorage.setItem('direct', '1'); if (calls !== 1 || receiver !== localStorage) throw Error('Patched receiver'); } finally { Storage.prototype.setItem = original; } console.log('proxy'); "#, ); assert_eq!(fixture.run(&entry), "proxy\n"); } #[test] fn reads_do_not_modify_the_database_and_corruption_names_the_file() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write(&entry, "localStorage.setItem('a', '1');"); fixture.run(&entry); let database = fixture .cache .join("webstorage/v1") .read_dir() .unwrap() .next() .unwrap() .unwrap() .path(); let before = fs::read(&database).unwrap(); let modified = fs::metadata(&database).unwrap().modified().unwrap(); write( &entry, "console.log(localStorage.getItem('a'), localStorage.length, localStorage.key(0));", ); assert_eq!(fixture.run(&entry), "1 1 a\n"); assert_eq!(fs::read(&database).unwrap(), before); assert_eq!( fs::metadata(&database).unwrap().modified().unwrap(), modified ); fs::write(&database, b"totally not a database").unwrap(); let output = command(&fixture.project, &fixture.cache) .arg("run") .arg(&entry) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("localStorage ("), "{stderr}"); assert!( stderr.contains(&database.to_string_lossy().to_string()), "{stderr}" ); assert!(stderr.contains("delete the file to reset"), "{stderr}"); } #[test] fn over_quota_first_write_and_info_create_no_storage_file() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, r#" try { localStorage.setItem('big', 'x'.repeat(11 * 1024 * 1024)); } catch {} console.log('rejected'); "#, ); assert_eq!(fixture.run(&entry), "rejected\n"); assert_eq!(file_count(&fixture.cache), 0); let output = command(&fixture.project, &fixture.cache) .arg("info") .arg(&entry) .output() .unwrap(); assert_success(&output); assert!(!fixture.cache.join("webstorage").exists()); } #[test] fn repl_in_a_subdirectory_shares_the_project_storage() { let fixture = Fixture::new(); write( &fixture.project.join("avril.conf"), "name: \"repl-test\",\n", ); let sub = fixture.project.join("sub"); fs::create_dir_all(&sub).unwrap(); // Unrestricted sessions, so config discovery applies; the -0 case is // covered by the same-directory REPL test above. assert_eq!( repl_args( &fixture.project, &fixture.cache, "localStorage.setItem('key', 'shared')\n.exit\n", &["repl"] ), "undefined\n" ); assert_eq!( repl_args( &sub, &fixture.cache, "localStorage.getItem('key')\n.exit\n", &["repl"] ), "\"shared\"\n" ); } #[test] fn workers_have_no_storage() { let fixture = Fixture::new(); write( &fixture.project.join("worker.js"), "console.log('worker', typeof localStorage, typeof sessionStorage, typeof Storage);", ); let entry = fixture.project.join("main.js"); write( &entry, r#" const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }); await new Promise(resolve => setTimeout(resolve, 300)); worker.terminate(); console.log('main', typeof localStorage); "#, ); assert_eq!( fixture.run(&entry), "worker undefined undefined undefined\nmain object\n" ); } #[test] fn concurrent_processes_block_and_retry_instead_of_failing() { let fixture = Fixture::new(); let entry = fixture.project.join("main.js"); write( &entry, r#" for (let i = 0; i < 100; i++) { localStorage.setItem('key', 'value' + i); if (localStorage.getItem('key') === null) throw Error('Lost value'); localStorage.length; } console.log('done'); "#, ); let first = command(&fixture.project, &fixture.cache) .arg("run") .arg(&entry) .spawn() .unwrap(); let second = command(&fixture.project, &fixture.cache) .arg("run") .arg(&entry) .spawn() .unwrap(); for child in [first, second] { let output = child.wait_with_output().unwrap(); assert_success(&output); assert_eq!(String::from_utf8(output.stdout).unwrap(), "done\n"); } }