Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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?

  1. Created a path boundary: PathBoundary::try_new_create("user_files") sets up a safe boundary
  2. Validated a path: path_boundary.strict_join("documents/report.txt") checks the path is safe
  3. 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) - Returns StrictPath or error
  • vroot.virtual_join(user_path) - Returns VirtualPath or error

Using Safe Paths

  • Both StrictPath and VirtualPath work with standard file operations
  • They implement .interop_os() so you can pass them to fs::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:

  1. Compile-time safety: Impossible to accidentally serve user uploads as web assets
  2. Clear interfaces: Function signatures document what type of files they expect
  3. Refactoring safety: If you change a function's context, the compiler finds all places that need updates
  4. 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:

  1. Web uploads: Users can't escape the upload directory
  2. Configuration: Config files stay in their designated area
  3. Multi-user: Each user gets isolated storage that feels like their own filesystem
  4. Archive extraction: Automatic protection against zip-slip attacks
  5. CLI tools: User-provided paths are validated safely
  6. Type safety: Marker types prevent mixing different storage contexts

The common pattern is:

  1. Create a PathBoundary or VirtualRoot for your safe area
  2. Always validate external paths through strict_join() or virtual_join()
  3. Use the resulting StrictPath or VirtualPath for file operations
  4. 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 configuration
  • PathBoundary::try_new_os_data("MyApp") - Application data storage
  • PathBoundary::try_new_os_cache("MyApp") - Application cache
  • PathBoundary::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 directory
  • PathBoundary::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

FeaturePath/PathBufStrictPathVirtualPath
SecurityNone 💥Validates & rejects ✅Clamps any input ✅
Join safetyUnsafe (can escape)Boundary-checkedBoundary-clamped
Boundary guaranteeNoneJailed (cannot escape)Jailed (virtual view)
Input permissivenessAny path (no validation)Only safe pathsAny input (auto-clamped)
Display formatOS pathOS pathVirtual 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 forKnown-safe pathsSystem boundariesUser 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

OS Standard Directories

Feature: dirs - Enable with features = ["dirs"] in your Cargo.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

MethodLinuxWindowsmacOSNotes
try_new_os_config
try_new_os_data
try_new_os_cache
try_new_os_config_localReturns error on macOS
try_new_os_data_localReturns 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_executablesUnix only
try_new_os_runtimeUnix only
try_new_os_stateLinux 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 Methoddirs FunctionPurpose
try_new_os_configdirs::config_dir() + joinApp config storage
try_new_os_datadirs::data_dir() + joinApp data storage
try_new_os_cachedirs::cache_dir() + joinApp cache storage
try_new_os_config_localdirs::config_local_dir() + joinLocal config (non-roaming)
try_new_os_data_localdirs::data_local_dir() + joinLocal data (non-roaming)
try_new_os_homedirs::home_dir()User home directory
try_new_os_desktopdirs::desktop_dir()Desktop folder
try_new_os_documentsdirs::document_dir()Documents folder
try_new_os_downloadsdirs::download_dir()Downloads folder
try_new_os_picturesdirs::picture_dir()Pictures folder
try_new_os_audiodirs::audio_dir()Music/Audio folder
try_new_os_videosdirs::video_dir()Videos folder
try_new_os_executablesdirs::executable_dir()User binaries (Unix)
try_new_os_runtimedirs::runtime_dir()Runtime files (Unix)
try_new_os_statedirs::state_dir() + joinState 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

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.

  • 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:

  1. Encoding normalization (but not breaking legitimate files)
  2. Symlink resolution (but preventing race conditions)
  3. Platform consistency (Windows ≠ Unix ≠ Web)
  4. Boundary enforcement (mathematical, not string-based)
  5. 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)
  • Internal/trusted paths (hardcoded/CLI/env): use Path/PathBuf; only validate when combining with untrusted segments.

Detailed Decision Matrix

SourceTypical InputUse VirtualPath ForUse StrictPath ForNotes
🌐 HTTP requestsURL path segments, file namesDisplay/logging, safe virtual joinsSystem-facing interop/I/OAlways clamp user paths via VirtualRoot::virtual_join
🌍 Web formsForm file fields, route paramsUser-facing display, UI navigationSystem-facing interop/I/OTreat all form inputs as untrusted
⚙️ Configuration filesPaths in configUI display and I/O within boundarySystem-facing interop/I/OValidate each path before I/O
💾 Database contentStored file pathsRendering paths in UI dashboardsSystem-facing interop/I/OStorage does not imply safety; validate on use
📂 CLI argumentsCommand-line path argsPretty printing, I/O within boundarySystem-facing interop/I/OValidate args before touching filesystem
🔌 External APIsWebhooks, 3rd-party payloadsPresent sanitized paths to logsSystem-facing interop/I/ONever trust external systems
🤖 LLM/AI outputGenerated file names/pathsDisplay suggestions, I/O within boundarySystem-facing interop/I/OLLM output is untrusted by default
📨 Inter-service msgsQueue/event payloadsObservability output, I/O within boundarySystem-facing interop/I/OValidate on the consumer side
📱 Apps (desktop/mobile)Drag-and-drop, file pickersShow picked paths in UISystem-facing interop/I/OValidate selected paths before I/O
📦 Archive contentsEntry names from ZIP/TARProgress UI, virtual joinsSystem-facing interop/I/OValidate each entry to block zip-slip
🔧 File format internalsEmbedded path stringsDiagnostics, I/O within boundarySystem-facing interop/I/ONever dereference without validation

Security Philosophy

Think of it this way:

  • StrictPath = Security Filter — validates and rejects unsafe paths
  • VirtualPath = 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.
  • 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 with virtual_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() on PathBoundary/StrictPath
    • User‑facing: virtualpath_display() on VirtualPath
  • Never use interop_path().to_string_lossy() for display.

Directory Discovery vs Validation

  • Discovery (walking): call read_dir(boundary.interop_path()) and strip_prefix(boundary.interop_path()) to get relatives.
  • Validation: join those relatives via boundary.strict_join(..) or vroot.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
  • 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() or as_unvirtual().interop_path() (use interop_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:

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.

Read about Type-History →

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:

  1. Take raw input from an untrusted source
  2. Clean/sanitize it
  3. Validate it meets requirements
  4. Transform it to final form
  5. 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:

  1. Canonicalize: Resolve ., .., symlinks, etc.
  2. Boundary Check: Make sure the path is within our jail
  3. 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 processing
  • PathHistory<(Raw, Canonicalized)> = Created, then canonicalized
  • PathHistory<((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

  1. Impossible to Forget Steps: The compiler prevents you from skipping required processing
  2. Self-Documenting Code: Function signatures clearly show what processing is required
  3. Refactor-Safe: If you change the processing pipeline, the compiler finds all places that need updates
  4. Zero Runtime Cost: All the type checking happens at compile time - no performance overhead
  5. 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

  1. Never convert secure types back to Path/PathBuf - use their native methods instead
  2. Make functions accept safe types - don't validate inside every function
  3. Name variables by purpose, not type - config_dir not boundary
  4. Use the right method for the job - strictpath_display() for display, interop_path() for external APIs
  5. Let callers control security policy - don't hide PathBoundary creation inside helpers
  6. 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!