Getting Started with strict-path
What is strict-path?
Have you ever worried about users trying to access files they shouldn't? Like when someone enters ../../../etc/passwd
to try to escape from a safe directory? That's called a "directory traversal" attack, and it's surprisingly common.
strict-path solves this problem by creating path boundaries - safe boundaries that paths cannot escape from. It comes in two modes: StrictPath (via PathBoundary) which is a path proven to have passed a validation filter, and VirtualPath (via VirtualRoot) which you could think of it like a sandboxed file path.
Why Should You Care?
Directory traversal vulnerabilities are everywhere:
- Web applications where users upload files
- CLI tools that accept file paths as arguments
- Any application that processes user-provided paths
- Systems that extract archives (ZIP files, etc.)
Getting path security wrong can expose your entire filesystem to attackers. With strict-path, the Rust compiler helps ensure you can't make these mistakes.
Your First PathBoundary
Let's start with a simple example. Say you're building a web app where users can upload and download their files, but you want to keep them contained in a specific directory:
use strict_path::PathBoundary; fn main() -> Result<(), Box<dyn std::error::Error>> { // Create a path boundary in the "user_files" directory // This creates the directory if it doesn't exist let user_files_dir = PathBoundary::try_new_create("user_files")?; // Now any path we validate through this path boundary will be contained // within the "user_files" directory // This is SAFE - creates "user_files/documents/report.txt" let report = user_files_dir.strict_join("documents/report.txt")?; report.create_parent_dir_all()?; report.write_string("Quarterly report contents")?; // This would FAIL - can't escape the path boundary! // let _bad = user_files_dir.strict_join("../../../etc/passwd")?; // Error! let display = report.strictpath_display(); println!("Safe path: {display}"); Ok(()) }
What Just Happened?
- Created a path boundary:
PathBoundary::try_new_create("user_files")
sets up a safe boundary - Validated a path:
path_boundary.strict_join("documents/report.txt")
checks the path is safe - Got protection: Any attempt to escape the path boundary (like
../../../etc/passwd
) fails immediately
The magic is that once you have a StrictPath
, you know it's safe. The type system guarantees it.
Working with Strict Paths
Once you have a StrictPath
, you can use it for file operations:
#![allow(unused)] fn main() { use strict_path::PathBoundary; fn save_user_file() -> Result<(), Box<dyn std::error::Error>> { let uploads_dir = PathBoundary::try_new_create("uploads")?; // User wants to save to "my-document.txt" let user_input = "my-document.txt"; // untrusted let safe_path = uploads_dir.strict_join(user_input)?; // Write some content safely using built-in helpers safe_path.write_string("Hello, world!")?; // Read it back let content = safe_path.read_to_string()?; println!("File contains: {content}"); Ok(()) } }
Type Safety: The Secret Sauce
Here's where strict-path gets really clever. You can write functions that only accept safe paths:
use strict_path::{PathBoundary, StrictPath}; // This function can ONLY be called with safe paths fn process_user_file(path: &StrictPath) -> std::io::Result<String> { // We know this path is safe - no need to validate again path.read_to_string() } fn main() -> Result<(), Box<dyn std::error::Error>> { let data_dir = PathBoundary::try_new_create("safe_area")?; let user_data = data_dir.strict_join("user-data.txt")?; // This works - user_data is a StrictPath let _content = process_user_file(&user_data)?; // This won't compile - can't pass an unsafe path! // let unsafe_path = std::path::Path::new("/etc/passwd"); // let _content = process_user_file(unsafe_path); // Compilation error! Ok(()) }
This means once you set up your path boundaries correctly, the compiler prevents you from accidentally using unsafe paths.
Virtual Paths: User-Friendly Sandboxes
Sometimes you want to give users the illusion that they have their own private filesystem, starting from /
. That's what VirtualPath
is for:
use strict_path::VirtualRoot; fn main() -> Result<(), Box<dyn std::error::Error>> { // Create a virtual root that maps to "user_123_files" on disk let vroot = VirtualRoot::try_new_create("user_123_files")?; // User thinks they're working from "/" let vpath = vroot.virtual_join("/documents/my-file.txt")?; // But it actually maps to "user_123_files/documents/my-file.txt" let user_sees = vpath.virtualpath_display(); let system_path = vpath.as_unvirtual().strictpath_display(); println!("User sees: {user_sees}"); println!("Actually stored at: {system_path}"); Ok(()) }
This is perfect for multi-user applications where each user should feel like they have their own filesystem.
API Summary
That's really all you need to know! The core API is simple:
Creating Safe Boundaries
PathBoundary::try_new(path)
- Use existing directory as path boundary (fails if not found)PathBoundary::try_new_create(path)
- Create directory if needed (for setup/initialization)VirtualRoot::try_new(path)
- Virtual filesystem root (expects existing directory)VirtualRoot::try_new_create(path)
- Create virtual root if needed (for user storage)
Validating Paths
path_boundary.strict_join(user_path)
- ReturnsStrictPath
or errorvroot.virtual_join(user_path)
- ReturnsVirtualPath
or error
Using Safe Paths
- Both
StrictPath
andVirtualPath
work with standard file operations - They implement
.interop_os()
so you can pass them tofs::read
,fs::write
, etc. - The type system prevents using unvalidated paths
Common Patterns
Web File Upload
#![allow(unused)] fn main() { use strict_path::{PathBoundary, StrictPath}; // Public API: callers pass untrusted filename; we validate, then call an internal helper fn handle_file_upload(filename: &str, content: &[u8]) -> Result<(), Box<dyn std::error::Error>> { let uploads_dir = PathBoundary::try_new_create("uploads")?; let dest = uploads_dir.strict_join(filename)?; // Validate external input save_uploaded(&dest, content) // Internal API enforces &StrictPath in signature } // Internal helper encodes guarantee in its signature fn save_uploaded(path: &StrictPath, content: &[u8]) -> std::io::Result<()> { path.create_parent_dir_all()?; path.write_bytes(content) } }
Configuration Files
#![allow(unused)] fn main() { use strict_path::{PathBoundary, VirtualRoot}; // Prefer signatures that encode guarantees explicitly: pass the boundary and the untrusted name fn load_config(config_dir: &PathBoundary, config_name: &str) -> Result<String, Box<dyn std::error::Error>> { config_dir.strict_join(config_name)?.read_to_string() } fn setup_user_storage(user_id: u32) -> Result<(), Box<dyn std::error::Error>> { // Create a user-facing virtual root for UI flows let vroot = VirtualRoot::try_new_create(format!("users/{user_id}"))?; let docs = vroot.virtual_join("documents")?; docs.create_dir_all()?; Ok(()) } }
What's Next?
- Real-World Examples: See complete applications using strict-path
- Understanding Type-History: Learn how the internal security works (for contributors)
The key rule: always validate external paths through a path boundary before using them. Whether it's user input, configuration files, or data from external sources - if you didn't create the path yourself, join it to a path boundary first!
Real-World Examples
This chapter shows practical, real-world scenarios where strict-path helps secure your applications. Each example includes complete, runnable code that you can adapt to your own projects.
Web File Upload Service
Let's build a simple file upload service that allows users to upload files safely:
use strict_path::{StrictPath, VirtualPath, VirtualRoot}; use std::io; struct FileUploadService; impl FileUploadService { // Multi-user: each user operates under their own VirtualRoot fn upload_file( &self, user_root: &VirtualRoot, filename: &str, content: &[u8], ) -> Result<VirtualPath, Box<dyn std::error::Error>> { // Validate the untrusted filename at the user’s virtual root let dest = user_root.virtual_join(filename)?; // Reuse strict-typed helper when needed self.save_uploaded(dest.as_unvirtual(), content)?; println!("✅ File uploaded safely to: {}", dest.virtualpath_display()); Ok(dest) } // Internal helper: signature encodes guarantee (accepts only &StrictPath) fn save_uploaded(&self, path: &StrictPath, content: &[u8]) -> io::Result<()> { path.create_parent_dir_all()?; path.write_bytes(content) } fn list_files( &self, user_root: &VirtualRoot, ) -> Result<Vec<VirtualPath>, Box<dyn std::error::Error>> { let mut files = Vec::new(); for entry in std::fs::read_dir(user_root.interop_path())? { let entry = entry?; if entry.file_type()?.is_file() { let vpath = user_root.virtual_join(entry.file_name())?; files.push(vpath); } } Ok(files) } fn download_file(&self, path: &VirtualPath) -> io::Result<Vec<u8>> { // Read and return the file content — type ensures safety path.read_bytes() } } fn main() -> Result<(), Box<dyn std::error::Error>> { let service = FileUploadService; // Per-user virtual roots let alice_root: VirtualRoot = VirtualRoot::try_new_create("user_uploads/alice")?; let bob_root: VirtualRoot = VirtualRoot::try_new_create("user_uploads/bob")?; // Simulate user uploads - these are all SAFE and isolated service.upload_file(&alice_root, "document.txt", b"Hello, world!")?; service.upload_file(&alice_root, "reports/january.pdf", b"PDF content here")?; service.upload_file(&bob_root, "images/photo.jpg", b"JPEG data")?; // These would be clamped/blocked by validation: // service.upload_file(&alice_root, "../../../etc/passwd", b"attack")?; // ❌ Blocked! // service.upload_file(&alice_root, "..\\windows\\system32\\evil.exe", b"malware")?; // ❌ Blocked! // List Alice’s uploaded files (virtual paths) println!("📁 Alice's files:"); for vpath in service.list_files(&alice_root)? { println!(" - {}", vpath.virtualpath_display()); } // Download a file using VirtualPath let target = alice_root.virtual_join("document.txt")?; let content = service.download_file(&target)?; println!("📄 Downloaded: {}", String::from_utf8_lossy(&content)); Ok(()) }
Configuration File Manager
Here's how to safely handle user configuration files:
use strict_path::{PathBoundary, StrictPath}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug)] struct AppConfig { theme: String, language: String, auto_save: bool, } impl Default for AppConfig { fn default() -> Self { Self { theme: "dark".to_string(), language: "en".to_string(), auto_save: true, } } } struct ConfigManager { config_dir: PathBoundary, } impl ConfigManager { fn new() -> Result<Self, Box<dyn std::error::Error>> { // Create a jail for configuration files let config_dir = PathBoundary::try_new_create("app_config")?; Ok(Self { config_dir }) } fn load_config(&self, config_name: &str) -> Result<AppConfig, Box<dyn std::error::Error>> { // Ensure the config file name is safe let config_path = self.config_dir.strict_join(config_name)?; // Load config or create default if config_path.exists() { let content = config_path.read_to_string()?; let config: AppConfig = serde_json::from_str(&content)?; println!("📖 Loaded config from: {}", config_path.strictpath_display()); Ok(config) } else { println!("🆕 Creating default config at: {}", config_path.strictpath_display()); let default_config = AppConfig::default(); self.save_config(config_name, &default_config)?; Ok(default_config) } } fn save_config(&self, config_name: &str, config: &AppConfig) -> Result<StrictPath, Box<dyn std::error::Error>> { // Validate the config file path let config_path = self.config_dir.strict_join(config_name)?; // Serialize and save let content = serde_json::to_string_pretty(config)?; config_path.write_string(&content)?; println!("💾 Saved config to: {}", config_path.strictpath_display()); Ok(config_path) } fn list_configs(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> { let mut configs = Vec::new(); for entry in std::fs::read_dir(self.config_dir.interop_path())? { let entry = entry?; if entry.file_type()?.is_file() { if let Some(name) = entry.file_name().to_str() { if name.ends_with(".json") { configs.push(name.to_string()); } } } } Ok(configs) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let config_manager = ConfigManager::new()?; // Load or create user config let mut user_config = config_manager.load_config("user.json")?; println!("Current config: {:#?}", user_config); // Modify and save user_config.theme = "light".to_string(); user_config.auto_save = false; config_manager.save_config("user.json", &user_config)?; // Create a different profile let admin_config = AppConfig { theme: "admin".to_string(), language: "en".to_string(), auto_save: true, }; config_manager.save_config("admin.json", &admin_config)?; // List all configs println!("📋 Available configs: {:?}", config_manager.list_configs()?); // These attempts would be blocked: // config_manager.load_config("../../../etc/passwd")?; // ❌ Blocked! // config_manager.save_config("..\\windows\\evil.json", &user_config)?; // ❌ Blocked! Ok(()) }
Multi-User Document Storage with VirtualPath
For applications where each user should feel like they have their own filesystem:
use strict_path::{VirtualRoot, VirtualPath}; use std::fs; use std::collections::HashMap; struct DocumentStore { user_roots: HashMap<String, VirtualRoot>, } impl DocumentStore { fn new() -> Self { Self { user_roots: HashMap::new(), } } fn get_user_root(&mut self, username: &str) -> Result<&VirtualRoot, Box<dyn std::error::Error>> { if !self.user_roots.contains_key(username) { // Each user gets their own isolated storage let user_dir = format!("user_data_{}", username); let vroot = VirtualRoot::try_new_create(&user_dir)?; self.user_roots.insert(username.to_string(), vroot); println!("🏠 Created virtual root for user: {}", username); } Ok(self.user_roots.get(username).unwrap()) } fn save_document(&mut self, username: &str, virtual_path: &str, content: &str) -> Result<VirtualPath, Box<dyn std::error::Error>> { let user_root = self.get_user_root(username)?; // User thinks they're saving to their own filesystem starting from "/" let doc_path = user_root.virtual_join(virtual_path)?; // Create parent directories and save doc_path.create_parent_dir_all()?; doc_path.write_string(content)?; println!("📝 User {username} saved document to: {}", doc_path.virtualpath_display()); println!(" (Actually stored at: {})", doc_path.as_unvirtual().strictpath_display()); Ok(doc_path) } fn load_document(&mut self, username: &str, virtual_path: &str) -> Result<String, Box<dyn std::error::Error>> { let user_root = self.get_user_root(username)?; let doc_path = user_root.virtual_join(virtual_path)?; let content = doc_path.read_to_string()?; println!("📖 User {} loaded document from: {}", username, virtual_path); Ok(content) } fn list_user_documents(&mut self, username: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> { let user_root = self.get_user_root(username)?; let mut docs = Vec::new(); fn collect_files(dir: impl AsRef<std::path::Path>, base: impl AsRef<std::path::Path>, docs: &mut Vec<String>) -> std::io::Result<()> { let dir = dir.as_ref(); let base = base.as_ref(); for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_file() { if let Ok(relative) = path.strip_prefix(base) { if let Some(path_str) = relative.to_str() { docs.push(format!("/{}", path_str.replace("\\", "/"))); } } } else if path.is_dir() { collect_files(&path, base, docs)?; } } Ok(()) } collect_files(user_root.interop_path(), user_root.interop_path(), &mut docs)?; Ok(docs) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let mut store = DocumentStore::new(); // Alice saves some documents store.save_document("alice", "/reports/quarterly.txt", "Q1 revenue was strong")?; store.save_document("alice", "/notes/meeting.md", "# Meeting Notes\n- Discuss new features")?; store.save_document("alice", "/drafts/proposal.doc", "Project proposal draft")?; // Bob saves his documents (completely separate from Alice) store.save_document("bob", "/code/main.rs", "fn main() { println!(\"Hello!\"); }")?; store.save_document("bob", "/docs/readme.txt", "My awesome project")?; // Charlie tries to access Alice's files - this is blocked at the path level // store.save_document("charlie", "/../alice/reports/quarterly.txt", "hacked")?; // ❌ Blocked! // Each user can access their own files println!("📄 Alice's quarterly report: {}", store.load_document("alice", "/reports/quarterly.txt")?); println!("💻 Bob's code: {}", store.load_document("bob", "/code/main.rs")?); // List each user's documents println!("📁 Alice's documents: {:?}", store.list_user_documents("alice")?); println!("📁 Bob's documents: {:?}", store.list_user_documents("bob")?); Ok(()) }
Archive Extraction with Safety
Safely extract ZIP files and other archives without zip-slip vulnerabilities:
use strict_path::{PathBoundary, StrictPath}; use std::fs; use std::io::Write; struct SafeArchiveExtractor { extraction_dir: PathBoundary, } impl SafeArchiveExtractor { fn new(extract_to: &str) -> Result<Self, Box<dyn std::error::Error>> { let extraction_dir = PathBoundary::try_new_create(extract_to)?; Ok(Self { extraction_dir }) } fn extract_entry(&self, entry_path: &str, content: &[u8]) -> Result<StrictPath, Box<dyn std::error::Error>> { // This automatically prevents zip-slip attacks let safe_path = self.extraction_dir.strict_join(entry_path)?; // Create parent directories and write the file safe_path.create_parent_dir_all()?; safe_path.write_bytes(content)?; println!("📦 Extracted: {entry_path} -> {}", safe_path.strictpath_display()); Ok(safe_path) } fn extract_mock_zip(&self) -> Result<Vec<StrictPath>, Box<dyn std::error::Error>> { // Simulate extracting a ZIP file with various entries let entries = vec![ ("readme.txt", b"Welcome to our software!"), ("src/main.rs", b"fn main() { println!(\"Hello!\"); }"), ("docs/api.md", b"# API Documentation"), ("config/settings.json", b"{ \"debug\": true }"), // These malicious entries would be automatically blocked: // ("../../../etc/passwd", b"hacked"), // ❌ Blocked! // ("..\\windows\\system32\\evil.exe", b"malware"), // ❌ Blocked! // ("/absolute/path/hack.txt", b"bad"), // ❌ Blocked! ]; let mut extracted_files = Vec::new(); for (entry_path, content) in entries { match self.extract_entry(entry_path, content) { Ok(safe_path) => extracted_files.push(safe_path), Err(e) => println!("⚠️ Blocked malicious entry '{}': {}", entry_path, e), } } Ok(extracted_files) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let extractor = SafeArchiveExtractor::new("extracted_files")?; println!("🗃️ Extracting archive safely..."); let extracted = extractor.extract_mock_zip()?; println!("\n✅ Successfully extracted {} files:", extracted.len()); for file in &extracted { println!(" 📄 {}", file.strictpath_display()); } // Verify we can read the extracted files for file in &extracted { if file.strictpath_extension().and_then(|s| s.to_str()) == Some("txt") { let content = file.read_to_string()?; println!("📖 {}: {}", file.strictpath_display(), content.trim()); } } Ok(()) }
CLI Tool with Safe Path Handling
A command-line tool that processes user-provided file paths safely:
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_string(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!
Advanced: Type-Safe Context Separation
One of the most powerful features is using marker types to prevent accidentally mixing different storage contexts at compile time:
#![allow(unused)] fn main() { use strict_path::{PathBoundary, StrictPath, VirtualRoot, VirtualPath}; // Define marker types for different contexts struct WebAssets; // CSS, JS, images struct UserFiles; // Uploaded documents struct ConfigData; // Application configuration // Functions enforce context via type system fn serve_asset(path: &StrictPath<WebAssets>) -> Result<Vec<u8>, std::io::Error> { path.read_bytes() } fn process_upload(path: &StrictPath<UserFiles>) -> Result<(), std::io::Error> { // Process user-uploaded file let content = path.read_to_string()?; println!("Processing user file: {}", content.len()); Ok(()) } fn load_config(path: &StrictPath<ConfigData>) -> Result<String, std::io::Error> { path.read_to_string() } fn example_type_safety() -> Result<(), Box<dyn std::error::Error>> { // Create context-specific boundaries let assets_root: VirtualRoot<WebAssets> = VirtualRoot::try_new("public")?; let uploads_root: VirtualRoot<UserFiles> = VirtualRoot::try_new("uploads")?; let config_boundary: PathBoundary<ConfigData> = PathBoundary::try_new("config")?; // Create paths with proper contexts let css: VirtualPath<WebAssets> = assets_root.virtual_join("app.css")?; let doc: VirtualPath<UserFiles> = uploads_root.virtual_join("report.pdf")?; let cfg: StrictPath<ConfigData> = config_boundary.strict_join("app.toml")?; // Type system prevents context mixing serve_asset(&css.unvirtual())?; // ✅ Correct context process_upload(&doc.unvirtual())?; // ✅ Correct context load_config(&cfg)?; // ✅ Correct context // These would be compile errors: // serve_asset(&doc.unvirtual())?; // ❌ Compile error - wrong context! // process_upload(&css.unvirtual())?; // ❌ Compile error - wrong context! // load_config(&css.unvirtual())?; // ❌ Compile error - wrong context! Ok(()) } }
Benefits of this approach:
- Compile-time safety: Impossible to accidentally serve user uploads as web assets
- Clear interfaces: Function signatures document what type of files they expect
- Refactoring safety: If you change a function's context, the compiler finds all places that need updates
- Team collaboration: New developers can't make context mixing mistakes
Function Signatures That Enforce Security
Design your functions to make security bypass impossible:
#![allow(unused)] fn main() { // ✅ SECURE: Function signature guarantees safety fn process_file<M>(path: &StrictPath<M>) -> std::io::Result<Vec<u8>> { path.read_bytes() // No validation needed - type system enforces it } // ✅ SECURE: Caller must validate before calling fn save_upload(file: &VirtualPath) -> std::io::Result<()> { file.write_bytes(&data) // Guaranteed within boundaries } // ❌ INSECURE: Function accepts dangerous inputs fn dangerous_function(path: &str) -> std::io::Result<Vec<u8>> { std::fs::read(path) // 🚨 Could read anything on filesystem } }
The Pattern: Push validation to the boundary, then use safe types everywhere.
Key Takeaways
These examples show how strict-path helps in real scenarios:
- Web uploads: Users can't escape the upload directory
- Configuration: Config files stay in their designated area
- Multi-user: Each user gets isolated storage that feels like their own filesystem
- Archive extraction: Automatic protection against zip-slip attacks
- CLI tools: User-provided paths are validated safely
- Type safety: Marker types prevent mixing different storage contexts
The common pattern is:
- Create a
PathBoundary
orVirtualRoot
for your safe area - Always validate external paths through
strict_join()
orvirtual_join()
- Use the resulting
StrictPath
orVirtualPath
for file operations - Let the compiler enforce that only validated paths are used
This makes your code both secure and maintainable - security isn't something you have to remember to check, it's built into the type system!
Features
The strict-path
crate provides several optional features that extend functionality while maintaining the core security guarantees. All features are disabled by default to keep the core library lightweight.
Available Features
dirs
- OS Standard Directories
Cross-platform access to operating system standard directories following platform conventions (XDG Base Directory on Linux, Known Folder API on Windows, Apple Standard Directories on macOS).
[dependencies]
strict-path = { version = "0.1.0-alpha.1", features = ["dirs"] }
Enables constructors like:
PathBoundary::try_new_os_config("MyApp")
- Application configurationPathBoundary::try_new_os_data("MyApp")
- Application data storagePathBoundary::try_new_os_cache("MyApp")
- Application cachePathBoundary::try_new_os_documents()
- User documents directory- And many more...
→ Full OS Directories Documentation
serde
- Serialization Support
Adds Serialize
implementations for StrictPath
and VirtualPath
, plus deserialization helpers for secure path handling in web APIs and configuration files.
[dependencies]
strict-path = { version = "0.1.0-alpha.1", features = ["serde"] }
Enables:
- Direct serialization:
serde_json::to_string(&strict_path)?
- Context-aware deserialization helpers
- Integration with web frameworks and config parsers
tempfile
- Temporary Directories
RAII temporary directories that are automatically cleaned up when dropped, with each PathBoundary
getting a unique temporary directory.
[dependencies]
strict-path = { version = "0.1.0-alpha.1", features = ["tempfile"] }
Enables:
PathBoundary::try_new_temp()
- Unique temporary directoryPathBoundary::try_new_temp_with_prefix("my-prefix")
- Custom prefix- Automatic cleanup when the boundary is dropped
app-path
- Portable Application Directories
Integration with the app-path
crate for discovering application directories relative to the executable with environment variable overrides.
[dependencies]
strict-path = { version = "0.1.0-alpha.1", features = ["app-path"] }
Enables portable application directory discovery for:
- Configuration directories relative to executable
- Data directories with environment overrides
- Cross-platform deployment scenarios
Feature Combinations
Features can be combined as needed:
[dependencies]
strict-path = {
version = "0.1.0-alpha.1",
features = ["dirs", "serde", "tempfile"]
}
Design Philosophy
All optional features:
- Maintain security: Never compromise path boundary enforcement
- Zero-cost when unused: Features add no overhead if not enabled
- Composable: Features work together seamlessly
- Platform-aware: Handle platform differences gracefully
- Standards-compliant: Follow established conventions and specifications
Migration and Compatibility
Features are additive and backward-compatible. Enabling new features won't break existing code, and the core API remains stable across all feature combinations.
When features are unavailable:
- Missing feature methods result in compile-time errors (not runtime failures)
- Documentation clearly indicates feature requirements
- Examples include feature guards for conditional compilation
API Reference Summary
Core Types Comparison
Feature | Path /PathBuf | StrictPath | VirtualPath |
---|---|---|---|
Security | None 💥 | Validates & rejects ✅ | Clamps any input ✅ |
Join safety | Unsafe (can escape) | Boundary-checked | Boundary-clamped |
Boundary guarantee | None | Jailed (cannot escape) | Jailed (virtual view) |
Input permissiveness | Any path (no validation) | Only safe paths | Any input (auto-clamped) |
Display format | OS path | OS path | Virtual root path |
Example: good input | "file.txt" → "file.txt" | "file.txt" → "boundary/file.txt" | "file.txt" → "/file.txt" |
Example: attack input | "/etc/passwd" → System breach 💥 | "/etc/passwd" → Error ❌ | "/etc/passwd" → /etc/passwd (safe) ✅ |
Best for | Known-safe paths | System boundaries | User interfaces |
Integration Examples
Serde Integration
#![allow(unused)] fn main() { use strict_path::{PathBoundary, StrictPath}; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct FileRequest { filename: String, // Accept as string first } #[derive(Serialize)] struct FileResponse { path: StrictPath<UserFiles>, // Serialize safe path } async fn handle_upload(req: FileRequest) -> Result<FileResponse, Error> { let uploads = PathBoundary::try_new("uploads")?; let safe_path = uploads.strict_join(&req.filename)?; // Validate here // ... process file ... Ok(FileResponse { path: safe_path }) } }
Axum Web Framework
#![allow(unused)] fn main() { use axum::{extract::Path, response::Result}; use strict_path::PathBoundary; struct StaticFiles; async fn serve_static(Path(filename): Path<String>) -> Result<Vec<u8>> { let static_dir = PathBoundary::<StaticFiles>::try_new("./static")?; let safe_path = static_dir.strict_join(&filename)?; // Attack = Error Ok(safe_path.read_bytes()?) } }
Configuration with app-path
#![allow(unused)] fn main() { use app_path::AppPath; use strict_path::PathBoundary; fn load_app_config() -> Result<Config, Box<dyn std::error::Error>> { let app_dir = AppPath::new("MyApp").get_app_dir(); let config_boundary = PathBoundary::try_new_create(app_dir)?; let config_file = config_boundary.strict_join("config.toml")?; Ok(toml::from_str(&config_file.read_to_string()?)?) } }
Next Steps
- For OS directories: See OS Standard Directories
- For serialization: Check the integrations section in Getting Started
- For examples: Browse Real-World Examples with feature-specific demos
OS Standard Directories
Feature:
dirs
- Enable withfeatures = ["dirs"]
in yourCargo.toml
The strict-path
crate provides seamless integration with operating system standard directories through the dirs
crate. This enables cross-platform applications to securely access user and system directories like configuration, data storage, cache, and user content locations.
Quick Start:
[dependencies]
strict-path = { version = "0.1.0-alpha.1", features = ["dirs"] }
Cross-Platform Standards
The integration follows established cross-platform directory standards:
Linux (XDG Base Directory Specification)
- Config:
$XDG_CONFIG_HOME
or~/.config
- Data:
$XDG_DATA_HOME
or~/.local/share
- Cache:
$XDG_CACHE_HOME
or~/.cache
- Runtime:
$XDG_RUNTIME_DIR
or/tmp
Windows (Known Folder API)
- Config:
%APPDATA%
(Roaming) - Data:
%APPDATA%
(Roaming) - Cache:
%LOCALAPPDATA%
- Local Config:
%LOCALAPPDATA%
- Local Data:
%LOCALAPPDATA%
macOS (Apple Standard Directories)
- Config:
~/Library/Application Support
- Data:
~/Library/Application Support
- Cache:
~/Library/Caches
API Reference
Both PathBoundary
and VirtualRoot
provide comprehensive OS directory constructors:
Application Directories
try_new_os_config(app_name: &str)
Creates a secure boundary for application configuration storage.
#![allow(unused)] fn main() { use strict_path::PathBoundary; let config_dir = PathBoundary::<()>::try_new_os_config("MyApp")?; let config_file = config_dir.strict_join("settings.json")?; config_file.write_string(r#"{"theme": "dark"}"#)?; }
Platform paths:
- Linux:
~/.config/MyApp/
- Windows:
%APPDATA%/MyApp/
- macOS:
~/Library/Application Support/MyApp/
try_new_os_data(app_name: &str)
Creates a secure boundary for application data storage.
#![allow(unused)] fn main() { let data_dir = PathBoundary::<()>::try_new_os_data("MyApp")?; let database = data_dir.strict_join("app.db")?; database.write_bytes(b"SQLite database content")?; }
Platform paths:
- Linux:
~/.local/share/MyApp/
- Windows:
%APPDATA%/MyApp/
- macOS:
~/Library/Application Support/MyApp/
try_new_os_cache(app_name: &str)
Creates a secure boundary for application cache storage.
#![allow(unused)] fn main() { let cache_dir = PathBoundary::<()>::try_new_os_cache("MyApp")?; let thumbnail_cache = cache_dir.strict_join("thumbnails/")?; thumbnail_cache.create_dir_all()?; }
Platform paths:
- Linux:
~/.cache/MyApp/
- Windows:
%LOCALAPPDATA%/MyApp/
- macOS:
~/Library/Caches/MyApp/
Platform-Specific Directories
try_new_os_config_local(app_name: &str)
(Windows/Linux only)
Creates a local (non-roaming) config directory boundary.
#![allow(unused)] fn main() { #[cfg(any(target_os = "windows", target_os = "linux"))] let local_config = PathBoundary::<()>::try_new_os_config_local("MyApp")?; }
Platform paths:
- Linux:
~/.config/MyApp/
(same as config) - Windows:
%LOCALAPPDATA%/MyApp/
(non-roaming) - macOS: Not available (returns
Err
)
try_new_os_data_local(app_name: &str)
(Windows/Linux only)
Creates a local (non-roaming) data directory boundary.
#![allow(unused)] fn main() { #[cfg(any(target_os = "windows", target_os = "linux"))] let local_data = PathBoundary::<()>::try_new_os_data_local("MyApp")?; }
User Content Directories
Standard User Folders
#![allow(unused)] fn main() { // User's home directory let home_dir = PathBoundary::<()>::try_new_os_home()?; // Desktop folder let desktop_dir = PathBoundary::<()>::try_new_os_desktop()?; // Documents folder let documents_dir = PathBoundary::<()>::try_new_os_documents()?; // Downloads folder let downloads_dir = PathBoundary::<()>::try_new_os_downloads()?; }
Media Directories
#![allow(unused)] fn main() { // Pictures/Photos let pictures_dir = PathBoundary::<()>::try_new_os_pictures()?; // Music/Audio files let audio_dir = PathBoundary::<()>::try_new_os_audio()?; // Videos/Movies let videos_dir = PathBoundary::<()>::try_new_os_videos()?; }
System Directories
try_new_os_executables()
(Unix only)
Creates a boundary for user executable binaries.
#![allow(unused)] fn main() { #[cfg(unix)] let bin_dir = PathBoundary::<()>::try_new_os_executables()?; // Typically ~/.local/bin on Linux }
try_new_os_runtime()
(Unix only)
Creates a boundary for runtime files like sockets and PIDs.
#![allow(unused)] fn main() { #[cfg(unix)] let runtime_dir = PathBoundary::<()>::try_new_os_runtime()?; // Uses $XDG_RUNTIME_DIR or falls back to /tmp }
try_new_os_state()
(Linux only)
Creates a boundary for application state data.
#![allow(unused)] fn main() { #[cfg(target_os = "linux")] let state_dir = PathBoundary::<()>::try_new_os_state("MyApp")?; // Uses $XDG_STATE_HOME or ~/.local/state/MyApp }
Virtual Root Integration
All OS directory constructors are available on VirtualRoot
as well:
#![allow(unused)] fn main() { use strict_path::VirtualRoot; // Create virtual root for user documents let docs_root = VirtualRoot::<()>::try_new_os_documents()?; // User sees clean virtual paths, system handles real location let project_file = docs_root.virtual_join("projects/my-app/notes.txt")?; println!("Virtual path: {}", project_file.virtualpath_display()); // Output: "/projects/my-app/notes.txt" println!("Real path: {}", project_file.as_unvirtual().strictpath_display()); // Output: "/home/user/Documents/projects/my-app/notes.txt" (Linux example) }
Complete Application Example
Here's a realistic media organizer application demonstrating the OS directories integration:
use strict_path::{PathBoundary, VirtualRoot}; use std::collections::HashMap; #[derive(Debug)] struct MediaOrganizerApp { config_dir: PathBoundary<()>, data_dir: PathBoundary<()>, cache_dir: PathBoundary<()>, } impl MediaOrganizerApp { fn new(app_name: &str) -> Result<Self, Box<dyn std::error::Error>> { // Initialize with OS standard directories let config_dir = PathBoundary::<()>::try_new_os_config(app_name)?; let data_dir = PathBoundary::<()>::try_new_os_data(app_name)?; let cache_dir = PathBoundary::<()>::try_new_os_cache(app_name)?; println!("📁 Config: {}", config_dir.strictpath_display()); println!("💾 Data: {}", data_dir.strictpath_display()); println!("🗄️ Cache: {}", cache_dir.strictpath_display()); Ok(Self { config_dir, data_dir, cache_dir }) } fn scan_user_media(&self) -> Result<(), Box<dyn std::error::Error>> { // Access standard user media directories securely let media_directories = vec![ ("Pictures", PathBoundary::<()>::try_new_os_pictures()?), ("Music", PathBoundary::<()>::try_new_os_audio()?), ("Videos", PathBoundary::<()>::try_new_os_videos()?), ("Downloads", PathBoundary::<()>::try_new_os_downloads()?), ]; for (dir_name, dir_path) in media_directories { println!("📂 Scanning {}: {}", dir_name, dir_path.strictpath_display()); // In a real app, recursively scan for media files // All file operations stay within secure boundaries if dir_path.exists() { println!(" ✅ Directory accessible and secure"); } } Ok(()) } fn manage_cache(&self) -> Result<(), Box<dyn std::error::Error>> { // Create cache subdirectories securely let thumbnails_dir = self.cache_dir.strict_join("thumbnails")?; let metadata_dir = self.cache_dir.strict_join("metadata")?; thumbnails_dir.create_dir_all()?; metadata_dir.create_dir_all()?; println!("🖼️ Thumbnails: {}", thumbnails_dir.strictpath_display()); println!("📝 Metadata: {}", metadata_dir.strictpath_display()); Ok(()) } } fn main() -> Result<(), Box<dyn std::error::Error>> { let app = MediaOrganizerApp::new("MediaOrganizer")?; app.scan_user_media()?; app.manage_cache()?; Ok(()) }
Error Handling
OS directory functions return StrictPathError
when:
- The directory doesn't exist and cannot be created
- Permission denied accessing the directory
- The OS doesn't support the requested directory type
- Invalid characters in the application name
#![allow(unused)] fn main() { use strict_path::{PathBoundary, StrictPathError}; match PathBoundary::<()>::try_new_os_config("My App") { Ok(config_dir) => println!("Config: {}", config_dir.strictpath_display()), Err(StrictPathError::PathResolutionError(msg)) => { eprintln!("Failed to resolve config directory: {}", msg); } Err(e) => eprintln!("Other error: {}", e), } }
Platform Compatibility
Method | Linux | Windows | macOS | Notes |
---|---|---|---|---|
try_new_os_config | ✅ | ✅ | ✅ | |
try_new_os_data | ✅ | ✅ | ✅ | |
try_new_os_cache | ✅ | ✅ | ✅ | |
try_new_os_config_local | ✅ | ✅ | ❌ | Returns error on macOS |
try_new_os_data_local | ✅ | ✅ | ❌ | Returns error on macOS |
try_new_os_home | ✅ | ✅ | ✅ | |
try_new_os_desktop | ✅ | ✅ | ✅ | |
try_new_os_documents | ✅ | ✅ | ✅ | |
try_new_os_downloads | ✅ | ✅ | ✅ | |
try_new_os_pictures | ✅ | ✅ | ✅ | |
try_new_os_audio | ✅ | ✅ | ✅ | |
try_new_os_videos | ✅ | ✅ | ✅ | |
try_new_os_executables | ✅ | ❌ | ✅ | Unix only |
try_new_os_runtime | ✅ | ❌ | ✅ | Unix only |
try_new_os_state | ✅ | ❌ | ❌ | Linux only |
Integration with dirs
Crate
This feature integrates with the dirs
crate v6.0.0, which provides the underlying OS directory discovery. The strict-path
crate adds:
- Security: All directory access happens within
PathBoundary
restrictions - Type Safety: Compile-time guarantees about directory boundaries
- Symlink Safety: Safe resolution of symbolic links and junctions
- Cross-Platform: Consistent API across Windows, macOS, and Linux
- Application Scoping: Automatic subdirectory creation for app-specific storage
Relationship to dirs
Functions
strict-path Method | dirs Function | Purpose |
---|---|---|
try_new_os_config | dirs::config_dir() + join | App config storage |
try_new_os_data | dirs::data_dir() + join | App data storage |
try_new_os_cache | dirs::cache_dir() + join | App cache storage |
try_new_os_config_local | dirs::config_local_dir() + join | Local config (non-roaming) |
try_new_os_data_local | dirs::data_local_dir() + join | Local data (non-roaming) |
try_new_os_home | dirs::home_dir() | User home directory |
try_new_os_desktop | dirs::desktop_dir() | Desktop folder |
try_new_os_documents | dirs::document_dir() | Documents folder |
try_new_os_downloads | dirs::download_dir() | Downloads folder |
try_new_os_pictures | dirs::picture_dir() | Pictures folder |
try_new_os_audio | dirs::audio_dir() | Music/Audio folder |
try_new_os_videos | dirs::video_dir() | Videos folder |
try_new_os_executables | dirs::executable_dir() | User binaries (Unix) |
try_new_os_runtime | dirs::runtime_dir() | Runtime files (Unix) |
try_new_os_state | dirs::state_dir() + join | State data (Linux) |
For more details on the underlying directory locations, see the dirs
crate documentation.
Best Practices
1. Application Naming
Use consistent, filesystem-safe application names:
#![allow(unused)] fn main() { // Good let config = PathBoundary::<()>::try_new_os_config("MyApp")?; // Avoid special characters that might cause issues let config = PathBoundary::<()>::try_new_os_config("My App & Tools")?; // Risky }
2. Graceful Fallbacks
Handle platform-specific directories gracefully:
#![allow(unused)] fn main() { // Try platform-specific first, fall back to generic let data_dir = PathBoundary::<()>::try_new_os_data_local("MyApp") .or_else(|_| PathBoundary::<()>::try_new_os_data("MyApp"))?; }
3. Directory Creation
Create application subdirectories as needed:
#![allow(unused)] fn main() { let config_dir = PathBoundary::<()>::try_new_os_config("MyApp")?; let themes_dir = config_dir.strict_join("themes")?; themes_dir.create_dir_all()?; }
4. Cross-Platform Testing
Test your application on all target platforms to verify directory behavior:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_config_directory_creation() { let config_dir = PathBoundary::<()>::try_new_os_config("TestApp").unwrap(); assert!(config_dir.exists() || config_dir.create_dir_all().is_ok()); } } }
See Also
- Real-World Examples - Complete application examples
- Getting Started - Basic
strict-path
concepts dirs
crate - Underlying OS directory library- XDG Base Directory Specification
- Windows Known Folder API
Using strict-path with archive extractors
Archive formats (ZIP, TAR, etc.) embed file names provided by untrusted sources. Treat each entry name as hostile and validate it through VirtualRoot or PathBoundary before any filesystem I/O.
Recommended patterns
- Prefer VirtualRoot for extraction pipelines: it accepts any input and clamps it to the path boundary. This makes batch extraction resilient and user-friendly.
- Use create_parent_dir_all() before writes to avoid TOCTOU-style parent creation races in your own code. Our operations re-validate boundaries internally.
- Do not concatenate paths manually. Always join via vroot.virtual_join(name) or path_boundary.strict_join(name).
- Treat absolute, UNC, drive-relative, or namespace-prefixed paths as untrusted inputs. The virtual join will clamp these to the virtual root.
- On Windows, NTFS Alternate Data Streams (ADS) like "file.txt:stream" are handled safely. Writes remain within the path boundary or are cleanly rejected by the OS.
Minimal example (ZIP-like flow)
#![allow(unused)] fn main() { use strict_path::VirtualRoot; fn extract_all<M: Default>(dest: &std::path::Path, entries: impl IntoIterator<Item=(String, Vec<u8>)>) -> std::io::Result<()> { let vroot: VirtualRoot<M> = VirtualRoot::try_new_create(dest)?; for (name, data) in entries { // 1) Safely map entry name to a strict path (clamped on escape attempts) let vpath = match vroot.virtual_join(&name) { Ok(v) => v, Err(_) => continue, // cleanly reject this entry, log if needed }; // 2) Ensure parent directories exist (inside the path boundary) vpath.create_parent_dir_all()?; // 3) Perform the write safely vpath.write_bytes(&data)?; } Ok(()) } }
Anti-patterns (don’t do this)
- Building paths with format!/push/join on std::path::Path without validation
- Stripping "../" by string replacement
- Allowing absolute paths through to the OS
- Treating encoded/unicode tricks (URL-encoded, dot lookalikes) as pre-sanitized
Testing advice
- Add corpus entries with: "../", "..\", absolute paths, UNC/\?\ prefixes, drive-relative ("C:..\foo"), unicode lookalikes, long paths.
- On Windows, include ADS cases like "decoy.txt:..\..\evil.exe" and reserved names.
- Assert that successful joins produce paths contained within the path boundary and failed joins are clean, with no creation outside the path boundary.
Notes on behavior
- Virtual joins clamp traversal lexically to the virtual root; system-facing escapes (e.g., via symlinks/junctions) are rejected during resolution.
- We do not normalize Unicode; you can store/display NFC or NFD forms as-is. Both are contained safely.
- Hard links and privileged mount tricks are outside the scope of path-level protections (see README limitations).
Best Practices & Guidelines
This page distills how to use strict-path correctly and ergonomically. Pair it with the Anti‑Patterns page for tell‑offs to avoid.
Why Every "Simple" Solution Fails
The path security rabbit hole is deeper than you think. Here's why every naive approach creates new vulnerabilities:
Approach 1: "Just check for ../
"
#![allow(unused)] fn main() { if path.contains("../") { return Err("Invalid path"); } // ✅ Blocks: "../../../etc/passwd" // ❌ Bypassed by: "..%2F..%2F..%2Fetc%2Fpasswd" (URL encoding) // ❌ Bypassed by: "....//....//etc//passwd" (double encoding) // ❌ Bypassed by: "..\\..\\..\etc\passwd" (Windows separators) }
Approach 2: "Use canonicalize() then check"
#![allow(unused)] fn main() { let canonical = fs::canonicalize(path)?; if !canonical.starts_with("/safe/") { return Err("Escape attempt"); } // ✅ Blocks: Most directory traversal // ❌ CVE-2022-21658: Race condition - symlink created between canonicalize and check // ❌ CVE-2019-9855: Windows 8.3 names ("PROGRA~1" → "Program Files") // ❌ Fails on non-existent files (can't canonicalize what doesn't exist) }
Approach 3: "Normalize the path first"
#![allow(unused)] fn main() { let normalized = path.replace("\\", "/").replace("../", ""); // ✅ Blocks: Basic traversal // ❌ Bypassed by: "....//" → "../" after one replacement // ❌ CVE-2020-12279: Unicode normalization attacks // ❌ CVE-2017-17793: NTFS Alternate Data Streams ("file.txt:hidden") // ❌ Misses absolute path replacement: "/etc/passwd" completely replaces base }
Approach 4: "Use a allowlist of safe characters"
#![allow(unused)] fn main() { if !path.chars().all(|c| c.is_alphanumeric() || c == '/') { return Err("Invalid"); } // ✅ Blocks: Most special characters // ❌ Still vulnerable to: "/etc/passwd" (absolute path replacement) // ❌ Too restrictive: blocks legitimate files like "report-2025.pdf" // ❌ CVE-2025-8088: Misses platform-specific issues (Windows UNC, device names) }
Approach 5: "Combine multiple checks"
#![allow(unused)] fn main() { // Check for ../, canonicalize, validate prefix, sanitize chars... // ✅ Blocks: Many attack vectors // ❌ Complex = Buggy: 20+ edge cases, hard to maintain // ❌ Platform-specific gaps: Windows vs Unix behavior differences // ❌ Performance cost: Multiple filesystem calls per validation // ❌ Future CVEs: New attack vectors require updating every check }
The Fundamental Problem
Each "fix" creates new attack surface. Path security isn't a single problem—it's a class of problems that interact in complex ways. You need:
- Encoding normalization (but not breaking legitimate files)
- Symlink resolution (but preventing race conditions)
- Platform consistency (Windows ≠ Unix ≠ Web)
- Boundary enforcement (mathematical, not string-based)
- Future-proof design (resistant to new attack vectors)
This is why strict-path exists. We solved this problem class once, correctly, so you don't have to.
Pick The Right Type
Quick Decision Guide
- External/untrusted segments (HTTP/DB/manifest/LLM/archive entry):
- UI/virtual flows:
VirtualRoot
+VirtualPath
(clamped joins, user‑facing display) - System flows:
PathBoundary
+StrictPath
(rejected joins, system display)
- UI/virtual flows:
- Internal/trusted paths (hardcoded/CLI/env): use
Path
/PathBuf
; only validate when combining with untrusted segments.
Detailed Decision Matrix
Source | Typical Input | Use VirtualPath For | Use StrictPath For | Notes |
---|---|---|---|---|
🌐 HTTP requests | URL path segments, file names | Display/logging, safe virtual joins | System-facing interop/I/O | Always clamp user paths via VirtualRoot::virtual_join |
🌍 Web forms | Form file fields, route params | User-facing display, UI navigation | System-facing interop/I/O | Treat all form inputs as untrusted |
⚙️ Configuration files | Paths in config | UI display and I/O within boundary | System-facing interop/I/O | Validate each path before I/O |
💾 Database content | Stored file paths | Rendering paths in UI dashboards | System-facing interop/I/O | Storage does not imply safety; validate on use |
📂 CLI arguments | Command-line path args | Pretty printing, I/O within boundary | System-facing interop/I/O | Validate args before touching filesystem |
🔌 External APIs | Webhooks, 3rd-party payloads | Present sanitized paths to logs | System-facing interop/I/O | Never trust external systems |
🤖 LLM/AI output | Generated file names/paths | Display suggestions, I/O within boundary | System-facing interop/I/O | LLM output is untrusted by default |
📨 Inter-service msgs | Queue/event payloads | Observability output, I/O within boundary | System-facing interop/I/O | Validate on the consumer side |
📱 Apps (desktop/mobile) | Drag-and-drop, file pickers | Show picked paths in UI | System-facing interop/I/O | Validate selected paths before I/O |
📦 Archive contents | Entry names from ZIP/TAR | Progress UI, virtual joins | System-facing interop/I/O | Validate each entry to block zip-slip |
🔧 File format internals | Embedded path strings | Diagnostics, I/O within boundary | System-facing interop/I/O | Never dereference without validation |
Security Philosophy
Think of it this way:
StrictPath
= Security Filter — validates and rejects unsafe pathsVirtualPath
= Complete Sandbox — clamps any input to stay safe
The Golden Rule: If you didn't create the path yourself, secure it first.
Encode Guarantees In Signatures
- Helpers that touch the filesystem must encode safety:
- Accept
&StrictPath<_>
or&VirtualPath<_>
directly, or - Accept
&PathBoundary<_>
/&VirtualRoot<_>
+ the untrusted segment.
- Accept
- Don’t construct boundaries/roots inside helpers — boundary choice is policy.
#![allow(unused)] fn main() { use strict_path::{PathBoundary, StrictPath, VirtualRoot, VirtualPath}; fn save_to_storage(p: &StrictPath) -> std::io::Result<()> { p.write_string("ok") } fn load_from_storage(p: &VirtualPath) -> std::io::Result<String> { p.read_to_string() } fn create_config(boundary: &PathBoundary, name: &str) -> std::io::Result<()> { boundary.strict_join(name)?.write_string("cfg") } }
Multi‑User Isolation (VirtualRoot)
- Per‑user/tenant: create a
VirtualRoot
per user and join untrusted names withvirtual_join
. - Share strict helpers by borrowing the strict view:
vpath.as_unvirtual()
.
#![allow(unused)] fn main() { fn upload(user_root: &VirtualRoot, filename: &str, bytes: &[u8]) -> std::io::Result<()> { let vpath = user_root.virtual_join(filename)?; vpath.create_parent_dir_all()?; vpath.write_bytes(bytes) } }
Interop & Display
- Interop (pass into
AsRef<Path>
APIs):path.interop_path()
(no allocations). - Display:
- System‑facing:
strictpath_display()
onPathBoundary
/StrictPath
- User‑facing:
virtualpath_display()
onVirtualPath
- System‑facing:
- Never use
interop_path().to_string_lossy()
for display.
Directory Discovery vs Validation
- Discovery (walking): call
read_dir(boundary.interop_path())
andstrip_prefix(boundary.interop_path())
to get relatives. - Validation: join those relatives via
boundary.strict_join(..)
orvroot.virtual_join(..)
before I/O. - Don’t validate constants like
"."
; only validate untrusted segments.
Operations (Use Explicit Methods)
- Joins:
strict_join(..)
/virtual_join(..)
- Parents:
strictpath_parent()
/virtualpath_parent()
- With file name/ext:
strictpath_with_file_name()
/virtualpath_with_file_name()
, etc. - Avoid std
Path::join
/parent
on leaked paths — they ignore strict/virtual semantics.
Naming (from AGENTS.md)
- Variables reflect domain, not type:
- Good:
config_dir
,uploads_root
,archive_src
,mirror_src
,user_vroot
- Bad:
boundary
,jail
,source_
prefix
- Good:
- Keep names consistent with the directory they represent (e.g.,
archive_src
for./archive_src
).
Do / Don’t
- Do: validate once at the boundary, pass types through helpers.
- Do: use
VirtualRoot
for per‑user isolation; borrow strict view for shared helpers. - Do: prefer
impl AsRef<Path>
in helper params where you forward to validation. - Don’t: wrap secure types in
Path::new
/PathBuf::from
. - Don’t: use
interop_path().as_ref()
oras_unvirtual().interop_path()
(useinterop_path()
directly). - Don’t: use lossy strings for display or comparisons.
Testing & Doctests
- Make doctests encode guarantees (signatures) and use the explicit ops.
- Create temporary roots via
PathBoundary::try_new_create(..)
/VirtualRoot::try_new_create(..)
in setup; clean up afterwards. - For archive/HTTP examples, prefer offline simulations with deterministic inputs.
Quick Patterns
- Validate + write:
#![allow(unused)] fn main() { fn write(boundary: &PathBoundary, name: &str, data: &[u8]) -> std::io::Result<()> { let sp = boundary.strict_join(name)?; sp.create_parent_dir_all()?; sp.write_bytes(data) } }
- Validate archive entry:
#![allow(unused)] fn main() { fn extract(vroot: &VirtualRoot, entry: &str, data: &[u8]) -> std::io::Result<()> { let vp = vroot.virtual_join(entry)?; vp.create_parent_dir_all()?; vp.write_bytes(data) } }
- Share logic across strict/virtual:
#![allow(unused)] fn main() { fn consume_strict(p: &StrictPath) -> std::io::Result<String> { p.read_to_string() } fn consume_virtual(p: &VirtualPath) -> std::io::Result<String> { consume_strict(p.as_unvirtual()) } }
Design & Internals
⚠️ CONTRIBUTOR DOCUMENTATION
This section is for contributors, library developers, and curious developers who want to understand how strict-path works internally.
If you're just using strict-path in your project, you probably want:
- Getting Started - Learn the basic API
- Real-World Examples - See practical usage patterns
What's in This Section
This section covers the internal design decisions and patterns that make strict-path secure and maintainable:
Type-History Design Pattern
The core security mechanism that uses Rust's type system to enforce that paths go through required validation steps in the correct order. This prevents accidentally using unvalidated paths and makes security guarantees compile-time checked rather than runtime hopes.
For Contributors
If you're contributing to strict-path, understanding these internals will help you:
- Maintain the security guarantees
- Add new features safely
- Understand why certain design decisions were made
- Write tests that verify the type-level constraints
The design patterns used here can also be applied to other security-critical Rust libraries where you need compile-time guarantees about data processing pipelines.
Type-History Design Pattern
The Problem We're Solving
Imagine you're writing code that needs to safely process data through multiple steps. You need to:
- Take raw input from an untrusted source
- Clean/sanitize it
- Validate it meets requirements
- Transform it to final form
- Only then use it for critical operations
The problem? It's really easy to forget a step, or do them in the wrong order. And if you mess up, you might have bugs, security vulnerabilities, or data corruption.
What if the compiler could remember which steps you've completed and enforce the correct order?
That's exactly what the Type-History pattern does.
Type-History in Simple Terms
The Type-History pattern is like having a checklist that follows your data around. Each time you complete a step, you get a new "stamp" on your checklist. Functions can then require that certain stamps are present before they'll work with your data.
Here's a simple example with strings:
#![allow(unused)] fn main() { // These are our "stamps" struct Raw; // Just created, no processing yet struct Trimmed; // Whitespace has been removed struct Validated; // Content has been checked // This is our wrapper that carries both data and stamps struct ProcessedString<History> { content: String, _stamps: std::marker::PhantomData<History>, // Invisible stamps } // Start with a raw string impl ProcessedString<Raw> { fn new(s: String) -> Self { ProcessedString { content: s, _stamps: std::marker::PhantomData } } } // Any string can be trimmed, adding a "Trimmed" stamp impl<H> ProcessedString<H> { fn trim(self) -> ProcessedString<(H, Trimmed)> { ProcessedString { content: self.content.trim().to_string(), _stamps: std::marker::PhantomData, } } } // Only trimmed strings can be validated impl<H> ProcessedString<(H, Trimmed)> { fn validate(self) -> Result<ProcessedString<((H, Trimmed), Validated)>, &'static str> { if self.content.is_empty() { Err("String cannot be empty") } else { Ok(ProcessedString { content: self.content, _stamps: std::marker::PhantomData, }) } } } // This function only accepts fully processed strings fn save_to_database(s: &ProcessedString<((Raw, Trimmed), Validated)>) { // We know this string has been trimmed AND validated println!("Safely saving: {}", s.content); } }
Now look what happens when you use it:
#![allow(unused)] fn main() { // This works - we follow the correct steps let s = ProcessedString::new(" hello world ".to_string()) .trim() // Now has (Raw, Trimmed) stamps .validate()?; // Now has ((Raw, Trimmed), Validated) stamps save_to_database(&s); // ✅ Compiles fine // This won't compile - we skipped trimming! let bad = ProcessedString::new("hello".to_string()) .validate()?; // This line itself won't compile! // This won't compile either - missing validation let also_bad = ProcessedString::new("hello".to_string()) .trim(); save_to_database(&also_bad); // ❌ Compilation error }
Other Applications of Type-History
The Type-History pattern is useful anywhere you have multi-step data processing that must be done correctly:
Network Request Processing
#![allow(unused)] fn main() { struct Raw; struct Authenticated; struct RateLimited; struct Validated; struct Request<H> { data: RequestData, _history: PhantomData<H>, } // Must authenticate, then rate-limit, then validate fn handle_request(req: &Request<(((Raw, Authenticated), RateLimited), Validated)>) { // We know this request is safe to process } }
Financial Transaction Processing
#![allow(unused)] fn main() { struct Raw; struct AmountValidated; struct FundsChecked; struct Authorized; struct Transaction<H> { amount: Decimal, from: AccountId, to: AccountId, _history: PhantomData<H>, } // Critical: must validate amount, check funds, get authorization fn execute_transfer(tx: &Transaction<(((Raw, AmountValidated), FundsChecked), Authorized)>) { // Guaranteed to be safe for execution } }
Database Query Building
#![allow(unused)] fn main() { struct Raw; struct Sanitized; struct Parameterized; struct Validated; struct Query<H> { sql: String, params: Vec<Value>, _history: PhantomData<H>, } // Must sanitize inputs, parameterize query, validate syntax fn execute_query(q: &Query<(((Raw, Sanitized), Parameterized), Validated)>) { // Safe from SQL injection } }
How This Applies to strict-path
For file paths, security is critical. We need to ensure that every path goes through the right checks in the right order:
- Canonicalize: Resolve
.
,..
, symlinks, etc. - Boundary Check: Make sure the path is within our jail
- Existence Check: Verify the path actually exists (if needed)
Using Type-History, we can make it impossible to use a path that hasn't been properly validated:
#![allow(unused)] fn main() { // These are the stamps for paths struct Raw; // Fresh from user input struct Canonicalized; // Cleaned up and resolved struct BoundaryChecked; // Verified to be within jail bounds struct Exists; // Confirmed to exist on filesystem // Our internal path wrapper (you rarely see this directly) struct PathHistory<History> { path: PathBuf, _stamps: std::marker::PhantomData<History>, } // Only canonicalized AND boundary-checked paths can be used for I/O fn safe_file_operation(path: &PathHistory<((Raw, Canonicalized), BoundaryChecked)>) { // We KNOW this path is safe to use std::fs::read_to_string(&path.path).unwrap(); } }
Reading the Type Signatures
The stamp history is written as nested tuples. Read them left-to-right to see the sequence:
PathHistory<Raw>
= Just created, no processingPathHistory<(Raw, Canonicalized)>
= Created, then canonicalizedPathHistory<((Raw, Canonicalized), BoundaryChecked)>
= Created, then canonicalized, then boundary-checked
It's like reading a receipt that shows every step that was completed.
Why Not Just Use Booleans?
You might wonder: "Why not just have a struct with boolean fields like is_canonicalized
and is_boundary_checked
?"
The problem with booleans is that they can lie:
#![allow(unused)] fn main() { // ❌ With booleans, you can fake it struct UnsafePath { path: PathBuf, is_canonicalized: bool, // I can set this to `true` is_boundary_checked: bool, // even if I never actually did the checks! } let fake_safe = UnsafePath { path: PathBuf::from("../../../etc/passwd"), is_canonicalized: true, // Lies! is_boundary_checked: true, // More lies! }; }
With Type-History, you literally cannot create a value with the wrong stamps unless you actually performed the operations. The type system enforces honesty.
The Public API Hides the Complexity
Users of strict-path never see PathHistory
directly. Instead, they work with simple types like StrictPath
and VirtualPath
. But internally, these types contain properly stamped paths:
#![allow(unused)] fn main() { // What users see pub struct StrictPath<Marker> { // What's hidden inside: a path that's been through the full validation pipeline inner: PathHistory<((Raw, Canonicalized), BoundaryChecked)>, // ... other fields } // Users just call simple methods let safe_dir = PathBoundary::try_new_create("safe_dir")?; let safe_user_file = safe_dir.strict_join("user_file.txt")?; // Returns StrictPath // But the type system guarantees this path is safe to use }
Benefits of This Approach
- Impossible to Forget Steps: The compiler prevents you from skipping required processing
- Self-Documenting Code: Function signatures clearly show what processing is required
- Refactor-Safe: If you change the processing pipeline, the compiler finds all places that need updates
- Zero Runtime Cost: All the type checking happens at compile time - no performance overhead
- Audit-Friendly: Security reviewers can see exactly what guarantees each function requires
When to Use Type-History
This pattern is overkill for simple cases, but it's valuable when:
- Security is critical (like file path validation)
- You have a multi-step process that must be done in order
- Skipping steps could cause bugs or vulnerabilities
- You want to encode important guarantees in the type system
- Multiple functions need different combinations of processing steps
Wrapping Up
The Type-History pattern might seem complex at first, but it's really just a way to make the compiler remember what you've done and enforce what you need to do. It turns potential runtime errors into compile-time guarantees.
In strict-path, this means that once you have a StrictPath
or VirtualPath
, you can be 100% confident it's safe to use - the type system guarantees it went through all the necessary security checks.
For most users of strict-path, you don't need to understand these internals. Just know that the library uses advanced type system features to make it impossible to accidentally create security vulnerabilities. The compiler has your back!
Common Mistakes to Avoid
Here are the most common mistakes developers make with strict-path, and how to fix them.
The Big Picture: Don't Defeat Your Own Security
Most anti-patterns come down to one thing: treating strict-path types like regular paths. When you convert back to Path
or String
, you're throwing away the safety you worked to create.
The core principle is: make functions safe by design. Instead of accepting raw strings and validating inside every function, accept safe types that guarantee the validation already happened.
Security Theater: Only Validating Constants
❌ What not to do:
#![allow(unused)] fn main() { let config_dir = PathBoundary::try_new("./config")?; let settings = config_dir.strict_join("settings.toml")?; // Only literals! let cache = config_dir.strict_join("cache")?; // No user input validated }
Why it's wrong: You're using strict-path but never validating untrusted input. This provides no security value—it's just security theater that looks safe but protects nothing.
✅ Do this instead:
#![allow(unused)] fn main() { let config_dir = PathBoundary::try_new("./config")?; // Actually validate untrusted input from users, HTTP, databases, archives, etc. let user_file = config_dir.strict_join(&user_provided_filename)?; let archive_entry = config_dir.strict_join(&entry_name_from_zip)?; let db_path = config_dir.strict_join(&path_from_database)?; }
Hidden Policy Decisions in Functions
❌ What not to do:
#![allow(unused)] fn main() { fn load_user_data(filename: &str) -> Result<String, Error> { // Policy hidden inside the function! let data_dir = PathBoundary::try_new("./userdata")?; let file = data_dir.strict_join(filename)?; file.read_to_string() } }
Why it's wrong: Callers can't see or control the security policy. What if they want a different directory? What if different users need different boundaries? The function makes security decisions that should be visible.
✅ Do this instead:
#![allow(unused)] fn main() { fn load_user_data(user_dir: &PathBoundary, filename: &str) -> std::io::Result<String> { let file = user_dir.strict_join(filename)?; file.read_to_string() } // OR even better - accept the validated path directly: fn load_user_data(file_path: &StrictPath) -> std::io::Result<String> { file_path.read_to_string() } }
Converting Back to Unsafe Types
❌ What not to do:
#![allow(unused)] fn main() { let safe_path = uploads_dir.strict_join("photo.jpg")?; // WHY are you converting back to the unsafe Path type?! if Path::new(safe_path.interop_path()).exists() { std::fs::copy( Path::new(safe_path.interop_path()), "./backup/photo.jpg" )?; } }
Why it's wrong: StrictPath
already has .exists()
, .read_bytes()
, .write_bytes()
, and other methods. You're defeating the entire point by converting back to Path
, which ignores all security restrictions.
✅ Do this instead:
#![allow(unused)] fn main() { let safe_path = uploads_dir.strict_join("photo.jpg")?; if safe_path.exists() { let backup_dir = PathBoundary::try_new("./backup")?; let backup_path = backup_dir.strict_join("photo.jpg")?; std::fs::copy(safe_path.interop_path(), backup_path.interop_path())?; } }
Using std Path Operations on Leaked Values
❌ What not to do:
#![allow(unused)] fn main() { let uploads_dir = PathBoundary::try_new("uploads")?; let leaked = Path::new(uploads_dir.interop_path()); let dangerous = leaked.join("../../../etc/passwd"); // Can escape! }
Why it's wrong: Path::join()
is the #1 cause of path traversal vulnerabilities. It completely replaces the base path when you pass an absolute path, ignoring all security restrictions.
✅ Do this instead:
#![allow(unused)] fn main() { let uploads_dir = PathBoundary::try_new("uploads")?; // This will return an error instead of escaping: let safe_result = uploads_dir.strict_join("../../../etc/passwd"); match safe_result { Ok(path) => println!("Safe path: {}", path.strictpath_display()), Err(e) => println!("Rejected dangerous path: {}", e), } }
Wrong Display Method
❌ What not to do:
#![allow(unused)] fn main() { println!("Processing: {}", file.interop_path().to_string_lossy()); }
Why it's wrong: interop_path()
is for passing to external APIs that need AsRef<Path>
, like std::fs::File::open()
. For displaying to users, it's the wrong tool and can lose information.
✅ Do this instead:
#![allow(unused)] fn main() { println!("Processing: {}", file.strictpath_display()); // For VirtualPath: println!("Virtual path: {}", vpath.virtualpath_display()); // For VirtualRoot: println!("Root: {}", vroot.as_unvirtual().strictpath_display()); }
Terrible Variable Names
❌ What not to do:
#![allow(unused)] fn main() { let boundary = PathBoundary::try_new("./uploads")?; let restriction = PathBoundary::try_new("./config")?; let jail = VirtualRoot::try_new("./user_data")?; }
Why it's wrong: These names tell you the type but nothing about what the directories are for. When you see boundary.strict_join("photo.jpg")
, you have no idea what boundary you're joining to.
✅ Do this instead:
#![allow(unused)] fn main() { let uploads_dir = PathBoundary::try_new("./uploads")?; let config_dir = PathBoundary::try_new("./config")?; let user_data = VirtualRoot::try_new("./user_data")?; }
Now uploads_dir.strict_join("photo.jpg")
reads naturally as "uploads directory join photo.jpg".
Functions That Accept Dangerous Inputs
❌ What not to do:
#![allow(unused)] fn main() { fn save_file(filename: &str, data: &[u8]) -> std::io::Result<()> { // Every function has to validate - error prone! let uploads = PathBoundary::try_new("uploads")?; let safe_path = uploads.strict_join(filename)?; safe_path.write_bytes(data) } }
Why it's wrong: Every caller has to trust that this function validates correctly. Someone could call save_file("../../../etc/passwd", data)
and you're relying on runtime validation instead of the type system.
✅ Do this instead:
#![allow(unused)] fn main() { fn save_file(safe_path: &StrictPath, data: &[u8]) -> std::io::Result<()> { safe_path.write_bytes(data) // Already guaranteed safe! } }
Now it's impossible to call this function unsafely. The validation happens once when creating the StrictPath
, and the type system prevents all misuse.
Multi-User Data with Single Boundary
❌ What not to do:
#![allow(unused)] fn main() { // Global boundary for all users - dangerous! static UPLOADS: PathBoundary = /* ... */; fn save_user_file(user_id: u64, filename: &str, data: &[u8]) { // All users share the same directory - data mixing risk! let path = UPLOADS.strict_join(&format!("{}/{}", user_id, filename))?; path.write_bytes(data)?; } }
Why it's wrong: All users share the same boundary, making it easy to accidentally access another user's files or create insecure paths.
✅ Do this instead:
#![allow(unused)] fn main() { fn get_user_root(user_id: u64) -> Result<VirtualRoot<UserData>, Error> { let user_dir = format!("./users/{}", user_id); VirtualRoot::try_new(user_dir) } fn save_user_file(user_root: &VirtualRoot<UserData>, filename: &str, data: &[u8]) -> Result<(), Error> { let safe_path = user_root.virtual_join(filename)?.as_unvirtual(); safe_path.write_bytes(data)?; Ok(()) } }
Redundant Method Chaining
❌ What not to do:
#![allow(unused)] fn main() { // Redundant .as_ref() call external_api(path.interop_path().as_ref()); // Redundant unvirtualization vroot.as_unvirtual().interop_path(); // VirtualRoot already has interop_path()! }
✅ Do this instead:
#![allow(unused)] fn main() { // interop_path() already implements AsRef<Path> external_api(path.interop_path()); // VirtualRoot and VirtualPath have interop_path() directly vroot.interop_path(); vpath.interop_path(); }
Quick Reference: Bad → Good
❌ Bad Pattern | ✅ Good Pattern |
---|---|
Path::new(secure_path.interop_path()).exists() | secure_path.exists() |
println!("{}", path.interop_path().to_string_lossy()) | println!("{}", path.strictpath_display()) |
fn process(path: &str) | fn process(path: &StrictPath<_>) |
let boundary = PathBoundary::try_new(...)? | let uploads_dir = PathBoundary::try_new(...)? |
leaked_path.join("child") | secure_path.strict_join("child")? |
vroot.as_unvirtual().interop_path() | vroot.interop_path() |
path.interop_path().as_ref() | path.interop_path() |
The Golden Rules
- Never convert secure types back to
Path
/PathBuf
- use their native methods instead - Make functions accept safe types - don't validate inside every function
- Name variables by purpose, not type -
config_dir
notboundary
- Use the right method for the job -
strictpath_display()
for display,interop_path()
for external APIs - Let callers control security policy - don't hide
PathBoundary
creation inside helpers - Actually validate untrusted input - don't just validate constants
Remember: The whole point of strict-path is to make path operations safe by design. If you find yourself converting back to regular paths or validating inside every function, you're probably doing it wrong!