1use std::borrow::Cow;
11use std::fmt;
12
13#[non_exhaustive]
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum UrlError {
27 NotAbsolute,
29 BadEscape,
31 EncodedSlash,
33 Backslash,
35 Control,
37 NotUtf8,
39 TooManySegments,
41}
42
43impl UrlError {
44 #[must_use]
53 pub const fn code(self) -> &'static str {
54 match self {
55 Self::NotAbsolute => "url.not_absolute",
56 Self::BadEscape => "url.bad_escape",
57 Self::EncodedSlash => "url.encoded_slash",
58 Self::Backslash => "url.backslash",
59 Self::Control => "url.control",
60 Self::NotUtf8 => "url.not_utf8",
61 Self::TooManySegments => "url.too_many_segments",
62 }
63 }
64}
65
66impl fmt::Display for UrlError {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 let what = match self {
69 Self::NotAbsolute => "the path does not start with a slash",
70 Self::BadEscape => "a percent sign is not followed by two hex digits",
71 Self::EncodedSlash => "the path holds an encoded slash",
72 Self::Backslash => "the path holds an encoded backslash",
73 Self::Control => "the path decodes to a control character",
74 Self::NotUtf8 => "the path does not decode to UTF-8",
75 Self::TooManySegments => "the path has too many segments",
76 };
77 f.write_str(what)
78 }
79}
80
81impl std::error::Error for UrlError {}
82
83#[non_exhaustive]
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub enum TrailingSlash {
102 #[default]
105 Strip,
106 Add,
109 Ignore,
111}
112
113pub(crate) const MAX_SEGMENTS: usize = 256;
115
116#[inline]
117const fn hex(b: u8) -> Option<u8> {
118 match b {
119 b'0'..=b'9' => Some(b - b'0'),
120 b'a'..=b'f' => Some(b - b'a' + 10),
121 b'A'..=b'F' => Some(b - b'A' + 10),
122 _ => None,
123 }
124}
125
126#[inline]
130pub(crate) const fn is_plain(b: u8) -> bool {
131 matches!(b,
132 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
133 | b'-' | b'.' | b'_' | b'~'
134 | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
135 | b':' | b'@')
136}
137
138const HEX: &[u8; 16] = b"0123456789ABCDEF";
139
140pub(crate) fn encode_segment(bytes: &[u8], out: &mut String) {
142 for &b in bytes {
143 if is_plain(b) {
144 out.push(b as char);
145 } else {
146 out.push('%');
147 out.push(HEX[(b >> 4) as usize] as char);
148 out.push(HEX[(b & 15) as usize] as char);
149 }
150 }
151}
152
153pub(crate) fn decode(raw: &str) -> Cow<'_, str> {
156 if !raw.contains('%') {
157 return Cow::Borrowed(raw);
158 }
159 let b = raw.as_bytes();
160 let mut out = Vec::with_capacity(b.len());
161 let mut i = 0;
162 while i < b.len() {
163 if b[i] == b'%' && i + 2 < b.len() {
164 if let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) {
165 out.push((h << 4) | l);
166 i += 3;
167 continue;
168 }
169 }
170 out.push(b[i]);
171 i += 1;
172 }
173 match String::from_utf8(out) {
174 Ok(s) => Cow::Owned(s),
175 Err(_) => Cow::Borrowed(raw),
176 }
177}
178
179fn plain_path(raw: &[u8], lowercase: bool) -> bool {
183 let mut seg_start = 1;
184 let mut segments = 0usize;
185 for i in 1..=raw.len() {
186 let end = i == raw.len();
187 if end || raw[i] == b'/' {
188 let seg = &raw[seg_start..i];
189 if (seg.is_empty() && !end) || seg == b"." || seg == b".." {
191 return false;
192 }
193 segments += 1;
194 seg_start = i + 1;
195 continue;
196 }
197 let b = raw[i];
198 if !is_plain(b) || (lowercase && b.is_ascii_uppercase()) {
199 return false;
200 }
201 }
202 segments <= MAX_SEGMENTS
203}
204
205pub(crate) fn normalize(raw: &str, lowercase: bool) -> Result<Cow<'_, str>, UrlError> {
217 let b = raw.as_bytes();
218 if b.first() != Some(&b'/') {
219 return Err(UrlError::NotAbsolute);
220 }
221 if plain_path(b, lowercase) {
222 return Ok(Cow::Borrowed(raw));
223 }
224 let mut buf: Vec<u8> = Vec::with_capacity(b.len());
227 let mut segs: Vec<(usize, usize)> = Vec::new();
228 let mut trailing = false;
229 let last_index = b[1..].iter().filter(|&&c| c == b'/').count();
230 if last_index >= MAX_SEGMENTS * 2 {
231 return Err(UrlError::TooManySegments);
232 }
233 for (n, seg) in b[1..].split(|&c| c == b'/').enumerate() {
234 let start = buf.len();
235 let mut i = 0;
236 while i < seg.len() {
237 let c = seg[i];
238 let byte = if c == b'%' {
239 let (Some(h), Some(l)) = (
240 seg.get(i + 1).copied().and_then(hex),
241 seg.get(i + 2).copied().and_then(hex),
242 ) else {
243 return Err(UrlError::BadEscape);
244 };
245 i += 3;
246 let d = (h << 4) | l;
247 match d {
248 b'/' => return Err(UrlError::EncodedSlash),
249 b'\\' => return Err(UrlError::Backslash),
250 0..=0x1f | 0x7f => return Err(UrlError::Control),
251 _ => d,
252 }
253 } else {
254 i += 1;
255 if c == b'\\' {
256 return Err(UrlError::Backslash);
257 }
258 if c < 0x20 || c == 0x7f {
259 return Err(UrlError::Control);
260 }
261 c
262 };
263 buf.push(if lowercase {
264 byte.to_ascii_lowercase()
265 } else {
266 byte
267 });
268 }
269 let decoded = &buf[start..];
270 let last = n == last_index;
271 match decoded {
272 b"" => {
273 buf.truncate(start);
274 trailing = last;
275 }
276 b"." => {
277 buf.truncate(start);
278 trailing = last;
279 }
280 b".." => {
281 buf.truncate(start);
282 if let Some((s, _)) = segs.pop() {
283 buf.truncate(s);
284 }
285 trailing = last;
286 }
287 _ => {
288 if core::str::from_utf8(decoded).is_err() {
289 return Err(UrlError::NotUtf8);
290 }
291 if decoded
294 .windows(2)
295 .any(|w| w[0] == 0xC2 && (0x80..=0x9F).contains(&w[1]))
296 {
297 return Err(UrlError::Control);
298 }
299 segs.push((start, buf.len()));
300 trailing = false;
301 }
302 }
303 }
304 if segs.len() > MAX_SEGMENTS {
305 return Err(UrlError::TooManySegments);
306 }
307 let mut out = String::with_capacity(b.len());
308 for &(s, e) in &segs {
309 out.push('/');
310 encode_segment(&buf[s..e], &mut out);
311 }
312 if out.is_empty() || trailing {
313 out.push('/');
314 }
315 if out == raw {
316 Ok(Cow::Borrowed(raw))
317 } else {
318 Ok(Cow::Owned(out))
319 }
320}
321
322pub(crate) fn canonical_literal(segment: &str) -> String {
325 let mut out = String::with_capacity(segment.len());
326 encode_segment(segment.as_bytes(), &mut out);
327 out
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn n(p: &str) -> Result<String, UrlError> {
335 normalize(p, false).map(Cow::into_owned)
336 }
337
338 #[test]
339 fn test_plain_paths_are_borrowed() {
340 for p in [
341 "/",
342 "/a",
343 "/a/b",
344 "/a/b/",
345 "/a-b_c.d~e",
346 "/a:b@c!$&'()*+,;=",
347 ] {
348 assert!(matches!(normalize(p, false), Ok(Cow::Borrowed(_))), "{p}");
349 }
350 }
351
352 #[test]
353 fn test_dot_segments_removed() {
354 assert_eq!(n("/a/./b/../c").unwrap(), "/a/c");
355 assert_eq!(n("/a/b/..").unwrap(), "/a/");
356 assert_eq!(n("/a/b/.").unwrap(), "/a/b/");
357 assert_eq!(n("/../../etc/passwd").unwrap(), "/etc/passwd");
358 assert_eq!(n("/..").unwrap(), "/");
359 assert_eq!(n("/%2e%2E/x").unwrap(), "/x");
360 assert_eq!(n("/a/%2e/b").unwrap(), "/a/b");
361 }
362
363 #[test]
364 fn test_empty_segments_collapse() {
365 assert_eq!(n("//a///b").unwrap(), "/a/b");
366 assert_eq!(n("/a//").unwrap(), "/a/");
367 assert_eq!(n("//").unwrap(), "/");
368 }
369
370 #[test]
371 fn test_escapes_canonical() {
372 assert_eq!(n("/%41%62c").unwrap(), "/Abc");
373 assert_eq!(n("/caf%c3%a9").unwrap(), "/caf%C3%A9");
374 assert_eq!(n("/caf%C3%A9").unwrap(), "/caf%C3%A9");
375 assert_eq!(n("/a%20b").unwrap(), "/a%20b");
376 assert_eq!(n("/a%3Ab").unwrap(), "/a:b");
377 assert_eq!(n("/a%25b").unwrap(), "/a%25b");
378 assert!(matches!(normalize("/a%20b", false), Ok(Cow::Borrowed(_))));
379 }
380
381 #[test]
382 fn test_refusals() {
383 assert_eq!(n("a"), Err(UrlError::NotAbsolute));
384 assert_eq!(n(""), Err(UrlError::NotAbsolute));
385 assert_eq!(n("/a%2Fb"), Err(UrlError::EncodedSlash));
386 assert_eq!(n("/a%2fb"), Err(UrlError::EncodedSlash));
387 assert_eq!(n("/a%5Cb"), Err(UrlError::Backslash));
388 assert_eq!(n("/a%00"), Err(UrlError::Control));
389 assert_eq!(n("/a%0d%0a"), Err(UrlError::Control));
390 assert_eq!(n("/a%7f"), Err(UrlError::Control));
391 assert_eq!(n("/%c2%80"), Err(UrlError::Control));
393 assert_eq!(n("/a%C2%85b"), Err(UrlError::Control));
394 assert_eq!(n("/a/%c2%9f"), Err(UrlError::Control));
395 assert_eq!(n("/%c2%a0").as_deref(), Ok("/%C2%A0"));
397 assert_eq!(n("/caf%c3%a9").as_deref(), Ok("/caf%C3%A9"));
398 assert_eq!(n("/a%zz"), Err(UrlError::BadEscape));
399 assert_eq!(n("/a%4"), Err(UrlError::BadEscape));
400 assert_eq!(n("/a%ff"), Err(UrlError::NotUtf8));
401 assert_eq!(n("/%c3"), Err(UrlError::NotUtf8));
402 }
403
404 #[test]
405 fn test_lowercase() {
406 assert_eq!(normalize("/About/Us", true).unwrap(), "/about/us");
407 assert_eq!(normalize("/%41", true).unwrap(), "/a");
408 assert!(matches!(normalize("/about", true), Ok(Cow::Borrowed(_))));
409 }
410
411 #[test]
412 fn test_normalize_is_idempotent() {
413 for p in [
414 "/a/./b/../c",
415 "//x//y/",
416 "/%41%2e%2e/b",
417 "/caf%c3%a9/",
418 "/a%20b/..",
419 ] {
420 let once = n(p).unwrap();
421 assert_eq!(n(&once).unwrap(), once, "{p}");
422 }
423 }
424
425 #[test]
426 fn test_decode() {
427 assert_eq!(decode("caf%C3%A9"), "café");
428 assert_eq!(decode("a%20b"), "a b");
429 assert_eq!(decode("plain"), "plain");
430 }
431
432 mod props {
433 use proptest::prelude::*;
434
435 use super::super::*;
436
437 fn raw_path() -> impl Strategy<Value = String> {
440 let piece = prop_oneof![
441 Just("/".to_owned()),
442 Just("//".to_owned()),
443 Just("/./".to_owned()),
444 Just("/../".to_owned()),
445 Just(".".to_owned()),
446 Just("..".to_owned()),
447 "[a-zA-Z0-9._~!$&'()*+,;=:@-]{1,6}",
448 (0u8..=255).prop_map(|b| format!("%{b:02X}")),
449 (0u8..=255).prop_map(|b| format!("%{b:02x}")),
450 ];
451 proptest::collection::vec(piece, 0..12).prop_map(|v| format!("/{}", v.concat()))
452 }
453
454 fn segment() -> impl Strategy<Value = String> {
457 "[^/\\\\\\x00-\\x1f\\x7f-\\x{9f}]{1,8}"
458 .prop_filter("not a dot segment", |s| s != "." && s != "..")
459 }
460
461 fn spell(seg: &str, choices: &[u8]) -> String {
464 let mut out = String::new();
465 for (i, &b) in seg.as_bytes().iter().enumerate() {
466 let c = choices.get(i).copied().unwrap_or(0);
467 if is_plain(b) && c % 3 == 0 {
468 out.push(b as char);
469 } else if c % 3 == 1 {
470 out.push_str(&format!("%{b:02x}"));
471 } else {
472 out.push_str(&format!("%{b:02X}"));
473 }
474 }
475 out
476 }
477
478 proptest! {
479 #[test]
480 fn prop_normalize_is_idempotent_and_clean(p in raw_path()) {
481 if let Ok(c) = normalize(&p, false) {
482 let again = normalize(&c, false).unwrap();
483 prop_assert!(matches!(again, Cow::Borrowed(_)), "{p} -> {c} -> {again}");
484 prop_assert!(c.starts_with('/'));
485 for bad in ["//", "/./", "/../"] {
486 prop_assert!(!c.contains(bad), "{p} -> {c}");
487 }
488 prop_assert!(!c.ends_with("/.") && !c.ends_with("/.."), "{p} -> {c}");
489 let decoded = decode(&c);
490 prop_assert!(!decoded.contains('\\') && !decoded.chars().any(char::is_control));
491 }
492 }
493
494 #[test]
495 fn prop_every_spelling_reaches_one_path(
496 segs in proptest::collection::vec(segment(), 1..5),
497 choices in proptest::collection::vec(any::<u8>(), 64),
498 noise in proptest::collection::vec(0u8..4, 5),
499 ) {
500 let mut canonical = String::new();
501 let mut spelled = String::new();
502 for (i, s) in segs.iter().enumerate() {
503 canonical.push('/');
504 encode_segment(s.as_bytes(), &mut canonical);
505 match noise[i % noise.len()] {
506 1 => spelled.push_str("/."),
507 2 => spelled.push_str("/x/.."),
508 3 => spelled.push('/'),
509 _ => {}
510 }
511 spelled.push('/');
512 spelled.push_str(&spell(s, &choices[i * 8 % 56..]));
513 }
514 let n = normalize(&spelled, false).unwrap();
515 prop_assert_eq!(n.as_ref(), canonical.as_str(), "{}", spelled);
516 }
517
518 #[test]
519 fn prop_decode_inverts_encode(s in "\\PC{0,16}") {
520 let mut e = String::new();
521 encode_segment(s.as_bytes(), &mut e);
522 let d = decode(&e);
523 prop_assert_eq!(d.as_ref(), s.as_str());
524 }
525 }
526 }
527
528 #[test]
529 fn test_too_many_segments() {
530 let long = "/a".repeat(MAX_SEGMENTS + 1);
531 assert_eq!(n(&long), Err(UrlError::TooManySegments));
532 let ok = "/a".repeat(MAX_SEGMENTS);
533 assert!(n(&ok).is_ok());
534 }
535}