rustapi_core/
path_validation.rs

1//! Route path validation utilities
2//!
3//! This module provides compile-time and runtime validation for route paths.
4//! The validation logic is shared between the proc-macro crate (for compile-time
5//! validation) and the core crate (for runtime validation and testing).
6
7/// Result of path validation
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum PathValidationError {
10    /// Path must start with '/'
11    MustStartWithSlash { path: String },
12    /// Path contains empty segment (double slash)
13    EmptySegment { path: String },
14    /// Nested braces are not allowed
15    NestedBraces { path: String, position: usize },
16    /// Unmatched closing brace
17    UnmatchedClosingBrace { path: String, position: usize },
18    /// Empty parameter name
19    EmptyParameterName { path: String, position: usize },
20    /// Invalid parameter name (contains invalid characters)
21    InvalidParameterName {
22        path: String,
23        param_name: String,
24        position: usize,
25    },
26    /// Parameter name starts with digit
27    ParameterStartsWithDigit {
28        path: String,
29        param_name: String,
30        position: usize,
31    },
32    /// Unclosed brace
33    UnclosedBrace { path: String },
34    /// Invalid character in path
35    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
118/// Validate route path syntax
119///
120/// Returns Ok(()) if the path is valid, or Err with a descriptive error.
121///
122/// # Valid paths
123/// - Must start with '/'
124/// - Can contain alphanumeric characters, '-', '_', '.', '/'
125/// - Can contain path parameters in the form `{param_name}`
126/// - Parameter names must be valid identifiers (alphanumeric + underscore, not starting with digit)
127///
128/// # Invalid paths
129/// - Paths not starting with '/'
130/// - Paths with empty segments (double slashes like '//')
131/// - Paths with unclosed or nested braces
132/// - Paths with empty parameter names like '{}'
133/// - Paths with invalid parameter names
134/// - Paths with invalid characters
135///
136/// # Examples
137///
138/// ```
139/// use rustapi_core::path_validation::validate_path;
140///
141/// // Valid paths
142/// assert!(validate_path("/").is_ok());
143/// assert!(validate_path("/users").is_ok());
144/// assert!(validate_path("/users/{id}").is_ok());
145/// assert!(validate_path("/users/{user_id}/posts/{post_id}").is_ok());
146///
147/// // Invalid paths
148/// assert!(validate_path("users").is_err()); // Missing leading /
149/// assert!(validate_path("/users//posts").is_err()); // Double slash
150/// assert!(validate_path("/users/{").is_err()); // Unclosed brace
151/// assert!(validate_path("/users/{}").is_err()); // Empty parameter
152/// assert!(validate_path("/users/{123}").is_err()); // Parameter starts with digit
153/// ```
154pub fn validate_path(path: &str) -> Result<(), PathValidationError> {
155    // Path must start with /
156    if !path.starts_with('/') {
157        return Err(PathValidationError::MustStartWithSlash {
158            path: path.to_string(),
159        });
160    }
161
162    // Check for empty path segments (double slashes)
163    if path.contains("//") {
164        return Err(PathValidationError::EmptySegment {
165            path: path.to_string(),
166        });
167    }
168
169    // Validate path parameter syntax
170    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                // Check that parameter name is not empty
195                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                    // Validate parameter name contains only valid identifier characters
204                    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                    // Parameter name must not start with a digit
212                    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            // Check for invalid characters in path (outside of parameters)
228            _ if brace_depth == 0 => {
229                // Allow alphanumeric, -, _, ., /, and common URL characters
230                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    // Check for unclosed braces
243    if brace_depth > 0 {
244        return Err(PathValidationError::UnclosedBrace {
245            path: path.to_string(),
246        });
247    }
248
249    Ok(())
250}
251
252/// Check if a path is valid (convenience function)
253pub 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    // Unit tests for specific cases
263    #[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()); // Wildcard
276    }
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    // **Feature: phase4-ergonomics-v1, Property 2: Invalid Path Syntax Rejection**
426    //
427    // For any route path string that contains invalid syntax (e.g., unclosed braces,
428    // invalid characters), the system should reject it with a clear error message.
429    //
430    // **Validates: Requirements 1.5**
431    proptest! {
432        #![proptest_config(ProptestConfig::with_cases(100))]
433
434        /// Property: Valid paths are accepted
435        ///
436        /// For any path that follows the valid path structure:
437        /// - Starts with /
438        /// - Contains only valid segments (alphanumeric, -, _, .)
439        /// - Has properly formed parameters {name} where name is a valid identifier
440        ///
441        /// The validation should succeed.
442        #[test]
443        fn prop_valid_paths_accepted(
444            // Generate valid path segments (non-empty to avoid double slashes)
445            segments in prop::collection::vec("[a-zA-Z][a-zA-Z0-9_-]{0,10}", 0..5),
446            // Generate valid parameter names (must start with letter or underscore)
447            params in prop::collection::vec("[a-zA-Z_][a-zA-Z0-9_]{0,10}", 0..3),
448        ) {
449            // Build a valid path from segments and parameters
450            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            // Add parameters at the end (only if we have segments or it's the root)
460            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            // If path is just "/", that's valid
470            // Otherwise ensure we have a valid structure
471            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        /// Property: Paths without leading slash are rejected
481        ///
482        /// For any path that doesn't start with '/', validation should fail
483        /// with MustStartWithSlash error.
484        #[test]
485        fn prop_missing_leading_slash_rejected(
486            // Generate path content that doesn't start with /
487            content in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
488        ) {
489            // Ensure the path doesn't start with /
490            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        /// Property: Paths with unclosed braces are rejected
506        ///
507        /// For any path containing an unclosed '{', validation should fail.
508        #[test]
509        fn prop_unclosed_brace_rejected(
510            // Use a valid prefix without double slashes
511            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            // Create a path with an unclosed brace
515            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        /// Property: Paths with unmatched closing braces are rejected
527        ///
528        /// For any path containing a '}' without a matching '{', validation should fail.
529        #[test]
530        fn prop_unmatched_closing_brace_rejected(
531            // Use a valid prefix without double slashes
532            prefix in "/[a-zA-Z][a-zA-Z0-9_-]{0,10}",
533            suffix in "[a-zA-Z0-9_]{0,5}",
534        ) {
535            // Create a path with an unmatched closing brace
536            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        /// Property: Paths with empty parameter names are rejected
548        ///
549        /// For any path containing '{}', validation should fail with EmptyParameterName.
550        #[test]
551        fn prop_empty_parameter_rejected(
552            // Use a valid prefix without double slashes
553            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            // Create a path with an empty parameter
558            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        /// Property: Paths with parameters starting with digits are rejected
575        ///
576        /// For any path containing a parameter that starts with a digit,
577        /// validation should fail with ParameterStartsWithDigit.
578        #[test]
579        fn prop_parameter_starting_with_digit_rejected(
580            // Use a valid prefix without double slashes
581            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            // Create a path with a parameter starting with a digit
586            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        /// Property: Paths with double slashes are rejected
598        ///
599        /// For any path containing '//', validation should fail with EmptySegment.
600        #[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            // Create a path with double slash
606            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        /// Property: Error messages contain the original path
618        ///
619        /// For any invalid path, the error message should contain the original path
620        /// for debugging purposes.
621        #[test]
622        fn prop_error_contains_path(
623            // Generate various invalid paths
624            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(), // Missing leading slash
629                1 => format!("/{}//test", content), // Double slash
630                2 => format!("/{}/{{", content), // Unclosed brace
631                3 => format!("/{}/{{}}", content), // Empty parameter
632                4 => format!("/{}/{{1{content}}}", content = content), // Parameter starts with digit
633                _ => 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}