e08988f7f3
development-plan.md (master plan): - 11-week implementation broken into 4 phases - 11 Rust crates with dependency graph - Per-week deliverables, tests, exit criteria - Deferred requirements (FR-21, FR-22) with rationale plans/week-01-foundation.md: - Workspace setup, core types, FUSE skeleton, local origin - Origin trait with watch() method (arch 4.3.4) - EventBus with FileAccessed event (FR-18.1) - All EROFS handlers for read-only enforcement (FR-4.1-4.5) plans/week-02-metadata.md: - symphonia metadata extraction (FR-6.1-6.5) - SQLite schema matching architecture 4.3.6 exactly - Column names: track/disc (not track_number/disc_number) - Hash columns as TEXT (hex-encoded, not BLOB) - Added idx_files_real index (FR-7.3) plans/week-03-virtual-tree.md: - Path resolver with $var syntax (arch 4.3.1) - Template vars: $artist, $album, $title, $track, $year, $disc, $genre, $format, $format_upper - RefreshPolicy struct for FR-9.3 (TTL-based refresh) - force_refresh() method for FR-9.4 (signal/API refresh) All plans Oracle-validated against architecture.md and requirements.md
30 KiB
30 KiB
Week 3: Virtual Tree & Basic Ops
Phase: 1 (MVP)
Prerequisites: Week 2 (Metadata Extraction)
Estimated effort: 5 days
Objective
Implement virtual path resolver, tree cache, and connect to FUSE operations (readdir, stat, read).
Deliverables
| Task | Crate | Files | Done |
|---|---|---|---|
| Virtual path resolver | musicfs-core | resolver.rs |
[ ] |
| Tree cache | musicfs-cache | tree.rs |
[ ] |
| readdir implementation | musicfs-fuse | ops/readdir.rs |
[ ] |
| stat implementation | musicfs-fuse | ops/stat.rs |
[ ] |
| open/read implementation | musicfs-fuse | ops/read.rs |
[ ] |
Task 1: Virtual Path Resolver
1.1 Add to musicfs-core/src/lib.rs
pub mod resolver;
pub use resolver::{PathResolver, PathTemplate};
1.2 Create musicfs-core/src/resolver.rs
use crate::{AudioMeta, VirtualPath};
use std::path::PathBuf;
/// Path template configuration (per architecture 4.3.1)
/// Uses $variable syntax: $artist, $album, $title, $track, $year, $genre,
/// $format, $format_upper, $disc
#[derive(Debug, Clone)]
pub struct PathTemplate {
/// Template pattern using $var syntax
/// Default: "$artist/$album ($year) [$format_upper]/$track - $title.$format"
pub pattern: String,
/// Fallback for missing artist
pub fallback_artist: String,
/// Fallback for missing album
pub fallback_album: String,
/// Fallback for missing title
pub fallback_title: String,
/// Fallback for missing year
pub fallback_year: String,
}
impl Default for PathTemplate {
fn default() -> Self {
Self {
// Architecture 4.3.1 default template
pattern: "$artist/$album ($year) [$format_upper]/$track - $title.$format".to_string(),
fallback_artist: "Unknown Artist".to_string(),
fallback_album: "Unknown Album".to_string(),
fallback_title: "Unknown Track".to_string(),
fallback_year: "Unknown".to_string(),
}
}
}
/// Virtual path resolver (FR-5.1, FR-5.2)
pub struct PathResolver {
template: PathTemplate,
}
impl PathResolver {
pub fn new(template: PathTemplate) -> Self {
Self { template }
}
/// Resolve real path + metadata to virtual path
/// Uses $var syntax per architecture 4.3.1
pub fn resolve(&self, meta: &AudioMeta, extension: &str) -> VirtualPath {
let artist = meta.artist.as_deref()
.unwrap_or(&self.template.fallback_artist);
let album = meta.album.as_deref()
.unwrap_or(&self.template.fallback_album);
let title = meta.title.as_deref()
.unwrap_or(&self.template.fallback_title);
let year = meta.year
.map(|y| y.to_string())
.unwrap_or_else(|| self.template.fallback_year.clone());
let track = meta.track.unwrap_or(0);
let disc = meta.disc.unwrap_or(1);
let genre = meta.genre.as_deref().unwrap_or("Unknown");
let format = extension.to_lowercase();
let format_upper = extension.to_uppercase();
// Sanitize path components
let artist = sanitize_path_component(artist);
let album = sanitize_path_component(album);
let title = sanitize_path_component(title);
let genre = sanitize_path_component(genre);
// Replace $var patterns (architecture 4.3.1 grammar)
let path = self.template.pattern
.replace("$artist", &artist)
.replace("$album", &album)
.replace("$title", &title)
.replace("$track", &format!("{:02}", track))
.replace("$disc", &disc.to_string())
.replace("$year", &year)
.replace("$genre", &genre)
.replace("$format_upper", &format_upper)
.replace("$format", &format);
VirtualPath::new(path)
}
/// Parse template pattern to extract directory structure
pub fn get_hierarchy(&self) -> Vec<&str> {
// Returns ["artist", "album"] for default template
self.template.pattern
.split('/')
.filter(|s| s.starts_with('{') && s.ends_with('}'))
.map(|s| s.trim_matches(|c| c == '{' || c == '}'))
.filter(|s| *s == "artist" || *s == "album")
.collect()
}
}
impl Default for PathResolver {
fn default() -> Self {
Self::new(PathTemplate::default())
}
}
/// Sanitize string for use in file path
fn sanitize_path_component(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
'\0' => '_',
c => c,
})
.collect::<String>()
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AudioFormat;
#[test]
fn test_resolve_complete_metadata() {
let resolver = PathResolver::default();
let meta = AudioMeta {
artist: Some("Metallica".to_string()),
album: Some("Master of Puppets".to_string()),
title: Some("Battery".to_string()),
track: Some(1),
year: Some(1986),
format: AudioFormat::Flac,
..Default::default()
};
let path = resolver.resolve(&meta, "flac");
// Default template: $artist/$album ($year) [$format_upper]/$track - $title.$format
assert_eq!(path.as_str(), "Metallica/Master of Puppets (1986) [FLAC]/01 - Battery.flac");
}
#[test]
fn test_resolve_missing_album() {
let resolver = PathResolver::default();
let meta = AudioMeta {
artist: Some("Artist".to_string()),
title: Some("Track".to_string()),
track: Some(5),
..Default::default()
};
let path = resolver.resolve(&meta, "mp3");
// Missing year uses fallback
assert_eq!(path.as_str(), "Artist/Unknown Album (Unknown) [MP3]/05 - Track.mp3");
}
#[test]
fn test_sanitize_special_chars() {
let resolver = PathResolver::default();
let meta = AudioMeta {
artist: Some("AC/DC".to_string()),
album: Some("Who Made Who?".to_string()),
title: Some("Test:Track".to_string()),
track: Some(1),
year: Some(1986),
..Default::default()
};
let path = resolver.resolve(&meta, "flac");
// Should not contain problematic characters
assert!(!path.as_str().contains(':'));
assert!(!path.as_str().contains('?'));
// AC/DC becomes AC_DC
assert!(path.as_str().contains("AC_DC"));
}
#[test]
fn test_custom_template() {
let template = PathTemplate {
pattern: "$genre/$artist - $album/$track $title.$format".to_string(),
..Default::default()
};
let resolver = PathResolver::new(template);
let meta = AudioMeta {
artist: Some("Artist".to_string()),
album: Some("Album".to_string()),
title: Some("Song".to_string()),
genre: Some("Rock".to_string()),
track: Some(3),
..Default::default()
};
let path = resolver.resolve(&meta, "flac");
assert_eq!(path.as_str(), "Rock/Artist - Album/03 Song.flac");
}
}
Task 2: Tree Cache
2.1 Add to musicfs-cache/src/lib.rs
mod tree;
pub use tree::{VirtualTree, VirtualNode, TreeBuilder};
2.2 Create musicfs-cache/src/tree.rs
use musicfs_core::{FileId, FileMeta, VirtualPath};
use std::collections::{BTreeMap, HashMap};
use std::ffi::{OsStr, OsString};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::RwLock;
use std::time::{Duration, SystemTime};
/// Inode number type
pub type Inode = u64;
/// Root inode (FUSE convention)
pub const ROOT_INODE: Inode = 1;
/// Node in the virtual tree
#[derive(Debug)]
pub enum VirtualNode {
Directory(DirNode),
File(FileNode),
}
impl VirtualNode {
pub fn inode(&self) -> Inode {
match self {
VirtualNode::Directory(d) => d.inode,
VirtualNode::File(f) => f.inode,
}
}
pub fn name(&self) -> &OsStr {
match self {
VirtualNode::Directory(d) => &d.name,
VirtualNode::File(f) => &f.name,
}
}
pub fn is_dir(&self) -> bool {
matches!(self, VirtualNode::Directory(_))
}
}
/// Directory node
#[derive(Debug)]
pub struct DirNode {
pub inode: Inode,
pub name: OsString,
pub children: BTreeMap<OsString, Inode>,
pub mtime: SystemTime,
}
/// File node
#[derive(Debug)]
pub struct FileNode {
pub inode: Inode,
pub name: OsString,
pub file_id: FileId,
pub size: u64,
pub mtime: SystemTime,
}
/// Refresh policy configuration (FR-9.3)
#[derive(Debug, Clone)]
pub struct RefreshPolicy {
/// Time-to-live for cached entries
pub ttl: Duration,
/// Whether to refresh on access
pub refresh_on_access: bool,
/// Background refresh interval (None = disabled)
pub background_interval: Option<Duration>,
}
impl Default for RefreshPolicy {
fn default() -> Self {
Self {
ttl: Duration::from_secs(300), // 5 minutes
refresh_on_access: false,
background_interval: None,
}
}
}
/// Virtual filesystem tree (FR-9.1-9.4)
pub struct VirtualTree {
nodes: HashMap<Inode, VirtualNode>,
path_to_inode: HashMap<VirtualPath, Inode>,
next_inode: AtomicU64,
/// Last refresh timestamp
last_refresh: RwLock<SystemTime>,
/// Refresh policy (FR-9.3)
refresh_policy: RefreshPolicy,
}
impl VirtualTree {
pub fn new() -> Self {
Self::with_policy(RefreshPolicy::default())
}
pub fn with_policy(policy: RefreshPolicy) -> Self {
let mut tree = Self {
nodes: HashMap::new(),
path_to_inode: HashMap::new(),
next_inode: AtomicU64::new(ROOT_INODE + 1),
last_refresh: RwLock::new(SystemTime::now()),
refresh_policy: policy,
};
// Create root directory
tree.nodes.insert(ROOT_INODE, VirtualNode::Directory(DirNode {
inode: ROOT_INODE,
name: OsString::from(""),
children: BTreeMap::new(),
mtime: SystemTime::now(),
}));
tree.path_to_inode.insert(VirtualPath::new("/"), ROOT_INODE);
tree
}
/// Allocate a new inode number
fn alloc_inode(&self) -> Inode {
self.next_inode.fetch_add(1, Ordering::SeqCst)
}
/// Get node by inode
pub fn get(&self, inode: Inode) -> Option<&VirtualNode> {
self.nodes.get(&inode)
}
/// Get node by path
pub fn get_by_path(&self, path: &VirtualPath) -> Option<&VirtualNode> {
self.path_to_inode.get(path).and_then(|ino| self.nodes.get(ino))
}
/// Lookup child in directory
pub fn lookup(&self, parent_inode: Inode, name: &OsStr) -> Option<Inode> {
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&parent_inode) {
dir.children.get(name).copied()
} else {
None
}
}
/// List directory children
pub fn readdir(&self, inode: Inode) -> Option<Vec<(OsString, Inode, bool)>> {
if let Some(VirtualNode::Directory(dir)) = self.nodes.get(&inode) {
Some(dir.children.iter().map(|(name, &ino)| {
let is_dir = self.nodes.get(&ino).map(|n| n.is_dir()).unwrap_or(false);
(name.clone(), ino, is_dir)
}).collect())
} else {
None
}
}
/// Insert a file into the tree
pub fn insert_file(&mut self, meta: &FileMeta) -> Inode {
let path = &meta.virtual_path;
// Ensure parent directories exist
let parent_inode = self.ensure_parents(path);
// Create file node
let inode = self.alloc_inode();
let name = std::path::Path::new(path.as_str())
.file_name()
.unwrap_or_default()
.to_os_string();
let file_node = FileNode {
inode,
name: name.clone(),
file_id: meta.id,
size: meta.size,
mtime: meta.mtime,
};
self.nodes.insert(inode, VirtualNode::File(file_node));
self.path_to_inode.insert(path.clone(), inode);
// Add to parent
if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) {
dir.children.insert(name, inode);
}
inode
}
/// Ensure all parent directories exist for a path
fn ensure_parents(&mut self, path: &VirtualPath) -> Inode {
let path_str = path.as_str();
let components: Vec<&str> = path_str
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
if components.len() <= 1 {
return ROOT_INODE;
}
let mut current_inode = ROOT_INODE;
let mut current_path = String::from("/");
// Process all but the last component (which is the file)
for component in &components[..components.len() - 1] {
current_path.push_str(component);
let vpath = VirtualPath::new(¤t_path);
if let Some(&existing) = self.path_to_inode.get(&vpath) {
current_inode = existing;
} else {
// Create directory
let new_inode = self.alloc_inode();
let name = OsString::from(*component);
let dir_node = DirNode {
inode: new_inode,
name: name.clone(),
children: BTreeMap::new(),
mtime: SystemTime::now(),
};
self.nodes.insert(new_inode, VirtualNode::Directory(dir_node));
self.path_to_inode.insert(vpath, new_inode);
// Add to parent
if let Some(VirtualNode::Directory(parent)) = self.nodes.get_mut(¤t_inode) {
parent.children.insert(name, new_inode);
}
current_inode = new_inode;
}
current_path.push('/');
}
current_inode
}
/// Remove a file from the tree
pub fn remove_file(&mut self, path: &VirtualPath) -> Option<FileId> {
let inode = self.path_to_inode.remove(path)?;
if let Some(VirtualNode::File(file)) = self.nodes.remove(&inode) {
// Remove from parent
let parent_path = std::path::Path::new(path.as_str())
.parent()
.map(|p| VirtualPath::new(p.to_string_lossy()))
.unwrap_or_else(|| VirtualPath::new("/"));
if let Some(&parent_inode) = self.path_to_inode.get(&parent_path) {
if let Some(VirtualNode::Directory(dir)) = self.nodes.get_mut(&parent_inode) {
dir.children.remove(&file.name);
}
}
Some(file.file_id)
} else {
None
}
}
/// Get total file count
pub fn file_count(&self) -> usize {
self.nodes.values().filter(|n| matches!(n, VirtualNode::File(_))).count()
}
/// Get total directory count
pub fn dir_count(&self) -> usize {
self.nodes.values().filter(|n| matches!(n, VirtualNode::Directory(_))).count()
}
// =========================================================================
// Refresh Policy (FR-9.3, FR-9.4)
// =========================================================================
/// Check if tree needs refresh based on policy (FR-9.3)
pub fn needs_refresh(&self) -> bool {
let last = *self.last_refresh.read().unwrap();
last.elapsed().unwrap_or(Duration::MAX) > self.refresh_policy.ttl
}
/// Force refresh - clears tree for rebuild (FR-9.4)
/// Call this from signal handler or API endpoint
pub fn force_refresh(&mut self) {
// Keep root, clear everything else
self.nodes.retain(|&ino, _| ino == ROOT_INODE);
self.path_to_inode.retain(|p, _| p.as_str() == "/");
// Reset root children
if let Some(VirtualNode::Directory(root)) = self.nodes.get_mut(&ROOT_INODE) {
root.children.clear();
}
*self.last_refresh.write().unwrap() = SystemTime::now();
}
/// Mark tree as refreshed
pub fn mark_refreshed(&self) {
*self.last_refresh.write().unwrap() = SystemTime::now();
}
/// Get current refresh policy
pub fn refresh_policy(&self) -> &RefreshPolicy {
&self.refresh_policy
}
}
impl Default for VirtualTree {
fn default() -> Self {
Self::new()
}
}
/// Builder for constructing tree from database
pub struct TreeBuilder {
tree: VirtualTree,
}
impl TreeBuilder {
pub fn new() -> Self {
Self {
tree: VirtualTree::new(),
}
}
pub fn add_file(&mut self, meta: &FileMeta) {
self.tree.insert_file(meta);
}
pub fn build(self) -> VirtualTree {
self.tree
}
}
impl Default for TreeBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use musicfs_core::{AudioMeta, OriginId, RealPath};
use std::path::PathBuf;
fn make_file_meta(id: i64, vpath: &str) -> FileMeta {
FileMeta {
id: FileId(id),
virtual_path: VirtualPath::new(vpath),
real_path: RealPath {
origin_id: OriginId::from("test"),
path: PathBuf::from("/test"),
},
size: 1000,
mtime: SystemTime::now(),
content_hash: None,
audio: None,
}
}
#[test]
fn test_tree_creation() {
let tree = VirtualTree::new();
assert!(tree.get(ROOT_INODE).is_some());
}
#[test]
fn test_insert_file() {
let mut tree = VirtualTree::new();
let meta = make_file_meta(1, "/Artist/Album/Track.flac");
tree.insert_file(&meta);
assert!(tree.get_by_path(&VirtualPath::new("/Artist")).is_some());
assert!(tree.get_by_path(&VirtualPath::new("/Artist/Album")).is_some());
assert!(tree.get_by_path(&VirtualPath::new("/Artist/Album/Track.flac")).is_some());
}
#[test]
fn test_readdir() {
let mut tree = VirtualTree::new();
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track1.flac"));
tree.insert_file(&make_file_meta(2, "/Artist/Album/Track2.flac"));
let root_children = tree.readdir(ROOT_INODE).unwrap();
assert_eq!(root_children.len(), 1);
assert_eq!(root_children[0].0, "Artist");
}
#[test]
fn test_lookup() {
let mut tree = VirtualTree::new();
tree.insert_file(&make_file_meta(1, "/Artist/Album/Track.flac"));
let artist_inode = tree.lookup(ROOT_INODE, OsStr::new("Artist")).unwrap();
assert!(tree.lookup(artist_inode, OsStr::new("Album")).is_some());
}
}
Task 3: FUSE Operations
3.1 Refactor musicfs-fuse/src/lib.rs
mod filesystem;
mod ops;
pub use filesystem::MusicFs;
3.2 Create musicfs-fuse/src/ops/mod.rs
pub mod readdir;
pub mod stat;
pub mod read;
3.3 Update musicfs-fuse/src/filesystem.rs
use fuser::{
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory,
ReplyEntry, ReplyOpen, Request, FUSE_ROOT_ID,
};
use musicfs_cache::{Database, MetadataCache, VirtualTree, VirtualNode, ROOT_INODE};
use musicfs_core::{Error, Result, VirtualPath};
use musicfs_origins::Origin;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::{debug, error, info, warn};
const TTL: Duration = Duration::from_secs(1);
const BLOCK_SIZE: u32 = 512;
/// Main FUSE filesystem implementation
pub struct MusicFs {
tree: Arc<RwLock<VirtualTree>>,
cache: Arc<MetadataCache>,
origins: Arc<HashMap<String, Box<dyn Origin>>>,
uid: u32,
gid: u32,
}
impl MusicFs {
pub fn new(
tree: Arc<RwLock<VirtualTree>>,
cache: Arc<MetadataCache>,
origins: Arc<HashMap<String, Box<dyn Origin>>>,
) -> Self {
Self {
tree,
cache,
origins,
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
}
}
fn node_to_attr(&self, node: &VirtualNode) -> FileAttr {
match node {
VirtualNode::Directory(dir) => FileAttr {
ino: dir.inode,
size: 0,
blocks: 0,
atime: dir.mtime,
mtime: dir.mtime,
ctime: dir.mtime,
crtime: dir.mtime,
kind: FileType::Directory,
perm: 0o755,
nlink: 2,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: BLOCK_SIZE,
flags: 0,
},
VirtualNode::File(file) => FileAttr {
ino: file.inode,
size: file.size,
blocks: (file.size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64,
atime: file.mtime,
mtime: file.mtime,
ctime: file.mtime,
crtime: file.mtime,
kind: FileType::RegularFile,
perm: 0o644,
nlink: 1,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: BLOCK_SIZE,
flags: 0,
},
}
}
}
impl Filesystem for MusicFs {
fn init(
&mut self,
_req: &Request<'_>,
_config: &mut fuser::KernelConfig,
) -> std::result::Result<(), libc::c_int> {
info!("MusicFS initialized");
Ok(())
}
fn destroy(&mut self) {
info!("MusicFS destroyed");
}
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
debug!("lookup(parent={}, name={:?})", parent, name);
let tree = self.tree.read().unwrap();
if let Some(inode) = tree.lookup(parent, name) {
if let Some(node) = tree.get(inode) {
let attr = self.node_to_attr(node);
reply.entry(&TTL, &attr, 0);
return;
}
}
reply.error(libc::ENOENT);
}
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
debug!("getattr(ino={})", ino);
let tree = self.tree.read().unwrap();
if let Some(node) = tree.get(ino) {
let attr = self.node_to_attr(node);
reply.attr(&TTL, &attr);
} else {
reply.error(libc::ENOENT);
}
}
fn readdir(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
mut reply: ReplyDirectory,
) {
debug!("readdir(ino={}, offset={})", ino, offset);
let tree = self.tree.read().unwrap();
if let Some(children) = tree.readdir(ino) {
let mut entries = vec![
(ino, FileType::Directory, "."),
(if ino == ROOT_INODE { ROOT_INODE } else { ROOT_INODE }, FileType::Directory, ".."),
];
for (name, child_ino, is_dir) in children {
let kind = if is_dir { FileType::Directory } else { FileType::RegularFile };
entries.push((child_ino, kind, name.to_str().unwrap_or("?")));
}
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
if reply.add(*inode, (i + 1) as i64, *kind, name) {
break;
}
}
reply.ok();
} else {
reply.error(libc::ENOENT);
}
}
fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) {
debug!("open(ino={}, flags={})", ino, flags);
// Check for write flags (FR-4.1)
let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC;
if flags & write_flags != 0 {
reply.error(libc::EROFS);
return;
}
let tree = self.tree.read().unwrap();
if tree.get(ino).is_some() {
reply.opened(0, 0);
} else {
reply.error(libc::ENOENT);
}
}
fn read(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
size: u32,
_flags: i32,
_lock_owner: Option<u64>,
reply: ReplyData,
) {
debug!("read(ino={}, offset={}, size={})", ino, offset, size);
// Get file info from tree
let file_id = {
let tree = self.tree.read().unwrap();
if let Some(VirtualNode::File(file)) = tree.get(ino) {
file.file_id
} else {
reply.error(libc::ENOENT);
return;
}
};
// TODO: Read from cache/origin
// For now, return empty data
reply.data(&[]);
}
fn release(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
_flags: i32,
_lock_owner: Option<u64>,
_flush: bool,
reply: fuser::ReplyEmpty,
) {
debug!("release(ino={})", ino);
reply.ok();
}
// Write operations - always EROFS (FR-4.1-4.4)
fn write(&mut self, _req: &Request, _ino: u64, _fh: u64, _offset: i64, _data: &[u8], _write_flags: u32, _flags: i32, _lock_owner: Option<u64>, reply: fuser::ReplyWrite) {
reply.error(libc::EROFS);
}
fn mkdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, _mode: u32, _umask: u32, reply: ReplyEntry) {
reply.error(libc::EROFS);
}
fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS);
}
fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS);
}
fn rename(&mut self, _req: &Request, _parent: u64, _name: &OsStr, _newparent: u64, _newname: &OsStr, _flags: u32, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS);
}
fn create(&mut self, _req: &Request, _parent: u64, _name: &OsStr, _mode: u32, _umask: u32, _flags: i32, reply: fuser::ReplyCreate) {
reply.error(libc::EROFS);
}
}
Task 4: Benchmarks
4.1 Create benches/tree_ops.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use musicfs_cache::{VirtualTree, TreeBuilder};
use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath};
use std::path::PathBuf;
use std::time::SystemTime;
fn make_file_meta(id: i64, artist: &str, album: &str, track: u32) -> FileMeta {
let vpath = format!("/{}/{}/{:02} - Track.flac", artist, album, track);
FileMeta {
id: FileId(id),
virtual_path: VirtualPath::new(&vpath),
real_path: RealPath {
origin_id: OriginId::from("test"),
path: PathBuf::from("/test"),
},
size: 30_000_000,
mtime: SystemTime::now(),
content_hash: None,
audio: None,
}
}
fn build_tree(n_artists: usize, albums_per_artist: usize, tracks_per_album: usize) -> VirtualTree {
let mut builder = TreeBuilder::new();
let mut id = 1i64;
for a in 0..n_artists {
for b in 0..albums_per_artist {
for t in 0..tracks_per_album {
let meta = make_file_meta(
id,
&format!("Artist {}", a),
&format!("Album {}", b),
t as u32 + 1,
);
builder.add_file(&meta);
id += 1;
}
}
}
builder.build()
}
fn bench_stat_cached(c: &mut Criterion) {
// 100 artists * 10 albums * 12 tracks = 12,000 files
let tree = build_tree(100, 10, 12);
let path = VirtualPath::new("/Artist 50/Album 5/06 - Track.flac");
c.bench_function("stat_cached", |b| {
b.iter(|| {
black_box(tree.get_by_path(&path))
})
});
// Target: <1ms p99 (NFR-1.1)
}
fn bench_readdir_1000_entries(c: &mut Criterion) {
// Create tree with 1000 artists (1000 entries in root)
let tree = build_tree(1000, 1, 1);
c.bench_function("readdir_1000", |b| {
b.iter(|| {
black_box(tree.readdir(1)) // ROOT_INODE
})
});
// Target: <10ms p99 (NFR-1.2)
}
fn bench_lookup(c: &mut Criterion) {
let tree = build_tree(100, 10, 12);
c.bench_function("lookup", |b| {
b.iter(|| {
let artist = tree.lookup(1, std::ffi::OsStr::new("Artist 50"));
if let Some(a) = artist {
black_box(tree.lookup(a, std::ffi::OsStr::new("Album 5")));
}
})
});
}
fn bench_mount_time(c: &mut Criterion) {
c.bench_function("tree_build_12k", |b| {
b.iter(|| {
black_box(build_tree(100, 10, 12))
})
});
// Target: <100ms, Max: <500ms (NFR-1.7)
}
criterion_group!(benches, bench_stat_cached, bench_readdir_1000_entries, bench_lookup, bench_mount_time);
criterion_main!(benches);
Tests
Integration Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fuse_readdir() {
// Build tree and verify readdir returns correct entries
}
#[test]
fn test_fuse_stat() {
// Verify stat returns correct size/mtime
}
#[test]
fn test_read_only_enforcement() {
// Verify write ops return EROFS
}
}
Exit Criteria
- Virtual tree built from metadata
ls /mnt/musicshows Artist directoriesls /mnt/music/Artist/Albumshows tracksstatreturns correct size, mtime- Write operations return EROFS (FR-4.1)
- stat benchmark <1ms p99 (NFR-1.1)
- readdir benchmark <10ms p99 (NFR-1.2)
- Mount completes in <500ms (NFR-1.7)
Next Week
Week 4 will implement CAS storage and chunk caching, enabling actual file reads.