Backend Implementer’s Guide
This guide walks you through implementing a custom AnyFS backend.
Overview
AnyFS uses layered traits - you implement only what you need:
FsPosix (full POSIX)
│
FsFuse (FUSE-mountable)
│
FsFull (std::fs features)
│
Fs (basic - 90% of use cases)
│
FsRead + FsWrite + FsDir (core)
Key properties:
- Backends accept
&Pathfor all path parameters - Backends receive already-resolved paths - FileStorage handles path resolution via pluggable
PathResolver(see ADR-033). Default isIterativeResolverfor symlink-aware resolution. - Backends handle storage only - just store/retrieve bytes at given paths
- Policy (limits, feature gates) is handled by middleware, not backends
- Implement only the traits your backend supports
- Backends must be thread-safe - all trait methods use
&self, so backends must use interior mutability (e.g.,RwLock,Mutex) for synchronization
Dependency
Depend only on anyfs-backend:
[dependencies]
anyfs-backend = "0.1"
Choosing Which Traits to Implement
| Your Backend Supports | Implement |
|---|---|
| Basic file operations | Fs (= FsRead + FsWrite + FsDir) |
| Links, permissions, sync | Add FsLink, FsPermissions, FsSync, FsStats |
| Hardlinks, FUSE mounting | Add FsInode → becomes FsFuse |
| Full POSIX (handles, locks, xattr) | Add FsHandles, FsLock, FsXattr → becomes FsPosix |
Minimal Backend: Just Fs
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsWrite, FsDir, FsError, Metadata, DirEntry};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
pub struct MyBackend {
// Your storage fields
}
// Implement FsRead
impl FsRead for MyBackend {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let path = path.as_ref();
todo!()
}
fn read_to_string(&self, path: &Path) -> Result<String, FsError> {
let data = self.read(path)?;
String::from_utf8(data).map_err(|e| FsError::Backend(e.to_string()))
}
fn read_range(&self, path: &Path, offset: u64, len: usize) -> Result<Vec<u8>, FsError> {
todo!()
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
todo!()
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
todo!()
}
fn open_read(&self, path: &Path) -> Result<Box<dyn Read + Send>, FsError> {
let data = self.read(path)?;
Ok(Box::new(std::io::Cursor::new(data)))
}
}
// Implement FsWrite
impl FsWrite for MyBackend {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
todo!()
}
fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
todo!()
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
todo!()
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
todo!()
}
fn copy(&self, from: &Path, to: &Path) -> Result<(), FsError> {
todo!()
}
fn truncate(&self, path: &Path, size: u64) -> Result<(), FsError> {
todo!()
}
fn open_write(&self, path: &Path) -> Result<Box<dyn Write + Send>, FsError> {
todo!()
}
}
// Implement FsDir
impl FsDir for MyBackend {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
todo!()
}
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
todo!()
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
todo!()
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
todo!()
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
todo!()
}
}
// MyBackend now implements Fs automatically (blanket impl)!
}
Implementation Steps
Step 1: Pick a Data Model
Your backend needs internal storage. Options:
- HashMap-based:
HashMap<PathBuf, Entry>for simple cases - Tree-based: Explicit directory tree structure
- Database-backed: SQLite, key-value store, etc.
Minimum metadata per entry:
- File type (file/directory/symlink)
- Size (for files)
- Content (for files)
- Timestamps (optional)
- Permissions (optional)
Step 2: Implement FsRead (Layer 1)
Start with read operations (easiest):
#![allow(unused)]
fn main() {
impl FsRead for MyBackend {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError>;
fn read_to_string(&self, path: &Path) -> Result<String, FsError>;
fn read_range(&self, path: &Path, offset: u64, len: usize) -> Result<Vec<u8>, FsError>;
fn exists(&self, path: &Path) -> Result<bool, FsError>;
fn metadata(&self, path: &Path) -> Result<Metadata, FsError>;
fn open_read(&self, path: &Path) -> Result<Box<dyn Read + Send>, FsError>;
}
}
Streaming implementation options:
For MemoryBackend or similar, you can use std::io::Cursor:
#![allow(unused)]
fn main() {
fn open_read(&self, path: &Path) -> Result<Box<dyn Read + Send>, FsError> {
let data = self.read(path)?;
Ok(Box::new(std::io::Cursor::new(data)))
}
}
For VRootFsBackend, return the actual file handle:
#![allow(unused)]
fn main() {
fn open_read(&self, path: &Path) -> Result<Box<dyn Read + Send>, FsError> {
let file = std::fs::File::open(self.resolve(path)?)?;
Ok(Box::new(file))
}
}
Step 3: Implement FsWrite (Layer 1)
#![allow(unused)]
fn main() {
impl FsWrite for MyBackend {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError>;
fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError>;
fn remove_file(&self, path: &Path) -> Result<(), FsError>;
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError>;
fn copy(&self, from: &Path, to: &Path) -> Result<(), FsError>;
fn truncate(&self, path: &Path, size: u64) -> Result<(), FsError>;
fn open_write(&self, path: &Path) -> Result<Box<dyn Write + Send>, FsError>;
}
}
Note on truncate:
- If
size < current: discard trailing bytes - If
size > current: extend with zero bytes - Required for FUSE support and editor save operations
Step 4: Implement FsDir (Layer 1)
#![allow(unused)]
fn main() {
impl FsDir for MyBackend {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError>;
fn create_dir(&self, path: &Path) -> Result<(), FsError>;
fn create_dir_all(&self, path: &Path) -> Result<(), FsError>;
fn remove_dir(&self, path: &Path) -> Result<(), FsError>;
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError>;
}
}
Congratulations! After implementing FsRead, FsWrite, and FsDir, your backend implements Fs automatically (blanket impl). This covers 90% of use cases.
Optional: Layer 2 Traits
Add these if your backend supports the features:
FsLink - Symlinks and Hardlinks
#![allow(unused)]
fn main() {
impl FsLink for MyBackend {
fn symlink(&self, original: &Path, link: &Path) -> Result<(), FsError>;
fn hard_link(&self, original: &Path, link: &Path) -> Result<(), FsError>;
fn read_link(&self, path: &Path) -> Result<PathBuf, FsError>;
fn symlink_metadata(&self, path: &Path) -> Result<Metadata, FsError>;
}
}
- Symlinks store a target path as a string
- Hard links share content with the original (update link count)
FsPermissions
#![allow(unused)]
fn main() {
impl FsPermissions for MyBackend {
fn set_permissions(&self, path: &Path, perm: Permissions) -> Result<(), FsError>;
}
}
FsSync - Durability
#![allow(unused)]
fn main() {
impl FsSync for MyBackend {
fn sync(&self) -> Result<(), FsError>;
fn fsync(&self, path: &Path) -> Result<(), FsError>;
}
}
sync(): Flush all pending writes to durable storagefsync(path): Flush pending writes for a specific fileMemoryBackendcan no-op these (volatile by design)SqliteBackend:PRAGMA wal_checkpointor connection flushVRootFsBackend:std::fs::File::sync_all()
FsStats - Filesystem Stats
#![allow(unused)]
fn main() {
impl FsStats for MyBackend {
fn statfs(&self) -> Result<StatFs, FsError>;
}
}
Return filesystem capacity information:
#![allow(unused)]
fn main() {
StatFs {
total_bytes: 0, // 0 = unlimited
used_bytes: ...,
available_bytes: ...,
total_inodes: 0,
used_inodes: ...,
available_inodes: ...,
block_size: 4096,
max_name_len: 255,
}
}
Optional: Layer 3 - FsInode (For FUSE)
Implement FsInode if you need FUSE mounting or inode-based hardlink tracking:
#![allow(unused)]
fn main() {
impl FsInode for MyBackend {
fn path_to_inode(&self, path: &Path) -> Result<u64, FsError>;
fn inode_to_path(&self, inode: u64) -> Result<PathBuf, FsError>;
fn lookup(&self, parent_inode: u64, name: &OsStr) -> Result<u64, FsError>;
fn metadata_by_inode(&self, inode: u64) -> Result<Metadata, FsError>;
}
}
No blanket/default implementation - you must implement this trait explicitly if you need:
- FUSE mounting: FUSE operates on inodes, not paths
- Inode tracking for hardlinks: Two paths share the same inode (note:
hard_link()creation is inFsLink)
Level 1: Simple backend (no FsInode)
Don’t implement FsInode. The backend won’t support FUSE mounting. Hardlink creation via FsLink::hard_link() still works, but inode sharing won’t be tracked.
Level 2: Hardlink support
Override path_to_inode so hardlinked paths return the same inode:
#![allow(unused)]
fn main() {
struct Node {
id: u64, // Unique node ID (the inode)
nlink: u64, // Hard link count
content: Vec<u8>,
}
struct MemoryBackend {
next_id: u64,
nodes: HashMap<u64, Node>, // inode -> Node
paths: HashMap<PathBuf, u64>, // path -> inode
}
impl FsInode for MemoryBackend {
fn path_to_inode(&self, path: &Path) -> Result<u64, FsError> {
self.paths.get(path.as_ref())
.copied()
.ok_or_else(|| FsError::NotFound { path: path.as_ref().into() })
}
// ... implement others
}
impl FsLink for MemoryBackend {
fn hard_link(&self, original: &Path, link: &Path) -> Result<(), FsError> {
let inode = self.path_to_inode(&original)?;
self.paths.insert(link.as_ref().to_path_buf(), inode);
self.nodes.get_mut(&inode).unwrap().nlink += 1;
Ok(())
}
}
}
Level 3: Full FUSE efficiency
Override all 4 methods for O(1) inode operations:
#![allow(unused)]
fn main() {
impl FsInode for SqliteBackend {
fn path_to_inode(&self, path: &Path) -> Result<u64, FsError> {
self.conn.query_row(
"SELECT id FROM nodes WHERE path = ?",
[path.as_ref().to_string_lossy()],
|row| Ok(row.get::<_, i64>(0)? as u64),
).map_err(|_| FsError::NotFound { path: path.as_ref().into() })
}
fn inode_to_path(&self, inode: u64) -> Result<PathBuf, FsError> {
self.conn.query_row(
"SELECT path FROM nodes WHERE id = ?",
[inode as i64],
|row| Ok(PathBuf::from(row.get::<_, String>(0)?)),
).map_err(|_| FsError::NotFound { path: format!("inode:{}", inode).into() })
}
fn lookup(&self, parent_inode: u64, name: &OsStr) -> Result<u64, FsError> {
self.conn.query_row(
"SELECT id FROM nodes WHERE parent_id = ? AND name = ?",
params![parent_inode as i64, name.to_string_lossy()],
|row| Ok(row.get::<_, i64>(0)? as u64),
).map_err(|_| FsError::NotFound { path: name.into() })
}
fn metadata_by_inode(&self, inode: u64) -> Result<Metadata, FsError> {
self.conn.query_row(
"SELECT type, size, nlink, created, modified FROM nodes WHERE id = ?",
[inode as i64],
|row| Ok(Metadata {
inode,
nlink: row.get(2)?,
// ...
}),
).map_err(|_| FsError::NotFound { path: format!("inode:{}", inode).into() })
}
}
}
Summary:
| Your Backend | Implement | Result |
|---|---|---|
| Simple (no hardlinks) | Nothing | Works with defaults |
| With hardlinks | FsInode::path_to_inode | Hardlinks work correctly |
| FUSE-optimized | Full FsInode | Maximum performance |
Optional: Layer 4 - POSIX Traits
For full POSIX semantics (file handles, locking, extended attributes):
FsHandles - File Handle Operations
#![allow(unused)]
fn main() {
impl FsHandles for MyBackend {
fn open(&self, path: &Path, flags: OpenFlags) -> Result<Handle, FsError>;
fn read_at(&self, handle: Handle, buf: &mut [u8], offset: u64) -> Result<usize, FsError>;
fn write_at(&self, handle: Handle, data: &[u8], offset: u64) -> Result<usize, FsError>;
fn close(&self, handle: Handle) -> Result<(), FsError>;
}
}
FsLock - File Locking
#![allow(unused)]
fn main() {
impl FsLock for MyBackend {
fn lock(&self, handle: Handle, lock: LockType) -> Result<(), FsError>;
fn try_lock(&self, handle: Handle, lock: LockType) -> Result<bool, FsError>;
fn unlock(&self, handle: Handle) -> Result<(), FsError>;
}
}
FsXattr - Extended Attributes
#![allow(unused)]
fn main() {
impl FsXattr for MyBackend {
fn get_xattr(&self, path: &Path, name: &str) -> Result<Vec<u8>, FsError>;
fn set_xattr(&self, path: &Path, name: &str, value: &[u8]) -> Result<(), FsError>;
fn remove_xattr(&self, path: &Path, name: &str) -> Result<(), FsError>;
fn list_xattr(&self, path: &Path) -> Result<Vec<String>, FsError>;
}
}
Note: Most backends don’t need Layer 4. Only implement if you’re wrapping a real filesystem (VRootFsBackend) or building a database that needs full POSIX semantics.
Error Handling
Return appropriate FsError variants:
| Situation | Error |
|---|---|
| Path doesn’t exist | FsError::NotFound { path, operation } |
| Path already exists | FsError::AlreadyExists { path, operation } |
| Expected file, got dir | FsError::NotAFile { path } |
| Expected dir, got file | FsError::NotADirectory { path } |
| Remove non-empty dir | FsError::DirectoryNotEmpty { path } |
| Internal error | FsError::Backend { message } |
What Backends Do NOT Do
| Concern | Where It Lives |
|---|---|
| Quota enforcement | Quota<B> middleware |
| Feature gating | Restrictions<B> middleware |
| Logging | Tracing<B> middleware |
| Ergonomic API | FileStorage<B> wrapper |
Backends focus on storage. Keep them simple.
Optional Optimizations
Some trait methods have default implementations that work universally but may be suboptimal for specific backends. You can override these for better performance.
Path Canonicalization (FsPath Trait)
The FsPath trait provides canonicalize() and soft_canonicalize() with default implementations that call read_link() and symlink_metadata() per path component.
Default behavior: O(n) calls for a path with n components
When to override:
- Your backend can resolve paths more efficiently (e.g., SQL query)
- Your backend delegates to OS (which has optimized syscalls)
SQLite Example - Single Query Resolution:
#![allow(unused)]
fn main() {
impl FsPath for SqliteBackend {
fn canonicalize(&self, path: &Path) -> Result<PathBuf, FsError> {
// Resolve entire path in one recursive CTE query
self.conn.query_row(
r#"
WITH RECURSIVE resolve(current, depth) AS (
SELECT :path, 0
UNION ALL
SELECT
CASE WHEN n.type = 'symlink'
THEN n.target
ELSE resolve.current
END,
depth + 1
FROM resolve
LEFT JOIN nodes n ON n.path = resolve.current
WHERE n.type = 'symlink' AND depth < 40
)
SELECT current FROM resolve ORDER BY depth DESC LIMIT 1
"#,
params![path.to_string_lossy()],
|row| Ok(PathBuf::from(row.get::<_, String>(0)?))
).map_err(|_| FsError::NotFound {
path: path.into(),
operation: "canonicalize"
})
}
}
}
VRootFsBackend Example - OS Delegation:
#![allow(unused)]
fn main() {
impl FsPath for VRootFsBackend {
fn canonicalize(&self, path: &Path) -> Result<PathBuf, FsError> {
// Delegate to OS, which uses optimized syscalls
let host_path = self.root.join(path.strip_prefix("/").unwrap_or(path));
let resolved = std::fs::canonicalize(&host_path)
.map_err(|e| FsError::NotFound {
path: path.into(),
operation: "canonicalize"
})?;
// Verify containment (security check)
if !resolved.starts_with(&self.root) {
return Err(FsError::AccessDenied {
path: path.into(),
reason: "path escapes root".into(),
});
}
// Convert back to virtual path
Ok(PathBuf::from("/").join(resolved.strip_prefix(&self.root).unwrap()))
}
}
}
Other Optimization Opportunities
| Method | Default | Optimization Opportunity |
|---|---|---|
canonicalize() | O(n) per component | SQL CTE, OS delegation |
create_dir_all() | Recursive create_dir() | Single SQL INSERT with path hierarchy |
remove_dir_all() | Recursive traversal | SQL DELETE with LIKE pattern |
copy() | read + write | Database-level copy, reflink |
General Pattern:
#![allow(unused)]
fn main() {
// Override any trait method with optimized implementation
impl FsDir for SqliteBackend {
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
// Instead of calling create_dir() for each level,
// insert all parent paths in a single transaction
self.conn.execute_batch(&format!(
"INSERT OR IGNORE INTO nodes (path, type) VALUES {}",
generate_ancestor_values(path)
))?;
Ok(())
}
}
}
When NOT to optimize:
MemoryBackend: In-memory operations are already fast; keep it simple- Low-volume operations: Optimize where it matters (hot paths)
- Prototype phase: Get correctness first, optimize later
See ADR-032 for the full design rationale.
Testing Your Backend
Use the conformance test suite:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::MyBackend;
use anyfs_backend::Fs;
fn create_backend() -> MyBackend {
MyBackend::new()
}
#[test]
fn test_write_read() {
let backend = create_backend();
backend.write(std::path::Path::new("/test.txt"), b"hello").unwrap();
let content = backend.read(std::path::Path::new("/test.txt")).unwrap();
assert_eq!(content, b"hello");
}
#[test]
fn test_create_dir() {
let backend = create_backend();
backend.create_dir(std::path::Path::new("/foo")).unwrap();
assert!(backend.exists(std::path::Path::new("/foo")).unwrap());
}
// ... more tests
}
}
Note on VRootFsBackend
If you are implementing a backend that wraps a real host filesystem directory, consider using strict-path::VirtualPath and strict-path::VirtualRoot internally for path containment. This ensures paths cannot escape the designated root directory.
This is an implementation choice for filesystem-based backends, not a requirement of the Fs trait.
For Middleware Authors: Wrapping Streams
Middleware that needs to intercept streaming I/O must wrap the returned Box<dyn Read/Write>.
CountingWriter Example
#![allow(unused)]
fn main() {
use std::io::{self, Write};
use std::sync::{Arc, atomic::{AtomicU64, Ordering}};
pub struct CountingWriter<W: Write> {
inner: W,
bytes_written: Arc<AtomicU64>,
}
impl<W: Write> CountingWriter<W> {
pub fn new(inner: W, counter: Arc<AtomicU64>) -> Self {
Self { inner, bytes_written: counter }
}
}
impl<W: Write + Send> Write for CountingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.inner.write(buf)?;
self.bytes_written.fetch_add(n as u64, Ordering::Relaxed);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
}
Using in Quota Middleware
#![allow(unused)]
fn main() {
impl<B: Fs> Fs for Quota<B> {
fn open_write(&self, path: &Path) -> Result<Box<dyn Write + Send>, FsError> {
// Check if we're at quota before opening
if self.usage.total_bytes >= self.limits.max_total_size {
return Err(FsError::QuotaExceeded { ... });
}
let inner = self.inner.open_write(path)?;
Ok(Box::new(CountingWriter::new(inner, self.usage.bytes_counter.clone())))
}
}
}
Alternatives to Wrapping
| Middleware | Alternative to wrapping |
|---|---|
| PathFilter | Check path at open time, pass stream through |
| ReadOnly | Block open_write entirely |
| RateLimit | Count the open call, not stream bytes |
| Tracing | Log the open call, pass stream through |
| DryRun | Return std::io::sink() instead of real writer |
Creating Custom Middleware
Custom middleware only requires anyfs-backend as a dependency - same as backends.
Dependency
[dependencies]
anyfs-backend = "0.1"
The Pattern (5 Minutes to Understand)
Middleware is just a struct that:
- Wraps another
Fs - Implements
Fsitself - Intercepts some methods, delegates others
#![allow(unused)]
fn main() {
// ┌─────────────────────────────────────┐
// │ Your Middleware │
// │ ┌─────────────────────────────────┐│
// │ │ Inner Backend (any Fs) ││
// │ └─────────────────────────────────┘│
// └─────────────────────────────────────┘
//
// Request → Middleware (intercept/modify) → Inner Backend
// Response ← Middleware (intercept/modify) ← Inner Backend
}
Simplest Possible Middleware: Operation Counter
This middleware counts how many operations are performed:
#![allow(unused)]
fn main() {
use anyfs_backend::{Fs, FsError, Metadata, DirEntry, Permissions, StatFs};
use std::sync::atomic::{AtomicU64, Ordering};
use std::path::{Path, PathBuf};
/// Counts all operations performed on the backend.
pub struct Counter<B> {
inner: B,
pub count: AtomicU64,
}
impl<B> Counter<B> {
pub fn new(inner: B) -> Self {
Self { inner, count: AtomicU64::new(0) }
}
pub fn operations(&self) -> u64 {
self.count.load(Ordering::Relaxed)
}
}
// Implement each trait the inner backend supports
impl<B: FsRead> FsRead for Counter<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.count.fetch_add(1, Ordering::Relaxed); // Count it
self.inner.read(path) // Delegate
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.exists(path)
}
// ... repeat for all FsRead methods
}
impl<B: FsWrite> FsWrite for Counter<B> {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed); // Count it
self.inner.write(path, data) // Delegate
}
// ... repeat for all FsWrite methods
}
impl<B: FsDir> FsDir for Counter<B> {
// ... implement FsDir methods
}
// Counter<B> now implements Fs when B: Fs (blanket impl)
}
Usage:
#![allow(unused)]
fn main() {
let backend = Counter::new(MemoryBackend::new());
backend.write(std::path::Path::new("/file.txt"), b"hello")?;
backend.read(std::path::Path::new("/file.txt"))?;
backend.read(std::path::Path::new("/file.txt"))?;
println!("Operations: {}", backend.operations()); // 3
}
That’s it. That’s the entire pattern.
Adding a Layer (for .layer() syntax)
To enable the fluent .layer() syntax, add a Layer struct. The .layer() method
comes from the LayerExt trait which has a blanket impl for all Fs types:
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, LayerExt}; // LayerExt provides .layer() method
pub struct CounterLayer;
impl<B: Fs> Layer<B> for CounterLayer {
type Backend = Counter<B>;
fn layer(self, backend: B) -> Counter<B> {
Counter::new(backend)
}
}
}
Usage with .layer():
#![allow(unused)]
fn main() {
// LayerExt is re-exported from anyfs crate
use anyfs::LayerExt;
let backend = MemoryBackend::new()
.layer(CounterLayer);
}
Real Example: ReadOnly Middleware
A practical middleware that blocks all write operations:
#![allow(unused)]
fn main() {
pub struct ReadOnly<B> {
inner: B,
}
impl<B> ReadOnly<B> {
pub fn new(inner: B) -> Self {
Self { inner }
}
}
// FsRead: just delegate
impl<B: FsRead> FsRead for ReadOnly<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.inner.read(path)
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
self.inner.exists(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.inner.metadata(path)
}
// ... delegate all FsRead methods
}
// FsDir: delegate reads, block writes
impl<B: FsDir> FsDir for ReadOnly<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
self.inner.read_dir(path)
}
fn create_dir(&self, _path: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "create_dir" })
}
fn create_dir_all(&self, _path: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "create_dir_all" })
}
fn remove_dir(&self, _path: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "remove_dir" })
}
fn remove_dir_all(&self, _path: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "remove_dir_all" })
}
}
// FsWrite: block all operations
impl<B: FsWrite> FsWrite for ReadOnly<B> {
fn write(&self, _path: &Path, _data: &[u8]) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "write" })
}
fn remove_file(&self, _path: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "remove_file" })
}
// ... block all FsWrite methods
}
}
Usage:
#![allow(unused)]
fn main() {
let backend = ReadOnly::new(MemoryBackend::new());
backend.read(std::path::Path::new("/file.txt")); // OK (if file exists)
backend.write(std::path::Path::new("/file.txt"), b""); // Error: ReadOnly
}
Middleware Decision Table
| What You Want | Intercept | Delegate | Example |
|---|---|---|---|
| Count operations | All methods (before) | All methods | Counter |
| Block writes | Write methods | Read methods | ReadOnly |
| Transform data | read/write | Everything else | Encryption |
| Check permissions | All methods (before) | All methods | PathFilter |
| Log operations | All methods (before) | All methods | Tracing |
| Enforce limits | Write methods (check size) | Read methods | Quota |
Macro for Boilerplate (Optional)
If you don’t want to manually delegate all 29 methods, you can use a macro:
#![allow(unused)]
fn main() {
macro_rules! delegate {
($self:ident, $method:ident, $($arg:ident),*) => {
$self.inner.$method($($arg),*)
};
}
impl<B: Fs> Fs for MyMiddleware<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
// Your logic here
delegate!(self, read, path)
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
delegate!(self, exists, path)
}
// ... etc
}
}
Or provide a delegate_all! macro in anyfs-backend that generates all the passthrough implementations.
Complete Example: Encryption Middleware
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsWrite, FsDir, Layer, FsError, Metadata, DirEntry};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
/// Middleware that encrypts/decrypts file contents transparently.
pub struct Encrypted<B> {
inner: B,
key: [u8; 32],
}
impl<B> Encrypted<B> {
pub fn new(inner: B, key: [u8; 32]) -> Self {
Self { inner, key }
}
fn encrypt(&self, data: &[u8]) -> Vec<u8> {
// Your encryption logic here
data.iter().map(|b| b ^ self.key[0]).collect()
}
fn decrypt(&self, data: &[u8]) -> Vec<u8> {
// Your decryption logic here (symmetric for XOR)
self.encrypt(data)
}
}
// FsRead: decrypt on read
impl<B: FsRead> FsRead for Encrypted<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let encrypted = self.inner.read(path)?;
Ok(self.decrypt(&encrypted))
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
self.inner.exists(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.inner.metadata(path)
}
// ... delegate other FsRead methods
}
// FsWrite: encrypt on write
impl<B: FsWrite> FsWrite for Encrypted<B> {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let encrypted = self.encrypt(data);
self.inner.write(path, &encrypted)
}
// ... delegate/encrypt other FsWrite methods
}
// FsDir: just delegate (directories don't need encryption)
impl<B: FsDir> FsDir for Encrypted<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
self.inner.read_dir(path)
}
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
self.inner.create_dir(path)
}
// ... delegate other FsDir methods
}
// Encrypted<B> now implements Fs when B: Fs (blanket impl)
/// Layer for creating Encrypted middleware.
pub struct EncryptedLayer {
key: [u8; 32],
}
impl EncryptedLayer {
pub fn new(key: [u8; 32]) -> Self {
Self { key }
}
}
impl<B: Fs> Layer<B> for EncryptedLayer {
type Backend = Encrypted<B>;
fn layer(self, backend: B) -> Self::Backend {
Encrypted::new(backend, self.key)
}
}
}
Usage
#![allow(unused)]
fn main() {
use anyfs::MemoryBackend;
use my_middleware::{EncryptedLayer, Encrypted};
// Direct construction
let fs = Encrypted::new(MemoryBackend::new(), key);
// Or via Layer trait
let fs = MemoryBackend::new()
.layer(EncryptedLayer::new(key));
}
Middleware Checklist
- Depends only on
anyfs-backend - Implements the same traits as the inner backend (
FsRead,FsWrite,FsDir, etc.) - Implements
Layer<B>forMyMiddlewareLayer - Delegates unmodified operations to inner backend
- Handles streaming I/O appropriately (wrap, pass-through, or block)
- Documents which operations are intercepted vs delegated
Backend Checklist
- Depends only on
anyfs-backend - Implements core traits:
FsRead,FsWrite,FsDir(=Fs) - Optional: Implements
FsLink,FsPermissions,FsSync,FsStats(=FsFull) - Optional: Implements
FsInodefor FUSE support (=FsFuse) - Optional: Implements
FsHandles,FsLock,FsXattrfor POSIX (=FsPosix) - Accepts
&Pathfor all paths - Returns correct
FsErrorvariants - Passes conformance tests for implemented traits
- No panics (see below)
- Thread-safe (see below)
- Documents performance characteristics
Critical Implementation Guidelines
These guidelines are derived from issues found in similar projects (vfs, agentfs). All implementations MUST follow these.
1. No Panic Policy
NEVER use .unwrap() or .expect() in library code.
#![allow(unused)]
fn main() {
// BAD - will panic on missing file
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let entry = self.entries.get(path.as_ref()).unwrap(); // PANIC!
Ok(entry.content.clone())
}
// GOOD - returns error
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let path = path.as_ref();
let entry = self.entries.get(path)
.ok_or_else(|| FsError::NotFound { path: path.to_path_buf() })?;
Ok(entry.content.clone())
}
}
Edge cases that must NOT panic:
- File doesn’t exist
- Directory doesn’t exist
- Path is empty string
- Path is invalid UTF-8 (if using OsStr)
- Parent directory missing
- Trying to read a directory as a file
- Trying to list a file as a directory
- Concurrent access conflicts
2. Thread Safety (Required)
All trait methods use &self, not &mut self. This means backends MUST use interior mutability for thread-safe concurrent access.
Why &self?
- Enables concurrent access patterns (multiple readers, concurrent operations)
- Matches real filesystem semantics (concurrent access is normal)
- More flexible API (can share references without exclusive ownership)
Backend implementer responsibility:
- Use
RwLock,Mutex, or similar for internal state - Ensure operations are atomic (a single
write()call shouldn’t produce partial results) - Handle lock poisoning gracefully
What the synchronization guarantees:
- Memory safety (no data corruption)
- Atomic operations (writes don’t interleave)
What it does NOT guarantee:
- Order of concurrent writes to the same path (last write wins - standard FS behavior)
#![allow(unused)]
fn main() {
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use std::path::PathBuf;
pub struct MemoryBackend {
entries: Arc<RwLock<HashMap<PathBuf, Entry>>>,
}
impl FsRead for MemoryBackend {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let entries = self.entries.read()
.map_err(|_| FsError::Backend("lock poisoned".into()))?;
// ...
}
}
impl FsWrite for MemoryBackend {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let mut entries = self.entries.write()
.map_err(|_| FsError::Backend("lock poisoned".into()))?;
// ...
}
}
}
Common race conditions to avoid:
create_dir_allcalled concurrently for same pathreadduringwriteto same fileread_dirwhile directory is being modifiedrenamewith concurrent access to source or destination
3. Path Resolution - NOT Your Job
Backends do NOT handle path resolution. FileStorage handles:
- Resolving
..and.components - Following symlinks for non-
SelfResolvingbackends that implementFsLink - Normalizing paths (
//→/, trailing slashes, etc.) - Walking the virtual directory structure
Your backend receives already-resolved, clean paths. Just store and retrieve bytes at those paths.
#![allow(unused)]
fn main() {
impl FsRead for MyBackend {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
// Path is already resolved - just use it directly
let path = path.as_ref();
self.storage.get(path).ok_or_else(|| FsError::NotFound { path: path.to_path_buf() })
}
}
}
Exception: If your backend wraps a real filesystem (like VRootFsBackend), implement SelfResolving to tell FileStorage to skip resolution - the OS handles it.
#![allow(unused)]
fn main() {
impl SelfResolving for VRootFsBackend {}
}
4. Error Messages
Include context in errors for debugging:
#![allow(unused)]
fn main() {
// BAD - no context
Err(FsError::NotFound)
// GOOD - includes path
Err(FsError::NotFound { path: path.to_path_buf() })
// BETTER - includes operation context
Err(FsError::Io {
path: path.to_path_buf(),
operation: "read",
source: io_error,
})
}
5. Drop Implementation
Ensure cleanup happens correctly:
#![allow(unused)]
fn main() {
impl Drop for SqliteBackend {
fn drop(&mut self) {
// Flush any pending writes
if let Err(e) = self.sync() {
eprintln!("Warning: failed to sync on drop: {}", e);
}
}
}
}
6. Performance Documentation
Document the complexity of operations:
#![allow(unused)]
fn main() {
/// Memory-based virtual filesystem backend.
///
/// # Performance Characteristics
///
/// | Operation | Complexity | Notes |
/// |-----------|------------|-------|
/// | `read` | O(1) | HashMap lookup |
/// | `write` | O(n) | n = data size |
/// | `read_dir` | O(k) | k = entries in directory |
/// | `create_dir_all` | O(d) | d = path depth |
/// | `remove_dir_all` | O(n) | n = total descendants |
///
/// # Thread Safety
///
/// All operations are thread-safe. Uses `RwLock` internally.
/// Multiple concurrent reads are allowed.
/// Writes are exclusive.
pub struct MemoryBackend { ... }
}
Testing Requirements
Your backend MUST pass these test categories:
Basic Functionality
#![allow(unused)]
fn main() {
#[test]
fn test_read_write_roundtrip() { ... }
#[test]
fn test_create_dir_and_list() { ... }
#[test]
fn test_remove_file() { ... }
}
Edge Cases (No Panics)
#![allow(unused)]
fn main() {
#[test]
fn test_read_nonexistent_returns_error() {
let backend = create_backend();
assert!(matches!(
backend.read(std::path::Path::new("/nonexistent")),
Err(FsError::NotFound { .. })
));
}
#[test]
fn test_read_dir_on_file_returns_error() {
let backend = create_backend();
backend.write(std::path::Path::new("/file.txt"), b"data").unwrap();
assert!(matches!(
backend.read_dir(std::path::Path::new("/file.txt")),
Err(FsError::NotADirectory { .. })
));
}
#[test]
fn test_empty_path_returns_error() {
let backend = create_backend();
assert!(backend.read(std::path::Path::new("")).is_err());
}
}
Thread Safety
#![allow(unused)]
fn main() {
#[test]
fn test_concurrent_reads() {
let backend = Arc::new(create_backend_with_data());
let handles: Vec<_> = (0..10).map(|_| {
let backend = backend.clone();
std::thread::spawn(move || {
for _ in 0..100 {
backend.read(std::path::Path::new("/test.txt")).unwrap();
}
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
#[test]
fn test_concurrent_create_dir_all() {
let backend = Arc::new(RwLock::new(create_backend()));
let handles: Vec<_> = (0..10).map(|_| {
let backend = backend.clone();
std::thread::spawn(move || {
let mut backend = backend.write().unwrap();
// Should not panic or corrupt state
let _ = backend.create_dir_all(std::path::Path::new("/a/b/c/d"));
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
}
Path Normalization
Note: These tests apply to
FileStorageintegration tests, NOT direct backend tests. Backends receive already-resolved paths fromFileStorage. The tests below verify thatFileStoragecorrectly normalizes paths before passing them to backends.
#![allow(unused)]
fn main() {
#[test]
fn test_filestorage_path_normalization() {
// Use FileStorage, not raw backend
let fs = FileStorage::new(create_backend());
fs.create_dir_all("/foo/bar").unwrap();
fs.write("/foo/bar/test.txt", b"data").unwrap();
// FileStorage resolves these before calling backend
assert_eq!(fs.read("/foo/bar/test.txt").unwrap(), b"data");
assert_eq!(fs.read("/foo/bar/../bar/test.txt").unwrap(), b"data");
assert_eq!(fs.read("/foo/./bar/test.txt").unwrap(), b"data");
}
// Direct backend calls should use clean paths only
#[test]
fn test_backend_with_clean_paths() {
let backend = create_backend();
backend.create_dir_all(std::path::Path::new("/foo/bar")).unwrap();
backend.write(std::path::Path::new("/foo/bar/test.txt"), b"data").unwrap();
// Backends receive clean, resolved paths
assert_eq!(backend.read(std::path::Path::new("/foo/bar/test.txt")).unwrap(), b"data");
}
}
MemoryBackend Snapshot & Restore
MemoryBackend supports cloning its entire state (snapshot) and serializing to bytes for persistence.
Core Concept
Snapshot = Clone the storage. That’s it.
#![allow(unused)]
fn main() {
// MemoryBackend implements Clone (custom impl, not derive)
pub struct MemoryBackend { ... }
impl Clone for MemoryBackend {
fn clone(&self) -> Self {
// Deep copy of Arc<RwLock<...>> contents
// ...
}
}
// Snapshot is just .clone()
let snapshot = fs.clone();
// Restore is just assignment
fs = snapshot;
}
API
#![allow(unused)]
fn main() {
impl MemoryBackend {
/// Clone the entire filesystem state.
/// This is a DEEP COPY - modifications to the clone don't affect the original.
/// Implemented via custom Clone (not #[derive(Clone)]) to ensure deep copy
/// of Arc<RwLock<...>> contents.
pub fn clone(&self) -> Self { ... }
/// Serialize to bytes for persistence/transfer.
pub fn to_bytes(&self) -> Result<Vec<u8>, FsError>;
/// Deserialize from bytes.
pub fn from_bytes(data: &[u8]) -> Result<Self, FsError>;
/// Save to file.
pub fn save_to(&self, path: impl AsRef<Path>) -> Result<(), FsError>;
/// Load from file.
pub fn load_from(path: impl AsRef<Path>) -> Result<Self, FsError>;
}
}
Usage
#![allow(unused)]
fn main() {
let fs = MemoryBackend::new();
fs.write(std::path::Path::new("/data.txt"), b"important")?;
// Snapshot = clone
let checkpoint = fs.clone();
// Do risky work...
fs.write(std::path::Path::new("/data.txt"), b"corrupted")?;
// Rollback = replace with clone
fs = checkpoint;
assert_eq!(fs.read(std::path::Path::new("/data.txt"))?, b"important");
}
Persistence
#![allow(unused)]
fn main() {
// Save to disk
fs.save_to("state.bin")?;
// Load from disk
let fs = MemoryBackend::load_from("state.bin")?;
}
SqliteBackend
SQLite already has persistence - the database file IS the snapshot. For explicit snapshots:
#![allow(unused)]
fn main() {
impl SqliteBackend {
/// Create an in-memory copy of the database.
pub fn clone_to_memory(&self) -> Result<Self, FsError>;
/// Backup to another file.
pub fn backup_to(&self, path: impl AsRef<Path>) -> Result<(), FsError>;
}
}