fix(extensions): restore URL globals for provider link resolution

This commit is contained in:
zarzet
2026-09-15 21:44:11 +07:00
parent aa99726439
commit eb717a6e7b
6 changed files with 429 additions and 1 deletions
+2
View File
@@ -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.
@@ -17,6 +17,7 @@ pub(crate) fn register<'js>(
services: &ExtensionServices,
) -> rquickjs::Result<Object<'js>> {
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);
@@ -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};
@@ -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() {
@@ -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<String>,
) -> rquickjs::Result<Object<'js>> {
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<Object<'js>> {
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::<Vec<_>>()
.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"
])
);
}
}
+140 -1
View File
@@ -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<String>,
pub has_credentials: bool,
pub host: Vec<u8>,
pub username: Vec<u8>,
pub password: Option<Vec<u8>>,
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<Self> {
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<Vec<u8>> {
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 {