dash_runtime/mpd/base_url.rs
1//! BaseURL and URL-reference resolution (ISO/IEC 23009-1 §5.6.5 over RFC 3986
2//! §5, as implemented by the `url` crate — SP3). `std` only.
3//!
4//! Moved here from `transmux` (#2355): the only caller was the MPD reader's
5//! BaseURL alternates, and `transmux` now writes MPDs rather than fetching
6//! them. The synthetic root keeps its historical `transmux-relative:///`
7//! scheme, which never leaves a result.
8//!
9//! A DASH client turns a relative `SegmentTemplate`/`BaseURL` reference into the
10//! URL it fetches by joining a `BaseURL` chain onto the MPD's own URL. With no
11//! location (an in-memory MPD) the join happens against a fixed synthetic
12//! root, `transmux-relative:///`, and [`render`] — the ONE place that knows
13//! about it — turns the result back into a reference.
14//!
15//! # Containment with no base
16//!
17//! A relative input never resolves to anything but a relative result: RFC 3986
18//! §5.2.4 `remove_dot_segments` drops a `..` that would climb above the root,
19//! so ../../../etc/passwd is `etc/passwd`, never `/etc/passwd` (a consumer
20//! that does `local_dir.join(resolved)` must not see an absolute path from an
21//! untrusted MPD). A result is absolute-path (`/x`) only when the MPD itself
22//! wrote an absolute-path reference (`/x`) in the chain or as the reference.
23//! Percent-encoded dot segments (`%2e%2e`) are dot segments, as in WHATWG.
24//! An input using the synthetic scheme itself is rejected (`None`).
25//!
26//! The `url` crate implements the WHATWG URL algorithm; it differs from strict
27//! RFC 3986 in two §5.4 rows (see `tests/it/base_url.rs`).
28
29use url::Url;
30
31use broadcast_common::url_ref;
32
33/// Scheme of the synthetic root (never leaves this module's results).
34const SYNTHETIC_SCHEME: &str = "transmux-relative";
35/// The synthetic root a base-less resolution joins against.
36const SYNTHETIC_BASE: &str = "transmux-relative:///";
37
38/// The first control character or whitespace in `s`, if any.
39///
40/// RFC 3986 §2 restricts a URI to a small US-ASCII subset, and a raw CR, LF, tab
41/// or space has no meaning in one. The WHATWG parser behind [`resolve`]
42/// silently DELETES tab, CR and LF instead of rejecting them (so
43/// `Url::join("a\r\nHost: x")` would yield a clean-looking `aHost:%20x`), which
44/// is why this check runs on the raw text BEFORE any join and cannot be left to
45/// the parser. Accepting such characters would let a reference smuggle a second
46/// request line or header into anything that later assembles an HTTP request
47/// from the resolved URL (CRLF injection).
48pub fn first_forbidden_char(s: &str) -> Option<char> {
49 url_ref::first_forbidden_char(s).or_else(|| s.chars().find(|c| c.is_whitespace()))
50}
51
52/// Resolve `reference` against `base` (RFC 3986 §5.2 as the `url` crate
53/// implements it). `base` is the MPD's own URL (a source URL, or
54/// `Url::from_file_path`); `None` for an in-memory MPD, in which case a result
55/// that stays relative is returned relative. `None` is returned when the
56/// reference contains a control character or whitespace
57/// ([`first_forbidden_char`]) or does not parse.
58pub fn resolve(base: Option<&Url>, reference: &str) -> Option<String> {
59 resolve_chain(base, &[], reference)
60}
61
62/// Resolve a `BaseURL` chain (outermost first) followed by a segment
63/// reference. Each entry is joined onto the running result, so a relative
64/// entry composes with the ones above it and an absolute one resets the chain.
65/// Empty and whitespace-only entries are skipped — ISO/IEC 23009-1 §5.6.5 makes
66/// several `BaseURL`s alternates for the same level, so an empty one
67/// contributes nothing. `None` for a control character or whitespace in any
68/// entry or the reference ([`first_forbidden_char`]), for an input that names
69/// the synthetic scheme, or for a join error. With `base == None` a relative
70/// outcome stays relative and contained (see the module docs).
71pub fn resolve_chain(base: Option<&Url>, chain: &[String], reference: &str) -> Option<String> {
72 // `BaseURL` element text routinely carries surrounding whitespace/newlines
73 // from the XML layout, so entries are trimmed (and blank ones dropped)
74 // BEFORE the forbidden-character guard; an interior control character or
75 // space still rejects the whole resolution. The reference is checked as is.
76 let entries: Vec<&str> = chain
77 .iter()
78 .map(|b| b.trim())
79 .filter(|b| !b.is_empty())
80 .collect();
81 if first_forbidden_char(reference).is_some()
82 || entries.iter().any(|b| first_forbidden_char(b).is_some())
83 || entries
84 .iter()
85 .chain(core::iter::once(&reference))
86 .any(|r| names_synthetic_scheme(r))
87 {
88 return None;
89 }
90 let mut current = match base {
91 Some(b) => b.clone(),
92 None => Url::parse(SYNTHETIC_BASE).ok()?,
93 };
94 // Set once the MPD itself wrote an absolute-path reference (`/x`).
95 let mut absolute_path = false;
96 for entry in entries.iter().copied().chain(core::iter::once(reference)) {
97 if is_absolute_path_reference(entry) {
98 absolute_path = true;
99 }
100 current = url_ref::resolve(¤t, entry).ok()?;
101 }
102 Some(render(¤t, absolute_path))
103}
104
105/// `true` for `/x` (but not `//host/x`): an absolute-path reference.
106fn is_absolute_path_reference(r: &str) -> bool {
107 r.starts_with('/') && !r.starts_with("//")
108}
109
110/// `true` when `r` starts with the synthetic scheme (`transmux-relative:`),
111/// case-insensitively.
112fn names_synthetic_scheme(r: &str) -> bool {
113 r.len() > SYNTHETIC_SCHEME.len()
114 && r.as_bytes()[..SYNTHETIC_SCHEME.len()].eq_ignore_ascii_case(SYNTHETIC_SCHEME.as_bytes())
115 && r.as_bytes()[SYNTHETIC_SCHEME.len()] == b':'
116}
117
118/// The single place the synthetic root is stripped. A URL that is not under it
119/// is its own text. A synthetic URL with an authority (`//host/x`) keeps its
120/// `//host/x` form. Otherwise the path is returned as a reference: relative
121/// (the leading `/` of the synthetic root removed, so nothing can climb out of
122/// it) unless `absolute_path` says the MPD wrote an absolute-path reference.
123pub fn render(url: &Url, absolute_path: bool) -> String {
124 let text = url.as_str();
125 let Some(rest) = text
126 .strip_prefix(SYNTHETIC_SCHEME)
127 .and_then(|r| r.strip_prefix(':'))
128 else {
129 return text.to_owned();
130 };
131 // `rest` is `//<authority><path>[?query][#fragment]`.
132 let Some(after_slashes) = rest.strip_prefix("//") else {
133 return rest.to_owned();
134 };
135 if !after_slashes.starts_with('/') {
136 return rest.to_owned(); // `//host/...`: a scheme-relative reference
137 }
138 if absolute_path {
139 after_slashes.to_owned()
140 } else {
141 after_slashes[1..].to_owned()
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn synthetic_base_parses() {
151 assert!(Url::parse(SYNTHETIC_BASE).is_ok());
152 }
153
154 #[test]
155 fn render_cases() {
156 let r = |s: &str, abs: bool| render(&Url::parse(s).unwrap(), abs);
157 assert_eq!(
158 r("transmux-relative:///a/b.m4s?q=1#f", false),
159 "a/b.m4s?q=1#f"
160 );
161 assert_eq!(r("transmux-relative:///x/y", true), "/x/y");
162 assert_eq!(r("transmux-relative://h/x", false), "//h/x");
163 assert_eq!(r("https://h/a/b", false), "https://h/a/b");
164 }
165}