CLI Tool with Safe Path Handling
Build command-line tools that safely process user-provided file paths. This example shows how to handle untrusted path arguments securely.
The Problem
CLI tools accept file paths from users, but must prevent:
- ❌ Users accessing files outside the working directory
- ❌ Path traversal attacks via command-line arguments
- ❌ Accidental exposure of sensitive files
The Solution
Use PathBoundary
to create a working directory jail. All file operations are restricted to this boundary.
Complete Example
use strict_path::{PathBoundary, StrictPath}; use std::env; use std::fs; struct SafeFileProcessor { working_dir: PathBoundary, } impl SafeFileProcessor { fn new(working_directory: &str) -> Result<Self, Box<dyn std::error::Error>> { // Create or validate the working directory let working_dir = PathBoundary::try_new_create(working_directory)?; println!("🔒 Working directory jail: {}", working_dir.strictpath_display()); Ok(Self { working_dir }) } fn process_file(&self, relative_path: &str) -> Result<(), Box<dyn std::error::Error>> { // Validate the user-provided path let safe_path = self.working_dir.strict_join(relative_path)?; if !safe_path.exists() { return Err(format!("File not found: {}", relative_path).into()); } // Process the file (example: count lines) let content = safe_path.read_to_string()?; let line_count = content.lines().count(); let word_count = content.split_whitespace().count(); let char_count = content.chars().count(); println!("📊 Statistics for {}:", relative_path); println!(" Lines: {}", line_count); println!(" Words: {}", word_count); println!(" Characters: {}", char_count); Ok(()) } fn create_sample_files(&self) -> Result<(), Box<dyn std::error::Error>> { // Create some sample files for testing let samples = vec![ ("sample1.txt", "Hello world!\nThis is a test file.\nWith multiple lines."), ("data/sample2.txt", "Another file\nwith some content\nfor processing."), ("docs/readme.md", "# Sample Project\n\nThis is a sample markdown file."), ]; for (path, content) in samples { let safe_path = self.working_dir.strict_join(path)?; safe_path.create_parent_dir_all()?; safe_path.write(content)?; println!("📝 Created: {path}"); } Ok(()) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let args: Vec<String> = env::args().collect(); if args.len() < 2 { println!("Usage: {} <file-path>", args[0]); println!(" {} --create-samples", args[0]); return Ok(()); } // Set up our safe processor let processor = SafeFileProcessor::new("workspace")?; if args[1] == "--create-samples" { processor.create_sample_files()?; println!("✅ Sample files created in workspace/"); return Ok(()); } // Process the user-specified file let file_path = &args[1]; match processor.process_file(file_path) { Ok(()) => println!("✅ File processed successfully!"), Err(e) => { println!("❌ Error processing file: {}", e); if file_path.contains("..") || file_path.starts_with('/') || file_path.contains('\\') { println!("💡 Tip: Use relative paths within the workspace directory only."); println!(" Trying to escape the workspace? That's not allowed! 🔒"); } } } Ok(()) } // Example usage: // cargo run -- --create-samples // cargo run -- sample1.txt # ✅ Works // cargo run -- data/sample2.txt # ✅ Works // cargo run -- ../../../etc/passwd # ❌ Blocked! // cargo run -- /absolute/path/hack.txt # ❌ Blocked!
Key Security Features
1. Working Directory Jail
#![allow(unused)] fn main() { let working_dir = PathBoundary::try_new_create(working_directory)?; }
All file operations are restricted to this directory and its subdirectories.
2. User Input Validation
#![allow(unused)] fn main() { let safe_path = self.working_dir.strict_join(relative_path)?; }
User-provided paths from command-line arguments are validated before any file access.
3. Helpful Error Messages
#![allow(unused)] fn main() { if file_path.contains("..") || file_path.starts_with('/') { println!("💡 Tip: Use relative paths within the workspace directory only."); } }
Guide users toward safe usage patterns.
4. Safe File Operations
All operations use the validated StrictPath
, so security is guaranteed by the type system.
Attack Scenarios Prevented
User Input | Result |
---|---|
sample1.txt | ✅ Processes workspace/sample1.txt |
data/sample2.txt | ✅ Processes workspace/data/sample2.txt |
../../../etc/passwd | ❌ Error: path escapes boundary |
/var/log/system.log | ❌ Error: absolute paths not allowed |
..\\..\\windows\\system32 | ❌ Error: path escapes boundary |
Advanced: Multiple Operations
Process multiple files from command-line arguments:
impl SafeFileProcessor { fn process_multiple(&self, paths: &[String]) -> Result<(), Box<dyn std::error::Error>> { for path in paths { match self.process_file(path) { Ok(()) => println!("✅ Processed: {}", path), Err(e) => println!("❌ Failed to process '{}': {}", path, e), } } Ok(()) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let args: Vec<String> = env::args().collect(); if args.len() < 2 { println!("Usage: {} <file1> [file2] [file3] ...", args[0]); return Ok(()); } let processor = SafeFileProcessor::new("workspace")?; let file_paths = &args[1..]; processor.process_multiple(file_paths)?; Ok(()) }
Pattern Matching and Filtering
Process files matching a pattern:
#![allow(unused)] fn main() { impl SafeFileProcessor { fn process_pattern(&self, pattern: &str) -> Result<Vec<StrictPath>, Box<dyn std::error::Error>> { let mut processed = Vec::new(); for entry in self.working_dir.read_dir()? { let entry = entry?; if entry.file_type()?.is_file() { let filename = entry.file_name(); let filename_str = filename.to_string_lossy(); // Simple pattern matching (extend with regex if needed) if filename_str.ends_with(pattern) { let file_path = self.working_dir.strict_join(&filename)?; self.process_file(&filename_str)?; processed.push(file_path); } } } Ok(processed) } } // Usage: // cargo run -- "*.txt" // Process all .txt files // cargo run -- "*.md" // Process all .md files }
Interactive Mode
Build an interactive CLI with safe path handling:
#![allow(unused)] fn main() { use std::io::{self, BufRead}; fn interactive_mode(processor: &SafeFileProcessor) -> Result<(), Box<dyn std::error::Error>> { println!("📂 Interactive mode - enter file paths to process (type 'quit' to exit)"); println!("🔒 Working in: {}", processor.working_dir.strictpath_display()); let stdin = io::stdin(); for line in stdin.lock().lines() { let line = line?; let trimmed = line.trim(); if trimmed == "quit" || trimmed == "exit" { break; } if trimmed == "list" { list_files(&processor.working_dir)?; continue; } if trimmed.is_empty() { continue; } match processor.process_file(trimmed) { Ok(()) => println!("✅ Done"), Err(e) => println!("❌ Error: {}", e), } } println!("👋 Goodbye!"); Ok(()) } fn list_files(boundary: &PathBoundary) -> Result<(), Box<dyn std::error::Error>> { println!("📁 Available files:"); for entry in boundary.read_dir()? { let entry = entry?; println!(" - {}", entry.file_name().to_string_lossy()); } Ok(()) } }
Output File Handling
Write results to output files safely:
#![allow(unused)] fn main() { impl SafeFileProcessor { fn process_to_output( &self, input_path: &str, output_path: &str, ) -> Result<(), Box<dyn std::error::Error>> { // Validate both input and output paths let input = self.working_dir.strict_join(input_path)?; let output = self.working_dir.strict_join(output_path)?; // Process input let content = input.read_to_string()?; let processed = content.to_uppercase(); // Example transformation // Write to output output.create_parent_dir_all()?; output.write(&processed)?; println!("✅ Processed {} -> {}", input_path, output_path); Ok(()) } } // Usage: // cargo run -- input.txt output.txt }
Environment Variable Configuration
Allow configuration via environment variables:
fn get_working_directory() -> String { env::var("WORKSPACE_DIR") .unwrap_or_else(|_| "workspace".to_string()) } fn main() -> Result<(), Box<dyn std::error::Error>> { let work_dir = get_working_directory(); let processor = SafeFileProcessor::new(&work_dir)?; // ... rest of implementation Ok(()) } // Usage: // WORKSPACE_DIR=/path/to/data cargo run -- file.txt
Progress Tracking
For processing many files:
#![allow(unused)] fn main() { impl SafeFileProcessor { fn process_directory(&self) -> Result<(), Box<dyn std::error::Error>> { let mut total = 0; let mut processed = 0; let mut failed = 0; // Count total files for entry in self.working_dir.read_dir()? { if entry?.file_type()?.is_file() { total += 1; } } println!("📊 Processing {} files...", total); // Process each file for entry in self.working_dir.read_dir()? { let entry = entry?; if entry.file_type()?.is_file() { let filename = entry.file_name(); let path_str = filename.to_string_lossy(); match self.process_file(&path_str) { Ok(()) => { processed += 1; println!("[{}/{}] ✅ {}", processed + failed, total, path_str); } Err(e) => { failed += 1; println!("[{}/{}] ❌ {}: {}", processed + failed, total, path_str, e); } } } } println!("\n📈 Summary:"); println!(" Total: {}", total); println!(" Processed: {}", processed); println!(" Failed: {}", failed); Ok(()) } } }
Best Practices
- Clear boundaries - Clearly communicate the working directory to users
- Helpful errors - Explain why paths are rejected and suggest alternatives
- Relative paths only - Guide users toward using relative paths
- Validate early - Check paths before performing expensive operations
- Log rejections - Track attempted path escapes for security monitoring
Integration Tips
With clap
for Argument Parsing
use clap::Parser; #[derive(Parser)] struct Cli { /// File to process (relative to workspace) #[arg(value_name = "FILE")] file_path: String, /// Working directory #[arg(short, long, default_value = "workspace")] workspace: String, } fn main() -> Result<(), Box<dyn std::error::Error>> { let cli = Cli::parse(); let processor = SafeFileProcessor::new(&cli.workspace)?; processor.process_file(&cli.file_path)?; Ok(()) }
With Glob Patterns
#![allow(unused)] fn main() { use glob::glob; fn process_glob(processor: &SafeFileProcessor, pattern: &str) -> Result<(), Box<dyn std::error::Error>> { let workspace = processor.working_dir.strictpath_display().to_string(); let full_pattern = format!("{}/{}", workspace, pattern); for entry in glob(&full_pattern)? { let path = entry?; if let Ok(relative) = path.strip_prefix(&workspace) { if let Some(relative_str) = relative.to_str() { processor.process_file(relative_str)?; } } } Ok(()) } }
Next Steps
- See Configuration Manager for handling config files safely
- See Archive Extraction for processing archives from CLI