diff --git a/rust_backend/NOTICE b/rust_backend/NOTICE index d3c7eddc..f5f4b1fe 100644 --- a/rust_backend/NOTICE +++ b/rust_backend/NOTICE @@ -8,6 +8,8 @@ the Rust backend. Retain this notice in source and binary distributions. The artwork resampling in `crates/core/src/cover.rs` also follows the ApproxBiLinear pixel-center and premultiplied-alpha calculations from `golang.org/x/image/draw`, under the same Go Authors BSD license below. +URL reference parsing and serialization in `crates/network/src/url.rs` follow +`net/url`, also under that license. Copyright 2009 The Go Authors. Copyright 2014 The Go Authors. All rights reserved. diff --git a/rust_backend/crates/extensions/src/host.rs b/rust_backend/crates/extensions/src/host.rs index 710593b3..b3fccdb9 100644 --- a/rust_backend/crates/extensions/src/host.rs +++ b/rust_backend/crates/extensions/src/host.rs @@ -17,6 +17,7 @@ pub(crate) fn register<'js>( services: &ExtensionServices, ) -> rquickjs::Result> { let host = Object::new(ctx.clone())?; + crate::url_host::register(ctx, &host)?; crate::utility_host::register(ctx, &host, Arc::clone(&control), services)?; crate::legacy_host::register(ctx, &host, Arc::clone(&control), services)?; let item_control = Arc::clone(&control); diff --git a/rust_backend/crates/extensions/src/lib.rs b/rust_backend/crates/extensions/src/lib.rs index fc691d8c..5da1f174 100644 --- a/rust_backend/crates/extensions/src/lib.rs +++ b/rust_backend/crates/extensions/src/lib.rs @@ -30,6 +30,7 @@ mod session_host; pub mod signed_session; pub mod storage; pub mod transfer_policy; +mod url_host; mod utility_host; pub use runtime::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits}; diff --git a/rust_backend/crates/extensions/src/prelude.js b/rust_backend/crates/extensions/src/prelude.js index ffa99428..8b2a24f9 100644 --- a/rust_backend/crates/extensions/src/prelude.js +++ b/rust_backend/crates/extensions/src/prelude.js @@ -67,6 +67,46 @@ } return goString(value); } + function queryMethods(values, mutable) { + const result = {}; + for (const method of mutable ? ["append", "delete", "get", "getAll", "has", "set"] : ["get", "getAll", "has"]) { + if (method === "append" || method === "set" || method === "delete") { + result[method] = function(key, value) { + if (arguments.length >= (method === "delete" ? 1 : 2)) { + values.write(method, goString(key), method === "delete" ? "" : goString(value)); + } + }; + } else { + result[method] = function(key) { + if (!arguments.length) return method === "getAll" ? [] : method === "has" ? false : null; + return values.read(method, goString(key)); + }; + } + } + result.toString = function() { return values.encode(); }; + return result; + } + globalThis.URL = function URL(input, base) { + if (!new.target) throw new TypeError("URL requires new"); + if (!arguments.length) { this.href = ""; return; } + const parsed = host.parseURL(goString(input), base === undefined ? undefined : goString(base)); + Object.assign(this, parsed); + if (parsed.searchParams) { + this.searchParams = queryMethods(parsed.searchParams, false); + this.toString = this.toJSON = function() { return parsed.href; }; + } + }; + globalThis.URLSearchParams = function URLSearchParams(init) { + if (!new.target) throw new TypeError("URLSearchParams requires new"); + const values = host.parseQuery(typeof init === "string" ? goString(init).replace(/^\?/, "") : ""); + const boxed = [boxedStringValue, boxedNumberValue, boxedBooleanValue].some(valueOf => { + try { stringApply(valueOf, init, []); return true; } catch (_) { return false; } + }); + if (isMap(init) && !boxed) { + for (const key of Object.keys(init)) values.write("set", key.toWellFormed(), formatGo(init[key])); + } + Object.assign(this, queryMethods(values, true)); + }; const byteArrays = new WeakSet(); const responseByteArrays = new WeakMap(); function emptyBytes() { diff --git a/rust_backend/crates/extensions/src/url_host.rs b/rust_backend/crates/extensions/src/url_host.rs new file mode 100644 index 00000000..40ad448a --- /dev/null +++ b/rust_backend/crates/extensions/src/url_host.rs @@ -0,0 +1,245 @@ +use crate::host::decode_go_utf8; +use rquickjs::{Ctx, Function, IntoJs, Object, Value}; +use spotiflac_network::{ + query, + url::{UrlParts, unescape_path}, +}; +use std::{cell::RefCell, rc::Rc}; + +pub(crate) fn register<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { + host.set("parseURL", Function::new(ctx.clone(), parse_url)?)?; + host.set("parseQuery", Function::new(ctx.clone(), query_object)?)?; + Ok(()) +} + +fn parse_url<'js>( + ctx: Ctx<'js>, + input: String, + base: Option, +) -> rquickjs::Result> { + let object = Object::new(ctx.clone())?; + let parsed = base + .as_deref() + .and_then(UrlParts::parse) + .and_then(|base| base.resolve_reference(&input)) + .or_else(|| UrlParts::parse(&input)); + let Some(parsed) = parsed else { + object.set("href", input)?; + return Ok(object); + }; + object.set("href", parsed.reference_string())?; + object.set("protocol", format!("{}:", parsed.scheme))?; + object.set("host", decode_go_utf8(&parsed.host))?; + let host_end = parsed.host.len() - parsed.port.as_ref().map_or(0, |port| port.len() + 1); + let hostname = &parsed.host[..host_end]; + let hostname = hostname + .strip_prefix(b"[") + .and_then(|host| host.strip_suffix(b"]")) + .unwrap_or(hostname); + object.set("hostname", decode_go_utf8(hostname))?; + object.set("port", parsed.port.as_deref().unwrap_or_default())?; + object.set( + "pathname", + if parsed.raw_path.is_empty() { + String::new() + } else { + decode_go_utf8(&parsed.path) + }, + )?; + object.set( + "search", + if parsed.raw_query.is_empty() { + String::new() + } else { + format!("?{}", parsed.raw_query) + }, + )?; + let fragment = decode_go_utf8(&unescape_path(&parsed.fragment).unwrap_or_default()); + object.set( + "hash", + if fragment.is_empty() { + String::new() + } else { + format!("#{fragment}") + }, + )?; + object.set( + "origin", + format!("{}://{}", parsed.scheme, decode_go_utf8(&parsed.host)), + )?; + object.set("username", decode_go_utf8(&parsed.username))?; + object.set( + "password", + decode_go_utf8(parsed.password.as_deref().unwrap_or_default()), + )?; + object.set("searchParams", query_object(ctx, parsed.raw_query)?)?; + Ok(object) +} + +fn query_object<'js>(ctx: Ctx<'js>, input: String) -> rquickjs::Result> { + let object = Object::new(ctx.clone())?; + let values = Rc::new(RefCell::new(query::parse(&input))); + let read = Rc::clone(&values); + object.set( + "read", + Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, method: String, key: String| { + let values = read.borrow(); + let found = values.get(key.as_bytes()); + match method.as_str() { + "has" => found.is_some().into_js(&ctx), + "getAll" => match found { + Some(values) => values + .iter() + .map(|value| decode_go_utf8(value)) + .collect::>() + .into_js(&ctx), + None => Ok(Value::new_null(ctx)), + }, + _ => match found + .and_then(|values| values.first()) + .filter(|value| !value.is_empty()) + { + Some(value) => decode_go_utf8(value).into_js(&ctx), + None => Ok(Value::new_null(ctx)), + }, + } + }, + )?, + )?; + let write = Rc::clone(&values); + object.set( + "write", + Function::new( + ctx.clone(), + move |method: String, key: String, value: String| { + let mut values = write.borrow_mut(); + match method.as_str() { + "append" => values + .entry(key.into_bytes()) + .or_default() + .push(value.into_bytes()), + "set" => query::set(&mut values, &key, &value), + _ => { + values.remove(key.as_bytes()); + } + } + }, + )?, + )?; + object.set( + "encode", + Function::new(ctx, move || query::encode(&values.borrow()))?, + )?; + Ok(object) +} + +#[cfg(test)] +mod tests { + use crate::{ExtensionRuntime, RuntimeLimits}; + use serde_json::json; + + #[test] + fn url_globals_resolve_track_paths_and_album_query_ids() { + let runtime = ExtensionRuntime::load( + r#" + registerExtension({probe() { + return [typeof URL, typeof URLSearchParams, ...[ + "https://music.example.test/tracks/TRACK123/", + "https://music.example.test/albums/ALBUM123?trackAsin=TRACK456", + "https://music.example.test/artists/ARTIST123" + ].map(link => { + try { + const url = new URL(link); + if (url.hostname !== "music.example.test") return null; + const track = url.searchParams.get("trackAsin"); + if (track) return track; + const match = url.pathname.match(/^\/tracks\/([^/]+)/); + return match ? match[1] : null; + } catch (_) { return null; } + })]; + }}); + "#, + "{}", + RuntimeLimits::default(), + ) + .unwrap(); + let result: serde_json::Value = + serde_json::from_str(&runtime.call("probe", "[]", None, 1000).unwrap()).unwrap(); + assert_eq!( + result, + json!(["function", "function", "TRACK123", "TRACK456", null]) + ); + } + + #[test] + fn url_and_query_objects_preserve_legacy_constructor_contracts() { + let runtime = ExtensionRuntime::load(r#" + registerExtension({probe() { + const url = new URL("../a%2Fb?z=2&z=3&empty&bad=%Q&semi=a;b#hi%20there", "https://u:p@EXAMPLE.test:443/x/y"); + const original = url.toString(); + url.href = "changed"; + const params = new URLSearchParams("?z=first&empty=&z=second&raw=%FF&bad=%Q&semi=a;b"); + const before = [params.get("z"), params.getAll("z"), params.get("empty"), params.has("empty"), params.getAll("missing"), params.getAll(), params.toString()]; + params.append("z", "last"); params.set("empty", "a b"); params.delete("raw"); params.set("ignored"); + const object = new URLSearchParams({z: [1, 2], nil: null, ok: true}); + return [original, url.protocol, url.host, url.hostname, url.port, url.pathname, url.search, url.hash, url.origin, url.username, url.password, + url.toString(), JSON.stringify(url), url.searchParams.toString(), typeof url.searchParams.set, + before, params.toString(), object.toString(), + Object.keys(new URL()), Object.keys(new URL("http://[bad]")), + new URL("https://example.test?").toString(), new URL("mailto:a@example.test").toString(), + new URL("/a", "broken base").toString(), new URL("https://[fe80::1%25en0]:80/a").host, + Object.keys(params), new URLSearchParams(new String("?x=y")).toString(), + new URL("https://%FF%E0%A4.test/").hostname, + new URLSearchParams("x=\ud800&\udfff=v").toString()]; + }}); + "#, "{}", RuntimeLimits::default()).unwrap(); + let result: serde_json::Value = + serde_json::from_str(&runtime.call("probe", "[]", None, 1000).unwrap()).unwrap(); + let href = "https://u:p@EXAMPLE.test:443/a%2Fb?z=2&z=3&empty&bad=%Q&semi=a;b#hi%20there"; + assert_eq!( + result, + json!([ + href, + "https:", + "EXAMPLE.test:443", + "EXAMPLE.test", + "443", + "/a/b", + "?z=2&z=3&empty&bad=%Q&semi=a;b", + "#hi there", + "https://EXAMPLE.test:443", + "u", + "p", + href, + serde_json::to_string(href).unwrap(), + "empty=&z=2&z=3", + "undefined", + [ + "first", + ["first", "second"], + null, + true, + null, + [], + "empty=&raw=%FF&z=first&z=second" + ], + "empty=a+b&z=first&z=second&z=last", + "nil=%3Cnil%3E&ok=true&z=%5B1+2%5D", + ["href"], + ["href"], + "https://example.test?", + "mailto:a@example.test", + "/a", + "[fe80::1%en0]:80", + [ + "append", "delete", "get", "getAll", "has", "set", "toString" + ], + "", + "���.test", + "x=%EF%BF%BD&%EF%BF%BD=v" + ]) + ); + } +} diff --git a/rust_backend/crates/network/src/url.rs b/rust_backend/crates/network/src/url.rs index 08c1648b..c5deb0a5 100644 --- a/rust_backend/crates/network/src/url.rs +++ b/rust_backend/crates/network/src/url.rs @@ -3,7 +3,7 @@ use std::net::Ipv6Addr; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct UrlParts { pub scheme: String, pub hostname: String, @@ -14,6 +14,11 @@ pub struct UrlParts { pub fragment: String, pub port: Option, pub has_credentials: bool, + pub host: Vec, + pub username: Vec, + pub password: Option>, + pub opaque: String, + pub omit_host: bool, } impl UrlParts { @@ -46,6 +51,9 @@ impl UrlParts { let mut hostname = String::new(); let mut port = None; let mut has_credentials = false; + let mut raw_host = Vec::new(); + let mut username = Vec::new(); + let mut password = None; if !rest.starts_with('/') { if !scheme.is_empty() { return Some(Self { @@ -58,6 +66,8 @@ impl UrlParts { fragment: fragment.to_owned(), port, has_credentials, + opaque: rest.to_owned(), + ..Default::default() }); } if rest.split('/').next()?.contains(':') { @@ -79,11 +89,20 @@ impl UrlParts { return None; } decode(user, Escape::Path)?; + let (name, secret) = user + .split_once(':') + .map_or((user, None), |(name, secret)| (name, Some(secret))); + username = decode(name, Escape::Path)?; + password = match secret { + Some(secret) => Some(decode(secret, Escape::Path)?), + None => None, + }; host } else { authority }; hostname = parse_host(host, scheme)?; + raw_host = decode(host, Escape::Path)?; let port_part = if host.starts_with('[') { &host[host.rfind(']')? + 1..] } else { @@ -91,6 +110,7 @@ impl UrlParts { }; port = port_part.strip_prefix(':').map(str::to_owned); } + let omit_host = !scheme.is_empty() && path.starts_with('/') && !path.starts_with("//"); let path = if rest.is_empty() { b"/".to_vec() } else { @@ -106,9 +126,111 @@ impl UrlParts { fragment: fragment.to_owned(), port, has_credentials, + host: raw_host, + username, + password, + opaque: String::new(), + omit_host, }) } + /// Serialize a parsed reference without the HTTP layer's credential removal + /// or empty-path normalization. + pub fn reference_string(&self) -> String { + let mut result = String::new(); + if !self.scheme.is_empty() { + result.push_str(&self.scheme); + result.push(':'); + } + if !self.opaque.is_empty() { + result.push_str(&self.opaque); + } else { + if (!self.scheme.is_empty() || !self.host.is_empty() || self.has_credentials) + && !(self.omit_host && self.host.is_empty() && !self.has_credentials) + { + if !self.host.is_empty() || !self.raw_path.is_empty() || self.has_credentials { + result.push_str("//"); + } + if self.has_credentials { + result.push_str(&escape_component(&self.username, b"$&+,;=")); + if let Some(password) = &self.password { + result.push(':'); + result.push_str(&escape_component(password, b"$&+,;=")); + } + result.push('@'); + } + result.push_str(&escape_component(&self.host, b"!$&'()*+,;=:[]<>\"")); + } + let path = self.escaped_path(); + if !path.is_empty() && !path.starts_with('/') && !self.host.is_empty() { + result.push('/'); + } + if result.is_empty() && path.split('/').next().is_some_and(|p| p.contains(':')) { + result.push_str("./"); + } + result.push_str(&path); + } + if self.force_query || !self.raw_query.is_empty() { + result.push('?'); + result.push_str(&self.raw_query); + } + if !self.fragment.is_empty() { + result.push('#'); + if self.fragment.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:[]/%@?".contains(&byte) + }) { + result.push_str(&self.fragment); + } else { + result.push_str(&escape_component( + &unescape_path(&self.fragment).unwrap_or_default(), + b"!$&()*+,/:;=?@", + )); + } + } + result + } + + /// Go ResolveReference semantics for JavaScript URL construction, including + /// credentials and opaque schemes that cannot be used as HTTP requests. + pub fn resolve_reference(&self, reference: &str) -> Option { + let mut target = Self::parse(reference)?; + let absolute = + !target.scheme.is_empty() || !target.host.is_empty() || target.has_credentials; + if target.scheme.is_empty() { + target.scheme.clone_from(&self.scheme); + } + if absolute { + if target.opaque.is_empty() { + target.raw_path = resolve_path(&target.escaped_path(), ""); + } + } else { + if target.raw_path.is_empty() && !target.force_query && target.raw_query.is_empty() { + target.raw_query.clone_from(&self.raw_query); + if target.fragment.is_empty() { + target.fragment.clone_from(&self.fragment); + } + } + if target.raw_path.is_empty() && !self.opaque.is_empty() { + target.opaque.clone_from(&self.opaque); + } else { + target.host.clone_from(&self.host); + target.hostname.clone_from(&self.hostname); + target.port.clone_from(&self.port); + target.has_credentials = self.has_credentials; + target.username.clone_from(&self.username); + target.password.clone_from(&self.password); + let base = if self.opaque.is_empty() { + self.escaped_path() + } else { + String::new() + }; + target.raw_path = resolve_path(&base, &target.escaped_path()); + } + } + // URL(base) reparses the resolved string in the legacy runtime. + Self::parse(&target.reference_string()) + } + pub fn authority(&self) -> String { let host = if self.hostname.contains(':') { format!("[{}]", self.hostname) @@ -222,6 +344,23 @@ impl UrlParts { } } +pub fn unescape_path(input: &str) -> Option> { + decode(input, Escape::Path) +} + +fn escape_component(value: &[u8], reserved: &[u8]) -> String { + let mut result = String::with_capacity(value.len()); + for &byte in value { + if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) || reserved.contains(&byte) { + result.push(char::from(byte)); + } else { + use std::fmt::Write; + let _ = write!(result, "%{byte:02X}"); + } + } + result +} + // RFC 3986 dot segments apply to escaped paths. In particular, %2e%2e and // %2f remain escaped rather than becoming browser-style traversal segments. fn resolve_path(base: &str, reference: &str) -> String {