1use std::borrow::Cow;
10use std::collections::HashMap;
11use webui_protocol::{web_ui_fragment::Fragment, WebUIProtocol};
12
13#[derive(Debug, Clone)]
15pub struct RouteMatch {
16 pub params: HashMap<String, String>,
18 pub specificity: usize,
20 pub consumed_segments: usize,
23}
24
25#[derive(Debug, Clone)]
27enum SegmentPattern {
28 Literal(String),
30 Param(String),
32 OptionalParam(String),
34 Splat(String),
36}
37
38#[derive(Debug)]
44pub(crate) struct CompiledRouteIndex {
45 patterns: HashMap<String, Vec<SegmentPattern>>,
46}
47
48impl CompiledRouteIndex {
49 #[must_use]
51 pub(crate) fn new(protocol: &WebUIProtocol) -> Self {
52 let mut patterns = HashMap::new();
53 let mut pending = Vec::new();
54
55 for fragment_list in protocol.fragments.values() {
56 for fragment in &fragment_list.fragments {
57 if let Some(Fragment::Route(route)) = fragment.fragment.as_ref() {
58 pending.push(route);
59 }
60 }
61 }
62
63 while let Some(route) = pending.pop() {
64 patterns
65 .entry(route.path.clone())
66 .or_insert_with(|| parse_template(&route.path));
67 pending.extend(route.children.iter());
68 }
69
70 Self { patterns }
71 }
72
73 fn get(&self, template: &str) -> Option<&[SegmentPattern]> {
74 self.patterns.get(template).map(Vec::as_slice)
75 }
76}
77
78pub(crate) fn match_route_indexed_with_segments(
83 index: &CompiledRouteIndex,
84 template: &str,
85 route_base: &str,
86 request_segments: &[&str],
87 exact: bool,
88) -> Option<RouteMatch> {
89 let patterns = index.get(template)?;
90 let base_segments = if template.is_empty() || is_relative_path(template) {
91 route_base
92 .split('/')
93 .filter(|segment| !segment.is_empty())
94 .count()
95 } else {
96 0
97 };
98 let remaining = request_segments.get(base_segments..)?;
99 let mut route_match = try_match(patterns, remaining, exact)?;
100 route_match.specificity += base_segments;
101 route_match.consumed_segments += base_segments;
102 Some(route_match)
103}
104
105pub fn split_request_path(path: &str) -> Vec<&str> {
109 split_path(path)
110}
111
112fn parse_template(template: &str) -> Vec<SegmentPattern> {
114 let mut segments = Vec::new();
115 let template = template.strip_prefix("./").unwrap_or(template);
116
117 for part in template.split('/') {
118 if part.is_empty() {
119 continue;
120 }
121
122 if let Some(rest) = part.strip_prefix('*') {
123 let name = if rest.is_empty() {
124 "rest".to_string()
125 } else {
126 rest.to_string()
127 };
128 segments.push(SegmentPattern::Splat(name));
129 } else if let Some(rest) = part.strip_prefix(':') {
130 if let Some(name) = rest.strip_suffix('?') {
131 segments.push(SegmentPattern::OptionalParam(name.to_string()));
132 } else {
133 segments.push(SegmentPattern::Param(rest.to_string()));
134 }
135 } else {
136 segments.push(SegmentPattern::Literal(part.to_string()));
137 }
138 }
139
140 segments
141}
142
143fn split_path(path: &str) -> Vec<&str> {
145 path.split('/').filter(|s| !s.is_empty()).collect()
146}
147
148pub(crate) fn hex_val(b: u8) -> Option<u8> {
150 match b {
151 b'0'..=b'9' => Some(b - b'0'),
152 b'a'..=b'f' => Some(b - b'a' + 10),
153 b'A'..=b'F' => Some(b - b'A' + 10),
154 _ => None,
155 }
156}
157
158fn percent_decode(input: &str) -> Option<String> {
163 let bytes = input.as_bytes();
164 if !bytes.contains(&b'%') {
165 return Some(input.to_owned());
166 }
167 let mut out = Vec::with_capacity(bytes.len());
168 let mut i = 0;
169 while i < bytes.len() {
170 if bytes[i] == b'%' {
171 if i + 2 >= bytes.len() {
172 return None;
173 }
174 let hi = hex_val(bytes[i + 1])?;
175 let lo = hex_val(bytes[i + 2])?;
176 out.push(hi << 4 | lo);
177 i += 3;
178 } else {
179 out.push(bytes[i]);
180 i += 1;
181 }
182 }
183 String::from_utf8(out).ok()
184}
185
186fn validate_param(value: &str) -> Option<String> {
193 let decoded = percent_decode(value)?;
194 if decoded == ".." || decoded.bytes().any(|b| b == 0) {
195 return None;
196 }
197 Some(decoded)
198}
199
200fn try_match(
204 patterns: &[SegmentPattern],
205 request_segments: &[&str],
206 exact: bool,
207) -> Option<RouteMatch> {
208 let mut params = HashMap::new();
209 let mut specificity: usize = 0;
210 let mut req_idx = 0;
211 let mut pat_idx = 0;
212
213 while pat_idx < patterns.len() {
214 match &patterns[pat_idx] {
215 SegmentPattern::Literal(expected) => {
216 if req_idx >= request_segments.len() {
217 return None;
218 }
219 if request_segments[req_idx] != expected.as_str() {
220 return None;
221 }
222 specificity += 1;
223 req_idx += 1;
224 }
225 SegmentPattern::Param(name) => {
226 if req_idx >= request_segments.len() {
227 return None;
228 }
229 params.insert(name.clone(), validate_param(request_segments[req_idx])?);
230 req_idx += 1;
231 }
232 SegmentPattern::OptionalParam(name) => {
233 if req_idx < request_segments.len() {
234 params.insert(name.clone(), validate_param(request_segments[req_idx])?);
235 req_idx += 1;
236 }
237 }
239 SegmentPattern::Splat(name) => {
240 let remaining = &request_segments[req_idx..];
242 let mut decoded_parts = Vec::with_capacity(remaining.len());
243 for seg in remaining {
244 decoded_parts.push(validate_param(seg)?);
245 }
246 params.insert(name.clone(), decoded_parts.join("/"));
247 req_idx = request_segments.len();
248 }
249 }
250 pat_idx += 1;
251 }
252
253 if exact && req_idx < request_segments.len() {
255 return None;
256 }
257
258 while pat_idx < patterns.len() {
261 match &patterns[pat_idx] {
262 SegmentPattern::OptionalParam(_) | SegmentPattern::Splat(_) => {}
263 _ => return None,
264 }
265 pat_idx += 1;
266 }
267
268 Some(RouteMatch {
269 params,
270 specificity,
271 consumed_segments: req_idx,
272 })
273}
274
275pub fn match_single_route(template: &str, request_path: &str, exact: bool) -> Option<RouteMatch> {
280 let request_segments = split_path(request_path);
281 let patterns = parse_template(template);
282 try_match(&patterns, &request_segments, exact)
283}
284
285pub fn is_relative_path(path: &str) -> bool {
287 !path.is_empty() && !path.starts_with('/')
288}
289
290pub fn resolve_route_path(path: &str, route_base: &str) -> String {
299 resolve_route_path_cow(path, route_base).into_owned()
300}
301
302pub(crate) fn resolve_route_path_cow<'a>(path: &'a str, route_base: &str) -> Cow<'a, str> {
304 if path.is_empty() {
306 return Cow::Owned(route_base.to_string());
307 }
308
309 if !is_relative_path(path) {
310 return Cow::Borrowed(path);
311 }
312
313 let relative = path.strip_prefix("./").unwrap_or(path);
315 if relative.is_empty() {
316 return Cow::Owned(route_base.to_string());
317 }
318
319 let mut resolved = String::with_capacity(route_base.len() + relative.len() + 1);
320 resolved.push_str(route_base);
321 if !route_base.ends_with('/') {
322 resolved.push('/');
323 }
324 resolved.push_str(relative);
325 Cow::Owned(resolved)
326}
327
328pub fn compute_route_base(request_path: &str, consumed_segments: usize) -> String {
333 if consumed_segments == 0 {
334 return "/".to_string();
335 }
336
337 let mut base = String::with_capacity(request_path.len());
338 let mut remaining = consumed_segments;
339 for part in request_path
340 .split('/')
341 .filter(|segment| !segment.is_empty())
342 {
343 if remaining == 0 {
344 break;
345 }
346 base.push('/');
347 base.push_str(part);
348 remaining -= 1;
349 }
350 if base.is_empty() {
351 base.push('/');
352 }
353 base
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use webui_protocol::{FragmentList, WebUIFragment, WebUiFragmentRoute};
360
361 #[test]
362 fn test_match_single_route_exact() {
363 let m = match_single_route("/contacts", "/contacts", true).unwrap();
364 assert!(m.params.is_empty());
365 }
366
367 #[test]
368 fn test_match_single_route_param() {
369 let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
370 assert_eq!(m.params["id"], "42");
371 }
372
373 #[test]
374 fn test_match_single_route_no_match_extra() {
375 assert!(match_single_route("/contacts", "/contacts/123", true).is_none());
376 }
377
378 #[test]
379 fn test_match_single_route_multiple_params() {
380 let m = match_single_route("/profile/:id/view/:section", "/profile/123/view/bio", true)
381 .unwrap();
382 assert_eq!(m.params["id"], "123");
383 assert_eq!(m.params["section"], "bio");
384 }
385
386 #[test]
387 fn test_match_single_route_splat() {
388 let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
389 assert_eq!(m.params["path"], "docs/readme.md");
390 }
391
392 #[test]
393 fn test_match_single_route_optional_param_present() {
394 let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
395 assert_eq!(m.params["query"], "hello");
396 }
397
398 #[test]
399 fn test_match_single_route_optional_param_absent() {
400 let m = match_single_route("/search/:query?", "/search", true).unwrap();
401 assert!(!m.params.contains_key("query"));
402 }
403
404 #[test]
405 fn test_parse_template_segments() {
406 let segs = parse_template("/users/:id/posts/:postId");
407 assert_eq!(segs.len(), 4);
408 assert!(matches!(&segs[0], SegmentPattern::Literal(s) if s == "users"));
409 assert!(matches!(&segs[1], SegmentPattern::Param(s) if s == "id"));
410 assert!(matches!(&segs[2], SegmentPattern::Literal(s) if s == "posts"));
411 assert!(matches!(&segs[3], SegmentPattern::Param(s) if s == "postId"));
412 }
413
414 #[test]
415 fn test_match_single_route_non_exact_prefix() {
416 let m = match_single_route("/", "/anything", false).unwrap();
417 assert!(m.params.is_empty());
418 }
419
420 #[test]
423 fn test_consumed_segments_exact() {
424 let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
425 assert_eq!(m.consumed_segments, 2);
426 }
427
428 #[test]
429 fn test_consumed_segments_prefix() {
430 let m = match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
431 assert_eq!(m.consumed_segments, 2);
432 }
433
434 #[test]
435 fn test_consumed_segments_root() {
436 let m = match_single_route("/", "/", true).unwrap();
437 assert_eq!(m.consumed_segments, 0);
438 }
439
440 #[test]
441 fn test_consumed_segments_splat() {
442 let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
443 assert_eq!(m.consumed_segments, 3);
444 }
445
446 #[test]
449 fn test_is_relative_path() {
450 assert!(is_relative_path("./sections/:id"));
451 assert!(is_relative_path("./"));
452 assert!(is_relative_path("sections/:id")); assert!(is_relative_path("topics/:topicId")); assert!(!is_relative_path("/sections/:id")); assert!(!is_relative_path("")); }
457
458 #[test]
459 fn test_resolve_route_path_dotslash_relative() {
460 assert_eq!(
461 resolve_route_path("./topics/:id", "/sections/1"),
462 "/sections/1/topics/:id"
463 );
464 }
465
466 #[test]
467 fn test_resolve_route_path_bare_relative() {
468 assert_eq!(
469 resolve_route_path("topics/:id", "/sections/1"),
470 "/sections/1/topics/:id"
471 );
472 }
473
474 #[test]
475 fn test_resolve_route_path_relative_at_root() {
476 assert_eq!(resolve_route_path("./sections/:id", "/"), "/sections/:id");
477 assert_eq!(resolve_route_path("sections/:id", "/"), "/sections/:id");
478 }
479
480 #[test]
481 fn test_resolve_route_path_absolute_unchanged() {
482 assert_eq!(
483 resolve_route_path("/sections/:id", "/some/base"),
484 "/sections/:id"
485 );
486 }
487
488 #[test]
489 fn test_resolve_route_path_deep_nesting() {
490 assert_eq!(
491 resolve_route_path("./lessons/:lessonId", "/sections/1/topics/react"),
492 "/sections/1/topics/react/lessons/:lessonId"
493 );
494 assert_eq!(
495 resolve_route_path("lessons/:lessonId", "/sections/1/topics/react"),
496 "/sections/1/topics/react/lessons/:lessonId"
497 );
498 }
499
500 #[test]
501 fn test_resolve_route_path_empty_resolves_to_base() {
502 assert_eq!(resolve_route_path("", "/search"), "/search");
503 assert_eq!(resolve_route_path("", "/"), "/");
504 assert_eq!(resolve_route_path("", "/a/b/c"), "/a/b/c");
505 }
506
507 #[test]
508 fn test_empty_child_route_matches_parent_path_exactly() {
509 let resolved = resolve_route_path("", "/search");
511 let m = match_single_route(&resolved, "/search", true);
512 assert!(m.is_some(), "empty child route must match parent path");
513 let m = m.unwrap();
514 assert_eq!(m.consumed_segments, 1);
515 }
516
517 #[test]
520 fn test_compute_route_base_two_segments() {
521 assert_eq!(
522 compute_route_base("/sections/1/topics/react", 2),
523 "/sections/1"
524 );
525 }
526
527 #[test]
528 fn test_compute_route_base_zero() {
529 assert_eq!(compute_route_base("/sections/1", 0), "/");
530 }
531
532 #[test]
533 fn test_compute_route_base_all_segments() {
534 assert_eq!(
535 compute_route_base("/sections/1/topics/react", 4),
536 "/sections/1/topics/react"
537 );
538 }
539
540 #[test]
541 fn test_compute_route_base_exceeds_segments() {
542 assert_eq!(compute_route_base("/sections/1", 10), "/sections/1");
543 }
544
545 #[test]
546 fn test_param_rejects_traversal() {
547 assert!(match_single_route("/files/:name", "/files/..", true).is_none());
549 }
550
551 #[test]
552 fn test_param_allows_double_dot_prefix() {
553 let m = match_single_route("/files/:name", "/files/..something", true).unwrap();
555 assert_eq!(m.params["name"], "..something");
556 }
557
558 #[test]
559 fn test_param_rejects_null_bytes() {
560 assert!(match_single_route("/users/:id", "/users/test\0injected", true).is_none());
561 }
562
563 #[test]
564 fn test_splat_rejects_traversal() {
565 assert!(
566 match_single_route("/files/*path", "/files/docs/../../../etc/passwd", false).is_none()
567 );
568 }
569
570 #[test]
571 fn test_param_rejects_encoded_traversal() {
572 assert!(match_single_route("/files/:name", "/files/%2e%2e", true).is_none());
574 }
575
576 #[test]
577 fn test_param_rejects_mixed_encoded_traversal() {
578 assert!(match_single_route("/files/:name", "/files/.%2e", true).is_none());
580 assert!(match_single_route("/files/:name", "/files/%2e.", true).is_none());
582 }
583
584 #[test]
585 fn test_param_rejects_encoded_null() {
586 assert!(match_single_route("/users/:id", "/users/test%00injected", true).is_none());
587 }
588
589 #[test]
590 fn test_param_rejects_malformed_percent() {
591 assert!(match_single_route("/users/:id", "/users/100%", true).is_none());
592 assert!(match_single_route("/users/:id", "/users/100%ZZ", true).is_none());
593 }
594
595 #[test]
596 fn test_param_decodes_valid_percent_encoding() {
597 let m = match_single_route("/files/:name", "/files/hello%20world", true).unwrap();
598 assert_eq!(m.params["name"], "hello world");
599 }
600
601 #[test]
602 fn test_splat_decodes_segments() {
603 let m = match_single_route("/files/*path", "/files/my%20docs/read%20me.md", false).unwrap();
604 assert_eq!(m.params["path"], "my docs/read me.md");
605 }
606
607 #[test]
608 fn test_splat_rejects_encoded_traversal() {
609 assert!(
610 match_single_route("/files/*path", "/files/docs/%2e%2e/etc/passwd", false).is_none()
611 );
612 }
613
614 #[test]
619 fn parity_root_exact() {
620 let m = match_single_route("/", "/", true).unwrap();
621 assert!(m.params.is_empty());
622 assert_eq!(m.consumed_segments, 0);
623 assert_eq!(m.specificity, 0);
624 }
625
626 #[test]
627 fn parity_root_prefix_matches_anything() {
628 let m = match_single_route("/", "/any/path/here", false).unwrap();
629 assert_eq!(m.consumed_segments, 0);
630 }
631
632 #[test]
633 fn parity_root_exact_rejects_non_root() {
634 assert!(match_single_route("/", "/something", true).is_none());
635 }
636
637 #[test]
638 fn parity_optional_param_present() {
639 let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
640 assert_eq!(m.params["query"], "hello");
641 assert_eq!(m.consumed_segments, 2);
642 }
643
644 #[test]
645 fn parity_optional_param_absent() {
646 let m = match_single_route("/search/:query?", "/search", true).unwrap();
647 assert!(!m.params.contains_key("query"));
648 assert_eq!(m.consumed_segments, 1);
649 }
650
651 #[test]
652 fn parity_splat_captures_all_remaining() {
653 let m = match_single_route("/files/*path", "/files/a/b/c/d.txt", false).unwrap();
654 assert_eq!(m.params["path"], "a/b/c/d.txt");
655 assert_eq!(m.consumed_segments, 5);
656 }
657
658 #[test]
659 fn parity_splat_captures_empty() {
660 let m = match_single_route("/files/*path", "/files", false).unwrap();
661 assert_eq!(m.params["path"], "");
662 assert_eq!(m.consumed_segments, 1);
663 }
664
665 #[test]
666 fn parity_unnamed_splat_defaults_to_rest() {
667 let m = match_single_route("/files/*", "/files/a/b", false).unwrap();
668 assert_eq!(m.params["rest"], "a/b");
669 }
670
671 #[test]
672 fn parity_percent_encoded_space() {
673 let m = match_single_route("/items/:name", "/items/hello%20world", true).unwrap();
674 assert_eq!(m.params["name"], "hello world");
675 }
676
677 #[test]
678 fn parity_percent_encoded_slash() {
679 let m = match_single_route("/items/:name", "/items/a%2Fb", true).unwrap();
680 assert_eq!(m.params["name"], "a/b");
681 }
682
683 #[test]
684 fn parity_unicode_path() {
685 let m = match_single_route("/pages/:title", "/pages/caf%C3%A9", true).unwrap();
686 assert_eq!(m.params["title"], "café");
687 }
688
689 #[test]
690 fn parity_specificity_literal_beats_param() {
691 let add = match_single_route("/contacts/add", "/contacts/add", true).unwrap();
693 let param = match_single_route("/contacts/:id", "/contacts/add", true).unwrap();
694 assert!(add.specificity > param.specificity);
695 }
696
697 #[test]
698 fn parity_equal_specificity_first_wins() {
699 let m1 = match_single_route("/items/:a", "/items/x", true).unwrap();
702 let m2 = match_single_route("/items/:b", "/items/x", true).unwrap();
703 assert_eq!(m1.specificity, m2.specificity, "equal specificity expected");
704 }
705
706 #[test]
707 fn parity_relative_path_resolution() {
708 assert_eq!(
709 resolve_route_path("./topics/:id", "/sections/1"),
710 "/sections/1/topics/:id"
711 );
712 assert_eq!(
713 resolve_route_path("topics/:id", "/sections/1"),
714 "/sections/1/topics/:id"
715 );
716 assert_eq!(resolve_route_path("/absolute", "/any/base"), "/absolute");
717 assert_eq!(resolve_route_path("", "/base"), "/base");
718 assert_eq!(resolve_route_path("./", "/base"), "/base");
719 }
720
721 #[test]
722 fn parity_compute_route_base_various() {
723 assert_eq!(compute_route_base("/", 0), "/");
724 assert_eq!(compute_route_base("/a/b/c", 1), "/a");
725 assert_eq!(compute_route_base("/a/b/c", 2), "/a/b");
726 assert_eq!(compute_route_base("/a/b/c", 3), "/a/b/c");
727 assert_eq!(compute_route_base("/a/b/c", 99), "/a/b/c");
728 }
729
730 #[test]
731 fn parity_nested_route_matching() {
732 let parent =
734 match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
735 assert_eq!(parent.params["id"], "1");
736 assert_eq!(parent.consumed_segments, 2);
737
738 let base = compute_route_base("/sections/1/topics/react", parent.consumed_segments);
740 assert_eq!(base, "/sections/1");
741
742 let child_path = resolve_route_path("./topics/:topicId", &base);
743 assert_eq!(child_path, "/sections/1/topics/:topicId");
744
745 let child = match_single_route(&child_path, "/sections/1/topics/react", true).unwrap();
746 assert_eq!(child.params["topicId"], "react");
747 assert_eq!(child.consumed_segments, 4);
748 }
749
750 #[test]
751 fn compiled_index_matches_relative_suffix_without_request_key() {
752 let child = WebUiFragmentRoute {
753 path: "topics/:topicId".to_string(),
754 fragment_id: "topic-page".to_string(),
755 exact: true,
756 ..Default::default()
757 };
758 let parent = WebUiFragmentRoute {
759 path: "/sections/:sectionId".to_string(),
760 fragment_id: "section-page".to_string(),
761 children: vec![child],
762 ..Default::default()
763 };
764 let protocol = WebUIProtocol::new(HashMap::from([(
765 "index.html".to_string(),
766 FragmentList {
767 fragments: vec![WebUIFragment::route_from(parent)],
768 contains_boundary: false,
769 },
770 )]));
771 let index = CompiledRouteIndex::new(&protocol);
772 let request_segments = split_request_path("/sections/1/topics/react");
773
774 let route_match = match_route_indexed_with_segments(
775 &index,
776 "topics/:topicId",
777 "/sections/1",
778 &request_segments,
779 true,
780 )
781 .expect("relative child route should match");
782
783 assert_eq!(route_match.params["topicId"], "react");
784 assert_eq!(route_match.consumed_segments, 4);
785 assert_eq!(route_match.specificity, 3);
786 assert_eq!(index.patterns.len(), 2);
787 }
788
789 #[test]
790 fn parity_non_exact_prefix_with_extra_segments() {
791 let m = match_single_route("/app", "/app/dashboard/settings", false).unwrap();
792 assert_eq!(m.consumed_segments, 1);
793 assert_eq!(m.specificity, 1);
794 }
795
796 #[test]
797 fn parity_exact_rejects_extra_segments() {
798 assert!(match_single_route("/app", "/app/dashboard", true).is_none());
799 }
800
801 #[test]
802 fn parity_multiple_optional_params() {
803 let m = match_single_route("/search/:q?/:page?", "/search", true).unwrap();
804 assert!(!m.params.contains_key("q"));
805 assert!(!m.params.contains_key("page"));
806
807 let m = match_single_route("/search/:q?/:page?", "/search/rust", true).unwrap();
808 assert_eq!(m.params["q"], "rust");
809 assert!(!m.params.contains_key("page"));
810
811 let m = match_single_route("/search/:q?/:page?", "/search/rust/2", true).unwrap();
812 assert_eq!(m.params["q"], "rust");
813 assert_eq!(m.params["page"], "2");
814 }
815}