Skip to main content

oxide_browser/
url.rs

1//! WHATWG URL Standard compliant URL parsing for the Oxide browser.
2//!
3//! Wraps the `url` crate (which implements the WHATWG URL spec) and adds
4//! Oxide-specific scheme handling (`oxide://` for internal pages) alongside
5//! standard `http`, `https`, and `file` schemes.
6
7use std::fmt;
8
9use url::Url;
10
11const SUPPORTED_SCHEMES: &[&str] = &["http", "https", "file", "oxide"];
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct OxideUrl {
15    inner: Url,
16}
17
18#[derive(Debug)]
19pub enum UrlError {
20    Parse(String),
21    UnsupportedScheme(String),
22    Empty,
23    RelativeRequiresBase,
24}
25
26impl fmt::Display for UrlError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            UrlError::Parse(msg) => write!(f, "URL parse error: {msg}"),
30            UrlError::UnsupportedScheme(s) => write!(f, "unsupported URL scheme: {s}"),
31            UrlError::Empty => write!(f, "empty URL"),
32            UrlError::RelativeRequiresBase => {
33                write!(f, "relative URL cannot be parsed without a base URL")
34            }
35        }
36    }
37}
38
39impl std::error::Error for UrlError {}
40
41#[allow(dead_code)]
42impl OxideUrl {
43    /// Parse a user-supplied URL string.
44    ///
45    /// Bare hostnames like `example.com/path` are assumed HTTPS.
46    /// Relative paths (starting with `/` or `.`) are rejected — use
47    /// [`OxideUrl::join`] to resolve them against a base URL.
48    pub fn parse(input: &str) -> Result<Self, UrlError> {
49        let trimmed = input.trim();
50        if trimmed.is_empty() {
51            return Err(UrlError::Empty);
52        }
53
54        if (trimmed.starts_with('/') || trimmed.starts_with('.')) && !trimmed.starts_with("//") {
55            return Err(UrlError::RelativeRequiresBase);
56        }
57
58        let normalized = if trimmed.contains("://") || trimmed.starts_with("//") {
59            trimmed.to_string()
60        } else {
61            format!("https://{trimmed}")
62        };
63
64        let inner = Url::parse(&normalized).map_err(|e| UrlError::Parse(e.to_string()))?;
65
66        if !SUPPORTED_SCHEMES.contains(&inner.scheme()) {
67            return Err(UrlError::UnsupportedScheme(inner.scheme().to_string()));
68        }
69
70        Ok(Self { inner })
71    }
72
73    /// Resolve a possibly-relative reference against this URL as the base.
74    pub fn join(&self, reference: &str) -> Result<Self, UrlError> {
75        let inner = self
76            .inner
77            .join(reference)
78            .map_err(|e| UrlError::Parse(e.to_string()))?;
79
80        if !SUPPORTED_SCHEMES.contains(&inner.scheme()) {
81            return Err(UrlError::UnsupportedScheme(inner.scheme().to_string()));
82        }
83
84        Ok(Self { inner })
85    }
86
87    pub fn scheme(&self) -> &str {
88        self.inner.scheme()
89    }
90
91    pub fn host_str(&self) -> Option<&str> {
92        self.inner.host_str()
93    }
94
95    pub fn port(&self) -> Option<u16> {
96        self.inner.port()
97    }
98
99    pub fn path(&self) -> &str {
100        self.inner.path()
101    }
102
103    pub fn query(&self) -> Option<&str> {
104        self.inner.query()
105    }
106
107    pub fn fragment(&self) -> Option<&str> {
108        self.inner.fragment()
109    }
110
111    pub fn as_str(&self) -> &str {
112        self.inner.as_str()
113    }
114
115    /// True for http/https URLs that can be fetched over the network.
116    pub fn is_fetchable(&self) -> bool {
117        matches!(self.scheme(), "http" | "https")
118    }
119
120    /// True for `file://` URLs.
121    pub fn is_local_file(&self) -> bool {
122        self.scheme() == "file"
123    }
124
125    /// True for `oxide://` internal browser pages.
126    pub fn is_internal(&self) -> bool {
127        self.scheme() == "oxide"
128    }
129
130    /// Extract the local filesystem path from a `file://` URL.
131    pub fn to_file_path(&self) -> Option<std::path::PathBuf> {
132        self.inner.to_file_path().ok()
133    }
134
135    pub fn set_fragment(&mut self, fragment: Option<&str>) {
136        self.inner.set_fragment(fragment);
137    }
138
139    pub fn set_query(&mut self, query: Option<&str>) {
140        self.inner.set_query(query);
141    }
142
143    pub fn query_pairs(&self) -> Vec<(String, String)> {
144        self.inner
145            .query_pairs()
146            .map(|(k, v)| (k.to_string(), v.to_string()))
147            .collect()
148    }
149
150    /// Scheme + host + port serialized as a string (for same-origin checks).
151    pub fn origin_str(&self) -> String {
152        match self.inner.origin() {
153            url::Origin::Opaque(_) => self.scheme().to_string(),
154            url::Origin::Tuple(scheme, host, port) => {
155                format!("{scheme}://{host}:{port}")
156            }
157        }
158    }
159
160    /// Check whether two URLs share the same origin.
161    pub fn same_origin(&self, other: &OxideUrl) -> bool {
162        self.inner.origin() == other.inner.origin()
163    }
164
165    /// Stable app identity used to scope permissions and storage.
166    ///
167    /// - `http`/`https`: scheme + host + port (path changes, e.g. via `push_state`, don't
168    ///   change the origin).
169    /// - `file`: the containing directory, so different local apps don't share state while
170    ///   an app and its sibling assets do.
171    /// - Other schemes fall back to [`OxideUrl::origin_str`].
172    pub fn app_origin(&self) -> String {
173        if self.is_local_file() {
174            let path = self.inner.path();
175            let dir = match path.rfind('/') {
176                Some(0) => "/",
177                Some(i) => &path[..i],
178                None => path,
179            };
180            format!("file://{dir}")
181        } else {
182            self.origin_str()
183        }
184    }
185}
186
187/// [`OxideUrl::app_origin`] for a raw URL string; falls back to the input when unparseable.
188pub fn app_origin_of(url: &str) -> String {
189    match OxideUrl::parse(url) {
190        Ok(parsed) => parsed.app_origin(),
191        Err(_) => url.to_string(),
192    }
193}
194
195impl fmt::Display for OxideUrl {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        write!(f, "{}", self.inner)
198    }
199}
200
201/// Percent-encode a string (useful for building URL path/query components).
202pub fn percent_encode(input: &str) -> String {
203    percent_encoding::utf8_percent_encode(input, percent_encoding::NON_ALPHANUMERIC).to_string()
204}
205
206/// Decode a percent-encoded string.
207pub fn percent_decode(input: &str) -> String {
208    percent_encoding::percent_decode_str(input)
209        .decode_utf8_lossy()
210        .to_string()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn parse_https() {
219        let url = OxideUrl::parse("https://example.com/app.wasm").unwrap();
220        assert_eq!(url.scheme(), "https");
221        assert_eq!(url.host_str(), Some("example.com"));
222        assert_eq!(url.path(), "/app.wasm");
223    }
224
225    #[test]
226    fn bare_hostname_becomes_https() {
227        let url = OxideUrl::parse("example.com/app.wasm").unwrap();
228        assert_eq!(url.scheme(), "https");
229        assert_eq!(url.as_str(), "https://example.com/app.wasm");
230    }
231
232    #[test]
233    fn resolve_relative() {
234        let base = OxideUrl::parse("https://example.com/apps/v1/main.wasm").unwrap();
235        let resolved = base.join("../v2/new.wasm").unwrap();
236        assert_eq!(resolved.as_str(), "https://example.com/apps/v2/new.wasm");
237    }
238
239    #[test]
240    fn file_url() {
241        let url = OxideUrl::parse("file:///tmp/app.wasm").unwrap();
242        assert!(url.is_local_file());
243        assert!(!url.is_fetchable());
244    }
245
246    #[test]
247    fn oxide_internal() {
248        let url = OxideUrl::parse("oxide://home").unwrap();
249        assert!(url.is_internal());
250    }
251
252    #[test]
253    fn unsupported_scheme() {
254        assert!(OxideUrl::parse("ftp://example.com").is_err());
255    }
256
257    #[test]
258    fn query_and_fragment() {
259        let url = OxideUrl::parse("https://example.com/app.wasm?v=1#section").unwrap();
260        assert_eq!(url.query(), Some("v=1"));
261        assert_eq!(url.fragment(), Some("section"));
262    }
263
264    #[test]
265    fn percent_encoding_roundtrip() {
266        let original = "hello world";
267        let encoded = percent_encode(original);
268        let decoded = percent_decode(&encoded);
269        assert_eq!(decoded, original);
270    }
271
272    #[test]
273    fn app_origin_https_ignores_path() {
274        let a = OxideUrl::parse("https://example.com/apps/a.wasm").unwrap();
275        let b = OxideUrl::parse("https://example.com/other/b.wasm").unwrap();
276        assert_eq!(a.app_origin(), "https://example.com:443");
277        assert_eq!(a.app_origin(), b.app_origin());
278    }
279
280    #[test]
281    fn app_origin_file_is_containing_directory() {
282        let a = OxideUrl::parse("file:///tmp/apps/a.wasm").unwrap();
283        let b = OxideUrl::parse("file:///tmp/other/b.wasm").unwrap();
284        assert_eq!(a.app_origin(), "file:///tmp/apps");
285        assert_ne!(a.app_origin(), b.app_origin());
286    }
287
288    #[test]
289    fn app_origin_of_falls_back_to_input() {
290        assert_eq!(app_origin_of(""), "");
291        assert_eq!(app_origin_of("./relative.wasm"), "./relative.wasm");
292    }
293
294    #[test]
295    fn relative_path_rejected_without_base() {
296        assert!(matches!(
297            OxideUrl::parse("../other.wasm"),
298            Err(UrlError::RelativeRequiresBase)
299        ));
300    }
301}