1use std::borrow::Cow;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone)]
14pub struct RouteMatch {
15 pub params: HashMap<String, String>,
17 pub specificity: usize,
19 pub consumed_segments: usize,
22}
23
24#[derive(Debug, Clone)]
26enum SegmentPattern {
27 Literal(String),
29 Param(String),
31 OptionalParam(String),
33 Splat(String),
35}
36
37#[derive(Debug, Default)]
43pub struct CompiledRouteCache {
44 cache: HashMap<String, Vec<SegmentPattern>>,
45}
46
47impl CompiledRouteCache {
48 #[must_use]
50 pub fn new() -> Self {
51 Self {
52 cache: HashMap::new(),
53 }
54 }
55
56 fn get_or_compile(&mut self, template: &str) -> &[SegmentPattern] {
58 self.cache
60 .entry(template.to_string())
61 .or_insert_with(|| parse_template(template))
62 }
63}
64
65pub fn match_route_cached(
69 cache: &mut CompiledRouteCache,
70 template: &str,
71 request_path: &str,
72 exact: bool,
73) -> Option<RouteMatch> {
74 let request_segments = split_path(request_path);
75 match_route_cached_with_segments(cache, template, &request_segments, exact)
76}
77
78pub fn match_route_cached_with_segments(
84 cache: &mut CompiledRouteCache,
85 template: &str,
86 request_segments: &[&str],
87 exact: bool,
88) -> Option<RouteMatch> {
89 let patterns = cache.get_or_compile(template);
90 try_match(patterns, request_segments, exact)
91}
92
93pub fn split_request_path(path: &str) -> Vec<&str> {
99 split_path(path)
100}
101
102fn parse_template(template: &str) -> Vec<SegmentPattern> {
104 let mut segments = Vec::new();
105
106 for part in template.split('/') {
107 if part.is_empty() {
108 continue;
109 }
110
111 if let Some(rest) = part.strip_prefix('*') {
112 let name = if rest.is_empty() {
113 "rest".to_string()
114 } else {
115 rest.to_string()
116 };
117 segments.push(SegmentPattern::Splat(name));
118 } else if let Some(rest) = part.strip_prefix(':') {
119 if let Some(name) = rest.strip_suffix('?') {
120 segments.push(SegmentPattern::OptionalParam(name.to_string()));
121 } else {
122 segments.push(SegmentPattern::Param(rest.to_string()));
123 }
124 } else {
125 segments.push(SegmentPattern::Literal(part.to_string()));
126 }
127 }
128
129 segments
130}
131
132fn split_path(path: &str) -> Vec<&str> {
134 path.split('/').filter(|s| !s.is_empty()).collect()
135}
136
137pub(crate) fn hex_val(b: u8) -> Option<u8> {
139 match b {
140 b'0'..=b'9' => Some(b - b'0'),
141 b'a'..=b'f' => Some(b - b'a' + 10),
142 b'A'..=b'F' => Some(b - b'A' + 10),
143 _ => None,
144 }
145}
146
147fn percent_decode(input: &str) -> Option<String> {
152 let bytes = input.as_bytes();
153 if !bytes.contains(&b'%') {
154 return Some(input.to_owned());
155 }
156 let mut out = Vec::with_capacity(bytes.len());
157 let mut i = 0;
158 while i < bytes.len() {
159 if bytes[i] == b'%' {
160 if i + 2 >= bytes.len() {
161 return None;
162 }
163 let hi = hex_val(bytes[i + 1])?;
164 let lo = hex_val(bytes[i + 2])?;
165 out.push(hi << 4 | lo);
166 i += 3;
167 } else {
168 out.push(bytes[i]);
169 i += 1;
170 }
171 }
172 String::from_utf8(out).ok()
173}
174
175fn validate_param(value: &str) -> Option<String> {
182 let decoded = percent_decode(value)?;
183 if decoded == ".." || decoded.bytes().any(|b| b == 0) {
184 return None;
185 }
186 Some(decoded)
187}
188
189fn try_match(
193 patterns: &[SegmentPattern],
194 request_segments: &[&str],
195 exact: bool,
196) -> Option<RouteMatch> {
197 let mut params = HashMap::new();
198 let mut specificity: usize = 0;
199 let mut req_idx = 0;
200 let mut pat_idx = 0;
201
202 while pat_idx < patterns.len() {
203 match &patterns[pat_idx] {
204 SegmentPattern::Literal(expected) => {
205 if req_idx >= request_segments.len() {
206 return None;
207 }
208 if request_segments[req_idx] != expected.as_str() {
209 return None;
210 }
211 specificity += 1;
212 req_idx += 1;
213 }
214 SegmentPattern::Param(name) => {
215 if req_idx >= request_segments.len() {
216 return None;
217 }
218 params.insert(name.clone(), validate_param(request_segments[req_idx])?);
219 req_idx += 1;
220 }
221 SegmentPattern::OptionalParam(name) => {
222 if req_idx < request_segments.len() {
223 params.insert(name.clone(), validate_param(request_segments[req_idx])?);
224 req_idx += 1;
225 }
226 }
228 SegmentPattern::Splat(name) => {
229 let remaining = &request_segments[req_idx..];
231 let mut decoded_parts = Vec::with_capacity(remaining.len());
232 for seg in remaining {
233 decoded_parts.push(validate_param(seg)?);
234 }
235 params.insert(name.clone(), decoded_parts.join("/"));
236 req_idx = request_segments.len();
237 }
238 }
239 pat_idx += 1;
240 }
241
242 if exact && req_idx < request_segments.len() {
244 return None;
245 }
246
247 while pat_idx < patterns.len() {
250 match &patterns[pat_idx] {
251 SegmentPattern::OptionalParam(_) | SegmentPattern::Splat(_) => {}
252 _ => return None,
253 }
254 pat_idx += 1;
255 }
256
257 Some(RouteMatch {
258 params,
259 specificity,
260 consumed_segments: req_idx,
261 })
262}
263
264pub fn match_single_route(template: &str, request_path: &str, exact: bool) -> Option<RouteMatch> {
269 let request_segments = split_path(request_path);
270 let patterns = parse_template(template);
271 try_match(&patterns, &request_segments, exact)
272}
273
274pub fn is_relative_path(path: &str) -> bool {
276 !path.is_empty() && !path.starts_with('/')
277}
278
279pub fn resolve_route_path(path: &str, route_base: &str) -> String {
288 resolve_route_path_cow(path, route_base).into_owned()
289}
290
291pub(crate) fn resolve_route_path_cow<'a>(path: &'a str, route_base: &str) -> Cow<'a, str> {
293 if path.is_empty() {
295 return Cow::Owned(route_base.to_string());
296 }
297
298 if !is_relative_path(path) {
299 return Cow::Borrowed(path);
300 }
301
302 let relative = path.strip_prefix("./").unwrap_or(path);
304 if relative.is_empty() {
305 return Cow::Owned(route_base.to_string());
306 }
307
308 let mut resolved = String::with_capacity(route_base.len() + relative.len() + 1);
309 resolved.push_str(route_base);
310 if !route_base.ends_with('/') {
311 resolved.push('/');
312 }
313 resolved.push_str(relative);
314 Cow::Owned(resolved)
315}
316
317pub fn compute_route_base(request_path: &str, consumed_segments: usize) -> String {
322 if consumed_segments == 0 {
323 return "/".to_string();
324 }
325
326 let mut base = String::with_capacity(request_path.len());
327 let mut remaining = consumed_segments;
328 for part in request_path
329 .split('/')
330 .filter(|segment| !segment.is_empty())
331 {
332 if remaining == 0 {
333 break;
334 }
335 base.push('/');
336 base.push_str(part);
337 remaining -= 1;
338 }
339 if base.is_empty() {
340 base.push('/');
341 }
342 base
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_match_single_route_exact() {
351 let m = match_single_route("/contacts", "/contacts", true).unwrap();
352 assert!(m.params.is_empty());
353 }
354
355 #[test]
356 fn test_match_single_route_param() {
357 let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
358 assert_eq!(m.params["id"], "42");
359 }
360
361 #[test]
362 fn test_match_single_route_no_match_extra() {
363 assert!(match_single_route("/contacts", "/contacts/123", true).is_none());
364 }
365
366 #[test]
367 fn test_match_single_route_multiple_params() {
368 let m = match_single_route("/profile/:id/view/:section", "/profile/123/view/bio", true)
369 .unwrap();
370 assert_eq!(m.params["id"], "123");
371 assert_eq!(m.params["section"], "bio");
372 }
373
374 #[test]
375 fn test_match_single_route_splat() {
376 let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
377 assert_eq!(m.params["path"], "docs/readme.md");
378 }
379
380 #[test]
381 fn test_match_single_route_optional_param_present() {
382 let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
383 assert_eq!(m.params["query"], "hello");
384 }
385
386 #[test]
387 fn test_match_single_route_optional_param_absent() {
388 let m = match_single_route("/search/:query?", "/search", true).unwrap();
389 assert!(!m.params.contains_key("query"));
390 }
391
392 #[test]
393 fn test_parse_template_segments() {
394 let segs = parse_template("/users/:id/posts/:postId");
395 assert_eq!(segs.len(), 4);
396 assert!(matches!(&segs[0], SegmentPattern::Literal(s) if s == "users"));
397 assert!(matches!(&segs[1], SegmentPattern::Param(s) if s == "id"));
398 assert!(matches!(&segs[2], SegmentPattern::Literal(s) if s == "posts"));
399 assert!(matches!(&segs[3], SegmentPattern::Param(s) if s == "postId"));
400 }
401
402 #[test]
403 fn test_match_single_route_non_exact_prefix() {
404 let m = match_single_route("/", "/anything", false).unwrap();
405 assert!(m.params.is_empty());
406 }
407
408 #[test]
411 fn test_consumed_segments_exact() {
412 let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
413 assert_eq!(m.consumed_segments, 2);
414 }
415
416 #[test]
417 fn test_consumed_segments_prefix() {
418 let m = match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
419 assert_eq!(m.consumed_segments, 2);
420 }
421
422 #[test]
423 fn test_consumed_segments_root() {
424 let m = match_single_route("/", "/", true).unwrap();
425 assert_eq!(m.consumed_segments, 0);
426 }
427
428 #[test]
429 fn test_consumed_segments_splat() {
430 let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
431 assert_eq!(m.consumed_segments, 3);
432 }
433
434 #[test]
437 fn test_is_relative_path() {
438 assert!(is_relative_path("./sections/:id"));
439 assert!(is_relative_path("./"));
440 assert!(is_relative_path("sections/:id")); assert!(is_relative_path("topics/:topicId")); assert!(!is_relative_path("/sections/:id")); assert!(!is_relative_path("")); }
445
446 #[test]
447 fn test_resolve_route_path_dotslash_relative() {
448 assert_eq!(
449 resolve_route_path("./topics/:id", "/sections/1"),
450 "/sections/1/topics/:id"
451 );
452 }
453
454 #[test]
455 fn test_resolve_route_path_bare_relative() {
456 assert_eq!(
457 resolve_route_path("topics/:id", "/sections/1"),
458 "/sections/1/topics/:id"
459 );
460 }
461
462 #[test]
463 fn test_resolve_route_path_relative_at_root() {
464 assert_eq!(resolve_route_path("./sections/:id", "/"), "/sections/:id");
465 assert_eq!(resolve_route_path("sections/:id", "/"), "/sections/:id");
466 }
467
468 #[test]
469 fn test_resolve_route_path_absolute_unchanged() {
470 assert_eq!(
471 resolve_route_path("/sections/:id", "/some/base"),
472 "/sections/:id"
473 );
474 }
475
476 #[test]
477 fn test_resolve_route_path_deep_nesting() {
478 assert_eq!(
479 resolve_route_path("./lessons/:lessonId", "/sections/1/topics/react"),
480 "/sections/1/topics/react/lessons/:lessonId"
481 );
482 assert_eq!(
483 resolve_route_path("lessons/:lessonId", "/sections/1/topics/react"),
484 "/sections/1/topics/react/lessons/:lessonId"
485 );
486 }
487
488 #[test]
489 fn test_resolve_route_path_empty_resolves_to_base() {
490 assert_eq!(resolve_route_path("", "/search"), "/search");
491 assert_eq!(resolve_route_path("", "/"), "/");
492 assert_eq!(resolve_route_path("", "/a/b/c"), "/a/b/c");
493 }
494
495 #[test]
496 fn test_empty_child_route_matches_parent_path_exactly() {
497 let resolved = resolve_route_path("", "/search");
499 let m = match_single_route(&resolved, "/search", true);
500 assert!(m.is_some(), "empty child route must match parent path");
501 let m = m.unwrap();
502 assert_eq!(m.consumed_segments, 1);
503 }
504
505 #[test]
508 fn test_compute_route_base_two_segments() {
509 assert_eq!(
510 compute_route_base("/sections/1/topics/react", 2),
511 "/sections/1"
512 );
513 }
514
515 #[test]
516 fn test_compute_route_base_zero() {
517 assert_eq!(compute_route_base("/sections/1", 0), "/");
518 }
519
520 #[test]
521 fn test_compute_route_base_all_segments() {
522 assert_eq!(
523 compute_route_base("/sections/1/topics/react", 4),
524 "/sections/1/topics/react"
525 );
526 }
527
528 #[test]
529 fn test_compute_route_base_exceeds_segments() {
530 assert_eq!(compute_route_base("/sections/1", 10), "/sections/1");
531 }
532
533 #[test]
534 fn test_param_rejects_traversal() {
535 assert!(match_single_route("/files/:name", "/files/..", true).is_none());
537 }
538
539 #[test]
540 fn test_param_allows_double_dot_prefix() {
541 let m = match_single_route("/files/:name", "/files/..something", true).unwrap();
543 assert_eq!(m.params["name"], "..something");
544 }
545
546 #[test]
547 fn test_param_rejects_null_bytes() {
548 assert!(match_single_route("/users/:id", "/users/test\0injected", true).is_none());
549 }
550
551 #[test]
552 fn test_splat_rejects_traversal() {
553 assert!(
554 match_single_route("/files/*path", "/files/docs/../../../etc/passwd", false).is_none()
555 );
556 }
557
558 #[test]
559 fn test_param_rejects_encoded_traversal() {
560 assert!(match_single_route("/files/:name", "/files/%2e%2e", true).is_none());
562 }
563
564 #[test]
565 fn test_param_rejects_mixed_encoded_traversal() {
566 assert!(match_single_route("/files/:name", "/files/.%2e", true).is_none());
568 assert!(match_single_route("/files/:name", "/files/%2e.", true).is_none());
570 }
571
572 #[test]
573 fn test_param_rejects_encoded_null() {
574 assert!(match_single_route("/users/:id", "/users/test%00injected", true).is_none());
575 }
576
577 #[test]
578 fn test_param_rejects_malformed_percent() {
579 assert!(match_single_route("/users/:id", "/users/100%", true).is_none());
580 assert!(match_single_route("/users/:id", "/users/100%ZZ", true).is_none());
581 }
582
583 #[test]
584 fn test_param_decodes_valid_percent_encoding() {
585 let m = match_single_route("/files/:name", "/files/hello%20world", true).unwrap();
586 assert_eq!(m.params["name"], "hello world");
587 }
588
589 #[test]
590 fn test_splat_decodes_segments() {
591 let m = match_single_route("/files/*path", "/files/my%20docs/read%20me.md", false).unwrap();
592 assert_eq!(m.params["path"], "my docs/read me.md");
593 }
594
595 #[test]
596 fn test_splat_rejects_encoded_traversal() {
597 assert!(
598 match_single_route("/files/*path", "/files/docs/%2e%2e/etc/passwd", false).is_none()
599 );
600 }
601
602 #[test]
607 fn parity_root_exact() {
608 let m = match_single_route("/", "/", true).unwrap();
609 assert!(m.params.is_empty());
610 assert_eq!(m.consumed_segments, 0);
611 assert_eq!(m.specificity, 0);
612 }
613
614 #[test]
615 fn parity_root_prefix_matches_anything() {
616 let m = match_single_route("/", "/any/path/here", false).unwrap();
617 assert_eq!(m.consumed_segments, 0);
618 }
619
620 #[test]
621 fn parity_root_exact_rejects_non_root() {
622 assert!(match_single_route("/", "/something", true).is_none());
623 }
624
625 #[test]
626 fn parity_optional_param_present() {
627 let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
628 assert_eq!(m.params["query"], "hello");
629 assert_eq!(m.consumed_segments, 2);
630 }
631
632 #[test]
633 fn parity_optional_param_absent() {
634 let m = match_single_route("/search/:query?", "/search", true).unwrap();
635 assert!(!m.params.contains_key("query"));
636 assert_eq!(m.consumed_segments, 1);
637 }
638
639 #[test]
640 fn parity_splat_captures_all_remaining() {
641 let m = match_single_route("/files/*path", "/files/a/b/c/d.txt", false).unwrap();
642 assert_eq!(m.params["path"], "a/b/c/d.txt");
643 assert_eq!(m.consumed_segments, 5);
644 }
645
646 #[test]
647 fn parity_splat_captures_empty() {
648 let m = match_single_route("/files/*path", "/files", false).unwrap();
649 assert_eq!(m.params["path"], "");
650 assert_eq!(m.consumed_segments, 1);
651 }
652
653 #[test]
654 fn parity_unnamed_splat_defaults_to_rest() {
655 let m = match_single_route("/files/*", "/files/a/b", false).unwrap();
656 assert_eq!(m.params["rest"], "a/b");
657 }
658
659 #[test]
660 fn parity_percent_encoded_space() {
661 let m = match_single_route("/items/:name", "/items/hello%20world", true).unwrap();
662 assert_eq!(m.params["name"], "hello world");
663 }
664
665 #[test]
666 fn parity_percent_encoded_slash() {
667 let m = match_single_route("/items/:name", "/items/a%2Fb", true).unwrap();
668 assert_eq!(m.params["name"], "a/b");
669 }
670
671 #[test]
672 fn parity_unicode_path() {
673 let m = match_single_route("/pages/:title", "/pages/caf%C3%A9", true).unwrap();
674 assert_eq!(m.params["title"], "café");
675 }
676
677 #[test]
678 fn parity_specificity_literal_beats_param() {
679 let add = match_single_route("/contacts/add", "/contacts/add", true).unwrap();
681 let param = match_single_route("/contacts/:id", "/contacts/add", true).unwrap();
682 assert!(add.specificity > param.specificity);
683 }
684
685 #[test]
686 fn parity_equal_specificity_first_wins() {
687 let m1 = match_single_route("/items/:a", "/items/x", true).unwrap();
690 let m2 = match_single_route("/items/:b", "/items/x", true).unwrap();
691 assert_eq!(m1.specificity, m2.specificity, "equal specificity expected");
692 }
693
694 #[test]
695 fn parity_relative_path_resolution() {
696 assert_eq!(
697 resolve_route_path("./topics/:id", "/sections/1"),
698 "/sections/1/topics/:id"
699 );
700 assert_eq!(
701 resolve_route_path("topics/:id", "/sections/1"),
702 "/sections/1/topics/:id"
703 );
704 assert_eq!(resolve_route_path("/absolute", "/any/base"), "/absolute");
705 assert_eq!(resolve_route_path("", "/base"), "/base");
706 assert_eq!(resolve_route_path("./", "/base"), "/base");
707 }
708
709 #[test]
710 fn parity_compute_route_base_various() {
711 assert_eq!(compute_route_base("/", 0), "/");
712 assert_eq!(compute_route_base("/a/b/c", 1), "/a");
713 assert_eq!(compute_route_base("/a/b/c", 2), "/a/b");
714 assert_eq!(compute_route_base("/a/b/c", 3), "/a/b/c");
715 assert_eq!(compute_route_base("/a/b/c", 99), "/a/b/c");
716 }
717
718 #[test]
719 fn parity_nested_route_matching() {
720 let parent =
722 match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
723 assert_eq!(parent.params["id"], "1");
724 assert_eq!(parent.consumed_segments, 2);
725
726 let base = compute_route_base("/sections/1/topics/react", parent.consumed_segments);
728 assert_eq!(base, "/sections/1");
729
730 let child_path = resolve_route_path("./topics/:topicId", &base);
731 assert_eq!(child_path, "/sections/1/topics/:topicId");
732
733 let child = match_single_route(&child_path, "/sections/1/topics/react", true).unwrap();
734 assert_eq!(child.params["topicId"], "react");
735 assert_eq!(child.consumed_segments, 4);
736 }
737
738 #[test]
739 fn parity_non_exact_prefix_with_extra_segments() {
740 let m = match_single_route("/app", "/app/dashboard/settings", false).unwrap();
741 assert_eq!(m.consumed_segments, 1);
742 assert_eq!(m.specificity, 1);
743 }
744
745 #[test]
746 fn parity_exact_rejects_extra_segments() {
747 assert!(match_single_route("/app", "/app/dashboard", true).is_none());
748 }
749
750 #[test]
751 fn parity_multiple_optional_params() {
752 let m = match_single_route("/search/:q?/:page?", "/search", true).unwrap();
753 assert!(!m.params.contains_key("q"));
754 assert!(!m.params.contains_key("page"));
755
756 let m = match_single_route("/search/:q?/:page?", "/search/rust", true).unwrap();
757 assert_eq!(m.params["q"], "rust");
758 assert!(!m.params.contains_key("page"));
759
760 let m = match_single_route("/search/:q?/:page?", "/search/rust/2", true).unwrap();
761 assert_eq!(m.params["q"], "rust");
762 assert_eq!(m.params["page"], "2");
763 }
764}