Stage 1: The Basic Promise — Paths That Can't Escape
"Give me one untrusted filename, and I'll show you a safe filesystem operation."
The Problem
You're building a web service. Users upload files. Simple, right? Wrong.
#![allow(unused)] fn main() { // ❌ DISASTER WAITING TO HAPPEN fn save_user_upload(filename: &str, data: &[u8]) -> std::io::Result<()> { let path = format!("uploads/{}", filename); std::fs::write(path, data)?; // filename could be "../../../etc/passwd" Ok(()) } }
What just happened? If filename = "../../../etc/passwd"
, you just gave an attacker write access to your entire filesystem. Game over.
The Solution: StrictPath
StrictPath
makes escapes mathematically impossible. Here's the same code, but safe:
#![allow(unused)] fn main() { use strict_path::StrictPath; fn save_user_upload(filename: &str, data: &[u8]) -> Result<(), Box<dyn std::error::Error>> { // Create a boundary — the perimeter fence let uploads_boundary = StrictPath::with_boundary_create("uploads")?; // Validate the untrusted filename let safe_path = uploads_boundary.strict_join(filename)?; // ✅ Attack = Error // Now we can safely write safe_path.write(data)?; Ok(()) } }
What Changed?
with_boundary_create("uploads")
— Sets up a security perimeter at./uploads/
strict_join(filename)
— Validates thatfilename
stays inside the boundary- Valid:
"report.txt"
→./uploads/report.txt
✅ - Valid:
"docs/report.txt"
→./uploads/docs/report.txt
✅ - Attack:
"../../../etc/passwd"
→ Error ❌
- Valid:
safe_path.write(data)
— Built-in I/O helpers that work directly onStrictPath
The guarantee: If you have a StrictPath
, it's impossible for it to reference anything outside its boundary. Not "we validated it" — impossible by construction.
Try It Yourself
use strict_path::StrictPath; fn main() -> Result<(), Box<dyn std::error::Error>> { // Create the boundary let data_dir = StrictPath::with_boundary_create("user_data")?; // These all work fine let file1 = data_dir.strict_join("notes.txt")?; let file2 = data_dir.strict_join("projects/rust/main.rs")?; let file3 = data_dir.strict_join("deeply/nested/structure/file.json")?; println!("✅ Safe: {}", file1.strictpath_display()); println!("✅ Safe: {}", file2.strictpath_display()); println!("✅ Safe: {}", file3.strictpath_display()); // This would fail at runtime with an error // let evil = data_dir.strict_join("../../../etc/passwd")?; // ❌ PathEscapesBoundary Ok(()) }
The Core Promise
If you have a
StrictPath
, it is impossible for it to escape its boundary.
This isn't validation — it's a type-level guarantee. The security is in the types, enforced by Rust's compiler.
Understanding the Boundary
Think of a StrictPath
like a smart pointer with memory of where it came from:
#![allow(unused)] fn main() { use strict_path::StrictPath; fn demonstrate_boundary() -> Result<(), Box<dyn std::error::Error>> { let uploads = StrictPath::with_boundary_create("uploads")?; // Every path remembers its boundary let doc = uploads.strict_join("document.pdf")?; let img = uploads.strict_join("images/photo.jpg")?; // Both carry a mathematical proof: "I'm inside uploads/" // The compiler enforces this guarantee Ok(()) } }
Head First Moment: Think of StrictPath
like a smart pointer that remembers its boundary. Once created, it carries a mathematical proof: "I'm inside the fence." The compiler won't let you break that promise.
What About Edge Cases?
Q: What if the user provides "../../etc/passwd"
?
A: strict_join()
returns an error. The path is never created.
Q: What about symlinks that escape?
A: strict-path
resolves symlinks during validation. If a symlink points outside the boundary, you get an error.
Q: What about Windows 8.3 short names (PROGRA~1
)?
A: Caught and rejected. We validate against all known path aliasing attacks.
Q: What about NTFS Alternate Data Streams (file.txt:hidden
)?
A: Normalized and handled safely. No escapes possible.
Q: Is this just string validation?
A: No! This is full canonicalization with filesystem resolution. We handle symlinks, junctions, mounts, and all platform quirks.
See Security Methodology for the complete list of 19+ CVEs we've tested against.
Common Operations
Once you have a StrictPath
, you can perform filesystem operations directly:
#![allow(unused)] fn main() { use strict_path::StrictPath; fn file_operations() -> Result<(), Box<dyn std::error::Error>> { let storage = StrictPath::with_boundary_create("storage")?; let file = storage.strict_join("data.txt")?; // Write file.write(b"Hello, world!")?; // Read let content = file.read_to_string()?; println!("Content: {}", content); // Check metadata let metadata = file.metadata()?; println!("Size: {} bytes", metadata.len()); // Create parent directories let nested = storage.strict_join("deep/nested/file.txt")?; nested.create_parent_dir_all()?; nested.write(b"Nested content")?; // Remove file file.remove_file()?; Ok(()) } }
Key Takeaways
✅ StrictPath
= Mathematical boundary guarantee
✅ Attack paths fail explicitly at validation time
✅ Works with any untrusted input (user input, config files, LLM output, archive entries)
✅ Built-in I/O helpers — no need to convert to Path
for common operations
✅ Handles edge cases — symlinks, Windows quirks, encoding tricks, etc.
What's Next?
You now understand the basic promise: paths cannot escape their boundaries.
But what happens when your app grows and you need multiple safe directories? That's where things get confusing...
Continue to Stage 2: The Mix-Up Problem →
Quick Reference:
#![allow(unused)] fn main() { // Create boundary let boundary = StrictPath::with_boundary_create("safe_dir")?; // Validate untrusted input let safe_path = boundary.strict_join(untrusted_filename)?; // Perform I/O safe_path.write(data)?; let content = safe_path.read_to_string()?; }