Skip to main content

reinhardt_urls/routers/
pattern.rs

1use aho_corasick::AhoCorasick;
2use matchit::Router as MatchitRouter;
3use regex::Regex;
4use reinhardt_http::PathParams;
5use std::collections::{HashMap, HashSet};
6
7/// Maximum allowed length for a URL pattern string in bytes.
8/// Patterns exceeding this limit are rejected to prevent ReDoS attacks
9/// from excessively long or complex regex patterns.
10const MAX_PATTERN_LENGTH: usize = 1024;
11
12/// Maximum allowed number of path segments in a URL pattern.
13/// Patterns with more segments than this are rejected to prevent
14/// resource exhaustion from deeply nested URL structures.
15const MAX_PATH_SEGMENTS: usize = 32;
16
17/// Maximum allowed size for compiled regex (in bytes).
18/// This limits the compiled regex DFA size to prevent memory exhaustion.
19const MAX_REGEX_SIZE: usize = 1 << 20; // 1 MiB
20
21/// Convert a type specifier to its corresponding regex pattern
22///
23/// This function maps type specifiers from `{<type:name>}` syntax
24/// to appropriate regex patterns for URL matching.
25///
26/// # Supported Type Specifiers
27///
28/// | Type | Pattern | Description |
29/// |------|---------|-------------|
30/// | `int` | `[0-9]+` | Unsigned integer (legacy) |
31/// | `i8`, `i16`, `i32`, `i64` | `-?[0-9]+` | Signed integers |
32/// | `u8`, `u16`, `u32`, `u64` | `[0-9]+` | Unsigned integers |
33/// | `f32`, `f64` | `-?[0-9]+(?:\.[0-9]+)?` | Floating point |
34/// | `str` | `[^/]+` | Any non-slash characters (default) |
35/// | `uuid` | UUID regex | UUID format |
36/// | `slug` | `[a-z0-9]+(?:-[a-z0-9]+)*` | Lowercase slug |
37/// | `path` | `.+` | Any characters **including** path separators (`/`); `..` segments are rejected by post-match validation |
38/// | `bool` | `true\|false\|1\|0` | Boolean literals |
39/// | `email` | Email regex | Email format |
40/// | `date` | `[0-9]{4}-[0-9]{2}-[0-9]{2}` | ISO 8601 date |
41fn type_spec_to_regex(type_spec: &str) -> &'static str {
42	match type_spec {
43		// Basic types (legacy)
44		"int" => r"[0-9]+",
45		"str" => r"[^/]+",
46		"uuid" => r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
47		"slug" => r"[a-z0-9]+(?:-[a-z0-9]+)*",
48		// Matches any characters including path separators (`/`).
49		// A pattern like `/files/{<path:filepath>}` will match
50		// `/files/a/b/c.txt`, capturing `a/b/c.txt` as a single value.
51		// Directory traversal (`..` segments) is rejected by post-match
52		// validation in extract_params() and match_path_linear().
53		"path" => r".+",
54		// Signed integers
55		"i8" | "i16" | "i32" | "i64" => r"-?[0-9]+",
56		// Unsigned integers
57		"u8" | "u16" | "u32" | "u64" => r"[0-9]+",
58		// Floating point
59		"f32" | "f64" => r"-?[0-9]+(?:\.[0-9]+)?",
60		// Other types
61		"bool" => r"true|false|1|0",
62		"email" => r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
63		"date" => r"[0-9]{4}-[0-9]{2}-[0-9]{2}",
64		// Default: treat as string
65		_ => r"[^/]+",
66	}
67}
68
69/// Validate that a matched path value does not contain directory traversal sequences.
70///
71/// This provides defense-in-depth for `path` type parameters by checking
72/// extracted values for `..` segments that could enable path traversal.
73///
74/// Rejects:
75/// - `..` as a path segment (forward-slash or backslash separated)
76/// - Percent-encoded traversal sequences (`%2e`, `%2f`, `%2E`, `%2F`, `%5c`, `%5C`)
77/// - Null bytes (literal or encoded `%00`)
78/// - Absolute paths starting with `/` or `\`
79fn validate_path_param(value: &str) -> bool {
80	// Reject null bytes
81	if value.contains('\0') {
82		return false;
83	}
84
85	// Reject percent-encoded dangerous characters:
86	// %2e / %2E = '.', %2f / %2F = '/', %5c / %5C = '\', %00 = null
87	let lower = value.to_ascii_lowercase();
88	if lower.contains("%2e")
89		|| lower.contains("%2f")
90		|| lower.contains("%5c")
91		|| lower.contains("%00")
92	{
93		return false;
94	}
95
96	// Reject absolute paths
97	if value.starts_with('/') || value.starts_with('\\') {
98		return false;
99	}
100
101	// Check for `..` as a complete path segment (forward-slash separated)
102	for segment in value.split('/') {
103		if segment == ".." {
104			return false;
105		}
106	}
107	// Also reject backslash-separated `..` segments
108	for segment in value.split('\\') {
109		if segment == ".." {
110			return false;
111		}
112	}
113
114	true
115}
116
117/// Validate a parameter value for URL reversal against injection attacks.
118///
119/// Rejects values containing:
120/// - Path separators (`/`, `\`)
121/// - Query string delimiters (`?`)
122/// - Fragment identifiers (`#`)
123/// - Null bytes
124/// - Path traversal sequences (`..`)
125/// - Percent-encoded dangerous characters (`%2f`, `%2e`, `%5c`, `%3f`, `%23`, `%00`)
126pub(crate) fn validate_reverse_param(value: &str) -> bool {
127	// Reject null bytes
128	if value.contains('\0') {
129		return false;
130	}
131
132	// Reject path separators and URL-special characters
133	if value.contains('/') || value.contains('\\') || value.contains('?') || value.contains('#') {
134		return false;
135	}
136
137	// Reject path traversal
138	if value == ".." || value.starts_with("../") || value.ends_with("/..") || value.contains("/../")
139	{
140		return false;
141	}
142
143	// Reject percent-encoded dangerous characters
144	let lower = value.to_ascii_lowercase();
145	if lower.contains("%2f")
146		|| lower.contains("%2e")
147		|| lower.contains("%5c")
148		|| lower.contains("%3f")
149		|| lower.contains("%23")
150		|| lower.contains("%00")
151	{
152		return false;
153	}
154
155	true
156}
157
158/// Path pattern for URL matching
159/// Similar to Django's URL patterns but using composition
160#[derive(Clone, Debug)]
161pub struct PathPattern {
162	/// Original pattern string (may contain type specifiers)
163	pattern: String,
164	/// Pattern normalized to `{name}` format for URL reversal
165	normalized_pattern: String,
166	regex: Regex,
167	param_names: Vec<String>,
168	/// Parameter names that use the `path` type specifier.
169	/// These require post-match validation to reject directory traversal.
170	path_type_params: HashSet<String>,
171	/// Pre-built Aho-Corasick automaton for efficient URL reversal
172	/// This is constructed once during pattern creation for O(n+m+z) reversal
173	aho_corasick: Option<AhoCorasick>,
174}
175
176/// Parse result containing regex, param names, and normalized pattern for URL reversal
177struct ParsePatternResult {
178	regex_str: String,
179	param_names: Vec<String>,
180	/// Parameter names that use the `path` type specifier
181	path_type_params: HashSet<String>,
182	/// Pattern normalized to `{name}` format for URL reversal
183	/// e.g., "/users/{<int:id>}/" -> "/users/{id}/"
184	normalized_pattern: String,
185}
186
187impl PathPattern {
188	/// Create a new path pattern
189	/// Patterns like "/users/{id}/" are converted to regex
190	///
191	/// # Examples
192	///
193	/// ```
194	/// use reinhardt_urls::routers::{PathPattern, path};
195	///
196	/// // Create a simple pattern without parameters
197	/// let pattern = PathPattern::new(path!("/users/")).unwrap();
198	/// assert_eq!(pattern.pattern(), "/users/");
199	///
200	/// // Create a pattern with a parameter
201	/// let pattern = PathPattern::new(path!("/users/{id}/")).unwrap();
202	/// assert_eq!(pattern.param_names(), &["id"]);
203	/// ```
204	pub fn new(pattern: impl Into<String>) -> Result<Self, String> {
205		let pattern = pattern.into();
206
207		// Reject patterns exceeding the maximum length to prevent ReDoS
208		if pattern.len() > MAX_PATTERN_LENGTH {
209			return Err(format!(
210				"Pattern length {} exceeds maximum allowed length of {} bytes",
211				pattern.len(),
212				MAX_PATTERN_LENGTH
213			));
214		}
215
216		// Reject patterns with excessive path segments to prevent resource exhaustion
217		let segment_count = pattern.split('/').count();
218		if segment_count > MAX_PATH_SEGMENTS {
219			return Err(format!(
220				"Pattern has {} path segments, exceeding maximum of {}",
221				segment_count, MAX_PATH_SEGMENTS
222			));
223		}
224
225		let parse_result = Self::parse_pattern(&pattern)?;
226
227		// Use RegexBuilder with size limits to prevent memory exhaustion
228		let regex = regex::RegexBuilder::new(&parse_result.regex_str)
229			.size_limit(MAX_REGEX_SIZE)
230			.build()
231			.map_err(|e| format!("Failed to compile pattern regex: {}", e))?;
232
233		// Build Aho-Corasick automaton for URL reversal if there are parameters
234		let aho_corasick = if !parse_result.param_names.is_empty() {
235			let placeholders: Vec<String> = parse_result
236				.param_names
237				.iter()
238				.map(|name| format!("{{{}}}", name))
239				.collect();
240
241			AhoCorasick::new(&placeholders)
242				.map(Some)
243				.map_err(|e| format!("Failed to build Aho-Corasick automaton: {}", e))?
244		} else {
245			None
246		};
247
248		Ok(Self {
249			pattern,
250			normalized_pattern: parse_result.normalized_pattern,
251			regex,
252			param_names: parse_result.param_names,
253			path_type_params: parse_result.path_type_params,
254			aho_corasick,
255		})
256	}
257
258	fn parse_pattern(pattern: &str) -> Result<ParsePatternResult, String> {
259		let mut regex_str = String::from("^");
260		let mut param_names = Vec::new();
261		let mut path_type_params = HashSet::new();
262		let mut normalized_pattern = String::new();
263		let mut chars = pattern.chars().peekable();
264
265		while let Some(ch) = chars.next() {
266			match ch {
267				'{' => {
268					// Extract parameter content (everything between { and })
269					let mut param_content = String::new();
270					while let Some(&next_ch) = chars.peek() {
271						if next_ch == '}' {
272							chars.next(); // consume '}'
273							break;
274						}
275						param_content.push(chars.next().unwrap());
276					}
277
278					if param_content.is_empty() {
279						return Err("Empty parameter name".to_string());
280					}
281
282					// Check for typed parameter syntax: {<type:name>}
283					let (param_name, regex_pattern) =
284						if param_content.starts_with('<') && param_content.ends_with('>') {
285							// Parse {<type:name>}
286							let inner = &param_content[1..param_content.len() - 1]; // Remove < >
287							if let Some(colon_pos) = inner.find(':') {
288								let type_spec = &inner[..colon_pos];
289								let name = &inner[colon_pos + 1..];
290								if name.is_empty() {
291									return Err(format!(
292										"Empty parameter name in typed parameter: {{<{}:>}}",
293										type_spec
294									));
295								}
296								if type_spec == "path" {
297									path_type_params.insert(name.to_string());
298								}
299								(name.to_string(), type_spec_to_regex(type_spec))
300							} else {
301								return Err(format!(
302									"Invalid typed parameter syntax: {{<{}>}}. Expected {{<type:name>}}",
303									inner
304								));
305							}
306						} else {
307							// Simple {name} parameter - use default [^/]+
308							(param_content, "[^/]+")
309						};
310
311					param_names.push(param_name.clone());
312					regex_str.push_str(&format!("(?P<{}>{})", param_name, regex_pattern));
313					// Write normalized placeholder for URL reversal
314					normalized_pattern.push_str(&format!("{{{}}}", param_name));
315				}
316				_ => {
317					// Escape special regex characters
318					if ".*+?^${}()|[]\\".contains(ch) {
319						regex_str.push('\\');
320					}
321					regex_str.push(ch);
322					// Copy literal characters to normalized pattern
323					normalized_pattern.push(ch);
324				}
325			}
326		}
327
328		regex_str.push('$');
329		Ok(ParsePatternResult {
330			regex_str,
331			param_names,
332			path_type_params,
333			normalized_pattern,
334		})
335	}
336	/// Get the original pattern string
337	///
338	/// # Examples
339	///
340	/// ```
341	/// use reinhardt_urls::routers::{PathPattern, path};
342	///
343	/// let pattern = PathPattern::new(path!("/users/{id}/")).unwrap();
344	/// assert_eq!(pattern.pattern(), "/users/{id}/");
345	/// ```
346	pub fn pattern(&self) -> &str {
347		&self.pattern
348	}
349
350	/// Convert pattern to matchit-compatible format
351	///
352	/// Transforms path-type parameters from `{<path:name>}` to `{*name}`
353	/// for use with the matchit radix router. Non-path parameters remain
354	/// as `{name}`.
355	pub(crate) fn to_matchit_pattern(&self) -> String {
356		let mut result = String::new();
357		let mut chars = self.pattern.chars().peekable();
358
359		while let Some(ch) = chars.next() {
360			if ch == '{' {
361				let mut param_content = String::new();
362				while let Some(&next_ch) = chars.peek() {
363					if next_ch == '}' {
364						chars.next();
365						break;
366					}
367					param_content.push(chars.next().unwrap());
368				}
369
370				// Check for typed parameter: {<type:name>}
371				if param_content.starts_with('<') && param_content.ends_with('>') {
372					let inner = &param_content[1..param_content.len() - 1];
373					if let Some(colon_pos) = inner.find(':') {
374						let type_spec = &inner[..colon_pos];
375						let name = &inner[colon_pos + 1..];
376						if type_spec == "path" {
377							// Convert path type to matchit catch-all: {*name}
378							result.push_str(&format!("{{*{}}}", name));
379						} else {
380							// Other typed params use simple {name}
381							result.push_str(&format!("{{{}}}", name));
382						}
383					} else {
384						result.push_str(&format!("{{{}}}", param_content));
385					}
386				} else {
387					// Simple {name} parameter
388					result.push_str(&format!("{{{}}}", param_content));
389				}
390			} else {
391				result.push(ch);
392			}
393		}
394
395		result
396	}
397	/// Get the list of parameter names in the pattern
398	///
399	/// # Examples
400	///
401	/// ```
402	/// use reinhardt_urls::routers::{PathPattern, path};
403	///
404	/// let pattern = PathPattern::new(path!("/users/{user_id}/posts/{post_id}/")).unwrap();
405	/// assert_eq!(pattern.param_names(), &["user_id", "post_id"]);
406	/// ```
407	pub fn param_names(&self) -> &[String] {
408		&self.param_names
409	}
410
411	/// Test if the pattern matches a given path
412	///
413	/// # Examples
414	///
415	/// ```
416	/// use reinhardt_urls::routers::{PathPattern, path};
417	///
418	/// let pattern = PathPattern::new(path!("/users/{id}/")).unwrap();
419	/// assert!(pattern.is_match("/users/123/"));
420	/// assert!(!pattern.is_match("/users/"));
421	/// ```
422	pub fn is_match(&self, path: &str) -> bool {
423		self.regex.is_match(path)
424	}
425
426	/// Match a path and extract parameters
427	///
428	/// # Examples
429	///
430	/// ```
431	/// use reinhardt_urls::routers::{PathPattern, path};
432	///
433	/// let pattern = PathPattern::new(path!("/users/{id}/")).unwrap();
434	/// let params = pattern.extract_params("/users/123/").unwrap();
435	/// assert_eq!(params.get("id"), Some(&"123".to_string()));
436	/// ```
437	pub fn extract_params(&self, path: &str) -> Option<HashMap<String, String>> {
438		self.regex.captures(path).and_then(|captures| {
439			let mut params = HashMap::new();
440			for name in self.param_names() {
441				if let Some(value) = captures.name(name) {
442					let val = value.as_str();
443					// Validate path-type parameters against directory traversal
444					if self.path_type_params.contains(name) && !validate_path_param(val) {
445						return None;
446					}
447					params.insert(name.clone(), val.to_string());
448				}
449			}
450			Some(params)
451		})
452	}
453
454	/// Reverse URL pattern with parameters using Aho-Corasick algorithm
455	///
456	/// This method uses pre-built Aho-Corasick automaton for efficient
457	/// multi-pattern matching with O(n+m+z) complexity where:
458	/// - n: pattern length
459	/// - m: total parameter values length
460	/// - z: number of placeholder matches
461	///
462	/// # Arguments
463	///
464	/// * `params` - HashMap of parameter names to values
465	///
466	/// # Returns
467	///
468	/// * `Ok(String)` - Reversed URL with parameters substituted
469	/// * `Err(String)` - If required parameters are missing
470	///
471	/// # Examples
472	///
473	/// ```
474	/// use reinhardt_urls::routers::{PathPattern, path};
475	/// use std::collections::HashMap;
476	///
477	/// let pattern = PathPattern::new(path!("/users/{id}/posts/{post_id}/")).unwrap();
478	///
479	/// let mut params = HashMap::new();
480	/// params.insert("id".to_string(), "123".to_string());
481	/// params.insert("post_id".to_string(), "456".to_string());
482	///
483	/// let url = pattern.reverse(&params).unwrap();
484	/// assert_eq!(url, "/users/123/posts/456/");
485	/// ```
486	pub fn reverse(&self, params: &HashMap<String, String>) -> Result<String, String> {
487		// Validate all required parameters are present
488		for param_name in &self.param_names {
489			if !params.contains_key(param_name) {
490				return Err(format!("Missing required parameter: {}", param_name));
491			}
492		}
493
494		// Validate parameter values against injection attacks
495		for (name, value) in params {
496			if !validate_reverse_param(value) {
497				return Err(format!(
498					"Invalid parameter value for '{}': contains dangerous characters",
499					name
500				));
501			}
502		}
503
504		// If no parameters, return normalized pattern as-is
505		if self.param_names.is_empty() {
506			return Ok(self.normalized_pattern.clone());
507		}
508
509		// Use Aho-Corasick if available, otherwise fallback to single-pass
510		match &self.aho_corasick {
511			Some(ac) => {
512				// Find all matches using Aho-Corasick on normalized pattern
513				let mut replacements = Vec::new();
514				for mat in ac.find_iter(&self.normalized_pattern) {
515					let param_name = &self.param_names[mat.pattern()];
516					// We already validated all params exist above
517					let value = params.get(param_name).unwrap();
518					replacements.push((mat.start(), mat.end(), value.clone()));
519				}
520
521				// Apply replacements from right to left to avoid position shifts
522				let mut result = self.normalized_pattern.clone();
523				for (start, end, value) in replacements.into_iter().rev() {
524					result.replace_range(start..end, &value);
525				}
526
527				Ok(result)
528			}
529			None => {
530				// Fallback: no parameters, just return normalized pattern
531				Ok(self.normalized_pattern.clone())
532			}
533		}
534	}
535}
536
537/// Matching mode for PathMatcher
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum MatchingMode {
540	/// Linear O(n) matching through all patterns (default)
541	Linear,
542	/// Radix Tree O(m) matching using matchit (recommended for >100 routes)
543	RadixTree,
544}
545
546/// Path matcher - uses composition to match paths
547///
548/// Supports two matching modes:
549/// - **Linear** (default): O(n) search through patterns, suitable for <100 routes
550/// - **RadixTree**: O(m) matching using radix tree, recommended for >100 routes
551pub struct PathMatcher {
552	patterns: Vec<(PathPattern, String)>, // (pattern, handler_id)
553	radix_router: Option<RadixRouter>,
554	mode: MatchingMode,
555}
556
557impl PathMatcher {
558	/// Create a new PathMatcher with linear matching (default)
559	///
560	/// # Examples
561	///
562	/// ```
563	/// use reinhardt_urls::routers::PathMatcher;
564	///
565	/// let matcher = PathMatcher::new();
566	/// assert_eq!(matcher.match_path("/users/"), None);
567	/// ```
568	pub fn new() -> Self {
569		Self {
570			patterns: Vec::new(),
571			radix_router: None,
572			mode: MatchingMode::Linear,
573		}
574	}
575
576	/// Create a new PathMatcher with specified matching mode
577	///
578	/// # Examples
579	///
580	/// ```
581	/// use reinhardt_urls::routers::{PathMatcher, MatchingMode};
582	///
583	/// let matcher = PathMatcher::with_mode(MatchingMode::RadixTree);
584	/// ```
585	pub fn with_mode(mode: MatchingMode) -> Self {
586		Self {
587			patterns: Vec::new(),
588			radix_router: if mode == MatchingMode::RadixTree {
589				Some(RadixRouter::new())
590			} else {
591				None
592			},
593			mode,
594		}
595	}
596
597	/// Enable radix tree matching mode
598	///
599	/// Rebuilds the radix router from existing patterns.
600	/// Recommended when route count exceeds 100.
601	///
602	/// # Examples
603	///
604	/// ```
605	/// use reinhardt_urls::routers::{PathMatcher, PathPattern, path};
606	///
607	/// let mut matcher = PathMatcher::new();
608	/// let pattern = PathPattern::new(path!("/users/")).unwrap();
609	/// matcher.add_pattern(pattern, "users_list".to_string());
610	///
611	/// // Enable radix tree mode
612	/// matcher.enable_radix_tree();
613	///
614	/// let result = matcher.match_path("/users/");
615	/// assert!(result.is_some());
616	/// ```
617	pub fn enable_radix_tree(&mut self) {
618		if self.mode == MatchingMode::RadixTree {
619			return; // Already enabled
620		}
621
622		self.mode = MatchingMode::RadixTree;
623		let mut radix_router = RadixRouter::new();
624
625		// Rebuild radix router from existing patterns
626		for (pattern, handler_id) in &self.patterns {
627			let _ = radix_router.add_route(&pattern.to_matchit_pattern(), handler_id.clone());
628		}
629
630		self.radix_router = Some(radix_router);
631	}
632
633	/// Get current matching mode
634	pub fn mode(&self) -> MatchingMode {
635		self.mode
636	}
637	/// Add a pattern to the matcher
638	///
639	/// If radix tree mode is enabled, also adds to the radix router.
640	///
641	/// # Examples
642	///
643	/// ```
644	/// use reinhardt_urls::routers::{PathMatcher, PathPattern, path};
645	///
646	/// let mut matcher = PathMatcher::new();
647	/// let pattern = PathPattern::new(path!("/users/")).unwrap();
648	/// matcher.add_pattern(pattern, "users_list".to_string());
649	///
650	/// let result = matcher.match_path("/users/");
651	/// assert!(result.is_some());
652	/// ```
653	pub fn add_pattern(&mut self, pattern: PathPattern, handler_id: String) {
654		let matchit_pattern = pattern.to_matchit_pattern();
655		self.patterns.push((pattern, handler_id.clone()));
656
657		// If radix tree mode is enabled, also add to radix router
658		if let Some(ref mut radix_router) = self.radix_router {
659			let _ = radix_router.add_route(&matchit_pattern, handler_id);
660		}
661	}
662	/// Match a path and extract parameters
663	///
664	/// Uses the configured matching mode:
665	/// - **Linear**: O(n) search through patterns (default)
666	/// - **RadixTree**: O(m) radix tree matching where m = path length
667	///
668	/// # Performance Notes
669	///
670	/// - **Linear mode**: O(n×m) where n = route count, m = path length
671	///   - Suitable for <100 routes
672	///   - Benefits from RouteCache for O(1) on cache hits
673	/// - **RadixTree mode**: O(m) where m = path length
674	///   - Recommended for >100 routes
675	///   - 3-5x faster for large route sets
676	///
677	/// # Examples
678	///
679	/// ```
680	/// use reinhardt_urls::routers::{PathMatcher, PathPattern, path};
681	///
682	/// let mut matcher = PathMatcher::new();
683	/// let pattern = PathPattern::new(path!("/users/{id}/")).unwrap();
684	/// matcher.add_pattern(pattern, "users_detail".to_string());
685	///
686	/// let result = matcher.match_path("/users/123/");
687	/// assert!(result.is_some());
688	/// let (handler_id, params) = result.unwrap();
689	/// assert_eq!(handler_id, "users_detail");
690	/// assert_eq!(params.get("id"), Some(&"123".to_string()));
691	/// ```
692	pub fn match_path(&self, path: &str) -> Option<(String, PathParams)> {
693		match self.mode {
694			MatchingMode::RadixTree => {
695				// Use radix tree for O(m) matching
696				if let Some(ref radix_router) = self.radix_router {
697					let (handler_id, params) = radix_router.match_path(path)?;
698
699					// Validate path-type parameters against directory traversal
700					if let Some((pattern, _)) =
701						self.patterns.iter().find(|(_, id)| *id == handler_id)
702					{
703						for (name, value) in params.iter() {
704							if pattern.path_type_params.contains(name)
705								&& !validate_path_param(value)
706							{
707								return None;
708							}
709						}
710					}
711
712					Some((handler_id, params))
713				} else {
714					// Fallback to linear if radix router not initialized
715					self.match_path_linear(path)
716				}
717			}
718			MatchingMode::Linear => {
719				// Use linear O(n) matching
720				self.match_path_linear(path)
721			}
722		}
723	}
724
725	/// Linear pattern matching (O(n))
726	fn match_path_linear(&self, path: &str) -> Option<(String, PathParams)> {
727		'outer: for (pattern, handler_id) in &self.patterns {
728			if let Some(captures) = pattern.regex.captures(path) {
729				// Use ordered `PathParams` so tuple extractors see params in
730				// URL pattern declaration order (issue #4013). `param_names()`
731				// already yields names in the order they appear in the pattern.
732				let mut params = PathParams::new();
733
734				for name in pattern.param_names() {
735					if let Some(value) = captures.name(name) {
736						let val = value.as_str();
737						// Validate path-type parameters against directory traversal
738						if pattern.path_type_params.contains(name) && !validate_path_param(val) {
739							continue 'outer;
740						}
741						params.insert(name.clone(), val.to_string());
742					}
743				}
744
745				return Some((handler_id.clone(), params));
746			}
747		}
748
749		None
750	}
751}
752
753impl Default for PathMatcher {
754	fn default() -> Self {
755		Self::new()
756	}
757}
758
759/// Error type for Radix Router operations
760#[derive(Debug, thiserror::Error)]
761pub enum RadixRouterError {
762	/// The route pattern is syntactically invalid.
763	#[error("Invalid pattern: {0}")]
764	InvalidPattern(String),
765	/// The route could not be inserted into the radix tree (e.g., conflict).
766	#[error("Route insertion failed: {0}")]
767	InsertionFailed(String),
768}
769
770/// Radix Tree-based router using matchit for O(m) matching
771///
772/// Provides significant performance improvements over linear O(n) matching,
773/// especially for applications with large route counts (>100 routes).
774///
775/// # Performance Characteristics
776///
777/// - **Insertion**: O(m) where m is the pattern length
778/// - **Matching**: O(m) where m is the path length (vs O(n×m) for linear)
779/// - **Memory**: O(total pattern characters) for the radix tree structure
780///
781/// # Pattern Syntax
782///
783/// Uses matchit's pattern syntax, compatible with Django-style patterns:
784/// - `/users/{id}` - Single parameter
785/// - `/posts/{post_id}/comments/{comment_id}` - Multiple parameters
786/// - `/files/{*path}` - Catch-all wildcard (matches remaining path including `/`)
787///
788/// # Examples
789///
790/// ```
791/// use reinhardt_urls::routers::{RadixRouter, path};
792///
793/// let mut router = RadixRouter::new();
794///
795/// // Register routes
796/// router.add_route(path!("/users/"), "users_list".to_string()).unwrap();
797/// router.add_route(path!("/users/{id}/"), "users_detail".to_string()).unwrap();
798///
799/// // Match paths
800/// let result = router.match_path("/users/123/");
801/// assert!(result.is_some());
802/// let (handler_id, params) = result.unwrap();
803/// assert_eq!(handler_id, "users_detail");
804/// assert_eq!(params.get("id"), Some(&"123".to_string()));
805/// ```
806pub struct RadixRouter {
807	router: MatchitRouter<String>,
808}
809
810impl RadixRouter {
811	/// Create a new RadixRouter
812	///
813	/// # Examples
814	///
815	/// ```
816	/// use reinhardt_urls::routers::RadixRouter;
817	///
818	/// let router = RadixRouter::new();
819	/// ```
820	pub fn new() -> Self {
821		Self {
822			router: MatchitRouter::new(),
823		}
824	}
825
826	/// Add a route pattern to the router
827	///
828	/// Converts Django-style `{param}` patterns to matchit's `{param}` syntax.
829	/// Both syntaxes are compatible, so no conversion is needed.
830	///
831	/// # Arguments
832	///
833	/// * `pattern` - URL pattern (e.g., `/users/{id}/`)
834	/// * `handler_id` - Identifier for the route handler
835	///
836	/// # Errors
837	///
838	/// Returns `RadixRouterError::InsertionFailed` if:
839	/// - Pattern conflicts with existing routes
840	/// - Pattern syntax is invalid
841	///
842	/// # Examples
843	///
844	/// ```
845	/// use reinhardt_urls::routers::{RadixRouter, path};
846	///
847	/// let mut router = RadixRouter::new();
848	/// router.add_route(path!("/users/{id}/"), "users_detail".to_string()).unwrap();
849	///
850	/// // Catch-all wildcard
851	/// router.add_route(path!("/files/{file_path}"), "serve_file".to_string()).unwrap();
852	/// ```
853	pub fn add_route(&mut self, pattern: &str, handler_id: String) -> Result<(), RadixRouterError> {
854		self.router
855			.insert(pattern, handler_id)
856			.map_err(|e| RadixRouterError::InsertionFailed(e.to_string()))
857	}
858
859	/// Match a path and return handler ID with extracted parameters
860	///
861	/// Performs O(m) matching where m is the path length.
862	///
863	/// # Arguments
864	///
865	/// * `path` - Request path to match
866	///
867	/// # Returns
868	///
869	/// `Some((handler_id, params))` if matched, `None` otherwise.
870	///
871	/// # Examples
872	///
873	/// ```
874	/// use reinhardt_urls::routers::{RadixRouter, path};
875	///
876	/// let mut router = RadixRouter::new();
877	/// router.add_route(path!("/users/{id}/posts/{post_id}/"), "post_detail".to_string()).unwrap();
878	///
879	/// let result = router.match_path("/users/123/posts/456/");
880	/// assert!(result.is_some());
881	///
882	/// let (handler_id, params) = result.unwrap();
883	/// assert_eq!(handler_id, "post_detail");
884	/// assert_eq!(params.get("id"), Some(&"123".to_string()));
885	/// assert_eq!(params.get("post_id"), Some(&"456".to_string()));
886	/// ```
887	pub fn match_path(&self, path: &str) -> Option<(String, PathParams)> {
888		match self.router.at(path) {
889			Ok(matched) => {
890				let handler_id = matched.value.clone();
891				// matchit's `Params` iterator yields parameters in URL pattern
892				// declaration order; collect into `PathParams` to preserve it
893				// (issue #4013).
894				let params: PathParams = matched
895					.params
896					.iter()
897					.map(|(k, v)| (k.to_string(), v.to_string()))
898					.collect();
899
900				Some((handler_id, params))
901			}
902			Err(_) => None,
903		}
904	}
905}
906
907impl Default for RadixRouter {
908	fn default() -> Self {
909		Self::new()
910	}
911}
912
913#[cfg(test)]
914mod tests {
915	use super::*;
916
917	#[test]
918	fn test_simple_pattern() {
919		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/")).unwrap();
920		assert!(pattern.regex.is_match("/users/"));
921		assert!(!pattern.regex.is_match("/users/123/"));
922	}
923
924	#[test]
925	fn test_parameter_pattern() {
926		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
927		assert_eq!(pattern.param_names(), &["id"]);
928		assert!(pattern.regex.is_match("/users/123/"));
929		assert!(!pattern.regex.is_match("/users/"));
930	}
931
932	#[test]
933	fn test_pattern_multiple_parameters() {
934		let pattern = PathPattern::new(reinhardt_routers_macros::path!(
935			"/users/{user_id}/posts/{post_id}/"
936		))
937		.unwrap();
938		assert_eq!(pattern.param_names(), &["user_id", "post_id"]);
939		assert!(pattern.regex.is_match("/users/123/posts/456/"));
940	}
941
942	#[test]
943	fn test_path_matcher() {
944		let mut matcher = PathMatcher::new();
945		matcher.add_pattern(
946			PathPattern::new(reinhardt_routers_macros::path!("/users/")).unwrap(),
947			"users_list".to_string(),
948		);
949		matcher.add_pattern(
950			PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap(),
951			"users_detail".to_string(),
952		);
953
954		let result = matcher.match_path("/users/123/");
955		assert!(result.is_some());
956		let (handler_id, params) = result.unwrap();
957		assert_eq!(handler_id, "users_detail");
958		assert_eq!(params.get("id"), Some(&"123".to_string()));
959	}
960
961	// ===================================================================
962	// URL Reversal Tests with Aho-Corasick
963	// ===================================================================
964
965	#[test]
966	fn test_reverse_simple_pattern_no_params() {
967		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/")).unwrap();
968		let params = HashMap::new();
969
970		let result = pattern.reverse(&params).unwrap();
971		assert_eq!(result, "/users/");
972	}
973
974	#[test]
975	fn test_reverse_single_parameter() {
976		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
977		let mut params = HashMap::new();
978		params.insert("id".to_string(), "123".to_string());
979
980		let result = pattern.reverse(&params).unwrap();
981		assert_eq!(result, "/users/123/");
982	}
983
984	#[test]
985	fn test_reverse_multiple_parameters() {
986		let pattern = PathPattern::new(reinhardt_routers_macros::path!(
987			"/users/{user_id}/posts/{post_id}/"
988		))
989		.unwrap();
990		let mut params = HashMap::new();
991		params.insert("user_id".to_string(), "42".to_string());
992		params.insert("post_id".to_string(), "100".to_string());
993
994		let result = pattern.reverse(&params).unwrap();
995		assert_eq!(result, "/users/42/posts/100/");
996	}
997
998	#[test]
999	fn test_reverse_many_parameters() {
1000		// Test with 10+ parameters to demonstrate Aho-Corasick performance
1001		let pattern = PathPattern::new(
1002			"/api/{p1}/{p2}/{p3}/{p4}/{p5}/{p6}/{p7}/{p8}/{p9}/{p10}/{p11}/{p12}/",
1003		)
1004		.unwrap();
1005
1006		let mut params = HashMap::new();
1007		params.insert("p1".to_string(), "v1".to_string());
1008		params.insert("p2".to_string(), "v2".to_string());
1009		params.insert("p3".to_string(), "v3".to_string());
1010		params.insert("p4".to_string(), "v4".to_string());
1011		params.insert("p5".to_string(), "v5".to_string());
1012		params.insert("p6".to_string(), "v6".to_string());
1013		params.insert("p7".to_string(), "v7".to_string());
1014		params.insert("p8".to_string(), "v8".to_string());
1015		params.insert("p9".to_string(), "v9".to_string());
1016		params.insert("p10".to_string(), "v10".to_string());
1017		params.insert("p11".to_string(), "v11".to_string());
1018		params.insert("p12".to_string(), "v12".to_string());
1019
1020		let result = pattern.reverse(&params).unwrap();
1021		assert_eq!(result, "/api/v1/v2/v3/v4/v5/v6/v7/v8/v9/v10/v11/v12/");
1022	}
1023
1024	#[test]
1025	fn test_reverse_consecutive_placeholders() {
1026		let pattern = PathPattern::new("/{a}{b}/").unwrap();
1027		let mut params = HashMap::new();
1028		params.insert("a".to_string(), "1".to_string());
1029		params.insert("b".to_string(), "2".to_string());
1030
1031		let result = pattern.reverse(&params).unwrap();
1032		assert_eq!(result, "/12/");
1033	}
1034
1035	#[test]
1036	fn test_reverse_missing_parameter() {
1037		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
1038		let params = HashMap::new();
1039
1040		let result = pattern.reverse(&params);
1041		assert!(result.is_err());
1042		assert!(
1043			result
1044				.unwrap_err()
1045				.contains("Missing required parameter: id")
1046		);
1047	}
1048
1049	#[test]
1050	fn test_reverse_partial_parameters() {
1051		let pattern = PathPattern::new(reinhardt_routers_macros::path!(
1052			"/users/{user_id}/posts/{post_id}/"
1053		))
1054		.unwrap();
1055		let mut params = HashMap::new();
1056		params.insert("user_id".to_string(), "42".to_string());
1057		// Missing post_id
1058
1059		let result = pattern.reverse(&params);
1060		assert!(result.is_err());
1061		assert!(result.unwrap_err().contains("Missing required parameter"));
1062	}
1063
1064	#[test]
1065	fn test_reverse_special_chars_in_values() {
1066		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/items/{id}/")).unwrap();
1067		let mut params = HashMap::new();
1068		params.insert("id".to_string(), "foo-bar_123".to_string());
1069
1070		let result = pattern.reverse(&params).unwrap();
1071		assert_eq!(result, "/items/foo-bar_123/");
1072	}
1073
1074	#[test]
1075	fn test_reverse_numeric_values() {
1076		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/items/{id}/")).unwrap();
1077		let mut params = HashMap::new();
1078		params.insert("id".to_string(), "12345".to_string());
1079
1080		let result = pattern.reverse(&params).unwrap();
1081		assert_eq!(result, "/items/12345/");
1082	}
1083
1084	#[test]
1085	fn test_reverse_unicode_values() {
1086		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{name}/")).unwrap();
1087		let mut params = HashMap::new();
1088		params.insert("name".to_string(), "ユーザー".to_string());
1089
1090		let result = pattern.reverse(&params).unwrap();
1091		assert_eq!(result, "/users/ユーザー/");
1092	}
1093
1094	#[test]
1095	fn test_reverse_param_at_start() {
1096		let pattern = PathPattern::new("{lang}/users/").unwrap();
1097		let mut params = HashMap::new();
1098		params.insert("lang".to_string(), "ja".to_string());
1099
1100		let result = pattern.reverse(&params).unwrap();
1101		assert_eq!(result, "ja/users/");
1102	}
1103
1104	#[test]
1105	fn test_reverse_param_at_end() {
1106		let pattern = PathPattern::new("/api/data.{format}").unwrap();
1107		let mut params = HashMap::new();
1108		params.insert("format".to_string(), "json".to_string());
1109
1110		let result = pattern.reverse(&params).unwrap();
1111		assert_eq!(result, "/api/data.json");
1112	}
1113
1114	#[test]
1115	fn test_reverse_complex_mixed_content() {
1116		let pattern = PathPattern::new("/items/{id}/actions/{action}/execute").unwrap();
1117		let mut params = HashMap::new();
1118		params.insert("id".to_string(), "123".to_string());
1119		params.insert("action".to_string(), "edit".to_string());
1120
1121		let result = pattern.reverse(&params).unwrap();
1122		assert_eq!(result, "/items/123/actions/edit/execute");
1123	}
1124
1125	#[test]
1126	fn test_reverse_long_value() {
1127		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/items/{id}/")).unwrap();
1128		let mut params = HashMap::new();
1129		let long_id = "a".repeat(1000);
1130		params.insert("id".to_string(), long_id.clone());
1131
1132		let result = pattern.reverse(&params).unwrap();
1133		assert_eq!(result, format!("/items/{}/", long_id));
1134	}
1135
1136	#[test]
1137	fn test_reverse_empty_value() {
1138		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/items/{id}/")).unwrap();
1139		let mut params = HashMap::new();
1140		params.insert("id".to_string(), "".to_string());
1141
1142		let result = pattern.reverse(&params).unwrap();
1143		assert_eq!(result, "/items//");
1144	}
1145
1146	#[test]
1147	fn test_reverse_extra_parameters() {
1148		// Extra parameters should be ignored
1149		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
1150		let mut params = HashMap::new();
1151		params.insert("id".to_string(), "123".to_string());
1152		params.insert("extra".to_string(), "ignored".to_string());
1153
1154		let result = pattern.reverse(&params).unwrap();
1155		assert_eq!(result, "/users/123/");
1156	}
1157
1158	// ===================================================================
1159	// RadixRouter Tests
1160	// ===================================================================
1161
1162	#[test]
1163	fn test_radix_router_basic_matching() {
1164		let mut router = RadixRouter::new();
1165		router
1166			.add_route("/users/", "users_list".to_string())
1167			.unwrap();
1168		router
1169			.add_route("/users/{id}/", "users_detail".to_string())
1170			.unwrap();
1171
1172		// Match list route
1173		let result = router.match_path("/users/");
1174		assert!(result.is_some());
1175		let (handler_id, params) = result.unwrap();
1176		assert_eq!(handler_id, "users_list");
1177		assert!(params.is_empty());
1178
1179		// Match detail route
1180		let result = router.match_path("/users/123/");
1181		assert!(result.is_some());
1182		let (handler_id, params) = result.unwrap();
1183		assert_eq!(handler_id, "users_detail");
1184		assert_eq!(params.get("id"), Some(&"123".to_string()));
1185	}
1186
1187	#[test]
1188	fn test_radix_router_multiple_parameters() {
1189		let mut router = RadixRouter::new();
1190		router
1191			.add_route("/users/{id}/posts/{post_id}/", "post_detail".to_string())
1192			.unwrap();
1193
1194		let result = router.match_path("/users/123/posts/456/");
1195		assert!(result.is_some());
1196		let (handler_id, params) = result.unwrap();
1197		assert_eq!(handler_id, "post_detail");
1198		assert_eq!(params.get("id"), Some(&"123".to_string()));
1199		assert_eq!(params.get("post_id"), Some(&"456".to_string()));
1200	}
1201
1202	#[test]
1203	fn test_radix_router_wildcard() {
1204		let mut router = RadixRouter::new();
1205		router
1206			.add_route("/files/{*path}", "serve_file".to_string())
1207			.unwrap();
1208
1209		let result = router.match_path("/files/images/logo.png");
1210		assert!(result.is_some());
1211		let (handler_id, params) = result.unwrap();
1212		assert_eq!(handler_id, "serve_file");
1213		assert_eq!(params.get("path"), Some(&"images/logo.png".to_string()));
1214	}
1215
1216	#[test]
1217	fn test_radix_router_no_match() {
1218		let mut router = RadixRouter::new();
1219		router
1220			.add_route("/users/", "users_list".to_string())
1221			.unwrap();
1222
1223		let result = router.match_path("/posts/");
1224		assert!(result.is_none());
1225	}
1226
1227	#[test]
1228	fn test_path_matcher_radix_tree_mode() {
1229		let mut matcher = PathMatcher::with_mode(MatchingMode::RadixTree);
1230		matcher.add_pattern(
1231			PathPattern::new(reinhardt_routers_macros::path!("/users/")).unwrap(),
1232			"users_list".to_string(),
1233		);
1234		matcher.add_pattern(
1235			PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap(),
1236			"users_detail".to_string(),
1237		);
1238
1239		assert_eq!(matcher.mode(), MatchingMode::RadixTree);
1240
1241		let result = matcher.match_path("/users/123/");
1242		assert!(result.is_some());
1243		let (handler_id, params) = result.unwrap();
1244		assert_eq!(handler_id, "users_detail");
1245		assert_eq!(params.get("id"), Some(&"123".to_string()));
1246	}
1247
1248	#[test]
1249	fn test_path_matcher_enable_radix_tree() {
1250		let mut matcher = PathMatcher::new();
1251		matcher.add_pattern(
1252			PathPattern::new(reinhardt_routers_macros::path!("/users/")).unwrap(),
1253			"users_list".to_string(),
1254		);
1255
1256		// Initially in linear mode
1257		assert_eq!(matcher.mode(), MatchingMode::Linear);
1258
1259		// Enable radix tree mode
1260		matcher.enable_radix_tree();
1261		assert_eq!(matcher.mode(), MatchingMode::RadixTree);
1262
1263		// Should still work after mode switch
1264		let result = matcher.match_path("/users/");
1265		assert!(result.is_some());
1266	}
1267
1268	#[test]
1269	fn test_path_matcher_linear_vs_radix() {
1270		// Create two matchers with same routes
1271		let mut linear_matcher = PathMatcher::new();
1272		let mut radix_matcher = PathMatcher::with_mode(MatchingMode::RadixTree);
1273
1274		for i in 1..=10 {
1275			let pattern = PathPattern::new(format!("/route{}/{{id}}/", i)).unwrap();
1276			linear_matcher.add_pattern(pattern.clone(), format!("handler_{}", i));
1277			radix_matcher.add_pattern(pattern, format!("handler_{}", i));
1278		}
1279
1280		// Both should produce the same results
1281		for i in 1..=10 {
1282			let path = format!("/route{}/123/", i);
1283			let linear_result = linear_matcher.match_path(&path);
1284			let radix_result = radix_matcher.match_path(&path);
1285
1286			assert_eq!(linear_result, radix_result);
1287			assert!(linear_result.is_some());
1288		}
1289	}
1290
1291	// ===================================================================
1292	// Path traversal prevention tests (Issue #425)
1293	// ===================================================================
1294
1295	#[test]
1296	fn test_path_type_rejects_traversal() {
1297		// Arrange
1298		let pattern = PathPattern::new("/files/{<path:filepath>}").unwrap();
1299
1300		// Act & Assert - should reject `..` segments
1301		assert!(
1302			pattern
1303				.extract_params("/files/../../../etc/passwd")
1304				.is_none(),
1305			"Path type should reject directory traversal"
1306		);
1307		assert!(
1308			pattern
1309				.extract_params("/files/foo/../../etc/passwd")
1310				.is_none(),
1311			"Path type should reject embedded directory traversal"
1312		);
1313	}
1314
1315	#[test]
1316	fn test_path_type_allows_valid_paths() {
1317		// Arrange
1318		let pattern = PathPattern::new("/files/{<path:filepath>}").unwrap();
1319
1320		// Act
1321		let result = pattern.extract_params("/files/images/logo.png");
1322
1323		// Assert
1324		assert!(result.is_some());
1325		let params = result.unwrap();
1326		assert_eq!(params.get("filepath"), Some(&"images/logo.png".to_string()));
1327	}
1328
1329	#[test]
1330	fn test_path_type_allows_dotfiles() {
1331		// Arrange
1332		let pattern = PathPattern::new("/files/{<path:filepath>}").unwrap();
1333
1334		// Act
1335		let result = pattern.extract_params("/files/.gitignore");
1336
1337		// Assert
1338		assert!(result.is_some());
1339		let params = result.unwrap();
1340		assert_eq!(params.get("filepath"), Some(&".gitignore".to_string()));
1341	}
1342
1343	#[test]
1344	fn test_path_type_matcher_rejects_traversal() {
1345		// Arrange
1346		let mut matcher = PathMatcher::new();
1347		matcher.add_pattern(
1348			PathPattern::new("/files/{<path:filepath>}").unwrap(),
1349			"serve_file".to_string(),
1350		);
1351
1352		// Act & Assert
1353		assert!(
1354			matcher.match_path("/files/../../../etc/passwd").is_none(),
1355			"PathMatcher should reject directory traversal in path params"
1356		);
1357
1358		// Valid path should work
1359		let result = matcher.match_path("/files/css/style.css");
1360		assert!(result.is_some());
1361	}
1362
1363	#[test]
1364	fn test_validate_path_param_function() {
1365		// Normal paths should pass
1366		assert!(validate_path_param("images/logo.png"));
1367		assert!(validate_path_param("css/style.css"));
1368		assert!(validate_path_param(".gitignore"));
1369		assert!(validate_path_param("dir/.hidden"));
1370
1371		// Traversal attacks should fail
1372		assert!(!validate_path_param("../etc/passwd"));
1373		assert!(!validate_path_param("foo/../../bar"));
1374		assert!(!validate_path_param(".."));
1375		assert!(!validate_path_param("foo/.."));
1376
1377		// Null bytes should fail
1378		assert!(!validate_path_param("foo\0bar"));
1379	}
1380
1381	// ===================================================================
1382	// Encoded path traversal prevention tests (Issue #425)
1383	// ===================================================================
1384
1385	#[test]
1386	fn test_validate_path_param_rejects_encoded_traversal() {
1387		// Arrange & Act & Assert
1388		// Percent-encoded dot sequences (%2e = '.')
1389		assert!(!validate_path_param("%2e%2e/%2e%2e/etc/passwd"));
1390		assert!(!validate_path_param("foo/%2e%2e/bar"));
1391		assert!(!validate_path_param("%2E%2E/secret"));
1392
1393		// Percent-encoded slash (%2f = '/')
1394		assert!(!validate_path_param("foo%2fbar"));
1395		assert!(!validate_path_param("..%2f..%2fetc%2fpasswd"));
1396		assert!(!validate_path_param("foo%2Fbar"));
1397
1398		// Percent-encoded backslash (%5c = '\')
1399		assert!(!validate_path_param("foo%5cbar"));
1400		assert!(!validate_path_param("..%5C..%5Csecret"));
1401
1402		// Percent-encoded null byte (%00)
1403		assert!(!validate_path_param("file%00.txt"));
1404	}
1405
1406	#[test]
1407	fn test_validate_path_param_rejects_absolute_paths() {
1408		// Arrange & Act & Assert
1409		assert!(!validate_path_param("/etc/passwd"));
1410		assert!(!validate_path_param("\\windows\\system32"));
1411	}
1412
1413	#[test]
1414	fn test_path_type_rejects_encoded_traversal() {
1415		// Arrange
1416		let pattern = PathPattern::new("/files/{<path:filepath>}").unwrap();
1417
1418		// Act & Assert - percent-encoded traversal
1419		assert!(
1420			pattern
1421				.extract_params("/files/%2e%2e/%2e%2e/etc/passwd")
1422				.is_none(),
1423			"Path type should reject percent-encoded traversal"
1424		);
1425		assert!(
1426			pattern
1427				.extract_params("/files/..%2f..%2fetc%2fpasswd")
1428				.is_none(),
1429			"Path type should reject mixed encoded traversal"
1430		);
1431		assert!(
1432			pattern.extract_params("/files/foo%00bar").is_none(),
1433			"Path type should reject encoded null bytes"
1434		);
1435	}
1436
1437	#[test]
1438	fn test_path_type_rejects_absolute_path_param() {
1439		// Arrange
1440		let pattern = PathPattern::new("/files/{<path:filepath>}").unwrap();
1441
1442		// Act & Assert - absolute paths in parameter value
1443		// Note: the regex `.+` will match, but validation rejects absolute paths
1444		assert!(
1445			pattern.extract_params("/files//etc/passwd").is_none(),
1446			"Path type should reject absolute path in parameter"
1447		);
1448	}
1449
1450	#[test]
1451	fn test_radix_tree_mode_rejects_traversal() {
1452		// Arrange
1453		let mut matcher = PathMatcher::with_mode(MatchingMode::RadixTree);
1454		matcher.add_pattern(
1455			PathPattern::new("/files/{<path:filepath>}").unwrap(),
1456			"serve_file".to_string(),
1457		);
1458
1459		// Act & Assert - should reject traversal in RadixTree mode
1460		assert!(
1461			matcher.match_path("/files/../../../etc/passwd").is_none(),
1462			"RadixTree mode should reject directory traversal in path params"
1463		);
1464		assert!(
1465			matcher.match_path("/files/foo/../../etc/passwd").is_none(),
1466			"RadixTree mode should reject embedded directory traversal"
1467		);
1468
1469		// Valid path should work
1470		let result = matcher.match_path("/files/css/style.css");
1471		assert!(result.is_some());
1472		let (handler_id, params) = result.unwrap();
1473		assert_eq!(handler_id, "serve_file");
1474		assert_eq!(params.get("filepath"), Some(&"css/style.css".to_string()));
1475	}
1476
1477	#[test]
1478	fn test_radix_tree_mode_rejects_encoded_traversal() {
1479		// Arrange
1480		let mut matcher = PathMatcher::with_mode(MatchingMode::RadixTree);
1481		matcher.add_pattern(
1482			PathPattern::new("/files/{<path:filepath>}").unwrap(),
1483			"serve_file".to_string(),
1484		);
1485
1486		// Act & Assert - percent-encoded traversal
1487		assert!(
1488			matcher
1489				.match_path("/files/%2e%2e/%2e%2e/etc/passwd")
1490				.is_none(),
1491			"RadixTree mode should reject percent-encoded traversal"
1492		);
1493		assert!(
1494			matcher
1495				.match_path("/files/..%2f..%2fetc%2fpasswd")
1496				.is_none(),
1497			"RadixTree mode should reject mixed encoded traversal"
1498		);
1499
1500		// Null byte injection
1501		assert!(
1502			matcher.match_path("/files/foo%00bar").is_none(),
1503			"RadixTree mode should reject encoded null bytes"
1504		);
1505	}
1506
1507	// ===================================================================
1508	// URL reversal parameter injection prevention tests (Issue #423)
1509	// ===================================================================
1510
1511	#[test]
1512	fn test_reverse_rejects_path_separator_injection() {
1513		// Arrange
1514		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
1515		let mut params = HashMap::new();
1516		params.insert("id".to_string(), "123/../../admin".to_string());
1517
1518		// Act
1519		let result = pattern.reverse(&params);
1520
1521		// Assert
1522		assert!(
1523			result.is_err(),
1524			"Reverse should reject path separators in parameter values"
1525		);
1526	}
1527
1528	#[test]
1529	fn test_reverse_rejects_query_string_injection() {
1530		// Arrange
1531		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
1532		let mut params = HashMap::new();
1533		params.insert("id".to_string(), "123?admin=true".to_string());
1534
1535		// Act
1536		let result = pattern.reverse(&params);
1537
1538		// Assert
1539		assert!(
1540			result.is_err(),
1541			"Reverse should reject query string delimiters in parameter values"
1542		);
1543	}
1544
1545	#[test]
1546	fn test_reverse_rejects_fragment_injection() {
1547		// Arrange
1548		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
1549		let mut params = HashMap::new();
1550		params.insert("id".to_string(), "123#fragment".to_string());
1551
1552		// Act
1553		let result = pattern.reverse(&params);
1554
1555		// Assert
1556		assert!(
1557			result.is_err(),
1558			"Reverse should reject fragment identifiers in parameter values"
1559		);
1560	}
1561
1562	#[test]
1563	fn test_reverse_rejects_encoded_injection() {
1564		// Arrange
1565		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/")).unwrap();
1566		let mut params = HashMap::new();
1567		params.insert("id".to_string(), "123%2f..%2f..%2fadmin".to_string());
1568
1569		// Act
1570		let result = pattern.reverse(&params);
1571
1572		// Assert
1573		assert!(
1574			result.is_err(),
1575			"Reverse should reject percent-encoded dangerous characters"
1576		);
1577	}
1578
1579	#[test]
1580	fn test_reverse_allows_safe_values() {
1581		// Arrange
1582		let pattern =
1583			PathPattern::new(reinhardt_routers_macros::path!("/users/{id}/posts/{slug}/")).unwrap();
1584		let mut params = HashMap::new();
1585		params.insert("id".to_string(), "123".to_string());
1586		params.insert("slug".to_string(), "my-blog-post".to_string());
1587
1588		// Act
1589		let result = pattern.reverse(&params);
1590
1591		// Assert
1592		assert!(result.is_ok());
1593		assert_eq!(result.unwrap(), "/users/123/posts/my-blog-post/");
1594	}
1595
1596	#[test]
1597	fn test_reverse_allows_unicode_values() {
1598		// Arrange
1599		let pattern = PathPattern::new(reinhardt_routers_macros::path!("/users/{name}/")).unwrap();
1600		let mut params = HashMap::new();
1601		params.insert("name".to_string(), "ユーザー".to_string());
1602
1603		// Act
1604		let result = pattern.reverse(&params);
1605
1606		// Assert
1607		assert!(result.is_ok());
1608		assert_eq!(result.unwrap(), "/users/ユーザー/");
1609	}
1610
1611	#[test]
1612	fn test_validate_reverse_param_function() {
1613		// Arrange & Act & Assert
1614
1615		// Safe values should pass
1616		assert!(validate_reverse_param("123"));
1617		assert!(validate_reverse_param("my-slug"));
1618		assert!(validate_reverse_param("foo_bar"));
1619		assert!(validate_reverse_param("ユーザー"));
1620		assert!(validate_reverse_param("hello-world-123"));
1621
1622		// Path separators should fail
1623		assert!(!validate_reverse_param("foo/bar"));
1624		assert!(!validate_reverse_param("foo\\bar"));
1625
1626		// URL-special characters should fail
1627		assert!(!validate_reverse_param("foo?bar=1"));
1628		assert!(!validate_reverse_param("foo#bar"));
1629
1630		// Null bytes should fail
1631		assert!(!validate_reverse_param("foo\0bar"));
1632
1633		// Encoded sequences should fail
1634		assert!(!validate_reverse_param("foo%2fbar"));
1635		assert!(!validate_reverse_param("foo%2ebar"));
1636		assert!(!validate_reverse_param("foo%5cbar"));
1637		assert!(!validate_reverse_param("foo%3fbar"));
1638		assert!(!validate_reverse_param("foo%23bar"));
1639		assert!(!validate_reverse_param("foo%00bar"));
1640	}
1641
1642	// ===================================================================
1643	// ReDoS prevention tests (Issue #430)
1644	// ===================================================================
1645
1646	#[test]
1647	fn test_pattern_rejects_excessive_length() {
1648		// Arrange: a pattern exceeding MAX_PATTERN_LENGTH (1024 bytes)
1649		let long_pattern = "/".to_string() + &"a".repeat(1025);
1650
1651		// Act
1652		let result = PathPattern::new(long_pattern);
1653
1654		// Assert
1655		assert!(result.is_err());
1656		assert!(
1657			result
1658				.unwrap_err()
1659				.contains("exceeds maximum allowed length")
1660		);
1661	}
1662
1663	#[test]
1664	fn test_pattern_accepts_within_length_limit() {
1665		// Arrange: a pattern within the limit
1666		let pattern = "/users/{id}/posts/{post_id}/";
1667
1668		// Act
1669		let result = PathPattern::new(pattern);
1670
1671		// Assert
1672		assert!(result.is_ok());
1673	}
1674
1675	#[test]
1676	fn test_pattern_rejects_at_boundary() {
1677		// Arrange: a pattern at exactly the boundary + 1
1678		let pattern = "/".to_string() + &"a/".repeat(512) + "end";
1679		if pattern.len() > MAX_PATTERN_LENGTH {
1680			// Act
1681			let result = PathPattern::new(pattern);
1682
1683			// Assert
1684			assert!(result.is_err());
1685		}
1686	}
1687
1688	// ===================================================================
1689	// Path segment count limit tests (Issue #431)
1690	// ===================================================================
1691
1692	#[test]
1693	fn test_pattern_rejects_excessive_segments() {
1694		// Arrange: a pattern with more than MAX_PATH_SEGMENTS segments
1695		let segments: Vec<&str> = (0..35).map(|_| "seg").collect();
1696		let pattern = format!("/{}/", segments.join("/"));
1697
1698		// Act
1699		let result = PathPattern::new(pattern);
1700
1701		// Assert
1702		assert!(result.is_err());
1703		assert!(result.unwrap_err().contains("exceeding maximum"));
1704	}
1705
1706	#[test]
1707	fn test_pattern_accepts_within_segment_limit() {
1708		// Arrange: a pattern with few segments
1709		let pattern = "/a/b/c/d/e/";
1710
1711		// Act
1712		let result = PathPattern::new(pattern);
1713
1714		// Assert
1715		assert!(result.is_ok());
1716	}
1717
1718	#[test]
1719	fn test_pattern_accepts_at_segment_boundary() {
1720		// Arrange: a pattern at exactly the maximum segment count
1721		let segments: Vec<String> = (0..MAX_PATH_SEGMENTS - 2)
1722			.map(|i| format!("s{}", i))
1723			.collect();
1724		let pattern = format!("/{}/", segments.join("/"));
1725
1726		// Act
1727		let result = PathPattern::new(&pattern);
1728
1729		// Assert
1730		assert!(result.is_ok());
1731	}
1732}