| Crates.io | strict-path |
| lib.rs | strict-path |
| version | 0.1.0-beta.1 |
| created_at | 2025-09-11 22:28:56.119393+00 |
| updated_at | 2025-09-24 23:37:54.67011+00 |
| description | More than path comparisons: full, cross-platform path security with type-level guarantees |
| homepage | https://github.com/DK26/strict-path-rs |
| repository | https://github.com/DK26/strict-path-rs |
| max_upload_size | |
| id | 1834690 |
| size | 333,925 |
📚 Complete Guide & Examples | 📖 API Docs | 🧭 Choosing Canonicalized vs Lexical Solution
Note: Our doc comments and
LLM_API_REFERENCE.mdare designed for LLMs with function calling—so an AI can use this crate safely and correctly for file and path operations.
More than path comparisons: full, cross‑platform path security with type‑level guarantees.
This crate is not a thin wrapper around Path or a naive string comparison.
It performs full normalization, canonicalization, and boundary enforcement with
symlink/junction handling, Windows‑specific edge cases (8.3 short names, UNC,
verbatim prefixes, ADS), and robust encoding/normalization behavior across
platforms. The type system encodes these guarantees: if a StrictPath<Marker>
exists, it’s already proven to be inside its allowed boundary — not by hope,
but by construction.
"If you can read this, you passed the PathBoundary checkpoint."
use strict_path::{StrictPath, VirtualPath};
// Strict system path rooted at ./data
let alice_file = StrictPath::with_boundary("./data")?
.strict_join("users/alice.txt")?;
// Virtual view rooted at ./public (displays as "/...")
let logo_file = VirtualPath::with_root("./public")?
.virtual_join("assets/logo.png")?;
The Type-State Police have set up PathBoundary checkpoints
because your LLM is running wild
"One does not simply walk into /etc/passwd."
// ❌ This single line can destroy your server
std::fs::write(user_input, data)?; // user_input = "../../../etc/passwd"
// ✅ This single line makes it mathematically impossible
StrictPath::with_boundary("uploads")?
.strict_join(user_input)?
.write(data)?;
The Reality: Every web server, LLM agent, and file processor faces the same vulnerability. One unvalidated path from user input, config files, or AI responses can grant attackers full filesystem access.
The Solution: Comprehensive path security with mathematical guarantees — including symlink safety, Windows path quirks, and encoding pitfalls — not just string checks.
Analogy:
StrictPathis to paths what a prepared statement is to SQL.
- The boundary/root you create is like preparing a statement: it encodes the policy (what’s allowed).
- The untrusted filename or path segment is like a bound parameter: it’s validated/clamped safely via
strict_join/virtual_join.- The API makes injection attempts inert: hostile inputs can’t escape the boundary, just like SQL parameters can’t change the query.
"Symlinks: the ninja assassins of your filesystem."
strict-path isn't just validation—it's a complete solution to path security:
soft-canonicalize foundation: Heavily tested against 19+ globally known path-related CVEsstrict_join() make security violations visible in code reviewstd::path ecosystemsYour security audit becomes: "We use strict-path for comprehensive path security." ✅
[dependencies]
strict-path = "0.1.0-beta.1"
use strict_path::StrictPath;
// 1. Create a boundary (your security perimeter)
// Use sugar for simple flows; switch to PathBoundary when you need reusable policy
let safe_root = StrictPath::with_boundary("uploads")?;
// 2. ANY external input becomes safe
let safe_path = safe_root.strict_join(dangerous_user_input)?; // Attack = Error
// 3. Use normal file operations - guaranteed secure
safe_path.write(file_data)?;
let info = safe_path.metadata()?; // Inspect filesystem metadata when needed
safe_path.remove_file()?; // Remove when cleanup is required
That's it. No complex validation logic. No CVE research. No security expertise required.
"Marker types: because your code deserves a secret identity."
Use marker types in your function signatures to encode policy and prevent mix-ups across storage domains. The compiler enforces that only the correct paths reach each function.
use strict_path::{PathBoundary, StrictPath};
struct PublicAssets; // CSS, JS, images
struct UserUploads; // Uploaded documents
// Create type-safe boundaries (policy)
let public_assets_root = PathBoundary::<PublicAssets>::try_new("./assets")?;
let user_uploads_root = PathBoundary::<UserUploads>::try_new("./uploads")?;
// Produce mathematically safe paths (cannot exist outside their boundary)
let css_file: StrictPath<PublicAssets> = public_assets_root.strict_join("style.css")?;
let avatar_file: StrictPath<UserUploads> = user_uploads_root.strict_join("avatar.jpg")?;
// Encode guarantees in signatures — prevents cross-domain mix-ups at compile time
fn serve_public_asset(css_file: &StrictPath<PublicAssets>) {
// Safe by construction; `css_file` cannot escape `public_assets_root`
}
serve_public_asset(&css_file); // ✅ OK
// serve_public_asset(&avatar_file); // ❌ Compile error: wrong marker
use strict_path::{VirtualRoot, VirtualPath};
struct UserUploads; // Uploaded documents
let user_id = 42; // Example unique user identifier
let user_uploads_root = VirtualRoot::try_new(format!("./uploads/{user_id}"))?; // per-user root
let avatar_file: VirtualPath<UserUploads> = user_uploads_root.virtual_join("avatar.jpg")?;
fn process_upload(avatar_file: &VirtualPath<UserUploads>) {
// Use virtualpath_display() for UI; clamp is guaranteed
}
process_upload(&avatar_file); // ✅ OK
use strict_path::{PathBoundary, StrictPath, VirtualRoot, VirtualPath};
struct PublicAssets;
struct UserUploads;
// A common helper that works with any StrictPath marker
fn process_common<M>(file: &StrictPath<M>) -> std::io::Result<Vec<u8>> {
file.read()
}
// Prepare one strict and one virtual path
let public_assets_root = PathBoundary::<PublicAssets>::try_new("./assets")?;
let css_file: StrictPath<PublicAssets> = public_assets_root.strict_join("style.css")?;
let user_id = 42;
let user_uploads_root = VirtualRoot::try_new(format!("./uploads/{user_id}"))?;
let avatar_file: VirtualPath<UserUploads> = user_uploads_root.virtual_join("avatar.jpg")?;
// Call with either type
let _ = process_common(&css_file)?; // StrictPath
let _ = process_common(avatar_file.as_unvirtual())?; // Borrow strict view from VirtualPath
Why this matters:
StrictPath<Marker> and VirtualPath<Marker> are boundary-checked — construction proves containment.as_unvirtual()."LLMs: great at generating paths, terrible at keeping secrets."
StrictPath/VirtualPath make those suggestions safe by validation (strict) or clamping (virtual) before any I/O.PathBoundary/VirtualRoot: When you want the compiler to enforce that a value is anchored to the initial root/boundary. Keeping the policy type separate from path values prevents helpers from “picking a root” silently. With features enabled, you also get ergonomic, policy‑aware constructors (e.g., dirs, tempfile, app-path).PublicAssets, UserUploads). They read like documentation and prevent cross‑domain mix‑ups at compile time.Trade‑offs you can choose explicitly:
Golden Rule: If you didn't create the path yourself, secure it first.
| Source/Input | Choose | Why | Notes |
|---|---|---|---|
| HTTP/CLI args/config/LLM/DB (untrusted segments) | StrictPath |
Reject attacks explicitly before I/O | Validate with PathBoundary.strict_join(...) |
| Archive contents, user uploads (user-facing UX) | VirtualPath |
Clamp hostile paths safely; rooted "/" display | Per-user VirtualRoot; use .virtual_join(...) |
| UI-only path display | VirtualPath |
Show clean rooted paths | virtualpath_display(); no system leakage |
| Your own code/hardcoded paths | Path/PathBuf |
You control the value | Never for untrusted input |
| External APIs/webhooks/inter-service messages | StrictPath |
System-facing interop/I/O requires validation | Validate on consume before touching FS |
| (See the full decision matrix in the book) |
Notes that matter:
VirtualPath conceptually extends StrictPath with a virtual "/" view; both support I/O and interop. Choose based on whether you need virtual, user-facing semantics (VirtualPath) or raw system-facing validation (StrictPath).&StrictPath<_> and call with vpath.as_unvirtual() as needed.| Feature | Path/PathBuf |
StrictPath |
VirtualPath |
|---|---|---|---|
| Security | None 💥 | Validates & rejects ✅ | Clamps any input ✅ |
| Join safety | Unsafe (can escape) | Boundary-checked | Boundary-clamped |
| Example attack | "../../../etc/passwd" → System breach |
"../../../etc/passwd" → Error |
"../../../etc/passwd" → /etc/passwd (safe) |
| Best for | Known-safe paths | System boundaries | User interfaces |
Further reading in the book:
"StrictPath: the vault door, not just a velvet rope."
At the heart of this crate is StrictPath - the fundamental security primitive that provides our ironclad guarantee: every StrictPath is mathematically proven to be within its boundary.
Everything in this crate builds upon StrictPath:
PathBoundary creates and validates StrictPath instancesVirtualPath extends StrictPath with user-friendly virtual root semanticsVirtualRoot provides a root context for creating VirtualPath instancesThe core promise: If you have a StrictPath<Marker>, it is impossible for it to reference anything outside its designated boundary. This isn't just validation - it's a type-level guarantee backed by cryptographic-grade path canonicalization.
Core Security Principle: Secure Every External Path
Any path from untrusted sources (HTTP, CLI, config, DB, LLMs, archives) must be validated into a boundary‑enforced type (StrictPath or VirtualPath) before I/O.
"Choose wisely: not all paths lead to safety."
"Give users their own private universe"
use strict_path::VirtualPath;
// Archive extraction - hostile names get clamped, not rejected
let extract_root = VirtualPath::with_root("./extracted")?;
for entry_name in malicious_zip_entries {
let safe_path = extract_root.virtual_join(entry_name)?; // "../../../etc" → "/etc"
safe_path.write(entry.data())?; // Always safe
}
// User cloud storage - users see friendly paths
let doc = VirtualPath::with_root(format!("users/{user_id}"))?
.virtual_join("My Documents/report.pdf")?;
println!("Saved to: {}", doc.virtualpath_display()); // Shows "/My Documents/report.pdf"
"Validate everything, trust nothing"
use strict_path::PathBoundary;
// LLM Agent file operations
let ai_workspace = PathBoundary::try_new("ai_sandbox")?;
let ai_request = llm.generate_path(); // Could be anything malicious
let safe_path = ai_workspace.strict_join(ai_request)?; // ✅ Attack = Explicit Error
safe_path.write(&ai_generated_content)?;
// Limited system access with clear boundaries
struct ConfigFiles;
let config_dir = PathBoundary::<ConfigFiles>::try_new("./config")?;
let user_config = config_dir.strict_join(user_selected_config)?; // ✅ Validated
"When you control the source"
use std::path::PathBuf;
// ✅ You control the input - no validation needed
let log_file = PathBuf::from(format!("logs/{}.log", timestamp));
let app_config = Path::new("config/app.toml"); // Hardcoded = safe
// ❌ NEVER with external input
let user_file = Path::new(user_input); // 🚨 SECURITY DISASTER
"Every example here survived a close encounter with an LLM."
use strict_path::PathBoundary;
// Encode guarantees in signature: pass workspace boundary and untrusted request
async fn llm_file_operation(workspace: &PathBoundary, request: &LlmRequest) -> Result<String> {
// LLM could suggest anything: "../../../etc/passwd", "C:/Windows/System32", etc.
let safe_path = workspace.strict_join(&request.filename)?; // ✅ Attack = Error
match request.operation.as_str() {
"write" => safe_path.write(&request.content)?,
"read" => return Ok(safe_path.read_to_string()?),
_ => return Err("Invalid operation".into()),
}
Ok(format!("File {} processed safely", safe_path.strictpath_display()))
}
use strict_path::VirtualPath;
// Encode guarantees in signature: construct a root once; pass untrusted entry names
fn extract_zip(zip_entries: impl IntoIterator<Item=(String, Vec<u8>)>) -> std::io::Result<()> {
let extract_root = VirtualPath::with_root("./extracted")?;
for (name, data) in zip_entries {
// Hostile names like "../../../etc/passwd" get clamped to "/etc/passwd"
let vpath = extract_root.virtual_join(&name)?; // ✅ Zip slip impossible
vpath.create_parent_dir_all()?;
vpath.write(&data)?;
}
Ok(())
}
use strict_path::PathBoundary;
struct StaticFiles;
async fn serve_static(static_dir: &PathBoundary<StaticFiles>, path: &str) -> Result<Response> {
let safe_path = static_dir.strict_join(path)?; // ✅ "../../../" → Error
Ok(Response::new(safe_path.read()?))
}
// Function signature prevents bypass - no validation needed inside!
async fn serve_file(safe_path: &strict_path::StrictPath<StaticFiles>) -> Response {
Response::new(safe_path.read().unwrap_or_default())
}
use strict_path::PathBoundary;
struct UserConfigs;
fn load_user_config(config_dir: &PathBoundary<UserConfigs>, config_name: &str) -> Result<Config> {
let config_file = config_dir.strict_join(config_name)?;
Ok(serde_json::from_str(&config_file.read_to_string()?)?)
}
"If your attacker has root, strict-path can't save you—but it can make them work for it."
What this protects against (99% of attacks):
../../../etc/passwd)What requires system-level privileges (rare):
Bottom line: If attackers have root/admin access, they've already won. This library stops the 99% of practical attacks that don't require special privileges.
"Type safety: because mixing up user files and web assets is so 2005."
Use markers to prevent mixing different storage contexts at compile time:
use strict_path::{PathBoundary, StrictPath, VirtualRoot, VirtualPath};
struct WebAssets; // CSS, JS, images
struct UserFiles; // Uploaded documents
// Functions enforce context via type system
fn serve_asset(web_asset_file: &StrictPath<WebAssets>) -> Response { /* ... */ }
fn process_upload(user_file: &StrictPath<UserFiles>) -> Result<()> { /* ... */ }
// Create context-specific roots
let public_assets_root: VirtualRoot<WebAssets> = VirtualRoot::try_new("public")?;
let user_uploads_root: VirtualRoot<UserFiles> = VirtualRoot::try_new("uploads")?;
let css_file: VirtualPath<WebAssets> = public_assets_root.virtual_join("app.css")?;
let report_file: VirtualPath<UserFiles> = user_uploads_root.virtual_join("report.pdf")?;
// Type system prevents context mixing
serve_asset(css_file.as_unvirtual()); // ✅ Correct context
// serve_asset(report_file.as_unvirtual()); // ❌ Compile error!
Your IDE and compiler become security guards.
App Configuration with app_path:
// ❌ Vulnerable - app dirs + user paths
use app_path::AppPath;
let app_dir = AppPath::new("MyApp").get_app_dir();
let config_file = app_dir.join(user_config_name); // 🚨 Potential escape
fs::write(config_file, settings)?;
// ✅ Protected - bounded app directories
use strict_path::PathBoundary;
let boundary = PathBoundary::try_new_create(AppPath::new("MyApp").get_app_dir())?;
let safe_config = boundary.strict_join(user_config_name)?; // ✅ Validated
safe_config.write(&settings)?;
"Don't be that developer: use the right display method."
use strict_path::PathBoundary;
let user_uploads_root = PathBoundary::try_new("./uploads")?; // user uploads root
// ❌ ANTI-PATTERN: Wrong method for display
println!("Path: {}", user_uploads_root.interop_path().to_string_lossy());
// ✅ CORRECT: Use proper display methods
println!("Path: {}", user_uploads_root.strictpath_display());
// For virtual flows, prefer `VirtualPath` and borrow strict view when needed:
use strict_path::VirtualPath;
let user_uploads_vroot = VirtualPath::with_root("./uploads")?; // user uploads root
let profile_avatar_file = user_uploads_vroot.virtual_join("profile/avatar.png")?; // file by domain role
println!("Virtual: {}", profile_avatar_file.virtualpath_display());
println!("System: {}", profile_avatar_file.as_unvirtual().strictpath_display());
Why this matters:
interop_path() is designed for external API interop (AsRef<Path>)*_display() methods are designed for human-readable outputstruct StaticFiles; // Marker for static assets
async fn serve_static_file(safe_path: &StrictPath<StaticFiles>) -> Result<Response> {
// Function signature enforces safety - no validation needed inside!
Ok(Response::new(safe_path.read()?))
}
// Caller handles validation once:
let static_files_dir = PathBoundary::<StaticFiles>::try_new("./static")?;
let safe_path = static_files_dir.strict_join(&user_requested_path)?; // ✅ Validated
serve_static_file(&safe_path).await?;
See the mdBook archive extractors guide for the full example and rationale: https://dk26.github.io/strict-path-rs/archive_extractors.html
// User chooses any path - always safe
let user_cloud_root = VirtualPath::with_root(format!("/cloud/user_{id}"))?;
let user_cloud_file = user_cloud_root.virtual_join(&user_requested_path)?; // ✅ Always safe
user_cloud_file.write(upload_data)?;
use strict_path::PathBoundary;
// Encode guarantees via the signature: pass the boundary and an untrusted name
fn load_config(config_dir: &PathBoundary, name: &str) -> Result<String> {
config_dir.strict_join(name)?.read_to_string() // ✅ Validated
}
// AI suggests file operations - always validated
let ai_workspace = PathBoundary::try_new("ai_workspace")?;
let ai_suggested_path = llm_generate_filename(); // Could be anything!
let safe_ai_path = ai_workspace.strict_join(ai_suggested_path)?; // ✅ Guaranteed safe
safe_ai_path.write(&ai_generated_content)?;
"If you read the docs, you get +10 security points."
soft-canonicalize - The underlying path resolution engine"Integrate like a pro: strict-path plays nice with everyone except attackers."
dirs feature): PathBoundary::try_new_os_config(), try_new_os_downloads(), etc.serde feature): Safe serialization/deserialization of path typesdemos/ for examples)MIT OR Apache-2.0