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