vfa_tui/security/
validate.rs1use std::path::{Path, PathBuf};
2
3use crate::error::TuiError;
4
5const SHELL_METACHARACTERS: &[char] = &[
7 ';', '|', '&', '$', '`', '\\', '<', '>', '(', ')', '{', '}', '!', '#', '*', '?', '[', ']',
8 '\n', '\r', '\0',
9];
10
11#[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
31pub 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
48pub 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
85pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, TuiError> {
89 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 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 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 let traversal = sub.join("..").join("..").join("etc").join("passwd");
216 let result = validate_path(&traversal, tmp.path());
217 assert!(result.is_err());
218 }
219}