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
| Attack | Result |
|---|---|
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 username = "alice";
let alice_root = VirtualRoot::try_new_create(format!("user_data_{username}"))?;
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
- Cache user roots - Store
VirtualRootinstances to avoid repeated creation - Lazy initialization - Only create directories when first accessed
- Batch operations - Group multiple file operations together
- Use async I/O - All paths work with
tokio::fsvia.interop_path()
Best Practices
- One root per user - Never share
VirtualRootbetween users - Store virtual paths - Save virtual paths in your database, not system paths
- Display virtual paths - Show users virtual paths (starting with
/) - Use system paths for I/O - Use
.as_unvirtual()when calling file operations
Next Steps
- See Web Upload Service for a simpler upload-only example
- See Type-Safe Context Separation to learn about using markers for different document types