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