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
166impl fmt::Display for OxideUrl {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        write!(f, "{}", self.inner)
169    }
170}
171
172/// Percent-encode a string (useful for building URL path/query components).
173pub fn percent_encode(input: &str) -> String {
174    percent_encoding::utf8_percent_encode(input, percent_encoding::NON_ALPHANUMERIC).to_string()
175}
176
177/// Decode a percent-encoded string.
178pub fn percent_decode(input: &str) -> String {
179    percent_encoding::percent_decode_str(input)
180        .decode_utf8_lossy()
181        .to_string()
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn parse_https() {
190        let url = OxideUrl::parse("https://example.com/app.wasm").unwrap();
191        assert_eq!(url.scheme(), "https");
192        assert_eq!(url.host_str(), Some("example.com"));
193        assert_eq!(url.path(), "/app.wasm");
194    }
195
196    #[test]
197    fn bare_hostname_becomes_https() {
198        let url = OxideUrl::parse("example.com/app.wasm").unwrap();
199        assert_eq!(url.scheme(), "https");
200        assert_eq!(url.as_str(), "https://example.com/app.wasm");
201    }
202
203    #[test]
204    fn resolve_relative() {
205        let base = OxideUrl::parse("https://example.com/apps/v1/main.wasm").unwrap();
206        let resolved = base.join("../v2/new.wasm").unwrap();
207        assert_eq!(resolved.as_str(), "https://example.com/apps/v2/new.wasm");
208    }
209
210    #[test]
211    fn file_url() {
212        let url = OxideUrl::parse("file:///tmp/app.wasm").unwrap();
213        assert!(url.is_local_file());
214        assert!(!url.is_fetchable());
215    }
216
217    #[test]
218    fn oxide_internal() {
219        let url = OxideUrl::parse("oxide://home").unwrap();
220        assert!(url.is_internal());
221    }
222
223    #[test]
224    fn unsupported_scheme() {
225        assert!(OxideUrl::parse("ftp://example.com").is_err());
226    }
227
228    #[test]
229    fn query_and_fragment() {
230        let url = OxideUrl::parse("https://example.com/app.wasm?v=1#section").unwrap();
231        assert_eq!(url.query(), Some("v=1"));
232        assert_eq!(url.fragment(), Some("section"));
233    }
234
235    #[test]
236    fn percent_encoding_roundtrip() {
237        let original = "hello world";
238        let encoded = percent_encode(original);
239        let decoded = percent_decode(&encoded);
240        assert_eq!(decoded, original);
241    }
242
243    #[test]
244    fn relative_path_rejected_without_base() {
245        assert!(matches!(
246            OxideUrl::parse("../other.wasm"),
247            Err(UrlError::RelativeRequiresBase)
248        ));
249    }
250}