Skip to main content

max / audiofiles

12.4 KB · 376 lines History Blame Raw
1 //! VFS symlink mirror: maintains a real directory tree mirroring the VFS
2 //! with friendly-named symlinks pointing into the content-addressed store.
3 //!
4 //! DAWs and file managers can browse the mirror directory directly.
5 //! Unix only (macOS + Linux). Windows skipped (symlinks need Developer Mode).
6
7 use std::collections::HashSet;
8 use std::path::{Path, PathBuf};
9
10 use tracing::{debug, instrument, warn};
11
12 use crate::db::Database;
13 use crate::error::{io_err, Result};
14 use crate::store::sample_extension;
15 use crate::vfs::{list_full_tree, NodeType};
16
17 /// Configuration for the mirror directory.
18 pub struct MirrorConfig {
19 /// Root directory for the mirror tree (e.g. `~/audiofiles-mirror/`).
20 pub mirror_root: PathBuf,
21 /// Root directory of the content-addressed sample store.
22 pub store_root: PathBuf,
23 }
24
25 /// Statistics from a mirror sync operation.
26 #[derive(Debug, Default)]
27 pub struct MirrorStats {
28 pub dirs_created: usize,
29 pub links_created: usize,
30 pub entries_removed: usize,
31 }
32
33 /// Synchronise the mirror directory with the current VFS state.
34 ///
35 /// Creates directories and symlinks for all VFS nodes, removes stale entries
36 /// that no longer exist in the VFS. Idempotent — safe to call repeatedly.
37 #[instrument(skip_all)]
38 pub fn sync_mirror(db: &Database, config: &MirrorConfig) -> Result<MirrorStats> {
39 let mut stats = MirrorStats::default();
40 let tree = list_full_tree(db)?;
41
42 std::fs::create_dir_all(&config.mirror_root)
43 .map_err(|e| io_err(&config.mirror_root, e))?;
44
45 // Collect existing mirror entries so we can remove stale ones later.
46 let mut existing = collect_existing_entries(&config.mirror_root);
47
48 // Build sample hash → extension map (batch query to avoid N+1).
49 let hashes: Vec<&str> = tree
50 .iter()
51 .filter_map(|n| n.sample_hash.as_deref())
52 .collect();
53 let extensions = batch_extensions(db, &hashes);
54
55 for node in &tree {
56 let sanitized = sanitize_path(&node.path);
57 let full_path = config.mirror_root.join(&sanitized);
58
59 match node.node_type {
60 NodeType::Directory => {
61 // Remove from stale set.
62 existing.remove(&full_path);
63
64 if !full_path.exists() {
65 std::fs::create_dir_all(&full_path)
66 .map_err(|e| io_err(&full_path, e))?;
67 stats.dirs_created += 1;
68 }
69 }
70 NodeType::Sample => {
71 if let Some(ref hash) = node.sample_hash {
72 let ext = extensions
73 .iter()
74 .find(|(h, _)| h.as_str() == hash.as_str())
75 .map(|(_, e)| e.as_str())
76 .unwrap_or("");
77
78 let store_file = if ext.is_empty() {
79 config.store_root.join(hash.as_str())
80 } else {
81 config.store_root.join(format!("{}.{}", hash, ext))
82 };
83
84 // Remove from stale set.
85 existing.remove(&full_path);
86
87 if !full_path.exists() {
88 // Ensure parent directory exists.
89 if let Some(parent) = full_path.parent() {
90 if !parent.exists() {
91 std::fs::create_dir_all(parent)
92 .map_err(|e| io_err(parent, e))?;
93 }
94 }
95 create_symlink(&store_file, &full_path)?;
96 stats.links_created += 1;
97 }
98 }
99 }
100 }
101 }
102
103 // Remove stale entries (files/dirs no longer in VFS).
104 // Sort descending so children are removed before parents.
105 let mut stale: Vec<PathBuf> = existing.into_iter().collect();
106 stale.sort_by(|a, b| b.cmp(a));
107
108 for path in stale {
109 // Don't remove the mirror root itself.
110 if path == config.mirror_root {
111 continue;
112 }
113 if path.is_dir() {
114 if std::fs::remove_dir(&path).is_ok() {
115 stats.entries_removed += 1;
116 }
117 } else if std::fs::remove_file(&path).is_ok() {
118 stats.entries_removed += 1;
119 }
120 }
121
122 debug!(
123 dirs = stats.dirs_created,
124 links = stats.links_created,
125 removed = stats.entries_removed,
126 "Mirror sync complete"
127 );
128
129 Ok(stats)
130 }
131
132 /// Remove the entire mirror directory tree.
133 #[instrument(skip_all)]
134 pub fn remove_mirror(mirror_root: &Path) -> Result<()> {
135 if mirror_root.exists() {
136 std::fs::remove_dir_all(mirror_root).map_err(|e| io_err(mirror_root, e))?;
137 }
138 Ok(())
139 }
140
141 /// Recursively collect all file and directory paths under `root`.
142 fn collect_existing_entries(root: &Path) -> HashSet<PathBuf> {
143 let mut entries = HashSet::new();
144 if let Ok(walker) = walkdir(root) {
145 for path in walker {
146 if path != root {
147 entries.insert(path);
148 }
149 }
150 }
151 entries
152 }
153
154 /// Simple recursive directory walker returning all paths.
155 fn walkdir(root: &Path) -> std::io::Result<Vec<PathBuf>> {
156 let mut result = Vec::new();
157 walkdir_inner(root, &mut result)?;
158 Ok(result)
159 }
160
161 fn walkdir_inner(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
162 // Use symlink_metadata to avoid following symlinks (prevents infinite loops
163 // and escaping the mirror root via directory symlinks).
164 let meta = match std::fs::symlink_metadata(dir) {
165 Ok(m) => m,
166 Err(_) => return Ok(()),
167 };
168 if !meta.is_dir() {
169 return Ok(());
170 }
171 for entry in std::fs::read_dir(dir)? {
172 let entry = entry?;
173 let path = entry.path();
174 out.push(path.clone());
175 let entry_meta = match std::fs::symlink_metadata(&path) {
176 Ok(m) => m,
177 Err(_) => continue,
178 };
179 if entry_meta.is_dir() {
180 walkdir_inner(&path, out)?;
181 }
182 }
183 Ok(())
184 }
185
186 /// Sanitize a VFS path for use as a filesystem path.
187 /// Replaces null bytes and strips leading/trailing dots from each component.
188 fn sanitize_path(path: &str) -> String {
189 path.split('/')
190 .map(sanitize_component)
191 .collect::<Vec<_>>()
192 .join("/")
193 }
194
195 /// Sanitize a single path component.
196 /// Replaces null bytes, rejects `.` and `..` to prevent traversal.
197 /// Preserves leading dots on other names (e.g. `.hidden` stays `.hidden`).
198 fn sanitize_component(name: &str) -> String {
199 let s = name.replace('\0', "_");
200 if s == "." || s == ".." || s.is_empty() {
201 return "_".to_string();
202 }
203 s
204 }
205
206 /// Batch-query file extensions for a set of sample hashes.
207 fn batch_extensions(db: &Database, hashes: &[&str]) -> Vec<(String, String)> {
208 let mut result = Vec::with_capacity(hashes.len());
209 // Deduplicate to avoid redundant queries.
210 let mut seen = HashSet::new();
211 for &hash in hashes {
212 if seen.insert(hash) {
213 if let Ok(ext) = sample_extension(db, hash) {
214 result.push((hash.to_string(), ext));
215 }
216 }
217 }
218 result
219 }
220
221 /// Create a symlink. Unix only.
222 fn create_symlink(original: &Path, link: &Path) -> Result<()> {
223 #[cfg(unix)]
224 {
225 std::os::unix::fs::symlink(original, link).map_err(|e| io_err(link, e))
226 }
227 #[cfg(not(unix))]
228 {
229 let _ = (original, link);
230 Err(crate::error::CoreError::Internal(
231 "VFS mirror symlinks are only supported on Unix".to_string(),
232 ))
233 }
234 }
235
236 #[cfg(test)]
237 mod tests {
238 use super::*;
239 use crate::test_helpers::insert_fake_sample;
240 use crate::vfs::{create_directory, create_sample_link, create_vfs, delete_node};
241 use tempfile::TempDir;
242
243 fn setup() -> (Database, TempDir, TempDir) {
244 let db = Database::open_in_memory().unwrap();
245 let mirror_dir = TempDir::new().unwrap();
246 let store_dir = TempDir::new().unwrap();
247 (db, mirror_dir, store_dir)
248 }
249
250 fn make_config(mirror_dir: &TempDir, store_dir: &TempDir) -> MirrorConfig {
251 MirrorConfig {
252 mirror_root: mirror_dir.path().to_path_buf(),
253 store_root: store_dir.path().to_path_buf(),
254 }
255 }
256
257 /// Create a fake store file so symlinks have a valid target.
258 fn create_store_file(store_dir: &TempDir, hash: &str, ext: &str) {
259 let filename = if ext.is_empty() {
260 hash.to_string()
261 } else {
262 format!("{hash}.{ext}")
263 };
264 std::fs::write(store_dir.path().join(filename), b"fake audio").unwrap();
265 }
266
267 #[test]
268 fn empty_vfs_creates_empty_mirror() {
269 let (db, mirror_dir, store_dir) = setup();
270 create_vfs(&db, "Library").unwrap();
271
272 let config = make_config(&mirror_dir, &store_dir);
273 let stats = sync_mirror(&db, &config).unwrap();
274
275 assert_eq!(stats.dirs_created, 0);
276 assert_eq!(stats.links_created, 0);
277 assert_eq!(stats.entries_removed, 0);
278 }
279
280 #[test]
281 fn directory_tree_mirrored() {
282 let (db, mirror_dir, store_dir) = setup();
283 let vfs_id = create_vfs(&db, "Library").unwrap();
284 let drums = create_directory(&db, vfs_id, None, "Drums").unwrap();
285 create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap();
286
287 let config = make_config(&mirror_dir, &store_dir);
288 let stats = sync_mirror(&db, &config).unwrap();
289
290 assert_eq!(stats.dirs_created, 2); // Drums, Kicks (Library/ created implicitly by create_dir_all)
291 assert!(mirror_dir.path().join("Library/Drums/Kicks").is_dir());
292 }
293
294 #[cfg(unix)]
295 #[test]
296 fn sample_symlinks_point_to_store() {
297 let (db, mirror_dir, store_dir) = setup();
298 insert_fake_sample(&db, "abc123");
299 create_store_file(&store_dir, "abc123", "wav");
300
301 let vfs_id = create_vfs(&db, "Library").unwrap();
302 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
303
304 let config = make_config(&mirror_dir, &store_dir);
305 let stats = sync_mirror(&db, &config).unwrap();
306
307 assert_eq!(stats.links_created, 1);
308 let link = mirror_dir.path().join("Library/kick.wav");
309 assert!(link.exists() || link.symlink_metadata().is_ok());
310
311 let target = std::fs::read_link(&link).unwrap();
312 assert!(target.to_string_lossy().contains("abc123.wav"));
313 }
314
315 #[cfg(unix)]
316 #[test]
317 fn stale_entries_removed() {
318 let (db, mirror_dir, store_dir) = setup();
319 insert_fake_sample(&db, "abc123");
320 create_store_file(&store_dir, "abc123", "wav");
321
322 let vfs_id = create_vfs(&db, "Library").unwrap();
323 let node_id =
324 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
325
326 let config = make_config(&mirror_dir, &store_dir);
327 sync_mirror(&db, &config).unwrap();
328 assert!(mirror_dir.path().join("Library/kick.wav").exists()
329 || mirror_dir
330 .path()
331 .join("Library/kick.wav")
332 .symlink_metadata()
333 .is_ok());
334
335 // Delete the VFS node and re-sync.
336 delete_node(&db, node_id).unwrap();
337 let stats = sync_mirror(&db, &config).unwrap();
338
339 assert!(stats.entries_removed > 0);
340 // The symlink should be gone.
341 assert!(mirror_dir.path().join("Library/kick.wav").symlink_metadata().is_err());
342 }
343
344 #[cfg(unix)]
345 #[test]
346 fn resync_is_idempotent() {
347 let (db, mirror_dir, store_dir) = setup();
348 insert_fake_sample(&db, "abc123");
349 create_store_file(&store_dir, "abc123", "wav");
350
351 let vfs_id = create_vfs(&db, "Library").unwrap();
352 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
353
354 let config = make_config(&mirror_dir, &store_dir);
355 sync_mirror(&db, &config).unwrap();
356
357 // Second sync should create nothing new.
358 let stats = sync_mirror(&db, &config).unwrap();
359 assert_eq!(stats.dirs_created, 0);
360 assert_eq!(stats.links_created, 0);
361 assert_eq!(stats.entries_removed, 0);
362 }
363
364 #[test]
365 fn sanitize_path_handles_special_chars() {
366 assert_eq!(sanitize_path("Library/Drums"), "Library/Drums");
367 assert_eq!(sanitize_path("a\0b/c"), "a_b/c");
368 // Only . and .. are replaced to prevent traversal; other dot-prefixed names preserved
369 assert_eq!(sanitize_component(".."), "_");
370 assert_eq!(sanitize_component("."), "_");
371 assert_eq!(sanitize_component(".hidden"), ".hidden");
372 assert_eq!(sanitize_component(""), "_");
373 assert_eq!(sanitize_component("normal"), "normal");
374 }
375 }
376