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
&boundaryinto 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
| Scenario | Use Sugar Constructor | Use Policy Type |
|---|---|---|
| One-off file operation | ✅ with_boundary() | Optional |
| Multiple joins against root | ⚠️ Suboptimal | ✅ PathBoundary |
| 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 ergonomic | Optional |
Learn More
- Best Practices Overview → - Core guidelines and decision matrices
- Real-World Patterns → - Production examples showing policy reuse
- Common Operations → - How to use paths after validation
- Authorization Patterns → - Markers for compile-time authorization