Skip to main content

domtree/
url.rs

1//! Zero-dependency URL reference resolution (RFC 3986 §5.2) and `srcset`
2//! parsing — enough to turn the relative links of a page into absolute ones
3//! without pulling in a URL crate.
4//!
5//! This is *reference resolution*, not a URL parser: it does not validate,
6//! percent-encode or normalize hosts. Like a browser, it ignores leading and
7//! trailing whitespace (and control characters), drops tabs and line breaks
8//! inside a URL, and reads `\` as `/` in `http`, `https`, `ftp`, `ws`, `wss`
9//! and `file` URLs.
10
11use alloc::string::String;
12use alloc::vec::Vec;
13
14/// Resolve `reference` against `base`, following RFC 3986 §5.2 (the algorithm
15/// browsers use for `href` and `src`).
16///
17/// Leading and trailing whitespace is ignored and tabs and line breaks inside
18/// either URL are dropped, as in a browser. If `base` has no scheme (it is
19/// itself relative, or empty) there is nothing to anchor to, and the cleaned-up
20/// `reference` is returned unchanged. An absolute `reference` (one with a
21/// scheme, such as `mailto:` or `javascript:`) is returned as is, apart from
22/// removing `.`/`..` path segments.
23///
24/// In the *special* schemes `http`, `https`, `ftp`, `ws`, `wss` and `file`,
25/// browsers read a backslash as a slash, so `\\evil.example\x` or
26/// `/\evil.example/x` on an `https` page leads to another host. Likewise,
27/// every `\` before the query (`?`) or fragment (`#`) of such a URL — the
28/// reference when its own scheme is special, or a relative reference against
29/// a special-scheme base — becomes `/` here, even when `base` is schemeless.
30///
31/// ```
32/// use domtree::resolve_url;
33///
34/// let base = "https://example.com/blog/post.html?x=1";
35/// assert_eq!(resolve_url(base, "../img/a.png"), "https://example.com/img/a.png");
36/// assert_eq!(resolve_url(base, "/about"), "https://example.com/about");
37/// assert_eq!(resolve_url(base, "#top"), "https://example.com/blog/post.html?x=1#top");
38/// assert_eq!(resolve_url(base, "//cdn.example.com/x.js"), "https://cdn.example.com/x.js");
39/// assert_eq!(resolve_url(base, " mailto:hi@example.com "), "mailto:hi@example.com");
40/// // A relative base cannot anchor anything:
41/// assert_eq!(resolve_url("/docs/", "a.html"), "a.html");
42/// // `\` is `/` in http(s) URLs, as in a browser:
43/// assert_eq!(resolve_url(base, "/\\evil.example/x"), "https://evil.example/x");
44/// ```
45pub fn resolve_url(base: &str, reference: &str) -> String {
46    let base = slashes(clean(base), None);
47    let base_scheme = scheme(&base);
48    let reference = slashes(clean(reference), base_scheme);
49    let b = Parts::split(&base);
50    if b.scheme.is_none() {
51        return reference;
52    }
53    let r = Parts::split(&reference);
54
55    let mut t = Parts {
56        scheme: b.scheme,
57        authority: b.authority,
58        path: String::new(),
59        query: r.query,
60        fragment: r.fragment,
61    };
62    if r.scheme.is_some() {
63        t.scheme = r.scheme;
64        t.authority = r.authority;
65        t.path = remove_dot_segments(&r.path);
66    } else if r.authority.is_some() {
67        t.authority = r.authority;
68        t.path = remove_dot_segments(&r.path);
69    } else if r.path.is_empty() {
70        t.path = b.path.clone();
71        t.query = r.query.or(b.query);
72    } else if r.path.starts_with('/') {
73        t.path = remove_dot_segments(&r.path);
74    } else {
75        let mut merged = String::new();
76        if b.authority.is_some() && b.path.is_empty() {
77            merged.push('/');
78        } else if let Some(dir) = b.path.rfind('/').and_then(|i| b.path.get(..=i)) {
79            merged.push_str(dir);
80        }
81        merged.push_str(&r.path);
82        t.path = remove_dot_segments(&merged);
83    }
84    t.join()
85}
86
87/// Resolve every URL in a `srcset` attribute value (`"a.png 1x, b.png 2x"`)
88/// against `base` with [`resolve_url`], keeping each candidate's descriptor.
89///
90/// Candidates are split as the HTML standard's `srcset` parser does: a URL is
91/// a run of non-whitespace (so URLs containing commas survive), trailing commas
92/// end a candidate, descriptors run to the next comma outside parentheses, and
93/// empty candidates are dropped. The result is normalized to
94/// `"url descriptor, url descriptor"`.
95///
96/// ```
97/// use domtree::resolve_srcset;
98///
99/// assert_eq!(
100///     resolve_srcset("https://x.dev/p/", "a.png 1x, /b.png 2x"),
101///     "https://x.dev/p/a.png 1x, https://x.dev/b.png 2x",
102/// );
103/// // Commas inside URLs are not separators:
104/// assert_eq!(
105///     resolve_srcset("https://x.dev/", "/c_fill,w_300/a.jpg 300w,/c_fill,w_600/a.jpg 600w"),
106///     "https://x.dev/c_fill,w_300/a.jpg 300w, https://x.dev/c_fill,w_600/a.jpg 600w",
107/// );
108/// ```
109pub fn resolve_srcset(base: &str, srcset: &str) -> String {
110    let mut out = String::new();
111    for (url, descriptor) in srcset_candidates(srcset) {
112        if !out.is_empty() {
113            out.push_str(", ");
114        }
115        out.push_str(&resolve_url(base, url));
116        if !descriptor.is_empty() {
117            out.push(' ');
118            out.push_str(descriptor);
119        }
120    }
121    out
122}
123
124/// The `(url, descriptor)` candidates of a `srcset` value, per the HTML
125/// standard's parsing algorithm (see [`resolve_srcset`]). The descriptor is
126/// trimmed and may be empty.
127pub(crate) fn srcset_candidates(srcset: &str) -> Srcset<'_> {
128    Srcset { rest: srcset }
129}
130
131/// Iterator over `srcset` candidates; see [`srcset_candidates`].
132pub(crate) struct Srcset<'a> {
133    rest: &'a str,
134}
135
136impl<'a> Iterator for Srcset<'a> {
137    type Item = (&'a str, &'a str);
138
139    fn next(&mut self) -> Option<(&'a str, &'a str)> {
140        let rest = self
141            .rest
142            .trim_start_matches(|c: char| is_ascii_space(c) || c == ',');
143        if rest.is_empty() {
144            self.rest = "";
145            return None;
146        }
147        // Non-empty and not starting with whitespace or a comma, so the URL is
148        // at least one character long: every call makes progress.
149        let end = rest.find(is_ascii_space).unwrap_or(rest.len());
150        let (url, after) = rest.split_at_checked(end).unwrap_or((rest, ""));
151        if url.ends_with(',') {
152            self.rest = after;
153            return Some((url.trim_end_matches(','), ""));
154        }
155        let mut depth = 0u32;
156        let mut stop = after.len();
157        for (i, c) in after.char_indices() {
158            match c {
159                '(' => depth = depth.saturating_add(1),
160                ')' => depth = depth.saturating_sub(1),
161                ',' if depth == 0 => {
162                    stop = i;
163                    break;
164                }
165                _ => {}
166            }
167        }
168        let (descriptor, tail) = after.split_at_checked(stop).unwrap_or((after, ""));
169        self.rest = tail.strip_prefix(',').unwrap_or(tail);
170        Some((url, descriptor.trim_matches(is_ascii_space)))
171    }
172}
173
174fn is_ascii_space(c: char) -> bool {
175    matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0C')
176}
177
178/// Whether a URL is a `javascript:` URL (scheme compared ASCII
179/// case-insensitively, after the clean-up browsers apply).
180pub(crate) fn is_javascript(url: &str) -> bool {
181    let url = clean(url);
182    url.get(..11)
183        .is_some_and(|s| s.eq_ignore_ascii_case("javascript:"))
184}
185
186/// Strip leading/trailing C0 controls and spaces, and remove ASCII tabs and
187/// newlines anywhere (the WHATWG URL parser's pre-processing).
188pub(crate) fn clean(url: &str) -> String {
189    url.trim_matches(|c: char| c <= ' ')
190        .chars()
191        .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
192        .collect()
193}
194
195/// A URL on its own, cleaned up as [`resolve_url`] does with a reference it
196/// cannot anchor: [`clean`], and `\` read as `/` if its scheme is special.
197pub(crate) fn normalize(url: &str) -> String {
198    slashes(clean(url), None)
199}
200
201/// Where `url`'s query or fragment starts (its length if it has neither).
202fn path_end(url: &str) -> usize {
203    // Byte and char loops, here and in `slashes`, rather than `['?', '#']` and
204    // `str::replace`: those generic instances are shared with code the wasm
205    // bindings use, and another caller changes how they inline there.
206    url.bytes()
207        .position(|b| matches!(b, b'?' | b'#'))
208        .unwrap_or(url.len())
209}
210
211/// The scheme of `url`, if it has one: an RFC 3986 scheme followed by `:`,
212/// ahead of any `?` or `#` (where [`Parts::split`] finds it).
213fn scheme(url: &str) -> Option<&str> {
214    let (scheme, _) = url.get(..path_end(url))?.split_once(':')?;
215    is_scheme(scheme).then_some(scheme)
216}
217
218/// Whether `scheme` is one of the WHATWG URL standard's special schemes, in
219/// which browsers read `\` as `/`.
220fn is_special(scheme: &str) -> bool {
221    ["http", "https", "ftp", "ws", "wss", "file"]
222        .iter()
223        .any(|s| s.eq_ignore_ascii_case(scheme))
224}
225
226/// `url` with every `\` ahead of its query or fragment turned into `/` when
227/// its scheme — or, if it has none, `base_scheme` — is special.
228fn slashes(url: String, base_scheme: Option<&str>) -> String {
229    if !scheme(&url).or(base_scheme).is_some_and(is_special) {
230        return url;
231    }
232    match url.split_at_checked(path_end(&url)) {
233        Some((head, tail)) if head.contains('\\') => {
234            let mut out: String = head
235                .chars()
236                .map(|c| if c == '\\' { '/' } else { c })
237                .collect();
238            out.push_str(tail);
239            out
240        }
241        _ => url,
242    }
243}
244
245struct Parts<'a> {
246    scheme: Option<&'a str>,
247    authority: Option<&'a str>,
248    path: String,
249    query: Option<&'a str>,
250    fragment: Option<&'a str>,
251}
252
253impl<'a> Parts<'a> {
254    fn split(s: &'a str) -> Parts<'a> {
255        let (s, fragment) = match s.split_once('#') {
256            Some((a, f)) => (a, Some(f)),
257            None => (s, None),
258        };
259        let (s, query) = match s.split_once('?') {
260            Some((a, q)) => (a, Some(q)),
261            None => (s, None),
262        };
263        let (scheme, rest) = match s.split_once(':') {
264            Some((scheme, rest)) if is_scheme(scheme) => (Some(scheme), rest),
265            _ => (None, s),
266        };
267        let (authority, path) = match rest.strip_prefix("//") {
268            Some(r) => {
269                let end = r.find('/').unwrap_or(r.len());
270                let (authority, path) = r.split_at_checked(end).unwrap_or((r, ""));
271                (Some(authority), path)
272            }
273            None => (None, rest),
274        };
275        Parts {
276            scheme,
277            authority,
278            path: String::from(path),
279            query,
280            fragment,
281        }
282    }
283
284    fn join(&self) -> String {
285        let mut out = String::new();
286        if let Some(s) = self.scheme {
287            out.push_str(s);
288            out.push(':');
289        }
290        if let Some(a) = self.authority {
291            out.push_str("//");
292            out.push_str(a);
293        }
294        out.push_str(&self.path);
295        if let Some(q) = self.query {
296            out.push('?');
297            out.push_str(q);
298        }
299        if let Some(f) = self.fragment {
300            out.push('#');
301            out.push_str(f);
302        }
303        out
304    }
305}
306
307/// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`
308fn is_scheme(s: &str) -> bool {
309    let mut chars = s.chars();
310    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
311        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
312}
313
314/// RFC 3986 §5.2.4, segment-wise.
315fn remove_dot_segments(path: &str) -> String {
316    let (absolute, body) = match path.strip_prefix('/') {
317        Some(body) => (true, body),
318        None => (false, path),
319    };
320    let mut out: Vec<&str> = Vec::new();
321    let mut segments = body.split('/').peekable();
322    while let Some(seg) = segments.next() {
323        match seg {
324            "." => {}
325            ".." => {
326                out.pop();
327            }
328            s => {
329                out.push(s);
330                continue;
331            }
332        }
333        if segments.peek().is_none() {
334            out.push(""); // keep the trailing slash: "a/b/.." -> "a/"
335        }
336    }
337    let mut joined = String::with_capacity(path.len());
338    if absolute {
339        joined.push('/');
340    }
341    for (i, seg) in out.iter().enumerate() {
342        if i > 0 {
343            joined.push('/');
344        }
345        joined.push_str(seg);
346    }
347    joined
348}