//! Content-addressed sample storage: imports files by SHA-256 hash, deduplicates, and manages on-disk blobs. //! //! ## Why content-addressed storage //! //! - **Dedup by design:** Importing the same file twice is a no-op (same hash = same row). //! Users often have the same sample in multiple folders. //! - **Sync-friendly:** Hash is a stable, globally unique identifier across devices. No UUID //! collisions, no server-assigned IDs, no coordination needed during offline edits. //! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later. use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; use symphonia::core::formats::probe::Hint; use symphonia::core::formats::{FormatOptions, TrackType}; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::units::Timestamp; use crate::db::Database; use crate::error::{CoreError, Result, io_err, unix_now}; use tracing::instrument; mod loose_files; pub use loose_files::*; /// Probe an audio file to extract its duration from metadata/headers. /// Returns `None` if the duration cannot be determined without full decode. fn probe_duration(path: &Path) -> Option { let file = fs::File::open(path).ok()?; let mss = MediaSourceStream::new( Box::new(file), symphonia::core::io::MediaSourceStreamOptions::default(), ); let mut hint = Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } if let Ok(format) = symphonia::default::get_probe().probe( &hint, mss, FormatOptions::default(), MetadataOptions::default(), ) { // symphonia 0.6 moved the timebase and frame count off codec_params onto // the track itself. let track = format.default_track(TrackType::Audio)?; let time_base = track.time_base?; let n_frames = track.num_frames?; let duration = time_base.calc_time(Timestamp::new(n_frames.try_into().ok()?))?; return Some(duration.as_secs_f64()); } // Fallback for WAV files Symphonia rejects (non-standard fmt chunk sizes) let is_wav = path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("wav")); if is_wav { let reader = hound::WavReader::open(path).ok()?; let spec = reader.spec(); let n_samples = reader.len() as f64; let frames = n_samples / spec.channels as f64; return Some(frames / spec.sample_rate as f64); } None } /// Validate that a file extension contains only safe characters. /// /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg, /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that /// could be used for directory traversal. pub fn validate_extension(ext: &str) -> Result<()> { if !ext.is_empty() && !ext .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') { return Err(CoreError::Internal(format!( "invalid file extension: {ext:?}" ))); } Ok(()) } /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256). /// /// Delegates to [`crate::SampleHash::validated`] so the rule lives in exactly one /// place, historically these were two parallel validators that could drift /// (e.g. on uppercase handling). pub fn validate_hash(hash: &str) -> Result<()> { crate::SampleHash::validated(hash).map(|_| ()).map_err(|_| { CoreError::HashInvalid(format!( "expected 64 lowercase hex chars, got {:?} ({} chars)", hash, hash.len() )) }) } /// Mark a stored blob read-only (best-effort, cross-platform). A failed chmod /// must never fail an import, it only weakens the write-through guard. fn set_blob_readonly(path: &Path) { if let Ok(meta) = fs::metadata(path) { let mut perms = meta.permissions(); perms.set_readonly(true); let _ = fs::set_permissions(path, perms); } } /// Manages on-disk sample blobs in a flat directory structure, storing files as /// `{sha256_hex}.{ext}` directly in the root directory. /// /// Deduplication via SHA-256: import streams the file through a hasher and skips /// the copy if a blob with the same hash already exists. /// /// Thread-safe: all operations are stateless reads/writes against the filesystem /// (no interior mutability or locks). pub struct SampleStore { root: PathBuf, } impl SampleStore { /// Create a new sample store, ensuring the root directory exists. #[instrument(skip_all)] pub fn new(root: impl Into) -> Result { let root = root.into(); fs::create_dir_all(&root).map_err(|e| io_err(&root, e))?; Ok(Self { root }) } /// Import a file into the store: hash it, copy to content-addressed path, /// insert into DB. Returns the hex SHA-256 hash. #[instrument(skip_all)] pub fn import(&self, path: &Path, db: &Database) -> Result { let (hash, file_size) = hash_file(path)?; self.import_hashed(path, &hash, file_size, db)?; Ok(hash) } /// Import a file whose SHA-256 hash and size were already computed (e.g. by a /// parallel pre-hash pass). Does the serial side-effecting work, content- /// addressed blob copy + DB insert + orphan cleanup, exactly as [`import`]. /// Splitting the pure hash out lets the import pipeline hash a batch in /// parallel while keeping every store/DB mutation serial. #[instrument(skip_all)] pub fn import_hashed( &self, path: &Path, hash: &str, file_size: i64, db: &Database, ) -> Result<()> { // Resolve the blob extension from an existing row if this hash is already // known. The store is content-addressed (one blob per hash), so identical // bytes imported under a different extension must reuse the existing blob // rather than write a second `{hash}.{newext}` file: remove() resolves the // blob path from the DB row, so any divergent-extension copy would be // unreachable and leak disk forever. Query without the deleted_at filter so // the blob naming stays consistent even for soft-deleted rows. Fresh import // falls back to the incoming file's extension. // tombstone-ok: point-lookup that intentionally includes tombstoned rows let ext = match db.conn().query_row( "SELECT file_extension FROM samples WHERE hash = ?1", [hash], |row| row.get::<_, String>(0), ) { Ok(existing) => existing, Err(rusqlite::Error::QueryReturnedNoRows) => crate::util::get_extension(path), Err(e) => return Err(CoreError::Db(e)), }; let original_name = clamp_original_name(crate::util::get_filename(path, "unknown")); // Probe duration from file headers (cheap, no full decode) let duration = probe_duration(path); // Copy file to store if not already present *and intact*. Write to a // temp path and atomically rename: a crash / full disk mid-copy must not // leave a truncated blob at the canonical content-addressed path, // because dedup would then trust that corrupt blob under this hash // forever. We also repair an already-present blob whose size doesn't // match the source (a partial left by a pre-atomic-fix crash): since the // store is content-addressed, the correct blob's size is exactly the // source's, so a cheap size check catches truncation without re-hashing. // Mirrors the atomic temp+rename the sync download path uses. The pid // suffix keeps two concurrent imports of the same hash from colliding on // the temp file. let dest = self.sample_path(hash, &ext)?; let needs_write = match fs::metadata(&dest) { Ok(m) => m.len() != file_size as u64, Err(_) => true, }; if needs_write { let tmp = dest.with_file_name(format!("{hash}.{ext}.{}.tmp", std::process::id())); // Copy *and* hash the bytes in a single pass, fsyncing the temp // before the rename. `hash` was computed by an earlier pass (batch // import pre-hashes the whole set, then copies one-by-one much // later); the source can mutate in that window (DAW re-render, // cloud-sync client, network share). Copying blindly would land // wrong bytes at `{hash}.ext` and, because dedup is size-only and // no read path re-hashes, trust them forever. Verifying the bytes // we actually wrote against the content address closes that TOCTOU: // a mismatch discards the temp and fails the import loudly rather // than corrupting the store. let copied = copy_hashing(path, &tmp).inspect_err(|_| { let _ = fs::remove_file(&tmp); })?; if copied != hash { let _ = fs::remove_file(&tmp); return Err(CoreError::HashMismatch(format!( "{} changed during import: expected {hash}, copied bytes hash to {copied}", path.display() ))); } if let Err(e) = fs::rename(&tmp, &dest) { let _ = fs::remove_file(&tmp); return Err(io_err(&dest, e)); } // Best-effort fsync of the directory so the rename itself is durable // (the new dirent survives a crash). Not all filesystems support // directory fsync; failure here only weakens durability, never the // import. if let Some(parent) = dest.parent() && let Ok(d) = fs::File::open(parent) { let _ = d.sync_all(); } // Mark the canonical blob read-only. The VFS mirror exposes store // blobs to DAWs/file managers via symlinks; a "save in place" would // otherwise write through and corrupt the SHA-256-named blob, // silently invalidating dedup for every other placement and device. // Read-only makes that write fail loudly. Unlink still works (it // needs write on the directory, not the file). Best-effort: a // filesystem that rejects the chmod must not fail the import. set_blob_readonly(&dest); } // Insert into DB (ignore if hash already exists). If this fails after we // just wrote a fresh blob, that blob is unreferenced, no row points at // it, and since `needs_write` was true nothing pointed at it before // either. Unlink it so a DB-write failure (disk full on the WAL, lock // timeout) can't leak an unreachable blob that no GC path reclaims, // upholding this module's "never orphan a blob" invariant (see remove()). // When `needs_write` was false the blob pre-existed and may be shared by // another row, so it is left untouched. let now = unix_now(); if let Err(e) = db.conn().execute( "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![hash, original_name, ext, file_size, now, now, duration], ) { if needs_write { let _ = fs::remove_file(&dest); } return Err(CoreError::Db(e)); } Ok(()) } /// Check if a sample file exists in the store. pub fn exists(&self, hash: &str, ext: &str) -> Result { Ok(self.sample_path(hash, ext)?.exists()) } /// Get the filesystem path for a sample. /// /// Validates that `hash` is exactly 64 lowercase hex characters (SHA-256) /// to prevent directory traversal or malformed paths. pub fn sample_path(&self, hash: &str, ext: &str) -> Result { validate_hash(hash)?; validate_extension(ext)?; Ok(store_blob_path(&self.root, hash, ext)) } /// Remove a sample from store and database. CASCADE handles VFS/tag refs. /// /// Deletes the file from disk first, then the DB row. If the file delete /// fails (in-use on Windows, permission denied, etc.), the DB row is left /// intact so the user can retry; nothing leaks. The reverse order would /// silently leak orphan blobs on every file-delete failure, accumulating /// disk waste invisible to the user. #[instrument(skip_all)] pub fn remove(&self, hash: &str, db: &Database) -> Result<()> { // Resolve the extension without the `deleted_at IS NULL` filter: this is // the hard-delete path, and the row may be a tombstone (purged from the // Trash view, or swept past its retention window). The blob name is // content-addressed and identical regardless of tombstone state, so the // unfiltered lookup is the correct one for locating the file to unlink. let ext = sample_extension_any(db, hash)?; let path = self.sample_path(hash, &ext)?; // File first. ENOENT is fine, the row was already pointing at nothing. match fs::remove_file(&path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(io_err(&path, e)), } // Then the DB row (CASCADE handles tags, vfs_nodes, etc.). db.conn() .execute("DELETE FROM samples WHERE hash = ?1", [hash])?; Ok(()) } /// Remove samples that are no longer referenced by any VFS node. /// /// This is a **local** garbage-collection of disk space, not a cross-device /// statement: each device has its own set of orphans. Sync triggers are /// suppressed for the duration so the row deletes never push a `samples` /// DELETE that would cascade-wipe another device's placements (engine-level /// CASCADE is not trigger-suppressible, see /// `docs/design-sample-deletion.md`). The suppression lives here, inside the /// transaction, so no caller can forget it: the flag flip is invisible to /// other connections under WAL snapshot isolation, so a concurrent import on /// the GUI connection still syncs normally. /// /// Returns the number of orphaned samples removed. #[instrument(skip_all)] pub fn remove_orphaned_samples(&self, db: &Database) -> Result { // Skip tombstoned rows, the eventual hard-delete sweep // (docs/design-sample-deletion.md Phase 4) will get them when the // retention window expires. Double-processing here would race the // sweep and pre-emptively destroy still-recoverable data. let mut stmt = db.conn().prepare( "SELECT s.hash, s.file_extension FROM live_samples s LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash WHERE vn.id IS NULL", )?; let orphans: Vec<(String, String)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::, _>>()?; drop(stmt); // Delete orphan rows in a single transaction with sync suppressed. // Re-check orphan status inside the DELETE so we don't lose a race with // a concurrent import that linked the sample after the query above, // and so we only unlink blobs for rows we actually removed (a live // re-link must keep both row and blob). // // The blob unlink runs INSIDE the transaction, while `BEGIN IMMEDIATE` // still holds the write lock. This is load-bearing: cleanup and import // hold separate connections (serialized only by SQLite's write lock, // not the in-process Database mutex), so an unlink performed after // COMMIT could delete a blob that a concurrent import had already // re-adopted in the gap (import sees the blob present, re-inserts the // row, leaves the blob), leaving a live row pointing at a missing // file. Holding the write lock across the unlink makes that re-adoption // unable to commit until the blob is already gone, so the re-import // re-writes the blob instead. A failed unlink only leaves an orphan // blob (disk waste, reclaimed by a later re-import or verify sweep) and // must not roll back the committed row delete, so its error is ignored. let deleted = db.transaction(|_tx| { db.conn().execute( "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", [], )?; let mut count = 0usize; for (hash, ext) in &orphans { let n = db.conn().execute( "DELETE FROM samples WHERE hash = ?1 \ AND NOT EXISTS (SELECT 1 FROM vfs_nodes WHERE sample_hash = ?1) \ AND deleted_at IS NULL", [hash], )?; if n > 0 { if let Ok(path) = self.sample_path(hash, ext) && path.exists() { let _ = fs::remove_file(&path); } count += 1; } } db.conn().execute( "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'", [], )?; Ok(count) })?; Ok(deleted) } /// Hard-delete tombstoned samples whose retention window has expired. /// /// A sample tombstoned more than `sample_tombstone_retain_days` ago (default /// 30) is past recovery: this removes its blob and its `samples` row, letting /// the engine CASCADE clean up placements, tags, and collection memberships. /// Unlike the local orphan GC, the `samples` DELETE is **not** sync-suppressed /// the deletion propagates so the user's other devices converge (each having /// had the same window to recover). Runs once at startup; the indexed /// `deleted_at` partial index keeps the scan cheap when nothing has expired. /// /// A blob/row removal that fails for one sample is logged and skipped so a /// single stuck file (in-use on Windows, permission denied) doesn't abort the /// rest of the sweep. Returns the number of samples hard-deleted. #[instrument(skip_all)] pub fn sweep_expired_tombstones(&self, db: &Database) -> Result { let cutoff = unix_now() - tombstone_retain_days(db) * 86_400; // tombstone-ok: the sweep operates on tombstones by definition let mut stmt = db .conn() .prepare("SELECT hash FROM samples WHERE deleted_at IS NOT NULL AND deleted_at < ?1")?; let expired: Vec = stmt .query_map([cutoff], |row| row.get(0))? .collect::, _>>()?; drop(stmt); let mut removed = 0usize; for hash in &expired { match self.remove(hash, db) { Ok(()) => removed += 1, Err(e) => { tracing::warn!(hash = %hash, "tombstone sweep: failed to purge sample: {e}"); } } } Ok(removed) } /// Get the store root directory. pub fn root(&self) -> &Path { &self.root } /// Re-hash a stored sample and compare against the expected hash. /// /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if /// it differs. Returns an error if the file cannot be read. #[instrument(skip_all)] pub fn verify_sample(&self, hash: &str, ext: &str) -> Result { let path = self.sample_path(hash, ext)?; let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?; let mut hasher = Sha256::new(); let mut buf = [0u8; 8192]; loop { let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?; if n == 0 { break; } hasher.update(&buf[..n]); } let computed = hex::encode(hasher.finalize()); Ok(computed == hash) } /// Scrub the managed store: re-hash every locally-present blob and report the /// hashes whose bytes no longer match their content address. /// /// This is the runtime home for [`verify_sample`](Self::verify_sample), the /// store's headline invariant is "the filename IS the content hash," and a /// scrub is what confirms that invariant still holds against silent on-disk /// corruption (bit-rot, an out-of-band edit through the VFS mirror, a /// truncated blob from a pre-atomic-fix crash). Only managed blobs are /// checked: loose-files samples live at their `source_path` (covered by /// [`check_loose_files_integrity`]) and `cloud_only` rows have no local blob /// to verify. A missing or unreadable blob counts as corrupt (its hash is /// returned) rather than aborting the whole sweep. /// /// Returns `(checked, corrupt_hashes)`. #[instrument(skip_all)] pub fn scrub(&self, db: &Database) -> Result<(usize, Vec)> { let mut stmt = db.conn().prepare( "SELECT hash, file_extension FROM live_samples \ WHERE source_path IS NULL AND cloud_only = 0", )?; let blobs: Vec<(String, String)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::, _>>()?; let mut checked = 0; let mut corrupt = Vec::new(); for (hash, ext) in blobs { checked += 1; // A read error (blob missing/unreadable) is itself an integrity // failure for a managed sample, record it as corrupt rather than // failing the sweep, so one bad blob doesn't hide the rest. match self.verify_sample(&hash, &ext) { Ok(true) => {} Ok(false) | Err(_) => corrupt.push(hash), } } Ok((checked, corrupt)) } } // --- Sample metadata queries --- /// Query a single text column from the samples table by hash. /// /// Only fields in the allowlist may be queried; any other value returns an error /// to prevent SQL injection through the interpolated column name. fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result { const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"]; if !ALLOWED_FIELDS.contains(&field) { return Err(CoreError::Internal(format!( "query_sample_field: disallowed field {field:?}" ))); } let sql = format!("SELECT {field} FROM live_samples WHERE hash = ?1"); db.conn() .query_row(&sql, [hash], |row| row.get(0)) .map_err(|e| match e { rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()), other => CoreError::Db(other), }) } /// Look up the file extension for a sample by its hash. pub fn sample_extension(db: &Database, hash: &str) -> Result { query_sample_field(db, hash, "file_extension") } /// Look up the file extension for a sample by its hash, including tombstoned /// rows. Used by the hard-delete and sweep paths, which must resolve the blob /// path for soft-deleted rows that `sample_extension` deliberately hides. fn sample_extension_any(db: &Database, hash: &str) -> Result { // tombstone-ok: point-lookup that must resolve blobs for tombstoned rows too db.conn() .query_row( "SELECT file_extension FROM samples WHERE hash = ?1", [hash], |row| row.get(0), ) .map_err(|e| match e { rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()), other => CoreError::Db(other), }) } /// Look up the original filename for a sample by its hash. pub fn sample_original_name(db: &Database, hash: &str) -> Result { query_sample_field(db, hash, "original_name") } // --- Soft delete (tombstones) --- // // See `docs/design-sample-deletion.md`. A non-NULL `samples.deleted_at` marks a // sample as tombstoned: hidden from every normal read path, recoverable from the // Trash view, and hard-deleted by the sweep once it ages past the retention // window. None of these operations suppress sync triggers, so a tombstone, an // undelete, and a permanent purge each replicate to the user's other devices. /// A tombstoned sample, as shown in the Trash view. #[derive(Debug, Clone)] pub struct TombstonedSample { pub hash: String, pub original_name: String, pub file_extension: String, pub file_size: i64, /// Unix timestamp (seconds) the sample was tombstoned. pub deleted_at: i64, } /// List every tombstoned sample, most recently deleted first. pub fn tombstoned_samples(db: &Database) -> Result> { // tombstone-ok: this IS the tombstone listing (Trash view) let mut stmt = db.conn().prepare( "SELECT hash, original_name, file_extension, file_size, deleted_at FROM samples WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC", )?; let rows = stmt .query_map([], |row| { Ok(TombstonedSample { hash: row.get(0)?, original_name: row.get(1)?, file_extension: row.get(2)?, file_size: row.get(3)?, deleted_at: row.get(4)?, }) })? .collect::, _>>()?; Ok(rows) } /// Soft-delete a sample: set `deleted_at` to now if it is currently live. The /// blob and every placement, tag, and collection membership stay intact so the /// user can recover from Trash until the sweep window expires. Returns `true` if /// a live row was tombstoned, `false` if the hash was unknown or already gone. #[instrument(skip_all)] pub fn tombstone_sample(db: &Database, hash: &str) -> Result { let n = db.conn().execute( "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2 AND deleted_at IS NULL", rusqlite::params![unix_now(), hash], )?; Ok(n > 0) } /// Restore a tombstoned sample, clearing `deleted_at`. Returns `true` if a /// tombstoned row was restored, `false` if the hash was unknown or already live. #[instrument(skip_all)] pub fn undelete_sample(db: &Database, hash: &str) -> Result { let n = db.conn().execute( "UPDATE samples SET deleted_at = NULL WHERE hash = ?1 AND deleted_at IS NOT NULL", [hash], )?; Ok(n > 0) } /// Read the tombstone retention window in days from `user_config`, falling back /// to the 30-day default (matching the M019 seed and macOS Trash convention) if /// the key is missing or unparseable. pub fn tombstone_retain_days(db: &Database) -> i64 { const DEFAULT_RETAIN_DAYS: i64 = 30; db.conn() .query_row( "SELECT value FROM user_config WHERE key = 'sample_tombstone_retain_days'", [], |row| row.get::<_, String>(0), ) .ok() .and_then(|v| v.parse::().ok()) .filter(|d| *d >= 0) .unwrap_or(DEFAULT_RETAIN_DAYS) } /// Clamp an imported file's display name before it enters the DB and the sync /// payload. The name is taken verbatim from disk, so bound its length (on a /// UTF-8 char boundary) to a filesystem-scale limit. SQL binds it as a /// parameter, so this is storage hygiene, not an injection guard. fn clamp_original_name(name: String) -> String { const MAX_BYTES: usize = 255; if name.len() <= MAX_BYTES { return name; } let mut end = MAX_BYTES; while end > 0 && !name.is_char_boundary(end) { end -= 1; } name[..end].to_string() } // --- Loose-files mode --- /// Look up the source_path for a sample (loose-files mode imports only). /// /// Returns `Ok(None)` for normal-mode samples (source_path is NULL). pub fn sample_source_path(db: &Database, hash: &str) -> Result> { db.conn() .query_row( "SELECT source_path FROM live_samples WHERE hash = ?1", [hash], |row| row.get(0), ) .map_err(|e| match e { rusqlite::Error::QueryReturnedNoRows => CoreError::SampleNotFound(hash.to_string()), other => CoreError::Db(other), }) } /// Where a sample's bytes live on disk. /// /// `Loose` is a loose-files sample (imported by reference): the bytes stay at the /// user's original `source_path`, never copied into the store. `Store` is a normal /// content-addressed blob. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SampleLocation { /// Content-addressed store blob at `{store_root}/{hash}[.ext]`. Store(PathBuf), /// Loose-files sample at the user's original path. Loose(PathBuf), } impl SampleLocation { /// The resolved on-disk path, regardless of which kind. pub fn path(&self) -> &Path { match self { SampleLocation::Store(p) | SampleLocation::Loose(p) => p, } } /// Consume into the resolved path. pub fn into_path(self) -> PathBuf { match self { SampleLocation::Store(p) | SampleLocation::Loose(p) => p, } } } /// Build the content-addressed store blob path for `hash`+`ext` under /// `store_root`. The single place `{store_root}/{hash}[.ext]` is constructed, no /// caller hand-rolls this join (it once drifted in the VFS mirror). pub fn store_blob_path(store_root: &Path, hash: &str, ext: &str) -> PathBuf { if ext.is_empty() { store_root.join(hash) } else { store_root.join(format!("{hash}.{ext}")) } } /// Single source of truth for "where does this sample's bytes live": the /// loose-files `source_path` if the sample has one, otherwise the store blob. /// /// Every consumer that needs a sample's file, playback, preview, export, and the /// VFS mirror, resolves through here (directly or via [`resolve_file_path`]), so a /// loose-files sample is never pointed at a non-existent store blob. This is a pure /// mapping (no filesystem access); [`resolve_file_path`] adds the runtime /// existence-fallback on top. pub fn sample_location( store_root: &Path, hash: &str, ext: &str, source_path: Option<&str>, ) -> SampleLocation { match source_path { Some(sp) => SampleLocation::Loose(PathBuf::from(sp)), None => SampleLocation::Store(store_blob_path(store_root, hash, ext)), } } /// Resolve the actual file path for a sample, checking source_path first. /// /// Builds on [`sample_location`] (the canonical loose-vs-store decision) and adds /// a runtime existence fallback: for a loose-files sample whose original file has /// moved, falls back to the store blob if one happens to exist, else returns the /// source path so the caller surfaces a clean "not found". pub fn resolve_file_path( store: &SampleStore, db: &Database, hash: &str, ext: &str, ) -> Result { match sample_location( store.root(), hash, ext, sample_source_path(db, hash)?.as_deref(), ) { SampleLocation::Store(_) => store.sample_path(hash, ext), SampleLocation::Loose(source) => { if source.exists() { return Ok(source); } // Fallback: maybe user re-imported in normal mode or placed file manually. let store_path = store.sample_path(hash, ext)?; if store_path.exists() { return Ok(store_path); } // Return the source path anyway, caller will handle the "not found". Ok(source) } } } /// Update the source_path for a sample after verifying the new file's hash matches. /// /// Used to relocate an loose-files mode sample whose original file has moved. pub fn relocate_sample( store: &SampleStore, db: &Database, hash: &str, new_path: &Path, ) -> Result<()> { // Verify hash matches let mut file = fs::File::open(new_path).map_err(|e| io_err(new_path, e))?; let mut hasher = Sha256::new(); let mut buf = [0u8; 8192]; loop { let n = file.read(&mut buf).map_err(|e| io_err(new_path, e))?; if n == 0 { break; } hasher.update(&buf[..n]); } let computed = hex::encode(hasher.finalize()); if computed != hash { return Err(CoreError::Internal(format!( "hash mismatch: expected {hash}, got {computed}; this is a different file" ))); } let abs_path = new_path .canonicalize() .map_err(|e| io_err(new_path, e))? .to_string_lossy() .to_string(); let changed = db.conn().execute( "UPDATE samples SET source_path = ?1 WHERE hash = ?2", rusqlite::params![abs_path, hash], )?; if changed == 0 { return Err(CoreError::SampleNotFound(hash.to_string())); } let _ = store; // unused but passed for API consistency Ok(()) } /// Hash a file's bytes with SHA-256, returning the hex digest and its size. /// /// Pure (no store/DB side effects), so a batch can be hashed in parallel before /// the serial copy+record step. Applies the same guards [`SampleStore::import`] /// did inline: rejects non-audio and zero-byte files. pub fn hash_file(path: &Path) -> Result<(String, i64)> { if !crate::util::is_audio_file(path) { return Err(CoreError::Internal(format!( "not a supported audio file: {}", path.display() ))); } let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?; let metadata = file.metadata().map_err(|e| io_err(path, e))?; let file_size = metadata.len() as i64; if file_size == 0 { return Err(CoreError::Internal(format!( "cannot import zero-byte file: {}", path.display() ))); } let mut hasher = Sha256::new(); let mut buf = [0u8; 8192]; loop { let n = file.read(&mut buf).map_err(|e| io_err(path, e))?; if n == 0 { break; } hasher.update(&buf[..n]); } Ok((hex::encode(hasher.finalize()), file_size)) } /// Copy `src` to `dst` while computing the SHA-256 of the bytes actually /// written, returning the hex digest. Used by the store write path so the copy /// can be verified against the content address it will be stored under, the /// hash is computed over the same bytes that land on disk, not over an earlier /// read of a source that may since have changed. Fsyncs `dst` before returning /// so the rename that follows can't be durable ahead of the contents. fn copy_hashing(src: &Path, dst: &Path) -> Result { let mut input = fs::File::open(src).map_err(|e| io_err(src, e))?; let mut output = fs::File::create(dst).map_err(|e| io_err(dst, e))?; let mut hasher = Sha256::new(); let mut buf = [0u8; 8192]; loop { let n = input.read(&mut buf).map_err(|e| io_err(src, e))?; if n == 0 { break; } hasher.update(&buf[..n]); output.write_all(&buf[..n]).map_err(|e| io_err(dst, e))?; } output.sync_all().map_err(|e| io_err(dst, e))?; Ok(hex::encode(hasher.finalize())) } /// Hash a batch of files in parallel (rayon). Each result is aligned to the /// corresponding entry in `paths`. Hashing is the dominant per-file import cost /// (SHA-256 over the whole file) and is embarrassingly parallel, every side /// effect (blob copy, DB insert) stays serial in the caller, so the content- /// addressed store invariants are untouched. pub fn hash_files_parallel(paths: &[PathBuf]) -> Vec> { use rayon::prelude::*; paths.par_iter().map(|p| hash_file(p)).collect() } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::TempDir; fn setup() -> (TempDir, Database, SampleStore) { let dir = TempDir::new().unwrap(); let db = Database::open_in_memory().unwrap(); let store_dir = dir.path().join("store"); let store = SampleStore::new(&store_dir).unwrap(); (dir, db, store) } fn create_test_file(dir: &TempDir, name: &str, content: &[u8]) -> PathBuf { let path = dir.path().join(name); let mut f = fs::File::create(&path).unwrap(); f.write_all(content).unwrap(); path } /// Give a sample one VFS placement, so CASCADE behaviour and placement /// preservation are observable. fn place_sample(db: &Database, hash: &str) { db.conn() .execute( "INSERT OR IGNORE INTO vfs (id, name, created_at, modified_at) \ VALUES (1, 'Library', 0, 0)", [], ) .unwrap(); db.conn() .execute( "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) \ VALUES (1, NULL, ?1, 'sample', ?1, 0)", [hash], ) .unwrap(); } fn count(db: &Database, sql: &str, hash: &str) -> i64 { db.conn().query_row(sql, [hash], |r| r.get(0)).unwrap() } #[test] fn tombstone_hides_sample_and_undelete_restores() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) .unwrap(); // Live: visible to the read path, absent from Trash. assert!(sample_extension(&db, &hash).is_ok()); assert!(tombstoned_samples(&db).unwrap().is_empty()); // Tombstone is a one-shot: the second call is a no-op. assert!(tombstone_sample(&db, &hash).unwrap()); assert!(!tombstone_sample(&db, &hash).unwrap()); // Hidden from the read path, surfaced in Trash with a timestamp. assert!(matches!( sample_extension(&db, &hash), Err(CoreError::SampleNotFound(_)) )); let trash = tombstoned_samples(&db).unwrap(); assert_eq!(trash.len(), 1); assert_eq!(trash[0].hash, hash); assert!(trash[0].deleted_at > 0); // Undelete restores it; a second undelete is a no-op. assert!(undelete_sample(&db, &hash).unwrap()); assert!(!undelete_sample(&db, &hash).unwrap()); assert!(sample_extension(&db, &hash).is_ok()); assert!(tombstoned_samples(&db).unwrap().is_empty()); } #[test] fn tombstone_preserves_placements_and_blob() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) .unwrap(); place_sample(&db, &hash); assert!(tombstone_sample(&db, &hash).unwrap()); // The whole point of soft delete: placements and the blob survive so the // user can recover everything. assert_eq!( count( &db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &hash ), 1 ); assert!(store.exists(&hash, "wav").unwrap()); } #[test] fn remove_purges_tombstoned_row_and_cascades() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "kick.wav", b"fake audio data"), &db) .unwrap(); place_sample(&db, &hash); assert!(tombstone_sample(&db, &hash).unwrap()); // Permanent delete must work on a tombstoned row even though the // filtered `sample_extension` would hide it, `remove` resolves the // blob path unfiltered. store.remove(&hash, &db).unwrap(); assert!(!store.exists(&hash, "wav").unwrap()); assert_eq!( count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &hash), 0 ); assert_eq!( count( &db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &hash ), 0 ); } #[test] fn sweep_hard_deletes_only_expired_tombstones() { let (dir, db, store) = setup(); let live = store .import(&create_test_file(&dir, "live.wav", b"live audio"), &db) .unwrap(); let fresh = store .import(&create_test_file(&dir, "fresh.wav", b"fresh audio"), &db) .unwrap(); let old = store .import(&create_test_file(&dir, "old.wav", b"old audio data"), &db) .unwrap(); place_sample(&db, &old); // fresh: tombstoned just now. old: tombstoned beyond the 30-day window. assert!(tombstone_sample(&db, &fresh).unwrap()); assert!(tombstone_sample(&db, &old).unwrap()); db.conn() .execute( "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2", rusqlite::params![unix_now() - 31 * 86_400, old], ) .unwrap(); let removed = store.sweep_expired_tombstones(&db).unwrap(); assert_eq!(removed, 1); // old is gone (row, blob, and CASCADE'd placement); fresh + live stay. assert!(!store.exists(&old, "wav").unwrap()); assert_eq!( count(&db, "SELECT COUNT(*) FROM samples WHERE hash = ?1", &old), 0 ); assert_eq!( count( &db, "SELECT COUNT(*) FROM vfs_nodes WHERE sample_hash = ?1", &old ), 0 ); assert_eq!(tombstoned_samples(&db).unwrap().len(), 1); assert!(sample_extension(&db, &live).is_ok()); assert!(store.exists(&fresh, "wav").unwrap()); } #[test] fn sweep_respects_retain_days_config() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "s.wav", b"some audio"), &db) .unwrap(); assert!(tombstone_sample(&db, &hash).unwrap()); db.conn() .execute( "UPDATE samples SET deleted_at = ?1 WHERE hash = ?2", rusqlite::params![unix_now() - 5 * 86_400, hash], ) .unwrap(); // 5 days old, default 30-day window: not yet expired. assert_eq!(tombstone_retain_days(&db), 30); assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 0); // Shrink the window to 3 days: now it sweeps. db.conn() .execute( "UPDATE user_config SET value = '3' WHERE key = 'sample_tombstone_retain_days'", [], ) .unwrap(); assert_eq!(tombstone_retain_days(&db), 3); assert_eq!(store.sweep_expired_tombstones(&db).unwrap(), 1); } #[test] fn clamp_original_name_caps_length_on_char_boundary() { // Short names pass through untouched. assert_eq!(clamp_original_name("kick.wav".to_string()), "kick.wav"); // Over-long ASCII is capped to 255 bytes. let long = "a".repeat(1000); assert_eq!(clamp_original_name(long).len(), 255); // Multi-byte chars at the cap don't split mid-codepoint (valid UTF-8). let multibyte = "é".repeat(200); // 400 bytes let clamped = clamp_original_name(multibyte); assert!(clamped.len() <= 255); assert!(std::str::from_utf8(clamped.as_bytes()).is_ok()); } #[test] fn hash_file_matches_import_and_rejects_bad_input() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fake audio data"); // hash_file's digest equals the hash import() records. let (hash, size) = hash_file(&src).unwrap(); assert_eq!(size, "fake audio data".len() as i64); let imported = store.import(&src, &db).unwrap(); assert_eq!(hash, imported); // Zero-byte and non-audio files are rejected (same guards as import). let empty = create_test_file(&dir, "empty.wav", b""); assert!(hash_file(&empty).is_err()); let txt = create_test_file(&dir, "notes.txt", b"hello"); assert!(hash_file(&txt).is_err()); } #[test] fn hash_file_matches_the_reference_sha256_digest() { // The content address is the library's primary key, so the exact hex // string a given byte sequence produces is a compatibility guarantee: // change it and every stored path and database row stops resolving. // Pinned against an independent SHA-256 of the same bytes, so a hasher // or hex-encoding swap has to survive a known answer, not just agree // with itself. let (dir, _db, _store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fake audio data"); let (hash, _) = hash_file(&src).unwrap(); assert_eq!( hash, "cec560f942befcb4e4a4d1161c5c03b3a787e2d525f650042641e62bf8773c69", "SHA-256 of b\"fake audio data\", lowercase hex, no separators" ); } #[test] fn import_hashed_matches_serial_import() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "snare.wav", b"some audio bytes"); // Pre-hash then record, the prehashed path must land the same blob + row // as the all-in-one import(). let (hash, size) = hash_file(&src).unwrap(); store.import_hashed(&src, &hash, size, &db).unwrap(); assert!(store.exists(&hash, "wav").unwrap()); let count: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM samples WHERE hash = ?1", [&hash], |r| r.get(0), ) .unwrap(); assert_eq!(count, 1); } #[test] fn hash_files_parallel_aligns_results() { let (dir, _db, _store) = setup(); let a = create_test_file(&dir, "a.wav", b"aaaa"); let b = create_test_file(&dir, "b.wav", b"bbbbbb"); let bad = create_test_file(&dir, "z.txt", b"nope"); // non-audio -> Err let results = hash_files_parallel(&[a.clone(), b.clone(), bad.clone()]); assert_eq!(results.len(), 3); // Aligned to input order; each Ok hash equals a direct hash_file call. assert_eq!(results[0].as_ref().unwrap().0, hash_file(&a).unwrap().0); assert_eq!(results[1].as_ref().unwrap().1, 6); assert!(results[2].is_err()); } #[test] fn import_creates_file_and_row() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fake audio data"); let hash = store.import(&src, &db).unwrap(); // File exists in store assert!(store.exists(&hash, "wav").unwrap()); // Row exists in DB let count: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM samples WHERE hash = ?1", [&hash], |row| row.get(0), ) .unwrap(); assert_eq!(count, 1); } #[test] fn import_deduplicates() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"same content"); let hash1 = store.import(&src, &db).unwrap(); let hash2 = store.import(&src, &db).unwrap(); assert_eq!(hash1, hash2); // Only one row let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn import_same_bytes_different_extension_reuses_blob() { let (dir, db, store) = setup(); // Identical bytes, two different audio extensions (is_audio_file keys on // extension, so the content can be arbitrary). let wav = create_test_file(&dir, "loop.wav", b"identical bytes"); let aiff = create_test_file(&dir, "loop.aiff", b"identical bytes"); let h1 = store.import(&wav, &db).unwrap(); let h2 = store.import(&aiff, &db).unwrap(); assert_eq!(h1, h2, "identical bytes hash to the same sample"); // Exactly one blob on disk: the second import must reuse `{hash}.wav`, not // write an unreachable `{hash}.aiff` orphan. let blob_count = || { std::fs::read_dir(store.root()) .unwrap() .filter_map(std::result::Result::ok) .filter(|e| e.path().is_file()) .count() }; assert_eq!( blob_count(), 1, "second extension must reuse the first blob" ); // And remove() leaves nothing behind, the orphan would otherwise survive, // since remove() resolves the blob path from the DB row's extension. store.remove(&h1, &db).unwrap(); assert_eq!(blob_count(), 0, "no orphan blob remains after remove"); } #[test] fn import_repairs_a_truncated_preexisting_blob() { // Reproduces the corrupt-blob trap: a crash mid-copy (or any partial // write) can leave a truncated file at the canonical content-addressed // path. A content-addressed store must never trust it, import must // detect the size mismatch and rewrite the correct bytes. let (dir, db, store) = setup(); let content = b"the genuine full sample payload"; let src = create_test_file(&dir, "kick.wav", content); // Pre-place a truncated blob at the canonical path the real import targets. let hash = hex::encode(Sha256::digest(content)); let dest = store.sample_path(&hash, "wav").unwrap(); fs::create_dir_all(dest.parent().unwrap()).unwrap(); fs::write(&dest, b"trunc").unwrap(); let imported = store.import(&src, &db).unwrap(); assert_eq!(imported, hash); // The stored blob now matches the source bytes exactly (hash verified). let stored = fs::read(&dest).unwrap(); assert_eq!(stored, content, "truncated blob must be repaired on import"); assert_eq!( hex::encode(Sha256::digest(&stored)), hash, "repaired blob hashes back to its content address" ); } #[test] fn remove_deletes_file_and_row() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "snare.wav", b"snare data"); let hash = store.import(&src, &db).unwrap(); assert!(store.exists(&hash, "wav").unwrap()); store.remove(&hash, &db).unwrap(); assert!(!store.exists(&hash, "wav").unwrap()); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn remove_nonexistent_returns_error() { let (_dir, db, store) = setup(); // Use a valid 64-char hex hash that doesn't exist in the DB let fake_hash = "a".repeat(64); let result = store.remove(&fake_hash, &db); assert!(matches!(result, Err(CoreError::SampleNotFound(_)))); } #[test] fn sample_path_rejects_traversal() { let (_dir, _db, store) = setup(); let result = store.sample_path("../../../etc/passwd", "wav"); assert!(matches!(result, Err(CoreError::HashInvalid(_)))); } #[test] fn sample_path_rejects_short_hash() { let (_dir, _db, store) = setup(); let result = store.sample_path("abcdef", "wav"); assert!(matches!(result, Err(CoreError::HashInvalid(_)))); } #[test] fn sample_path_rejects_uppercase() { let (_dir, _db, store) = setup(); let hash = "A".repeat(64); let result = store.sample_path(&hash, "wav"); assert!(matches!(result, Err(CoreError::HashInvalid(_)))); } #[test] fn sample_path_accepts_valid_hash() { let (_dir, _db, store) = setup(); let hash = "a1b2c3d4e5f6".to_string() + &"0".repeat(52); let path = store.sample_path(&hash, "wav").unwrap(); assert!(path.to_string_lossy().ends_with(".wav")); } #[test] fn sample_path_rejects_traversal_in_extension() { let (_dir, _db, store) = setup(); let hash = "a".repeat(64); let result = store.sample_path(&hash, "../etc/passwd"); assert!(matches!(result, Err(CoreError::Internal(_)))); } #[test] fn sample_path_rejects_path_separator_in_extension() { let (_dir, _db, store) = setup(); let hash = "a".repeat(64); let result = store.sample_path(&hash, "wav/../../etc"); assert!(matches!(result, Err(CoreError::Internal(_)))); } #[test] fn sample_path_accepts_common_extensions() { let (_dir, _db, store) = setup(); let hash = "a".repeat(64); for ext in &["wav", "mp3", "flac", "aiff", "ogg", "tar.gz"] { assert!( store.sample_path(&hash, ext).is_ok(), "rejected valid ext: {ext}" ); } } #[test] fn query_sample_field_rejects_disallowed_field() { let (_dir, db, _store) = setup(); let hash = "a".repeat(64); let result = query_sample_field(&db, &hash, "hash; DROP TABLE samples --"); assert!(matches!(result, Err(CoreError::Internal(_)))); } #[test] fn verify_sample_matches_after_import() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "hihat.wav", b"hihat audio data"); let hash = store.import(&src, &db).unwrap(); assert!(store.verify_sample(&hash, "wav").unwrap()); } #[test] fn verify_sample_detects_corruption() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "snare.wav", b"original data"); let hash = store.import(&src, &db).unwrap(); // Corrupt the stored file. Store blobs are written read-only (the // mirror write-through guard), so clear that first to simulate bit-rot. let stored_path = store.sample_path(&hash, "wav").unwrap(); let mut perms = fs::metadata(&stored_path).unwrap().permissions(); // Intentionally clear read-only to simulate bit-rot on a stored blob. #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); fs::set_permissions(&stored_path, perms).unwrap(); fs::write(&stored_path, b"corrupted data").unwrap(); assert!(!store.verify_sample(&hash, "wav").unwrap()); } #[test] fn verify_sample_errors_on_missing_file() { let (_dir, _db, store) = setup(); let fake_hash = "b".repeat(64); let result = store.verify_sample(&fake_hash, "wav"); assert!(matches!(result, Err(CoreError::Io { .. }))); } #[test] fn import_hashed_rejects_hash_that_does_not_match_bytes() { // Simulates the source file mutating between the parallel pre-hash pass // and the serial copy: import_hashed is handed a hash that does not // describe the bytes now on disk. The copy must be rejected, never // committed to `{wrong_hash}.ext`. let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"the actual bytes on disk"); let wrong_hash = hex::encode(Sha256::digest(b"what we hashed earlier")); let result = store.import_hashed(&src, &wrong_hash, 24, &db); assert!( matches!(result, Err(CoreError::HashMismatch(_))), "expected HashMismatch, got: {result:?}" ); // No blob may exist at the wrong content address, and no row inserted. assert!(!store.sample_path(&wrong_hash, "wav").unwrap().exists()); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); // And no temp file leaked in the store directory. let leaked = fs::read_dir(store.root()) .into_iter() .flatten() .flatten() .any(|e| e.file_name().to_string_lossy().contains(".tmp")); assert!(!leaked, "a .tmp file leaked after the rejected import"); } #[test] fn import_hashed_accepts_matching_hash() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "clap.wav", b"clap bytes"); let (hash, size) = hash_file(&src).unwrap(); store.import_hashed(&src, &hash, size, &db).unwrap(); assert!(store.verify_sample(&hash, "wav").unwrap()); } #[test] fn scrub_passes_a_clean_store() { let (dir, db, store) = setup(); let a = store .import(&create_test_file(&dir, "a.wav", b"aaaa"), &db) .unwrap(); let b = store .import(&create_test_file(&dir, "b.wav", b"bbbb"), &db) .unwrap(); assert_ne!(a, b); let (checked, corrupt) = store.scrub(&db).unwrap(); assert_eq!(checked, 2); assert!(corrupt.is_empty()); } #[test] fn scrub_reports_a_corrupt_blob() { let (dir, db, store) = setup(); store .import(&create_test_file(&dir, "good.wav", b"good"), &db) .unwrap(); let bad = store .import(&create_test_file(&dir, "bad.wav", b"original"), &db) .unwrap(); // Corrupt the stored blob in place (clear read-only first, as the store // marks canonical blobs read-only). let path = store.sample_path(&bad, "wav").unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); fs::set_permissions(&path, perms).unwrap(); fs::write(&path, b"tampered").unwrap(); let (checked, corrupt) = store.scrub(&db).unwrap(); assert_eq!(checked, 2); assert_eq!(corrupt, vec![bad]); } #[test] fn scrub_flags_a_missing_blob_as_corrupt() { let (dir, db, store) = setup(); let hash = store .import(&create_test_file(&dir, "gone.wav", b"here now"), &db) .unwrap(); let path = store.sample_path(&hash, "wav").unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); fs::set_permissions(&path, perms).unwrap(); fs::remove_file(&path).unwrap(); let (checked, corrupt) = store.scrub(&db).unwrap(); assert_eq!(checked, 1); assert_eq!(corrupt, vec![hash]); } #[test] fn import_rejects_zero_byte_file() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "empty.wav", b""); let result = store.import(&src, &db); assert!(result.is_err()); let err_msg = format!("{}", result.unwrap_err()); assert!( err_msg.contains("zero-byte"), "expected zero-byte error, got: {err_msg}" ); // No row should have been inserted let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn import_accepts_non_empty_file() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "valid.wav", b"audio content"); let hash = store.import(&src, &db).unwrap(); assert!(!hash.is_empty()); assert!(store.exists(&hash, "wav").unwrap()); } #[test] fn remove_tolerates_missing_file() { // If the blob has already been deleted out from under us (manual rm, // crash mid-remove, etc.), the DB row should still be cleaned up. let (dir, db, store) = setup(); let src = create_test_file(&dir, "ghost.wav", b"ghost data"); let hash = store.import(&src, &db).unwrap(); let stored = store.sample_path(&hash, "wav").unwrap(); fs::remove_file(&stored).unwrap(); store.remove(&hash, &db).unwrap(); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); } #[test] fn remove_deletes_file_before_db_row() { // Verify that after remove(), both the DB row and the file are gone. // The ordering guarantee (file first, then DB row) means a dangling DB // row is the only possible failure mode, never an orphaned blob. let (dir, db, store) = setup(); let src = create_test_file(&dir, "tom.wav", b"tom data"); let hash = store.import(&src, &db).unwrap(); let stored_path = store.sample_path(&hash, "wav").unwrap(); assert!(stored_path.exists()); store.remove(&hash, &db).unwrap(); // DB row gone let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 0); // File gone assert!(!stored_path.exists()); } #[test] fn remove_orphaned_samples_cleans_unreferenced() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"kick data"); let src2 = create_test_file(&dir, "snare.wav", b"snare data"); let hash1 = store.import(&src1, &db).unwrap(); let hash2 = store.import(&src2, &db).unwrap(); // Create a VFS and link only hash1 let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link(&db, vfs_id, None, "kick.wav", &hash1).unwrap(); // hash2 is orphaned (no VFS node), hash1 is referenced let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 1); // hash1 still exists, hash2 is gone assert!(store.exists(&hash1, "wav").unwrap()); assert!(!store.exists(&hash2, "wav").unwrap()); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn imported_blob_is_read_only() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"kick data"); let hash = store.import(&src, &db).unwrap(); let path = store.sample_path(&hash, "wav").unwrap(); assert!( fs::metadata(&path).unwrap().permissions().readonly(), "store blobs must be read-only so a mirror write-through fails loudly" ); } #[test] fn orphan_cleanup_does_not_push_sync_delete() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"kick data"); let src2 = create_test_file(&dir, "snare.wav", b"snare data"); let hash1 = store.import(&src1, &db).unwrap(); let _hash2 = store.import(&src2, &db).unwrap(); let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link(&db, vfs_id, None, "kick.wav", &hash1).unwrap(); // Clear anything import/link logged, then GC the orphan (hash2). db.conn().execute("DELETE FROM sync_changelog", []).unwrap(); let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 1); // Local GC must never push a destructive `samples` DELETE: that would // cascade-wipe another device's placements of the same blob. let pushed: i64 = db .conn() .query_row( "SELECT COUNT(*) FROM sync_changelog WHERE table_name = 'samples' AND op = 'DELETE'", [], |r| r.get(0), ) .unwrap(); assert_eq!( pushed, 0, "orphan cleanup must be local-only (sync suppressed)" ); // And the applying_remote flag is left cleared. let flag: String = db .conn() .query_row( "SELECT value FROM sync_state WHERE key = 'applying_remote'", [], |r| r.get(0), ) .unwrap(); assert_eq!(flag, "0"); } #[test] fn remove_orphaned_samples_keeps_referenced() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "hat.wav", b"hat data"); let hash = store.import(&src, &db).unwrap(); let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link(&db, vfs_id, None, "hat.wav", &hash).unwrap(); let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 0); assert!(store.exists(&hash, "wav").unwrap()); } #[test] fn remove_orphaned_after_vfs_delete() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "clap.wav", b"clap data"); let hash = store.import(&src, &db).unwrap(); let vfs_id = crate::vfs::create_vfs(&db, "Lib").unwrap(); crate::vfs::create_sample_link(&db, vfs_id, None, "clap.wav", &hash).unwrap(); // Delete the VFS (cascades to vfs_nodes) crate::vfs::delete_vfs(&db, vfs_id).unwrap(); // Sample is now orphaned let removed = store.remove_orphaned_samples(&db).unwrap(); assert_eq!(removed, 1); assert!(!store.exists(&hash, "wav").unwrap()); } // --- Loose-files mode tests --- #[test] fn import_loose_files_does_not_copy_file() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"unsafe kick data"); let hash = store.import_loose_files(&src, &db).unwrap(); // No file in the store assert!(!store.exists(&hash, "wav").unwrap()); // Row exists in DB with source_path set let sp: Option = db .conn() .query_row( "SELECT source_path FROM samples WHERE hash = ?1", [&hash], |row| row.get(0), ) .unwrap(); assert!(sp.is_some()); assert!(sp.unwrap().ends_with("kick.wav")); } #[test] fn import_loose_files_deduplicates() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"same unsafe content"); let hash1 = store.import_loose_files(&src, &db).unwrap(); let hash2 = store.import_loose_files(&src, &db).unwrap(); assert_eq!(hash1, hash2); let count: i64 = db .conn() .query_row("SELECT COUNT(*) FROM samples", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1); } #[test] fn sample_source_path_returns_none_for_normal() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"normal import"); let hash = store.import(&src, &db).unwrap(); assert!(sample_source_path(&db, &hash).unwrap().is_none()); } #[test] fn sample_source_path_returns_path_for_loose_files() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"unsafe import"); let hash = store.import_loose_files(&src, &db).unwrap(); let sp = sample_source_path(&db, &hash).unwrap(); assert!(sp.is_some()); } #[test] fn resolve_file_path_prefers_source_path() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"unsafe resolve test"); let hash = store.import_loose_files(&src, &db).unwrap(); let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap(); // Should resolve to the original file, not the store assert!(!resolved.starts_with(store.root())); } #[test] fn resolve_file_path_falls_back_to_store() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"fallback test"); // Import normally (file exists in store) let hash = store.import(&src, &db).unwrap(); let resolved = resolve_file_path(&store, &db, &hash, "wav").unwrap(); assert!(resolved.starts_with(store.root())); } #[test] fn relocate_sample_rejects_hash_mismatch() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"original content"); let hash = store.import_loose_files(&src, &db).unwrap(); let wrong_file = create_test_file(&dir, "snare.wav", b"different content"); let result = relocate_sample(&store, &db, &hash, &wrong_file); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("hash mismatch")); } #[test] fn relocate_sample_updates_source_path() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"relocate content"); let hash = store.import_loose_files(&src, &db).unwrap(); // Move the file let new_loc = dir.path().join("moved_kick.wav"); fs::copy(&src, &new_loc).unwrap(); relocate_sample(&store, &db, &hash, &new_loc).unwrap(); let sp = sample_source_path(&db, &hash).unwrap().unwrap(); assert!(sp.contains("moved_kick.wav")); } #[test] fn check_loose_files_integrity_counts_correctly() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"integrity kick"); let src2 = create_test_file(&dir, "snare.wav", b"integrity snare"); store.import_loose_files(&src1, &db).unwrap(); let hash2 = store.import_loose_files(&src2, &db).unwrap(); // Delete snare from disk to simulate missing file let sp = sample_source_path(&db, &hash2).unwrap().unwrap(); fs::remove_file(&sp).unwrap(); let (valid, missing) = check_loose_files_integrity(&db).unwrap(); assert_eq!(valid, 1); assert_eq!(missing, 1); } #[test] fn purge_missing_loose_files_removes_only_missing() { let (dir, db, store) = setup(); let src1 = create_test_file(&dir, "kick.wav", b"purge kick"); let src2 = create_test_file(&dir, "snare.wav", b"purge snare"); let hash1 = store.import_loose_files(&src1, &db).unwrap(); let hash2 = store.import_loose_files(&src2, &db).unwrap(); // Delete snare from disk let sp = sample_source_path(&db, &hash2).unwrap().unwrap(); fs::remove_file(&sp).unwrap(); let purged = purge_missing_loose_files(&db).unwrap(); assert_eq!(purged, 1); // kick still exists, snare is gone assert!(sample_source_path(&db, &hash1).is_ok()); assert!(matches!( sample_source_path(&db, &hash2), Err(CoreError::SampleNotFound(_)) )); } #[test] fn purge_missing_loose_files_noop_when_all_valid() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"all valid"); store.import_loose_files(&src, &db).unwrap(); let purged = purge_missing_loose_files(&db).unwrap(); assert_eq!(purged, 0); } #[test] fn relocate_missing_finds_moved_file_by_hash() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"moved-away content"); let hash = store.import_loose_files(&src, &db).unwrap(); // Move the source into a subdirectory and delete the original path. let subdir = dir.path().join("relocated"); fs::create_dir_all(&subdir).unwrap(); let moved = subdir.join("kick.wav"); fs::rename(&src, &moved).unwrap(); assert_eq!(check_loose_files_integrity(&db).unwrap(), (0, 1)); let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap(); assert_eq!(relocated, 1); assert_eq!(still_missing, 0); // source_path now points at the moved file, and integrity is restored. let sp = sample_source_path(&db, &hash).unwrap().unwrap(); assert!(sp.contains("relocated")); assert_eq!(check_loose_files_integrity(&db).unwrap(), (1, 0)); } #[test] fn relocate_missing_reports_still_missing_when_absent() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "ghost.wav", b"gone forever"); store.import_loose_files(&src, &db).unwrap(); fs::remove_file(&src).unwrap(); // Search a fresh empty directory, nothing to find. let empty = dir.path().join("empty"); fs::create_dir_all(&empty).unwrap(); let (relocated, still_missing) = relocate_missing_loose_files(&db, &empty).unwrap(); assert_eq!(relocated, 0); assert_eq!(still_missing, 1); } #[test] fn relocate_missing_ignores_same_name_different_content() { let (dir, db, store) = setup(); let src = create_test_file(&dir, "kick.wav", b"the real bytes"); store.import_loose_files(&src, &db).unwrap(); fs::remove_file(&src).unwrap(); // A decoy with the same basename but different content must NOT match // (hash verify guards against same-name collisions). let decoy_dir = dir.path().join("decoy"); fs::create_dir_all(&decoy_dir).unwrap(); fs::write(decoy_dir.join("kick.wav"), b"an impostor with other bytes").unwrap(); let (relocated, still_missing) = relocate_missing_loose_files(&db, dir.path()).unwrap(); assert_eq!(relocated, 0); assert_eq!(still_missing, 1); } }