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_model_argument(arg: &str) -> Result<(), TuiError> {
62 let reject = |rule: &str| {
63 Err(TuiError::ValidationRejected {
64 value: arg.to_string(),
65 rule: rule.to_string(),
66 })
67 };
68 for c in arg.chars() {
69 if c == '[' || c == ']' {
70 continue;
71 }
72 if SHELL_METACHARACTERS.contains(&c) || crate::security::sanitize::is_disallowed_control(c)
73 {
74 return Err(TuiError::ValidationRejected {
75 value: arg.to_string(),
76 rule: format!("contains forbidden character {c:?}"),
77 });
78 }
79 }
80 let opens = arg.matches('[').count();
81 let closes = arg.matches(']').count();
82 if opens == 0 && closes == 0 {
83 return Ok(());
84 }
85 if opens != 1 || closes != 1 {
86 return reject("model parameters must be a single \"[...]\" group");
87 }
88 let open_at = arg.find('[').expect("one open bracket");
89 if !arg.ends_with(']') {
90 return reject("model parameter group must be at the end of the value");
91 }
92 if open_at == 0 {
93 return reject("model parameter group must follow a model id");
94 }
95 if arg.find(']').expect("one close bracket") < open_at {
96 return reject("model parameter group brackets are inverted");
97 }
98 Ok(())
99}
100
101pub fn validate_registry_path(path: &Path) -> Result<(), TuiError> {
111 let path_str = path.to_str().ok_or_else(|| TuiError::ValidationRejected {
112 value: path.to_string_lossy().to_string(),
113 rule: "non-UTF-8 sequence in registry path".to_string(),
114 })?;
115
116 for c in path_str.chars() {
117 let cp = c as u32;
118 let invalid_class: Option<InvalidCharClass> = if cp == 0 {
119 Some(InvalidCharClass::NullByte)
120 } else if cp <= 0x1F || cp == 0x7F {
121 Some(InvalidCharClass::ControlCharacter(cp))
122 } else if (0x80..=0x9F).contains(&cp) {
123 Some(InvalidCharClass::NonPrintableUnicode(cp))
124 } else {
125 None
126 };
127
128 if let Some(class) = invalid_class {
129 return Err(TuiError::ValidationRejected {
130 value: format!("registry path '{}' contains {}", path_str, class),
131 rule: format!("registry_path_invalid_char: {class}"),
132 });
133 }
134 }
135 Ok(())
136}
137
138pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, TuiError> {
142 if let Some(s) = path.to_str() {
144 if s.contains('\0') {
145 return Err(TuiError::PathTraversal {
146 path: path.display().to_string(),
147 });
148 }
149 } else {
150 return Err(TuiError::PathTraversal {
152 path: path.to_string_lossy().to_string(),
153 });
154 }
155
156 let canonical_root = workspace_root
157 .canonicalize()
158 .map_err(|_| TuiError::PathTraversal {
159 path: path.display().to_string(),
160 })?;
161
162 let canonical_path = path.canonicalize().map_err(|_| TuiError::PathTraversal {
163 path: path.display().to_string(),
164 })?;
165
166 if !canonical_path.starts_with(&canonical_root) {
167 return Err(TuiError::PathTraversal {
168 path: path.display().to_string(),
169 });
170 }
171
172 Ok(canonical_path)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use std::fs;
179 use tempfile::TempDir;
180
181 #[test]
182 fn validate_argument_accepts_safe() {
183 assert!(validate_argument("hello-world_1.0/path").is_ok());
184 }
185
186 #[test]
187 fn validate_model_argument_accepts_cursor_parameter_group() {
188 assert!(validate_model_argument("composer-2.5").is_ok());
189 assert!(validate_model_argument("claude-opus-5[effort=high]").is_ok());
190 assert!(validate_model_argument("gpt-5.6-sol[effort=high,context=300k]").is_ok());
191 assert!(validate_argument("claude-opus-5[effort=high]").is_err());
193 }
194
195 #[test]
196 fn validate_model_argument_still_rejects_metacharacters() {
197 for bad in [
198 "claude-opus-5[effort=high]; rm -rf /",
199 "claude-opus-5[effort=$(id)]",
200 "claude-opus-5[effort=`id`]",
201 "claude-opus-5[effort=high]|tee",
202 ] {
203 assert!(validate_model_argument(bad).is_err(), "should reject {bad}");
204 }
205 }
206
207 #[test]
208 fn validate_model_argument_rejects_malformed_bracket_shapes() {
209 for bad in [
210 "claude-opus-5[effort=high",
211 "claude-opus-5effort=high]",
212 "[effort=high]",
213 "claude[a=1][b=2]",
214 "claude-opus-5[a=1]trailing",
215 "claude-opus-5]a=1[",
216 ] {
217 assert!(validate_model_argument(bad).is_err(), "should reject {bad}");
218 }
219 }
220
221 #[test]
222 fn validate_argument_rejects_semicolon() {
223 let err = validate_argument("cmd; rm -rf /").unwrap_err();
224 match err {
225 TuiError::ValidationRejected { rule, .. } => {
226 assert!(rule.contains(';'));
227 }
228 other => panic!("unexpected error: {other:?}"),
229 }
230 }
231
232 #[test]
233 fn validate_argument_rejects_pipe() {
234 assert!(validate_argument("cmd | cat").is_err());
235 }
236
237 #[test]
238 fn validate_argument_rejects_backtick() {
239 assert!(validate_argument("$(whoami)").is_err());
240 }
241
242 #[test]
243 fn validate_argument_rejects_null() {
244 assert!(validate_argument("hello\0world").is_err());
245 }
246
247 #[test]
248 fn validate_argument_rejects_newline() {
249 assert!(validate_argument("line1\nline2").is_err());
250 }
251
252 #[test]
253 fn validate_argument_rejects_escape_control_byte() {
254 let err = validate_argument("gpt-5.6-sol\x1b[31m").unwrap_err();
258 match err {
259 TuiError::ValidationRejected { value, .. } => {
260 assert!(value.contains('\x1b'));
261 }
262 other => panic!("unexpected error: {other:?}"),
263 }
264 }
265
266 #[test]
267 fn validate_argument_accepts_registered_model_names() {
268 assert!(validate_argument("gpt-5.6-sol").is_ok());
269 assert!(validate_argument("llama3.3:70b").is_ok());
270 assert!(validate_argument("anthropic/claude-sonnet-4.5").is_ok());
271 }
272
273 #[test]
274 fn validate_path_accepts_within_workspace() {
275 let tmp = TempDir::new().unwrap();
276 let sub = tmp.path().join("subdir");
277 fs::create_dir_all(&sub).unwrap();
278 let file = sub.join("test.txt");
279 fs::write(&file, "content").unwrap();
280
281 let result = validate_path(&file, tmp.path());
282 assert!(result.is_ok());
283 }
284
285 #[test]
286 fn validate_path_rejects_outside_workspace() {
287 let tmp1 = TempDir::new().unwrap();
288 let tmp2 = TempDir::new().unwrap();
289 let file = tmp2.path().join("secret.txt");
290 fs::write(&file, "secret").unwrap();
291
292 let result = validate_path(&file, tmp1.path());
293 assert!(result.is_err());
294 }
295
296 #[test]
297 fn validate_path_rejects_traversal() {
298 let tmp = TempDir::new().unwrap();
299 let sub = tmp.path().join("sub");
300 fs::create_dir_all(&sub).unwrap();
301
302 let traversal = sub.join("..").join("..").join("etc").join("passwd");
304 let result = validate_path(&traversal, tmp.path());
305 assert!(result.is_err());
306 }
307}