Skip to main content

max / audiofiles

72.5 KB · 1903 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, Write};
14 use std::path::{Path, PathBuf};
15
16 use sha2::{Digest, Sha256};
17 use symphonia::core::formats::probe::Hint;
18 use symphonia::core::formats::{FormatOptions, TrackType};
19 use symphonia::core::io::MediaSourceStream;
20 use symphonia::core::meta::MetadataOptions;
21 use symphonia::core::units::Timestamp;
22
23 use crate::db::Database;
24 use crate::error::{CoreError, Result, io_err, unix_now};
25 use tracing::instrument;
26
27 mod loose_files;
28 pub use loose_files::*;
29
30 /// Probe an audio file to extract its duration from metadata/headers.
31 /// Returns `None` if the duration cannot be determined without full decode.
32 fn probe_duration(path: &Path) -> Option<f64> {
33 let file = fs::File::open(path).ok()?;
34 let mss = MediaSourceStream::new(
35 Box::new(file),
36 symphonia::core::io::MediaSourceStreamOptions::default(),
37 );
38 let mut hint = Hint::new();
39 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
40 hint.with_extension(ext);
41 }
42 if let Ok(format) = symphonia::default::get_probe().probe(
43 &hint,
44 mss,
45 FormatOptions::default(),
46 MetadataOptions::default(),
47 ) {
48 // symphonia 0.6 moved the timebase and frame count off codec_params onto
49 // the track itself.
50 let track = format.default_track(TrackType::Audio)?;
51 let time_base = track.time_base?;
52 let n_frames = track.num_frames?;
53 let duration = time_base.calc_time(Timestamp::new(n_frames.try_into().ok()?))?;
54 return Some(duration.as_secs_f64());
55 }
56 // Fallback for WAV files Symphonia rejects (non-standard fmt chunk sizes)
57 let is_wav = path
58 .extension()
59 .and_then(|e| e.to_str())
60 .is_some_and(|e| e.eq_ignore_ascii_case("wav"));
61 if is_wav {
62 let reader = hound::WavReader::open(path).ok()?;
63 let spec = reader.spec();
64 let n_samples = reader.len() as f64;
65 let frames = n_samples / spec.channels as f64;
66 return Some(frames / spec.sample_rate as f64);
67 }
68 None
69 }
70
71 /// Validate that a file extension contains only safe characters.
72 ///
73 /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg,
74 /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that
75 /// could be used for directory traversal.
76 pub fn validate_extension(ext: &str) -> Result<()> {
77 if !ext.is_empty()
78 && !ext
79 .bytes()
80 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
81 {
82 return Err(CoreError::Internal(format!(
83 "invalid file extension: {ext:?}"
84 )));
85 }
86 Ok(())
87 }
88
89 /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256).
90 ///
91 /// Delegates to [`crate::SampleHash::validated`] so the rule lives in exactly one
92 /// place, historically these were two parallel validators that could drift
93 /// (e.g. on uppercase handling).
94 pub fn validate_hash(hash: &str) -> Result<()> {
95 crate::SampleHash::validated(hash).map(|_| ()).map_err(|_| {
96 CoreError::HashInvalid(format!(
97 "expected 64 lowercase hex chars, got {:?} ({} chars)",
98 hash,
99 hash.len()
100 ))
101 })
102 }
103
104 /// Mark a stored blob read-only (best-effort, cross-platform). A failed chmod
105 /// must never fail an import, it only weakens the write-through guard.
106 fn set_blob_readonly(path: &Path) {
107 if let Ok(meta) = fs::metadata(path) {
108 let mut perms = meta.permissions();
109 perms.set_readonly(true);
110 let _ = fs::set_permissions(path, perms);
111 }
112 }
113
114 /// Manages on-disk sample blobs in a flat directory structure, storing files as
115 /// `{sha256_hex}.{ext}` directly in the root directory.
116 ///
117 /// Deduplication via SHA-256: import streams the file through a hasher and skips
118 /// the copy if a blob with the same hash already exists.
119 ///
120 /// Thread-safe: all operations are stateless reads/writes against the filesystem
121 /// (no interior mutability or locks).
122 pub struct SampleStore {
123 root: PathBuf,
124 }
125
126 impl SampleStore {
127 /// Create a new sample store, ensuring the root directory exists.
128 #[instrument(skip_all)]
129 pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
130 let root = root.into();
131 fs::create_dir_all(&root).map_err(|e| io_err(&root, e))?;
132 Ok(Self { root })
133 }
134
135 /// Import a file into the store: hash it, copy to content-addressed path,
136 /// insert into DB. Returns the hex SHA-256 hash.
137 #[instrument(skip_all)]
138 pub fn import(&self, path: &Path, db: &Database) -> Result<String> {
139 let (hash, file_size) = hash_file(path)?;
140 self.import_hashed(path, &hash, file_size, db)?;
141 Ok(hash)
142 }
143
144 /// Import a file whose SHA-256 hash and size were already computed (e.g. by a
145 /// parallel pre-hash pass). Does the serial side-effecting work, content-
146 /// addressed blob copy + DB insert + orphan cleanup, exactly as [`import`].
147 /// Splitting the pure hash out lets the import pipeline hash a batch in
148 /// parallel while keeping every store/DB mutation serial.
149 #[instrument(skip_all)]
150 pub fn import_hashed(
151 &self,
152 path: &Path,
153 hash: &str,
154 file_size: i64,
155 db: &Database,
156 ) -> Result<()> {
157 // Resolve the blob extension from an existing row if this hash is already
158 // known. The store is content-addressed (one blob per hash), so identical
159 // bytes imported under a different extension must reuse the existing blob
160 // rather than write a second `{hash}.{newext}` file: remove() resolves the
161 // blob path from the DB row, so any divergent-extension copy would be
162 // unreachable and leak disk forever. Query without the deleted_at filter so
163 // the blob naming stays consistent even for soft-deleted rows. Fresh import
164 // falls back to the incoming file's extension.
165 // tombstone-ok: point-lookup that intentionally includes tombstoned rows
166 let ext = match db.conn().query_row(
167 "SELECT file_extension FROM samples WHERE hash = ?1",
168 [hash],
169 |row| row.get::<_, String>(0),
170 ) {
171 Ok(existing) => existing,
172 Err(rusqlite::Error::QueryReturnedNoRows) => crate::util::get_extension(path),
173 Err(e) => return Err(CoreError::Db(e)),
174 };
175 let original_name = clamp_original_name(crate::util::get_filename(path, "unknown"));
176
177 // Probe duration from file headers (cheap, no full decode)
178 let duration = probe_duration(path);
179
180 // Copy file to store if not already present *and intact*. Write to a
181 // temp path and atomically rename: a crash / full disk mid-copy must not
182 // leave a truncated blob at the canonical content-addressed path,
183 // because dedup would then trust that corrupt blob under this hash
184 // forever. We also repair an already-present blob whose size doesn't
185 // match the source (a partial left by a pre-atomic-fix crash): since the
186 // store is content-addressed, the correct blob's size is exactly the
187 // source's, so a cheap size check catches truncation without re-hashing.
188 // Mirrors the atomic temp+rename the sync download path uses. The pid
189 // suffix keeps two concurrent imports of the same hash from colliding on
190 // the temp file.
191 let dest = self.sample_path(hash, &ext)?;
192 let needs_write = match fs::metadata(&dest) {
193 Ok(m) => m.len() != file_size as u64,
194 Err(_) => true,
195 };
196 if needs_write {
197 let tmp = dest.with_file_name(format!("{hash}.{ext}.{}.tmp", std::process::id()));
198 // Copy *and* hash the bytes in a single pass, fsyncing the temp
199 // before the rename. `hash` was computed by an earlier pass (batch
200 // import pre-hashes the whole set, then copies one-by-one much
201 // later); the source can mutate in that window (DAW re-render,
202 // cloud-sync client, network share). Copying blindly would land
203 // wrong bytes at `{hash}.ext` and, because dedup is size-only and
204 // no read path re-hashes, trust them forever. Verifying the bytes
205 // we actually wrote against the content address closes that TOCTOU:
206 // a mismatch discards the temp and fails the import loudly rather
207 // than corrupting the store.
208 let copied = copy_hashing(path, &tmp).inspect_err(|_| {
209 let _ = fs::remove_file(&tmp);
210 })?;
211 if copied != hash {
212 let _ = fs::remove_file(&tmp);
213 return Err(CoreError::HashMismatch(format!(
214 "{} changed during import: expected {hash}, copied bytes hash to {copied}",
215 path.display()
216 )));
217 }
218 if let Err(e) = fs::rename(&tmp, &dest) {
219 let _ = fs::remove_file(&tmp);
220 return Err(io_err(&dest, e));
221 }
222 // Best-effort fsync of the directory so the rename itself is durable
223 // (the new dirent survives a crash). Not all filesystems support
224 // directory fsync; failure here only weakens durability, never the
225 // import.
226 if let Some(parent) = dest.parent()
227 && let Ok(d) = fs::File::open(parent)
228 {
229 let _ = d.sync_all();
230 }
231 // Mark the canonical blob read-only. The VFS mirror exposes store
232 // blobs to DAWs/file managers via symlinks; a "save in place" would
233 // otherwise write through and corrupt the SHA-256-named blob,
234 // silently invalidating dedup for every other placement and device.
235 // Read-only makes that write fail loudly. Unlink still works (it
236 // needs write on the directory, not the file). Best-effort: a
237 // filesystem that rejects the chmod must not fail the import.
238 set_blob_readonly(&dest);
239 }
240
241 // Insert into DB (ignore if hash already exists). If this fails after we
242 // just wrote a fresh blob, that blob is unreferenced, no row points at
243 // it, and since `needs_write` was true nothing pointed at it before
244 // either. Unlink it so a DB-write failure (disk full on the WAL, lock
245 // timeout) can't leak an unreachable blob that no GC path reclaims,
246 // upholding this module's "never orphan a blob" invariant (see remove()).
247 // When `needs_write` was false the blob pre-existed and may be shared by
248 // another row, so it is left untouched.
249 let now = unix_now();
250 if let Err(e) = db.conn().execute(
251 "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration)
252 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
253 rusqlite::params![hash, original_name, ext, file_size, now, now, duration],
254 ) {
255 if needs_write {
256 let _ = fs::remove_file(&dest);
257 }
258 return Err(CoreError::Db(e));
259 }
260
261 Ok(())
262 }
263
264 /// Check if a sample file exists in the store.
265 pub fn exists(&self, hash: &str, ext: &str) -> Result<bool> {
266 Ok(self.sample_path(hash, ext)?.exists())
267 }
268
269 /// Get the filesystem path for a sample.
270 ///
271 /// Validates that `hash` is exactly 64 lowercase hex characters (SHA-256)
272 /// to prevent directory traversal or malformed paths.
273 pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> {
274 validate_hash(hash)?;
275 validate_extension(ext)?;
276 Ok(store_blob_path(&self.root, hash, ext))
277 }
278
279 /// Remove a sample from store and database. CASCADE handles VFS/tag refs.
280 ///
281 /// Deletes the file from disk first, then the DB row. If the file delete
282 /// fails (in-use on Windows, permission denied, etc.), the DB row is left
283 /// intact so the user can retry; nothing leaks. The reverse order would
284 /// silently leak orphan blobs on every file-delete failure, accumulating
285 /// disk waste invisible to the user.
286 #[instrument(skip_all)]
287 pub fn remove(&self, hash: &str, db: &Database) -> Result<()> {
288 // Resolve the extension without the `deleted_at IS NULL` filter: this is
289 // the hard-delete path, and the row may be a tombstone (purged from the
290 // Trash view, or swept past its retention window). The blob name is
291 // content-addressed and identical regardless of tombstone state, so the
292 // unfiltered lookup is the correct one for locating the file to unlink.
293 let ext = sample_extension_any(db, hash)?;
294 let path = self.sample_path(hash, &ext)?;
295
296 // File first. ENOENT is fine, the row was already pointing at nothing.
297 match fs::remove_file(&path) {
298 Ok(()) => {}
299 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
300 Err(e) => return Err(io_err(&path, e)),
301 }
302
303 // Then the DB row (CASCADE handles tags, vfs_nodes, etc.).
304 db.conn()
305 .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
306
307 Ok(())
308 }
309
310 /// Remove samples that are no longer referenced by any VFS node.
311 ///
312 /// This is a **local** garbage-collection of disk space, not a cross-device
313 /// statement: each device has its own set of orphans. Sync triggers are
314 /// suppressed for the duration so the row deletes never push a `samples`
315 /// DELETE that would cascade-wipe another device's placements (engine-level
316 /// CASCADE is not trigger-suppressible, see
317 /// `docs/design-sample-deletion.md`). The suppression lives here, inside the
318 /// transaction, so no caller can forget it: the flag flip is invisible to
319 /// other connections under WAL snapshot isolation, so a concurrent import on
320 /// the GUI connection still syncs normally.
321 ///
322 /// Returns the number of orphaned samples removed.
323 #[instrument(skip_all)]
324 pub fn remove_orphaned_samples(&self, db: &Database) -> Result<usize> {
325 // Skip tombstoned rows, the eventual hard-delete sweep
326 // (docs/design-sample-deletion.md Phase 4) will get them when the
327 // retention window expires. Double-processing here would race the
328 // sweep and pre-emptively destroy still-recoverable data.
329 let mut stmt = db.conn().prepare(
330 "SELECT s.hash, s.file_extension
331 FROM live_samples s
332 LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
333 WHERE vn.id IS NULL",
334 )?;
335 let orphans: Vec<(String, String)> = stmt
336 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
337 .collect::<std::result::Result<Vec<_>, _>>()?;
338 drop(stmt);
339
340 // Delete orphan rows in a single transaction with sync suppressed.
341 // Re-check orphan status inside the DELETE so we don't lose a race with
342 // a concurrent import that linked the sample after the query above,
343 // and so we only unlink blobs for rows we actually removed (a live
344 // re-link must keep both row and blob).
345 //
346 // The blob unlink runs INSIDE the transaction, while `BEGIN IMMEDIATE`
347 // still holds the write lock. This is load-bearing: cleanup and import
348 // hold separate connections (serialized only by SQLite's write lock,
349 // not the in-process Database mutex), so an unlink performed after
350 // COMMIT could delete a blob that a concurrent import had already
351 // re-adopted in the gap (import sees the blob present, re-inserts the
352 // row, leaves the blob), leaving a live row pointing at a missing
353 // file. Holding the write lock across the unlink makes that re-adoption
354 // unable to commit until the blob is already gone, so the re-import
355 // re-writes the blob instead. A failed unlink only leaves an orphan
356 // blob (disk waste, reclaimed by a later re-import or verify sweep) and
357 // must not roll back the committed row delete, so its error is ignored.
358 let deleted = db.transaction(|_tx| {
359 db.conn().execute(
360 "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'",
361 [],
362 )?;
363 let mut count = 0usize;
364 for (hash, ext) in &orphans {
365 let n = db.conn().execute(
366 "DELETE FROM samples WHERE hash = ?1 \
367 AND NOT EXISTS (SELECT 1 FROM vfs_nodes WHERE sample_hash = ?1) \
368 AND deleted_at IS NULL",
369 [hash],
370 )?;
371 if n > 0 {
372 if let Ok(path) = self.sample_path(hash, ext)
373 && path.exists()
374 {
375 let _ = fs::remove_file(&path);
376 }
377 count += 1;
378 }
379 }
380 db.conn().execute(
381 "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'",
382 [],
383 )?;
384 Ok(count)
385 })?;
386
387 Ok(deleted)
388 }
389
390 /// Hard-delete tombstoned samples whose retention window has expired.
391 ///
392 /// A sample tombstoned more than `sample_tombstone_retain_days` ago (default
393 /// 30) is past recovery: this removes its blob and its `samples` row, letting
394 /// the engine CASCADE clean up placements, tags, and collection memberships.
395 /// Unlike the local orphan GC, the `samples` DELETE is **not** sync-suppressed
396 /// the deletion propagates so the user's other devices converge (each having
397 /// had the same window to recover). Runs once at startup; the indexed
398 /// `deleted_at` partial index keeps the scan cheap when nothing has expired.
399 ///
400 /// A blob/row removal that fails for one sample is logged and skipped so a
401 /// single stuck file (in-use on Windows, permission denied) doesn't abort the
402 /// rest of the sweep. Returns the number of samples hard-deleted.
403 #[instrument(skip_all)]
404 pub fn sweep_expired_tombstones(&self, db: &Database) -> Result<usize> {
405 let cutoff = unix_now() - tombstone_retain_days(db) * 86_400;
406
407 // tombstone-ok: the sweep operates on tombstones by definition
408 let mut stmt = db
409 .conn()
410 .prepare("SELECT hash FROM samples WHERE deleted_at IS NOT NULL AND deleted_at < ?1")?;
411 let expired: Vec<String> = stmt
412 .query_map([cutoff], |row| row.get(0))?
413 .collect::<std::result::Result<Vec<_>, _>>()?;
414 drop(stmt);
415
416 let mut removed = 0usize;
417 for hash in &expired {
418 match self.remove(hash, db) {
419 Ok(()) => removed += 1,
420 Err(e) => {
421 tracing::warn!(hash = %hash, "tombstone sweep: failed to purge sample: {e}");
422 }
423 }
424 }
425 Ok(removed)
426 }
427
428 /// Get the store root directory.
429 pub fn root(&self) -> &Path {
430 &self.root
431 }
432
433 /// Re-hash a stored sample and compare against the expected hash.
434 ///
435 /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if
436 /// it differs. Returns an error if the file cannot be read.
437 #[instrument(skip_all)]
438 pub fn verify_sample(&self, hash: &str, ext: &str) -> Result<bool> {
439 let path = self.sample_path(hash, ext)?;
440 let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
441
442 let mut hasher = Sha256::new();
443 let mut buf = [0u8; 8192];
444 loop {
445 let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?;
446 if n == 0 {
447 break;
448 }
449 hasher.update(&buf[..n]);
450 }
451 let computed = hex::encode(hasher.finalize());
452
453 Ok(computed == hash)
454 }
455
456 /// Scrub the managed store: re-hash every locally-present blob and report the
457 /// hashes whose bytes no longer match their content address.
458 ///
459 /// This is the runtime home for [`verify_sample`](Self::verify_sample), the
460 /// store's headline invariant is "the filename IS the content hash," and a
461 /// scrub is what confirms that invariant still holds against silent on-disk
462 /// corruption (bit-rot, an out-of-band edit through the VFS mirror, a
463 /// truncated blob from a pre-atomic-fix crash). Only managed blobs are
464 /// checked: loose-files samples live at their `source_path` (covered by
465 /// [`check_loose_files_integrity`]) and `cloud_only` rows have no local blob
466 /// to verify. A missing or unreadable blob counts as corrupt (its hash is
467 /// returned) rather than aborting the whole sweep.
468 ///
469 /// Returns `(checked, corrupt_hashes)`.
470 #[instrument(skip_all)]
471 pub fn scrub(&self, db: &Database) -> Result<(usize, Vec<String>)> {
472 let mut stmt = db.conn().prepare(
473 "SELECT hash, file_extension FROM live_samples \
474 WHERE source_path IS NULL AND cloud_only = 0",
475 )?;
476 let blobs: Vec<(String, String)> = stmt
477 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
478 .collect::<std::result::Result<Vec<_>, _>>()?;
479
480 let mut checked = 0;
481 let mut corrupt = Vec::new();
482 for (hash, ext) in blobs {
483 checked += 1;
484 // A read error (blob missing/unreadable) is itself an integrity
485 // failure for a managed sample, record it as corrupt rather than
486 // failing the sweep, so one bad blob doesn't hide the rest.
487 match self.verify_sample(&hash, &ext) {
488 Ok(true) => {}
489 Ok(false) | Err(_) => corrupt.push(hash),
490 }
491 }
492 Ok((checked, corrupt))
493 }
494 }
495
496 // --- Sample metadata queries ---
497
498 /// Query a single text column from the samples table by hash.
499 ///
500 /// Only fields in the allowlist may be queried; any other value returns an error
501 /// to prevent SQL injection through the interpolated column name.
502 fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> {
503 const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"];
504
505 if !ALLOWED_FIELDS.contains(&field) {
506 return Err(CoreError::Internal(format!(
507 "query_sample_field: disallowed field {field:?}"
508 )));
509 }
510
511 let sql = format!("SELECT {field} FROM live_samples WHERE hash = ?1");
512 db.conn()
513 .query_row(&sql, [hash], |row| row.get(0))
514 .map_err(|e| match e {
515 rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()),
516 other => CoreError::Db(other),
517 })
518 }
519
520 /// Look up the file extension for a sample by its hash.
521 pub fn sample_extension(db: &Database, hash: &str) -> Result<String> {
522 query_sample_field(db, hash, "file_extension")
523 }
524
525 /// Look up the file extension for a sample by its hash, including tombstoned
526 /// rows. Used by the hard-delete and sweep paths, which must resolve the blob
527 /// path for soft-deleted rows that `sample_extension` deliberately hides.
528 fn sample_extension_any(db: &Database, hash: &str) -> Result<String> {
529 // tombstone-ok: point-lookup that must resolve blobs for tombstoned rows too
530 db.conn()
531 .query_row(
532 "SELECT file_extension FROM samples WHERE hash = ?1",
533 [hash],
534 |row| row.get(0),
535 )
536 .map_err(|e| match e {
537 rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()),
538 other => CoreError::Db(other),
539 })
540 }
541
542 /// Look up the original filename for a sample by its hash.
543 pub fn sample_original_name(db: &Database, hash: &str) -> Result<String> {
544 query_sample_field(db, hash, "original_name")
545 }
546
547 // --- Soft delete (tombstones) ---
548 //
549 // See `docs/design-sample-deletion.md`. A non-NULL `samples.deleted_at` marks a
550 // sample as tombstoned: hidden from every normal read path, recoverable from the
551 // Trash view, and hard-deleted by the sweep once it ages past the retention
552 // window. None of these operations suppress sync triggers, so a tombstone, an
553 // undelete, and a permanent purge each replicate to the user's other devices.
554
555 /// A tombstoned sample, as shown in the Trash view.
556 #[derive(Debug, Clone)]
557 pub struct TombstonedSample {
558 pub hash: String,
559 pub original_name: String,
560 pub file_extension: String,
561 pub file_size: i64,
562 /// Unix timestamp (seconds) the sample was tombstoned.
563 pub deleted_at: i64,
564 }
565
566 /// List every tombstoned sample, most recently deleted first.
567 pub fn tombstoned_samples(db: &Database) -> Result<Vec<TombstonedSample>> {
568 // tombstone-ok: this IS the tombstone listing (Trash view)
569 let mut stmt = db.conn().prepare(
570 "SELECT hash, original_name, file_extension, file_size, deleted_at
571 FROM samples
572 WHERE deleted_at IS NOT NULL
573 ORDER BY deleted_at DESC",
574 )?;
575 let rows = stmt
576 .query_map([], |row| {
577 Ok(TombstonedSample {
578 hash: row.get(0)?,
579 original_name: row.get(1)?,
580 file_extension: row.get(2)?,
581 file_size: row.get(3)?,
582 deleted_at: row.get(4)?,
583 })
584 })?
585 .collect::<std::result::Result<Vec<_>, _>>()?;
586 Ok(rows)
587 }
588
589 /// Soft-delete a sample: set `deleted_at` to now if it is currently live. The
590 /// blob and every placement, tag, and collection membership stay intact so the
591 /// user can recover from Trash until the sweep window expires. Returns `true` if
592 /// a live row was tombstoned, `false` if the hash was unknown or already gone.
593 #[instrument(skip_all)]
594 pub fn tombstone_sample(db: &Database, hash: &str) -> Result<bool> {
595 let n = db.conn().execute(
596 "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2 AND deleted_at IS NULL",
597 rusqlite::params![unix_now(), hash],
598 )?;
599 Ok(n > 0)
600 }
601
602 /// Restore a tombstoned sample, clearing `deleted_at`. Returns `true` if a
603 /// tombstoned row was restored, `false` if the hash was unknown or already live.
604 #[instrument(skip_all)]
605 pub fn undelete_sample(db: &Database, hash: &str) -> Result<bool> {
606 let n = db.conn().execute(
607 "UPDATE samples SET deleted_at = NULL WHERE hash = ?1 AND deleted_at IS NOT NULL",
608 [hash],
609 )?;
610 Ok(n > 0)
611 }
612
613 /// Read the tombstone retention window in days from `user_config`, falling back
614 /// to the 30-day default (matching the M019 seed and macOS Trash convention) if
615 /// the key is missing or unparseable.
616 pub fn tombstone_retain_days(db: &Database) -> i64 {
617 const DEFAULT_RETAIN_DAYS: i64 = 30;
618 db.conn()
619 .query_row(
620 "SELECT value FROM user_config WHERE key = 'sample_tombstone_retain_days'",
621 [],
622 |row| row.get::<_, String>(0),
623 )
624 .ok()
625 .and_then(|v| v.parse::<i64>().ok())
626 .filter(|d| *d >= 0)
627 .unwrap_or(DEFAULT_RETAIN_DAYS)
628 }
629
630 /// Clamp an imported file's display name before it enters the DB and the sync
631 /// payload. The name is taken verbatim from disk, so bound its length (on a
632 /// UTF-8 char boundary) to a filesystem-scale limit. SQL binds it as a
633 /// parameter, so this is storage hygiene, not an injection guard.
634 fn clamp_original_name(name: String) -> String {
635 const MAX_BYTES: usize = 255;
636 if name.len() <= MAX_BYTES {
637 return name;
638 }
639 let mut end = MAX_BYTES;
640 while end > 0 && !name.is_char_boundary(end) {
641 end -= 1;
642 }
643 name[..end].to_string()
644 }
645
646 // --- Loose-files mode ---
647
648 /// Look up the source_path for a sample (loose-files mode imports only).
649 ///
650 /// Returns `Ok(None)` for normal-mode samples (source_path is NULL).
651 pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> {
652 db.conn()
653 .query_row(
654 "SELECT source_path FROM live_samples WHERE hash = ?1",
655 [hash],
656 |row| row.get(0),
657 )
658 .map_err(|e| match e {
659 rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()),
660 other => CoreError::Db(other),
661 })
662 }
663
664 /// Where a sample's bytes live on disk.
665 ///
666 /// `Loose` is a loose-files sample (imported by reference): the bytes stay at the
667 /// user's original `source_path`, never copied into the store. `Store` is a normal
668 /// content-addressed blob.
669 #[derive(Debug, Clone, PartialEq, Eq)]
670 pub enum SampleLocation {
671 /// Content-addressed store blob at `{store_root}/{hash}[.ext]`.
672 Store(PathBuf),
673 /// Loose-files sample at the user's original path.
674 Loose(PathBuf),
675 }
676
677 impl SampleLocation {
678 /// The resolved on-disk path, regardless of which kind.
679 pub fn path(&self) -> &Path {
680 match self {
681 SampleLocation::Store(p) | SampleLocation::Loose(p) => p,
682 }
683 }
684
685 /// Consume into the resolved path.
686 pub fn into_path(self) -> PathBuf {
687 match self {
688 SampleLocation::Store(p) | SampleLocation::Loose(p) => p,
689 }
690 }
691 }
692
693 /// Build the content-addressed store blob path for `hash`+`ext` under
694 /// `store_root`. The single place `{store_root}/{hash}[.ext]` is constructed, no
695 /// caller hand-rolls this join (it once drifted in the VFS mirror).
696 pub fn store_blob_path(store_root: &Path, hash: &str, ext: &str) -> PathBuf {
697 if ext.is_empty() {
698 store_root.join(hash)
699 } else {
700 store_root.join(format!("{hash}.{ext}"))
701 }
702 }
703
704 /// Single source of truth for "where does this sample's bytes live": the
705 /// loose-files `source_path` if the sample has one, otherwise the store blob.
706 ///
707 /// Every consumer that needs a sample's file, playback, preview, export, and the
708 /// VFS mirror, resolves through here (directly or via [`resolve_file_path`]), so a
709 /// loose-files sample is never pointed at a non-existent store blob. This is a pure
710 /// mapping (no filesystem access); [`resolve_file_path`] adds the runtime
711 /// existence-fallback on top.
712 pub fn sample_location(
713 store_root: &Path,
714 hash: &str,
715 ext: &str,
716 source_path: Option<&str>,
717 ) -> SampleLocation {
718 match source_path {
719 Some(sp) => SampleLocation::Loose(PathBuf::from(sp)),
720 None => SampleLocation::Store(store_blob_path(store_root, hash, ext)),
721 }
722 }
723
724 /// Resolve the actual file path for a sample, checking source_path first.
725 ///
726 /// Builds on [`sample_location`] (the canonical loose-vs-store decision) and adds
727 /// a runtime existence fallback: for a loose-files sample whose original file has
728 /// moved, falls back to the store blob if one happens to exist, else returns the
729 /// source path so the caller surfaces a clean "not found".
730 pub fn resolve_file_path(
731 store: &SampleStore,
732 db: &Database,
733 hash: &str,
734 ext: &str,
735 ) -> Result<PathBuf> {
736 match sample_location(
737 store.root(),
738 hash,
739 ext,
740 sample_source_path(db, hash)?.as_deref(),
741 ) {
742 SampleLocation::Store(_) => store.sample_path(hash, ext),
743 SampleLocation::Loose(source) => {
744 if source.exists() {
745 return Ok(source);
746 }
747 // Fallback: maybe user re-imported in normal mode or placed file manually.
748 let store_path = store.sample_path(hash, ext)?;
749 if store_path.exists() {
750 return Ok(store_path);
751 }
752 // Return the source path anyway, caller will handle the "not found".
753 Ok(source)
754 }
755 }
756 }
757
758 /// Update the source_path for a sample after verifying the new file's hash matches.
759 ///
760 /// Used to relocate an loose-files mode sample whose original file has moved.
761 pub fn relocate_sample(
762 store: &SampleStore,
763 db: &Database,
764 hash: &str,
765 new_path: &Path,
766 ) -> Result<()> {
767 // Verify hash matches
768 let mut file = fs::File::open(new_path).map_err(|e| io_err(new_path, e))?;
769 let mut hasher = Sha256::new();
770 let mut buf = [0u8; 8192];
771 loop {
772 let n = file.read(&mut buf).map_err(|e| io_err(new_path, e))?;
773 if n == 0 {
774 break;
775 }
776 hasher.update(&buf[..n]);
777 }
778 let computed = hex::encode(hasher.finalize());
779
780 if computed != hash {
781 return Err(CoreError::Internal(format!(
782 "hash mismatch: expected {hash}, got {computed}; this is a different file"
783 )));
784 }
785
786 let abs_path = new_path
787 .canonicalize()
788 .map_err(|e| io_err(new_path, e))?
789 .to_string_lossy()
790 .to_string();
791
792 let changed = db.conn().execute(
793 "UPDATE samples SET source_path = ?1 WHERE hash = ?2",
794 rusqlite::params![abs_path, hash],
795 )?;
796 if changed == 0 {
797 return Err(CoreError::SampleNotFound(hash.to_string()));
798 }
799 let _ = store; // unused but passed for API consistency
800 Ok(())
801 }
802
803 /// Hash a file's bytes with SHA-256, returning the hex digest and its size.
804 ///
805 /// Pure (no store/DB side effects), so a batch can be hashed in parallel before
806 /// the serial copy+record step. Applies the same guards [`SampleStore::import`]
807 /// did inline: rejects non-audio and zero-byte files.
808 pub fn hash_file(path: &Path) -> Result<(String, i64)> {
809 if !crate::util::is_audio_file(path) {
810 return Err(CoreError::Internal(format!(
811 "not a supported audio file: {}",
812 path.display()
813 )));
814 }
815
816 let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
817 let metadata = file.metadata().map_err(|e| io_err(path, e))?;
818 let file_size = metadata.len() as i64;
819
820 if file_size == 0 {
821 return Err(CoreError::Internal(format!(
822 "cannot import zero-byte file: {}",
823 path.display()
824 )));
825 }
826
827 let mut hasher = Sha256::new();
828 let mut buf = [0u8; 8192];
829 loop {
830 let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
831 if n == 0 {
832 break;
833 }
834 hasher.update(&buf[..n]);
835 }
836 Ok((hex::encode(hasher.finalize()), file_size))
837 }
838
839 /// Copy `src` to `dst` while computing the SHA-256 of the bytes actually
840 /// written, returning the hex digest. Used by the store write path so the copy
841 /// can be verified against the content address it will be stored under, the
842 /// hash is computed over the same bytes that land on disk, not over an earlier
843 /// read of a source that may since have changed. Fsyncs `dst` before returning
844 /// so the rename that follows can't be durable ahead of the contents.
845 fn copy_hashing(src: &Path, dst: &Path) -> Result<String> {
846 let mut input = fs::File::open(src).map_err(|e| io_err(src, e))?;
847 let mut output = fs::File::create(dst).map_err(|e| io_err(dst, e))?;
848 let mut hasher = Sha256::new();
849 let mut buf = [0u8; 8192];
850 loop {
851 let n = input.read(&mut buf).map_err(|e| io_err(src, e))?;
852 if n == 0 {
853 break;
854 }
855 hasher.update(&buf[..n]);
856 output.write_all(&buf[..n]).map_err(|e| io_err(dst, e))?;
857 }
858 output.sync_all().map_err(|e| io_err(dst, e))?;
859 Ok(hex::encode(hasher.finalize()))
860 }
861
862 /// Hash a batch of files in parallel (rayon). Each result is aligned to the
863 /// corresponding entry in `paths`. Hashing is the dominant per-file import cost
864 /// (SHA-256 over the whole file) and is embarrassingly parallel, every side
865 /// effect (blob copy, DB insert) stays serial in the caller, so the content-
866 /// addressed store invariants are untouched.
867 pub fn hash_files_parallel(paths: &[PathBuf]) -> Vec<Result<(String, i64)>> {
868 use rayon::prelude::*;
869 paths.par_iter().map(|p| hash_file(p)).collect()
870 }
871
872 #[cfg(test)]
873 mod tests {
874 use super::*;
875 use std::io::Write;
876 use tempfile::TempDir;
877
878 fn setup() -> (TempDir, Database, SampleStore) {
879 let dir = TempDir::new().unwrap();
880 let db = Database::open_in_memory().unwrap();
881 let store_dir = dir.path().join("store");
882 let store = SampleStore::new(&store_dir).unwrap();
883 (dir, db, store)
884 }
885
886 fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
887 let path = dir.path().join(name);
888 let mut f = fs::File::create(&path).unwrap();
889 f.write_all(content).unwrap();
890 path
891 }
892
893 /// Give a sample one VFS placement, so CASCADE behaviour and placement
894 /// preservation are observable.
895 fn place_sample(db: &Database, hash: &str) {
896 db.conn()
897 .execute(
898 "INSERT OR IGNORE INTO vfs (id, name, created_at, modified_at) \
899 VALUES (1, 'Library', 0, 0)",
900 [],
901 )
902 .unwrap();
903 db.conn()
904 .execute(
905 "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \
906 VALUES (1, NULL, ?1, 'sample', ?1, 0)",
907 [hash],
908 )
909 .unwrap();
910 }
911
912 fn count(db: &Database, sql: &str, hash: &str) -> i64 {
913 db.conn().query_row(sql, [hash], |r| r.get(0)).unwrap()
914 }
915
916 #[test]
917 fn tombstone_hides_sample_and_undelete_restores() {
918 let (dir, db, store) = setup();
919 let hash = store
920 .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
921 .unwrap();
922
923 // Live: visible to the read path, absent from Trash.
924 assert!(sample_extension(&db, &hash).is_ok());
925 assert!(tombstoned_samples(&db).unwrap().is_empty());
926
927 // Tombstone is a one-shot: the second call is a no-op.
928 assert!(tombstone_sample(&db, &hash).unwrap());
929 assert!(!tombstone_sample(&db, &hash).unwrap());
930
931 // Hidden from the read path, surfaced in Trash with a timestamp.
932 assert!(matches!(
933 sample_extension(&db, &hash),
934 Err(CoreError::SampleNotFound(_))
935 ));
936 let trash = tombstoned_samples(&db).unwrap();
937 assert_eq!(trash.len(), 1);
938 assert_eq!(trash[0].hash, hash);
939 assert!(trash[0].deleted_at > 0);
940
941 // Undelete restores it; a second undelete is a no-op.
942 assert!(undelete_sample(&db, &hash).unwrap());
943 assert!(!undelete_sample(&db, &hash).unwrap());
944 assert!(sample_extension(&db, &hash).is_ok());
945 assert!(tombstoned_samples(&db).unwrap().is_empty());
946 }
947
948 #[test]
949 fn tombstone_preserves_placements_and_blob() {
950 let (dir, db, store) = setup();
951 let hash = store
952 .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
953 .unwrap();
954 place_sample(&db, &hash);
955
956 assert!(tombstone_sample(&db, &hash).unwrap());
957
958 // The whole point of soft delete: placements and the blob survive so the
959 // user can recover everything.
960 assert_eq!(
961 count(
962 &db,
963 "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1",
964 &hash
965 ),
966 1
967 );
968 assert!(store.exists(&hash, "wav").unwrap());
969 }
970
971 #[test]
972 fn remove_purges_tombstoned_row_and_cascades() {
973 let (dir, db, store) = setup();
974 let hash = store
975 .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
976 .unwrap();
977 place_sample(&db, &hash);
978 assert!(tombstone_sample(&db, &hash).unwrap());
979
980 // Permanent delete must work on a tombstoned row even though the
981 // filtered `sample_extension` would hide it, `remove` resolves the
982 // blob path unfiltered.
983 store.remove(&hash, &db).unwrap();
984
985 assert!(!store.exists(&hash, "wav").unwrap());
986 assert_eq!(
987 count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &hash),
988 0
989 );
990 assert_eq!(
991 count(
992 &db,
993 "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1",
994 &hash
995 ),
996 0
997 );
998 }
999
1000 #[test]
1001 fn sweep_hard_deletes_only_expired_tombstones() {
1002 let (dir, db, store) = setup();
1003 let live = store
1004 .import(&create_test_file(&dir, "live.wav", b"live audio"), &db)
1005 .unwrap();
1006 let fresh = store
1007 .import(&create_test_file(&dir, "fresh.wav", b"fresh audio"), &db)
1008 .unwrap();
1009 let old = store
1010 .import(&create_test_file(&dir, "old.wav", b"old audio data"), &db)
1011 .unwrap();
1012 place_sample(&db, &old);
1013
1014 // fresh: tombstoned just now. old: tombstoned beyond the 30-day window.
1015 assert!(tombstone_sample(&db, &fresh).unwrap());
1016 assert!(tombstone_sample(&db, &old).unwrap());
1017 db.conn()
1018 .execute(
1019 "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2",
1020 rusqlite::params![unix_now() - 31 * 86_400, old],
1021 )
1022 .unwrap();
1023
1024 let removed = store.sweep_expired_tombstones(&db).unwrap();
1025 assert_eq!(removed, 1);
1026
1027 // old is gone (row, blob, and CASCADE'd placement); fresh + live stay.
1028 assert!(!store.exists(&old, "wav").unwrap());
1029 assert_eq!(
1030 count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &old),
1031 0
1032 );
1033 assert_eq!(
1034 count(
1035 &db,
1036 "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1",
1037 &old
1038 ),
1039 0
1040 );
1041 assert_eq!(tombstoned_samples(&db).unwrap().len(), 1);
1042 assert!(sample_extension(&db, &live).is_ok());
1043 assert!(store.exists(&fresh, "wav").unwrap());
1044 }
1045
1046 #[test]
1047 fn sweep_respects_retain_days_config() {
1048 let (dir, db, store) = setup();
1049 let hash = store
1050 .import(&create_test_file(&dir, "s.wav", b"some audio"), &db)
1051 .unwrap();
1052 assert!(tombstone_sample(&db, &hash).unwrap());
1053 db.conn()
1054 .execute(
1055 "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2",
1056 rusqlite::params![unix_now() - 5 * 86_400, hash],
1057 )
1058 .unwrap();
1059
1060 // 5 days old, default 30-day window: not yet expired.
1061 assert_eq!(tombstone_retain_days(&db), 30);
1062 assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 0);
1063
1064 // Shrink the window to 3 days: now it sweeps.
1065 db.conn()
1066 .execute(
1067 "UPDATE user_config SET value = '3' WHERE key = 'sample_tombstone_retain_days'",
1068 [],
1069 )
1070 .unwrap();
1071 assert_eq!(tombstone_retain_days(&db), 3);
1072 assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 1);
1073 }
1074
1075 #[test]
1076 fn clamp_original_name_caps_length_on_char_boundary() {
1077 // Short names pass through untouched.
1078 assert_eq!(clamp_original_name("kick.wav".to_string()), "kick.wav");
1079
1080 // Over-long ASCII is capped to 255 bytes.
1081 let long = "a".repeat(1000);
1082 assert_eq!(clamp_original_name(long).len(), 255);
1083
1084 // Multi-byte chars at the cap don't split mid-codepoint (valid UTF-8).
1085 let multibyte = "é".repeat(200); // 400 bytes
1086 let clamped = clamp_original_name(multibyte);
1087 assert!(clamped.len() <= 255);
1088 assert!(std::str::from_utf8(clamped.as_bytes()).is_ok());
1089 }
1090
1091 #[test]
1092 fn hash_file_matches_import_and_rejects_bad_input() {
1093 let (dir, db, store) = setup();
1094 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
1095
1096 // hash_file's digest equals the hash import() records.
1097 let (hash, size) = hash_file(&src).unwrap();
1098 assert_eq!(size, "fake audio data".len() as i64);
1099 let imported = store.import(&src, &db).unwrap();
1100 assert_eq!(hash, imported);
1101
1102 // Zero-byte and non-audio files are rejected (same guards as import).
1103 let empty = create_test_file(&dir, "empty.wav", b"");
1104 assert!(hash_file(&empty).is_err());
1105 let txt = create_test_file(&dir, "notes.txt", b"hello");
1106 assert!(hash_file(&txt).is_err());
1107 }
1108
1109 #[test]
1110 fn hash_file_matches_the_reference_sha256_digest() {
1111 // The content address is the library's primary key, so the exact hex
1112 // string a given byte sequence produces is a compatibility guarantee:
1113 // change it and every stored path and database row stops resolving.
1114 // Pinned against an independent SHA-256 of the same bytes, so a hasher
1115 // or hex-encoding swap has to survive a known answer, not just agree
1116 // with itself.
1117 let (dir, _db, _store) = setup();
1118 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
1119 let (hash, _) = hash_file(&src).unwrap();
1120 assert_eq!(
1121 hash, "cec560f942befcb4e4a4d1161c5c03b3a787e2d525f650042641e62bf8773c69",
1122 "SHA-256 of b\"fake audio data\", lowercase hex, no separators"
1123 );
1124 }
1125
1126 #[test]
1127 fn import_hashed_matches_serial_import() {
1128 let (dir, db, store) = setup();
1129 let src = create_test_file(&dir, "snare.wav", b"some audio bytes");
1130
1131 // Pre-hash then record, the prehashed path must land the same blob + row
1132 // as the all-in-one import().
1133 let (hash, size) = hash_file(&src).unwrap();
1134 store.import_hashed(&src, &hash, size, &db).unwrap();
1135
1136 assert!(store.exists(&hash, "wav").unwrap());
1137 let count: i64 = db
1138 .conn()
1139 .query_row(
1140 "SELECT COUNT(*) FROM samples WHERE hash = ?1",
1141 [&hash],
1142 |r| r.get(0),
1143 )
1144 .unwrap();
1145 assert_eq!(count, 1);
1146 }
1147
1148 #[test]
1149 fn hash_files_parallel_aligns_results() {
1150 let (dir, _db, _store) = setup();
1151 let a = create_test_file(&dir, "a.wav", b"aaaa");
1152 let b = create_test_file(&dir, "b.wav", b"bbbbbb");
1153 let bad = create_test_file(&dir, "z.txt", b"nope"); // non-audio -> Err
1154
1155 let results = hash_files_parallel(&[a.clone(), b.clone(), bad.clone()]);
1156 assert_eq!(results.len(), 3);
1157 // Aligned to input order; each Ok hash equals a direct hash_file call.
1158 assert_eq!(results[0].as_ref().unwrap().0, hash_file(&a).unwrap().0);
1159 assert_eq!(results[1].as_ref().unwrap().1, 6);
1160 assert!(results[2].is_err());
1161 }
1162
1163 #[test]
1164 fn import_creates_file_and_row() {
1165 let (dir, db, store) = setup();
1166 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
1167
1168 let hash = store.import(&src, &db).unwrap();
1169
1170 // File exists in store
1171 assert!(store.exists(&hash, "wav").unwrap());
1172
1173 // Row exists in DB
1174 let count: i64 = db
1175 .conn()
1176 .query_row(
1177 "SELECT COUNT(*) FROM samples WHERE hash = ?1",
1178 [&hash],
1179 |row| row.get(0),
1180 )
1181 .unwrap();
1182 assert_eq!(count, 1);
1183 }
1184
1185 #[test]
1186 fn import_deduplicates() {
1187 let (dir, db, store) = setup();
1188 let src = create_test_file(&dir, "kick.wav", b"same content");
1189
1190 let hash1 = store.import(&src, &db).unwrap();
1191 let hash2 = store.import(&src, &db).unwrap();
1192
1193 assert_eq!(hash1, hash2);
1194
1195 // Only one row
1196 let count: i64 = db
1197 .conn()
1198 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1199 .unwrap();
1200 assert_eq!(count, 1);
1201 }
1202
1203 #[test]
1204 fn import_same_bytes_different_extension_reuses_blob() {
1205 let (dir, db, store) = setup();
1206 // Identical bytes, two different audio extensions (is_audio_file keys on
1207 // extension, so the content can be arbitrary).
1208 let wav = create_test_file(&dir, "loop.wav", b"identical bytes");
1209 let aiff = create_test_file(&dir, "loop.aiff", b"identical bytes");
1210
1211 let h1 = store.import(&wav, &db).unwrap();
1212 let h2 = store.import(&aiff, &db).unwrap();
1213 assert_eq!(h1, h2, "identical bytes hash to the same sample");
1214
1215 // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not
1216 // write an unreachable `{hash}.aiff` orphan.
1217 let blob_count = || {
1218 std::fs::read_dir(store.root())
1219 .unwrap()
1220 .filter_map(std::result::Result::ok)
1221 .filter(|e| e.path().is_file())
1222 .count()
1223 };
1224 assert_eq!(
1225 blob_count(),
1226 1,
1227 "second extension must reuse the first blob"
1228 );
1229
1230 // And remove() leaves nothing behind, the orphan would otherwise survive,
1231 // since remove() resolves the blob path from the DB row's extension.
1232 store.remove(&h1, &db).unwrap();
1233 assert_eq!(blob_count(), 0, "no orphan blob remains after remove");
1234 }
1235
1236 #[test]
1237 fn import_repairs_a_truncated_preexisting_blob() {
1238 // Reproduces the corrupt-blob trap: a crash mid-copy (or any partial
1239 // write) can leave a truncated file at the canonical content-addressed
1240 // path. A content-addressed store must never trust it, import must
1241 // detect the size mismatch and rewrite the correct bytes.
1242 let (dir, db, store) = setup();
1243 let content = b"the genuine full sample payload";
1244 let src = create_test_file(&dir, "kick.wav", content);
1245
1246 // Pre-place a truncated blob at the canonical path the real import targets.
1247 let hash = hex::encode(Sha256::digest(content));
1248 let dest = store.sample_path(&hash, "wav").unwrap();
1249 fs::create_dir_all(dest.parent().unwrap()).unwrap();
1250 fs::write(&dest, b"trunc").unwrap();
1251
1252 let imported = store.import(&src, &db).unwrap();
1253 assert_eq!(imported, hash);
1254
1255 // The stored blob now matches the source bytes exactly (hash verified).
1256 let stored = fs::read(&dest).unwrap();
1257 assert_eq!(stored, content, "truncated blob must be repaired on import");
1258 assert_eq!(
1259 hex::encode(Sha256::digest(&stored)),
1260 hash,
1261 "repaired blob hashes back to its content address"
1262 );
1263 }
1264
1265 #[test]
1266 fn remove_deletes_file_and_row() {
1267 let (dir, db, store) = setup();
1268 let src = create_test_file(&dir, "snare.wav", b"snare data");
1269
1270 let hash = store.import(&src, &db).unwrap();
1271 assert!(store.exists(&hash, "wav").unwrap());
1272
1273 store.remove(&hash, &db).unwrap();
1274
1275 assert!(!store.exists(&hash, "wav").unwrap());
1276 let count: i64 = db
1277 .conn()
1278 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1279 .unwrap();
1280 assert_eq!(count, 0);
1281 }
1282
1283 #[test]
1284 fn remove_nonexistent_returns_error() {
1285 let (_dir, db, store) = setup();
1286 // Use a valid 64-char hex hash that doesn't exist in the DB
1287 let fake_hash = "a".repeat(64);
1288 let result = store.remove(&fake_hash, &db);
1289 assert!(matches!(result, Err(CoreError::SampleNotFound(_))));
1290 }
1291
1292 #[test]
1293 fn sample_path_rejects_traversal() {
1294 let (_dir, _db, store) = setup();
1295 let result = store.sample_path("../../../etc/passwd", "wav");
1296 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
1297 }
1298
1299 #[test]
1300 fn sample_path_rejects_short_hash() {
1301 let (_dir, _db, store) = setup();
1302 let result = store.sample_path("abcdef", "wav");
1303 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
1304 }
1305
1306 #[test]
1307 fn sample_path_rejects_uppercase() {
1308 let (_dir, _db, store) = setup();
1309 let hash = "A".repeat(64);
1310 let result = store.sample_path(&hash, "wav");
1311 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
1312 }
1313
1314 #[test]
1315 fn sample_path_accepts_valid_hash() {
1316 let (_dir, _db, store) = setup();
1317 let hash = "a1b2c3d4e5f6".to_string() + &"0".repeat(52);
1318 let path = store.sample_path(&hash, "wav").unwrap();
1319 assert!(path.to_string_lossy().ends_with(".wav"));
1320 }
1321
1322 #[test]
1323 fn sample_path_rejects_traversal_in_extension() {
1324 let (_dir, _db, store) = setup();
1325 let hash = "a".repeat(64);
1326 let result = store.sample_path(&hash, "../etc/passwd");
1327 assert!(matches!(result, Err(CoreError::Internal(_))));
1328 }
1329
1330 #[test]
1331 fn sample_path_rejects_path_separator_in_extension() {
1332 let (_dir, _db, store) = setup();
1333 let hash = "a".repeat(64);
1334 let result = store.sample_path(&hash, "wav/../../etc");
1335 assert!(matches!(result, Err(CoreError::Internal(_))));
1336 }
1337
1338 #[test]
1339 fn sample_path_accepts_common_extensions() {
1340 let (_dir, _db, store) = setup();
1341 let hash = "a".repeat(64);
1342 for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] {
1343 assert!(
1344 store.sample_path(&hash, ext).is_ok(),
1345 "rejected valid ext: {ext}"
1346 );
1347 }
1348 }
1349
1350 #[test]
1351 fn query_sample_field_rejects_disallowed_field() {
1352 let (_dir, db, _store) = setup();
1353 let hash = "a".repeat(64);
1354 let result = query_sample_field(&db, &hash, "hash; DROP TABLE samples --");
1355 assert!(matches!(result, Err(CoreError::Internal(_))));
1356 }
1357
1358 #[test]
1359 fn verify_sample_matches_after_import() {
1360 let (dir, db, store) = setup();
1361 let src = create_test_file(&dir, "hihat.wav", b"hihat audio data");
1362
1363 let hash = store.import(&src, &db).unwrap();
1364 assert!(store.verify_sample(&hash, "wav").unwrap());
1365 }
1366
1367 #[test]
1368 fn verify_sample_detects_corruption() {
1369 let (dir, db, store) = setup();
1370 let src = create_test_file(&dir, "snare.wav", b"original data");
1371
1372 let hash = store.import(&src, &db).unwrap();
1373
1374 // Corrupt the stored file. Store blobs are written read-only (the
1375 // mirror write-through guard), so clear that first to simulate bit-rot.
1376 let stored_path = store.sample_path(&hash, "wav").unwrap();
1377 let mut perms = fs::metadata(&stored_path).unwrap().permissions();
1378 // Intentionally clear read-only to simulate bit-rot on a stored blob.
1379 #[allow(clippy::permissions_set_readonly_false)]
1380 perms.set_readonly(false);
1381 fs::set_permissions(&stored_path, perms).unwrap();
1382 fs::write(&stored_path, b"corrupted data").unwrap();
1383
1384 assert!(!store.verify_sample(&hash, "wav").unwrap());
1385 }
1386
1387 #[test]
1388 fn verify_sample_errors_on_missing_file() {
1389 let (_dir, _db, store) = setup();
1390 let fake_hash = "b".repeat(64);
1391 let result = store.verify_sample(&fake_hash, "wav");
1392 assert!(matches!(result, Err(CoreError::Io { .. })));
1393 }
1394
1395 #[test]
1396 fn import_hashed_rejects_hash_that_does_not_match_bytes() {
1397 // Simulates the source file mutating between the parallel pre-hash pass
1398 // and the serial copy: import_hashed is handed a hash that does not
1399 // describe the bytes now on disk. The copy must be rejected, never
1400 // committed to `{wrong_hash}.ext`.
1401 let (dir, db, store) = setup();
1402 let src = create_test_file(&dir, "kick.wav", b"the actual bytes on disk");
1403 let wrong_hash = hex::encode(Sha256::digest(b"what we hashed earlier"));
1404
1405 let result = store.import_hashed(&src, &wrong_hash, 24, &db);
1406 assert!(
1407 matches!(result, Err(CoreError::HashMismatch(_))),
1408 "expected HashMismatch, got: {result:?}"
1409 );
1410
1411 // No blob may exist at the wrong content address, and no row inserted.
1412 assert!(!store.sample_path(&wrong_hash, "wav").unwrap().exists());
1413 let count: i64 = db
1414 .conn()
1415 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1416 .unwrap();
1417 assert_eq!(count, 0);
1418 // And no temp file leaked in the store directory.
1419 let leaked = fs::read_dir(store.root())
1420 .into_iter()
1421 .flatten()
1422 .flatten()
1423 .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
1424 assert!(!leaked, "a .tmp file leaked after the rejected import");
1425 }
1426
1427 #[test]
1428 fn import_hashed_accepts_matching_hash() {
1429 let (dir, db, store) = setup();
1430 let src = create_test_file(&dir, "clap.wav", b"clap bytes");
1431 let (hash, size) = hash_file(&src).unwrap();
1432 store.import_hashed(&src, &hash, size, &db).unwrap();
1433 assert!(store.verify_sample(&hash, "wav").unwrap());
1434 }
1435
1436 #[test]
1437 fn scrub_passes_a_clean_store() {
1438 let (dir, db, store) = setup();
1439 let a = store
1440 .import(&create_test_file(&dir, "a.wav", b"aaaa"), &db)
1441 .unwrap();
1442 let b = store
1443 .import(&create_test_file(&dir, "b.wav", b"bbbb"), &db)
1444 .unwrap();
1445 assert_ne!(a, b);
1446
1447 let (checked, corrupt) = store.scrub(&db).unwrap();
1448 assert_eq!(checked, 2);
1449 assert!(corrupt.is_empty());
1450 }
1451
1452 #[test]
1453 fn scrub_reports_a_corrupt_blob() {
1454 let (dir, db, store) = setup();
1455 store
1456 .import(&create_test_file(&dir, "good.wav", b"good"), &db)
1457 .unwrap();
1458 let bad = store
1459 .import(&create_test_file(&dir, "bad.wav", b"original"), &db)
1460 .unwrap();
1461
1462 // Corrupt the stored blob in place (clear read-only first, as the store
1463 // marks canonical blobs read-only).
1464 let path = store.sample_path(&bad, "wav").unwrap();
1465 let mut perms = fs::metadata(&path).unwrap().permissions();
1466 #[allow(clippy::permissions_set_readonly_false)]
1467 perms.set_readonly(false);
1468 fs::set_permissions(&path, perms).unwrap();
1469 fs::write(&path, b"tampered").unwrap();
1470
1471 let (checked, corrupt) = store.scrub(&db).unwrap();
1472 assert_eq!(checked, 2);
1473 assert_eq!(corrupt, vec![bad]);
1474 }
1475
1476 #[test]
1477 fn scrub_flags_a_missing_blob_as_corrupt() {
1478 let (dir, db, store) = setup();
1479 let hash = store
1480 .import(&create_test_file(&dir, "gone.wav", b"here now"), &db)
1481 .unwrap();
1482 let path = store.sample_path(&hash, "wav").unwrap();
1483 let mut perms = fs::metadata(&path).unwrap().permissions();
1484 #[allow(clippy::permissions_set_readonly_false)]
1485 perms.set_readonly(false);
1486 fs::set_permissions(&path, perms).unwrap();
1487 fs::remove_file(&path).unwrap();
1488
1489 let (checked, corrupt) = store.scrub(&db).unwrap();
1490 assert_eq!(checked, 1);
1491 assert_eq!(corrupt, vec![hash]);
1492 }
1493
1494 #[test]
1495 fn import_rejects_zero_byte_file() {
1496 let (dir, db, store) = setup();
1497 let src = create_test_file(&dir, "empty.wav", b"");
1498
1499 let result = store.import(&src, &db);
1500 assert!(result.is_err());
1501 let err_msg = format!("{}", result.unwrap_err());
1502 assert!(
1503 err_msg.contains("zero-byte"),
1504 "expected zero-byte error, got: {err_msg}"
1505 );
1506
1507 // No row should have been inserted
1508 let count: i64 = db
1509 .conn()
1510 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1511 .unwrap();
1512 assert_eq!(count, 0);
1513 }
1514
1515 #[test]
1516 fn import_accepts_non_empty_file() {
1517 let (dir, db, store) = setup();
1518 let src = create_test_file(&dir, "valid.wav", b"audio content");
1519
1520 let hash = store.import(&src, &db).unwrap();
1521 assert!(!hash.is_empty());
1522 assert!(store.exists(&hash, "wav").unwrap());
1523 }
1524
1525 #[test]
1526 fn remove_tolerates_missing_file() {
1527 // If the blob has already been deleted out from under us (manual rm,
1528 // crash mid-remove, etc.), the DB row should still be cleaned up.
1529 let (dir, db, store) = setup();
1530 let src = create_test_file(&dir, "ghost.wav", b"ghost data");
1531 let hash = store.import(&src, &db).unwrap();
1532 let stored = store.sample_path(&hash, "wav").unwrap();
1533 fs::remove_file(&stored).unwrap();
1534
1535 store.remove(&hash, &db).unwrap();
1536
1537 let count: i64 = db
1538 .conn()
1539 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1540 .unwrap();
1541 assert_eq!(count, 0);
1542 }
1543
1544 #[test]
1545 fn remove_deletes_file_before_db_row() {
1546 // Verify that after remove(), both the DB row and the file are gone.
1547 // The ordering guarantee (file first, then DB row) means a dangling DB
1548 // row is the only possible failure mode, never an orphaned blob.
1549 let (dir, db, store) = setup();
1550 let src = create_test_file(&dir, "tom.wav", b"tom data");
1551
1552 let hash = store.import(&src, &db).unwrap();
1553 let stored_path = store.sample_path(&hash, "wav").unwrap();
1554 assert!(stored_path.exists());
1555
1556 store.remove(&hash, &db).unwrap();
1557
1558 // DB row gone
1559 let count: i64 = db
1560 .conn()
1561 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1562 .unwrap();
1563 assert_eq!(count, 0);
1564
1565 // File gone
1566 assert!(!stored_path.exists());
1567 }
1568
1569 #[test]
1570 fn remove_orphaned_samples_cleans_unreferenced() {
1571 let (dir, db, store) = setup();
1572 let src1 = create_test_file(&dir, "kick.wav", b"kick data");
1573 let src2 = create_test_file(&dir, "snare.wav", b"snare data");
1574
1575 let hash1 = store.import(&src1, &db).unwrap();
1576 let hash2 = store.import(&src2, &db).unwrap();
1577
1578 // Create a VFS and link only hash1
1579 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
1580 crate::vfs::create_sample_link(&db, vfs_id, None, "kick.wav", &hash1).unwrap();
1581
1582 // hash2 is orphaned (no VFS node), hash1 is referenced
1583 let removed = store.remove_orphaned_samples(&db).unwrap();
1584 assert_eq!(removed, 1);
1585
1586 // hash1 still exists, hash2 is gone
1587 assert!(store.exists(&hash1, "wav").unwrap());
1588 assert!(!store.exists(&hash2, "wav").unwrap());
1589
1590 let count: i64 = db
1591 .conn()
1592 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1593 .unwrap();
1594 assert_eq!(count, 1);
1595 }
1596
1597 #[test]
1598 fn imported_blob_is_read_only() {
1599 let (dir, db, store) = setup();
1600 let src = create_test_file(&dir, "kick.wav", b"kick data");
1601 let hash = store.import(&src, &db).unwrap();
1602 let path = store.sample_path(&hash, "wav").unwrap();
1603 assert!(
1604 fs::metadata(&path).unwrap().permissions().readonly(),
1605 "store blobs must be read-only so a mirror write-through fails loudly"
1606 );
1607 }
1608
1609 #[test]
1610 fn orphan_cleanup_does_not_push_sync_delete() {
1611 let (dir, db, store) = setup();
1612 let src1 = create_test_file(&dir, "kick.wav", b"kick data");
1613 let src2 = create_test_file(&dir, "snare.wav", b"snare data");
1614 let hash1 = store.import(&src1, &db).unwrap();
1615 let _hash2 = store.import(&src2, &db).unwrap();
1616
1617 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
1618 crate::vfs::create_sample_link(&db, vfs_id, None, "kick.wav", &hash1).unwrap();
1619
1620 // Clear anything import/link logged, then GC the orphan (hash2).
1621 db.conn().execute("DELETE FROM sync_changelog", []).unwrap();
1622 let removed = store.remove_orphaned_samples(&db).unwrap();
1623 assert_eq!(removed, 1);
1624
1625 // Local GC must never push a destructive `samples` DELETE: that would
1626 // cascade-wipe another device's placements of the same blob.
1627 let pushed: i64 = db
1628 .conn()
1629 .query_row(
1630 "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'samples' AND op = 'DELETE'",
1631 [],
1632 |r| r.get(0),
1633 )
1634 .unwrap();
1635 assert_eq!(
1636 pushed, 0,
1637 "orphan cleanup must be local-only (sync suppressed)"
1638 );
1639
1640 // And the applying_remote flag is left cleared.
1641 let flag: String = db
1642 .conn()
1643 .query_row(
1644 "SELECT value FROM sync_state WHERE key = 'applying_remote'",
1645 [],
1646 |r| r.get(0),
1647 )
1648 .unwrap();
1649 assert_eq!(flag, "0");
1650 }
1651
1652 #[test]
1653 fn remove_orphaned_samples_keeps_referenced() {
1654 let (dir, db, store) = setup();
1655 let src = create_test_file(&dir, "hat.wav", b"hat data");
1656 let hash = store.import(&src, &db).unwrap();
1657
1658 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
1659 crate::vfs::create_sample_link(&db, vfs_id, None, "hat.wav", &hash).unwrap();
1660
1661 let removed = store.remove_orphaned_samples(&db).unwrap();
1662 assert_eq!(removed, 0);
1663 assert!(store.exists(&hash, "wav").unwrap());
1664 }
1665
1666 #[test]
1667 fn remove_orphaned_after_vfs_delete() {
1668 let (dir, db, store) = setup();
1669 let src = create_test_file(&dir, "clap.wav", b"clap data");
1670 let hash = store.import(&src, &db).unwrap();
1671
1672 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
1673 crate::vfs::create_sample_link(&db, vfs_id, None, "clap.wav", &hash).unwrap();
1674
1675 // Delete the VFS (cascades to vfs_nodes)
1676 crate::vfs::delete_vfs(&db, vfs_id).unwrap();
1677
1678 // Sample is now orphaned
1679 let removed = store.remove_orphaned_samples(&db).unwrap();
1680 assert_eq!(removed, 1);
1681 assert!(!store.exists(&hash, "wav").unwrap());
1682 }
1683
1684 // --- Loose-files mode tests ---
1685
1686 #[test]
1687 fn import_loose_files_does_not_copy_file() {
1688 let (dir, db, store) = setup();
1689 let src = create_test_file(&dir, "kick.wav", b"unsafe kick data");
1690
1691 let hash = store.import_loose_files(&src, &db).unwrap();
1692
1693 // No file in the store
1694 assert!(!store.exists(&hash, "wav").unwrap());
1695
1696 // Row exists in DB with source_path set
1697 let sp: Option<String> = db
1698 .conn()
1699 .query_row(
1700 "SELECT source_path FROM samples WHERE hash = ?1",
1701 [&hash],
1702 |row| row.get(0),
1703 )
1704 .unwrap();
1705 assert!(sp.is_some());
1706 assert!(sp.unwrap().ends_with("kick.wav"));
1707 }
1708
1709 #[test]
1710 fn import_loose_files_deduplicates() {
1711 let (dir, db, store) = setup();
1712 let src = create_test_file(&dir, "kick.wav", b"same unsafe content");
1713
1714 let hash1 = store.import_loose_files(&src, &db).unwrap();
1715 let hash2 = store.import_loose_files(&src, &db).unwrap();
1716 assert_eq!(hash1, hash2);
1717
1718 let count: i64 = db
1719 .conn()
1720 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1721 .unwrap();
1722 assert_eq!(count, 1);
1723 }
1724
1725 #[test]
1726 fn sample_source_path_returns_none_for_normal() {
1727 let (dir, db, store) = setup();
1728 let src = create_test_file(&dir, "kick.wav", b"normal import");
1729 let hash = store.import(&src, &db).unwrap();
1730
1731 assert!(sample_source_path(&db, &hash).unwrap().is_none());
1732 }
1733
1734 #[test]
1735 fn sample_source_path_returns_path_for_loose_files() {
1736 let (dir, db, store) = setup();
1737 let src = create_test_file(&dir, "kick.wav", b"unsafe import");
1738 let hash = store.import_loose_files(&src, &db).unwrap();
1739
1740 let sp = sample_source_path(&db, &hash).unwrap();
1741 assert!(sp.is_some());
1742 }
1743
1744 #[test]
1745 fn resolve_file_path_prefers_source_path() {
1746 let (dir, db, store) = setup();
1747 let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test");
1748 let hash = store.import_loose_files(&src, &db).unwrap();
1749
1750 let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
1751 // Should resolve to the original file, not the store
1752 assert!(!resolved.starts_with(store.root()));
1753 }
1754
1755 #[test]
1756 fn resolve_file_path_falls_back_to_store() {
1757 let (dir, db, store) = setup();
1758 let src = create_test_file(&dir, "kick.wav", b"fallback test");
1759
1760 // Import normally (file exists in store)
1761 let hash = store.import(&src, &db).unwrap();
1762
1763 let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap();
1764 assert!(resolved.starts_with(store.root()));
1765 }
1766
1767 #[test]
1768 fn relocate_sample_rejects_hash_mismatch() {
1769 let (dir, db, store) = setup();
1770 let src = create_test_file(&dir, "kick.wav", b"original content");
1771 let hash = store.import_loose_files(&src, &db).unwrap();
1772
1773 let wrong_file = create_test_file(&dir, "snare.wav", b"different content");
1774 let result = relocate_sample(&store, &db, &hash, &wrong_file);
1775 assert!(result.is_err());
1776 assert!(result.unwrap_err().to_string().contains("hash mismatch"));
1777 }
1778
1779 #[test]
1780 fn relocate_sample_updates_source_path() {
1781 let (dir, db, store) = setup();
1782 let src = create_test_file(&dir, "kick.wav", b"relocate content");
1783 let hash = store.import_loose_files(&src, &db).unwrap();
1784
1785 // Move the file
1786 let new_loc = dir.path().join("moved_kick.wav");
1787 fs::copy(&src, &new_loc).unwrap();
1788
1789 relocate_sample(&store, &db, &hash, &new_loc).unwrap();
1790
1791 let sp = sample_source_path(&db, &hash).unwrap().unwrap();
1792 assert!(sp.contains("moved_kick.wav"));
1793 }
1794
1795 #[test]
1796 fn check_loose_files_integrity_counts_correctly() {
1797 let (dir, db, store) = setup();
1798 let src1 = create_test_file(&dir, "kick.wav", b"integrity kick");
1799 let src2 = create_test_file(&dir, "snare.wav", b"integrity snare");
1800
1801 store.import_loose_files(&src1, &db).unwrap();
1802 let hash2 = store.import_loose_files(&src2, &db).unwrap();
1803
1804 // Delete snare from disk to simulate missing file
1805 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
1806 fs::remove_file(&sp).unwrap();
1807
1808 let (valid, missing) = check_loose_files_integrity(&db).unwrap();
1809 assert_eq!(valid, 1);
1810 assert_eq!(missing, 1);
1811 }
1812
1813 #[test]
1814 fn purge_missing_loose_files_removes_only_missing() {
1815 let (dir, db, store) = setup();
1816 let src1 = create_test_file(&dir, "kick.wav", b"purge kick");
1817 let src2 = create_test_file(&dir, "snare.wav", b"purge snare");
1818
1819 let hash1 = store.import_loose_files(&src1, &db).unwrap();
1820 let hash2 = store.import_loose_files(&src2, &db).unwrap();
1821
1822 // Delete snare from disk
1823 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
1824 fs::remove_file(&sp).unwrap();
1825
1826 let purged = purge_missing_loose_files(&db).unwrap();
1827 assert_eq!(purged, 1);
1828
1829 // kick still exists, snare is gone
1830 assert!(sample_source_path(&db, &hash1).is_ok());
1831 assert!(matches!(
1832 sample_source_path(&db, &hash2),
1833 Err(CoreError::SampleNotFound(_))
1834 ));
1835 }
1836
1837 #[test]
1838 fn purge_missing_loose_files_noop_when_all_valid() {
1839 let (dir, db, store) = setup();
1840 let src = create_test_file(&dir, "kick.wav", b"all valid");
1841 store.import_loose_files(&src, &db).unwrap();
1842
1843 let purged = purge_missing_loose_files(&db).unwrap();
1844 assert_eq!(purged, 0);
1845 }
1846
1847 #[test]
1848 fn relocate_missing_finds_moved_file_by_hash() {
1849 let (dir, db, store) = setup();
1850 let src = create_test_file(&dir, "kick.wav", b"moved-away content");
1851 let hash = store.import_loose_files(&src, &db).unwrap();
1852
1853 // Move the source into a subdirectory and delete the original path.
1854 let subdir = dir.path().join("relocated");
1855 fs::create_dir_all(&subdir).unwrap();
1856 let moved = subdir.join("kick.wav");
1857 fs::rename(&src, &moved).unwrap();
1858 assert_eq!(check_loose_files_integrity(&db).unwrap(), (0, 1));
1859
1860 let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap();
1861 assert_eq!(relocated, 1);
1862 assert_eq!(still_missing, 0);
1863
1864 // source_path now points at the moved file, and integrity is restored.
1865 let sp = sample_source_path(&db, &hash).unwrap().unwrap();
1866 assert!(sp.contains("relocated"));
1867 assert_eq!(check_loose_files_integrity(&db).unwrap(), (1, 0));
1868 }
1869
1870 #[test]
1871 fn relocate_missing_reports_still_missing_when_absent() {
1872 let (dir, db, store) = setup();
1873 let src = create_test_file(&dir, "ghost.wav", b"gone forever");
1874 store.import_loose_files(&src, &db).unwrap();
1875 fs::remove_file(&src).unwrap();
1876
1877 // Search a fresh empty directory, nothing to find.
1878 let empty = dir.path().join("empty");
1879 fs::create_dir_all(&empty).unwrap();
1880 let (relocated, still_missing) = relocate_missing_loose_files(&db, &empty).unwrap();
1881 assert_eq!(relocated, 0);
1882 assert_eq!(still_missing, 1);
1883 }
1884
1885 #[test]
1886 fn relocate_missing_ignores_same_name_different_content() {
1887 let (dir, db, store) = setup();
1888 let src = create_test_file(&dir, "kick.wav", b"the real bytes");
1889 store.import_loose_files(&src, &db).unwrap();
1890 fs::remove_file(&src).unwrap();
1891
1892 // A decoy with the same basename but different content must NOT match
1893 // (hash verify guards against same-name collisions).
1894 let decoy_dir = dir.path().join("decoy");
1895 fs::create_dir_all(&decoy_dir).unwrap();
1896 fs::write(decoy_dir.join("kick.wav"), b"an impostor with other bytes").unwrap();
1897
1898 let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap();
1899 assert_eq!(relocated, 0);
1900 assert_eq!(still_missing, 1);
1901 }
1902 }
1903