diff --git a/moli-renderer-v8/src/context_bootstrap/url_form/attributes.rs b/moli-renderer-v8/src/context_bootstrap/url_form/attributes.rs index fcbd845b34..4fcb7fcb96 100644 --- a/moli-renderer-v8/src/context_bootstrap/url_form/attributes.rs +++ b/moli-renderer-v8/src/context_bootstrap/url_form/attributes.rs @@ -283,179 +283,67 @@ fn url_writable_attribute_setter_callback<'s>( URL_WRITABLE_ATTRIBUTES, "URL writable attributes", ) else { - rv.set_undefined(); return; }; let Some(this) = require_url_receiver(scope, args.this()) else { return; }; - match attribute { - UrlAttribute::Href => { - let Some(href) = url_attribute_usv_string(scope, args.get(0), attribute) else { - return; - }; - match url::Url::parse(&href) { - Ok(url) => apply_url_update(scope, this, &url), - Err(_) => throw_type_error( - scope, - "Failed to set the 'href' property on 'URL': Invalid URL.", - ), - } - } - UrlAttribute::Protocol => { - let Some(mut url) = url_object_value(scope, this) else { - rv.set_undefined(); - return; - }; - let Some(protocol) = url_attribute_usv_string(scope, args.get(0), attribute) else { - rv.set_undefined(); - return; - }; - let scheme = protocol.trim_end_matches(':'); - if !scheme.is_empty() && url.set_scheme(scheme).is_ok() { - apply_url_update(scope, this, &url); - } - } - UrlAttribute::Username => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(username) = url_attribute_usv_string(scope, args.get(0), attribute) - && url.set_username(&username).is_ok() - { - apply_url_update(scope, this, &url); - } - } - UrlAttribute::Password => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(password) = url_attribute_usv_string(scope, args.get(0), attribute) - && url.set_password(Some(&password)).is_ok() - { - apply_url_update(scope, this, &url); - } + // WebIDL conversion can run script that changes this URL. Read its current + // record only after conversion, rather than overwriting those side effects. + let Some(value) = url_attribute_usv_string(scope, args.get(0), attribute) else { + return; + }; + if matches!(attribute, UrlAttribute::Href) { + match url::Url::parse(&value) { + Ok(url) => apply_url_update(scope, this, &url), + Err(_) => throw_type_error( + scope, + "Failed to set the 'href' property on 'URL': Invalid URL.", + ), } + return; + } + let Some(mut url) = url_object_value(scope, this) else { + return; + }; + let applied = match attribute { + UrlAttribute::Protocol => url::quirks::set_protocol(&mut url, &value).is_ok(), + UrlAttribute::Username => url::quirks::set_username(&mut url, &value).is_ok(), + UrlAttribute::Password => url::quirks::set_password(&mut url, &value).is_ok(), UrlAttribute::Host => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(host) = url_attribute_usv_string(scope, args.get(0), attribute) - { - if host.is_empty() { - rv.set_undefined(); - return; - } - let parsed = if host.starts_with('[') { - let Some(end_bracket) = host.find(']') else { - rv.set_undefined(); - return; - }; - let hostname_part = &host[..=end_bracket]; - let suffix = &host[end_bracket + 1..]; - if suffix.is_empty() { - Some((hostname_part, None)) - } else { - suffix - .strip_prefix(':') - .map(|port_part| (hostname_part, Some(port_part))) - } - } else if let Some(colon_idx) = host.rfind(':') { - let hostname_part = &host[..colon_idx]; - let port_part = &host[colon_idx + 1..]; - if hostname_part.contains(':') { - Some((host.as_str(), None)) - } else if hostname_part.is_empty() { - None - } else { - Some((hostname_part, Some(port_part))) - } - } else { - Some((host.as_str(), None)) - }; - let Some((hostname_part, explicit_port)) = parsed else { - rv.set_undefined(); - return; - }; - if url.set_host(Some(hostname_part)).is_ok() { - let port_result = match explicit_port { - Some("") => url.set_port(None), - Some(port_part) => match port_part.parse::() { - Ok(port) => url.set_port(Some(port)), - Err(_) => { - rv.set_undefined(); - return; - } - }, - None => url.set_port(None), - }; - if port_result.is_ok() { - apply_url_update(scope, this, &url); - } - } + if url.cannot_be_a_base() { + return; } + moli_url::components::set_host(&mut url, &value); + true } UrlAttribute::Hostname => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(hostname) = url_attribute_usv_string(scope, args.get(0), attribute) - && !hostname.is_empty() - { - let port = url.port(); - if url.set_host(Some(&hostname)).is_ok() { - let _ = url.set_port(port); - apply_url_update(scope, this, &url); - } else { - let candidate = format!( - "{}{}{}", - &url[..url::Position::BeforeHost], - hostname, - &url[url::Position::AfterHost..] - ); - if let Ok(next_url) = url::Url::parse(&candidate) { - apply_url_update(scope, this, &next_url); - } - } - } + moli_url::components::set_hostname(&mut url, &value); + true } UrlAttribute::Port => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(port) = url_attribute_usv_string(scope, args.get(0), attribute) - { - let updated = if port.is_empty() { - url.set_port(None) - } else if let Ok(parsed) = port.parse::() { - url.set_port(Some(parsed)) - } else { - rv.set_undefined(); - return; - }; - if updated.is_ok() { - apply_url_update(scope, this, &url); - } - } + moli_url::components::set_port(&mut url, &value); + true } UrlAttribute::Pathname => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(mut pathname) = url_attribute_usv_string(scope, args.get(0), attribute) - { - if !pathname.starts_with('/') { - pathname.insert(0, '/'); - } - url.set_path(&pathname); - apply_url_update(scope, this, &url); + if url.cannot_be_a_base() { + return; } + moli_url::components::set_pathname(&mut url, &value); + true } UrlAttribute::Search => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(search) = url_attribute_usv_string(scope, args.get(0), attribute) - { - url::quirks::set_search(&mut url, &search); - apply_url_update(scope, this, &url); - } + url::quirks::set_search(&mut url, &value); + true } UrlAttribute::Hash => { - if let Some(mut url) = url_object_value(scope, this) - && let Some(hash) = url_attribute_usv_string(scope, args.get(0), attribute) - { - url::quirks::set_hash(&mut url, &hash); - apply_url_update(scope, this, &url); - } + url::quirks::set_hash(&mut url, &value); + true } - UrlAttribute::Origin | UrlAttribute::SearchParams => {} + UrlAttribute::Href | UrlAttribute::Origin | UrlAttribute::SearchParams => false, + }; + if applied { + apply_url_update(scope, this, &url); } rv.set_undefined(); } diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 8a297d048e..aaba5aad8f 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -672,10 +672,9 @@ pub(super) use tree_mutation::{ }; pub(in crate::native_bridge) use url_attributes::parse_url_with_document_query_encoding; use url_attributes::{ - default_port_for_scheme, disconnected_iframe_can_materialize_detached_content, - iframe_has_inactive_child_context, iframe_is_in_own_child_document, - iframe_is_inside_its_own_child_context_document, iframe_uses_detached_content_cache, - normalize_url_default_port, parsed_url_like_attribute, resolve_url_like_attribute, + disconnected_iframe_can_materialize_detached_content, iframe_has_inactive_child_context, + iframe_is_in_own_child_document, iframe_is_inside_its_own_child_context_document, + iframe_uses_detached_content_cache, parsed_url_like_attribute, resolve_url_like_attribute, set_resolved_url_attribute, should_block_dangling_markup_subresource, }; pub(super) use url_attributes::{ @@ -2374,43 +2373,38 @@ fn anchor_host_getter_function<'s>( ); } +fn hyperlink_url_setter_input<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + property: &'static str, +) -> Option<(*mut JsContextHost, DomHandle, url::Url, String)> { + node_runtime_and_handle_from_object_or_detached(scope, args.this()).ok()?; + let value = + property_usv_string_value(scope, args.get(0), "HTMLHyperlinkElementUtils", property)?; + // Conversion can change href, the document's base, or the node's owner. + // Resolve the current node and URL only after those script side effects. + let (runtime_ptr, handle) = + node_runtime_and_handle_from_object_or_detached(scope, args.this()).ok()?; + let url = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href")?; + Some((runtime_ptr, handle, url, value)) +} + fn anchor_host_setter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "host") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { + if url.cannot_be_a_base() { return; - }; - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; - let applied = if let Some((host, port)) = value - .rsplit_once(':') - .filter(|(_, port)| !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit())) - { - if url.set_host(Some(host)).is_err() { - false - } else { - let port = port.parse::().ok(); - if default_port_for_scheme(url.scheme()) == port { - url.set_port(None).is_ok() - } else { - url.set_port(port).is_ok() - } - } - } else { - url.set_host(Some(&value)).is_ok() - }; - if applied { - normalize_url_default_port(&mut url); - set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); } + moli_url::components::set_host(&mut url, &value); + // HTML updates href even when component parsing leaves the URL unchanged. + set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } @@ -2433,21 +2427,17 @@ fn anchor_hostname_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "hostname") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { + if url.cannot_be_a_base() { return; - }; - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; - if url.set_host(Some(&value)).is_ok() { - normalize_url_default_port(&mut url); - set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); } + moli_url::components::set_hostname(&mut url, &value); + // HTML updates href even when component parsing leaves the URL unchanged. + set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } @@ -2470,36 +2460,17 @@ fn anchor_port_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "port") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { + if !anchor_url_can_have_userinfo(&url) { return; - }; - let value = args.get(0); - let applied = if value.is_null_or_undefined() { - url.set_port(None).is_ok() - } else if let Some(value) = property_string_value(scope, value) { - if value.is_empty() { - url.set_port(None).is_ok() - } else if let Ok(port) = value.parse::() { - if default_port_for_scheme(url.scheme()) == Some(port) { - url.set_port(None).is_ok() - } else { - url.set_port(Some(port)).is_ok() - } - } else { - url.set_port(None).is_ok() - } - } else { - false - }; - if applied { - normalize_url_default_port(&mut url); - set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); } + moli_url::components::set_port(&mut url, &value); + // HTML updates href even when component parsing leaves the URL unchanged. + set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } @@ -2516,22 +2487,15 @@ fn anchor_pathname_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "pathname") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { + if url.cannot_be_a_base() { return; - }; - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; - if value.starts_with('/') { - url.set_path(&value); - } else { - url.set_path(&format!("/{value}")); } + moli_url::components::set_pathname(&mut url, &value); set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } @@ -2555,17 +2519,11 @@ fn anchor_search_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "search") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { - return; - }; - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; url::quirks::set_search(&mut url, &value); set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); @@ -2590,17 +2548,11 @@ fn anchor_hash_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "hash") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { - return; - }; - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; url::quirks::set_hash(&mut url, &value); set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); @@ -2639,22 +2591,14 @@ fn anchor_protocol_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "protocol") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { - return; - }; - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; - let scheme = value.trim_end_matches(':'); - if url.set_scheme(scheme).is_ok() { - normalize_url_default_port(&mut url); - set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); - } + let _ = url::quirks::set_protocol(&mut url, &value); + // HTML updates href even when component parsing leaves the URL unchanged. + set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } @@ -2671,23 +2615,16 @@ fn anchor_username_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "username") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { - return; - }; if !anchor_url_can_have_userinfo(&url) { return; } - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; - if url.set_username(&value).is_ok() { - set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); - } + let _ = url::quirks::set_username(&mut url, &value); + set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } @@ -2710,33 +2647,21 @@ fn anchor_password_setter_function<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - let Ok((runtime_ptr, handle)) = - node_runtime_and_handle_from_object_or_detached(scope, args.this()) + let Some((runtime_ptr, handle, mut url, value)) = + hyperlink_url_setter_input(scope, &args, "password") else { return; }; - let Some(mut url) = parsed_url_like_attribute(unsafe { &*runtime_ptr }, handle, "href") else { - return; - }; if !anchor_url_can_have_userinfo(&url) { return; } - let Some(value) = property_string_value(scope, args.get(0)) else { - return; - }; - let password = if value.is_empty() { - None - } else { - Some(value.as_str()) - }; - if url.set_password(password).is_ok() { - set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); - } + let _ = url::quirks::set_password(&mut url, &value); + set_resolved_url_attribute(scope, runtime_ptr, handle, "href", &url); rv.set_undefined(); } fn anchor_url_can_have_userinfo(url: &url::Url) -> bool { - !url.cannot_be_a_base() && url.host().is_some() + url.scheme() != "file" && url.host_str().is_some_and(|host| !host.is_empty()) } fn reflected_url_attribute_getter_function<'s>( diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes.rs index d7562419c8..e8c1b7443b 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes.rs @@ -3,8 +3,7 @@ mod iframe; pub(in crate::native_bridge) use self::helpers::parse_url_with_document_query_encoding; pub(super) use self::helpers::{ - default_port_for_scheme, normalize_url_default_port, parsed_url_like_attribute, - resolve_url_like_attribute, set_resolved_url_attribute, + parsed_url_like_attribute, resolve_url_like_attribute, set_resolved_url_attribute, should_block_dangling_markup_subresource, }; pub(super) use self::iframe::{ diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs index 894ead9109..71037a661e 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs @@ -118,23 +118,6 @@ fn document_handle_for_url_context( runtime.dom_host().owner_document_handle(handle) } -pub(in crate::native_bridge::element) fn default_port_for_scheme(scheme: &str) -> Option { - match scheme { - "http" => Some(80), - "https" => Some(443), - _ => None, - } -} - -pub(in crate::native_bridge::element) fn normalize_url_default_port(url: &mut Url) { - if url - .port() - .is_some_and(|port| default_port_for_scheme(url.scheme()) == Some(port)) - { - let _ = url.set_port(None); - } -} - pub(in crate::native_bridge::element) fn set_resolved_url_attribute( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, diff --git a/moli-renderer-v8/src/script_vm/tests/url_components.rs b/moli-renderer-v8/src/script_vm/tests/url_components.rs index a57561b55d..4e3f294b59 100644 --- a/moli-renderer-v8/src/script_vm/tests/url_components.rs +++ b/moli-renderer-v8/src/script_vm/tests/url_components.rs @@ -13,6 +13,8 @@ const frame = document.body.appendChild(document.createElement('iframe')); const factories = [ ['URL', href => new URL(href)], ['URL.parse', href => URL.parse(href)], + ['child URL', href => new frame.contentWindow.URL(href)], + ['child URL.parse', href => frame.contentWindow.URL.parse(href)], ]; for (const [name, doc] of [ ['main', document], @@ -30,6 +32,16 @@ for (const [name, doc] of [ }} }} }} +const checkSetter = (href, property, value, expected) => {{ + for (const [name, create] of factories) {{ + const object = create(href); + const params = object.searchParams; + object[property] = value; + assert(object.href === expected, + `${{name}}: ${{property}} = ${{JSON.stringify(value)}}: expected ${{expected}}, got ${{object.href}}`); + if (params) assert(object.searchParams === params, 'setters preserve the URLSearchParams object'); + }} +}}; {script} return 'ok'; }})()"# @@ -113,6 +125,233 @@ for (const [name, create] of factories) { ); } +#[test] +fn url_components_protocol_setter_stops_at_the_first_colon() { + assert_url_components( + r#" +for (const value of ['HTTPS:any suffix', 'https::::', 'h\r\ntt\tps:ignored']) { + checkSetter('http://example.test:443/path?q=value#frag', 'protocol', value, + 'https://example.test/path?q=value#frag'); +} +checkSetter('data:text/plain,hello', 'protocol', 'custom:ignored', 'custom:text/plain,hello'); +for (const value of ['', '1https', 'https ', 'https\0', 'custom:']) { + checkSetter('http://example.test/path', 'protocol', value, 'http://example.test/path'); +} +checkSetter('https://user:pass@example.test/path', 'protocol', 'file:', + 'https://user:pass@example.test/path'); +checkSetter('file:///path', 'protocol', 'https:', 'file:///path'); +"#, + ); +} + +#[test] +fn url_components_host_setter_preserves_ports_and_accepts_partial_updates() { + assert_url_components( + r#" +const base = 'https://old.test:8443/path?q=value#frag'; +for (const value of ['new.test', 'new.test:', 'new.test:invalid', 'new.test:65536']) { + checkSetter(base, 'host', value, 'https://new.test:8443/path?q=value#frag'); +} +for (const suffix of ['/discard', '?discard:99', '#discard', '\\discard']) { + checkSetter(base, 'host', 'new.test' + suffix, 'https://new.test:8443/path?q=value#frag'); + checkSetter(base, 'host', 'new.test:443' + suffix, 'https://new.test/path?q=value#frag'); +} +checkSetter(base, 'host', '[2001:db8::2]:123tail', 'https://[2001:db8::2]:123/path?q=value#frag'); +checkSetter(base, 'host', '[::1]', 'https://[::1]:8443/path?q=value#frag'); +checkSetter(base, 'host', 'new\t.\r\ntest', 'https://new.test:8443/path?q=value#frag'); +for (const value of ['', 'bad host', 'user@new.test', '[::invalid]']) { + checkSetter(base, 'host', value, base); +} +checkSetter('file://old.test/path', 'host', '', 'file:///path'); +checkSetter('file://old.test/path', 'host', '\t\r\n', 'file:///path'); +checkSetter('file://old.test/path', 'host', 'loc%41lhost', 'file:///path'); +checkSetter('custom://old.test/path', 'host', '', 'custom:///path'); +checkSetter('custom:/path', 'host', 'new.test', 'custom://new.test/path'); +checkSetter('mailto:user@example.test', 'host', 'new.test', 'mailto:user@example.test'); +for (const href of ['custom://user@old.test/path', 'custom://:pass@old.test/path', 'custom://old.test:123/path']) { + for (const value of ['', '/discard', '#discard', '\t\r\n']) checkSetter(href, 'host', value, href); +} +"#, + ); +} + +#[test] +fn url_components_hostname_setter_does_not_accept_ports_or_modify_other_components() { + assert_url_components( + r#" +const base = 'https://old.test:8443/path?q=value#frag'; +for (const value of ['new.test', 'new.test/discard', 'new.test?discard', 'new.test#discard']) { + checkSetter(base, 'hostname', value, 'https://new.test:8443/path?q=value#frag'); +} +for (const value of ['new.test:443', 'new.test:', '[::1]:443', 'user@new.test', 'bad host']) { + checkSetter(base, 'hostname', value, base); +} +checkSetter(base, 'hostname', '[2001:db8::2]', 'https://[2001:db8::2]:8443/path?q=value#frag'); +checkSetter('file://old.test/path', 'hostname', '', 'file:///path'); +checkSetter('file://old.test/path', 'hostname', '\t\r\n', 'file:///path'); +checkSetter('custom://old.test/path', 'hostname', '', 'custom:///path'); +checkSetter('data:payload', 'hostname', 'new.test', 'data:payload'); +checkSetter('mailto:user@example.test', 'hostname', 'new.test', 'mailto:user@example.test'); +"#, + ); +} + +#[test] +fn url_components_port_setter_parses_digit_prefixes_without_clearing_invalid_values() { + assert_url_components( + r#" +const base = 'https://example.test:8443/path?q=value#frag'; +for (const value of ['123tail', '123/path', '123?query', '123#hash', '\t1\n2\r3\t']) { + checkSetter(base, 'port', value, 'https://example.test:123/path?q=value#frag'); +} +for (const value of [null, undefined, 'invalid', '+123', '-1', '65536', ' 123', '\n\t\r']) { + checkSetter(base, 'port', value, base); +} +for (const value of ['', '443']) { + checkSetter(base, 'port', value, 'https://example.test/path?q=value#frag'); +} +for (const href of ['file://example.test/path', 'custom:///path', 'custom:/path', 'data:payload']) { + checkSetter(href, 'port', '123', href); +} +"#, + ); +} + +#[test] +fn url_components_pathname_setter_respects_opaque_paths_and_scheme_delimiters() { + assert_url_components( + r#" +for (const href of ['mailto:user@example.test', 'data:payload', 'custom:payload']) { + checkSetter(href, 'pathname', '/replacement', href); +} +checkSetter('https://example.test/old?q=value#frag', 'pathname', '\\one\\..\\two', + 'https://example.test/two?q=value#frag'); +checkSetter('custom://example.test/old?q=value#frag', 'pathname', '\\one\\two', + 'custom://example.test/\\one\\two?q=value#frag'); +checkSetter('custom://example.test/old?q=value#frag', 'pathname', '', + 'custom://example.test?q=value#frag'); +checkSetter('custom:///old', 'pathname', '', 'custom://'); +checkSetter('custom:///old', 'pathname', '\t\r\n', 'custom://'); +checkSetter('https://example.test/old', 'pathname', '\t/next', 'https://example.test/next'); +checkSetter('https://example.test/old', 'pathname', '\n\\next', 'https://example.test/next'); +checkSetter('custom:/old', 'pathname', '', 'custom:/'); +checkSetter('https://example.test/old', 'pathname', '', 'https://example.test/'); +checkSetter('https://example.test/old?q=value#frag', 'pathname', '/?new#value', + 'https://example.test/%3Fnew%23value?q=value#frag'); +"#, + ); +} + +#[test] +fn url_components_setters_convert_input_before_reading_the_current_url() { + assert_url_components( + r#" +const cases = [ + ['protocol', 'https', 'https://new.test:8080/new?q=next#next'], + ['host', 'host.test', 'http://host.test:8080/new?q=next#next'], + ['hostname', 'host.test', 'http://host.test:8080/new?q=next#next'], + ['port', '123', 'http://new.test:123/new?q=next#next'], + ['pathname', '/replacement', 'http://new.test:8080/replacement?q=next#next'], + ['username', 'user', 'http://user@new.test:8080/new?q=next#next'], + ['password', 'pass', 'http://:pass@new.test:8080/new?q=next#next'], + ['search', '?q=changed', 'http://new.test:8080/new?q=changed#next'], + ['hash', '#changed', 'http://new.test:8080/new?q=next#changed'], +]; +for (const [name, create] of factories) { + for (const [property, value, expected] of cases) { + const object = create('https://old.test/old?q=old#old'); + let conversions = 0; + object[property] = {toString() { + conversions++; + object.href = 'http://new.test:8080/new?q=next#next'; + return value; + }}; + assert(conversions === 1 && object.href === expected, `${name}: ${property} observes conversion side effects`); + for (const href of ['data:payload', 'file:///path']) { + const target = create(href); + const error = new Error('conversion'); + let caught; + try { target[property] = {toString() { throw error; }}; } catch (value) { caught = value; } + assert(caught === error && target.href === href, `${name}: ${property} still converts on a non-settable URL`); + } + if (object.getAttribute) { + object.href = 'https://[invalid'; + object[property] = {toString() { + object.href = 'http://new.test:8080/new?q=next#next'; + return value; + }}; + assert(object.href === expected, `${name}: conversion can repair an invalid href before parsing`); + } + } +} +"#, + ); +} + +#[test] +fn hyperlink_component_setters_update_href_after_parser_rejection_but_not_for_opaque_paths() { + assert_url_components( + r#" +for (const tag of ['a', 'area']) { + const object = document.body.appendChild(document.createElement(tag)); + const observer = new MutationObserver(() => {}); + for (const [property, value] of [ + ['protocol', '1invalid'], ['host', 'bad host'], ['hostname', 'bad host'], ['port', 'invalid'] + ]) { + object.setAttribute('href', '../path'); + observer.observe(object, {attributes: true, attributeOldValue: true}); + object[property] = value; + assert(object.getAttribute('href') === 'https://url-components.test/path', `${tag}: ${property} serializes the resolved href`); + const records = observer.takeRecords(); + assert(records.length === 1 && records[0].attributeName === 'href' && records[0].oldValue === '../path', + `${tag}: ${property} uses the normal href mutation path`); + observer.disconnect(); + } + for (const property of ['host', 'hostname', 'port', 'pathname']) { + object.setAttribute('href', 'data:payload'); + observer.observe(object, {attributes: true}); + object[property] = 'ignored'; + assert(object.getAttribute('href') === 'data:payload' && observer.takeRecords().length === 0, + `${tag}: ${property} leaves opaque URLs and attributes untouched`); + observer.disconnect(); + } +} +"#, + ); +} + +#[test] +fn hyperlink_component_setters_reparse_after_base_changes_and_adoption() { + assert_url_components( + r#" +for (const tag of ['a', 'area']) { + const object = document.createElement(tag); + object.href = 'relative?q=keep#frag'; + const base = document.createElement('base'); + base.href = 'https://base-changed.test/dir/'; + object.pathname = {toString() { + document.head.appendChild(base); + return '/replacement'; + }}; + assert(object.href === 'https://base-changed.test/replacement?q=keep#frag', `${tag}: conversion changes the document base`); + base.remove(); + + const owner = document.implementation.createHTMLDocument('adopt'); + const adoptedBase = owner.createElement('base'); + adoptedBase.href = 'https://adopted.test/dir/'; + owner.head.appendChild(adoptedBase); + object.href = 'relative?q=keep#frag'; + object.pathname = {toString() { + owner.body.appendChild(object); + return '/replacement'; + }}; + assert(object.ownerDocument === owner && object.href === 'https://adopted.test/replacement?q=keep#frag', + `${tag}: conversion adopts the node before URL parsing`); +} +"#, + ); +} + #[test] fn location_components_empty_getters_preserve_document_urls() { assert_url_components( diff --git a/moli-url/src/components.rs b/moli-url/src/components.rs new file mode 100644 index 0000000000..70922a5d50 --- /dev/null +++ b/moli-url/src/components.rs @@ -0,0 +1,147 @@ +use std::borrow::Cow; +use url::Url; + +/// Apply the Web API host setter, including its partial host/port updates. +pub fn set_host(url: &mut Url, value: &str) { + let value = strip_tabs_and_newlines(value); + let mut updated = url.clone(); + if url::quirks::set_host(&mut updated, &value).is_err() { + return; + } + // rust-url's host setter checks usernames but misses password-only + // credentials when rejecting an empty host. Keep this guard shared by URL + // and HTML hyperlinks; an empty host cannot retain credentials or a port. + if url::quirks::hostname(&updated).is_empty() + && (!updated.username().is_empty() + || updated + .password() + .is_some_and(|password| !password.is_empty()) + || updated.port().is_some()) + { + return; + } + *url = updated; +} + +/// Apply the Web API hostname setter without interpreting a port suffix. +pub fn set_hostname(url: &mut Url, value: &str) { + // In particular, file URLs must treat a tab/newline-only input as an empty + // host, including rust-url's early check before entering its host parser. + let _ = url::quirks::set_hostname(url, &strip_tabs_and_newlines(value)); +} + +/// Apply the Web API port setter, distinguishing an empty value from empty input. +pub fn set_port(url: &mut Url, value: &str) { + let input = strip_tabs_and_newlines(value); + // Only the literal empty value clears the port. A nonempty value whose + // parser input becomes empty leaves the existing port unchanged. + if !value.is_empty() && input.is_empty() { + return; + } + let _ = url::quirks::set_port(url, &input); +} + +/// Apply the Web API pathname setter while preserving opaque paths. +pub fn set_pathname(url: &mut Url, value: &str) { + if url.cannot_be_a_base() { + return; + } + let value = strip_tabs_and_newlines(value); + // rust-url represents an empty host like a missing host. An authority + // distinguishes them: non-special URLs with an empty host can have no path. + if value.is_empty() && url.has_authority() { + url.set_path(&value); + } else { + url::quirks::set_pathname(url, &value); + } +} + +fn strip_tabs_and_newlines(value: &str) -> Cow<'_, str> { + if value + .bytes() + .any(|byte| matches!(byte, b'\t' | b'\n' | b'\r')) + { + Cow::Owned(value.replace(['\t', '\n', '\r'], "")) + } else { + Cow::Borrowed(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn web_host_setter_rejects_empty_hosts_with_credentials_or_ports() { + for input in [ + "custom://user@example.test/path", + "custom://:password@example.test/path", + "custom://example.test:123/path", + ] { + for value in ["", "/discard", "?discard", "#discard", "\t\r\n"] { + let mut url = Url::parse(input).unwrap(); + set_host(&mut url, value); + assert_eq!(url.as_str(), input, "host = {value:?}"); + } + } + } + + #[test] + fn web_host_setter_preserves_partial_updates_and_file_host_normalization() { + for (input, value, expected) in [ + ( + "https://old.test:123/path", + "new.test:65536", + "https://new.test:123/path", + ), + ( + "https://old.test:123/path", + "new.test:443tail", + "https://new.test/path", + ), + ("custom://old.test/path", "", "custom:///path"), + ("file://old.test/path", "loc%41lhost", "file:///path"), + ("file://old.test/path", "\t\r\n", "file:///path"), + ] { + let mut url = Url::parse(input).unwrap(); + set_host(&mut url, value); + assert_eq!(url.as_str(), expected); + } + } + + #[test] + fn web_component_setters_distinguish_empty_inputs_and_filter_parser_controls() { + let mut file = Url::parse("file://old.test/path").unwrap(); + set_hostname(&mut file, "\t\r\n"); + assert_eq!(file.as_str(), "file:///path"); + + let mut url = Url::parse("https://example.test:123/path").unwrap(); + set_port(&mut url, "\t\r\n"); + assert_eq!(url.port(), Some(123)); + set_port(&mut url, "\t4\n5\r6tail"); + assert_eq!(url.port(), Some(456)); + set_port(&mut url, ""); + assert_eq!(url.port(), None); + + for (input, value, expected) in [ + ("custom:///path", "", "custom://"), + ("custom:///path", "\t\r\n", "custom://"), + ("custom:/path", "", "custom:/"), + ( + "https://example.test/path", + "\t/next", + "https://example.test/next", + ), + ( + "https://example.test/path", + "\n\\next", + "https://example.test/next", + ), + ("data:payload", "replacement", "data:payload"), + ] { + let mut url = Url::parse(input).unwrap(); + set_pathname(&mut url, value); + assert_eq!(url.as_str(), expected); + } + } +} diff --git a/moli-url/src/lib.rs b/moli-url/src/lib.rs index 4239f3f03b..b0c11f4515 100644 --- a/moli-url/src/lib.rs +++ b/moli-url/src/lib.rs @@ -1,3 +1,4 @@ +pub mod components; pub mod origin; pub mod search_params; diff --git a/moli-wpt-compat/fixtures/wpt/ported/url/url-basic.html b/moli-wpt-compat/fixtures/wpt/ported/url/url-basic.html index ef337bd2db..8fade089d0 100644 --- a/moli-wpt-compat/fixtures/wpt/ported/url/url-basic.html +++ b/moli-wpt-compat/fixtures/wpt/ported/url/url-basic.html @@ -31,7 +31,7 @@ test(function () { url.pathname = "/changed"; url.search = "?q=two"; url.hash = "#done"; - assert_equals(url.href, "http://user:pass@example.test/changed?q=two#done"); + assert_equals(url.href, "http://user:pass@example.test:8443/changed?q=two#done"); assert_equals(String(url), url.href); assert_equals(url.toJSON(), url.href); }, "URL resolves relative input and exposes mutable URL components"); @@ -49,8 +49,8 @@ test(function () { url.host = "static.example.test"; assert_equals( url.href, - "https://user:pass@static.example.test/root/base/", - "host setter without an explicit port should clear the existing port", + "https://user:pass@static.example.test:8443/root/base/", + "host setter without an explicit port should preserve the existing port", ); url.host = "cdn.example.test:9443"; @@ -59,6 +59,16 @@ test(function () { "https://user:pass@cdn.example.test:9443/root/base/", "host setter with an explicit port should replace both hostname and port", ); + + url.host = "cdn.example.test:"; + assert_equals(url.port, "9443", "host setter with an empty port should preserve the existing port"); + + url.port = ""; + assert_equals( + url.href, + "https://user:pass@cdn.example.test/root/base/", + "port setter with an empty value should explicitly clear the port", + ); }, "URL host and hostname setters keep port semantics aligned"); test(function () {