AnyFS Backend Guide
anyfs-backend is the foundational crate for the AnyFS ecosystem. It provides a trait-based abstraction for filesystem operations, allowing you to:
- Write code that works with any filesystem backend
- Create middleware layers that add cross-cutting functionality
- Build FUSE filesystems with a clean, type-safe API
Who Is This Guide For?
- Backend implementers: You want to create a new filesystem backend (S3, Google Drive, in-memory, etc.)
- Middleware authors: You want to add logging, caching, encryption, or other features as composable layers
- Library users: You want to understand how to use anyfs effectively
What You’ll Learn
Implementing a Backend
Step-by-step guide to implementing a complete filesystem backend, from basic file operations to full POSIX support.
Implementing Middleware
How to create reusable middleware layers using the Tower-inspired Layer pattern.
Quick Example
use anyfs_backend::{Fs, FsError};
use std::path::Path;
// Write a generic function that works with ANY backend
fn copy_file<B: Fs>(fs: &B, src: &Path, dst: &Path) -> Result<(), FsError> {
let content = fs.read(src)?;
fs.write(dst, &content)?;
Ok(())
}
// Use with any backend that implements Fs
fn main() {
let fs = my_backend::MyFs::new();
copy_file(&fs, Path::new("/src.txt"), Path::new("/dst.txt")).unwrap();
}
Design Principles
- Trait-based: All operations are defined as traits, enabling generic code
- Layered: Traits are organized in layers (Fs → FsFull → FsFuse → FsPosix)
- Composable: Middleware layers can be stacked to add functionality
- Thread-safe: All traits require
Send + Syncfor concurrent access - Error-rich: Detailed error types with full context
Quick Start
Add anyfs-backend to your Cargo.toml:
[dependencies]
anyfs-backend = "0.1"
Using a Filesystem Backend
All backends implement the Fs trait (or higher-level traits). Write generic code against these traits:
#![allow(unused)]
fn main() {
use anyfs_backend::{Fs, FsError};
use std::path::Path;
fn list_files<B: Fs>(fs: &B, dir: &Path) -> Result<Vec<String>, FsError> {
let mut names = Vec::new();
for entry in fs.read_dir(dir)? {
let entry = entry?;
names.push(entry.name);
}
Ok(names)
}
}
Creating a Simple Backend
Here’s a minimal in-memory filesystem:
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsWrite, FsDir, FsError, Metadata, DirEntry, FileType, Permissions, ReadDirIter};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
pub struct SimpleFs {
files: RwLock<HashMap<PathBuf, Vec<u8>>>,
}
impl SimpleFs {
pub fn new() -> Self {
Self { files: RwLock::new(HashMap::new()) }
}
}
impl FsRead for SimpleFs {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.files.read().unwrap()
.get(path)
.cloned()
.ok_or_else(|| FsError::NotFound { path: path.to_path_buf() })
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
let files = self.files.read().unwrap();
let content = files.get(path)
.ok_or_else(|| FsError::NotFound { path: path.to_path_buf() })?;
Ok(Metadata {
path: path.to_path_buf(),
file_type: FileType::File,
len: content.len() as u64,
permissions: Permissions::default(),
..Default::default()
})
}
fn exists(&self, path: &Path) -> bool {
self.files.read().unwrap().contains_key(path)
}
}
impl FsWrite for SimpleFs {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
self.files.write().unwrap().insert(path.to_path_buf(), content.to_vec());
Ok(())
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
self.files.write().unwrap()
.remove(path)
.map(|_| ())
.ok_or_else(|| FsError::NotFound { path: path.to_path_buf() })
}
}
// FsDir implementation would go here...
}
Using Middleware Layers
Wrap any backend with middleware:
#![allow(unused)]
fn main() {
use anyfs_backend::{Fs, Layer};
// Assuming LoggingLayer is a middleware that logs operations
let fs = SimpleFs::new();
let fs = LoggingLayer::new("MyApp").layer(fs);
// Now all operations are logged
fs.write(Path::new("/hello.txt"), b"Hello!").unwrap();
}
Next Steps
- Trait Hierarchy - Understand the layer system
- Backend Tutorial - Complete backend implementation guide
- Middleware Tutorial - Create reusable layers
Trait Hierarchy
The anyfs-backend crate organizes filesystem operations into a layered hierarchy of traits. Each layer adds more capabilities.
The Four Layers
┌─────────────────────────────────────────────────────────────┐
│ Layer 4: FsPosix │
│ Full POSIX semantics with handles and locks │
│ = FsFuse + FsHandles + FsLock + FsXattr │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: FsFuse │
│ FUSE-compatible with inode operations │
│ = FsFull + FsInode │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: FsFull │
│ Complete filesystem with links, permissions, stats │
│ = Fs + FsLink + FsPermissions + FsSync + FsStats │
├─────────────────────────────────────────────────────────────┤
│ Layer 1: Fs │
│ Basic file operations │
│ = FsRead + FsWrite + FsDir │
└─────────────────────────────────────────────────────────────┘
Layer 1: Fs (Basic Operations)
The minimum for a functional filesystem.
| Trait | Purpose |
|---|---|
FsRead | Read files, get metadata, check existence |
FsWrite | Write files, delete files |
FsDir | List, create, remove directories; rename |
Use Fs when: You need basic file I/O and don’t care about permissions, symlinks, or advanced features.
#![allow(unused)]
fn main() {
fn backup<B: Fs>(fs: &B, src: &Path, dst: &Path) -> Result<(), FsError> {
let data = fs.read(src)?;
fs.write(dst, &data)
}
}
Layer 2: FsFull (Complete Filesystem)
Adds features most real filesystems need.
| Trait | Purpose |
|---|---|
FsLink | Symbolic and hard links |
FsPermissions | Set permissions and ownership |
FsSync | Flush writes to storage |
FsStats | Filesystem statistics (space usage) |
Use FsFull when: You need symlinks, permissions, or disk usage stats.
#![allow(unused)]
fn main() {
fn create_symlink<B: FsFull>(fs: &B, target: &Path, link: &Path) -> Result<(), FsError> {
fs.symlink(target, link)
}
}
Layer 3: FsFuse (FUSE Support)
Adds inode-based operations for FUSE implementations.
| Trait | Purpose |
|---|---|
FsInode | Path↔inode conversion, lookup by inode |
Use FsFuse when: Building a FUSE filesystem that operates on inodes.
#![allow(unused)]
fn main() {
fn get_child<B: FsFuse>(fs: &B, parent_inode: u64, name: &str) -> Result<u64, FsError> {
fs.lookup(parent_inode, name)
}
}
Layer 4: FsPosix (Full POSIX)
Complete POSIX semantics.
| Trait | Purpose |
|---|---|
FsHandles | Open files, read/write at offset |
FsLock | File locking (shared/exclusive) |
FsXattr | Extended attributes |
Use FsPosix when: You need file handles, locking, or extended attributes.
#![allow(unused)]
fn main() {
fn locked_write<B: FsPosix>(fs: &B, path: &Path, data: &[u8]) -> Result<(), FsError> {
let handle = fs.open(path, OpenFlags::WRITE | OpenFlags::CREATE)?;
fs.lock(handle, LockType::Exclusive)?;
fs.write_at(handle, 0, data)?;
fs.unlock(handle)?;
fs.close(handle)
}
}
Choosing the Right Trait Bound
| Your needs | Use this bound |
|---|---|
| Basic read/write/list | Fs |
| + symlinks, permissions, stats | FsFull |
| + inode operations (FUSE) | FsFuse |
| + file handles, locking | FsPosix |
Automatic Implementation
The composite traits (Fs, FsFull, FsFuse, FsPosix) are automatically implemented via blanket impls. You only implement the component traits:
#![allow(unused)]
fn main() {
// You implement these:
impl FsRead for MyBackend { ... }
impl FsWrite for MyBackend { ... }
impl FsDir for MyBackend { ... }
// This is automatic:
// impl Fs for MyBackend {} // ← Provided by blanket impl
}
Thread Safety
All traits require Send + Sync. This means:
- Methods take
&self, not&mut self - Use interior mutability (
RwLock,Mutex) for mutable state - Safe for concurrent access from multiple threads
#![allow(unused)]
fn main() {
pub struct MyBackend {
// Use RwLock for mutable state
files: RwLock<HashMap<PathBuf, Vec<u8>>>,
}
impl FsRead for MyBackend {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
// Acquire read lock
self.files.read().unwrap().get(path).cloned()
.ok_or_else(|| FsError::NotFound { path: path.to_path_buf() })
}
}
}
Implementing a Backend
This tutorial walks you through implementing a complete filesystem backend from scratch. By the end, you’ll have a working in-memory filesystem that implements all traits up to FsPosix.
What You’ll Build
An in-memory filesystem (TutorialFs) that:
- Stores files and directories in memory
- Supports symlinks
- Tracks permissions and timestamps
- Provides inode-based access for FUSE
- Implements file handles and locking
Prerequisites
- Basic Rust knowledge (structs, traits,
Result) - Understanding of filesystem concepts (files, directories, symlinks)
Tutorial Structure
Each chapter introduces one or more traits:
- Core Data Structures - Design the internal state
- FsRead - Read files and metadata
- FsWrite - Write and delete files
- FsDir - Directory operations
- The Fs Trait - Combining the basics
- FsLink - Symlink support
- FsFull - Permissions, sync, stats
- FsInode - FUSE inode operations
- FsPosix - Handles and locking
Running the Examples
Each chapter has runnable example code. Clone the repository and run:
cargo run --example tutorial_backend_complete
The End Result
After completing this tutorial, you’ll understand:
- How each trait fits into the hierarchy
- What each method should do and return
- Error handling conventions
- Thread-safety requirements
- How blanket implementations work
Let’s start with Core Data Structures →
Core Data Structures
Before implementing any traits, we need to design our internal data structures. These hold the actual filesystem state.
Key Design Decisions
1. Thread Safety
All trait methods take &self (not &mut self), so we must use interior mutability:
#![allow(unused)]
fn main() {
// ❌ Won't work - traits don't allow &mut self
impl FsWrite for MyFs {
fn write(&mut self, path: &Path, content: &[u8]) -> Result<(), FsError> { ... }
}
// ✅ Correct - use interior mutability
impl FsWrite for MyFs {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
let mut files = self.files.write().unwrap(); // RwLock
files.insert(path.to_path_buf(), content.to_vec());
Ok(())
}
}
}
We wrap mutable state in RwLock (or Mutex).
2. Path Normalization
Paths like /foo/bar, /foo//bar, and /foo/./bar should all refer to the same file. Always normalize before using as keys:
#![allow(unused)]
fn main() {
fn normalize_path(path: &Path) -> PathBuf {
let mut result = PathBuf::from("/");
for component in path.components() {
match component {
Component::RootDir => result = PathBuf::from("/"),
Component::CurDir => {} // Skip "."
Component::ParentDir => { result.pop(); } // Handle ".."
Component::Normal(name) => { result.push(name); }
Component::Prefix(_) => {} // Windows, skip
}
}
result
}
}
3. Inode Design
Even if you only need Fs, designing with inodes from the start makes it easier to add FsFuse later:
#![allow(unused)]
fn main() {
struct FsNode {
inode: u64, // Unique identifier
// ... other fields
}
}
The FsNode Structure
Each node in our filesystem (file, directory, or symlink) has:
#![allow(unused)]
fn main() {
use anyfs_backend::{FileType, Permissions};
use std::path::PathBuf;
use std::time::SystemTime;
/// Represents a single node in the filesystem.
#[derive(Clone)]
struct FsNode {
/// Type: File, Directory, or Symlink
file_type: FileType,
/// File contents (empty for directories)
content: Vec<u8>,
/// Permission bits (e.g., 0o644)
permissions: Permissions,
/// Symlink target (only for symlinks)
symlink_target: Option<PathBuf>,
/// Unique inode number
inode: u64,
/// Timestamps
created: SystemTime,
modified: SystemTime,
accessed: SystemTime,
}
}
Factory Methods
Create nodes easily:
#![allow(unused)]
fn main() {
impl FsNode {
fn new_file(content: Vec<u8>, inode: u64) -> Self {
let now = SystemTime::now();
Self {
file_type: FileType::File,
content,
permissions: Permissions::from_mode(0o644), // rw-r--r--
symlink_target: None,
inode,
created: now,
modified: now,
accessed: now,
}
}
fn new_directory(inode: u64) -> Self {
let now = SystemTime::now();
Self {
file_type: FileType::Directory,
content: Vec::new(),
permissions: Permissions::from_mode(0o755), // rwxr-xr-x
symlink_target: None,
inode,
created: now,
modified: now,
accessed: now,
}
}
fn new_symlink(target: PathBuf, inode: u64) -> Self {
let now = SystemTime::now();
Self {
file_type: FileType::Symlink,
content: Vec::new(),
permissions: Permissions::from_mode(0o777), // lrwxrwxrwx
symlink_target: Some(target),
inode,
created: now,
modified: now,
accessed: now,
}
}
}
}
The Inner State
All mutable state goes in a struct wrapped by RwLock:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::path::PathBuf;
use anyfs_backend::{Handle, OpenFlags, LockType};
struct TutorialFsInner {
/// Maps normalized paths to nodes
nodes: HashMap<PathBuf, FsNode>,
/// Maps inodes to paths (for inode-based lookups)
inode_to_path: HashMap<u64, PathBuf>,
/// Next available inode number
next_inode: u64,
/// Open file handles (for FsHandles)
handles: HashMap<Handle, HandleState>,
/// Next available handle ID
next_handle: u64,
/// Total filesystem size for stats
total_size: u64,
}
/// State for an open file handle.
struct HandleState {
path: PathBuf,
flags: OpenFlags,
locked: Option<LockType>,
}
}
The Public Backend Type
The main struct wraps everything in Arc<RwLock<_>>:
#![allow(unused)]
fn main() {
use std::sync::{Arc, RwLock};
/// Our tutorial filesystem backend.
pub struct TutorialFs {
inner: Arc<RwLock<TutorialFsInner>>,
}
impl TutorialFs {
pub fn new() -> Self {
let mut nodes = HashMap::new();
let mut inode_to_path = HashMap::new();
// Always create root directory with inode 1 (ROOT_INODE)
use anyfs_backend::ROOT_INODE;
let root = FsNode::new_directory(ROOT_INODE);
nodes.insert(PathBuf::from("/"), root);
inode_to_path.insert(ROOT_INODE, PathBuf::from("/"));
Self {
inner: Arc::new(RwLock::new(TutorialFsInner {
nodes,
inode_to_path,
next_inode: 2, // Start after ROOT_INODE
next_handle: 1,
handles: HashMap::new(),
total_size: 100 * 1024 * 1024, // 100 MB virtual size
})),
}
}
}
}
Helper Methods
Add utility methods for common operations:
#![allow(unused)]
fn main() {
impl TutorialFs {
/// Normalize a path for consistent storage and lookup.
fn normalize_path(path: &Path) -> PathBuf {
// Implementation from above
}
/// Allocate a new inode number.
fn alloc_inode(inner: &mut TutorialFsInner) -> u64 {
let inode = inner.next_inode;
inner.next_inode += 1;
inode
}
/// Allocate a new file handle.
fn alloc_handle(inner: &mut TutorialFsInner) -> Handle {
let id = inner.next_handle;
inner.next_handle += 1;
Handle(id)
}
}
}
Converting to Metadata
The traits return Metadata structs. Add a conversion method:
#![allow(unused)]
fn main() {
use anyfs_backend::Metadata;
impl FsNode {
fn to_metadata(&self, path: &Path) -> Metadata {
Metadata {
path: path.to_path_buf(),
file_type: self.file_type,
len: self.content.len() as u64,
permissions: self.permissions.clone(),
created: Some(self.created),
modified: Some(self.modified),
accessed: Some(self.accessed),
inode: Some(self.inode),
uid: Some(1000),
gid: Some(1000),
nlink: Some(1),
}
}
}
}
Summary
We now have:
| Type | Purpose |
|---|---|
FsNode | Represents a file, directory, or symlink |
TutorialFsInner | All mutable state |
TutorialFs | Public backend with Arc<RwLock<Inner>> |
Next, we’ll implement FsRead →
FsRead: Reading Files
FsRead provides read-only access to files and metadata. This is the foundation for all filesystem access.
The Trait
#![allow(unused)]
fn main() {
pub trait FsRead: Send + Sync {
/// Read the entire contents of a file.
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError>;
/// Get metadata (size, type, timestamps, etc.).
fn metadata(&self, path: &Path) -> Result<Metadata, FsError>;
/// Check if a path exists.
fn exists(&self, path: &Path) -> bool;
}
}
Implementation
read - Read File Contents
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsError, FileType};
impl FsRead for TutorialFs {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
// Look up the node
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
// Directories can't be read as files
if node.file_type == FileType::Directory {
return Err(FsError::IsADirectory { path });
}
Ok(node.content.clone())
}
// ...
}
}
Key points:
- Normalize the path first for consistent lookup
- Return
FsError::NotFoundif the path doesn’t exist - Return
FsError::IsADirectoryif trying to read a directory
metadata - Get File Information
#![allow(unused)]
fn main() {
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
Ok(node.to_metadata(&path))
}
}
The Metadata struct contains:
| Field | Type | Description |
|---|---|---|
path | PathBuf | The queried path |
file_type | FileType | File, Directory, or Symlink |
len | u64 | Size in bytes |
permissions | Permissions | Permission bits |
created | Option<SystemTime> | Creation time |
modified | Option<SystemTime> | Last modification |
accessed | Option<SystemTime> | Last access |
inode | Option<u64> | Inode number |
uid | Option<u32> | Owner user ID |
gid | Option<u32> | Owner group ID |
nlink | Option<u32> | Hard link count |
exists - Check Path Existence
#![allow(unused)]
fn main() {
fn exists(&self, path: &Path) -> bool {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
inner.nodes.contains_key(&path)
}
}
Important: exists never fails. It returns false for any error condition.
Error Handling Guidelines
| Situation | Error to return |
|---|---|
| Path doesn’t exist | FsError::NotFound { path } |
| Path is a directory when file expected | FsError::IsADirectory { path } |
| Permission denied | FsError::PermissionDenied { path } |
Always include the path in error context so callers know what failed.
Testing Your Implementation
#![allow(unused)]
fn main() {
#[test]
fn test_read_existing_file() {
let fs = TutorialFs::new();
// Setup: Create a file (we'll implement write later)
{
let mut inner = fs.inner.write().unwrap();
let inode = TutorialFs::alloc_inode(&mut inner);
let node = FsNode::new_file(b"Hello, World!".to_vec(), inode);
inner.nodes.insert(PathBuf::from("/test.txt"), node);
}
// Test read
let content = fs.read(Path::new("/test.txt")).unwrap();
assert_eq!(content, b"Hello, World!");
}
#[test]
fn test_read_nonexistent_file() {
let fs = TutorialFs::new();
let result = fs.read(Path::new("/nonexistent.txt"));
assert!(matches!(result, Err(FsError::NotFound { .. })));
}
#[test]
fn test_read_directory_fails() {
let fs = TutorialFs::new();
// Root directory exists
let result = fs.read(Path::new("/"));
assert!(matches!(result, Err(FsError::IsADirectory { .. })));
}
#[test]
fn test_exists() {
let fs = TutorialFs::new();
assert!(fs.exists(Path::new("/"))); // Root always exists
assert!(!fs.exists(Path::new("/nonexistent")));
}
}
Summary
FsRead provides:
read()- Get file contentsmetadata()- Get file/directory informationexists()- Quick existence check
Next, we’ll implement FsWrite →
FsWrite: Writing Files
FsWrite provides write operations for files.
The Trait
#![allow(unused)]
fn main() {
pub trait FsWrite: Send + Sync {
/// Write content to a file, creating it if it doesn't exist.
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError>;
/// Remove a file.
fn remove_file(&self, path: &Path) -> Result<(), FsError>;
}
}
Implementation
write - Write File Contents
#![allow(unused)]
fn main() {
use anyfs_backend::{FsWrite, FsError, FileType};
impl FsWrite for TutorialFs {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
// Check parent directory exists
if let Some(parent) = path.parent() {
let parent = Self::normalize_path(parent);
match inner.nodes.get(&parent) {
None => {
return Err(FsError::NotFound { path: parent });
}
Some(node) if node.file_type != FileType::Directory => {
return Err(FsError::NotADirectory { path: parent });
}
_ => {}
}
}
// Can't write to a directory
if let Some(existing) = inner.nodes.get(&path) {
if existing.file_type == FileType::Directory {
return Err(FsError::IsADirectory { path });
}
}
// Create or update the file
let inode = if let Some(existing) = inner.nodes.get(&path) {
existing.inode // Reuse existing inode
} else {
Self::alloc_inode(&mut inner)
};
let mut node = FsNode::new_file(content.to_vec(), inode);
node.modified = SystemTime::now();
inner.inode_to_path.insert(inode, path.clone());
inner.nodes.insert(path, node);
Ok(())
}
// ...
}
}
Key points:
- Verify parent directory exists before creating file
- Reuse inode if file already exists (overwrite)
- Update modification timestamp
remove_file - Delete a File
#![allow(unused)]
fn main() {
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
// Can't remove directories with remove_file
if node.file_type == FileType::Directory {
return Err(FsError::IsADirectory { path });
}
let inode = node.inode;
inner.nodes.remove(&path);
inner.inode_to_path.remove(&inode);
Ok(())
}
}
Error Handling
| Situation | Error |
|---|---|
| Parent directory doesn’t exist | FsError::NotFound { path: parent } |
| Parent path is a file, not directory | FsError::NotADirectory { path: parent } |
| Target path is a directory | FsError::IsADirectory { path } |
| File to remove doesn’t exist | FsError::NotFound { path } |
Testing
#![allow(unused)]
fn main() {
#[test]
fn test_write_creates_file() {
let fs = TutorialFs::new();
fs.write(Path::new("/hello.txt"), b"Hello!").unwrap();
let content = fs.read(Path::new("/hello.txt")).unwrap();
assert_eq!(content, b"Hello!");
}
#[test]
fn test_write_overwrites_existing() {
let fs = TutorialFs::new();
fs.write(Path::new("/file.txt"), b"First").unwrap();
fs.write(Path::new("/file.txt"), b"Second").unwrap();
let content = fs.read(Path::new("/file.txt")).unwrap();
assert_eq!(content, b"Second");
}
#[test]
fn test_write_to_nonexistent_parent_fails() {
let fs = TutorialFs::new();
let result = fs.write(Path::new("/no/such/dir/file.txt"), b"data");
assert!(matches!(result, Err(FsError::NotFound { .. })));
}
#[test]
fn test_remove_file() {
let fs = TutorialFs::new();
fs.write(Path::new("/temp.txt"), b"temp").unwrap();
assert!(fs.exists(Path::new("/temp.txt")));
fs.remove_file(Path::new("/temp.txt")).unwrap();
assert!(!fs.exists(Path::new("/temp.txt")));
}
}
Summary
FsWrite provides:
write()- Create or overwrite file contentsremove_file()- Delete a file
Next, we’ll implement FsDir →
FsDir: Directory Operations
FsDir provides directory operations: listing, creating, removing, and renaming.
The Trait
#![allow(unused)]
fn main() {
pub trait FsDir: Send + Sync {
/// List directory contents.
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError>;
/// Create a single directory.
fn create_dir(&self, path: &Path) -> Result<(), FsError>;
/// Create directory and all parent directories.
fn create_dir_all(&self, path: &Path) -> Result<(), FsError>;
/// Remove an empty directory.
fn remove_dir(&self, path: &Path) -> Result<(), FsError>;
/// Remove directory and all contents recursively.
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError>;
/// Rename/move a file or directory.
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError>;
}
}
The ReadDirIter Type
read_dir returns a ReadDirIter, which is a boxed iterator over Result<DirEntry, FsError>:
#![allow(unused)]
fn main() {
// DirEntry contains info about each directory entry
pub struct DirEntry {
pub path: PathBuf, // Full path
pub name: String, // Just the filename
pub file_type: FileType, // File, Directory, or Symlink
pub inode: Option<u64>, // Inode if available
}
// ReadDirIter is an iterator
pub struct ReadDirIter(Box<dyn Iterator<Item = Result<DirEntry, FsError>> + Send>);
}
Create a ReadDirIter from a vector:
#![allow(unused)]
fn main() {
let entries = vec![
Ok(DirEntry { path: PathBuf::from("/foo"), name: "foo".into(), ... }),
Ok(DirEntry { path: PathBuf::from("/bar"), name: "bar".into(), ... }),
];
ReadDirIter::from_vec(entries)
}
Implementation
read_dir - List Directory Contents
#![allow(unused)]
fn main() {
impl FsDir for TutorialFs {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
// Verify path exists and is a directory
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
if node.file_type != FileType::Directory {
return Err(FsError::NotADirectory { path });
}
// Collect direct children
let mut entries = Vec::new();
for (child_path, child_node) in &inner.nodes {
if let Some(parent) = child_path.parent() {
if Self::normalize_path(parent) == path && child_path != &path {
let name = child_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
entries.push(Ok(DirEntry {
path: child_path.clone(),
name,
file_type: child_node.file_type,
inode: Some(child_node.inode),
}));
}
}
}
// Sort for consistent ordering
entries.sort_by_key(|e| e.as_ref().map(|e| e.name.clone()).ok());
Ok(ReadDirIter::from_vec(entries))
}
// ...
}
}
create_dir - Create Single Directory
#![allow(unused)]
fn main() {
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
// Check if already exists
if inner.nodes.contains_key(&path) {
return Err(FsError::AlreadyExists { path });
}
// Check parent exists and is a directory
if let Some(parent) = path.parent() {
let parent = Self::normalize_path(parent);
match inner.nodes.get(&parent) {
None => return Err(FsError::NotFound { path: parent }),
Some(node) if node.file_type != FileType::Directory => {
return Err(FsError::NotADirectory { path: parent });
}
_ => {}
}
}
let inode = Self::alloc_inode(&mut inner);
let node = FsNode::new_directory(inode);
inner.inode_to_path.insert(inode, path.clone());
inner.nodes.insert(path, node);
Ok(())
}
}
create_dir_all - Create Directory Tree
#![allow(unused)]
fn main() {
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
let path = Self::normalize_path(path);
// Build list of directories to create (from root to leaf)
let mut to_create = Vec::new();
let mut current = path.clone();
while current != Path::new("/") {
if !self.exists(¤t) {
to_create.push(current.clone());
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => break,
}
}
// Create from root towards leaf
to_create.reverse();
for dir in to_create {
match self.create_dir(&dir) {
Ok(()) | Err(FsError::AlreadyExists { .. }) => {}
Err(e) => return Err(e),
}
}
Ok(())
}
}
Note: create_dir_all is idempotent—it succeeds even if the directory exists.
remove_dir - Remove Empty Directory
#![allow(unused)]
fn main() {
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
if node.file_type != FileType::Directory {
return Err(FsError::NotADirectory { path });
}
// Check if directory is empty
for other_path in inner.nodes.keys() {
if let Some(parent) = other_path.parent() {
if Self::normalize_path(parent) == path {
return Err(FsError::DirectoryNotEmpty { path });
}
}
}
let inode = node.inode;
inner.nodes.remove(&path);
inner.inode_to_path.remove(&inode);
Ok(())
}
}
remove_dir_all - Remove Recursively
#![allow(unused)]
fn main() {
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
// Verify it exists and is a directory
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
if node.file_type != FileType::Directory {
return Err(FsError::NotADirectory { path });
}
// Collect all paths to remove
let to_remove: Vec<PathBuf> = inner.nodes.keys()
.filter(|p| p.starts_with(&path))
.cloned()
.collect();
for p in to_remove {
if let Some(node) = inner.nodes.remove(&p) {
inner.inode_to_path.remove(&node.inode);
}
}
Ok(())
}
}
rename - Move/Rename
#![allow(unused)]
fn main() {
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
let from = Self::normalize_path(from);
let to = Self::normalize_path(to);
let mut inner = self.inner.write().unwrap();
if !inner.nodes.contains_key(&from) {
return Err(FsError::NotFound { path: from });
}
if inner.nodes.contains_key(&to) {
return Err(FsError::AlreadyExists { path: to });
}
// Check destination parent exists
if let Some(parent) = to.parent() {
let parent = Self::normalize_path(parent);
if !inner.nodes.contains_key(&parent) {
return Err(FsError::NotFound { path: parent });
}
}
if let Some(node) = inner.nodes.remove(&from) {
inner.inode_to_path.insert(node.inode, to.clone());
inner.nodes.insert(to, node);
}
Ok(())
}
}
Testing
#![allow(unused)]
fn main() {
#[test]
fn test_create_and_list_dir() {
let fs = TutorialFs::new();
fs.create_dir(Path::new("/subdir")).unwrap();
fs.write(Path::new("/subdir/file.txt"), b"content").unwrap();
let entries: Vec<_> = fs.read_dir(Path::new("/subdir"))
.unwrap()
.collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].as_ref().unwrap().name, "file.txt");
}
#[test]
fn test_create_dir_all() {
let fs = TutorialFs::new();
fs.create_dir_all(Path::new("/a/b/c/d")).unwrap();
assert!(fs.exists(Path::new("/a")));
assert!(fs.exists(Path::new("/a/b")));
assert!(fs.exists(Path::new("/a/b/c")));
assert!(fs.exists(Path::new("/a/b/c/d")));
}
#[test]
fn test_remove_nonempty_dir_fails() {
let fs = TutorialFs::new();
fs.create_dir(Path::new("/dir")).unwrap();
fs.write(Path::new("/dir/file.txt"), b"data").unwrap();
let result = fs.remove_dir(Path::new("/dir"));
assert!(matches!(result, Err(FsError::DirectoryNotEmpty { .. })));
}
}
Summary
FsDir provides:
read_dir()- List directory entriescreate_dir()/create_dir_all()- Create directoriesremove_dir()/remove_dir_all()- Remove directoriesrename()- Move or rename entries
Next: The Fs Trait →
The Fs Trait
Fs is the first composite trait. It combines FsRead, FsWrite, and FsDir into a single bound.
Automatic Implementation
Here’s the magic: you don’t implement Fs directly. It’s automatically provided:
#![allow(unused)]
fn main() {
// In anyfs-backend (simplified)
pub trait Fs: FsRead + FsWrite + FsDir + Send + Sync {}
// Blanket implementation
impl<T> Fs for T where T: FsRead + FsWrite + FsDir + Send + Sync {}
}
If your type implements FsRead + FsWrite + FsDir and is Send + Sync, it automatically implements Fs.
Verify Your Implementation
After implementing the three component traits, verify Fs works:
// Compile-time verification
fn use_fs<B: Fs>(_: &B) {}
fn main() {
let fs = TutorialFs::new();
use_fs(&fs); // ✅ Compiles! TutorialFs implements Fs
}
You can also verify Send + Sync at compile time:
#![allow(unused)]
fn main() {
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<TutorialFs>();
};
}
Using the Fs Bound
Now you can write generic functions that work with any filesystem:
#![allow(unused)]
fn main() {
use anyfs_backend::{Fs, FsError};
use std::path::Path;
/// Copy a file from src to dst.
fn copy_file<B: Fs>(fs: &B, src: &Path, dst: &Path) -> Result<(), FsError> {
let content = fs.read(src)?;
fs.write(dst, &content)
}
/// Count files in a directory (non-recursive).
fn count_files<B: Fs>(fs: &B, dir: &Path) -> Result<usize, FsError> {
let mut count = 0;
for entry in fs.read_dir(dir)? {
let entry = entry?;
if entry.file_type == FileType::File {
count += 1;
}
}
Ok(count)
}
/// Check if a path is a file.
fn is_file<B: Fs>(fs: &B, path: &Path) -> bool {
fs.metadata(path)
.map(|m| m.file_type == FileType::File)
.unwrap_or(false)
}
}
What Fs Provides
At this point, your backend supports:
| Operation | Method |
|---|---|
| Read file contents | read() |
| Get metadata | metadata() |
| Check existence | exists() |
| Write file | write() |
| Delete file | remove_file() |
| List directory | read_dir() |
| Create directory | create_dir() / create_dir_all() |
| Remove directory | remove_dir() / remove_dir_all() |
| Rename/move | rename() |
This is sufficient for many use cases!
What’s Missing?
Fs doesn’t include:
- Symlinks (
FsLink) - Permissions (
FsPermissions) - Sync/flush (
FsSync) - Disk stats (
FsStats) - Inode operations (
FsInode) - File handles (
FsHandles) - Locking (
FsLock)
The remaining tutorials add these features.
Integration Test
Here’s a complete test exercising Fs:
#[test]
fn test_fs_workflow() {
let fs = TutorialFs::new();
// Create a project structure
fs.create_dir_all(Path::new("/project/src")).unwrap();
// Write some files
fs.write(Path::new("/project/README.md"), b"# My Project").unwrap();
fs.write(Path::new("/project/src/main.rs"), b"fn main() {}").unwrap();
// Verify structure
assert!(fs.exists(Path::new("/project")));
assert!(fs.exists(Path::new("/project/src")));
assert!(fs.exists(Path::new("/project/README.md")));
// Read back
let readme = fs.read(Path::new("/project/README.md")).unwrap();
assert_eq!(readme, b"# My Project");
// List directory
let entries: Vec<_> = fs.read_dir(Path::new("/project"))
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 2); // README.md and src/
// Rename
fs.rename(
Path::new("/project/README.md"),
Path::new("/project/README.txt"),
).unwrap();
assert!(!fs.exists(Path::new("/project/README.md")));
assert!(fs.exists(Path::new("/project/README.txt")));
// Clean up
fs.remove_dir_all(Path::new("/project")).unwrap();
assert!(!fs.exists(Path::new("/project")));
}
Summary
Fs = FsRead + FsWrite + FsDir + Send + Sync- Automatically implemented via blanket impl
- Provides basic file operations
- Sufficient for simple use cases
Next: FsLink: Symlinks →
FsLink: Symlinks
FsLink adds symbolic and hard link support to your backend.
The Trait
#![allow(unused)]
fn main() {
pub trait FsLink: Send + Sync {
/// Create a symbolic link.
fn symlink(&self, target: &Path, link: &Path) -> Result<(), FsError>;
/// Read the target of a symbolic link.
fn read_link(&self, path: &Path) -> Result<PathBuf, FsError>;
/// Create a hard link.
fn hard_link(&self, target: &Path, link: &Path) -> Result<(), FsError>;
}
}
Understanding Symlinks vs Hard Links
| Aspect | Symlink | Hard Link |
|---|---|---|
| What it stores | Path to target | Same inode as target |
| Target can be… | Anything (even nonexistent) | Must exist and be a file |
| Cross-filesystem | Yes | No |
| If target deleted | Becomes broken | File still accessible |
Implementation
symlink - Create Symbolic Link
#![allow(unused)]
fn main() {
impl FsLink for TutorialFs {
fn symlink(&self, target: &Path, link: &Path) -> Result<(), FsError> {
let link = Self::normalize_path(link);
let mut inner = self.inner.write().unwrap();
// Check if link path already exists
if inner.nodes.contains_key(&link) {
return Err(FsError::AlreadyExists { path: link });
}
// Check parent directory exists
if let Some(parent) = link.parent() {
let parent = Self::normalize_path(parent);
if !inner.nodes.contains_key(&parent) {
return Err(FsError::NotFound { path: parent });
}
}
// Create the symlink node
// Note: target is stored as-is, not normalized
let inode = Self::alloc_inode(&mut inner);
let node = FsNode::new_symlink(target.to_path_buf(), inode);
inner.inode_to_path.insert(inode, link.clone());
inner.nodes.insert(link, node);
Ok(())
}
// ...
}
}
Key points:
- The
targetpath is stored as-is (can be relative or absolute) - The target doesn’t need to exist
- The
linkpath must not already exist
read_link - Get Symlink Target
#![allow(unused)]
fn main() {
fn read_link(&self, path: &Path) -> Result<PathBuf, FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
match &node.symlink_target {
Some(target) => Ok(target.clone()),
None => Err(FsError::InvalidData {
details: format!("{} is not a symbolic link", path.display()),
}),
}
}
}
hard_link - Create Hard Link
Hard links are more complex because they share inodes. For simplicity, you can return Unsupported:
#![allow(unused)]
fn main() {
fn hard_link(&self, _target: &Path, _link: &Path) -> Result<(), FsError> {
Err(FsError::Unsupported {
operation: "hard_link".to_string(),
})
}
}
Or implement properly by having multiple paths point to the same inode:
#![allow(unused)]
fn main() {
fn hard_link(&self, target: &Path, link: &Path) -> Result<(), FsError> {
let target = Self::normalize_path(target);
let link = Self::normalize_path(link);
let mut inner = self.inner.write().unwrap();
// Target must exist and be a file
let target_node = inner.nodes.get(&target)
.ok_or_else(|| FsError::NotFound { path: target.clone() })?;
if target_node.file_type != FileType::File {
return Err(FsError::InvalidData {
details: "hard links can only target files".to_string(),
});
}
// Link must not exist
if inner.nodes.contains_key(&link) {
return Err(FsError::AlreadyExists { path: link });
}
// Clone the node (same content, same inode)
let mut link_node = target_node.clone();
// Note: In a real impl, you'd track nlink count
inner.nodes.insert(link.clone(), link_node);
// Don't add to inode_to_path - inode already maps to target
Ok(())
}
}
Symlink Resolution
When reading through a symlink, you may need to resolve it:
#![allow(unused)]
fn main() {
/// Resolve a path, following symlinks.
fn resolve_path<B: Fs + FsLink>(fs: &B, path: &Path) -> Result<PathBuf, FsError> {
let mut current = path.to_path_buf();
let mut seen = std::collections::HashSet::new();
loop {
// Prevent infinite loops
if !seen.insert(current.clone()) {
return Err(FsError::InvalidData {
details: "symlink loop detected".to_string(),
});
}
match fs.metadata(¤t) {
Ok(meta) if meta.file_type == FileType::Symlink => {
let target = fs.read_link(¤t)?;
current = if target.is_absolute() {
target
} else {
current.parent().unwrap_or(Path::new("/")).join(target)
};
}
Ok(_) => return Ok(current),
Err(e) => return Err(e),
}
}
}
}
Testing
#![allow(unused)]
fn main() {
#[test]
fn test_symlink_creation() {
let fs = TutorialFs::new();
fs.write(Path::new("/original.txt"), b"content").unwrap();
fs.symlink(Path::new("/original.txt"), Path::new("/link.txt")).unwrap();
// Check metadata shows it's a symlink
let meta = fs.metadata(Path::new("/link.txt")).unwrap();
assert_eq!(meta.file_type, FileType::Symlink);
// Read the link target
let target = fs.read_link(Path::new("/link.txt")).unwrap();
assert_eq!(target, Path::new("/original.txt"));
}
#[test]
fn test_symlink_to_nonexistent() {
let fs = TutorialFs::new();
// This should succeed - symlinks can point to nonexistent targets
fs.symlink(Path::new("/nonexistent"), Path::new("/broken-link")).unwrap();
let target = fs.read_link(Path::new("/broken-link")).unwrap();
assert_eq!(target, Path::new("/nonexistent"));
}
#[test]
fn test_read_link_on_regular_file_fails() {
let fs = TutorialFs::new();
fs.write(Path::new("/file.txt"), b"data").unwrap();
let result = fs.read_link(Path::new("/file.txt"));
assert!(matches!(result, Err(FsError::InvalidData { .. })));
}
}
Summary
FsLink provides:
symlink()- Create symbolic linksread_link()- Read symlink targethard_link()- Create hard links (optional)
Next: FsFull: Complete Filesystem →
FsFull: Complete Filesystem
FsFull adds permissions, sync, and stats to reach “complete” filesystem semantics.
The Trait
#![allow(unused)]
fn main() {
// FsFull = Fs + FsLink + FsPermissions + FsSync + FsStats
pub trait FsFull: Fs + FsLink + FsPermissions + FsSync + FsStats {}
}
Like Fs, it’s automatically implemented via blanket impl.
Component Traits
FsPermissions
#![allow(unused)]
fn main() {
pub trait FsPermissions: Send + Sync {
/// Set file/directory permissions.
fn set_permissions(&self, path: &Path, permissions: Permissions) -> Result<(), FsError>;
/// Set owner UID and/or GID.
fn set_owner(&self, path: &Path, uid: Option<u32>, gid: Option<u32>) -> Result<(), FsError>;
}
}
Implementation:
#![allow(unused)]
fn main() {
impl FsPermissions for TutorialFs {
fn set_permissions(&self, path: &Path, permissions: Permissions) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
let node = inner.nodes.get_mut(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
node.permissions = permissions;
node.modified = SystemTime::now();
Ok(())
}
fn set_owner(&self, path: &Path, uid: Option<u32>, gid: Option<u32>) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
// Verify path exists
if !inner.nodes.contains_key(&path) {
return Err(FsError::NotFound { path });
}
// In-memory fs: we don't actually store uid/gid changes
// A real implementation would update the node
Ok(())
}
}
}
FsSync
#![allow(unused)]
fn main() {
pub trait FsSync: Send + Sync {
/// Flush pending writes for a specific file.
fn sync(&self, path: &Path) -> Result<(), FsError>;
/// Flush all pending writes.
fn sync_all(&self) -> Result<(), FsError>;
}
}
Implementation:
#![allow(unused)]
fn main() {
impl FsSync for TutorialFs {
fn sync(&self, path: &Path) -> Result<(), FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
// Verify file exists
if !inner.nodes.contains_key(&path) {
return Err(FsError::NotFound { path });
}
// In-memory: nothing to sync
Ok(())
}
fn sync_all(&self) -> Result<(), FsError> {
// In-memory: nothing to sync
Ok(())
}
}
}
For a real disk-backed filesystem, you’d call fsync() or equivalent.
FsStats
#![allow(unused)]
fn main() {
pub trait FsStats: Send + Sync {
/// Get filesystem statistics.
fn statfs(&self) -> Result<StatFs, FsError>;
}
pub struct StatFs {
pub total_bytes: u64,
pub available_bytes: u64,
pub used_bytes: u64,
pub total_inodes: Option<u64>,
pub available_inodes: Option<u64>,
pub used_inodes: Option<u64>,
pub block_size: Option<u64>,
}
}
Implementation:
#![allow(unused)]
fn main() {
impl FsStats for TutorialFs {
fn statfs(&self) -> Result<StatFs, FsError> {
let inner = self.inner.read().unwrap();
// Calculate used space
let used_bytes: u64 = inner.nodes.values()
.map(|n| n.content.len() as u64)
.sum();
let used_inodes = inner.nodes.len() as u64;
Ok(StatFs {
total_bytes: inner.total_size,
available_bytes: inner.total_size.saturating_sub(used_bytes),
used_bytes,
total_inodes: Some(1_000_000),
available_inodes: Some(1_000_000 - used_inodes),
used_inodes: Some(used_inodes),
block_size: Some(4096),
})
}
}
}
Verify FsFull
After implementing all component traits:
fn use_fs_full<B: FsFull>(_: &B) {}
fn main() {
let fs = TutorialFs::new();
use_fs_full(&fs); // ✅ Compiles!
}
Using FsFull
#![allow(unused)]
fn main() {
use anyfs_backend::{FsFull, FsError, Permissions};
/// Make a file read-only.
fn make_readonly<B: FsFull>(fs: &B, path: &Path) -> Result<(), FsError> {
fs.set_permissions(path, Permissions::from_mode(0o444))
}
/// Get disk usage percentage.
fn disk_usage<B: FsFull>(fs: &B) -> Result<f64, FsError> {
let stats = fs.statfs()?;
Ok((stats.used_bytes as f64 / stats.total_bytes as f64) * 100.0)
}
/// Write and flush immediately.
fn write_sync<B: FsFull>(fs: &B, path: &Path, data: &[u8]) -> Result<(), FsError> {
fs.write(path, data)?;
fs.sync(path)
}
}
Testing
#![allow(unused)]
fn main() {
#[test]
fn test_set_permissions() {
let fs = TutorialFs::new();
fs.write(Path::new("/file.txt"), b"data").unwrap();
fs.set_permissions(Path::new("/file.txt"), Permissions::from_mode(0o600)).unwrap();
let meta = fs.metadata(Path::new("/file.txt")).unwrap();
assert_eq!(meta.permissions.mode(), 0o600);
}
#[test]
fn test_statfs() {
let fs = TutorialFs::new();
// Write some data
fs.write(Path::new("/a.txt"), b"Hello").unwrap();
fs.write(Path::new("/b.txt"), b"World!").unwrap();
let stats = fs.statfs().unwrap();
assert!(stats.used_bytes >= 11); // At least "Hello" + "World!"
assert!(stats.available_bytes < stats.total_bytes);
}
}
Summary
FsFull = Fs + FsLink + FsPermissions + FsSync + FsStats
| Trait | Purpose |
|---|---|
FsPermissions | Set mode and ownership |
FsSync | Flush data to storage |
FsStats | Disk space information |
This is suitable for most real-world applications.
Next: FsInode: FUSE Support →
FsInode: FUSE Support
FsInode provides inode-based operations, essential for FUSE (Filesystem in Userspace) implementations.
Why Inodes?
FUSE operates on inodes rather than paths:
Path-based: read("/home/user/file.txt")
Inode-based: read(inode=12345, offset=0, len=100)
Benefits:
- Efficiency: Inode lookup is O(1), path resolution is O(n)
- Handles edge cases: Deleted-but-open files, renamed files
- FUSE requirement: FUSE kernel module uses inodes
The Trait
#![allow(unused)]
fn main() {
pub trait FsInode: Send + Sync {
/// Convert a path to its inode number.
fn path_to_inode(&self, path: &Path) -> Result<u64, FsError>;
/// Convert an inode to its path.
fn inode_to_path(&self, inode: u64) -> Result<PathBuf, FsError>;
/// Get metadata by inode.
fn metadata_by_inode(&self, inode: u64) -> Result<Metadata, FsError>;
/// Look up child by name within a parent directory.
fn lookup(&self, parent_inode: u64, name: &str) -> Result<u64, FsError>;
}
}
The ROOT_INODE Constant
Root directory always has inode 1:
#![allow(unused)]
fn main() {
use anyfs_backend::ROOT_INODE;
assert_eq!(ROOT_INODE, 1);
}
Implementation
path_to_inode
#![allow(unused)]
fn main() {
impl FsInode for TutorialFs {
fn path_to_inode(&self, path: &Path) -> Result<u64, FsError> {
let path = Self::normalize_path(path);
let inner = self.inner.read().unwrap();
let node = inner.nodes.get(&path)
.ok_or_else(|| FsError::NotFound { path: path.clone() })?;
Ok(node.inode)
}
// ...
}
}
inode_to_path
#![allow(unused)]
fn main() {
fn inode_to_path(&self, inode: u64) -> Result<PathBuf, FsError> {
let inner = self.inner.read().unwrap();
inner.inode_to_path.get(&inode)
.cloned()
.ok_or(FsError::InodeNotFound { inode })
}
}
Note the special error type FsError::InodeNotFound.
metadata_by_inode
#![allow(unused)]
fn main() {
fn metadata_by_inode(&self, inode: u64) -> Result<Metadata, FsError> {
let inner = self.inner.read().unwrap();
let path = inner.inode_to_path.get(&inode)
.ok_or(FsError::InodeNotFound { inode })?;
let node = inner.nodes.get(path)
.ok_or(FsError::InodeNotFound { inode })?;
Ok(node.to_metadata(path))
}
}
lookup - The Key FUSE Operation
This is how FUSE navigates directories:
#![allow(unused)]
fn main() {
fn lookup(&self, parent_inode: u64, name: &str) -> Result<u64, FsError> {
let inner = self.inner.read().unwrap();
// Get parent path
let parent_path = inner.inode_to_path.get(&parent_inode)
.ok_or(FsError::InodeNotFound { inode: parent_inode })?;
// Build child path
let child_path = parent_path.join(name);
// Look up child
let child_node = inner.nodes.get(&child_path)
.ok_or_else(|| FsError::NotFound { path: child_path })?;
Ok(child_node.inode)
}
}
How FUSE Uses This
When a user accesses /home/user/file.txt, FUSE:
- Starts at ROOT_INODE (1)
- Calls
lookup(1, "home")→ inode 2 - Calls
lookup(2, "user")→ inode 5 - Calls
lookup(5, "file.txt")→ inode 12 - Calls
metadata_by_inode(12)→ file metadata
FsFuse Trait
FsFuse combines everything:
#![allow(unused)]
fn main() {
pub trait FsFuse: FsFull + FsInode {}
}
It’s automatically implemented:
fn use_fs_fuse<B: FsFuse>(_: &B) {}
fn main() {
let fs = TutorialFs::new();
use_fs_fuse(&fs); // ✅ Works!
}
Testing
#![allow(unused)]
fn main() {
#[test]
fn test_path_to_inode() {
let fs = TutorialFs::new();
// Root is always inode 1
let root_inode = fs.path_to_inode(Path::new("/")).unwrap();
assert_eq!(root_inode, ROOT_INODE);
}
#[test]
fn test_inode_roundtrip() {
let fs = TutorialFs::new();
fs.create_dir(Path::new("/mydir")).unwrap();
let inode = fs.path_to_inode(Path::new("/mydir")).unwrap();
let path = fs.inode_to_path(inode).unwrap();
assert_eq!(path, Path::new("/mydir"));
}
#[test]
fn test_lookup() {
let fs = TutorialFs::new();
fs.create_dir(Path::new("/parent")).unwrap();
fs.write(Path::new("/parent/child.txt"), b"data").unwrap();
let parent_inode = fs.path_to_inode(Path::new("/parent")).unwrap();
let child_inode = fs.lookup(parent_inode, "child.txt").unwrap();
let child_meta = fs.metadata_by_inode(child_inode).unwrap();
assert_eq!(child_meta.file_type, FileType::File);
}
#[test]
fn test_lookup_from_root() {
let fs = TutorialFs::new();
fs.create_dir(Path::new("/documents")).unwrap();
let docs_inode = fs.lookup(ROOT_INODE, "documents").unwrap();
assert!(docs_inode > ROOT_INODE); // Should be a new inode
}
#[test]
fn test_inode_not_found() {
let fs = TutorialFs::new();
let result = fs.inode_to_path(99999);
assert!(matches!(result, Err(FsError::InodeNotFound { inode: 99999 })));
}
}
Summary
FsInode provides inode-based access:
path_to_inode()/inode_to_path()- Convert between paths and inodesmetadata_by_inode()- Get metadata by inodelookup()- Find child in directory by name
FsFuse = FsFull + FsInode - Ready for FUSE implementation.
Next: FsPosix: Full POSIX →
FsPosix: Full POSIX Semantics
FsPosix is the final layer, adding file handles, locking, and extended attributes for complete POSIX semantics.
The Trait
#![allow(unused)]
fn main() {
pub trait FsPosix: FsFuse + FsHandles + FsLock + FsXattr {}
}
Component Traits
FsHandles - File Handle Operations
Instead of reading/writing entire files, handles allow:
- Opening a file once, performing many operations
- Reading/writing at specific offsets
- Keeping files open across operations
#![allow(unused)]
fn main() {
pub trait FsHandles: Send + Sync {
/// Open a file and return a handle.
fn open(&self, path: &Path, flags: OpenFlags) -> Result<Handle, FsError>;
/// Close a file handle.
fn close(&self, handle: Handle) -> Result<(), FsError>;
/// Read from a file at a specific offset.
fn read_at(&self, handle: Handle, offset: u64, len: usize) -> Result<Vec<u8>, FsError>;
/// Write to a file at a specific offset.
fn write_at(&self, handle: Handle, offset: u64, data: &[u8]) -> Result<usize, FsError>;
}
}
OpenFlags
#![allow(unused)]
fn main() {
bitflags! {
pub struct OpenFlags: u32 {
const READ = 0b0001;
const WRITE = 0b0010;
const CREATE = 0b0100;
const TRUNCATE = 0b1000;
}
}
}
Implementation
#![allow(unused)]
fn main() {
impl FsHandles for TutorialFs {
fn open(&self, path: &Path, flags: OpenFlags) -> Result<Handle, FsError> {
let path = Self::normalize_path(path);
let mut inner = self.inner.write().unwrap();
let exists = inner.nodes.contains_key(&path);
// Handle creation
if flags.contains(OpenFlags::CREATE) && !exists {
let inode = Self::alloc_inode(&mut inner);
let node = FsNode::new_file(Vec::new(), inode);
inner.inode_to_path.insert(inode, path.clone());
inner.nodes.insert(path.clone(), node);
} else if !exists {
return Err(FsError::NotFound { path });
}
// Truncate if requested
if flags.contains(OpenFlags::TRUNCATE) {
if let Some(node) = inner.nodes.get_mut(&path) {
node.content.clear();
}
}
// Allocate handle
let handle = Self::alloc_handle(&mut inner);
inner.handles.insert(handle, HandleState {
path,
flags,
locked: None,
});
Ok(handle)
}
fn close(&self, handle: Handle) -> Result<(), FsError> {
let mut inner = self.inner.write().unwrap();
inner.handles.remove(&handle)
.ok_or(FsError::InvalidHandle { handle })?;
Ok(())
}
fn read_at(&self, handle: Handle, offset: u64, len: usize) -> Result<Vec<u8>, FsError> {
let inner = self.inner.read().unwrap();
let state = inner.handles.get(&handle)
.ok_or(FsError::InvalidHandle { handle })?;
// Check read permission
if !state.flags.contains(OpenFlags::READ) {
return Err(FsError::PermissionDenied { path: state.path.clone() });
}
let node = inner.nodes.get(&state.path)
.ok_or_else(|| FsError::NotFound { path: state.path.clone() })?;
let start = offset as usize;
if start >= node.content.len() {
return Ok(Vec::new()); // EOF
}
let end = (start + len).min(node.content.len());
Ok(node.content[start..end].to_vec())
}
fn write_at(&self, handle: Handle, offset: u64, data: &[u8]) -> Result<usize, FsError> {
let mut inner = self.inner.write().unwrap();
// Get path from handle
let path = {
let state = inner.handles.get(&handle)
.ok_or(FsError::InvalidHandle { handle })?;
if !state.flags.contains(OpenFlags::WRITE) {
return Err(FsError::PermissionDenied { path: state.path.clone() });
}
state.path.clone()
};
let node = inner.nodes.get_mut(&path)
.ok_or_else(|| FsError::NotFound { path })?;
let start = offset as usize;
// Extend file if necessary
if start + data.len() > node.content.len() {
node.content.resize(start + data.len(), 0);
}
node.content[start..start + data.len()].copy_from_slice(data);
node.modified = SystemTime::now();
Ok(data.len())
}
}
}
FsLock - File Locking
Prevents concurrent access conflicts:
#![allow(unused)]
fn main() {
pub trait FsLock: Send + Sync {
/// Acquire a lock (blocks until available).
fn lock(&self, handle: Handle, lock_type: LockType) -> Result<(), FsError>;
/// Try to acquire a lock (non-blocking).
fn try_lock(&self, handle: Handle, lock_type: LockType) -> Result<bool, FsError>;
/// Release a lock.
fn unlock(&self, handle: Handle) -> Result<(), FsError>;
}
pub enum LockType {
Shared, // Multiple readers allowed
Exclusive, // Single writer, no readers
}
}
Implementation
#![allow(unused)]
fn main() {
impl FsLock for TutorialFs {
fn lock(&self, handle: Handle, lock_type: LockType) -> Result<(), FsError> {
let mut inner = self.inner.write().unwrap();
let state = inner.handles.get_mut(&handle)
.ok_or(FsError::InvalidHandle { handle })?;
// Simple implementation: just record the lock
// Real implementation would check for conflicts
state.locked = Some(lock_type);
Ok(())
}
fn try_lock(&self, handle: Handle, lock_type: LockType) -> Result<bool, FsError> {
// For simplicity, always succeed
self.lock(handle, lock_type)?;
Ok(true)
}
fn unlock(&self, handle: Handle) -> Result<(), FsError> {
let mut inner = self.inner.write().unwrap();
let state = inner.handles.get_mut(&handle)
.ok_or(FsError::InvalidHandle { handle })?;
state.locked = None;
Ok(())
}
}
}
FsXattr - Extended Attributes
Store arbitrary metadata on files (like Linux xattr):
#![allow(unused)]
fn main() {
pub trait FsXattr: Send + Sync {
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>;
}
}
For simplicity, you can return Unsupported:
#![allow(unused)]
fn main() {
impl FsXattr for TutorialFs {
fn get_xattr(&self, _path: &Path, _name: &str) -> Result<Vec<u8>, FsError> {
Err(FsError::Unsupported { operation: "xattr".to_string() })
}
// ... same for other methods
}
}
Putting It Together
With all traits implemented, verify FsPosix:
fn use_fs_posix<B: FsPosix>(_: &B) {}
fn main() {
let fs = TutorialFs::new();
use_fs_posix(&fs); // ✅ Full POSIX support!
}
Usage Example
#![allow(unused)]
fn main() {
use anyfs_backend::{FsPosix, OpenFlags, LockType, FsError};
fn atomic_update<B: FsPosix>(
fs: &B,
path: &Path,
updater: impl FnOnce(&[u8]) -> Vec<u8>,
) -> Result<(), FsError> {
// Open with read/write
let handle = fs.open(path, OpenFlags::READ | OpenFlags::WRITE)?;
// Lock exclusively
fs.lock(handle, LockType::Exclusive)?;
// Read current content
let current = fs.read_at(handle, 0, usize::MAX)?;
// Apply update
let new_content = updater(¤t);
// Write back (truncate by writing from offset 0)
fs.write_at(handle, 0, &new_content)?;
// Unlock and close
fs.unlock(handle)?;
fs.close(handle)?;
Ok(())
}
}
Testing
#![allow(unused)]
fn main() {
#[test]
fn test_handle_read_write() {
let fs = TutorialFs::new();
// Create and open file
let handle = fs.open(
Path::new("/file.txt"),
OpenFlags::READ | OpenFlags::WRITE | OpenFlags::CREATE,
).unwrap();
// Write
fs.write_at(handle, 0, b"Hello, World!").unwrap();
// Read back
let data = fs.read_at(handle, 0, 5).unwrap();
assert_eq!(data, b"Hello");
// Read with offset
let data = fs.read_at(handle, 7, 5).unwrap();
assert_eq!(data, b"World");
fs.close(handle).unwrap();
}
#[test]
fn test_locking() {
let fs = TutorialFs::new();
let handle = fs.open(
Path::new("/locked.txt"),
OpenFlags::WRITE | OpenFlags::CREATE,
).unwrap();
fs.lock(handle, LockType::Exclusive).unwrap();
fs.write_at(handle, 0, b"Protected data").unwrap();
fs.unlock(handle).unwrap();
fs.close(handle).unwrap();
}
#[test]
fn test_invalid_handle() {
let fs = TutorialFs::new();
let invalid = Handle(99999);
assert!(matches!(
fs.read_at(invalid, 0, 10),
Err(FsError::InvalidHandle { .. })
));
}
}
Summary
You’ve implemented a complete filesystem backend!
| Layer | Traits |
|---|---|
| Fs | FsRead + FsWrite + FsDir |
| FsFull | Fs + FsLink + FsPermissions + FsSync + FsStats |
| FsFuse | FsFull + FsInode |
| FsPosix | FsFuse + FsHandles + FsLock + FsXattr |
Your TutorialFs now supports:
- ✅ File read/write
- ✅ Directory operations
- ✅ Symlinks
- ✅ Permissions
- ✅ Filesystem stats
- ✅ Inode-based access
- ✅ File handles
- ✅ File locking
🎉 Congratulations! You’ve built a full-featured filesystem backend.
Next, learn how to create middleware: Implementing Middleware →
Implementing Middleware
This tutorial teaches you how to create middleware layers that wrap filesystem backends to add cross-cutting functionality.
What You’ll Learn
- The Layer pattern (inspired by Tower)
- How to wrap any backend with additional behavior
- Creating logging, metrics, caching, and access control layers
- Composing multiple layers together
Prerequisites
- Completed the backend tutorial or understand the trait hierarchy
- Familiarity with Rust traits and generics
The Layer Pattern
Middleware wraps a backend to intercept operations:
Request
│
▼
┌─────────────────────┐
│ Logging Layer │ ← Logs all operations
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Caching Layer │ ← Serves from cache
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Actual Backend │ ← Real filesystem
└─────────────────────┘
│
▼
Response
Tutorial Structure
- The Layer Pattern - Understanding the Layer trait
- Logging Layer - Log all operations
- Metrics Layer - Collect statistics
- Caching Layer - Cache read results
- Access Control Layer - Restrict operations
- Composing Layers - Stack layers together
Quick Example
#![allow(unused)]
fn main() {
use anyfs_backend::{Fs, Layer};
// Create a backend
let fs = InMemoryFs::new();
// Wrap with logging
let fs = LoggingLayer::new("MyApp").layer(fs);
// Wrap with caching
let fs = CachingLayer::new(Duration::from_secs(60)).layer(fs);
// Use normally - logging and caching are automatic
fs.write(Path::new("/file.txt"), b"Hello").unwrap();
let data = fs.read(Path::new("/file.txt")).unwrap(); // Cached!
}
Let’s start with The Layer Pattern →
The Layer Pattern
The Layer pattern allows you to wrap a backend with additional behavior without modifying the backend itself.
The Layer Trait
#![allow(unused)]
fn main() {
pub trait Layer<B> {
/// The resulting wrapped type.
type Wrapped;
/// Wrap the backend, producing a new type.
fn layer(self, inner: B) -> Self::Wrapped;
}
}
B: The inner backend type being wrappedWrapped: The resulting wrapped type (must implement same traits)layer(): Consumes the layer config and backend, produces wrapped backend
Basic Structure
Every layer needs two types:
- Layer type: Configuration/factory (e.g.,
LoggingLayer) - Wrapped type: The actual wrapper (e.g.,
LoggingFs<B>)
#![allow(unused)]
fn main() {
/// Layer configuration (the factory)
pub struct MyLayer {
// Configuration options
}
/// The wrapped backend
pub struct MyWrapper<B> {
inner: B,
// Layer state
}
impl<B> Layer<B> for MyLayer {
type Wrapped = MyWrapper<B>;
fn layer(self, inner: B) -> Self::Wrapped {
MyWrapper {
inner,
// Initialize state from config
}
}
}
}
Pass-Through Layer Example
The simplest layer does nothing—it just forwards all calls:
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter};
use std::path::Path;
/// A layer that does nothing.
pub struct PassThroughLayer;
/// Wraps any backend, forwarding all calls.
pub struct PassThrough<B> {
inner: B,
}
impl<B> Layer<B> for PassThroughLayer {
type Wrapped = PassThrough<B>;
fn layer(self, inner: B) -> Self::Wrapped {
PassThrough { inner }
}
}
// Forward all trait methods to inner
impl<B: FsRead> FsRead for PassThrough<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.inner.read(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.inner.metadata(path)
}
fn exists(&self, path: &Path) -> bool {
self.inner.exists(path)
}
}
impl<B: FsWrite> FsWrite for PassThrough<B> {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
self.inner.write(path, content)
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_file(path)
}
}
impl<B: FsDir> FsDir for PassThrough<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)
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.inner.create_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_dir(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_dir_all(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
self.inner.rename(from, to)
}
}
}
Why This Pattern?
1. Composition
Layers can be stacked:
#![allow(unused)]
fn main() {
let fs = backend
.layer(LoggingLayer::new())
.layer(CachingLayer::new())
.layer(MetricsLayer::new());
}
2. Separation of Concerns
Each layer handles one thing:
- Logging layer: only logs
- Caching layer: only caches
- Metrics layer: only counts
3. Reusability
Write once, use with any backend:
#![allow(unused)]
fn main() {
let memory_fs = InMemoryFs::new().layer(LoggingLayer::new());
let disk_fs = DiskFs::new("/").layer(LoggingLayer::new());
let s3_fs = S3Fs::new(bucket).layer(LoggingLayer::new());
}
The LayerExt Trait
For convenient chaining, use LayerExt:
#![allow(unused)]
fn main() {
use anyfs_backend::LayerExt;
// Instead of:
let fs = LoggingLayer::new().layer(backend);
// You can write:
let fs = backend.layer(LoggingLayer::new());
}
LayerExt is automatically implemented for all types.
Trait Bounds
The wrapper only implements traits that the inner backend implements:
#![allow(unused)]
fn main() {
impl<B: FsRead> FsRead for MyWrapper<B> { ... }
// ^^^^^^
// Only if B implements FsRead
}
This means:
- Wrapping an
Fsbackend gives you anFswrapper - Wrapping an
FsFullbackend gives you anFsFullwrapper - The wrapper “inherits” the inner backend’s capabilities
Key Points
- Layer = Configuration + Factory
- Wrapped = The actual wrapper struct
- Forward traits you want to preserve
- Add behavior in the forwarding methods
- Use generics to work with any backend
Next: Logging Layer →
Logging Layer
A logging layer prints information about each filesystem operation. Useful for:
- Debugging
- Auditing
- Understanding access patterns
Design
#![allow(unused)]
fn main() {
pub struct LoggingLayer {
prefix: String, // Prefix for log messages
}
pub struct LoggingFs<B> {
inner: B,
prefix: String,
}
}
Implementation
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter};
use std::path::Path;
use std::time::Instant;
pub struct LoggingLayer {
prefix: String,
}
impl LoggingLayer {
pub fn new(prefix: impl Into<String>) -> Self {
Self { prefix: prefix.into() }
}
}
pub struct LoggingFs<B> {
inner: B,
prefix: String,
}
impl<B> Layer<B> for LoggingLayer {
type Wrapped = LoggingFs<B>;
fn layer(self, inner: B) -> Self::Wrapped {
LoggingFs {
inner,
prefix: self.prefix,
}
}
}
}
Logging FsRead
#![allow(unused)]
fn main() {
impl<B: FsRead> FsRead for LoggingFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let start = Instant::now();
println!("[{}] read: {}", self.prefix, path.display());
let result = self.inner.read(path);
let elapsed = start.elapsed();
match &result {
Ok(data) => println!(
"[{}] read: {} → {} bytes ({:?})",
self.prefix, path.display(), data.len(), elapsed
),
Err(e) => println!(
"[{}] read: {} → ERROR: {} ({:?})",
self.prefix, path.display(), e, elapsed
),
}
result
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
println!("[{}] metadata: {}", self.prefix, path.display());
self.inner.metadata(path)
}
fn exists(&self, path: &Path) -> bool {
let result = self.inner.exists(path);
println!("[{}] exists: {} → {}", self.prefix, path.display(), result);
result
}
}
}
Logging FsWrite
#![allow(unused)]
fn main() {
impl<B: FsWrite> FsWrite for LoggingFs<B> {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
println!(
"[{}] write: {} ({} bytes)",
self.prefix, path.display(), content.len()
);
let result = self.inner.write(path, content);
match &result {
Ok(()) => println!("[{}] write: {} → OK", self.prefix, path.display()),
Err(e) => println!("[{}] write: {} → ERROR: {}", self.prefix, path.display(), e),
}
result
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
println!("[{}] remove_file: {}", self.prefix, path.display());
self.inner.remove_file(path)
}
}
}
Logging FsDir
#![allow(unused)]
fn main() {
impl<B: FsDir> FsDir for LoggingFs<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
println!("[{}] read_dir: {}", self.prefix, path.display());
self.inner.read_dir(path)
}
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
println!("[{}] create_dir: {}", self.prefix, path.display());
self.inner.create_dir(path)
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
println!("[{}] create_dir_all: {}", self.prefix, path.display());
self.inner.create_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
println!("[{}] remove_dir: {}", self.prefix, path.display());
self.inner.remove_dir(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
println!("[{}] remove_dir_all: {}", self.prefix, path.display());
self.inner.remove_dir_all(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
println!(
"[{}] rename: {} → {}",
self.prefix, from.display(), to.display()
);
self.inner.rename(from, to)
}
}
}
Usage
#![allow(unused)]
fn main() {
use anyfs_backend::LayerExt;
let fs = InMemoryFs::new()
.layer(LoggingLayer::new("FS"));
fs.create_dir(Path::new("/docs")).unwrap();
fs.write(Path::new("/docs/readme.txt"), b"Hello").unwrap();
let _ = fs.read(Path::new("/docs/readme.txt")).unwrap();
}
Output:
[FS] create_dir: /docs
[FS] write: /docs/readme.txt (5 bytes)
[FS] write: /docs/readme.txt → OK
[FS] read: /docs/readme.txt
[FS] read: /docs/readme.txt → 5 bytes (45µs)
Variations
Log Level Support
#![allow(unused)]
fn main() {
pub struct LoggingLayer {
prefix: String,
level: LogLevel,
}
pub enum LogLevel {
Debug,
Info,
Warn,
}
impl<B: FsRead> FsRead for LoggingFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
if self.level <= LogLevel::Debug {
println!("[{}] read: {}", self.prefix, path.display());
}
// ...
}
}
}
Log to File/Custom Logger
#![allow(unused)]
fn main() {
use std::sync::Arc;
pub trait Logger: Send + Sync {
fn log(&self, message: &str);
}
pub struct LoggingLayer<L> {
logger: Arc<L>,
}
pub struct LoggingFs<B, L> {
inner: B,
logger: Arc<L>,
}
}
Filter by Path
#![allow(unused)]
fn main() {
pub struct LoggingLayer {
prefix: String,
filter: Option<PathBuf>, // Only log operations under this path
}
}
Key Points
- Log before and after - Shows timing and results
- Include context - Path, size, duration
- Log errors - Don’t suppress, just log and forward
- Configurable - Prefix, level, filter
Next: Metrics Layer →
Metrics Layer
A metrics layer collects statistics about filesystem usage:
- Operation counts
- Bytes read/written
- Error counts
- Latency histograms
Design
Use atomic counters for thread safety:
#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[derive(Debug, Default)]
pub struct Metrics {
pub reads: AtomicU64,
pub writes: AtomicU64,
pub bytes_read: AtomicU64,
pub bytes_written: AtomicU64,
pub errors: AtomicU64,
}
impl Metrics {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn summary(&self) -> String {
format!(
"reads={}, writes={}, bytes_read={}, bytes_written={}, errors={}",
self.reads.load(Ordering::Relaxed),
self.writes.load(Ordering::Relaxed),
self.bytes_read.load(Ordering::Relaxed),
self.bytes_written.load(Ordering::Relaxed),
self.errors.load(Ordering::Relaxed),
)
}
}
}
Implementation
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter};
use std::path::Path;
pub struct MetricsLayer {
metrics: Arc<Metrics>,
}
impl MetricsLayer {
pub fn new(metrics: Arc<Metrics>) -> Self {
Self { metrics }
}
}
pub struct MetricsFs<B> {
inner: B,
metrics: Arc<Metrics>,
}
impl<B> Layer<B> for MetricsLayer {
type Wrapped = MetricsFs<B>;
fn layer(self, inner: B) -> Self::Wrapped {
MetricsFs {
inner,
metrics: self.metrics,
}
}
}
}
Counting FsRead
#![allow(unused)]
fn main() {
impl<B: FsRead> FsRead for MetricsFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.metrics.reads.fetch_add(1, Ordering::Relaxed);
match self.inner.read(path) {
Ok(data) => {
self.metrics.bytes_read
.fetch_add(data.len() as u64, Ordering::Relaxed);
Ok(data)
}
Err(e) => {
self.metrics.errors.fetch_add(1, Ordering::Relaxed);
Err(e)
}
}
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.inner.metadata(path)
}
fn exists(&self, path: &Path) -> bool {
self.inner.exists(path)
}
}
}
Counting FsWrite
#![allow(unused)]
fn main() {
impl<B: FsWrite> FsWrite for MetricsFs<B> {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
self.metrics.writes.fetch_add(1, Ordering::Relaxed);
match self.inner.write(path, content) {
Ok(()) => {
self.metrics.bytes_written
.fetch_add(content.len() as u64, Ordering::Relaxed);
Ok(())
}
Err(e) => {
self.metrics.errors.fetch_add(1, Ordering::Relaxed);
Err(e)
}
}
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_file(path)
}
}
}
Forwarding FsDir
#![allow(unused)]
fn main() {
impl<B: FsDir> FsDir for MetricsFs<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)
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.inner.create_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_dir(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_dir_all(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
self.inner.rename(from, to)
}
}
}
Usage
#![allow(unused)]
fn main() {
use anyfs_backend::LayerExt;
// Create shared metrics
let metrics = Metrics::new();
let fs = InMemoryFs::new()
.layer(MetricsLayer::new(metrics.clone()));
// Use the filesystem
fs.write(Path::new("/a.txt"), b"Hello").unwrap();
fs.write(Path::new("/b.txt"), b"World!").unwrap();
fs.read(Path::new("/a.txt")).unwrap();
fs.read(Path::new("/b.txt")).unwrap();
fs.read(Path::new("/a.txt")).unwrap();
// Check metrics
println!("{}", metrics.summary());
// Output: reads=3, writes=2, bytes_read=16, bytes_written=11, errors=0
}
Advanced Metrics
Latency Tracking
#![allow(unused)]
fn main() {
use std::time::{Duration, Instant};
use std::sync::RwLock;
pub struct DetailedMetrics {
pub reads: AtomicU64,
pub read_latencies: RwLock<Vec<Duration>>, // For percentile calculations
}
impl<B: FsRead> FsRead for MetricsFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let start = Instant::now();
let result = self.inner.read(path);
let elapsed = start.elapsed();
self.metrics.reads.fetch_add(1, Ordering::Relaxed);
self.metrics.read_latencies.write().unwrap().push(elapsed);
result
}
}
}
Per-Path Metrics
#![allow(unused)]
fn main() {
use std::collections::HashMap;
pub struct PathMetrics {
pub by_path: RwLock<HashMap<PathBuf, u64>>,
}
impl<B: FsRead> FsRead for PathMetricsFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let result = self.inner.read(path);
if result.is_ok() {
let mut by_path = self.metrics.by_path.write().unwrap();
*by_path.entry(path.to_path_buf()).or_default() += 1;
}
result
}
}
}
Prometheus/OpenTelemetry Integration
#![allow(unused)]
fn main() {
// Pseudo-code for real metrics systems
pub struct PrometheusMetrics {
reads: prometheus::Counter,
bytes_read: prometheus::Counter,
read_duration: prometheus::Histogram,
}
impl<B: FsRead> FsRead for PrometheusFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let timer = self.metrics.read_duration.start_timer();
let result = self.inner.read(path);
timer.observe_duration();
self.metrics.reads.inc();
if let Ok(data) = &result {
self.metrics.bytes_read.inc_by(data.len() as f64);
}
result
}
}
}
Key Points
- Use atomics for thread-safe counting
- Share metrics via
Arcto read from outside - Count before/after for accurate error tracking
- Consider scope - global vs per-path vs per-operation
Next: Caching Layer →
Caching Layer
A caching layer stores read results to avoid repeated backend access. Essential for:
- Remote backends (S3, network filesystems)
- Slow storage
- Reducing API calls/costs
Design Decisions
- What to cache: File contents, metadata, or both
- TTL: How long entries remain valid
- Invalidation: When to remove stale entries
- Size limits: Maximum cache size (LRU eviction)
Basic Implementation
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{Duration, Instant};
pub struct CachingLayer {
ttl: Duration,
}
impl CachingLayer {
pub fn new(ttl: Duration) -> Self {
Self { ttl }
}
}
struct CacheEntry {
data: Vec<u8>,
expires_at: Instant,
}
pub struct CachingFs<B> {
inner: B,
cache: RwLock<HashMap<PathBuf, CacheEntry>>,
ttl: Duration,
}
impl<B> Layer<B> for CachingLayer {
type Wrapped = CachingFs<B>;
fn layer(self, inner: B) -> Self::Wrapped {
CachingFs {
inner,
cache: RwLock::new(HashMap::new()),
ttl: self.ttl,
}
}
}
}
Caching Reads
#![allow(unused)]
fn main() {
impl<B: FsRead> FsRead for CachingFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let path_buf = path.to_path_buf();
// Check cache first
{
let cache = self.cache.read().unwrap();
if let Some(entry) = cache.get(&path_buf) {
if entry.expires_at > Instant::now() {
return Ok(entry.data.clone()); // Cache hit!
}
}
}
// Cache miss - read from backend
let data = self.inner.read(path)?;
// Store in cache
{
let mut cache = self.cache.write().unwrap();
cache.insert(path_buf, CacheEntry {
data: data.clone(),
expires_at: Instant::now() + self.ttl,
});
}
Ok(data)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
// Could cache metadata too
self.inner.metadata(path)
}
fn exists(&self, path: &Path) -> bool {
self.inner.exists(path)
}
}
}
Invalidating on Write
Critical: Writes must invalidate cached entries!
#![allow(unused)]
fn main() {
impl<B: FsWrite> FsWrite for CachingFs<B> {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
// Invalidate cache entry BEFORE writing
{
let mut cache = self.cache.write().unwrap();
cache.remove(path);
}
self.inner.write(path, content)
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
{
let mut cache = self.cache.write().unwrap();
cache.remove(path);
}
self.inner.remove_file(path)
}
}
}
Handling Directory Operations
#![allow(unused)]
fn main() {
impl<B: FsDir> FsDir for CachingFs<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)
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.inner.create_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
self.inner.remove_dir(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
// Invalidate ALL entries under this path
{
let mut cache = self.cache.write().unwrap();
cache.retain(|p, _| !p.starts_with(path));
}
self.inner.remove_dir_all(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
// Invalidate both paths
{
let mut cache = self.cache.write().unwrap();
cache.remove(from);
cache.remove(to);
}
self.inner.rename(from, to)
}
}
}
Usage
#![allow(unused)]
fn main() {
use anyfs_backend::LayerExt;
use std::time::Duration;
let fs = InMemoryFs::new()
.layer(CachingLayer::new(Duration::from_secs(60)));
// First read - cache miss, reads from backend
let data1 = fs.read(Path::new("/file.txt")).unwrap();
// Second read - cache hit, returns cached data
let data2 = fs.read(Path::new("/file.txt")).unwrap();
// After write, cache is invalidated
fs.write(Path::new("/file.txt"), b"new content").unwrap();
// This read fetches fresh data
let data3 = fs.read(Path::new("/file.txt")).unwrap();
}
Advanced Features
LRU Eviction
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
struct LruCache {
entries: HashMap<PathBuf, CacheEntry>,
order: VecDeque<PathBuf>, // Oldest first
max_size: usize,
}
impl LruCache {
fn insert(&mut self, path: PathBuf, data: Vec<u8>, ttl: Duration) {
// Evict if at capacity
while self.entries.len() >= self.max_size {
if let Some(oldest) = self.order.pop_front() {
self.entries.remove(&oldest);
}
}
self.entries.insert(path.clone(), CacheEntry {
data,
expires_at: Instant::now() + ttl,
});
self.order.push_back(path);
}
}
}
Size-Limited Cache
#![allow(unused)]
fn main() {
struct SizeAwareCache {
entries: HashMap<PathBuf, CacheEntry>,
total_bytes: usize,
max_bytes: usize,
}
impl SizeAwareCache {
fn insert(&mut self, path: PathBuf, data: Vec<u8>, ttl: Duration) {
let size = data.len();
// Don't cache if too large
if size > self.max_bytes / 10 {
return;
}
// Evict until space available
while self.total_bytes + size > self.max_bytes {
// Evict oldest/smallest/least-used
}
self.total_bytes += size;
self.entries.insert(path, CacheEntry { data, expires_at: ... });
}
}
}
Negative Caching
Cache “not found” results to avoid repeated lookups:
#![allow(unused)]
fn main() {
enum CachedResult {
Found(Vec<u8>),
NotFound,
}
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
if let Some(cached) = self.get_cached(path) {
match cached {
CachedResult::Found(data) => return Ok(data),
CachedResult::NotFound => return Err(FsError::NotFound { ... }),
}
}
match self.inner.read(path) {
Ok(data) => {
self.cache(path, CachedResult::Found(data.clone()));
Ok(data)
}
Err(FsError::NotFound { .. }) => {
self.cache(path, CachedResult::NotFound);
Err(FsError::NotFound { path: path.to_path_buf() })
}
Err(e) => Err(e),
}
}
}
Cache Consistency
Strong Consistency
Invalidate on every write operation. Safe but may miss external changes.
Eventual Consistency
Use short TTLs. Allows stale reads but simpler.
Write-Through
Update cache on write instead of invalidating:
#![allow(unused)]
fn main() {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
self.inner.write(path, content)?;
// Update cache with new content
let mut cache = self.cache.write().unwrap();
cache.insert(path.to_path_buf(), CacheEntry {
data: content.to_vec(),
expires_at: Instant::now() + self.ttl,
});
Ok(())
}
}
Key Points
- Always invalidate on writes - Stale cache is worse than no cache
- Consider TTL carefully - Too long = stale data, too short = no benefit
- Handle directory operations -
remove_dir_allaffects many paths - Memory limits - Unbounded caches can cause OOM
Next: Access Control Layer →
Access Control Layer
An access control layer restricts operations based on rules:
- Read-only mode
- Path restrictions
- User-based permissions
Design
#![allow(unused)]
fn main() {
pub enum AccessRule {
/// Deny all write operations
ReadOnly,
/// Only allow operations under a specific path
RestrictToPath(PathBuf),
/// Custom rule function
Custom(Box<dyn Fn(&Path, Operation) -> bool + Send + Sync>),
}
pub enum Operation {
Read,
Write,
Delete,
List,
Create,
}
}
Read-Only Implementation
The simplest access control: block all writes.
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter};
use std::path::Path;
pub struct ReadOnlyLayer;
pub struct ReadOnlyFs<B> {
inner: B,
}
impl<B> Layer<B> for ReadOnlyLayer {
type Wrapped = ReadOnlyFs<B>;
fn layer(self, inner: B) -> Self::Wrapped {
ReadOnlyFs { inner }
}
}
// Forward all reads unchanged
impl<B: FsRead> FsRead for ReadOnlyFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.inner.read(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.inner.metadata(path)
}
fn exists(&self, path: &Path) -> bool {
self.inner.exists(path)
}
}
// Block all writes
impl<B: FsWrite> FsWrite for ReadOnlyFs<B> {
fn write(&self, path: &Path, _content: &[u8]) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
}
impl<B: FsDir> FsDir for ReadOnlyFs<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
self.inner.read_dir(path) // Reading is allowed
}
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
fn rename(&self, from: &Path, _to: &Path) -> Result<(), FsError> {
Err(FsError::PermissionDenied { path: from.to_path_buf() })
}
}
}
Path-Restricted Implementation
Only allow access under certain paths:
#![allow(unused)]
fn main() {
use std::path::PathBuf;
pub struct PathRestrictedLayer {
allowed_paths: Vec<PathBuf>,
}
impl PathRestrictedLayer {
pub fn new(paths: Vec<PathBuf>) -> Self {
Self { allowed_paths: paths }
}
pub fn single(path: impl Into<PathBuf>) -> Self {
Self { allowed_paths: vec![path.into()] }
}
}
pub struct PathRestrictedFs<B> {
inner: B,
allowed_paths: Vec<PathBuf>,
}
impl<B> PathRestrictedFs<B> {
fn check_path(&self, path: &Path) -> Result<(), FsError> {
for allowed in &self.allowed_paths {
if path.starts_with(allowed) {
return Ok(());
}
}
Err(FsError::PermissionDenied { path: path.to_path_buf() })
}
}
impl<B> Layer<B> for PathRestrictedLayer {
type Wrapped = PathRestrictedFs<B>;
fn layer(self, inner: B) -> Self::Wrapped {
PathRestrictedFs {
inner,
allowed_paths: self.allowed_paths,
}
}
}
impl<B: FsRead> FsRead for PathRestrictedFs<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.check_path(path)?;
self.inner.read(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.check_path(path)?;
self.inner.metadata(path)
}
fn exists(&self, path: &Path) -> bool {
if self.check_path(path).is_err() {
return false; // Pretend it doesn't exist
}
self.inner.exists(path)
}
}
impl<B: FsWrite> FsWrite for PathRestrictedFs<B> {
fn write(&self, path: &Path, content: &[u8]) -> Result<(), FsError> {
self.check_path(path)?;
self.inner.write(path, content)
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
self.check_path(path)?;
self.inner.remove_file(path)
}
}
// FsDir implementation similar...
}
Usage
Read-Only Mode
#![allow(unused)]
fn main() {
use anyfs_backend::LayerExt;
// Populate the filesystem first
let fs = InMemoryFs::new();
fs.create_dir(Path::new("/data")).unwrap();
fs.write(Path::new("/data/file.txt"), b"content").unwrap();
// Now make it read-only
let fs = fs.layer(ReadOnlyLayer);
// Reading works
let data = fs.read(Path::new("/data/file.txt")).unwrap();
// Writing fails
match fs.write(Path::new("/data/new.txt"), b"test") {
Err(FsError::PermissionDenied { .. }) => println!("Blocked!"),
_ => panic!("Should have been blocked"),
}
}
Path Restriction
#![allow(unused)]
fn main() {
let fs = InMemoryFs::new();
fs.create_dir_all(Path::new("/allowed/subdir")).unwrap();
fs.create_dir(Path::new("/forbidden")).unwrap();
fs.write(Path::new("/allowed/file.txt"), b"ok").unwrap();
fs.write(Path::new("/forbidden/secret.txt"), b"hidden").unwrap();
// Restrict to /allowed only
let fs = fs.layer(PathRestrictedLayer::single("/allowed"));
// This works
let data = fs.read(Path::new("/allowed/file.txt")).unwrap();
// This fails
match fs.read(Path::new("/forbidden/secret.txt")) {
Err(FsError::PermissionDenied { .. }) => println!("Access denied!"),
_ => panic!("Should have been denied"),
}
}
Advanced: Custom Rules
#![allow(unused)]
fn main() {
pub struct CustomAccessLayer<F> {
checker: F,
}
impl<F> CustomAccessLayer<F>
where
F: Fn(&Path, Operation) -> bool + Send + Sync + Clone,
{
pub fn new(checker: F) -> Self {
Self { checker }
}
}
// Example: Allow reads anywhere, writes only to /tmp
let fs = backend.layer(CustomAccessLayer::new(|path, op| {
match op {
Operation::Read | Operation::List => true,
Operation::Write | Operation::Create | Operation::Delete => {
path.starts_with("/tmp")
}
}
}));
}
Combining with Other Layers
Access control should usually be the outermost layer:
#![allow(unused)]
fn main() {
let fs = InMemoryFs::new()
.layer(CachingLayer::new(Duration::from_secs(60)))
.layer(MetricsLayer::new(metrics.clone()))
.layer(LoggingLayer::new("FS"))
.layer(ReadOnlyLayer); // Outermost - checked first
// Flow: ReadOnly -> Logging -> Metrics -> Caching -> Backend
}
This way:
- Access control rejects unauthorized requests immediately
- Logging sees the rejection
- Metrics don’t count blocked requests (if desired)
Key Points
- Return
PermissionDeniedfor blocked operations - Check early - Don’t do work before validating access
- Consider
exists()- Should forbidden paths appear to not exist? - Layer order matters - Put access control outermost
Next: Composing Layers →
Composing Layers
The power of layers comes from composition. Stack multiple layers to combine functionality.
Basic Composition
#![allow(unused)]
fn main() {
use anyfs_backend::LayerExt;
let fs = InMemoryFs::new()
.layer(CachingLayer::new(Duration::from_secs(60)))
.layer(MetricsLayer::new(metrics.clone()))
.layer(LoggingLayer::new("FS"));
}
Understanding Layer Order
When you compose layers:
#![allow(unused)]
fn main() {
backend.layer(A).layer(B).layer(C)
}
The result is: C wraps B wraps A wraps backend
Request → C → B → A → Backend → A → B → C → Response
↓ ↓ ↓ ↑ ↑ ↑
(1) (2) (3) (3) (2) (1)
Outer layers see requests first and responses last.
Order Matters!
Example: Logging + Caching
#![allow(unused)]
fn main() {
// Option 1: Logging outside Caching
let fs = backend
.layer(CachingLayer::new(ttl))
.layer(LoggingLayer::new("FS"));
// Log shows: read /file.txt (even for cache hits)
// Because logging is outside, it sees ALL requests
}
#![allow(unused)]
fn main() {
// Option 2: Caching outside Logging
let fs = backend
.layer(LoggingLayer::new("FS"))
.layer(CachingLayer::new(ttl));
// Log shows: read /file.txt (only for cache misses)
// Because cache handles request before it reaches logging
}
Example: Metrics + Caching
#![allow(unused)]
fn main() {
// Metrics outside Caching - counts ALL reads
let fs = backend
.layer(CachingLayer::new(ttl))
.layer(MetricsLayer::new(m.clone()));
// metrics.reads = 100 (total requests)
// Caching outside Metrics - counts only cache misses
let fs = backend
.layer(MetricsLayer::new(m.clone()))
.layer(CachingLayer::new(ttl));
// metrics.reads = 10 (backend hits only)
}
Recommended Layer Order
From innermost to outermost:
#![allow(unused)]
fn main() {
let fs = backend
// 1. Transformations (encryption, compression)
.layer(EncryptionLayer::new(key))
// 2. Caching (after transformation)
.layer(CachingLayer::new(Duration::from_secs(60)))
// 3. Retry/resilience
.layer(RetryLayer::new(3))
// 4. Metrics (count actual operations)
.layer(MetricsLayer::new(metrics.clone()))
// 5. Logging (see everything)
.layer(LoggingLayer::new("FS"))
// 6. Access control (reject early)
.layer(AccessControlLayer::new(rules));
}
Reasoning:
- Encryption must wrap raw backend to encrypt all data
- Caching stores encrypted data (or plaintext, depending on requirements)
- Retry retries failed operations
- Metrics count operations that reach this point
- Logging logs everything including rejections
- Access control rejects unauthorized requests immediately
Type Complexity
Each layer adds a wrapper type:
#![allow(unused)]
fn main() {
let fs: LoggingFs<MetricsFs<CachingFs<InMemoryFs>>> = ...;
}
This can get unwieldy. Solutions:
1. Type Alias
#![allow(unused)]
fn main() {
type MyFs = LoggingFs<MetricsFs<CachingFs<InMemoryFs>>>;
fn create_fs() -> MyFs {
InMemoryFs::new()
.layer(CachingLayer::new(ttl))
.layer(MetricsLayer::new(metrics))
.layer(LoggingLayer::new("FS"))
}
}
2. Box with dyn Fs
#![allow(unused)]
fn main() {
fn create_fs() -> Box<dyn Fs> {
let fs = InMemoryFs::new()
.layer(CachingLayer::new(ttl))
.layer(MetricsLayer::new(metrics))
.layer(LoggingLayer::new("FS"));
Box::new(fs)
}
}
3. impl Trait
#![allow(unused)]
fn main() {
fn create_fs() -> impl Fs {
InMemoryFs::new()
.layer(CachingLayer::new(ttl))
.layer(MetricsLayer::new(metrics))
.layer(LoggingLayer::new("FS"))
}
}
Runtime Composition
For dynamic layer selection:
#![allow(unused)]
fn main() {
fn create_fs(config: &Config) -> Box<dyn Fs> {
let mut fs: Box<dyn Fs> = Box::new(InMemoryFs::new());
if config.enable_caching {
fs = Box::new(CachingLayer::new(config.cache_ttl).layer(fs));
}
if config.enable_logging {
fs = Box::new(LoggingLayer::new(&config.log_prefix).layer(fs));
}
if config.read_only {
fs = Box::new(ReadOnlyLayer.layer(fs));
}
fs
}
}
Note: This requires layers to work with Box<dyn Fs>, which means implementing traits for the boxed type.
Complete Example
use anyfs_backend::{Fs, LayerExt};
use std::path::Path;
use std::time::Duration;
fn main() {
// Create shared metrics
let metrics = Metrics::new();
// Build the layered filesystem
let fs = InMemoryFs::new()
.layer(CachingLayer::new(Duration::from_secs(60)))
.layer(MetricsLayer::new(metrics.clone()))
.layer(LoggingLayer::new("APP"));
// Setup
fs.create_dir(Path::new("/data")).unwrap();
fs.write(Path::new("/data/config.json"), b"{}").unwrap();
// Multiple reads - watch cache behavior
for i in 0..5 {
let _ = fs.read(Path::new("/data/config.json"));
println!("After read {}: {}", i + 1, metrics.summary());
}
// Output shows:
// - All 5 reads logged (logging is outermost)
// - Only 1 read in metrics (cache handles the rest)
}
Testing Layered Systems
#![allow(unused)]
fn main() {
#[test]
fn test_layer_composition() {
let metrics = Metrics::new();
let fs = InMemoryFs::new()
.layer(CachingLayer::new(Duration::from_secs(60)))
.layer(MetricsLayer::new(metrics.clone()));
fs.write(Path::new("/test.txt"), b"data").unwrap();
// First read - cache miss, hits metrics
fs.read(Path::new("/test.txt")).unwrap();
assert_eq!(metrics.reads.load(Ordering::Relaxed), 1);
// Second read - cache hit, doesn't hit metrics
fs.read(Path::new("/test.txt")).unwrap();
assert_eq!(metrics.reads.load(Ordering::Relaxed), 1); // Still 1!
// Write invalidates cache
fs.write(Path::new("/test.txt"), b"new").unwrap();
// Next read - cache miss again
fs.read(Path::new("/test.txt")).unwrap();
assert_eq!(metrics.reads.load(Ordering::Relaxed), 2);
}
}
Summary
- Compose with
.layer()- Clean, fluent API - Order matters - Outer layers see requests first
- Think about what each layer should see - Metrics before or after cache?
- Use type aliases or
impl Trait- Manage type complexity - Test the composition - Verify layers interact correctly
🎉 Congratulations! You now know how to:
- Create middleware layers
- Log, measure, cache, and control access
- Compose layers for powerful, reusable functionality
Go build something awesome!
Error Types
AnyFS defines a unified error type for all filesystem operations.
FsError
The core error type used by all traits:
#![allow(unused)]
fn main() {
use anyfs_backend::FsError;
use std::path::Path;
fn handle_error(e: FsError) {
match e {
FsError::NotFound { path, operation } => {
println!("{} not found during {}", path.display(), operation);
}
FsError::AlreadyExists { path, operation } => {
println!("{} already exists during {}", path.display(), operation);
}
FsError::PermissionDenied { path, operation } => {
println!("Permission denied: {} during {}", path.display(), operation);
}
FsError::Io { source, path, operation } => {
println!("IO error on {}: {} during {}",
path.map(|p| p.display().to_string()).unwrap_or_default(),
source, operation);
}
// ... handle other variants
}
}
}
Error Variants
| Variant | When Used | Required Fields |
|---|---|---|
NotFound | File or directory doesn’t exist | path, operation |
AlreadyExists | Creating something that exists | path, operation |
PermissionDenied | Access not allowed | path, operation |
IsDirectory | Expected file, got directory | path, operation |
NotDirectory | Expected directory, got file | path, operation |
DirectoryNotEmpty | Removing non-empty directory | path, operation |
InvalidPath | Malformed path | path, operation, reason |
TooManySymlinks | Symlink loop detected | path, operation |
ReadOnly | Write on read-only filesystem | path, operation |
CrossDevice | Cross-filesystem operation | source, destination, operation |
Io | General I/O error | source, path (optional), operation |
Other | Unclassified errors | message, operation |
Creating Errors
Use the constructor methods for clean error creation:
#![allow(unused)]
fn main() {
use anyfs_backend::FsError;
use std::path::Path;
// NotFound
let err = FsError::not_found(Path::new("/missing.txt"), "read");
// AlreadyExists
let err = FsError::already_exists(Path::new("/exists"), "create_dir");
// PermissionDenied
let err = FsError::permission_denied(Path::new("/secret"), "read");
// IsDirectory
let err = FsError::is_directory(Path::new("/folder"), "read");
// NotDirectory
let err = FsError::not_directory(Path::new("/file.txt"), "read_dir");
// DirectoryNotEmpty
let err = FsError::directory_not_empty(Path::new("/folder"), "remove_dir");
// InvalidPath
let err = FsError::invalid_path(
Path::new("/bad\0path"),
"contains null byte",
"open"
);
// ReadOnly
let err = FsError::read_only(Path::new("/file.txt"), "write");
// IO error
let err = FsError::io(
std::io::Error::new(std::io::ErrorKind::Other, "disk full"),
Some(Path::new("/file.txt")),
"write"
);
}
Error Conversion
From std::io::Error
#![allow(unused)]
fn main() {
use anyfs_backend::FsError;
use std::io;
fn from_io_error(e: io::Error, path: &Path, op: &str) -> FsError {
FsError::io(e, Some(path), op)
}
// Or use From trait (without path context)
let io_err = io::Error::new(io::ErrorKind::NotFound, "not found");
let fs_err: FsError = io_err.into();
}
To std::io::Error
#![allow(unused)]
fn main() {
use anyfs_backend::FsError;
use std::io;
let fs_err = FsError::not_found(Path::new("/missing"), "read");
let io_err: io::Error = fs_err.into();
assert_eq!(io_err.kind(), io::ErrorKind::NotFound);
}
Error Display
All errors implement Display with helpful messages:
#![allow(unused)]
fn main() {
let err = FsError::not_found(Path::new("/file.txt"), "read");
println!("{}", err);
// Output: not found: /file.txt (during read)
let err = FsError::permission_denied(Path::new("/secret"), "open");
println!("{}", err);
// Output: permission denied: /secret (during open)
}
Best Practices
1. Always Include Operation Context
#![allow(unused)]
fn main() {
// ✓ Good - includes operation
FsError::not_found(path, "read")
// ✗ Bad - no context
FsError::NotFound { path: path.into(), operation: String::new() }
}
2. Include Path When Available
#![allow(unused)]
fn main() {
// ✓ Good - includes path
FsError::io(e, Some(path), "write")
// ✗ Avoid - loses context
FsError::io(e, None, "write")
}
3. Use Specific Error Types
#![allow(unused)]
fn main() {
// ✓ Good - specific error
if path.exists() {
return Err(FsError::already_exists(path, "create"));
}
// ✗ Avoid - generic error
return Err(FsError::other("file exists", "create"));
}
4. Pattern Match for Handling
#![allow(unused)]
fn main() {
match fs.read(path) {
Ok(data) => process(data),
Err(FsError::NotFound { .. }) => create_default(),
Err(FsError::PermissionDenied { .. }) => request_access(),
Err(e) => return Err(e),
}
}
Core Types
This reference documents the core types used throughout AnyFS.
Metadata
File and directory metadata:
#![allow(unused)]
fn main() {
use anyfs_backend::Metadata;
let meta = fs.metadata(path)?;
// Check type
if meta.is_file() {
println!("Size: {} bytes", meta.len());
} else if meta.is_dir() {
println!("Directory");
} else if meta.is_symlink() {
println!("Symlink");
}
// Timestamps (optional - may be None for some backends)
if let Some(created) = meta.created() {
println!("Created: {:?}", created);
}
if let Some(modified) = meta.modified() {
println!("Modified: {:?}", modified);
}
if let Some(accessed) = meta.accessed() {
println!("Accessed: {:?}", accessed);
}
// Permissions (if available)
if let Some(perms) = meta.permissions() {
println!("Readonly: {}", perms.readonly());
}
}
Metadata Fields
| Field | Type | Description |
|---|---|---|
file_type | FileType | File, directory, or symlink |
len | u64 | Size in bytes (0 for directories) |
created | Option<SystemTime> | Creation time |
modified | Option<SystemTime> | Last modification |
accessed | Option<SystemTime> | Last access |
permissions | Option<Permissions> | Permission info |
Creating Metadata
For backend implementations:
#![allow(unused)]
fn main() {
use anyfs_backend::{Metadata, FileType, Permissions};
use std::time::SystemTime;
// File metadata
let meta = Metadata::file(1024)
.with_created(SystemTime::now())
.with_modified(SystemTime::now())
.with_permissions(Permissions::readonly(false));
// Directory metadata
let meta = Metadata::dir()
.with_modified(SystemTime::now());
// Symlink metadata
let meta = Metadata::symlink()
.with_modified(SystemTime::now());
}
FileType
Enumeration of filesystem entry types:
#![allow(unused)]
fn main() {
use anyfs_backend::FileType;
let ft = metadata.file_type();
match ft {
FileType::File => println!("Regular file"),
FileType::Dir => println!("Directory"),
FileType::Symlink => println!("Symbolic link"),
}
// Convenience methods
assert!(FileType::File.is_file());
assert!(FileType::Dir.is_dir());
assert!(FileType::Symlink.is_symlink());
}
DirEntry
Entry returned when reading directories:
#![allow(unused)]
fn main() {
use anyfs_backend::DirEntry;
for entry in fs.read_dir(path)? {
let entry = entry?;
// Name of the entry (not full path)
println!("Name: {}", entry.name());
// Full path
println!("Path: {}", entry.path().display());
// Type (if available without extra syscall)
if let Some(ft) = entry.file_type() {
println!("Type: {:?}", ft);
}
// Full metadata (may require extra syscall)
let meta = entry.metadata()?;
println!("Size: {}", meta.len());
}
}
DirEntry Fields
| Method | Return Type | Description |
|---|---|---|
name() | &str | Entry name (not path) |
path() | &Path | Full path |
file_type() | Option<FileType> | Type if known cheaply |
metadata() | Result<Metadata, FsError> | Full metadata |
Permissions
File permission information:
#![allow(unused)]
fn main() {
use anyfs_backend::Permissions;
// Create permissions
let perms = Permissions::readonly(false); // read-write
let perms = Permissions::readonly(true); // read-only
// Check permissions
if perms.readonly() {
println!("File is read-only");
}
// POSIX mode (if supported)
#[cfg(unix)]
{
let perms = Permissions::from_mode(0o755);
println!("Mode: {:o}", perms.mode());
}
}
Extended Permissions (Unix)
For backends that support POSIX permissions:
#![allow(unused)]
fn main() {
use anyfs_backend::Permissions;
// From mode bits
let perms = Permissions::from_mode(0o644);
// Check mode
let mode = perms.mode(); // 0o644
// Permission bits
let owner_read = (mode & 0o400) != 0;
let owner_write = (mode & 0o200) != 0;
let owner_exec = (mode & 0o100) != 0;
}
OpenOptions
Options for opening files:
#![allow(unused)]
fn main() {
use anyfs_backend::OpenOptions;
// Read only (default)
let opts = OpenOptions::new().read(true);
// Write, create if missing
let opts = OpenOptions::new()
.write(true)
.create(true);
// Append mode
let opts = OpenOptions::new()
.append(true)
.create(true);
// Create new (fail if exists)
let opts = OpenOptions::new()
.write(true)
.create_new(true);
// Truncate existing
let opts = OpenOptions::new()
.write(true)
.truncate(true);
}
OpenOptions Fields
| Method | Default | Description |
|---|---|---|
read(bool) | true | Open for reading |
write(bool) | false | Open for writing |
append(bool) | false | Append to end |
create(bool) | false | Create if missing |
create_new(bool) | false | Create, fail if exists |
truncate(bool) | false | Truncate to zero length |
SeekFrom
Position for seeking within files:
#![allow(unused)]
fn main() {
use std::io::SeekFrom;
// From start of file
let pos = SeekFrom::Start(100);
// From end of file (negative offset)
let pos = SeekFrom::End(-50);
// From current position
let pos = SeekFrom::Current(25);
}
Used with file handles:
#![allow(unused)]
fn main() {
use std::io::{Read, Seek, SeekFrom};
let mut handle = fs.open_read(path)?;
// Jump to offset 100
handle.seek(SeekFrom::Start(100))?;
// Read from there
let mut buf = [0u8; 50];
handle.read(&mut buf)?;
}
FileTimes
For setting file timestamps:
#![allow(unused)]
fn main() {
use anyfs_backend::FileTimes;
use std::time::SystemTime;
let times = FileTimes::new()
.set_accessed(SystemTime::now())
.set_modified(SystemTime::now());
fs.set_times(path, times)?;
}
FsStats
Filesystem statistics (capacity, usage):
#![allow(unused)]
fn main() {
use anyfs_backend::FsStats;
let stats: FsStats = fs.stats()?;
println!("Total: {} bytes", stats.total_bytes);
println!("Free: {} bytes", stats.free_bytes);
println!("Available: {} bytes", stats.available_bytes);
println!("Used: {}%",
(stats.total_bytes - stats.available_bytes) * 100 / stats.total_bytes
);
}
FsStats Fields
| Field | Type | Description |
|---|---|---|
total_bytes | u64 | Total capacity |
free_bytes | u64 | Free space |
available_bytes | u64 | Available to non-root |
total_inodes | Option<u64> | Total inodes (Unix) |
free_inodes | Option<u64> | Free inodes (Unix) |
InodeId
Unique identifier for files (used by FsInode trait):
#![allow(unused)]
fn main() {
use anyfs_backend::InodeId;
let inode = fs.inode(path)?;
println!("Inode: {}", inode);
// Compare inodes to check if same file
let inode1 = fs.inode(path1)?;
let inode2 = fs.inode(path2)?;
if inode1 == inode2 {
println!("Same file (hard links)");
}
}
Summary
| Type | Purpose |
|---|---|
Metadata | File/directory attributes |
FileType | File, Dir, or Symlink |
DirEntry | Directory listing entry |
Permissions | Access permissions |
OpenOptions | File open configuration |
FileTimes | Timestamp modification |
FsStats | Filesystem capacity |
InodeId | Unique file identifier |
FsError | Error handling (see Errors) |