adaptive_pipeline_bootstrap/cli/
validator.rs

1// /////////////////////////////////////////////////////////////////////////////
2// Adaptive Pipeline
3// Copyright (c) 2025 Michael Gardner, A Bit of Help, Inc.
4// SPDX-License-Identifier: BSD-3-Clause
5// See LICENSE file in the project root.
6// /////////////////////////////////////////////////////////////////////////////
7
8//! # Secure Command-Line Argument Parsing
9//!
10//! Security-first argument parsing with comprehensive validation.
11//!
12//! ## Security Features
13//!
14//! - **Length limits** - Prevent buffer overflow attempts
15//! - **Pattern detection** - Block path traversal and injection
16//! - **Path normalization** - Canonical path resolution
17//! - **System directory protection** - Prevent access to sensitive paths
18//!
19//! ## Dangerous Patterns Detected
20//!
21//! - `..` - Path traversal
22//! - `~` - Home directory expansion (security risk)
23//! - `$` - Variable expansion
24//! - Backticks - Command substitution
25//! - `;` `&` `|` - Command chaining
26//! - `>` `<` - Redirection
27//! - Null bytes, newlines, carriage returns
28//!
29//! ## Usage
30//!
31//! ```rust,no_run
32//! use adaptive_pipeline_bootstrap::cli::SecureArgParser;
33//! use adaptive_pipeline_bootstrap::config::AppConfig;
34//!
35//! let args: Vec<String> = std::env::args().collect();
36//! let config = SecureArgParser::parse(&args)?;
37//!
38//! println!("Running: {}", config.app_name());
39//! # Ok::<(), Box<dyn std::error::Error>>(())
40//! ```
41
42use crate::config::AppConfig;
43use std::path::{Path, PathBuf};
44use thiserror::Error;
45
46/// Maximum argument count (prevent DOS)
47const MAX_ARG_COUNT: usize = 100;
48
49/// Maximum single argument length
50const MAX_ARG_LENGTH: usize = 1000;
51
52/// Maximum path length
53const MAX_PATH_LENGTH: usize = 4096;
54
55/// Dangerous patterns that indicate potential attacks
56const DANGEROUS_PATTERNS: &[&str] = &[
57    "..", // Path traversal
58    "~",  // Home directory
59    "$",  // Variable expansion
60    "`",  // Command substitution
61    ";",  // Command chaining
62    "&",  // Background/AND
63    "|",  // Pipe
64    ">",  // Redirect output
65    "<",  // Redirect input
66    "\n", // Newline
67    "\r", // Carriage return
68    "\0", // Null byte
69];
70
71/// Protected system directories
72const PROTECTED_DIRS: &[&str] = &[
73    "/etc",
74    "/bin",
75    "/sbin",
76    "/usr/bin",
77    "/usr/sbin",
78    "/boot",
79    "/sys",
80    "/proc",
81    "/dev",
82];
83
84/// Secure argument parsing errors
85#[derive(Debug, Error)]
86pub enum ParseError {
87    /// Too many arguments provided
88    #[error("Too many arguments (max {MAX_ARG_COUNT})")]
89    TooManyArguments,
90
91    /// Argument exceeds maximum length
92    #[error("Argument too long (max {MAX_ARG_LENGTH} characters): {0}")]
93    ArgumentTooLong(String),
94
95    /// Dangerous pattern detected
96    #[error("Dangerous pattern detected in argument: {pattern} in {arg}")]
97    DangerousPattern { pattern: String, arg: String },
98
99    /// Path too long
100    #[error("Path exceeds maximum length (max {MAX_PATH_LENGTH})")]
101    PathTooLong,
102
103    /// Attempted access to protected system directory
104    #[error("Access to protected system directory denied: {0}")]
105    ProtectedDirectory(String),
106
107    /// Path does not exist
108    #[error("Path does not exist: {0}")]
109    PathNotFound(String),
110
111    /// Invalid path
112    #[error("Invalid path: {0}")]
113    InvalidPath(String),
114
115    /// Missing required argument
116    #[error("Missing required argument: {0}")]
117    MissingArgument(String),
118
119    /// Invalid argument value
120    #[error("Invalid argument value for {arg}: {reason}")]
121    InvalidValue { arg: String, reason: String },
122}
123
124/// Secure argument parser
125///
126/// Provides security-first parsing with comprehensive validation.
127pub struct SecureArgParser;
128
129impl SecureArgParser {
130    /// Parse command-line arguments securely
131    ///
132    /// # Security Validations
133    ///
134    /// 1. Count limit check
135    /// 2. Length validation
136    /// 3. Dangerous pattern detection
137    /// 4. Path normalization
138    /// 5. Protected directory check
139    ///
140    /// # Errors
141    ///
142    /// Returns `ParseError` if any validation fails
143    pub fn parse(args: &[String]) -> Result<AppConfig, ParseError> {
144        // Validate argument count
145        if args.len() > MAX_ARG_COUNT {
146            return Err(ParseError::TooManyArguments);
147        }
148
149        // For now, create a simple default config
150        // In a real implementation, this would use clap to parse args
151        // and then validate each parsed value
152
153        // This is a placeholder - proper implementation would:
154        // 1. Use clap to define CLI structure
155        // 2. Parse arguments
156        // 3. Validate each value using validation methods below
157        // 4. Build and return AppConfig
158
159        Ok(AppConfig::builder().app_name("adaptive-pipeline").build())
160    }
161
162    /// Validate a single argument for security issues
163    ///
164    /// # Errors
165    ///
166    /// - `ArgumentTooLong` if exceeds max length
167    /// - `DangerousPattern` if contains dangerous patterns
168    pub fn validate_argument(arg: &str) -> Result<(), ParseError> {
169        // Length check
170        if arg.len() > MAX_ARG_LENGTH {
171            return Err(ParseError::ArgumentTooLong(
172                arg.chars().take(50).collect::<String>() + "...",
173            ));
174        }
175
176        // Dangerous pattern check
177        for pattern in DANGEROUS_PATTERNS {
178            if arg.contains(pattern) {
179                return Err(ParseError::DangerousPattern {
180                    pattern: pattern.to_string(),
181                    arg: arg.to_string(),
182                });
183            }
184        }
185
186        Ok(())
187    }
188
189    /// Validate and normalize a file path
190    ///
191    /// # Security Checks
192    ///
193    /// 1. Length validation
194    /// 2. Dangerous pattern detection
195    /// 3. Path canonicalization
196    /// 4. Protected directory check
197    ///
198    /// # Returns
199    ///
200    /// Canonical absolute path if valid
201    ///
202    /// # Errors
203    ///
204    /// Returns `ParseError` if path fails validation
205    pub fn validate_path(path: &str) -> Result<PathBuf, ParseError> {
206        // Basic validation
207        Self::validate_argument(path).map_err(|e| match e {
208            ParseError::ArgumentTooLong(_) => ParseError::InvalidPath(format!("Path too long: {}", path)),
209            ParseError::DangerousPattern { pattern, .. } => {
210                ParseError::InvalidPath(format!("Path contains dangerous pattern '{}': {}", pattern, path))
211            }
212            other => other,
213        })?;
214
215        // Path object creation
216        let path_obj = Path::new(path);
217
218        // Try to canonicalize (resolves .., symlinks, etc.)
219        let canonical = path_obj.canonicalize().map_err(|e| {
220            if !path_obj.exists() {
221                ParseError::PathNotFound(path.to_string())
222            } else {
223                ParseError::InvalidPath(format!("{}: {}", path, e))
224            }
225        })?;
226
227        // Length check on canonical path
228        if canonical.to_string_lossy().len() > MAX_PATH_LENGTH {
229            return Err(ParseError::PathTooLong);
230        }
231
232        // Protected directory check
233        for protected in PROTECTED_DIRS {
234            if canonical.starts_with(protected) {
235                return Err(ParseError::ProtectedDirectory(canonical.display().to_string()));
236            }
237        }
238
239        Ok(canonical)
240    }
241
242    /// Validate an optional path (may be None)
243    pub fn validate_optional_path(path: Option<&str>) -> Result<Option<PathBuf>, ParseError> {
244        match path {
245            Some(p) => Self::validate_path(p).map(Some),
246            None => Ok(None),
247        }
248    }
249
250    /// Validate a number argument
251    pub fn validate_number<T>(arg_name: &str, value: &str, min: Option<T>, max: Option<T>) -> Result<T, ParseError>
252    where
253        T: std::str::FromStr + PartialOrd + std::fmt::Display,
254    {
255        // Basic validation
256        Self::validate_argument(value)?;
257
258        // Parse
259        let num = value.parse::<T>().map_err(|_| ParseError::InvalidValue {
260            arg: arg_name.to_string(),
261            reason: format!("Not a valid number: {}", value),
262        })?;
263
264        // Range check
265        if let Some(min_val) = min {
266            if num < min_val {
267                return Err(ParseError::InvalidValue {
268                    arg: arg_name.to_string(),
269                    reason: format!("Value {} is less than minimum {}", value, min_val),
270                });
271            }
272        }
273
274        if let Some(max_val) = max {
275            if num > max_val {
276                return Err(ParseError::InvalidValue {
277                    arg: arg_name.to_string(),
278                    reason: format!("Value {} is greater than maximum {}", value, max_val),
279                });
280            }
281        }
282
283        Ok(num)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    mod argument_validation {
292        use super::*;
293
294        #[test]
295        fn accepts_safe_arguments() {
296            assert!(SecureArgParser::validate_argument("safe-arg").is_ok());
297            assert!(SecureArgParser::validate_argument("file.txt").is_ok());
298            assert!(SecureArgParser::validate_argument("path/to/file").is_ok());
299        }
300
301        #[test]
302        fn rejects_too_long_arguments() {
303            let long_arg = "a".repeat(MAX_ARG_LENGTH + 1);
304            assert!(matches!(
305                SecureArgParser::validate_argument(&long_arg),
306                Err(ParseError::ArgumentTooLong(_))
307            ));
308        }
309
310        #[test]
311        fn detects_dangerous_patterns() {
312            let dangerous = vec![
313                "../etc/passwd",
314                "~/.ssh/id_rsa",
315                "$(whoami)",
316                "`ls`",
317                "file;rm -rf /",
318                "file&background",
319                "file|pipe",
320                "file>output",
321                "file<input",
322                "file\nwith\nnewlines",
323            ];
324
325            for arg in dangerous {
326                assert!(
327                    matches!(
328                        SecureArgParser::validate_argument(arg),
329                        Err(ParseError::DangerousPattern { .. })
330                    ),
331                    "Failed to detect dangerous pattern in: {}",
332                    arg
333                );
334            }
335        }
336    }
337
338    mod number_validation {
339        use super::*;
340
341        #[test]
342        fn validates_valid_numbers() {
343            let result = SecureArgParser::validate_number::<u32>("threads", "8", Some(1), Some(16));
344            assert_eq!(result.unwrap(), 8);
345        }
346
347        #[test]
348        fn rejects_invalid_numbers() {
349            let result = SecureArgParser::validate_number::<u32>("threads", "abc", None, None);
350            assert!(matches!(result, Err(ParseError::InvalidValue { .. })));
351        }
352
353        #[test]
354        fn enforces_range_constraints() {
355            let result = SecureArgParser::validate_number::<u32>("threads", "100", Some(1), Some(16));
356            assert!(matches!(result, Err(ParseError::InvalidValue { .. })));
357
358            let result = SecureArgParser::validate_number::<u32>("threads", "0", Some(1), Some(16));
359            assert!(matches!(result, Err(ParseError::InvalidValue { .. })));
360        }
361    }
362
363    mod parsing {
364        use super::*;
365
366        #[test]
367        fn parses_basic_arguments() {
368            let args = vec!["program".to_string()];
369            let result = SecureArgParser::parse(&args);
370            assert!(result.is_ok());
371        }
372
373        #[test]
374        fn rejects_too_many_arguments() {
375            let args = vec!["arg".to_string(); MAX_ARG_COUNT + 1];
376            let result = SecureArgParser::parse(&args);
377            assert!(matches!(result, Err(ParseError::TooManyArguments)));
378        }
379    }
380}