fix(url): align component setters with web parsing semantics

Use Web API component parsing for URL objects and HTML hyperlinks, preserving omitted ports and partial host updates, rejecting hostname port injection, and handling port digit prefixes, parser controls, and opaque paths.

Convert setter inputs before resolving the current URL so href, base URL, and adoption side effects are preserved. Keep normal hyperlink href mutations after parser rejection. Share guards for rust-url edge cases and correct the local url-basic fixture to preserve omitted ports.

Validation: cargo fmt --all; cargo clippy --workspace --all-targets --all-features -- -D warnings; cargo nextest run --no-fail-fast (17608 passed, 13 skipped). Across 50 targeted WPT cases, CLI and CDP agree: 39 to 41 passing cases and 372 additional passing subtests, with no new failing subtests. CDP smoke: 42/43 groups passed; Puppeteer could not run because node is absent. Existing underlying URL parser failures remain.
This commit is contained in:
ldm0
2026-09-08 08:52:40 +08:00
parent 1c3a184048
commit 85efe4be9d
8 changed files with 501 additions and 309 deletions
@@ -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::<u16>() {
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::<u16>() {
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();
}
+59 -134
View File
@@ -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::<u16>().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::<u16>() {
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>(
@@ -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::{
@@ -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<u16> {
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,
@@ -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(
+147
View File
@@ -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);
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod components;
pub mod origin;
pub mod search_params;
@@ -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 () {