\n text\n" the stray
+ // `` close spans offset 13..20, line 2 col 0..7 (0-based line/col).
+ // Verified vs @angular/compiler@21.2.7.
+ let allocator = Allocator::default();
+ let result = HtmlParser::new(&allocator, "
\n text\n", "test.html").parse();
+ assert_eq!(result.errors.len(), 1, "errors: {:?}", result.errors);
+ let span = &result.errors[0].span;
+ assert_eq!((span.start.offset, span.start.line, span.start.col), (13, 2, 0), "start loc");
+ assert_eq!((span.end.offset, span.end.line, span.end.col), (20, 2, 7), "end loc");
+ assert!(
+ result.errors[0].msg.ends_with("implied-end-tags"),
+ "msg: {:?}",
+ result.errors[0].msg
+ );
+ }
+
+ #[test]
+ fn test_parse_default_mode_underscore_tag_is_text() {
+ let allocator = Allocator::default();
+ let parser = HtmlParser::new(&allocator, "<_foo>", "test.html");
+ let result = parser.parse();
+ // Open tag becomes a Text node "<_foo>".
+ assert_eq!(result.nodes.len(), 1, "expected a single Text node, got {:?}", result.nodes);
+ match &result.nodes[0] {
+ HtmlNode::Text(t) => assert_eq!(t.value.as_str(), "<_foo>"),
+ other => panic!("expected Text node, got {other:?}"),
+ }
+ // The only error is the unexpected-closing-tag error for ``; the lexer's
+ // unexpected-character error for the open tag is swallowed (upstream parity).
+ assert_eq!(result.errors.len(), 1, "errors: {:?}", result.errors);
+ // FINDING 3: in DEFAULT mode `` is an ELEMENT close, so the message
+ // includes the W3C help URL and the span is the FULL close-token span [6-13].
+ // Verified vs @angular/compiler@21.2.7 (`_consumeElementEndTag`, parser.ts:581).
+ assert_eq!(
+ result.errors[0].msg,
+ "Unexpected closing tag \"_foo\". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags",
+ "unexpected error message: {:?}",
+ result.errors[0].msg
+ );
+ assert_eq!(
+ (result.errors[0].span.start.offset, result.errors[0].span.end.offset),
+ (6, 13),
+ "error span must be the full close-token span"
+ );
+ }
+
+ // ---- Finding 2 (parser): mid-text selectorless `<_foo>` is TEXT + dangling close ----
+ //
+ // Oracle (`@angular/compiler@21.2.7`, `{selectorlessEnabled:true}`):
+ // `x<_foo>` -> Text "x<_foo>" [0-7] + a COMPONENT-close error
+ // (NO URL) at span [7-14].
+ // `
x<_Foo>
`-> div with child Text "x<_Foo>" [5-12] + a
+ // component-close error at [12-19].
+ #[test]
+ fn test_parse_selectorless_midtext_underscore_is_text() {
+ let allocator = Allocator::default();
+ let result =
+ HtmlParser::with_selectorless(&allocator, "x<_foo>", "test.html").parse();
+ // Single root Text node "x<_foo>" — the `<_foo>` open is NOT a component mid-text.
+ assert_eq!(result.nodes.len(), 1, "nodes: {:?}", result.nodes);
+ match &result.nodes[0] {
+ HtmlNode::Text(t) => assert_eq!(t.value.as_str(), "x<_foo>"),
+ other => panic!("expected Text node, got {other:?}"),
+ }
+ // The dangling `` is a COMPONENT close: error has NO W3C URL, span [7-14].
+ assert_eq!(result.errors.len(), 1, "errors: {:?}", result.errors);
+ assert_eq!(
+ result.errors[0].msg,
+ "Unexpected closing tag \"_foo\". It may happen when the tag has already been closed by another tag.",
+ "msg: {:?}",
+ result.errors[0].msg
+ );
+ assert_eq!((result.errors[0].span.start.offset, result.errors[0].span.end.offset), (7, 14));
+ }
+
+ #[test]
+ fn test_parse_selectorless_midtext_underscore_inside_element() {
+ let allocator = Allocator::default();
+ let result =
+ HtmlParser::with_selectorless(&allocator, "
x<_Foo>
", "test.html")
+ .parse();
+ // One root `div` containing a single Text child "x<_Foo>".
+ assert_eq!(result.nodes.len(), 1, "nodes: {:?}", result.nodes);
+ match &result.nodes[0] {
+ HtmlNode::Element(el) => {
+ assert_eq!(el.name.as_str(), "div");
+ assert_eq!(el.children.len(), 1, "children: {:?}", el.children);
+ match &el.children[0] {
+ HtmlNode::Text(t) => assert_eq!(t.value.as_str(), "x<_Foo>"),
+ other => panic!("expected Text child, got {other:?}"),
+ }
+ }
+ other => panic!("expected div Element, got {other:?}"),
+ }
+ // The dangling `` component close error at [12-19].
+ assert_eq!(result.errors.len(), 1, "errors: {:?}", result.errors);
+ assert_eq!(
+ (result.errors[0].span.start.offset, result.errors[0].span.end.offset),
+ (12, 19)
+ );
+ }
+
+ #[test]
+ fn test_parse_selectorless_midtext_uppercase_is_component() {
+ // Control: mid-text uppercase `
` IS a tag start and opens a component.
+ let allocator = Allocator::default();
+ let result = HtmlParser::with_selectorless(&allocator, "x", "test.html").parse();
+ assert_eq!(result.errors.len(), 0, "errors: {:?}", result.errors);
+ assert_eq!(result.nodes.len(), 2, "nodes: {:?}", result.nodes);
+ match &result.nodes[0] {
+ HtmlNode::Text(t) => assert_eq!(t.value.as_str(), "x"),
+ other => panic!("expected Text node, got {other:?}"),
+ }
+ match &result.nodes[1] {
+ HtmlNode::Element(el) => {
+ assert!(el.is_component, "mid-text `` must be a component");
+ assert_eq!(el.name.as_str(), "Foo");
+ }
+ other => panic!("expected component Element, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_parse_selectorless_mode_underscore_tag_is_component() {
+ let allocator = Allocator::default();
+ let parser = HtmlParser::with_selectorless(&allocator, "<_foo>", "test.html");
+ let result = parser.parse();
+ assert_eq!(result.nodes.len(), 1, "nodes: {:?}", result.nodes);
+ match &result.nodes[0] {
+ HtmlNode::Element(el) => {
+ assert!(el.is_component, "selectorless `<_foo>` must be a component");
+ assert_eq!(el.name.as_str(), "_foo");
+ }
+ other => panic!("expected component Element, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_parse_default_mode_uppercase_tag_is_normal_element() {
+ let allocator = Allocator::default();
+ let parser = HtmlParser::new(&allocator, "", "test.html");
+ let result = parser.parse();
+ assert_eq!(result.nodes.len(), 1);
+ match &result.nodes[0] {
+ HtmlNode::Element(el) => {
+ assert!(!el.is_component, "default-mode `` must be a normal element");
+ assert_eq!(el.name.as_str(), "MyCmp");
+ }
+ other => panic!("expected Element, got {other:?}"),
+ }
+ }
+
#[test]
fn test_parse_nested() {
let allocator = Allocator::default();
diff --git a/crates/oxc_angular_compiler/src/parser/html/whitespace.rs b/crates/oxc_angular_compiler/src/parser/html/whitespace.rs
index 3be7958e6..d7d439825 100644
--- a/crates/oxc_angular_compiler/src/parser/html/whitespace.rs
+++ b/crates/oxc_angular_compiler/src/parser/html/whitespace.rs
@@ -377,6 +377,7 @@ impl<'a> WhitespaceVisitor<'a> {
end_span: el.end_span,
is_self_closing: el.is_self_closing,
is_void: el.is_void,
+ is_component: el.is_component,
}
}
@@ -570,6 +571,7 @@ impl<'a> WhitespaceVisitor<'a> {
end_span: element.end_span,
is_self_closing: element.is_self_closing,
is_void: element.is_void,
+ is_component: element.is_component,
},
self.allocator,
)));
@@ -591,6 +593,7 @@ impl<'a> WhitespaceVisitor<'a> {
end_span: element.end_span,
is_self_closing: element.is_self_closing,
is_void: element.is_void,
+ is_component: element.is_component,
},
self.allocator,
)))
diff --git a/crates/oxc_angular_compiler/src/partial/factory.rs b/crates/oxc_angular_compiler/src/partial/factory.rs
index 817b2184a..2a7d9a5dd 100644
--- a/crates/oxc_angular_compiler/src/partial/factory.rs
+++ b/crates/oxc_angular_compiler/src/partial/factory.rs
@@ -175,7 +175,10 @@ fn factory_target_expr<'a>(
FactoryTarget::Directive => "Directive",
FactoryTarget::Pipe => "Pipe",
FactoryTarget::NgModule => "NgModule",
- FactoryTarget::Injectable | FactoryTarget::Service => "Injectable",
+ FactoryTarget::Injectable => "Injectable",
+ // `@Service` (Angular v22+) uses the same `ɵɵinject` token resolution as
+ // `Injectable`, so its partial-declaration factory target is `Injectable`.
+ FactoryTarget::Service => "Injectable",
};
let factory_target_ref = OutputExpression::ReadProp(Box::new_in(
diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs
index efae25b02..61ecb6ee9 100644
--- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs
+++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs
@@ -4085,60 +4085,6 @@ fn ingest_host_dom_property<'a>(
job.root.update.push(op);
}
-/// Computes the security context for an attribute binding.
-///
-/// This is a simplified implementation of Angular's `calcPossibleSecurityContexts`
-/// that handles the most common cases based on element and property names.
-///
-/// Ported from Angular's `binding_parser.ts` and `dom_security_schema.ts`.
-fn compute_security_context(selector: &str, attr_name: &str) -> SecurityContext {
- use crate::schema::{calc_security_context_for_unknown_element, get_security_context};
-
- // Extract element name from selector if present (e.g., "a[myDirective]" → "a")
- let element = extract_element_from_selector(selector);
-
- match element {
- Some(element_name) => {
- // Element is known - use the specific lookup
- get_security_context(&element_name, attr_name)
- }
- None => {
- // Element is unknown (e.g., attribute-only directive like [myDirective])
- // Use the ambiguous lookup that checks all possible elements
- calc_security_context_for_unknown_element(attr_name)
- }
- }
-}
-
-/// Extracts the element name from a CSS selector.
-///
-/// Examples:
-/// - "a[myDirective]" → Some("a")
-/// - "div.my-class" → Some("div")
-/// - "[myDirective]" → None
-/// - ".my-class" → None
-fn extract_element_from_selector(selector: &str) -> Option {
- // Skip leading whitespace
- let s = selector.trim();
-
- // If starts with [, ., or :, there's no element
- if s.starts_with('[') || s.starts_with('.') || s.starts_with(':') || s.starts_with('#') {
- return None;
- }
-
- // Find the element name (alphanumeric and hyphens until a special char)
- let mut element_end = 0;
- for (i, c) in s.char_indices() {
- if c.is_alphanumeric() || c == '-' || c == '_' {
- element_end = i + c.len_utf8();
- } else {
- break;
- }
- }
-
- if element_end > 0 { Some(s[..element_end].to_lowercase()) } else { None }
-}
-
/// Ingests a static host attribute.
///
/// Host attributes are static attributes that should be extracted to `hostAttrs`
@@ -4153,6 +4099,7 @@ fn ingest_host_attribute<'a>(
) {
use crate::ir::expression::IrExpression;
use crate::ir::ops::{BindingOp, UpdateOp, UpdateOpBase};
+ use crate::schema::compute_security_context;
let allocator = job.allocator;
diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs b/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs
index 38dffe0d5..9c706b5c7 100644
--- a/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs
+++ b/crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs
@@ -31,6 +31,7 @@ use crate::ir::ops::{
AnimationBindingOp, AttributeOp, ControlOp, CreateOp, DomPropertyOp, PropertyOp,
TwoWayPropertyOp, UpdateOp, UpdateOpBase, XrefId,
};
+use crate::parser::html::split_ns_name;
use crate::pipeline::compilation::{
ComponentCompilationJob, HostBindingCompilationJob, TemplateCompilationMode,
};
@@ -46,39 +47,21 @@ fn is_aria_attribute(name: &str) -> bool {
name.starts_with(ARIA_PREFIX) && name.len() > ARIA_PREFIX.len()
}
-/// Known XML/SVG namespace prefixes.
-/// These are standard namespace prefixes that should be separated from the local name.
-const KNOWN_NS_PREFIXES: &[&str] = &["xlink", "xml", "xmlns"];
-
-/// Splits a namespaced name into (namespace, local_name).
+/// Splits a namespaced attribute name into (namespace, local_name), faithfully
+/// matching upstream `splitNsName` (`ml_parser/tags.ts:27-43`): ONLY the
+/// internal `:namespace:name` form is split; a plain `prefix:name` (e.g. the
+/// host-binding `xlink:href`) is returned as `(None, "prefix:name")` — exactly
+/// what the binding-specialization phase's `splitNsName(op.name)` does upstream.
///
-/// Handles two formats:
-/// - `:namespace:name` → (Some("namespace"), "name") - Angular's internal format
-/// - `namespace:name` → (Some("namespace"), "name") - for known namespaces like xlink, xml, xmlns
-/// - `name` → (None, "name") - no namespace
-fn split_ns_name(name: &str) -> (Option<&str>, &str) {
- // Check Angular's internal format first: `:namespace:name`
- if name.starts_with(':') {
- if let Some(colon_index) = name[1..].find(':') {
- let namespace = &name[1..colon_index + 1];
- let local_name = &name[colon_index + 2..];
- return (Some(namespace), local_name);
- }
- // Malformed `:` prefix - fall through
- }
-
- // Check for known namespace prefixes: `namespace:name`
- if let Some(colon_index) = name.find(':') {
- let prefix = &name[..colon_index];
- if KNOWN_NS_PREFIXES.contains(&prefix) {
- let local_name = &name[colon_index + 1..];
- return (Some(prefix), local_name);
- }
- }
-
- // No namespace
- (None, name)
-}
+/// The TEMPLATE attribute path stores the merged `:ns:name` form (via
+/// `mergeNsAndName` in `html_to_r3.rs`, mirroring `createBoundElementProperty`),
+/// so namespaced template attrs split here and emit the namespace argument of
+/// `ɵɵattribute(name, value, sanitizer?, namespace?)`. The HOST attribute path
+/// stores the plain name (upstream host ingest never merges), so it does NOT
+/// split and emits `ɵɵattribute("xlink:href", ...)` with no namespace — both
+/// faithful to v21.2.7.
+///
+/// (See the `split_ns_name` import at the top of this module.)
/// Specializes generic bindings to specific binding operations.
///
diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs
index 2b56aac97..eb2ab7b90 100644
--- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs
+++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs
@@ -27,10 +27,13 @@ fn get_sanitizer_fn(security_context: SecurityContext) -> Option<&'static str> {
// selects the actual sanitizer at runtime based on the tag name.
SecurityContext::UrlOrResourceUrl => Some(Identifiers::SANITIZE_URL_OR_RESOURCE_URL),
SecurityContext::None => None,
- // AttributeNoBinding means the attribute should not be bound at all.
- // This should produce a compile-time error in the HTML-to-R3 transform.
- // For now, return None but the binding should have been rejected earlier.
- SecurityContext::AttributeNoBinding => None,
+ // `ATTRIBUTE_NO_BINDING` attributes (e.g. `animate|attributeName`,
+ // `iframe|sandbox`) must be validated at runtime: upstream
+ // `resolve_sanitizers.ts` maps this context to `Identifiers.validateAttribute`
+ // (`ɵɵvalidateAttribute`). Previously this returned `None`, leaving such
+ // bindings unprotected (Issue #315 sub-gap 2). Mirror upstream by emitting
+ // the validate-attribute "sanitizer" so the binding is checked at runtime.
+ SecurityContext::AttributeNoBinding => Some(Identifiers::VALIDATE_ATTRIBUTE),
}
}
diff --git a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs
index 30c5337e9..498c4c68e 100644
--- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs
+++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs
@@ -7,7 +7,9 @@
//!
//! DO NOT EDIT THIS LIST OF SECURITY SENSITIVE PROPERTIES WITHOUT A SECURITY REVIEW!
+use crate::ast::expression::BindingType;
use crate::ast::r3::SecurityContext;
+use crate::parser::html::split_ns_name;
use rustc_hash::FxHashMap;
use std::sync::LazyLock;
@@ -122,6 +124,17 @@ static SECURITY_SCHEMA: LazyLock> = Laz
"object|codebase",
"object|data",
"script|src",
+ // SVGScriptElement href sinks. v21.2.7 dom_security_schema.ts:122-125
+ // registers `script|href` and `script|xlink:href` as RESOURCE_URL with a
+ // comment pointing at SVGScriptElement.href. Because a namespaced
+ // `` is stored `:svg:script` and `get_security_context`
+ // strips the `:ns:` prefix before lookup, `:svg:script|href` resolves to
+ // these bare `script|...` keys. This pairs with the transform keeping
+ // `:svg:script` alive (G4): the SVG script element survives and its href
+ // is sanitized as a resource URL — the actual XSS mechanism for an
+ // SVGScriptElement.
+ "script|href",
+ "script|xlink:href",
],
);
@@ -136,6 +149,28 @@ static SECURITY_SCHEMA: LazyLock> = Laz
"animatemotion|attributename",
"animatetransform|attributename",
"unknown|attributename",
+ // SVG animation *value* attributes. Binding these animates the
+ // referenced attribute's value at runtime, so they are an XSS vector
+ // identical to `attributeName` and must be validated. Latest upstream
+ // `dom_security_schema.ts` registers them under SVG_NAMESPACE as
+ // ATTRIBUTE_NO_BINDING:
+ // ['animate', ['attributeName', 'values', 'to', 'from']]
+ // ['set', ['to', 'attributeName']]
+ // OXC stores schema keys non-namespaced (the `:ns:` prefix is stripped
+ // in `get_security_context`), so they are added here as bare lowercase
+ // `tag|attr` keys. (Issue #315 sub-gap 2: "SVG animation value
+ // attributes bypass sanitization".)
+ "animate|to",
+ "animate|from",
+ "animate|values",
+ "set|to",
+ // The no-namespace `unknown` element aggregates every value attribute
+ // upstream registers, so bindings on an unknown host still validate
+ // these. (Issue #315 sub-gap 2 / upstream `unknown` entry in
+ // `dom_security_schema.ts`.)
+ "unknown|to",
+ "unknown|from",
+ "unknown|values",
"iframe|sandbox",
"iframe|allow",
"iframe|allowfullscreen",
@@ -180,8 +215,16 @@ pub fn get_security_context(element: &str, property: &str) -> SecurityContext {
let element_lower = element.to_ascii_lowercase();
let property_lower = property.to_ascii_lowercase();
+ // Strip any explicit `:ns:` prefix so namespaced element names resolve to the
+ // same security context as the bare local name. Angular's template pipeline
+ // looks security up by the element's *local* name (namespace tracked
+ // separately), so e.g. `` (stored as `:svg:animate`) must resolve
+ // like `animate`. Schema keys are never namespaced. (Issue #315 sub-gap 2 /
+ // Codex namespaced-lookup finding.)
+ let (_, element_normalized) = split_ns_name(&element_lower);
+
// First try element-specific lookup
- let key = format!("{}|{}", element_lower, property_lower);
+ let key = format!("{}|{}", element_normalized, property_lower);
if let Some(&ctx) = SECURITY_SCHEMA.get(key.as_str()) {
return ctx;
}
@@ -196,6 +239,89 @@ pub fn get_security_context(element: &str, property: &str) -> SecurityContext {
SecurityContext::None
}
+/// `SECURITY_SCHEMA` element segments that are NOT a BARE entry of upstream
+/// `DomElementSchemaRegistry.allKnownElementNames()` — the complete "phantom"
+/// set. These bare keys exist in `SECURITY_SCHEMA` (and upstream
+/// `dom_security_schema.ts`) but the matching element name is registered in the
+/// element SCHEMA only under a namespace (e.g. `:svg:animate`, `:math:math`) or
+/// is absent from the element schema entirely (e.g. `none`, `annotation`,
+/// `malignmark`, `mglyph`, `mprescripts`, `annotation-xml`).
+///
+/// Upstream's host-unknown scan (`calcPossibleSecurityContexts`,
+/// `binding_parser.ts:888-896`) iterates the REAL `allKnownElementNames()` and
+/// maps each through `securityContext(name, prop)` WITHOUT stripping the
+/// namespace (`dom_element_schema_registry.ts:449-456`). So for a namespaced-only
+/// element, `securityContext(':svg:animate','to')`/`securityContext(':math:math',
+/// 'href')` looks up `:svg:animate|to`/`:math:math|href` -> NOT FOUND -> NONE,
+/// and the bare keys (`animate|to`, `math|href`, …) are UNREACHABLE from the host
+/// path; an absent element name is never iterated at all. OXC stores the security
+/// schema non-namespaced (the `:ns:` prefix is stripped at lookup), so without
+/// this skip these bare keys would contribute to the host-unknown aggregation as
+/// if a real `animate`/`math`/`semantics`/… element existed.
+///
+/// The only OBSERVABLE divergence this causes is when a `:not(element)` selector
+/// excludes EVERY real bare contributor of a property while a phantom segment
+/// still supplies it. Concretely, the MathML `*|href`/`*|xlink:href` URL keys
+/// (`math|href`, `mi|href`, `annotation|href`, `semantics|href`, …) made
+/// `[x]:not(a):not(area):not(base):not(link):not(script)` + `[attr.href]` resolve
+/// to `Url` in OXC where @angular/compiler@21.2.7 yields `NONE` (all the real
+/// bare `href` contributors — `a`, `area`, `base`, `link`, `script` — are
+/// excluded, and the MathML elements exist only as `:math:*`). Skipping the full
+/// phantom set makes OXC's host-unknown scan byte-for-byte match upstream's
+/// real-element-name iteration for every property, since each phantom prop is
+/// also supplied by a bare-known element (`unknown` for the SVG animation
+/// value/`attributeName` props; `a`/`area`/`base`/`link`/`script` for the
+/// MathML href props), so the only difference is the unreachable-upstream
+/// phantom contribution.
+///
+/// Derived from the @angular/compiler@21.2.7 oracle: every `SECURITY_SCHEMA`
+/// element segment minus the bare `allKnownElementNames()` entries. The real bare
+/// elements (`a`, `area`, `base`, `embed`, `form`, `frame`, `iframe`, `img`,
+/// `link`, `object`, `script`, `video`, `unknown`) and the `*` wildcard are NOT
+/// in this set and keep contributing.
+const ELEMENTS_ONLY_KNOWN_NAMESPACED: &[&str] = &[
+ // SVG animation elements (registered only as `:svg:*`).
+ "animate",
+ "set",
+ "animatemotion",
+ "animatetransform",
+ // MathML elements (registered only as `:math:*`).
+ "maction",
+ "math",
+ "merror",
+ "mfrac",
+ "mi",
+ "mmultiscripts",
+ "mn",
+ "mo",
+ "mover",
+ "mpadded",
+ "mphantom",
+ "mroot",
+ "mrow",
+ "ms",
+ "mspace",
+ "msqrt",
+ "mstyle",
+ "msub",
+ "msubsup",
+ "msup",
+ "mtable",
+ "mtd",
+ "mtext",
+ "mtr",
+ "munder",
+ "munderover",
+ "semantics",
+ // Names absent from the element SCHEMA entirely (never iterated upstream).
+ "annotation",
+ "annotation-xml",
+ "malignmark",
+ "mglyph",
+ "mprescripts",
+ "none",
+];
+
/// Calculates all possible security contexts for a property when the element is unknown.
///
/// This is used when the host element isn't known at compile time (e.g., for directives
@@ -214,9 +340,47 @@ pub fn get_security_context(element: &str, property: &str) -> SecurityContext {
/// # Returns
/// The appropriate `SecurityContext` for sanitization.
pub fn calc_security_context_for_unknown_element(property: &str) -> SecurityContext {
+ calc_security_context_for_unknown_element_excluding(property, &[])
+}
+
+/// Like [`calc_security_context_for_unknown_element`], but excludes any schema
+/// entry whose element name appears in `excluded_elements` via a CASE-SENSITIVE
+/// exact match. This mirrors upstream `notElementNames.has(elName)`, a `Set.has`
+/// over the LOWERCASE `allKnownElementNames()` vs the case-PRESERVED `:not()`
+/// element names (`CssSelector.setElement` does not lowercase). The schema keys
+/// are lowercase, so `:not(object)` excludes `object` but `:not(OBJECT)` does
+/// not.
+///
+/// This mirrors the attribute-only / wildcard branch of upstream
+/// `calcPossibleSecurityContexts` (`binding_parser.ts:888-896`), where the
+/// candidate element set is `registry.allKnownElementNames()` minus the
+/// `:not(element)` exclusions before each name is mapped through
+/// `registry.securityContext(name, propName)`:
+///
+/// ```ts
+/// const elementNames = selector.element ? [selector.element] : registry.allKnownElementNames();
+/// const notElementNames = new Set(
+/// selector.notSelectors.filter((s) => s.isElementSelector()).map((s) => s.element),
+/// );
+/// const possibleElementNames = elementNames.filter((elName) => !notElementNames.has(elName));
+/// ctxs.push(...possibleElementNames.map(nameToContext));
+/// ```
+///
+/// OXC scans the schema map keyed `"element|property"` rather than iterating an
+/// explicit element list, so the exclusion is applied by skipping element-specific
+/// entries whose `element` segment is excluded. Wildcard entries (`"*|property"`)
+/// are never excluded: upstream a `*|prop` context applies to *every* known
+/// element name, so it survives unless every element is excluded — and `*` is
+/// never itself an element `:not()` name. (With `excluded_elements` empty this is
+/// identical to the original all-elements scan.)
+fn calc_security_context_for_unknown_element_excluding(
+ property: &str,
+ excluded_elements: &[String],
+) -> SecurityContext {
let property_lower = property.to_ascii_lowercase();
- // Collect all security contexts for this property across all elements
+ // Collect all security contexts for this property across all (non-excluded)
+ // elements.
let mut has_url = false;
let mut has_resource_url = false;
let mut has_other = false;
@@ -225,8 +389,57 @@ pub fn calc_security_context_for_unknown_element(property: &str) -> SecurityCont
for (key, &ctx) in SECURITY_SCHEMA.iter() {
// Check if this entry is for our property (format: "element|property")
if let Some(pipe_pos) = key.find('|') {
+ let elem = &key[..pipe_pos];
let prop = &key[pipe_pos + 1..];
if prop.eq_ignore_ascii_case(&property_lower) {
+ // Skip element-specific entries whose element is excluded by a
+ // `:not(element)` selector (upstream `possibleElementNames`
+ // filter). Wildcard `*|prop` entries are handled separately
+ // below and never excluded.
+ if elem == "*" {
+ continue;
+ }
+ // Skip schema keys whose element segment is NOT a real bare
+ // `allKnownElementNames()` entry (the complete phantom set; see
+ // `ELEMENTS_ONLY_KNOWN_NAMESPACED`). Upstream's host-unknown scan
+ // iterates `DomElementSchemaRegistry.allKnownElementNames()` and
+ // maps each through `securityContext(name, prop)` WITHOUT
+ // stripping the namespace (`dom_element_schema_registry.ts:449`).
+ // The SVG animation elements (`:svg:animate`, `:svg:set`,
+ // `:svg:animateMotion`, `:svg:animateTransform`) and the MathML
+ // elements (`:math:math`, `:math:mi`, `:math:semantics`, …) are
+ // registered in the element schema ONLY as namespaced keys; a few
+ // others (`none`, `annotation`, `annotation-xml`, `malignmark`,
+ // `mglyph`, `mprescripts`) are absent from the element schema
+ // entirely. So e.g. `securityContext(':svg:animate','to')` and
+ // `securityContext(':math:math','href')` resolve to NONE, and the
+ // bare keys (`animate|to`, `set|to`, `math|href`, `mi|href`,
+ // `semantics|href`, …) are UNREACHABLE from upstream's
+ // host-unknown scan. OXC stores the security schema non-namespaced
+ // (with ns stripped at lookup), which would otherwise make these
+ // bare keys contribute here as if a real `animate`/`math`/… element
+ // existed. The observable bug: `[x]:not(a):not(area):not(base)
+ // :not(link):not(script)` + `[attr.href]` reduced to `Url` in OXC
+ // via the phantom MathML href keys, where @angular/compiler@21.2.7
+ // yields NONE (all real bare `href` contributors excluded).
+ // Skipping the full phantom set restores faithfulness: each phantom
+ // prop is also supplied by a real bare element (`unknown` for the
+ // SVG animation value/`attributeName` props; `a`/`area`/`base`/
+ // `link`/`script` for the MathML href props), so the only behavior
+ // change is removing the unreachable-upstream phantom contribution.
+ // The `iframe|*` sandbox-family keys are kept (real `iframe`).
+ if ELEMENTS_ONLY_KNOWN_NAMESPACED.contains(&elem) {
+ continue;
+ }
+ // CASE-SENSITIVE exact match, mirroring upstream
+ // `notElementNames.has(elName)` (a `Set.has` over LOWERCASE
+ // `allKnownElementNames()` vs the case-PRESERVED `:not()` name).
+ // The schema keys (`elem`) are lowercase, so a lowercase
+ // `:not(object)` still excludes `object`, while `:not(OBJECT)`
+ // does NOT (no exact match) — matching v21.2.7.
+ if excluded_elements.iter().any(|ex| ex == elem) {
+ continue;
+ }
match ctx {
SecurityContext::Url => has_url = true,
SecurityContext::ResourceUrl => has_resource_url = true,
@@ -240,7 +453,7 @@ pub fn calc_security_context_for_unknown_element(property: &str) -> SecurityCont
}
}
- // Also check wildcard entries
+ // Also check wildcard entries (apply to all known elements -> never excluded).
let wildcard_key = format!("*|{}", property_lower);
if let Some(&ctx) = SECURITY_SCHEMA.get(wildcard_key.as_str()) {
match ctx {
@@ -270,6 +483,264 @@ pub fn calc_security_context_for_unknown_element(property: &str) -> SecurityCont
}
}
+/// Collects the element names excluded by an alternate's `:not(...)` selectors,
+/// mirroring upstream's `notSelectors.filter((s) => s.isElementSelector())`.
+///
+/// Upstream `isElementSelector()` (`directive_matching.ts:168-175`) is `true`
+/// only when the negated selector is a *pure* element selector: it has an
+/// element AND no classes, no attributes, and no nested `:not()`. So
+/// `:not(object)` contributes `object`, but `:not(object.foo)`,
+/// `:not(object[x])`, `:not(.foo)`, and `:not([y])` contribute nothing.
+///
+/// The element name is returned with its ORIGINAL case PRESERVED, matching
+/// upstream `CssSelector.setElement` (`directive_matching.ts:181-183`), which
+/// stores the element verbatim (NO `.toLowerCase()`). The exclusion downstream
+/// is a CASE-SENSITIVE exact match against the lowercase known/schema element
+/// names (upstream `notElementNames.has(...)` over `allKnownElementNames()`),
+/// so `:not(object)` excludes `object` but `:not(OBJECT)` does not.
+fn not_element_names(css: &crate::pipeline::selector::CssSelector) -> Vec {
+ css.not_selectors
+ .iter()
+ .filter_map(|n| {
+ // Pure element selector only (upstream `isElementSelector()`).
+ if n.class_names.is_empty() && n.attrs.is_empty() && n.not_selectors.is_empty() {
+ n.element.as_deref().map(str::to_string)
+ } else {
+ None
+ }
+ })
+ .collect()
+}
+
+/// Computes the security context for a HOST binding given a directive/component
+/// selector and the bound attribute/property name.
+///
+/// This mirrors the upstream HOST pipeline, which differs from the template
+/// pipeline in how it reduces the candidate contexts to a single result:
+///
+/// 1. `calcPossibleSecurityContexts(registry, selector, propName, isAttribute)`
+/// (`binding_parser.ts:874-900`) parses ALL comma-separated alternates via
+/// `CssSelector.parse(selector)` (one `CssSelector` per alternate) and, for
+/// each alternate, looks up `registry.securityContext(elName, ...)` for every
+/// `possibleElementName`. When the alternate names a concrete element the set
+/// is just that element; when it is attribute-only
+/// (`selector.element === null`) it is ALL known element names. In BOTH cases
+/// the `:not(element)` element exclusions are filtered out first
+/// (`possibleElementNames = elementNames.filter((n) => !notElementNames.has(n))`),
+/// so e.g. `[x]:not(object)` excludes `object` from the all-elements scan.
+/// The collected contexts are de-duplicated and returned.
+///
+/// 2. The HOST consumer in `ingest.ts:117-130` then
+/// `.filter(context => context !== SecurityContext.NONE)` (DROP `NONE`).
+///
+/// 3. `resolve_sanitizers.ts:60-99` resolves the filtered array to a sanitizer:
+/// if it is EXACTLY `{URL, RESOURCE_URL}` -> `sanitizeUrlOrResourceUrl`;
+/// otherwise `getOnlySecurityContext` (which throws on length > 1 and returns
+/// the single remaining context, or `NONE` when empty).
+///
+/// In this codebase, `resolve_sanitizers.rs` consumes a single
+/// `SecurityContext` (not an array), with the special `UrlOrResourceUrl`
+/// variant standing in for the `{URL, RESOURCE_URL}` pair. So this function
+/// performs steps 1-3 here and returns that single reduced context:
+///
+/// - empty (after dropping `NONE`) -> `SecurityContext::None`
+/// - exactly `{Url, ResourceUrl}` -> `SecurityContext::UrlOrResourceUrl`
+/// - a single surviving context -> that context
+/// - more than one (and not the URL pair) -> the lowest-enum-order context
+/// (see the fallback note below)
+///
+/// The attribute-vs-property (`isAttribute`) distinction is carried by the
+/// `name` passed in: callers pass the `attr.`-stripped name for attribute
+/// bindings and the plain property name for property bindings, matching the
+/// schema keys (e.g. `animate|to`, `a|href`, `*|innerhtml`). This is the same
+/// convention the template path uses in `html_to_r3.rs`.
+///
+/// NOTE: this helper implements the HOST-path reduction only. The template path
+/// uses `get_security_context` directly with the concrete element name (see
+/// `html_to_r3.rs`) and the upstream `securityContexts[0]` rule; it does NOT go
+/// through this function.
+pub fn compute_security_context(selector: &str, name: &str) -> SecurityContext {
+ use crate::pipeline::selector::CssSelector;
+
+ // Step 1: collect a context for every possible element across all
+ // comma-separated alternates (mirrors `calcPossibleSecurityContexts`).
+ let mut contexts: Vec = Vec::new();
+ let mut push_unique = |ctx: SecurityContext| {
+ if !contexts.contains(&ctx) {
+ contexts.push(ctx);
+ }
+ };
+
+ for css in CssSelector::parse(selector) {
+ // `:not(element)` exclusions for this alternate (upstream
+ // `notElementNames`). Only *pure* element `:not()` selectors count, per
+ // upstream `isElementSelector()`.
+ let excluded = not_element_names(&css);
+ match &css.element {
+ // Concrete element alternate (e.g. `img` in `img[x]`).
+ Some(element_name) if element_name != "*" => {
+ // Honor `:not(element)` element exclusions, like upstream's
+ // `possibleElementNames = elementNames.filter(...)`: a concrete
+ // element that is itself excluded contributes no context
+ // (upstream's single-element list becomes empty). CASE-SENSITIVE
+ // exact match (upstream `notElementNames.has(elName)` over the
+ // case-preserved `[selector.element]`), so `object:not(OBJECT)`
+ // does NOT self-exclude.
+ let is_excluded = excluded.iter().any(|n| n == element_name);
+ if !is_excluded {
+ push_unique(get_security_context(element_name, name));
+ }
+ }
+ // Attribute-only alternate (`selector.element === null`) or the `*`
+ // wildcard: aggregate across ALL known elements, but first drop the
+ // `:not(element)` exclusions, mirroring upstream's
+ // `possibleElementNames = registry.allKnownElementNames().filter(
+ // (elName) => !notElementNames.has(elName))`. Without this filter
+ // a directive like `[x]:not(object)` with host `[attr.data]` would
+ // still see `object|data` and over-sanitize as RESOURCE_URL, whereas
+ // upstream excludes `object` and yields no sanitizer.
+ _ => {
+ push_unique(calc_security_context_for_unknown_element_excluding(name, &excluded));
+ }
+ }
+ }
+
+ // `calc_security_context_for_unknown_element` already collapses the
+ // all-elements URL/RESOURCE_URL pair into the `UrlOrResourceUrl` variant.
+ // Expand it back to its constituents so the merge below can combine it with
+ // contexts contributed by other (concrete-element) alternates and re-derive
+ // the pair faithfully.
+ let mut expanded: Vec = Vec::new();
+ for ctx in contexts {
+ match ctx {
+ SecurityContext::UrlOrResourceUrl => {
+ if !expanded.contains(&SecurityContext::Url) {
+ expanded.push(SecurityContext::Url);
+ }
+ if !expanded.contains(&SecurityContext::ResourceUrl) {
+ expanded.push(SecurityContext::ResourceUrl);
+ }
+ }
+ other if !expanded.contains(&other) => expanded.push(other),
+ _ => {}
+ }
+ }
+
+ // Step 2: drop `NONE` (the HOST-path filter in `ingest.ts`).
+ expanded.retain(|ctx| *ctx != SecurityContext::None);
+
+ // Step 3: reduce to a single context, mirroring `resolve_sanitizers.ts`.
+ match expanded.as_slice() {
+ // empty -> NONE (no sanitizer)
+ [] => SecurityContext::None,
+ // exactly one surviving context
+ [single] => *single,
+ // exactly the {URL, RESOURCE_URL} pair -> runtime-resolved sanitizer
+ two if two.len() == 2
+ && two.contains(&SecurityContext::Url)
+ && two.contains(&SecurityContext::ResourceUrl) =>
+ {
+ SecurityContext::UrlOrResourceUrl
+ }
+ // More than one surviving context that is NOT the URL/RESOURCE_URL pair.
+ // Upstream `getOnlySecurityContext` THROWS an `AssertionError` here
+ // ("Ambiguous security context") — its own comment says this is believed
+ // to never happen in practice outside the URL/RESOURCE_URL case. A
+ // compiler should not panic on user input, so we pick the lowest context
+ // by enum order (matching upstream's ascending sort + TDB's historical
+ // "take the first one" behavior). This branch is effectively unreachable.
+ many => many
+ .iter()
+ .copied()
+ .min_by_key(|ctx| security_context_rank(*ctx))
+ .unwrap_or(SecurityContext::None),
+ }
+}
+
+/// Rank a `SecurityContext` by upstream Angular's `SecurityContext` enum order
+/// (`core.ts`): NONE=0, HTML=1, STYLE=2, SCRIPT=3, URL=4, RESOURCE_URL=5,
+/// ATTRIBUTE_NO_BINDING=6. Used only for the unreachable >1-context fallback in
+/// `compute_security_context` to deterministically pick the lowest context,
+/// matching upstream's ascending sort. `UrlOrResourceUrl` is an OXC-only
+/// composite and never appears in the ranked set (it is expanded beforehand).
+fn security_context_rank(ctx: SecurityContext) -> u8 {
+ match ctx {
+ SecurityContext::None => 0,
+ SecurityContext::Html => 1,
+ SecurityContext::Style => 2,
+ SecurityContext::Script => 3,
+ SecurityContext::Url => 4,
+ SecurityContext::ResourceUrl => 5,
+ SecurityContext::AttributeNoBinding => 6,
+ // Composite variant; not part of upstream's enum. Rank after all real
+ // contexts so it never wins the `min_by_key` (it is expanded away).
+ SecurityContext::UrlOrResourceUrl => 7,
+ }
+}
+
+/// Extracts the element name from a CSS selector, if the selector begins with
+/// a concrete element name.
+///
+/// Examples:
+/// - `"a[myDirective]"` → `Some("a")`
+/// - `"div.my-class"` → `Some("div")`
+/// - `"[myDirective]"` → `None`
+/// - `".my-class"` → `None`
+/// - `""` → `None` (no element; mirrors upstream's `selector === null`)
+pub fn extract_element_from_selector(selector: &str) -> Option {
+ let s = selector.trim();
+
+ // A leading `[`, `.`, `:`, or `#` means there is no element in this selector.
+ if s.starts_with('[') || s.starts_with('.') || s.starts_with(':') || s.starts_with('#') {
+ return None;
+ }
+
+ // The element name runs until the first non-identifier character.
+ let mut element_end = 0;
+ for (i, c) in s.char_indices() {
+ if c.is_alphanumeric() || c == '-' || c == '_' {
+ element_end = i + c.len_utf8();
+ } else {
+ break;
+ }
+ }
+
+ if element_end > 0 { Some(s[..element_end].to_lowercase()) } else { None }
+}
+
+/// Computes the security context for a `host` binding, given its binding type,
+/// the bound (already prefix-stripped) name, and the directive/component
+/// selector used as the element context.
+///
+/// Mirrors upstream `BindingParser.createBoundElementProperty`
+/// (`binding_parser.ts`): attribute bindings use the attribute security lookup
+/// (`isAttribute = true`) and property bindings the property lookup
+/// (`isAttribute = false`), while `class`/`style`/animation bindings carry their
+/// fixed contexts (`NONE`/`STYLE`/`NONE`). In this codebase the `isAttribute`
+/// distinction is encoded by the schema key (the `attr.`-stripped name passed in
+/// for attribute bindings), so both attribute and property bindings resolve via
+/// the shared `compute_security_context`.
+pub fn host_binding_security_context(
+ binding_type: BindingType,
+ name: &str,
+ selector: &str,
+) -> SecurityContext {
+ match binding_type {
+ // Attribute (`[attr.X]`) and property (`[domProp]`) bindings get a real
+ // security context derived from the selector's element.
+ BindingType::Attribute | BindingType::Property | BindingType::TwoWay => {
+ compute_security_context(selector, name)
+ }
+ // `[style.X]` bindings are always the STYLE context (upstream uses
+ // `[SecurityContext.STYLE]` for the `style` prefix).
+ BindingType::Style => SecurityContext::Style,
+ // `[class.X]` and animation bindings are never sanitized.
+ BindingType::Class | BindingType::Animation | BindingType::LegacyAnimation => {
+ SecurityContext::None
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -301,6 +772,24 @@ mod tests {
assert_eq!(get_security_context("embed", "src"), SecurityContext::ResourceUrl);
}
+ #[test]
+ fn test_svg_script_href_resource_url_context() {
+ // v21.2.7 dom_security_schema.ts:122-125 registers `script|href` and
+ // `script|xlink:href` as RESOURCE_URL (the SVGScriptElement.href sinks).
+ // Plain (bare) lookups resolve directly.
+ assert_eq!(get_security_context("script", "href"), SecurityContext::ResourceUrl);
+ assert_eq!(get_security_context("script", "xlink:href"), SecurityContext::ResourceUrl);
+ // A namespaced `` is stored `:svg:script`; the `:ns:` prefix is
+ // stripped before lookup, so it resolves to the same RESOURCE_URL context.
+ // This is reachable in templates now that the transform keeps `:svg:script`
+ // alive (G4).
+ assert_eq!(get_security_context(":svg:script", "href"), SecurityContext::ResourceUrl);
+ assert_eq!(get_security_context(":svg:script", "xlink:href"), SecurityContext::ResourceUrl);
+ // Lookup is case-insensitive.
+ assert_eq!(get_security_context("SCRIPT", "HREF"), SecurityContext::ResourceUrl);
+ assert_eq!(get_security_context(":svg:script", "XLINK:HREF"), SecurityContext::ResourceUrl);
+ }
+
#[test]
fn test_attribute_no_binding_context() {
assert_eq!(
@@ -310,6 +799,68 @@ mod tests {
assert_eq!(get_security_context("iframe", "sandbox"), SecurityContext::AttributeNoBinding);
}
+ #[test]
+ fn test_svg_animation_value_attribute_no_binding() {
+ // Latest upstream `dom_security_schema.ts` registers SVG animation *value*
+ // attributes as ATTRIBUTE_NO_BINDING (under SVG_NAMESPACE):
+ // ['animate', ['attributeName', 'values', 'to', 'from']]
+ // ['set', ['to', 'attributeName']]
+ // plus the no-namespace `unknown` aggregate (which includes 'values', 'to',
+ // 'from'). Binding these animates an attribute's *value* at runtime, so
+ // leaving them unsanitized is the XSS vector this closes. OXC stores keys
+ // non-namespaced (the `:ns:` prefix is stripped before lookup), so they
+ // resolve to AttributeNoBinding -> `ɵɵvalidateAttribute` at runtime.
+ // (Issue #315 sub-gap 2.)
+ assert_eq!(get_security_context("animate", "to"), SecurityContext::AttributeNoBinding);
+ assert_eq!(get_security_context("animate", "from"), SecurityContext::AttributeNoBinding);
+ assert_eq!(get_security_context("animate", "values"), SecurityContext::AttributeNoBinding);
+ assert_eq!(get_security_context("set", "to"), SecurityContext::AttributeNoBinding);
+ assert_eq!(get_security_context("unknown", "to"), SecurityContext::AttributeNoBinding);
+ assert_eq!(get_security_context("unknown", "from"), SecurityContext::AttributeNoBinding);
+ assert_eq!(get_security_context("unknown", "values"), SecurityContext::AttributeNoBinding);
+ // The namespaced element form resolves identically (the `:svg:` prefix is
+ // stripped before lookup).
+ assert_eq!(get_security_context(":svg:animate", "to"), SecurityContext::AttributeNoBinding);
+ // Lookup is case-insensitive (matching the SVG `` element name).
+ assert_eq!(get_security_context("ANIMATE", "TO"), SecurityContext::AttributeNoBinding);
+ // The pre-existing `attributeName` registration is unaffected.
+ assert_eq!(
+ get_security_context("animate", "attributeName"),
+ SecurityContext::AttributeNoBinding
+ );
+ assert_eq!(
+ get_security_context("ANIMATE", "ATTRIBUTENAME"),
+ SecurityContext::AttributeNoBinding
+ );
+ assert_eq!(
+ get_security_context("unknown", "attributeName"),
+ SecurityContext::AttributeNoBinding
+ );
+ }
+
+ #[test]
+ fn test_namespaced_svg_element_security_lookup() {
+ // Issue #315 sub-gap 2 / Codex namespaced-lookup finding: an explicitly
+ // namespaced element is stored with a `:ns:` prefix (e.g. `:svg:animate`).
+ // The lookup strips the prefix so the namespaced form resolves to the same
+ // context as the bare local name.
+ assert_eq!(
+ get_security_context(":svg:animate", "attributeName"),
+ SecurityContext::AttributeNoBinding
+ );
+ assert_eq!(
+ get_security_context(":svg:set", "attributeName"),
+ SecurityContext::AttributeNoBinding
+ );
+ assert_eq!(
+ get_security_context(":svg:iframe", "sandbox"),
+ SecurityContext::AttributeNoBinding
+ );
+ // Namespaced URL contexts (wildcard and element-specific) still resolve.
+ assert_eq!(get_security_context(":svg:a", "href"), SecurityContext::Url);
+ assert_eq!(get_security_context(":svg:iframe", "srcdoc"), SecurityContext::Html);
+ }
+
#[test]
fn test_no_context() {
assert_eq!(get_security_context("div", "class"), SecurityContext::None);
@@ -352,4 +903,324 @@ mod tests {
// "class" has no security context
assert_eq!(calc_security_context_for_unknown_element("class"), SecurityContext::None);
}
+
+ // -----------------------------------------------------------------------
+ // G1: `compute_security_context` (HOST path) must consider EVERY
+ // comma-separated selector alternate and merge their contexts, mirroring
+ // upstream `calcPossibleSecurityContexts` + the host NONE-filter +
+ // `resolve_sanitizers` URL/RESOURCE_URL special case.
+ // -----------------------------------------------------------------------
+ #[test]
+ fn test_compute_sc_single_concrete_element() {
+ // Single alternate, concrete element -> element-specific lookup.
+ assert_eq!(compute_security_context("img[x]", "src"), SecurityContext::Url);
+ assert_eq!(compute_security_context("iframe[x]", "src"), SecurityContext::ResourceUrl);
+ assert_eq!(compute_security_context("a[appLink]", "href"), SecurityContext::Url);
+ }
+
+ #[test]
+ fn test_compute_sc_attribute_only_aggregates() {
+ // Attribute-only selector (`element === null`) -> unknown-element scan.
+ assert_eq!(
+ compute_security_context("[appHref]", "href"),
+ SecurityContext::UrlOrResourceUrl
+ );
+ }
+
+ #[test]
+ fn test_compute_sc_merges_url_and_resource_url() {
+ // img|src = URL, iframe|src = RESOURCE_URL -> {URL, RESOURCE_URL} merge.
+ assert_eq!(
+ compute_security_context("img[x],iframe[x]", "src"),
+ SecurityContext::UrlOrResourceUrl
+ );
+ }
+
+ #[test]
+ fn test_compute_sc_filters_none_single_survivor() {
+ // div|src = NONE (filtered), iframe|src = RESOURCE_URL -> single survivor.
+ assert_eq!(
+ compute_security_context("div[x],iframe[x]", "src"),
+ SecurityContext::ResourceUrl
+ );
+ // Order must not matter.
+ assert_eq!(
+ compute_security_context("iframe[x],div[x]", "src"),
+ SecurityContext::ResourceUrl
+ );
+ }
+
+ #[test]
+ fn test_compute_sc_all_none_is_none() {
+ // Neither a|title nor b|title is sensitive -> empty after filter -> NONE.
+ assert_eq!(compute_security_context("a[x],b[x]", "title"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_compute_sc_dedupes_same_context() {
+ // Two URL alternates collapse to a single URL context (not the pair).
+ assert_eq!(compute_security_context("a[x],area[x]", "href"), SecurityContext::Url);
+ }
+
+ #[test]
+ fn test_compute_sc_not_excludes_concrete_element() {
+ // `:not(iframe)` removes the only RESOURCE_URL contributor, leaving just
+ // img|src = URL.
+ assert_eq!(
+ compute_security_context("img[x],iframe[x]:not(iframe)", "src"),
+ SecurityContext::Url
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // v21.2.7 faithfulness (Codex iteration-10): the attribute-only / wildcard
+ // branch must filter `:not(element)` names out of the all-elements scan,
+ // mirroring upstream `possibleElementNames = elementNames.filter(...)` in
+ // `binding_parser.ts:888-896`. Previously OXC ignored `:not(...)` here and
+ // over-sanitized.
+ //
+ // Schema facts (dom_security_schema.ts): `data`/`codebase` are ONLY on
+ // `object` (RESOURCE_URL); `srcdoc` is ONLY on `iframe` (HTML). So excluding
+ // those elements removes the sole contributor and yields NONE.
+ // -----------------------------------------------------------------------
+ #[test]
+ fn test_compute_sc_attr_only_not_object_excludes_data() {
+ // `object|data` is the ONLY `data` sink. `[x]:not(object)` excludes it,
+ // so nothing else contributes `data` -> NONE (upstream: no sanitizer).
+ // Sanity: `object|data` really is RESOURCE_URL and `data` is object-only.
+ assert_eq!(get_security_context("object", "data"), SecurityContext::ResourceUrl);
+ assert_eq!(calc_security_context_for_unknown_element("data"), SecurityContext::ResourceUrl);
+ assert_eq!(compute_security_context("[x]:not(object)", "data"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_compute_sc_attr_only_not_object_excludes_codebase() {
+ // `object|codebase` is the ONLY `codebase` sink. Excluding `object`
+ // leaves nothing -> NONE.
+ assert_eq!(get_security_context("object", "codebase"), SecurityContext::ResourceUrl);
+ assert_eq!(compute_security_context("[x]:not(object)", "codebase"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_compute_sc_attr_only_not_iframe_excludes_srcdoc() {
+ // `iframe|srcdoc` is the ONLY `srcdoc` sink (HTML). Excluding `iframe`
+ // leaves nothing -> NONE.
+ assert_eq!(get_security_context("iframe", "srcdoc"), SecurityContext::Html);
+ assert_eq!(calc_security_context_for_unknown_element("srcdoc"), SecurityContext::Html);
+ assert_eq!(compute_security_context("[x]:not(iframe)", "srcdoc"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_compute_sc_attr_only_no_not_still_sanitizes() {
+ // CONTROL: without `:not`, `object|data` is still in the set -> RESOURCE_URL.
+ assert_eq!(compute_security_context("[x]", "data"), SecurityContext::ResourceUrl);
+ assert_eq!(compute_security_context("[x]", "srcdoc"), SecurityContext::Html);
+ }
+
+ #[test]
+ fn test_compute_sc_attr_only_not_unrelated_element_still_sanitizes() {
+ // CONTROL: excluding a non-sink element (`div`) leaves `object` in the
+ // set -> still RESOURCE_URL for `data`.
+ assert_eq!(compute_security_context("[x]:not(div)", "data"), SecurityContext::ResourceUrl);
+ }
+
+ #[test]
+ fn test_compute_sc_attr_only_non_element_not_does_not_filter() {
+ // CONTROL: a NON-element `:not()` (class / attribute) is NOT an
+ // `isElementSelector()`, so it excludes nothing -> still RESOURCE_URL.
+ assert_eq!(compute_security_context("[x]:not(.foo)", "data"), SecurityContext::ResourceUrl);
+ assert_eq!(compute_security_context("[x]:not([y])", "data"), SecurityContext::ResourceUrl);
+ }
+
+ #[test]
+ fn test_compute_sc_wildcard_not_object_excludes_data() {
+ // The `*` wildcard alternate aggregates over all elements just like the
+ // attribute-only case, and must also honor `:not(object)`.
+ assert_eq!(compute_security_context("*:not(object)", "data"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_compute_sc_attr_only_not_object_keeps_wildcard_prop() {
+ // Excluding `object` must NOT drop a wildcard `*|prop` context, which
+ // upstream treats as applying to every (remaining) element. `innerhtml`
+ // is `*|innerhtml` = HTML, so `[x]:not(object)` still yields HTML.
+ assert_eq!(compute_security_context("[x]:not(object)", "innerHTML"), SecurityContext::Html);
+ }
+
+ // -----------------------------------------------------------------------
+ // v21.2.7 faithfulness (Finding 1): the `:not(element)` exclusion is a
+ // CASE-SENSITIVE exact match. Upstream `CssSelector.setElement`
+ // (directive_matching.ts:181-183) stores the element verbatim (no
+ // `.toLowerCase()`), while `allKnownElementNames()` and the schema keys are
+ // LOWERCASE. So `notElementNames.has(name)` only excludes when the `:not()`
+ // name is exactly the lowercase known name.
+ //
+ // Oracle (faithful reimpl of calcPossibleSecurityContexts over the real
+ // @angular/compiler@21.2.7 DomElementSchemaRegistry + CssSelector):
+ // [x]:not(object) + data => [NONE] -> None
+ // [x]:not(OBJECT) + data => [NONE, RESOURCE_URL] -> ResourceUrl
+ // [x]:not(IFRAME) + src => [NONE, URL, RESOURCE_URL] -> UrlOrResourceUrl
+ //
+ // Previously OXC lowercased the `:not()` name and compared
+ // case-insensitively, so `:not(OBJECT)` wrongly excluded `object` -> None,
+ // an UNDER-sanitization XSS gap.
+ // -----------------------------------------------------------------------
+ #[test]
+ fn test_compute_sc_not_uppercase_object_does_not_exclude() {
+ // Uppercase `:not(OBJECT)` does NOT exclude lowercase `object`, so
+ // `object|data` (RESOURCE_URL) survives.
+ assert_eq!(
+ compute_security_context("[x]:not(OBJECT)", "data"),
+ SecurityContext::ResourceUrl
+ );
+ }
+
+ #[test]
+ fn test_compute_sc_not_lowercase_object_excludes() {
+ // CONTROL companion: lowercase `:not(object)` DOES exclude `object`.
+ assert_eq!(compute_security_context("[x]:not(object)", "data"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_compute_sc_not_uppercase_iframe_does_not_exclude() {
+ // Uppercase `:not(IFRAME)` does NOT exclude lowercase `iframe`; the
+ // across-all-elements `src` set is {URL, RESOURCE_URL}.
+ assert_eq!(
+ compute_security_context("[x]:not(IFRAME)", "src"),
+ SecurityContext::UrlOrResourceUrl
+ );
+ }
+
+ #[test]
+ fn test_compute_sc_concrete_uppercase_not_self_does_not_exclude() {
+ // A concrete-element alternate whose `:not()` is the SAME element but in
+ // a different case must NOT self-exclude (case-sensitive). `object` is a
+ // concrete element here and `:not(OBJECT)` does not match it, so
+ // `object|data` = RESOURCE_URL survives.
+ assert_eq!(
+ compute_security_context("object:not(OBJECT)", "data"),
+ SecurityContext::ResourceUrl
+ );
+ // CONTROL: same-case self-exclusion DOES drop it -> None.
+ assert_eq!(compute_security_context("object:not(object)", "data"), SecurityContext::None);
+ }
+
+ #[test]
+ fn test_calc_excluding_helper_directly() {
+ // The exclusion helper drops the named element from the all-elements scan.
+ assert_eq!(
+ calc_security_context_for_unknown_element_excluding("data", &[]),
+ SecurityContext::ResourceUrl
+ );
+ assert_eq!(
+ calc_security_context_for_unknown_element_excluding("data", &["object".to_string()]),
+ SecurityContext::None
+ );
+ // Excluding `object` does not affect a wildcard context.
+ assert_eq!(
+ calc_security_context_for_unknown_element_excluding(
+ "innerhtml",
+ &["object".to_string()]
+ ),
+ SecurityContext::Html
+ );
+ }
+
+ // Finding 1 (issue #315): host-unknown `:not()` faithfulness.
+ //
+ // Upstream `calcPossibleSecurityContexts` iterates
+ // `DomElementSchemaRegistry.allKnownElementNames()` and maps each through
+ // `securityContext(name, prop)` WITHOUT stripping the namespace. The SVG
+ // animation elements exist in the element schema ONLY as namespaced keys
+ // (`:svg:animate`, `:svg:set`, `:svg:animatemotion`, `:svg:animatetransform`),
+ // so they resolve to NONE; the bare ATTRIBUTE_NO_BINDING security keys
+ // (`animate|to`, `set|to`, …) are reachable from the host-unknown scan ONLY
+ // via the real `unknown` element. Verified against @angular/compiler@21.2.7:
+ // calc('[x]','to',true) -> [NONE, ATTRIBUTE_NO_BINDING] -> host ATTRIBUTE_NO_BINDING
+ // calc('[x]:not(unknown)','to',true) -> [NONE] -> host NONE
+ // calc('[x]:not(animate)','to',true) -> [NONE, ATTRIBUTE_NO_BINDING] -> host ATTRIBUTE_NO_BINDING
+ #[test]
+ fn host_unknown_animation_props_match_v21_2_7() {
+ // Base attribute-only host selector: ATTRIBUTE_NO_BINDING via real `unknown`.
+ for prop in ["to", "from", "values", "attributeName"] {
+ assert_eq!(
+ compute_security_context("[x]", prop),
+ SecurityContext::AttributeNoBinding,
+ "base [x] + {prop} should be ATTRIBUTE_NO_BINDING (via real `unknown` element)",
+ );
+ }
+ // `:not(unknown)` removes the ONLY real contributor -> NONE upstream. The
+ // phantom bare `animate`/`set` keys must NOT keep it ATTRIBUTE_NO_BINDING.
+ for prop in ["to", "from", "values", "attributeName"] {
+ assert_eq!(
+ compute_security_context("[x]:not(unknown)", prop),
+ SecurityContext::None,
+ "[x]:not(unknown) + {prop} should be NONE to match @angular/compiler@21.2.7",
+ );
+ }
+ // `:not(animate)` excludes a phantom (non-)element; the real `unknown`
+ // element still contributes -> ATTRIBUTE_NO_BINDING (matches upstream).
+ assert_eq!(
+ compute_security_context("[x]:not(animate)", "to"),
+ SecurityContext::AttributeNoBinding,
+ );
+ // The iframe sandbox-family ATTRIBUTE_NO_BINDING props are kept under
+ // `:not(unknown)` because the real `iframe` element still contributes
+ // (upstream parity).
+ for prop in ["sandbox", "allow", "allowfullscreen", "csp", "fetchpriority"] {
+ assert_eq!(
+ compute_security_context("[x]:not(unknown)", prop),
+ SecurityContext::AttributeNoBinding,
+ "[x]:not(unknown) + {prop} stays ATTRIBUTE_NO_BINDING via real `iframe`",
+ );
+ }
+ }
+
+ // Finding (iteration-21): the host-unknown scan must skip the COMPLETE phantom
+ // element set, not just the four SVG animation names. The MathML `*|href` /
+ // `*|xlink:href` URL keys (`math|href`, `mi|href`, `annotation|href`,
+ // `semantics|href`, …) name elements registered in the element schema ONLY as
+ // `:math:*` (or absent entirely), so upstream's `allKnownElementNames()`
+ // iteration never reaches them. Verified against @angular/compiler@21.2.7:
+ // calc('[x]:not(a):not(area):not(base):not(link):not(script)','href',true)
+ // -> [NONE] -> host NONE
+ // (all real bare `href` contributors are `a`/`area`/`base`/`link`/`script`).
+ // Previously OXC scanned bare SECURITY_SCHEMA keys and the phantom MathML
+ // `*|href` keys kept it `Url`.
+ #[test]
+ fn host_unknown_mathml_href_phantom_match_v21_2_7() {
+ // Excluding EVERY real bare `href` contributor leaves only phantom MathML
+ // elements -> upstream NONE. OXC must not return `Url` via phantom keys.
+ assert_eq!(
+ compute_security_context(
+ "[x]:not(a):not(area):not(base):not(link):not(script)",
+ "href"
+ ),
+ SecurityContext::None,
+ "all real bare `href` sinks excluded; phantom MathML keys must not contribute",
+ );
+ // Same for `xlink:href` (real bare contributors are only `a` and `script`).
+ assert_eq!(
+ compute_security_context("[x]:not(a):not(script)", "xlink:href"),
+ SecurityContext::None,
+ "all real bare `xlink:href` sinks excluded; phantom MathML keys must not contribute",
+ );
+ // CONTROL: without the `:not()` exclusions the real bare `href` sinks make
+ // `[x]` + `href` the {URL, RESOURCE_URL} pair (`a`/`area` = URL,
+ // `base`/`link` = RESOURCE_URL), matching upstream `[NONE,URL,RESOURCE_URL]`.
+ assert_eq!(compute_security_context("[x]", "href"), SecurityContext::UrlOrResourceUrl,);
+ // CONTROL: excluding only some real contributors still leaves `a` (URL),
+ // so the phantom skip does not over-reduce. `:not(area):not(base)
+ // :not(link):not(script)` keeps `a|href` = URL.
+ assert_eq!(
+ compute_security_context("[x]:not(area):not(base):not(link):not(script)", "href"),
+ SecurityContext::Url,
+ "`a|href` (real bare URL sink) survives",
+ );
+ // CONTROL: a concrete MathML-ish phantom element selector resolves via the
+ // bare-key element-specific lookup in `get_security_context` (the template
+ // path), which is unchanged — only the host-unknown aggregation skips
+ // phantoms. `math[x]` + `href` is still URL (concrete element path).
+ assert_eq!(compute_security_context("math[x]", "href"), SecurityContext::Url);
+ }
}
diff --git a/crates/oxc_angular_compiler/src/schema/mod.rs b/crates/oxc_angular_compiler/src/schema/mod.rs
index 4a3a14d89..3825ec295 100644
--- a/crates/oxc_angular_compiler/src/schema/mod.rs
+++ b/crates/oxc_angular_compiler/src/schema/mod.rs
@@ -4,5 +4,10 @@
//! and security contexts.
mod dom_security_schema;
+mod trusted_types_sinks;
-pub use dom_security_schema::{calc_security_context_for_unknown_element, get_security_context};
+pub use dom_security_schema::{
+ calc_security_context_for_unknown_element, compute_security_context,
+ extract_element_from_selector, get_security_context, host_binding_security_context,
+};
+pub use trusted_types_sinks::is_trusted_types_sink;
diff --git a/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs b/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs
new file mode 100644
index 000000000..ebb7d576c
--- /dev/null
+++ b/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs
@@ -0,0 +1,88 @@
+//! Trusted Types sinks
+//!
+//! Set of `tagName|propertyName` corresponding to Trusted Types sinks. Properties applying to all
+//! tags use `*`.
+//!
+//! Ported from Angular's `schema/trusted_types_sinks.ts`. Extracted from, and should be kept in
+//! sync with .
+//!
+//! DO NOT EDIT THIS LIST OF SECURITY SENSITIVE SINKS WITHOUT A SECURITY REVIEW!
+
+use rustc_hash::FxHashSet;
+use std::sync::LazyLock;
+
+/// Set of `"tagName|propertyName"` Trusted Types sinks. Properties applying to all tags use `"*"`.
+///
+/// NOTE: All strings in this set *must* be lowercase!
+static TRUSTED_TYPES_SINKS: LazyLock> = LazyLock::new(|| {
+ FxHashSet::from_iter([
+ // TrustedHTML
+ "iframe|srcdoc",
+ "*|innerhtml",
+ "*|outerhtml",
+ // NB: no TrustedScript here, as the corresponding tags are stripped by the compiler.
+ // TrustedScriptURL
+ "embed|src",
+ "iframe|src",
+ "object|codebase",
+ "object|data",
+ ])
+});
+
+/// Returns `true` if the given property on the given DOM tag is a Trusted Types sink.
+///
+/// In that case, use [`crate::schema::get_security_context`] to determine which particular
+/// Trusted Type is required for values passed to the sink:
+/// - [`crate::ast::r3::SecurityContext::Html`] corresponds to `TrustedHTML`
+/// - [`crate::ast::r3::SecurityContext::ResourceUrl`] corresponds to `TrustedScriptURL`
+///
+/// The lookup is case-insensitive, so that case differences between attribute and property names
+/// do not have a security impact.
+pub fn is_trusted_types_sink(tag_name: &str, prop_name: &str) -> bool {
+ let tag = tag_name.to_ascii_lowercase();
+ let prop = prop_name.to_ascii_lowercase();
+
+ TRUSTED_TYPES_SINKS.contains(format!("{tag}|{prop}").as_str())
+ || TRUSTED_TYPES_SINKS.contains(format!("*|{prop}").as_str())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn detects_html_sinks() {
+ assert!(is_trusted_types_sink("iframe", "srcdoc"));
+ assert!(is_trusted_types_sink("p", "innerHTML"));
+ assert!(is_trusted_types_sink("div", "outerHTML"));
+ }
+
+ #[test]
+ fn detects_resource_url_sinks() {
+ assert!(is_trusted_types_sink("embed", "src"));
+ assert!(is_trusted_types_sink("object", "codebase"));
+ assert!(is_trusted_types_sink("object", "data"));
+ }
+
+ #[test]
+ fn detects_iframe_src() {
+ // Issue #315 sub-gap 1 / upstream commit 78dea55351: `iframe|src` is a sink.
+ assert!(is_trusted_types_sink("iframe", "src"));
+ }
+
+ #[test]
+ fn is_case_insensitive() {
+ assert!(is_trusted_types_sink("IFRAME", "SRC"));
+ assert!(is_trusted_types_sink("P", "iNnErHtMl"));
+ }
+
+ #[test]
+ fn rejects_non_sinks() {
+ assert!(!is_trusted_types_sink("a", "href"));
+ assert!(!is_trusted_types_sink("base", "href"));
+ assert!(!is_trusted_types_sink("div", "style"));
+ // `img|src` is a navigable URL, not a Trusted Types sink.
+ assert!(!is_trusted_types_sink("img", "src"));
+ assert!(!is_trusted_types_sink("p", "formaction"));
+ }
+}
diff --git a/crates/oxc_angular_compiler/src/styles/encapsulation.rs b/crates/oxc_angular_compiler/src/styles/encapsulation.rs
index b2b015a1e..5039974fe 100644
--- a/crates/oxc_angular_compiler/src/styles/encapsulation.rs
+++ b/crates/oxc_angular_compiler/src/styles/encapsulation.rs
@@ -320,8 +320,78 @@ pub fn shim_css_text(css: &str, content_attr: &str, host_attr: &str) -> String {
restore_comments(&result, &comments)
}
+/// Returns `true` if `ch` is an ECMAScript `\s` whitespace code point.
+///
+/// FINDING 2: upstream `ShadowCss` matches comments with two JavaScript regexes
+/// whose `\s` follows ECMAScript whitespace semantics, NOT Rust's `char::is_whitespace`
+/// (Unicode `White_Space`). The two sets differ for exactly the code points that bite
+/// here:
+/// * U+FEFF (BOM / ZERO WIDTH NO-BREAK SPACE) — IS ECMAScript `\s`, but is NOT in
+/// Rust's `White_Space`.
+/// * U+0085 (NEL) — is NOT ECMAScript `\s`, but IS in Rust's `White_Space`.
+/// So the hash-comment predicate must use this function instead of `trim`/`is_whitespace`.
+///
+/// ECMAScript `\s` = WhiteSpace ∪ LineTerminator, i.e.:
+/// \t \n \v \f \r, U+0020, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029,
+/// U+202F, U+205F, U+3000, U+FEFF.
+fn is_ecmascript_whitespace(ch: char) -> bool {
+ matches!(
+ ch,
+ '\u{0009}' // \t
+ | '\u{000A}' // \n
+ | '\u{000B}' // \v
+ | '\u{000C}' // \f
+ | '\u{000D}' // \r
+ | '\u{0020}' // space
+ | '\u{00A0}' // NBSP
+ | '\u{1680}'
+ | '\u{2000}'
+ ..='\u{200A}'
+ | '\u{2028}' // LINE SEPARATOR
+ | '\u{2029}' // PARAGRAPH SEPARATOR
+ | '\u{202F}'
+ | '\u{205F}'
+ | '\u{3000}'
+ | '\u{FEFF}' // BOM / ZWNBSP — ECMAScript ws but NOT Rust White_Space
+ )
+}
+
+/// Tests a closed comment against upstream `_commentWithHashRe`
+/// (`/\/\*\s*#\s*source(Mapping)?URL=/g`): literal `/*`, ECMAScript-`\s`*, `#`,
+/// ECMAScript-`\s`*, then literally `sourceURL=` or `sourceMappingURL=` (trailing `=`
+/// required; `Mapping` optional; case-sensitive). `comment` includes the leading `/*`.
+///
+/// FINDING 1: upstream uses `m.match(_commentWithHashRe)`, which is UNANCHORED — it
+/// searches the ENTIRE comment string `m` for the pattern. Because the pattern STARTS
+/// WITH a literal `/*`, it can match an INNER `/*#sourceURL=` occurrence inside the
+/// comment body, e.g. `/* outer /*#sourceURL=x */` (a single closed comment per
+/// `_commentRe`'s non-greedy match to the first `*/`) matches the inner `/*#sourceURL=`
+/// and is PRESERVED. So we must scan EVERY `/*` position in `comment`, not just the
+/// leading one.
+fn comment_is_sourcemap(comment: &str) -> bool {
+ let bytes = comment.as_bytes();
+ // Find every literal `/*` in the comment (the pattern's required prefix).
+ for start in 0..bytes.len().saturating_sub(1) {
+ if bytes[start] == b'/' && bytes[start + 1] == b'*' {
+ // `start + 2` is a UTF-8 char boundary (`/` and `*` are ASCII).
+ let after_start = &comment[start + 2..];
+ let after_ws = after_start.trim_start_matches(is_ecmascript_whitespace);
+ let Some(after_hash) = after_ws.strip_prefix('#') else {
+ continue;
+ };
+ let token = after_hash.trim_start_matches(is_ecmascript_whitespace);
+ if token.starts_with("sourceURL=") || token.starts_with("sourceMappingURL=") {
+ return true;
+ }
+ }
+ }
+ false
+}
+
/// Extract comments and replace them with placeholders.
-/// Sourcemap comments are preserved, other comments are replaced with newlines.
+/// Sourcemap comments are preserved verbatim; other comments are replaced with
+/// only their interior newlines (empty when single-line), matching upstream
+/// Angular `ShadowCss` v21.2.7 so that stripping a comment adds no extra lines.
fn extract_comments(css: &str) -> (String, Vec) {
let mut comments = Vec::new();
let mut result = String::with_capacity(css.len());
@@ -333,37 +403,75 @@ fn extract_comments(css: &str) -> (String, Vec) {
// Check for comment start: /*
if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
let comment_start = i;
- i += 2;
-
- // Find comment end: */
- while i + 1 < len {
- if bytes[i] == b'*' && bytes[i + 1] == b'/' {
- i += 2;
+ let mut scan = i + 2;
+
+ // Find comment end: */ (CLOSED comments only).
+ let mut closed = false;
+ while scan + 1 < len {
+ if bytes[scan] == b'*' && bytes[scan + 1] == b'/' {
+ scan += 2;
+ closed = true;
break;
}
- i += 1;
+ scan += 1;
+ }
+
+ // FINDING 2: upstream `_commentRe = /\/\*[\s\S]*?\*\//g` matches ONLY closed
+ // comments. An unterminated `/*` (no closing `*/`) is NOT a comment — leave it
+ // (and the rest of the text) UNCHANGED. Emit the leading `/` as an ordinary
+ // char and continue normal char-by-char scanning so any later well-formed
+ // `/* ... */` still gets processed.
+ if !closed {
+ i += push_utf8_char(&mut result, css, i);
+ continue;
}
+ i = scan;
let comment = &css[comment_start..i];
- // Check if it's a sourcemap comment: /* # source... or /*# source...
- // Matches regex r"/\*\s*#\s*source"
- let is_sourcemap = {
- let after_start = &comment[2..]; // skip /*
- let trimmed = after_start.trim_start();
- trimmed.starts_with('#') && trimmed[1..].trim_start().starts_with("source")
- };
+ // Preserve ONLY sourcemap/sourceURL comments verbatim; every other comment
+ // is stripped (collapsed to its interior newlines) so no comment text — which
+ // could leak sensitive data — survives. This matches upstream Angular
+ // `ShadowCss` v21.2.7 exactly (shadow_css.ts:182-189), which tests each
+ // CLOSED comment (`_commentRe`, shadow_css.ts:1113) against:
+ // const _commentWithHashRe = /\/\*\s*#\s*source(Mapping)?URL=/g;
+ // i.e. `/*`, optional whitespace, `#`, optional whitespace, then literally
+ // `sourceURL=` OR `sourceMappingURL=` (the `(Mapping)?` group is optional and
+ // the trailing `=` is REQUIRED; the match is case-sensitive). So
+ // `/* # source: secret */`, `/* #sourceURLx */`, `/* sourceURL= */` (no `#`)
+ // and `/* # sourceMap */` are all NON-matching and get stripped, while
+ // `/*#sourceURL=foo*/` and `/* # sourceMappingURL=foo */` are preserved.
+ // The `\s` here uses `is_ecmascript_whitespace` (NOT Rust `trim`), so it
+ // matches JS exactly for the divergent code points U+FEFF (JS ws, Rust not)
+ // and U+0085 (Rust ws, JS not).
+ let is_sourcemap = comment_is_sourcemap(comment);
if is_sourcemap {
comments.push(comment.to_string());
} else {
- // Count newlines in the comment to preserve line count for sourcemaps
- let newline_count = comment.bytes().filter(|&b| b == b'\n').count();
+ // Replace non-hash comments with only their interior newlines. This mirrors
+ // upstream Angular `ShadowCss` (v21.2.7), which substitutes a non-sourcemap
+ // comment with `newLinesMatches?.join('') ?? ''` using `_newLinesRe = /\r?\n/g`.
+ // The comment collapses to nothing except the `\r\n`/`\n` sequences it contained,
+ // so removing a comment does not add extra blank lines while interior newlines
+ // (needed to keep sourcemap line counts stable) are preserved verbatim.
let mut preserved = String::new();
- for _ in 0..newline_count {
- preserved.push('\n');
+ let comment_bytes = comment.as_bytes();
+ let mut j = 0;
+ while j < comment_bytes.len() {
+ if comment_bytes[j] == b'\r'
+ && j + 1 < comment_bytes.len()
+ && comment_bytes[j + 1] == b'\n'
+ {
+ preserved.push_str("\r\n");
+ j += 2;
+ } else if comment_bytes[j] == b'\n' {
+ preserved.push('\n');
+ j += 1;
+ } else {
+ j += 1;
+ }
}
- preserved.push('\n');
comments.push(preserved);
}
@@ -377,19 +485,53 @@ fn extract_comments(css: &str) -> (String, Vec) {
}
/// Restore comments from placeholders.
+///
+/// FINDING 2: this must be a SINGLE left-to-right pass over the scoped CSS, mirroring
+/// upstream `scopedCssText.replace(_commentWithHashPlaceHolderRe, () => comments[idx++])`
+/// (shadow_css.ts:199). JavaScript `String.replace(re, replacer)` matches placeholders
+/// ONLY in the original string and never rescans replacement text. The previous OXC
+/// implementation looped `while result.find(PLACEHOLDER)`, re-searching the WHOLE result
+/// after each replacement — so if a PRESERVED (sourceURL) comment's body literally
+/// contained the `%COMMENT%` sentinel, that literal text was treated as another
+/// placeholder slot, corrupting/shifting the output. Walking the scoped string once and
+/// replacing each ORIGINAL placeholder exactly once (in order) keeps any literal
+/// `%COMMENT%` inside a restored comment literal, matching upstream exactly.
+///
+/// FINDING 3 (surplus placeholders): upstream's sentinel is the literal `%COMMENT%`
+/// (shadow_css.ts:1115), which a CSS author CAN type outside any comment (e.g. in a
+/// string or `url()`). Such a literal survives extraction (it is not a `/* */` comment)
+/// and is then matched by the global restore replace. Because the extraction step pushes
+/// exactly one `comments[]` entry per extracted comment, a surplus match has no
+/// corresponding entry: `comments[idx++]` is `undefined`, which JavaScript's
+/// `String.replace` coerces to the STRING `"undefined"`. We replicate that quirk
+/// faithfully — surplus `%COMMENT%` occurrences become the literal text `undefined`
+/// rather than being dropped. Verified against `@angular/compiler@21.2.7`
+/// `ShadowCss().shimCssText`, e.g. `div { content: "%COMMENT%"; }` ->
+/// `... content: "undefined"; ...`.
fn restore_comments(css: &str, comments: &[String]) -> String {
- let mut result = css.to_string();
+ let mut result = String::with_capacity(css.len());
+ let mut rest = css;
let mut idx = 0;
- while result.find(COMMENT_PLACEHOLDER).is_some() {
+ while let Some(pos) = rest.find(COMMENT_PLACEHOLDER) {
+ // Copy literal text before the placeholder verbatim.
+ result.push_str(&rest[..pos]);
+ // Substitute this placeholder with its stored comment. For a surplus placeholder
+ // (more `%COMMENT%` occurrences than extracted comments — e.g. an author-typed
+ // literal `%COMMENT%`), upstream's `comments[idx++]` is `undefined`, stringified
+ // by `String.replace` to "undefined"; emit that literal to match exactly.
if idx < comments.len() {
- result = result.replacen(COMMENT_PLACEHOLDER, &comments[idx], 1);
- idx += 1;
+ result.push_str(&comments[idx]);
} else {
- break;
+ result.push_str("undefined");
}
+ idx += 1;
+ // Continue AFTER the matched placeholder; never rescan the inserted comment.
+ rest = &rest[pos + COMMENT_PLACEHOLDER.len()..];
}
+ // Copy any trailing text after the last placeholder.
+ result.push_str(rest);
result
}
@@ -1801,6 +1943,37 @@ fn scope_selector_list(selector_list: &str, content_attr: &str, host_attr: &str)
scope_selector_list_with_context(selector_list, &ctx)
}
+/// Length (in bytes) of a LEADING `\s*(%COMMENT%\s*)*` run at the start of `s`,
+/// mirroring capture group 1 of upstream's `_ruleRe` (shadow_css.ts:1119-1120):
+/// `(\s*(?:%COMMENT%\s*)*)`. Such a prefix is stripped off the selector and
+/// re-prepended verbatim AFTER scoping, so a leading/whole comment placeholder is
+/// never scoped. A comment placeholder that is NOT part of this leading run (e.g.
+/// embedded in `a%COMMENT%b`, or after a comma) stays in the selector and is scoped
+/// normally.
+fn leading_comment_prefix_len(s: &str) -> usize {
+ // Consume a run of ECMAScript `\s` (matching the regex `\s*`) starting at byte
+ // index `i`, returning the new byte index. Char-based so non-ASCII whitespace
+ // (e.g. U+FEFF / U+00A0, which upstream's `\s` matches) is handled correctly.
+ fn skip_ws(s: &str, mut i: usize) -> usize {
+ while let Some(ch) = s[i..].chars().next() {
+ if is_ecmascript_whitespace(ch) {
+ i += ch.len_utf8();
+ } else {
+ break;
+ }
+ }
+ i
+ }
+
+ // Leading whitespace, then zero or more (`%COMMENT%` followed by `\s*`).
+ let mut i = skip_ws(s, 0);
+ while s[i..].starts_with(COMMENT_PLACEHOLDER) {
+ i += COMMENT_PLACEHOLDER.len();
+ i = skip_ws(s, i);
+ }
+ i
+}
+
/// Scope a selector list with a given context (allows recursive calls to share state)
fn scope_selector_list_with_context(selector_list: &str, parent_ctx: &ScopingContext) -> String {
// Preserve leading and trailing whitespace
@@ -1811,6 +1984,34 @@ fn scope_selector_list_with_context(selector_list: &str, parent_ctx: &ScopingCon
}
let trailing: &str = &selector_list[selector_list.trim_end().len()..];
+ // FINDING 1: strip a LEADING `\s*(%COMMENT%\s*)*` run (upstream `_ruleRe` group 1)
+ // from the selector list before scoping; re-prepend it verbatim afterwards. This
+ // keeps a leading/whole comment placeholder unscoped while letting embedded
+ // placeholders be scoped as ordinary selector text. `leading` already captured the
+ // outermost whitespace, so here we only need to consume the comment run (plus any
+ // interior whitespace between/after placeholders) from the trimmed selector.
+ //
+ // CRUCIAL: upstream performs this strip ONLY at the real CSS rule boundary
+ // (`processRules`/`_ruleRe`), feeding `_scopeSelector({ isParentSelector: true })`.
+ // The RECURSIVE calls for pseudo-function inner selectors (`:is()`/`:where()`,
+ // reached below via `scope_selector_list_with_context(inner, &child_ctx)` with
+ // `is_parent_selector == false`) do NOT get the pre-strip, so a leading comment
+ // placeholder inside `:is(/*x*/ .b)` is scoped as ordinary selector text (becomes
+ // `:is([c] .b[c])`), NOT treated as an unscoped prefix. We therefore gate the
+ // strip to the parent (top-level rule) context only. Verified vs
+ // @angular/compiler@21.2.7.
+ let (comment_prefix, trimmed): (&str, &str) = if parent_ctx.is_parent_selector {
+ let comment_prefix_len = leading_comment_prefix_len(trimmed);
+ if comment_prefix_len == trimmed.len() {
+ // Selector was nothing but a leading comment placeholder run (a pure-comment
+ // "rule"): emit it unchanged, exactly like upstream (empty `m[2]`).
+ return selector_list.to_string();
+ }
+ (&trimmed[..comment_prefix_len], &trimmed[comment_prefix_len..])
+ } else {
+ ("", trimmed)
+ };
+
// Create SafeSelector to escape problematic patterns
// (attribute selectors, escaped characters, :nth-*() expressions)
let safe_selector = SafeSelector::new(trimmed);
@@ -1832,7 +2033,7 @@ fn scope_selector_list_with_context(selector_list: &str, parent_ctx: &ScopingCon
// Restore escaped patterns
let restored = safe_selector.restore(&scoped_result);
- format!("{}{}{}", leading, restored, trailing)
+ format!("{}{}{}{}", leading, comment_prefix, restored, trailing)
}
/// Split a string by top-level commas (not inside parentheses).
@@ -2184,10 +2385,16 @@ fn scope_simple_selector(selector: &str, content_attr: &str) -> String {
return String::new();
}
- // Don't scope comment placeholders
- if selector.contains(COMMENT_PLACEHOLDER) {
- return selector.to_string();
- }
+ // NOTE (FINDING 1): there is intentionally NO `contains(COMMENT_PLACEHOLDER)`
+ // bypass here. Upstream `ShadowCss` never special-cases the comment placeholder
+ // inside `_scopeSelector`/`_applySelectorScope` (shadow_css.ts:735-857). A
+ // comment placeholder embedded in a selector part is ordinary selector text and
+ // must receive the content attribute, e.g. `a%COMMENT%b` -> `a%COMMENT%b[c]`.
+ // The ONLY comment placeholders upstream excludes from scoping are a LEADING
+ // run, which `_ruleRe` group 1 (shadow_css.ts:1119-1120) strips off the selector
+ // before scoping; that stripping is mirrored in `scope_selector_list_with_context`.
+ // A previous broad `if selector.contains(COMMENT_PLACEHOLDER) { return; }` bypass
+ // here caused an emulated-encapsulation LEAK (rule emitted unscoped).
// Already has the content attribute
let attr = format!("[{}]", content_attr);
@@ -3211,6 +3418,125 @@ mod tests {
assert!(result.contains(".button[contenta]"), "Got: {}", result);
}
+ // ---- FINDING 1: ShadowCss must not leak rules whose selector contains an
+ // embedded comment placeholder. Upstream `_ruleRe` (shadow_css.ts:1119) strips a
+ // LEADING `\s*(%COMMENT%\s*)*` prefix off the selector and scopes the rest
+ // normally; it never special-cases `%COMMENT%` inside `_scopeSelector`. An
+ // embedded comment placeholder is therefore part of an ordinary selector part
+ // and gets the content attribute. Verified against @angular/compiler@21.2.7
+ // `new ShadowCss().shimCssText(css, 'contenta', 'hosta')`.
+ //
+ // NOTE: OXC normalizes descendant combinators to a single space (pre-existing
+ // `normalize_combinator` behavior; upstream emits three spaces). These tests
+ // therefore assert the LEAK is closed (content attribute present on every
+ // comment-bearing part) rather than upstream's exact inter-part whitespace.
+
+ #[test]
+ fn test_leading_comment_inside_pseudo_function_is_scoped_not_stripped() {
+ // FINDING 1 (recursive path): the leading-comment prefix strip must apply ONLY at
+ // the top-level rule boundary, NOT inside the recursive `:is()`/`:where()` inner
+ // selector scoping. Upstream's `_ruleRe` group-1 strip happens in `processRules`
+ // and feeds `_scopeSelector({isParentSelector:true})`; the recursive pseudo calls
+ // use `isParentSelector:false`, so a leading comment placeholder inside `:is(...)`
+ // is scoped as ordinary selector text. Verified vs @angular/compiler@21.2.7:
+ // ":is(/*x*/ .b)" -> ":is([contenta] .b[contenta])"
+ // ":where(/*# sourceURL=x */ .b)" ->
+ // ":where(/*# sourceURL=x */[contenta] .b[contenta])"
+ // (OXC single-spaces descendant combinators; the load-bearing fact is that the
+ // comment part receives `[contenta]` rather than being stripped as a prefix.)
+ let out = shim_css_text(":is(/*x*/ .b) {color:red}", "contenta", "hosta");
+ assert_eq!(out, ":is([contenta] .b[contenta]) {color:red}", "Got: {out}");
+
+ // Hash comment is kept AND the comment part is scoped (not stripped).
+ let out2 = shim_css_text(":where(/*# sourceURL=x */ .b) {color:red}", "contenta", "hosta");
+ assert_eq!(
+ out2, ":where(/*# sourceURL=x */[contenta] .b[contenta]) {color:red}",
+ "Got: {out2}"
+ );
+ }
+
+ #[test]
+ fn test_embedded_comment_single_part_is_scoped() {
+ // Upstream: "a/*x*/b {color:red}" -> "ab[contenta] {color:red}"
+ let out = shim_css_text("a/*x*/b {color:red}", "contenta", "hosta");
+ assert_eq!(out, "ab[contenta] {color:red}", "Got: {out}");
+ }
+
+ #[test]
+ fn test_embedded_comment_class_part_is_scoped() {
+ // Upstream: ".foo/*x*/.bar {color:red}" -> ".foo.bar[contenta] {color:red}"
+ let out = shim_css_text(".foo/*x*/.bar {color:red}", "contenta", "hosta");
+ assert_eq!(out, ".foo.bar[contenta] {color:red}", "Got: {out}");
+ }
+
+ #[test]
+ fn test_leading_comment_then_embedded_comment_is_scoped() {
+ // Upstream: "/*# sourceURL=h */.a/*# sourceURL=h2 */ {color:red}"
+ // -> "/*# sourceURL=h */.a/*# sourceURL=h2 */[contenta] {color:red}"
+ let out = shim_css_text(
+ "/*# sourceURL=h */.a/*# sourceURL=h2 */ {color:red}",
+ "contenta",
+ "hosta",
+ );
+ assert_eq!(
+ out, "/*# sourceURL=h */.a/*# sourceURL=h2 */[contenta] {color:red}",
+ "Got: {out}"
+ );
+ }
+
+ #[test]
+ fn test_leading_comment_placeholder_is_not_scoped() {
+ // Upstream: "/*x*/ .a {color:red}" -> " .a[contenta] {color:red}"
+ // The leading comment prefix is stripped and re-prepended verbatim; only
+ // `.a` is scoped.
+ let out = shim_css_text("/*x*/ .a {color:red}", "contenta", "hosta");
+ assert_eq!(out, " .a[contenta] {color:red}", "Got: {out}");
+ }
+
+ #[test]
+ fn test_leading_hash_comment_is_kept_and_selector_scoped() {
+ // Upstream: "/*# sourceURL=foo.css */ .a {color:red}"
+ // -> "/*# sourceURL=foo.css */ .a[contenta] {color:red}"
+ let out = shim_css_text("/*# sourceURL=foo.css */ .a {color:red}", "contenta", "hosta");
+ assert_eq!(out, "/*# sourceURL=foo.css */ .a[contenta] {color:red}", "Got: {out}");
+ }
+
+ #[test]
+ fn test_comment_after_comma_is_scoped_not_treated_as_leading() {
+ // Upstream strips a leading prefix only at the very start of the whole
+ // selector list. A comment after a comma is part of the 2nd selector and
+ // every part of it is scoped. (OXC single-space normalization.)
+ // Upstream: ".a[contenta], [contenta] .b[contenta] {color:red}" (the non-hash
+ // comment `/*x*/` restores to empty). OXC matches except for descendant
+ // combinator whitespace (single space vs upstream's three — pre-existing).
+ let out = shim_css_text(".a, /*x*/ .b {color:red}", "contenta", "hosta");
+ // Leak proof: both selectors and the comment-bearing part are scoped.
+ assert!(out.starts_with(".a[contenta], "), "Got: {out}");
+ assert!(out.contains(".b[contenta]"), "Got: {out}");
+ // The comment-only middle part must also receive the attribute (no leak).
+ // The non-hash comment is stripped to empty, leaving a bare `[contenta]`.
+ assert!(out.contains("[contenta] .b[contenta]"), "Got: {out}");
+ }
+
+ #[test]
+ fn test_middle_comment_part_is_scoped() {
+ // Upstream scopes the empty-comment middle part: `%COMMENT%` -> `[contenta]`.
+ // Upstream: "div[contenta] [contenta] span[contenta] {color:red}".
+ // OXC single-spaces descendant combinators (pre-existing) ->
+ // "div[contenta] [contenta] span[contenta] {color:red}".
+ let out = shim_css_text("div /*x*/ span {color:red}", "contenta", "hosta");
+ assert_eq!(out, "div[contenta] [contenta] span[contenta] {color:red}", "Got: {out}");
+ }
+
+ #[test]
+ fn test_trailing_comment_part_is_scoped() {
+ // Upstream: "a b/*# sourceURL=h */ {color:red}"
+ // The trailing comment is part of the 2nd selector part and is scoped.
+ let out = shim_css_text("a b/*# sourceURL=h */ {color:red}", "contenta", "hosta");
+ assert!(out.contains("a[contenta]"), "Got: {out}");
+ assert!(out.contains("b/*# sourceURL=h */[contenta]"), "Got: {out}");
+ }
+
#[test]
fn test_multiple_selectors() {
let result = shim_css_text("h1, h2, h3 { font-weight: bold; }", "contenta", "");
@@ -3508,4 +3834,48 @@ mod tests {
result
);
}
+
+ // ---- Finding 3: surplus literal `%COMMENT%` placeholders restore to "undefined" ----
+ //
+ // Upstream's sentinel is the literal `%COMMENT%` (shadow_css.ts:1115). A CSS author
+ // can type it outside any comment; it survives extraction and is matched by the global
+ // restore replace `() => comments[idx++]`. With no corresponding extracted comment,
+ // `comments[idx]` is `undefined`, stringified to "undefined" by `String.replace`.
+ //
+ // Oracle (`@angular/compiler@21.2.7` `ShadowCss().shimCssText`):
+ // div { content: "%COMMENT%"; } -> content: "undefined";
+ // /*# sourceURL=foo */ div { content:"%COMMENT%"}-> comment kept, surplus->undefined
+ // div::before { content: "%COMMENT%%COMMENT%"; } -> content: "undefinedundefined";
+ // div { background: url(%COMMENT%); } -> url(undefined)
+ #[test]
+ fn test_surplus_comment_placeholder_becomes_undefined() {
+ let out = shim_css_text(r#"div { content: "%COMMENT%"; }"#, "a-host", "a-host");
+ assert!(out.contains(r#"content: "undefined";"#), "Got: {out}");
+ }
+
+ #[test]
+ fn test_surplus_comment_placeholder_two_occurrences() {
+ let out =
+ shim_css_text(r#"div::before { content: "%COMMENT%%COMMENT%"; }"#, "a-host", "a-host");
+ assert!(out.contains(r#"content: "undefinedundefined";"#), "Got: {out}");
+ }
+
+ #[test]
+ fn test_surplus_comment_placeholder_in_url() {
+ let out = shim_css_text("div { background: url(%COMMENT%); }", "a-host", "a-host");
+ assert!(out.contains("url(undefined)"), "Got: {out}");
+ }
+
+ #[test]
+ fn test_real_comment_kept_with_surplus_literal() {
+ // A real hash comment is restored at its position; the extra author-typed literal
+ // is surplus and becomes "undefined".
+ let out = shim_css_text(
+ r#"/*# sourceURL=foo */ div { content: "%COMMENT%"; }"#,
+ "a-host",
+ "a-host",
+ );
+ assert!(out.contains("/*# sourceURL=foo */"), "comment must be kept. Got: {out}");
+ assert!(out.contains(r#"content: "undefined";"#), "Got: {out}");
+ }
}
diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs
index ec4cd6e76..79c87ce88 100644
--- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs
+++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs
@@ -29,7 +29,8 @@ use crate::i18n::parser::I18nMessageFactory;
use crate::i18n::placeholder::PlaceholderRegistry;
use crate::parser::expression::{BindingParser, find_comment_start};
use crate::parser::html::decode_entities_in_string;
-use crate::schema::get_security_context;
+use crate::parser::html::split_ns_name;
+use crate::schema::{get_security_context, is_trusted_types_sink};
use crate::transform::control_flow::{parse_conditional_params, parse_defer_triggers};
use crate::util::ParseError;
@@ -308,18 +309,46 @@ impl<'a> HtmlToR3Transform<'a> {
fn visit_element(&mut self, element: &HtmlElement<'a>) -> Option> {
let raw_name = element.name.as_str();
- // Check for special elements
- if raw_name == "script" {
+ // Classify special elements the way upstream `template_preparser.ts` does.
+ // It computes `nodeName = ast.name.toLowerCase()` — the FULL (lower-cased)
+ // node name *including* any namespace prefix — and then compares it against:
+ // - SCRIPT_ELEMENT = 'script' (singular string, NOT a set)
+ // - STYLE_ELEMENT = 'style'
+ // - LINK_ELEMENT = 'link'
+ // (v21.2.7 template_preparser.ts:18,45,49-53). Because the comparison uses the
+ // FULL node name, only the un-namespaced `` (any case) closes it. The parser stores namespaced names in
+ // Angular's internal `:ns:name` form. These assertions were verified empirically against
+ // the lexer and match v21.2.7 1:1.
+ #[test]
+ fn svg_script_closes_on_local_name_and_swallows_until_bare_close() {
+ // No bare `` is present, so `` does not close the raw-text
+ // element and everything runs to EOF as the script's raw text (v21.2.7-faithful).
+ let result = parse_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element(":svg:script", 0), text("evilafter
", 1)]
+ );
+
+ // A BARE `` (local name) DOES close it case-insensitively, leaving the
+ // following `after
` as a sibling — proving local-name close matching.
+ let result = parse_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element(":svg:script", 0), text("evil", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ #[test]
+ fn svg_style_closes_on_local_name_and_swallows_until_bare_close() {
+ // No bare ``: `` does not close; runs to EOF (v21.2.7-faithful).
+ let result = parse_and_humanize("xafter
");
+ assert_eq!(
+ result,
+ vec![element(":svg:style", 0), text("xafter
", 1)]
+ );
+
+ // A bare `` closes it; the `` survives as a sibling.
+ let result = parse_and_humanize("
xafter
");
+ assert_eq!(
+ result,
+ vec![element(":svg:style", 0), text("x", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ #[test]
+ fn svg_script_close_tag_is_case_insensitive_on_local_name() {
+ // Local-name close matching is case-insensitive (`_attemptStrCaseInsensitive`).
+ // `` does NOT close `` — the boundary is the local name
+ // `script`, but the source close tag reads `SVG:SCRIPT` (a prefixed name), so the
+ // raw-text scan runs to EOF (v21.2.7-faithful).
+ let result = parse_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element(":svg:script", 0), text("evilafter
", 1)]
+ );
+
+ // A BARE upper-case `` DOES close it (case-insensitive local match),
+ // leaving the `` as a sibling.
+ let result = parse_and_humanize("
evilafter
");
+ assert_eq!(
+ result,
+ vec![element(":svg:script", 0), text("evil", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ #[test]
+ fn plain_script_still_keeps_following_sibling() {
+ // Guard against regressions in the non-namespaced raw-text path.
+ let result = parse_and_humanize("b
");
+ assert_eq!(
+ result,
+ vec![element("script", 0), text("a", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ #[test]
+ fn plain_style_still_keeps_following_sibling() {
+ let result = parse_and_humanize("b
");
+ assert_eq!(
+ result,
+ vec![element("style", 0), text(".a{}", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ // Issue: an UPPER-case (or mixed-case) raw-text opening tag (`b
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element("SCRIPT", 0), text("a", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ #[test]
+ fn mixedcase_script_keeps_following_sibling() {
+ let (nodes, errors) = parse_with_errors("b
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element("Script", 0), text("a", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ #[test]
+ fn uppercase_namespaced_script_closes_on_bare_local_name() {
+ // v21.2.7-faithful: a namespaced raw-text element closes on its LOCAL name only,
+ // case-insensitively. `` does NOT close `
` (the boundary is
+ // local `script`, but the source close tag reads `svg:SCRIPT`), so the scan runs to
+ // EOF and swallows the following content as raw text.
+ let (nodes, errors) = parse_with_errors("evilafter
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element(":svg:SCRIPT", 0), text("evilafter
", 1)]
+ );
+
+ // A BARE `` closes it (case-insensitive local match). The element keeps its
+ // ORIGINAL-case name `:svg:SCRIPT` (H2: the synthetic close token carries the
+ // original-case open-tag parts), so open/close pair with no errors and the `
`
+ // survives as a sibling.
+ let (nodes, errors) = parse_with_errors("
evilafter
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element(":svg:SCRIPT", 0), text("evil", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ #[test]
+ fn uppercase_textarea_keeps_following_sibling() {
+ let (nodes, errors) = parse_with_errors("b
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element("TEXTAREA", 0), text("a", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ #[test]
+ fn uppercase_title_keeps_following_sibling() {
+ let (nodes, errors) = parse_with_errors("ab
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element("TITLE", 0), text("a", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ #[test]
+ fn lowercase_script_still_keeps_following_sibling_with_no_errors() {
+ // Confirm the lower-case path (already correct) is unaffected by the case fix.
+ let (nodes, errors) = parse_with_errors("b
");
+ assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
+ assert_eq!(
+ nodes,
+ vec![element("script", 0), text("a", 1), element("div", 0), text("b", 1),]
+ );
+ }
+
+ // Codex finding F5: an IMPLICIT (un-prefixed) child of an SVG element must
+ // inherit the parent's namespace prefix, mirroring upstream `_getPrefix`.
+ // `");
+ assert_eq!(result, vec![element(":svg:svg", 0), element(":svg:style", 1), text(".x{}", 2)]);
+ }
+
+ #[test]
+ fn foreign_object_children_reset_to_html() {
+ // `foreignObject` sets preventNamespaceInheritance, so its children do NOT
+ // inherit the svg namespace and stay in the HTML namespace (bare `div`).
+ // `foreignObject` itself is `:svg:foreignObject` (implicitNamespacePrefix='svg').
+ let result = parse_and_humanize("");
+ assert_eq!(
+ result,
+ vec![element(":svg:svg", 0), element(":svg:foreignObject", 1), element("div", 2),]
+ );
+ }
+
+ #[test]
+ fn explicit_svg_style_still_namespaced() {
+ // Explicit `` keeps working and is namespaced `:svg:style` (regression
+ // guard for the explicit path). v21.2.7-faithful: the raw-text element closes on its
+ // LOCAL name, so the close tag here must be the bare ``; `` would
+ // not close it (it would run to EOF as raw text).
+ let result = parse_and_humanize("x");
+ assert_eq!(result, vec![element(":svg:style", 0), text("x", 1)]);
}
}
@@ -1725,6 +2263,140 @@ mod parser_component_tags {
let result = parse_selectorless_and_humanize("");
assert!(!result.is_empty());
}
+
+ // Faithful to upstream v21.2.7: a selectorless-COMPONENT raw-text element
+ // (e.g. ``, whose suffix `script` is RAW_TEXT, or ``,
+ // whose suffix `title` is ESCAPABLE_RAW_TEXT) closes on the FULL prefixed component
+ // close name, matched CASE-INSENSITIVELY. In `_consumeTagOpen`
+ // (ml_parser/lexer.ts:821-827) the component branch builds
+ // `closingTagName = parts[0](+ ":" + prefix)(+ ":" + tagName)` (e.g. `Comp:script`),
+ // then closes via the SAME `_consumeRawTextWithTagClose(openToken, closingTagName, ...)`
+ // used by the regular path, whose boundary match is `_attemptStrCaseInsensitive`
+ // (line 900) — case-insensitive — while the emitted ComponentClose token uses
+ // `_endToken(openToken.parts)` (line 911), i.e. the ORIGINAL-case open-tag parts.
+ // So `` (different case) DOES close ``, and the following
+ // `after
` survives as a sibling. A genuinely different prefixed close name
+ // (``) does NOT match and the raw text runs to EOF. These assertions
+ // were verified empirically against the lexer.
+ #[test]
+ fn component_raw_text_close_is_case_insensitive_and_keeps_sibling() {
+ // Same-case close: closes `` and `after
` is a sibling.
+ let result =
+ parse_selectorless_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("evil", 1), element("div", 0), text("after", 1),]
+ );
+
+ // DIFFERENT-case close `` closes the component raw-text element
+ // case-insensitively, leaving `after
` as a sibling (the F1 fix). Before
+ // the fix this byte-sensitive compare failed and the scan swallowed to EOF.
+ let result =
+ parse_selectorless_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("evil", 1), element("div", 0), text("after", 1),]
+ );
+
+ // Mixed-case close `` (only the suffix differs) also closes it.
+ let result =
+ parse_selectorless_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("evil", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ #[test]
+ fn component_escapable_raw_text_close_is_case_insensitive() {
+ // `` has an ESCAPABLE_RAW_TEXT suffix (`title`); a different-case
+ // close `` closes it case-insensitively, `after
` survives.
+ let result = parse_selectorless_and_humanize("xafter
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("x", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ #[test]
+ fn component_raw_text_non_matching_close_runs_to_eof() {
+ // A genuinely DIFFERENT prefixed component close name does NOT match the boundary
+ // (the full prefixed name compare fails even case-insensitively), so the raw-text
+ // scan runs to EOF and swallows the trailing markup — proving the boundary is the
+ // FULL prefixed component close name, not a bare local name.
+ let result =
+ parse_selectorless_and_humanize("evilafter
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("evilafter
", 1),]
+ );
+ }
+
+ // CURSOR ROLLBACK (faithful to upstream v21.2.7 `_consumeRawText`,
+ // ml_parser/lexer.ts:741-746): before each `` close attempt the cursor is
+ // SNAPSHOTTED; on a FAILED attempt the cursor is RESTORED to the snapshot and the `<`
+ // is consumed as one ordinary text char, so a later VALID close is still found. Both
+ // the regular raw-text path and the component path now share that core, so a
+ // non-matching `` candidate preceding the real `` does NOT
+ // swallow the following `` sibling to EOF. Before unification the component fork
+ // never rolled back the cursor, so the non-matching candidate consumed the real close
+ // and everything ran to EOF.
+ #[test]
+ fn component_raw_text_rolls_back_failed_close_and_keeps_real_close() {
+ let result =
+ parse_selectorless_and_humanize("
xafter
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("x` is RAW_TEXT;
+ // its boundary is the bare local name `script`). A non-matching `` candidate
+ // is rolled back and the real `` still closes, keeping `` as a sibling.
+ #[test]
+ fn regular_raw_text_rolls_back_failed_close_and_keeps_real_close() {
+ let result = parse_selectorless_and_humanize("
after
");
+ assert_eq!(
+ result,
+ vec![element("script", 0), text("x`
+ // has an ESCAPABLE_RAW_TEXT suffix (`title`), so `&` DECODES to `&`. The close is
+ // matched case-insensitively (``) and `
after
` survives. Before
+ // unification the component fork used `consume_entities` only to pick the token type and
+ // never decoded, leaving the literal `&`.
+ #[test]
+ fn component_escapable_raw_text_decodes_entities() {
+ let result =
+ parse_selectorless_and_humanize("
&after
");
+ assert_eq!(
+ result,
+ vec![element("Comp", 0), text("&", 1), element("div", 0), text("after", 1),]
+ );
+ }
+
+ // ENTITY NON-DECODING for FLAT component raw text (faithful to upstream: `
`
+ // is RAW_TEXT, `consumeEntities=false`, so `_consumeRawText` never enters the entity
+ // branch and `&` stays LITERAL). This mirrors the regular ``
+ // behavior — see `regular_raw_text_does_not_decode_entities` below.
+ #[test]
+ fn component_raw_text_does_not_decode_entities() {
+ let result = parse_selectorless_and_humanize("&");
+ assert_eq!(result, vec![element("Comp", 0), text("&", 1)]);
+ }
+
+ // REGULAR-path parity for entity non-decoding: `");
+ assert_eq!(result, vec![element("script", 0), text("&", 1)]);
+ }
}
// ============================================================================
@@ -2191,9 +2863,11 @@ mod more_blocks {
#[test]
fn should_infer_namespace_through_block_boundary() {
// TS: it("should infer namespace through block boundary")
+ // The @if block is skipped when finding the closest element-like parent, so
+ // `` still inherits `:svg:` from the enclosing ``.
let result = parse_and_humanize("@if (cond) {}");
- assert!(result.iter().any(|n| n.name() == Some("svg")));
- assert!(result.iter().any(|n| n.name() == Some("circle")));
+ assert!(result.iter().any(|n| n.name() == Some(":svg:svg")));
+ assert!(result.iter().any(|n| n.name() == Some(":svg:circle")));
}
}
@@ -2444,3 +3118,473 @@ mod visitor_tests {
assert!(result.iter().filter(|n| n.node_type() == Some("Attribute")).count() == 2);
}
}
+
+// ============================================================================
+// Selectorless component namespace inheritance
+//
+// Mirrors upstream `Parser._getComponentTagName` / `_getComponentFullName` /
+// `_getPrefix` (ml_parser/parser.ts ~924-1002): a selectorless component's tag
+// name inherits the parent implicit namespace exactly like a normal element.
+// OXC stores the *resolved* prefix in `HtmlElement.component_prefix` and the raw
+// tag name in `HtmlElement.component_tag_name`; the R3 transform then merges them
+// into `:prefix:tag`. These tests assert the resolved prefix/tag directly on the
+// parsed AST so they don't depend on the R3 transform.
+//
+// Ported from `html_parser_spec.ts` "component nodes" group, which the conformance
+// harness cannot capture: those upstream tests use the two-statement
+// `const parsed = humanizeDom(...); expect(parsed).toEqual([...])` pattern, which
+// the extractor stores as `expected: []`, and the conformance Humanizer humanizes
+// selectorless components as plain `html.Element` nodes (no component fields).
+// ============================================================================
+mod selectorless_component_namespace {
+ use oxc_allocator::Allocator;
+ use oxc_angular_compiler::ast::html::{HtmlElement, HtmlNode};
+ use oxc_angular_compiler::parser::html::HtmlParser;
+
+ /// Finds the first selectorless component element (one whose `name` is the
+ /// component class name, i.e. starts uppercase) anywhere in the tree and
+ /// returns its `(component_name, component_prefix, component_tag_name)`.
+ fn first_component<'a>(
+ nodes: &[HtmlNode<'a>],
+ ) -> Option<(String, Option, Option)> {
+ for node in nodes {
+ if let HtmlNode::Element(el) = node {
+ if el
+ .name
+ .as_str()
+ .chars()
+ .next()
+ .is_some_and(|c| c.is_ascii_uppercase() || c == '_')
+ {
+ return Some((
+ el.name.to_string(),
+ el.component_prefix.as_ref().map(|p| p.to_string()),
+ el.component_tag_name.as_ref().map(|t| t.to_string()),
+ ));
+ }
+ if let Some(found) = first_component(&el.children) {
+ return Some(found);
+ }
+ }
+ }
+ None
+ }
+
+ fn parse_component(html: &str) -> (String, Option, Option) {
+ let allocator = Allocator::default();
+ let parser = HtmlParser::with_selectorless(&allocator, html, "TestComp");
+ let result = parser.parse();
+ assert!(
+ result.errors.is_empty(),
+ "Unexpected parse errors for '{}': {:?}",
+ html,
+ result.errors.iter().map(|e| e.msg.clone()).collect::>()
+ );
+ first_component(&result.nodes).unwrap_or_else(|| panic!("no component found in '{html}'"))
+ }
+
+ /// Local mirror of the R3 transform's tag-name/full-name composition
+ /// (html_to_r3.rs `visit_html_element` component branch) so the test asserts
+ /// the *observable* tagName/fullName upstream humanizes.
+ fn tag_and_full(
+ name: &str,
+ prefix: Option<&str>,
+ tag: Option<&str>,
+ ) -> (Option, String) {
+ let tag_name = match (prefix, tag) {
+ (None, None) => None,
+ (None, Some(t)) => Some(t.to_string()),
+ (Some(p), None) => Some(format!(":{p}:ng-component")),
+ (Some(p), Some(t)) => Some(format!(":{p}:{t}")),
+ };
+ let full_name = match &tag_name {
+ Some(t) if t.starts_with(':') => format!("{name}{t}"),
+ Some(t) => format!("{name}:{t}"),
+ None => name.to_string(),
+ };
+ (tag_name, full_name)
+ }
+
+ #[test]
+ fn simple_component_no_namespace() {
+ // -> tagName null, fullName "MyComp"
+ let (name, prefix, tag) = parse_component("Hello");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (None, None));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name, None);
+ assert_eq!(full_name, "MyComp");
+ }
+
+ #[test]
+ fn component_with_tag_name_no_namespace() {
+ // -> tagName "button", fullName "MyComp:button"
+ let (name, prefix, tag) = parse_component("Hello");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (None, Some("button")));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some("button"));
+ assert_eq!(full_name, "MyComp:button");
+ }
+
+ #[test]
+ fn component_with_explicit_namespace() {
+ // -> tagName ":svg:title", fullName "MyComp:svg:title"
+ let (name, prefix, tag) = parse_component("Hello");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (Some("svg"), Some("title")));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some(":svg:title"));
+ assert_eq!(full_name, "MyComp:svg:title");
+ }
+
+ #[test]
+ fn inferred_svg_namespace_no_tag_name() {
+ // ... -> tagName ":svg:ng-component",
+ // fullName "MyComp:svg:ng-component" (parent implicit ns inherited).
+ let (name, prefix, tag) = parse_component("Hello");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (Some("svg"), None));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some(":svg:ng-component"));
+ assert_eq!(full_name, "MyComp:svg:ng-component");
+ }
+
+ #[test]
+ fn inferred_svg_namespace_with_tag_name() {
+ // ... -> tagName ":svg:button",
+ // fullName "MyComp:svg:button" (THE divergence case from the finding).
+ let (name, prefix, tag) =
+ parse_component("Hello");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (Some("svg"), Some("button")));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some(":svg:button"));
+ assert_eq!(full_name, "MyComp:svg:button");
+ }
+
+ #[test]
+ fn inferred_math_with_explicit_svg_namespace_wins() {
+ // : an EXPLICIT prefix beats inheritance,
+ // so tagName stays ":svg:title", fullName "MyComp:svg:title".
+ let (name, prefix, tag) =
+ parse_component("");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (Some("svg"), Some("title")));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some(":svg:title"));
+ assert_eq!(full_name, "MyComp:svg:title");
+ }
+
+ #[test]
+ fn non_svg_control_stays_unnamespaced() {
+ // Control: ...
— a non-namespaced parent does NOT
+ // inject a namespace, so tagName stays "button", fullName "MyComp:button".
+ let (name, prefix, tag) =
+ parse_component("Hello
");
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (None, Some("button")));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some("button"));
+ assert_eq!(full_name, "MyComp:button");
+ }
+
+ #[test]
+ fn foreign_object_prevents_namespace_inheritance() {
+ // ...: foreignObject prevents namespace
+ // inheritance, so the component does NOT inherit `:svg:` -> tagName "button".
+ let (name, prefix, tag) = parse_component(
+ "Hello",
+ );
+ assert_eq!((prefix.as_deref(), tag.as_deref()), (None, Some("button")));
+ let (tag_name, full_name) = tag_and_full(&name, prefix.as_deref(), tag.as_deref());
+ assert_eq!(tag_name.as_deref(), Some("button"));
+ assert_eq!(full_name, "MyComp:button");
+ }
+
+ // Silence unused-import warnings if the helper signature changes.
+ #[allow(dead_code)]
+ fn _uses(_e: &HtmlElement<'_>) {}
+}
+
+// ============================================================================
+// Selectorless-component CHILD namespace inheritance (the iteration-11 finding)
+//
+// Upstream `_getPrefix` (ml_parser/parser.ts:990-998) inherits a child's namespace
+// from the closest element-like parent's NAME: for an `html.Element` parent that is
+// `parent.name`, but for an `html.Component` parent it is `parent.tagName` (line 991:
+// `parent instanceof html.Element ? parent.name : parent.tagName`) — i.e. the
+// RESOLVED component tag name (`:svg:button`), NOT the component class name (`MyComp`).
+// `getNsPrefix(':svg:button')` is `'svg'`, so a child with no explicit/implicit
+// namespace inherits `:svg:`. These tests assert the resolved CHILD element names.
+// ============================================================================
+mod selectorless_component_child_namespace {
+ use super::{HumanizedNode, element, parse_selectorless_and_humanize, text};
+
+ /// Returns the resolved name of the first element whose humanized name equals
+ /// `child_name` is unnecessary; instead we assert on the full humanized tree.
+ fn humanize(html: &str) -> Vec {
+ parse_selectorless_and_humanize(html)
+ }
+
+ #[test]
+ fn child_div_inherits_svg_through_component_with_tag_name() {
+ //
+ // Component tagName resolves to `:svg:button`; getNsPrefix -> 'svg', so the
+ // child inherits and resolves to `:svg:div` (NOT plain html `div`).
+ let result = humanize("
");
+ assert_eq!(
+ result,
+ vec![
+ element(":svg:svg", 0),
+ // Component humanizes by its class name at depth 1.
+ element("MyComp", 1),
+ element(":svg:div", 2),
+ ]
+ );
+ }
+
+ #[test]
+ fn child_script_inherits_svg_and_is_kept_namespaced() {
+ // The security-relevant case: a ");
+ assert_eq!(
+ result,
+ vec![
+ element(":svg:svg", 0),
+ element("MyComp", 1),
+ element(":svg:script", 2),
+ text("x", 3),
+ ]
+ );
+ }
+
+ #[test]
+ fn child_style_inherits_svg_and_is_kept_namespaced() {
+ let result = humanize("
");
+ assert_eq!(
+ result,
+ vec![
+ element(":svg:svg", 0),
+ element("MyComp", 1),
+ element(":svg:style", 2),
+ text("x", 3),
+ ]
+ );
+ }
+
+ #[test]
+ fn child_inherits_svg_through_explicitly_namespaced_component() {
+ // Explicit `
` (no enclosing needed): tagName `:svg:rect`,
+ // getNsPrefix -> 'svg', so the child inherits -> `:svg:circle`.
+ let result = humanize("");
+ assert_eq!(result, vec![element("MyComp", 0), element(":svg:circle", 1)]);
+ }
+
+ #[test]
+ fn child_inherits_svg_through_component_without_tag_part_under_svg() {
+ //