Skip to main content

webui_handler/
route_matcher.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Iterative path template matcher for server-side route resolution.
5//!
6//! Matches request paths against route path templates without regex.
7//! Supports `:param`, `:param?` (optional), and `*splat` segments.
8
9use std::collections::HashMap;
10
11/// Result of matching a request path against a route path template.
12#[derive(Debug, Clone)]
13pub struct RouteMatch {
14    /// The matched route name (or fragment ID).
15    pub route_key: String,
16    /// Bound parameter values from the path.
17    pub params: HashMap<String, String>,
18    /// How many literal (non-param) segments matched exactly.
19    pub specificity: usize,
20    /// How many request path segments were consumed by the match.
21    /// Used by nested routes to compute the child route base.
22    pub consumed_segments: usize,
23}
24
25/// A single parsed segment from a path template.
26#[derive(Debug, Clone)]
27enum SegmentPattern {
28    /// Literal segment (e.g., "users").
29    Literal(String),
30    /// Named parameter (e.g., `:id`).
31    Param(String),
32    /// Optional named parameter (e.g., `:id?`).
33    OptionalParam(String),
34    /// Splat / catch-all (e.g., `*rest` or `*`).
35    Splat(String),
36}
37
38/// Parse a path template string into segment patterns.
39fn parse_template(template: &str) -> Vec<SegmentPattern> {
40    let mut segments = Vec::new();
41
42    for part in template.split('/') {
43        if part.is_empty() {
44            continue;
45        }
46
47        if let Some(rest) = part.strip_prefix('*') {
48            let name = if rest.is_empty() {
49                "rest".to_string()
50            } else {
51                rest.to_string()
52            };
53            segments.push(SegmentPattern::Splat(name));
54        } else if let Some(rest) = part.strip_prefix(':') {
55            if let Some(name) = rest.strip_suffix('?') {
56                segments.push(SegmentPattern::OptionalParam(name.to_string()));
57            } else {
58                segments.push(SegmentPattern::Param(rest.to_string()));
59            }
60        } else {
61            segments.push(SegmentPattern::Literal(part.to_string()));
62        }
63    }
64
65    segments
66}
67
68/// Split a request path into segments, filtering empty parts.
69fn split_path(path: &str) -> Vec<&str> {
70    path.split('/').filter(|s| !s.is_empty()).collect()
71}
72
73/// Convert an ASCII hex digit to its numeric value.
74fn hex_val(b: u8) -> Option<u8> {
75    match b {
76        b'0'..=b'9' => Some(b - b'0'),
77        b'a'..=b'f' => Some(b - b'a' + 10),
78        b'A'..=b'F' => Some(b - b'A' + 10),
79        _ => None,
80    }
81}
82
83/// Percent-decode a URL path segment (e.g. `%2F` → `/`, `%20` → ` `).
84///
85/// Returns `None` if the input contains malformed percent-encoding (a `%` not
86/// followed by two hex digits) or if the decoded bytes are not valid UTF-8.
87fn percent_decode(input: &str) -> Option<String> {
88    let bytes = input.as_bytes();
89    if !bytes.contains(&b'%') {
90        return Some(input.to_owned());
91    }
92    let mut out = Vec::with_capacity(bytes.len());
93    let mut i = 0;
94    while i < bytes.len() {
95        if bytes[i] == b'%' {
96            if i + 2 >= bytes.len() {
97                return None;
98            }
99            let hi = hex_val(bytes[i + 1])?;
100            let lo = hex_val(bytes[i + 2])?;
101            out.push(hi << 4 | lo);
102            i += 3;
103        } else {
104            out.push(bytes[i]);
105            i += 1;
106        }
107    }
108    String::from_utf8(out).ok()
109}
110
111/// Validate and percent-decode a single route parameter segment.
112///
113/// Returns `None` (rejecting the match) if the segment:
114/// - contains malformed percent-encoding,
115/// - decodes to `..` (path traversal), or
116/// - contains a NUL byte (`\0`).
117fn validate_param(value: &str) -> Option<String> {
118    let decoded = percent_decode(value)?;
119    if decoded == ".." || decoded.bytes().any(|b| b == 0) {
120        return None;
121    }
122    Some(decoded)
123}
124
125/// Try matching a request path against pre-parsed route template patterns.
126///
127/// Returns `Some(RouteMatch)` if the path matches, `None` otherwise.
128fn try_match(
129    route_key: &str,
130    patterns: &[SegmentPattern],
131    request_segments: &[&str],
132    exact: bool,
133) -> Option<RouteMatch> {
134    let mut params = HashMap::new();
135    let mut specificity: usize = 0;
136    let mut req_idx = 0;
137    let mut pat_idx = 0;
138
139    while pat_idx < patterns.len() {
140        match &patterns[pat_idx] {
141            SegmentPattern::Literal(expected) => {
142                if req_idx >= request_segments.len() {
143                    return None;
144                }
145                if request_segments[req_idx] != expected.as_str() {
146                    return None;
147                }
148                specificity += 1;
149                req_idx += 1;
150            }
151            SegmentPattern::Param(name) => {
152                if req_idx >= request_segments.len() {
153                    return None;
154                }
155                params.insert(name.clone(), validate_param(request_segments[req_idx])?);
156                req_idx += 1;
157            }
158            SegmentPattern::OptionalParam(name) => {
159                if req_idx < request_segments.len() {
160                    params.insert(name.clone(), validate_param(request_segments[req_idx])?);
161                    req_idx += 1;
162                }
163                // Optional — ok to skip
164            }
165            SegmentPattern::Splat(name) => {
166                // Splat consumes all remaining segments; validate each individually.
167                let remaining = &request_segments[req_idx..];
168                let mut decoded_parts = Vec::with_capacity(remaining.len());
169                for seg in remaining {
170                    decoded_parts.push(validate_param(seg)?);
171                }
172                params.insert(name.clone(), decoded_parts.join("/"));
173                req_idx = request_segments.len();
174            }
175        }
176        pat_idx += 1;
177    }
178
179    // If exact matching required, all request segments must be consumed
180    if exact && req_idx < request_segments.len() {
181        return None;
182    }
183
184    // All pattern segments must be consumed (non-optional ones already checked)
185    // But remaining patterns must all be optional
186    while pat_idx < patterns.len() {
187        match &patterns[pat_idx] {
188            SegmentPattern::OptionalParam(_) | SegmentPattern::Splat(_) => {}
189            _ => return None,
190        }
191        pat_idx += 1;
192    }
193
194    Some(RouteMatch {
195        route_key: route_key.to_string(),
196        params,
197        specificity,
198        consumed_segments: req_idx,
199    })
200}
201
202/// Match a single route template against a request path.
203///
204/// Returns `Some(RouteMatch)` if the path matches, `None` otherwise.
205/// Used by the SSR route pruning to check individual `<f-route>` elements.
206pub fn match_single_route(template: &str, request_path: &str, exact: bool) -> Option<RouteMatch> {
207    let request_segments = split_path(request_path);
208    let patterns = parse_template(template);
209    try_match("", &patterns, &request_segments, exact)
210}
211
212/// Check whether a route path is relative (does NOT start with `/`).
213pub fn is_relative_path(path: &str) -> bool {
214    !path.is_empty() && !path.starts_with('/')
215}
216
217/// Resolve a route path against a base path.
218///
219/// - Relative paths (`topics/:id` or `./topics/:id`) are prepended with the base.
220/// - Absolute paths (`/topics/:id`) are returned unchanged.
221///
222/// `"topics/:id"` + `"/sections/1"` → `"/sections/1/topics/:id"`
223/// `"./topics/:id"` + `"/sections/1"` → `"/sections/1/topics/:id"`
224/// `"/topics/:id"` + `"/sections/1"` → `"/topics/:id"`
225pub fn resolve_route_path(path: &str, route_base: &str) -> String {
226    // An empty path means "match at the parent level" — resolve to base.
227    if path.is_empty() {
228        return route_base.to_string();
229    }
230
231    if !is_relative_path(path) {
232        return path.to_string();
233    }
234
235    // Strip leading "./" if present
236    let relative = path.strip_prefix("./").unwrap_or(path);
237    if relative.is_empty() {
238        return route_base.to_string();
239    }
240
241    let mut resolved = String::with_capacity(route_base.len() + relative.len() + 1);
242    resolved.push_str(route_base);
243    if !route_base.ends_with('/') {
244        resolved.push('/');
245    }
246    resolved.push_str(relative);
247    resolved
248}
249
250/// Compute the new route base from a matched route's consumed request segments.
251///
252/// Given request path `/sections/1/topics/react` and consumed=2,
253/// returns `/sections/1`.
254pub fn compute_route_base(request_path: &str, consumed_segments: usize) -> String {
255    let parts = split_path(request_path);
256    if consumed_segments == 0 || parts.is_empty() {
257        return "/".to_string();
258    }
259
260    let n = consumed_segments.min(parts.len());
261    let mut base = String::with_capacity(request_path.len());
262    for part in &parts[..n] {
263        base.push('/');
264        base.push_str(part);
265    }
266    base
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_match_single_route_exact() {
275        let m = match_single_route("/contacts", "/contacts", true).unwrap();
276        assert!(m.params.is_empty());
277    }
278
279    #[test]
280    fn test_match_single_route_param() {
281        let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
282        assert_eq!(m.params["id"], "42");
283    }
284
285    #[test]
286    fn test_match_single_route_no_match_extra() {
287        assert!(match_single_route("/contacts", "/contacts/123", true).is_none());
288    }
289
290    #[test]
291    fn test_match_single_route_multiple_params() {
292        let m = match_single_route("/profile/:id/view/:section", "/profile/123/view/bio", true)
293            .unwrap();
294        assert_eq!(m.params["id"], "123");
295        assert_eq!(m.params["section"], "bio");
296    }
297
298    #[test]
299    fn test_match_single_route_splat() {
300        let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
301        assert_eq!(m.params["path"], "docs/readme.md");
302    }
303
304    #[test]
305    fn test_match_single_route_optional_param_present() {
306        let m = match_single_route("/search/:query?", "/search/hello", true).unwrap();
307        assert_eq!(m.params["query"], "hello");
308    }
309
310    #[test]
311    fn test_match_single_route_optional_param_absent() {
312        let m = match_single_route("/search/:query?", "/search", true).unwrap();
313        assert!(!m.params.contains_key("query"));
314    }
315
316    #[test]
317    fn test_parse_template_segments() {
318        let segs = parse_template("/users/:id/posts/:postId");
319        assert_eq!(segs.len(), 4);
320        assert!(matches!(&segs[0], SegmentPattern::Literal(s) if s == "users"));
321        assert!(matches!(&segs[1], SegmentPattern::Param(s) if s == "id"));
322        assert!(matches!(&segs[2], SegmentPattern::Literal(s) if s == "posts"));
323        assert!(matches!(&segs[3], SegmentPattern::Param(s) if s == "postId"));
324    }
325
326    #[test]
327    fn test_match_single_route_non_exact_prefix() {
328        let m = match_single_route("/", "/anything", false).unwrap();
329        assert!(m.params.is_empty());
330    }
331
332    // ── Consumed segments tests ──
333
334    #[test]
335    fn test_consumed_segments_exact() {
336        let m = match_single_route("/contacts/:id", "/contacts/42", true).unwrap();
337        assert_eq!(m.consumed_segments, 2);
338    }
339
340    #[test]
341    fn test_consumed_segments_prefix() {
342        let m = match_single_route("/sections/:id", "/sections/1/topics/react", false).unwrap();
343        assert_eq!(m.consumed_segments, 2);
344    }
345
346    #[test]
347    fn test_consumed_segments_root() {
348        let m = match_single_route("/", "/", true).unwrap();
349        assert_eq!(m.consumed_segments, 0);
350    }
351
352    #[test]
353    fn test_consumed_segments_splat() {
354        let m = match_single_route("/files/*path", "/files/docs/readme.md", false).unwrap();
355        assert_eq!(m.consumed_segments, 3);
356    }
357
358    // ── Relative path resolution tests ──
359
360    #[test]
361    fn test_is_relative_path() {
362        assert!(is_relative_path("./sections/:id"));
363        assert!(is_relative_path("./"));
364        assert!(is_relative_path("sections/:id")); // bare relative
365        assert!(is_relative_path("topics/:topicId")); // bare relative
366        assert!(!is_relative_path("/sections/:id")); // absolute
367        assert!(!is_relative_path("")); // empty
368    }
369
370    #[test]
371    fn test_resolve_route_path_dotslash_relative() {
372        assert_eq!(
373            resolve_route_path("./topics/:id", "/sections/1"),
374            "/sections/1/topics/:id"
375        );
376    }
377
378    #[test]
379    fn test_resolve_route_path_bare_relative() {
380        assert_eq!(
381            resolve_route_path("topics/:id", "/sections/1"),
382            "/sections/1/topics/:id"
383        );
384    }
385
386    #[test]
387    fn test_resolve_route_path_relative_at_root() {
388        assert_eq!(resolve_route_path("./sections/:id", "/"), "/sections/:id");
389        assert_eq!(resolve_route_path("sections/:id", "/"), "/sections/:id");
390    }
391
392    #[test]
393    fn test_resolve_route_path_absolute_unchanged() {
394        assert_eq!(
395            resolve_route_path("/sections/:id", "/some/base"),
396            "/sections/:id"
397        );
398    }
399
400    #[test]
401    fn test_resolve_route_path_deep_nesting() {
402        assert_eq!(
403            resolve_route_path("./lessons/:lessonId", "/sections/1/topics/react"),
404            "/sections/1/topics/react/lessons/:lessonId"
405        );
406        assert_eq!(
407            resolve_route_path("lessons/:lessonId", "/sections/1/topics/react"),
408            "/sections/1/topics/react/lessons/:lessonId"
409        );
410    }
411
412    #[test]
413    fn test_resolve_route_path_empty_resolves_to_base() {
414        assert_eq!(resolve_route_path("", "/search"), "/search");
415        assert_eq!(resolve_route_path("", "/"), "/");
416        assert_eq!(resolve_route_path("", "/a/b/c"), "/a/b/c");
417    }
418
419    #[test]
420    fn test_empty_child_route_matches_parent_path_exactly() {
421        // Simulates <route path="search"> <route path="" exact /> when visiting /search
422        let resolved = resolve_route_path("", "/search");
423        let m = match_single_route(&resolved, "/search", true);
424        assert!(m.is_some(), "empty child route must match parent path");
425        let m = m.unwrap();
426        assert_eq!(m.consumed_segments, 1);
427    }
428
429    // ── Compute route base tests ──
430
431    #[test]
432    fn test_compute_route_base_two_segments() {
433        assert_eq!(
434            compute_route_base("/sections/1/topics/react", 2),
435            "/sections/1"
436        );
437    }
438
439    #[test]
440    fn test_compute_route_base_zero() {
441        assert_eq!(compute_route_base("/sections/1", 0), "/");
442    }
443
444    #[test]
445    fn test_compute_route_base_all_segments() {
446        assert_eq!(
447            compute_route_base("/sections/1/topics/react", 4),
448            "/sections/1/topics/react"
449        );
450    }
451
452    #[test]
453    fn test_compute_route_base_exceeds_segments() {
454        assert_eq!(compute_route_base("/sections/1", 10), "/sections/1");
455    }
456
457    #[test]
458    fn test_param_rejects_traversal() {
459        // Exact `..` segment is path traversal — must reject.
460        assert!(match_single_route("/files/:name", "/files/..", true).is_none());
461    }
462
463    #[test]
464    fn test_param_allows_double_dot_prefix() {
465        // `..something` is NOT traversal (not a standalone `..` segment).
466        let m = match_single_route("/files/:name", "/files/..something", true).unwrap();
467        assert_eq!(m.params["name"], "..something");
468    }
469
470    #[test]
471    fn test_param_rejects_null_bytes() {
472        assert!(match_single_route("/users/:id", "/users/test\0injected", true).is_none());
473    }
474
475    #[test]
476    fn test_splat_rejects_traversal() {
477        assert!(
478            match_single_route("/files/*path", "/files/docs/../../../etc/passwd", false).is_none()
479        );
480    }
481
482    #[test]
483    fn test_param_rejects_encoded_traversal() {
484        // %2e%2e decodes to `..`
485        assert!(match_single_route("/files/:name", "/files/%2e%2e", true).is_none());
486    }
487
488    #[test]
489    fn test_param_rejects_mixed_encoded_traversal() {
490        // .%2e decodes to `..`
491        assert!(match_single_route("/files/:name", "/files/.%2e", true).is_none());
492        // %2e. decodes to `..`
493        assert!(match_single_route("/files/:name", "/files/%2e.", true).is_none());
494    }
495
496    #[test]
497    fn test_param_rejects_encoded_null() {
498        assert!(match_single_route("/users/:id", "/users/test%00injected", true).is_none());
499    }
500
501    #[test]
502    fn test_param_rejects_malformed_percent() {
503        assert!(match_single_route("/users/:id", "/users/100%", true).is_none());
504        assert!(match_single_route("/users/:id", "/users/100%ZZ", true).is_none());
505    }
506
507    #[test]
508    fn test_param_decodes_valid_percent_encoding() {
509        let m = match_single_route("/files/:name", "/files/hello%20world", true).unwrap();
510        assert_eq!(m.params["name"], "hello world");
511    }
512
513    #[test]
514    fn test_splat_decodes_segments() {
515        let m = match_single_route("/files/*path", "/files/my%20docs/read%20me.md", false).unwrap();
516        assert_eq!(m.params["path"], "my docs/read me.md");
517    }
518
519    #[test]
520    fn test_splat_rejects_encoded_traversal() {
521        assert!(
522            match_single_route("/files/*path", "/files/docs/%2e%2e/etc/passwd", false).is_none()
523        );
524    }
525}