Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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