Skip to main content

urlparse_rs/
lib.rs

1//! A Rust port of Python's [`urllib.parse`](https://docs.python.org/3/library/urllib.parse.html).
2//!
3//! Port target: the standard library `urllib/parse.py` (CPython 3.13). Behaviour is verified
4//! against the reference module itself across a large corpus via differential testing.
5//!
6//! **Surface:** the percent-encoding layer (`quote`, `quote_plus`, `quote_from_bytes`, `unquote`,
7//! `unquote_plus`, `unquote_to_bytes`) and the URL splitting/joining layer (`urlsplit`,
8//! `urlunsplit`, `urlparse`, `urlunparse`, `urljoin`, `urldefrag`, `parse_qs`/`parse_qsl`,
9//! `urlencode`), including the `SplitResult`/`ParseResult` netloc accessors and `urlsplit`'s full
10//! authority validation — bracketed IPv6/IPvFuture hosts plus the NFKC homograph check on
11//! non-ASCII authorities (`_checknetloc`). That NFKC pass is the crate's one dependency
12//! (`unicode-normalization`, as used by `url`/`idna`), since Rust std has no Unicode normalization.
13//! See `STATUS.md`.
14//!
15//! Like `urllib.parse`, percent-encoding works on bytes: a `str` is first encoded as UTF-8,
16//! every byte that is not "always safe" (`A-Z a-z 0-9 _ . - ~`) or in the caller's `safe` set
17//! becomes `%XX` (uppercase hex), and decoding reverses it (UTF-8, replacing invalid sequences).
18
19/// Bytes that are never quoted: unreserved characters per RFC 3986 (`A-Z a-z 0-9 _ . - ~`).
20/// Port of `_ALWAYS_SAFE`.
21const ALWAYS_SAFE: &[u8] =
22    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~";
23
24const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
25
26/// Build the 128-entry "is this ASCII byte safe?" table for a given `safe` set. Non-ASCII bytes
27/// are never safe, mirroring `quote_from_bytes`'s `safe.encode('ascii', 'ignore')` normalization.
28fn safe_table(safe: &str) -> [bool; 128] {
29    let mut table = [false; 128];
30    for &b in ALWAYS_SAFE {
31        table[b as usize] = true;
32    }
33    for b in safe.bytes() {
34        if b < 128 {
35            table[b as usize] = true;
36        }
37    }
38    table
39}
40
41/// Like [`quote`], but takes a byte slice directly and does no string-to-bytes encoding. Always
42/// returns an ASCII string. Port of `quote_from_bytes`.
43///
44/// ```
45/// use urlparse_rs::quote_from_bytes;
46/// assert_eq!(quote_from_bytes(b"abc def\x3f", "/"), "abc%20def%3F");
47/// ```
48pub fn quote_from_bytes(bytes: &[u8], safe: &str) -> String {
49    let table = safe_table(safe);
50    let mut out = String::with_capacity(bytes.len());
51    for &b in bytes {
52        if b < 128 && table[b as usize] {
53            out.push(b as char);
54        } else {
55            out.push('%');
56            out.push(HEX_UPPER[(b >> 4) as usize] as char);
57            out.push(HEX_UPPER[(b & 0x0f) as usize] as char);
58        }
59    }
60    out
61}
62
63/// Percent-encode a string. Letters, digits and `_.-~` are never quoted; everything else outside
64/// `safe` becomes `%XX`. The string is encoded as UTF-8 first. Port of `quote` (str input).
65///
66/// Python's `quote` defaults `safe` to `"/"`; pass it explicitly here.
67///
68/// ```
69/// use urlparse_rs::quote;
70/// assert_eq!(quote("/El Niño/", "/"), "/El%20Ni%C3%B1o/");
71/// ```
72pub fn quote(string: &str, safe: &str) -> String {
73    quote_from_bytes(string.as_bytes(), safe)
74}
75
76/// Like [`quote`], but also encodes `' '` as `'+'`, as required for HTML form values. Port of
77/// `quote_plus` (Python defaults `safe` to `""`).
78///
79/// ```
80/// use urlparse_rs::quote_plus;
81/// assert_eq!(quote_plus("/El Niño/", ""), "%2FEl+Ni%C3%B1o%2F");
82/// ```
83pub fn quote_plus(string: &str, safe: &str) -> String {
84    if !string.contains(' ') {
85        return quote(string, safe);
86    }
87    // Add space to the safe set so it survives quoting, then swap it for '+'.
88    let mut safe_with_space = String::with_capacity(safe.len() + 1);
89    safe_with_space.push_str(safe);
90    safe_with_space.push(' ');
91    quote(string, &safe_with_space).replace(' ', "+")
92}
93
94fn hex_val(b: u8) -> Option<u8> {
95    match b {
96        b'0'..=b'9' => Some(b - b'0'),
97        b'a'..=b'f' => Some(b - b'a' + 10),
98        b'A'..=b'F' => Some(b - b'A' + 10),
99        _ => None,
100    }
101}
102
103/// Decode `%xx` escapes in a byte slice, returning bytes. Port of `_unquote_impl`: invalid
104/// escapes (no two hex digits after `%`) are left verbatim.
105fn unquote_impl(bytes: &[u8]) -> Vec<u8> {
106    let mut parts = bytes.split(|&b| b == b'%');
107    let first = parts.next().unwrap_or(&[]);
108    let mut res = first.to_vec();
109    for item in parts {
110        if item.len() >= 2
111            && let (Some(h), Some(l)) = (hex_val(item[0]), hex_val(item[1]))
112        {
113            res.push((h << 4) | l);
114            res.extend_from_slice(&item[2..]);
115            continue;
116        }
117        res.push(b'%');
118        res.extend_from_slice(item);
119    }
120    res
121}
122
123/// Replace `%xx` escapes by the bytes they encode. Port of `unquote_to_bytes` (the input string
124/// is encoded as UTF-8 first).
125///
126/// ```
127/// use urlparse_rs::unquote_to_bytes;
128/// assert_eq!(unquote_to_bytes("a%26%EF"), b"a&\xef");
129/// ```
130pub fn unquote_to_bytes(string: &str) -> Vec<u8> {
131    unquote_impl(string.as_bytes())
132}
133
134/// Replace `%xx` escapes by their character equivalent, decoding as UTF-8 with invalid sequences
135/// replaced by U+FFFD (Python's `errors='replace'`). Port of `unquote`.
136///
137/// Mirrors `urllib.parse`: only maximal runs of ASCII characters are `%`-decoded and UTF-8
138/// decoded as a unit; non-ASCII characters are passed through unchanged.
139///
140/// ```
141/// use urlparse_rs::unquote;
142/// assert_eq!(unquote("/El%20Ni%C3%B1o/"), "/El Niño/");
143/// ```
144pub fn unquote(string: &str) -> String {
145    if !string.contains('%') {
146        return string.to_string();
147    }
148    let mut out = String::with_capacity(string.len());
149    let mut ascii_run = String::new();
150    let flush = |run: &mut String, out: &mut String| {
151        if !run.is_empty() {
152            out.push_str(&String::from_utf8_lossy(&unquote_impl(run.as_bytes())));
153            run.clear();
154        }
155    };
156    for ch in string.chars() {
157        if (ch as u32) <= 0x7f {
158            ascii_run.push(ch);
159        } else {
160            flush(&mut ascii_run, &mut out);
161            out.push(ch);
162        }
163    }
164    flush(&mut ascii_run, &mut out);
165    out
166}
167
168/// Like [`unquote`], but also replaces `'+'` with `' '`, as required for HTML form values. Port
169/// of `unquote_plus`.
170///
171/// ```
172/// use urlparse_rs::unquote_plus;
173/// assert_eq!(unquote_plus("%7e/abc+def"), "~/abc def");
174/// ```
175pub fn unquote_plus(string: &str) -> String {
176    unquote(&string.replace('+', " "))
177}
178
179// ---------------------------------------------------------------------------
180// URL splitting / joining
181// ---------------------------------------------------------------------------
182
183/// Schemes that use a `//netloc` authority component. Port of `uses_netloc`.
184const USES_NETLOC: &[&str] = &[
185    "", "ftp", "http", "gopher", "nntp", "telnet", "imap", "wais", "file", "mms", "https",
186    "shttp", "snews", "prospero", "rtsp", "rtsps", "rtspu", "rsync", "svn", "svn+ssh", "sftp",
187    "nfs", "git", "git+ssh", "ws", "wss", "itms-services",
188];
189
190/// Error raised by the parsing functions, mirroring the `ValueError`s in `urllib.parse`.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct UrlError(pub String);
193
194impl std::fmt::Display for UrlError {
195    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196        f.write_str(&self.0)
197    }
198}
199
200impl std::error::Error for UrlError {}
201
202/// The five components of a URL, as returned by [`urlsplit`]. Port of `SplitResult`.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct SplitResult {
205    pub scheme: String,
206    pub netloc: String,
207    pub path: String,
208    pub query: String,
209    pub fragment: String,
210}
211
212fn is_scheme_char(c: char) -> bool {
213    c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'
214}
215
216/// A WHATWG C0 control or space (code point `<= 0x20`), stripped from URL/scheme ends.
217fn is_c0_or_space(c: char) -> bool {
218    (c as u32) <= 0x20
219}
220
221/// Port of `_splitnetloc`: split off the authority starting at `start`, ending at the earliest
222/// of `/`, `?`, or `#`.
223fn split_netloc(url: &str, start: usize) -> (&str, &str) {
224    let mut delim = url.len();
225    for c in ['/', '?', '#'] {
226        if let Some(pos) = url[start..].find(c) {
227            delim = delim.min(start + pos);
228        }
229    }
230    (&url[start..delim], &url[delim..])
231}
232
233/// Validate a bracketed-IPv6 authority. Port of `_check_bracketed_netloc`; mirrors the splitting
234/// done by [`netloc_hostinfo`]. Only called when the netloc contains both `[` and `]`.
235fn check_bracketed_netloc(netloc: &str) -> Result<(), UrlError> {
236    let hostname_and_port = match netloc.rfind('@') {
237        Some(at) => &netloc[at + 1..],
238        None => netloc,
239    };
240    let hostname = match hostname_and_port.find('[') {
241        Some(ob) => {
242            // No data is allowed before the bracket.
243            if ob != 0 {
244                return Err(UrlError("Invalid IPv6 URL".into()));
245            }
246            let bracketed = &hostname_and_port[ob + 1..];
247            let (hostname, port) = match bracketed.find(']') {
248                Some(cb) => (&bracketed[..cb], &bracketed[cb + 1..]),
249                None => (bracketed, ""),
250            };
251            // No data is allowed after the bracket but before the port delimiter.
252            if !port.is_empty() && !port.starts_with(':') {
253                return Err(UrlError("Invalid IPv6 URL".into()));
254            }
255            hostname
256        }
257        None => match hostname_and_port.find(':') {
258            Some(c) => &hostname_and_port[..c],
259            None => hostname_and_port,
260        },
261    };
262    check_bracketed_host(hostname)
263}
264
265/// Validate a host that appeared inside `[...]`: a valid IPv6 address (IPv4 is rejected) or an
266/// IPvFuture literal. Port of `_check_bracketed_host`.
267fn check_bracketed_host(hostname: &str) -> Result<(), UrlError> {
268    if let Some(rest) = hostname.strip_prefix('v') {
269        // IPvFuture: `v` HEXDIG+ `.` <at least one char> (port of `\Av[a-fA-F0-9]+\..+\Z`).
270        let valid = match rest.find('.') {
271            Some(dot) => {
272                dot > 0
273                    && rest[..dot].bytes().all(|b| b.is_ascii_hexdigit())
274                    && rest.len() > dot + 1
275            }
276            None => false,
277        };
278        if !valid {
279            return Err(UrlError("IPvFuture address is invalid".into()));
280        }
281        Ok(())
282    } else {
283        match classify_bracketed_ip(hostname) {
284            Some(true) => Ok(()), // IPv6
285            Some(false) => Err(UrlError("An IPv4 address cannot be in brackets".into())),
286            None => Err(UrlError(format!(
287                "{hostname:?} does not appear to be an IPv4 or IPv6 address"
288            ))),
289        }
290    }
291}
292
293/// `Some(true)` for an IPv6 address, `Some(false)` for IPv4, `None` if neither. Mirrors
294/// `ipaddress.ip_address`, including its acceptance of an IPv6 scope id (`addr%zone`, where `zone`
295/// is non-empty and `%`-free).
296fn classify_bracketed_ip(host: &str) -> Option<bool> {
297    use std::net::{Ipv4Addr, Ipv6Addr};
298    if let Some((addr, zone)) = host.split_once('%') {
299        // A scope id is only valid on IPv6, must be non-empty, and may not contain another `%`.
300        if zone.is_empty() || zone.contains('%') {
301            return None;
302        }
303        return addr.parse::<Ipv6Addr>().ok().map(|_| true);
304    }
305    if host.parse::<Ipv6Addr>().is_ok() {
306        Some(true)
307    } else if host.parse::<Ipv4Addr>().is_ok() {
308        Some(false)
309    } else {
310        None
311    }
312}
313
314/// Reject a non-ASCII authority that hides a delimiter under NFKC normalization. Port of
315/// `_checknetloc`: characters such as `℀` (U+2100) normalize to `a/c`, which would silently inject
316/// a path separator. ASCII (and empty) netlocs are passed through untouched.
317fn checknetloc(netloc: &str) -> Result<(), UrlError> {
318    if netloc.is_empty() || netloc.is_ascii() {
319        return Ok(());
320    }
321    use unicode_normalization::UnicodeNormalization;
322    // Ignore delimiters already present in the surrounding text; we only care about ones that
323    // normalization *introduces*.
324    let n: String = netloc.chars().filter(|c| !matches!(c, '@' | ':' | '#' | '?')).collect();
325    let netloc2: String = n.nfkc().collect();
326    if n == netloc2 {
327        return Ok(());
328    }
329    if netloc2.contains(['/', '?', '#', '@', ':']) {
330        return Err(UrlError(format!(
331            "netloc '{netloc}' contains invalid characters under NFKC normalization"
332        )));
333    }
334    Ok(())
335}
336
337/// Parse a URL into 5 components: `scheme://netloc/path?query#fragment`. Port of `urlsplit`.
338///
339/// `scheme` is the default scheme used when the URL has none; pass `""` for no default.
340/// `allow_fragments` controls whether a `#fragment` is split off. Note: `%` escapes are not
341/// expanded.
342///
343/// Returns [`UrlError`] for a malformed bracketed authority (mismatched brackets, data outside the
344/// brackets, IPv4-in-brackets, or a non-IP host) or a non-ASCII authority that hides a delimiter
345/// under NFKC normalization (`_checknetloc`).
346pub fn urlsplit(url: &str, scheme: &str, allow_fragments: bool) -> Result<SplitResult, UrlError> {
347    // Only lstrip the URL (apps rely on a preserved trailing space); strip both ends of scheme.
348    let mut url = url.trim_start_matches(is_c0_or_space).replace(['\t', '\r', '\n'], "");
349    let mut scheme = scheme.trim_matches(is_c0_or_space).replace(['\t', '\r', '\n'], "");
350
351    let mut netloc = String::new();
352    let mut query = String::new();
353    let mut fragment = String::new();
354
355    if let Some(i) = url.find(':')
356        && i > 0
357        && url.as_bytes()[0].is_ascii_alphabetic()
358        && url[..i].chars().all(is_scheme_char)
359    {
360        scheme = url[..i].to_ascii_lowercase();
361        url = url[i + 1..].to_string();
362    }
363
364    if url.starts_with("//") {
365        let (nl, rest) = split_netloc(&url, 2);
366        netloc = nl.to_string();
367        let has_open = netloc.contains('[');
368        let has_close = netloc.contains(']');
369        if has_open != has_close {
370            return Err(UrlError("Invalid IPv6 URL".into()));
371        }
372        if has_open && has_close {
373            check_bracketed_netloc(&netloc)?;
374        }
375        url = rest.to_string();
376    }
377
378    if allow_fragments && let Some(pos) = url.find('#') {
379        fragment = url[pos + 1..].to_string();
380        url.truncate(pos);
381    }
382
383    if let Some(pos) = url.find('?') {
384        query = url[pos + 1..].to_string();
385        url.truncate(pos);
386    }
387
388    checknetloc(&netloc)?;
389    Ok(SplitResult { scheme, netloc, path: url, query, fragment })
390}
391
392/// Combine 5 URL components into a string. Port of `urlunsplit`.
393pub fn urlunsplit(
394    scheme: &str,
395    netloc: &str,
396    path: &str,
397    query: &str,
398    fragment: &str,
399) -> String {
400    let mut url = path.to_string();
401    if !netloc.is_empty() {
402        if !url.is_empty() && !url.starts_with('/') {
403            url = format!("/{url}");
404        }
405        url = format!("//{netloc}{url}");
406    } else if url.starts_with("//")
407        // Mirrors urllib's two `//`-prefixing elif branches (the second guards a netloc-using
408        // scheme with an empty or absolute path).
409        || (!scheme.is_empty()
410            && USES_NETLOC.contains(&scheme)
411            && (url.is_empty() || url.starts_with('/')))
412    {
413        url = format!("//{url}");
414    }
415    if !scheme.is_empty() {
416        url = format!("{scheme}:{url}");
417    }
418    if !query.is_empty() {
419        url = format!("{url}?{query}");
420    }
421    if !fragment.is_empty() {
422        url = format!("{url}#{fragment}");
423    }
424    url
425}
426
427// Netloc sub-component parsing, shared by `SplitResult` and `ParseResult`. Ports of the
428// `_NetlocResultMixin` properties (`_userinfo`, `_hostinfo`, `username`/`password`/`hostname`/
429// `port`).
430
431/// `(username, password)` from the netloc userinfo. Port of `_userinfo`.
432fn netloc_userinfo(netloc: &str) -> (Option<&str>, Option<&str>) {
433    match netloc.rfind('@') {
434        Some(at) => {
435            let userinfo = &netloc[..at];
436            match userinfo.find(':') {
437                Some(c) => (Some(&userinfo[..c]), Some(&userinfo[c + 1..])),
438                None => (Some(userinfo), None),
439            }
440        }
441        None => (None, None),
442    }
443}
444
445/// `(hostname, port)` substrings from the netloc, handling bracketed IPv6. Port of `_hostinfo`.
446fn netloc_hostinfo(netloc: &str) -> (&str, Option<&str>) {
447    let hostinfo = match netloc.rfind('@') {
448        Some(at) => &netloc[at + 1..],
449        None => netloc,
450    };
451    let (hostname, port) = if let Some(ob) = hostinfo.find('[') {
452        let bracketed = &hostinfo[ob + 1..];
453        let (hostname, after) = match bracketed.find(']') {
454            Some(cb) => (&bracketed[..cb], &bracketed[cb + 1..]),
455            None => (bracketed, ""),
456        };
457        let port = match after.find(':') {
458            Some(c) => &after[c + 1..],
459            None => "",
460        };
461        (hostname, port)
462    } else {
463        match hostinfo.find(':') {
464            Some(c) => (&hostinfo[..c], &hostinfo[c + 1..]),
465            None => (hostinfo, ""),
466        }
467    };
468    (hostname, if port.is_empty() { None } else { Some(port) })
469}
470
471/// The lowercased hostname (an IPv6 zone id after `%` is preserved), or `None`. Port of the
472/// `hostname` property.
473fn netloc_hostname(netloc: &str) -> Option<String> {
474    let (host, _) = netloc_hostinfo(netloc);
475    if host.is_empty() {
476        return None;
477    }
478    Some(match host.find('%') {
479        Some(p) => format!("{}{}", host[..p].to_lowercase(), &host[p..]),
480        None => host.to_lowercase(),
481    })
482}
483
484/// The integer port, or `None`. Port of the `port` property: errors if the port is non-numeric
485/// or outside `0..=65535`.
486fn netloc_port(netloc: &str) -> Result<Option<u16>, UrlError> {
487    let (_, port) = netloc_hostinfo(netloc);
488    let Some(port) = port else { return Ok(None) };
489    if port.bytes().all(|b| b.is_ascii_digit()) {
490        // Python uses an arbitrary-precision int, so an all-digit overflow is "out of range".
491        match port.parse::<u64>() {
492            Ok(n) if n <= 65535 => Ok(Some(n as u16)),
493            _ => Err(UrlError("Port out of range 0-65535".into())),
494        }
495    } else {
496        Err(UrlError(format!(
497            "Port could not be cast to integer value as '{port}'"
498        )))
499    }
500}
501
502impl SplitResult {
503    /// Reconstruct the URL string. Port of `SplitResult.geturl`.
504    pub fn geturl(&self) -> String {
505        urlunsplit(&self.scheme, &self.netloc, &self.path, &self.query, &self.fragment)
506    }
507
508    /// The username, or `None`. Port of the `username` property.
509    pub fn username(&self) -> Option<&str> {
510        netloc_userinfo(&self.netloc).0
511    }
512
513    /// The password, or `None`. Port of the `password` property.
514    pub fn password(&self) -> Option<&str> {
515        netloc_userinfo(&self.netloc).1
516    }
517
518    /// The lowercased hostname, or `None`. Port of the `hostname` property.
519    pub fn hostname(&self) -> Option<String> {
520        netloc_hostname(&self.netloc)
521    }
522
523    /// The integer port, or `None`. Port of the `port` property.
524    pub fn port(&self) -> Result<Option<u16>, UrlError> {
525        netloc_port(&self.netloc)
526    }
527}
528
529/// Schemes whose path may carry `;params`. Port of `uses_params`.
530const USES_PARAMS: &[&str] = &[
531    "", "ftp", "hdl", "prospero", "http", "imap", "https", "shttp", "rtsp", "rtsps", "rtspu",
532    "sip", "sips", "mms", "sftp", "tel",
533];
534
535/// The six components of a URL, as returned by [`urlparse`]. Port of `ParseResult` (like
536/// [`SplitResult`] but with a `;params` field split out of the path).
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct ParseResult {
539    pub scheme: String,
540    pub netloc: String,
541    pub path: String,
542    pub params: String,
543    pub query: String,
544    pub fragment: String,
545}
546
547impl ParseResult {
548    /// Reconstruct the URL string. Port of `ParseResult.geturl`.
549    pub fn geturl(&self) -> String {
550        urlunparse(
551            &self.scheme, &self.netloc, &self.path, &self.params, &self.query, &self.fragment,
552        )
553    }
554
555    /// The username, or `None`. Port of the `username` property.
556    pub fn username(&self) -> Option<&str> {
557        netloc_userinfo(&self.netloc).0
558    }
559
560    /// The password, or `None`. Port of the `password` property.
561    pub fn password(&self) -> Option<&str> {
562        netloc_userinfo(&self.netloc).1
563    }
564
565    /// The lowercased hostname, or `None`. Port of the `hostname` property.
566    pub fn hostname(&self) -> Option<String> {
567        netloc_hostname(&self.netloc)
568    }
569
570    /// The integer port, or `None`. Port of the `port` property.
571    pub fn port(&self) -> Result<Option<u16>, UrlError> {
572        netloc_port(&self.netloc)
573    }
574}
575
576/// Split a `;params` suffix off the last path segment. Port of `_splitparams`.
577fn split_params(url: &str) -> (&str, &str) {
578    let i = if let Some(slash) = url.rfind('/') {
579        match url[slash..].find(';') {
580            Some(rel) => slash + rel,
581            None => return (url, ""),
582        }
583    } else {
584        match url.find(';') {
585            Some(pos) => pos,
586            None => return (url, ""),
587        }
588    };
589    (&url[..i], &url[i + 1..])
590}
591
592/// Parse a URL into 6 components: `scheme://netloc/path;params?query#fragment`. Port of
593/// `urlparse`. Like [`urlsplit`], but also splits `;params` off the path for schemes in
594/// `uses_params`.
595pub fn urlparse(url: &str, scheme: &str, allow_fragments: bool) -> Result<ParseResult, UrlError> {
596    let s = urlsplit(url, scheme, allow_fragments)?;
597    let (path, params) = if USES_PARAMS.contains(&s.scheme.as_str()) && s.path.contains(';') {
598        let (p, pr) = split_params(&s.path);
599        (p.to_string(), pr.to_string())
600    } else {
601        (s.path, String::new())
602    };
603    Ok(ParseResult {
604        scheme: s.scheme,
605        netloc: s.netloc,
606        path,
607        params,
608        query: s.query,
609        fragment: s.fragment,
610    })
611}
612
613/// Combine 6 URL components into a string. Port of `urlunparse`.
614pub fn urlunparse(
615    scheme: &str,
616    netloc: &str,
617    path: &str,
618    params: &str,
619    query: &str,
620    fragment: &str,
621) -> String {
622    let path = if params.is_empty() {
623        path.to_string()
624    } else {
625        format!("{path};{params}")
626    };
627    urlunsplit(scheme, netloc, &path, query, fragment)
628}
629
630/// Schemes that support relative-reference resolution. Port of `uses_relative`.
631const USES_RELATIVE: &[&str] = &[
632    "", "ftp", "http", "gopher", "nntp", "imap", "wais", "file", "https", "shttp", "mms",
633    "prospero", "rtsp", "rtsps", "rtspu", "sftp", "svn", "svn+ssh", "ws", "wss",
634];
635
636/// Join a base URL and a possibly-relative URL into an absolute one. Port of `urljoin`.
637///
638/// Mirrors `urllib.parse` (which is intentionally not fully RFC 3986 compliant for backward
639/// compatibility), not the strict RFC algorithm.
640pub fn urljoin(base: &str, url: &str, allow_fragments: bool) -> Result<String, UrlError> {
641    if base.is_empty() {
642        return Ok(url.to_string());
643    }
644    if url.is_empty() {
645        return Ok(base.to_string());
646    }
647
648    let b = urlparse(base, "", allow_fragments)?;
649    let u = urlparse(url, &b.scheme, allow_fragments)?;
650
651    let scheme = u.scheme;
652    let mut netloc = u.netloc;
653    let mut path = u.path;
654    let mut params = u.params;
655    let mut query = u.query;
656    let fragment = u.fragment;
657
658    if scheme != b.scheme || !USES_RELATIVE.contains(&scheme.as_str()) {
659        return Ok(url.to_string());
660    }
661    if USES_NETLOC.contains(&scheme.as_str()) {
662        if !netloc.is_empty() {
663            return Ok(urlunparse(&scheme, &netloc, &path, &params, &query, &fragment));
664        }
665        netloc = b.netloc.clone();
666    }
667    if path.is_empty() && params.is_empty() {
668        path = b.path.clone();
669        params = b.params.clone();
670        if query.is_empty() {
671            query = b.query.clone();
672        }
673        return Ok(urlunparse(&scheme, &netloc, &path, &params, &query, &fragment));
674    }
675
676    // The base path contributes its directory segments (drop the trailing non-directory part).
677    let mut base_parts: Vec<&str> = b.path.split('/').collect();
678    if base_parts.last() != Some(&"") {
679        base_parts.pop();
680    }
681
682    let segments: Vec<&str> = if path.starts_with('/') {
683        path.split('/').collect()
684    } else {
685        let mut segs = base_parts;
686        segs.extend(path.split('/'));
687        // Port of `segments[1:-1] = filter(None, segments[1:-1])`: drop empty middle segments
688        // (which would otherwise produce redundant slashes), keeping the first and last.
689        if segs.len() > 2 {
690            let mut rebuilt = Vec::with_capacity(segs.len());
691            rebuilt.push(segs[0]);
692            rebuilt.extend(segs[1..segs.len() - 1].iter().copied().filter(|s| !s.is_empty()));
693            rebuilt.push(segs[segs.len() - 1]);
694            segs = rebuilt;
695        }
696        segs
697    };
698
699    let mut resolved: Vec<&str> = Vec::with_capacity(segments.len());
700    for &seg in &segments {
701        match seg {
702            ".." => {
703                resolved.pop(); // an extra `..` past the root is silently ignored
704            }
705            "." => continue,
706            _ => resolved.push(seg),
707        }
708    }
709    if matches!(segments.last(), Some(&"." | &"..")) {
710        // A trailing relative-dir segment leaves a trailing slash.
711        resolved.push("");
712    }
713
714    let joined = resolved.join("/");
715    let resolved_path = if joined.is_empty() { "/".to_string() } else { joined };
716    Ok(urlunparse(&scheme, &netloc, &resolved_path, &params, &query, &fragment))
717}
718
719/// A URL split into its defragmented part and its fragment, as returned by [`urldefrag`]. Port of
720/// `DefragResult`.
721#[derive(Debug, Clone, PartialEq, Eq)]
722pub struct DefragResult {
723    pub url: String,
724    pub fragment: String,
725}
726
727impl DefragResult {
728    /// Reconstruct the URL string. Port of `DefragResult.geturl`.
729    pub fn geturl(&self) -> String {
730        if self.fragment.is_empty() {
731            self.url.clone()
732        } else {
733            format!("{}#{}", self.url, self.fragment)
734        }
735    }
736}
737
738/// Remove the fragment from a URL, returning the defragmented URL and the fragment. Port of
739/// `urldefrag`.
740pub fn urldefrag(url: &str) -> Result<DefragResult, UrlError> {
741    if url.contains('#') {
742        let p = urlparse(url, "", true)?;
743        let defrag = urlunparse(&p.scheme, &p.netloc, &p.path, &p.params, &p.query, "");
744        Ok(DefragResult { url: defrag, fragment: p.fragment })
745    } else {
746        Ok(DefragResult { url: url.to_string(), fragment: String::new() })
747    }
748}
749
750// ---------------------------------------------------------------------------
751// Query strings
752// ---------------------------------------------------------------------------
753
754/// Parse a query string into a list of `(name, value)` pairs. Port of `parse_qsl`.
755///
756/// `keep_blank_values` keeps fields whose value is empty; `strict_parsing` errors on a field with
757/// no `=`; `max_num_fields` (if set) caps the number of fields. `separator` is the field separator
758/// (Python defaults to `"&"`). Values are decoded with [`unquote_plus`] (UTF-8, `errors='replace'`).
759pub fn parse_qsl(
760    qs: &str,
761    keep_blank_values: bool,
762    strict_parsing: bool,
763    max_num_fields: Option<usize>,
764    separator: &str,
765) -> Result<Vec<(String, String)>, UrlError> {
766    if separator.is_empty() {
767        return Err(UrlError("Separator must be of type string or bytes.".into()));
768    }
769    if qs.is_empty() {
770        return Ok(Vec::new());
771    }
772    if let Some(max) = max_num_fields {
773        let num_fields = 1 + qs.matches(separator).count();
774        if max < num_fields {
775            return Err(UrlError("Max number of fields exceeded".into()));
776        }
777    }
778
779    let mut r = Vec::new();
780    for name_value in qs.split(separator) {
781        if name_value.is_empty() && !strict_parsing {
782            continue;
783        }
784        let (name, has_eq, value) = match name_value.find('=') {
785            Some(i) => (&name_value[..i], true, &name_value[i + 1..]),
786            None => (name_value, false, ""),
787        };
788        if !has_eq && strict_parsing {
789            return Err(UrlError(format!("bad query field: '{name_value}'")));
790        }
791        if !value.is_empty() || keep_blank_values {
792            r.push((unquote_plus(name), unquote_plus(value)));
793        }
794    }
795    Ok(r)
796}
797
798/// Parse a query string into a list of `(name, values)` entries (insertion order, values grouped
799/// per name). Port of `parse_qs` (which returns a dict). See [`parse_qsl`] for the arguments.
800pub fn parse_qs(
801    qs: &str,
802    keep_blank_values: bool,
803    strict_parsing: bool,
804    max_num_fields: Option<usize>,
805    separator: &str,
806) -> Result<Vec<(String, Vec<String>)>, UrlError> {
807    let pairs = parse_qsl(qs, keep_blank_values, strict_parsing, max_num_fields, separator)?;
808    let mut result: Vec<(String, Vec<String>)> = Vec::new();
809    for (name, value) in pairs {
810        match result.iter_mut().find(|(k, _)| *k == name) {
811            Some(entry) => entry.1.push(value),
812            None => result.push((name, vec![value])),
813        }
814    }
815    Ok(result)
816}
817
818/// Encode a sequence of `(key, value)` pairs into a `key=value&...` query string, with each part
819/// run through [`quote_plus`]. Port of `urlencode` (with `doseq=False`, the default `quote_plus`).
820pub fn urlencode(query: &[(&str, &str)], safe: &str) -> String {
821    query
822        .iter()
823        .map(|(k, v)| format!("{}={}", quote_plus(k, safe), quote_plus(v, safe)))
824        .collect::<Vec<_>>()
825        .join("&")
826}
827
828/// Like [`urlencode`], but each key maps to a sequence of values, emitting one `key=value` pair
829/// per element. Port of `urlencode` with `doseq=True`.
830pub fn urlencode_doseq(query: &[(&str, &[&str])], safe: &str) -> String {
831    let mut parts = Vec::new();
832    for (k, seq) in query {
833        let key = quote_plus(k, safe);
834        for elt in *seq {
835            parts.push(format!("{key}={}", quote_plus(elt, safe)));
836        }
837    }
838    parts.join("&")
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn quote_basics() {
847        assert_eq!(quote("abc def", "/"), "abc%20def");
848        assert_eq!(quote("a/b/c", "/"), "a/b/c");
849        assert_eq!(quote("a/b/c", ""), "a%2Fb%2Fc");
850        assert_eq!(quote("~_.-", "/"), "~_.-");
851        assert_eq!(quote("/El Niño/", "/"), "/El%20Ni%C3%B1o/");
852    }
853
854    #[test]
855    fn quote_plus_basics() {
856        assert_eq!(quote_plus("a b c", ""), "a+b+c");
857        assert_eq!(quote_plus("a+b", ""), "a%2Bb");
858        assert_eq!(quote_plus("/El Niño/", ""), "%2FEl+Ni%C3%B1o%2F");
859    }
860
861    #[test]
862    fn unquote_basics() {
863        assert_eq!(unquote("abc%20def"), "abc def");
864        assert_eq!(unquote("no-escapes"), "no-escapes");
865        assert_eq!(unquote("/El%20Ni%C3%B1o/"), "/El Niño/");
866        // Invalid escapes are left verbatim.
867        assert_eq!(unquote("%zz%"), "%zz%");
868        assert_eq!(unquote("100%"), "100%");
869    }
870
871    #[test]
872    fn unquote_plus_and_bytes() {
873        assert_eq!(unquote_plus("%7e/abc+def"), "~/abc def");
874        assert_eq!(unquote_to_bytes("a%26%EF"), b"a&\xef");
875    }
876
877    #[test]
878    fn quote_roundtrip_bytes() {
879        assert_eq!(quote_from_bytes(b"abc def\x3f", "/"), "abc%20def%3F");
880        assert_eq!(quote_from_bytes(&[], "/"), "");
881    }
882
883    fn split(url: &str) -> SplitResult {
884        urlsplit(url, "", true).unwrap()
885    }
886
887    #[test]
888    fn urlsplit_basics() {
889        let r = split("http://docs.python.org:80/3/library/urllib.parse.html?h=1#frag");
890        assert_eq!(r.scheme, "http");
891        assert_eq!(r.netloc, "docs.python.org:80");
892        assert_eq!(r.path, "/3/library/urllib.parse.html");
893        assert_eq!(r.query, "h=1");
894        assert_eq!(r.fragment, "frag");
895    }
896
897    #[test]
898    fn urlsplit_edge_cases() {
899        assert_eq!(split("//netloc/path").netloc, "netloc");
900        assert_eq!(split("path/only").scheme, "");
901        assert_eq!(split("path/only").path, "path/only");
902        // Scheme requires an ASCII-alpha first char; "1http:..." is not a scheme.
903        assert_eq!(split("1http://x").scheme, "");
904        assert_eq!(split("HTTP://x").scheme, "http");
905        // Mismatched IPv6 brackets raise.
906        assert!(urlsplit("//[::1/path", "", true).is_err());
907    }
908
909    #[test]
910    fn urlsplit_bracketed_hosts() {
911        // Valid bracketed authorities: IPv6, IPv6 + scope id, IPv4-mapped, and IPvFuture.
912        for url in [
913            "http://[::1]:8080/p",
914            "http://[fe80::1%eth0]:443/",
915            "http://[::ffff:1.2.3.4]/",
916            "http://[v1.fe80::a+b-c]/",
917        ] {
918            assert!(urlsplit(url, "", true).is_ok(), "should accept {url:?}");
919        }
920        // Malformed bracketed authorities all raise.
921        for url in [
922            "http://[1.2.3.4]/",   // IPv4 cannot be bracketed
923            "http://[notanip]/",   // not an IP
924            "http://[::1]x/",      // data after the bracket
925            "http://x[::1]/",      // data before the bracket
926            "http://[v.x]/",       // IPvFuture without hex
927            "http://[v1]/",        // IPvFuture without a dot
928            "http://[fe80::1%]/",  // empty scope id
929            "http://[fe80::1%a%b]/", // doubled scope delimiter
930        ] {
931            assert!(urlsplit(url, "", true).is_err(), "should reject {url:?}");
932        }
933    }
934
935    #[test]
936    fn urlsplit_nfkc_netloc() {
937        // Non-ASCII authorities that are NFKC-stable, or whose normalization injects no delimiter,
938        // are accepted.
939        for url in ["http://naïve.com/p", "http://ABC/x", "http://üser@höst:80/"] {
940            assert!(urlsplit(url, "", true).is_ok(), "should accept {url:?}");
941        }
942        // Authorities whose NFKC form injects a delimiter are rejected: ℀ -> "a/c", ℅ -> "c/o",
943        // and the fullwidth forms of / @ : normalize to their ASCII delimiters.
944        for url in [
945            "http://exa℀mple/",
946            "http://h℅o/",
947            "http://exa/mple/",
948            "http://user@ho@st/",
949            "http://h:p/",
950        ] {
951            assert!(urlsplit(url, "", true).is_err(), "should reject {url:?}");
952        }
953    }
954
955    #[test]
956    fn urlsplit_roundtrip() {
957        for url in [
958            "http://a.com/b?c#d",
959            "https://user:pass@host:8080/p",
960            "mailto:a@b.com",
961            "//host/path",
962            "path",
963            "?query",
964            "#frag",
965        ] {
966            let r = split(url);
967            assert_eq!(r.geturl(), url, "roundtrip failed for {url:?}");
968        }
969    }
970
971    #[test]
972    fn netloc_accessors() {
973        let r = split("http://user:pw@Example.COM:8080/p");
974        assert_eq!(r.username(), Some("user"));
975        assert_eq!(r.password(), Some("pw"));
976        assert_eq!(r.hostname().as_deref(), Some("example.com"));
977        assert_eq!(r.port(), Ok(Some(8080)));
978
979        let r = split("http://host/p");
980        assert_eq!(r.username(), None);
981        assert_eq!(r.password(), None);
982        assert_eq!(r.port(), Ok(None));
983
984        // IPv6 with zone id: only the host is lowercased.
985        let r = split("http://[FE80::1%tESt]:1234/");
986        assert_eq!(r.hostname().as_deref(), Some("fe80::1%tESt"));
987        assert_eq!(r.port(), Ok(Some(1234)));
988
989        assert!(split("http://host:notaport/").port().is_err());
990        assert!(split("http://host:99999/").port().is_err());
991    }
992
993    #[test]
994    fn urlparse_params() {
995        // http is in uses_params: the ;params split off the last path segment.
996        let r = urlparse("http://h/a/b;type=d?q=1#f", "", true).unwrap();
997        assert_eq!(r.path, "/a/b");
998        assert_eq!(r.params, "type=d");
999        assert_eq!(r.query, "q=1");
1000        assert_eq!(r.fragment, "f");
1001
1002        // A ; earlier in the path (not the last segment) is not a params separator.
1003        let r = urlparse("http://h/a;x/b", "", true).unwrap();
1004        assert_eq!(r.path, "/a;x/b");
1005        assert_eq!(r.params, "");
1006
1007        // A scheme not in uses_params keeps the ; in the path.
1008        let r = urlparse("git://h/a;b", "", true).unwrap();
1009        assert_eq!(r.path, "/a;b");
1010        assert_eq!(r.params, "");
1011    }
1012
1013    #[test]
1014    fn urlparse_roundtrip() {
1015        for url in [
1016            "http://h/a/b;type=d?q=1#f",
1017            "https://user:pass@host:8080/p;k=v?x#y",
1018            "mailto:a@b.com",
1019            "scheme:opaque",
1020            "//host/path",
1021        ] {
1022            let r = urlparse(url, "", true).unwrap();
1023            assert_eq!(r.geturl(), url, "roundtrip failed for {url:?}");
1024        }
1025    }
1026
1027    fn join(base: &str, rel: &str) -> String {
1028        urljoin(base, rel, true).unwrap()
1029    }
1030
1031    #[test]
1032    fn urljoin_rfc3986() {
1033        let base = "http://a/b/c/d;p?q";
1034        assert_eq!(join(base, "g"), "http://a/b/c/g");
1035        assert_eq!(join(base, "./g"), "http://a/b/c/g");
1036        assert_eq!(join(base, "/g"), "http://a/g");
1037        assert_eq!(join(base, "//g"), "http://g");
1038        assert_eq!(join(base, "?y"), "http://a/b/c/d;p?y");
1039        assert_eq!(join(base, "#s"), "http://a/b/c/d;p?q#s");
1040        assert_eq!(join(base, ".."), "http://a/b/");
1041        assert_eq!(join(base, "../g"), "http://a/b/g");
1042        assert_eq!(join(base, "../../g"), "http://a/g");
1043        // urllib's quirk: same-scheme opaque is resolved relatively, not kept as "http:g".
1044        assert_eq!(join(base, "http:g"), "http://a/b/c/g");
1045        // Empty pieces.
1046        assert_eq!(join(base, ""), base);
1047        assert_eq!(join("", "g"), "g");
1048    }
1049
1050    #[test]
1051    fn urldefrag_basics() {
1052        let r = urldefrag("http://h/p?q#frag").unwrap();
1053        assert_eq!(r.url, "http://h/p?q");
1054        assert_eq!(r.fragment, "frag");
1055        assert_eq!(r.geturl(), "http://h/p?q#frag");
1056
1057        let r = urldefrag("http://h/p?q").unwrap();
1058        assert_eq!(r.url, "http://h/p?q");
1059        assert_eq!(r.fragment, "");
1060    }
1061
1062    #[test]
1063    fn parse_qsl_basics() {
1064        assert_eq!(
1065            parse_qsl("a=1&b=2&a=3", false, false, None, "&").unwrap(),
1066            vec![
1067                ("a".to_string(), "1".to_string()),
1068                ("b".to_string(), "2".to_string()),
1069                ("a".to_string(), "3".to_string()),
1070            ]
1071        );
1072        // Blank values dropped unless kept.
1073        assert_eq!(parse_qsl("a=&b=2", false, false, None, "&").unwrap().len(), 1);
1074        assert_eq!(parse_qsl("a=&b=2", true, false, None, "&").unwrap().len(), 2);
1075        // Percent and plus decoding.
1076        assert_eq!(
1077            parse_qsl("k=%41+B", false, false, None, "&").unwrap(),
1078            vec![("k".to_string(), "A B".to_string())]
1079        );
1080        // Strict parsing errors on a field with no '='.
1081        assert!(parse_qsl("a=1&b", false, true, None, "&").is_err());
1082        // max_num_fields cap.
1083        assert!(parse_qsl("a=1&b=2&c=3", false, false, Some(2), "&").is_err());
1084    }
1085
1086    #[test]
1087    fn parse_qs_groups() {
1088        assert_eq!(
1089            parse_qs("a=1&b=2&a=3", false, false, None, "&").unwrap(),
1090            vec![
1091                ("a".to_string(), vec!["1".to_string(), "3".to_string()]),
1092                ("b".to_string(), vec!["2".to_string()]),
1093            ]
1094        );
1095    }
1096
1097    #[test]
1098    fn urlencode_basics() {
1099        assert_eq!(urlencode(&[("a", "1"), ("b", "2")], ""), "a=1&b=2");
1100        assert_eq!(urlencode(&[("name", "El Niño")], ""), "name=El+Ni%C3%B1o");
1101        assert_eq!(urlencode(&[], ""), "");
1102        assert_eq!(
1103            urlencode_doseq(&[("a", &["1", "2", "3"][..])], ""),
1104            "a=1&a=2&a=3"
1105        );
1106    }
1107}