Skip to main content

rskit_validation/
input.rs

1//! Validation helpers for path and text inputs.
2
3use rskit_errors::{AppError, AppResult};
4
5/// Validate a required string that must not contain leading or trailing whitespace.
6pub fn validate_required_trimmed(field: &str, value: &str) -> AppResult<()> {
7    reject_unicode_controls(field, value)?;
8    if value.trim().is_empty() {
9        return Err(AppError::invalid_input(field, "is required"));
10    }
11    if value != value.trim() {
12        return Err(AppError::invalid_input(
13            field,
14            "cannot contain leading or trailing whitespace",
15        ));
16    }
17    Ok(())
18}
19
20/// Validate a required identifier that is safe to use as a path segment.
21pub fn validate_path_safe_identifier(field: &str, value: &str) -> AppResult<()> {
22    validate_required_trimmed(field, value)?;
23    if value.contains(['/', '\\', ':']) || value == "." || value == ".." {
24        return Err(AppError::invalid_input(
25            field,
26            "cannot contain path separators or traversal markers",
27        ));
28    }
29    Ok(())
30}
31
32/// Validate an optional string when present and return `None` for absent values.
33pub fn validate_optional_trimmed(field: &str, value: Option<String>) -> AppResult<Option<String>> {
34    value
35        .map(|value| {
36            validate_required_trimmed(field, &value)?;
37            Ok(value)
38        })
39        .transpose()
40}
41
42/// Validate that a path-like input cannot traverse outside its base.
43pub fn validate_safe_path(path: &str) -> AppResult<()> {
44    reject_unicode_controls("path", path)?;
45    if path.is_empty()
46        || path.starts_with('/')
47        || path.starts_with('\\')
48        || path.split(['/', '\\']).any(|segment| {
49            segment.is_empty() || segment == "." || segment == ".." || segment.contains(':')
50        })
51    {
52        return Err(AppError::invalid_input(
53            "path",
54            "path must be relative and must not contain traversal segments",
55        ));
56    }
57    Ok(())
58}
59
60/// Reject control characters that can hide or reorder text.
61pub fn reject_unicode_controls(field: &str, value: &str) -> AppResult<()> {
62    for ch in value.chars() {
63        if ch.is_control()
64            || matches!(
65                ch,
66                '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}'
67            )
68        {
69            return Err(AppError::invalid_input(
70                field,
71                "input contains forbidden Unicode control characters",
72            ));
73        }
74    }
75    Ok(())
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn path_validation_rejects_traversal_and_mixed_separators() {
84        assert!(validate_safe_path("tenant/report.json").is_ok());
85        assert!(validate_safe_path("../secret").is_err());
86        assert!(validate_safe_path("tenant\\..\\secret").is_err());
87        assert!(validate_safe_path("tenant/..\\secret").is_err());
88    }
89
90    #[test]
91    fn unicode_validation_rejects_control_characters() {
92        assert!(reject_unicode_controls("identifier", "safe-id").is_ok());
93        assert!(reject_unicode_controls("identifier", "safe\u{202e}txt").is_err());
94        assert!(reject_unicode_controls("identifier", "павел").is_ok());
95    }
96
97    #[test]
98    fn required_trimmed_rejects_whitespace_only_and_untrimmed_values() {
99        assert!(validate_required_trimmed("name", "value").is_ok());
100        assert!(validate_required_trimmed("name", "   ").is_err());
101        assert!(validate_required_trimmed("name", "\t").is_err());
102        assert!(validate_required_trimmed("name", " value").is_err());
103        assert!(validate_required_trimmed("name", "value ").is_err());
104    }
105
106    #[test]
107    fn optional_trimmed_accepts_absent_and_rejects_invalid_present_values() {
108        assert_eq!(validate_optional_trimmed("name", None).unwrap(), None);
109        assert_eq!(
110            validate_optional_trimmed("name", Some("value".to_string())).unwrap(),
111            Some("value".to_string())
112        );
113        assert!(validate_optional_trimmed("name", Some(" value".to_string())).is_err());
114        assert!(validate_optional_trimmed("name", Some("\n".to_string())).is_err());
115    }
116
117    #[test]
118    fn path_safe_identifier_rejects_controls_traversal_and_separators() {
119        assert!(validate_path_safe_identifier("id", "tenant_01").is_ok());
120        assert!(validate_path_safe_identifier("id", ".").is_err());
121        assert!(validate_path_safe_identifier("id", "..").is_err());
122        assert!(validate_path_safe_identifier("id", "tenant/name").is_err());
123        assert!(validate_path_safe_identifier("id", "tenant\\name").is_err());
124        assert!(validate_path_safe_identifier("id", "tenant:name").is_err());
125        assert!(validate_path_safe_identifier("id", "tenant\u{202e}name").is_err());
126    }
127
128    #[test]
129    fn safe_path_rejects_empty_absolute_controls_traversal_and_separators() {
130        assert!(validate_safe_path("tenant/report.json").is_ok());
131        assert!(validate_safe_path("").is_err());
132        assert!(validate_safe_path("/tenant/report.json").is_err());
133        assert!(validate_safe_path("\\tenant\\report.json").is_err());
134        assert!(validate_safe_path("./tenant").is_err());
135        assert!(validate_safe_path("tenant//report.json").is_err());
136        assert!(validate_safe_path("tenant/report:name").is_err());
137        assert!(validate_safe_path("tenant/re\u{202e}port").is_err());
138    }
139}