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!
StrictPath vs VirtualPath: Symlink Semantics
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
- Validate once, use everywhere: Create typed paths at boundaries, pass typed paths to functions
- Encode intent in signatures: Function parameters should clearly show what domains they work with
- Separate validation from business logic: Keep path validation separate from file processing
- Use meaningful marker names: Markers should describe business domains, not technical implementation
- Fail at compile time: Structure your code so domain mix-ups become type errors
- 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!