From 76e5280c321cf6f0e76db6561f433753a54da81b Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:00:52 +0000 Subject: [PATCH] fix(dom): preserve namespaced attribute qualified names --- crates/obscura-dom/src/serialize.rs | 7 ++-- crates/obscura-dom/src/tree.rs | 55 +++++++++++++++++------------ crates/obscura-js/js/bootstrap.js | 23 +++++++++++- crates/obscura-js/src/ops.rs | 4 +-- crates/obscura-js/src/runtime.rs | 34 ++++++++++++++++++ 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/crates/obscura-dom/src/serialize.rs b/crates/obscura-dom/src/serialize.rs index 8cb2abf..b5f1d25 100644 --- a/crates/obscura-dom/src/serialize.rs +++ b/crates/obscura-dom/src/serialize.rs @@ -99,8 +99,11 @@ impl DomTree { buf.push_str(tag); for attr in attrs { buf.push(' '); - let attr_name = attr.name.local.as_ref(); - buf.push_str(attr_name); + if let Some(prefix) = &attr.name.prefix { + buf.push_str(prefix); + buf.push(':'); + } + buf.push_str(attr.name.local.as_ref()); buf.push_str("=\""); escape_attr(&attr.value, buf); buf.push('"'); diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs index afcd545..40ce783 100644 --- a/crates/obscura-dom/src/tree.rs +++ b/crates/obscura-dom/src/tree.rs @@ -1,4 +1,4 @@ -use html5ever::{LocalName, Namespace, QualName}; +use html5ever::{LocalName, Namespace, Prefix, QualName}; use std::cell::RefCell; use std::collections::HashMap; use std::fmt; @@ -32,13 +32,24 @@ pub struct Attribute { pub value: String, } -/// The local-name portion of an attribute name, i.e. the part after any prefix. -/// `"xlink:href"` -> `"href"`, `"href"` -> `"href"`. Used for namespace-aware -/// attribute lookup, which keys on `(namespace, localName)`. -fn attr_local_suffix(name: &str) -> &str { - match name.rfind(':') { - Some(i) => &name[i + 1..], - None => name, +impl Attribute { + pub fn qualified_name(&self) -> String { + match &self.name.prefix { + Some(prefix) => format!("{}:{}", prefix, self.name.local), + None => self.name.local.to_string(), + } + } + + pub fn qualified_name_eq(&self, name: &str) -> bool { + match &self.name.prefix { + Some(prefix) => { + name.len() == prefix.len() + self.name.local.len() + 1 + && name.starts_with(prefix.as_ref()) + && name.as_bytes().get(prefix.len()) == Some(&b':') + && &name[prefix.len() + 1..] == self.name.local.as_ref() + } + None => self.name.local.as_ref() == name, + } } } @@ -115,7 +126,7 @@ impl Node { pub fn get_attribute(&self, name: &str) -> Option<&str> { self.attrs()?.iter().find_map(|a| { - if a.name.local.as_ref() == name { + if a.qualified_name_eq(name) { Some(a.value.as_str()) } else { None @@ -136,13 +147,10 @@ impl Node { } } - // Read a namespaced attribute by (namespace, localName). The stored local - // name may be qualified (e.g. "xlink:href" for setAttributeNS-created attrs) - // or a bare local (e.g. "href" for parser-created ones), so compare against - // the part after any prefix. + // Read a namespaced attribute by (namespace, localName). pub fn get_attribute_ns(&self, ns: &str, local: &str) -> Option<&str> { self.attrs()?.iter().find_map(|a| { - if a.name.ns.as_ref() == ns && attr_local_suffix(a.name.local.as_ref()) == local { + if a.name.ns.as_ref() == ns && a.name.local.as_ref() == local { Some(a.value.as_str()) } else { None @@ -150,21 +158,24 @@ impl Node { }) } - // Set a namespaced attribute. `qualified` is the qualified name - // (e.g. "xlink:href"); it is stored as the local name with the namespace - // recorded, matching how the non-namespaced path stores names, so - // getAttribute(qualified) and serialization keep working unchanged. + // Set a namespaced attribute using a proper QualName: prefix and local name + // remain separate while qualified-name APIs and serialization reconstruct + // `prefix:local` when needed. pub fn set_attribute_ns(&mut self, ns: &str, qualified: &str, value: String) { if let NodeData::Element { attrs, .. } = &mut self.data { - let local = attr_local_suffix(qualified); + let (prefix, local) = match qualified.split_once(':') { + Some((prefix, local)) => (Some(Prefix::from(prefix)), local), + None => (None, qualified), + }; if let Some(attr) = attrs .iter_mut() - .find(|a| a.name.ns.as_ref() == ns && attr_local_suffix(a.name.local.as_ref()) == local) + .find(|a| a.name.ns.as_ref() == ns && a.name.local.as_ref() == local) { + attr.name.prefix = prefix; attr.value = value; } else { attrs.push(Attribute { - name: QualName::new(None, Namespace::from(ns), LocalName::from(qualified)), + name: QualName::new(prefix, Namespace::from(ns), LocalName::from(local)), value, }); } @@ -174,7 +185,7 @@ impl Node { pub fn remove_attribute_ns(&mut self, ns: &str, local: &str) { if let NodeData::Element { attrs, .. } = &mut self.data { attrs.retain(|a| { - !(a.name.ns.as_ref() == ns && attr_local_suffix(a.name.local.as_ref()) == local) + !(a.name.ns.as_ref() == ns && a.name.local.as_ref() == local) }); } } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 2193f4d..404e52e 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1402,7 +1402,12 @@ class Element extends Node { if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n); } - setAttributeNS(ns, n, v) { _dom("set_attribute_ns", this._nid, String(ns == null ? "" : ns) + "\0" + String(n) + "\0" + String(v)); } // n is the qualified name; stored with its namespace + setAttributeNS(ns, n, v) { + ns = ns == null || ns === '' ? '' : String(ns); + n = String(n); + _ns_validateQualifiedName(ns, n); + _dom("set_attribute_ns", this._nid, ns + "\0" + n + "\0" + String(v)); + } removeAttribute(n) { n = _htmlAttrName(this, n); const popoverPrev = (n === "popover") ? this.popover : undefined; _dom("remove_attribute", this._nid, n); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); } removeAttributeNS(ns, n) { _dom("remove_attribute_ns", this._nid, String(ns == null ? "" : ns) + "\0" + String(n)); } hasAttribute(n) { return this.getAttribute(n) !== null; } @@ -8423,6 +8428,22 @@ const _ns_isValidXmlName = (name) => { return /^[A-Za-z_:][\w.\-:]*$/.test(name); }; +const _ns_validateQualifiedName = (namespaceURI, qualifiedName) => { + const parts = qualifiedName.split(':'); + if (parts.length > 2 || parts.some((part) => !_ns_isValidXmlName(part))) { + throw new DOMException('Invalid attribute name', 'InvalidCharacterError'); + } + const prefix = parts.length === 2 ? parts[0] : null; + const XML = 'http://www.w3.org/XML/1998/namespace'; + const XMLNS = 'http://www.w3.org/2000/xmlns/'; + if ((prefix && !namespaceURI) + || (prefix === 'xml' && namespaceURI !== XML) + || ((qualifiedName === 'xmlns' || prefix === 'xmlns') && namespaceURI !== XMLNS) + || (namespaceURI === XMLNS && qualifiedName !== 'xmlns' && prefix !== 'xmlns')) { + throw new DOMException('The namespace is invalid', 'NamespaceError'); + } +}; + // Document.prototype.createAttribute: create a detached Attr node if (!Document.prototype.createAttribute) { Document.prototype.createAttribute = function(localName) { diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 68cba17..a704748 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -321,7 +321,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str let names: Vec = dom .with_node(NodeId::new(nid), |n| { n.attrs() - .map(|a| a.iter().map(|x| x.name.local.as_ref().to_string()).collect()) + .map(|a| a.iter().map(|x| x.qualified_name()).collect()) .unwrap_or_default() }) .unwrap_or_default(); @@ -373,7 +373,7 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str let nid = arg1.parse::().unwrap_or(0); dom.with_node_mut(NodeId::new(nid), |n| { if let NodeData::Element { attrs, .. } = &mut n.data { - attrs.retain(|a| a.name.local.as_ref() != arg2.as_str()); + attrs.retain(|a| !a.qualified_name_eq(&arg2)); } }); "true".into() diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index e0c3d1b..3ba0863 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1345,6 +1345,40 @@ mod tests { assert_eq!(v, serde_json::json!("hi")); } + #[test] + fn namespaced_attribute_keeps_its_qualified_name() { + let mut rt = setup_runtime(""); + let v = rt + .evaluate("(function(){var s=document.createElementNS('http://www.w3.org/2000/svg','svg');s.setAttributeNS('http://www.w3.org/1999/xlink','xlink:href','#g');return s.getAttribute('xlink:href')+'|'+s.getAttributeNames()[0]+'|'+s.outerHTML;})()") + .unwrap(); + assert_eq!(v, serde_json::json!("#g|xlink:href|")); + } + + #[test] + fn parsed_xlink_attribute_is_available_through_both_apis() { + let mut rt = setup_runtime( + r##""##, + ); + let v = rt + .evaluate("(function(){var u=document.getElementById('u');return u.getAttribute('xlink:href')+'|'+u.getAttributeNS('http://www.w3.org/1999/xlink','href')+'|'+u.getAttributeNames().join(',');})()") + .unwrap(); + assert_eq!(v, serde_json::json!("#icon|#icon|id,xlink:href")); + } + + #[test] + fn set_attribute_ns_validates_namespace_constraints() { + let mut rt = setup_runtime(""); + let v = rt + .evaluate("(function(){var e=document.createElement('div'),out=[];for(const args of [[null,'x:y'],['urn:test','a:b:c'],['urn:test','xml:lang'],['urn:test','xmlns:x']]){try{e.setAttributeNS(args[0],args[1],'v');out.push('none')}catch(err){out.push(err.name)}}return out.join('|');})()") + .unwrap(); + assert_eq!( + v, + serde_json::json!( + "NamespaceError|InvalidCharacterError|NamespaceError|NamespaceError" + ) + ); + } + #[test] fn test_document_title() { let mut rt = setup_runtime("Test");