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 strictly enforces path boundaries to prevent directory traversal attacks. It creates safe boundaries that paths cannot escape from. It comes in two modes: StrictPath (via PathBoundary) which detects and rejects escape attempts, and VirtualPath (via VirtualRoot) which contains and redirects escape attempts within a virtual sandbox.
Which Type Should I Use?
- Path/PathBuf (std): When the path comes from a safe source within your control, not external input.
- StrictPath: When you want to restrict paths to a specific boundary and error if they escape.
- VirtualPath: When you want to provide path freedom under isolation.
See the Complete Decision Matrix → for detailed guidance.
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.
Get Secure in 30 Seconds
#![allow(unused)] fn main() { use strict_path::StrictPath; // GET /download?file=report.pdf let user_input = request.query_param("file"); // Untrusted: "report.pdf" or "../../etc/passwd" let untrusted_user_input = user_input.to_string(); let file = StrictPath::with_boundary("/var/app/downloads")? .strict_join(&untrusted_user_input)?; // Validates untrusted input - attack blocked! let contents = file.read()?; // Built-in safe I/O }
That's it. Simple, safe, and path traversal attacks are blocked automatically.
Analogy:
StrictPathis to paths what prepared statements are to SQL. The boundary is your prepared statement (the policy). The untrusted segment is the parameter (validated safely). Injection attempts become inert.
Your First PathBoundary
For reusable boundaries (e.g., passing to functions), use PathBoundary:
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")?; // Simulate user input from HTTP request, CLI args, form data, etc. let user_input = "documents/report.txt"; // In real code: from request.form_data() // ✅ This is SAFE - validates and creates "user_files/documents/report.txt" let report = user_files_dir.strict_join(user_input)?; report.create_parent_dir_all()?; report.write("Quarterly report contents")?; // ❌ This would FAIL - can't escape the path boundary! let attack_input = "../../../etc/passwd"; // Attacker-controlled input // let _bad = user_files_dir.strict_join(attack_input)?; // 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("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.
The Critical Difference: Symlink Behavior
StrictPath validates paths and rejects anything that escapes:
- User input
"../../../etc/passwd"→ ❌ Error - Symlink pointing to
/etc/passwd→ ❌ Error (if outside boundary)
VirtualPath implements true virtual filesystem and clamps absolute paths:
- User input
"../../../etc/passwd"→ ✅ Clamped tovroot/etc/passwd - Symlink pointing to
/etc/passwd→ ✅ Clamped tovroot/etc/passwd
This makes VirtualPath perfect for:
- 🗜️ Archive extraction (malicious entries are safely clamped)
- 🏢 Multi-tenant systems (users can't escape their sandbox)
- 📦 Container-like environments (absolute paths stay inside)
Rule of thumb: Use StrictPath for system resources (explicit validation), use VirtualPath for user sandboxes (graceful containment).
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)- ReturnsStrictPathor errorvroot.virtual_join(user_path)- ReturnsVirtualPathor error
Using Safe Paths
- Both
StrictPathandVirtualPathwork 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(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() // ✅ Validated } 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)
- Choosing Canonicalized vs Lexical: See Ergonomics → Choosing Canonicalized vs Lexical for performance vs safety trade-offs
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!