1use std::collections::HashMap;
10
11#[derive(Debug, Clone)]
13pub struct RouteMatch {
14 pub route_key: String,
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
38fn 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
68fn split_path(path: &str) -> Vec<&str> {
70 path.split('/').filter(|s| !s.is_empty()).collect()
71}
72
73fn 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
83fn 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
111fn 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
125fn 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 }
165 SegmentPattern::Splat(name) => {
166 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 && req_idx < request_segments.len() {
181 return None;
182 }
183
184 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
202pub 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
212pub fn is_relative_path(path: &str) -> bool {
214 !path.is_empty() && !path.starts_with('/')
215}
216
217pub fn resolve_route_path(path: &str, route_base: &str) -> String {
226 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 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
250pub 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 #[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 #[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")); assert!(is_relative_path("topics/:topicId")); assert!(!is_relative_path("/sections/:id")); assert!(!is_relative_path("")); }
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 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 #[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 assert!(match_single_route("/files/:name", "/files/..", true).is_none());
461 }
462
463 #[test]
464 fn test_param_allows_double_dot_prefix() {
465 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 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 assert!(match_single_route("/files/:name", "/files/.%2e", true).is_none());
492 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}