Skip to main content

vfa_tui/security/
validate.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::TuiError;
4
5/// Characters that are rejected in subprocess arguments.
6const SHELL_METACHARACTERS: &[char] = &[
7    ';', '|', '&', '$', '`', '\\', '<', '>', '(', ')', '{', '}', '!', '#', '*', '?', '[', ']',
8    '\n', '\r', '\0',
9];
10
11/// Describes what class of invalid character was found in a registry path.
12#[derive(Debug, PartialEq, Eq)]
13pub enum InvalidCharClass {
14    NullByte,
15    ControlCharacter(u32),
16    NonPrintableUnicode(u32),
17}
18
19impl std::fmt::Display for InvalidCharClass {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            InvalidCharClass::NullByte => write!(f, "null byte (U+0000)"),
23            InvalidCharClass::ControlCharacter(cp) => write!(f, "control character (U+{cp:04X})"),
24            InvalidCharClass::NonPrintableUnicode(cp) => {
25                write!(f, "non-printable Unicode character (U+{cp:04X})")
26            }
27        }
28    }
29}
30
31/// Validate a subprocess argument. Rejects if it contains any shell metacharacters.
32pub fn validate_argument(arg: &str) -> Result<(), TuiError> {
33    for c in arg.chars() {
34        if SHELL_METACHARACTERS.contains(&c) {
35            return Err(TuiError::ValidationRejected {
36                value: arg.to_string(),
37                rule: format!("contains forbidden character {:?}", c),
38            });
39        }
40    }
41    Ok(())
42}
43
44/// Validate a workspace registry path string.
45///
46/// Rejects paths that contain:
47/// - Null bytes (U+0000) — Req 22.3, 20.5
48/// - ASCII control characters (0x01-0x1F, 0x7F) — Req 22.3
49/// - Unicode C1 controls (U+0080-U+009F) — Req 22.3
50///
51/// On rejection, the error message identifies the rejected path and the specific
52/// invalid character class. Non-UTF-8 `OsStr` paths are also rejected.
53pub fn validate_registry_path(path: &Path) -> Result<(), TuiError> {
54    let path_str = path.to_str().ok_or_else(|| TuiError::ValidationRejected {
55        value: path.to_string_lossy().to_string(),
56        rule: "non-UTF-8 sequence in registry path".to_string(),
57    })?;
58
59    for c in path_str.chars() {
60        let cp = c as u32;
61        let invalid_class: Option<InvalidCharClass> = if cp == 0 {
62            Some(InvalidCharClass::NullByte)
63        } else if cp <= 0x1F || cp == 0x7F {
64            Some(InvalidCharClass::ControlCharacter(cp))
65        } else if (0x80..=0x9F).contains(&cp) {
66            Some(InvalidCharClass::NonPrintableUnicode(cp))
67        } else {
68            None
69        };
70
71        if let Some(class) = invalid_class {
72            return Err(TuiError::ValidationRejected {
73                value: format!("registry path '{}' contains {}", path_str, class),
74                rule: format!("registry_path_invalid_char: {class}"),
75            });
76        }
77    }
78    Ok(())
79}
80
81/// Validate a path is within the workspace root.
82/// Canonicalizes both paths, then checks that the path starts with the workspace root.
83/// Rejects null bytes and paths that escape the workspace.
84pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, TuiError> {
85    // Reject null bytes in path
86    if let Some(s) = path.to_str() {
87        if s.contains('\0') {
88            return Err(TuiError::PathTraversal {
89                path: path.display().to_string(),
90            });
91        }
92    } else {
93        // Non-UTF-8 path - reject
94        return Err(TuiError::PathTraversal {
95            path: path.to_string_lossy().to_string(),
96        });
97    }
98
99    let canonical_root = workspace_root
100        .canonicalize()
101        .map_err(|_| TuiError::PathTraversal {
102            path: path.display().to_string(),
103        })?;
104
105    let canonical_path = path.canonicalize().map_err(|_| TuiError::PathTraversal {
106        path: path.display().to_string(),
107    })?;
108
109    if !canonical_path.starts_with(&canonical_root) {
110        return Err(TuiError::PathTraversal {
111            path: path.display().to_string(),
112        });
113    }
114
115    Ok(canonical_path)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::fs;
122    use tempfile::TempDir;
123
124    #[test]
125    fn validate_argument_accepts_safe() {
126        assert!(validate_argument("hello-world_1.0/path").is_ok());
127    }
128
129    #[test]
130    fn validate_argument_rejects_semicolon() {
131        let err = validate_argument("cmd; rm -rf /").unwrap_err();
132        match err {
133            TuiError::ValidationRejected { rule, .. } => {
134                assert!(rule.contains(';'));
135            }
136            other => panic!("unexpected error: {other:?}"),
137        }
138    }
139
140    #[test]
141    fn validate_argument_rejects_pipe() {
142        assert!(validate_argument("cmd | cat").is_err());
143    }
144
145    #[test]
146    fn validate_argument_rejects_backtick() {
147        assert!(validate_argument("$(whoami)").is_err());
148    }
149
150    #[test]
151    fn validate_argument_rejects_null() {
152        assert!(validate_argument("hello\0world").is_err());
153    }
154
155    #[test]
156    fn validate_argument_rejects_newline() {
157        assert!(validate_argument("line1\nline2").is_err());
158    }
159
160    #[test]
161    fn validate_path_accepts_within_workspace() {
162        let tmp = TempDir::new().unwrap();
163        let sub = tmp.path().join("subdir");
164        fs::create_dir_all(&sub).unwrap();
165        let file = sub.join("test.txt");
166        fs::write(&file, "content").unwrap();
167
168        let result = validate_path(&file, tmp.path());
169        assert!(result.is_ok());
170    }
171
172    #[test]
173    fn validate_path_rejects_outside_workspace() {
174        let tmp1 = TempDir::new().unwrap();
175        let tmp2 = TempDir::new().unwrap();
176        let file = tmp2.path().join("secret.txt");
177        fs::write(&file, "secret").unwrap();
178
179        let result = validate_path(&file, tmp1.path());
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn validate_path_rejects_traversal() {
185        let tmp = TempDir::new().unwrap();
186        let sub = tmp.path().join("sub");
187        fs::create_dir_all(&sub).unwrap();
188
189        // Try to traverse out using ..
190        let traversal = sub.join("..").join("..").join("etc").join("passwd");
191        let result = validate_path(&traversal, tmp.path());
192        assert!(result.is_err());
193    }
194}