diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs index 84438feb4d..d38d46d353 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/bindings.rs @@ -2187,6 +2187,13 @@ macro_rules! define_svg_uri_reference_accessors { } define_svg_uri_reference_accessors!(SvgAUriReferenceAccessorsDeclaration, SVGAElement); + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::SVGAElement, enumerable, receiver)] +struct SvgAElementTargetAccessorsDeclaration { + #[webapi(accessor_property = "target", getter = svg_a_target_getter)] + target: (), +} define_svg_uri_reference_accessors!(SvgImageUriReferenceAccessorsDeclaration, SVGImageElement); define_svg_uri_reference_accessors!(SvgUseUriReferenceAccessorsDeclaration, SVGUseElement); define_svg_uri_reference_accessors!( @@ -2765,6 +2772,7 @@ pub(super) fn install_svg_element_accessor_bindings<'s>( } "SVGAElement" => { SvgAUriReferenceAccessorsDeclaration::initialize_prototype_template(scope, prototype); + SvgAElementTargetAccessorsDeclaration::initialize_prototype_template(scope, prototype); } "SVGScriptElement" => { SvgScriptUriReferenceAccessorsDeclaration::initialize_prototype_template( diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/callbacks.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/callbacks.rs index 6f6b2165d9..184e53c3f1 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/callbacks.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/callbacks.rs @@ -357,6 +357,20 @@ pub(super) fn svg_uri_href_getter<'s>( svg_animated_string_attribute_getter(scope, owner, rv, SVG_URI_HREF_SLOT, "href"); } +pub(super) fn svg_a_target_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + rv: v8::ReturnValue<'_, v8::Value>, +) { + let owner = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("SVG anchor receiver validated by WebIDL"); + let Some(context) = owner.get_creation_context(scope) else { + return; + }; + let scope = &mut v8::ContextScope::new(scope, context); + svg_animated_string_attribute_getter(scope, owner, rv, "__moliSvgAAnimatedTarget", "target"); +} + pub(super) fn svg_fe_convolve_matrix_preserve_alpha_getter<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index b8ed018377..ed80a95b06 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -1689,7 +1689,10 @@ pub(crate) fn install_element_template_bindings<'s>( "HTMLScriptElement" => install!(HtmlScriptElementPrototypeDeclaration), "SVGScriptElement" => install!(SvgScriptElementPrototypeDeclaration), "SVGImageElement" => install!(SvgImageElementPrototypeDeclaration), - "SVGAElement" => install!(SvgAElementRelListPrototypeDeclaration), + "SVGAElement" => install!( + SvgAElementPrototypeDeclaration, + SvgAElementUrlPrototypeDeclaration + ), "HTMLStyleElement" => install!(HtmlStyleElementPrototypeDeclaration), "SVGStyleElement" => install!(SvgStyleElementPrototypeDeclaration), "HTMLTableElement" => install!(HtmlTableElementPrototypeDeclaration), diff --git a/moli-renderer-v8/src/native_bridge/element/class_list.rs b/moli-renderer-v8/src/native_bridge/element/class_list.rs index 4a83dd2104..55461a82c3 100644 --- a/moli-renderer-v8/src/native_bridge/element/class_list.rs +++ b/moli-renderer-v8/src/native_bridge/element/class_list.rs @@ -3,7 +3,7 @@ use super::super::{ node::{node_is_element, node_runtime_and_handle_from_object_or_detached}, throw_dom_exception, validate_class_list_token, validate_class_list_token_pair, }; -use super::{property_dom_string_value, reflected_attribute, set_reflected_attribute}; +use super::{reflected_attribute, set_reflected_attribute}; use crate::{document_runtime::DomHandle, util::v8_string}; mod accessors; diff --git a/moli-renderer-v8/src/native_bridge/element/class_list/accessors.rs b/moli-renderer-v8/src/native_bridge/element/class_list/accessors.rs index 85322beff7..5550754004 100644 --- a/moli-renderer-v8/src/native_bridge/element/class_list/accessors.rs +++ b/moli-renderer-v8/src/native_bridge/element/class_list/accessors.rs @@ -52,10 +52,18 @@ fn set_token_list_for_receiver<'s>( else { return; }; - let Some(value) = property_dom_string_value(scope, value, owner, property) else { + let Some(units) = + super::super::reflection::property_dom_string_utf16_value(scope, value, owner, property) + else { return; }; - set_reflected_attribute(scope, runtime_ptr, handle, attribute, &value); + super::super::reflection::set_reflected_attribute_utf16( + scope, + runtime_ptr, + handle, + attribute, + units, + ); } pub(in crate::native_bridge) fn html_rel_list_getter_function<'s>( diff --git a/moli-renderer-v8/src/native_bridge/element/class_list/properties.rs b/moli-renderer-v8/src/native_bridge/element/class_list/properties.rs index ae513113c9..e7eb2c4d64 100644 --- a/moli-renderer-v8/src/native_bridge/element/class_list/properties.rs +++ b/moli-renderer-v8/src/native_bridge/element/class_list/properties.rs @@ -1,7 +1,6 @@ use super::{ identity::class_list_runtime_handle_and_kind_from_object, tokens::{class_list_tokens, token_list_attribute_name}, - *, }; pub(super) fn class_list_length_getter_callback( @@ -30,13 +29,11 @@ pub(super) fn class_list_value_getter_callback( rv.set_null(); return; }; - let value = reflected_attribute( - unsafe { &*runtime_ptr }, - handle, - token_list_attribute_name(kind), - ) - .unwrap_or_default(); - let Some(value) = v8_string(scope, &value) else { + let units = unsafe { &*runtime_ptr } + .dom_host() + .get_attribute_ns_utf16_units(handle, None, token_list_attribute_name(kind)) + .unwrap_or_default(); + let Some(value) = crate::util::v8_string_from_utf16_units(scope, &units) else { rv.set_null(); return; }; @@ -54,16 +51,21 @@ pub(super) fn class_list_value_setter_callback<'s>( rv.set_undefined(); return; }; - let Some(value) = property_dom_string_value(scope, args.get(0), "DOMTokenList", "value") else { + let Some(units) = super::super::reflection::property_dom_string_utf16_value( + scope, + args.get(0), + "DOMTokenList", + "value", + ) else { rv.set_undefined(); return; }; - set_reflected_attribute( + super::super::reflection::set_reflected_attribute_utf16( scope, runtime_ptr, handle, token_list_attribute_name(kind), - &value, + units, ); rv.set_undefined(); } diff --git a/moli-renderer-v8/src/native_bridge/element/reflection.rs b/moli-renderer-v8/src/native_bridge/element/reflection.rs index b55b898460..af6c8bdcbb 100644 --- a/moli-renderer-v8/src/native_bridge/element/reflection.rs +++ b/moli-renderer-v8/src/native_bridge/element/reflection.rs @@ -268,6 +268,11 @@ pub(super) enum DomStringReflection { TableWidth, TrackLabel, UlType, + SvgAnchorDownload, + SvgAnchorRel, + SvgAnchorHreflang, + SvgAnchorType, + SvgAnchorReferrerPolicy, Count, } @@ -831,6 +836,26 @@ const DOM_STRING_REFLECTION_DESCRIPTORS: &[(DomStringReflection, DomStringReflec DomStringReflection::UlType, DomStringReflectionDescriptor::new("HTMLUListElement", "type", "type"), ), + ( + DomStringReflection::SvgAnchorDownload, + DomStringReflectionDescriptor::new("SVGAElement", "download", "download"), + ), + ( + DomStringReflection::SvgAnchorRel, + DomStringReflectionDescriptor::new("SVGAElement", "rel", "rel"), + ), + ( + DomStringReflection::SvgAnchorHreflang, + DomStringReflectionDescriptor::new("SVGAElement", "hreflang", "hreflang"), + ), + ( + DomStringReflection::SvgAnchorType, + DomStringReflectionDescriptor::new("SVGAElement", "type", "type"), + ), + ( + DomStringReflection::SvgAnchorReferrerPolicy, + DomStringReflectionDescriptor::new("SVGAElement", "referrerpolicy", "referrerPolicy"), + ), ]; const _: () = { @@ -869,6 +894,7 @@ pub(super) enum UsvStringReflection { ObjectData, QuoteCite, SourceSrcset, + SvgAnchorPing, Count, } @@ -917,6 +943,10 @@ const USV_STRING_REFLECTION_DESCRIPTORS: &[(UsvStringReflection, ReflectedAttrib UsvStringReflection::SourceSrcset, ReflectedAttributeDescriptor::new("HTMLSourceElement", "srcset", "srcset"), ), + ( + UsvStringReflection::SvgAnchorPing, + ReflectedAttributeDescriptor::new("SVGAElement", "ping", "ping"), + ), ]; const _: () = { @@ -1441,6 +1471,10 @@ pub(super) fn element_reflection_receiver_or_throw<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, ) -> Option<(*mut super::super::JsContextHost, DomHandle)> { + let Some(object) = moli_webapi_declare::web_api_object_target(scope, object) else { + webidl::throw_type_error(scope, "Illegal invocation"); + return None; + }; let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, object) else { webidl::throw_type_error(scope, "Illegal invocation"); diff --git a/moli-renderer-v8/src/native_bridge/element/resource_elements.rs b/moli-renderer-v8/src/native_bridge/element/resource_elements.rs index 7c80613f60..179f46a00b 100644 --- a/moli-renderer-v8/src/native_bridge/element/resource_elements.rs +++ b/moli-renderer-v8/src/native_bridge/element/resource_elements.rs @@ -7,6 +7,10 @@ fn anchor_url_string_function_getter<'s>( project: impl FnOnce(&url::Url) -> String, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + let Some(receiver) = moli_webapi_declare::web_api_object_target(scope, receiver) else { + rv.set_empty_string(); + return; + }; let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, receiver) else { @@ -96,8 +100,11 @@ pub(super) fn html_referrer_policy_getter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + let Some(receiver) = moli_webapi_declare::web_api_object_target(scope, args.this()) else { + return; + }; let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + node_runtime_and_handle_from_object_or_detached(scope, receiver) else { rv.set_null(); return; @@ -301,13 +308,14 @@ fn hyperlink_url_setter_input<'s>( args: &v8::FunctionCallbackArguments<'s>, property: &'static str, ) -> Option<(*mut JsContextHost, DomHandle, url::Url, String)> { - node_runtime_and_handle_from_object_or_detached(scope, args.this()).ok()?; + let receiver = moli_webapi_declare::web_api_object_target(scope, args.this())?; + node_runtime_and_handle_from_object_or_detached(scope, receiver).ok()?; let value = property_usv_string_value(scope, args.get(0), "HTMLHyperlinkElementUtils", property)?; // Conversion can change href, the document's base, or the node's owner. // Resolve the current node and URL only after those script side effects. let (runtime_ptr, handle) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()).ok()?; + node_runtime_and_handle_from_object_or_detached(scope, receiver).ok()?; let url = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href")?; Some((runtime_ptr, handle, url, value)) } @@ -2629,7 +2637,50 @@ pub(super) const HTML_NAME_REFLECTION_INTERFACES: &[ElementReflectionInterface] #[derive(WebApiFunctionTemplate)] #[webapi(interface = web_api_interfaces::SVGAElement, enumerable, receiver)] -pub(super) struct SvgAElementRelListPrototypeDeclaration { +pub(super) struct SvgAElementPrototypeDeclaration { + #[webapi( + accessor_property = "download", + getter = dom_string_reflection_getter_function, + setter = dom_string_reflection_setter_function, + data = DomStringReflection::SvgAnchorDownload + )] + download: (), + #[webapi( + accessor_property = "rel", + getter = dom_string_reflection_getter_function, + setter = dom_string_reflection_setter_function, + data = DomStringReflection::SvgAnchorRel + )] + rel: (), + #[webapi( + accessor_property = "hreflang", + getter = dom_string_reflection_getter_function, + setter = dom_string_reflection_setter_function, + data = DomStringReflection::SvgAnchorHreflang + )] + hreflang: (), + #[webapi( + accessor_property = "type", + getter = dom_string_reflection_getter_function, + setter = dom_string_reflection_setter_function, + data = DomStringReflection::SvgAnchorType + )] + r#type: (), + #[webapi( + accessor_property = "referrerPolicy", + getter = html_referrer_policy_getter_function, + setter = dom_string_reflection_setter_function, + setter_data = DomStringReflection::SvgAnchorReferrerPolicy + )] + referrer_policy: (), + #[webapi( + accessor_property, + getter = usv_string_reflection_getter_function, + setter = usv_string_reflection_setter_function, + data = UsvStringReflection::SvgAnchorPing + )] + ping: (), + #[webapi( accessor_property = "relList", getter = html_rel_list_getter_function, @@ -2637,3 +2688,64 @@ pub(super) struct SvgAElementRelListPrototypeDeclaration { )] rel_list: (), } + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::SVGAElement, enumerable, receiver)] +pub(super) struct SvgAElementUrlPrototypeDeclaration { + #[webapi( + accessor_property, + getter = anchor_host_getter_function, + setter = anchor_host_setter_function + )] + host: (), + #[webapi( + accessor_property, + getter = anchor_hostname_getter_function, + setter = anchor_hostname_setter_function + )] + hostname: (), + #[webapi( + accessor_property, + getter = anchor_port_getter_function, + setter = anchor_port_setter_function + )] + port: (), + #[webapi( + accessor_property, + getter = anchor_pathname_getter_function, + setter = anchor_pathname_setter_function + )] + pathname: (), + #[webapi( + accessor_property, + getter = anchor_search_getter_function, + setter = anchor_search_setter_function + )] + search: (), + #[webapi( + accessor_property, + getter = anchor_hash_getter_function, + setter = anchor_hash_setter_function + )] + hash: (), + #[webapi(accessor_property, getter = anchor_origin_getter_function)] + origin: (), + #[webapi( + accessor_property, + getter = anchor_protocol_getter_function, + setter = anchor_protocol_setter_function + )] + protocol: (), + #[webapi( + accessor_property, + getter = anchor_username_getter_function, + setter = anchor_username_setter_function + )] + username: (), + #[webapi( + accessor_property, + getter = anchor_password_getter_function, + setter = anchor_password_setter_function + )] + password: (), +} diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs index 430b1a8385..fb80680c36 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs @@ -42,7 +42,16 @@ pub(in crate::native_bridge::element) fn parsed_url_like_attribute( handle: DomHandle, name: &str, ) -> Option { - let value = reflected_attribute(runtime, handle, name)?; + let value = reflected_attribute(runtime, handle, name).or_else(|| { + if name != "href" || !is_svg_anchor(runtime, handle) { + return None; + } + runtime.dom_host().get_attribute_ns( + handle, + Some(crate::native_bridge::document::XLINK_NS), + name, + ) + })?; let base = url_base_for_handle(runtime, handle); parse_url_with_document_query_encoding(runtime, handle, &base, &value).ok() } @@ -119,9 +128,41 @@ pub(in crate::native_bridge::element) fn set_resolved_url_attribute( name: &str, url: &Url, ) { + // SVG2 HyperlinkElementUtils reads href first, but update-href writes the + // namespaced attribute whenever xlink:href is present, even if both exist. + let runtime = unsafe { &*runtime_ptr }; + if name == "href" + && is_svg_anchor(runtime, handle) + && runtime + .dom_host() + .get_attribute_ns(handle, Some(crate::native_bridge::document::XLINK_NS), name) + .is_some() + { + crate::custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| { + let _ = super::super::set_live_element_attribute_ns_appending_to_current_reaction_queue( + scope, + runtime_ptr, + handle, + Some(crate::native_bridge::document::XLINK_NS), + Some("xlink"), + name, + "xlink:href", + url.as_ref(), + ); + }); + return; + } set_reflected_attribute(scope, runtime_ptr, handle, name, url.as_ref()); } +fn is_svg_anchor(runtime: &JsContextHost, handle: DomHandle) -> bool { + runtime + .dom_host() + .node(handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_svg_element("a")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 61ef1e5b51..60b33fc062 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2367,5 +2367,6 @@ mod window_scroll_methods; mod window_sync_method_receivers; mod rectangle_receivers; +mod svg_hyperlinks; mod svg_transform_lists; mod svg_value_lists; diff --git a/moli-renderer-v8/src/script_vm/tests/svg_hyperlinks.js b/moli-renderer-v8/src/script_vm/tests/svg_hyperlinks.js new file mode 100644 index 0000000000..04d6b57cd9 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_hyperlinks.js @@ -0,0 +1,143 @@ +(() => { + const checks = []; + const check = (name, run) => { + try { checks.push({name, passed: run() === true}); } + catch (error) { checks.push({name, passed: false, error: String(error)}); } + }; + const SVG = 'http://www.w3.org/2000/svg', XLINK = 'http://www.w3.org/1999/xlink'; + const child = document.querySelector('iframe').contentWindow; + const components = ['origin','protocol','username','password','host','hostname','port','pathname','search','hash']; + const strings = ['download','rel','hreflang','type','referrerPolicy','ping']; + for (const [realmName, realm] of [['top',globalThis],['child',child]]) { + const proto = realm.SVGAElement.prototype; + for (const property of ['target', ...strings, ...components]) { + check(`${realmName} descriptor ${property}`, () => { + const d = Object.getOwnPropertyDescriptor(proto, property); + return d.enumerable && d.configurable && typeof d.get === 'function' && + (['target','origin'].includes(property) ? d.set === undefined : typeof d.set === 'function'); + }); + const wrong = realm.document.createElement('a'); + const genuine = realm.document.createElementNS(SVG,'a'); + const revoked = realm.Proxy.revocable(genuine,{}); revoked.revoke(); + let traps = 0; + const proxy = new realm.Proxy(genuine,{get(){traps++;throw Error('trap');}}); + for (const [kind,receiver] of [['null',null],['undefined',undefined],['plain',{}],['forged',Object.create(proto)],['inherited',Object.create(genuine)],['HTML',wrong],['other SVG',realm.document.createElementNS(SVG,'rect')],['proxy',proxy],['revoked',revoked.proxy]]) { + check(`${realmName} ${property} rejects ${kind} getter`, () => { + const d = Object.getOwnPropertyDescriptor(proto,property); let error; + try { d.get.call(receiver); } catch (e) { error=e; } + return error instanceof realm.TypeError && traps===0; + }); + if (!['target','origin'].includes(property)) check(`${realmName} ${property} rejects ${kind} before conversion`, () => { + let converted=0,error; const d=Object.getOwnPropertyDescriptor(proto,property); + try { d.set.call(receiver,{toString(){converted++;return 'changed';}}); } catch(e){error=e;} + return error instanceof realm.TypeError && converted===0 && traps===0; + }); + } + } + const documents = [['live',realm.document], ['windowless HTML',realm.document.implementation.createHTMLDocument('')], ['XML',new realm.DOMParser().parseFromString(``,'image/svg+xml')]]; + for (const [documentName,doc] of documents) { + const prefix=`${realmName} ${documentName}`; + const anchor=doc.createElementNS(SVG,'a'); + for (const property of ['download','rel','hreflang','type']) { + const attr=property; + for(const value of ['', ' MiXeD value ', '\ud800x\udfff', 'null']) check(`${prefix} ${property} DOMString ${JSON.stringify(value)}`, () => { + Object.getOwnPropertyDescriptor(proto,property).set.call(anchor,value); + return anchor.getAttribute(attr)===value && Object.getOwnPropertyDescriptor(proto,property).get.call(anchor)===value; + }); + check(`${prefix} ${property} removal`,()=>{anchor.removeAttribute(attr);return anchor[property]==='';}); + check(`${prefix} ${property} original conversion exception`,()=>{ + const marker=new realm.Error('conversion'); let error; + try{Object.getOwnPropertyDescriptor(proto,property).set.call(anchor,{toString(){throw marker;}});}catch(e){error=e;} + return error===marker && !anchor.hasAttribute(attr); + }); + } + check(`${prefix} ping USVString`,()=>{anchor.ping='\ud800 /two';return anchor.getAttribute('ping')==='\ufffd /two' && anchor.ping==='\ufffd /two';}); + check(`${prefix} ping attribute USVString`,()=>{anchor.setAttribute('ping','\ud800');return anchor.ping==='\ufffd' && anchor.getAttribute('ping')==='\ud800';}); + for(const policy of ['no-referrer','no-referrer-when-downgrade','same-origin','origin','strict-origin','origin-when-cross-origin','strict-origin-when-cross-origin','unsafe-url','invalid',' origin ','\ud800']) check(`${prefix} referrerPolicy ${JSON.stringify(policy)}`,()=>{ + anchor.referrerPolicy=policy.toUpperCase(); + return anchor.getAttribute('referrerpolicy')===policy.toUpperCase() && anchor.referrerPolicy===(['invalid',' origin ','\ud800'].includes(policy)?'':policy); + }); + check(`${prefix} target native realm and identity`,()=>{ + const target=Object.getOwnPropertyDescriptor(proto,'target').get.call(anchor); + return target===anchor.target && target instanceof realm.SVGAnimatedString && target.baseVal==='' && target.animVal===''; + }); + check(`${prefix} target live UTF16`,()=>{ + const target=anchor.target; anchor.setAttribute('target','\ud800frame'); + if(target.baseVal!=='\ud800frame'||target.animVal!=='\ud800frame')return false; + target.baseVal='_blank'; + return anchor.getAttribute('target')==='_blank' && target.animVal==='_blank' && target===anchor.target; + }); + check(`${prefix} target removal`,()=>{const saved=anchor.target;anchor.removeAttribute('target');return saved.baseVal===''&&saved.animVal==='';}); + check(`${prefix} relList SameObject and token updates`,()=>{ + anchor.rel='one one two';const list=anchor.relList; + if(list.length!==2||list[0]!=='one'||list[1]!=='two')return false; + list.add('three');list.remove('one');list.toggle('four',true);list.replace('two','five'); + return anchor.rel==='five three four' && anchor.relList===list; + }); + check(`${prefix} relList PutForwards UTF16`,()=>{anchor.relList='\ud800 token';return anchor.rel==='\ud800 token'&&anchor.relList.value==='\ud800 token';}); + check(`${prefix} relList supports`,()=>['noopener','noreferrer','opener'].every(t=>anchor.relList.supports(t)) && !anchor.relList.supports('stylesheet')); + check(`${prefix} relList token exception`,()=>{ + anchor.rel='one';let error;try{anchor.relList.add('two','has space');}catch(e){error=e;} + return error instanceof realm.DOMException && error.name==='InvalidCharacterError' && anchor.rel==='one'; + }); + for(const [kind,href,xlink] of [['missing',null,null],['href','https://user:pass@example.test:8443/a/b?q=1#part',null],['xlink',null,'https://legacy.test:8080/old?q=2#p'],['both','https://primary.test/a','https://legacy.test/old'],['empty','', 'https://legacy.test/old'],['invalid','http://[invalid',null],['opaque','mailto:user@example.test',null]]) { + anchor.removeAttribute('href');anchor.removeAttributeNS(XLINK,'href'); + if(href!==null)anchor.setAttribute('href',href); + if(xlink!==null)anchor.setAttributeNS(XLINK,'xlink:href',xlink); + let expected=null;const raw=href===null?xlink:href; + if(raw!==null)try{expected=new URL(raw,doc.baseURI);}catch{} + for(const property of components) check(`${prefix} ${kind} ${property}`,()=> + Object.getOwnPropertyDescriptor(proto,property).get.call(anchor)===(expected?expected[property]:property==='protocol'?':':'')); + check(`${prefix} ${kind} href stays SVGAnimatedString`,()=>anchor.href instanceof realm.SVGAnimatedString && anchor.href.baseVal===(raw===null?'':raw)); + } + const initial='https://user:pass@example.test:8443/a/b?q=1#part'; + for(const [property,value] of [['protocol','http'],['host','other.test:9876'],['hostname','[::1]'],['port','443'],['port','invalid'],['username','other user'],['password','secret?'],['pathname','/new path'],['search','?q=two three'],['hash','#next part'],['protocol','invalid space:']]) { + for(const mode of ['href','xlink','both'])check(`${prefix} ${mode} ${property} setter ${value}`,()=>{ + anchor.removeAttribute('href');anchor.removeAttributeNS(XLINK,'href'); + if(mode!=='xlink')anchor.setAttribute('href',initial); + if(mode!=='href')anchor.setAttributeNS(XLINK,'xlink:href',mode==='both'?'https://ignored.test/':initial); + const expected=new URL(initial);expected[property]=value; + Object.getOwnPropertyDescriptor(proto,property).set.call(anchor,value); + // SVG2 update-href writes xlink:href whenever that attribute is present. + return (mode==='href'?anchor.getAttribute('href'):anchor.getAttributeNS(XLINK,'href'))===expected.href && + (mode!=='both'||anchor.getAttribute('href')===initial); + }); + } + for(const property of components.filter(p=>p!=='origin')) { + check(`${prefix} ${property} propagates conversion exception`,()=>{ + anchor.setAttribute('href',initial);const marker=new realm.Error('convert');let error; + try{Object.getOwnPropertyDescriptor(proto,property).set.call(anchor,{toString(){throw marker;}});}catch(e){error=e;} + return error===marker && anchor.getAttribute('href')===initial; + }); + check(`${prefix} ${property} converts absent URL`,()=>{ + anchor.removeAttribute('href');anchor.removeAttributeNS(XLINK,'href');let converted=0; + Object.getOwnPropertyDescriptor(proto,property).set.call(anchor,{toString(){converted++;return 'value';}}); + return converted===1 && !anchor.hasAttribute('href'); + }); + } + check(`${prefix} reentrant href mutation before URL parse`,()=>{ + anchor.removeAttributeNS(XLINK,'href');anchor.setAttribute('href',initial); + anchor.pathname={toString(){anchor.setAttribute('href','https://changed.test/root');return '/converted';}}; + return anchor.getAttribute('href')==='https://changed.test/converted'; + }); + check(`${prefix} adoption during conversion`,()=>{ + const destination=realm.document.implementation.createHTMLDocument(''); + anchor.setAttribute('href','https://old.test/');anchor.removeAttributeNS(XLINK,'href'); + anchor.hash={toString(){destination.adoptNode(anchor);anchor.setAttribute('href','https://adopted.test/');return '#ok';}}; + return anchor.ownerDocument===destination && anchor.getAttribute('href')==='https://adopted.test/#ok'; + }); + } + check(`${realmName} document base update`,()=>{ + const doc=realm.document,base=doc.createElement('base'),a=doc.createElementNS(SVG,'a'); + base.href='https://first.test/dir/';doc.head.prepend(base);a.setAttribute('href','item'); + try{ + if(a.host!=='first.test'||a.pathname!=='/dir/item')return false; + base.href='https://second.test/next/'; + if(a.host!=='second.test'||a.pathname!=='/next/item')return false; + a.hash={toString(){base.href='https://third.test/final/';return '#done';}}; + return a.getAttribute('href')==='https://third.test/final/item#done'; + }finally{base.remove();} + }); + } + globalThis.__uiEventResults={checks,complete:true,total:checks.length,passed:checks.filter(c=>c.passed).length}; +})(); diff --git a/moli-renderer-v8/src/script_vm/tests/svg_hyperlinks.rs b/moli-renderer-v8/src/script_vm/tests/svg_hyperlinks.rs new file mode 100644 index 0000000000..d40d5f043c --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_hyperlinks.rs @@ -0,0 +1,98 @@ +use super::*; + +#[test] +fn svg_hyperlinks_reflect_attributes_and_preserve_conversion_order() { + let mut vm = new_storage_page_task_executor_test_vm("https://svg-hyperlinks.test/"); + vm.eval("document.body.innerHTML=''") + .unwrap(); + vm.eval(include_str!("svg_hyperlinks.js")).unwrap(); + assert_eq!(vm.eval("__uiEventResults.complete").unwrap(), "true"); + assert_eq!(vm.eval("__uiEventResults.total").unwrap(), "1656"); + assert_eq!( + vm.eval("JSON.stringify(__uiEventResults.checks.filter(row=>!row.passed).slice(0,20))") + .unwrap(), + "[]" + ); + assert_eq!(vm.eval("__uiEventResults.passed").unwrap(), "1656"); +} + +#[test] +fn svg_hyperlink_registered_proxies_share_native_state() { + let mut vm = new_storage_page_task_executor_test_vm("https://svg-link-proxies.test/"); + vm.eval( + r#" + document.body.innerHTML=''; + const child=document.querySelector('iframe').contentWindow; + const detached=child.document.implementation.createHTMLDocument(''); + const anchor=detached.createElementNS('http://www.w3.org/2000/svg','a'); + anchor.setAttribute('href','https://example.test/old'); + globalThis.__linkTargets={anchor,target:anchor.target,href:anchor.href}; + globalThis.__linkProxies={};globalThis.__linkTraps=0; + globalThis.__linkTrap=function(){__linkTraps++;throw Error('proxy trap');}; + "#, + ) + .unwrap(); + let context_ptr = &vm.page_default_context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| { + let global = scope.get_current_context().global(scope); + let targets_key = crate::util::v8str(scope, "__linkTargets"); + let targets = global.get(scope, targets_key.into()).unwrap(); + let targets = v8::Local::::try_from(targets).unwrap(); + let proxies_key = crate::util::v8str(scope, "__linkProxies"); + let proxies = global.get(scope, proxies_key.into()).unwrap(); + let trap_key = crate::util::v8str(scope, "__linkTrap"); + let trap = global.get(scope, trap_key.into()).unwrap(); + let proxies = v8::Local::::try_from(proxies).unwrap(); + for name in ["anchor", "target", "href"] { + let key = crate::util::v8str(scope, name); + let target = targets.get(scope, key.into()).unwrap(); + let target = v8::Local::::try_from(target).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let get_key = crate::util::v8str(scope, "get"); + assert_eq!( + handler.create_data_property(scope, get_key.into(), trap), + Some(true) + ); + let proxy = v8::Proxy::new(scope, target, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + assert_eq!( + proxies.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + } + Ok(()) + }) + .unwrap(); + assert_eq!( + vm.eval( + r#"(() => { + const own=(realm,kind,key)=>Object.getOwnPropertyDescriptor(realm[kind].prototype,key); + const proxy=__linkProxies.anchor,real=__linkTargets.anchor; + for(const realm of [globalThis,child]){ + for(const [property,value,attr] of [['download','\ud800name','download'],['rel','noopener','rel'],['hreflang','en','hreflang'],['type','text/html','type'],['referrerPolicy','ORIGIN','referrerpolicy'],['ping','\ud800path','ping']]){ + const d=own(realm,'SVGAElement',property);d.set.call(proxy,value); + const expected=property==='ping'?'\ufffdpath':value; + if(real.getAttribute(attr)!==expected||d.get.call(proxy)!==(property==='referrerPolicy'?'origin':expected))return property; + } + for(const property of ['target','href'])if(own(realm,'SVGAElement',property).get.call(proxy)!==__linkTargets[property])return property+' identity'; + const list=own(realm,'SVGAElement','relList').get.call(proxy); + if(Object.getPrototypeOf(list)!==child.DOMTokenList.prototype)return 'list realm'; + own(realm,'SVGAElement','relList').set.call(proxy,'\ud800 token'); + if(real.rel!=='\ud800 token')return 'list value'; + list.value='\udfff value'; + if(real.rel!=='\udfff value'||list.value!=='\udfff value')return 'DOMTokenList UTF16'; + own(realm,'SVGAElement','pathname').set.call(proxy,'/new'); + if(own(realm,'SVGAElement','host').get.call(proxy)!=='example.test'||real.href.baseVal!=='https://example.test/new')return 'URL'; + own(realm,'SVGAnimatedString','baseVal').set.call(__linkProxies.target,'_blank'); + if(real.getAttribute('target')!=='_blank')return 'target mutation'; + const wrapped=new Proxy(proxy,{get:__linkTrap});let converted=0,error; + try{own(realm,'SVGAElement','download').set.call(wrapped,{toString(){converted++;return 'bad';}});}catch(e){error=e;} + if(!(error instanceof realm.TypeError)||converted!==0)return 'author wrapper'; + } + return __linkTraps===0?'ok':'proxy trap'; + })()"#, + ) + .unwrap(), + "ok" + ); +}