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 model value for the model-policy builder.
49///
50/// Same denylist as [`validate_argument`] except that `[` and `]` are allowed,
51/// because Cursor documents per-model parameters appended to the model id in
52/// square brackets (`claude-opus-5[effort=high,context=300k]`). The relaxation
53/// is deliberately confined to this one field rather than widened in
54/// [`SHELL_METACHARACTERS`], so scope ids and every other argument keep the
55/// stricter rule.
56///
57/// Brackets must form exactly one balanced group at the end of the value; a
58/// stray, nested or leading bracket is rejected. Semantic validation of the
59/// group's contents (documented keys, key=value shape, no duplicates) belongs
60/// to `scripts/model-policy.mjs` and is not duplicated here.
61pub 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
101/// Validate a workspace registry path string.
102///
103/// Rejects paths that contain:
104/// - Null bytes (U+0000) — Req 22.3, 20.5
105/// - ASCII control characters (0x01-0x1F, 0x7F) — Req 22.3
106/// - Unicode C1 controls (U+0080-U+009F) — Req 22.3
107///
108/// On rejection, the error message identifies the rejected path and the specific
109/// invalid character class. Non-UTF-8 `OsStr` paths are also rejected.
110pub 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
138/// Validate a path is within the workspace root.
139/// Canonicalizes both paths, then checks that the path starts with the workspace root.
140/// Rejects null bytes and paths that escape the workspace.
141pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, TuiError> {
142    // Reject null bytes in path
143    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        // Non-UTF-8 path - reject
151        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        // The bracket relaxation must not leak into the general validator.
192        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        // SEC-7: ESC (0x1B) is a C0 control byte that predates the shell
255        // metacharacter list — it must be rejected even though it's not one
256        // of the punctuation characters in SHELL_METACHARACTERS.
257        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        // Try to traverse out using ..
303        let traversal = sub.join("..").join("..").join("etc").join("passwd");
304        let result = validate_path(&traversal, tmp.path());
305        assert!(result.is_err());
306    }
307}