kindly_guard_server/cli/
validation.rs1use anyhow::{anyhow, bail, Result};
17use regex::Regex;
18use std::path::PathBuf;
19
20const MAX_SCAN_INPUT_SIZE: usize = 10 * 1024 * 1024;
22
23const MIN_USER_PORT: u16 = 1024;
25const MAX_PORT: u16 = 65535;
26
27const MAX_PATH_LENGTH: usize = 4096;
29
30const VALID_FEATURES: &[&str] = &[
32 "unicode",
33 "injection",
34 "path",
35 "advanced",
36 "enhanced",
37 "all",
38];
39
40const VALID_FORMATS: &[&str] = &["json", "text", "minimal", "compact", "dashboard"];
42
43static SAFE_NAME_PATTERN: std::sync::LazyLock<Regex> =
45 std::sync::LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap());
46
47pub fn validate_file_path(path: &str) -> Result<PathBuf> {
49 if path.len() > MAX_PATH_LENGTH {
51 bail!("Path too long: maximum {} characters", MAX_PATH_LENGTH);
52 }
53
54 if path.contains('\0') {
56 bail!("Invalid path: contains null bytes");
57 }
58
59 let path_buf = PathBuf::from(path);
60
61 if path.contains("..") {
63 bail!("Invalid path: directory traversal detected");
64 }
65
66 let canonical = if path_buf.exists() {
68 path_buf
69 .canonicalize()
70 .map_err(|e| anyhow!("Failed to resolve path: {}", e))?
71 } else {
72 if let Some(parent) = path_buf.parent() {
74 if parent.exists() && !parent.is_dir() {
75 bail!("Parent path is not a directory");
76 }
77 }
78 path_buf
79 };
80
81 Ok(canonical)
82}
83
84pub fn validate_scan_input(input: &str) -> Result<String> {
86 if input.len() > MAX_SCAN_INPUT_SIZE {
88 bail!(
89 "Input too large: maximum {} MB",
90 MAX_SCAN_INPUT_SIZE / 1024 / 1024
91 );
92 }
93
94 if let Err(e) = std::str::from_utf8(input.as_bytes()) {
96 bail!("Invalid UTF-8 input: {}", e);
97 }
98
99 Ok(input.to_string())
100}
101
102pub fn validate_port(port: u16) -> Result<u16> {
104 if port < MIN_USER_PORT {
105 bail!(
106 "Port {} is reserved. Use ports {} and above",
107 port,
108 MIN_USER_PORT
109 );
110 }
111
112 if port > MAX_PORT {
113 bail!("Invalid port: {} exceeds maximum", port);
114 }
115
116 Ok(port)
117}
118
119pub fn validate_feature_name(feature: &str) -> Result<String> {
121 let feature_lower = feature.to_lowercase();
123 if VALID_FEATURES.contains(&feature_lower.as_str()) {
124 return Ok(feature_lower);
125 }
126
127 if !SAFE_NAME_PATTERN.is_match(feature) {
129 bail!("Invalid feature name: must be alphanumeric with dashes/underscores");
130 }
131
132 if feature.len() > 50 {
133 bail!("Feature name too long: maximum 50 characters");
134 }
135
136 Ok(feature.to_string())
137}
138
139pub fn validate_format(format: &str) -> Result<String> {
141 let format_lower = format.to_lowercase();
142
143 if VALID_FORMATS.contains(&format_lower.as_str()) {
144 Ok(format_lower)
145 } else {
146 Ok("text".to_string())
148 }
149}
150
151pub fn sanitize_output(text: &str) -> String {
153 let mut result = String::with_capacity(text.len());
155
156 let mut chars = text.chars();
157 while let Some(ch) = chars.next() {
158 match ch {
159 '\n' | '\t' => result.push(ch),
160 '\x1b' => {
161 result.push(ch);
163 if chars.next() == Some('[') {
165 result.push('[');
166 for ch in chars.by_ref() {
168 result.push(ch);
169 if ch == 'm' || !ch.is_ascii() {
170 break;
171 }
172 }
173 }
174 },
175 _ if ch.is_control() => {
176 },
178 _ => result.push(ch),
179 }
180 }
181
182 result
183}
184
185pub struct CommandValidator;
187
188impl CommandValidator {
189 pub fn validate_scan(input: &str, is_text: bool) -> Result<String> {
191 if is_text {
192 validate_scan_input(input)
193 } else {
194 validate_file_path(input).map(|p| p.to_string_lossy().to_string())
195 }
196 }
197
198 pub fn validate_info_feature(feature: Option<&str>) -> Result<Option<String>> {
200 match feature {
201 Some(f) => validate_feature_name(f).map(Some),
202 None => Ok(None),
203 }
204 }
205
206 pub fn validate_dashboard_port(port: u16) -> Result<u16> {
208 validate_port(port)
209 }
210
211 pub fn validate_format(format: &str) -> Result<String> {
213 validate_format(format)
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_file_path_validation() {
223 assert!(validate_file_path("/tmp/test.txt").is_ok());
225 assert!(validate_file_path("./local.file").is_ok());
226
227 assert!(validate_file_path("../../../etc/passwd").is_err());
229 assert!(validate_file_path("/tmp/../etc/passwd").is_err());
230
231 assert!(validate_file_path("/tmp/test\0.txt").is_err());
233
234 let long_path = "a".repeat(5000);
236 assert!(validate_file_path(&long_path).is_err());
237 }
238
239 #[test]
240 fn test_scan_input_validation() {
241 assert!(validate_scan_input("Hello, world!").is_ok());
243 assert!(validate_scan_input("Unicode: 你好").is_ok());
244
245 let large_input = "x".repeat(MAX_SCAN_INPUT_SIZE + 1);
247 assert!(validate_scan_input(&large_input).is_err());
248 }
249
250 #[test]
251 fn test_port_validation() {
252 assert_eq!(validate_port(3000).unwrap(), 3000);
254 assert_eq!(validate_port(8080).unwrap(), 8080);
255 assert_eq!(validate_port(65535).unwrap(), 65535);
256
257 assert!(validate_port(80).is_err());
259 assert!(validate_port(443).is_err());
260 assert!(validate_port(22).is_err());
261 }
262
263 #[test]
264 fn test_feature_name_validation() {
265 assert_eq!(validate_feature_name("unicode").unwrap(), "unicode");
267 assert_eq!(validate_feature_name("INJECTION").unwrap(), "injection");
268
269 assert_eq!(
271 validate_feature_name("custom-feature").unwrap(),
272 "custom-feature"
273 );
274 assert_eq!(validate_feature_name("test_123").unwrap(), "test_123");
275
276 assert!(validate_feature_name("../../etc").is_err());
278 assert!(validate_feature_name("feature with spaces").is_err());
279 assert!(validate_feature_name("feature!@#").is_err());
280 }
281
282 #[test]
283 fn test_format_validation() {
284 assert_eq!(validate_format("json").unwrap(), "json");
286 assert_eq!(validate_format("JSON").unwrap(), "json");
287 assert_eq!(validate_format("minimal").unwrap(), "minimal");
288
289 assert_eq!(validate_format("invalid").unwrap(), "text");
291 assert_eq!(validate_format("").unwrap(), "text");
292 }
293
294 #[test]
295 fn test_output_sanitization() {
296 assert_eq!(sanitize_output("Hello, world!"), "Hello, world!");
298
299 assert_eq!(
301 sanitize_output("Line 1\nLine 2\tTabbed"),
302 "Line 1\nLine 2\tTabbed"
303 );
304
305 assert_eq!(
307 sanitize_output("\x1b[31mRed text\x1b[0m"),
308 "\x1b[31mRed text\x1b[0m"
309 );
310
311 assert_eq!(sanitize_output("Bad\x00\x01\x02chars"), "Badchars");
313 assert_eq!(sanitize_output("Bell\x07Alert"), "BellAlert");
314 }
315}
316
317#[cfg(test)]
318mod fuzz_tests {
319 use super::*;
320 use proptest::prelude::*;
321
322 proptest! {
323 #[test]
324 fn fuzz_path_validation(path in "\\PC*") {
325 let _ = validate_file_path(&path);
327 }
328
329 #[test]
330 fn fuzz_input_validation(input in "\\PC*") {
331 let _ = validate_scan_input(&input);
333 }
334
335 #[test]
336 fn fuzz_feature_validation(feature in "\\PC*") {
337 let _ = validate_feature_name(&feature);
339 }
340
341 #[test]
342 fn fuzz_output_sanitization(output in "\\PC*") {
343 let sanitized = sanitize_output(&output);
345 for ch in sanitized.chars() {
347 assert!(
348 !ch.is_control() ||
349 ch == '\n' ||
350 ch == '\t' ||
351 ch == '\x1b'
352 );
353 }
354 }
355 }
356}