max / audiofiles
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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(); |