fix(svg): implement anchor hyperlink attributes

Expose SVG anchor target as a live owner-realm SVGAnimatedString, reflect download/ping/rel/hreflang/type/referrerPolicy through existing DOMString and USVString bindings, and share native hyperlink component callbacks under generated SVGAElement receiver checks. Apply SVG2 href/xlink namespace rules when reading and updating URL components, preserve token-list PutForwards UTF-16, and cover live/windowless documents, both realms, reentrant conversion and registered native Proxies.
This commit is contained in:
ldm0
2026-10-11 18:06:48 +08:00
parent f6941eb5da
commit 4e29ef8326
12 changed files with 484 additions and 20 deletions
@@ -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(
@@ -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>,
@@ -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),
@@ -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;
@@ -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>(
@@ -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();
}
@@ -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");
@@ -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: (),
}
@@ -42,7 +42,16 @@ pub(in crate::native_bridge::element) fn parsed_url_like_attribute(
handle: DomHandle,
name: &str,
) -> Option<Url> {
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::*;
@@ -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;
@@ -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(`<svg xmlns="${SVG}"/>`,'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};
})();
@@ -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='<iframe></iframe>'")
.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='<iframe></iframe>';
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::<v8::Object>::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::<v8::Object>::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::<v8::Object>::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"
);
}