Skip to main content

kindly_guard_server/cli/
validation.rs

1// Copyright 2025 Kindly Software Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! Input validation and sanitization for CLI commands
15
16use anyhow::{anyhow, bail, Result};
17use regex::Regex;
18use std::path::PathBuf;
19
20/// Maximum input size for text scanning (10MB)
21const MAX_SCAN_INPUT_SIZE: usize = 10 * 1024 * 1024;
22
23/// Valid port range for dashboard
24const MIN_USER_PORT: u16 = 1024;
25const MAX_PORT: u16 = 65535;
26
27/// Maximum path length
28const MAX_PATH_LENGTH: usize = 4096;
29
30/// Valid feature names for info command
31const VALID_FEATURES: &[&str] = &[
32    "unicode",
33    "injection",
34    "path",
35    "advanced",
36    "enhanced",
37    "all",
38];
39
40/// Valid output formats
41const VALID_FORMATS: &[&str] = &["json", "text", "minimal", "compact", "dashboard"];
42
43/// Pattern for safe feature names (alphanumeric + dash/underscore)
44static SAFE_NAME_PATTERN: std::sync::LazyLock<Regex> =
45    std::sync::LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap());
46
47/// Validate and sanitize file path
48pub fn validate_file_path(path: &str) -> Result<PathBuf> {
49    // Check length
50    if path.len() > MAX_PATH_LENGTH {
51        bail!("Path too long: maximum {} characters", MAX_PATH_LENGTH);
52    }
53
54    // Check for null bytes
55    if path.contains('\0') {
56        bail!("Invalid path: contains null bytes");
57    }
58
59    let path_buf = PathBuf::from(path);
60
61    // Prevent directory traversal
62    if path.contains("..") {
63        bail!("Invalid path: directory traversal detected");
64    }
65
66    // Normalize path
67    let canonical = if path_buf.exists() {
68        path_buf
69            .canonicalize()
70            .map_err(|e| anyhow!("Failed to resolve path: {}", e))?
71    } else {
72        // For non-existent files, just check parent directory
73        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
84/// Validate text input for scanning
85pub fn validate_scan_input(input: &str) -> Result<String> {
86    // Check size
87    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    // Validate UTF-8 (already guaranteed by Rust strings, but be explicit)
95    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
102/// Validate port number
103pub 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
119/// Validate feature name
120pub fn validate_feature_name(feature: &str) -> Result<String> {
121    // Check if it's a known feature
122    let feature_lower = feature.to_lowercase();
123    if VALID_FEATURES.contains(&feature_lower.as_str()) {
124        return Ok(feature_lower);
125    }
126
127    // For unknown features, ensure they're safe
128    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
139/// Validate output format
140pub 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        // Don't fail, just use default
147        Ok("text".to_string())
148    }
149}
150
151/// Sanitize output for display
152pub fn sanitize_output(text: &str) -> String {
153    // Remove control characters except newline, tab, and ANSI escape sequences
154    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                // Preserve ANSI escape sequences
162                result.push(ch);
163                // Simple ANSI sequence handling
164                if chars.next() == Some('[') {
165                    result.push('[');
166                    // Read until 'm' or invalid
167                    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                // Skip other control characters
177            },
178            _ => result.push(ch),
179        }
180    }
181
182    result
183}
184
185/// Validate command-line arguments before execution
186pub struct CommandValidator;
187
188impl CommandValidator {
189    /// Validate scan command inputs
190    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    /// Validate info command feature
199    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    /// Validate dashboard port
207    pub fn validate_dashboard_port(port: u16) -> Result<u16> {
208        validate_port(port)
209    }
210
211    /// Validate format option
212    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        // Valid paths
224        assert!(validate_file_path("/tmp/test.txt").is_ok());
225        assert!(validate_file_path("./local.file").is_ok());
226
227        // Directory traversal
228        assert!(validate_file_path("../../../etc/passwd").is_err());
229        assert!(validate_file_path("/tmp/../etc/passwd").is_err());
230
231        // Null bytes
232        assert!(validate_file_path("/tmp/test\0.txt").is_err());
233
234        // Too long
235        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        // Valid input
242        assert!(validate_scan_input("Hello, world!").is_ok());
243        assert!(validate_scan_input("Unicode: 你好").is_ok());
244
245        // Too large
246        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        // Valid ports
253        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        // Reserved ports
258        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        // Known features
266        assert_eq!(validate_feature_name("unicode").unwrap(), "unicode");
267        assert_eq!(validate_feature_name("INJECTION").unwrap(), "injection");
268
269        // Safe unknown features
270        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        // Invalid features
277        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        // Valid formats
285        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        // Invalid formats default to text
290        assert_eq!(validate_format("invalid").unwrap(), "text");
291        assert_eq!(validate_format("").unwrap(), "text");
292    }
293
294    #[test]
295    fn test_output_sanitization() {
296        // Preserve normal text
297        assert_eq!(sanitize_output("Hello, world!"), "Hello, world!");
298
299        // Preserve newlines and tabs
300        assert_eq!(
301            sanitize_output("Line 1\nLine 2\tTabbed"),
302            "Line 1\nLine 2\tTabbed"
303        );
304
305        // Preserve ANSI colors
306        assert_eq!(
307            sanitize_output("\x1b[31mRed text\x1b[0m"),
308            "\x1b[31mRed text\x1b[0m"
309        );
310
311        // Remove other control characters
312        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            // Should not panic on any input
326            let _ = validate_file_path(&path);
327        }
328
329        #[test]
330        fn fuzz_input_validation(input in "\\PC*") {
331            // Should not panic on any input
332            let _ = validate_scan_input(&input);
333        }
334
335        #[test]
336        fn fuzz_feature_validation(feature in "\\PC*") {
337            // Should not panic on any input
338            let _ = validate_feature_name(&feature);
339        }
340
341        #[test]
342        fn fuzz_output_sanitization(output in "\\PC*") {
343            // Should not panic and produce valid output
344            let sanitized = sanitize_output(&output);
345            // Result should not contain control chars except allowed ones
346            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}