1#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum PathValidationError {
10 MustStartWithSlash { path: String },
12 EmptySegment { path: String },
14 NestedBraces { path: String, position: usize },
16 UnmatchedClosingBrace { path: String, position: usize },
18 EmptyParameterName { path: String, position: usize },
20 InvalidParameterName {
22 path: String,
23 param_name: String,
24 position: usize,
25 },
26 ParameterStartsWithDigit {
28 path: String,
29 param_name: String,
30 position: usize,
31 },
32 UnclosedBrace { path: String },
34 InvalidCharacter {
36 path: String,
37 character: char,
38 position: usize,
39 },
40}
41
42impl std::fmt::Display for PathValidationError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 PathValidationError::MustStartWithSlash { path } => {
46 write!(f, "route path must start with '/', got: \"{}\"", path)
47 }
48 PathValidationError::EmptySegment { path } => {
49 write!(
50 f,
51 "route path contains empty segment (double slash): \"{}\"",
52 path
53 )
54 }
55 PathValidationError::NestedBraces { path, position } => {
56 write!(
57 f,
58 "nested braces are not allowed in route path at position {}: \"{}\"",
59 position, path
60 )
61 }
62 PathValidationError::UnmatchedClosingBrace { path, position } => {
63 write!(
64 f,
65 "unmatched closing brace '}}' at position {} in route path: \"{}\"",
66 position, path
67 )
68 }
69 PathValidationError::EmptyParameterName { path, position } => {
70 write!(
71 f,
72 "empty parameter name '{{}}' at position {} in route path: \"{}\"",
73 position, path
74 )
75 }
76 PathValidationError::InvalidParameterName {
77 path,
78 param_name,
79 position,
80 } => {
81 write!(f, "invalid parameter name '{{{}}}' at position {} - parameter names must contain only alphanumeric characters and underscores: \"{}\"", param_name, position, path)
82 }
83 PathValidationError::ParameterStartsWithDigit {
84 path,
85 param_name,
86 position,
87 } => {
88 write!(
89 f,
90 "parameter name '{{{}}}' cannot start with a digit at position {}: \"{}\"",
91 param_name, position, path
92 )
93 }
94 PathValidationError::UnclosedBrace { path } => {
95 write!(
96 f,
97 "unclosed brace '{{' in route path (missing closing '}}'): \"{}\"",
98 path
99 )
100 }
101 PathValidationError::InvalidCharacter {
102 path,
103 character,
104 position,
105 } => {
106 write!(
107 f,
108 "invalid character '{}' at position {} in route path: \"{}\"",
109 character, position, path
110 )
111 }
112 }
113 }
114}
115
116impl std::error::Error for PathValidationError {}
117
118pub fn validate_path(path: &str) -> Result<(), PathValidationError> {
155 if !path.starts_with('/') {
157 return Err(PathValidationError::MustStartWithSlash {
158 path: path.to_string(),
159 });
160 }
161
162 if path.contains("//") {
164 return Err(PathValidationError::EmptySegment {
165 path: path.to_string(),
166 });
167 }
168
169 let mut brace_depth = 0;
171 let mut param_start = None;
172
173 for (i, ch) in path.char_indices() {
174 match ch {
175 '{' => {
176 if brace_depth > 0 {
177 return Err(PathValidationError::NestedBraces {
178 path: path.to_string(),
179 position: i,
180 });
181 }
182 brace_depth += 1;
183 param_start = Some(i);
184 }
185 '}' => {
186 if brace_depth == 0 {
187 return Err(PathValidationError::UnmatchedClosingBrace {
188 path: path.to_string(),
189 position: i,
190 });
191 }
192 brace_depth -= 1;
193
194 if let Some(start) = param_start {
196 let param_name = &path[start + 1..i];
197 if param_name.is_empty() {
198 return Err(PathValidationError::EmptyParameterName {
199 path: path.to_string(),
200 position: start,
201 });
202 }
203 if !param_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
205 return Err(PathValidationError::InvalidParameterName {
206 path: path.to_string(),
207 param_name: param_name.to_string(),
208 position: start,
209 });
210 }
211 if param_name
213 .chars()
214 .next()
215 .map(|c| c.is_ascii_digit())
216 .unwrap_or(false)
217 {
218 return Err(PathValidationError::ParameterStartsWithDigit {
219 path: path.to_string(),
220 param_name: param_name.to_string(),
221 position: start,
222 });
223 }
224 }
225 param_start = None;
226 }
227 _ if brace_depth == 0 => {
229 if !ch.is_alphanumeric() && !"-_./*".contains(ch) {
231 return Err(PathValidationError::InvalidCharacter {
232 path: path.to_string(),
233 character: ch,
234 position: i,
235 });
236 }
237 }
238 _ => {}
239 }
240 }
241
242 if brace_depth > 0 {
244 return Err(PathValidationError::UnclosedBrace {
245 path: path.to_string(),
246 });
247 }
248
249 Ok(())
250}
251
252pub fn is_valid_path(path: &str) -> bool {
254 validate_path(path).is_ok()
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use proptest::prelude::*;
261
262 #[test]
264 fn test_valid_paths() {
265 assert!(validate_path("/").is_ok());
266 assert!(validate_path("/users").is_ok());
267 assert!(validate_path("/users/{id}").is_ok());
268 assert!(validate_path("/users/{user_id}").is_ok());
269 assert!(validate_path("/users/{user_id}/posts").is_ok());
270 assert!(validate_path("/users/{user_id}/posts/{post_id}").is_ok());
271 assert!(validate_path("/api/v1/users").is_ok());
272 assert!(validate_path("/api-v1/users").is_ok());
273 assert!(validate_path("/api_v1/users").is_ok());
274 assert!(validate_path("/api.v1/users").is_ok());
275 assert!(validate_path("/users/*").is_ok()); }
277
278 #[test]
279 fn test_missing_leading_slash() {
280 let result = validate_path("users");
281 assert!(matches!(
282 result,
283 Err(PathValidationError::MustStartWithSlash { .. })
284 ));
285
286 let result = validate_path("users/{id}");
287 assert!(matches!(
288 result,
289 Err(PathValidationError::MustStartWithSlash { .. })
290 ));
291 }
292
293 #[test]
294 fn test_double_slash() {
295 let result = validate_path("/users//posts");
296 assert!(matches!(
297 result,
298 Err(PathValidationError::EmptySegment { .. })
299 ));
300
301 let result = validate_path("//users");
302 assert!(matches!(
303 result,
304 Err(PathValidationError::EmptySegment { .. })
305 ));
306 }
307
308 #[test]
309 fn test_unclosed_brace() {
310 let result = validate_path("/users/{id");
311 assert!(matches!(
312 result,
313 Err(PathValidationError::UnclosedBrace { .. })
314 ));
315
316 let result = validate_path("/users/{");
317 assert!(matches!(
318 result,
319 Err(PathValidationError::UnclosedBrace { .. })
320 ));
321 }
322
323 #[test]
324 fn test_unmatched_closing_brace() {
325 let result = validate_path("/users/id}");
326 assert!(matches!(
327 result,
328 Err(PathValidationError::UnmatchedClosingBrace { .. })
329 ));
330
331 let result = validate_path("/users/}");
332 assert!(matches!(
333 result,
334 Err(PathValidationError::UnmatchedClosingBrace { .. })
335 ));
336 }
337
338 #[test]
339 fn test_empty_parameter_name() {
340 let result = validate_path("/users/{}");
341 assert!(matches!(
342 result,
343 Err(PathValidationError::EmptyParameterName { .. })
344 ));
345
346 let result = validate_path("/users/{}/posts");
347 assert!(matches!(
348 result,
349 Err(PathValidationError::EmptyParameterName { .. })
350 ));
351 }
352
353 #[test]
354 fn test_nested_braces() {
355 let result = validate_path("/users/{{id}}");
356 assert!(matches!(
357 result,
358 Err(PathValidationError::NestedBraces { .. })
359 ));
360
361 let result = validate_path("/users/{outer{inner}}");
362 assert!(matches!(
363 result,
364 Err(PathValidationError::NestedBraces { .. })
365 ));
366 }
367
368 #[test]
369 fn test_parameter_starts_with_digit() {
370 let result = validate_path("/users/{123}");
371 assert!(matches!(
372 result,
373 Err(PathValidationError::ParameterStartsWithDigit { .. })
374 ));
375
376 let result = validate_path("/users/{1id}");
377 assert!(matches!(
378 result,
379 Err(PathValidationError::ParameterStartsWithDigit { .. })
380 ));
381 }
382
383 #[test]
384 fn test_invalid_parameter_name() {
385 let result = validate_path("/users/{id-name}");
386 assert!(matches!(
387 result,
388 Err(PathValidationError::InvalidParameterName { .. })
389 ));
390
391 let result = validate_path("/users/{id.name}");
392 assert!(matches!(
393 result,
394 Err(PathValidationError::InvalidParameterName { .. })
395 ));
396
397 let result = validate_path("/users/{id name}");
398 assert!(matches!(
399 result,
400 Err(PathValidationError::InvalidParameterName { .. })
401 ));
402 }
403
404 #[test]
405 fn test_invalid_characters() {
406 let result = validate_path("/users?query");
407 assert!(matches!(
408 result,
409 Err(PathValidationError::InvalidCharacter { .. })
410 ));
411
412 let result = validate_path("/users#anchor");
413 assert!(matches!(
414 result,
415 Err(PathValidationError::InvalidCharacter { .. })
416 ));
417
418 let result = validate_path("/users@domain");
419 assert!(matches!(
420 result,
421 Err(PathValidationError::InvalidCharacter { .. })
422 ));
423 }
424
425 proptest! {
432 #![proptest_config(ProptestConfig::with_cases(100))]
433
434 #[test]
443 fn prop_valid_paths_accepted(
444 segments in prop::collection::vec("[a-zA-Z][a-zA-Z0-9_-]{0,10}", 0..5),
446 params in prop::collection::vec("[a-zA-Z_][a-zA-Z0-9_]{0,10}", 0..3),
448 ) {
449 let mut path = String::from("/");
451
452 for (i, segment) in segments.iter().enumerate() {
453 if i > 0 {
454 path.push('/');
455 }
456 path.push_str(segment);
457 }
458
459 for param in params.iter() {
461 if path != "/" {
462 path.push('/');
463 }
464 path.push('{');
465 path.push_str(param);
466 path.push('}');
467 }
468
469 let result = validate_path(&path);
472 prop_assert!(
473 result.is_ok(),
474 "Valid path '{}' should be accepted, but got error: {:?}",
475 path,
476 result.err()
477 );
478 }
479
480 #[test]
485 fn prop_missing_leading_slash_rejected(
486 content in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
488 ) {
489 let path = if content.starts_with('/') {
491 format!("x{}", content)
492 } else {
493 content
494 };
495
496 let result = validate_path(&path);
497 prop_assert!(
498 matches!(result, Err(PathValidationError::MustStartWithSlash { .. })),
499 "Path '{}' without leading slash should be rejected with MustStartWithSlash, got: {:?}",
500 path,
501 result
502 );
503 }
504
505 #[test]
509 fn prop_unclosed_brace_rejected(
510 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
512 param_start in "[a-zA-Z_][a-zA-Z0-9_]{0,5}",
513 ) {
514 let path = format!("{}/{{{}", prefix, param_start);
516
517 let result = validate_path(&path);
518 prop_assert!(
519 matches!(result, Err(PathValidationError::UnclosedBrace { .. })),
520 "Path '{}' with unclosed brace should be rejected with UnclosedBrace, got: {:?}",
521 path,
522 result
523 );
524 }
525
526 #[test]
530 fn prop_unmatched_closing_brace_rejected(
531 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
533 suffix in "[a-zA-Z0-9_]{0,5}",
534 ) {
535 let path = format!("{}/{}}}", prefix, suffix);
537
538 let result = validate_path(&path);
539 prop_assert!(
540 matches!(result, Err(PathValidationError::UnmatchedClosingBrace { .. })),
541 "Path '{}' with unmatched closing brace should be rejected, got: {:?}",
542 path,
543 result
544 );
545 }
546
547 #[test]
551 fn prop_empty_parameter_rejected(
552 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
554 has_suffix in proptest::bool::ANY,
555 suffix_content in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
556 ) {
557 let suffix = if has_suffix {
559 format!("/{}", suffix_content)
560 } else {
561 String::new()
562 };
563 let path = format!("{}/{{}}{}", prefix, suffix);
564
565 let result = validate_path(&path);
566 prop_assert!(
567 matches!(result, Err(PathValidationError::EmptyParameterName { .. })),
568 "Path '{}' with empty parameter should be rejected with EmptyParameterName, got: {:?}",
569 path,
570 result
571 );
572 }
573
574 #[test]
579 fn prop_parameter_starting_with_digit_rejected(
580 prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
582 digit in "[0-9]",
583 rest in "[a-zA-Z0-9_]{0,5}",
584 ) {
585 let path = format!("{}/{{{}{}}}", prefix, digit, rest);
587
588 let result = validate_path(&path);
589 prop_assert!(
590 matches!(result, Err(PathValidationError::ParameterStartsWithDigit { .. })),
591 "Path '{}' with parameter starting with digit should be rejected, got: {:?}",
592 path,
593 result
594 );
595 }
596
597 #[test]
601 fn prop_double_slash_rejected(
602 prefix in "/[a-zA-Z0-9_-]{0,10}",
603 suffix in "[a-zA-Z0-9/_-]{0,10}",
604 ) {
605 let path = format!("{}//{}", prefix, suffix);
607
608 let result = validate_path(&path);
609 prop_assert!(
610 matches!(result, Err(PathValidationError::EmptySegment { .. })),
611 "Path '{}' with double slash should be rejected with EmptySegment, got: {:?}",
612 path,
613 result
614 );
615 }
616
617 #[test]
622 fn prop_error_contains_path(
623 invalid_type in 0..5usize,
625 content in "[a-zA-Z][a-zA-Z0-9_]{1,10}",
626 ) {
627 let path = match invalid_type {
628 0 => content.clone(), 1 => format!("/{}//test", content), 2 => format!("/{}/{{", content), 3 => format!("/{}/{{}}", content), 4 => format!("/{}/{{1{content}}}", content = content), _ => content.clone(),
634 };
635
636 let result = validate_path(&path);
637 if let Err(err) = result {
638 let error_message = err.to_string();
639 prop_assert!(
640 error_message.contains(&path) || error_message.contains(&content),
641 "Error message '{}' should contain the path or content for debugging",
642 error_message
643 );
644 }
645 }
646 }
647}