//! VFS symlink mirror: maintains a real directory tree mirroring the VFS //! with friendly-named symlinks pointing into the content-addressed store. //! //! DAWs and file managers can browse the mirror directory directly. //! Unix only (macOS + Linux). Windows skipped (symlinks need Developer Mode). use std::collections::HashSet; use std::path::{Path, PathBuf}; use tracing::{debug, instrument, warn}; use crate::db::Database; use crate::error::{io_err, Result}; use crate::store::sample_extension; use crate::vfs::{list_full_tree, NodeType}; /// Configuration for the mirror directory. pub struct MirrorConfig { /// Root directory for the mirror tree (e.g. `~/audiofiles-mirror/`). pub mirror_root: PathBuf, /// Root directory of the content-addressed sample store. pub store_root: PathBuf, } /// Statistics from a mirror sync operation. #[derive(Debug, Default)] pub struct MirrorStats { pub dirs_created: usize, pub links_created: usize, pub entries_removed: usize, } /// Synchronise the mirror directory with the current VFS state. /// /// Creates directories and symlinks for all VFS nodes, removes stale entries /// that no longer exist in the VFS. Idempotent — safe to call repeatedly. #[instrument(skip_all)] pub fn sync_mirror(db: &Database, config: &MirrorConfig) -> Result { let mut stats = MirrorStats::default(); let tree = list_full_tree(db)?; std::fs::create_dir_all(&config.mirror_root) .map_err(|e| io_err(&config.mirror_root, e))?; // Collect existing mirror entries so we can remove stale ones later. let mut existing = collect_existing_entries(&config.mirror_root); // Build sample hash → extension map (batch query to avoid N+1). let hashes: Vec<&str> = tree .iter() .filter_map(|n| n.sample_hash.as_deref()) .collect(); let extensions = batch_extensions(db, &hashes); for node in &tree { let sanitized = sanitize_path(&node.path); let full_path = config.mirror_root.join(&sanitized); match node.node_type { NodeType::Directory => { // Remove from stale set. existing.remove(&full_path); if !full_path.exists() { std::fs::create_dir_all(&full_path) .map_err(|e| io_err(&full_path, e))?; stats.dirs_created += 1; } } NodeType::Sample => { if let Some(ref hash) = node.sample_hash { let ext = extensions .iter() .find(|(h, _)| h.as_str() == hash.as_str()) .map(|(_, e)| e.as_str()) .unwrap_or(""); let store_file = if ext.is_empty() { config.store_root.join(hash.as_str()) } else { config.store_root.join(format!("{}.{}", hash, ext)) }; // Remove from stale set. existing.remove(&full_path); if !full_path.exists() { // Ensure parent directory exists. if let Some(parent) = full_path.parent() && !parent.exists() { std::fs::create_dir_all(parent) .map_err(|e| io_err(parent, e))?; } create_symlink(&store_file, &full_path)?; stats.links_created += 1; } } } } } // Remove stale entries (files/dirs no longer in VFS). // Sort descending so children are removed before parents. let mut stale: Vec = existing.into_iter().collect(); stale.sort_by(|a, b| b.cmp(a)); for path in stale { // Don't remove the mirror root itself. if path == config.mirror_root { continue; } if path.is_dir() { if std::fs::remove_dir(&path).is_ok() { stats.entries_removed += 1; } } else if std::fs::remove_file(&path).is_ok() { stats.entries_removed += 1; } } debug!( dirs = stats.dirs_created, links = stats.links_created, removed = stats.entries_removed, "Mirror sync complete" ); Ok(stats) } /// Remove the entire mirror directory tree. #[instrument(skip_all)] pub fn remove_mirror(mirror_root: &Path) -> Result<()> { if mirror_root.exists() { std::fs::remove_dir_all(mirror_root).map_err(|e| io_err(mirror_root, e))?; } Ok(()) } /// Recursively collect all file and directory paths under `root`. fn collect_existing_entries(root: &Path) -> HashSet { let mut entries = HashSet::new(); if let Ok(walker) = walkdir(root) { for path in walker { if path != root { entries.insert(path); } } } entries } /// Simple recursive directory walker returning all paths. fn walkdir(root: &Path) -> std::io::Result> { let mut result = Vec::new(); walkdir_inner(root, &mut result)?; Ok(result) } fn walkdir_inner(dir: &Path, out: &mut Vec) -> std::io::Result<()> { // Use symlink_metadata to avoid following symlinks (prevents infinite loops // and escaping the mirror root via directory symlinks). let meta = match std::fs::symlink_metadata(dir) { Ok(m) => m, Err(_) => return Ok(()), }; if !meta.is_dir() { return Ok(()); } for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); out.push(path.clone()); let entry_meta = match std::fs::symlink_metadata(&path) { Ok(m) => m, Err(_) => continue, }; if entry_meta.is_dir() { walkdir_inner(&path, out)?; } } Ok(()) } /// Sanitize a VFS path for use as a filesystem path. /// Replaces null bytes and strips leading/trailing dots from each component. fn sanitize_path(path: &str) -> String { path.split('/') .map(sanitize_component) .collect::>() .join("/") } /// Sanitize a single path component. /// Replaces null bytes, rejects `.` and `..` to prevent traversal. /// Preserves leading dots on other names (e.g. `.hidden` stays `.hidden`). fn sanitize_component(name: &str) -> String { let s = name.replace('\0', "_"); if s == "." || s == ".." || s.is_empty() { return "_".to_string(); } s } /// Batch-query file extensions for a set of sample hashes. fn batch_extensions(db: &Database, hashes: &[&str]) -> Vec<(String, String)> { let mut result = Vec::with_capacity(hashes.len()); // Deduplicate to avoid redundant queries. let mut seen = HashSet::new(); for &hash in hashes { if seen.insert(hash) && let Ok(ext) = sample_extension(db, hash) { result.push((hash.to_string(), ext)); } } result } /// Create a symlink. Unix only. fn create_symlink(original: &Path, link: &Path) -> Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(original, link).map_err(|e| io_err(link, e)) } #[cfg(not(unix))] { let _ = (original, link); Err(crate::error::CoreError::Internal( "VFS mirror symlinks are only supported on Unix".to_string(), )) } } #[cfg(test)] mod tests { use super::*; use crate::test_helpers::insert_fake_sample; use crate::vfs::{create_directory, create_sample_link, create_vfs, delete_node}; use tempfile::TempDir; fn setup() -> (Database, TempDir, TempDir) { let db = Database::open_in_memory().unwrap(); let mirror_dir = TempDir::new().unwrap(); let store_dir = TempDir::new().unwrap(); (db, mirror_dir, store_dir) } fn make_config(mirror_dir: &TempDir, store_dir: &TempDir) -> MirrorConfig { MirrorConfig { mirror_root: mirror_dir.path().to_path_buf(), store_root: store_dir.path().to_path_buf(), } } /// Create a fake store file so symlinks have a valid target. fn create_store_file(store_dir: &TempDir, hash: &str, ext: &str) { let filename = if ext.is_empty() { hash.to_string() } else { format!("{hash}.{ext}") }; std::fs::write(store_dir.path().join(filename), b"fake audio").unwrap(); } #[test] fn empty_vfs_creates_empty_mirror() { let (db, mirror_dir, store_dir) = setup(); create_vfs(&db, "Library").unwrap(); let config = make_config(&mirror_dir, &store_dir); let stats = sync_mirror(&db, &config).unwrap(); assert_eq!(stats.dirs_created, 0); assert_eq!(stats.links_created, 0); assert_eq!(stats.entries_removed, 0); } #[test] fn directory_tree_mirrored() { let (db, mirror_dir, store_dir) = setup(); let vfs_id = create_vfs(&db, "Library").unwrap(); let drums = create_directory(&db, vfs_id, None, "Drums").unwrap(); create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap(); let config = make_config(&mirror_dir, &store_dir); let stats = sync_mirror(&db, &config).unwrap(); assert_eq!(stats.dirs_created, 2); // Drums, Kicks (Library/ created implicitly by create_dir_all) assert!(mirror_dir.path().join("Library/Drums/Kicks").is_dir()); } #[cfg(unix)] #[test] fn sample_symlinks_point_to_store() { let (db, mirror_dir, store_dir) = setup(); insert_fake_sample(&db, "abc123"); create_store_file(&store_dir, "abc123", "wav"); let vfs_id = create_vfs(&db, "Library").unwrap(); create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap(); let config = make_config(&mirror_dir, &store_dir); let stats = sync_mirror(&db, &config).unwrap(); assert_eq!(stats.links_created, 1); let link = mirror_dir.path().join("Library/kick.wav"); assert!(link.exists() || link.symlink_metadata().is_ok()); let target = std::fs::read_link(&link).unwrap(); assert!(target.to_string_lossy().contains("abc123.wav")); } #[cfg(unix)] #[test] fn stale_entries_removed() { let (db, mirror_dir, store_dir) = setup(); insert_fake_sample(&db, "abc123"); create_store_file(&store_dir, "abc123", "wav"); let vfs_id = create_vfs(&db, "Library").unwrap(); let node_id = create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap(); let config = make_config(&mirror_dir, &store_dir); sync_mirror(&db, &config).unwrap(); assert!(mirror_dir.path().join("Library/kick.wav").exists() || mirror_dir .path() .join("Library/kick.wav") .symlink_metadata() .is_ok()); // Delete the VFS node and re-sync. delete_node(&db, node_id).unwrap(); let stats = sync_mirror(&db, &config).unwrap(); assert!(stats.entries_removed > 0); // The symlink should be gone. assert!(mirror_dir.path().join("Library/kick.wav").symlink_metadata().is_err()); } #[cfg(unix)] #[test] fn resync_is_idempotent() { let (db, mirror_dir, store_dir) = setup(); insert_fake_sample(&db, "abc123"); create_store_file(&store_dir, "abc123", "wav"); let vfs_id = create_vfs(&db, "Library").unwrap(); create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap(); let config = make_config(&mirror_dir, &store_dir); sync_mirror(&db, &config).unwrap(); // Second sync should create nothing new. let stats = sync_mirror(&db, &config).unwrap(); assert_eq!(stats.dirs_created, 0); assert_eq!(stats.links_created, 0); assert_eq!(stats.entries_removed, 0); } #[test] fn sanitize_path_handles_special_chars() { assert_eq!(sanitize_path("Library/Drums"), "Library/Drums"); assert_eq!(sanitize_path("a\0b/c"), "a_b/c"); // Only . and .. are replaced to prevent traversal; other dot-prefixed names preserved assert_eq!(sanitize_component(".."), "_"); assert_eq!(sanitize_component("."), "_"); assert_eq!(sanitize_component(".hidden"), ".hidden"); assert_eq!(sanitize_component(""), "_"); assert_eq!(sanitize_component("normal"), "normal"); } }