Skip to main content

max / audiofiles

38.1 KB · 1096 lines History Blame Raw
1 //! Content-addressed sample storage: imports files by SHA-256 hash, deduplicates, and manages on-disk blobs.
2 //!
3 //! ## Why content-addressed storage
4 //!
5 //! - **Dedup by design:** Importing the same file twice is a no-op (same hash = same row).
6 //! Users often have the same sample in multiple folders.
7 //! - **Sync-friendly:** Hash is a stable, globally unique identifier across devices. No UUID
8 //! collisions, no server-assigned IDs, no coordination needed during offline edits.
9 //! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the
10 //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later.
11
12 use std::fs;
13 use std::io::Read;
14 use std::path::{Path, PathBuf};
15
16 use sha2::{Digest, Sha256};
17 use symphonia::core::formats::FormatOptions;
18 use symphonia::core::io::MediaSourceStream;
19 use symphonia::core::meta::MetadataOptions;
20 use symphonia::core::probe::Hint;
21
22 use crate::db::Database;
23 use crate::error::{io_err, unix_now, CoreError, Result};
24 use tracing::instrument;
25
26 /// Probe an audio file to extract its duration from metadata/headers.
27 /// Returns `None` if the duration cannot be determined without full decode.
28 fn probe_duration(path: &Path) -> Option<f64> {
29 let file = fs::File::open(path).ok()?;
30 let mss = MediaSourceStream::new(Box::new(file), Default::default());
31 let mut hint = Hint::new();
32 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
33 hint.with_extension(ext);
34 }
35 if let Ok(probed) = symphonia::default::get_probe()
36 .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
37 {
38 let track = probed.format.default_track()?;
39 let time_base = track.codec_params.time_base?;
40 let n_frames = track.codec_params.n_frames?;
41 let duration = time_base.calc_time(n_frames);
42 return Some(duration.seconds as f64 + duration.frac);
43 }
44 // Fallback for WAV files Symphonia rejects (non-standard fmt chunk sizes)
45 let is_wav = path.extension().and_then(|e| e.to_str())
46 .is_some_and(|e| e.eq_ignore_ascii_case("wav"));
47 if is_wav {
48 let reader = hound::WavReader::open(path).ok()?;
49 let spec = reader.spec();
50 let n_samples = reader.len() as f64;
51 let frames = n_samples / spec.channels as f64;
52 return Some(frames / spec.sample_rate as f64);
53 }
54 None
55 }
56
57 /// Validate that a file extension contains only safe characters.
58 ///
59 /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg,
60 /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that
61 /// could be used for directory traversal.
62 pub fn validate_extension(ext: &str) -> Result<()> {
63 if !ext.is_empty()
64 && !ext
65 .bytes()
66 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
67 {
68 return Err(CoreError::Internal(format!(
69 "invalid file extension: {ext:?}"
70 )));
71 }
72 Ok(())
73 }
74
75 /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256).
76 pub fn validate_hash(hash: &str) -> Result<()> {
77 if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
78 {
79 return Err(CoreError::HashInvalid(format!(
80 "expected 64 lowercase hex chars, got {:?} ({} chars)",
81 hash,
82 hash.len()
83 )));
84 }
85 Ok(())
86 }
87
88 /// Manages on-disk sample blobs in a flat directory structure, storing files as
89 /// `{sha256_hex}.{ext}` directly in the root directory.
90 ///
91 /// Deduplication via SHA-256: import streams the file through a hasher and skips
92 /// the copy if a blob with the same hash already exists.
93 ///
94 /// Thread-safe: all operations are stateless reads/writes against the filesystem
95 /// (no interior mutability or locks).
96 pub struct SampleStore {
97 root: PathBuf,
98 }
99
100 impl SampleStore {
101 /// Create a new sample store, ensuring the root directory exists.
102 #[instrument(skip_all)]
103 pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
104 let root = root.into();
105 fs::create_dir_all(&root).map_err(|e| io_err(&root, e))?;
106 Ok(Self { root })
107 }
108
109 /// Import a file into the store: hash it, copy to content-addressed path,
110 /// insert into DB. Returns the hex SHA-256 hash.
111 #[instrument(skip_all)]
112 pub fn import(&self, path: &Path, db: &Database) -> Result<String> {
113 if !crate::util::is_audio_file(path) {
114 return Err(CoreError::Internal(format!(
115 "not a supported audio file: {}",
116 path.display()
117 )));
118 }
119
120 let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
121 let metadata = file.metadata().map_err(|e| io_err(path, e))?;
122 let file_size = metadata.len() as i64;
123
124 if file_size == 0 {
125 return Err(CoreError::Internal(format!(
126 "cannot import zero-byte file: {}",
127 path.display()
128 )));
129 }
130
131 // Stream through SHA-256
132 let mut hasher = Sha256::new();
133 let mut buf = [0u8; 8192];
134 loop {
135 let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
136 if n == 0 {
137 break;
138 }
139 hasher.update(&buf[..n]);
140 }
141 let hash = format!("{:x}", hasher.finalize());
142
143 let ext = crate::util::get_extension(path);
144 let original_name = crate::util::get_filename(path, "unknown");
145
146 // Probe duration from file headers (cheap, no full decode)
147 let duration = probe_duration(path);
148
149 // Copy file to store if not already present
150 let dest = self.sample_path(&hash, &ext)?;
151 if !dest.exists() {
152 fs::copy(path, &dest).map_err(|e| io_err(&dest, e))?;
153 }
154
155 // Insert into DB (ignore if hash already exists)
156 let now = unix_now();
157 db.conn().execute(
158 "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration)
159 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
160 rusqlite::params![hash, original_name, ext, file_size, now, now, duration],
161 )?;
162
163 Ok(hash)
164 }
165
166 /// Check if a sample file exists in the store.
167 pub fn exists(&self, hash: &str, ext: &str) -> Result<bool> {
168 Ok(self.sample_path(hash, ext)?.exists())
169 }
170
171 /// Get the filesystem path for a sample.
172 ///
173 /// Validates that `hash` is exactly 64 lowercase hex characters (SHA-256)
174 /// to prevent directory traversal or malformed paths.
175 pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> {
176 validate_hash(hash)?;
177 validate_extension(ext)?;
178 if ext.is_empty() {
179 Ok(self.root.join(hash))
180 } else {
181 Ok(self.root.join(format!("{hash}.{ext}")))
182 }
183 }
184
185 /// Remove a sample from store and database. CASCADE handles VFS/tag refs.
186 ///
187 /// Deletes the DB row first, then removes the file from disk. This ordering
188 /// ensures that if the file deletion fails, the only residue is an orphaned
189 /// blob on disk (harmless, can be cleaned up later). The reverse order would
190 /// risk deleting the file while the DB row still references it.
191 #[instrument(skip_all)]
192 pub fn remove(&self, hash: &str, db: &Database) -> Result<()> {
193 // Look up extension before deleting the row
194 let ext = sample_extension(db, hash)?;
195 let path = self.sample_path(hash, &ext)?;
196
197 // Delete DB row first (CASCADE handles tags, vfs_nodes, etc.)
198 db.conn()
199 .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
200
201 // Then delete the file — an orphaned blob is harmless if this fails
202 if path.exists() {
203 fs::remove_file(&path).map_err(|e| io_err(&path, e))?;
204 }
205
206 Ok(())
207 }
208
209 /// Remove samples that are no longer referenced by any VFS node.
210 ///
211 /// Returns the number of orphaned samples removed. Each orphan is deleted
212 /// from the database first (CASCADE handles tags, analysis, etc.), then the
213 /// file blob is removed from disk.
214 #[instrument(skip_all)]
215 pub fn remove_orphaned_samples(&self, db: &Database) -> Result<usize> {
216 let mut stmt = db.conn().prepare(
217 "SELECT s.hash, s.file_extension
218 FROM samples s
219 LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
220 WHERE vn.id IS NULL",
221 )?;
222 let orphans: Vec<(String, String)> = stmt
223 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
224 .collect::<std::result::Result<Vec<_>, _>>()?;
225
226 let count = orphans.len();
227
228 // Delete all orphan DB rows in a single transaction so a concurrent
229 // VFS link can't reference a sample between query and delete.
230 db.transaction(|| {
231 for (hash, _) in &orphans {
232 db.conn()
233 .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
234 }
235 Ok(())
236 })?;
237
238 // Remove files after the transaction (orphaned blobs are harmless if this fails)
239 for (hash, ext) in &orphans {
240 if let Ok(path) = self.sample_path(hash, ext) {
241 if path.exists() {
242 let _ = fs::remove_file(&path);
243 }
244 }
245 }
246 Ok(count)
247 }
248
249 /// Get the store root directory.
250 pub fn root(&self) -> &Path {
251 &self.root
252 }
253
254 /// Re-hash a stored sample and compare against the expected hash.
255 ///
256 /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if
257 /// it differs. Returns an error if the file cannot be read.
258 #[instrument(skip_all)]
259 pub fn verify_sample(&self, hash: &str, ext: &str) -> Result<bool> {
260 let path = self.sample_path(hash, ext)?;
261 let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
262
263 let mut hasher = Sha256::new();
264 let mut buf = [0u8; 8192];
265 loop {
266 let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?;
267 if n == 0 {
268 break;
269 }
270 hasher.update(&buf[..n]);
271 }
272 let computed = format!("{:x}", hasher.finalize());
273
274 Ok(computed == hash)
275 }
276 }
277
278 // --- Sample metadata queries ---
279
280 /// Helper to query a single text column from the samples table by hash.
281 ///
282 /// Only fields in the allowlist may be queried; any other value returns an error
283 /// to prevent SQL injection through the interpolated column name.
284 fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> {
285 const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"];
286
287 if !ALLOWED_FIELDS.contains(&field) {
288 return Err(CoreError::Internal(format!(
289 "query_sample_field: disallowed field {field:?}"
290 )));
291 }
292
293 let sql = format!("SELECT {field} FROM samples WHERE hash = ?1");
294 db.conn()
295 .query_row(&sql, [hash], |row| row.get(0))
296 .map_err(|e| match e {
297 rusqlite::Error::QueryReturnedNoRows => {
298 CoreError::SampleNotFound(hash.to_string())
299 }
300 other => CoreError::Db(other),
301 })
302 }
303
304 /// Look up the file extension for a sample by its hash.
305 pub fn sample_extension(db: &Database, hash: &str) -> Result<String> {
306 query_sample_field(db, hash, "file_extension")
307 }
308
309 /// Look up the original filename for a sample by its hash.
310 pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> {
311 query_sample_field(db, hash, "original_name")
312 }
313
314 // --- Loose-files mode ---
315
316 /// Look up the source_path for a sample (loose-files mode imports only).
317 ///
318 /// Returns `Ok(None)` for normal-mode samples (source_path is NULL).
319 pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> {
320 db.conn()
321 .query_row(
322 "SELECT source_path FROM samples WHERE hash = ?1",
323 [hash],
324 |row| row.get(0),
325 )
326 .map_err(|e| match e {
327 rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()),
328 other => CoreError::Db(other),
329 })
330 }
331
332 /// Resolve the actual file path for a sample, checking source_path first.
333 ///
334 /// For loose-files mode samples (source_path is set), returns the source path if
335 /// the file exists, otherwise falls back to the store path. For normal samples,
336 /// returns the store path directly.
337 pub fn resolve_file_path(store: &SampleStore, db: &Database, hash: &str, ext: &str) -> Result<PathBuf> {
338 if let Some(sp) = sample_source_path(db, hash)? {
339 let source = PathBuf::from(&sp);
340 if source.exists() {
341 return Ok(source);
342 }
343 // Fallback: maybe user re-imported in normal mode or placed file manually
344 let store_path = store.sample_path(hash, ext)?;
345 if store_path.exists() {
346 return Ok(store_path);
347 }
348 // Return the source path anyway — caller will handle the "not found"
349 return Ok(source);
350 }
351 store.sample_path(hash, ext)
352 }
353
354 /// Update the source_path for a sample after verifying the new file's hash matches.
355 ///
356 /// Used to relocate an loose-files mode sample whose original file has moved.
357 pub fn relocate_sample(
358 store: &SampleStore,
359 db: &Database,
360 hash: &str,
361 new_path: &Path,
362 ) -> Result<()> {
363 // Verify hash matches
364 let mut file = fs::File::open(new_path).map_err(|e| io_err(new_path, e))?;
365 let mut hasher = Sha256::new();
366 let mut buf = [0u8; 8192];
367 loop {
368 let n = file.read(&mut buf).map_err(|e| io_err(new_path, e))?;
369 if n == 0 {
370 break;
371 }
372 hasher.update(&buf[..n]);
373 }
374 let computed = format!("{:x}", hasher.finalize());
375
376 if computed != hash {
377 return Err(CoreError::Internal(format!(
378 "hash mismatch: expected {hash}, got {computed} — this is a different file"
379 )));
380 }
381
382 let abs_path = new_path
383 .canonicalize()
384 .map_err(|e| io_err(new_path, e))?
385 .to_string_lossy()
386 .to_string();
387
388 let changed = db.conn().execute(
389 "UPDATE samples SET source_path = ?1 WHERE hash = ?2",
390 rusqlite::params![abs_path, hash],
391 )?;
392 if changed == 0 {
393 return Err(CoreError::SampleNotFound(hash.to_string()));
394 }
395 let _ = store; // unused but passed for API consistency
396 Ok(())
397 }
398
399 /// Check integrity of loose-files mode samples.
400 ///
401 /// Returns `(valid, missing)` — counts of source_path entries where the file
402 /// exists vs. does not exist on disk.
403 pub fn check_loose_files_integrity(db: &Database) -> Result<(usize, usize)> {
404 let mut stmt = db.conn().prepare(
405 "SELECT source_path FROM samples WHERE source_path IS NOT NULL",
406 )?;
407 let paths: Vec<String> = stmt
408 .query_map([], |row| row.get(0))?
409 .collect::<std::result::Result<Vec<_>, _>>()?;
410
411 let mut valid = 0;
412 let mut missing = 0;
413 for p in &paths {
414 if Path::new(p).exists() {
415 valid += 1;
416 } else {
417 missing += 1;
418 }
419 }
420 Ok((valid, missing))
421 }
422
423 /// Try to re-locate loose-files mode samples whose source files have moved.
424 ///
425 /// Walks `search_root` recursively, building a basename map of candidate
426 /// files. For each missing sample, looks up its basename, then hash-verifies
427 /// candidates (cheapest: size-check before re-hashing the full file). On hash
428 /// match, the sample's `source_path` is updated to the new location.
429 ///
430 /// Returns `(relocated, still_missing)`. `relocated` counts samples whose
431 /// `source_path` was successfully repointed; `still_missing` is the residual
432 /// count for the dialog to surface so the user can run Locate again against a
433 /// different directory.
434 pub fn relocate_missing_loose_files(
435 db: &Database,
436 search_root: &Path,
437 ) -> Result<(usize, usize)> {
438 // 1. Gather missing samples — hash, basename of stored source_path, and
439 // the recorded file_size (used for cheap pre-filter before re-hashing).
440 let mut stmt = db.conn().prepare(
441 "SELECT hash, source_path, file_size FROM samples \
442 WHERE source_path IS NOT NULL",
443 )?;
444 let rows: Vec<(String, String, i64)> = stmt
445 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
446 .collect::<std::result::Result<Vec<_>, _>>()?;
447 let missing: Vec<(String, String, i64)> = rows
448 .into_iter()
449 .filter(|(_, source_path, _)| !Path::new(source_path).exists())
450 .collect();
451
452 if missing.is_empty() {
453 return Ok((0, 0));
454 }
455
456 // 2. Walk search_root, build basename -> Vec<PathBuf>. Lowercased so a
457 // case-changed filesystem (e.g. moved between macOS/Linux) still
458 // matches. Bounded by what the filesystem returns; large trees walk
459 // once and stay in memory for the duration of this call.
460 let mut candidates: std::collections::HashMap<String, Vec<PathBuf>> =
461 std::collections::HashMap::new();
462 let mut dirs = vec![search_root.to_path_buf()];
463 while let Some(d) = dirs.pop() {
464 let Ok(entries) = std::fs::read_dir(&d) else { continue };
465 for entry in entries.flatten() {
466 let path = entry.path();
467 if path.is_dir() {
468 if !crate::util::is_macos_metadata_dir(&path) {
469 dirs.push(path);
470 }
471 } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
472 candidates
473 .entry(name.to_lowercase())
474 .or_default()
475 .push(path);
476 }
477 }
478 }
479
480 // 3. For each missing sample, check candidates with matching basename.
481 // Size check filters out same-name-different-file collisions before we
482 // spend cycles hashing. Hash verify is the authoritative match.
483 let mut relocated_pairs: Vec<(String, String)> = Vec::new();
484 for (hash, source_path, file_size) in &missing {
485 let Some(basename) = Path::new(source_path)
486 .file_name()
487 .and_then(|n| n.to_str())
488 else { continue };
489 let key = basename.to_lowercase();
490 let Some(paths) = candidates.get(&key) else { continue };
491 for cand in paths {
492 let Ok(md) = std::fs::metadata(cand) else { continue };
493 if md.len() as i64 != *file_size {
494 continue;
495 }
496 // Hash verify. Bail on first match; sample hashes are unique so a
497 // second match for the same hash would be redundant.
498 let Ok(mut file) = fs::File::open(cand) else { continue };
499 let mut hasher = Sha256::new();
500 let mut buf = [0u8; 8192];
501 let ok = loop {
502 let Ok(n) = file.read(&mut buf) else { break false };
503 if n == 0 {
504 break true;
505 }
506 hasher.update(&buf[..n]);
507 };
508 if !ok {
509 continue;
510 }
511 let computed = format!("{:x}", hasher.finalize());
512 if computed == *hash {
513 let abs = cand
514 .canonicalize()
515 .map(|p| p.to_string_lossy().to_string())
516 .unwrap_or_else(|_| cand.to_string_lossy().to_string());
517 relocated_pairs.push((hash.clone(), abs));
518 break;
519 }
520 }
521 }
522
523 // 4. Atomic update of all relocated source_paths.
524 let relocated = relocated_pairs.len();
525 if relocated > 0 {
526 db.transaction(|| {
527 for (hash, new_path) in &relocated_pairs {
528 db.conn().execute(
529 "UPDATE samples SET source_path = ?1 WHERE hash = ?2",
530 rusqlite::params![new_path, hash],
531 )?;
532 }
533 Ok(())
534 })?;
535 }
536
537 let still_missing = missing.len() - relocated;
538 Ok((relocated, still_missing))
539 }
540
541 /// Delete all loose-files mode samples whose source files no longer exist on disk.
542 ///
543 /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc.
544 pub fn purge_missing_loose_files(db: &Database) -> Result<usize> {
545 let mut stmt = db.conn().prepare(
546 "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL",
547 )?;
548 let rows: Vec<(String, String)> = stmt
549 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
550 .collect::<std::result::Result<Vec<_>, _>>()?;
551
552 // Collect hashes to purge, then delete atomically in one transaction.
553 let to_purge: Vec<&str> = rows
554 .iter()
555 .filter(|(_, source_path)| !Path::new(source_path).exists())
556 .map(|(hash, _)| hash.as_str())
557 .collect();
558 let purged = to_purge.len();
559
560 if purged > 0 {
561 db.transaction(|| {
562 for hash in &to_purge {
563 db.conn()
564 .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
565 }
566 Ok(())
567 })?;
568 }
569
570 Ok(purged)
571 }
572
573 impl SampleStore {
574 /// Import a file in loose-files mode: hash it but do NOT copy to the store.
575 ///
576 /// Records the original absolute path as `source_path` in the database.
577 /// The file stays where it is on disk.
578 #[instrument(skip_all)]
579 pub fn import_loose_files(&self, path: &Path, db: &Database) -> Result<String> {
580 if !crate::util::is_audio_file(path) {
581 return Err(CoreError::Internal(format!(
582 "not a supported audio file: {}",
583 path.display()
584 )));
585 }
586
587 let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
588 let metadata = file.metadata().map_err(|e| io_err(path, e))?;
589 let file_size = metadata.len() as i64;
590
591 if file_size == 0 {
592 return Err(CoreError::Internal(format!(
593 "cannot import zero-byte file: {}",
594 path.display()
595 )));
596 }
597
598 // Stream through SHA-256
599 let mut hasher = Sha256::new();
600 let mut buf = [0u8; 8192];
601 loop {
602 let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
603 if n == 0 {
604 break;
605 }
606 hasher.update(&buf[..n]);
607 }
608 let hash = format!("{:x}", hasher.finalize());
609
610 let ext = crate::util::get_extension(path);
611 let original_name = crate::util::get_filename(path, "unknown");
612
613 // Probe duration from file headers (cheap, no full decode)
614 let duration = probe_duration(path);
615
616 // Resolve absolute path for storage
617 let abs_path = path
618 .canonicalize()
619 .map_err(|e| io_err(path, e))?
620 .to_string_lossy()
621 .to_string();
622
623 // Insert into DB with source_path (ignore if hash already exists)
624 let now = unix_now();
625 db.conn().execute(
626 "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration, source_path)
627 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
628 rusqlite::params![hash, original_name, ext, file_size, now, now, duration, abs_path],
629 )?;
630
631 Ok(hash)
632 }
633 }
634
635 #[cfg(test)]
636 mod tests {
637 use super::*;
638 use std::io::Write;
639 use tempfile::TempDir;
640
641 fn setup() -> (TempDir, Database, SampleStore) {
642 let dir = TempDir::new().unwrap();
643 let db = Database::open_in_memory().unwrap();
644 let store_dir = dir.path().join("store");
645 let store = SampleStore::new(&store_dir).unwrap();
646 (dir, db, store)
647 }
648
649 fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
650 let path = dir.path().join(name);
651 let mut f = fs::File::create(&path).unwrap();
652 f.write_all(content).unwrap();
653 path
654 }
655
656 #[test]
657 fn import_creates_file_and_row() {
658 let (dir, db, store) = setup();
659 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
660
661 let hash = store.import(&src, &db).unwrap();
662
663 // File exists in store
664 assert!(store.exists(&hash, "wav").unwrap());
665
666 // Row exists in DB
667 let count: i64 = db
668 .conn()
669 .query_row(
670 "SELECT COUNT(*) FROM samples WHERE hash = ?1",
671 [&hash],
672 |row| row.get(0),
673 )
674 .unwrap();
675 assert_eq!(count, 1);
676 }
677
678 #[test]
679 fn import_deduplicates() {
680 let (dir, db, store) = setup();
681 let src = create_test_file(&dir, "kick.wav", b"same content");
682
683 let hash1 = store.import(&src, &db).unwrap();
684 let hash2 = store.import(&src, &db).unwrap();
685
686 assert_eq!(hash1, hash2);
687
688 // Only one row
689 let count: i64 = db
690 .conn()
691 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
692 .unwrap();
693 assert_eq!(count, 1);
694 }
695
696 #[test]
697 fn remove_deletes_file_and_row() {
698 let (dir, db, store) = setup();
699 let src = create_test_file(&dir, "snare.wav", b"snare data");
700
701 let hash = store.import(&src, &db).unwrap();
702 assert!(store.exists(&hash, "wav").unwrap());
703
704 store.remove(&hash, &db).unwrap();
705
706 assert!(!store.exists(&hash, "wav").unwrap());
707 let count: i64 = db
708 .conn()
709 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
710 .unwrap();
711 assert_eq!(count, 0);
712 }
713
714 #[test]
715 fn remove_nonexistent_returns_error() {
716 let (_dir, db, store) = setup();
717 // Use a valid 64-char hex hash that doesn't exist in the DB
718 let fake_hash = "a".repeat(64);
719 let result = store.remove(&fake_hash, &db);
720 assert!(matches!(result, Err(CoreError::SampleNotFound(_))));
721 }
722
723 #[test]
724 fn sample_path_rejects_traversal() {
725 let (_dir, _db, store) = setup();
726 let result = store.sample_path("../../../etc/passwd", "wav");
727 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
728 }
729
730 #[test]
731 fn sample_path_rejects_short_hash() {
732 let (_dir, _db, store) = setup();
733 let result = store.sample_path("abcdef", "wav");
734 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
735 }
736
737 #[test]
738 fn sample_path_rejects_uppercase() {
739 let (_dir, _db, store) = setup();
740 let hash = "A".repeat(64);
741 let result = store.sample_path(&hash, "wav");
742 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
743 }
744
745 #[test]
746 fn sample_path_accepts_valid_hash() {
747 let (_dir, _db, store) = setup();
748 let hash = "a1b2c3d4e5f6".to_string() + &"0".repeat(52);
749 let path = store.sample_path(&hash, "wav").unwrap();
750 assert!(path.to_string_lossy().ends_with(".wav"));
751 }
752
753 #[test]
754 fn sample_path_rejects_traversal_in_extension() {
755 let (_dir, _db, store) = setup();
756 let hash = "a".repeat(64);
757 let result = store.sample_path(&hash, "../etc/passwd");
758 assert!(matches!(result, Err(CoreError::Internal(_))));
759 }
760
761 #[test]
762 fn sample_path_rejects_path_separator_in_extension() {
763 let (_dir, _db, store) = setup();
764 let hash = "a".repeat(64);
765 let result = store.sample_path(&hash, "wav/../../etc");
766 assert!(matches!(result, Err(CoreError::Internal(_))));
767 }
768
769 #[test]
770 fn sample_path_accepts_common_extensions() {
771 let (_dir, _db, store) = setup();
772 let hash = "a".repeat(64);
773 for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] {
774 assert!(store.sample_path(&hash, ext).is_ok(), "rejected valid ext: {ext}");
775 }
776 }
777
778 #[test]
779 fn query_sample_field_rejects_disallowed_field() {
780 let (_dir, db, _store) = setup();
781 let hash = "a".repeat(64);
782 let result = query_sample_field(&db, &hash, "hash; DROP TABLE samples --");
783 assert!(matches!(result, Err(CoreError::Internal(_))));
784 }
785
786 #[test]
787 fn verify_sample_matches_after_import() {
788 let (dir, db, store) = setup();
789 let src = create_test_file(&dir, "hihat.wav", b"hihat audio data");
790
791 let hash = store.import(&src, &db).unwrap();
792 assert!(store.verify_sample(&hash, "wav").unwrap());
793 }
794
795 #[test]
796 fn verify_sample_detects_corruption() {
797 let (dir, db, store) = setup();
798 let src = create_test_file(&dir, "snare.wav", b"original data");
799
800 let hash = store.import(&src, &db).unwrap();
801
802 // Corrupt the stored file
803 let stored_path = store.sample_path(&hash, "wav").unwrap();
804 fs::write(&stored_path, b"corrupted data").unwrap();
805
806 assert!(!store.verify_sample(&hash, "wav").unwrap());
807 }
808
809 #[test]
810 fn verify_sample_errors_on_missing_file() {
811 let (_dir, _db, store) = setup();
812 let fake_hash = "b".repeat(64);
813 let result = store.verify_sample(&fake_hash, "wav");
814 assert!(matches!(result, Err(CoreError::Io { .. })));
815 }
816
817 #[test]
818 fn import_rejects_zero_byte_file() {
819 let (dir, db, store) = setup();
820 let src = create_test_file(&dir, "empty.wav", b"");
821
822 let result = store.import(&src, &db);
823 assert!(result.is_err());
824 let err_msg = format!("{}", result.unwrap_err());
825 assert!(
826 err_msg.contains("zero-byte"),
827 "expected zero-byte error, got: {err_msg}"
828 );
829
830 // No row should have been inserted
831 let count: i64 = db
832 .conn()
833 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
834 .unwrap();
835 assert_eq!(count, 0);
836 }
837
838 #[test]
839 fn import_accepts_non_empty_file() {
840 let (dir, db, store) = setup();
841 let src = create_test_file(&dir, "valid.wav", b"audio content");
842
843 let hash = store.import(&src, &db).unwrap();
844 assert!(!hash.is_empty());
845 assert!(store.exists(&hash, "wav").unwrap());
846 }
847
848 #[test]
849 fn remove_deletes_db_row_before_file() {
850 // Verify that after remove(), both the DB row and the file are gone.
851 // The ordering guarantee (DB first, then file) means an orphaned blob
852 // is the only possible failure mode — never a dangling DB reference.
853 let (dir, db, store) = setup();
854 let src = create_test_file(&dir, "tom.wav", b"tom data");
855
856 let hash = store.import(&src, &db).unwrap();
857 let stored_path = store.sample_path(&hash, "wav").unwrap();
858 assert!(stored_path.exists());
859
860 store.remove(&hash, &db).unwrap();
861
862 // DB row gone
863 let count: i64 = db
864 .conn()
865 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
866 .unwrap();
867 assert_eq!(count, 0);
868
869 // File gone
870 assert!(!stored_path.exists());
871 }
872
873 #[test]
874 fn remove_orphaned_samples_cleans_unreferenced() {
875 let (dir, db, store) = setup();
876 let src1 = create_test_file(&dir, "kick.wav", b"kick data");
877 let src2 = create_test_file(&dir, "snare.wav", b"snare data");
878
879 let hash1 = store.import(&src1, &db).unwrap();
880 let hash2 = store.import(&src2, &db).unwrap();
881
882 // Create a VFS and link only hash1
883 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
884 crate::vfs::create_sample_link(&db, vfs_id, None, "kick.wav", &hash1).unwrap();
885
886 // hash2 is orphaned (no VFS node), hash1 is referenced
887 let removed = store.remove_orphaned_samples(&db).unwrap();
888 assert_eq!(removed, 1);
889
890 // hash1 still exists, hash2 is gone
891 assert!(store.exists(&hash1, "wav").unwrap());
892 assert!(!store.exists(&hash2, "wav").unwrap());
893
894 let count: i64 = db
895 .conn()
896 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
897 .unwrap();
898 assert_eq!(count, 1);
899 }
900
901 #[test]
902 fn remove_orphaned_samples_keeps_referenced() {
903 let (dir, db, store) = setup();
904 let src = create_test_file(&dir, "hat.wav", b"hat data");
905 let hash = store.import(&src, &db).unwrap();
906
907 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
908 crate::vfs::create_sample_link(&db, vfs_id, None, "hat.wav", &hash).unwrap();
909
910 let removed = store.remove_orphaned_samples(&db).unwrap();
911 assert_eq!(removed, 0);
912 assert!(store.exists(&hash, "wav").unwrap());
913 }
914
915 #[test]
916 fn remove_orphaned_after_vfs_delete() {
917 let (dir, db, store) = setup();
918 let src = create_test_file(&dir, "clap.wav", b"clap data");
919 let hash = store.import(&src, &db).unwrap();
920
921 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
922 crate::vfs::create_sample_link(&db, vfs_id, None, "clap.wav", &hash).unwrap();
923
924 // Delete the VFS (cascades to vfs_nodes)
925 crate::vfs::delete_vfs(&db, vfs_id).unwrap();
926
927 // Sample is now orphaned
928 let removed = store.remove_orphaned_samples(&db).unwrap();
929 assert_eq!(removed, 1);
930 assert!(!store.exists(&hash, "wav").unwrap());
931 }
932
933 // --- Loose-files mode tests ---
934
935 #[test]
936 fn import_loose_files_does_not_copy_file() {
937 let (dir, db, store) = setup();
938 let src = create_test_file(&dir, "kick.wav", b"unsafe kick data");
939
940 let hash = store.import_loose_files(&src, &db).unwrap();
941
942 // No file in the store
943 assert!(!store.exists(&hash, "wav").unwrap());
944
945 // Row exists in DB with source_path set
946 let sp: Option<String> = db
947 .conn()
948 .query_row(
949 "SELECT source_path FROM samples WHERE hash = ?1",
950 [&hash],
951 |row| row.get(0),
952 )
953 .unwrap();
954 assert!(sp.is_some());
955 assert!(sp.unwrap().ends_with("kick.wav"));
956 }
957
958 #[test]
959 fn import_loose_files_deduplicates() {
960 let (dir, db, store) = setup();
961 let src = create_test_file(&dir, "kick.wav", b"same unsafe content");
962
963 let hash1 = store.import_loose_files(&src, &db).unwrap();
964 let hash2 = store.import_loose_files(&src, &db).unwrap();
965 assert_eq!(hash1, hash2);
966
967 let count: i64 = db
968 .conn()
969 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
970 .unwrap();
971 assert_eq!(count, 1);
972 }
973
974 #[test]
975 fn sample_source_path_returns_none_for_normal() {
976 let (dir, db, store) = setup();
977 let src = create_test_file(&dir, "kick.wav", b"normal import");
978 let hash = store.import(&src, &db).unwrap();
979
980 assert!(sample_source_path(&db, &hash).unwrap().is_none());
981 }
982
983 #[test]
984 fn sample_source_path_returns_path_for_loose_files() {
985 let (dir, db, store) = setup();
986 let src = create_test_file(&dir, "kick.wav", b"unsafe import");
987 let hash = store.import_loose_files(&src, &db).unwrap();
988
989 let sp = sample_source_path(&db, &hash).unwrap();
990 assert!(sp.is_some());
991 }
992
993 #[test]
994 fn resolve_file_path_prefers_source_path() {
995 let (dir, db, store) = setup();
996 let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test");
997 let hash = store.import_loose_files(&src, &db).unwrap();
998
999 let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
1000 // Should resolve to the original file, not the store
1001 assert!(!resolved.starts_with(store.root()));
1002 }
1003
1004 #[test]
1005 fn resolve_file_path_falls_back_to_store() {
1006 let (dir, db, store) = setup();
1007 let src = create_test_file(&dir, "kick.wav", b"fallback test");
1008
1009 // Import normally (file exists in store)
1010 let hash = store.import(&src, &db).unwrap();
1011
1012 let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
1013 assert!(resolved.starts_with(store.root()));
1014 }
1015
1016 #[test]
1017 fn relocate_sample_rejects_hash_mismatch() {
1018 let (dir, db, store) = setup();
1019 let src = create_test_file(&dir, "kick.wav", b"original content");
1020 let hash = store.import_loose_files(&src, &db).unwrap();
1021
1022 let wrong_file = create_test_file(&dir, "snare.wav", b"different content");
1023 let result = relocate_sample(&store, &db, &hash, &wrong_file);
1024 assert!(result.is_err());
1025 assert!(result.unwrap_err().to_string().contains("hash mismatch"));
1026 }
1027
1028 #[test]
1029 fn relocate_sample_updates_source_path() {
1030 let (dir, db, store) = setup();
1031 let src = create_test_file(&dir, "kick.wav", b"relocate content");
1032 let hash = store.import_loose_files(&src, &db).unwrap();
1033
1034 // Move the file
1035 let new_loc = dir.path().join("moved_kick.wav");
1036 fs::copy(&src, &new_loc).unwrap();
1037
1038 relocate_sample(&store, &db, &hash, &new_loc).unwrap();
1039
1040 let sp = sample_source_path(&db, &hash).unwrap().unwrap();
1041 assert!(sp.contains("moved_kick.wav"));
1042 }
1043
1044 #[test]
1045 fn check_loose_files_integrity_counts_correctly() {
1046 let (dir, db, store) = setup();
1047 let src1 = create_test_file(&dir, "kick.wav", b"integrity kick");
1048 let src2 = create_test_file(&dir, "snare.wav", b"integrity snare");
1049
1050 store.import_loose_files(&src1, &db).unwrap();
1051 let hash2 = store.import_loose_files(&src2, &db).unwrap();
1052
1053 // Delete snare from disk to simulate missing file
1054 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
1055 fs::remove_file(&sp).unwrap();
1056
1057 let (valid, missing) = check_loose_files_integrity(&db).unwrap();
1058 assert_eq!(valid, 1);
1059 assert_eq!(missing, 1);
1060 }
1061
1062 #[test]
1063 fn purge_missing_loose_files_removes_only_missing() {
1064 let (dir, db, store) = setup();
1065 let src1 = create_test_file(&dir, "kick.wav", b"purge kick");
1066 let src2 = create_test_file(&dir, "snare.wav", b"purge snare");
1067
1068 let hash1 = store.import_loose_files(&src1, &db).unwrap();
1069 let hash2 = store.import_loose_files(&src2, &db).unwrap();
1070
1071 // Delete snare from disk
1072 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
1073 fs::remove_file(&sp).unwrap();
1074
1075 let purged = purge_missing_loose_files(&db).unwrap();
1076 assert_eq!(purged, 1);
1077
1078 // kick still exists, snare is gone
1079 assert!(sample_source_path(&db, &hash1).is_ok());
1080 assert!(matches!(
1081 sample_source_path(&db, &hash2),
1082 Err(CoreError::SampleNotFound(_))
1083 ));
1084 }
1085
1086 #[test]
1087 fn purge_missing_loose_files_noop_when_all_valid() {
1088 let (dir, db, store) = setup();
1089 let src = create_test_file(&dir, "kick.wav", b"all valid");
1090 store.import_loose_files(&src, &db).unwrap();
1091
1092 let purged = purge_missing_loose_files(&db).unwrap();
1093 assert_eq!(purged, 0);
1094 }
1095 }
1096