Skip to main content

max / audiofiles

Remediate fuzz-2026-07-06 risk findings Work the risk-lens fuzz findings in priority order. SERIOUS: - CAS write TOCTOU: import_hashed copies-and-hashes in one pass and rejects a content-address mismatch (new CoreError::HashMismatch), so a source mutated between the pre-hash pass and the serial copy can no longer land wrong bytes at {hash}.ext. Wire the previously dead verify_sample into a runtime scrub (SampleStore::scrub) exposed via the maintenance worker, backend start_store_verify, and a "Verify library integrity" Settings action. - Sync user_config import: apply the same sync_%/loose_files denylist on import (config_key_excluded_from_sync) that export enforces, so a hostile server cannot flip loose-files mode or inject sync-internal keys via apply_upsert/apply_delete. UI-freeze / availability: - Clear backfill_in_progress in run_analysis so a foreground import's results are not misrouted as a background backfill (B1). - Route CLI/"Open with", loose-file drops, and both "Import files..." menus through the import worker via a new ImportFiles command + import_file_list + backend start_file_import; no more per-file hashing on the GUI thread (B2/B3). - Guard apply_edit_trim against total_frames==0 / start>=end (B4). Sync / OTA / export: - apply_remote_changes skips-and-logs a constraint-violating remote row (is_constraint_violation) so one bad row cannot wedge pull_cursor forever; genuine infra errors still roll back. - Cap the OTA manifest body (read_capped_body: Content-Length + 64 KiB streamed cap) and truncate notes to 4 KiB. - Run plugin filename overrides through naming_rules + traversal-strip + dedup instead of returning early (F1). - Reject over-cap Original exports up front from source metadata instead of after the full write (F2). cargo test --workspace green, cargo clippy --workspace clean.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:45 UTC
Signed with PGP, not checked
Commit: 65498ed58e7618fd45ec7302637ef6bd4f92ee61
Parent: 3f0916f
16 files changed, +1017 insertions, -97 deletions
@@ -526,10 +526,34 @@
526 526 let sample_rate = shared.device_sample_rate.load(std::sync::atomic::Ordering::Relaxed) as f32;
527 527 match BrowserState::new(data_dir, shared, sample_rate, vault_name) {
528 528 Ok(mut browser) => {
529 - for arg in std::env::args().skip(1) {
530 - let path = PathBuf::from(&arg);
531 - if path.exists() {
532 - browser.import_path(&path);
529 + // CLI args / OS "Open with": route through the background import
530 + // worker, never the synchronous per-file path — the sync walk here
531 + // ran before the eframe loop started, so the window didn't appear
532 + // until the whole tree was hashed (fuzz-2026-07-06 B2).
533 + if let Some(vfs_id) = browser.current_vfs_id() {
534 + let parent_id = browser.nav.current_dir;
535 + let mut files = Vec::new();
536 + for arg in std::env::args().skip(1) {
537 + let path = PathBuf::from(&arg);
538 + if !path.exists() {
539 + continue;
540 + }
541 + if path.is_dir() {
542 + let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
543 + vfs_id,
544 + parent_id,
545 + };
546 + browser.start_folder_import(path, strategy);
547 + } else {
548 + files.push(path);
549 + }
550 + }
551 + if !files.is_empty() {
552 + let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
553 + vfs_id,
554 + parent_id,
555 + };
556 + browser.start_files_import(files, strategy);
533 557 }
534 558 }
535 559 (Some(browser), None)
@@ -819,17 +843,30 @@
819 843
820 844 if let Some(ref mut browser) = self.browser {
821 845 if let Some(vfs_id) = browser.current_vfs_id() {
846 + let parent_id = browser.nav.current_dir;
847 + // Loose files go to the worker as one batch — dropping hundreds
848 + // of files used to hash them synchronously on the frame thread
849 + // and freeze the window (fuzz-2026-07-06 B3). Directories keep
850 + // their structured (folder-preserving) import path.
851 + let mut files = Vec::new();
822 852 for path in dropped {
823 853 if path.is_dir() {
824 854 let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
825 855 vfs_id,
826 - parent_id: browser.nav.current_dir,
856 + parent_id,
827 857 };
828 858 browser.start_folder_import(path, strategy);
829 859 } else {
830 - browser.import_path(&path);
860 + files.push(path);
831 861 }
832 862 }
863 + if !files.is_empty() {
864 + let strategy = audiofiles_browser::import::ImportStrategy::MergeIntoVfs {
865 + vfs_id,
866 + parent_id,
867 + };
868 + browser.start_files_import(files, strategy);
869 + }
833 870 }
834 871 audiofiles_browser::editor::draw_browser(ui, browser, self.sync_manager.as_ref());
835 872
@@ -21,6 +21,17 @@
21 21 /// Current app version (from Cargo.toml at compile time).
22 22 const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
23 23
24 + /// Hard cap on the OTA manifest body. The manifest is a tiny JSON object
25 + /// (version + url + notes); anything larger is a misbehaving or compromised
26 + /// endpoint. Without this, a 200 from an allowlisted host (or an auto-followed
27 + /// redirect) could stream unbounded bytes into memory — the 15 s request
28 + /// timeout bounds *time*, not *size*.
29 + const MAX_UPDATE_BODY: usize = 64 * 1024;
30 +
31 + /// Cap on the release-notes string we retain and later render. Bounds what a
32 + /// compromised endpoint can push into the UI even within a well-formed manifest.
33 + const MAX_NOTES_LEN: usize = 4096;
34 +
24 35 /// The response format from the MNW OTA updater endpoint.
25 36 #[derive(serde::Deserialize)]
26 37 struct UpdateResponse {
@@ -29,6 +40,36 @@
29 40 notes: String,
30 41 }
31 42
43 + /// Read a response body into memory, refusing to buffer more than `cap` bytes.
44 + /// Rejects up front on an advertised `Content-Length` over the cap, then streams
45 + /// chunk-by-chunk and aborts the moment the accumulated size would exceed it —
46 + /// so a chunked/streamed response with no (or a lying) length header can't grow
47 + /// the buffer without bound.
48 + async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Result<Vec<u8>, String> {
49 + if let Some(len) = resp.content_length()
50 + && len > cap as u64
51 + {
52 + return Err(format!("body advertises {len} bytes (cap {cap})"));
53 + }
54 + let mut resp = resp;
55 + let mut buf = Vec::new();
56 + while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
57 + if buf.len() + chunk.len() > cap {
58 + return Err(format!("body exceeded {cap} bytes"));
59 + }
60 + buf.extend_from_slice(&chunk);
61 + }
62 + Ok(buf)
63 + }
64 +
65 + /// Truncate `s` in place to at most `max` characters, respecting UTF-8
66 + /// boundaries (never mid-code-point).
67 + fn truncate_chars(s: &mut String, max: usize) {
68 + if let Some((idx, _)) = s.char_indices().nth(max) {
69 + s.truncate(idx);
70 + }
71 + }
72 +
32 73 /// Shared update status, polled by the UI each frame.
33 74 #[derive(Clone, Default)]
34 75 pub struct UpdateStatus {
@@ -194,20 +235,26 @@
194 235 tracing::info!("audiofiles is up to date (v{CURRENT_VERSION})");
195 236 }
196 237 Ok(resp) if resp.status().is_success() => {
197 - match resp.json::<UpdateResponse>().await {
198 - Ok(update) => {
199 - if let Ok(remote) = Version::parse(&update.version)
200 - && remote > current && is_trusted_download_url(&update.url) {
201 - tracing::info!("Update available: v{}", update.version);
202 - let mut s = status.lock();
203 - s.available = true;
204 - s.version = update.version;
205 - s.notes = update.notes;
206 - s.download_url = update.url;
207 - }
208 - }
238 + match read_capped_body(resp, MAX_UPDATE_BODY).await {
239 + Ok(body) => match serde_json::from_slice::<UpdateResponse>(&body) {
240 + Ok(mut update) => {
241 + if let Ok(remote) = Version::parse(&update.version)
242 + && remote > current && is_trusted_download_url(&update.url) {
243 + tracing::info!("Update available: v{}", update.version);
244 + truncate_chars(&mut update.notes, MAX_NOTES_LEN);
245 + let mut s = status.lock();
246 + s.available = true;
247 + s.version = update.version;
248 + s.notes = update.notes;
249 + s.download_url = update.url;
250 + }
251 + }
252 + Err(e) => {
253 + tracing::warn!("Failed to parse update response: {e}");
254 + }
255 + },
209 256 Err(e) => {
210 - tracing::warn!("Failed to parse update response: {e}");
257 + tracing::warn!("Update manifest rejected: {e}");
211 258 }
212 259 }
213 260 }
@@ -362,6 +409,24 @@
362 409 assert_eq!(resp.notes, "Bug fixes");
363 410 }
364 411
412 + #[test]
413 + fn truncate_chars_caps_length_on_char_boundary() {
414 + let mut s = "hello world".to_string();
415 + truncate_chars(&mut s, 5);
416 + assert_eq!(s, "hello");
417 +
418 + // Under the cap: unchanged.
419 + let mut short = "hi".to_string();
420 + truncate_chars(&mut short, 5);
421 + assert_eq!(short, "hi");
422 +
423 + // Multi-byte code points: never split mid-char.
424 + let mut emoji = "a\u{1f600}b\u{1f600}c".to_string(); // a😀b😀c
425 + truncate_chars(&mut emoji, 2);
426 + assert_eq!(emoji, "a\u{1f600}");
427 + assert!(emoji.is_char_boundary(emoji.len()));
428 + }
429 +
365 430 #[test]
366 431 fn current_version_is_valid_semver() {
367 432 Version::parse(CURRENT_VERSION)
@@ -68,6 +68,14 @@
68 68 source: PathBuf,
69 69 strategy: ImportStrategy,
70 70 },
71 + /// Import an explicit list of files (loose drops, CLI args, OS "Open with")
72 + /// using the given strategy. Non-audio and directory entries are ignored.
73 + /// Same worker path as [`ImportDirectory`] so the hashing never runs on the
74 + /// GUI thread — the sync per-file loop used to freeze the window.
75 + ImportFiles {
76 + paths: Vec<PathBuf>,
77 + strategy: ImportStrategy,
78 + },
71 79 /// Cancel the current import (sets the worker's cancel flag synchronously).
72 80 Cancel,
73 81 }
@@ -483,6 +491,29 @@
483 491 false
484 492 }
485 493
494 + /// Import an explicit list of files at one flat level. Directory and non-audio
495 + /// entries are ignored (the caller may pass a mixed drop). Pre-hashes the audio
496 + /// files in parallel, then records each serially with progress — the same shape
497 + /// as [`import_directory_flat`], minus the recursive walk. Returns `true` if
498 + /// cancelled mid-way.
499 + fn import_file_list(
500 + paths: &[PathBuf],
501 + vfs_id: VfsId,
502 + parent_id: Option<NodeId>,
503 + ctx: &mut ImportContext<'_>,
504 + ) -> bool {
505 + let mut hashes = prehash_level(paths);
506 + for path in paths {
507 + if ctx.is_cancelled() {
508 + return true;
509 + }
510 + if path.is_file() && is_audio_file(path) {
511 + ctx.process_file(path, vfs_id, parent_id, hashes.remove(path));
512 + }
513 + }
514 + false
515 + }
516 +
486 517 /// Structured import: iterate source dir's immediate children, import each top-level
487 518 /// subdirectory via `import_directory_recursive`, tracking `ImportedFolder` per top-level dir.
488 519 /// Files directly in the source root are imported without a folder grouping.
@@ -572,10 +603,20 @@
572 603 (false, folders)
573 604 }
574 605
606 + /// A resolved import request: either a directory to walk or an explicit list of
607 + /// files. Unifies the two worker commands so strategy resolution, the
608 + /// loose-files read, Phase 2, and the Complete emission are shared.
609 + enum ImportSource {
610 + Dir(PathBuf),
611 + Files(Vec<PathBuf>),
612 + }
613 +
575 614 fn import_step(worker: &mut ImportWorker, cmd: ImportCommand, ctx: &WorkerCtx<ImportEvent>) {
576 - let ImportCommand::ImportDirectory { source, strategy } = cmd else {
615 + let (import_source, strategy) = match cmd {
616 + ImportCommand::ImportDirectory { source, strategy } => (ImportSource::Dir(source), strategy),
617 + ImportCommand::ImportFiles { paths, strategy } => (ImportSource::Files(paths), strategy),
577 618 // Cancel: the flag was already set synchronously by the handle.
578 - return;
619 + ImportCommand::Cancel => return,
579 620 };
580 621
581 622 ctx.reset_cancel();
@@ -604,18 +645,32 @@
604 645 ImportStrategy::MergeIntoVfs { vfs_id, parent_id } => (vfs_id, parent_id, false),
605 646 };
606 647
607 - // Phase 1: pre-walk to count audio files and sum sizes
608 - let (total, total_bytes) = match count_audio_files(&source, cancel, sink) {
609 - Some(result) => result,
610 - None => {
611 - sink.emit(ImportEvent::Complete {
612 - imported: Vec::new(),
613 - total_files: 0,
614 - errors: 0,
615 - duplicates: 0,
616 - folders: Vec::new(),
617 - });
618 - return;
648 + // Phase 1: determine the total audio-file count and byte size. A directory
649 + // is walked (cancellable); an explicit file list is filtered in place.
650 + let (total, total_bytes) = match &import_source {
651 + ImportSource::Dir(source) => match count_audio_files(source, cancel, sink) {
652 + Some(result) => result,
653 + None => {
654 + sink.emit(ImportEvent::Complete {
655 + imported: Vec::new(),
656 + total_files: 0,
657 + errors: 0,
658 + duplicates: 0,
659 + folders: Vec::new(),
660 + });
661 + return;
662 + }
663 + },
664 + ImportSource::Files(paths) => {
665 + let mut count = 0;
666 + let mut bytes = 0u64;
667 + for p in paths {
668 + if p.is_file() && is_audio_file(p) {
669 + count += 1;
670 + bytes += fs::metadata(p).map(|m| m.len()).unwrap_or(0);
671 + }
672 + }
673 + (count, bytes)
619 674 }
620 675 };
621 676
@@ -656,11 +711,16 @@
656 711 loose_files,
657 712 };
658 713
659 - let (cancelled, folders) = if flat {
660 - let c = import_directory_flat(&source, vfs_id, parent_id, &mut import_ctx);
661 - (c, Vec::new())
662 - } else {
663 - import_structured(&source, vfs_id, parent_id, &mut import_ctx)
714 + let (cancelled, folders) = match &import_source {
715 + ImportSource::Dir(source) if flat => {
716 + (import_directory_flat(source, vfs_id, parent_id, &mut import_ctx), Vec::new())
717 + }
718 + ImportSource::Dir(source) => import_structured(source, vfs_id, parent_id, &mut import_ctx),
719 + // An explicit file list has no directory structure to preserve, so it
720 + // always imports flat (folders empty), regardless of strategy.
721 + ImportSource::Files(paths) => {
722 + (import_file_list(paths, vfs_id, parent_id, &mut import_ctx), Vec::new())
723 + }
664 724 };
665 725
666 726 let total_files = if cancelled { completed } else { total };
@@ -684,6 +744,46 @@
684 744 mod tests {
685 745 use super::*;
686 746
747 + #[test]
748 + fn import_files_command_imports_explicit_list_off_thread() {
749 + // B2/B3: an explicit file list imports through the worker (not the GUI
750 + // thread). Two audio files + one non-audio; only the two audio files
751 + // land, and the non-audio is ignored.
752 + let dir = tempfile::TempDir::new().unwrap();
753 + let db_path = dir.path().join("audiofiles.db");
754 + let store_root = dir.path().join("store");
755 + let db = Database::open(&db_path).unwrap();
756 + let vfs_id = vfs::create_vfs(&db, "Library").unwrap();
757 +
758 + let a = dir.path().join("a.wav");
759 + let b = dir.path().join("b.wav");
760 + let c = dir.path().join("notes.txt");
761 + fs::write(&a, b"AAAA").unwrap();
762 + fs::write(&b, b"BBBB").unwrap();
763 + fs::write(&c, b"not audio").unwrap();
764 + // Release the GUI-side connection so the worker's own connection can open
765 + // the same DB file without contention.
766 + drop(db);
767 +
768 + let handle = spawn_import_worker(db_path, store_root).unwrap();
769 + assert!(handle.send(ImportCommand::ImportFiles {
770 + paths: vec![a, b, c],
771 + strategy: ImportStrategy::MergeIntoVfs { vfs_id, parent_id: None },
772 + }));
773 +
774 + let mut done = None;
775 + for _ in 0..100 {
776 + if let Some(ImportEvent::Complete { imported, total_files, .. }) = handle.try_recv() {
777 + done = Some((imported.len(), total_files));
778 + break;
779 + }
780 + std::thread::sleep(std::time::Duration::from_millis(20));
781 + }
782 + let (imported, total) = done.expect("expected a Complete event");
783 + assert_eq!(total, 2, "only the two audio files count");
784 + assert_eq!(imported, 2, "both audio files imported");
785 + }
786 +
687 787 #[test]
688 788 fn is_audio_file_recognises_extensions() {
689 789 assert!(is_audio_file(Path::new("kick.wav")));
@@ -8,6 +8,7 @@
8 8 use tracing::instrument;
9 9
10 10 use audiofiles_core::db::Database;
11 + use audiofiles_core::store::SampleStore;
11 12 use audiofiles_core::worker_runtime::{spawn_worker, WorkerCtx, WorkerHandle};
12 13
13 14 /// Command from the GUI thread to the loose-files worker.
@@ -18,6 +19,10 @@
18 19 Relocate(PathBuf),
19 20 /// Remove loose-files samples whose source file is missing.
20 21 Purge,
22 + /// Re-hash every managed blob and report any whose bytes no longer match
23 + /// their content address (the CAS scrub). Managed-mode counterpart to
24 + /// `CheckIntegrity`, which only checks loose-files source presence.
25 + VerifyStore,
21 26 }
22 27
23 28 /// Event from the loose-files worker back to the GUI thread.
@@ -28,6 +33,9 @@
28 33 RelocateResult { relocated: usize, still_missing: usize },
29 34 /// Purge finished.
30 35 PurgeResult { purged: usize },
36 + /// Store scrub finished: `checked` managed blobs re-hashed, `corrupt` of
37 + /// them failed to match their content address.
38 + VerifyResult { checked: usize, corrupt: usize },
31 39 /// An operation failed.
32 40 Failed { message: String },
33 41 }
@@ -46,7 +54,10 @@
46 54 /// Spawn the loose-files worker. It opens its own `Database` to avoid contending
47 55 /// with the GUI connection.
48 56 #[instrument(skip_all)]
49 - pub fn spawn_loose_files_worker(db_path: PathBuf) -> std::io::Result<LooseFilesHandle> {
57 + pub fn spawn_loose_files_worker(
58 + db_path: PathBuf,
59 + store_root: PathBuf,
60 + ) -> std::io::Result<LooseFilesHandle> {
50 61 spawn_worker(
51 62 "loose-files-worker",
52 63 move || Database::open(&db_path),
@@ -56,11 +67,16 @@
56 67 |_state| LooseFilesEvent::Failed {
57 68 message: "loose-files maintenance panicked (internal error)".to_string(),
58 69 },
59 - loose_files_step,
70 + move |db, cmd, ctx| loose_files_step(db, &store_root, cmd, ctx),
60 71 )
61 72 }
62 73
63 - fn loose_files_step(db: &mut Database, cmd: LooseFilesCommand, ctx: &WorkerCtx<LooseFilesEvent>) {
74 + fn loose_files_step(
75 + db: &mut Database,
76 + store_root: &std::path::Path,
77 + cmd: LooseFilesCommand,
78 + ctx: &WorkerCtx<LooseFilesEvent>,
79 + ) {
64 80 let event = match cmd {
65 81 LooseFilesCommand::CheckIntegrity => {
66 82 match audiofiles_core::store::check_loose_files_integrity(db) {
@@ -87,6 +103,17 @@
87 103 message: e.to_string(),
88 104 },
89 105 },
106 + LooseFilesCommand::VerifyStore => {
107 + match SampleStore::new(store_root).and_then(|store| store.scrub(db)) {
108 + Ok((checked, corrupt)) => LooseFilesEvent::VerifyResult {
109 + checked,
110 + corrupt: corrupt.len(),
111 + },
112 + Err(e) => LooseFilesEvent::Failed {
113 + message: e.to_string(),
114 + },
115 + }
116 + }
90 117 };
91 118 ctx.emit(event);
92 119 }
@@ -101,7 +128,7 @@
101 128 let db_path = dir.path().join("audiofiles.db");
102 129 let _db = Database::open(&db_path).unwrap();
103 130
104 - let handle = spawn_loose_files_worker(db_path).unwrap();
131 + let handle = spawn_loose_files_worker(db_path, dir.path().join("samples")).unwrap();
105 132 assert!(handle.try_recv().is_none());
106 133 drop(handle);
107 134 }
@@ -112,7 +139,7 @@
112 139 let db_path = dir.path().join("audiofiles.db");
113 140 let _db = Database::open(&db_path).unwrap();
114 141
115 - let handle = spawn_loose_files_worker(db_path).unwrap();
142 + let handle = spawn_loose_files_worker(db_path, dir.path().join("samples")).unwrap();
116 143 assert!(handle.send(LooseFilesCommand::CheckIntegrity));
117 144
118 145 let mut got = false;
@@ -63,6 +63,14 @@
63 63 #[error("invalid hash: {0}")]
64 64 HashInvalid(String),
65 65
66 + /// The bytes actually copied into the store did not hash to the content
67 + /// address they were to be stored under — the source file changed between
68 + /// the pre-hash pass and the serial copy (a DAW re-render, a cloud-sync
69 + /// client, a network share). The copy is discarded rather than committed to
70 + /// a wrong `{hash}.ext` path, which would silently corrupt the CAS invariant.
71 + #[error("content hash mismatch: {0}")]
72 + HashMismatch(String),
73 +
66 74 /// Audio analysis error (decode failure, unsupported format, etc.).
67 75 #[error("analysis error: {0}")]
68 76 Analysis(#[from] AnalysisError),
@@ -10,7 +10,7 @@
10 10 //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later.
11 11
12 12 use std::fs;
13 - use std::io::Read;
13 + use std::io::{Read, Write};
14 14 use std::path::{Path, PathBuf};
15 15
16 16 use sha2::{Digest, Sha256};
@@ -181,13 +181,25 @@
181 181 };
182 182 if needs_write {
183 183 let tmp = dest.with_file_name(format!("{hash}.{ext}.{}.tmp", std::process::id()));
184 - fs::copy(path, &tmp).map_err(|e| io_err(&tmp, e))?;
185 - // fsync the blob bytes to disk before the rename. Without this the
186 - // rename can be durable while the file contents are not, leaving a
187 - // canonical-path blob with garbage after a power loss — which a
188 - // content-addressed store would then trust forever.
189 - if let Ok(f) = fs::File::open(&tmp) {
190 - let _ = f.sync_all();
184 + // Copy *and* hash the bytes in a single pass, fsyncing the temp
185 + // before the rename. `hash` was computed by an earlier pass (batch
186 + // import pre-hashes the whole set, then copies one-by-one much
187 + // later); the source can mutate in that window (DAW re-render,
188 + // cloud-sync client, network share). Copying blindly would land
189 + // wrong bytes at `{hash}.ext` and — because dedup is size-only and
190 + // no read path re-hashes — trust them forever. Verifying the bytes
191 + // we actually wrote against the content address closes that TOCTOU:
192 + // a mismatch discards the temp and fails the import loudly rather
193 + // than corrupting the store.
194 + let copied = copy_hashing(path, &tmp).inspect_err(|_| {
195 + let _ = fs::remove_file(&tmp);
196 + })?;
197 + if copied != hash {
198 + let _ = fs::remove_file(&tmp);
199 + return Err(CoreError::HashMismatch(format!(
200 + "{} changed during import: expected {hash}, copied bytes hash to {copied}",
201 + path.display()
202 + )));
191 203 }
192 204 if let Err(e) = fs::rename(&tmp, &dest) {
193 205 let _ = fs::remove_file(&tmp);
@@ -423,6 +435,45 @@
423 435
424 436 Ok(computed == hash)
425 437 }
438 +
439 + /// Scrub the managed store: re-hash every locally-present blob and report the
440 + /// hashes whose bytes no longer match their content address.
441 + ///
442 + /// This is the runtime home for [`verify_sample`](Self::verify_sample) — the
443 + /// store's headline invariant is "the filename IS the content hash," and a
444 + /// scrub is what confirms that invariant still holds against silent on-disk
445 + /// corruption (bit-rot, an out-of-band edit through the VFS mirror, a
446 + /// truncated blob from a pre-atomic-fix crash). Only managed blobs are
447 + /// checked: loose-files samples live at their `source_path` (covered by
448 + /// [`check_loose_files_integrity`]) and `cloud_only` rows have no local blob
449 + /// to verify. A missing or unreadable blob counts as corrupt (its hash is
450 + /// returned) rather than aborting the whole sweep.
451 + ///
452 + /// Returns `(checked, corrupt_hashes)`.
453 + #[instrument(skip_all)]
454 + pub fn scrub(&self, db: &Database) -> Result<(usize, Vec<String>)> {
455 + let mut stmt = db.conn().prepare(
456 + "SELECT hash, file_extension FROM live_samples \
457 + WHERE source_path IS NULL AND cloud_only = 0",
458 + )?;
459 + let blobs: Vec<(String, String)> = stmt
460 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
461 + .collect::<std::result::Result<Vec<_>, _>>()?;
462 +
463 + let mut checked = 0;
464 + let mut corrupt = Vec::new();
465 + for (hash, ext) in blobs {
466 + checked += 1;
467 + // A read error (blob missing/unreadable) is itself an integrity
468 + // failure for a managed sample — record it as corrupt rather than
469 + // failing the sweep, so one bad blob doesn't hide the rest.
470 + match self.verify_sample(&hash, &ext) {
471 + Ok(true) => {}
472 + Ok(false) | Err(_) => corrupt.push(hash),
473 + }
474 + }
475 + Ok((checked, corrupt))
476 + }
426 477 }
427 478
428 479 // --- Sample metadata queries ---
@@ -983,6 +1034,29 @@
983 1034 Ok((format!("{:x}", hasher.finalize()), file_size))
984 1035 }
985 1036
1037 + /// Copy `src` to `dst` while computing the SHA-256 of the bytes actually
1038 + /// written, returning the hex digest. Used by the store write path so the copy
1039 + /// can be verified against the content address it will be stored under — the
1040 + /// hash is computed over the same bytes that land on disk, not over an earlier
1041 + /// read of a source that may since have changed. Fsyncs `dst` before returning
1042 + /// so the rename that follows can't be durable ahead of the contents.
1043 + fn copy_hashing(src: &Path, dst: &Path) -> Result<String> {
1044 + let mut input = fs::File::open(src).map_err(|e| io_err(src, e))?;
1045 + let mut output = fs::File::create(dst).map_err(|e| io_err(dst, e))?;
1046 + let mut hasher = Sha256::new();
1047 + let mut buf = [0u8; 8192];
1048 + loop {
1049 + let n = input.read(&mut buf).map_err(|e| io_err(src, e))?;
1050 + if n == 0 {
1051 + break;
1052 + }
1053 + hasher.update(&buf[..n]);
1054 + output.write_all(&buf[..n]).map_err(|e| io_err(dst, e))?;
1055 + }
1056 + output.sync_all().map_err(|e| io_err(dst, e))?;
1057 + Ok(format!("{:x}", hasher.finalize()))
1058 + }
1059 +
986 1060 /// Hash a batch of files in parallel (rayon). Each result is aligned to the
987 1061 /// corresponding entry in `paths`. Hashing is the dominant per-file import cost
988 1062 /// (SHA-256 over the whole file) and is embarrassingly parallel — every side
@@ -1470,6 +1544,105 @@
1470 1544 assert!(matches!(result, Err(CoreError::Io { .. })));
1471 1545 }
1472 1546
1547 + #[test]
1548 + fn import_hashed_rejects_hash_that_does_not_match_bytes() {
1549 + // Simulates the source file mutating between the parallel pre-hash pass
1550 + // and the serial copy: import_hashed is handed a hash that does not
1551 + // describe the bytes now on disk. The copy must be rejected, never
1552 + // committed to `{wrong_hash}.ext`.
1553 + let (dir, db, store) = setup();
1554 + let src = create_test_file(&dir, "kick.wav", b"the actual bytes on disk");
1555 + let wrong_hash = format!("{:x}", Sha256::digest(b"what we hashed earlier"));
1556 +
1557 + let result = store.import_hashed(&src, &wrong_hash, 24, &db);
1558 + assert!(
1559 + matches!(result, Err(CoreError::HashMismatch(_))),
1560 + "expected HashMismatch, got: {result:?}"
1561 + );
1562 +
1563 + // No blob may exist at the wrong content address, and no row inserted.
1564 + assert!(!store.sample_path(&wrong_hash, "wav").unwrap().exists());
1565 + let count: i64 = db
1566 + .conn()
1567 + .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0))
1568 + .unwrap();
1569 + assert_eq!(count, 0);
1570 + // And no temp file leaked in the store directory.
1571 + let leaked = fs::read_dir(store.root())
1572 + .into_iter()
1573 + .flatten()
1574 + .flatten()
1575 + .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
1576 + assert!(!leaked, "a .tmp file leaked after the rejected import");
1577 + }
1578 +
1579 + #[test]
1580 + fn import_hashed_accepts_matching_hash() {
1581 + let (dir, db, store) = setup();
1582 + let src = create_test_file(&dir, "clap.wav", b"clap bytes");
1583 + let (hash, size) = hash_file(&src).unwrap();
1584 + store.import_hashed(&src, &hash, size, &db).unwrap();
1585 + assert!(store.verify_sample(&hash, "wav").unwrap());
1586 + }
1587 +
1588 + #[test]
1589 + fn scrub_passes_a_clean_store() {
1590 + let (dir, db, store) = setup();
1591 + let a = store
1592 + .import(&create_test_file(&dir, "a.wav", b"aaaa"), &db)
1593 + .unwrap();
1594 + let b = store
1595 + .import(&create_test_file(&dir, "b.wav", b"bbbb"), &db)
1596 + .unwrap();
1597 + assert_ne!(a, b);
1598 +
1599 + let (checked, corrupt) = store.scrub(&db).unwrap();
1600 + assert_eq!(checked, 2);
1601 + assert!(corrupt.is_empty());
1602 + }
1603 +
1604 + #[test]
1605 + fn scrub_reports_a_corrupt_blob() {
1606 + let (dir, db, store) = setup();
1607 + store
1608 + .import(&create_test_file(&dir, "good.wav", b"good"), &db)
1609 + .unwrap();
1610 + let bad = store
1611 + .import(&create_test_file(&dir, "bad.wav", b"original"), &db)
1612 + .unwrap();
1613 +
1614 + // Corrupt the stored blob in place (clear read-only first, as the store
1615 + // marks canonical blobs read-only).
1616 + let path = store.sample_path(&bad, "wav").unwrap();
1617 + let mut perms = fs::metadata(&path).unwrap().permissions();
1618 + #[allow(clippy::permissions_set_readonly_false)]
1619 + perms.set_readonly(false);
1620 + fs::set_permissions(&path, perms).unwrap();
1621 + fs::write(&path, b"tampered").unwrap();
1622 +
1623 + let (checked, corrupt) = store.scrub(&db).unwrap();
1624 + assert_eq!(checked, 2);
1625 + assert_eq!(corrupt, vec![bad]);
1626 + }
1627 +
1628 + #[test]
1629 + fn scrub_flags_a_missing_blob_as_corrupt() {
1630 + let (dir, db, store) = setup();
1631 + let hash = store
1632 + .import(&create_test_file(&dir, "gone.wav", b"here now"), &db)
1633 + .unwrap();
1634 + let path = store.sample_path(&hash, "wav").unwrap();
1635 + let mut perms = fs::metadata(&path).unwrap().permissions();
1636 + #[allow(clippy::permissions_set_readonly_false)]
1637 + perms.set_readonly(false);
1638 + fs::set_permissions(&path, perms).unwrap();
1639 + fs::remove_file(&path).unwrap();
1640 +
1641 + let (checked, corrupt) = store.scrub(&db).unwrap();
1642 + assert_eq!(checked, 1);
1643 + assert_eq!(corrupt, vec![hash]);
1644 + }
1645 +
1473 1646 #[test]
1474 1647 fn import_rejects_zero_byte_file() {
1475 1648 let (dir, db, store) = setup();