1use core::fmt;
45
46#[derive(Clone, Debug, PartialEq, Eq, Hash)]
66pub struct WebUrl(String);
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
70#[non_exhaustive]
71pub enum WebUrlError {
72 #[error("{found:?} at byte {at} must be percent-encoded in a URL")]
76 Character {
77 found: char,
79 at: usize,
81 },
82
83 #[error("the % at byte {at} does not start a percent escape")]
85 Escape {
86 at: usize,
88 },
89
90 #[error("only http and https URLs can be opened")]
92 Scheme,
93
94 #[error("the URL has no host, or a malformed one")]
96 Host,
97
98 #[error("a URL with a user name or password before the host is refused")]
100 UserInfo,
101
102 #[error("the URL's port is not a number from 0 to 65535")]
104 Port,
105}
106
107impl WebUrl {
108 pub fn parse(text: &str) -> Result<Self, WebUrlError> {
115 check_characters(text)?;
116 let (scheme, rest) = text.split_once(':').ok_or(WebUrlError::Scheme)?;
117 if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
118 return Err(WebUrlError::Scheme);
119 }
120 let rest = rest.strip_prefix("//").ok_or(WebUrlError::Host)?;
121 let (authority, tail) = rest.split_at(rest.find(['/', '?', '#']).unwrap_or(rest.len()));
122 check_authority(authority)?;
123 let tail_start = text.len() - tail.len();
124 if let Some((offset, found)) = tail.char_indices().find(|&(_, c)| c == '[' || c == ']') {
125 return Err(WebUrlError::Character {
126 found,
127 at: tail_start + offset,
128 });
129 }
130 Ok(Self(text.to_owned()))
131 }
132
133 #[must_use]
135 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138}
139
140impl fmt::Display for WebUrl {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 f.write_str(&self.0)
143 }
144}
145
146fn check_characters(text: &str) -> Result<(), WebUrlError> {
149 let bytes = text.as_bytes();
150 for (at, found) in text.char_indices() {
151 match found {
152 '%' => {
153 let escape = bytes.get(at + 1..at + 3);
154 if !escape.is_some_and(|digits| digits.iter().all(u8::is_ascii_hexdigit)) {
155 return Err(WebUrlError::Escape { at });
156 }
157 }
158 'A'..='Z' | 'a'..='z' | '0'..='9' => {}
159 '-' | '.' | '_' | '~' => {}
161 ':' | '/' | '?' | '#' | '[' | ']' | '@' => {}
162 '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' => {}
163 _ => return Err(WebUrlError::Character { found, at }),
164 }
165 }
166 Ok(())
167}
168
169fn check_authority(authority: &str) -> Result<(), WebUrlError> {
173 if authority.contains('@') {
174 return Err(WebUrlError::UserInfo);
175 }
176 let port = if let Some(literal) = authority.strip_prefix('[') {
177 let (address, after) = literal.split_once(']').ok_or(WebUrlError::Host)?;
178 let is_ipv6 = address.contains(':')
179 && address
180 .bytes()
181 .all(|byte| byte.is_ascii_hexdigit() || byte == b':' || byte == b'.');
182 if !is_ipv6 {
183 return Err(WebUrlError::Host);
184 }
185 if after.is_empty() {
186 None
187 } else {
188 Some(after.strip_prefix(':').ok_or(WebUrlError::Host)?)
189 }
190 } else {
191 if authority.contains(['[', ']']) {
192 return Err(WebUrlError::Host);
193 }
194 let (host, port) = authority
195 .split_once(':')
196 .map_or((authority, None), |(host, port)| (host, Some(port)));
197 if host.is_empty() {
198 return Err(WebUrlError::Host);
199 }
200 port
201 };
202 match port {
203 None => Ok(()),
204 Some(port)
205 if (1..=5).contains(&port.len())
206 && port.bytes().all(|byte| byte.is_ascii_digit())
207 && port.parse::<u32>().is_ok_and(|number| number <= 65535) =>
208 {
209 Ok(())
210 }
211 Some(_) => Err(WebUrlError::Port),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn refused(text: &str) -> WebUrlError {
220 WebUrl::parse(text).expect_err(text)
221 }
222
223 #[test]
224 fn web_urls_are_accepted_verbatim() {
225 for text in [
226 "http://example.com",
227 "https://example.com/",
228 "https://store.example/app/1234?ref=menu&x=1#reviews",
229 "HTTPS://EXAMPLE.COM/PATH",
230 "HtTp://example.com",
231 "https://example.com:443/",
232 "http://example.com:0",
233 "https://example.com:65535/x",
234 "https://127.0.0.1:8080/",
235 "https://[::1]/",
236 "https://[2001:db8::7]:8443/a?b#c",
237 "https://[::ffff:192.0.2.1]",
238 "https://example.com/a%20b/%C3%A9?q=%2F",
239 "https://example.com?query",
240 "https://example.com#fragment",
241 "https://example.com/!$&'()*+,;=:@-._~",
242 "https://xn--bcher-kva.example/",
243 ] {
244 let url = WebUrl::parse(text).unwrap_or_else(|error| panic!("{text}: {error}"));
245 assert_eq!(url.as_str(), text, "kept as given");
246 assert_eq!(url.to_string(), text);
247 }
248 }
249
250 #[test]
251 fn other_schemes_are_refused() {
252 for text in [
253 "file:///C:/Windows/System32/calc.exe",
254 "FILE://server/share/x.lnk",
255 "javascript:alert(1)",
256 "ms-msdt:/id",
257 "search-ms:query=x",
258 "shell:startup",
259 "about:blank",
260 "ftp://example.com/",
261 "httpx://example.com",
262 "https+x://example.com",
263 "data:text/html,x",
264 "mailto:someone@example.com",
265 "C:/game/patch.exe",
266 "://example.com",
267 "",
268 "example.com",
269 "/usr/bin/xterm",
270 "relative/path",
271 ] {
272 assert_eq!(refused(text), WebUrlError::Scheme, "{text}");
273 }
274 }
275
276 #[test]
277 fn a_web_scheme_needs_a_host() {
278 for text in [
279 "https:example.com",
280 "https:/example.com",
281 "https:",
282 "https://",
283 "https:///path",
284 "http://?q",
285 "http://#f",
286 "https://:443/",
287 ] {
288 assert_eq!(refused(text), WebUrlError::Host, "{text}");
289 }
290 }
291
292 #[test]
293 fn characters_a_url_cannot_carry_are_refused_where_they_are() {
294 for (text, found, at) in [
295 (" https://example.com", ' ', 0),
296 ("https://example.com ", ' ', 19),
297 ("https://example.com\n", '\n', 19),
298 ("\thttps://example.com", '\t', 0),
299 ("https://exa\0mple.com", '\0', 11),
300 ("https://example.com/\0.exe", '\0', 20),
301 ("https://example.com/a b", ' ', 21),
302 ("https://example.com/\"--gpu-launcher", '"', 20),
303 (r"https://good.example\@evil.example", '\\', 20),
304 (r"C:\x.exe", '\\', 2),
305 (r"\\server\share\payload.exe", '\\', 0),
306 ("https://exämple.com", 'ä', 10),
307 ("https://example.com/\u{202e}exe.txt", '\u{202e}', 20),
308 ("https://example.com/<script>", '<', 20),
309 ("https://example.com/a|b", '|', 21),
310 ("https://example.com/{x}", '{', 20),
311 ("https://example.com/`x`", '`', 20),
312 ("https://example.com/a^b", '^', 21),
313 ("https://example.com/[x]", '[', 20),
314 ("https://example.com/?q=]", ']', 23),
315 ("https://example.com#[", '[', 20),
316 ] {
317 assert_eq!(
318 refused(text),
319 WebUrlError::Character { found, at },
320 "{text:?}"
321 );
322 }
323 }
324
325 #[test]
326 fn a_percent_must_begin_an_escape() {
327 for (text, at) in [
328 ("https://example.com/%zz", 20),
329 ("https://example.com/%4", 20),
330 ("https://example.com/%", 20),
331 ("https://example.com/%%41", 20),
332 ("https://ex%2", 10),
333 ] {
334 assert_eq!(refused(text), WebUrlError::Escape { at }, "{text}");
335 }
336 }
337
338 #[test]
339 fn user_information_is_refused() {
340 for text in [
341 "https://user@example.com/",
342 "https://user:password@example.com/",
343 "https://bank.example@evil.example/",
344 "https://@example.com",
345 ] {
346 assert_eq!(refused(text), WebUrlError::UserInfo, "{text}");
347 }
348 }
349
350 #[test]
351 fn a_port_is_a_number_a_port_can_be() {
352 for text in [
353 "https://example.com:/",
354 "https://example.com:65536",
355 "https://example.com:99999",
356 "https://example.com:000080",
357 "https://example.com:8a",
358 "https://example.com:+80",
359 "https://example.com:80:80",
360 "https://[::1]:",
361 "https://[::1]:x",
362 ] {
363 assert_eq!(refused(text), WebUrlError::Port, "{text}");
364 }
365 }
366
367 #[test]
368 fn brackets_are_only_an_ipv6_literal() {
369 for text in [
370 "https://[::1",
371 "https://[]/",
372 "https://[example.com]/",
373 "https://[v1.x]/",
374 "https://[127.0.0.1]/",
375 "https://[::1]x/",
376 "https://a]b/",
377 "https://a[b/",
378 "https://example.com:[80]",
379 ] {
380 assert_eq!(refused(text), WebUrlError::Host, "{text}");
381 }
382 }
383
384 #[test]
385 fn errors_say_what_is_wrong() {
386 assert_eq!(
387 refused("https://example.com/a b").to_string(),
388 "' ' at byte 21 must be percent-encoded in a URL"
389 );
390 assert_eq!(
391 refused("file:///x").to_string(),
392 "only http and https URLs can be opened"
393 );
394 }
395}