Skip to main content

max / audiofiles

39.7 KB · 1263 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use std::io::Write;
5 use tempfile::TempDir;
6
7 fn setup() -> (TempDir, Database, SampleStore) {
8 let dir = TempDir::new().unwrap();
9 let db = Database::open_in_memory().unwrap();
10 let store_dir = dir.path().join("store");
11 let store = SampleStore::new(&store_dir).unwrap();
12 (dir, db, store)
13 }
14
15 fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf {
16 let path = dir.path().join(name);
17 let mut f = fs::File::create(&path).unwrap();
18 f.write_all(content).unwrap();
19 path
20 }
21
22 /// Count every file anywhere under `root`, shard directories included.
23 ///
24 /// Blob-count assertions must not stop at the root's own entries: under the
25 /// sharded layout that reads 0 whatever the store actually holds, which would
26 /// turn an orphan-detection test into one that cannot fail.
27 fn count_blobs_recursively(root: &Path) -> usize {
28 let Ok(entries) = fs::read_dir(root) else {
29 return 0;
30 };
31 entries
32 .filter_map(std::result::Result::ok)
33 .map(|e| {
34 let path = e.path();
35 if path.is_dir() {
36 count_blobs_recursively(&path)
37 } else {
38 1
39 }
40 })
41 .sum()
42 }
43
44 /// Give a sample one VFS placement, so CASCADE behaviour and placement
45 /// preservation are observable.
46 fn place_sample(db: &Database, hash: &str) {
47 db.conn()
48 .execute(
49 "INSERT OR IGNORE INTO vfs (id, name, created_at, modified_at) \
50 VALUES (1, 'Library', 0, 0)",
51 [],
52 )
53 .unwrap();
54 db.conn()
55 .execute(
56 "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \
57 VALUES (1, NULL, ?1, 'sample', ?1, 0)",
58 [hash],
59 )
60 .unwrap();
61 }
62
63 fn count(db: &Database, sql: &str, hash: &str) -> i64 {
64 db.conn().query_row(sql, [hash], |r| r.get(0)).unwrap()
65 }
66
67 #[test]
68 fn tombstone_hides_sample_and_undelete_restores() {
69 let (dir, db, store) = setup();
70 let hash = store
71 .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
72 .unwrap();
73
74 // Live: visible to the read path, absent from Trash.
75 assert!(sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())).is_ok());
76 assert!(tombstoned_samples(&db).unwrap().is_empty());
77
78 // Tombstone is a one-shot: the second call is a no-op.
79 assert!(tombstone_sample(&db, &hash).unwrap());
80 assert!(!tombstone_sample(&db, &hash).unwrap());
81
82 // Hidden from the read path, surfaced in Trash with a timestamp.
83 assert!(matches!(
84 sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())),
85 Err(CoreError::SampleNotFound(_))
86 ));
87 let trash = tombstoned_samples(&db).unwrap();
88 assert_eq!(trash.len(), 1);
89 assert_eq!(trash[0].hash, hash);
90 assert!(trash[0].deleted_at > 0);
91
92 // Undelete restores it; a second undelete is a no-op.
93 assert!(undelete_sample(&db, &hash).unwrap());
94 assert!(!undelete_sample(&db, &hash).unwrap());
95 assert!(sample_extension(&db, &crate::SampleHash::from_trusted(hash.clone())).is_ok());
96 assert!(tombstoned_samples(&db).unwrap().is_empty());
97 }
98
99 #[test]
100 fn tombstone_preserves_placements_and_blob() {
101 let (dir, db, store) = setup();
102 let hash = store
103 .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
104 .unwrap();
105 place_sample(&db, &hash);
106
107 assert!(tombstone_sample(&db, &hash).unwrap());
108
109 // The whole point of soft delete: placements and the blob survive so the
110 // user can recover everything.
111 assert_eq!(
112 count(
113 &db,
114 "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1",
115 &hash
116 ),
117 1
118 );
119 assert!(
120 store
121 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
122 .unwrap()
123 );
124 }
125
126 #[test]
127 fn remove_purges_tombstoned_row_and_cascades() {
128 let (dir, db, store) = setup();
129 let hash = store
130 .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db)
131 .unwrap();
132 place_sample(&db, &hash);
133 assert!(tombstone_sample(&db, &hash).unwrap());
134
135 // Permanent delete must work on a tombstoned row even though the
136 // filtered `sample_extension` would hide it, `remove` resolves the
137 // blob path unfiltered.
138 store
139 .remove(&crate::SampleHash::from_trusted(hash.clone()), &db)
140 .unwrap();
141
142 assert!(
143 !store
144 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
145 .unwrap()
146 );
147 assert_eq!(
148 count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &hash),
149 0
150 );
151 assert_eq!(
152 count(
153 &db,
154 "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1",
155 &hash
156 ),
157 0
158 );
159 }
160
161 #[test]
162 fn sweep_hard_deletes_only_expired_tombstones() {
163 let (dir, db, store) = setup();
164 let live = store
165 .import(&create_test_file(&dir, "live.wav", b"live audio"), &db)
166 .unwrap();
167 let fresh = store
168 .import(&create_test_file(&dir, "fresh.wav", b"fresh audio"), &db)
169 .unwrap();
170 let old = store
171 .import(&create_test_file(&dir, "old.wav", b"old audio data"), &db)
172 .unwrap();
173 place_sample(&db, &old);
174
175 // fresh: tombstoned just now. old: tombstoned beyond the 30-day window.
176 assert!(tombstone_sample(&db, &fresh).unwrap());
177 assert!(tombstone_sample(&db, &old).unwrap());
178 db.conn()
179 .execute(
180 "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2",
181 rusqlite::params![unix_now() - 31 * 86_400, old],
182 )
183 .unwrap();
184
185 let removed = store.sweep_expired_tombstones(&db).unwrap();
186 assert_eq!(removed, 1);
187
188 // old is gone (row, blob, and CASCADE'd placement); fresh + live stay.
189 assert!(
190 !store
191 .exists(&crate::SampleHash::from_trusted(old.clone()), "wav")
192 .unwrap()
193 );
194 assert_eq!(
195 count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &old),
196 0
197 );
198 assert_eq!(
199 count(
200 &db,
201 "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1",
202 &old
203 ),
204 0
205 );
206 assert_eq!(tombstoned_samples(&db).unwrap().len(), 1);
207 assert!(sample_extension(&db, &crate::SampleHash::from_trusted(live.clone())).is_ok());
208 assert!(
209 store
210 .exists(&crate::SampleHash::from_trusted(fresh.clone()), "wav")
211 .unwrap()
212 );
213 }
214
215 #[test]
216 fn sweep_respects_retain_days_config() {
217 let (dir, db, store) = setup();
218 let hash = store
219 .import(&create_test_file(&dir, "s.wav", b"some audio"), &db)
220 .unwrap();
221 assert!(tombstone_sample(&db, &hash).unwrap());
222 db.conn()
223 .execute(
224 "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2",
225 rusqlite::params![unix_now() - 5 * 86_400, hash],
226 )
227 .unwrap();
228
229 // 5 days old, default 30-day window: not yet expired.
230 assert_eq!(tombstone_retain_days(&db), 30);
231 assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 0);
232
233 // Shrink the window to 3 days: now it sweeps.
234 db.conn()
235 .execute(
236 "UPDATE user_config SET value = '3' WHERE key = 'sample_tombstone_retain_days'",
237 [],
238 )
239 .unwrap();
240 assert_eq!(tombstone_retain_days(&db), 3);
241 assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 1);
242 }
243
244 #[test]
245 fn clamp_original_name_caps_length_on_char_boundary() {
246 // Short names pass through untouched.
247 assert_eq!(clamp_original_name("kick.wav".to_string()), "kick.wav");
248
249 // Over-long ASCII is capped to 255 bytes.
250 let long = "a".repeat(1000);
251 assert_eq!(clamp_original_name(long).len(), 255);
252
253 // Multi-byte chars at the cap don't split mid-codepoint (valid UTF-8).
254 let multibyte = "é".repeat(200); // 400 bytes
255 let clamped = clamp_original_name(multibyte);
256 assert!(clamped.len() <= 255);
257 assert!(std::str::from_utf8(clamped.as_bytes()).is_ok());
258 }
259
260 #[test]
261 fn hash_file_matches_import_and_rejects_bad_input() {
262 let (dir, db, store) = setup();
263 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
264
265 // hash_file's digest equals the hash import() records.
266 let (hash, size) = hash_file(&src).unwrap();
267 assert_eq!(size, "fake audio data".len() as i64);
268 let imported = store.import(&src, &db).unwrap();
269 assert_eq!(hash, imported);
270
271 // Zero-byte and non-audio files are rejected (same guards as import).
272 let empty = create_test_file(&dir, "empty.wav", b"");
273 assert!(hash_file(&empty).is_err());
274 let txt = create_test_file(&dir, "notes.txt", b"hello");
275 assert!(hash_file(&txt).is_err());
276 }
277
278 #[test]
279 fn hash_file_matches_the_reference_sha256_digest() {
280 // The content address is the library's primary key, so the exact hex
281 // string a given byte sequence produces is a compatibility guarantee:
282 // change it and every stored path and database row stops resolving.
283 // Pinned against an independent SHA-256 of the same bytes, so a hasher
284 // or hex-encoding swap has to survive a known answer, not just agree
285 // with itself.
286 let (dir, _db, _store) = setup();
287 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
288 let (hash, _) = hash_file(&src).unwrap();
289 assert_eq!(
290 hash, "cec560f942befcb4e4a4d1161c5c03b3a787e2d525f650042641e62bf8773c69",
291 "SHA-256 of b\"fake audio data\", lowercase hex, no separators"
292 );
293 }
294
295 #[test]
296 fn import_hashed_matches_serial_import() {
297 let (dir, db, store) = setup();
298 let src = create_test_file(&dir, "snare.wav", b"some audio bytes");
299
300 // Pre-hash then record, the prehashed path must land the same blob + row
301 // as the all-in-one import().
302 let (hash, size) = hash_file(&src).unwrap();
303 store
304 .import_hashed(
305 &src,
306 &crate::SampleHash::from_trusted(hash.clone()),
307 size,
308 &db,
309 )
310 .unwrap();
311
312 assert!(
313 store
314 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
315 .unwrap()
316 );
317 let count: i64 = db
318 .conn()
319 .query_row(
320 "SELECT COUNT(*) FROM samples WHERE hash = ?1",
321 [&hash],
322 |r| r.get(0),
323 )
324 .unwrap();
325 assert_eq!(count, 1);
326 }
327
328 #[test]
329 fn hash_files_parallel_aligns_results() {
330 let (dir, _db, _store) = setup();
331 let a = create_test_file(&dir, "a.wav", b"aaaa");
332 let b = create_test_file(&dir, "b.wav", b"bbbbbb");
333 let bad = create_test_file(&dir, "z.txt", b"nope"); // non-audio -> Err
334
335 let results = hash_files_parallel(&[a.clone(), b.clone(), bad.clone()]);
336 assert_eq!(results.len(), 3);
337 // Aligned to input order; each Ok hash equals a direct hash_file call.
338 assert_eq!(results[0].as_ref().unwrap().0, hash_file(&a).unwrap().0);
339 assert_eq!(results[1].as_ref().unwrap().1, 6);
340 assert!(results[2].is_err());
341 }
342
343 #[test]
344 fn import_creates_file_and_row() {
345 let (dir, db, store) = setup();
346 let src = create_test_file(&dir, "kick.wav", b"fake audio data");
347
348 let hash = store.import(&src, &db).unwrap();
349
350 // File exists in store
351 assert!(
352 store
353 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
354 .unwrap()
355 );
356
357 // Row exists in DB
358 let count: i64 = db
359 .conn()
360 .query_row(
361 "SELECT COUNT(*) FROM samples WHERE hash = ?1",
362 [&hash],
363 |row| row.get(0),
364 )
365 .unwrap();
366 assert_eq!(count, 1);
367 }
368
369 /// The two entry points make opposite promises about the directory fsync,
370 /// and the split is the whole of the 27% the hoist bought: `import_hashed`
371 /// is the batch path and leaves its shard directory owing a flush, while
372 /// `import` is one-shot and owes nothing on return.
373 ///
374 /// Pins the bookkeeping rather than the fsync, which is not observable. The
375 /// regression it guards is a caller (or a future one) that loops
376 /// `import_hashed` and never flushes, or an `import` that quietly starts
377 /// deferring on behalf of callers that have no batch end.
378 #[test]
379 fn the_batch_path_defers_its_directory_fsync_and_the_one_shot_path_does_not() {
380 let (dir, db, store) = setup();
381
382 let batched = create_test_file(&dir, "batched.wav", b"batched bytes");
383 let (hash, size) = hash_file(&batched).unwrap();
384 let hash = crate::SampleHash::from_trusted(hash);
385 store.import_hashed(&batched, &hash, size, &db).unwrap();
386
387 assert_eq!(
388 store.pending_dirs().len(),
389 1,
390 "the batch path leaves its shard directory owing an fsync",
391 );
392 // Deferring the fsync must not defer the blob: it is renamed into place
393 // and readable now, which is why the batch end is soon enough.
394 assert!(store.exists(&hash, "wav").unwrap());
395
396 store.flush_dirs();
397 assert!(
398 store.pending_dirs().is_empty(),
399 "flush_dirs drains the set, so a second run does not re-sync the world",
400 );
401
402 let one_shot = create_test_file(&dir, "one_shot.wav", b"one-shot bytes");
403 store.import(&one_shot, &db).unwrap();
404 assert!(
405 store.pending_dirs().is_empty(),
406 "the one-shot path has no batch end to defer to, so it flushes itself",
407 );
408 }
409
410 #[test]
411 fn import_deduplicates() {
412 let (dir, db, store) = setup();
413 let src = create_test_file(&dir, "kick.wav", b"same content");
414
415 let hash1 = store.import(&src, &db).unwrap();
416 let hash2 = store.import(&src, &db).unwrap();
417
418 assert_eq!(hash1, hash2);
419
420 // Only one row
421 let count: i64 = db
422 .conn()
423 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
424 .unwrap();
425 assert_eq!(count, 1);
426 }
427
428 #[test]
429 fn import_same_bytes_different_extension_reuses_blob() {
430 let (dir, db, store) = setup();
431 // Identical bytes, two different audio extensions (is_audio_file keys on
432 // extension, so the content can be arbitrary).
433 let wav = create_test_file(&dir, "loop.wav", b"identical bytes");
434 let aiff = create_test_file(&dir, "loop.aiff", b"identical bytes");
435
436 let h1 = store.import(&wav, &db).unwrap();
437 let h2 = store.import(&aiff, &db).unwrap();
438 assert_eq!(h1, h2, "identical bytes hash to the same sample");
439
440 // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not
441 // write an unreachable `{hash}.aiff` orphan. Counted recursively, because
442 // blobs live under a shard directory now; counting only the root's own files
443 // would read 0 here and pass this test vacuously for the wrong reason.
444 let blob_count = || count_blobs_recursively(store.root());
445 assert_eq!(
446 blob_count(),
447 1,
448 "second extension must reuse the first blob"
449 );
450
451 // And remove() leaves nothing behind, the orphan would otherwise survive,
452 // since remove() resolves the blob path from the DB row's extension.
453 store
454 .remove(&crate::SampleHash::from_trusted(h1.clone()), &db)
455 .unwrap();
456 assert_eq!(blob_count(), 0, "no orphan blob remains after remove");
457 }
458
459 #[test]
460 fn import_repairs_a_truncated_preexisting_blob() {
461 // Reproduces the corrupt-blob trap: a crash mid-copy (or any partial
462 // write) can leave a truncated file at the canonical content-addressed
463 // path. A content-addressed store must never trust it, import must
464 // detect the size mismatch and rewrite the correct bytes.
465 let (dir, db, store) = setup();
466 let content = b"the genuine full sample payload";
467 let src = create_test_file(&dir, "kick.wav", content);
468
469 // Pre-place a truncated blob at the canonical path the real import targets.
470 let hash = hex::encode(Sha256::digest(content));
471 let dest = store
472 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
473 .unwrap();
474 fs::create_dir_all(dest.parent().unwrap()).unwrap();
475 fs::write(&dest, b"trunc").unwrap();
476
477 let imported = store.import(&src, &db).unwrap();
478 assert_eq!(imported, hash);
479
480 // The stored blob now matches the source bytes exactly (hash verified).
481 let stored = fs::read(&dest).unwrap();
482 assert_eq!(stored, content, "truncated blob must be repaired on import");
483 assert_eq!(
484 hex::encode(Sha256::digest(&stored)),
485 hash,
486 "repaired blob hashes back to its content address"
487 );
488 }
489
490 #[test]
491 fn remove_deletes_file_and_row() {
492 let (dir, db, store) = setup();
493 let src = create_test_file(&dir, "snare.wav", b"snare data");
494
495 let hash = store.import(&src, &db).unwrap();
496 assert!(
497 store
498 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
499 .unwrap()
500 );
501
502 store
503 .remove(&crate::SampleHash::from_trusted(hash.clone()), &db)
504 .unwrap();
505
506 assert!(
507 !store
508 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
509 .unwrap()
510 );
511 let count: i64 = db
512 .conn()
513 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
514 .unwrap();
515 assert_eq!(count, 0);
516 }
517
518 #[test]
519 fn remove_nonexistent_returns_error() {
520 let (_dir, db, store) = setup();
521 // Use a valid 64-char hex hash that doesn't exist in the DB
522 let fake_hash = "a".repeat(64);
523 let result = store.remove(&crate::SampleHash::from_trusted(fake_hash.clone()), &db);
524 assert!(matches!(result, Err(CoreError::SampleNotFound(_))));
525 }
526
527 #[test]
528 fn sample_path_rejects_traversal() {
529 let (_dir, _db, store) = setup();
530 let result = store.sample_path(
531 &crate::SampleHash::from_trusted("../../../etc/passwd"),
532 "wav",
533 );
534 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
535 }
536
537 #[test]
538 fn sample_path_rejects_short_hash() {
539 let (_dir, _db, store) = setup();
540 let result = store.sample_path(&crate::SampleHash::from_trusted("abcdef"), "wav");
541 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
542 }
543
544 #[test]
545 fn sample_path_rejects_uppercase() {
546 let (_dir, _db, store) = setup();
547 let hash = "A".repeat(64);
548 let result = store.sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav");
549 assert!(matches!(result, Err(CoreError::HashInvalid(_))));
550 }
551
552 #[test]
553 fn sample_path_accepts_valid_hash() {
554 let (_dir, _db, store) = setup();
555 let hash = "a1b2c3d4e5f6".to_string() + &"0".repeat(52);
556 let path = store
557 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
558 .unwrap();
559 assert!(path.to_string_lossy().ends_with(".wav"));
560 }
561
562 #[test]
563 fn sample_path_rejects_traversal_in_extension() {
564 let (_dir, _db, store) = setup();
565 let hash = "a".repeat(64);
566 let result = store.sample_path(
567 &crate::SampleHash::from_trusted(hash.clone()),
568 "../etc/passwd",
569 );
570 assert!(matches!(result, Err(CoreError::Internal(_))));
571 }
572
573 #[test]
574 fn sample_path_rejects_path_separator_in_extension() {
575 let (_dir, _db, store) = setup();
576 let hash = "a".repeat(64);
577 let result = store.sample_path(
578 &crate::SampleHash::from_trusted(hash.clone()),
579 "wav/../../etc",
580 );
581 assert!(matches!(result, Err(CoreError::Internal(_))));
582 }
583
584 #[test]
585 fn sample_path_accepts_common_extensions() {
586 let (_dir, _db, store) = setup();
587 let hash = "a".repeat(64);
588 for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] {
589 assert!(
590 store
591 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), ext)
592 .is_ok(),
593 "rejected valid ext: {ext}"
594 );
595 }
596 }
597
598 #[test]
599 fn query_sample_field_rejects_disallowed_field() {
600 let (_dir, db, _store) = setup();
601 let hash = "a".repeat(64);
602 let result = query_sample_field(
603 &db,
604 &crate::SampleHash::from_trusted(hash.clone()),
605 "hash; DROP TABLE samples --",
606 );
607 assert!(matches!(result, Err(CoreError::Internal(_))));
608 }
609
610 #[test]
611 fn verify_sample_matches_after_import() {
612 let (dir, db, store) = setup();
613 let src = create_test_file(&dir, "hihat.wav", b"hihat audio data");
614
615 let hash = store.import(&src, &db).unwrap();
616 assert!(
617 store
618 .verify_sample(&crate::SampleHash::from_trusted(hash.clone()), "wav")
619 .unwrap()
620 );
621 }
622
623 #[test]
624 fn verify_sample_detects_corruption() {
625 let (dir, db, store) = setup();
626 let src = create_test_file(&dir, "snare.wav", b"original data");
627
628 let hash = store.import(&src, &db).unwrap();
629
630 // Corrupt the stored file. Store blobs are written read-only (the
631 // mirror write-through guard), so clear that first to simulate bit-rot.
632 let stored_path = store
633 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
634 .unwrap();
635 let mut perms = fs::metadata(&stored_path).unwrap().permissions();
636 // Intentionally clear read-only to simulate bit-rot on a stored blob.
637 #[allow(clippy::permissions_set_readonly_false)]
638 perms.set_readonly(false);
639 fs::set_permissions(&stored_path, perms).unwrap();
640 fs::write(&stored_path, b"corrupted data").unwrap();
641
642 assert!(
643 !store
644 .verify_sample(&crate::SampleHash::from_trusted(hash.clone()), "wav")
645 .unwrap()
646 );
647 }
648
649 #[test]
650 fn verify_sample_errors_on_missing_file() {
651 let (_dir, _db, store) = setup();
652 let fake_hash = "b".repeat(64);
653 let result = store.verify_sample(&crate::SampleHash::from_trusted(fake_hash.clone()), "wav");
654 assert!(matches!(result, Err(CoreError::Io { .. })));
655 }
656
657 #[test]
658 fn import_hashed_rejects_hash_that_does_not_match_bytes() {
659 // Simulates the source file mutating between the parallel pre-hash pass
660 // and the serial copy: import_hashed is handed a hash that does not
661 // describe the bytes now on disk. The copy must be rejected, never
662 // committed to `{wrong_hash}.ext`.
663 let (dir, db, store) = setup();
664 let src = create_test_file(&dir, "kick.wav", b"the actual bytes on disk");
665 let wrong_hash = hex::encode(Sha256::digest(b"what we hashed earlier"));
666
667 let result = store.import_hashed(
668 &src,
669 &crate::SampleHash::from_trusted(wrong_hash.clone()),
670 24,
671 &db,
672 );
673 assert!(
674 matches!(result, Err(CoreError::HashMismatch(_))),
675 "expected HashMismatch, got: {result:?}"
676 );
677
678 // No blob may exist at the wrong content address, and no row inserted.
679 assert!(
680 !store
681 .sample_path(&crate::SampleHash::from_trusted(wrong_hash.clone()), "wav")
682 .unwrap()
683 .exists()
684 );
685 let count: i64 = db
686 .conn()
687 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
688 .unwrap();
689 assert_eq!(count, 0);
690 // And no temp file leaked in the store directory.
691 let leaked = fs::read_dir(store.root())
692 .into_iter()
693 .flatten()
694 .flatten()
695 .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
696 assert!(!leaked, "a .tmp file leaked after the rejected import");
697 }
698
699 #[test]
700 fn import_hashed_accepts_matching_hash() {
701 let (dir, db, store) = setup();
702 let src = create_test_file(&dir, "clap.wav", b"clap bytes");
703 let (hash, size) = hash_file(&src).unwrap();
704 store
705 .import_hashed(
706 &src,
707 &crate::SampleHash::from_trusted(hash.clone()),
708 size,
709 &db,
710 )
711 .unwrap();
712 assert!(
713 store
714 .verify_sample(&crate::SampleHash::from_trusted(hash.clone()), "wav")
715 .unwrap()
716 );
717 }
718
719 #[test]
720 fn scrub_passes_a_clean_store() {
721 let (dir, db, store) = setup();
722 let a = store
723 .import(&create_test_file(&dir, "a.wav", b"aaaa"), &db)
724 .unwrap();
725 let b = store
726 .import(&create_test_file(&dir, "b.wav", b"bbbb"), &db)
727 .unwrap();
728 assert_ne!(a, b);
729
730 let (checked, corrupt) = store.scrub(&db).unwrap();
731 assert_eq!(checked, 2);
732 assert!(corrupt.is_empty());
733 }
734
735 #[test]
736 fn scrub_reports_a_corrupt_blob() {
737 let (dir, db, store) = setup();
738 store
739 .import(&create_test_file(&dir, "good.wav", b"good"), &db)
740 .unwrap();
741 let bad = store
742 .import(&create_test_file(&dir, "bad.wav", b"original"), &db)
743 .unwrap();
744
745 // Corrupt the stored blob in place (clear read-only first, as the store
746 // marks canonical blobs read-only).
747 let path = store
748 .sample_path(&crate::SampleHash::from_trusted(bad.clone()), "wav")
749 .unwrap();
750 let mut perms = fs::metadata(&path).unwrap().permissions();
751 #[allow(clippy::permissions_set_readonly_false)]
752 perms.set_readonly(false);
753 fs::set_permissions(&path, perms).unwrap();
754 fs::write(&path, b"tampered").unwrap();
755
756 let (checked, corrupt) = store.scrub(&db).unwrap();
757 assert_eq!(checked, 2);
758 assert_eq!(corrupt, vec![bad]);
759 }
760
761 #[test]
762 fn scrub_flags_a_missing_blob_as_corrupt() {
763 let (dir, db, store) = setup();
764 let hash = store
765 .import(&create_test_file(&dir, "gone.wav", b"here now"), &db)
766 .unwrap();
767 let path = store
768 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
769 .unwrap();
770 let mut perms = fs::metadata(&path).unwrap().permissions();
771 #[allow(clippy::permissions_set_readonly_false)]
772 perms.set_readonly(false);
773 fs::set_permissions(&path, perms).unwrap();
774 fs::remove_file(&path).unwrap();
775
776 let (checked, corrupt) = store.scrub(&db).unwrap();
777 assert_eq!(checked, 1);
778 assert_eq!(corrupt, vec![hash]);
779 }
780
781 #[test]
782 fn import_rejects_zero_byte_file() {
783 let (dir, db, store) = setup();
784 let src = create_test_file(&dir, "empty.wav", b"");
785
786 let result = store.import(&src, &db);
787 assert!(result.is_err());
788 let err_msg = format!("{}", result.unwrap_err());
789 assert!(
790 err_msg.contains("zero-byte"),
791 "expected zero-byte error, got: {err_msg}"
792 );
793
794 // No row should have been inserted
795 let count: i64 = db
796 .conn()
797 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
798 .unwrap();
799 assert_eq!(count, 0);
800 }
801
802 #[test]
803 fn import_accepts_non_empty_file() {
804 let (dir, db, store) = setup();
805 let src = create_test_file(&dir, "valid.wav", b"audio content");
806
807 let hash = store.import(&src, &db).unwrap();
808 assert!(!hash.is_empty());
809 assert!(
810 store
811 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
812 .unwrap()
813 );
814 }
815
816 #[test]
817 fn remove_tolerates_missing_file() {
818 // If the blob has already been deleted out from under us (manual rm,
819 // crash mid-remove, etc.), the DB row should still be cleaned up.
820 let (dir, db, store) = setup();
821 let src = create_test_file(&dir, "ghost.wav", b"ghost data");
822 let hash = store.import(&src, &db).unwrap();
823 let stored = store
824 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
825 .unwrap();
826 fs::remove_file(&stored).unwrap();
827
828 store
829 .remove(&crate::SampleHash::from_trusted(hash.clone()), &db)
830 .unwrap();
831
832 let count: i64 = db
833 .conn()
834 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
835 .unwrap();
836 assert_eq!(count, 0);
837 }
838
839 #[test]
840 fn remove_deletes_file_before_db_row() {
841 // Verify that after remove(), both the DB row and the file are gone.
842 // The ordering guarantee (file first, then DB row) means a dangling DB
843 // row is the only possible failure mode, never an orphaned blob.
844 let (dir, db, store) = setup();
845 let src = create_test_file(&dir, "tom.wav", b"tom data");
846
847 let hash = store.import(&src, &db).unwrap();
848 let stored_path = store
849 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
850 .unwrap();
851 assert!(stored_path.exists());
852
853 store
854 .remove(&crate::SampleHash::from_trusted(hash.clone()), &db)
855 .unwrap();
856
857 // DB row gone
858 let count: i64 = db
859 .conn()
860 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
861 .unwrap();
862 assert_eq!(count, 0);
863
864 // File gone
865 assert!(!stored_path.exists());
866 }
867
868 #[test]
869 fn remove_orphaned_samples_cleans_unreferenced() {
870 let (dir, db, store) = setup();
871 let src1 = create_test_file(&dir, "kick.wav", b"kick data");
872 let src2 = create_test_file(&dir, "snare.wav", b"snare data");
873
874 let hash1 = store.import(&src1, &db).unwrap();
875 let hash2 = store.import(&src2, &db).unwrap();
876
877 // Create a VFS and link only hash1
878 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
879 crate::vfs::create_sample_link(
880 &db,
881 vfs_id,
882 None,
883 "kick.wav",
884 &crate::SampleHash::from_trusted(hash1.clone()),
885 )
886 .unwrap();
887
888 // hash2 is orphaned (no VFS node), hash1 is referenced
889 let removed = store.remove_orphaned_samples(&db).unwrap();
890 assert_eq!(removed, 1);
891
892 // hash1 still exists, hash2 is gone
893 assert!(
894 store
895 .exists(&crate::SampleHash::from_trusted(hash1.clone()), "wav")
896 .unwrap()
897 );
898 assert!(
899 !store
900 .exists(&crate::SampleHash::from_trusted(hash2.clone()), "wav")
901 .unwrap()
902 );
903
904 let count: i64 = db
905 .conn()
906 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
907 .unwrap();
908 assert_eq!(count, 1);
909 }
910
911 #[test]
912 fn imported_blob_is_read_only() {
913 let (dir, db, store) = setup();
914 let src = create_test_file(&dir, "kick.wav", b"kick data");
915 let hash = store.import(&src, &db).unwrap();
916 let path = store
917 .sample_path(&crate::SampleHash::from_trusted(hash.clone()), "wav")
918 .unwrap();
919 assert!(
920 fs::metadata(&path).unwrap().permissions().readonly(),
921 "store blobs must be read-only so a mirror write-through fails loudly"
922 );
923 }
924
925 #[test]
926 fn orphan_cleanup_does_not_push_sync_delete() {
927 let (dir, db, store) = setup();
928 let src1 = create_test_file(&dir, "kick.wav", b"kick data");
929 let src2 = create_test_file(&dir, "snare.wav", b"snare data");
930 let hash1 = store.import(&src1, &db).unwrap();
931 let _hash2 = store.import(&src2, &db).unwrap();
932
933 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
934 crate::vfs::create_sample_link(
935 &db,
936 vfs_id,
937 None,
938 "kick.wav",
939 &crate::SampleHash::from_trusted(hash1.clone()),
940 )
941 .unwrap();
942
943 // Clear anything import/link logged, then GC the orphan (hash2).
944 db.conn().execute("DELETE FROM sync_changelog", []).unwrap();
945 let removed = store.remove_orphaned_samples(&db).unwrap();
946 assert_eq!(removed, 1);
947
948 // Local GC must never push a destructive `samples` DELETE: that would
949 // cascade-wipe another device's placements of the same blob.
950 let pushed: i64 = db
951 .conn()
952 .query_row(
953 "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'samples' AND op = 'DELETE'",
954 [],
955 |r| r.get(0),
956 )
957 .unwrap();
958 assert_eq!(
959 pushed, 0,
960 "orphan cleanup must be local-only (sync suppressed)"
961 );
962
963 // And the applying_remote flag is left cleared.
964 let flag: String = db
965 .conn()
966 .query_row(
967 "SELECT value FROM sync_state WHERE key = 'applying_remote'",
968 [],
969 |r| r.get(0),
970 )
971 .unwrap();
972 assert_eq!(flag, "0");
973 }
974
975 #[test]
976 fn remove_orphaned_samples_keeps_referenced() {
977 let (dir, db, store) = setup();
978 let src = create_test_file(&dir, "hat.wav", b"hat data");
979 let hash = store.import(&src, &db).unwrap();
980
981 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
982 crate::vfs::create_sample_link(
983 &db,
984 vfs_id,
985 None,
986 "hat.wav",
987 &crate::SampleHash::from_trusted(hash.clone()),
988 )
989 .unwrap();
990
991 let removed = store.remove_orphaned_samples(&db).unwrap();
992 assert_eq!(removed, 0);
993 assert!(
994 store
995 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
996 .unwrap()
997 );
998 }
999
1000 #[test]
1001 fn remove_orphaned_after_vfs_delete() {
1002 let (dir, db, store) = setup();
1003 let src = create_test_file(&dir, "clap.wav", b"clap data");
1004 let hash = store.import(&src, &db).unwrap();
1005
1006 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap();
1007 crate::vfs::create_sample_link(
1008 &db,
1009 vfs_id,
1010 None,
1011 "clap.wav",
1012 &crate::SampleHash::from_trusted(hash.clone()),
1013 )
1014 .unwrap();
1015
1016 // Delete the VFS (cascades to vfs_nodes)
1017 crate::vfs::delete_vfs(&db, vfs_id).unwrap();
1018
1019 // Sample is now orphaned
1020 let removed = store.remove_orphaned_samples(&db).unwrap();
1021 assert_eq!(removed, 1);
1022 assert!(
1023 !store
1024 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
1025 .unwrap()
1026 );
1027 }
1028
1029 // --- Loose-files mode tests ---
1030
1031 #[test]
1032 fn import_loose_files_does_not_copy_file() {
1033 let (dir, db, store) = setup();
1034 let src = create_test_file(&dir, "kick.wav", b"unsafe kick data");
1035
1036 let hash = store.import_loose_files(&src, &db).unwrap();
1037
1038 // No file in the store
1039 assert!(
1040 !store
1041 .exists(&crate::SampleHash::from_trusted(hash.clone()), "wav")
1042 .unwrap()
1043 );
1044
1045 // Row exists in DB with source_path set
1046 let sp: Option<String> = db
1047 .conn()
1048 .query_row(
1049 "SELECT source_path FROM samples WHERE hash = ?1",
1050 [&hash],
1051 |row| row.get(0),
1052 )
1053 .unwrap();
1054 assert!(sp.is_some());
1055 assert!(sp.unwrap().ends_with("kick.wav"));
1056 }
1057
1058 #[test]
1059 fn import_loose_files_deduplicates() {
1060 let (dir, db, store) = setup();
1061 let src = create_test_file(&dir, "kick.wav", b"same unsafe content");
1062
1063 let hash1 = store.import_loose_files(&src, &db).unwrap();
1064 let hash2 = store.import_loose_files(&src, &db).unwrap();
1065 assert_eq!(hash1, hash2);
1066
1067 let count: i64 = db
1068 .conn()
1069 .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1070 .unwrap();
1071 assert_eq!(count, 1);
1072 }
1073
1074 #[test]
1075 fn sample_source_path_returns_none_for_normal() {
1076 let (dir, db, store) = setup();
1077 let src = create_test_file(&dir, "kick.wav", b"normal import");
1078 let hash = store.import(&src, &db).unwrap();
1079
1080 assert!(sample_source_path(&db, &hash).unwrap().is_none());
1081 }
1082
1083 #[test]
1084 fn sample_source_path_returns_path_for_loose_files() {
1085 let (dir, db, store) = setup();
1086 let src = create_test_file(&dir, "kick.wav", b"unsafe import");
1087 let hash = store.import_loose_files(&src, &db).unwrap();
1088
1089 let sp = sample_source_path(&db, &hash).unwrap();
1090 assert!(sp.is_some());
1091 }
1092
1093 #[test]
1094 fn resolve_file_path_prefers_source_path() {
1095 let (dir, db, store) = setup();
1096 let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test");
1097 let hash = store.import_loose_files(&src, &db).unwrap();
1098
1099 let resolved = resolve_file_path(
1100 &store,
1101 &db,
1102 &crate::SampleHash::from_trusted(hash.clone()),
1103 "wav",
1104 )
1105 .unwrap();
1106 // Should resolve to the original file, not the store
1107 assert!(!resolved.starts_with(store.root()));
1108 }
1109
1110 #[test]
1111 fn resolve_file_path_falls_back_to_store() {
1112 let (dir, db, store) = setup();
1113 let src = create_test_file(&dir, "kick.wav", b"fallback test");
1114
1115 // Import normally (file exists in store)
1116 let hash = store.import(&src, &db).unwrap();
1117
1118 let resolved = resolve_file_path(
1119 &store,
1120 &db,
1121 &crate::SampleHash::from_trusted(hash.clone()),
1122 "wav",
1123 )
1124 .unwrap();
1125 assert!(resolved.starts_with(store.root()));
1126 }
1127
1128 #[test]
1129 fn relocate_sample_rejects_hash_mismatch() {
1130 let (dir, db, store) = setup();
1131 let src = create_test_file(&dir, "kick.wav", b"original content");
1132 let hash = store.import_loose_files(&src, &db).unwrap();
1133
1134 let wrong_file = create_test_file(&dir, "snare.wav", b"different content");
1135 let result = relocate_sample(&store, &db, &hash, &wrong_file);
1136 assert!(result.is_err());
1137 assert!(result.unwrap_err().to_string().contains("hash mismatch"));
1138 }
1139
1140 #[test]
1141 fn relocate_sample_updates_source_path() {
1142 let (dir, db, store) = setup();
1143 let src = create_test_file(&dir, "kick.wav", b"relocate content");
1144 let hash = store.import_loose_files(&src, &db).unwrap();
1145
1146 // Move the file
1147 let new_loc = dir.path().join("moved_kick.wav");
1148 fs::copy(&src, &new_loc).unwrap();
1149
1150 relocate_sample(&store, &db, &hash, &new_loc).unwrap();
1151
1152 let sp = sample_source_path(&db, &hash).unwrap().unwrap();
1153 assert!(sp.contains("moved_kick.wav"));
1154 }
1155
1156 #[test]
1157 fn check_loose_files_integrity_counts_correctly() {
1158 let (dir, db, store) = setup();
1159 let src1 = create_test_file(&dir, "kick.wav", b"integrity kick");
1160 let src2 = create_test_file(&dir, "snare.wav", b"integrity snare");
1161
1162 store.import_loose_files(&src1, &db).unwrap();
1163 let hash2 = store.import_loose_files(&src2, &db).unwrap();
1164
1165 // Delete snare from disk to simulate missing file
1166 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
1167 fs::remove_file(&sp).unwrap();
1168
1169 let (valid, missing) = check_loose_files_integrity(&db).unwrap();
1170 assert_eq!(valid, 1);
1171 assert_eq!(missing, 1);
1172 }
1173
1174 #[test]
1175 fn purge_missing_loose_files_removes_only_missing() {
1176 let (dir, db, store) = setup();
1177 let src1 = create_test_file(&dir, "kick.wav", b"purge kick");
1178 let src2 = create_test_file(&dir, "snare.wav", b"purge snare");
1179
1180 let hash1 = store.import_loose_files(&src1, &db).unwrap();
1181 let hash2 = store.import_loose_files(&src2, &db).unwrap();
1182
1183 // Delete snare from disk
1184 let sp = sample_source_path(&db, &hash2).unwrap().unwrap();
1185 fs::remove_file(&sp).unwrap();
1186
1187 let purged = purge_missing_loose_files(&db).unwrap();
1188 assert_eq!(purged, 1);
1189
1190 // kick still exists, snare is gone
1191 assert!(sample_source_path(&db, &hash1).is_ok());
1192 assert!(matches!(
1193 sample_source_path(&db, &hash2),
1194 Err(CoreError::SampleNotFound(_))
1195 ));
1196 }
1197
1198 #[test]
1199 fn purge_missing_loose_files_noop_when_all_valid() {
1200 let (dir, db, store) = setup();
1201 let src = create_test_file(&dir, "kick.wav", b"all valid");
1202 store.import_loose_files(&src, &db).unwrap();
1203
1204 let purged = purge_missing_loose_files(&db).unwrap();
1205 assert_eq!(purged, 0);
1206 }
1207
1208 #[test]
1209 fn relocate_missing_finds_moved_file_by_hash() {
1210 let (dir, db, store) = setup();
1211 let src = create_test_file(&dir, "kick.wav", b"moved-away content");
1212 let hash = store.import_loose_files(&src, &db).unwrap();
1213
1214 // Move the source into a subdirectory and delete the original path.
1215 let subdir = dir.path().join("relocated");
1216 fs::create_dir_all(&subdir).unwrap();
1217 let moved = subdir.join("kick.wav");
1218 fs::rename(&src, &moved).unwrap();
1219 assert_eq!(check_loose_files_integrity(&db).unwrap(), (0, 1));
1220
1221 let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap();
1222 assert_eq!(relocated, 1);
1223 assert_eq!(still_missing, 0);
1224
1225 // source_path now points at the moved file, and integrity is restored.
1226 let sp = sample_source_path(&db, &hash).unwrap().unwrap();
1227 assert!(sp.contains("relocated"));
1228 assert_eq!(check_loose_files_integrity(&db).unwrap(), (1, 0));
1229 }
1230
1231 #[test]
1232 fn relocate_missing_reports_still_missing_when_absent() {
1233 let (dir, db, store) = setup();
1234 let src = create_test_file(&dir, "ghost.wav", b"gone forever");
1235 store.import_loose_files(&src, &db).unwrap();
1236 fs::remove_file(&src).unwrap();
1237
1238 // Search a fresh empty directory, nothing to find.
1239 let empty = dir.path().join("empty");
1240 fs::create_dir_all(&empty).unwrap();
1241 let (relocated, still_missing) = relocate_missing_loose_files(&db, &empty).unwrap();
1242 assert_eq!(relocated, 0);
1243 assert_eq!(still_missing, 1);
1244 }
1245
1246 #[test]
1247 fn relocate_missing_ignores_same_name_different_content() {
1248 let (dir, db, store) = setup();
1249 let src = create_test_file(&dir, "kick.wav", b"the real bytes");
1250 store.import_loose_files(&src, &db).unwrap();
1251 fs::remove_file(&src).unwrap();
1252
1253 // A decoy with the same basename but different content must NOT match
1254 // (hash verify guards against same-name collisions).
1255 let decoy_dir = dir.path().join("decoy");
1256 fs::create_dir_all(&decoy_dir).unwrap();
1257 fs::write(decoy_dir.join("kick.wav"), b"an impostor with other bytes").unwrap();
1258
1259 let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap();
1260 assert_eq!(relocated, 0);
1261 assert_eq!(still_missing, 1);
1262 }
1263