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
32/// metacharacters or C0/C1 control bytes (e.g. ESC 0x1B) — the metachar list
33/// alone stops `; | & $` etc., but a raw control byte can still confuse a
34/// terminal or downstream tool even when it can't be interpreted by a shell.
35pub fn validate_argument(arg: &str) -> Result<(), TuiError> {
36    for c in arg.chars() {
37        if SHELL_METACHARACTERS.contains(&c) || crate::security::sanitize::is_disallowed_control(c)
38        {
39            return Err(TuiError::ValidationRejected {
40                value: arg.to_string(),
41                rule: format!("contains forbidden character {:?}", c),
42            });
43        }
44    }
45    Ok(())
46}
47
48/// Validate a workspace registry path string.
49///
50/// Rejects paths that contain:
51/// - Null bytes (U+0000) — Req 22.3, 20.5
52/// - ASCII control characters (0x01-0x1F, 0x7F) — Req 22.3
53/// - Unicode C1 controls (U+0080-U+009F) — Req 22.3
54///
55/// On rejection, the error message identifies the rejected path and the specific
56/// invalid character class. Non-UTF-8 `OsStr` paths are also rejected.
57pub fn validate_registry_path(path: &Path) -> Result<(), TuiError> {
58    let path_str = path.to_str().ok_or_else(|| TuiError::ValidationRejected {
59        value: path.to_string_lossy().to_string(),
60        rule: "non-UTF-8 sequence in registry path".to_string(),
61    })?;
62
63    for c in path_str.chars() {
64        let cp = c as u32;
65        let invalid_class: Option<InvalidCharClass> = if cp == 0 {
66            Some(InvalidCharClass::NullByte)
67        } else if cp <= 0x1F || cp == 0x7F {
68            Some(InvalidCharClass::ControlCharacter(cp))
69        } else if (0x80..=0x9F).contains(&cp) {
70            Some(InvalidCharClass::NonPrintableUnicode(cp))
71        } else {
72            None
73        };
74
75        if let Some(class) = invalid_class {
76            return Err(TuiError::ValidationRejected {
77                value: format!("registry path '{}' contains {}", path_str, class),
78                rule: format!("registry_path_invalid_char: {class}"),
79            });
80        }
81    }
82    Ok(())
83}
84
85/// Validate a path is within the workspace root.
86/// Canonicalizes both paths, then checks that the path starts with the workspace root.
87/// Rejects null bytes and paths that escape the workspace.
88pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, TuiError> {
89    // Reject null bytes in path
90    if let Some(s) = path.to_str() {
91        if s.contains('\0') {
92            return Err(TuiError::PathTraversal {
93                path: path.display().to_string(),
94            });
95        }
96    } else {
97        // Non-UTF-8 path - reject
98        return Err(TuiError::PathTraversal {
99            path: path.to_string_lossy().to_string(),
100        });
101    }
102
103    let canonical_root = workspace_root
104        .canonicalize()
105        .map_err(|_| TuiError::PathTraversal {
106            path: path.display().to_string(),
107        })?;
108
109    let canonical_path = path.canonicalize().map_err(|_| TuiError::PathTraversal {
110        path: path.display().to_string(),
111    })?;
112
113    if !canonical_path.starts_with(&canonical_root) {
114        return Err(TuiError::PathTraversal {
115            path: path.display().to_string(),
116        });
117    }
118
119    Ok(canonical_path)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::fs;
126    use tempfile::TempDir;
127
128    #[test]
129    fn validate_argument_accepts_safe() {
130        assert!(validate_argument("hello-world_1.0/path").is_ok());
131    }
132
133    #[test]
134    fn validate_argument_rejects_semicolon() {
135        let err = validate_argument("cmd; rm -rf /").unwrap_err();
136        match err {
137            TuiError::ValidationRejected { rule, .. } => {
138                assert!(rule.contains(';'));
139            }
140            other => panic!("unexpected error: {other:?}"),
141        }
142    }
143
144    #[test]
145    fn validate_argument_rejects_pipe() {
146        assert!(validate_argument("cmd | cat").is_err());
147    }
148
149    #[test]
150    fn validate_argument_rejects_backtick() {
151        assert!(validate_argument("$(whoami)").is_err());
152    }
153
154    #[test]
155    fn validate_argument_rejects_null() {
156        assert!(validate_argument("hello\0world").is_err());
157    }
158
159    #[test]
160    fn validate_argument_rejects_newline() {
161        assert!(validate_argument("line1\nline2").is_err());
162    }
163
164    #[test]
165    fn validate_argument_rejects_escape_control_byte() {
166        // SEC-7: ESC (0x1B) is a C0 control byte that predates the shell
167        // metacharacter list — it must be rejected even though it's not one
168        // of the punctuation characters in SHELL_METACHARACTERS.
169        let err = validate_argument("gpt-5.6-sol\x1b[31m").unwrap_err();
170        match err {
171            TuiError::ValidationRejected { value, .. } => {
172                assert!(value.contains('\x1b'));
173            }
174            other => panic!("unexpected error: {other:?}"),
175        }
176    }
177
178    #[test]
179    fn validate_argument_accepts_registered_model_names() {
180        assert!(validate_argument("gpt-5.6-sol").is_ok());
181        assert!(validate_argument("llama3.3:70b").is_ok());
182        assert!(validate_argument("anthropic/claude-sonnet-4.5").is_ok());
183    }
184
185    #[test]
186    fn validate_path_accepts_within_workspace() {
187        let tmp = TempDir::new().unwrap();
188        let sub = tmp.path().join("subdir");
189        fs::create_dir_all(&sub).unwrap();
190        let file = sub.join("test.txt");
191        fs::write(&file, "content").unwrap();
192
193        let result = validate_path(&file, tmp.path());
194        assert!(result.is_ok());
195    }
196
197    #[test]
198    fn validate_path_rejects_outside_workspace() {
199        let tmp1 = TempDir::new().unwrap();
200        let tmp2 = TempDir::new().unwrap();
201        let file = tmp2.path().join("secret.txt");
202        fs::write(&file, "secret").unwrap();
203
204        let result = validate_path(&file, tmp1.path());
205        assert!(result.is_err());
206    }
207
208    #[test]
209    fn validate_path_rejects_traversal() {
210        let tmp = TempDir::new().unwrap();
211        let sub = tmp.path().join("sub");
212        fs::create_dir_all(&sub).unwrap();
213
214        // Try to traverse out using ..
215        let traversal = sub.join("..").join("..").join("etc").join("passwd");
216        let result = validate_path(&traversal, tmp.path());
217        assert!(result.is_err());
218    }
219}