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> {
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
44pub 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
81pub fn validate_path(path: &Path, workspace_root: &Path) -> Result<PathBuf, TuiError> {
85 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 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 let traversal = sub.join("..").join("..").join("etc").join("passwd");
191 let result = validate_path(&traversal, tmp.path());
192 assert!(result.is_err());
193 }
194}