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 strictly enforces path boundaries to prevent directory traversal attacks. It creates safe boundaries that paths cannot escape from. It comes in two modes: StrictPath (via PathBoundary) which detects and rejects escape attempts, and VirtualPath (via VirtualRoot) which contains and redirects escape attempts within a virtual sandbox.

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("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("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.

StrictPath validates paths and rejects anything that escapes:

  • User input "../../../etc/passwd" → ❌ Error
  • Symlink pointing to /etc/passwd → ❌ Error (if outside boundary)

VirtualPath implements true virtual filesystem and clamps absolute paths:

  • User input "../../../etc/passwd" → ✅ Clamped to vroot/etc/passwd
  • Symlink pointing to /etc/passwd → ✅ Clamped to vroot/etc/passwd

This makes VirtualPath perfect for:

  • 🗜️ Archive extraction (malicious entries are safely clamped)
  • 🏢 Multi-tenant systems (users can't escape their sandbox)
  • 📦 Container-like environments (absolute paths stay inside)

Rule of thumb: Use StrictPath for system resources (explicit validation), use VirtualPath for user sandboxes (graceful containment).

API Summary

That's really all you need to know! The core API is simple:

Creating Safe Boundaries

  • PathBoundary::try_new(path) - Use existing directory as path boundary (fails if not found)
  • PathBoundary::try_new_create(path) - Create directory if needed (for setup/initialization)
  • VirtualRoot::try_new(path) - Virtual filesystem root (expects existing directory)
  • VirtualRoot::try_new_create(path) - Create virtual root if needed (for user storage)

Validating Paths

  • path_boundary.strict_join(user_path) - 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(content)
}
}

Configuration Files

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, VirtualRoot};

// Prefer signatures that encode guarantees explicitly: pass the boundary and the untrusted name
fn load_config(config_dir: &PathBoundary, config_name: &str) -> Result<String, Box<dyn std::error::Error>> {
    config_dir.strict_join(config_name)?.read_to_string() // ✅ Validated
}

fn setup_user_storage(user_id: u32) -> Result<(), Box<dyn std::error::Error>> {
    // Create a user-facing virtual root for UI flows
    let vroot = VirtualRoot::try_new_create(format!("users/{user_id}"))?;
    let docs = vroot.virtual_join("documents")?;
    docs.create_dir_all()?;
    Ok(())
}
}

What's Next?

  • Real-World Examples: See complete applications using strict-path
  • Understanding Type-History: Learn how the internal security works (for contributors)
  • Choosing Canonicalized vs Lexical: See Ergonomics → Choosing Canonicalized vs Lexical for performance vs safety trade-offs

The key rule: always validate external paths through a path boundary before using them. Whether it's user input, configuration files, or data from external sources - if you didn't create the path yourself, join it to a path boundary first!

Unlocking the Mathematical Security of strict-path

Welcome! You're about to learn how to make filesystem attacks mathematically impossible in your code. No CVE research required. No security expertise needed. Just types, the compiler, and some clever design patterns.

This tutorial builds your understanding step-by-step, from basic path validation to compile-time authorization guarantees. Each section introduces one concept at a time, with runnable examples you can copy and paste.

What You'll Learn

Stage 1: The Basic Promise
Learn how StrictPath makes path escapes mathematically impossible, without any markers yet.

Stage 2: The Mix-Up Problem
Discover the confusing problem that emerges when you have multiple boundaries.

Stage 3: Markers to the Rescue
See how markers solve the mix-up problem with compile-time domain separation.

Stage 4: Authorization with change_marker()
Learn to encode authorization requirements in the type system using change_marker().

Stage 5: Virtual Paths
Understand how VirtualPath extends StrictPath with user-friendly sandboxing semantics.

Stage 6: Feature Integration
Integrate with your ecosystem using feature-gated constructors (dirs, tempfile, app-path, serde).

The Progressive Guarantee

As you progress through the stages, the compiler's guarantees grow stronger:

StageWhat You MasterThe Guarantee
1Basic boundariesPath cannot escape
2(Problem statement)
3Domain separationPath is in correct domain
4Authorization encodingAuthorization proven by compiler
5Virtual sandboxesClean UX + safe system paths
6Ecosystem integrationExternal APIs + boundary enforcement

The End Result

By the end of this tutorial, you'll understand how the Rust compiler can mathematically prove that:

  • ✅ Paths cannot escape their boundaries
  • ✅ Paths are in the correct resource domain
  • ✅ Authorization was granted for the specified operations
  • ✅ All of this happens at compile time — no runtime overhead!

Ready? Let's unlock the security vault. 🔐

Start with Stage 1: The Basic Promise →

Stage 1: The Basic Promise — Paths That Can't Escape

"Give me one untrusted filename, and I'll show you a safe filesystem operation."

The Problem

You're building a web service. Users upload files. Simple, right? Wrong.

#![allow(unused)]
fn main() {
// ❌ DISASTER WAITING TO HAPPEN
fn save_user_upload(filename: &str, data: &[u8]) -> std::io::Result<()> {
    let path = format!("uploads/{}", filename);
    std::fs::write(path, data)?;  // filename could be "../../../etc/passwd"
    Ok(())
}
}

What just happened? If filename = "../../../etc/passwd", you just gave an attacker write access to your entire filesystem. Game over.

The Solution: StrictPath

StrictPath makes escapes mathematically impossible. Here's the same code, but safe:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn save_user_upload(filename: &str, data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    // Create a boundary — the perimeter fence
    let uploads_boundary = StrictPath::with_boundary_create("uploads")?;

    // Validate the untrusted filename
    let safe_path = uploads_boundary.strict_join(filename)?;  // ✅ Attack = Error

    // Now we can safely write
    safe_path.write(data)?;

    Ok(())
}
}

What Changed?

  1. with_boundary_create("uploads") — Sets up a security perimeter at ./uploads/
  2. strict_join(filename) — Validates that filename stays inside the boundary
    • Valid: "report.txt"./uploads/report.txt
    • Valid: "docs/report.txt"./uploads/docs/report.txt
    • Attack: "../../../etc/passwd"Error
  3. safe_path.write(data) — Built-in I/O helpers that work directly on StrictPath

The guarantee: If you have a StrictPath, it's impossible for it to reference anything outside its boundary. Not "we validated it" — impossible by construction.

Try It Yourself

use strict_path::StrictPath;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create the boundary
    let data_dir = StrictPath::with_boundary_create("user_data")?;

    // These all work fine
    let file1 = data_dir.strict_join("notes.txt")?;
    let file2 = data_dir.strict_join("projects/rust/main.rs")?;
    let file3 = data_dir.strict_join("deeply/nested/structure/file.json")?;

    println!("✅ Safe: {}", file1.strictpath_display());
    println!("✅ Safe: {}", file2.strictpath_display());
    println!("✅ Safe: {}", file3.strictpath_display());

    // This would fail at runtime with an error
    // let evil = data_dir.strict_join("../../../etc/passwd")?;  // ❌ PathEscapesBoundary

    Ok(())
}

The Core Promise

If you have a StrictPath, it is impossible for it to escape its boundary.

This isn't validation — it's a type-level guarantee. The security is in the types, enforced by Rust's compiler.

Understanding the Boundary

Think of a StrictPath like a smart pointer with memory of where it came from:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn demonstrate_boundary() -> Result<(), Box<dyn std::error::Error>> {
    let uploads = StrictPath::with_boundary_create("uploads")?;
    
    // Every path remembers its boundary
    let doc = uploads.strict_join("document.pdf")?;
    let img = uploads.strict_join("images/photo.jpg")?;
    
    // Both carry a mathematical proof: "I'm inside uploads/"
    // The compiler enforces this guarantee
    
    Ok(())
}
}

Head First Moment: Think of StrictPath like a smart pointer that remembers its boundary. Once created, it carries a mathematical proof: "I'm inside the fence." The compiler won't let you break that promise.

What About Edge Cases?

Q: What if the user provides "../../etc/passwd"?
A: strict_join() returns an error. The path is never created.

Q: What about symlinks that escape?
A: strict-path resolves symlinks during validation. If a symlink points outside the boundary, you get an error.

Q: What about Windows 8.3 short names (PROGRA~1)?
A: Caught and rejected. We validate against all known path aliasing attacks.

Q: What about NTFS Alternate Data Streams (file.txt:hidden)?
A: Normalized and handled safely. No escapes possible.

Q: Is this just string validation?
A: No! This is full canonicalization with filesystem resolution. We handle symlinks, junctions, mounts, and all platform quirks.

See Security Methodology for the complete list of 19+ CVEs we've tested against.

Common Operations

Once you have a StrictPath, you can perform filesystem operations directly:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn file_operations() -> Result<(), Box<dyn std::error::Error>> {
    let storage = StrictPath::with_boundary_create("storage")?;
    let file = storage.strict_join("data.txt")?;

    // Write
    file.write(b"Hello, world!")?;

    // Read
    let content = file.read_to_string()?;
    println!("Content: {}", content);

    // Check metadata
    let metadata = file.metadata()?;
    println!("Size: {} bytes", metadata.len());

    // Create parent directories
    let nested = storage.strict_join("deep/nested/file.txt")?;
    nested.create_parent_dir_all()?;
    nested.write(b"Nested content")?;

    // Remove file
    file.remove_file()?;

    Ok(())
}
}

Key Takeaways

StrictPath = Mathematical boundary guarantee
Attack paths fail explicitly at validation time
Works with any untrusted input (user input, config files, LLM output, archive entries)
Built-in I/O helpers — no need to convert to Path for common operations
Handles edge cases — symlinks, Windows quirks, encoding tricks, etc.

What's Next?

You now understand the basic promise: paths cannot escape their boundaries.

But what happens when your app grows and you need multiple safe directories? That's where things get confusing...

Continue to Stage 2: The Mix-Up Problem →


Quick Reference:

#![allow(unused)]
fn main() {
// Create boundary
let boundary = StrictPath::with_boundary_create("safe_dir")?;

// Validate untrusted input
let safe_path = boundary.strict_join(untrusted_filename)?;

// Perform I/O
safe_path.write(data)?;
let content = safe_path.read_to_string()?;
}

Stage 2: The Mix-Up Problem — When You Have Multiple Boundaries

"Wait, which uploads folder is this again?"

In Stage 1, you learned that StrictPath guarantees paths can't escape their boundaries. Perfect! But real applications need multiple safe directories. That's where a new problem emerges...

Real-World Complexity

As your app grows, you need multiple safe directories:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn file_server() -> Result<(), Box<dyn std::error::Error>> {
    // User uploads
    let uploads_dir = StrictPath::with_boundary_create("user_uploads")?;
    
    // Public web assets (CSS, JS, images)
    let assets_dir = StrictPath::with_boundary_create("public_assets")?;
    
    // System configuration files
    let config_dir = StrictPath::with_boundary_create("system_config")?;

    // Now we have paths from different domains...
    let user_file = uploads_dir.strict_join("document.pdf")?;
    let css_file = assets_dir.strict_join("style.css")?;
    let config_file = config_dir.strict_join("database.toml")?;

    // But they're all the same type!
    // let _: StrictPath = user_file;
    // let _: StrictPath = css_file;
    // let _: StrictPath = config_file;

    // 🚨 DANGER: Easy to mix them up!
    serve_public_asset(&user_file)?;      // Oops! Serving user upload as public asset
    save_user_upload(&config_file)?;      // Double oops! User overwrites config

    Ok(())
}

fn serve_public_asset(path: &StrictPath) -> std::io::Result<Vec<u8>> {
    path.read()  // Should only serve public assets!
}

fn save_user_upload(path: &StrictPath) -> std::io::Result<()> {
    path.write(b"user data")  // Should only write to user uploads!
}
}

The Problem

All StrictPath values look the same to the compiler:

  • User uploads → StrictPath
  • Public assets → StrictPath
  • System config → StrictPath

The compiler can't help you catch domain mix-ups. Code review is your only defense. And humans make mistakes.

What Could Go Wrong?

Let's see the concrete dangers:

1. Security Leak: Private Files Exposed

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn security_leak_example() -> Result<(), Box<dyn std::error::Error>> {
    let private_uploads = StrictPath::with_boundary_create("private_uploads")?;
    let public_site = StrictPath::with_boundary_create("public_site")?;

    // User uploads a private document
    let tax_return = private_uploads.strict_join("tax_return_2024.pdf")?;
    tax_return.write(b"Sensitive financial data")?;

    // Oops! Developer accidentally serves it from the public site handler
    serve_to_internet(&tax_return)?;  // 🚨 Private file now publicly accessible!

    Ok(())
}

fn serve_to_internet(path: &StrictPath) -> std::io::Result<()> {
    // This function should only receive public site files
    // But the compiler can't enforce that!
    println!("Serving {} to the internet...", path.strictpath_display());
    Ok(())
}
}

2. Data Corruption: Wrong Directory Modified

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn data_corruption_example() -> Result<(), Box<dyn std::error::Error>> {
    let user_data = StrictPath::with_boundary_create("user_data")?;
    let system_logs = StrictPath::with_boundary_create("system_logs")?;

    let user_note = user_data.strict_join("notes.txt")?;
    let system_log = system_logs.strict_join("audit.log")?;

    // Oops! Passed the wrong path to the wrong function
    append_user_content(&system_log, "User's random thoughts")?;  // 🚨 Corrupting system log!
    append_audit_entry(&user_note, "ADMIN LOGIN")?;              // 🚨 Audit data in user file!

    Ok(())
}

fn append_user_content(path: &StrictPath, content: &str) -> std::io::Result<()> {
    // Should only receive user_data paths
    let mut existing = path.read_to_string().unwrap_or_default();
    existing.push_str(content);
    path.write(existing.as_bytes())
}

fn append_audit_entry(path: &StrictPath, entry: &str) -> std::io::Result<()> {
    // Should only receive system_logs paths
    let mut log = path.read_to_string().unwrap_or_default();
    log.push_str(&format!("[AUDIT] {}\n", entry));
    path.write(log.as_bytes())
}
}

3. Authorization Bypass: Wrong Permissions Applied

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn authorization_bypass_example() -> Result<(), Box<dyn std::error::Error>> {
    let admin_files = StrictPath::with_boundary_create("admin_files")?;
    let guest_files = StrictPath::with_boundary_create("guest_files")?;

    let sensitive_config = admin_files.strict_join("secrets.toml")?;
    let public_readme = guest_files.strict_join("README.md")?;

    // Oops! Applied wrong permission check to wrong path
    allow_guest_access(&sensitive_config)?;  // 🚨 Guest can access admin secrets!
    require_admin_access(&public_readme)?;   // 🚨 Admin required for public file!

    Ok(())
}

fn allow_guest_access(path: &StrictPath) -> std::io::Result<()> {
    println!("Guest can access: {}", path.strictpath_display());
    Ok(())
}

fn require_admin_access(path: &StrictPath) -> std::io::Result<()> {
    println!("Admin required for: {}", path.strictpath_display());
    Ok(())
}
}

Why This Happens

The problem is type erasure. Once you create paths from different boundaries, they all collapse to the same type:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn demonstrate_type_erasure() -> Result<(), Box<dyn std::error::Error>> {
    let uploads = StrictPath::with_boundary_create("uploads")?;
    let config = StrictPath::with_boundary_create("config")?;
    let cache = StrictPath::with_boundary_create("cache")?;

    let file1 = uploads.strict_join("a.txt")?;  // Type: StrictPath
    let file2 = config.strict_join("b.txt")?;   // Type: StrictPath
    let file3 = cache.strict_join("c.txt")?;    // Type: StrictPath

    // The compiler sees them all as identical
    // You can accidentally swap them and nothing will complain
    let paths = vec![file1, file2, file3];
    
    // Which path is which? The compiler doesn't know!
    for path in paths {
        // Is this uploads, config, or cache? 🤷
        println!("{}", path.strictpath_display());
    }

    Ok(())
}
}

The Defense: Human Code Review (Fragile!)

Without compiler help, you rely on:

  • ✍️ Careful naming — Hope developers use descriptive variable names
  • 👀 Code review — Hope reviewers catch the mix-ups
  • 📝 Documentation — Hope everyone reads and remembers it
  • 🧪 Testing — Hope tests cover the edge cases

Problem: Humans are fallible. Mistakes slip through. Security bugs ship to production.

Head First Moment

Imagine a hospital where every door key looks identical. The keys work — they're genuine hospital keys — but there's no way to know which key opens which door.

  • 🔑 Operating room key? Looks like every other key.
  • 🔑 Medicine cabinet key? Looks like every other key.
  • 🔑 Patient records room key? Looks like every other key.

Sure, you intend to use the right key for the right door. But mistakes happen:

  • Tired nurse grabs the wrong key ❌
  • New employee doesn't know the system ❌
  • Emergency situation, grab the nearest key ❌

We need keys that physically can't open the wrong doors.

The Real-World Impact

These mix-ups cause real security incidents:

  • CVE-2021-XXXXX: Web framework served user uploads from static asset handler → RCE
  • CVE-2020-XXXXX: Config parser wrote user data to system directory → Privilege escalation
  • CVE-2019-XXXXX: Admin dashboard mixed up user ID directories → Data leak

The pattern is always the same: Path from Domain A used in Domain B.

What We Need

We need the compiler to distinguish between paths from different domains:

#![allow(unused)]
fn main() {
// This should compile:
serve_public_asset(&public_css_file)?;      // ✅ Correct domain

// This should NOT compile:
serve_public_asset(&private_user_file)?;    // ❌ Wrong domain — should be compile error!
}

But how? StrictPath already gives us boundary safety. We just need a way to teach the compiler which boundary a path came from...

The Solution Preview

What if we could label each boundary? Give it a name the compiler understands?

#![allow(unused)]
fn main() {
// Pseudocode (not real syntax yet)
let uploads: StrictPath<"UserUploads"> = ...;
let assets: StrictPath<"PublicAssets"> = ...;
let config: StrictPath<"SystemConfig"> = ...;

// Now the compiler can see they're different!
fn serve_public_asset(path: &StrictPath<"PublicAssets">) { ... }

serve_public_asset(&assets)?;   // ✅ Compiles
serve_public_asset(&uploads)?;  // ❌ Compiler error: expected PublicAssets, found UserUploads
}

This is exactly what markers do. And that's what you'll learn in the next stage.

Key Takeaways

🚨 Multiple boundaries → same type → mix-ups possible
🚨 Mix-ups cause security bugs (data leaks, corruption, auth bypass)
🚨 Code review is fragile — humans make mistakes
🚨 We need compiler enforcement — catch errors at compile time

What's Next?

You've seen the problem: multiple boundaries create confusion and risk.

Now you're ready for the solution: markers that make the compiler your security guard.

Continue to Stage 3: Markers to the Rescue →

Stage 3: Markers to the Rescue — Compile-Time Domain Separation

"Give each boundary a name the compiler understands."

In Stage 2, you saw how multiple boundaries create confusion — all StrictPath values look identical to the compiler. Now you'll learn how markers solve this problem by encoding domain information in the type system.

Introducing Markers

A marker is a zero-cost compile-time label. It's like writing "THIS IS USER UPLOADS" directly on the type:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

// Define markers (zero runtime cost!)
struct UserUploads;
struct PublicAssets;
struct SystemConfig;
}

That's it! Three simple structs. But now watch what happens when we use them:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct UserUploads;
struct PublicAssets;
struct SystemConfig;

fn file_server_with_markers() -> Result<(), Box<dyn std::error::Error>> {
    // Now each boundary has a distinct type
    let uploads_dir: StrictPath<UserUploads> = 
        StrictPath::with_boundary_create("user_uploads")?;
    
    let assets_dir: StrictPath<PublicAssets> = 
        StrictPath::with_boundary_create("public_assets")?;
    
    let config_dir: StrictPath<SystemConfig> = 
        StrictPath::with_boundary_create("system_config")?;

    // Paths inherit their marker
    let user_file = uploads_dir.strict_join("document.pdf")?;  // StrictPath<UserUploads>
    let css_file = assets_dir.strict_join("style.css")?;      // StrictPath<PublicAssets>
    let config_file = config_dir.strict_join("database.toml")?; // StrictPath<SystemConfig>

    // ✅ Correct usage
    serve_public_asset(&css_file)?;
    save_user_upload(&user_file)?;

    // ❌ Compiler errors — wrong domain!
    // serve_public_asset(&user_file)?;     // Won't compile!
    // save_user_upload(&config_file)?;     // Won't compile!

    Ok(())
}

// Functions now express their requirements in the type system
fn serve_public_asset(path: &StrictPath<PublicAssets>) -> std::io::Result<Vec<u8>> {
    path.read()  // Guaranteed: path is in public_assets/
}

fn save_user_upload(path: &StrictPath<UserUploads>) -> std::io::Result<()> {
    path.write(b"user data")  // Guaranteed: path is in user_uploads/
}
}

What Just Happened?

  1. Zero-cost labels: struct UserUploads; — empty struct, no fields, no runtime overhead
  2. Type-level tracking: StrictPath<UserUploads> vs StrictPath<PublicAssets> are different types
  3. Compiler enforcement: Can't pass the wrong marker to a function — compile error
  4. Self-documenting: Function signatures show exactly what paths they accept

The New Guarantee: Not only is the path safe (Stage 1), but the compiler proves it's in the correct domain (Stage 3).

The Compiler as Security Guard

Let's see the compiler catch mistakes:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct SensitiveData;
struct PublicWebsite;

fn demonstrate_compiler_enforcement() -> Result<(), Box<dyn std::error::Error>> {
    let sensitive_dir: StrictPath<SensitiveData> = 
        StrictPath::with_boundary_create("sensitive")?;
    
    let public_dir: StrictPath<PublicWebsite> = 
        StrictPath::with_boundary_create("public")?;

    let secret_file = sensitive_dir.strict_join("passwords.txt")?;
    let css_file = public_dir.strict_join("styles.css")?;

    // ✅ This compiles — correct domain
    serve_public_file(&css_file)?;

    // ❌ This fails at compile time — wrong domain!
    // serve_public_file(&secret_file)?;
    //                   ^^^^^^^^^^^^ 
    // ERROR: expected `&StrictPath<PublicWebsite>`, 
    //        found `&StrictPath<SensitiveData>`

    Ok(())
}

fn serve_public_file(path: &StrictPath<PublicWebsite>) -> std::io::Result<()> {
    println!("Serving public file: {}", path.strictpath_display());
    Ok(())
}
}

Before markers: Mistake ships to production → security incident.
After markers: Mistake caught at compile time → fix before commit.

Try It Yourself

Here's a realistic example you can run:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct Documents;
struct Photos;
struct Music;

fn organize_media() -> Result<(), Box<dyn std::error::Error>> {
    // Create distinct boundaries
    let docs_dir: StrictPath<Documents> = StrictPath::with_boundary_create("docs")?;
    let photos_dir: StrictPath<Photos> = StrictPath::with_boundary_create("photos")?;
    let music_dir: StrictPath<Music> = StrictPath::with_boundary_create("music")?;

    // Create files in each domain
    let report = docs_dir.strict_join("quarterly_report.pdf")?;
    let vacation = photos_dir.strict_join("beach_2024.jpg")?;
    let song = music_dir.strict_join("favorite_song.mp3")?;

    // Correct domain usage
    archive_document(&report)?;           // ✅ Works
    backup_photo(&vacation)?;             // ✅ Works
    transcode_audio(&song)?;              // ✅ Works

    // Wrong domain usage — won't compile!
    // archive_document(&vacation)?;      // ❌ Compile error
    // backup_photo(&song)?;              // ❌ Compile error
    // transcode_audio(&report)?;         // ❌ Compile error

    Ok(())
}

fn archive_document(doc: &StrictPath<Documents>) -> std::io::Result<()> {
    println!("Archiving document: {}", doc.strictpath_display());
    Ok(())
}

fn backup_photo(photo: &StrictPath<Photos>) -> std::io::Result<()> {
    println!("Backing up photo: {}", photo.strictpath_display());
    Ok(())
}

fn transcode_audio(audio: &StrictPath<Music>) -> std::io::Result<()> {
    println!("Transcoding audio: {}", audio.strictpath_display());
    Ok(())
}
}

Markers Are Zero-Cost

Let's verify that markers have zero runtime overhead:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;
use std::mem;

struct MyMarker;

fn demonstrate_zero_cost() {
    // Size of StrictPath with and without marker
    let size_without = mem::size_of::<StrictPath<()>>();
    let size_with = mem::size_of::<StrictPath<MyMarker>>();

    println!("StrictPath<()>: {} bytes", size_without);
    println!("StrictPath<MyMarker>: {} bytes", size_with);
    
    // They're identical! The marker is compile-time only.
    assert_eq!(size_without, size_with);
}
}

The marker is erased at compile time. It exists only in the type system. No runtime memory, no runtime checks, no performance cost.

Naming Markers: Best Practices

Markers should describe what resource is stored under the boundary, not who accesses it:

✅ Good Marker Names (What is stored)

#![allow(unused)]
fn main() {
struct UserUploads;        // Stores: user-uploaded files
struct ProductImages;      // Stores: product catalog images
struct SystemLogs;         // Stores: application log files
struct ConfigFiles;        // Stores: configuration files
struct TempWorkspace;      // Stores: temporary processing files
}

❌ Bad Marker Names (Who accesses it)

#![allow(unused)]
fn main() {
struct AdminMarker;        // ❌ Describes user role, not storage
struct GuestAccess;        // ❌ Describes permission, not content
struct AuthorizedPath;     // ❌ Describes state, not resource
}

Why? Markers describe boundaries (physical storage locations), not permissions (authorization levels). We'll add permissions in Stage 4.

Real-World Example: Web Server

Here's how you'd structure a real web server with markers:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

// Define domains
struct StaticAssets;     // CSS, JS, images served to browsers
struct UserUploads;      // Files uploaded by users
struct TemplateFiles;    // HTML templates for rendering
struct AppLogs;          // Application logs

struct WebServer {
    static_dir: StrictPath<StaticAssets>,
    uploads_dir: StrictPath<UserUploads>,
    templates_dir: StrictPath<TemplateFiles>,
    logs_dir: StrictPath<AppLogs>,
}

impl WebServer {
    fn new() -> Result<Self, Box<dyn std::error::Error>> {
        Ok(Self {
            static_dir: StrictPath::with_boundary_create("public/static")?,
            uploads_dir: StrictPath::with_boundary_create("data/uploads")?,
            templates_dir: StrictPath::with_boundary_create("templates")?,
            logs_dir: StrictPath::with_boundary_create("logs")?,
        })
    }

    fn serve_static(&self, filename: &str) -> std::io::Result<Vec<u8>> {
        let asset_path = self.static_dir.strict_join(filename)?;
        serve_to_client(&asset_path)  // Type-safe: only StaticAssets
    }

    fn save_upload(&self, filename: &str, data: &[u8]) -> std::io::Result<()> {
        let upload_path = self.uploads_dir.strict_join(filename)?;
        store_user_file(&upload_path, data)  // Type-safe: only UserUploads
    }

    fn render_template(&self, template: &str) -> std::io::Result<String> {
        let tmpl_path = self.templates_dir.strict_join(template)?;
        load_template(&tmpl_path)  // Type-safe: only TemplateFiles
    }

    fn write_log(&self, entry: &str) -> std::io::Result<()> {
        let log_path = self.logs_dir.strict_join("app.log")?;
        append_log_entry(&log_path, entry)  // Type-safe: only AppLogs
    }
}

// Type-safe helper functions
fn serve_to_client(asset: &StrictPath<StaticAssets>) -> std::io::Result<Vec<u8>> {
    asset.read()
}

fn store_user_file(upload: &StrictPath<UserUploads>, data: &[u8]) -> std::io::Result<()> {
    upload.write(data)
}

fn load_template(tmpl: &StrictPath<TemplateFiles>) -> std::io::Result<String> {
    tmpl.read_to_string()
}

fn append_log_entry(log: &StrictPath<AppLogs>, entry: &str) -> std::io::Result<()> {
    let mut content = log.read_to_string().unwrap_or_default();
    content.push_str(entry);
    content.push('\n');
    log.write(content.as_bytes())
}
}

Head First Moment

Markers are like colored wristbands at a conference:

  • 🔵 Blue wristband → Speaker (can access speaker lounge)
  • 🟢 Green wristband → Attendee (can access general sessions)
  • 🔴 Red wristband → Staff (can access backstage)

The compiler checks your wristband at every function door:

  • Function requires 🔵 blue? You need StrictPath<Speaker>.
  • Try to enter with 🟢 green? Compile error: "Sorry, speakers only."
  • Wrong color? Access denied at compile time.

You can't fake a wristband, and you can't sneak into the wrong area. The type system physically prevents it.

Comparison: Before and After

Before Markers (Stage 2)

#![allow(unused)]
fn main() {
// ❌ All paths look the same
let user_file: StrictPath = ...;
let config_file: StrictPath = ...;
let log_file: StrictPath = ...;

// ❌ Functions can't distinguish
fn process(path: &StrictPath) { ... }

// ❌ Easy to mix up — compiler can't help
process(&config_file);  // Oops, wrong file!
}

After Markers (Stage 3)

#![allow(unused)]
fn main() {
// ✅ Each path has its domain encoded
let user_file: StrictPath<UserUploads> = ...;
let config_file: StrictPath<ConfigFiles> = ...;
let log_file: StrictPath<AppLogs> = ...;

// ✅ Functions express requirements
fn process_user_file(path: &StrictPath<UserUploads>) { ... }

// ✅ Compiler catches mistakes
process_user_file(&user_file);     // ✅ Correct
process_user_file(&config_file);   // ❌ Compile error!
}

Key Takeaways

Markers = Zero-cost compile-time labels
StrictPath<Marker> = Path + domain information
Compiler enforces domain separation — wrong marker = compile error
Self-documenting code — function signatures show requirements
No runtime overhead — markers are erased after compilation

The Updated Guarantee

If you have a StrictPath<Marker>, the compiler guarantees:

  1. ✅ The path cannot escape its boundary (Stage 1)
  2. ✅ The path is in the correct domain (Stage 3)

What's Next?

You now know how to prevent domain mix-ups with markers. But what about authorization? How do you encode "this user is authorized to access this path" in the type system?

That's where things get really powerful...

Continue to Stage 4: Authorization with change_marker() →


Quick Reference:

#![allow(unused)]
fn main() {
// Define markers
struct MyDomain;

// Create typed boundary
let boundary: StrictPath<MyDomain> = 
    StrictPath::with_boundary_create("path")?;

// Paths inherit marker
let file = boundary.strict_join("file.txt")?;  // StrictPath<MyDomain>

// Functions enforce domain
fn process(path: &StrictPath<MyDomain>) { ... }
}

Stage 4: Authorization with change_marker() — Compile-Time Authorization Proofs

"The compiler can mathematically prove that authorization happened first."

In Stage 3, you learned how markers prevent domain mix-ups. Now you'll learn how to encode authorization in markers using change_marker(), so the compiler can mathematically prove that authorization checks weren't forgotten.

The Authorization Problem

Markers prevent domain confusion. But what about permissions? How do we encode "this user is authorized to write to this directory"?

Traditional Approach: Runtime Checks Everywhere

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct UserFiles;

// ❌ Problem: Authorization check inside every operation
fn write_user_file(path: &StrictPath<UserFiles>, user_id: &str, data: &[u8]) 
    -> std::io::Result<()> 
{
    if !is_authorized(user_id) {  // Runtime check (can forget!)
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied, 
            "Unauthorized"
        ));
    }
    path.write(data)
}

fn delete_user_file(path: &StrictPath<UserFiles>, user_id: &str) 
    -> std::io::Result<()> 
{
    if !is_authorized(user_id) {  // Repeated check (can forget!)
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied, 
            "Unauthorized"
        ));
    }
    path.remove_file()
}

fn read_user_file(path: &StrictPath<UserFiles>, user_id: &str) 
    -> std::io::Result<Vec<u8>> 
{
    // Oops! Forgot the authorization check here! 🚨
    path.read()
}

fn is_authorized(user_id: &str) -> bool {
    user_id == "alice"
}
}

Problems:

  • ❌ Authorization checks scattered everywhere
  • ❌ Easy to forget a check (see read_user_file)
  • ❌ No compile-time guarantee that authorization happened
  • ❌ Code review has to catch missing checks (humans are fallible)

Better Approach: Encode Authorization in the Type

Instead of checking authorization repeatedly, we encode it in the type once:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

// Resource marker: describes WHAT directory
struct UserFiles;

// Permission markers: describe LEVEL of access
struct ReadOnly;
struct ReadWrite;

// Authorization gate: validates token → returns authorized marker
fn authenticate_user_access(
    token: &str,
    path: StrictPath<(UserFiles, ReadOnly)>
) -> Option<StrictPath<(UserFiles, ReadWrite)>> {
    // ✅ Authorization: Token validated (checked once here!)
    if validate_token(token) {
        // Transform marker to encode proven authorization
        Some(path.change_marker::<(UserFiles, ReadWrite)>())
    } else {
        None
    }
}

fn validate_token(token: &str) -> bool {
    token == "valid-token-12345"  // Real apps: JWT validation, database lookup, etc.
}

// Functions accept paths that already prove authorization
fn write_user_file(path: &StrictPath<(UserFiles, ReadWrite)>, data: &[u8]) 
    -> std::io::Result<()> 
{
    // No authorization check needed! Type proves it already happened.
    path.write(data)
}

fn delete_user_file(path: &StrictPath<(UserFiles, ReadWrite)>) 
    -> std::io::Result<()> 
{
    // No authorization check needed! Type proves it already happened.
    path.remove_file()
}

fn read_user_file(path: &StrictPath<(UserFiles, ReadOnly)>) 
    -> std::io::Result<Vec<u8>> 
{
    // ReadOnly access is sufficient for reading
    path.read()
}
}

Understanding change_marker()

What change_marker() Is NOT

#![allow(unused)]
fn main() {
// ❌ WRONG way to think about it:
// "change_marker() grants permissions"
// "change_marker() does authorization"
}

What change_marker() Actually Does

#![allow(unused)]
fn main() {
// ✅ RIGHT way to think about it:
// "change_marker() ENCODES proven authorization in the type"
// "change_marker() transforms the marker AFTER authorization passed"
}

The pattern:

  1. Check authorization (token validation, capability check, etc.)
  2. If authorized: call change_marker() to encode that fact in the type
  3. Pass the new type to functions that require authorization
  4. The compiler proves authorization happened (can't get the marker any other way!)

Using It: Complete Example

use strict_path::StrictPath;

struct UserFiles;
struct ReadOnly;
struct ReadWrite;

fn handle_request(token: &str, filename: &str, data: Option<&[u8]>) 
    -> Result<(), Box<dyn std::error::Error>> 
{
    // Start with read-only access (no authorization yet)
    let user_files_dir: StrictPath<(UserFiles, ReadOnly)> = 
        StrictPath::with_boundary_create("user_files")?;
    
    let file_path = user_files_dir.strict_join(filename)?;

    // Anyone can read with ReadOnly marker
    let _content = read_user_file(&file_path)?;
    println!("✅ Read succeeded (no authorization needed)");

    // Try to upgrade to ReadWrite by authenticating
    if let Some(writable_path) = authenticate_user_access(token, file_path) {
        // ✅ Token validated! Now we have ReadWrite access
        println!("✅ Authorization succeeded");
        
        if let Some(data) = data {
            write_user_file(&writable_path, data)?;
            println!("✅ Write succeeded (authorization proven by type)");
        }
        
        delete_user_file(&writable_path)?;
        println!("✅ Delete succeeded (authorization proven by type)");
    } else {
        println!("❌ Authorization failed — cannot write or delete");
    }

    Ok(())
}

fn authenticate_user_access(
    token: &str,
    path: StrictPath<(UserFiles, ReadOnly)>
) -> Option<StrictPath<(UserFiles, ReadWrite)>> {
    if validate_token(token) {
        Some(path.change_marker())
    } else {
        None
    }
}

fn validate_token(token: &str) -> bool {
    token == "valid-token-12345"
}

fn read_user_file(path: &StrictPath<(UserFiles, ReadOnly)>) -> std::io::Result<Vec<u8>> {
    path.read()
}

fn write_user_file(path: &StrictPath<(UserFiles, ReadWrite)>, data: &[u8]) -> std::io::Result<()> {
    path.write(data)
}

fn delete_user_file(path: &StrictPath<(UserFiles, ReadWrite)>) -> std::io::Result<()> {
    path.remove_file()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Valid token — authorization succeeds
    handle_request("valid-token-12345", "notes.txt", Some(b"New content"))?;
    
    // Invalid token — authorization fails
    handle_request("invalid-token", "notes.txt", Some(b"Hack attempt"))?;
    
    Ok(())
}

Tuple Markers: Composing Resources and Permissions

Notice we're using tuple markers: (UserFiles, ReadOnly) and (UserFiles, ReadWrite).

#![allow(unused)]
fn main() {
struct UserFiles;      // First element: WHAT resource
struct ReadOnly;       // Second element: WHAT permission level
struct ReadWrite;

// Composed together:
// StrictPath<(UserFiles, ReadOnly)>   = User files with read-only access
// StrictPath<(UserFiles, ReadWrite)>  = User files with read-write access
}

Why tuples?

  • Flexible composition: Mix and match resources with permissions
  • Easy to transform: change_marker() can swap out permission levels
  • Standard Rust idiom: No need to learn special syntax

Try It Yourself: Capability-Based Authorization

Here's a more sophisticated example with multiple capability levels:

use strict_path::StrictPath;

struct ProjectFiles;
struct CanRead;
struct CanWrite;
struct CanDelete;

// Check user role and return appropriate marker
fn grant_project_access(
    user_role: &str,
    path: StrictPath<ProjectFiles>
) -> Option<StrictPath<(ProjectFiles, CanRead, CanWrite, CanDelete)>> {
    // ✅ Authorization: Role checked
    if user_role == "admin" {
        // Admin gets full access (read + write + delete)
        Some(path.change_marker::<(ProjectFiles, CanRead, CanWrite, CanDelete)>())
    } else {
        None
    }
}

fn grant_editor_access(
    user_role: &str,
    path: StrictPath<ProjectFiles>
) -> Option<StrictPath<(ProjectFiles, CanRead, CanWrite)>> {
    // ✅ Authorization: Role checked
    if user_role == "editor" || user_role == "admin" {
        // Editors can read and write (but not delete)
        Some(path.change_marker::<(ProjectFiles, CanRead, CanWrite)>())
    } else {
        None
    }
}

fn grant_readonly_access(
    user_role: &str,
    path: StrictPath<ProjectFiles>
) -> Option<StrictPath<(ProjectFiles, CanRead)>> {
    // ✅ Authorization: Role checked
    if user_role == "viewer" || user_role == "editor" || user_role == "admin" {
        Some(path.change_marker::<(ProjectFiles, CanRead)>())
    } else {
        None
    }
}

// Functions require specific capabilities in their signature
fn read_project(path: &StrictPath<(ProjectFiles, CanRead)>) -> std::io::Result<String> {
    path.read_to_string()
}

fn update_project(path: &StrictPath<(ProjectFiles, CanRead, CanWrite)>) -> std::io::Result<()> {
    path.write(b"Updated project data")
}

fn delete_project(path: &StrictPath<(ProjectFiles, CanRead, CanWrite, CanDelete)>) -> std::io::Result<()> {
    path.remove_file()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let projects_dir: StrictPath<ProjectFiles> = 
        StrictPath::with_boundary_create("projects")?;
    
    let project = projects_dir.strict_join("proposal.md")?;

    // Viewer can only read
    if let Some(readonly_path) = grant_readonly_access("viewer", project.clone()) {
        read_project(&readonly_path)?;
        println!("✅ Viewer: read succeeded");
        // update_project(&readonly_path)?;  // ❌ Won't compile: missing CanWrite
    }

    // Editor can read and write
    if let Some(editor_path) = grant_editor_access("editor", project.clone()) {
        read_project(&editor_path)?;      // ✅ Has CanRead
        update_project(&editor_path)?;    // ✅ Has CanRead + CanWrite
        println!("✅ Editor: read and write succeeded");
        // delete_project(&editor_path)?; // ❌ Won't compile: missing CanDelete
    }

    // Admin can do everything
    if let Some(admin_path) = grant_project_access("admin", project) {
        read_project(&admin_path)?;      // ✅ Has CanRead
        update_project(&admin_path)?;    // ✅ Has CanRead + CanWrite
        delete_project(&admin_path)?;    // ✅ Has CanRead + CanWrite + CanDelete
        println!("✅ Admin: full access succeeded");
    }

    Ok(())
}

Head First Moment: Passport Stamps

Think of change_marker() like stamping a passport:

  1. You apply for a visa (submit token for validation)
  2. Visa office checks credentials (authorization function validates token)
  3. If approved, they stamp your passport (call change_marker())
  4. Guards at checkpoints check your stamp (functions check marker type)

The stamp doesn't grant permission — the visa office did that. The stamp just proves permission was granted.

Functions check your stamp (marker), not your visa application (token).

This means:

  • ✅ Authorization happens once (at the visa office)
  • ✅ Every checkpoint trusts the stamp (no re-checking)
  • ✅ Can't forge a stamp (only way to get marker is through auth function)
  • ✅ Compiler ensures you have the right stamp for each checkpoint

The Authorization Pattern Summary

#![allow(unused)]
fn main() {
// 1️⃣ Define resource and permission markers
struct Resource;
struct ReadOnly;
struct ReadWrite;

// 2️⃣ Create authorization gate
fn authorize(token: &str, path: StrictPath<(Resource, ReadOnly)>) 
    -> Option<StrictPath<(Resource, ReadWrite)>> 
{
    if validate(token) {                        // ✅ Check authorization
        Some(path.change_marker())              // ✅ Encode in type
    } else {
        None                                    // ❌ Authorization failed
    }
}

// 3️⃣ Functions require authorized marker
fn protected_operation(path: &StrictPath<(Resource, ReadWrite)>) {
    // No authorization check needed!
    // Type proves authorization already happened.
}
}

Real-World Example: Web API

Here's how you'd use this in a web server:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct ApiUploads;
struct AuthToken(String);
struct ReadAccess;
struct WriteAccess;

// Authorization: Validate JWT token
fn authorize_write_access(
    token: &AuthToken,
    path: StrictPath<(ApiUploads, ReadAccess)>
) -> Result<StrictPath<(ApiUploads, ReadAccess, WriteAccess)>, AuthError> {
    // ✅ Authorization: Validate JWT token
    if verify_jwt(&token.0)? {
        Ok(path.change_marker())
    } else {
        Err(AuthError::InvalidToken)
    }
}

fn verify_jwt(token: &str) -> Result<bool, AuthError> {
    // Real implementation would:
    // - Verify signature
    // - Check expiration
    // - Validate claims
    Ok(token.starts_with("Bearer "))
}

// API handlers
fn handle_read(uploads: &StrictPath<(ApiUploads, ReadAccess)>, filename: &str) 
    -> Result<Vec<u8>, ApiError> 
{
    let file = uploads.strict_join(filename)?;
    Ok(file.read()?)
}

fn handle_write(
    uploads: &StrictPath<(ApiUploads, ReadAccess, WriteAccess)>, 
    filename: &str,
    data: &[u8]
) -> Result<(), ApiError> 
{
    let file = uploads.strict_join(filename)?;
    Ok(file.write(data)?)
}

#[derive(Debug)]
enum AuthError {
    InvalidToken,
}

#[derive(Debug)]
enum ApiError {
    PathError(strict_path::StrictPathError),
    IoError(std::io::Error),
}

impl From<strict_path::StrictPathError> for ApiError {
    fn from(e: strict_path::StrictPathError) -> Self {
        ApiError::PathError(e)
    }
}

impl From<std::io::Error> for ApiError {
    fn from(e: std::io::Error) -> Self {
        ApiError::IoError(e)
    }
}
}

Key Takeaways

change_marker() encodes proven authorization (doesn't grant it)
Tuple markers compose resources and permissions
Authorization happens once — type system enforces it everywhere
Impossible to bypass — only way to get the marker is through auth gate
Compiler catches missing authorization — won't compile without proper marker

The Complete Guarantee So Far

If a function accepts StrictPath<(Resource, Permission)>, the compiler mathematically proves that:

  1. ✅ The path cannot escape its boundary (Stage 1)
  2. ✅ The path is in the correct domain (Stage 3)
  3. ✅ Authorization was granted for that permission level (Stage 4)

This is compile-time authorization. Forget a check? Won't compile. Use the wrong permission level? Won't compile. Bypass authorization? Impossible.

What's Next?

You've mastered authorization with markers. But what about user-facing applications where you want to show clean paths like /documents/file.txt instead of ugly system paths?

That's where VirtualPath comes in...

Continue to Stage 5: Virtual Paths →


Quick Reference:

#![allow(unused)]
fn main() {
// Define markers
struct Resource;
struct ReadOnly;
struct ReadWrite;

// Authorization gate
fn authorize(token: &str, path: StrictPath<(Resource, ReadOnly)>) 
    -> Option<StrictPath<(Resource, ReadWrite)>> 
{
    if validate(token) {
        Some(path.change_marker())  // Encode authorization
    } else {
        None
    }
}

// Protected function
fn protected(path: &StrictPath<(Resource, ReadWrite)>) {
    // No auth check needed — type proves it!
}
}

Stage 5: Virtual Paths — Containment for Sandboxes

"Contain escape attempts for multi-tenant isolation and security research."

In Stage 4, you learned how to encode authorization in markers. Now you'll learn how VirtualPath extends StrictPath with virtual filesystem semantics — designed for scenarios where path escapes are expected but must be controlled.

Important: VirtualPath is opt-in via the virtual-path feature. Use it only when you need containment (multi-tenant systems, malware sandboxes) rather than detection (archive extraction, file uploads).

The Problem with StrictPath for User UX

StrictPath is perfect for system operations, but it exposes real filesystem paths:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn show_user_files() -> Result<(), Box<dyn std::error::Error>> {
    let uploads_dir = StrictPath::with_boundary_create("/var/app/users/alice/uploads")?;
    let file = uploads_dir.strict_join("documents/report.pdf")?;

    // User sees ugly system path
    println!("Your file: {}", file.strictpath_display());
    // Output: /var/app/users/alice/uploads/documents/report.pdf
    
    // User thinks: "Why do I need to know about /var/app/users/alice?"
    // "I just want to see: /documents/report.pdf"

    Ok(())
}
}

Problems:

  • ❌ Users see internal directory structure
  • ❌ Paths are long and confusing
  • ❌ Exposes system architecture details
  • ❌ Not user-friendly for file browsers, cloud storage UI, etc.

The Solution: VirtualPath

VirtualPath provides a virtual root — users see paths starting from /, but the system enforces the real boundary:

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

fn show_user_files_virtually() -> Result<(), Box<dyn std::error::Error>> {
    // Create a virtual root (system boundary: /var/app/users/alice/uploads)
    let user_vroot = VirtualPath::with_root("/var/app/users/alice/uploads")?;
    
    let file = user_vroot.virtual_join("documents/report.pdf")?;

    // User sees clean virtual path
    println!("Your file: {}", file.virtualpath_display());
    // Output: /documents/report.pdf
    
    // User thinks: "Perfect! That's my file."

    // System still operates on real path
    file.write(b"File contents")?;  
    // Actually writes to: /var/app/users/alice/uploads/documents/report.pdf

    println!("System path: {}", file.as_unvirtual().strictpath_display());
    // Output: /var/app/users/alice/uploads/documents/report.pdf

    Ok(())
}
}

Clamping vs. Rejecting

This is the key difference between VirtualPath and StrictPath:

StrictPath: Rejects Escapes

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn strict_behavior() -> Result<(), Box<dyn std::error::Error>> {
    let boundary = StrictPath::with_boundary_create("sandbox")?;

    // Normal path works
    let file1 = boundary.strict_join("data/file.txt")?;
    println!("✅ Valid: {}", file1.strictpath_display());

    // Attack attempt: FAILS with error
    let file2 = boundary.strict_join("../../../etc/passwd");
    match file2 {
        Ok(_) => println!("✅ Valid path"),
        Err(e) => println!("❌ Error: {}", e),  // PathEscapesBoundary
    }

    Ok(())
}
}

VirtualPath: Clamps Escapes

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

fn virtual_behavior() -> Result<(), Box<dyn std::error::Error>> {
    let vroot = VirtualPath::with_root("sandbox")?;

    // Normal path works
    let file1 = vroot.virtual_join("data/file.txt")?;
    println!("Virtual: {}", file1.virtualpath_display());  // /data/file.txt

    // Attack attempt: CLAMPED safely
    let file2 = vroot.virtual_join("../../../etc/passwd")?;  // No error!
    println!("Virtual: {}", file2.virtualpath_display());    // /etc/passwd (clamped!)
    
    // But system path is still safe:
    println!("System: {}", file2.as_unvirtual().strictpath_display());
    // Output: sandbox/etc/passwd (still inside boundary!)

    Ok(())
}
}

Key difference:

  • StrictPath: Escape attempt → Error (explicit rejection)
  • VirtualPath: Escape attempt → Clamped to boundary (graceful containment)

When to Use Which

ScenarioUseWhy
Web API validationStrictPathFail fast on invalid input
System config filesStrictPathReject malformed paths explicitly
User file browserVirtualPathShow clean / paths, clamp escapes gracefully
Archive extractionVirtualPathHostile archive entries can't escape
Cloud storage UIVirtualPathUsers see /MyFiles/ instead of system paths
LLM file operationsStrictPathLLM-generated paths validated strictly

Rule of thumb:

  • System-facing?StrictPath (explicit errors)
  • User-facing?VirtualPath (graceful clamping)

Try It Yourself: Per-User Sandboxes

Here's a realistic example of per-user isolation:

use strict_path::{VirtualPath, VirtualRoot};

struct UserFiles;

fn create_user_workspace(user_id: u64) -> Result<VirtualRoot<UserFiles>, Box<dyn std::error::Error>> {
    // Each user gets their own virtual root
    let user_dir = format!("users/user_{}", user_id);
    Ok(VirtualRoot::try_new_create(user_dir)?)
}

fn user_file_browser(user_id: u64) -> Result<(), Box<dyn std::error::Error>> {
    let user_workspace = create_user_workspace(user_id)?;

    // User uploads files (they see clean paths)
    let doc = user_workspace.virtual_join("Documents/report.pdf")?;
    doc.create_parent_dir_all()?;
    doc.write(b"User document content")?;

    println!("User {} sees: {}", user_id, doc.virtualpath_display());
    // Output: /Documents/report.pdf

    println!("System stores at: {}", doc.as_unvirtual().strictpath_display());
    // Output: users/user_123/Documents/report.pdf

    // Even if user tries to escape, they stay in their sandbox
    let sneaky = user_workspace.virtual_join("../../../etc/passwd")?;
    println!("Attack clamped to: {}", sneaky.virtualpath_display());
    // Output: /etc/passwd (virtual)
    
    println!("Actually safe at: {}", sneaky.as_unvirtual().strictpath_display());
    // Output: users/user_123/etc/passwd (still in their sandbox!)

    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    user_file_browser(123)?;
    user_file_browser(456)?;
    Ok(())
}

VirtualPath = StrictPath + Virtual View

Under the hood, VirtualPath wraps a StrictPath and adds a virtual display layer:

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

fn demonstrate_duality() -> Result<(), Box<dyn std::error::Error>> {
    let vpath = VirtualPath::with_root("data")?.virtual_join("file.txt")?;

    // Virtual view (user-facing)
    println!("Virtual: {}", vpath.virtualpath_display());
    // Output: /file.txt

    // System view (actual filesystem path)
    println!("System: {}", vpath.as_unvirtual().strictpath_display());
    // Output: data/file.txt

    // All StrictPath operations work
    vpath.write(b"Hello, virtual world!")?;
    let content = vpath.read_to_string()?;
    println!("Content: {}", content);

    Ok(())
}
}

The relationship:

#![allow(unused)]
fn main() {
VirtualPath<Marker> = StrictPath<Marker> + virtual display semantics
}

This is where VirtualPath truly shines as a virtual filesystem. It doesn't just clamp relative path escapes — it also clamps absolute symlink targets:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn strict_symlink_behavior() -> Result<(), Box<dyn std::error::Error>> {
    let boundary = StrictPath::with_boundary_create("sandbox")?;

    // If "sandbox/config_link" symlinks to "/etc/passwd":
    let symlink_path = boundary.strict_join("config_link");
    match symlink_path {
        Ok(_) => println!("✅ Symlink target is inside boundary"),
        Err(e) => println!("❌ Symlink escapes boundary: {}", e),
    }
    
    // StrictPath follows the symlink and validates the *target* is inside boundary
    // If target is outside → Error (PathEscapesBoundary)

    Ok(())
}
}
#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

fn virtual_symlink_behavior() -> Result<(), Box<dyn std::error::Error>> {
    let vroot = VirtualPath::with_root("sandbox")?;

    // If "sandbox/config_link" symlinks to "/etc/passwd":
    let symlink_path = vroot.virtual_join("config_link")?;  // No error!
    
    println!("Virtual view: {}", symlink_path.virtualpath_display());
    // Output: /etc/passwd (clamped to virtual root!)
    
    println!("System path: {}", symlink_path.as_unvirtual().strictpath_display());
    // Output: sandbox/etc/passwd (safely inside boundary!)

    // VirtualPath treats absolute symlink targets as *relative to the virtual root*
    // The symlink target "/etc/passwd" becomes "sandbox/etc/passwd"
    
    Ok(())
}
}

The Key Insight:

In a virtual filesystem (container, chroot, sandbox), absolute paths are always relative to the virtual root. This applies whether the absolute path comes from:

  • User input: vroot.virtual_join("/etc/passwd") → clamped
  • Symlink target: config_link -> /etc/passwd → clamped

Why This Matters:

ScenarioStrictPath BehaviorVirtualPath Behavior
User input "../../../etc/passwd"❌ Error (rejected)✅ Clamped to /etc/passwd in vroot
Symlink link -> /etc/passwd❌ Error if outside✅ Clamped to vroot /etc/passwd
Archive entry "/sensitive/data"❌ Error (rejected)✅ Clamped to vroot /sensitive/data

Use Cases:

  • Archive extraction: Malicious archives with absolute symlinks are automatically safe
  • Multi-tenant storage: User A's symlink can't escape to user B's files
  • Container-like semantics: Perfect for sandboxed environments where / means "root of this container"

Key point: VirtualPath implements true virtual filesystem semantics where absolute paths (from any source) are interpreted relative to the virtual root. This is not a "trust everything" mode — it's a mathematically consistent sandbox model.

Real-World Example: Cloud File Storage

use strict_path::{VirtualPath, VirtualRoot};

struct CloudStorage;

struct UserCloudStorage {
    user_id: u64,
    vroot: VirtualRoot<CloudStorage>,
}

impl UserCloudStorage {
    fn new(user_id: u64) -> Result<Self, Box<dyn std::error::Error>> {
        let storage_path = format!("cloud_storage/user_{}", user_id);
        let vroot = VirtualRoot::try_new_create(storage_path)?;
        Ok(Self { user_id, vroot })
    }

    fn upload_file(&self, virtual_path: &str, data: &[u8]) 
        -> Result<String, Box<dyn std::error::Error>> 
    {
        let file = self.vroot.virtual_join(virtual_path)?;
        file.create_parent_dir_all()?;
        file.write(data)?;
        
        // Return clean virtual path for UI display
        Ok(file.virtualpath_display().to_string())
    }

    fn download_file(&self, virtual_path: &str) 
        -> Result<Vec<u8>, Box<dyn std::error::Error>> 
    {
        let file = self.vroot.virtual_join(virtual_path)?;
        Ok(file.read()?)
    }

    fn list_files(&self, virtual_dir: &str) 
        -> Result<Vec<String>, Box<dyn std::error::Error>> 
    {
        let dir = self.vroot.virtual_join(virtual_dir)?;
        let mut files = Vec::new();
        
        for entry in dir.read_dir()? {
            let entry = entry?;
            let vpath = self.vroot.virtual_join(entry.file_name().to_string_lossy().as_ref())?;
            files.push(vpath.virtualpath_display().to_string());
        }
        
        Ok(files)
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let alice_storage = UserCloudStorage::new(1001)?;

    // Upload files (user sees clean paths)
    let path1 = alice_storage.upload_file("Photos/vacation.jpg", b"photo data")?;
    let path2 = alice_storage.upload_file("Documents/report.pdf", b"document data")?;
    
    println!("Uploaded: {}", path1);  // /Photos/vacation.jpg
    println!("Uploaded: {}", path2);  // /Documents/report.pdf

    // Download files
    let data = alice_storage.download_file("/Documents/report.pdf")?;
    println!("Downloaded {} bytes", data.len());

    // User tries to escape — safely clamped
    let evil_path = alice_storage.upload_file("../../../etc/passwd", b"attack")?;
    println!("Attack clamped to: {}", evil_path);  // /etc/passwd (in user's sandbox!)

    Ok(())
}

Markers Work with VirtualPath Too

Just like StrictPath, you can use markers with VirtualPath:

#![allow(unused)]
fn main() {
use strict_path::{VirtualPath, VirtualRoot};

struct UserPhotos;
struct UserDocuments;

fn organize_virtual_storage(user_id: u64) -> Result<(), Box<dyn std::error::Error>> {
    // Each domain gets its own virtual root
    let photos_vroot: VirtualRoot<UserPhotos> = 
        VirtualRoot::try_new_create(format!("users/user_{}/photos", user_id))?;
    
    let docs_vroot: VirtualRoot<UserDocuments> = 
        VirtualRoot::try_new_create(format!("users/user_{}/documents", user_id))?;

    let photo = photos_vroot.virtual_join("vacation.jpg")?;  // VirtualPath<UserPhotos>
    let doc = docs_vroot.virtual_join("report.pdf")?;        // VirtualPath<UserDocuments>

    process_photo(&photo)?;              // ✅ Correct type
    process_document(&doc)?;             // ✅ Correct type
    // process_photo(&doc)?;             // ❌ Compile error!

    Ok(())
}

fn process_photo(photo: &VirtualPath<UserPhotos>) -> std::io::Result<()> {
    println!("Processing photo: {}", photo.virtualpath_display());
    Ok(())
}

fn process_document(doc: &VirtualPath<UserDocuments>) -> std::io::Result<()> {
    println!("Processing document: {}", doc.virtualpath_display());
    Ok(())
}
}

Head First Moment: Storefront Facade

VirtualPath is like a storefront with a clean facade:

  • Customers see: Beautiful /Products/Item URLs
  • Behind the scenes: Files stored at /var/www/store/inventory/category-5/sku-12345/item.jpg

The facade (virtual path) makes for better UX. The real structure (strict path) handles the actual filesystem operations.

Best of both worlds:

  • Users see clean, understandable paths
  • System operates on real, validated paths
  • Security boundary enforced throughout

When to Use VirtualPath vs. StrictPath

Use VirtualPath (Containment) When:

  • Multi-tenant systems — each user needs isolated / view
  • Malware sandboxes — observe behavior while containing escapes
  • Archive analysis — safely study suspicious archives in research environments
  • Container-like plugins — modules get their own filesystem view
  • Security research — simulate contained environments
  • ✅ Path escapes are expected but must be controlled

Use StrictPath (Detection) When:

  • Production archive extraction — detect malicious paths, reject compromised archives, alert users
  • File uploads — reject user paths with traversal attempts
  • Config loading — fail on untrusted paths that try to escape
  • System resources — logs, cache, assets with strict boundaries
  • ✅ Path escapes indicate malicious intent that must be detected

Key Insight for Archives: Use StrictPath for production extraction (detect and reject attacks). Use VirtualPath for research/sandboxing (safely analyze suspicious archives while containing their behavior).

Key Takeaways

VirtualPath = StrictPath + virtual / view
Clamping behavior — escapes are contained, not rejected
User-friendly display — show clean paths in UIs
Per-user sandboxes — each user gets their own virtual root
Markers work — domain separation applies to virtual paths too
Symlinks still validated — not a "trust everything" mode
Opt-in feature — requires virtual-path in Cargo.toml

The Complete Guarantee

If you have a VirtualPath<Marker>, the compiler guarantees:

  1. ✅ The path cannot escape its boundary (Stage 1)
  2. ✅ The path is in the correct domain (Stage 3)
  3. ✅ Virtual display is always rooted at / (Stage 5)
  4. ✅ System operations use the validated real path (Stage 5)

What's Next?

You now understand both StrictPath and VirtualPath. But how do you integrate with external ecosystem crates like OS directories, temp files, and app-specific paths?

That's where feature-gated constructors come in...

Continue to Stage 6: Feature Integration →


Quick Reference:

#![allow(unused)]
fn main() {
// Create virtual root
let vroot = VirtualPath::with_root("path")?;

// Validate and clamp
let vpath = vroot.virtual_join(untrusted_input)?;

// Display
println!("Virtual: {}", vpath.virtualpath_display());      // /file.txt
println!("System: {}", vpath.as_unvirtual().strictpath_display());  // path/file.txt

// I/O operations
vpath.write(data)?;
let content = vpath.read_to_string()?;
}

Stage 6: Feature Integration — Ecosystem Integration with Safe Boundaries

"Integrate with OS directories, temp files, and app-specific paths — safely."

You've mastered the core concepts: boundaries, markers, authorization, and virtual paths. Now you'll learn how to integrate strict-path with your ecosystem using feature-gated constructors that work seamlessly with popular Rust crates.

The Problem: External Directory APIs

Your app needs to work with standard directories:

  • User config: ~/.config/myapp/ (Linux) or C:\Users\Alice\AppData\Roaming\myapp\ (Windows)
  • Temp files: System temp directory with automatic cleanup
  • Downloads: User's Downloads folder
  • App directories: Portable app-specific paths

But you still need boundary enforcement! Otherwise, untrusted input can escape these directories too.

The Solution: Feature-Gated Constructors

Enable features in Cargo.toml:

[dependencies]
strict-path = { version = "0.1.0-beta.2", features = ["dirs", "tempfile", "app-path", "serde"] }

Now you get special constructors that combine external crate APIs with strict-path's boundary enforcement.

Feature: dirs — OS Standard Directories

The dirs feature adds constructors for platform-specific user directories:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

struct AppConfig;
struct UserDownloads;
struct UserDocuments;

fn use_os_directories() -> Result<(), Box<dyn std::error::Error>> {
    // Get user's config directory (platform-specific)
    let config_dir: PathBoundary<AppConfig> = PathBoundary::try_new_os_config("myapp")?;
    // Linux: ~/.config/myapp/
    // Windows: C:\Users\Alice\AppData\Roaming\myapp\
    // macOS: ~/Library/Application Support/myapp/

    let config_file = config_dir.strict_join("settings.toml")?;
    config_file.write(b"theme = dark\nlanguage = en")?;
    println!("Config: {}", config_file.strictpath_display());

    // Get user's downloads directory
    let downloads_dir: PathBoundary<UserDownloads> = PathBoundary::try_new_os_downloads()?;
    let export_file = downloads_dir.strict_join("export.csv")?;
    export_file.write(b"col1,col2\nval1,val2")?;
    println!("Export: {}", export_file.strictpath_display());

    // Get user's documents directory
    let docs_dir: PathBoundary<UserDocuments> = PathBoundary::try_new_os_documents()?;
    let report = docs_dir.strict_join("report.pdf")?;
    report.write(b"PDF content")?;
    println!("Report: {}", report.strictpath_display());

    Ok(())
}
}

Available OS Directory Constructors

ConstructorLinuxWindowsmacOS
try_new_os_config("app")~/.config/app/C:\Users\...\AppData\Roaming\app\~/Library/Application Support/app/
try_new_os_data("app")~/.local/share/app/C:\Users\...\AppData\Roaming\app\~/Library/Application Support/app/
try_new_os_cache("app")~/.cache/app/C:\Users\...\AppData\Local\app\~/Library/Caches/app/
try_new_os_downloads()~/Downloads/C:\Users\...\Downloads\~/Downloads/
try_new_os_documents()~/Documents/C:\Users\...\Documents\~/Documents/
try_new_os_pictures()~/Pictures/C:\Users\...\Pictures\~/Pictures/
try_new_os_videos()~/Videos/C:\Users\...\Videos\~/Videos/
try_new_os_music()~/Music/C:\Users\...\Music\~/Music/

See the OS Directories chapter for the complete list and details.

Try It: Cross-Platform Config Manager

use strict_path::PathBoundary;

struct AppSettings;

fn save_user_settings(theme: &str, language: &str) -> Result<(), Box<dyn std::error::Error>> {
    // Works on Linux, Windows, and macOS automatically!
    let config_dir: PathBoundary<AppSettings> = 
        PathBoundary::try_new_os_config("myapp")?;

    let settings_file = config_dir.strict_join("settings.toml")?;
    let content = format!("theme = {}\nlanguage = {}\n", theme, language);
    settings_file.write(content.as_bytes())?;

    println!("Settings saved to: {}", settings_file.strictpath_display());
    Ok(())
}

fn load_user_settings() -> Result<String, Box<dyn std::error::Error>> {
    let config_dir: PathBoundary<AppSettings> = 
        PathBoundary::try_new_os_config("myapp")?;

    let settings_file = config_dir.strict_join("settings.toml")?;
    Ok(settings_file.read_to_string()?)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    save_user_settings("dark", "en")?;
    let settings = load_user_settings()?;
    println!("Loaded settings:\n{}", settings);
    Ok(())
}

Feature: tempfile — Automatic Cleanup with RAII

The tempfile feature works with the tempfile crate for automatic cleanup:

use strict_path::PathBoundary;
use tempfile::TempDir;

struct WorkDir;

fn process_with_temp() -> Result<(), Box<dyn std::error::Error>> {
    // Create temporary directory (cleaned up automatically when dropped)
    let temp = TempDir::new()?;
    
    println!("Created temp directory: {:?}", temp.path());

    // Wrap in PathBoundary for safe operations
    let work_dir: PathBoundary<WorkDir> = PathBoundary::try_new(temp.path())?;

    // Do work inside temp directory — all paths validated!
    let intermediate = work_dir.strict_join("intermediate.json")?;
    intermediate.write(b"{\"status\": \"processing\"}")?;

    let output = work_dir.strict_join("output.txt")?;
    output.write(b"Final result")?;

    // Try to escape — fails!
    match work_dir.strict_join("../../../etc/passwd") {
        Ok(_) => println!("❌ Escape succeeded (should not happen!)"),
        Err(e) => println!("✅ Escape blocked: {}", e),
    }

    println!("Work directory: {}", work_dir.strictpath_display());
    println!("Output file: {}", output.strictpath_display());

    // When this function returns, `temp` is dropped → directory deleted automatically
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    process_with_temp()?;
    println!("Temp directory has been automatically cleaned up!");
    Ok(())
}

Key benefits:

  • RAII cleanup — temp directory deleted when TempDir drops
  • Boundary enforcement — even in temp directories, paths can't escape
  • No manual cleanup — Rust handles it for you

Try It: Safe Archive Processing

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;
use tempfile::TempDir;

struct ArchiveExtract;

fn extract_and_process_archive(archive_data: &[u8]) 
    -> Result<Vec<String>, Box<dyn std::error::Error>> 
{
    // Create temp directory for extraction
    let temp = TempDir::new()?;
    let extract_dir: PathBoundary<ArchiveExtract> = 
        PathBoundary::try_new(temp.path())?;

    // Simulate extracting files (in reality, use zip crate)
    let file1 = extract_dir.strict_join("readme.txt")?;
    file1.write(b"Archive contents...")?;

    let file2 = extract_dir.strict_join("data/values.csv")?;
    file2.create_parent_dir_all()?;
    file2.write(b"col1,col2\n1,2")?;

    // Even if archive contains hostile paths, they're validated
    match extract_dir.strict_join("../../../evil.sh") {
        Ok(_) => println!("❌ Hostile path accepted!"),
        Err(e) => println!("✅ Hostile path blocked: {}", e),
    }

    // Collect extracted files
    let mut files = Vec::new();
    files.push(file1.strictpath_display().to_string());
    files.push(file2.strictpath_display().to_string());

    // Temp directory deleted automatically when function returns
    Ok(files)
}
}

Feature: app-path — Portable Application Directories

The app-path feature provides portable app-specific paths with environment variable overrides:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

struct AppLogs;
struct AppData;

fn setup_app_directories() -> Result<(), Box<dyn std::error::Error>> {
    // Get app-specific log directory with environment override support
    // If MYAPP_LOGS_DIR is set, uses that path
    // Otherwise, uses platform-specific app directory + "logs" subdirectory
    let logs_dir: PathBoundary<AppLogs> = 
        PathBoundary::try_new_app_path("logs", Some("MYAPP_LOGS_DIR"))?;
    
    let error_log = logs_dir.strict_join("errors.log")?;
    error_log.write(b"[ERROR] Example error message\n")?;

    let access_log = logs_dir.strict_join("access.log")?;
    access_log.write(b"[INFO] User accessed /api/data\n")?;

    println!("Logs directory: {}", logs_dir.strictpath_display());

    // Get app-specific data directory with environment override support
    let data_dir: PathBoundary<AppData> = 
        PathBoundary::try_new_app_path("data", Some("MYAPP_DATA_DIR"))?;

    let database = data_dir.strict_join("app.db")?;
    database.write(b"SQLite database content")?;

    println!("Data directory: {}", data_dir.strictpath_display());

    Ok(())
}
}

Environment Variable Overrides

You can override the default locations using environment variables:

# Override logs directory
export MYAPP_LOGS_DIR=/custom/log/path

# Override data directory
export MYAPP_DATA_DIR=/custom/data/path

When the environment variable is set, the path is resolved to the final directory — no subdirectory append happens.

This is useful for:

  • ✅ Testing with custom paths
  • ✅ Deployment-specific configurations
  • ✅ Docker container mounts
  • ✅ CI/CD pipelines

Feature: serde — Safe Deserialization with Validation

The serde feature adds safe serialization/deserialization with automatic validation:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath, serde_ext::WithBoundary};
use serde::{Deserialize, Serialize};

struct ConfigFiles;
struct DataFiles;

#[derive(Deserialize, Serialize)]
struct AppConfig {
    app_name: String,
    
    // Deserialize with validation through boundary
    #[serde(deserialize_with = "deserialize_log_file")]
    log_file: StrictPath<ConfigFiles>,
    
    #[serde(deserialize_with = "deserialize_data_file")]
    data_file: StrictPath<DataFiles>,
}

fn deserialize_log_file<'de, D>(deserializer: D) 
    -> Result<StrictPath<ConfigFiles>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let config_dir: PathBoundary<ConfigFiles> = 
        PathBoundary::try_new("config").map_err(serde::de::Error::custom)?;
    
    // Use WithBoundary seed to validate during deserialization
    let seed = WithBoundary(&config_dir);
    seed.deserialize(deserializer)
}

fn deserialize_data_file<'de, D>(deserializer: D) 
    -> Result<StrictPath<DataFiles>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let data_dir: PathBoundary<DataFiles> = 
        PathBoundary::try_new("data").map_err(serde::de::Error::custom)?;
    
    let seed = WithBoundary(&data_dir);
    seed.deserialize(deserializer)
}

fn load_config() -> Result<(), Box<dyn std::error::Error>> {
    let json = r#"{
        "app_name": "MyApp",
        "log_file": "logs/app.log",
        "data_file": "db/app.db"
    }"#;

    let config: AppConfig = serde_json::from_str(json)?;
    
    println!("App: {}", config.app_name);
    println!("Log file: {}", config.log_file.strictpath_display());
    println!("Data file: {}", config.data_file.strictpath_display());

    // Try loading config with hostile paths
    let evil_json = r#"{
        "app_name": "EvilApp",
        "log_file": "../../../etc/passwd",
        "data_file": "db/app.db"
    }"#;

    match serde_json::from_str::<AppConfig>(evil_json) {
        Ok(_) => println!("❌ Hostile config accepted!"),
        Err(e) => println!("✅ Hostile config rejected: {}", e),
    }

    Ok(())
}
}

Key point: Deserialization validates paths through boundaries — untrusted config files can't escape!

Combining Features: Real-World Application

Here's how you'd combine multiple features in a real application:

use strict_path::{PathBoundary, VirtualRoot};
use tempfile::TempDir;

struct AppConfig;
struct AppLogs;
struct UserFiles;
struct TempProcessing;

struct Application {
    config_dir: PathBoundary<AppConfig>,
    logs_dir: PathBoundary<AppLogs>,
    user_files_root: VirtualRoot<UserFiles>,
}

impl Application {
    fn new(user_id: u64) -> Result<Self, Box<dyn std::error::Error>> {
        // OS-specific config directory
        let config_dir = PathBoundary::try_new_os_config("myapp")?;
        
        // App-specific log directory (with env override support)
        let logs_dir = PathBoundary::try_new_app_path("logs", None)?;
        
        // Per-user virtual root for file isolation
        let user_storage = format!("users/user_{}", user_id);
        let user_files_root = VirtualRoot::try_new_create(user_storage)?;

        Ok(Self {
            config_dir,
            logs_dir,
            user_files_root,
        })
    }

    fn load_config(&self, config_name: &str) -> Result<String, Box<dyn std::error::Error>> {
        let config_file = self.config_dir.strict_join(config_name)?;
        Ok(config_file.read_to_string()?)
    }

    fn log_event(&self, message: &str) -> Result<(), Box<dyn std::error::Error>> {
        let log_file = self.logs_dir.strict_join("app.log")?;
        let mut log = log_file.read_to_string().unwrap_or_default();
        log.push_str(message);
        log.push('\n');
        log_file.write(log.as_bytes())?;
        Ok(())
    }

    fn process_user_file(&self, filename: &str) 
        -> Result<String, Box<dyn std::error::Error>> 
    {
        // Use temp directory for processing
        let temp = TempDir::new()?;
        let temp_dir: PathBoundary<TempProcessing> = 
            PathBoundary::try_new(temp.path())?;

        // Get user file (virtual path)
        let user_file = self.user_files_root.virtual_join(filename)?;
        let data = user_file.read()?;

        // Process in temp directory
        let temp_file = temp_dir.strict_join("processing.tmp")?;
        temp_file.write(&data)?;

        // Log the operation
        self.log_event(&format!("Processed file: {}", filename))?;

        // Return result
        Ok(format!("Processed {} bytes", data.len()))
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = Application::new(123)?;
    
    // Load config from OS-specific directory
    let config = app.load_config("settings.toml").unwrap_or_default();
    println!("Config: {}", config);

    // Process user file using temp directory
    let result = app.process_user_file("documents/report.pdf")?;
    println!("Result: {}", result);

    Ok(())
}

Key Takeaways

dirs feature — OS-specific user directories (config, downloads, documents, etc.)
tempfile feature — RAII temp directories with boundary enforcement
app-path feature — Portable app paths with env override support
serde feature — Safe deserialization with automatic validation
Combine features — Build real-world apps with ecosystem integration
Boundaries everywhere — Even external directories enforce security

The Final Complete Guarantee

By combining all stages, you achieve:

  1. ✅ Paths cannot escape boundaries (Stage 1)
  2. ✅ Paths are in the correct domain (Stage 3)
  3. ✅ Authorization proven by compiler (Stage 4)
  4. ✅ Clean virtual UX for users (Stage 5)
  5. ✅ Ecosystem integration with safety (Stage 6)

All enforced at compile time with zero runtime overhead.

Feature Combinations

Features can be combined as needed:

[dependencies]
strict-path = { 
    version = "0.1.0-beta.2", 
    features = ["dirs", "serde", "tempfile", "app-path"] 
}

All combinations work seamlessly together - choose the features your application needs.

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

Congratulations! 🎉

You've completed the full tutorial! You now understand:

  • ✅ How StrictPath prevents path escapes
  • ✅ How markers prevent domain mix-ups
  • ✅ How change_marker() encodes authorization
  • ✅ How VirtualPath provides user-friendly sandboxing
  • ✅ How features integrate with the Rust ecosystem

What's Next?

Explore these resources to deepen your knowledge:

You're ready to build secure systems! 🚀


Quick Reference Card:

#![allow(unused)]
fn main() {
// OS directories
let config = PathBoundary::<MyConfig>::try_new_os_config("app")?;
let downloads = PathBoundary::<Downloads>::try_new_os_downloads()?;

// Temp directories
let temp = TempDir::new()?;
let work = PathBoundary::<Work>::try_new(temp.path())?;

// App paths (with env override)
let logs = PathBoundary::<Logs>::try_new_app_path("logs", None)?;

// Serde validation
#[serde(deserialize_with = "deserialize_with_boundary")]
log_file: StrictPath<ConfigFiles>
}

← Back to Tutorial Overview

Type-System Guarantees in Signatures

One of strict-path's most powerful features is its marker type system that lets you encode domain-specific path guarantees in function signatures. This makes incorrect path usage a compile-time error rather than a runtime vulnerability.

What Are Markers?

A marker is a zero-cost type parameter that describes what a path contains or how it should be used. Markers have no runtime representation - they exist purely to help the type system prevent mistakes.

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

// Define markers for different content domains
struct PublicAssets;   // CSS, JS, images for website
struct UserUploads;    // Documents uploaded by users  
struct TempFiles;      // Temporary processing files
struct ConfigFiles;    // Application configuration

// Use markers to create domain-specific boundaries
let public_assets_dir: PathBoundary<PublicAssets> = PathBoundary::try_new("static")?;
let user_uploads_dir: PathBoundary<UserUploads> = PathBoundary::try_new("uploads")?;
let temp_files_dir: PathBoundary<TempFiles> = PathBoundary::try_new("temp")?;
let app_config_dir: PathBoundary<ConfigFiles> = PathBoundary::try_new("config")?;

// Join paths with their appropriate markers
let css_file: StrictPath<PublicAssets> = public_assets_dir.strict_join("style.css")?;
let user_doc: StrictPath<UserUploads> = user_uploads_dir.strict_join("report.pdf")?;
let temp_file: StrictPath<TempFiles> = temp_files_dir.strict_join("processing.tmp")?;
let app_config: StrictPath<ConfigFiles> = app_config_dir.strict_join("settings.json")?;
}

Why Markers Matter

Without markers, it's easy to accidentally mix up different types of paths:

#![allow(unused)]
fn main() {
// Without markers - dangerous mix-ups are possible
fn process_user_upload(file_path: &StrictPath) -> io::Result<()> {
    // Is this a user file? Config file? Temp file? 
    // No way to know from the type!
    file_path.read_to_string()
}

// With markers - impossible to mix up domains  
fn process_user_upload(user_file: &StrictPath<UserUploads>) -> io::Result<String> {
    // Clear: this function ONLY processes user uploads
    user_file.read_to_string() 
}

fn load_app_config(config_file: &StrictPath<ConfigFiles>) -> io::Result<AppConfig> {
    // Clear: this function ONLY loads config files
    let content = config_file.read_to_string()?;
    serde_json::from_str(&content)
}
}

Compile-Time Safety

With markers, the compiler prevents domain mix-ups:

#![allow(unused)]
fn main() {
let user_doc: StrictPath<UserUploads> = user_uploads_dir.strict_join("report.pdf")?;
let app_config: StrictPath<ConfigFiles> = app_config_dir.strict_join("settings.json")?;

// ✅ These work - correct marker types
process_user_upload(&user_doc)?;
load_app_config(&app_config)?;

// ❌ These are compile errors - marker type mismatch!
// process_user_upload(&app_config)?;  // Can't pass ConfigFiles to UserUploads function
// load_app_config(&user_doc)?;        // Can't pass UserUploads to ConfigFiles function
}

The power: If your code compiles, you know you're not accidentally processing config files as user uploads, or serving user uploads as public assets!

Function Signature Patterns

Pattern 1: Accept Validated Paths

When the caller has already validated the path, accept the typed path directly:

#![allow(unused)]
fn main() {
fn serve_public_asset(asset: &StrictPath<PublicAssets>) -> io::Result<Vec<u8>> {
    // Caller proved this is a public asset - we can serve it safely
    asset.read()
}

fn delete_user_file(user_file: &StrictPath<UserUploads>) -> io::Result<()> {
    // Caller proved this is a user upload - safe to delete
    user_file.remove_file()
}

fn backup_config(config_file: &StrictPath<ConfigFiles>, backup_dir: &StrictPath<BackupStorage>) -> io::Result<()> {
    let content = config_file.read()?;
    let backup_name = format!("config-{}.json", chrono::Utc::now().format("%Y%m%d"));
    let backup_path = backup_dir.strict_join(&backup_name)?;
    backup_path.write(content)
}
}

Pattern 2: Validate Inside Helper

When the helper needs to validate user input, accept the boundary plus untrusted segment:

#![allow(unused)]
fn main() {
fn save_user_upload(
    uploads_dir: &PathBoundary<UserUploads>, 
    filename: &str,  // untrusted input
    content: &[u8]
) -> io::Result<()> {
    // Validate the untrusted filename
    let user_file = uploads_dir.strict_join(filename)?;
    user_file.create_parent_dir_all()?;
    user_file.write(content)
}

fn load_config_by_name(
    config_dir: &PathBoundary<ConfigFiles>, 
    config_name: &str  // untrusted input
) -> io::Result<serde_json::Value> {
    // Validate the untrusted config name
    let config_file = config_dir.strict_join(config_name)?; 
    let content = config_file.read_to_string()?;
    serde_json::from_str(&content).map_err(Into::into)
}
}

Pattern 3: Multiple Domain Access

Some functions need to work with multiple domains - use multiple parameters:

#![allow(unused)]
fn main() {
fn generate_report(
    user_data: &StrictPath<UserUploads>,
    template: &StrictPath<PublicAssets>, 
    temp_reports_dir: &PathBoundary<TempFiles>
) -> io::Result<StrictPath<TempFiles>> {
    let data = user_data.read_to_string()?;
    let template_content = template.read_to_string()?;
    
    // Process data with template...
    let report = process_template(&template_content, &data);
    
    let report_file = temp_reports_dir.strict_join("report.html")?;
    report_file.write(report)?;
    Ok(report_file)
}
}

Marker Best Practices

Use Descriptive Domain Names

#![allow(unused)]
fn main() {
// ✅ Good - describes what the paths contain
struct UserHomes;
struct ProductImages; 
struct DatabaseBackups;
struct AuditLogs;

// ❌ Avoid - generic or implementation-focused names
struct Files;
struct Directory;
struct Database;
struct Secure;
}

Create Markers for Business Domains

#![allow(unused)]
fn main() {
// ✅ Good - matches your application's business logic
struct CustomerDocuments;
struct FinancialReports;
struct ProductCatalog; 
struct EmployeeRecords;
struct MarketingAssets;

// ❌ Avoid - technical implementation details as primary markers
struct JsonFiles;
struct ReadOnlyData;
struct EncryptedStorage;
}

Use Meaningful Function Names

#![allow(unused)]
fn main() {
// ✅ Good - function names explain business intent
fn archive_customer_document(doc: &StrictPath<CustomerDocuments>) -> io::Result<()> { ... }
fn publish_marketing_asset(asset: &StrictPath<MarketingAssets>) -> io::Result<()> { ... }
fn audit_financial_report(report: &StrictPath<FinancialReports>) -> io::Result<()> { ... }

// ❌ Avoid - generic names that don't explain purpose  
fn process_file(path: &StrictPath<SomeMarker>) -> io::Result<()> { ... }
fn handle_data(path: &StrictPath<SomeMarker>) -> io::Result<()> { ... }
}

Advanced Marker Patterns

Hierarchical Markers

You can create marker hierarchies for more sophisticated type safety:

#![allow(unused)]
fn main() {
struct MediaFiles;
struct Images;
struct Videos;
struct Documents;

// Use PhantomData for hierarchical relationships
struct MediaFile<T>(std::marker::PhantomData<T>);

type ImageFile = MediaFile<Images>;
type VideoFile = MediaFile<Videos>;
type DocumentFile = MediaFile<Documents>;

fn process_image(img: &StrictPath<ImageFile>) -> io::Result<()> { ... }
fn process_video(vid: &StrictPath<VideoFile>) -> io::Result<()> { ... }
fn process_document(doc: &StrictPath<DocumentFile>) -> io::Result<()> { ... }
}

Environment-Specific Markers

#![allow(unused)]
fn main() {
struct Production;
struct Staging;  
struct Development;

struct ConfigFile<Env>(std::marker::PhantomData<Env>);

type ProdConfig = ConfigFile<Production>;
type StagingConfig = ConfigFile<Staging>;
type DevConfig = ConfigFile<Development>;

fn deploy_to_production(config: &StrictPath<ProdConfig>) -> io::Result<()> {
    // Only production configs can be deployed to production
    apply_production_config(config)
}
}

Integration with Serde

When deserializing paths from configuration, you still need runtime validation:

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize)]
struct AppConfig {
    upload_directory: String,  // Raw path from config
    static_directory: String,  // Raw path from config
}

fn load_app_config() -> Result<(PathBoundary<UserUploads>, PathBoundary<PublicAssets>), ConfigError> {
    let config: AppConfig = serde_json::from_str(&config_json)?;
    
    // Validate raw config paths into typed boundaries
    let user_uploads_dir = PathBoundary::<UserUploads>::try_new_create(&config.upload_directory)?;
    let public_assets_dir = PathBoundary::<PublicAssets>::try_new_create(&config.static_directory)?;
    
    Ok((user_uploads_dir, public_assets_dir))
}
}

Zero Runtime Cost

It's important to understand that markers are zero-cost abstractions:

#![allow(unused)]
fn main() {
// These have identical runtime performance
let generic_path: StrictPath = some_root.strict_join("file.txt")?;
let typed_path: StrictPath<UserUploads> = user_uploads_dir.strict_join("file.txt")?;

// The marker information is erased at compile time
assert_eq!(
    std::mem::size_of::<StrictPath>(), 
    std::mem::size_of::<StrictPath<UserUploads>>()
);
}

All the type safety benefits come at compile time with no runtime overhead!

One critical difference between StrictPath and VirtualPath is how they handle symlinks:

StrictPath: System Filesystem Semantics

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

// StrictPath follows symlinks and validates targets
let boundary = StrictPath::with_boundary_create("system_root")?;

// If "system_root/config_link" symlinks to "/etc/app.conf":
let config = boundary.strict_join("config_link");
// ❌ Error if target is outside boundary
// ✅ OK if target resolves to path inside boundary
}

Use for: Shared system resources, config files, traditional filesystem operations

VirtualPath: Virtual Filesystem Semantics

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

// VirtualPath clamps absolute symlink targets to virtual root
let vroot = VirtualPath::with_root("user_sandbox")?;

// If "user_sandbox/config_link" symlinks to "/etc/app.conf":
let config = vroot.virtual_join("config_link")?;
// ✅ Always OK - target clamped to "user_sandbox/etc/app.conf"

println!("Virtual: {}", config.virtualpath_display());
// Output: /etc/app.conf (from user's perspective)

println!("System: {}", config.as_unvirtual().strictpath_display());  
// Output: user_sandbox/etc/app.conf (actually safe!)
}

Use for: Multi-tenant systems, archive extraction, sandboxed environments, container-like semantics

Key Insight: In a virtual filesystem, absolute paths (whether from user input or symlink targets) are always relative to the virtual root. This makes VirtualPath perfect for untrusted inputs where you want graceful containment rather than explicit rejection.

Common Patterns Summary

  1. Validate once, use everywhere: Create typed paths at boundaries, pass typed paths to functions
  2. Encode intent in signatures: Function parameters should clearly show what domains they work with
  3. Separate validation from business logic: Keep path validation separate from file processing
  4. Use meaningful marker names: Markers should describe business domains, not technical implementation
  5. Fail at compile time: Structure your code so domain mix-ups become type errors
  6. Choose the right semantics: Use StrictPath for system operations (validation), VirtualPath for sandboxing (clamping)

The type system becomes your ally in preventing path-related security bugs and logic errors!

How We Achieve Security

This chapter provides a detailed look at the multi-layered security methodology behind strict-path. Rather than relying on simple string validation or ad-hoc checks, we've built a comprehensive defense-in-depth approach that addresses path security from the ground up.

1. Battle-Tested Foundation: soft-canonicalize

Our security starts with soft-canonicalize—a purpose-built path resolution library that has been validated against 19+ globally known path-related CVEs. These CVEs represent years of accumulated attack patterns and edge cases discovered across the software ecosystem.

What it handles:

  • Symlink cycles and complex link chains: Prevents infinite loops and traversal through symbolic links
  • Path resolution consistency: Ensures paths are resolved consistently during validation, reducing some timing-related inconsistencies in path interpretation
  • Platform-specific quirks: Windows 8.3 short names (PROGRA~1), UNC paths, NTFS Alternate Data Streams
  • Encoding tricks: Unicode normalization attacks, case sensitivity issues, and filesystem encoding edge cases
  • Canonicalization edge cases: Proper handling of ., .., multiple slashes, and malformed path components

Why this matters: Most directory traversal vulnerabilities stem from incomplete path resolution. By building on soft-canonicalize, we benefit from systematic validation against years of documented attack vectors that simple string validation would miss.

1.1 Validated Against Real-World CVEs

Our security is not theoretical—it's validated against actual vulnerabilities discovered in production software. Here are specific CVEs that strict-path protects against:

CVE-2025-8088: WinRAR NTFS Alternate Data Streams (ADS) Bypass

Attack: Malicious archives containing paths with NTFS Alternate Data Streams (e.g., file.txt:hidden:$DATA) could bypass security checks and write to arbitrary locations.

How strict-path protects:

  • Canonicalization resolves ADS references to their actual filesystem locations
  • Boundary validation checks the resolved path, not the syntactic path
  • Virtual paths clamp even crafted ADS targets to the boundary
#![allow(unused)]
fn main() {
// Attack attempt: ../../sensitive.doc:stream
let boundary = PathBoundary::try_new("archive_extract")?;
match boundary.strict_join("../../sensitive.doc:stream") {
    Ok(_) => unreachable!("Never succeeds"),
    Err(e) => println!("🛡️ ADS attack blocked: {e}"),
}
}

CVE-2022-21658: Rust Cargo TOCTOU (Time-of-Check-Time-of-Use)

Attack: Race condition where a path is validated, then a symlink is created before the path is used, allowing escape.

How strict-path protects:

  • Canonicalization resolves symlinks at validation time
  • The validated StrictPath carries the resolved target
  • No TOCTOU window between validation and use
#![allow(unused)]
fn main() {
// Validation and resolution happen atomically
let boundary = PathBoundary::try_new("workspace")?;
let safe_path = boundary.strict_join("config.toml")?; // Resolves symlinks NOW
safe_path.read_to_string()?; // Uses already-resolved path, no race
}

CVE-2019-9855: LibreOffice Windows 8.3 Short Name Bypass

Attack: Windows 8.3 short names (e.g., PROGRA~1 for Program Files) could bypass path validation that only checked long-form names.

How strict-path protects:

  • Canonicalization automatically expands Windows 8.3 short names to their long forms
  • Boundary checking operates on the canonical form
  • Mathematical proof: canonical path within canonical boundary = secure
#![allow(unused)]
fn main() {
// Attack attempt using short name: ../PROGRA~1/system.dll
let boundary = PathBoundary::try_new("C:/Users/Alice/Documents")?;
match boundary.strict_join("../PROGRA~1/system.dll") {
    Ok(_) => unreachable!("Short name attack blocked"),
    Err(e) => println!("🛡️ 8.3 attack blocked: {e}"),
}
}

CVE-2018-1002200: Zip Slip (Archive Path Traversal)

Attack: Malicious archives containing entries with ../../ in filenames to write outside extraction directory.

How strict-path protects:

  • Every archive entry path must pass through strict_join() validation
  • Traversal attempts return Err(PathEscapesBoundary)
  • Extraction loop fails immediately on malicious entry
#![allow(unused)]
fn main() {
// Safe archive extraction
let extract_dir = PathBoundary::try_new_create("./extracted")?;

for entry in archive.entries()? {
    let entry_path = entry.path()?;
    match extract_dir.strict_join(&entry_path) {
        Ok(safe_dest) => {
            safe_dest.create_parent_dir_all()?;
            entry.extract_to(safe_dest.interop_path())?;
        },
        Err(e) => {
            eprintln!("🚨 Malicious archive entry blocked: {}", entry_path.display());
            return Err(e.into());
        }
    }
}
}

Additional CVEs Validated in soft-canonicalize Test Suite

The underlying soft-canonicalize library has been validated against 15+ additional CVEs covering:

  • Unicode normalization attacks - CVE-2008-2938, CVE-2009-0689 (different representations of same path)
  • Null byte injection - CVE-2006-1547 (path truncation attacks)
  • Symlink directory bombs - CVE-2014-8086 (infinite symlink loops)
  • UNC path bypasses - CVE-2010-0442 (Windows extended-length path tricks)
  • Case sensitivity exploits - Various CVEs on case-insensitive filesystems
  • Trailing dot/space bypasses - CVE-2007-2446 (Windows reserved name handling)
  • Device namespace abuse - CVE-2009-2692 (Windows device names like CON, PRN)

1.2 Coverage: What We Protect Against

✅ Comprehensive Protection (99% of attacks):

  • Basic traversal - ../../../etc/passwd, ..\..\Windows\System32
  • Symlink escapes - Links pointing outside boundaries
  • Archive attacks - Zip Slip, TAR traversal, malicious archive extraction
  • Encoding bypasses - Unicode normalization, UTF-8 vs UTF-16, null bytes
  • Windows-specific - 8.3 short names (PROGRA~1), UNC paths (\\?\C:\), NTFS streams (:$DATA)
  • Race conditions - TOCTOU during path resolution
  • Symlink cycles - Infinite loop protection with bounded depth tracking
  • Platform quirks - Mixed separators, case sensitivity, trailing dots/spaces
  • Path length limits - Windows MAX_PATH (260) handling

⚠️ Requires System-Level Privileges (1% edge cases):

  • Hard links - Creating hard links to files outside boundary (requires admin/root)
  • Mount points - Mounting new filesystems (requires admin/root)

Bottom Line: strict-path stops 99% of practical path traversal attacks without requiring elevated privileges. The 1% that require system-level access are mitigated by OS-level security (users can't create hard links or mount points without admin rights).

1.3 Continuous Security Validation

Our security validation is ongoing:

  • Monitor new CVE disclosures for path-related vulnerabilities
  • Reproduce attacks in our test suite to verify protection
  • Adapt defenses as new attack patterns emerge
  • Contribute findings to soft-canonicalize for ecosystem-wide benefit

Security is not a one-time achievement—it's a continuous process of adaptation and improvement.


2. Secure API Design

Our API design is built around the principle that security should be the easiest path forward. Every design decision prioritizes preventing misuse over convenience.

2.1 LLM Agent-Aware Design

Modern threats include AI agents processing untrusted paths from various sources. Our API is designed specifically for this threat model:

  • Clear validation points: strict_join() and virtual_join() make validation explicit and visible
  • LLM-friendly documentation: Complete parameter documentation and usage examples specifically for AI consumption
  • Fail-safe defaults: Operations fail closed rather than permitting potentially dangerous paths
  • Explicit interop boundaries: .interop_path() makes filesystem handoffs to third-party code obvious

2.2 Minimal API Surface for Minimal Error Margin

We deliberately limit our public API surface to reduce the possibility of misuse:

  • No leaky trait implementations: No AsRef<Path>, Deref<Target = Path>, or implicit conversions that bypass validation
  • Controlled constructors: Only specific, well-audited entry points for creating secure path types
  • Helper API restrictions: New public functions require explicit maintainer approval to prevent API drift
  • Dimension separation: Strict and virtual paths have separate, non-interchangeable operations

2.3 Explicit Methods That Make Logic Errors Visible

Our method names are designed to make security-relevant operations obvious during code review:

#![allow(unused)]
fn main() {
// ❌ Unclear security implications
path.join(user_input)

// ✅ Security implications clear at a glance
boundary.strict_join(user_input)?
vroot.virtual_join(user_input)?
}

Key principles:

  • Verbose over clever: strict_join() instead of join() makes the security operation explicit
  • Dimension-specific operations: strictpath_display() vs virtualpath_display() prevent confusion
  • No hidden validation: Every path that enters the system must go through an explicit validation step

2.4 Rust Type System for Mathematical Correctness

We leverage Rust's type system to provide compile-time guarantees about path security:

  • Marker types prevent confusion: StrictPath<UserUploads> vs StrictPath<SystemConfig> prevent accidentally mixing boundaries
  • Borrowing prevents mutation: Once validated, paths cannot be secretly modified
  • Ownership tracking: The type system ensures validated paths aren't leaked or corrupted
  • Zero-cost abstractions: Security guarantees come at compile time, not runtime

2.5 Distinct Types for "Hard to Get Wrong" Approach

Different use cases get different types with appropriate guarantees:

  • PathBoundary<Marker>: For creating and managing restriction policies
  • StrictPath<Marker>: For paths that must stay within boundaries (fails on violations)
  • VirtualRoot<Marker>: For creating virtual filesystem views
  • VirtualPath<Marker>: For virtual paths that clamp to safe boundaries
  • StrictPathError: Comprehensive error handling for all failure modes
  • Safe builtin I/O operations: Direct filesystem operations that bypass the need for .interop_path() calls

2.6 Type System-Enforced Authorization

The marker system enables compile-time authorization guarantees:

#![allow(unused)]
fn main() {
// Authorization proof required to construct the marker
struct SecureDocuments;
impl SecureDocuments {
    fn new(auth_token: ValidatedAdminToken) -> Self { Self }
}

// Type system ensures authorization happened
fn access_secure_file(path: &StrictPath<SecureDocuments>) -> Result<String> {
    path.read_to_string() // Compiler guarantees authorization
}
}

2.7 Safe Builtin I/O Operations

A critical security feature is our comprehensive suite of safe I/O operations that eliminate the need to escape to unsafe std::fs calls for routine work. The APIs mirror the semantics and return values of the standard library while preserving boundary guarantees.

File operations:

  • read_to_string(), read() — Read file contents
  • write<C: AsRef<[u8]>>() — Write bytes (e.g., &str, &[u8])
  • create_file(), open_file() — Obtain file handles
  • remove_file() — Delete files

Directory operations:

  • create_dir(), create_dir_all() — Create directories
  • read_dir() — Iterate directory entries (discover names; re-join through strict/virtual APIs)
  • metadata() — Access filesystem metadata
  • remove_dir(), remove_dir_all() — Delete directories

Move/Copy operations (dimension-specific):

  • StrictPath::strict_rename(..) / VirtualPath::virtual_rename(..) — Rename/move within the restriction
  • StrictPath::strict_copy(..) / VirtualPath::virtual_copy(..) — Copy within the restriction (returns bytes copied)

Links (creation):

  • StrictPath::strict_symlink(..) / VirtualPath::virtual_symlink(..) — Create symlinks within the same restriction
  • StrictPath::strict_hard_link(..) / VirtualPath::virtual_hard_link(..) — Create hard links (subject to platform constraints)

Note: We intentionally do not expose separate helpers for "symlink metadata" or standalone canonicalization. When you must interoperate with APIs that require AsRef<Path> or specific OS semantics, use .interop_path() to get the validated path as &OsStr and keep such calls isolated to interop boundaries.

Why this matters: By providing safe alternatives to common std::fs operations, we eliminate the need for .interop_path() in routine file work, keeping the API surface focused on validated operations while still enabling necessary third‑party integrations.

3. Active CVE Research and Validation

We maintain a systematic approach to understanding and defending against path-related vulnerabilities:

Research activities:

  • CVE database analysis: Study of documented path-related vulnerabilities across software ecosystems
  • Security advisory analysis: Analysis of how attacks work and why existing solutions failed
  • Historical attack validation: Testing our defenses against known attack patterns
  • Comparative analysis: Study of similar libraries and their security approaches

Validation process:

  • Attack patterns are tested against our validation logic during development
  • Gaps identified in research inform security improvements
  • Security enhancements are implemented with careful consideration of compatibility
  • Relevant findings contribute to the broader security community understanding

4. Open-Source Transparency for Rapid Issue Detection

Security through obscurity is not security at all. Our open-source approach enables:

Community validation:

  • Expert review: Security researchers can audit our implementation
  • Diverse testing: Community members test on platforms and use cases we haven't considered
  • Collaborative bug reporting: Issues are tracked and addressed openly through GitHub
  • Collaborative improvement: Security enhancements come from the community as well as maintainers

Transparency benefits:

  • No hidden vulnerabilities: All code paths are visible for audit
  • Public issue tracking: Security concerns are discussed openly
  • Reproducible security: Anyone can verify our claims by reading the code
  • Trust through verification: Don't trust our claims—verify them yourself

5. Pseudo Projects for API Effectiveness Testing

We maintain a suite of realistic demo projects that test our API in real-world scenarios:

Demo categories:

  • Web servers: File upload handlers, static asset serving, user content management
  • CLI tools: File processors, archive extractors, configuration managers
  • LLM agents: AI-driven file operations, automated code generation
  • Archive handling: ZIP extraction, tar processing, backup restoration
  • Configuration systems: Multi-environment config loading, user preference handling

Testing methodology:

  • Production authenticity: Demos use real protocols and official ecosystem crates
  • Security integration patterns: Each demo shows correct validation flow
  • Failure mode testing: Demos include examples of rejected hostile inputs
  • Performance validation: Real-world load testing of validation logic

Why this matters: APIs that work perfectly in isolation often fail when integrated into real systems. Our demos catch integration issues, performance problems, and usability gaps that unit tests miss.

6. Security Testing and Validation

We employ comprehensive testing methodologies to validate our security approach:

6.1 Black-Box Testing

Automated fuzzing:

  • Random path generation across all Unicode ranges
  • Platform-specific attack vectors (Windows short names, Unix special files)
  • Encoding attack patterns (mixed encodings, normalization attacks)
  • Length-based attacks (extremely long paths, empty components)

LLM-assisted testing:

  • AI-generated attack patterns: Using advanced LLMs to generate potential bypass attempts
  • Reasoning model validation: Employing reasoning models to explore attack vectors
  • Multi-model consensus: Cross-validating security assumptions across different AI models
  • Systematic attack exploration: Multi-step validation approaches that build complexity

6.2 White-Box Testing

Code analysis:

  • Control flow analysis: Mapping all possible execution paths through validation logic
  • State space exploration: Testing all combinations of internal validation states
  • Boundary condition testing: Edge cases in canonicalization, length limits, character handling
  • Race condition simulation: Concurrent access patterns and filesystem state changes

Architecture review:

  • Trust boundary analysis: Verifying that security boundaries are correctly enforced
  • Assumption validation: Testing that our security assumptions hold under all conditions
  • Integration point review: Ensuring third-party integrations don't introduce vulnerabilities

6.3 Security Validation Process

Testing results inform our ongoing development:

  • Successful attacks become test cases and drive security improvements
  • Failed attacks validate our defenses and expand our test coverage
  • Novel attack vectors contribute to the security community's understanding
  • Performance characteristics of attacks inform our optimization decisions

7. Comprehensive Test Suite

Our testing strategy covers multiple layers of validation:

7.1 Unit Testing

Core logic validation:

  • Every public function has comprehensive test coverage
  • Edge cases and boundary conditions are explicitly tested
  • Platform-specific behavior is validated on all supported systems
  • Error conditions are tested to ensure proper failure modes

7.2 Integration Testing

Real-world scenario testing:

  • Full end-to-end flows from untrusted input to filesystem operations
  • Cross-platform compatibility validation
  • Third-party integration testing with common ecosystem crates
  • Performance testing under realistic load conditions

7.3 Property-Based Testing

Automated verification:

  • QuickCheck-style property validation for core invariants
  • Fuzzing with structured inputs to explore edge cases
  • Shrinking of failing test cases to minimal reproduction examples
  • Statistical validation of security properties across large input spaces

7.4 Security-Focused Testing

Attack simulation:

  • Known CVE reproduction tests to ensure we block historical attacks
  • Platform-specific security tests (Windows short names, Unix symlinks)
  • Encoding and normalization attack tests
  • Filesystem race condition simulations

7.5 Continuous Testing

Automated validation:

  • CI/CD pipeline runs full test suite on every change
  • Multiple platform testing (Windows, Linux, macOS)
  • MSRV (Minimum Supported Rust Version) compatibility validation
  • Performance regression detection

Security Is a Process, Not a Product

Our security methodology recognizes that security is an ongoing commitment rather than a one-time achievement. We are committed to:

  • Monitor for new attack vectors and vulnerability patterns
  • Adapt our defenses as the threat landscape evolves
  • Learn from security incidents in the broader ecosystem
  • Improve our methods based on real-world feedback and usage
  • Contribute our knowledge to the security community

The result is a library designed not only to address known path security issues but to evolve and adapt as new threats emerge. By building security into every layer—from the foundational libraries through the API design to the testing methodology—we provide comprehensive protection against the entire class of path traversal vulnerabilities.

Remember: Path security isn't just about blocking ../../../etc/passwd. It's about creating a robust defense against all the ways that untrusted paths can be crafted to bypass your security controls. That's what strict-path delivers.

Examples by Mode

Quick visual guide: When to use StrictPath vs VirtualPath vs Path/PathBuf

This page provides quick, at-a-glance examples showing when to use each path type. For detailed guidance and decision matrices, see Best Practices: Security Philosophy.


🌐 VirtualPath - User Sandboxes & Multi-Tenant Systems

Philosophy: "Let things try to escape, but silently contain them"

Use when: Path escapes are expected but must be controlled

Archive Extraction (Safe Sandboxing)

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

// Extract ZIP files - hostile names get clamped, not rejected
let extract_root = VirtualPath::with_root_create("./extracted")?;

for entry_name in zip_entries {
    // "../../../etc/passwd" → "/etc/passwd" (safely clamped)
    let safe_path = extract_root.virtual_join(entry_name)?;
    safe_path.create_parent_dir_all()?;
    safe_path.write(entry.data())?; // Always safe within boundary
}
}

Multi-Tenant Cloud Storage

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

// Each user gets their own isolated filesystem view
let user_storage = VirtualPath::with_root_create(format!("storage/user_{user_id}"))?;

// User sees: "/documents/report.pdf"
// Actually stored: "./storage/user_42/documents/report.pdf"
let user_file = user_storage.virtual_join("documents/report.pdf")?;
user_file.write(uploaded_data)?;

// Show user-friendly paths
println!("Saved: {}", user_file.virtualpath_display()); // "/documents/report.pdf"
}

Key behavior:

  • ✅ Escape attempts are silently clamped to stay within boundary
  • ✅ Users see clean rooted paths (/file.txt) hiding real system structure
  • ✅ Symlinks to absolute paths are clamped to virtual root
  • 🎯 Perfect for: Multi-tenant systems, sandboxes, user isolation

Requires feature: virtual-path in Cargo.toml


⚔️ StrictPath - Security Boundaries & System Resources

Philosophy: "If something tries to escape, I want to know about it"

Use when: Path escapes indicate malicious intent

LLM Agent File Operations

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// LLM generates file operations - validate everything
let workspace = PathBoundary::try_new_create("./agent_workspace")?;

let ai_request = llm.generate_filename(); // Could be ANYTHING
match workspace.strict_join(ai_request) {
    Ok(safe_path) => {
        safe_path.write(&ai_content)?;
        println!("✅ Saved: {}", safe_path.strictpath_display());
    },
    Err(e) => {
        eprintln!("🚨 Attack blocked: {e}");
        // Log the attack, alert security team
    }
}
}

File Upload Validation

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

struct UserUploads;

// Validate user-provided filenames
fn handle_upload(
    uploads_dir: &PathBoundary<UserUploads>,
    filename: &str,
    data: &[u8]
) -> Result<(), Box<dyn std::error::Error>> {
    // Reject malicious filenames like "../../../etc/passwd"
    let safe_file = uploads_dir.strict_join(filename)?; // Returns Err on escape
    safe_file.write(data)?;
    Ok(())
}
}

Configuration File Loading

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// Load config files - reject traversal attempts
let config_dir = PathBoundary::try_new("./config")?;

match config_dir.strict_join(user_selected_config) {
    Ok(config_file) => {
        let content = config_file.read_to_string()?;
        // Parse and use safely
    },
    Err(_) => {
        // User tried to load "../../../etc/passwd" - reject!
        return Err("Invalid config file path");
    }
}
}

Key behavior:

  • ✅ Escape attempts return Err(PathEscapesBoundary)
  • ✅ Application can detect attacks, log, alert, and reject
  • ✅ Symlinks outside boundary return Error
  • 🎯 Perfect for: Archive extraction, file uploads, config loading, system resources

No feature required - always available


🔓 Path/PathBuf - Controlled/Known Paths

Philosophy: "I created this path, I control it"

Use when: You control the path source (hardcoded, generated by your code)

Application-Generated Paths

#![allow(unused)]
fn main() {
use std::path::{Path, PathBuf};

// ✅ SAFE - You control the timestamp
let log_file = PathBuf::from(format!("logs/app-{timestamp}.log"));
std::fs::write(&log_file, log_data)?;

// ✅ SAFE - Hardcoded path
let schema_file = Path::new("config/db-schema.sql");
let schema = std::fs::read_to_string(schema_file)?;

// ✅ SAFE - Environment variable from trusted source
let config_dir = std::env::var("APP_CONFIG_DIR")?;
let config = PathBuf::from(config_dir).join("settings.toml");
}

❌ NEVER with External Input

#![allow(unused)]
fn main() {
use std::path::Path;

// 🚨 DISASTER - User input directly to Path
let user_file = Path::new(user_input); // user_input = "../../../etc/passwd"
std::fs::write(user_file, data)?; // System compromised!

// ✅ CORRECT - Validate first with StrictPath
let uploads = PathBoundary::try_new("uploads")?;
let safe_file = uploads.strict_join(user_input)?; // Attack rejected
safe_file.write(data)?;
}

Key behavior:

  • ⚠️ No validation - trusts you completely
  • ⚠️ Can escape anywhere on the filesystem
  • 🎯 Perfect for: Hardcoded paths, app-generated filenames, trusted environment variables

Golden Rule: If you didn't create the path yourself, validate it first with StrictPath or VirtualPath!


📊 Quick Comparison Table

FeaturePath/PathBufStrictPathVirtualPath
SecurityNone 💥Validates & rejects ✅Clamps any input ✅
Escape attempts../../../etcSystem breach../../../etcError../../../etc/etc (safely clamped)
Symlink escapeslink -> /etcSystem breachlink -> /etcErrorlink -> /etc/etc (clamped to boundary)
DisplaySystem pathSystem pathVirtual rooted path (/file.txt)
Use caseKnown-safe, controlled pathsSecurity boundaries, detect attacksMulti-tenant isolation, sandboxes
Feature neededAlways availableAlways availableRequires virtual-path feature

🎯 Decision Flowchart

Is the path from external/untrusted input?
│
├─ NO (hardcoded/app-generated) ──> Path/PathBuf
│
└─ YES (user/config/LLM/archive) ──> Validate first!
    │
    ├─ Need to DETECT escape attempts? ──> StrictPath
    │   (file uploads, config, LLM agents)
    │
    └─ Need to CONTAIN escape attempts? ──> VirtualPath
        (multi-tenant, sandboxes, user isolation)

📚 Learn More

Real-World Examples

This section 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.

📚 Example Categories

Web Applications

Application Development

Security-Critical Operations

🎯 Common Patterns

All examples follow the same security pattern:

  1. Create a boundary - Define your safe area with PathBoundary or VirtualRoot
  2. Validate external input - Always use strict_join() or virtual_join() for untrusted paths
  3. Use safe types - Operate through StrictPath or VirtualPath for all file operations
  4. Let the compiler help - Type signatures encode security guarantees

🔐 What Makes These Secure?

  • No path escapes - Users can't use ../ or absolute paths to escape boundaries
  • Compile-time safety - Wrong marker types won't compile
  • Clear interfaces - Function signatures document what paths they accept
  • Maintainable - Security isn't something to remember, it's in the type system

💡 Using These Examples

Each example is:

  • Complete - Includes all necessary imports and error handling
  • Runnable - Copy-paste and adapt to your needs
  • Explained - Comments highlight security patterns and key concepts
  • Battle-tested - Shows real attack vectors that are automatically blocked

Choose an example that matches your use case and start building secure applications!

Web File Upload Service

Let's build a simple file upload service that allows users to upload files safely. This example demonstrates per-user isolation using VirtualRoot.

The Problem

Web applications need to accept file uploads from users, but must prevent:

  • ❌ Path traversal attacks (../../../etc/passwd)
  • ❌ Users accessing other users' files
  • ❌ Absolute path injections (/var/www/html/shell.php)

The Solution

Use VirtualRoot to create isolated storage for each user. Each user operates in their own sandboxed environment.

Complete Example

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_uploads_root: &VirtualRoot,
        upload_file_name: &str,
        upload_file_content: &[u8],
    ) -> Result<VirtualPath, Box<dyn std::error::Error>> {
        // Validate the untrusted filename at the user's virtual root
        let uploaded_file: VirtualPath = user_uploads_root.virtual_join(upload_file_name)?;
        // Reuse strict-typed helper when needed
        self.save_uploaded(uploaded_file.as_unvirtual(), upload_file_content)?;
        println!("✅ File uploaded safely to: {}", uploaded_file.virtualpath_display());
        Ok(uploaded_file)
    }

    // Internal helper: signature encodes guarantee (accepts only &StrictPath)
    fn save_uploaded(&self, file: &StrictPath, content: &[u8]) -> io::Result<()> {
        file.create_parent_dir_all()?;
        file.write(content)
    }

    fn list_files(
        &self,
        user_uploads_root: &VirtualRoot,
    ) -> Result<Vec<VirtualPath>, Box<dyn std::error::Error>> {
        let mut files = Vec::new();
        for entry in user_uploads_root.read_dir()? {
            let entry = entry?;
            if entry.file_type()?.is_file() {
                let file: VirtualPath = user_uploads_root.virtual_join(entry.file_name())?;
                files.push(file);
            }
        }
        Ok(files)
    }

    fn download_file(&self, file: &VirtualPath) -> io::Result<Vec<u8>> {
        // Read and return the file content — type ensures safety
        file.read()
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let service = FileUploadService;

    // Per-user virtual roots
    let alice_uploads_root: VirtualRoot = VirtualRoot::try_new_create("user_uploads/alice")?;
    let bob_uploads_root: VirtualRoot = VirtualRoot::try_new_create("user_uploads/bob")?;

    // Simulate user uploads - these are all SAFE and isolated
    service.upload_file(&alice_uploads_root, "document.txt", b"Hello, world!")?;
    service.upload_file(&alice_uploads_root, "reports/january.pdf", b"PDF content here")?;
    service.upload_file(&bob_uploads_root, "images/photo.jpg", b"JPEG data")?;

    // These would be clamped/blocked by validation:
    // service.upload_file(&alice_uploads_root, "../../../etc/passwd", b"attack")?;  // ❌ Blocked!
    // service.upload_file(&alice_uploads_root, "..\\windows\\system32\\evil.exe", b"malware")?;  // ❌ Blocked!

    // List Alice's uploaded files (virtual paths)
    println!("📁 Alice's files:");
    for file in service.list_files(&alice_uploads_root)? {
        println!("  - {}", file.virtualpath_display());
    }

    // Download a file using VirtualPath
    let document_file = alice_uploads_root.virtual_join("document.txt")?;
    let content = service.download_file(&document_file)?;
    println!("📄 Downloaded: {}", String::from_utf8_lossy(&content));

    Ok(())
}

Key Security Features

1. Per-User Isolation

Each user gets their own VirtualRoot. Alice can't access Bob's files and vice versa.

2. Automatic Path Validation

#![allow(unused)]
fn main() {
let uploaded_file = user_uploads_root.virtual_join(upload_file_name)?;
}

This validates the filename and ensures it stays within the user's boundary. Attacks are automatically blocked.

3. Type-Safe Helpers

#![allow(unused)]
fn main() {
fn save_uploaded(&self, file: &StrictPath, content: &[u8]) -> io::Result<()>
}

By accepting &StrictPath, the function signature guarantees the path has been validated.

4. Virtual Path Display

#![allow(unused)]
fn main() {
uploaded_file.virtualpath_display()  // Shows "/document.txt" to the user
uploaded_file.strictpath_display()   // Shows "user_uploads/alice/document.txt" (system path)
}

Users see clean paths starting from /, while the system knows the real location.

Attack Scenarios Prevented

AttackResult
../../../etc/passwd❌ Clamped to user's root
..\\windows\\system32\\evil.exe❌ Clamped to user's root
/var/www/html/shell.php❌ Treated as relative, stays in boundary
alice/../bob/secret.txt❌ Normalized and clamped

Sharing Common Logic

If you need to share logic between strict and virtual paths:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath, VirtualPath, VirtualRoot};
use std::io;

// One helper that works with any marker
fn process_common<M>(file: &StrictPath<M>) -> io::Result<Vec<u8>> {
    file.read()
}

// Prepare one strict file and one virtual file
let public_assets_root = PathBoundary::try_new("./assets")?;
let css_file: StrictPath = public_assets_root.strict_join("style.css")?;

let alice_uploads_root = VirtualRoot::try_new("./uploads/alice")?;
let avatar_file: VirtualPath = alice_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
}

Integration Tips

With Web Frameworks

#![allow(unused)]
fn main() {
// Example with axum/actix-web
async fn upload_handler(
    user_id: String,
    filename: String,
    content: Vec<u8>,
) -> Result<String, AppError> {
    let user_root = get_user_root(&user_id)?;
    let file = user_root.virtual_join(&filename)?;
    file.write(&content)?;
    Ok(file.virtualpath_display().to_string())
}
}

With Async Runtimes

All file operations work with tokio::fs or async-std - just use .interop_path() when needed:

#![allow(unused)]
fn main() {
tokio::fs::write(file.interop_path(), content).await?;
}

Next Steps

Configuration File Manager

Learn how to safely handle user configuration files with automatic path validation and type-safe file operations.

The Problem

Applications need to load and save configuration files, but must prevent:

  • ❌ Users reading system configuration files (../../../etc/shadow)
  • ❌ Writing config files outside the app's config directory
  • ❌ Accidental path injections from corrupted config data

The Solution

Use PathBoundary to create a jail for configuration files. All config operations stay within the boundary.

Complete Example

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(&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 self.config_dir.read_dir()? {
            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(())
}

Key Security Features

1. Bounded Configuration Directory

#![allow(unused)]
fn main() {
let config_dir = PathBoundary::try_new_create("app_config")?;
}

All configuration operations are restricted to this directory.

2. Validated File Names

#![allow(unused)]
fn main() {
let config_path = self.config_dir.strict_join(config_name)?;
}

User-provided config names are validated before any file operation.

3. Safe Returns

#![allow(unused)]
fn main() {
fn save_config(&self, config_name: &str, config: &AppConfig) -> Result<StrictPath, ...>
}

Returning StrictPath ensures callers can only operate on validated paths.

4. Automatic Parent Directory Creation

#![allow(unused)]
fn main() {
config_path.write(&content)?;
}

The safe file operations handle parent directory creation automatically.

Attack Scenarios Prevented

AttackResult
load_config("../../../etc/passwd")❌ Path escape blocked
save_config("/tmp/evil.json", ...)❌ Absolute path blocked
load_config("..\\windows\\system.ini")❌ Path escape blocked

Integration with Serde

For more complex deserialization scenarios, use the serde feature:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath, serde_ext::WithBoundary};
use serde::Deserialize;

#[derive(Deserialize)]
struct AppConfig {
    name: String,
    
    // Deserialize with validation through boundary
    #[serde(deserialize_with = "deserialize_config_file")]
    config_file: StrictPath<ConfigFiles>,
}

fn deserialize_config_file<'de, D>(deserializer: D) -> Result<StrictPath<ConfigFiles>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let boundary = PathBoundary::<ConfigFiles>::try_new("config")?;
    let path_str = String::deserialize(deserializer)?;
    boundary.strict_join(&path_str).map_err(serde::de::Error::custom)
}
}

OS-Specific Config Locations

For platform-specific config directories, use the dirs feature:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn new_with_os_config() -> Result<Self, Box<dyn std::error::Error>> {
    // Uses XDG on Linux, AppData on Windows, etc.
    let config_dir = PathBoundary::try_new_os_config("myapp")?;
    Ok(Self { config_dir })
}
}

See the OS Standard Directories chapter for more details.

Environment Variable Overrides

For deployment flexibility, use the app-path feature:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn new_with_override() -> Result<Self, Box<dyn std::error::Error>> {
    // Checks MYAPP_CONFIG_DIR env var first, falls back to default
    let config_dir = PathBoundary::try_new_app_path("config", Some("MYAPP_CONFIG_DIR"))?;
    Ok(Self { config_dir })
}
}

Best Practices

  1. Store the boundary - Keep PathBoundary as a field in your manager struct
  2. Validate early - Use strict_join() immediately when receiving config names
  3. Return safe types - Functions should return StrictPath instead of raw strings
  4. Handle missing configs - Provide sensible defaults when configs don't exist

Next Steps

Multi-User Document Storage

Build a document storage system where each user feels like they have their own filesystem, complete with directory traversal prevention and user isolation.

The Problem

Multi-user applications need to provide isolated storage where:

  • ❌ Users can't access other users' files
  • ❌ Path traversal attacks don't work
  • ❌ Users see clean paths (like /reports/january.pdf) instead of system paths

The Solution

Use VirtualRoot per user. Each user operates in their own sandboxed environment with clean virtual paths.

Complete Example

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(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(())
}

Key Security Features

1. Lazy User Root Creation

#![allow(unused)]
fn main() {
fn get_user_root(&mut self, username: &str) -> Result<&VirtualRoot, ...>
}

Each user gets their own VirtualRoot created on first access. Users are completely isolated from each other.

2. Virtual Path Display

#![allow(unused)]
fn main() {
doc_path.virtualpath_display()    // Shows: "/reports/quarterly.txt"
doc_path.strictpath_display()     // Shows: "user_data_alice/reports/quarterly.txt"
}

Users see clean paths starting from /, while the system maintains real paths.

3. Automatic Isolation

#![allow(unused)]
fn main() {
store.save_document("charlie", "/../alice/reports/quarterly.txt", "hacked")?;
}

This is automatically blocked because /../alice/... gets clamped to Charlie's root.

4. Cross-User Access Prevention

Even if you try:

#![allow(unused)]
fn main() {
let alice_root = store.get_user_root("alice")?;
let bob_root = store.get_user_root("bob")?;

// These are completely separate - no way to cross boundaries
let alice_doc = alice_root.virtual_join("/secret.txt")?;
let bob_doc = bob_root.virtual_join("/secret.txt")?;

// alice_doc and bob_doc point to different physical files
}

Attack Scenarios Prevented

AttackResult
save_document("alice", "/../bob/data.txt", ...)❌ Clamped to alice's root
save_document("alice", "/../../etc/passwd", ...)❌ Clamped to alice's root
load_document("bob", "/../alice/secret.txt")❌ Clamped to bob's root
Symlink to another user's directory❌ Resolved within boundary

System Path vs Virtual Path

Understanding the difference:

#![allow(unused)]
fn main() {
let alice_root = VirtualRoot::try_new_create("user_data_alice")?;
let doc = alice_root.virtual_join("/reports/january.pdf")?;

// What the user sees:
println!("{}", doc.virtualpath_display());
// Output: /reports/january.pdf

// What the system uses:
println!("{}", doc.as_unvirtual().strictpath_display());
// Output: user_data_alice/reports/january.pdf

// Both point to the same file, just different representations
}

Integration Tips

With Databases

Store virtual paths in the database:

#![allow(unused)]
fn main() {
struct Document {
    id: i64,
    user_id: i64,
    virtual_path: String,  // "/reports/january.pdf"
    created_at: DateTime,
}

// When retrieving:
let user_root = get_user_root(user_id)?;
let doc_path = user_root.virtual_join(&doc.virtual_path)?;
let content = doc_path.read()?;
}

With Web Frameworks

#![allow(unused)]
fn main() {
async fn get_document(
    user_id: String,
    path: String,
) -> Result<Vec<u8>, AppError> {
    let user_root = get_user_root(&user_id)?;
    let doc = user_root.virtual_join(&path)?;
    Ok(doc.read()?)
}

async fn save_document(
    user_id: String,
    path: String,
    content: Vec<u8>,
) -> Result<String, AppError> {
    let user_root = get_user_root(&user_id)?;
    let doc = user_root.virtual_join(&path)?;
    doc.create_parent_dir_all()?;
    doc.write(&content)?;
    Ok(doc.virtualpath_display().to_string())
}
}

With Shared Helpers

Share logic between users by accepting &StrictPath:

#![allow(unused)]
fn main() {
fn analyze_document<M>(path: &StrictPath<M>) -> Result<DocumentStats, Error> {
    let content = path.read_to_string()?;
    Ok(DocumentStats {
        lines: content.lines().count(),
        words: content.split_whitespace().count(),
    })
}

// Works for any user:
let alice_doc = alice_root.virtual_join("/report.txt")?;
let bob_doc = bob_root.virtual_join("/notes.txt")?;

let alice_stats = analyze_document(alice_doc.as_unvirtual())?;
let bob_stats = analyze_document(bob_doc.as_unvirtual())?;
}

Advanced: Quota Management

Track storage per user:

#![allow(unused)]
fn main() {
impl DocumentStore {
    fn get_user_storage_size(&self, username: &str) -> Result<u64, Box<dyn std::error::Error>> {
        let user_root = self.user_roots.get(username)
            .ok_or("User not found")?;
        
        let mut total_size = 0u64;
        for entry in walkdir::WalkDir::new(user_root.interop_path()) {
            let entry = entry?;
            if entry.file_type().is_file() {
                total_size += entry.metadata()?.len();
            }
        }
        
        Ok(total_size)
    }
    
    fn check_quota(&self, username: &str, quota: u64) -> Result<bool, Box<dyn std::error::Error>> {
        let used = self.get_user_storage_size(username)?;
        Ok(used < quota)
    }
}
}

Performance Considerations

  1. Cache user roots - Store VirtualRoot instances to avoid repeated creation
  2. Lazy initialization - Only create directories when first accessed
  3. Batch operations - Group multiple file operations together
  4. Use async I/O - All paths work with tokio::fs via .interop_path()

Best Practices

  1. One root per user - Never share VirtualRoot between users
  2. Store virtual paths - Save virtual paths in your database, not system paths
  3. Display virtual paths - Show users virtual paths (starting with /)
  4. Use system paths for I/O - Use .as_unvirtual() when calling file operations

Next Steps

Archive Extraction with Safety

Extract ZIP files and other archives safely without zip-slip vulnerabilities. This example shows how PathBoundary detects and rejects malicious archive entries.

The Problem

Archive extractors are vulnerable to zip-slip attacks where malicious archives contain entries like:

  • ../../../etc/passwd - Escapes to system files
  • ..\\..\\windows\\system32\\evil.exe - Escapes on Windows
  • ❌ Symlinks pointing outside the extraction directory

The Solution: Choose Based on Your Use Case

Production Archive Extraction: Use PathBoundary to Detect Attacks

Use PathBoundary for production archive extraction. This detects malicious paths and allows you to:

  • Log the attack attempt
  • Reject the entire archive as compromised
  • Alert administrators
  • Take appropriate security action

When extracting archives in production:

  • Escape attempts indicate a malicious archive
  • You want to detect and reject the archive, not silently hide the attack
  • The archive should be quarantined or deleted
  • Users/admins should be alerted to the attempted attack

PathBoundary returns Err(PathEscapesBoundary) so you can handle the security event appropriately.

Research/Sandbox: Use VirtualRoot to Safely Analyze

Use VirtualRoot when analyzing suspicious archives in a controlled environment:

  • Malware analysis and security research
  • Safely studying attack techniques
  • Observing malicious behavior while containing it
  • Testing archive parsing without risk

In research scenarios, you want to see what the malicious archive tries to do, but safely contained within a virtual boundary.

  • ✅ Use create_parent_dir_all() before writes to avoid race conditions
  • ✅ Always join via virtual_join() or strict_join() - never concatenate paths manually
  • ✅ Treat absolute, UNC, drive-relative, or namespace-prefixed paths as untrusted
  • ✅ On Windows, NTFS Alternate Data Streams (ADS) like "file.txt:stream" are handled safely

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

Complete Example with PathBoundary

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(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!" as &[u8]),
            ("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(())
}

Handling Malicious Archives

When a malicious path is detected, you should:

#![allow(unused)]
fn main() {
fn extract_entry_with_security(
    extraction_dir: &PathBoundary,
    entry_path: &str,
    content: &[u8],
) -> Result<StrictPath, Box<dyn std::error::Error>> {
    match extraction_dir.strict_join(entry_path) {
        Ok(safe_path) => {
            // Valid path - extract normally
            safe_path.create_parent_dir_all()?;
            safe_path.write(content)?;
            Ok(safe_path)
        }
        Err(e) => {
            // Malicious path detected!
            eprintln!("🚨 SECURITY ALERT: Malicious archive entry detected!");
            eprintln!("   Entry path: {}", entry_path);
            eprintln!("   Error: {}", e);
            eprintln!("   Action: Rejecting entire archive as compromised");
            
            // Return error to stop extraction
            Err(format!("Archive contains malicious path: {}", entry_path).into())
        }
    }
}

// Usage
let entries = vec![
    ("readme.txt", b"Safe content" as &[u8]),
    ("../../../etc/passwd", b"Malicious"), // Skipped with log
    ("docs/api.md", b"More safe content"),
];

let count = extract_all_resilient("extracted", entries)?;
println!("✅ Successfully extracted {} files", count);
}

Key Security Features

1. Bounded Extraction Directory

#![allow(unused)]
fn main() {
let extraction_dir = PathBoundary::try_new_create(extract_to)?;
}

All extracted files must stay within this directory.

2. Automatic Malicious Path Detection

#![allow(unused)]
fn main() {
let safe_path = self.extraction_dir.strict_join(entry_path)?;
}

This line does all the heavy lifting:

  • Normalizes ../ sequences
  • Blocks absolute paths
  • Prevents symlink escapes
  • Returns an error for malicious paths

3. Parent Directory Creation

#![allow(unused)]
fn main() {
safe_path.create_parent_dir_all()?;
}

Automatically creates any necessary parent directories within the boundary.

4. Type-Safe Returns

#![allow(unused)]
fn main() {
fn extract_entry(&self, entry_path: &str, content: &[u8]) -> Result<StrictPath, ...>
}

Returning StrictPath ensures extracted paths are always validated.

Attack Scenarios Prevented

Malicious EntryStrictPath ResultVirtualPath Result
../../../etc/passwd❌ Error: path escapes boundary✅ Clamped to vroot /etc/passwd
..\\windows\\system32\\evil.exe❌ Error: path escapes boundary✅ Clamped to vroot /windows/system32/evil.exe
/var/www/html/shell.php❌ Error: absolute path rejected✅ Clamped to vroot /var/www/html/shell.php
legitimate/../../etc/passwd❌ Normalized and blocked✅ Normalized and clamped
Symlink to /etc/passwd❌ Target validated, error if outside✅ Target clamped to vroot /etc/passwd

Note: For archive extraction, consider using VirtualPath instead of StrictPath to gracefully clamp malicious entries rather than rejecting them. This provides defense-in-depth: even hostile archives with absolute paths or symlinks are safely contained within the extraction directory.

Real ZIP Integration

With the zip crate:

use strict_path::{PathBoundary, StrictPath};
use zip::ZipArchive;
use std::fs::File;
use std::io::Read;

struct RealArchiveExtractor {
    extraction_dir: PathBoundary,
}

impl RealArchiveExtractor {
    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_zip(&self, zip_path: &str) -> Result<Vec<StrictPath>, Box<dyn std::error::Error>> {
        let file = File::open(zip_path)?;
        let mut archive = ZipArchive::new(file)?;
        let mut extracted_files = Vec::new();
        
        for i in 0..archive.len() {
            let mut file = archive.by_index(i)?;
            let entry_path = file.name();
            
            // Validate the entry path - blocks zip-slip automatically
            let safe_path = match self.extraction_dir.strict_join(entry_path) {
                Ok(path) => path,
                Err(e) => {
                    println!("⚠️  Skipping malicious entry '{}': {}", entry_path, e);
                    continue;
                }
            };
            
            if file.is_dir() {
                safe_path.create_dir_all()?;
            } else {
                safe_path.create_parent_dir_all()?;
                let mut content = Vec::new();
                file.read_to_end(&mut content)?;
                safe_path.write(&content)?;
                extracted_files.push(safe_path);
                println!("📦 Extracted: {}", entry_path);
            }
        }
        
        Ok(extracted_files)
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let extractor = RealArchiveExtractor::new("extracted")?;
    
    // Extract a real ZIP file safely
    let files = extractor.extract_zip("archive.zip")?;
    println!("✅ Extracted {} files", files.len());
    
    Ok(())
}

TAR Archives

With the tar crate:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};
use tar::Archive;
use std::fs::File;

fn extract_tar(tar_path: &str, extract_to: &str) -> Result<Vec<StrictPath>, Box<dyn std::error::Error>> {
    let boundary = PathBoundary::try_new_create(extract_to)?;
    let mut extracted = Vec::new();
    
    let file = File::open(tar_path)?;
    let mut archive = Archive::new(file);
    
    for entry in archive.entries()? {
        let mut entry = entry?;
        let entry_path = entry.path()?;
        let entry_path_str = entry_path.to_string_lossy();
        
        // Validate each entry path
        let safe_path = match boundary.strict_join(&*entry_path_str) {
            Ok(path) => path,
            Err(e) => {
                println!("⚠️  Skipping malicious entry '{}': {}", entry_path_str, e);
                continue;
            }
        };
        
        // Extract using the validated path
        entry.unpack(safe_path.interop_path())?;
        extracted.push(safe_path);
        println!("📦 Extracted: {}", entry_path_str);
    }
    
    Ok(extracted)
}
}

Advanced: Extraction with Filters

Skip certain files or enforce naming patterns:

#![allow(unused)]
fn main() {
impl SafeArchiveExtractor {
    fn extract_with_filter<F>(
        &self,
        entries: Vec<(&str, &[u8])>,
        filter: F,
    ) -> Result<Vec<StrictPath>, Box<dyn std::error::Error>>
    where
        F: Fn(&str) -> bool,
    {
        let mut extracted = Vec::new();
        
        for (entry_path, content) in entries {
            // Apply custom filter
            if !filter(entry_path) {
                println!("⏭️  Skipped by filter: {}", entry_path);
                continue;
            }
            
            // Validate and extract
            match self.extract_entry(entry_path, content) {
                Ok(path) => extracted.push(path),
                Err(e) => println!("⚠️  Failed to extract '{}': {}", entry_path, e),
            }
        }
        
        Ok(extracted)
    }
}

// Usage:
let extracted = extractor.extract_with_filter(entries, |path| {
    // Only allow certain file types
    path.ends_with(".txt") || path.ends_with(".md") || path.ends_with(".rs")
})?;
}

Temporary Extraction

Extract to a temporary directory for processing:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;
use tempfile::TempDir;

fn extract_to_temp(archive_path: &str) -> Result<(TempDir, Vec<StrictPath>), Box<dyn std::error::Error>> {
    // Create temp directory
    let temp = TempDir::new()?;
    
    // Create boundary from temp path
    let boundary = PathBoundary::try_new(temp.path())?;
    
    // Extract archive
    let extracted = extract_archive_to_boundary(&boundary, archive_path)?;
    
    // Return both TempDir (to keep it alive) and extracted paths
    Ok((temp, extracted))
}

// Temp directory is automatically cleaned up when TempDir is dropped
}

Testing Advice

Test your extraction code with a malicious archive corpus:

Test Cases to Include

  • Directory traversal: "../", "..\\", "legitimate/../../etc/passwd"
  • Absolute paths: "/var/www/evil", "C:\\windows\\system32\\evil.exe"
  • Windows-specific:
    • UNC paths: "\\\\?\\C:\\windows\\evil"
    • Drive-relative: "C:..\\foo"
    • ADS streams: "decoy.txt:..\\..\\evil.exe"
    • Reserved names: "CON", "PRN", "AUX"
  • Unicode tricks: Dot lookalikes, NFC vs NFD forms
  • Long paths: Paths exceeding system limits

Assertions

#![allow(unused)]
fn main() {
#[test]
fn test_archive_extraction_safety() {
    let boundary = PathBoundary::try_new_create("test_extract").unwrap();
    
    // Should succeed
    assert!(boundary.strict_join("safe/path.txt").is_ok());
    
    // Should fail
    assert!(boundary.strict_join("../../../etc/passwd").is_err());
    assert!(boundary.strict_join("/absolute/path").is_err());
    
    // Cleanup
    std::fs::remove_dir_all("test_extract").ok();
}
}

Behavior Notes

  • Virtual joins clamp traversal lexically to the virtual root
  • System-facing escapes (via symlinks/junctions) are rejected during resolution
  • Unicode is not normalized - NFC and NFD forms are stored as-is, both safely contained
  • Hard links and privileged mount tricks are outside path-level protections (see README limitations)

Using VirtualPath for Extra Safety

For even safer archive extraction, consider using VirtualPath instead of StrictPath. This clamps malicious entries instead of rejecting them:

#![allow(unused)]
fn main() {
use strict_path::{VirtualRoot, VirtualPath};

struct VirtualArchiveExtractor {
    extraction_vroot: VirtualRoot,
}

impl VirtualArchiveExtractor {
    fn new(extract_to: &str) -> Result<Self, Box<dyn std::error::Error>> {
        let extraction_vroot = VirtualRoot::try_new_create(extract_to)?;
        Ok(Self { extraction_vroot })
    }
    
    fn extract_entry(&self, entry_path: &str, content: &[u8]) 
        -> Result<VirtualPath, Box<dyn std::error::Error>> 
    {
        // Malicious paths are CLAMPED instead of rejected
        // "../../../etc/passwd" becomes safe "vroot/etc/passwd"
        // Absolute symlink targets are also clamped to vroot
        let safe_path = self.extraction_vroot.virtual_join(entry_path)?;

        safe_path.create_parent_dir_all()?;
        safe_path.write(content)?;

        println!("📦 Extracted: {} -> {}", 
                 entry_path, 
                 safe_path.virtualpath_display());
        Ok(safe_path)
    }
}
}

Why VirtualPath for archives?

  • ✅ Hostile entries with ../../../ are clamped, not rejected
  • ✅ Absolute symlink targets (e.g., link -> /etc/passwd) are clamped to vroot
  • ✅ Archive extraction continues even with malicious entries
  • ✅ Defense-in-depth: every entry is safely contained
  • ✅ Perfect for untrusted archives from the internet

When to use each:

  • StrictPath: Fail-fast validation — reject malicious archives immediately
  • VirtualPath: Graceful containment — clamp every entry to stay safe, continue extraction

Best Practices

  1. Always validate - Never trust archive entry paths
  2. Log suspicious entries - Track and alert on blocked paths
  3. Limit extraction size - Check total extracted size to prevent zip bombs
  4. Filter file types - Only extract expected file types
  5. Use temporary storage - Extract to temp directory first, then move to final location
  6. Consider VirtualPath - Use for untrusted archives to clamp rather than reject malicious entries

Integration Tips

With Web Uploads

#![allow(unused)]
fn main() {
async fn handle_upload(file: UploadedFile) -> Result<Vec<String>, AppError> {
    // Save uploaded file
    let temp_zip = save_upload(file).await?;
    
    // Extract safely
    let extractor = SafeArchiveExtractor::new("uploads/extracted")?;
    let files = extractor.extract_zip(&temp_zip)?;
    
    // Return list of extracted files
    Ok(files.iter()
        .map(|p| p.strictpath_display().to_string())
        .collect())
}
}

With Background Jobs

#![allow(unused)]
fn main() {
async fn extract_job(job_id: String, archive_path: String) -> Result<(), JobError> {
    let extract_dir = format!("jobs/{}/extracted", job_id);
    let extractor = SafeArchiveExtractor::new(&extract_dir)?;
    
    let files = extractor.extract_zip(&archive_path)?;
    
    // Store results in database
    for file in files {
        db_store_file(&job_id, file.strictpath_display())?;
    }
    
    Ok(())
}
}

Next Steps

CLI Tool with Safe Path Handling

Build command-line tools that safely process user-provided file paths. This example shows how to handle untrusted path arguments securely.

The Problem

CLI tools accept file paths from users, but must prevent:

  • ❌ Users accessing files outside the working directory
  • ❌ Path traversal attacks via command-line arguments
  • ❌ Accidental exposure of sensitive files

The Solution

Use PathBoundary to create a working directory jail. All file operations are restricted to this boundary.

Complete Example

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(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!

Key Security Features

1. Working Directory Jail

#![allow(unused)]
fn main() {
let working_dir = PathBoundary::try_new_create(working_directory)?;
}

All file operations are restricted to this directory and its subdirectories.

2. User Input Validation

#![allow(unused)]
fn main() {
let safe_path = self.working_dir.strict_join(relative_path)?;
}

User-provided paths from command-line arguments are validated before any file access.

3. Helpful Error Messages

#![allow(unused)]
fn main() {
if file_path.contains("..") || file_path.starts_with('/') {
    println!("💡 Tip: Use relative paths within the workspace directory only.");
}
}

Guide users toward safe usage patterns.

4. Safe File Operations

All operations use the validated StrictPath, so security is guaranteed by the type system.

Attack Scenarios Prevented

User InputResult
sample1.txt✅ Processes workspace/sample1.txt
data/sample2.txt✅ Processes workspace/data/sample2.txt
../../../etc/passwd❌ Error: path escapes boundary
/var/log/system.log❌ Error: absolute paths not allowed
..\\..\\windows\\system32❌ Error: path escapes boundary

Advanced: Multiple Operations

Process multiple files from command-line arguments:

impl SafeFileProcessor {
    fn process_multiple(&self, paths: &[String]) -> Result<(), Box<dyn std::error::Error>> {
        for path in paths {
            match self.process_file(path) {
                Ok(()) => println!("✅ Processed: {}", path),
                Err(e) => println!("❌ Failed to process '{}': {}", path, e),
            }
        }
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = env::args().collect();
    
    if args.len() < 2 {
        println!("Usage: {} <file1> [file2] [file3] ...", args[0]);
        return Ok(());
    }
    
    let processor = SafeFileProcessor::new("workspace")?;
    let file_paths = &args[1..];
    
    processor.process_multiple(file_paths)?;
    
    Ok(())
}

Pattern Matching and Filtering

Process files matching a pattern:

#![allow(unused)]
fn main() {
impl SafeFileProcessor {
    fn process_pattern(&self, pattern: &str) -> Result<Vec<StrictPath>, Box<dyn std::error::Error>> {
        let mut processed = Vec::new();
        
        for entry in self.working_dir.read_dir()? {
            let entry = entry?;
            if entry.file_type()?.is_file() {
                let filename = entry.file_name();
                let filename_str = filename.to_string_lossy();
                
                // Simple pattern matching (extend with regex if needed)
                if filename_str.ends_with(pattern) {
                    let file_path = self.working_dir.strict_join(&filename)?;
                    self.process_file(&filename_str)?;
                    processed.push(file_path);
                }
            }
        }
        
        Ok(processed)
    }
}

// Usage:
// cargo run -- "*.txt"  // Process all .txt files
// cargo run -- "*.md"   // Process all .md files
}

Interactive Mode

Build an interactive CLI with safe path handling:

#![allow(unused)]
fn main() {
use std::io::{self, BufRead};

fn interactive_mode(processor: &SafeFileProcessor) -> Result<(), Box<dyn std::error::Error>> {
    println!("📂 Interactive mode - enter file paths to process (type 'quit' to exit)");
    println!("🔒 Working in: {}", processor.working_dir.strictpath_display());
    
    let stdin = io::stdin();
    for line in stdin.lock().lines() {
        let line = line?;
        let trimmed = line.trim();
        
        if trimmed == "quit" || trimmed == "exit" {
            break;
        }
        
        if trimmed == "list" {
            list_files(&processor.working_dir)?;
            continue;
        }
        
        if trimmed.is_empty() {
            continue;
        }
        
        match processor.process_file(trimmed) {
            Ok(()) => println!("✅ Done"),
            Err(e) => println!("❌ Error: {}", e),
        }
    }
    
    println!("👋 Goodbye!");
    Ok(())
}

fn list_files(boundary: &PathBoundary) -> Result<(), Box<dyn std::error::Error>> {
    println!("📁 Available files:");
    for entry in boundary.read_dir()? {
        let entry = entry?;
        println!("  - {}", entry.file_name().to_string_lossy());
    }
    Ok(())
}
}

Output File Handling

Write results to output files safely:

#![allow(unused)]
fn main() {
impl SafeFileProcessor {
    fn process_to_output(
        &self,
        input_path: &str,
        output_path: &str,
    ) -> Result<(), Box<dyn std::error::Error>> {
        // Validate both input and output paths
        let input = self.working_dir.strict_join(input_path)?;
        let output = self.working_dir.strict_join(output_path)?;
        
        // Process input
        let content = input.read_to_string()?;
        let processed = content.to_uppercase(); // Example transformation
        
        // Write to output
        output.create_parent_dir_all()?;
        output.write(&processed)?;
        
        println!("✅ Processed {} -> {}", input_path, output_path);
        
        Ok(())
    }
}

// Usage:
// cargo run -- input.txt output.txt
}

Environment Variable Configuration

Allow configuration via environment variables:

fn get_working_directory() -> String {
    env::var("WORKSPACE_DIR")
        .unwrap_or_else(|_| "workspace".to_string())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let work_dir = get_working_directory();
    let processor = SafeFileProcessor::new(&work_dir)?;
    
    // ... rest of implementation
    Ok(())
}

// Usage:
// WORKSPACE_DIR=/path/to/data cargo run -- file.txt

Progress Tracking

For processing many files:

#![allow(unused)]
fn main() {
impl SafeFileProcessor {
    fn process_directory(&self) -> Result<(), Box<dyn std::error::Error>> {
        let mut total = 0;
        let mut processed = 0;
        let mut failed = 0;
        
        // Count total files
        for entry in self.working_dir.read_dir()? {
            if entry?.file_type()?.is_file() {
                total += 1;
            }
        }
        
        println!("📊 Processing {} files...", total);
        
        // Process each file
        for entry in self.working_dir.read_dir()? {
            let entry = entry?;
            if entry.file_type()?.is_file() {
                let filename = entry.file_name();
                let path_str = filename.to_string_lossy();
                
                match self.process_file(&path_str) {
                    Ok(()) => {
                        processed += 1;
                        println!("[{}/{}] ✅ {}", processed + failed, total, path_str);
                    }
                    Err(e) => {
                        failed += 1;
                        println!("[{}/{}] ❌ {}: {}", processed + failed, total, path_str, e);
                    }
                }
            }
        }
        
        println!("\n📈 Summary:");
        println!("   Total: {}", total);
        println!("   Processed: {}", processed);
        println!("   Failed: {}", failed);
        
        Ok(())
    }
}
}

Best Practices

  1. Clear boundaries - Clearly communicate the working directory to users
  2. Helpful errors - Explain why paths are rejected and suggest alternatives
  3. Relative paths only - Guide users toward using relative paths
  4. Validate early - Check paths before performing expensive operations
  5. Log rejections - Track attempted path escapes for security monitoring

Integration Tips

With clap for Argument Parsing

use clap::Parser;

#[derive(Parser)]
struct Cli {
    /// File to process (relative to workspace)
    #[arg(value_name = "FILE")]
    file_path: String,
    
    /// Working directory
    #[arg(short, long, default_value = "workspace")]
    workspace: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();
    let processor = SafeFileProcessor::new(&cli.workspace)?;
    processor.process_file(&cli.file_path)?;
    Ok(())
}

With Glob Patterns

#![allow(unused)]
fn main() {
use glob::glob;

fn process_glob(processor: &SafeFileProcessor, pattern: &str) -> Result<(), Box<dyn std::error::Error>> {
    let workspace = processor.working_dir.strictpath_display().to_string();
    let full_pattern = format!("{}/{}", workspace, pattern);
    
    for entry in glob(&full_pattern)? {
        let path = entry?;
        if let Ok(relative) = path.strip_prefix(&workspace) {
            if let Some(relative_str) = relative.to_str() {
                processor.process_file(relative_str)?;
            }
        }
    }
    
    Ok(())
}
}

Next Steps

Type-Safe Context Separation

Learn how to use marker types to prevent accidentally mixing different storage contexts at compile time. This is one of the most powerful features of strict-path.

The Problem

Applications often have multiple storage areas for different purposes:

  • 🌐 Web assets (CSS, JS, images)
  • 📁 User uploads (documents, photos)
  • ⚙️ Configuration files
  • 🔒 Sensitive data (keys, tokens)

Without type safety, you might accidentally:

  • ❌ Serve a user's private document as a web asset
  • ❌ Write config data to the uploads directory
  • ❌ Read a sensitive key file when expecting a CSS file

The Solution

Use marker types with StrictPath<Marker> and VirtualPath<Marker> to encode context at the type level. The compiler prevents context mixing.

Complete Example

#![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()
}

fn process_upload(path: &StrictPath<UserFiles>) -> Result<(), std::io::Error> {
    // Process user-uploaded file
    let content = path.read_to_string()?;
    println!("Processing user file: {} bytes", 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.as_unvirtual())?;         // ✅ Correct context
    process_upload(doc.as_unvirtual())?;      // ✅ Correct context  
    load_config(&cfg)?;                       // ✅ Correct context

    // These would be compile errors:
    // serve_asset(doc.as_unvirtual())?;      // ❌ Compile error - wrong context!
    // process_upload(css.as_unvirtual())?;   // ❌ Compile error - wrong context!
    // load_config(css.as_unvirtual())?;      // ❌ Compile error - wrong context!

    Ok(())
}
}

Key Benefits

1. Compile-Time Safety

The compiler catches context mixing errors:

#![allow(unused)]
fn main() {
let css: VirtualPath<WebAssets> = assets_root.virtual_join("app.css")?;
let doc: VirtualPath<UserFiles> = uploads_root.virtual_join("report.pdf")?;

serve_asset(css.as_unvirtual())?;  // ✅ OK
serve_asset(doc.as_unvirtual())?;  // ❌ Compile error!
//          ^^^ expected WebAssets, found UserFiles
}

2. Clear Interfaces

Function signatures document what they accept:

#![allow(unused)]
fn main() {
// This function ONLY accepts web assets
fn serve_asset(path: &StrictPath<WebAssets>) -> Result<Vec<u8>, std::io::Error> {
    // No need to check if this is the right type of file
    // The type system guarantees it
    path.read()
}
}

3. Refactoring Safety

If you change a function's context requirement:

#![allow(unused)]
fn main() {
// Change signature from WebAssets to ConfigData
fn serve_asset(path: &StrictPath<ConfigData>) -> Result<Vec<u8>, std::io::Error> {
    path.read()
}
}

The compiler finds all call sites that need updating. Zero-cost migration!

4. Team Collaboration

New developers can't make context mixing mistakes - the compiler teaches them the correct patterns.

Real-World Pattern: Multi-Context Web Server

use strict_path::{PathBoundary, StrictPath, VirtualRoot, VirtualPath};

struct WebAssets;
struct UserUploads;
struct ServerConfig;

struct WebServer {
    assets: VirtualRoot<WebAssets>,
    uploads: VirtualRoot<UserUploads>,
    config: PathBoundary<ServerConfig>,
}

impl WebServer {
    fn new() -> Result<Self, Box<dyn std::error::Error>> {
        Ok(Self {
            assets: VirtualRoot::try_new("public")?,
            uploads: VirtualRoot::try_new("uploads")?,
            config: PathBoundary::try_new("config")?,
        })
    }
    
    // This can ONLY serve web assets
    fn serve_static_file(&self, path: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
        let asset: VirtualPath<WebAssets> = self.assets.virtual_join(path)?;
        Ok(self.read_asset(asset.as_unvirtual())?)
    }
    
    // Helper enforces WebAssets context
    fn read_asset(&self, path: &StrictPath<WebAssets>) -> std::io::Result<Vec<u8>> {
        path.read()
    }
    
    // This can ONLY handle user uploads
    fn save_upload(&self, filename: &str, content: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
        let upload: VirtualPath<UserUploads> = self.uploads.virtual_join(filename)?;
        self.write_upload(upload.as_unvirtual(), content)?;
        Ok(())
    }
    
    // Helper enforces UserUploads context
    fn write_upload(&self, path: &StrictPath<UserUploads>, content: &[u8]) -> std::io::Result<()> {
        path.create_parent_dir_all()?;
        path.write(content)
    }
    
    // This can ONLY read config files
    fn load_config(&self, name: &str) -> Result<String, Box<dyn std::error::Error>> {
        let cfg: StrictPath<ServerConfig> = self.config.strict_join(name)?;
        Ok(self.read_config(&cfg)?)
    }
    
    // Helper enforces ServerConfig context
    fn read_config(&self, path: &StrictPath<ServerConfig>) -> std::io::Result<String> {
        path.read_to_string()
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = WebServer::new()?;
    
    // Each method can only access its designated context
    let css = server.serve_static_file("app.css")?;
    server.save_upload("document.pdf", b"PDF content")?;
    let config = server.load_config("server.toml")?;
    
    // These would be impossible to mess up due to type safety:
    // - Can't serve an upload as a static file
    // - Can't save a config as an upload
    // - Can't read an asset as config
    
    Ok(())
}

Advanced: Permission Markers

Combine resource markers with permission markers using tuples:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

// Resource markers
struct Documents;
struct DatabaseFiles;

// Permission markers
struct ReadOnly;
struct ReadWrite;

// Type-safe permission enforcement
fn read_document(path: &StrictPath<(Documents, ReadOnly)>) -> std::io::Result<String> {
    path.read_to_string()
}

fn write_document(
    path: &StrictPath<(Documents, ReadWrite)>,
    content: &str,
) -> std::io::Result<()> {
    path.write(content)
}

fn backup_database(
    source: &StrictPath<(DatabaseFiles, ReadOnly)>,
    dest: &StrictPath<(DatabaseFiles, ReadWrite)>,
) -> std::io::Result<()> {
    let data = source.read()?;
    dest.write(&data)
}

fn example_permissions() -> Result<(), Box<dyn std::error::Error>> {
    let docs_ro: PathBoundary<(Documents, ReadOnly)> = 
        PathBoundary::try_new("documents")?;
    let docs_rw: PathBoundary<(Documents, ReadWrite)> = 
        PathBoundary::try_new("documents")?;
    
    let file_ro = docs_ro.strict_join("report.txt")?;
    let file_rw = docs_rw.strict_join("report.txt")?;
    
    // Can read from read-only
    read_document(&file_ro)?;
    
    // Can't write to read-only - compile error!
    // write_document(&file_ro, "new content")?;  // ❌ Compile error!
    
    // Can write to read-write
    write_document(&file_rw, "new content")?;
    
    Ok(())
}
}

Advanced: Authorization Markers

Use change_marker() after authorization checks:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

struct UserFiles;
struct ReadOnly;
struct ReadWrite;

fn authenticate_and_upgrade(
    path: StrictPath<(UserFiles, ReadOnly)>,
    user_has_write_access: bool,
) -> Result<StrictPath<(UserFiles, ReadWrite)>, &'static str> {
    if user_has_write_access {
        // Authorization succeeded - change marker to encode permission
        Ok(path.change_marker())
    } else {
        Err("Access denied")
    }
}

fn write_file(path: &StrictPath<(UserFiles, ReadWrite)>, content: &[u8]) -> std::io::Result<()> {
    path.write(content)
}

// Usage:
let boundary: PathBoundary<(UserFiles, ReadOnly)> = 
    PathBoundary::try_new("uploads")?;
let file_ro = boundary.strict_join("document.txt")?;

// Can't write yet - read-only marker
// write_file(&file_ro, b"data")?;  // ❌ Compile error!

// After authorization, upgrade to read-write
if let Ok(file_rw) = authenticate_and_upgrade(file_ro, check_permissions()) {
    write_file(&file_rw, b"data")?;  // ✅ Now allowed
}
}

See the Authorization & Permissions chapter for more details.

Shared Logic Across Contexts

Use generics when logic applies to any context:

#![allow(unused)]
fn main() {
// Generic over marker type - works with any context
fn get_file_size<M>(path: &StrictPath<M>) -> std::io::Result<u64> {
    path.metadata().map(|m| m.len())
}

// Works with any marker
let asset_size = get_file_size(&css_file)?;
let upload_size = get_file_size(&upload_file)?;
let config_size = get_file_size(&config_file)?;
}

Best Practices

1. Name Markers After Resources

#![allow(unused)]
fn main() {
struct UserDocuments;   // ✅ Clear
struct Documents;       // ⚠️  Which documents?
struct MyMarker;        // ❌ Meaningless
}

2. Use Tuples for Multi-Dimensional Context

#![allow(unused)]
fn main() {
StrictPath<(ResourceType, PermissionLevel)>
StrictPath<(UserFiles, ReadWrite)>
}

3. Keep Markers Simple

#![allow(unused)]
fn main() {
// ✅ Simple, zero-size
struct WebAssets;

// ❌ Don't add fields
struct WebAssets {
    size_limit: usize,  // Wrong - use runtime checks
}
}

4. Document Marker Meaning

#![allow(unused)]
fn main() {
/// Marker for publicly-accessible web assets
/// (CSS, JavaScript, images, fonts)
struct WebAssets;

/// Marker for user-uploaded files
/// (documents, photos, videos)
struct UserUploads;
}

Integration Tips

With Web Frameworks

#![allow(unused)]
fn main() {
// Axum route handlers
async fn serve_asset(
    Path(asset_path): Path<String>,
) -> Result<Vec<u8>, StatusCode> {
    let assets: VirtualRoot<WebAssets> = get_assets_root();
    let asset = assets.virtual_join(&asset_path)
        .map_err(|_| StatusCode::BAD_REQUEST)?;
    
    read_asset(asset.as_unvirtual())
        .map_err(|_| StatusCode::NOT_FOUND)
}

fn read_asset(path: &StrictPath<WebAssets>) -> std::io::Result<Vec<u8>> {
    path.read()
}
}

With Async Runtimes

Type safety works with async code too:

#![allow(unused)]
fn main() {
async fn read_asset_async(path: &StrictPath<WebAssets>) -> std::io::Result<Vec<u8>> {
    tokio::fs::read(path.interop_path()).await
}
}

Common Patterns

Pattern 1: Service with Multiple Contexts

#![allow(unused)]
fn main() {
struct AppService {
    assets: VirtualRoot<WebAssets>,
    uploads: VirtualRoot<UserFiles>,
    config: PathBoundary<ConfigData>,
}
}

Pattern 2: Generic Helpers

#![allow(unused)]
fn main() {
fn exists<M>(path: &StrictPath<M>) -> bool {
    path.exists()
}
}

Pattern 3: Marker Transformation

#![allow(unused)]
fn main() {
fn authorize<R>(
    path: StrictPath<(R, ReadOnly)>,
) -> Result<StrictPath<(R, ReadWrite)>, Error> {
    // Check permissions...
    Ok(path.change_marker())
}
}

Next Steps

Axum Web Service Tutorial

This tutorial demonstrates key security patterns for web services using Axum and strict-path. We focus on the essential integration points where path validation prevents vulnerabilities.

Quick Start: If you want a framework-agnostic example first, see Web File Upload Service for the basic concepts without web framework complexity.

What You'll Learn

How to integrate strict-path into an Axum web service:

  • Static file serving with PathBoundary to prevent directory traversal
  • Per-user file storage with VirtualRoot for user isolation
  • Type-safe contexts with marker types to prevent mixing boundaries

Why This Matters

Without strict-path, common mistakes lead to vulnerabilities:

#![allow(unused)]
fn main() {
// ❌ UNSAFE: User can access any file
let file_path = format!("./uploads/{}", user_input);
std::fs::read_to_string(file_path)?

// ✅ SAFE: Validated path, guaranteed within boundary
let file = uploads_root.virtual_join(user_input)?;
file.read_to_string()?
}

Tutorial Structure

Short, focused chapters showing essential patterns:

Chapter 1: Project Setup

Basic project structure, marker types, and boundary initialization.

Chapter 2: Static Assets

Serve static files safely with StrictPath<WebAssets>.

Chapter 3: Per-User Storage

Isolate user files with VirtualRoot<UserUploads>.

Chapter 3: User Authentication

Add user authentication and create per-user storage isolation.

What you'll learn:

  • Simple session-based authentication
  • Creating VirtualRoot per user
  • Authorization markers with change_marker()
  • Protecting routes with middleware

Chapter 4: File Upload System

Build a secure file upload system with per-user isolation.

Prerequisites

  • Rust: 1.71.0 or later
  • Basic Axum knowledge: Understanding handlers and state

Ready to start?Chapter 1: Project Setup

Chapter 1: Project Setup

Let's set up our Axum web service with proper security boundaries from the start. We'll create the project structure, define our marker types, and establish path boundaries for different storage areas.

Create the Project

cargo new file-sharing-service
cd file-sharing-service

Add Dependencies

Update your Cargo.toml:

[package]
name = "file-sharing-service"
version = "0.1.0"
edition = "2021"

[dependencies]
# Web framework
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["fs", "trace"] }

# Security and paths
strict-path = { version = "0.1", features = ["serde"] }

# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# Utilities
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.0", features = ["v4", "serde"] }

Define Security Boundaries

Create src/markers.rs - this is where we define our type-safe contexts:

#![allow(unused)]
fn main() {
//! Type-safe markers for different storage contexts.
//! 
//! These zero-cost markers prevent accidentally mixing different
//! types of files (e.g., serving user uploads as web assets).

/// Public web assets (CSS, JavaScript, images, fonts)
/// 
/// Files with this marker can be served to anyone without authentication.
pub struct WebAssets;

/// User-uploaded files (documents, photos, videos)
/// 
/// Each user has their own isolated VirtualRoot with this marker.
/// Files are private and require authentication to access.
pub struct UserUploads;

/// Application configuration files
/// 
/// Server configuration, secrets, and settings.
/// Never exposed to users.
pub struct AppConfig;

/// Read-only permission marker
pub struct ReadOnly;

/// Read-write permission marker  
pub struct ReadWrite;
}

Application State

Create src/state.rs - this holds our path boundaries and user sessions:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, VirtualRoot};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;

use crate::markers::{WebAssets, UserUploads, AppConfig};

/// Shared application state passed to all route handlers
#[derive(Clone)]
pub struct AppState {
    /// Path boundary for public web assets
    pub assets: Arc<PathBoundary<WebAssets>>,
    
    /// Path boundary for server configuration
    pub config: Arc<PathBoundary<AppConfig>>,
    
    /// Per-user upload roots (user_id -> VirtualRoot)
    pub user_uploads: Arc<RwLock<HashMap<Uuid, VirtualRoot<UserUploads>>>>,
    
    /// Active user sessions (session_id -> user_id)
    pub sessions: Arc<RwLock<HashMap<String, Uuid>>>,
}

impl AppState {
    /// Create new application state with initialized boundaries
    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
        // Create boundary for public assets
        let assets = PathBoundary::try_new_create("public")?;
        
        // Create boundary for config files
        let config = PathBoundary::try_new_create("config")?;
        
        Ok(Self {
            assets: Arc::new(assets),
            config: Arc::new(config),
            user_uploads: Arc::new(RwLock::new(HashMap::new())),
            sessions: Arc::new(RwLock::new(HashMap::new())),
        })
    }
    
    /// Get or create a VirtualRoot for a specific user
    pub async fn get_user_uploads(
        &self,
        user_id: Uuid,
    ) -> Result<VirtualRoot<UserUploads>, Box<dyn std::error::Error>> {
        let mut uploads = self.user_uploads.write().await;
        
        if let Some(vroot) = uploads.get(&user_id) {
            // Return existing user root
            Ok(vroot.clone())
        } else {
            // Create new isolated storage for this user
            let user_dir = format!("uploads/user_{}", user_id);
            let vroot = VirtualRoot::try_new_create(&user_dir)?;
            uploads.insert(user_id, vroot.clone());
            
            tracing::info!("Created upload directory for user {}", user_id);
            Ok(vroot)
        }
    }
    
    /// Create a new user session
    pub async fn create_session(&self, user_id: Uuid) -> String {
        let session_id = Uuid::new_v4().to_string();
        self.sessions.write().await.insert(session_id.clone(), user_id);
        session_id
    }
    
    /// Get user ID from session ID
    pub async fn get_user_from_session(&self, session_id: &str) -> Option<Uuid> {
        self.sessions.read().await.get(session_id).copied()
    }
}
}

Main Server Setup

Update src/main.rs:

use axum::{
    Router,
    routing::get,
};
use std::net::SocketAddr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod markers;
mod state;

use state::AppState;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize tracing
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "file_sharing_service=debug,tower_http=debug".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Initialize application state with security boundaries
    let state = AppState::new()?;
    
    tracing::info!("🔒 Security boundaries initialized:");
    tracing::info!("  - Public assets: {}", state.assets.strictpath_display());
    tracing::info!("  - Config files: {}", state.config.strictpath_display());
    tracing::info!("  - User uploads: uploads/user_<uuid>/");

    // Build our application with routes
    let app = Router::new()
        .route("/", get(root_handler))
        .route("/health", get(health_check))
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    // Run the server
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("🚀 Server listening on {}", addr);
    
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

async fn root_handler() -> &'static str {
    "File Sharing Service - Use /health to check status"
}

async fn health_check() -> &'static str {
    "OK"
}

Project Structure

Create the initial directory structure:

mkdir -p public/{css,js,images}
mkdir -p config
mkdir -p src/routes
mkdir -p src/middleware

Create a sample HTML file in public/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>File Sharing Service</title>
    <link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
    <h1>🔐 Secure File Sharing Service</h1>
    <p>Protected by strict-path security boundaries.</p>
</body>
</html>

Create public/css/style.css:

body {
    font-family: system-ui, -apple-system, sans-serif;
    max-width: 800px;
    margin: 2rem auto;
    padding: 0 1rem;
    background: #f5f5f5;
}

h1 {
    color: #2c3e50;
}

Test the Server

Run the server:

cargo run

You should see:

🔒 Security boundaries initialized:
  - Public assets: public
  - Config files: config
  - User uploads: uploads/user_<uuid>/
🚀 Server listening on 127.0.0.1:3000

Visit http://localhost:3000/health - you should see "OK".

Understanding the Security Model

Let's examine what we've built:

1. Separate Boundaries for Each Context

#![allow(unused)]
fn main() {
pub assets: Arc<PathBoundary<WebAssets>>,
pub config: Arc<PathBoundary<AppConfig>>,
}

Each storage area has its own PathBoundary with a different marker type. This means:

  • ✅ You cannot accidentally serve config files as web assets
  • ✅ You cannot write user uploads to the config directory
  • ✅ The compiler enforces these boundaries

2. Per-User Isolated Storage

#![allow(unused)]
fn main() {
pub user_uploads: Arc<RwLock<HashMap<Uuid, VirtualRoot<UserUploads>>>>,
}

Each user gets their own VirtualRoot:

  • ✅ User A cannot access User B's files
  • ✅ Path traversal attacks (../other-user/file.txt) are automatically blocked
  • ✅ Each user sees clean paths starting from /

3. Type-Safe State

#![allow(unused)]
fn main() {
pub async fn get_user_uploads(
    &self,
    user_id: Uuid,
) -> Result<VirtualRoot<UserUploads>, Box<dyn std::error::Error>>
}

Functions return typed paths:

  • ✅ You know exactly what type of storage you're working with
  • ✅ Can't mix user uploads with web assets
  • ✅ Refactoring is safe - compiler finds all usages

What's Next?

Now that we have our security boundaries established, we'll implement:

  1. Chapter 2: Static Asset Serving - Serve CSS, JS, and images safely
  2. User authentication and session management
  3. File upload system with per-user isolation
  4. File download and listing with authorization
  5. Configuration management and deployment

Key Takeaways

Separate boundaries - One PathBoundary per storage context
Type-safe markers - Compiler prevents context mixing
Per-user isolation - VirtualRoot for each user
Lazy initialization - User storage created on first access
Shared state - Arc<RwLock<>> for thread-safe access


Next: Chapter 2: Static Asset Serving →

Navigation:
Tutorial Overview | Chapter 2 →

Chapter 2: Static Asset Serving

Now that we have our security boundaries established, let's implement secure static file serving. We'll serve CSS, JavaScript, and images while preventing path traversal attacks.

The Security Challenge

Static file servers are a common attack vector:

  • GET /assets/../config/secrets.json - Try to escape to config
  • GET /assets/../../etc/passwd - Try to access system files
  • GET /assets/../uploads/user_123/private.pdf - Try to access user files

With strict-path, these attacks are impossible because the type system enforces boundaries.

Create the Assets Route Handler

Create src/routes/assets.rs:

#![allow(unused)]
fn main() {
use axum::{
    extract::{Path, State},
    http::{StatusCode, header},
    response::{IntoResponse, Response},
};
use strict_path::StrictPath;
use crate::markers::WebAssets;
use crate::state::AppState;

/// Serve a static asset file
/// 
/// Security: The PathBoundary<WebAssets> ensures files can ONLY
/// come from the public/ directory. Path traversal attacks are
/// automatically blocked by strict_join().
pub async fn serve_asset(
    State(state): State<AppState>,
    Path(asset_path): Path<String>,
) -> Result<Response, AppError> {
    // Validate the requested path against the assets boundary
    // This is where security happens - strict_join() prevents escapes
    let safe_path: StrictPath<WebAssets> = state.assets
        .strict_join(&asset_path)
        .map_err(|e| {
            tracing::warn!("❌ Blocked path traversal attempt: {}", asset_path);
            AppError::PathTraversal(e.to_string())
        })?;
    
    // Check if file exists
    if !safe_path.exists() {
        tracing::debug!("Asset not found: {}", asset_path);
        return Err(AppError::NotFound);
    }
    
    // Check if it's actually a file (not a directory)
    if !safe_path.is_file() {
        tracing::warn!("Attempted to serve directory as file: {}", asset_path);
        return Err(AppError::NotFound);
    }
    
    // Read the file - safe because path is validated
    let content = read_asset(&safe_path).await?;
    
    // Determine content type from extension
    let content_type = get_content_type(&safe_path);
    
    tracing::debug!("✅ Serving asset: {} ({})", asset_path, content_type);
    
    // Build response with appropriate content-type
    Ok((
        StatusCode::OK,
        [(header::CONTENT_TYPE, content_type)],
        content,
    ).into_response())
}

/// Read asset file - helper enforces WebAssets context
async fn read_asset(path: &StrictPath<WebAssets>) -> Result<Vec<u8>, AppError> {
    tokio::fs::read(path.interop_path())
        .await
        .map_err(|e| AppError::IoError(e.to_string()))
}

/// Determine content-type from file extension
fn get_content_type(path: &StrictPath<WebAssets>) -> &'static str {
    match path.strictpath_extension().and_then(|s| s.to_str()) {
        Some("html") => "text/html; charset=utf-8",
        Some("css") => "text/css; charset=utf-8",
        Some("js") => "application/javascript; charset=utf-8",
        Some("json") => "application/json",
        Some("png") => "image/png",
        Some("jpg") | Some("jpeg") => "image/jpeg",
        Some("gif") => "image/gif",
        Some("svg") => "image/svg+xml",
        Some("ico") => "image/x-icon",
        Some("woff") => "font/woff",
        Some("woff2") => "font/woff2",
        Some("ttf") => "font/ttf",
        Some("txt") => "text/plain; charset=utf-8",
        _ => "application/octet-stream",
    }
}

/// Application errors with appropriate HTTP status codes
#[derive(Debug)]
pub enum AppError {
    PathTraversal(String),
    NotFound,
    IoError(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::PathTraversal(msg) => {
                (StatusCode::BAD_REQUEST, format!("Invalid path: {}", msg))
            }
            AppError::NotFound => {
                (StatusCode::NOT_FOUND, "File not found".to_string())
            }
            AppError::IoError(msg) => {
                (StatusCode::INTERNAL_SERVER_ERROR, format!("IO error: {}", msg))
            }
        };
        
        (status, message).into_response()
    }
}
}

Update Main Router

Update src/main.rs to include the assets route:

use axum::{
    Router,
    routing::get,
};
use std::net::SocketAddr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod markers;
mod state;
mod routes;

use state::AppState;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize tracing
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "file_sharing_service=debug,tower_http=debug".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    // Initialize application state with security boundaries
    let state = AppState::new()?;
    
    tracing::info!("🔒 Security boundaries initialized:");
    tracing::info!("  - Public assets: {}", state.assets.strictpath_display());
    tracing::info!("  - Config files: {}", state.config.strictpath_display());

    // Build our application with routes
    let app = Router::new()
        .route("/", get(root_handler))
        .route("/health", get(health_check))
        // Serve static assets - path parameter is validated by strict_join()
        .route("/assets/*path", get(routes::assets::serve_asset))
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    // Run the server
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("🚀 Server listening on {}", addr);
    
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

async fn root_handler() -> &'static str {
    "File Sharing Service - Use /health to check status"
}

async fn health_check() -> &'static str {
    "OK"
}

Create src/routes/mod.rs:

#![allow(unused)]
fn main() {
pub mod assets;
}

Test Asset Serving

Run the server:

cargo run

Test Valid Paths

# Should work - file exists in public/
curl http://localhost:3000/assets/css/style.css

# Should work - subdirectory access
curl http://localhost:3000/assets/images/logo.png

Test Attack Scenarios

# ❌ Try to escape to parent directory
curl http://localhost:3000/assets/../config/secrets.json
# Response: 400 Bad Request - "Invalid path: ..."

# ❌ Try to access system files
curl http://localhost:3000/assets/../../etc/passwd
# Response: 400 Bad Request - "Invalid path: ..."

# ❌ Try to access user uploads
curl http://localhost:3000/assets/../uploads/user_123/file.txt
# Response: 400 Bad Request - "Invalid path: ..."

# ❌ Try absolute path
curl http://localhost:3000/assets//var/log/system.log
# Response: 400 Bad Request - "Invalid path: ..."

All attacks are automatically blocked! 🎉

Understanding the Security

1. Validation Happens at the Boundary

#![allow(unused)]
fn main() {
let safe_path: StrictPath<WebAssets> = state.assets
    .strict_join(&asset_path)
    .map_err(|e| {
        tracing::warn!("❌ Blocked path traversal attempt: {}", asset_path);
        AppError::PathTraversal(e.to_string())
    })?;
}

This single line provides complete protection:

  • strict_join() normalizes the path (resolves .., ., etc.)
  • Checks if the result is within the public/ boundary
  • Returns an error if the path escapes

2. Type-Safe Helpers

#![allow(unused)]
fn main() {
async fn read_asset(path: &StrictPath<WebAssets>) -> Result<Vec<u8>, AppError> {
    tokio::fs::read(path.interop_path()).await
        .map_err(|e| AppError::IoError(e.to_string()))
}
}

By accepting &StrictPath<WebAssets>, this function:

  • ✅ Only accepts validated asset paths
  • ✅ Cannot be called with user uploads or config files
  • ✅ Compiler enforces the security contract

3. Content-Type Based on Extension

#![allow(unused)]
fn main() {
fn get_content_type(path: &StrictPath<WebAssets>) -> &'static str {
    match path.strictpath_extension().and_then(|s| s.to_str()) {
        Some("css") => "text/css; charset=utf-8",
        // ...
    }
}
}

We safely use the validated path to determine content-type. No risk of path manipulation here.

Why This Is Better Than Standard Approaches

❌ Unsafe: String-Based Validation

#![allow(unused)]
fn main() {
// Don't do this!
async fn serve_asset_unsafe(Path(asset_path): Path<String>) -> Response {
    // Manual validation - easy to get wrong
    if asset_path.contains("..") {
        return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
    }
    
    // Still vulnerable to attacks like:
    // - Encoded paths (%2e%2e%2f)
    // - Symlinks
    // - Case sensitivity issues on Windows
    
    let full_path = format!("public/{}", asset_path);
    let content = tokio::fs::read(&full_path).await.unwrap();
    // ...
}
}

✅ Safe: Type-Based Validation

#![allow(unused)]
fn main() {
// Do this!
let safe_path: StrictPath<WebAssets> = state.assets.strict_join(&asset_path)?;
let content = read_asset(&safe_path).await?;
}

Strict-path handles:

  • ✅ Path normalization (., .., multiple /)
  • ✅ Symlink resolution
  • ✅ Encoding issues
  • ✅ Case sensitivity
  • ✅ Platform differences

Adding More Assets

Create some sample files to serve:

# Create a JavaScript file
cat > public/js/app.js << 'EOF'
console.log('File sharing service initialized');
document.addEventListener('DOMContentLoaded', () => {
    console.log('DOM loaded - ready to upload files');
});
EOF

# Create an image (placeholder)
echo "Sample image data" > public/images/logo.png

# Create a robots.txt
cat > public/robots.txt << 'EOF'
User-agent: *
Disallow: /uploads/
EOF

Test them:

curl http://localhost:3000/assets/js/app.js
curl http://localhost:3000/assets/robots.txt

Handling Index Files

Want to serve index.html when accessing /assets/? Update the route handler:

#![allow(unused)]
fn main() {
pub async fn serve_asset(
    State(state): State<AppState>,
    Path(asset_path): Path<String>,
) -> Result<Response, AppError> {
    // If path ends with /, append index.html
    let request_path = if asset_path.ends_with('/') || asset_path.is_empty() {
        format!("{}index.html", asset_path)
    } else {
        asset_path
    };
    
    let safe_path: StrictPath<WebAssets> = state.assets
        .strict_join(&request_path)
        .map_err(|e| {
            tracing::warn!("❌ Blocked path traversal attempt: {}", request_path);
            AppError::PathTraversal(e.to_string())
        })?;
    
    // ... rest of the function
}
}

Now http://localhost:3000/assets/ serves public/index.html!

Performance Optimization

For production, consider adding caching headers:

#![allow(unused)]
fn main() {
use axum::http::header;

pub async fn serve_asset(
    // ... parameters
) -> Result<Response, AppError> {
    // ... validation and reading
    
    // Add cache headers for static assets
    let cache_control = if is_immutable_asset(&safe_path) {
        "public, max-age=31536000, immutable"  // 1 year for versioned assets
    } else {
        "public, max-age=3600"  // 1 hour for other assets
    };
    
    Ok((
        StatusCode::OK,
        [
            (header::CONTENT_TYPE, content_type),
            (header::CACHE_CONTROL, cache_control),
        ],
        content,
    ).into_response())
}

fn is_immutable_asset(path: &StrictPath<WebAssets>) -> bool {
    // Check if filename contains hash (e.g., app-abc123.js)
    path.strictpath_file_name()
        .and_then(|n| n.to_str())
        .map(|n| n.contains('-') && n.split('-').nth(1).is_some())
        .unwrap_or(false)
}
}

Key Takeaways

Single validation point - strict_join() handles all path security
Type-safe helpers - Functions accept StrictPath<WebAssets> only
Automatic attack blocking - No manual checks needed
Clear error handling - Failed validation returns appropriate HTTP errors
Content-type safety - Based on validated path extension

What's Next?

Now that we can serve static assets securely, let's add user authentication:

Chapter 3: User Authentication →

In the next chapter, we'll:

  • Implement simple session-based authentication
  • Create per-user VirtualRoot instances
  • Use authorization markers with change_marker()
  • Protect routes with middleware

Navigation:
← Chapter 1 | Tutorial Overview | Chapter 3 →

Chapter 3: Per-User Storage with VirtualRoot

This chapter shows how to isolate user file storage using VirtualRoot<UserUploads>. Each user gets their own virtual filesystem that cannot access other users' files.

The Problem: User Isolation

Without proper isolation, users could access each other's files:

#![allow(unused)]
fn main() {
// ❌ UNSAFE: Users can escape their directory
let user_file = format!("./uploads/{}/{}", user_id, filename);
// User sends filename="../other_user/secret.txt"
}

The Solution: VirtualRoot Per User

VirtualRoot creates an isolated view where paths are relative to the user's directory:

#![allow(unused)]
fn main() {
// ✅ SAFE: Isolated per-user virtual filesystem
let user_root = VirtualRoot::<UserUploads>::try_new(
    format!("./uploads/user_{user_id}")
)?;

// User's paths are always within their root
let file = user_root.virtual_join(filename)?; // Can't escape!
}

Implementation: File Upload Handler

Create src/routes/upload.rs:

#![allow(unused)]
fn main() {
use axum::{
    extract::{Multipart, Path, State},
    http::StatusCode,
    response::IntoResponse,
};
use strict_path::VirtualRoot;
use crate::{markers::UserUploads, state::AppState, error::AppError};

/// Handle file upload for authenticated user
pub async fn upload_file(
    State(state): State<AppState>,
    Path(user_id): Path<String>,
    mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
    // Get or create user's virtual root
    let user_root = state.get_user_root(&user_id)?;

    while let Some(field) = multipart.next_field().await? {
        let filename = field
            .file_name()
            .ok_or(AppError::MissingFilename)?
            .to_string();

        // SECURITY: virtual_join validates filename
        // Rejects: "../", absolute paths, special chars
        let file_path = user_root
            .virtual_join(&filename)
            .map_err(|_| AppError::InvalidFilename)?;

        let data = field.bytes().await?;
        
        // Safe: file_path is guaranteed within user's boundary
        file_path.write(data.as_ref())?;
    }

    Ok(StatusCode::CREATED)
}

/// List files in user's directory
pub async fn list_files(
    State(state): State<AppState>,
    Path(user_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
    let user_root = state.get_user_root(&user_id)?;
    
    // Convert to StrictPath to read directory
    let root_dir = user_root.as_unvirtual();
    let entries = root_dir.read_dir()?;

    let files: Vec<String> = entries
        .filter_map(|e| e.ok())
        .filter_map(|e| e.file_name().into_string().ok())
        .collect();

    Ok(axum::Json(files))
}
}

Update AppState

Modify src/state.rs to manage per-user roots:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use strict_path::{PathBoundary, VirtualRoot};
use crate::markers::{WebAssets, UserUploads, AppConfig};

pub struct AppState {
    pub assets: PathBoundary<WebAssets>,
    pub config: PathBoundary<AppConfig>,
    uploads_base: PathBoundary<UserUploads>,
    // Cache of user virtual roots
    user_roots: Arc<RwLock<HashMap<String, VirtualRoot<UserUploads>>>>,
}

impl AppState {
    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
        Ok(Self {
            assets: PathBoundary::try_new_create("./data/assets")?,
            config: PathBoundary::try_new("./data/config")?,
            uploads_base: PathBoundary::try_new_create("./data/uploads")?,
            user_roots: Arc::new(RwLock::new(HashMap::new())),
        })
    }

    /// Get or create virtual root for user
    pub fn get_user_root(
        &self,
        user_id: &str,
    ) -> Result<VirtualRoot<UserUploads>, Box<dyn std::error::Error>> {
        // Check cache first
        {
            let cache = self.user_roots.read().unwrap();
            if let Some(root) = cache.get(user_id) {
                return Ok(root.clone());
            }
        }

        // Create new user directory and virtual root
        let user_dir = self.uploads_base.strict_join(user_id)?;
        user_dir.create_dir_all()?;

        let vroot = VirtualRoot::try_new(user_dir.interop_path())?;

        // Cache it
        self.user_roots.write().unwrap().insert(user_id.to_string(), vroot.clone());

        Ok(vroot)
    }
}
}

Register Routes

Update src/main.rs:

mod routes {
    pub mod assets;
    pub mod upload;
}

use axum::{
    routing::{get, post},
    Router,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let state = AppState::new()?;

    let app = Router::new()
        .route("/assets/*path", get(routes::assets::serve_asset))
        .route("/users/:user_id/files", post(routes::upload::upload_file))
        .route("/users/:user_id/files", get(routes::upload::list_files))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    println!("Server running on http://127.0.0.1:3000");
    
    axum::serve(listener, app).await?;
    Ok(())
}

Key Security Properties

  1. User Isolation: Each VirtualRoot is scoped to one user's directory
  2. Path Validation: virtual_join() prevents directory traversal
  3. Type Safety: VirtualRoot<UserUploads> can't mix with PathBoundary<WebAssets>
  4. Automatic Caching: User roots are cached for performance

Testing the Isolation

# Upload to user_001
curl -F "file=@test.txt" http://localhost:3000/users/user_001/files

# Try to access user_002's files (will fail)
curl -F "file=@../user_002/secret.txt" http://localhost:3000/users/user_001/files
# Returns 400: InvalidFilename

# List user_001's files (only shows their files)
curl http://localhost:3000/users/user_001/files

What We Learned

  • VirtualRoot provides per-user filesystem isolation
  • virtual_join() validates filenames and prevents escapes
  • AppState can manage multiple virtual roots efficiently
  • Type markers prevent accidentally mixing user storage with other boundaries

Navigation:
← Chapter 2 | Tutorial Overview

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-beta.2", 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(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(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

Best Practices & Guidelines

Your complete guide to using strict-path correctly and ergonomically.

This page provides the essential decision matrices, core principles, and quick references for daily use. For deeper dives, we've split detailed content into focused chapters—each covering a single topic so you can digest one concept at a time.


📚 Focused Chapters (Deep Dives)

For detailed explanations and comprehensive examples, see these focused chapters:

Start here for fundamentals, then jump to focused chapters when you need details.


Why strict-path Exists (TL;DR)

Path security isn't one problem—it's a class of interacting problems. Every "simple" approach (check for ../, canonicalize then check, normalize, allowlist chars, combine checks) creates new attack surface:

  • Encoding bypasses (URL encoding, double encoding, Unicode normalization)
  • Race conditions (TOCTOU with symlinks: CVE-2022-21658)
  • Platform gaps (Windows 8.3 names, UNC paths, ADS: CVE-2019-9855, CVE-2017-17793)
  • Performance costs (repeated filesystem calls)
  • Future CVEs (new attack vectors require updating every check)

strict-path solved this problem class once, correctly, so you don't have to.

→ Full analysis with CVE examples →


Pick The Right Type (Quick Reference)

30-Second Decision Guide

External/untrusted segments (HTTP, DB, manifests, LLM output, archive entries):

  • Detection (90% of cases): StrictPath::with_boundary(..).strict_join(..) — detects escapes, rejects attacks
  • Containment (10% of cases): VirtualPath::with_root(..).virtual_join(..) — silently clamps escapes, isolates users

Internal/trusted paths (hardcoded, CLI, env): Use Path/PathBuf; only validate when combining with untrusted segments.

For policy reuse across many joins: Keep a PathBoundary or VirtualRoot and call strict_join(..)/virtual_join(..) repeatedly.

Decision Matrix by Source

SourceTypical InputDefault ChoiceNotes
🌐 HTTP/WebURL segments, form fieldsVirtualPath or StrictPathVirtualPath for UI display, StrictPath for system I/O
⚙️ Config/DBPaths in config/databaseStrictPathStorage ≠ safety; validate on use
📂 CLI/External APIsArgs, webhooks, payloadsStrictPathNever trust external input
🤖 LLM/AIGenerated paths/filenamesStrictPathLLM output is untrusted by default
📦 ArchivesZIP/TAR entry namesStrictPath ONLYDetect malicious paths, reject bad archives
🏢 Multi-tenantPer-user file operationsVirtualPathIsolate users with virtual roots

Security Philosophy: Detect vs. Contain

The fundamental distinction: Are path escapes attacks or expected behavior?

StrictPath — Detect & Reject (Default, 90%)

  • Philosophy: "If it tries to escape, I want to know"
  • Returns: Err(PathEscapesBoundary) on escape attempts
  • Use for: Archives, file uploads, config loading, security boundaries
  • Always available (no feature flag)

VirtualPath — Contain & Redirect (Opt-in, 10%)

  • Philosophy: "Let it try to escape, but silently contain it"
  • Behavior: Silently clamps escapes within boundary
  • Use for: Multi-tenant systems, malware sandboxes, security research, user isolation
  • Requires: virtual-path feature in Cargo.toml

How They Differ

Attempting ../../../etc/passwd:

  • StrictPath: Err(PathEscapesBoundary) → log, alert, reject
  • VirtualPath: Silently clamped to boundary → contained, not reported

Symlink to /etc/passwd:

  • StrictPath: Follows, validates target → Error if outside boundary
  • VirtualPath: Treats as relative → clamped to vroot/etc/passwd

Critical Rule: Use StrictPath for archives to detect attacks. VirtualPath hides them.

Golden Rule: If you didn't create the path yourself, secure it first.

→ Full comparison with examples →


When to Use Policy Types vs. Sugar

Sugar constructors (StrictPath::with_boundary(..), VirtualPath::with_root(..)) are great for simple, one-off operations.

Policy types (PathBoundary, VirtualRoot) matter when you need:

  • Policy reuse (canonicalize once, join many times)
  • Performance (1 canonicalization vs 1000 in loops)
  • Testability (inject test boundaries)
  • Serde integration (contextual deserialization)
  • Clear signatures (encode guarantees in types)

Quick Example:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// ❌ SLOW: 1000 canonicalizations
for name in files {
    let boundary = PathBoundary::try_new(base)?;
    boundary.strict_join(name)?;
}

// ✅ FAST: 1 canonicalization
let boundary = PathBoundary::try_new(base)?;
for name in files {
    boundary.strict_join(name)?; // Reuses canonical state
}
}

Rule of thumb: Start with sugar; upgrade to policy types when you need reuse, performance, or testing.

→ Full guide with benchmarks, serde patterns, and testing examples →


Encode Guarantees In Function Signatures

Helpers that touch the filesystem must encode safety in their signatures:

Two canonical patterns:

  1. Accept validated path when validation already happened: fn save(p: &StrictPath) -> io::Result<()>
  2. Accept boundary + segment when validation happens inside: fn load(b: &PathBoundary, name: &str) -> io::Result<String>

Don't construct boundaries/roots inside helpers — boundary choice is policy; make it explicit at call sites.

→ Full patterns with examples →


Multi‑User Isolation (VirtualPath)

VirtualPath (opt-in via virtual-path feature) is for containment scenarios: multi-tenant systems, malware sandboxes, security research.

  • Per-user: Create VirtualRoot per user, call virtual_join(..) for all operations
  • Share helpers: Borrow strict view with vpath.as_unvirtual()
  • Use for: Multi-tenant isolation, observing malicious behavior safely

NOT for: Archive extraction (use StrictPath to detect attacks, not hide them)

→ Full multi-tenant example →


Interop & Display

Interop (pass to AsRef<Path> APIs): path.interop_path() — no allocations

Display:

  • System paths: strictpath_display() on PathBoundary/StrictPath
  • User-facing: virtualpath_display() on VirtualPath

Never: interop_path().to_string_lossy() for display


Directory Discovery vs Validation

Discovery (walking): Use .read_dir(), collect names via entry.file_name()

Validation: Re-join discovered names through strict_join/virtual_join before I/O

#![allow(unused)]
fn main() {
for entry in boundary.read_dir()? {
    let name = entry?.file_name();
    let validated = boundary.strict_join(&name.to_string_lossy())?; // Validate!
    // Now safe to use validated path
}
}

Don't validate constants like "." — only validate untrusted segments.


Multi‑User Isolation (VirtualPath for Containment)

Note: VirtualPath is opt-in via the virtual-path feature. Use it when you need containment (multi-tenant isolation, sandboxes) rather than detection (security boundaries).

  • Per‑user/tenant: for small flows, construct a root via VirtualPath::with_root(..) and join untrusted names with virtual_join(..). For larger flows and reuse, create a VirtualRoot per user and call 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)
}

// Sugar-first call site (one-off):
// let vroot = VirtualPath::with_root(format!("./cloud/user_{user_id}"))?;
// let vpath = vroot.virtual_join(filename)?; // same guarantees; keep VirtualRoot for reuse
}

When to use each for archives:

  • StrictPath for production archive extraction — detect malicious paths, reject compromised archives, alert users
  • VirtualPath for sandbox/research — safely analyze suspicious archives by containing escapes while observing behavior
  • StrictPath for file uploads to shared storage — reject attacks at the security boundary
  • StrictPath for config loading — fail explicitly on untrusted paths that try to escape

The key: use StrictPath to detect attacks in production; use VirtualPath to contain behavior in research/analysis scenarios.

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 boundary.read_dir() (or vroot.read_dir()), collect names via entry.file_name(), then re‑join with strict_join/virtual_join to validate before I/O.
  • Validation: join those relatives via boundary.strict_join(..) or vroot.virtual_join(..) before I/O. For small flows without a reusable root, you can construct via StrictPath::with_boundary(..) or VirtualPath::with_root(..) and then join.
  • Don’t validate constants like "."; only validate untrusted segments.

Common Operations (Quick Reference)

Always use dimension-specific methods (strict_* / virtualpath_*). Never use std::path methods on leaked paths.

Available operations:

  • Joins: strict_join(..) / virtual_join(..) — validate and combine paths
  • Parents: strictpath_parent() / virtualpath_parent() — navigate up directory tree
  • Filenames: strictpath_with_file_name(..) / strictpath_with_extension(..) — modify names/extensions
  • Rename: strict_rename(..) / virtual_rename(..) — move/rename within boundary
  • Deletion: .remove_file(), .remove_dir(), .remove_dir_all() — safe deletion
  • Metadata: .metadata(), .exists(), .is_file(), .is_dir() — inspect properties
  • Copy: .copy(&dest) — duplicate files
  • I/O: .read(), .read_to_string(), .write(), .create_file() — file operations

→ Complete operations guide with examples →

Naming Conventions

Variables reflect domain, not type:

  • ✅ Good: config_dir, uploads_root, archive_src, tenant_vroot
  • ❌ Bad: boundary, jail, source_ prefix, _path suffix

Keep names consistent with the directory they represent.


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

  • Encode guarantees in function signatures
  • Use *_create constructors for temp directories in tests
  • Prefer offline simulations with deterministic inputs
  • Clean up test directories after tests

Learn More

All detailed content has been moved to focused chapters for digestibility:

Core Concepts:

Practical Guides:

Advanced Topics:


Quick Reference Card

Type Selection (30 seconds)

Input SourceDefault ChoiceNotes
HTTP/Web/LLM/ArchivesStrictPathDetect attacks, reject bad input
Multi-tenant isolationVirtualPathContain per-user, clean UI paths
Trusted/hardcodedPath/PathBufOnly validate when mixing untrusted

Sugar vs Policy Types

NeedUse
One-off operationSugar: with_boundary(..) / with_root(..)
Reuse, performance, testPolicy: PathBoundary / VirtualRoot

Core Operations

#![allow(unused)]
fn main() {
// Validate
let file = boundary.strict_join("path")?;

// I/O
file.write(b"data")?;
let content = file.read_to_string()?;

// Metadata
if file.exists() && file.metadata()?.len() > 0 { }

// Rename/Move
let renamed = file.strict_rename("newpath")?;

// Display
println!("System: {}", file.strictpath_display());
println!("User: {}", vpath.virtualpath_display()); // VirtualPath only
}

Do / Don't Checklist

✅ DO:

  • Validate untrusted segments before I/O
  • Pass &StrictPath / &VirtualPath to encode guarantees
  • Use dimension-specific methods (strict_* / virtualpath_*)
  • Call interop_path() only for AsRef<Path> APIs
  • Name variables by domain (uploads_root, config_dir)

❌ DON'T:

  • Wrap secure types in Path::new() / PathBuf::from()
  • Use std::path methods on leaked paths
  • Use interop_path() for display (use *_display())
  • Construct boundaries inside helpers
  • Validate constants like "." (only untrusted segments)

Anti-Patterns Reference

For detailed anti-patterns and fixes, see: Anti-Patterns Guide →


That's it! This page is your quick reference. Dive into the focused chapters when you need details.

For source-level API documentation: API Reference (strict-path crate docs) →

Why Every "Simple" Solution Fails

The path security rabbit hole is deeper than you think.

Every developer's first instinct: "I'll just validate the path with a simple check." But path security isn't simple—it's a problem class with dozens of interacting edge cases. 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"); 
}
}

What it blocks:

  • ✅ Basic traversal: "../../../etc/passwd"

What bypasses it:

  • ❌ URL encoding: "..%2F..%2F..%2Fetc%2Fpasswd"
  • ❌ Double encoding: "....//....//etc//passwd""..//..//etc//passwd" after one replacement
  • ❌ Windows separators: "..\\..\\..\etc\passwd"
  • ❌ Mixed separators: "../\\../etc/passwd"

Verdict: String matching is insufficient. Attackers use encoding tricks.


Approach 2: "Use canonicalize() then check"

#![allow(unused)]
fn main() {
let canonical = fs::canonicalize(path)?;
if !canonical.starts_with("/safe/") { 
    return Err("Escape attempt"); 
}
}

What it blocks:

  • ✅ Most directory traversal attempts
  • ✅ Resolves symlinks correctly

What it misses:

  • CVE-2022-21658: Race condition (TOCTOU) - symlink created between canonicalize() and the check
  • CVE-2019-9855: Windows 8.3 short names ("PROGRA~1""Program Files") bypass string checks
  • ❌ Fails on non-existent files (can't canonicalize paths that don't exist yet)
  • ❌ Requires filesystem access for every validation (performance cost)

Verdict: Race conditions and platform quirks make this dangerous.


Approach 3: "Normalize the path first"

#![allow(unused)]
fn main() {
let normalized = path.replace("\\", "/").replace("../", "");
}

What it blocks:

  • ✅ Basic traversal patterns

What bypasses it:

  • ❌ Recursive patterns: "....//....//etc//passwd""..\\..\\etc\\passwd" after one replacement
  • CVE-2020-12279: Unicode normalization attacks ("..∕..∕etc∕passwd" - different Unicode slashes)
  • CVE-2017-17793: NTFS Alternate Data Streams ("file.txt:hidden:$DATA")
  • ❌ Absolute path replacement: "/etc/passwd" completely replaces the base path
  • ❌ UNC paths on Windows: "\\\\?\\C:\\Windows\\..\\..\\.."

Verdict: String replacement creates new attack vectors.


Approach 4: "Use an allowlist of safe characters"

#![allow(unused)]
fn main() {
if !path.chars().all(|c| c.is_alphanumeric() || c == '/') { 
    return Err("Invalid"); 
}
}

What it blocks:

  • ✅ Most special characters and encoding tricks

What it misses:

  • ❌ Absolute path replacement: "/etc/passwd" (all valid chars!)
  • ❌ Too restrictive: blocks legitimate files like "report-2025.pdf", "user_data.json"
  • CVE-2025-8088: Misses platform-specific issues (Windows device names: "CON", "PRN", "NUL")
  • ❌ Doesn't handle Unicode properly (internationalized filenames)

Verdict: Either too restrictive (breaks legitimate use) or still vulnerable.


Approach 5: "Combine multiple checks"

#![allow(unused)]
fn main() {
// Check for ../, canonicalize, validate prefix, sanitize chars, check length...
fn validate_path(path: &str) -> Result<PathBuf, Error> {
    if path.contains("../") { return Err("traversal"); }
    if path.contains("\\") { return Err("backslash"); }
    if path.starts_with("/") { return Err("absolute"); }
    // ... 20 more checks ...
    let canonical = fs::canonicalize(path)?;
    if !canonical.starts_with("/safe/") { return Err("escape"); }
    Ok(canonical)
}
}

What it blocks:

  • ✅ Many known attack vectors
  • ✅ Shows defensive programming

What it misses:

  • Complexity = Bugs: 20+ edge cases means maintenance nightmare
  • Platform gaps: Windows behavior ≠ Unix behavior ≠ Web behavior
  • Performance cost: Multiple filesystem calls per validation
  • Future CVEs: New attack vectors require updating every check
  • False sense of security: Hard to verify you've covered everything

Verdict: Complex validation logic is error-prone and incomplete.


The Fundamental Problem

Each "fix" creates new attack surface.

Path security isn't a single problem—it's a problem class with complex interactions:

The 5 Core Challenges

  1. Encoding Normalization

    • Must handle URL encoding, Unicode, platform-specific encodings
    • Can't break legitimate international filenames
    • Attackers exploit normalization edge cases
  2. Symlink Resolution

    • Must follow symlinks safely
    • Prevent race conditions (TOCTOU attacks)
    • Handle symlink cycles and bombs
    • Validate symlink targets stay within boundaries
  3. Platform Consistency

    • Windows ≠ Unix ≠ Web
    • Case sensitivity differences
    • Path separator differences
    • Platform-specific features (8.3 names, UNC paths, Alternate Data Streams, device names)
  4. Boundary Enforcement

    • Must be mathematical, not string-based
    • Resist all encoding and normalization tricks
    • Work for both existing and non-existent paths
    • Handle absolute vs. relative path semantics correctly
  5. Future-Proof Design

    • Resistant to new attack vectors
    • Doesn't require updating for every new CVE
    • Compositional security properties
    • No "clever hacks" that break later

Why This Is Hard

Each validation approach fixes one or two challenges while introducing new vulnerabilities in the others. You'd need:

  • Deep filesystem expertise across all platforms
  • Knowledge of dozens of path-related CVEs
  • Months of testing edge cases
  • Ongoing maintenance as new attacks emerge

This is why strict-path exists.


The Solution: Solve the Problem Class Once

Instead of patching individual vulnerabilities, strict-path solves the entire problem class:

  • Built on soft-canonicalize: Battle-tested against 19+ real CVEs
  • Mathematical boundary proofs: Type system guarantees paths stay within bounds
  • Platform-aware: Handles Windows 8.3 names, UNC paths, symlinks, junctions
  • Future-proof: Architectural design resists entire classes of attacks
  • Composable: Safe by construction, not by validation
#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// One line replaces all the complexity above
let boundary = PathBoundary::try_new("./safe")?;
let safe_path = boundary.strict_join(user_input)?; // ✅ All attacks blocked
}

The trade-off: Learn one crate's API vs. implementing (and maintaining) dozens of validation checks.


Learn More

Real-World Patterns

Production-ready examples showing how to use strict-path in common scenarios.

This chapter provides complete, copy-pasteable examples for typical use cases. Each pattern includes error handling, best practices, and explanations.


LLM Agent File Manager

Challenge: LLM-generated paths are untrusted by definition—they could suggest anything from legitimate filenames to sophisticated traversal attacks.

Solution: Use StrictPath to detect and reject escape attempts explicitly.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// Encode guarantees in signature: pass workspace directory boundary and untrusted request
async fn llm_file_operation(
    workspace_dir: &PathBoundary,
    request: &LlmRequest
) -> Result<String, Box<dyn std::error::Error>> {
    // LLM could suggest anything: "../../../etc/passwd", "C:/Windows/System32", etc.
    let safe_path = workspace_dir.strict_join(&request.filename)?; // ✅ Attack = Error

    match request.operation.as_str() {
        "write" => {
            safe_path.create_parent_dir_all()?;
            safe_path.write(&request.content)?;
        },
        "read" => {
            return Ok(safe_path.read_to_string()?);
        },
        "delete" => {
            safe_path.remove_file()?;
        },
        _ => return Err("Invalid operation".into()),
    }
    Ok(format!("File {} processed safely", safe_path.strictpath_display()))
}

// Stub types
struct LlmRequest {
    filename: String,
    operation: String,
    content: Vec<u8>,
}
}

Key points:

  • Pass &PathBoundary into the helper—boundary choice is policy
  • Reject escape attempts explicitly with ? operator
  • Use strictpath_display() for system-facing output
  • Create parent directories explicitly when needed

Archive Extraction: Detect vs. Contain

Critical distinction: Choose the right tool based on whether escapes are attacks or expected behavior.

Pattern 1: Detect Malicious Archives (Production)

When to use: Production archive extraction where malicious paths indicate a compromised archive.

Solution: Use StrictPath to detect and reject compromised archives:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn extract_zip_strict(
    zip_entries: impl IntoIterator<Item = (String, Vec<u8>)>
) -> Result<(), Box<dyn std::error::Error>> {
    let extract_dir = PathBoundary::try_new_create("./extracted")?;
    
    for (name, data) in zip_entries {
        // Malicious names like "../../../etc/passwd" return Error
        match extract_dir.strict_join(&name) {
            Ok(safe_path) => {
                safe_path.create_parent_dir_all()?;
                safe_path.write(&data)?;
            },
            Err(e) => {
                eprintln!("🚨 Malicious path detected: {name}");
                eprintln!("Error: {e}");
                return Err(format!("Archive contains malicious path: {name}").into());
            }
        }
    }
    Ok(())
}
}

Benefits:

  • Detects compromised archives immediately
  • Allows logging and alerting on attacks
  • Fails fast—doesn't partially extract malicious content
  • Users know their archive was rejected

Pattern 2: Sandbox Suspicious Archives (Research/Analysis)

When to use: Malware analysis, security research, or safely inspecting untrusted archives.

Solution: Use VirtualPath to contain escape attempts while observing behavior:

#![allow(unused)]
fn main() {
#[cfg(feature = "virtual-path")]
use strict_path::VirtualPath;

#[cfg(feature = "virtual-path")]
fn extract_zip_sandbox(
    zip_entries: impl IntoIterator<Item = (String, Vec<u8>)>
) -> std::io::Result<()> {
    let extract_root = VirtualPath::with_root_create("./sandbox")?;
    
    for (name, data) in zip_entries {
        // Hostile names like "../../../etc/passwd" → "/etc/passwd" (safely clamped)
        let vpath = extract_root.virtual_join(&name)?;
        
        println!("Entry: {name}");
        println!("  Virtual path: {}", vpath.virtualpath_display());
        println!("  System path: {}", vpath.as_unvirtual().strictpath_display());
        
        vpath.create_parent_dir_all()?;
        vpath.write(&data)?;
    }
    Ok(())
}
}

Benefits:

  • Escapes are contained—observe malicious behavior safely
  • See what paths the archive tried to write
  • Perfect for forensic analysis
  • No partial extraction issues

When to Use Which

ScenarioUse Pattern 1 (StrictPath)Use Pattern 2 (VirtualPath)
Production extraction✅ Detect attacks❌ Hides attacks
File uploads✅ Reject at boundary❌ Hides attacks
Malware analysis❌ Can't observe behavior✅ Safe observation
Security research❌ Escapes prevent analysis✅ Contained escapes
User-facing services✅ Users know it's malicious❌ Silently "fixes" it

Rule of thumb: Use StrictPath (detect) for production; use VirtualPath (contain) for research/analysis.


Web File Server

Challenge: Prevent directory traversal attacks while serving static files, and ensure user uploads can't be served as static assets.

Solution: Use marker types to enforce domain separation at compile time.

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

struct StaticFiles;    // CSS, JS, images
struct UserUploads;    // User documents

async fn serve_static(
    static_dir: &PathBoundary<StaticFiles>,
    path: &str
) -> Result<Response, Box<dyn std::error::Error>> {
    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: &StrictPath<StaticFiles>) -> Response {
    Response::new(safe_path.read().unwrap_or_default())
}

// This function CANNOT accept UserUploads paths - compile error!
fn handle_request(
    static_files_dir: &PathBoundary<StaticFiles>,
    user_uploads_dir: &PathBoundary<UserUploads>,
    request_path: &str
) -> Result<Response, Box<dyn std::error::Error>> {
    let static_file = static_files_dir.strict_join(request_path)?;
    let _response = serve_file(&static_file); // ✅ Works
    
    let user_file = user_uploads_dir.strict_join(request_path)?;
    // serve_file(&user_file); // ❌ Compile error: wrong domain!
    
    Ok(Response::new(Vec::new()))
}

// Stub types
struct Response { data: Vec<u8> }
impl Response {
    fn new(data: Vec<u8>) -> Self { Response { data } }
}
}

Key benefits:

  • Marker types prevent cross-domain mix-ups at compile time
  • Function signatures encode security requirements
  • No runtime validation needed when types guarantee safety
  • Refactoring changes propagate through type system

Configuration Manager

Challenge: Load configuration files safely when the filename comes from user input or external sources.

Solution: Validate config file paths before loading; encode validation state in function signatures.

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

struct UserConfigs;

fn load_user_config(
    config_dir: &PathBoundary<UserConfigs>,
    config_name: &str
) -> Result<Config, Box<dyn std::error::Error>> {
    let config_file = config_dir.strict_join(config_name)?;
    
    // Use built-in I/O helpers
    let content = config_file.read_to_string()?;
    Ok(serde_json::from_str(&content)?)
}

fn save_user_config(
    config_file: &StrictPath<UserConfigs>,
    config: &Config
) -> Result<(), Box<dyn std::error::Error>> {
    // Function signature guarantees path is already validated
    let json = serde_json::to_string_pretty(config)?;
    config_file.write(json.as_bytes())?;
    Ok(())
}

// Stub types
struct Config { setting: String }
}

Pattern notes:

  • Pass &PathBoundary when validation is needed in the helper
  • Pass &StrictPath when validation already happened at call site
  • Use built-in I/O methods to avoid .interop_path() calls
  • Marker types document which config directory is being accessed

Multi-Tenant Cloud Storage

Challenge: Each user needs isolated storage where they can't access other users' files, and paths should look clean (no system paths exposed).

Solution: Use VirtualPath to create per-user isolated filesystems with clean rooted paths.

#![allow(unused)]
fn main() {
#[cfg(feature = "virtual-path")]
use strict_path::{VirtualRoot, VirtualPath};

#[cfg(feature = "virtual-path")]
async fn handle_upload(
    user_root: &VirtualRoot,
    filename: &str,
    bytes: &[u8]
) -> std::io::Result<()> {
    // User can request ANY path - always safely contained
    let vpath = user_root.virtual_join(filename)?;
    
    vpath.create_parent_dir_all()?;
    vpath.write(bytes)?;
    
    // Show user-friendly path
    println!("Saved: {}", vpath.virtualpath_display());
    // Output: "Saved: /documents/report.pdf"
    // (Real path: storage/user_42/documents/report.pdf)
    
    Ok(())
}

#[cfg(feature = "virtual-path")]
async fn handle_download(
    user_root: &VirtualRoot,
    filename: &str
) -> std::io::Result<Vec<u8>> {
    let vpath = user_root.virtual_join(filename)?;
    
    // Share strict helper logic by borrowing
    vpath.as_unvirtual().read()
}

#[cfg(feature = "virtual-path")]
fn setup_user_storage(user_id: u64) -> Result<VirtualRoot, Box<dyn std::error::Error>> {
    let user_root = VirtualRoot::try_new_create(format!("storage/user_{user_id}"))?;
    Ok(user_root)
}
}

Isolation benefits:

  • Users see clean paths like /documents/report.pdf
  • Real path hidden: storage/user_42/documents/report.pdf
  • Escape attempts silently contained within user's boundary
  • Each user's / is their own root—complete isolation
  • Share strict helpers with as_unvirtual() borrowing

Common Quick Patterns

Validate + Write

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn write_file(boundary: &PathBoundary, name: &str, data: &[u8]) -> std::io::Result<()> {
    let safe_path = boundary.strict_join(name)?;
    safe_path.create_parent_dir_all()?;
    safe_path.write(data)
}
}

Validate + Read

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn read_file(boundary: &PathBoundary, name: &str) -> std::io::Result<String> {
    boundary.strict_join(name)?.read_to_string()
}
}

Directory Walking with Validation

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn process_directory(base_dir: &PathBoundary) -> std::io::Result<Vec<StrictPath>> {
    let mut paths = Vec::new();
    
    // Walk the directory
    for entry in base_dir.read_dir()? {
        let entry = entry?;
        let name = entry.file_name();
        
        // Re-validate each discovered name before use
        let safe_path = base_dir.strict_join(&name.to_string_lossy())?;
        paths.push(safe_path);
    }
    
    Ok(paths)
}
}

Error Handling with Security Logging

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPathError};

fn robust_file_access(
    boundary: &PathBoundary,
    filename: &str
) -> Result<String, Box<dyn std::error::Error>> {
    match boundary.strict_join(filename) {
        Ok(safe_path) => {
            match safe_path.read_to_string() {
                Ok(content) => Ok(content),
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                    // File doesn't exist - create default
                    safe_path.write(b"default content")?;
                    Ok("default content".to_string())
                },
                Err(e) => Err(e.into()),
            }
        },
        Err(StrictPathError::PathEscapesBoundary { .. }) => {
            // Log security incident
            eprintln!("🚨 Path escape attempt: {filename}");
            Err("Invalid path".into())
        },
        Err(e) => Err(e.into()),
    }
}
}

Learn More

Common Operations Guide

Complete reference for all path operations with strict-path.

This chapter provides comprehensive examples for every operation you'll need when working with validated paths. Always use dimension-specific methods—never use std::path methods on leaked paths.


Joins

Purpose: Combine a boundary/root with an untrusted segment to create a validated path.

Basic Joins

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn join_examples(boundary: &PathBoundary) -> std::io::Result<()> {
    // Single join
    let file = boundary.strict_join("docs/readme.md")?;
    
    // Join with slash or backslash - both work
    let file2 = boundary.strict_join("docs\\readme.md")?;
    
    // Multi-segment path
    let deep = boundary.strict_join("a/b/c/d/file.txt")?;
    
    Ok(())
}
}

Chained Joins

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn chained_joins(boundary: &PathBoundary) -> std::io::Result<()> {
    // Navigate through directories
    let level1 = boundary.strict_join("level1")?;
    let level2 = level1.strict_join("level2")?;
    let file = level2.strict_join("file.txt")?;
    
    // Or go up and down
    let sibling = level2
        .strictpath_parent().unwrap()
        .strict_join("sibling/file.txt")?;
    
    Ok(())
}
}

Joining Discovered Names

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn discover_and_join(boundary: &PathBoundary) -> std::io::Result<Vec<StrictPath>> {
    let mut files = Vec::new();
    
    // Walk directory
    for entry in boundary.read_dir()? {
        let entry = entry?;
        let name = entry.file_name();
        
        // IMPORTANT: Re-validate each discovered name
        let validated = boundary.strict_join(&name.to_string_lossy())?;
        files.push(validated);
    }
    
    Ok(files)
}
}

Key rules:

  • Always validate untrusted segments with strict_join() or virtual_join()
  • Re-validate discovered directory names before using them
  • Never use std::path::Path::join() on untrusted input

Parents and Ancestors

Purpose: Navigate up the directory tree safely.

Getting Parent Directory

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn parent_examples(file: &StrictPath) -> std::io::Result<()> {
    // Get parent directory
    if let Some(parent) = file.strictpath_parent() {
        println!("Parent: {}", parent.strictpath_display());
        
        // Create parent if needed
        parent.create_dir_all()?;
    } else {
        println!("At boundary root");
    }
    
    Ok(())
}
}

Walking Up to Root

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn walk_to_root(file: &StrictPath) {
    let mut current = Some(file.clone());
    let mut level = 0;
    
    while let Some(path) = current {
        println!("Level {}: {}", level, path.strictpath_display());
        current = path.strictpath_parent();
        level += 1;
    }
}
}

Finding Ancestor with Specific Name

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn find_ancestor(file: &StrictPath, target_name: &str) -> Option<StrictPath> {
    let mut current = Some(file.clone());
    
    while let Some(path) = current {
        if path.strictpath_display().to_string().ends_with(target_name) {
            return Some(path);
        }
        current = path.strictpath_parent();
    }
    
    None
}
}

Key insight: strictpath_parent() returns None at the boundary root—you can't escape upward.


File Name and Extension Operations

Purpose: Modify path components while staying within the boundary.

Changing File Names

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn filename_operations(file: &StrictPath) -> std::io::Result<()> {
    // Change filename (keeps directory and extension)
    let renamed = file.strictpath_with_file_name("newname.txt")?;
    
    // Change just the stem (keeps extension)
    let new_stem = file.strictpath_with_file_name("report")?
        .strictpath_with_extension(
            file.strictpath_extension().unwrap_or("txt")
        )?;
    
    Ok(())
}
}

Changing Extensions

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn extension_operations(file: &StrictPath) -> std::io::Result<()> {
    // Change extension
    let markdown = file.strictpath_with_extension("md")?;
    let json = file.strictpath_with_extension("json")?;
    
    // Remove extension
    let no_ext = file.strictpath_with_extension("")?;
    
    // Add extension if missing
    let with_ext = if file.strictpath_extension().is_none() {
        file.strictpath_with_extension("txt")?
    } else {
        file.clone()
    };
    
    Ok(())
}
}

Combining Operations

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn combined_operations(file: &StrictPath) -> std::io::Result<()> {
    // Change both filename and extension
    let transformed = file
        .strictpath_with_file_name("report")?
        .strictpath_with_extension("pdf")?;
    
    // Add timestamp to filename
    let timestamp = "2025-10-14";
    let current_name = file.strictpath_file_stem().unwrap_or("file");
    let timestamped = file.strictpath_with_file_name(
        format!("{}_{}", current_name, timestamp)
    )?.strictpath_with_extension(
        file.strictpath_extension().unwrap_or("txt")
    )?;
    
    Ok(())
}
}

Rename and Move Operations

Purpose: Move files/directories while staying within the boundary.

Simple Rename (Same Directory)

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn simple_rename(boundary: &PathBoundary) -> std::io::Result<()> {
    let current = boundary.strict_join("logs/app.log")?;
    current.write(b"log data")?;
    
    // Rename returns the new path
    let renamed = current.strict_rename("logs/app.old")?;
    
    assert!(renamed.exists());
    assert!(!current.exists()); // Original path no longer exists
    
    Ok(())
}
}

Move to Different Directory

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn move_file(boundary: &PathBoundary) -> std::io::Result<()> {
    let source = boundary.strict_join("temp/file.txt")?;
    source.write(b"data")?;
    
    // Create destination directory first
    let dest_dir = boundary.strict_join("archive")?;
    dest_dir.create_dir_all()?;
    
    // Move to new directory
    let moved = source.strict_rename("archive/file.txt")?;
    
    Ok(())
}
}

Rename with Parent Directory Creation

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn rename_with_mkdir(boundary: &PathBoundary) -> std::io::Result<()> {
    let file = boundary.strict_join("data.txt")?;
    file.write(b"content")?;
    
    // Rename to path in subdirectory (create if needed)
    let new_path = boundary.strict_join("backups/2025/data.txt")?;
    if let Some(parent) = new_path.strictpath_parent() {
        parent.create_dir_all()?;
    }
    
    let renamed = file.strict_rename("backups/2025/data.txt")?;
    
    Ok(())
}
}

Virtual Rename (Clean Paths)

#![allow(unused)]
fn main() {
#[cfg(feature = "virtual-path")]
use strict_path::VirtualRoot;

#[cfg(feature = "virtual-path")]
fn virtual_rename_example(vroot: &VirtualRoot) -> std::io::Result<()> {
    let file = vroot.virtual_join("uploads/photo.jpg")?;
    file.write(b"image data")?;
    
    // Virtual rename - user sees clean paths
    let renamed = file.virtual_rename("uploads/photo_2025.jpg")?;
    
    println!("User sees: {}", renamed.virtualpath_display());
    // Output: "/uploads/photo_2025.jpg"
    
    println!("System path: {}", renamed.as_unvirtual().strictpath_display());
    // Output: actual filesystem path
    
    Ok(())
}
}

Deletion Operations

Purpose: Remove files and directories safely.

Delete Single File

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn delete_file(boundary: &PathBoundary) -> std::io::Result<()> {
    let file = boundary.strict_join("temp/cache.tmp")?;
    
    // Check existence before deleting
    if file.exists() {
        file.remove_file()?;
    }
    
    Ok(())
}
}

Delete Empty Directory

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn delete_empty_dir(boundary: &PathBoundary) -> std::io::Result<()> {
    let dir = boundary.strict_join("temp/empty")?;
    
    // Only works if directory is empty
    if dir.exists() && dir.metadata()?.is_dir() {
        dir.remove_dir()?;
    }
    
    Ok(())
}
}

Recursive Directory Deletion

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn delete_directory_tree(boundary: &PathBoundary) -> std::io::Result<()> {
    let dir = boundary.strict_join("temp/data")?;
    
    // Removes directory and ALL contents recursively
    if dir.exists() {
        dir.remove_dir_all()?;
    }
    
    Ok(())
}
}

Safe Cleanup with Validation

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn safe_cleanup(boundary: &PathBoundary, path: &str) -> std::io::Result<()> {
    // Validate path first
    match boundary.strict_join(path) {
        Ok(safe_path) => {
            if safe_path.exists() {
                if safe_path.metadata()?.is_dir() {
                    safe_path.remove_dir_all()?;
                } else {
                    safe_path.remove_file()?;
                }
                println!("Deleted: {}", safe_path.strictpath_display());
            }
            Ok(())
        },
        Err(e) => {
            eprintln!("🚨 Invalid path, refusing to delete: {e}");
            Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
        }
    }
}
}

Safety note: Always validate paths before deletion. Never delete based on untrusted input without validation.


Metadata Inspection

Purpose: Query file/directory properties without reading contents.

Basic Metadata

#![allow(unused)]
fn main() {
use strict_path::StrictPath;
use std::time::SystemTime;

fn inspect_metadata(file: &StrictPath) -> std::io::Result<()> {
    let meta = file.metadata()?;
    
    // File type checks
    println!("Is file: {}", meta.is_file());
    println!("Is directory: {}", meta.is_dir());
    println!("Is symlink: {}", meta.file_type().is_symlink());
    
    // Size and permissions
    println!("Size: {} bytes", meta.len());
    println!("Read-only: {}", meta.permissions().readonly());
    
    // Timestamps
    if let Ok(modified) = meta.modified() {
        let duration = SystemTime::now().duration_since(modified).unwrap();
        println!("Modified {} seconds ago", duration.as_secs());
    }
    
    Ok(())
}
}

Conditional Operations Based on Metadata

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn cleanup_empty_files(file: &StrictPath) -> std::io::Result<()> {
    let meta = file.metadata()?;
    
    if meta.is_file() && meta.len() == 0 {
        println!("Empty file, removing: {}", file.strictpath_display());
        file.remove_file()?;
    }
    
    Ok(())
}
}

Finding Files by Criteria

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn find_large_files(boundary: &PathBoundary, min_size: u64) -> std::io::Result<Vec<StrictPath>> {
    let mut large_files = Vec::new();
    
    for entry in boundary.read_dir()? {
        let entry = entry?;
        let name = entry.file_name();
        let path = boundary.strict_join(&name.to_string_lossy())?;
        
        if let Ok(meta) = path.metadata() {
            if meta.is_file() && meta.len() > min_size {
                large_files.push(path);
            }
        }
    }
    
    Ok(large_files)
}
}

Copy Operations

Purpose: Duplicate files while preserving validation.

Simple Copy

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn simple_copy(boundary: &PathBoundary) -> std::io::Result<()> {
    let source = boundary.strict_join("docs/original.txt")?;
    let dest = boundary.strict_join("docs/copy.txt")?;
    
    // Returns number of bytes copied
    let bytes_copied = source.copy(&dest)?;
    println!("Copied {bytes_copied} bytes");
    
    Ok(())
}
}

Copy with Overwrite Protection

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn copy_if_not_exists(boundary: &PathBoundary) -> std::io::Result<()> {
    let source = boundary.strict_join("docs/original.txt")?;
    let dest = boundary.strict_join("docs/backup.txt")?;
    
    if !dest.exists() {
        source.copy(&dest)?;
        println!("Copied to {}", dest.strictpath_display());
    } else {
        println!("Destination already exists, skipping");
    }
    
    Ok(())
}
}

Copy to Different Directory

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn copy_to_archive(boundary: &PathBoundary) -> std::io::Result<()> {
    let source = boundary.strict_join("docs/report.pdf")?;
    
    // Create backup directory
    let backup_dir = boundary.strict_join("backups/2025")?;
    backup_dir.create_dir_all()?;
    
    // Copy to backup location
    let dest = boundary.strict_join("backups/2025/report.pdf")?;
    source.copy(&dest)?;
    
    Ok(())
}
}

Comprehensive Example: File Management

Putting it all together—a complete file management function:

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};
use std::time::{SystemTime, Duration};

fn manage_user_file(
    uploads_dir: &PathBoundary,
    filename: &str
) -> Result<FileInfo, Box<dyn std::error::Error>> {
    // 1. Validate path
    let file = uploads_dir.strict_join(filename)?;
    
    // 2. Check existence
    if !file.exists() {
        return Err("File not found".into());
    }
    
    // 3. Get metadata
    let meta = file.metadata()?;
    
    // 4. Archive old files
    if should_archive(&meta)? {
        let archive_dir = uploads_dir.strict_join("archive")?;
        archive_dir.create_dir_all()?;
        
        let archived = file.strict_rename(&format!("archive/{filename}"))?;
        
        // 5. Compress large files
        if meta.len() > 10_000_000 {
            compress_file(&archived)?;
        }
        
        return Ok(FileInfo {
            path: archived.strictpath_display().to_string(),
            status: FileStatus::Archived,
            size: meta.len(),
        });
    }
    
    Ok(FileInfo {
        path: file.strictpath_display().to_string(),
        status: FileStatus::Active,
        size: meta.len(),
    })
}

fn should_archive(meta: &std::fs::Metadata) -> std::io::Result<bool> {
    let modified = meta.modified()?;
    let age = SystemTime::now().duration_since(modified)
        .unwrap_or(Duration::ZERO);
    
    Ok(age > Duration::from_secs(30 * 24 * 60 * 60)) // 30 days
}

fn compress_file(_file: &StrictPath) -> std::io::Result<()> {
    // Compression implementation
    Ok(())
}

#[derive(Debug)]
struct FileInfo {
    path: String,
    status: FileStatus,
    size: u64,
}

#[derive(Debug)]
enum FileStatus {
    Active,
    Archived,
}
}

Key Principles

Always use dimension-specific methods:

  • Use strict_join() / virtual_join() for joins
  • Use strictpath_parent() / virtualpath_parent() for parents
  • Use strictpath_with_*() / virtualpath_with_*() for modifications
  • Never use std::path methods on leaked paths

Handle errors explicitly:

  • Path operations can fail (permissions, disk full, invalid paths)
  • Use ? operator or explicit match for error handling
  • Log security incidents when paths escape boundaries

Check before destructive operations:

  • Use .exists() before deletion
  • Use .metadata() to check file vs. directory
  • Create parent directories with .create_dir_all() before moves

Validate discovered paths:

  • Re-validate directory entries with strict_join() / virtual_join()
  • Don't trust filesystem listings—validate before use

Learn More

Policy & Reuse Patterns

Why VirtualRoot and PathBoundary matter for correctness, performance, and maintainability.

The sugar constructors (StrictPath::with_boundary(..), VirtualPath::with_root(..)) are great for simple flows, but the root/boundary types still matter as your code grows. This chapter explains when and why to use them.


The Core Insight

Roots/boundaries represent security policy (the restriction), while paths represent validated values within that policy.

This separation enables:

  • Policy reuse without repeated validation
  • Clear function signatures that encode guarantees
  • Performance optimization through canonicalization caching
  • Better testing with injectable boundaries
  • Explicit deserialization with serde seeds

Policy Reuse and Separation of Concerns

Anti-Pattern: Reconstructing Policy

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// ❌ BAD: Reconstructing boundary for every file
fn process_files(base_path: &str, filenames: &[String]) -> std::io::Result<()> {
    for name in filenames {
        let boundary = PathBoundary::try_new(base_path)?; // Repeated canonicalization!
        let file = boundary.strict_join(name)?;
        file.write(b"data")?;
    }
    Ok(())
}
}

Problems:

  • Canonicalizes the same base path repeatedly (performance waste)
  • Hides policy choice inside the helper (reviewability issue)
  • Hard to test with different boundaries (testability issue)

Better: Policy Reuse

use strict_path::PathBoundary;

// ✅ GOOD: Construct policy once, reuse everywhere
fn process_files(boundary: &PathBoundary, filenames: &[String]) -> std::io::Result<()> {
    for name in filenames {
        let file = boundary.strict_join(name)?; // Reuses canonicalized boundary
        file.write(b"data")?;
    }
    Ok(())
}

// Usage: Policy choice explicit at call site
fn main() -> std::io::Result<()> {
    let uploads = PathBoundary::try_new("./uploads")?;
    let files = vec!["a.txt".to_string(), "b.txt".to_string()];
    
    process_files(&uploads, &files)?; // Clear what boundary is being used
    Ok(())
}

Benefits:

  • Canonicalizes once, reuses for all operations
  • Policy choice visible at call site
  • Easy to inject test boundaries
  • Reviewers see security decisions explicitly

Key insight: Don't construct boundaries inside helpers — boundary choice is policy; encoding it at call sites improves reviewability and testing.


Clear Function Signatures (Stronger Guarantees)

Two canonical patterns make intent obvious:

Pattern 1: Accept Validated Path

When to use: Validation already happened at the call site.

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

fn write_report(report_file: &StrictPath) -> std::io::Result<()> {
    // Guaranteed: path is already validated
    // No validation needed inside this function
    report_file.write(b"report data")
}
}

Benefits:

  • Function signature proves validation happened
  • No redundant validation inside
  • Clear contract: "I only accept validated paths"

Pattern 2: Accept Boundary + Segment

When to use: Validation happens inside the helper.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn load_config(config_dir: &PathBoundary, name: &str) -> std::io::Result<String> {
    // Validation happens inside this function
    config_dir.strict_join(name)?.read_to_string()
}
}

Benefits:

  • Helper performs validation explicitly
  • Reuses provided boundary (no policy choice inside)
  • Clear contract: "I validate against your boundary"

Usage Example: Patterns in Context

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

fn example_workflow() -> std::io::Result<()> {
    let reports_dir = PathBoundary::try_new("reports")?;
    let config_dir = PathBoundary::try_new("config")?;
    
    // Pattern 1: Validation at call site
    let report = reports_dir.strict_join("q4_2025.pdf")?;
    write_report(&report)?; // Function knows it's validated
    
    // Pattern 2: Validation inside helper
    let settings = load_config(&config_dir, "app.toml")?;
    
    Ok(())
}

fn write_report(report_file: &StrictPath) -> std::io::Result<()> {
    report_file.write(b"report data")
}

fn load_config(config_dir: &PathBoundary, name: &str) -> std::io::Result<String> {
    config_dir.strict_join(name)?.read_to_string()
}
}

Key insight: Signatures prevent helpers from "picking a root" silently, making security rules visible in code review.


Contextual Deserialization (Serde)

StrictPath/VirtualPath can't implement blanket Deserialize safely—they need runtime context (the boundary/root) to validate.

The Problem

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize)]
struct Config {
    name: String,
    // path: StrictPath, // ❌ Won't compile - no context for validation!
}
}

Solution 1: Deserialize Then Validate

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};
use serde::Deserialize;

#[derive(Deserialize)]
struct RawConfig {
    name: String,
    path: String, // Deserialize as string first
}

fn load_safe_config(json: &str) -> Result<StrictPath, Box<dyn std::error::Error>> {
    let config_dir = PathBoundary::try_new("./configs")?;
    
    // 1. Deserialize raw data
    let raw: RawConfig = serde_json::from_str(json)?;
    
    // 2. Validate against boundary
    let safe_path = config_dir.strict_join(&raw.path)?;
    
    Ok(safe_path)
}
}

Solution 2: Serde Seeds (Advanced)

#![allow(unused)]
fn main() {
#[cfg(feature = "serde")]
use strict_path::{PathBoundary, StrictPath, serde_ext};

#[cfg(feature = "serde")]
fn deserialize_with_seed(
    json: &str,
    boundary: &PathBoundary
) -> Result<StrictPath, Box<dyn std::error::Error>> {
    // Seed provides validation context
    let seed = serde_ext::WithBoundary(boundary);
    let safe_path: StrictPath = serde_json::from_str(json)?;
    Ok(safe_path)
}
}

Key insight: Deserialization is explicit and auditable—where did the policy come from? What are we validating against?


Performance and Canonicalization

Canonicalize the root once; strict/virtual joins reuse that canonicalized state.

Performance Anti-Pattern

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn slow_approach(files: &[String]) -> std::io::Result<()> {
    // ❌ SLOW: Canonicalizes base path 1000 times
    for name in files {
        let boundary = PathBoundary::try_new("./data")?; // Filesystem call every time!
        let _file = boundary.strict_join(name)?;
    }
    Ok(())
}
}

Performance Optimization

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn fast_approach(files: &[String]) -> std::io::Result<()> {
    // ✅ FAST: Canonicalizes base path once, reuses for all joins
    let boundary = PathBoundary::try_new("./data")?; // Single filesystem call
    
    for name in files {
        let _file = boundary.strict_join(name)?; // Reuses canonical state
    }
    
    Ok(())
}
}

Benchmark Comparison

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn benchmark_comparison() -> std::io::Result<()> {
    let files: Vec<String> = (0..1000).map(|i| format!("file{i}.txt")).collect();
    
    // Slow: ~1000 canonicalization calls
    for name in &files {
        let boundary = PathBoundary::try_new("./data")?;
        let _ = boundary.strict_join(name)?;
    }
    
    // Fast: 1 canonicalization call + 1000 cheap joins
    let boundary = PathBoundary::try_new("./data")?;
    for name in &files {
        let _ = boundary.strict_join(name)?;
    }
    
    Ok(())
}
}

Performance benefit: Virtual joins use anchored canonicalization to apply virtual semantics safely and consistently without repeated filesystem calls.


Auditability and Testing

Centralizing policy in a root value simplifies logging, tracing, and tests.

Testable Helper

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

// Easy to inject test boundaries
fn save_user_data(
    uploads_dir: &PathBoundary,
    filename: &str,
    data: &[u8]
) -> std::io::Result<()> {
    let file = uploads_dir.strict_join(filename)?;
    file.create_parent_dir_all()?;
    file.write(data)
}
}

Testing with Injected Boundaries

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_with_temp_boundary() -> std::io::Result<()> {
        // Create test boundary
        let temp_dir = std::env::temp_dir().join("test_uploads");
        std::fs::create_dir_all(&temp_dir)?;
        let boundary = PathBoundary::try_new(&temp_dir)?;
        
        // Test with injected boundary
        save_user_data(&boundary, "test.txt", b"data")?;
        
        // Verify
        let file = boundary.strict_join("test.txt")?;
        assert_eq!(file.read()?, b"data");
        
        // Cleanup
        std::fs::remove_dir_all(&temp_dir)?;
        Ok(())
    }
    
    #[test]
    fn test_rejects_escapes() {
        let boundary = PathBoundary::try_new(".").unwrap();
        
        // Verify security properties
        assert!(save_user_data(&boundary, "../../../etc/passwd", b"data").is_err());
    }
}
}

Testing benefits:

  • Pass &boundary into helpers for easy mocking
  • Test with different boundaries (temp dirs, test fixtures)
  • Verify security properties with escape attempt tests
  • No need to mock filesystem for unit tests

Debug verbosity: VirtualPath::Debug is intentionally verbose (system path + virtual view + restriction root + marker) to aid audits and troubleshooting.


When Not to Use Policy Types

If your flow is small, local, and won't be reused, the sugar constructors are perfectly fine:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

// ✅ Fine for simple, one-off operations
fn quick_write() -> std::io::Result<()> {
    let file = StrictPath::with_boundary_create("./temp")?
        .strict_join("quick.txt")?;
    file.write(b"data")
}
}

Rule of thumb: Start with sugar; upgrade to PathBoundary/VirtualRoot when you need:

  • Policy reuse across multiple operations
  • Performance optimization (many joins against same root)
  • Serde integration with contextual deserialization
  • Testability with injectable boundaries
  • Shared helpers that accept boundaries

Summary: When to Use What

ScenarioUse Sugar ConstructorUse Policy Type
One-off file operationwith_boundary()Optional
Multiple joins against root⚠️ SuboptimalPathBoundary
Reusable helper functions❌ Hidden policy choice✅ Accept &PathBoundary
Performance-critical loops❌ Repeated canonicalization✅ Canonicalize once
Serde deserialization❌ No validation context✅ Use serde seeds
Testing with mock boundaries❌ Hard to inject✅ Pass &PathBoundary param
Simple scripts/prototypes✅ Quick and ergonomicOptional

Learn More

Authorization Architecture with Markers

Move authorization bugs from "runtime disasters" into "won't compile" problems.

Marker types enable compile-time authorization architectures where the compiler mathematically proves that any path with an authorization-requiring marker went through proper authorization.

This chapter shows three levels of authorization patterns: basic authentication, permission tuples, and dynamic elevation.


Core Concept: Markers as Proof

Key insight: A marker with a private field can only be constructed by authorized code. Functions requiring that marker have compile-time proof that authorization happened.

#![allow(unused)]
fn main() {
struct UserHome { 
    _proof: ()  // Private field = can't construct outside this module
}

// This function signature enforces authentication
fn read_user_file(file: &strict_path::StrictPath<UserHome>) -> std::io::Result<String> {
    // Guaranteed: path is validated AND user was authenticated
    file.read_to_string()
}
}

Without a UserHome marker, you cannot call read_user_file(). The compiler enforces this.


Level 1: Basic Authentication Markers

Use markers with private fields to prove authentication happened.

Implementation

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

// Marker describes user's home directory with compile-time proof
struct UserHome { 
    _proof: ()  // Private field prevents construction outside this module
}

impl UserHome {
    /// Authenticates user and returns authorization marker
    pub fn authenticate(token: &AuthToken) -> Result<Self, AuthError> {
        // Real authentication logic here (verify JWT, session, etc.)
        if verify_token(token)? {
            Ok(UserHome { _proof: () })  // Grant marker after verification
        } else {
            Err(AuthError::InvalidToken)
        }
    }
}

// Functions require pre-authorized paths
fn read_user_file(file: &StrictPath<UserHome>) -> std::io::Result<String> {
    // Guaranteed: path is safe AND user was authenticated
    file.read_to_string()
}

fn list_user_files(dir: &PathBoundary<UserHome>) -> std::io::Result<Vec<String>> {
    let mut names = Vec::new();
    for entry in dir.strict_join("")?.read_dir()? {
        let entry = entry?;
        names.push(entry.file_name().to_string_lossy().to_string());
    }
    Ok(names)
}

// Usage: authentication required to get marker
fn handle_request(
    token: &AuthToken,
    filename: &str
) -> Result<String, Box<dyn std::error::Error>> {
    // Authentication checkpoint
    let _auth = UserHome::authenticate(token)?;
    
    // Create boundary with authorized marker
    let username = token.username();
    let home_dir = PathBoundary::<UserHome>::try_new(format!("/home/{username}"))?;
    
    // Path inherits authorization from boundary
    let file = home_dir.strict_join(filename)?;
    
    // Function call proves authentication happened
    Ok(read_user_file(&file)?)
}

// Stub types for example
struct AuthToken { username: String }
impl AuthToken {
    fn username(&self) -> &str { &self.username }
}
enum AuthError { InvalidToken }
fn verify_token(_token: &AuthToken) -> Result<(), AuthError> { Ok(()) }
}

Key Pattern Elements

  1. Private _proof field prevents external construction
  2. authenticate() constructor verifies credentials before granting marker
  3. Functions accept &StrictPath<UserHome> = compile-time proof
  4. Wrong marker = compile error (can't pass StrictPath<AdminFiles> to read_user_file())

Benefits:

  • Impossible to bypass authentication (can't construct marker without verifying)
  • Refactoring changes propagate through type system
  • Authentication logic centralized in marker constructor

Level 2: Tuple Markers for Permissions

Encode both domain and permission level in the type using tuple markers.

Implementation

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

// Domain markers
struct SystemFiles;
struct UserDocuments;

// Permission markers (private construction)
struct ReadOnly { _proof: () }
struct ReadWrite { _proof: () }
struct AdminPermission { _proof: () }

impl ReadOnly {
    pub fn grant_read(user: &User) -> Result<Self, PermissionError> {
        if user.can_read_system_files() {
            Ok(ReadOnly { _proof: () })
        } else {
            Err(PermissionError::Denied)
        }
    }
}

impl ReadWrite {
    pub fn grant_write(user: &User) -> Result<Self, PermissionError> {
        if user.can_write_documents() {
            Ok(ReadWrite { _proof: () })
        } else {
            Err(PermissionError::Denied)
        }
    }
}

impl AdminPermission {
    pub fn grant_admin(user: &User) -> Result<Self, PermissionError> {
        if user.is_admin() {
            Ok(AdminPermission { _proof: () })
        } else {
            Err(PermissionError::Denied)
        }
    }
}

// Functions encode both domain and permission requirements
fn view_system_file(
    path: &StrictPath<(SystemFiles, ReadOnly)>
) -> std::io::Result<String> {
    path.read_to_string()  // Can read but not modify
}

fn modify_system_file(
    path: &StrictPath<(SystemFiles, AdminPermission)>,
    data: &[u8]
) -> std::io::Result<()> {
    path.write(data)  // Requires admin permission
}

fn edit_user_document(
    path: &StrictPath<(UserDocuments, ReadWrite)>,
    data: &[u8]
) -> std::io::Result<()> {
    path.write(data)  // User documents + write permission
}

// Usage: Permission matrix enforced at compile time
fn user_workflow(user: &User) -> Result<(), Box<dyn std::error::Error>> {
    // Grant appropriate permissions
    let _read_perm = ReadOnly::grant_read(user)?;
    let _write_perm = ReadWrite::grant_write(user)?;
    
    // Create boundaries with permission markers
    let system_dir = PathBoundary::<(SystemFiles, ReadOnly)>::try_new("/etc")?;
    let docs_dir = PathBoundary::<(UserDocuments, ReadWrite)>::try_new("/home/user/docs")?;
    
    // Operations matched to permissions
    let config = system_dir.strict_join("app.conf")?;
    let content = view_system_file(&config)?;  // ✅ ReadOnly matches
    
    let doc = docs_dir.strict_join("notes.txt")?;
    edit_user_document(&doc, b"updated")?;  // ✅ ReadWrite matches
    
    // ❌ Compile error: wrong permission level
    // modify_system_file(&config, b"hacked")?;
    //   Expected: (SystemFiles, AdminPermission)
    //   Found:    (SystemFiles, ReadOnly)
    
    Ok(())
}

// Stub types
struct User { role: Role }
enum Role { Regular, Admin }
impl User {
    fn can_read_system_files(&self) -> bool { true }
    fn can_write_documents(&self) -> bool { !matches!(self.role, Role::Admin) }
    fn is_admin(&self) -> bool { matches!(self.role, Role::Admin) }
}
enum PermissionError { Denied }
}

Permission Matrix Enforced by Compiler

FunctionRequired MarkerWhat it Proves
view_system_file()(SystemFiles, ReadOnly)Domain = system, Permission = read
modify_system_file()(SystemFiles, AdminPermission)Domain = system, Permission = admin
edit_user_document()(UserDocuments, ReadWrite)Domain = user docs, Permission = write

Key insight: Wrong domain OR wrong permission = compile error. The type system enforces your entire permission matrix.


Level 3: Dynamic Authorization with change_marker()

Sometimes permission levels change after runtime checks. Use change_marker() to transform markers after verification.

Implementation

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct Documents;
struct ReadOnly { _proof: () }
struct ReadWrite { _proof: () }

impl ReadWrite {
    fn elevate(user: &User) -> Result<Self, PermissionError> {
        if user.has_write_permission() {
            Ok(ReadWrite { _proof: () })
        } else {
            Err(PermissionError::Denied)
        }
    }
}

fn escalate_permissions(
    user: &User,
    file: StrictPath<(Documents, ReadOnly)>
) -> Result<(), Box<dyn std::error::Error>> {
    // Start with read-only access
    let content = file.read_to_string()?;
    println!("Current content: {content}");
    
    // Check if user can write
    if let Ok(_write_perm) = ReadWrite::elevate(user) {
        // ✅ CORRECT: change_marker() after authorization check
        let writable: StrictPath<(Documents, ReadWrite)> = file.change_marker();
        writable.write(b"updated content")?;
        println!("Updated successfully");
    } else {
        println!("Read-only access - cannot modify");
    }
    
    Ok(())
}

// Stub types
struct User { can_write: bool }
impl User {
    fn has_write_permission(&self) -> bool { self.can_write }
}
enum PermissionError { Denied }
}

Critical Rule: Verify Before Transform

NEVER use change_marker() without authorization:

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

struct Documents;
struct ReadOnly;
struct ReadWrite;

// ❌ WRONG: Speculative marker change without verification
fn escalate_wrong(file: StrictPath<(Documents, ReadOnly)>) -> StrictPath<(Documents, ReadWrite)> {
    file.change_marker()  // No authorization check!
}

// ✅ CORRECT: Verify authorization first
fn escalate_correct(
    user: &User,
    file: StrictPath<(Documents, ReadOnly)>
) -> Result<StrictPath<(Documents, ReadWrite)>, PermissionError> {
    if user.has_write_permission() {
        Ok(file.change_marker())  // Transform after verification
    } else {
        Err(PermissionError::Denied)
    }
}

struct User { can_write: bool }
impl User {
    fn has_write_permission(&self) -> bool { self.can_write }
}
enum PermissionError { Denied }
}

When to use change_marker():

  • After authenticating/authorizing a user and granting different permissions
  • When escalating or downgrading access levels based on runtime checks
  • When reinterpreting a path's security context after validation

When NOT to use change_marker():

  • When converting between path types (conversions preserve markers automatically)
  • Without verifying authorization first (NEVER change markers speculatively)

Architecture Comparison Table

LevelMarker PatternCompile-Time GuaranteeRuntime Check LocationUse Case
Basic AuthStrictPath<UserHome>User was authenticatedMarker constructionProve login happened
PermissionsStrictPath<(Domain, Permission)>User has specific permission in domainPermission grantEnforce permission matrix
Dynamicchange_marker() after checkAuthorization verified before transformBefore change_marker()Runtime permission escalation

Real-World Example: Multi-Level Authorization

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

// Domain markers
struct ProjectFiles;

// Permission markers
struct Viewer { _proof: () }
struct Editor { _proof: () }
struct Owner { _proof: () }

impl Viewer {
    fn authenticate(user: &User, project_id: &str) -> Result<Self, AuthError> {
        if user.can_view(project_id) {
            Ok(Viewer { _proof: () })
        } else {
            Err(AuthError::Forbidden)
        }
    }
}

impl Editor {
    fn promote_from_viewer(user: &User, project_id: &str) -> Result<Self, AuthError> {
        if user.can_edit(project_id) {
            Ok(Editor { _proof: () })
        } else {
            Err(AuthError::Forbidden)
        }
    }
}

impl Owner {
    fn promote_from_editor(user: &User, project_id: &str) -> Result<Self, AuthError> {
        if user.is_owner(project_id) {
            Ok(Owner { _proof: () })
        } else {
            Err(AuthError::Forbidden)
        }
    }
}

// Functions with different permission requirements
fn read_project_file(file: &StrictPath<(ProjectFiles, Viewer)>) -> std::io::Result<String> {
    file.read_to_string()
}

fn update_project_file(
    file: &StrictPath<(ProjectFiles, Editor)>,
    data: &[u8]
) -> std::io::Result<()> {
    file.write(data)
}

fn delete_project(dir: &PathBoundary<(ProjectFiles, Owner)>) -> std::io::Result<()> {
    std::fs::remove_dir_all(dir.strict_join("")?.interop_path())
}

// Workflow: Dynamic permission escalation
fn handle_project_request(
    user: &User,
    project_id: &str,
    action: Action
) -> Result<(), Box<dyn std::error::Error>> {
    // Step 1: Basic authentication
    let _viewer = Viewer::authenticate(user, project_id)?;
    let project_dir = PathBoundary::<(ProjectFiles, Viewer)>::try_new(
        format!("/projects/{project_id}")
    )?;
    
    match action {
        Action::Read(filename) => {
            let file = project_dir.strict_join(&filename)?;
            let content = read_project_file(&file)?;
            println!("Content: {content}");
        },
        
        Action::Edit(filename, data) => {
            // Step 2: Escalate to Editor
            let _editor = Editor::promote_from_viewer(user, project_id)?;
            let project_dir_edit: PathBoundary<(ProjectFiles, Editor)> = 
                project_dir.change_marker();
            
            let file = project_dir_edit.strict_join(&filename)?;
            update_project_file(&file, data.as_bytes())?;
        },
        
        Action::Delete => {
            // Step 3: Escalate to Owner
            let _owner = Owner::promote_from_editor(user, project_id)?;
            let project_dir_owner: PathBoundary<(ProjectFiles, Owner)> = 
                project_dir.change_marker();
            
            delete_project(&project_dir_owner)?;
        },
    }
    
    Ok(())
}

// Stub types
struct User { id: String, permissions: Vec<String> }
impl User {
    fn can_view(&self, _project: &str) -> bool { true }
    fn can_edit(&self, project: &str) -> bool { 
        self.permissions.contains(&format!("edit:{project}"))
    }
    fn is_owner(&self, project: &str) -> bool {
        self.permissions.contains(&format!("own:{project}"))
    }
}
enum Action { Read(String), Edit(String, String), Delete }
enum AuthError { Forbidden }
}

Key patterns in this example:

  • Viewer → Editor → Owner escalation chain
  • Each level requires explicit runtime check
  • change_marker() called after verification
  • Compiler prevents calling higher-privilege functions with lower-privilege markers

Summary: Authorization Levels

Choose the right level for your needs:

NeedUse PatternExample
Prove login happenedBasic markerStrictPath<UserHome>
Enforce permission matrixTuple markersStrictPath<(Domain, Permission)>
Runtime permission changeschange_marker() after checkfile.change_marker::<ReadWrite>()

Core principle: Move authorization from "runtime checks we hope happen" to "compile-time proofs the compiler enforces."


Learn More

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(), .write(), 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")?;
    safe_path.strict_copy(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(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(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(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(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!

Ergonomics Overview

This section collects high-signal, copy-pasteable guidance for day-to-day use without re-explaining the security model. Each page is short and focused so you can jump directly to what you need.

New to strict-path?

Start with Daily Usage Patterns for common workflows and real-world examples.

Quick Reference

  • Builtin I/O Operations: Complete guide to file/directory operations, when to use builtin methods vs std::fs
  • Generic Functions & Markers: Write reusable functions with <M>, understand when to use generic vs specific markers
  • Daily Usage Patterns: Common workflows (user input validation, config loading, per-user isolation, archive extraction, etc.)
  • Interop vs Display: How to pass paths to std/third-party APIs vs how to render them for users
  • Function Signatures: Encode guarantees in types; when to accept strict/virtual vs roots + segments
  • Escape Hatches: Borrowing and ownership conversions; when to use them (sparingly)
  • Equality & Ordering: How comparisons work; what to compare and what not to
  • Naming Conventions: Domain-first naming that teaches intent in code review
  • Canonicalized vs Lexical: Choosing the right solution for your use case

For in-depth design and security rationale, see Best Practices and Anti-Patterns. This section stays focused on ergonomics.

Builtin I/O Operations

strict-path provides safe I/O helpers that maintain boundary security while eliminating the need for raw std::fs calls on leaked paths.

Why Use Builtin Methods?

Security: All operations stay within validated boundaries
Ergonomics: No need to call .interop_path() for common operations
Correctness: Type-checked paths guarantee safety at compile time

File Operations

Reading Files

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

let config_dir = PathBoundary::try_new("/etc/myapp")?;
let config_file = config_dir.strict_join("config.toml")?;

// Read entire file as string
let contents = config_file.read_to_string()?;

// Read as bytes
let bytes = config_file.read()?;
}

Writing Files

#![allow(unused)]
fn main() {
use strict_path::VirtualRoot;

let user_docs = VirtualRoot::try_new("/home/user/documents")?;
let report = user_docs.virtual_join("reports/summary.txt")?;

// Ensure parent directories exist
report.create_parent_dir_all()?;

// Write string content
report.write("Monthly Summary\n\nTotal: 42")?;

// Write bytes
report.write_bytes(b"Binary data")?;
}

File Handles

For streaming or more control, use file handles:

#![allow(unused)]
fn main() {
use std::io::{Read, Write};

// Create or truncate file, get writable handle
let mut file = report.create_file()?;
file.write_all(b"Line 1\n")?;
file.write_all(b"Line 2\n")?;

// Open for reading
let mut file = report.open_file()?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
}

Directory Operations

Creating Directories

#![allow(unused)]
fn main() {
// Create single directory (parent must exist)
let subdir = config_dir.strict_join("plugins")?;
subdir.create_dir()?;

// Create all parent directories if missing
let nested = config_dir.strict_join("data/cache/temp")?;
nested.create_dir_all()?;

// Non-recursive variant (VirtualPath)
let vdir = user_docs.virtual_join("archive")?;
vdir.create_dir_non_recursive()?; // Fails if parent missing
}

Listing Directory Contents

#![allow(unused)]
fn main() {
// Discovery: enumerate entries
for entry in config_dir.read_dir()? {
    let entry = entry?;
    let name = entry.file_name();
    
    // Re-validate discovered path before use
    let validated = config_dir.strict_join(&name)?;
    println!("Found: {}", validated.strictpath_display());
}
}

Removing Directories

#![allow(unused)]
fn main() {
// Remove empty directory
subdir.remove_dir()?;

// Remove directory and all contents (dangerous!)
nested.remove_dir_all()?;
}

Metadata Operations

Checking Existence and Type

#![allow(unused)]
fn main() {
// Check if path exists
if config_file.exists() {
    println!("Config file found");
}

// Check type
if config_file.is_file() {
    println!("It's a file");
}

if config_dir.is_dir() {
    println!("It's a directory");
}
}

Getting Metadata

#![allow(unused)]
fn main() {
// Get full metadata
let metadata = config_file.metadata()?;

println!("Size: {} bytes", metadata.len());
println!("Read-only: {}", metadata.permissions().readonly());
println!("Modified: {:?}", metadata.modified()?);
}
#![allow(unused)]
fn main() {
// Get metadata without following symlinks
let link_meta = config_file.symlink_metadata()?;

if link_meta.is_symlink() {
    println!("This is a symbolic link");
}
}

Copying Files

#![allow(unused)]
fn main() {
let source = config_dir.strict_join("template.conf")?;
let dest = config_dir.strict_join("active.conf")?;

// Copy file, returns bytes copied
let bytes = source.strict_copy(&dest)?;
println!("Copied {} bytes", bytes);
}

Renaming/Moving

#![allow(unused)]
fn main() {
let old_name = config_dir.strict_join("draft.txt")?;
let new_name = config_dir.strict_join("final.txt")?;

// Move/rename within same boundary
old_name.strict_rename(&new_name)?;
}
#![allow(unused)]
fn main() {
// Symbolic link
let target = config_dir.strict_join("current")?;
let link = config_dir.strict_join("link-to-current")?;
target.strict_symlink(&link)?;

// Hard link
target.strict_hard_link(&link)?;
}

Builtin vs std::fs Comparison

Operationstrict-pathstd::fsWhy Prefer Builtin?
Read file.read_to_string()?fs::read_to_string(path.interop_path())?Shorter, stays typed
Write file.write("content")?fs::write(path.interop_path(), "content")?No interop needed
Copy file.strict_copy(&dest)?fs::copy(src.interop_path(), dst.interop_path())?Both paths validated
Metadata.metadata()?fs::metadata(path.interop_path())?Cleaner, same result
Create dir.create_dir_all()?fs::create_dir_all(path.interop_path())?Type-safe path

When to Use interop_path()

Only for unavoidable third-party crates that demand AsRef<Path>:

#![allow(unused)]
fn main() {
// ✅ GOOD: Third-party crate requires AsRef<Path>
let image = config_dir.strict_join("logo.png")?;
let img = image::open(image.interop_path())?; // No choice

// ❌ BAD: Using interop when builtin exists
std::fs::read_to_string(image.interop_path())?; // Use .read_to_string() instead

// ✅ GOOD: Use builtin
let contents = image.read_to_string()?;
}

VirtualPath I/O Operations

All methods work on VirtualPath too, maintaining virtual display semantics:

#![allow(unused)]
fn main() {
let user_root = VirtualRoot::try_new("/var/lib/app/users/alice")?;
let doc = user_root.virtual_join("documents/readme.txt")?;

// All I/O operations available
doc.create_parent_dir_all()?;
doc.write("Welcome to your virtual filesystem!")?;

// Display shows virtual path
println!("Wrote to: {}", doc.virtualpath_display()); // "/documents/readme.txt"

// But I/O happens at real system location
println!("System location: {}", doc.as_unvirtual().strictpath_display());
}

Performance Notes

No overhead: Builtin methods call std::fs internally with zero abstraction cost.
Same syscalls: Operations compile to identical machine code as direct std::fs usage.
Validation cost: Only paid once during strict_join/virtual_join, not on every I/O operation.

Summary

  • Use builtin methods for all common I/O operations
  • Reserve .interop_path() for third-party crates
  • Both StrictPath and VirtualPath support the full I/O API
  • No performance penalty vs raw std::fs
  • Type safety and boundary security maintained automatically

Generic Functions and Marker Patterns

Learn how to write reusable functions that work with any marker type using Rust's generics.

The <M> Pattern

When you write <M>, you're saying "this function works with paths of any marker type."

#![allow(unused)]
fn main() {
use strict_path::StrictPath;

// ✅ Generic: works with any marker
fn get_size<M>(path: &StrictPath<M>) -> std::io::Result<u64> {
    Ok(path.metadata()?.len())
}

// Can call with any marker type
let config: StrictPath<ConfigDir> = ...;
let uploads: StrictPath<UserUploads> = ...;

let config_size = get_size(&config)?;   // M = ConfigDir
let upload_size = get_size(&uploads)?;  // M = UserUploads
}

When to Use Generic Functions

Use <M> when:

  • Function logic doesn't care about the specific marker
  • You're building reusable utilities
  • The operation applies to any path type

Use specific markers when:

  • Function requires specific authorization level
  • Business logic depends on path context
  • Type safety prevents mixing different domains

Common Generic Patterns

Pattern 1: Generic Helpers

#![allow(unused)]
fn main() {
/// Read and parse JSON from any path
fn read_json<M, T: serde::de::DeserializeOwned>(
    path: &StrictPath<M>
) -> Result<T, Box<dyn std::error::Error>> {
    let contents = path.read_to_string()?;
    Ok(serde_json::from_str(&contents)?)
}

// Works with any marker
let config: Config = read_json(&config_path)?;
let data: UserData = read_json(&user_path)?;
}

Pattern 2: Generic Validation

#![allow(unused)]
fn main() {
/// Ensure path exists and is a file
fn validate_file<M>(path: &StrictPath<M>) -> std::io::Result<()> {
    if !path.exists() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!("File not found: {}", path.strictpath_display())
        ));
    }
    
    if !path.is_file() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "Path is not a file"
        ));
    }
    
    Ok(())
}
}

Pattern 3: Generic Logging

#![allow(unused)]
fn main() {
use tracing::info;

/// Log path access for audit trail
fn log_access<M>(path: &StrictPath<M>, operation: &str) {
    info!(
        operation = operation,
        path = %path.strictpath_display(),
        "Path accessed"
    );
}

// Use in your code
log_access(&user_file, "read");
}

Pattern 4: Generic Directory Processing

#![allow(unused)]
fn main() {
/// Count files in directory
fn count_files<M>(dir: &StrictPath<M>) -> std::io::Result<usize> {
    let mut count = 0;
    
    for entry in dir.read_dir()? {
        let entry = entry?;
        if entry.metadata()?.is_file() {
            count += 1;
        }
    }
    
    Ok(count)
}
}

Specific Marker Functions

Sometimes you want exactly the right marker type:

#![allow(unused)]
fn main() {
struct UserData;
struct ConfigDir;

// ✅ Only accepts UserData paths
fn process_user_file(file: &StrictPath<UserData>) -> Result<(), Box<dyn std::error::Error>> {
    let contents = file.read_to_string()?;
    // Process user-specific data
    Ok(())
}

// ✅ Only accepts ConfigDir paths
fn load_config(config: &StrictPath<ConfigDir>) -> Result<AppConfig, Box<dyn std::error::Error>> {
    let contents = config.read_to_string()?;
    Ok(toml::from_str(&contents)?)
}

// ❌ Won't compile: wrong marker type
let user_file: StrictPath<UserData> = ...;
load_config(&user_file); // ERROR: expected ConfigDir, found UserData
}

Generic VirtualPath Functions

The same patterns work for VirtualPath:

#![allow(unused)]
fn main() {
use strict_path::VirtualPath;

/// Generic file writer
fn write_log<M>(path: &VirtualPath<M>, message: &str) -> std::io::Result<()> {
    path.create_parent_dir_all()?;
    path.write(format!("[{}] {}\n", chrono::Utc::now(), message))
}

/// Generic directory validator
fn ensure_directory<M>(path: &VirtualPath<M>) -> std::io::Result<()> {
    if !path.exists() {
        path.create_dir_all()?;
    } else if !path.is_dir() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            "Path exists but is not a directory"
        ));
    }
    Ok(())
}
}

Passing Paths Through Call Chains

Generic Chain

#![allow(unused)]
fn main() {
// Generic all the way down
fn read_data<M>(path: &StrictPath<M>) -> std::io::Result<Vec<u8>> {
    validate_file(path)?;           // Generic
    log_access(path, "read");        // Generic
    path.read()                      // Read the file
}
}

Specific Chain

#![allow(unused)]
fn main() {
struct TenantData;

// Specific markers maintained through chain
fn load_tenant_data(path: &StrictPath<TenantData>) -> Result<Data, Error> {
    validate_tenant_path(path)?;     // Takes StrictPath<TenantData>
    parse_tenant_data(path)?;        // Takes StrictPath<TenantData>
    decrypt_tenant_data(path)        // Takes StrictPath<TenantData>
}

// Each function in chain requires TenantData marker
fn validate_tenant_path(path: &StrictPath<TenantData>) -> Result<(), Error> { ... }
fn parse_tenant_data(path: &StrictPath<TenantData>) -> Result<(), Error> { ... }
fn decrypt_tenant_data(path: &StrictPath<TenantData>) -> Result<Data, Error> { ... }
}

Returning Generic Paths

You can return paths with preserved markers:

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

/// Find config file in directory
fn find_config<M>(dir: &PathBoundary<M>) -> Option<StrictPath<M>> {
    for entry in dir.read_dir().ok()? {
        let entry = entry.ok()?;
        let name = entry.file_name();
        
        if name.to_string_lossy().ends_with(".conf") {
            return dir.strict_join(&name).ok();
        }
    }
    None
}

// Marker type flows through
let config_dir: PathBoundary<ConfigDir> = ...;
let found: Option<StrictPath<ConfigDir>> = find_config(&config_dir);
}

Generic with Constraints

You can constrain markers with trait bounds:

#![allow(unused)]
fn main() {
/// Marker must implement Send + Sync
fn parallel_process<M: Send + Sync>(
    paths: Vec<StrictPath<M>>
) -> Vec<std::io::Result<String>> {
    use rayon::prelude::*;
    
    paths.par_iter()
        .map(|p| p.read_to_string())
        .collect()
}

/// Marker must implement custom trait
trait Auditable {
    fn audit_log_name() -> &'static str;
}

fn audit_access<M: Auditable>(path: &StrictPath<M>) {
    println!("Accessing {} path: {}", 
        M::audit_log_name(), 
        path.strictpath_display()
    );
}
}

Common Mistakes

❌ Unnecessary Generic Constraint

#![allow(unused)]
fn main() {
// Bad: overly restrictive
fn read_size<M: Sized>(path: &StrictPath<M>) -> std::io::Result<u64> {
    Ok(path.metadata()?.len())
}

// Good: no constraint needed
fn read_size<M>(path: &StrictPath<M>) -> std::io::Result<u64> {
    Ok(path.metadata()?.len())
}
}

❌ Mixing Markers Unsafely

#![allow(unused)]
fn main() {
// Bad: forces marker type conversion
fn bad_mix<M1, M2>(src: &StrictPath<M1>, dst: &StrictPath<M2>) {
    // Won't compile: can't copy between different marker types
    src.strict_copy(dst); // ERROR: marker type mismatch
}

// Good: require same marker
fn good_copy<M>(src: &StrictPath<M>, dst: &StrictPath<M>) {
    src.strict_copy(dst); // ✅ OK: both have marker M
}
}

❌ Losing Marker Type

#![allow(unused)]
fn main() {
// Bad: loses type information
fn process(path: &StrictPath<()>) { ... }

// Good: preserve marker
fn process<M>(path: &StrictPath<M>) { ... }
}

Best Practices

✅ Start Generic, Add Constraints as Needed

#![allow(unused)]
fn main() {
// Start here
fn process<M>(path: &StrictPath<M>) { ... }

// Add trait bounds only if needed
fn process<M: Send + Sync>(path: &StrictPath<M>) { ... }

// Use specific markers only when required for business logic
fn process_user(path: &StrictPath<UserData>) { ... }
}

✅ Document Marker Expectations

#![allow(unused)]
fn main() {
/// Process any file in any boundary.
///
/// Generic over marker type `M` because the operation
/// doesn't depend on specific authorization or context.
fn process_file<M>(file: &StrictPath<M>) -> std::io::Result<()> {
    // ...
}

/// Load user-specific configuration.
///
/// Requires `UserData` marker to enforce that only
/// user-scoped paths can be processed here.
fn load_user_config(file: &StrictPath<UserData>) -> Result<Config> {
    // ...
}
}

✅ Use Generic for Utilities, Specific for Domain Logic

#![allow(unused)]
fn main() {
// ✅ Generic utility
fn file_size<M>(path: &StrictPath<M>) -> std::io::Result<u64> { ... }

// ✅ Specific domain logic
fn charge_storage_fees(path: &StrictPath<BillingData>) -> Result<Amount> { ... }
}

Summary

  • <M> means "works with any marker"
  • Use generics for reusable utilities
  • Use specific markers for domain-specific logic
  • The compiler prevents marker type mismatches
  • Marker types flow through call chains automatically
  • Add trait bounds only when needed

Daily Usage Patterns

Common workflows and patterns for everyday strict-path usage.

Pattern 1: Validate User Input

Problem: User provides a filename, you need to ensure it stays in your directory.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn handle_user_request(user_filename: &str) -> Result<String, Box<dyn std::error::Error>> {
    let uploads = PathBoundary::try_new("/var/uploads")?;
    
    // Validate the user input
    let safe_path = uploads.strict_join(user_filename)?;
    
    // Now safe to use
    let contents = safe_path.read_to_string()?;
    Ok(contents)
}

// ✅ Safe: "../etc/passwd" gets rejected
// ✅ Safe: "documents/report.pdf" gets validated
}

Pattern 2: Configuration Loading

Problem: Load config files from multiple standard locations.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;
use std::path::PathBuf;

fn find_config() -> Result<String, Box<dyn std::error::Error>> {
    let config_locations = vec![
        PathBuf::from("/etc/myapp/config.toml"),
        PathBuf::from("/usr/local/etc/myapp/config.toml"),
        dirs::config_dir().unwrap().join("myapp/config.toml"),
    ];
    
    for location in config_locations {
        if let Some(parent) = location.parent() {
            if let Ok(boundary) = PathBoundary::try_new(parent) {
                if let Some(filename) = location.file_name() {
                    if let Ok(config_path) = boundary.strict_join(filename) {
                        if config_path.exists() {
                            return config_path.read_to_string();
                        }
                    }
                }
            }
        }
    }
    
    Err("No config file found".into())
}
}

Pattern 3: Per-User Virtual Filesystem

Problem: Multiple users, each needs their own isolated filesystem view.

#![allow(unused)]
fn main() {
use strict_path::VirtualRoot;

struct UserSession {
    user_id: String,
    root: VirtualRoot<UserSpace>,
}

struct UserSpace;

impl UserSession {
    fn new(user_id: String) -> Result<Self, Box<dyn std::error::Error>> {
        let base_dir = format!("/var/lib/app/users/{}", user_id);
        let root = VirtualRoot::try_new_create(&base_dir)?;
        Ok(Self { user_id, root })
    }
    
    fn read_file(&self, virtual_path: &str) -> Result<String, Box<dyn std::error::Error>> {
        let file = self.root.virtual_join(virtual_path)?;
        Ok(file.read_to_string()?)
    }
    
    fn write_file(&self, virtual_path: &str, contents: &str) -> Result<(), Box<dyn std::error::Error>> {
        let file = self.root.virtual_join(virtual_path)?;
        file.create_parent_dir_all()?;
        file.write(contents)?;
        Ok(())
    }
    
    fn list_files(&self, virtual_dir: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
        let dir = self.root.virtual_join(virtual_dir)?;
        let mut files = Vec::new();
        
        for entry in dir.as_unvirtual().read_dir()? {
            let entry = entry?;
            // Re-validate discovered names
            let validated = self.root.virtual_join(format!("{}/{}", virtual_dir, entry.file_name().to_string_lossy()))?;
            files.push(validated.virtualpath_display().to_string());
        }
        
        Ok(files)
    }
}

// Usage
let alice = UserSession::new("alice".to_string())?;
alice.write_file("documents/report.txt", "Quarterly report")?;
let report = alice.read_file("documents/report.txt")?;
}

Pattern 4: Safe Archive Extraction

Problem: Extract ZIP/TAR without directory traversal attacks.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;
use zip::ZipArchive;
use std::fs::File;

fn safe_extract_zip(
    zip_path: &std::path::Path,
    extract_to: &str
) -> Result<(), Box<dyn std::error::Error>> {
    let extract_dir = PathBoundary::try_new_create(extract_to)?;
    let mut archive = ZipArchive::new(File::open(zip_path)?)?;
    
    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let filename = file.name();
        
        // Validate each file path before extraction
        let safe_path = match extract_dir.strict_join(filename) {
            Ok(p) => p,
            Err(_) => {
                eprintln!("Skipping malicious path: {}", filename);
                continue; // Skip paths that escape
            }
        };
        
        // Create parent directories
        safe_path.create_parent_dir_all()?;
        
        // Extract to validated path
        let mut outfile = safe_path.create_file()?;
        std::io::copy(&mut file, &mut outfile)?;
    }
    
    Ok(())
}
}

Pattern 5: Temporary File Processing

Problem: Create temp directory for processing, auto-cleanup.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn process_upload(
    data: &[u8]
) -> Result<ProcessedData, Box<dyn std::error::Error>> {
    // Create temp directory with RAII cleanup
    let temp_dir = PathBoundary::<()>::try_new_temp()?;
    
    // Write input
    let input_file = temp_dir.strict_join("input.dat")?;
    input_file.write_bytes(data)?;
    
    // Process
    let output_file = temp_dir.strict_join("output.dat")?;
    process_data(&input_file, &output_file)?;
    
    // Read result
    let result = output_file.read()?;
    
    // temp_dir dropped here, automatically cleaned up
    Ok(ProcessedData::from_bytes(&result))
}
}

Pattern 6: Chaining Operations

Problem: Multiple sequential operations on paths.

#![allow(unused)]
fn main() {
use strict_path::PathBoundary;

fn backup_and_update_config(
    new_config: &str
) -> Result<(), Box<dyn std::error::Error>> {
    let config_dir = PathBoundary::try_new("/etc/myapp")?;
    
    let config = config_dir.strict_join("config.toml")?;
    let backup = config_dir.strict_join("config.toml.backup")?;
    
    // Chain operations
    config.strict_copy(&backup)?;           // Backup current
    config.write(new_config)?;               // Write new
    
    // Verify
    if config.read_to_string()? == new_config {
        println!("Config updated successfully");
    }
    
    Ok(())
}
}

Pattern 7: Authorization with Markers

Problem: Different users need different access levels.

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};

struct ReadOnly;
struct ReadWrite;

fn authenticate_user(
    username: &str,
    password: &str
) -> Result<PathBoundary<ReadWrite>, AuthError> {
    // Check credentials
    if verify_credentials(username, password) {
        let user_dir = format!("/var/data/users/{}", username);
        let boundary: PathBoundary<ReadOnly> = PathBoundary::try_new(&user_dir)
            .map_err(|_| AuthError::NoAccess)?;
        
        // Escalate to write access after auth check
        Ok(boundary.change_marker())
    } else {
        Err(AuthError::InvalidCredentials)
    }
}

fn write_user_data(
    boundary: &PathBoundary<ReadWrite>, // Requires write marker
    filename: &str,
    data: &str
) -> Result<(), Box<dyn std::error::Error>> {
    let file = boundary.strict_join(filename)?;
    file.write(data)?;
    Ok(())
}

// ✅ Can only call write_user_data with ReadWrite marker
let rw_boundary = authenticate_user("alice", "secret123")?;
write_user_data(&rw_boundary, "notes.txt", "My notes")?;
}

Pattern 8: Error Handling

Problem: Gracefully handle path validation failures.

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPathError};

fn safe_file_access(
    base: &str,
    user_path: &str
) -> Result<String, AppError> {
    let boundary = PathBoundary::try_new(base)
        .map_err(|e| AppError::InvalidBase(e))?;
    
    let file = boundary.strict_join(user_path)
        .map_err(|e| match e {
            StrictPathError::PathEscapesBoundary { attempted, boundary } => {
                AppError::PathEscape { 
                    path: attempted.display().to_string(),
                    reason: "Attempted directory traversal"
                }
            }
            _ => AppError::ValidationFailed(e)
        })?;
    
    file.read_to_string()
        .map_err(|e| AppError::IoError(e))
}

#[derive(Debug)]
enum AppError {
    InvalidBase(StrictPathError),
    PathEscape { path: String, reason: &'static str },
    InvalidPath { reason: String },
    ValidationFailed(StrictPathError),
    IoError(std::io::Error),
}
}

Pattern 9: Database Path Storage

Problem: Store and retrieve validated paths from database.

#![allow(unused)]
fn main() {
use strict_path::{PathBoundary, StrictPath};
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct FileRecord {
    id: u64,
    // Store as string in DB
    relative_path: String,
}

struct FileService {
    boundary: PathBoundary<DataDir>,
}

struct DataDir;

impl FileService {
    fn save_file(&self, name: &str, data: &[u8]) -> Result<FileRecord, Box<dyn std::error::Error>> {
        // Validate before using
        let file = self.boundary.strict_join(name)?;
        file.write_bytes(data)?;
        
        // Store relative path in DB
        Ok(FileRecord {
            id: generate_id(),
            relative_path: name.to_string(),
        })
    }
    
    fn load_file(&self, record: &FileRecord) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
        // Re-validate on load
        let file = self.boundary.strict_join(&record.relative_path)?;
        Ok(file.read()?)
    }
}
}

Pattern 10: Logging and Auditing

Problem: Log file access for compliance.

#![allow(unused)]
fn main() {
use strict_path::StrictPath;
use tracing::{info, warn};

fn audit_read<M>(
    path: &StrictPath<M>,
    user: &str
) -> std::io::Result<String> {
    info!(
        user = user,
        path = %path.strictpath_display(),
        action = "read",
        "File access"
    );
    
    match path.read_to_string() {
        Ok(contents) => {
            info!(
                user = user,
                path = %path.strictpath_display(),
                size = contents.len(),
                "Read successful"
            );
            Ok(contents)
        }
        Err(e) => {
            warn!(
                user = user,
                path = %path.strictpath_display(),
                error = %e,
                "Read failed"
            );
            Err(e)
        }
    }
}
}

Performance Tips

Reuse Boundaries

#![allow(unused)]
fn main() {
// ✅ Good: create boundary once
let boundary = PathBoundary::try_new("/var/data")?;

for filename in filenames {
    let file = boundary.strict_join(filename)?;
    process(file)?;
}

// ❌ Bad: recreating boundary every iteration
for filename in filenames {
    let boundary = PathBoundary::try_new("/var/data")?; // Wasteful!
    let file = boundary.strict_join(filename)?;
    process(file)?;
}
}

Batch Validation

#![allow(unused)]
fn main() {
// Validate all paths upfront
let files: Result<Vec<_>, _> = filenames
    .iter()
    .map(|name| boundary.strict_join(name))
    .collect();

let files = files?; // Single error handling point

// Then process
for file in files {
    process(&file)?;
}
}

Avoid Redundant Checks

#![allow(unused)]
fn main() {
// ❌ Bad: checking twice
if file.exists() {
    file.read_to_string()?;
}

// ✅ Good: let I/O operation handle it
match file.read_to_string() {
    Ok(contents) => { /* use contents */ }
    Err(e) if e.kind() == std::io::ErrorKind::NotFound => { /* handle missing */ }
    Err(e) => return Err(e.into()),
}
}

Summary

  • Always validate user input through strict_join/virtual_join
  • Create boundaries once, reuse for multiple paths
  • Use markers to encode authorization at type level
  • Handle errors gracefully with specific error types
  • Re-validate paths loaded from databases
  • Log file operations for audit trails
  • Prefer builtin I/O methods over .interop_path()
  • Use temporary directories with RAII cleanup

Interop vs Display

  • Interop (AsRef): use interop_path() on StrictPath, VirtualPath, PathBoundary, and VirtualRoot. It borrows the underlying OS path without allocations.
  • Display to users:
    • System paths: use strictpath_display() (on StrictPath/PathBoundary).
    • Virtual UI paths: use virtualpath_display() (on VirtualPath).
  • Never use interop_path().to_string_lossy() for display—mixes concerns and may leak internals.
  • Do not wrap secure types with Path::new or PathBuf::from.
  • Directory discovery vs validation:
    • Discover children via read_dir(root.interop_path()) or root helpers.
    • Re-validate names with strict_join()/virtual_join() before any I/O.

Function Signatures

Encode guarantees so misuse is hard:

  • Accept validated paths directly when the caller did validation:
    • fn process(file: &StrictPath<MyMarker>) -> io::Result<()> { ... }
    • fn read(user_file: &VirtualPath<MyMarker>) -> io::Result<Vec<u8>> { ... }
  • Validate inside helpers by accepting policy + untrusted segment:
    • fn write(cfg: &PathBoundary<MyMarker>, name: &str) -> io::Result<()> { ... }
    • fn upload(vroot: &VirtualRoot<MyMarker>, filename: &str) -> io::Result<()> { ... }
  • Don’t construct boundaries/roots inside helpers—policy lives at the call site.
  • Prefer domain names over type names: uploads_root, config_dir, user_project_root.

Escape Hatches

Use escape hatches sparingly and deliberately.

  • Borrow strict view from virtual: vpath.as_unvirtual() (preferred for shared helpers).
  • Ownership conversions:
    • StrictPath::virtualize()VirtualPath
    • VirtualPath::unvirtual()StrictPath
    • StrictPath::unstrict()PathBuf (avoid unless you truly need an owned PathBuf)
  • Avoid chaining escape hatches in application code. If you must own a PathBuf, isolate it in a clearly-marked narrow scope.

Equality & Ordering

  • StrictPath and VirtualPath equality/ordering are based on their underlying system paths (within the same restriction).
  • Do not compare display strings. Use the types’ built-in Eq/Ord/Hash.
  • When you need system-path equality in virtual flows, compare via as_unvirtual().
  • Avoid lossy or normalization-prone string conversions for comparisons.

Naming

Prefer domain-based names; avoid type-based names.

  • Good: config_dir, uploads_root, archive_src, user_vroot, system_root.
  • Avoid: boundary, jail, source_ prefixes, or one-letter variables.
  • Keep names consistent with the directory they represent and convey intent in code review.

Marker Types

  • Name markers after the storage domain (struct PublicAssets;, struct BrandEditorWorkspace;). Reviewers should understand the filesystem contents from the type alone.
  • Skip suffixes like Marker, Type, or Root; they repeat what Rust already communicates. struct MediaLibrary; is clearer than struct MediaLibraryMarker;.
  • Tuples that pair storage with authorization should keep the resource first and the capability second: StrictPath<(BrandDirectorArchive, FullControlCapability)>.
  • Focus on what's stored, not who uses it. A marker like BrandAssets tells you the directory contains brand materials, while EditorFiles only tells you someone called "Editor" uses it. The marker describes the filesystem contents and access policy, not the caller's identity.

Choosing Canonicalized vs Lexical Solution

Scope: strict-path always uses canonicalized path security. There is no “lexical mode” in this crate. When we say “lexical,” we mean using a different crate that only does string/segment checks. This page helps you decide when to use strict-path (canonicalized) versus when a lexical-only crate might be acceptable.

New here? This page helps you pick the right approach without security footguns.

In one sentence: Prefer the “canonicalized” approach unless you 100% control the environment and have tests proving your assumptions. Lexical (in other crates) is for rare, performance‑critical hot paths with strong guarantees.

First: What do these words mean?

  • Canonicalized (what strict-path does): We ask the OS to resolve the real, absolute path before deciding if it’s safe. This resolves symlinks/junctions and normalizes platform‑specific quirks (like Windows 8.3 short names, UNC, ADS). That way, sneaky inputs can’t trick simple string checks.
  • Lexical (other crates): We treat the path like plain text and only do string/segment checks (no OS resolution). It can be fast, but it doesn’t see what’s really on disk.

Why you probably want canonicalized (i.e., strict-path)

  • Defends against real‑world attacks: directory traversal (../../../), symlink swaps, aliasing (8.3 short names like PROGRA~1), UNC/verbatim forms, ADS, Unicode normalization tricks.
  • Works across platforms the same way.
  • Matches “zero‑trust” handling for inputs from HTTP, config files, databases, archives, and LLMs.

Trade‑off: a bit more I/O work to ask the filesystem what’s actually there.

When lexical (other crates) can be OK

Only consider lexical if ALL of these are true:

  • No symlinks/junctions/mounts in the relevant tree
  • Inputs are already normalized (no weird separators or encodings)
  • You own the environment (e.g., an internal tool in a sealed container)
  • You have tests that enforce the above (so a future change doesn’t silently break safety)

If you’re unsure, use strict-path (canonicalized).

Fast decision guide

  • Is the input from users, files, network, LLMs, or archives? → Use strict-path (canonicalized: StrictPath/VirtualPath).
  • Is this a perf‑critical inner loop on paths you generate yourself and you’ve proven there are no symlinks? → A lexical-only crate might be acceptable.
  • Mixed or uncertain? → Use strict-path (canonicalized).

Concrete examples

  • “User uploads a file named ../../etc/passwd

    • strict-path (canonicalized): Rejected or clamped safely; cannot escape the root.
    • lexical-only crate: Traversal may be blocked, but symlinks or platform quirks can still break containment.
  • “Windows machine with C:\Program Files also visible as C:\PROGRA~1

    • strict-path (canonicalized): Treats both as the same real place; escape attempts fail.
    • lexical-only crate: A clever alias or hidden symlink may trick a simple prefix check—even if traversal is blocked.

Short recipes

  • strict-path (canonicalized, default):

    • Validate via a boundary/root, then operate through StrictPath/VirtualPath methods.
    • Accept &StrictPath<_>/&VirtualPath<_> in helpers, or accept a &PathBoundary/_VirtualRoot plus the untrusted segment.
  • If you intentionally use a lexical-only crate (advanced):

    • Keep lexical checks isolated and documented; add tests that assert “no symlinks / normalized inputs”.
    • If the situation changes later, migrate back to strict-path with minimal refactors because your signatures stayed explicit.

See also:

  • Ergonomics → Interop vs Display
  • README → “Where This Makes Sense”

"Lexical checks aren't just about traversal—symlinks and platform quirks are the real troublemakers."

Design Decisions - Guide for the Frustrated Rustacean

Since this is a security crate, I took on myself more design decision liberties towards increased security, correctness and avoidance of misuse.

The Journey: From Ergonomic to Secure

The initial prototype had straightforward ergonomics, with non-original, boring API. Which is normally good and required design. Easy transitions between types and common method names.

As I was generating code using different LLMs, a disaster unfolded. The LLM did not use the API correctly at all! It constantly worked its way around safety features and since it was generating a lot of code, it became harder to have code review and easy to miss an introduced vulnerability by the LLM.

That had me realize that since LLM Agents is all that is happening nowadays, I had to think carefully about how to guide it towards correct usage of my API in a way that a human will also benefit from.

Security Measures Taken

LLM-Aware Documentation

  • Complete API summary file dedicated to an LLM - LLM_API_REFERENCE.md provides usage-first guidance
  • Code comments in a style that tooling agents can reason with - Explicit function documentation with SUMMARY/PARAMETERS/RETURNS/ERRORS/EXAMPLE sections

API Design Philosophy

  • Highly explicit API - Easy to review and detect errors by method names
    • strictpath_display() vs virtualpath_display() instead of generic display()
    • strict_join() vs virtual_join() instead of generic join()
    • interop_path() for third-party integration instead of hidden AsRef<Path> impls
  • Best practices vs anti-patterns in docs - Clear guidance on what to do and what to avoid
  • Minimal API surface - Less ways to get it wrong
  • Safe built-in I/O operations - read_to_string(), write(), create_file() on the secure types
  • Type-based security - Markers enforce boundaries at compile time

The Path Extension Trait Decision

I was thinking about having an extension trait for Path/PathBuf, to introduce built-in I/O methods just like we have in our StrictPath and VirtualPath. The idea was to keep the code nice and consistent, since using Path and PathBuf are legit in some contexts.

However, I realized it is far quicker to notice we are using the wrong Path type. The moment we see old-style code for I/O, it helps ask questions like:

"Why do we use regular Path here? Is this legit?"

And that's awesome for code review and overall security! 🛡️

Why This Matters for You

Human Benefits

  • Code review clarity - Suspicious patterns are immediately visible
  • Intention signaling - Method names communicate security guarantees
  • Compile-time safety - Type system prevents mixing secure and insecure paths

LLM Agent Benefits

  • Explicit guidance - Clear documentation prevents misuse
  • Fewer escape hatches - Limited ways to bypass security
  • Pattern recognition - Consistent naming helps AI understand correct usage

Examples of Security-First Design

❌ What We Could Have Done (Ergonomic but Dangerous)

#![allow(unused)]
fn main() {
// Hypothetical "ergonomic" design - DON'T DO THIS
let path: StrictPath<_> = boundary.join(user_input)?;  // Generic method
let content = std::fs::read_to_string(path)?;          // Easy to bypass
}

✅ What We Actually Do (Explicit and Secure)

#![allow(unused)]
fn main() {
// Actual secure design - explicit and reviewable
let path: StrictPath<_> = boundary.strict_join(user_input)?;  // Clearly strict
let content = path.read_to_string()?;                        // Built-in secure I/O
}

The second example makes it immediately clear:

  1. We're operating in strict mode (strict_join)
  2. We're using built-in secure I/O (no raw std::fs)
  3. The path type carries security guarantees

The Result

This design philosophy has proven effective in practice:

  • Reduced vulnerabilities - Harder to accidentally introduce path traversal
  • Better code reviews - Security issues are immediately visible
  • LLM-compatible - AI agents use the API correctly when following the documentation
  • Human-friendly - Developers understand the security implications at a glance

Remember: Security-critical crates should prioritize correctness over ergonomics. A slightly more verbose API that prevents vulnerabilities is infinitely better than an elegant API that's easy to misuse.


Comparison with Alternatives

Understanding how strict-path compares to other path-handling solutions helps you choose the right tool for your needs.

strict-path vs soft-canonicalize

soft-canonicalize is the foundation that strict-path builds upon. Think of it as the difference between a low-level graphics library and a game engine.

Featurestrict-pathsoft-canonicalize
LevelHigh-level security APILow-level path resolution
PurposeEnforce boundaries + authorizationNormalize & canonicalize paths
ReturnsStrictPath<Marker> / VirtualPath<Marker> with compile-time guaranteesPathBuf
I/O operationsComplete filesystem API (read, write, rename, copy, etc.)Not included (just path resolution)
Boundary enforcementBuilt-in: strict_join() / virtual_join() validate against boundariesManual: you implement checks yourself
AuthorizationCompile-time marker proofs (type system verifies auth)Not applicable
Use caseApplication-level security (validate external paths, enforce policies)Building custom path security logic
ComplexityHigh-level, opinionated (fewer decisions to make)Low-level, flexible (more control, more responsibility)

When to use strict-path:

  • ✅ You need comprehensive path security out of the box
  • ✅ You want compile-time guarantees about path boundaries
  • ✅ You're validating paths from external sources (HTTP, CLI, LLM, config)
  • ✅ You want authorization encoded in types
  • ✅ You prefer opinionated security over custom logic

When to use soft-canonicalize:

  • ✅ You're building custom path security abstractions
  • ✅ You need just canonicalization without boundary enforcement
  • ✅ You want maximum flexibility to design your own security model
  • ✅ You're implementing path comparison/deduplication logic
  • ✅ You need canonicalization for non-existing paths

Example: The Relationship

#![allow(unused)]
fn main() {
// soft-canonicalize: low-level resolution
use soft_canonicalize::soft_canonicalize;
let resolved = soft_canonicalize("config/../data/file.txt")?;
// You get: PathBuf - now manually check if it's within bounds

// strict-path: high-level security (uses soft-canonicalize internally)
use strict_path::StrictPath;
let safe_path = StrictPath::with_boundary("data")?
    .strict_join("../file.txt")?;  // Returns Err if outside "data"
safe_path.read_to_string()?;       // Built-in secure I/O
}

strict-path vs path_absolutize

path_absolutize offers different security philosophies. Understanding these differences is critical for choosing the right approach.

Featurestrict-pathpath_absolutize::absolutize_virtually
Escape handlingStrictPath: Returns Err(PathEscapesBoundary)
VirtualPath: Silently clamps to boundary
Returns Err on escape attempts (rejection model only)
Symlink resolutionFull filesystem-based - Follows symlinks, resolves targetsLexical only - Does NOT follow symlinks (faster but less accurate)
Security modelTwo modes:
1. Detect escapes (StrictPath)
2. Contain escapes (VirtualPath)
One mode: Reject invalid paths
CanonicalizationFull canonicalization (resolves ., .., symlinks, Windows short names)Lexical normalization (string manipulation, no filesystem I/O)
AuthorizationCompile-time marker proofsNot applicable
I/O operationsComplete built-in APINot included
Use whenSecurity boundaries where symlinks exist or accuracy is criticalPerformance-critical paths where symlinks are guaranteed not to exist

Critical Distinction: Symlink Behavior

The symlink handling difference is security-critical:

#![allow(unused)]
fn main() {
// Setup: Create symlink that escapes boundary
// /safe/link -> /etc/passwd

// path_absolutize (lexical only - DANGEROUS if symlinks exist):
use path_absolutize::Absolutize;
let abs = Path::new("/safe/link").absolutize_virtually("/safe")?;
// Result: /safe/link (string looks safe, but symlink escapes!)
// Reading this symlink gives you /etc/passwd content!

// strict-path StrictPath (filesystem-based - SAFE):
use strict_path::PathBoundary;
let boundary = PathBoundary::try_new("/safe")?;
let validated = boundary.strict_join("link")?;  // Follows symlink, sees target is /etc/passwd
// Result: Err(PathEscapesBoundary) - attack detected!

// strict-path VirtualPath (filesystem-based with clamping):
use strict_path::VirtualRoot;
let vroot = VirtualRoot::try_new("/safe")?;
let contained = vroot.virtual_join("link")?;  // Follows symlink, clamps target to /safe/etc/passwd
// Result: Ok - target rewritten to stay within boundary, user sees "/etc/passwd" in virtual space
}

When lexical (path_absolutize) is safe:

  • ✅ You can guarantee no symlinks exist in your paths
  • ✅ Performance is critical and you've validated the environment
  • ✅ You control all path creation (e.g., build artifacts, codegen)

When filesystem-based (strict-path) is required:

  • ✅ Any possibility of symlinks existing
  • ✅ Handling user-provided paths (HTTP, CLI, config, archives)
  • ✅ Security is more important than performance
  • ✅ You need to detect attacks (escapes are malicious)
  • ✅ You need to contain escapes (multi-tenant isolation)

Performance vs Security Trade-off:

  • Lexical resolution (path_absolutize): ~10-100x faster (no filesystem I/O), but vulnerable to symlink attacks
  • Filesystem-based (strict-path): Slower (requires stat calls), but mathematically secure against symlink escapes

Which One Should You Use?

Ask yourself: "Can I guarantee no symlinks will ever exist in these paths?"

  • No / Not sure → Use strict-path (security over performance)
  • Yes, absolutely certain → Consider path_absolutize (performance)
  • Need to detect attacks → Use strict-path with StrictPath
  • Need to contain escapes → Use strict-path with VirtualPath (unique to this crate)

Decision Matrix: Choosing the Right Tool

ScenarioChooseRationale
Web server serving user-requested filesstrict-path (StrictPath)Symlinks may exist, escapes are attacks
LLM agent file operationsstrict-path (StrictPath)AI-generated paths are untrusted, need boundary enforcement
Archive extraction (Zip, TAR)strict-path (StrictPath)Archives may contain malicious symlinks (Zip Slip attacks)
Multi-tenant cloud storagestrict-path (VirtualPath)Each user needs isolated virtual filesystem
Build system artifactspath_absolutize OR soft-canonicalizeYou control creation, no symlinks, performance matters
Custom security abstractionssoft-canonicalizeBuild your own policy on stable foundation
Path comparison/deduplicationsoft-canonicalizeJust need canonicalization, no boundary enforcement

Bottom Line:

  • Need high-level security?strict-path
  • Need low-level building blocks?soft-canonicalize
  • Need fast lexical paths in controlled environments?path_absolutize (but be careful!)
  • Not sure? → Start with strict-path and optimize later if needed

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!

The Journey to strict-path

Why Do I Need This Library?

The development of strict-path is a story of discovering security gaps in path handling and iteratively building a comprehensive solution. Here's the complete development journey that led to the creation of this crate.

Why Use This Crate (TL;DR)

Path security is not about comparing strings. It requires:

  • Full normalization/canonicalization that works even when targets don’t exist
  • Safe symlink/junction handling (with cycle detection and boundary enforcement)
  • Windows-specific defenses (8.3 short names, UNC/verbatim prefixes, ADS)
  • Unicode/encoding awareness (mixed separators, normalization differences)

strict-path solves this class of problems comprehensively, then encodes the guarantees in the type system. If a StrictPath<Marker> exists, it’s proven to be inside its boundary by construction.

The Development Process Story

The Simple Beginning

It started as an apparently simple idea for a crate that validates paths by making sure they are within an expected boundary, using canonicalization. The concept was straightforward: create a type that validates the correct path (PathValidator) and a generated byproduct that serves as proof of validation (JailedPath).

That wasn't too hard to do... except...

The First Major Obstacle

The std::canonicalize Problem: Rust's standard library canonicalize() could only accept and work with paths that already exist. This was a fundamental limitation that broke the entire concept.

Existing Crates Were Insufficient: Other Rust crates were only offering lexical path resolution, ignoring symlinks and unable to deliver the promise of canonicalized/realpath values without demanding that the target path must exist.

This was a big problem! How could I validate that a location for a future file is within a legal boundary if the file doesn't exist yet?

The Python Inspiration

A quick search revealed that Python had already faced this exact problem and solved it in Python 3.6 by adding the following feature: pathlib.Path.resolve(strict=False).

That's when I realized I'd need to create another crate! One that mimics that same logic—both to solve my problem and as an opportunity to give back to the Rust community.

Enter soft-canonicalize: This became the foundation crate that would enable proper path validation without requiring file existence.

Building soft-canonicalize

I asked an LLM agent to fetch Python's implementation unit tests, translate them to Rust, and run them over our soft-canonicalize implementation. This revealed gaps in my own implementation and led me to ask for the same algorithm that Python uses (later modified for optimizations and CVE resolutions).

Voilà! I had a working soft-canonicalize crate, so I could publish it and continue work on my jailed-path crate.

From here, the path guarantee became practical: validate first (without requiring existence), then operate safely.

The Marker Type Innovation

Continuing work on JailedPath, I realized that sometimes we might wish to have more than one validated path, but how could we identify them correctly? That's when I came up with the Marker type idea: simply create your very own Marker type, providing additional context for the compiler and allowing us to prevent mixing up paths!

Security Research and CVE Analysis

OK, now we have a really cool JailedPath crate! Let's further validate that we are safe by researching CVEs.

Oops! It looked like we had some gaps in our soft-canonicalize crate. That's where I took additional time investing in improving correctness, resilience, and performance. I created comprehensive Python benchmarks where I could validate soft-canonicalize performance vs Python's C language implementation. That took a while to perfect, but it was worth it because it could improve scalability in heavy usage cases.

The Virtual Path Discovery

Researching existing alternatives, I discovered a use case for virtual paths—paths that are clamped to a virtual root. This made me reconsider my own use case for creating this crate, revealing a lot of potential.

I started wondering if this should be our default behavior. Eventually, I came to this conclusion: All I needed was a secure, validated Path type. So I applied the KISS method (Keep It Simple, Stupid) and decided that the core JailedPath should represent simply a path that has been validated.

However, there were clear uses for VirtualPath. After long consideration about whether this should be in a different crate, I decided to keep it inside JailedPath because:

  • They share the same foundation
  • I didn't want to scatter logic across two crates
  • It's easier to maintain, use, and perform transitions between the two

The Great Renaming Journey

From PathValidator to Jail

My first gut feeling was that while our PathValidator type was quite self-explanatory, it felt like an extra tool we needed to carry around. I was aiming to simplify the developer experience. PathValidator seemed easy to understand but not fun, with no clear relation to JailedPath.

So I decided to rename PathValidator to Jail. It made sense: we set up a jail and then validate paths against it.

From Jail to PathBoundary

Eventually, Jail didn't feel completely right either, only because we were also supporting VirtualPath (created from a VirtualRoot). I realized that a newcomer (or someone returning to code after a long while) might get confused about what behavior to expect from a Jail and JailedPath type.

The API Surface Problem

As a result of LLM agents generating faulty code, I could see how the API was being misused. This motivated me to reduce the API surface to the minimum required and ensure all methods are explicit about the difference between JailedPath and VirtualPath. No vague method names (such as as_ref()). No Path type escapes—the LLM would simply defeat the purpose of my crate by calling its inner path and calling .join() on it.

The Problem with .join(): Calling Path::join() is no longer validated. The path could escape easily. And joining to a full path would completely override the path it's being joined to.

The "Three join() Problem"

This led me to the "Three join() problem"—each time I saw a generated .join() in test code, I had to take a moment following the chain of methods to figure out if a join() belongs to Path, JailedPath, or VirtualPath.

This is where I decided that methods must be explicit. Seeing them in generated code helps immediately notice and understand their behavior:

  • jailedpath_join() vs virtualpath_join() vs join()

Seeing join() in our code would mean unsafe behavior that we could notice immediately.

This explicitness is critical for LLM- and review-friendly code: .strict_join(..)/.virtual_join(..) are visibly safe; raw Path::join stands out as a red flag.

Finding the Right Balance

Fixing my demo projects, these methods seemed verbose. Since they were very common, I decided on shorter, easier names:

  • jailed_join(), virtual_join()

But we're back to behavior differences. Seeing jailed_join(), what does it mean? We'd need to refer to docs. While docs are important, wouldn't it be nicer if we could understand from the method name what's happening?

The Final Names

Eventually (and finally), I did another rename:

  • JailedPathStrictPath (clear that the path is restricted!)
  • JailPathBoundary (goes hand-in-hand with VirtualRoot)
  • strict_join() vs virtual_join() (perfect clarity!)

Path Ergonomics and Safety

Path ergonomics were crucial! I wanted to be as ergonomic as possible without breaking our established safety rules—especially not leaking out a Path type that could do a .join().

Eventually, I came up with .interop_path(). It contains the suffix _path to hint that this is what API users need to interop VirtualPath and StrictPath directly in places where AsRef<Path> is expected. But we do not expose a Path type! Instead, we expose a borrow of an OsStr.

This is perfect! OsStr:

  • Implements AsRef<Path> for integration with everything expecting AsRef<Path>
  • Is cross-platform and fits the underlying operating system
  • Doesn't lose any data
  • Is what Path wraps anyway—we're just stripping off all the dangerous methods
  • Is what Path wraps anyway—we're just stripping off all the dangerous methods

Escape hatches exist, but are explicit:

  • Borrow strict from virtual: vpath.as_unvirtual()
  • Ownership conversions: virtualize() / unvirtual() / unstrict() (use sparingly)

Feature Integration

I wanted to explore additional features by integrating with popular crates:

  • app-path: My own crate for easily referring to files near our executable, ensuring operations cannot escape our application directory
  • dirs: Cross-platform access to system directories
  • tempfile: Generate temporary directories with PathBoundary::try_new_temp()

API Simplification

I kept improving demo examples and API clarity. Eventually, I realized: StrictPath contains the boundary path within it, just as VirtualRoot contains its root path (which is a StrictPath).

I explored whether we could work with just 2 types: VirtualPath and StrictPath. While possible, it wouldn't be ideal—sometimes we want to be explicit about roots and boundaries as promises.

I decided to keep VirtualRoot and PathBoundary but make common usage more concise with StrictPath::with_boundary() and VirtualPath::with_root(). This made code much more concise while remaining highly readable.

Zero‑Trust vs Lexical Approaches

  • If you want a zero‑trust approach that covers (almost) everything that can go wrong, prefer canonicalized validation and joins. They resolve symlinks and normalize platform-specific forms before enforcement.
  • If you need maximum performance and you are absolutely certain symlinks cannot occur and paths are already canonical/normalized, a lexical solution from another crate may fit — but you accept the risk and narrower threat model.

The Road to Publication

This was a long journey, but it isn't over yet. It's time to make this crate public, ensuring all generated docs are correct and we don't have leftovers.

The version is now good enough to be the first stable foundation for a security crate! I hope this catches on (I didn't really expect it when I started), and at some point, I began thinking of it as a potential new standard for securing paths.

If this succeeds, I'd like to port it to other programming languages—JavaScript, Java, and Python first! In a way, I hope this will be what prepared statements are for SQL: a fundamental security practice that becomes standard across the ecosystem.

Lessons Learned

The journey taught me several important lessons:

  1. Security requires iteration: Each security review revealed new edge cases
  2. API design is crucial: Small naming decisions have huge impacts on usability
  3. Ergonomics vs Safety: You can have both, but it requires careful design
  4. Community feedback matters: LLM-generated code revealed real usage patterns
  5. Standards evolve: What seems like a simple idea often grows into something much more comprehensive

The result is strict-path—a crate that not only solves the original path validation problem but provides a comprehensive, ergonomic, and secure foundation for all path operations in Rust applications.