Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/oxc_angular_compiler/src/ast/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ pub struct HtmlElement<'a> {
/// Whether this is a void element (area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr).
/// Void elements cannot have content and do not have end tags.
pub is_void: bool,
/// Parsed from a selectorless component tag (`<MyComp>`, `<MyComp:iframe>`).
/// The class is `name`; the host element is `component_prefix` / `component_tag_name`.
pub is_component: bool,
}

/// A selectorless component in the HTML AST.
Expand Down Expand Up @@ -518,6 +521,7 @@ mod tests {
end_span: None,
is_self_closing: false,
is_void: false,
is_component: false,
};

let child2 = HtmlElement {
Expand All @@ -532,6 +536,7 @@ mod tests {
end_span: None,
is_self_closing: false,
is_void: false,
is_component: false,
};

let mut children = Vec::new_in(&&allocator);
Expand All @@ -550,6 +555,7 @@ mod tests {
end_span: None,
is_self_closing: false,
is_void: false,
is_component: false,
};

let mut nodes = Vec::new_in(&&allocator);
Expand Down
30 changes: 20 additions & 10 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3701,8 +3701,10 @@ fn compile_component_full<'a>(
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options =
R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes };
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: options.angular_version,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);

Expand Down Expand Up @@ -4161,8 +4163,10 @@ pub fn compile_component_template<'a>(
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options =
R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes };
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: None,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);

Expand Down Expand Up @@ -4258,8 +4262,10 @@ pub fn compile_template_to_js_with_options<'a>(
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options =
R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes };
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: options.angular_version,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);

Expand Down Expand Up @@ -4433,8 +4439,10 @@ pub fn compile_template_for_hmr<'a>(
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options =
R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes };
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: options.angular_version,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);

Expand Down Expand Up @@ -5132,8 +5140,10 @@ pub fn compile_template_for_linker<'a>(
};

// Stage 2: Transform HTML to R3 AST
let r3_transform_options =
R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes };
let r3_transform_options = R3TransformOptions {
collect_comment_nodes: parse_options.collect_comment_nodes,
angular_version: None,
};
let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options);
let r3_result = transformer.transform(nodes);

Expand Down
20 changes: 15 additions & 5 deletions crates/oxc_angular_compiler/src/directive/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use crate::output::ast::{
};
use crate::parser::expression::BindingParser;
use crate::pipeline::emit::{HostBindingCompilationResult, compile_host_bindings};
use crate::pipeline::ingest::{HostBindingInput, ingest_host_binding};
use crate::pipeline::ingest::{HostBindingInput, ingest_host_binding_with_version};
use crate::pipeline::selector::{
parse_selector_to_r3_selector as parse_css_to_r3, r3_selector_to_output_expr,
};
Expand Down Expand Up @@ -163,9 +163,12 @@ fn build_base_directive_fields<'a>(
// - hostVars: number of host variables (only if > 0)
// - hostBindings: the host binding function
if metadata.host.has_bindings() {
if let Some((result, new_pool_index)) =
compile_directive_host_bindings(allocator, metadata, pool_starting_index)
{
if let Some((result, new_pool_index)) = compile_directive_host_bindings(
allocator,
metadata,
pool_starting_index,
angular_version,
) {
next_pool_index = new_pool_index;

// hostAttrs: [...] - static host attributes
Expand Down Expand Up @@ -562,6 +565,7 @@ fn compile_directive_host_bindings<'a>(
allocator: &'a Allocator,
metadata: &R3DirectiveMetadata<'a>,
pool_starting_index: u32,
angular_version: Option<crate::AngularVersion>,
) -> Option<(HostBindingCompilationResult<'a>, u32)> {
let host = &metadata.host;

Expand All @@ -580,7 +584,13 @@ fn compile_directive_host_bindings<'a>(

// Ingest and compile the host bindings using the IR pipeline
// Use the provided pool_starting_index to continue from where previous compilations left off
let mut job = ingest_host_binding(allocator, input, pool_starting_index);
let mut job = ingest_host_binding_with_version(
allocator,
input,
pool_starting_index,
angular_version,
None,
);
let result = compile_host_bindings(&mut job);

// Get the next pool index after host binding compilation
Expand Down
185 changes: 174 additions & 11 deletions crates/oxc_angular_compiler/src/i18n/extractor_merger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::i18n::ast::{
};
use crate::i18n::parser::{I18nMessageFactory, create_i18n_message_factory};
use crate::i18n::translation_bundle::TranslationBundle;
use crate::schema::is_trusted_types_sink;
use crate::util::{ParseSourceFile, ParseSourceSpan};

// ============================================================================
Expand Down Expand Up @@ -1083,7 +1084,7 @@ impl<'a> I18nVisitor<'a> {

/// Translates attributes for merge mode, handling i18n-* attributes.
fn translate_attributes_for_merge(
&self,
&mut self,
element_name: &str,
attrs: &[HtmlAttrRef<'_>],
) -> Vec<TranslatedAttribute> {
Expand All @@ -1095,6 +1096,15 @@ impl<'a> I18nVisitor<'a> {
for attr in attrs {
if attr.name.starts_with(I18N_ATTR_PREFIX) {
let target_name = &attr.name[I18N_ATTR_PREFIX.len()..];
if is_trusted_types_sink(element_name, target_name) {
Comment thread
Brooooooklyn marked this conversation as resolved.
Outdated
self.report_error(
attr.span,
&format!(
"Translating attribute '{target_name}' is disallowed for security reasons."
),
);
continue;
}
explicit_attr_meta.insert(target_name.to_string(), attr.value.to_string());
}
}
Expand All @@ -1109,8 +1119,27 @@ impl<'a> I18nVisitor<'a> {

// Check if this attribute needs translation
let i18n_meta = explicit_attr_meta.get(attr.name);
let needs_translation =
i18n_meta.is_some() || implicit_attr_names.iter().any(|n| n == attr.name);
let implicit = implicit_attr_names.iter().any(|n| n == attr.name);
let needs_translation = i18n_meta.is_some() || implicit;

// Implicit config can name a sink (`iframe` → `src`) without an
// `i18n-*` marker. The marker loop above never sees that case.
if needs_translation && is_trusted_types_sink(element_name, attr.name) {
if implicit && i18n_meta.is_none() {
self.report_error(
attr.span,
&format!(
"Translating attribute '{}' is disallowed for security reasons.",
attr.name
),
);
}
return Some(TranslatedAttribute {
name: attr.name.to_string(),
value: attr.value.to_string(),
span: attr.span,
});
}

if needs_translation && !attr.is_interpolation_only && !attr.value.trim().is_empty()
{
Expand Down Expand Up @@ -1235,10 +1264,20 @@ impl<'a> I18nVisitor<'a> {
let implicit_attr_names =
self.implicit_attrs.get(element_name).cloned().unwrap_or_default();

// Collect explicit i18n-* attributes
// Collect explicit i18n-* attributes. Trusted Types sinks are rejected
// and not extracted (`i18n/meta.ts`).
for attr in attrs {
if attr.name.starts_with(I18N_ATTR_PREFIX) {
let target_name = &attr.name[I18N_ATTR_PREFIX.len()..];
if is_trusted_types_sink(element_name, target_name) {
Comment thread
Brooooooklyn marked this conversation as resolved.
Outdated
self.report_error(
attr.span,
&format!(
"Translating attribute '{target_name}' is disallowed for security reasons."
),
);
continue;
}
explicit_attr_names.insert(target_name.to_string(), attr.value.to_string());
}
}
Expand All @@ -1254,13 +1293,23 @@ impl<'a> I18nVisitor<'a> {
attr.is_interpolation_only,
);
} else if implicit_attr_names.iter().any(|n| n == attr.name) {
self.add_message_from_attr(
attr.name,
attr.value,
"",
attr.span,
attr.is_interpolation_only,
);
if is_trusted_types_sink(element_name, attr.name) {
self.report_error(
attr.span,
&format!(
"Translating attribute '{}' is disallowed for security reasons.",
attr.name
),
);
} else {
self.add_message_from_attr(
attr.name,
attr.value,
"",
attr.span,
attr.is_interpolation_only,
);
}
}
}
}
Expand Down Expand Up @@ -1794,6 +1843,120 @@ mod tests {
assert!(result.errors.is_empty());
}

#[test]
fn test_iframe_src_i18n_is_rejected() {
let source_file = Arc::new(ParseSourceFile::new("", "<test>"));
let span = Span::default();
let nodes = vec![HtmlNodeRef::Element {
name: "iframe",
attrs: vec![
HtmlAttrRef {
name: "i18n-src",
value: "translated url",
span,
is_interpolation_only: false,
},
HtmlAttrRef {
name: "src",
value: "https://example.com",
span,
is_interpolation_only: false,
},
],
children: vec![],
span,
start_span: span,
end_span: None,
}];
let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file);
assert!(result.messages.is_empty());
assert!(result.errors.iter().any(|err| err.message.contains("disallowed")));
}

#[test]
fn test_plain_title_i18n_is_still_extracted() {
let source_file = Arc::new(ParseSourceFile::new("", "<test>"));
let span = Span::default();
let nodes = vec![HtmlNodeRef::Element {
name: "div",
attrs: vec![
HtmlAttrRef {
name: "i18n-title",
value: "meaning|desc",
span,
is_interpolation_only: false,
},
HtmlAttrRef { name: "title", value: "Hello", span, is_interpolation_only: false },
],
children: vec![],
span,
start_span: span,
end_span: None,
}];
let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file);
assert!(result.errors.is_empty());
assert!(!result.messages.is_empty());
}

#[test]
fn test_implicit_iframe_src_is_rejected() {
let source_file = Arc::new(ParseSourceFile::new("", "<test>"));
let span = Span::default();
let mut implicit_attrs = FxHashMap::default();
implicit_attrs.insert("iframe".to_string(), vec!["src".to_string()]);
let nodes = vec![HtmlNodeRef::Element {
name: "iframe",
attrs: vec![HtmlAttrRef {
name: "src",
value: "https://example.com",
span,
is_interpolation_only: false,
}],
children: vec![],
span,
start_span: span,
end_span: None,
}];
let result = extract_messages(&nodes, &[], &implicit_attrs, true, source_file);
assert!(result.messages.is_empty());
assert!(result.errors.iter().any(|err| err.message.contains("disallowed")));
}

#[test]
fn test_implicit_iframe_src_is_not_rewritten_on_merge() {
let source_file = Arc::new(ParseSourceFile::new("", "<test>"));
let span = Span::default();
let mut implicit_attrs = FxHashMap::default();
implicit_attrs.insert("iframe".to_string(), vec!["src".to_string()]);
let nodes = vec![HtmlNodeRef::Element {
name: "iframe",
attrs: vec![HtmlAttrRef {
name: "src",
value: "https://example.com",
span,
is_interpolation_only: false,
}],
children: vec![],
span,
start_span: span,
end_span: None,
}];
let bundle = crate::i18n::translation_bundle::TranslationBundle::new_empty(
crate::i18n::digest::compute_digest,
crate::i18n::i18n_html_parser::MissingTranslationStrategy::Ignore,
None,
);
let result = merge_translations(&nodes, &bundle, &[], &implicit_attrs, source_file);
assert!(result.errors.iter().any(|err| err.message.contains("disallowed")));
match &result.nodes[0] {
TranslatedNode::Element { attrs, .. } => {
assert_eq!(attrs[0].name, "src");
assert_eq!(attrs[0].value, "https://example.com");
}
_ => panic!("expected an element"),
}
}

#[test]
fn test_parse_translated_text_plain() {
let nodes = parse_translated_text("Hello World", Span::default());
Expand Down
Loading
Loading