adaptive_pipeline_bootstrap/
cli.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//! # Command-Line Interface Module
9//!
10//! Bootstrap-layer CLI handling with security-first design.
11//!
12//! ## Architecture
13//!
14//! ```text
15//! ┌─────────────────────────────────────┐
16//! │  1. parser::parse()                 │  Parse CLI with clap
17//! └─────────────────┬───────────────────┘
18//!                   ↓
19//! ┌─────────────────────────────────────┐
20//! │  2. validator::validate()           │  Security validation
21//! └─────────────────┬───────────────────┘
22//!                   ↓
23//! ┌─────────────────────────────────────┐
24//! │  3. ValidatedConfig                 │  Safe, validated config
25//! └─────────────────────────────────────┘
26//! ```
27//!
28//! ## Modules
29//!
30//! - `parser` - CLI structure and clap parsing
31//! - `validator` - Security validation layer
32//! - `commands` - Validated command parameters
33
34pub mod parser;
35pub mod validator;
36
37pub use parser::{parse_cli, Cli, Commands};
38pub use validator::{ParseError, SecureArgParser};
39
40use std::path::PathBuf;
41
42/// Validated CLI configuration
43///
44/// This structure holds all CLI arguments after security validation.
45/// All paths are canonicalized and all values are range-checked.
46#[derive(Debug, Clone)]
47pub struct ValidatedCli {
48    pub command: ValidatedCommand,
49    pub verbose: bool,
50    pub config: Option<PathBuf>,
51    pub cpu_threads: Option<usize>,
52    pub io_threads: Option<usize>,
53    pub storage_type: Option<String>,
54    pub channel_depth: usize,
55}
56
57/// Validated command variants
58#[derive(Debug, Clone)]
59pub enum ValidatedCommand {
60    Process {
61        input: PathBuf,
62        output: PathBuf,
63        pipeline: String,
64        chunk_size_mb: Option<usize>,
65        workers: Option<usize>,
66    },
67    Create {
68        name: String,
69        stages: String,
70        output: Option<PathBuf>,
71    },
72    List,
73    Show {
74        pipeline: String,
75    },
76    Delete {
77        pipeline: String,
78        force: bool,
79    },
80    Benchmark {
81        file: Option<PathBuf>,
82        size_mb: usize,
83        iterations: usize,
84    },
85    Validate {
86        config: PathBuf,
87    },
88    ValidateFile {
89        file: PathBuf,
90        full: bool,
91    },
92    Restore {
93        input: PathBuf,
94        output_dir: Option<PathBuf>,
95        mkdir: bool,
96        overwrite: bool,
97    },
98    Compare {
99        original: PathBuf,
100        adapipe: PathBuf,
101        detailed: bool,
102    },
103}
104
105/// Parse and validate CLI arguments
106///
107/// This function combines parsing and validation:
108/// 1. Parse CLI with clap
109/// 2. Validate all paths with SecureArgParser
110/// 3. Validate all numeric values
111/// 4. Return ValidatedCli on success
112///
113/// # Returns
114///
115/// `ValidatedCli` with all arguments security-checked
116///
117/// # Errors
118///
119/// Returns `ParseError` if any validation fails
120pub fn parse_and_validate() -> Result<ValidatedCli, ParseError> {
121    let cli = parse_cli();
122    validate_cli(cli)
123}
124
125/// Validate parsed CLI arguments
126///
127/// Applies security validation to all CLI arguments:
128/// - Path canonicalization and security checks
129/// - Numeric range validation
130/// - String pattern validation
131///
132/// # Errors
133///
134/// Returns `ParseError` if any validation fails
135fn validate_cli(cli: Cli) -> Result<ValidatedCli, ParseError> {
136    // Validate global config path if provided
137    let config = if let Some(ref path) = cli.config {
138        // For output paths that don't exist yet, just validate the string
139        SecureArgParser::validate_argument(&path.to_string_lossy())?;
140        Some(path.clone())
141    } else {
142        None
143    };
144
145    // Validate channel depth
146    if cli.channel_depth == 0 {
147        return Err(ParseError::InvalidValue {
148            arg: "channel-depth".to_string(),
149            reason: "must be greater than 0".to_string(),
150        });
151    }
152
153    // Validate CPU threads if specified
154    if let Some(threads) = cli.cpu_threads {
155        if threads == 0 || threads > 128 {
156            return Err(ParseError::InvalidValue {
157                arg: "cpu-threads".to_string(),
158                reason: "must be between 1 and 128".to_string(),
159            });
160        }
161    }
162
163    // Validate I/O threads if specified
164    if let Some(threads) = cli.io_threads {
165        if threads == 0 || threads > 256 {
166            return Err(ParseError::InvalidValue {
167                arg: "io-threads".to_string(),
168                reason: "must be between 1 and 256".to_string(),
169            });
170        }
171    }
172
173    // Validate command-specific arguments
174    let command = match cli.command {
175        Commands::Process {
176            input,
177            output,
178            pipeline,
179            chunk_size_mb,
180            workers,
181        } => {
182            // Validate input file exists
183            let validated_input = SecureArgParser::validate_path(&input.to_string_lossy())?;
184
185            // Output file doesn't exist yet - validate string only
186            SecureArgParser::validate_argument(&output.to_string_lossy())?;
187
188            // Validate pipeline name (no dangerous patterns)
189            SecureArgParser::validate_argument(&pipeline)?;
190
191            // Validate chunk size if specified
192            if let Some(size) = chunk_size_mb {
193                if size == 0 || size > 1024 {
194                    return Err(ParseError::InvalidValue {
195                        arg: "chunk-size-mb".to_string(),
196                        reason: "must be between 1 and 1024 MB".to_string(),
197                    });
198                }
199            }
200
201            // Validate workers if specified
202            if let Some(w) = workers {
203                if w == 0 || w > 128 {
204                    return Err(ParseError::InvalidValue {
205                        arg: "workers".to_string(),
206                        reason: "must be between 1 and 128".to_string(),
207                    });
208                }
209            }
210
211            ValidatedCommand::Process {
212                input: validated_input,
213                output,
214                pipeline,
215                chunk_size_mb,
216                workers,
217            }
218        }
219        Commands::Create { name, stages, output } => {
220            SecureArgParser::validate_argument(&name)?;
221            SecureArgParser::validate_argument(&stages)?;
222
223            if let Some(ref path) = output {
224                SecureArgParser::validate_argument(&path.to_string_lossy())?;
225            }
226
227            ValidatedCommand::Create { name, stages, output }
228        }
229        Commands::List => ValidatedCommand::List,
230        Commands::Show { pipeline } => {
231            SecureArgParser::validate_argument(&pipeline)?;
232            ValidatedCommand::Show { pipeline }
233        }
234        Commands::Delete { pipeline, force } => {
235            SecureArgParser::validate_argument(&pipeline)?;
236            ValidatedCommand::Delete { pipeline, force }
237        }
238        Commands::Benchmark {
239            file,
240            size_mb,
241            iterations,
242        } => {
243            let validated_file = if let Some(ref path) = file {
244                Some(SecureArgParser::validate_path(&path.to_string_lossy())?)
245            } else {
246                None
247            };
248
249            if size_mb == 0 || size_mb > 100_000 {
250                return Err(ParseError::InvalidValue {
251                    arg: "size-mb".to_string(),
252                    reason: "must be between 1 and 100000 MB".to_string(),
253                });
254            }
255
256            if iterations == 0 || iterations > 1000 {
257                return Err(ParseError::InvalidValue {
258                    arg: "iterations".to_string(),
259                    reason: "must be between 1 and 1000".to_string(),
260                });
261            }
262
263            ValidatedCommand::Benchmark {
264                file: validated_file,
265                size_mb,
266                iterations,
267            }
268        }
269        Commands::Validate { config } => {
270            let validated_config = SecureArgParser::validate_path(&config.to_string_lossy())?;
271            ValidatedCommand::Validate {
272                config: validated_config,
273            }
274        }
275        Commands::ValidateFile { file, full } => {
276            let validated_file = SecureArgParser::validate_path(&file.to_string_lossy())?;
277            ValidatedCommand::ValidateFile {
278                file: validated_file,
279                full,
280            }
281        }
282        Commands::Restore {
283            input,
284            output_dir,
285            mkdir,
286            overwrite,
287        } => {
288            let validated_input = SecureArgParser::validate_path(&input.to_string_lossy())?;
289
290            let validated_output_dir = if let Some(ref path) = output_dir {
291                // Output dir might not exist yet
292                SecureArgParser::validate_argument(&path.to_string_lossy())?;
293                Some(path.clone())
294            } else {
295                None
296            };
297
298            ValidatedCommand::Restore {
299                input: validated_input,
300                output_dir: validated_output_dir,
301                mkdir,
302                overwrite,
303            }
304        }
305        Commands::Compare {
306            original,
307            adapipe,
308            detailed,
309        } => {
310            let validated_original = SecureArgParser::validate_path(&original.to_string_lossy())?;
311            let validated_adapipe = SecureArgParser::validate_path(&adapipe.to_string_lossy())?;
312            ValidatedCommand::Compare {
313                original: validated_original,
314                adapipe: validated_adapipe,
315                detailed,
316            }
317        }
318    };
319
320    Ok(ValidatedCli {
321        command,
322        verbose: cli.verbose,
323        config,
324        cpu_threads: cli.cpu_threads,
325        io_threads: cli.io_threads,
326        storage_type: cli.storage_type,
327        channel_depth: cli.channel_depth,
328    })
329}