diff --git a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map.rs b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map.rs index 7e87143275..3f00adbc73 100644 --- a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map.rs +++ b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map.rs @@ -18,7 +18,7 @@ pub(in crate::native_bridge::document) use methods::{ named_node_map_set_named_item_ns_method_callback, }; #[derive(WebApiFunctionTemplate)] -#[webapi(interface = web_api_interfaces::NamedNodeMap, enumerable)] +#[webapi(interface = web_api_interfaces::NamedNodeMap, enumerable, receiver)] struct NamedNodeMapPrototypeDeclaration { #[webapi(accessor_property, getter = named_node_map_length_getter_callback)] length: (), diff --git a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/cache.rs b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/cache.rs index 5d57fae7b4..8dd93aa2bb 100644 --- a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/cache.rs +++ b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/cache.rs @@ -42,6 +42,9 @@ pub(crate) fn live_named_node_map_wrapper<'s>( wrapper.set_internal_field(0, element.into()), "NamedNodeMap wrapper must expose its owner element field" ); + web_api_interfaces::NamedNodeMap::DESCRIPTOR + .initialize(scope, wrapper) + .expect("NamedNodeMap wrapper must have native identity"); super::super::super::super::bindings::set_named_constructor_prototype( scope, wrapper, diff --git a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/helpers.rs b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/helpers.rs index 8e75767480..096b3de0bd 100644 --- a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/helpers.rs +++ b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/helpers.rs @@ -11,6 +11,7 @@ pub(super) fn named_node_map_element<'s>( scope: &mut v8::PinScope<'s, '_>, map: v8::Local<'s, v8::Object>, ) -> Option> { + let map = moli_webapi_declare::web_api_object_target(scope, map)?; get_private_object(scope, map, NAMED_NODE_MAP_ELEMENT_SLOT).or_else(|| { map.get_internal_field(scope, 0) .and_then(|value| v8::Local::::try_from(value).ok()) diff --git a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/methods.rs b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/methods.rs index 6d2a4302c6..83b2da54b5 100644 --- a/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/methods.rs +++ b/moli-renderer-v8/src/native_bridge/document/attributes/named_node_map/methods.rs @@ -30,6 +30,13 @@ struct NamedNodeMapNamespaceArgs { local_name: String, } +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "NamedNodeMap")] +struct NamedNodeMapAttrArgs<'s> { + #[webidl(required, interface = web_api_interfaces::Attr)] + attr: v8::Local<'s, v8::Object>, +} + pub(in crate::native_bridge::document) fn named_node_map_item_method_callback<'a>( scope: &mut v8::PinScope<'a, '_>, args: v8::FunctionCallbackArguments<'a>, @@ -77,15 +84,20 @@ pub(in crate::native_bridge::document) fn named_node_map_set_named_item_method_c rv.set_null(); return; }; + let Some(parsed) = webidl::parse_args::>(scope, &args) else { + return; + }; + let attr = moli_webapi_declare::web_api_object_target(scope, parsed.attr) + .expect("converted Attr has native identity"); if let Some((runtime_ptr, _)) = detached_native_element_runtime_and_handle(scope, element) { crate::custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { - if let Some(value) = detached_native_set_attribute_node(scope, element, args.get(0)) { + if let Some(value) = detached_native_set_attribute_node(scope, element, attr.into()) { rv.set(value); } }); return; } - let Some(value) = live_set_attribute_node(scope, element, args.get(0)) else { + let Some(value) = live_set_attribute_node(scope, element, attr.into()) else { rv.set_null(); return; }; @@ -168,15 +180,20 @@ pub(in crate::native_bridge::document) fn named_node_map_set_named_item_ns_metho rv.set_null(); return; }; + let Some(parsed) = webidl::parse_args::>(scope, &args) else { + return; + }; + let attr = moli_webapi_declare::web_api_object_target(scope, parsed.attr) + .expect("converted Attr has native identity"); if let Some((runtime_ptr, _)) = detached_native_element_runtime_and_handle(scope, element) { crate::custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { - if let Some(value) = detached_native_set_attribute_node(scope, element, args.get(0)) { + if let Some(value) = detached_native_set_attribute_node(scope, element, attr.into()) { rv.set(value); } }); return; } - let Some(value) = live_set_attribute_node(scope, element, args.get(0)) else { + let Some(value) = live_set_attribute_node(scope, element, attr.into()) else { rv.set_null(); return; }; 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 e76f62d702..e3898e136a 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 @@ -83,3 +83,5 @@ mod window_current_event_private; mod video_codec_support; mod event_prototype_attributes; + +mod named_node_map_receivers; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/named_node_map_receivers.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/named_node_map_receivers.rs new file mode 100644 index 0000000000..6245f9fca2 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/named_node_map_receivers.rs @@ -0,0 +1,126 @@ +use super::*; + +#[test] +fn named_node_map_receivers_validate_native_identity_before_attr_conversion() { + let mut vm = new_parsed_test_vm( + "https://named-node-map-receivers.test/", + "", + ); + let result = vm + .eval(include_str!( + "../../../../tests/fixtures/named-node-map-receivers.js" + )) + .expect("NamedNodeMap receiver matrix should complete"); + assert_eq!(vm.eval("__uiEventResults.total").unwrap(), "1058"); + assert_eq!( + result, + "true", + "{}", + vm.eval("JSON.stringify(__uiEventResults.rows.filter(row => !row.passed))") + .unwrap(), + ); +} + +#[test] +fn named_node_map_receivers_and_attr_arguments_accept_registered_native_proxies() { + let mut vm = new_parsed_test_vm( + "https://named-node-map-native-proxies.test/", + "", + ); + vm.eval( + r#" + globalThis.nativeMapRecords = []; + globalThis.nativeMapTraps = 0; + const other = document.querySelector('#child').contentWindow; + function handler() { + return { + get() {nativeMapTraps++; throw new Error('get trap');}, + getPrototypeOf() {nativeMapTraps++; throw new Error('prototype trap');} + }; + } + for (const owner of [globalThis, other]) { + for (const flavor of ['live', 'html', 'xml', 'parsed']) { + const doc = flavor === 'live' ? owner.document : + flavor === 'html' ? owner.document.implementation.createHTMLDocument('attributes') : + flavor === 'xml' ? owner.document.implementation.createDocument(null, 'root') : + new owner.DOMParser().parseFromString('', 'application/xml'); + const root = flavor === 'xml' || flavor === 'parsed' ? doc.documentElement : doc.createElement('div'); + root.setAttribute('data-x', 'one'); + const map = root.attributes; + const attr = doc.createAttributeNS('urn:native', 'n:data-native'); attr.value = 'native'; + const nativeMap = Proxy.revocable(map, handler()); + const nativeAttr = Proxy.revocable(attr, handler()); + nativeMapRecords.push({root, map, attr, mapProxy: nativeMap.proxy, attrProxy: nativeAttr.proxy, + mapRevoke: nativeMap.revoke, attrRevoke: nativeAttr.revoke, + callee: owner === globalThis ? other : globalThis}); + } + } + true; + "#, + ) + .unwrap(); + vm.with_default_context_scope_and_checkpoint_for_test(|scope, _host_ptr| { + let global = scope.get_current_context().global(scope); + let key = v8::String::new(scope, "nativeMapRecords").unwrap(); + let value = global.get(scope, key.into()).unwrap(); + let records = v8::Local::::try_from(value).unwrap(); + assert_eq!(records.length(), 8); + for index in 0..records.length() { + let value = records.get_index(scope, index).unwrap(); + let record = v8::Local::::try_from(value).unwrap(); + for (name, is_map) in [("mapProxy", true), ("attrProxy", false)] { + let key = v8::String::new(scope, name).unwrap(); + let value = record.get(scope, key.into()).unwrap(); + let proxy = v8::Local::::try_from(value).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + assert_eq!( + crate::web_api_interfaces::NamedNodeMap::is_instance(scope, proxy.into()), + is_map, + ); + assert_eq!( + crate::web_api_interfaces::Attr::is_instance(scope, proxy.into()), + !is_map, + ); + } + } + Ok(()) + }) + .unwrap(); + assert_eq!( + vm.eval( + r#"(() => { + for (const record of nativeMapRecords) { + const {root, map, attr, mapProxy, attrProxy, mapRevoke, attrRevoke, callee} = record; + const prototype = callee.NamedNodeMap.prototype; + const length = Object.getOwnPropertyDescriptor(prototype, 'length').get; + const fails = body => { + try { body(); return false; } + catch (error) { return Object.getPrototypeOf(error) === callee.TypeError.prototype; } + }; + if (length.call(mapProxy) !== 1 || prototype.item.call(mapProxy, 0) !== root.getAttributeNode('data-x')) return false; + if (prototype.setNamedItem.call(mapProxy, attrProxy) !== null || attr.ownerElement !== root) return false; + if (prototype.getNamedItem.call(mapProxy, 'n:data-native') !== attr) return false; + if (prototype.removeNamedItem.call(mapProxy, 'n:data-native') !== attr || attr.ownerElement !== null) return false; + if (prototype.setNamedItemNS.call(mapProxy, attrProxy) !== null || attr.ownerElement !== root) return false; + if (prototype.getNamedItemNS.call(mapProxy, 'urn:native', 'data-native') !== attr) return false; + if (prototype.removeNamedItemNS.call(mapProxy, 'urn:native', 'data-native') !== attr || attr.ownerElement !== null) return false; + if (length.call(mapProxy) !== 1 || root.attributes !== map) return false; + let conversions = 0; + const name = {toString() {conversions++; throw 1;}}; + if (!fails(() => length.call(new Proxy(mapProxy, {})))) return false; + if (!fails(() => prototype.getNamedItem.call(new Proxy(mapProxy, {}), name))) return false; + if (!fails(() => prototype.setNamedItem.call(mapProxy, new Proxy(attrProxy, {})))) return false; + if (!fails(() => prototype.setNamedItemNS.call(mapProxy, root))) return false; + if (conversions !== 0 || nativeMapTraps !== 0) return false; + attrRevoke(); + if (!fails(() => prototype.setNamedItem.call(mapProxy, attrProxy))) return false; + mapRevoke(); + if (!fails(() => length.call(mapProxy))) return false; + } + return nativeMapTraps === 0; + })()"#, + ) + .unwrap(), + "true", + ); +} diff --git a/moli-renderer-v8/tests/fixtures/named-node-map-receivers.js b/moli-renderer-v8/tests/fixtures/named-node-map-receivers.js new file mode 100644 index 0000000000..71cc74eff0 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/named-node-map-receivers.js @@ -0,0 +1,166 @@ +(() => { + const rows = []; + const check = (name, body) => { + try { rows.push({name, passed: body() === true}); } + catch (error) { rows.push({name, passed: false, error: String(error)}); } + }; + const typeError = (body, realm) => { + try { body(); return false; } + catch (error) { return Object.getPrototypeOf(error) === realm.TypeError.prototype; } + }; + const realms = [['top', globalThis], ['child', document.querySelector('#child').contentWindow]]; + const entries = [['item', 1], ['getNamedItem', 1], ['getNamedItemNS', 2], + ['setNamedItem', 1], ['setNamedItemNS', 1], ['removeNamedItem', 1], ['removeNamedItemNS', 2]]; + const setup = (owner, flavor = 'live') => { + const doc = flavor === 'live' ? owner.document : + flavor === 'html' ? owner.document.implementation.createHTMLDocument('attributes') : + flavor === 'xml' ? owner.document.implementation.createDocument(null, 'root') : + new owner.DOMParser().parseFromString('', 'application/xml'); + const root = flavor === 'xml' || flavor === 'parsed' ? doc.documentElement : doc.createElement('div'); + root.setAttribute('data-x', 'one'); + root.setAttributeNS('urn:map', 'p:y', 'two'); + return {doc, root, map: root.attributes}; + }; + for (const [calleeName, callee] of realms) { + const prototype = callee.NamedNodeMap.prototype; + const length = Object.getOwnPropertyDescriptor(prototype, 'length').get; + const methods = Object.fromEntries(entries.map(([name]) => [name, prototype[name]])); + check(`${calleeName} length prototype descriptor`, () => { + const d = Object.getOwnPropertyDescriptor(prototype, 'length'); + return d.enumerable && d.configurable && d.get.name === 'get length' && d.get.length === 0 && + d.set === undefined && Object.getPrototypeOf(d.get) === callee.Function.prototype; + }); + for (const [name, arity] of entries) { + check(`${calleeName} ${name} prototype descriptor`, () => { + const d = Object.getOwnPropertyDescriptor(prototype, name); + return d.enumerable && d.configurable && d.writable && d.value.name === name && + d.value.length === arity && Object.getPrototypeOf(d.value) === callee.Function.prototype; + }); + } + for (const [ownerName, owner] of realms) { + for (const flavor of ['live', 'html', 'xml', 'parsed']) { + const label = `${calleeName} borrows ${ownerName} ${flavor}`; + const {doc, root, map} = setup(owner, flavor); + check(`${label} factory realm and SameObject`, () => + Object.getPrototypeOf(map) === owner.NamedNodeMap.prototype && + map instanceof owner.NamedNodeMap && root.attributes === map); + check(`${label} borrowed getters and lookups retain Attr identity`, () => + length.call(map) === 2 && methods.item.call(map, 0) === root.getAttributeNode('data-x') && + methods.getNamedItem.call(map, 'data-x') === map[0] && + methods.getNamedItemNS.call(map, 'urn:map', 'y') === root.getAttributeNodeNS('urn:map', 'y')); + check(`${label} live mutation retains cached Attr identity`, () => { + const attr = methods.getNamedItem.call(map, 'data-x'); + root.setAttribute('data-x', 'updated'); root.setAttribute('data-late', 'later'); + return root.attributes === map && length.call(map) === 3 && + methods.getNamedItem.call(map, 'data-x') === attr && attr.value === 'updated' && + methods.getNamedItem.call(map, 'data-late').value === 'later'; + }); + check(`${label} set and remove preserve owner and return value`, () => { + const sample = setup(owner, flavor), attr = sample.doc.createAttribute('data-new'); attr.value = 'new'; + return methods.setNamedItem.call(sample.map, attr) === null && attr.ownerElement === sample.root && + length.call(sample.map) === 3 && methods.getNamedItem.call(sample.map, 'data-new') === attr && + methods.removeNamedItem.call(sample.map, 'data-new') === attr && attr.ownerElement === null && + length.call(sample.map) === 2; + }); + check(`${label} namespace mutation preserves owner and return value`, () => { + const sample = setup(owner, flavor), attr = sample.doc.createAttributeNS('urn:new', 'n:later'); attr.value = 'new'; + return methods.setNamedItemNS.call(sample.map, attr) === null && attr.ownerElement === sample.root && + methods.getNamedItemNS.call(sample.map, 'urn:new', 'later') === attr && + methods.removeNamedItemNS.call(sample.map, 'urn:new', 'later') === attr && + attr.ownerElement === null && length.call(sample.map) === 2; + }); + check(`${label} replacement returns the previous Attr`, () => { + const sample = setup(owner, flavor), old = sample.root.getAttributeNode('data-x'); + const next = sample.doc.createAttribute('data-x'); next.value = 'replacement'; + return methods.setNamedItem.call(sample.map, next) === old && old.ownerElement === null && + next.ownerElement === sample.root && methods.getNamedItem.call(sample.map, 'data-x') === next && + length.call(sample.map) === 2; + }); + check(`${label} prototype removal preserves native backing`, () => { + const sample = setup(owner, flavor); Object.setPrototypeOf(sample.map, null); + const attr = sample.doc.createAttribute('data-new'); + return length.call(sample.map) === 2 && methods.getNamedItem.call(sample.map, 'data-x') === sample.map[0] && + methods.setNamedItem.call(sample.map, attr) === null && + methods.removeNamedItem.call(sample.map, 'data-new') === attr; + }); + check(`${label} author Proxy still forwards own indexed and named data`, () => { + const sample = setup(owner, flavor), proxy = new Proxy(sample.map, {}); + return proxy[0] === sample.map[0] && proxy['data-x'] === sample.map['data-x']; + }); + check(`${label} intrinsic iterator preserves Attr values`, () => { + const sample = setup(owner, flavor), iterate = prototype[Symbol.iterator]; + const values = Array.from(iterate.call(sample.map)); + return iterate === callee.Array.prototype.values && values.length === 2 && + values[0] === methods.item.call(sample.map, 0) && values[1] === methods.item.call(sample.map, 1); + }); + } + const sample = setup(owner); + let traps = 0, conversions = 0; + const handler = {get() {traps++; throw 1;}, getPrototypeOf() {traps++; throw 1;}, has() {traps++; throw 1;}}; + const proxy = new Proxy(sample.map, handler); + const revoked = Proxy.revocable(sample.map, handler); revoked.revoke(); + const invalid = [['null', null], ['undefined', undefined], ['plain', {}], ['number', 1], + ['prototype', owner.NamedNodeMap.prototype], ['forged', Object.create(owner.NamedNodeMap.prototype)], + ['inherited instance', Object.create(sample.map)], ['author Proxy', proxy], ['revoked Proxy', revoked.proxy], + ['Node', sample.root], ['Attr', sample.root.getAttributeNode('data-x')], + ['NodeIterator', owner.document.createNodeIterator(sample.root)], ['DOMTokenList', sample.root.classList]]; + const converted = {toString() {conversions++; throw 1;}, valueOf() {conversions++; throw 1;}}; + for (const [receiverName, receiver] of invalid) { + check(`${calleeName} length rejects ${ownerName} ${receiverName} in callee realm`, () => + typeError(() => length.call(receiver), callee)); + for (const [name] of entries) { + const attr = sample.doc.createAttribute('data-never'); + const args = name === 'item' ? [0] : name.endsWith('NS') ? + name.startsWith('set') ? [attr] : ['urn:map', 'y'] : name.startsWith('set') ? [attr] : ['data-x']; + check(`${calleeName} ${name} rejects ${ownerName} ${receiverName} with valid arguments`, () => + typeError(() => methods[name].call(receiver, ...args), callee)); + const poisoned = name.startsWith('set') ? [new Proxy(attr, handler)] : + name.endsWith('NS') ? [converted, converted] : [converted]; + check(`${calleeName} ${name} rejects ${ownerName} ${receiverName} before conversion`, () => + typeError(() => methods[name].call(receiver, ...poisoned), callee) && conversions === 0 && traps === 0); + } + } + check(`${calleeName} ${ownerName} receiver validation executes no author traps or conversions`, () => + traps === 0 && conversions === 0 && length.call(sample.map) === 2 && sample.root.getAttribute('data-x') === 'one'); + for (const [name, arity] of entries) { + for (let supplied = 0; supplied < arity; supplied++) { + check(`${calleeName} ${name} enforces ${ownerName} required arguments after ${supplied} supplied`, () => + typeError(() => methods[name].call(sample.map, ...Array(supplied).fill(null)), callee)); + } + } + const realAttr = sample.doc.createAttribute('data-invalid'); + const revokedAttr = Proxy.revocable(realAttr, handler); revokedAttr.revoke(); + const badAttributes = [['undefined', undefined], ['null', null], ['plain', {}], ['Node', sample.root], + ['prototype', owner.Attr.prototype], ['forged', Object.create(owner.Attr.prototype)], + ['inherited instance', Object.create(realAttr)], ['author Proxy', new Proxy(realAttr, handler)], + ['revoked Proxy', revokedAttr.proxy]]; + for (const name of ['setNamedItem', 'setNamedItemNS']) { + for (const [argumentName, argument] of badAttributes) { + check(`${calleeName} ${name} rejects ${ownerName} ${argumentName} Attr argument before mutation`, () => + typeError(() => methods[name].call(sample.map, argument), callee) && traps === 0 && + length.call(sample.map) === 2 && sample.root.getAttribute('data-x') === 'one'); + } + } + check(`${calleeName} ${ownerName} valid argument conversion retains order`, () => { + const trace = []; + const namespace = {toString() {trace.push('namespace'); return 'urn:map';}}; + const localName = {toString() {trace.push('name'); return 'y';}}; + return methods.getNamedItemNS.call(sample.map, namespace, localName) === sample.root.getAttributeNodeNS('urn:map', 'y') && + trace.join(',') === 'namespace,name'; + }); + } + check(`${calleeName} factory uses intrinsic identity after constructor replacement`, () => { + const saved = Object.getOwnPropertyDescriptor(callee, 'NamedNodeMap'); + try { + Object.defineProperty(callee, 'NamedNodeMap', {...saved, value: function FakeMap() { throw 1; }}); + const sample = setup(callee); + return Object.getPrototypeOf(sample.map) === prototype && length.call(sample.map) === 2 && + methods.getNamedItem.call(sample.map, 'data-x') === sample.map[0]; + } finally { Object.defineProperty(callee, 'NamedNodeMap', saved); } + }); + } + if (new Set(rows.map(row => row.name)).size !== rows.length) throw new Error('Duplicate NamedNodeMap probe names'); + globalThis.__uiEventResults = {complete: true, total: rows.length, + passed: rows.filter(row => row.passed).length, rows}; + return rows.every(row => row.passed); +})()