diff --git a/moli-renderer-v8/src/context_bootstrap/event_legacy.rs b/moli-renderer-v8/src/context_bootstrap/event_legacy.rs index c85ba88e00..c7a32bda44 100644 --- a/moli-renderer-v8/src/context_bootstrap/event_legacy.rs +++ b/moli-renderer-v8/src/context_bootstrap/event_legacy.rs @@ -49,16 +49,16 @@ struct InitStorageEventArgs<'s> { bubbles: bool, #[webidl(default = false)] cancelable: bool, - #[webidl(index = 3, nullable)] - key: Option, - #[webidl(name = "oldValue", index = 4, nullable)] - old_value: Option, - #[webidl(name = "newValue", index = 5, nullable)] - new_value: Option, + #[webidl(index = 3, nullable, converter = "raw")] + key: Option, + #[webidl(name = "oldValue", index = 4, nullable, converter = "raw")] + old_value: Option, + #[webidl(name = "newValue", index = 5, nullable, converter = "raw")] + new_value: Option, #[webidl(default = "", index = 6, converter = "usv_string")] url: String, #[webidl(index = 7, converter = "raw", nullable)] - storage_area: Option>, + storage_area: Option>, } #[derive(webidl::WebIdlArgs)] @@ -508,14 +508,14 @@ pub(super) fn storage_event_init_callback<'s>( ) { return; } - super::events::define_storage_event_properties( + super::events::define_storage_event_properties_utf16( scope, event, - parsed.key.as_deref(), - parsed.old_value.as_deref(), - parsed.new_value.as_deref(), + parsed.key.as_ref().map(|value| value.0.as_slice()), + parsed.old_value.as_ref().map(|value| value.0.as_slice()), + parsed.new_value.as_ref().map(|value| value.0.as_slice()), &parsed.url, - parsed.storage_area, + parsed.storage_area.map(|value| value.0.into()), ); } diff --git a/moli-renderer-v8/src/context_bootstrap/events.rs b/moli-renderer-v8/src/context_bootstrap/events.rs index dca93b5b67..230047aec2 100644 --- a/moli-renderer-v8/src/context_bootstrap/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/events.rs @@ -1,5 +1,5 @@ use super::*; -use crate::util::{get_private_value, utf16_units, v8_string, v8_string_from_utf16_units, v8str}; +use crate::util::{get_private_value, v8_string, v8_string_from_utf16_units, v8str}; use moli_webapi_declare::{ObjectLiteralDeclaration, WebApiObject}; mod base; @@ -181,29 +181,6 @@ pub(crate) fn construct_original_storage_event_utf16<'s>( Some(event) } -pub(in crate::context_bootstrap) fn define_storage_event_properties<'s>( - scope: &mut v8::PinScope<'s, '_>, - event: v8::Local<'s, v8::Object>, - key: Option<&str>, - old_value: Option<&str>, - new_value: Option<&str>, - url: &str, - storage_area: Option>, -) { - let key = key.map(utf16_units); - let old_value = old_value.map(utf16_units); - let new_value = new_value.map(utf16_units); - define_storage_event_properties_utf16( - scope, - event, - key.as_deref(), - old_value.as_deref(), - new_value.as_deref(), - url, - storage_area, - ); -} - pub(in crate::context_bootstrap) fn define_storage_event_properties_utf16<'s>( scope: &mut v8::PinScope<'s, '_>, event: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/events/subclasses/constructor.rs b/moli-renderer-v8/src/context_bootstrap/events/subclasses/constructor.rs index 1d96815c23..15f7326d91 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/subclasses/constructor.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/subclasses/constructor.rs @@ -42,7 +42,18 @@ fn event_subclass_constructor_callback<'s>( init_arg.to_object(scope) } }; - let (bubbles, cancelable, composed) = read_event_init(scope, &args); + let storage_event_init = if kind == EventSubclassKind::StorageEvent { + let Some(init) = data::parse_storage_event_init(scope, &args) else { + return; + }; + Some(init) + } else { + None + }; + let (bubbles, cancelable, composed) = storage_event_init + .as_ref() + .map(data::StorageEventInitMembers::event_flags) + .unwrap_or_else(|| read_event_init(scope, &args)); initialize_event_object_with_type(scope, event, event_type, bubbles, cancelable); define_event_property( @@ -113,9 +124,11 @@ fn event_subclass_constructor_callback<'s>( } EventSubclassKind::MessageEvent => data::initialize_message_event(scope, event, init), EventSubclassKind::StorageEvent => { - if !data::initialize_storage_event(scope, event, init) { - return; - } + data::initialize_storage_event( + scope, + event, + storage_event_init.expect("StorageEvent init should be parsed"), + ); } EventSubclassKind::ErrorEvent => data::initialize_error_event(scope, event, init), EventSubclassKind::PromiseRejectionEvent => { diff --git a/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs b/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs index 11ed91b8d4..32e19e4f23 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/subclasses/data.rs @@ -10,6 +10,7 @@ use crate::context_bootstrap::navigation_handler_callbacks::{ use crate::context_bootstrap::navigation_window::{ child_browsing_context_handle_for_runtime_owner, runtime_window_is_global, runtime_window_owner, }; +use crate::context_bootstrap::web_storage::StorageReference; use crate::native_bridge::element::scroll_to_url_fragment_or_top; use crate::native_bridge::throw_dom_exception; use crate::util::context_host_ptr_from_global_bridge; @@ -314,17 +315,47 @@ struct ToggleEventInitMembers<'s> { #[derive(Default, webidl::WebIdlDictionary)] #[webidl(prefix = "StorageEventInit")] -struct StorageEventInitMembers<'s> { - #[webidl(nullable)] - key: Option, - #[webidl(name = "oldValue", nullable)] - old_value: Option, - #[webidl(name = "newValue", nullable)] - new_value: Option, +pub(super) struct StorageEventInitMembers<'s> { + #[webidl(default = false)] + bubbles: bool, + #[webidl(default = false)] + cancelable: bool, + #[webidl(default = false)] + composed: bool, + #[webidl(nullable, converter = "raw")] + key: Option, + #[webidl(name = "newValue", nullable, converter = "raw")] + new_value: Option, + #[webidl(name = "oldValue", nullable, converter = "raw")] + old_value: Option, + #[webidl(name = "storageArea", converter = "raw", nullable)] + storage_area: Option>, #[webidl(default = "", converter = "usv_string")] url: String, - #[webidl(name = "storageArea", converter = "raw", nullable)] - storage_area: Option>, +} + +impl StorageEventInitMembers<'_> { + pub(super) fn event_flags(&self) -> (bool, bool, bool) { + (self.bubbles, self.cancelable, self.composed) + } +} + +pub(super) fn parse_storage_event_init<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, +) -> Option> { + let parsed = webidl::dictionary_arg(args, 1, webidl::Context::argument("StorageEvent", 2)) + .and_then(|object| match object { + Some(object) => webidl::parse_dictionary_object(scope, object), + None => Ok(StorageEventInitMembers::default()), + }); + match parsed { + Ok(init) => Some(init), + Err(error) => { + webidl::throw_error(scope, &error); + None + } + } } fn toggle_event_source_member<'s>( @@ -482,30 +513,17 @@ pub(in crate::context_bootstrap::events::subclasses) fn initialize_message_event pub(in crate::context_bootstrap::events::subclasses) fn initialize_storage_event<'s>( scope: &mut v8::PinScope<'s, '_>, event: v8::Local<'s, v8::Object>, - init: Option>, -) -> bool { - let parsed = match init { - Some(init) => { - match webidl::parse_dictionary_object::(scope, init) { - Ok(parsed) => parsed, - Err(error) => { - webidl::throw_error(scope, &error); - return false; - } - } - } - None => StorageEventInitMembers::default(), - }; - crate::context_bootstrap::events::define_storage_event_properties( + parsed: StorageEventInitMembers<'s>, +) { + crate::context_bootstrap::events::define_storage_event_properties_utf16( scope, event, - parsed.key.as_deref(), - parsed.old_value.as_deref(), - parsed.new_value.as_deref(), + parsed.key.as_ref().map(|value| value.0.as_slice()), + parsed.old_value.as_ref().map(|value| value.0.as_slice()), + parsed.new_value.as_ref().map(|value| value.0.as_slice()), &parsed.url, - parsed.storage_area, + parsed.storage_area.map(|value| value.0.into()), ); - true } pub(in crate::context_bootstrap::events::subclasses) fn initialize_input_event<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/web_storage.rs b/moli-renderer-v8/src/context_bootstrap/web_storage.rs index 5b96e7728e..12ebffd590 100644 --- a/moli-renderer-v8/src/context_bootstrap/web_storage.rs +++ b/moli-renderer-v8/src/context_bootstrap/web_storage.rs @@ -1,4 +1,25 @@ use super::*; +use crate::webidl; + +pub(crate) struct StorageReference<'scope>(pub(crate) v8::Local<'scope, v8::Object>); + +impl<'scope> webidl::WebIdlConverter<'scope> for StorageReference<'scope> { + type Options = (); + + fn convert( + scope: &mut v8::PinScope<'scope, '_>, + value: v8::Local<'scope, v8::Value>, + context: webidl::Context, + _options: &Self::Options, + ) -> Result { + if let Ok(object) = v8::Local::::try_from(value) + && crate::web_api_interfaces::Storage::is_instance(scope, object) + { + return Ok(Self(object)); + } + Err(webidl::WebIdlError::cannot_convert(context, "Storage")) + } +} mod accessors; mod callbacks; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 4ae3bfb477..8c476d8574 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -49,6 +49,7 @@ mod session_description; mod simple_handler_object; mod speech_synthesis; mod storage_access; +mod storage_event_init; mod structured_clone; mod transferable_streams; mod traversal; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/storage_event_init.js b/moli-renderer-v8/src/script_vm/tests/browser_api/storage_event_init.js new file mode 100644 index 0000000000..e93448a667 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/storage_event_init.js @@ -0,0 +1,186 @@ +(() => { + const rows = []; + const check = (name, run) => { + try { + if (run() !== true) throw new Error('assertion failed'); + rows.push({name, passed: true}); + } catch (error) { + rows.push({name, passed: false, error: String(error), stack: error.stack}); + } + }; + const throws = (run, expected) => { + try { run(); } catch (error) { return error === expected; } + return false; + }; + const typeError = (run, realm = globalThis) => { + try { run(); } catch (error) { return error instanceof realm.TypeError; } + return false; + }; + const legacy = (key, oldValue, newValue, url, storageArea) => { + const event = new StorageEvent('before'); + event.initStorageEvent('after', true, true, key, oldValue, newValue, url, storageArea); + return event; + }; + const strings = ['', '\ud800', '\udc00', '\ud83d\ude00', 'a\ud800b\udc00\u0000\ud83d\ude00']; + for (const field of ['key', 'oldValue', 'newValue']) { + for (let i = 0; i < strings.length; i++) { + const text = strings[i]; + check(`constructor ${field} UTF-16 ${i}`, () => new StorageEvent('x', {[field]: text})[field] === text); + check(`legacy ${field} UTF-16 ${i}`, () => legacy(text, text, text, '')[field] === text); + } + for (const value of [undefined, null]) { + check(`constructor ${field} nullable ${String(value)}`, () => new StorageEvent('x', {[field]: value})[field] === null); + check(`legacy ${field} nullable ${String(value)}`, () => legacy(value, value, value, '')[field] === null); + } + check(`constructor ${field} string conversion`, () => { + let conversions = 0; + const value = {[Symbol.toPrimitive](hint) { if (hint !== 'string') throw new Error(hint); conversions++; return strings[4]; }}; + const event = new StorageEvent('x', {[field]: value}); + return conversions === 1 && event[field] === strings[4]; + }); + check(`legacy ${field} string conversion`, () => { + let conversions = 0; + const value = {toString() { conversions++; return strings[4]; }}; + const values = {key: null, oldValue: null, newValue: null, [field]: value}; + const event = legacy(values.key, values.oldValue, values.newValue, ''); + return conversions === 1 && event[field] === strings[4]; + }); + check(`constructor ${field} Symbol rejects`, () => typeError(() => new StorageEvent('x', {[field]: Symbol()}))); + check(`legacy ${field} Symbol rejects`, () => { + const values = {key: null, oldValue: null, newValue: null, [field]: Symbol()}; + return typeError(() => legacy(values.key, values.oldValue, values.newValue, '')); + }); + } + check('constructor defaults', () => { + const event = new StorageEvent('x'); + return event.key === null && event.oldValue === null && event.newValue === null && event.url === '' && event.storageArea === null && !event.bubbles && !event.cancelable && !event.composed; + }); + check('legacy defaults', () => { + const event = new StorageEvent('before', {key: 'k', oldValue: 'o', newValue: 'n', composed: true}); + const result = event.initStorageEvent('after'); + return result === undefined && event.type === 'after' && event.key === null && event.oldValue === null && event.newValue === null && event.url === '' && event.storageArea === null && event.composed; + }); + for (const [value, expected] of [[undefined, ''], [null, 'null'], [strings[4], 'a\ufffdb\ufffd\u0000\ud83d\ude00']]) { + check(`constructor url USVString ${String(value)}`, () => new StorageEvent('x', {url: value}).url === expected); + check(`legacy url USVString ${String(value)}`, () => legacy(null, null, null, value).url === expected); + } + for (const value of [undefined, null]) { + check(`nullable dictionary ${String(value)}`, () => new StorageEvent('x', value).key === null); + } + for (const value of [false, true, 0, 1, '', 'init', Symbol('init'), 1n]) { + check(`primitive dictionary ${String(value)}`, () => typeError(() => new StorageEvent('x', value))); + } + check('function dictionary', () => { + const init = () => {}; + init.key = strings[4]; + return new StorageEvent('x', init).key === strings[4]; + }); + const order = ['bubbles', 'cancelable', 'composed', 'key', 'newValue', 'oldValue', 'storageArea', 'url']; + check('inherited then own dictionary order', () => { + const seen = []; + new StorageEvent('x', new Proxy({}, {get(_target, name) { seen.push(name); }})); + return seen.join() === order.join(); + }); + check('dictionary reads and conversions are interleaved once', () => { + const seen = []; + const init = new Proxy({}, {get(_target, name) { + seen.push(`get:${name}`); + if (['key', 'newValue', 'oldValue', 'url'].includes(name)) { + return {[Symbol.toPrimitive](hint) { seen.push(`convert:${name}:${hint}`); return strings[4]; }}; + } + }}); + const event = new StorageEvent('x', init); + const expected = order.flatMap(name => ['key', 'newValue', 'oldValue', 'url'].includes(name) ? [`get:${name}`, `convert:${name}:string`] : [`get:${name}`]); + return seen.join() === expected.join() && event.key === strings[4] && event.url === 'a\ufffdb\ufffd\u0000\ud83d\ude00'; + }); + for (const stop of order) { + check(`dictionary getter exception stops at ${stop}`, () => { + const sentinel = {}; + const seen = []; + const init = new Proxy({}, {get(_target, name) { seen.push(name); if (name === stop) throw sentinel; }}); + return throws(() => new StorageEvent('x', init), sentinel) && seen.join() === order.slice(0, order.indexOf(stop) + 1).join(); + }); + } + for (const stop of ['key', 'newValue', 'oldValue', 'url']) { + check(`dictionary conversion exception stops at ${stop}`, () => { + const sentinel = {}; + const seen = []; + const init = new Proxy({}, {get(_target, name) { seen.push(name); if (name === stop) return {toString() { throw sentinel; }}; }}); + return throws(() => new StorageEvent('x', init), sentinel) && seen.join() === order.slice(0, order.indexOf(stop) + 1).join(); + }); + } + check('inherited dictionary values retain UTF-16', () => new StorageEvent('x', Object.create({key: strings[4], newValue: strings[1]})).key === strings[4]); + check('event type converts before dictionary', () => { + const seen = []; + const type = {toString() { seen.push('type'); return strings[4]; }}; + const init = new Proxy({}, {get(_target, name) { seen.push(name); }}); + const event = new StorageEvent(type, init); + return event.type === strings[4] && seen.join() === ['type', ...order].join(); + }); + check('legacy positional conversion order', () => { + const seen = []; + const value = name => ({toString() { seen.push(name); return name; }}); + const event = new StorageEvent('before'); + event.initStorageEvent(value('type'), true, true, value('key'), value('oldValue'), value('newValue'), value('url'), null); + return seen.join() === 'type,key,oldValue,newValue,url' && event.key === 'key' && event.oldValue === 'oldValue' && event.newValue === 'newValue'; + }); + for (const field of ['key', 'oldValue', 'newValue', 'url']) { + check(`legacy failed ${field} conversion is atomic`, () => { + const event = new StorageEvent('before', {bubbles: true, composed: true, key: 'k', oldValue: 'o', newValue: 'n', url: 'u'}); + const sentinel = {}; + const seen = []; + const names = ['key', 'oldValue', 'newValue', 'url']; + const values = names.map(name => ({toString() { seen.push(name); if (name === field) throw sentinel; return name; }})); + return throws(() => event.initStorageEvent('after', false, true, ...values, null), sentinel) && seen.join() === names.slice(0, names.indexOf(field) + 1).join() && event.type === 'before' && event.bubbles && !event.cancelable && event.composed && event.key === 'k' && event.oldValue === 'o' && event.newValue === 'n' && event.url === 'u'; + }); + } + const invalidAreas = [false, 1, 'storage', {}, [], Object.create(Storage.prototype)]; + for (let i = 0; i < invalidAreas.length; i++) { + const storageArea = invalidAreas[i]; + check(`constructor rejects unbranded storageArea ${i}`, () => typeError(() => new StorageEvent('x', {storageArea}))); + check(`legacy rejects unbranded storageArea ${i} before mutation`, () => { + const event = new StorageEvent('before', {key: 'keep'}); + return typeError(() => event.initStorageEvent('after', false, false, null, null, null, '', storageArea)) && event.type === 'before' && event.key === 'keep'; + }); + } + check('invalid storageArea stops before url getter', () => { + let reads = 0; + return typeError(() => new StorageEvent('x', {storageArea: {}, get url() { reads++; return ''; }})) && reads === 0; + }); + const other = document.getElementById('child').contentWindow; + check('constructor conversion TypeError uses callee realm', () => typeError(() => new other.StorageEvent('x', {key: Symbol()}), other)); + check('legacy conversion TypeError uses callee realm', () => typeError(() => other.StorageEvent.prototype.initStorageEvent.call(new StorageEvent('x'), 'x', false, false, Symbol()), other)); + check('constructor storageArea TypeError uses callee realm', () => typeError(() => new other.StorageEvent('x', {storageArea: {}}), other)); + check('legacy rejects forged receiver before converting arguments', () => { + let conversions = 0; + const type = {toString() { conversions++; return 'x'; }}; + return typeError(() => StorageEvent.prototype.initStorageEvent.call(Object.create(StorageEvent.prototype), type)) && conversions === 0; + }); + check('legacy dispatch guard follows conversion and preserves state', () => { + const target = new EventTarget(); + const event = new StorageEvent('storage', {key: 'keep'}); + let conversions = 0; + target.addEventListener('storage', current => { + current.initStorageEvent('after', true, true, {toString() { conversions++; return strings[4]; }}); + }); + target.dispatchEvent(event); + return conversions === 1 && event.type === 'storage' && event.key === 'keep' && !event.bubbles && !event.cancelable; + }); + if (location.protocol !== 'data:') { + for (const [name, storageArea] of [['local', localStorage], ['session', sessionStorage], ['child', other.localStorage]]) { + check(`constructor accepts branded ${name} Storage`, () => new StorageEvent('x', {storageArea}).storageArea === storageArea); + check(`legacy accepts branded ${name} Storage`, () => legacy(null, null, null, '', storageArea).storageArea === storageArea); + } + let traps = 0; + const author = new Proxy(localStorage, {get() { traps++; throw new Error('trap'); }, getPrototypeOf() { traps++; throw new Error('trap'); }}); + const revoked = Proxy.revocable(localStorage, {}); revoked.revoke(); + for (const [name, storageArea] of [['author proxy', author], ['revoked proxy', revoked.proxy], ['inherited instance', Object.create(localStorage)]]) { + check(`constructor rejects ${name} Storage without traps`, () => typeError(() => new StorageEvent('x', {storageArea})) && traps === 0); + check(`legacy rejects ${name} Storage without traps`, () => typeError(() => legacy(null, null, null, '', storageArea)) && traps === 0); + } + check('cross realm constructor accepts genuine parent Storage', () => new other.StorageEvent('x', {storageArea: localStorage, key: strings[4]}).storageArea === localStorage); + } + globalThis.__storageEventResults = {total: rows.length, passed: rows.filter(row => row.passed).length, rows}; + const failed = rows.filter(row => !row.passed); + return failed.length ? JSON.stringify(failed) : true; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/storage_event_init.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/storage_event_init.rs new file mode 100644 index 0000000000..ab602d384b --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/storage_event_init.rs @@ -0,0 +1,12 @@ +use super::*; + +#[test] +fn storage_event_initializers_preserve_dom_strings_and_validate_storage_interfaces() { + let mut vm = new_storage_html_test_vm("https://storage-event-init-webidl.test/"); + vm.eval("document.body.innerHTML = '';") + .unwrap(); + assert_eq!( + vm.eval(include_str!("storage_event_init.js")).unwrap(), + "true" + ); +}