Skip to main content

max / audiofiles

12.4 KB · 374 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 && !parent.exists() {
91 std::fs::create_dir_all(parent)
92 .map_err(|e| io_err(parent, e))?;
93 }
94 create_symlink(&store_file, &full_path)?;
95 stats.links_created += 1;
96 }
97 }
98 }
99 }
100 }
101
102 // Remove stale entries (files/dirs no longer in VFS).
103 // Sort descending so children are removed before parents.
104 let mut stale: Vec<PathBuf> = existing.into_iter().collect();
105 stale.sort_by(|a, b| b.cmp(a));
106
107 for path in stale {
108 // Don't remove the mirror root itself.
109 if path == config.mirror_root {
110 continue;
111 }
112 if path.is_dir() {
113 if std::fs::remove_dir(&path).is_ok() {
114 stats.entries_removed += 1;
115 }
116 } else if std::fs::remove_file(&path).is_ok() {
117 stats.entries_removed += 1;
118 }
119 }
120
121 debug!(
122 dirs = stats.dirs_created,
123 links = stats.links_created,
124 removed = stats.entries_removed,
125 "Mirror sync complete"
126 );
127
128 Ok(stats)
129 }
130
131 /// Remove the entire mirror directory tree.
132 #[instrument(skip_all)]
133 pub fn remove_mirror(mirror_root: &Path) -> Result<()> {
134 if mirror_root.exists() {
135 std::fs::remove_dir_all(mirror_root).map_err(|e| io_err(mirror_root, e))?;
136 }
137 Ok(())
138 }
139
140 /// Recursively collect all file and directory paths under `root`.
141 fn collect_existing_entries(root: &Path) -> HashSet<PathBuf> {
142 let mut entries = HashSet::new();
143 if let Ok(walker) = walkdir(root) {
144 for path in walker {
145 if path != root {
146 entries.insert(path);
147 }
148 }
149 }
150 entries
151 }
152
153 /// Simple recursive directory walker returning all paths.
154 fn walkdir(root: &Path) -> std::io::Result<Vec<PathBuf>> {
155 let mut result = Vec::new();
156 walkdir_inner(root, &mut result)?;
157 Ok(result)
158 }
159
160 fn walkdir_inner(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
161 // Use symlink_metadata to avoid following symlinks (prevents infinite loops
162 // and escaping the mirror root via directory symlinks).
163 let meta = match std::fs::symlink_metadata(dir) {
164 Ok(m) => m,
165 Err(_) => return Ok(()),
166 };
167 if !meta.is_dir() {
168 return Ok(());
169 }
170 for entry in std::fs::read_dir(dir)? {
171 let entry = entry?;
172 let path = entry.path();
173 out.push(path.clone());
174 let entry_meta = match std::fs::symlink_metadata(&path) {
175 Ok(m) => m,
176 Err(_) => continue,
177 };
178 if entry_meta.is_dir() {
179 walkdir_inner(&path, out)?;
180 }
181 }
182 Ok(())
183 }
184
185 /// Sanitize a VFS path for use as a filesystem path.
186 /// Replaces null bytes and strips leading/trailing dots from each component.
187 fn sanitize_path(path: &str) -> String {
188 path.split('/')
189 .map(sanitize_component)
190 .collect::<Vec<_>>()
191 .join("/")
192 }
193
194 /// Sanitize a single path component.
195 /// Replaces null bytes, rejects `.` and `..` to prevent traversal.
196 /// Preserves leading dots on other names (e.g. `.hidden` stays `.hidden`).
197 fn sanitize_component(name: &str) -> String {
198 let s = name.replace('\0', "_");
199 if s == "." || s == ".." || s.is_empty() {
200 return "_".to_string();
201 }
202 s
203 }
204
205 /// Batch-query file extensions for a set of sample hashes.
206 fn batch_extensions(db: &Database, hashes: &[&str]) -> Vec<(String, String)> {
207 let mut result = Vec::with_capacity(hashes.len());
208 // Deduplicate to avoid redundant queries.
209 let mut seen = HashSet::new();
210 for &hash in hashes {
211 if seen.insert(hash)
212 && let Ok(ext) = sample_extension(db, hash) {
213 result.push((hash.to_string(), ext));
214 }
215 }
216 result
217 }
218
219 /// Create a symlink. Unix only.
220 fn create_symlink(original: &Path, link: &Path) -> Result<()> {
221 #[cfg(unix)]
222 {
223 std::os::unix::fs::symlink(original, link).map_err(|e| io_err(link, e))
224 }
225 #[cfg(not(unix))]
226 {
227 let _ = (original, link);
228 Err(crate::error::CoreError::Internal(
229 "VFS mirror symlinks are only supported on Unix".to_string(),
230 ))
231 }
232 }
233
234 #[cfg(test)]
235 mod tests {
236 use super::*;
237 use crate::test_helpers::insert_fake_sample;
238 use crate::vfs::{create_directory, create_sample_link, create_vfs, delete_node};
239 use tempfile::TempDir;
240
241 fn setup() -> (Database, TempDir, TempDir) {
242 let db = Database::open_in_memory().unwrap();
243 let mirror_dir = TempDir::new().unwrap();
244 let store_dir = TempDir::new().unwrap();
245 (db, mirror_dir, store_dir)
246 }
247
248 fn make_config(mirror_dir: &TempDir, store_dir: &TempDir) -> MirrorConfig {
249 MirrorConfig {
250 mirror_root: mirror_dir.path().to_path_buf(),
251 store_root: store_dir.path().to_path_buf(),
252 }
253 }
254
255 /// Create a fake store file so symlinks have a valid target.
256 fn create_store_file(store_dir: &TempDir, hash: &str, ext: &str) {
257 let filename = if ext.is_empty() {
258 hash.to_string()
259 } else {
260 format!("{hash}.{ext}")
261 };
262 std::fs::write(store_dir.path().join(filename), b"fake audio").unwrap();
263 }
264
265 #[test]
266 fn empty_vfs_creates_empty_mirror() {
267 let (db, mirror_dir, store_dir) = setup();
268 create_vfs(&db, "Library").unwrap();
269
270 let config = make_config(&mirror_dir, &store_dir);
271 let stats = sync_mirror(&db, &config).unwrap();
272
273 assert_eq!(stats.dirs_created, 0);
274 assert_eq!(stats.links_created, 0);
275 assert_eq!(stats.entries_removed, 0);
276 }
277
278 #[test]
279 fn directory_tree_mirrored() {
280 let (db, mirror_dir, store_dir) = setup();
281 let vfs_id = create_vfs(&db, "Library").unwrap();
282 let drums = create_directory(&db, vfs_id, None, "Drums").unwrap();
283 create_directory(&db, vfs_id, Some(drums), "Kicks").unwrap();
284
285 let config = make_config(&mirror_dir, &store_dir);
286 let stats = sync_mirror(&db, &config).unwrap();
287
288 assert_eq!(stats.dirs_created, 2); // Drums, Kicks (Library/ created implicitly by create_dir_all)
289 assert!(mirror_dir.path().join("Library/Drums/Kicks").is_dir());
290 }
291
292 #[cfg(unix)]
293 #[test]
294 fn sample_symlinks_point_to_store() {
295 let (db, mirror_dir, store_dir) = setup();
296 insert_fake_sample(&db, "abc123");
297 create_store_file(&store_dir, "abc123", "wav");
298
299 let vfs_id = create_vfs(&db, "Library").unwrap();
300 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
301
302 let config = make_config(&mirror_dir, &store_dir);
303 let stats = sync_mirror(&db, &config).unwrap();
304
305 assert_eq!(stats.links_created, 1);
306 let link = mirror_dir.path().join("Library/kick.wav");
307 assert!(link.exists() || link.symlink_metadata().is_ok());
308
309 let target = std::fs::read_link(&link).unwrap();
310 assert!(target.to_string_lossy().contains("abc123.wav"));
311 }
312
313 #[cfg(unix)]
314 #[test]
315 fn stale_entries_removed() {
316 let (db, mirror_dir, store_dir) = setup();
317 insert_fake_sample(&db, "abc123");
318 create_store_file(&store_dir, "abc123", "wav");
319
320 let vfs_id = create_vfs(&db, "Library").unwrap();
321 let node_id =
322 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
323
324 let config = make_config(&mirror_dir, &store_dir);
325 sync_mirror(&db, &config).unwrap();
326 assert!(mirror_dir.path().join("Library/kick.wav").exists()
327 || mirror_dir
328 .path()
329 .join("Library/kick.wav")
330 .symlink_metadata()
331 .is_ok());
332
333 // Delete the VFS node and re-sync.
334 delete_node(&db, node_id).unwrap();
335 let stats = sync_mirror(&db, &config).unwrap();
336
337 assert!(stats.entries_removed > 0);
338 // The symlink should be gone.
339 assert!(mirror_dir.path().join("Library/kick.wav").symlink_metadata().is_err());
340 }
341
342 #[cfg(unix)]
343 #[test]
344 fn resync_is_idempotent() {
345 let (db, mirror_dir, store_dir) = setup();
346 insert_fake_sample(&db, "abc123");
347 create_store_file(&store_dir, "abc123", "wav");
348
349 let vfs_id = create_vfs(&db, "Library").unwrap();
350 create_sample_link(&db, vfs_id, None, "kick.wav", "abc123").unwrap();
351
352 let config = make_config(&mirror_dir, &store_dir);
353 sync_mirror(&db, &config).unwrap();
354
355 // Second sync should create nothing new.
356 let stats = sync_mirror(&db, &config).unwrap();
357 assert_eq!(stats.dirs_created, 0);
358 assert_eq!(stats.links_created, 0);
359 assert_eq!(stats.entries_removed, 0);
360 }
361
362 #[test]
363 fn sanitize_path_handles_special_chars() {
364 assert_eq!(sanitize_path("Library/Drums"), "Library/Drums");
365 assert_eq!(sanitize_path("a\0b/c"), "a_b/c");
366 // Only . and .. are replaced to prevent traversal; other dot-prefixed names preserved
367 assert_eq!(sanitize_component(".."), "_");
368 assert_eq!(sanitize_component("."), "_");
369 assert_eq!(sanitize_component(".hidden"), ".hidden");
370 assert_eq!(sanitize_component(""), "_");
371 assert_eq!(sanitize_component("normal"), "normal");
372 }
373 }
374