Skip to main content

max / audiofiles

Extract loose-files subsystem from store into store/loose_files Convert store.rs to store/mod.rs and peel the loose-files subsystem into a new store/loose_files.rs: the integrity/relocate/purge free functions plus the impl SampleStore block for import_loose_files and import_loose_files_hashed. loose_files uses use super::* (inheriting the parent's named imports and reaching its private helpers as a descendant) plus an explicit tracing::instrument import for the macro. mod.rs re-exports via pub use loose_files::*, so every store::* path across the browser and app crates is unchanged, and the loose-files tests stay in mod.rs and resolve through the re-export. relocate_sample stays in mod.rs as a general source_path repoint. No behavior change; build and clippy clean, all 640 core tests pass, dependent crates build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 17:50 UTC
Commit: 5a46f4a04d2799e9e7409f4b098125fa76a0f15d
Parent: 230e3e8
3 files changed, +732 insertions, -500 deletions
@@ -1,2054 +0,0 @@
1 - //! Content-addressed sample storage: imports files by SHA-256 hash, deduplicates, and manages on-disk blobs.
2 - //!
3 - //! ## Why content-addressed storage
4 - //!
5 - //! - **Dedup by design:** Importing the same file twice is a no-op (same hash = same row).
6 - //! Users often have the same sample in multiple folders.
7 - //! - **Sync-friendly:** Hash is a stable, globally unique identifier across devices. No UUID
8 - //! collisions, no server-assigned IDs, no coordination needed during offline edits.
9 - //! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the
10 - //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later.
11 -
12 - use std::fs;
13 - use std::io::{Read, Write};
14 - use std::path::{Path, PathBuf};
15 -
16 - use sha2::{Digest, Sha256};
17 - use symphonia::core::formats::FormatOptions;
18 - use symphonia::core::io::MediaSourceStream;
19 - use symphonia::core::meta::MetadataOptions;
20 - use symphonia::core::probe::Hint;
21 -
22 - use crate::db::Database;
23 - use crate::error::{io_err, unix_now, CoreError, Result};
24 - use tracing::instrument;
25 -
26 - /// Probe an audio file to extract its duration from metadata/headers.
27 - /// Returns `None` if the duration cannot be determined without full decode.
28 - fn probe_duration(path: &Path) -> Option<f64> {
29 - let file = fs::File::open(path).ok()?;
30 - let mss = MediaSourceStream::new(Box::new(file), Default::default());
31 - let mut hint = Hint::new();
32 - if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
33 - hint.with_extension(ext);
34 - }
35 - if let Ok(probed) = symphonia::default::get_probe()
36 - .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
37 - {
38 - let track = probed.format.default_track()?;
39 - let time_base = track.codec_params.time_base?;
40 - let n_frames = track.codec_params.n_frames?;
41 - let duration = time_base.calc_time(n_frames);
42 - return Some(duration.seconds as f64 + duration.frac);
43 - }
44 - // Fallback for WAV files Symphonia rejects (non-standard fmt chunk sizes)
45 - let is_wav = path.extension().and_then(|e| e.to_str())
46 - .is_some_and(|e| e.eq_ignore_ascii_case("wav"));
47 - if is_wav {
48 - let reader = hound::WavReader::open(path).ok()?;
49 - let spec = reader.spec();
50 - let n_samples = reader.len() as f64;
51 - let frames = n_samples / spec.channels as f64;
52 - return Some(frames / spec.sample_rate as f64);
53 - }
54 - None
55 - }
56 -
57 - /// Validate that a file extension contains only safe characters.
58 - ///
59 - /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg,
60 - /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that
61 - /// could be used for directory traversal.
62 - pub fn validate_extension(ext: &str) -> Result<()> {
63 - if !ext.is_empty()
64 - && !ext
65 - .bytes()
66 - .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
67 - {
68 - return Err(CoreError::Internal(format!(
69 - "invalid file extension: {ext:?}"
70 - )));
71 - }
72 - Ok(())
73 - }
74 -
75 - /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256).
76 - ///
77 - /// Delegates to [`crate::SampleHash::validated`] so the rule lives in exactly one
78 - /// place — historically these were two parallel validators that could drift
79 - /// (e.g. on uppercase handling).
80 - pub fn validate_hash(hash: &str) -> Result<()> {
81 - crate::SampleHash::validated(hash).map(|_| ()).map_err(|_| {
82 - CoreError::HashInvalid(format!(
83 - "expected 64 lowercase hex chars, got {:?} ({} chars)",
84 - hash,
85 - hash.len()
86 - ))
87 - })
88 - }
89 -
90 - /// Mark a stored blob read-only (best-effort, cross-platform). A failed chmod
91 - /// must never fail an import — it only weakens the write-through guard.
92 - fn set_blob_readonly(path: &Path) {
93 - if let Ok(meta) = fs::metadata(path) {
94 - let mut perms = meta.permissions();
95 - perms.set_readonly(true);
96 - let _ = fs::set_permissions(path, perms);
97 - }
98 - }
99 -
100 - /// Manages on-disk sample blobs in a flat directory structure, storing files as
101 - /// `{sha256_hex}.{ext}` directly in the root directory.
102 - ///
103 - /// Deduplication via SHA-256: import streams the file through a hasher and skips
104 - /// the copy if a blob with the same hash already exists.
105 - ///
106 - /// Thread-safe: all operations are stateless reads/writes against the filesystem
107 - /// (no interior mutability or locks).
108 - pub struct SampleStore {
109 - root: PathBuf,
110 - }
111 -
112 - impl SampleStore {
113 - /// Create a new sample store, ensuring the root directory exists.
114 - #[instrument(skip_all)]
115 - pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
116 - let root = root.into();
117 - fs::create_dir_all(&root).map_err(|e| io_err(&root, e))?;
118 - Ok(Self { root })
119 - }
120 -
121 - /// Import a file into the store: hash it, copy to content-addressed path,
122 - /// insert into DB. Returns the hex SHA-256 hash.
123 - #[instrument(skip_all)]
124 - pub fn import(&self, path: &Path, db: &Database) -> Result<String> {
125 - let (hash, file_size) = hash_file(path)?;
126 - self.import_hashed(path, &hash, file_size, db)?;
127 - Ok(hash)
128 - }
129 -
130 - /// Import a file whose SHA-256 hash and size were already computed (e.g. by a
131 - /// parallel pre-hash pass). Does the serial side-effecting work — content-
132 - /// addressed blob copy + DB insert + orphan cleanup — exactly as [`import`].
133 - /// Splitting the pure hash out lets the import pipeline hash a batch in
134 - /// parallel while keeping every store/DB mutation serial.
135 - #[instrument(skip_all)]
136 - pub fn import_hashed(
137 - &self,
138 - path: &Path,
139 - hash: &str,
140 - file_size: i64,
141 - db: &Database,
142 - ) -> Result<()> {
143 - // Resolve the blob extension from an existing row if this hash is already
144 - // known. The store is content-addressed (one blob per hash), so identical
145 - // bytes imported under a different extension must reuse the existing blob
146 - // rather than write a second `{hash}.{newext}` file: remove() resolves the
147 - // blob path from the DB row, so any divergent-extension copy would be
148 - // unreachable and leak disk forever. Query without the deleted_at filter so
149 - // the blob naming stays consistent even for soft-deleted rows. Fresh import
150 - // falls back to the incoming file's extension.
151 - // tombstone-ok: point-lookup that intentionally includes tombstoned rows
152 - let ext = match db.conn().query_row(
153 - "SELECT file_extension FROM samples WHERE hash = ?1",
154 - [hash],
155 - |row| row.get::<_, String>(0),
156 - ) {
157 - Ok(existing) => existing,
158 - Err(rusqlite::Error::QueryReturnedNoRows) => crate::util::get_extension(path),
159 - Err(e) => return Err(CoreError::Db(e)),
160 - };
161 - let original_name = clamp_original_name(crate::util::get_filename(path, "unknown"));
162 -
163 - // Probe duration from file headers (cheap, no full decode)
164 - let duration = probe_duration(path);
165 -
166 - // Copy file to store if not already present *and intact*. Write to a
167 - // temp path and atomically rename: a crash / full disk mid-copy must not
168 - // leave a truncated blob at the canonical content-addressed path,
169 - // because dedup would then trust that corrupt blob under this hash
170 - // forever. We also repair an already-present blob whose size doesn't
171 - // match the source (a partial left by a pre-atomic-fix crash): since the
172 - // store is content-addressed, the correct blob's size is exactly the
173 - // source's, so a cheap size check catches truncation without re-hashing.
174 - // Mirrors the atomic temp+rename the sync download path uses. The pid
175 - // suffix keeps two concurrent imports of the same hash from colliding on
176 - // the temp file.
177 - let dest = self.sample_path(hash, &ext)?;
178 - let needs_write = match fs::metadata(&dest) {
179 - Ok(m) => m.len() != file_size as u64,
180 - Err(_) => true,
181 - };
182 - if needs_write {
183 - let tmp = dest.with_file_name(format!("{hash}.{ext}.{}.tmp", std::process::id()));
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 - )));
203 - }
204 - if let Err(e) = fs::rename(&tmp, &dest) {
205 - let _ = fs::remove_file(&tmp);
206 - return Err(io_err(&dest, e));
207 - }
208 - // Best-effort fsync of the directory so the rename itself is durable
209 - // (the new dirent survives a crash). Not all filesystems support
210 - // directory fsync; failure here only weakens durability, never the
211 - // import.
212 - if let Some(parent) = dest.parent()
213 - && let Ok(d) = fs::File::open(parent) {
214 - let _ = d.sync_all();
215 - }
216 - // Mark the canonical blob read-only. The VFS mirror exposes store
217 - // blobs to DAWs/file managers via symlinks; a "save in place" would
218 - // otherwise write through and corrupt the SHA-256-named blob,
219 - // silently invalidating dedup for every other placement and device.
220 - // Read-only makes that write fail loudly. Unlink still works (it
221 - // needs write on the directory, not the file). Best-effort: a
222 - // filesystem that rejects the chmod must not fail the import.
223 - set_blob_readonly(&dest);
224 - }
225 -
226 - // Insert into DB (ignore if hash already exists). If this fails after we
227 - // just wrote a fresh blob, that blob is unreferenced — no row points at
228 - // it, and since `needs_write` was true nothing pointed at it before
229 - // either. Unlink it so a DB-write failure (disk full on the WAL, lock
230 - // timeout) can't leak an unreachable blob that no GC path reclaims —
231 - // upholding this module's "never orphan a blob" invariant (see remove()).
232 - // When `needs_write` was false the blob pre-existed and may be shared by
233 - // another row, so it is left untouched.
234 - let now = unix_now();
235 - if let Err(e) = db.conn().execute(
236 - "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration)
237 - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
238 - rusqlite::params![hash, original_name, ext, file_size, now, now, duration],
239 - ) {
240 - if needs_write {
241 - let _ = fs::remove_file(&dest);
242 - }
243 - return Err(CoreError::Db(e));
244 - }
245 -
246 - Ok(())
247 - }
248 -
249 - /// Check if a sample file exists in the store.
250 - pub fn exists(&self, hash: &str, ext: &str) -> Result<bool> {
251 - Ok(self.sample_path(hash, ext)?.exists())
252 - }
253 -
254 - /// Get the filesystem path for a sample.
255 - ///
256 - /// Validates that `hash` is exactly 64 lowercase hex characters (SHA-256)
257 - /// to prevent directory traversal or malformed paths.
258 - pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> {
259 - validate_hash(hash)?;
260 - validate_extension(ext)?;
261 - Ok(store_blob_path(&self.root, hash, ext))
262 - }
263 -
264 - /// Remove a sample from store and database. CASCADE handles VFS/tag refs.
265 - ///
266 - /// Deletes the file from disk first, then the DB row. If the file delete
267 - /// fails (in-use on Windows, permission denied, etc.), the DB row is left
268 - /// intact so the user can retry; nothing leaks. The reverse order would
269 - /// silently leak orphan blobs on every file-delete failure, accumulating
270 - /// disk waste invisible to the user.
271 - #[instrument(skip_all)]
272 - pub fn remove(&self, hash: &str, db: &Database) -> Result<()> {
273 - // Resolve the extension without the `deleted_at IS NULL` filter: this is
274 - // the hard-delete path, and the row may be a tombstone (purged from the
275 - // Trash view, or swept past its retention window). The blob name is
276 - // content-addressed and identical regardless of tombstone state, so the
277 - // unfiltered lookup is the correct one for locating the file to unlink.
278 - let ext = sample_extension_any(db, hash)?;
279 - let path = self.sample_path(hash, &ext)?;
280 -
281 - // File first. ENOENT is fine — the row was already pointing at nothing.
282 - match fs::remove_file(&path) {
283 - Ok(()) => {}
284 - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
285 - Err(e) => return Err(io_err(&path, e)),
286 - }
287 -
288 - // Then the DB row (CASCADE handles tags, vfs_nodes, etc.).
289 - db.conn()
290 - .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
291 -
292 - Ok(())
293 - }
294 -
295 - /// Remove samples that are no longer referenced by any VFS node.
296 - ///
297 - /// This is a **local** garbage-collection of disk space, not a cross-device
298 - /// statement: each device has its own set of orphans. Sync triggers are
299 - /// suppressed for the duration so the row deletes never push a `samples`
300 - /// DELETE that would cascade-wipe another device's placements (engine-level
301 - /// CASCADE is not trigger-suppressible — see
302 - /// `docs/design-sample-deletion.md`). The suppression lives here, inside the
303 - /// transaction, so no caller can forget it: the flag flip is invisible to
304 - /// other connections under WAL snapshot isolation, so a concurrent import on
305 - /// the GUI connection still syncs normally.
306 - ///
307 - /// Returns the number of orphaned samples removed.
308 - #[instrument(skip_all)]
309 - pub fn remove_orphaned_samples(&self, db: &Database) -> Result<usize> {
310 - // Skip tombstoned rows — the eventual hard-delete sweep
311 - // (docs/design-sample-deletion.md Phase 4) will get them when the
312 - // retention window expires. Double-processing here would race the
313 - // sweep and pre-emptively destroy still-recoverable data.
314 - let mut stmt = db.conn().prepare(
315 - "SELECT s.hash, s.file_extension
316 - FROM live_samples s
317 - LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
318 - WHERE vn.id IS NULL",
319 - )?;
320 - let orphans: Vec<(String, String)> = stmt
321 - .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
322 - .collect::<std::result::Result<Vec<_>, _>>()?;
323 - drop(stmt);
324 -
325 - // Delete orphan rows in a single transaction with sync suppressed.
326 - // Re-check orphan status inside the DELETE so we don't lose a race with
327 - // a concurrent import that linked the sample after the query above —
328 - // and so we only unlink blobs for rows we actually removed (a live
329 - // re-link must keep both row and blob).
330 - //
331 - // The blob unlink runs INSIDE the transaction, while `BEGIN IMMEDIATE`
332 - // still holds the write lock. This is load-bearing: cleanup and import
333 - // hold separate connections (serialized only by SQLite's write lock,
334 - // not the in-process Database mutex), so an unlink performed after
335 - // COMMIT could delete a blob that a concurrent import had already
336 - // re-adopted in the gap (import sees the blob present, re-inserts the
337 - // row, leaves the blob) — leaving a live row pointing at a missing
338 - // file. Holding the write lock across the unlink makes that re-adoption
339 - // unable to commit until the blob is already gone, so the re-import
340 - // re-writes the blob instead. A failed unlink only leaves an orphan
341 - // blob (disk waste, reclaimed by a later re-import or verify sweep) and
342 - // must not roll back the committed row delete, so its error is ignored.
343 - let deleted = db.transaction(|_tx| {
344 - db.conn().execute(
345 - "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'",
346 - [],
347 - )?;
348 - let mut count = 0usize;
349 - for (hash, ext) in &orphans {
350 - let n = db.conn().execute(
351 - "DELETE FROM samples WHERE hash = ?1 \
352 - AND NOT EXISTS (SELECT 1 FROM vfs_nodes WHERE sample_hash = ?1) \
353 - AND deleted_at IS NULL",
354 - [hash],
355 - )?;
356 - if n > 0 {
357 - if let Ok(path) = self.sample_path(hash, ext)
358 - && path.exists()
359 - {
360 - let _ = fs::remove_file(&path);
361 - }
362 - count += 1;
363 - }
364 - }
365 - db.conn().execute(
366 - "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'",
367 - [],
368 - )?;
369 - Ok(count)
370 - })?;
371 -
372 - Ok(deleted)
373 - }
374 -
375 - /// Hard-delete tombstoned samples whose retention window has expired.
376 - ///
377 - /// A sample tombstoned more than `sample_tombstone_retain_days` ago (default
378 - /// 30) is past recovery: this removes its blob and its `samples` row, letting
379 - /// the engine CASCADE clean up placements, tags, and collection memberships.
380 - /// Unlike the local orphan GC, the `samples` DELETE is **not** sync-suppressed
381 - /// — the deletion propagates so the user's other devices converge (each having
382 - /// had the same window to recover). Runs once at startup; the indexed
383 - /// `deleted_at` partial index keeps the scan cheap when nothing has expired.
384 - ///
385 - /// A blob/row removal that fails for one sample is logged and skipped so a
386 - /// single stuck file (in-use on Windows, permission denied) doesn't abort the
387 - /// rest of the sweep. Returns the number of samples hard-deleted.
388 - #[instrument(skip_all)]
389 - pub fn sweep_expired_tombstones(&self, db: &Database) -> Result<usize> {
390 - let cutoff = unix_now() - tombstone_retain_days(db) * 86_400;
391 -
392 - // tombstone-ok: the sweep operates on tombstones by definition
393 - let mut stmt = db.conn().prepare(
394 - "SELECT hash FROM samples WHERE deleted_at IS NOT NULL AND deleted_at < ?1",
395 - )?;
396 - let expired: Vec<String> = stmt
397 - .query_map([cutoff], |row| row.get(0))?
398 - .collect::<std::result::Result<Vec<_>, _>>()?;
399 - drop(stmt);
400 -
401 - let mut removed = 0usize;
402 - for hash in &expired {
403 - match self.remove(hash, db) {
404 - Ok(()) => removed += 1,
405 - Err(e) => tracing::warn!(hash = %hash, "tombstone sweep: failed to purge sample: {e}"),
406 - }
407 - }
408 - Ok(removed)
409 - }
410 -
411 - /// Get the store root directory.
412 - pub fn root(&self) -> &Path {
413 - &self.root
414 - }
415 -
416 - /// Re-hash a stored sample and compare against the expected hash.
417 - ///
418 - /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if
419 - /// it differs. Returns an error if the file cannot be read.
420 - #[instrument(skip_all)]
421 - pub fn verify_sample(&self, hash: &str, ext: &str) -> Result<bool> {
422 - let path = self.sample_path(hash, ext)?;
423 - let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
424 -
425 - let mut hasher = Sha256::new();
426 - let mut buf = [0u8; 8192];
427 - loop {
428 - let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?;
429 - if n == 0 {
430 - break;
431 - }
432 - hasher.update(&buf[..n]);
433 - }
434 - let computed = format!("{:x}", hasher.finalize());
435 -
436 - Ok(computed == hash)
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 - }
477 - }
478 -
479 - // --- Sample metadata queries ---
480 -
481 - /// Helper to query a single text column from the samples table by hash.
482 - ///
483 - /// Only fields in the allowlist may be queried; any other value returns an error
484 - /// to prevent SQL injection through the interpolated column name.
485 - fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> {
486 - const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"];
487 -
488 - if !ALLOWED_FIELDS.contains(&field) {
489 - return Err(CoreError::Internal(format!(
490 - "query_sample_field: disallowed field {field:?}"
491 - )));
492 - }
493 -
494 - let sql = format!("SELECT {field} FROM live_samples WHERE hash = ?1");
495 - db.conn()
496 - .query_row(&sql, [hash], |row| row.get(0))
497 - .map_err(|e| match e {
498 - rusqlite::Error::QueryReturnedNoRows => {
499 - CoreError::SampleNotFound(hash.to_string())
500 - }
Lines truncated
@@ -0,0 +1,232 @@
1 + //! Loose-files mode: import samples by reference (recording their on-disk
2 + //! `source_path`) instead of copying blobs into the content-addressed store,
3 + //! plus the integrity / relocate / purge operations over those references.
4 + //!
5 + //! Split out of the parent `store` module; every path stays `store::*` via the
6 + //! parent's `pub use loose_files::*`.
7 +
8 + use super::*;
9 + use tracing::instrument;
10 +
11 + /// Check integrity of loose-files mode samples.
12 + ///
13 + /// Returns `(valid, missing)` — counts of source_path entries where the file
14 + /// exists vs. does not exist on disk.
15 + pub fn check_loose_files_integrity(db: &Database) -> Result<(usize, usize)> {
16 + let mut stmt = db.conn().prepare(
17 + "SELECT source_path FROM live_samples WHERE source_path IS NOT NULL",
18 + )?;
19 + let paths: Vec<String> = stmt
20 + .query_map([], |row| row.get(0))?
21 + .collect::<std::result::Result<Vec<_>, _>>()?;
22 +
23 + let mut valid = 0;
24 + let mut missing = 0;
25 + for p in &paths {
26 + if Path::new(p).exists() {
27 + valid += 1;
28 + } else {
29 + missing += 1;
30 + }
31 + }
32 + Ok((valid, missing))
33 + }
34 +
35 + /// Try to re-locate loose-files mode samples whose source files have moved.
36 + ///
37 + /// Walks `search_root` recursively, building a basename map of candidate
38 + /// files. For each missing sample, looks up its basename, then hash-verifies
39 + /// candidates (cheapest: size-check before re-hashing the full file). On hash
40 + /// match, the sample's `source_path` is updated to the new location.
41 + ///
42 + /// Returns `(relocated, still_missing)`. `relocated` counts samples whose
43 + /// `source_path` was successfully repointed; `still_missing` is the residual
44 + /// count for the dialog to surface so the user can run Locate again against a
45 + /// different directory.
46 + pub fn relocate_missing_loose_files(
47 + db: &Database,
48 + search_root: &Path,
49 + ) -> Result<(usize, usize)> {
50 + // 1. Gather missing samples — hash, basename of stored source_path, and
51 + // the recorded file_size (used for cheap pre-filter before re-hashing).
52 + let mut stmt = db.conn().prepare(
53 + "SELECT hash, source_path, file_size FROM live_samples \
54 + WHERE source_path IS NOT NULL",
55 + )?;
56 + let rows: Vec<(String, String, i64)> = stmt
57 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
58 + .collect::<std::result::Result<Vec<_>, _>>()?;
59 + let missing: Vec<(String, String, i64)> = rows
60 + .into_iter()
61 + .filter(|(_, source_path, _)| !Path::new(source_path).exists())
62 + .collect();
63 +
64 + if missing.is_empty() {
65 + return Ok((0, 0));
66 + }
67 +
68 + // 2. Walk search_root, build basename -> Vec<PathBuf>. Lowercased so a
69 + // case-changed filesystem (e.g. moved between macOS/Linux) still
70 + // matches. Bounded by what the filesystem returns; large trees walk
71 + // once and stay in memory for the duration of this call.
72 + let mut candidates: std::collections::HashMap<String, Vec<PathBuf>> =
73 + std::collections::HashMap::new();
74 + let mut dirs = vec![search_root.to_path_buf()];
75 + while let Some(d) = dirs.pop() {
76 + let Ok(entries) = std::fs::read_dir(&d) else { continue };
77 + for entry in entries.flatten() {
78 + let path = entry.path();
79 + if path.is_dir() {
80 + if !crate::util::is_macos_metadata_dir(&path) {
81 + dirs.push(path);
82 + }
83 + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
84 + candidates
85 + .entry(name.to_lowercase())
86 + .or_default()
87 + .push(path);
88 + }
89 + }
90 + }
91 +
92 + // 3. For each missing sample, check candidates with matching basename.
93 + // Size check filters out same-name-different-file collisions before we
94 + // spend cycles hashing. Hash verify is the authoritative match.
95 + let mut relocated_pairs: Vec<(String, String)> = Vec::new();
96 + for (hash, source_path, file_size) in &missing {
97 + let Some(basename) = Path::new(source_path)
98 + .file_name()
99 + .and_then(|n| n.to_str())
100 + else { continue };
101 + let key = basename.to_lowercase();
102 + let Some(paths) = candidates.get(&key) else { continue };
103 + for cand in paths {
104 + let Ok(md) = std::fs::metadata(cand) else { continue };
105 + if md.len() as i64 != *file_size {
106 + continue;
107 + }
108 + // Hash verify. Bail on first match; sample hashes are unique so a
109 + // second match for the same hash would be redundant.
110 + let Ok(mut file) = fs::File::open(cand) else { continue };
111 + let mut hasher = Sha256::new();
112 + let mut buf = [0u8; 8192];
113 + let ok = loop {
114 + let Ok(n) = file.read(&mut buf) else { break false };
115 + if n == 0 {
116 + break true;
117 + }
118 + hasher.update(&buf[..n]);
119 + };
120 + if !ok {
121 + continue;
122 + }
123 + let computed = format!("{:x}", hasher.finalize());
124 + if computed == *hash {
125 + let abs = cand
126 + .canonicalize()
127 + .map(|p| p.to_string_lossy().to_string())
128 + .unwrap_or_else(|_| cand.to_string_lossy().to_string());
129 + relocated_pairs.push((hash.clone(), abs));
130 + break;
131 + }
132 + }
133 + }
134 +
135 + // 4. Atomic update of all relocated source_paths.
136 + let relocated = relocated_pairs.len();
137 + if relocated > 0 {
138 + db.transaction(|_tx| {
139 + for (hash, new_path) in &relocated_pairs {
140 + db.conn().execute(
141 + "UPDATE samples SET source_path = ?1 WHERE hash = ?2",
142 + rusqlite::params![new_path, hash],
143 + )?;
144 + }
145 + Ok(())
146 + })?;
147 + }
148 +
149 + let still_missing = missing.len() - relocated;
150 + Ok((relocated, still_missing))
151 + }
152 +
153 + /// Delete all loose-files mode samples whose source files no longer exist on disk.
154 + ///
155 + /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc.
156 + pub fn purge_missing_loose_files(db: &Database) -> Result<usize> {
157 + let mut stmt = db.conn().prepare(
158 + "SELECT hash, source_path FROM live_samples WHERE source_path IS NOT NULL",
159 + )?;
160 + let rows: Vec<(String, String)> = stmt
161 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
162 + .collect::<std::result::Result<Vec<_>, _>>()?;
163 +
164 + // Collect hashes to purge, then delete atomically in one transaction.
165 + let to_purge: Vec<&str> = rows
166 + .iter()
167 + .filter(|(_, source_path)| !Path::new(source_path).exists())
168 + .map(|(hash, _)| hash.as_str())
169 + .collect();
170 + let purged = to_purge.len();
171 +
172 + if purged > 0 {
173 + db.transaction(|_tx| {
174 + for hash in &to_purge {
175 + db.conn()
176 + .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
177 + }
178 + Ok(())
179 + })?;
180 + }
181 +
182 + Ok(purged)
183 + }
184 +
185 + impl SampleStore {
186 + /// Import a file in loose-files mode: hash it but do NOT copy to the store.
187 + ///
188 + /// Records the original absolute path as `source_path` in the database.
189 + /// The file stays where it is on disk.
190 + #[instrument(skip_all)]
191 + pub fn import_loose_files(&self, path: &Path, db: &Database) -> Result<String> {
192 + let (hash, file_size) = hash_file(path)?;
193 + self.import_loose_files_hashed(path, &hash, file_size, db)?;
194 + Ok(hash)
195 + }
196 +
197 + /// Loose-files import with a pre-computed hash (see [`import_hashed`]). Records
198 + /// the source path; no blob copy.
199 + ///
200 + /// [`import_hashed`]: SampleStore::import_hashed
201 + #[instrument(skip_all)]
202 + pub fn import_loose_files_hashed(
203 + &self,
204 + path: &Path,
205 + hash: &str,
206 + file_size: i64,
207 + db: &Database,
208 + ) -> Result<()> {
209 + let ext = crate::util::get_extension(path);
210 + let original_name = clamp_original_name(crate::util::get_filename(path, "unknown"));
211 +
212 + // Probe duration from file headers (cheap, no full decode)
213 + let duration = probe_duration(path);
214 +
215 + // Resolve absolute path for storage
216 + let abs_path = path
217 + .canonicalize()
218 + .map_err(|e| io_err(path, e))?
219 + .to_string_lossy()
220 + .to_string();
221 +
222 + // Insert into DB with source_path (ignore if hash already exists)
223 + let now = unix_now();
224 + db.conn().execute(
225 + "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration, source_path)
226 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
227 + rusqlite::params![hash, original_name, ext, file_size, now, now, duration, abs_path],
228 + )?;
229 +
230 + Ok(())
231 + }
232 + }
@@ -0,0 +1,1834 @@
1 + //! Content-addressed sample storage: imports files by SHA-256 hash, deduplicates, and manages on-disk blobs.
2 + //!
3 + //! ## Why content-addressed storage
4 + //!
5 + //! - **Dedup by design:** Importing the same file twice is a no-op (same hash = same row).
6 + //! Users often have the same sample in multiple folders.
7 + //! - **Sync-friendly:** Hash is a stable, globally unique identifier across devices. No UUID
8 + //! collisions, no server-assigned IDs, no coordination needed during offline edits.
9 + //! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the
10 + //! metadata row. The hash lets SyncKit re-download the exact file from blob storage later.
11 +
12 + use std::fs;
13 + use std::io::{Read, Write};
14 + use std::path::{Path, PathBuf};
15 +
16 + use sha2::{Digest, Sha256};
17 + use symphonia::core::formats::FormatOptions;
18 + use symphonia::core::io::MediaSourceStream;
19 + use symphonia::core::meta::MetadataOptions;
20 + use symphonia::core::probe::Hint;
21 +
22 + use crate::db::Database;
23 + use crate::error::{io_err, unix_now, CoreError, Result};
24 + use tracing::instrument;
25 +
26 + mod loose_files;
27 + pub use loose_files::*;
28 +
29 + /// Probe an audio file to extract its duration from metadata/headers.
30 + /// Returns `None` if the duration cannot be determined without full decode.
31 + fn probe_duration(path: &Path) -> Option<f64> {
32 + let file = fs::File::open(path).ok()?;
33 + let mss = MediaSourceStream::new(Box::new(file), Default::default());
34 + let mut hint = Hint::new();
35 + if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
36 + hint.with_extension(ext);
37 + }
38 + if let Ok(probed) = symphonia::default::get_probe()
39 + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
40 + {
41 + let track = probed.format.default_track()?;
42 + let time_base = track.codec_params.time_base?;
43 + let n_frames = track.codec_params.n_frames?;
44 + let duration = time_base.calc_time(n_frames);
45 + return Some(duration.seconds as f64 + duration.frac);
46 + }
47 + // Fallback for WAV files Symphonia rejects (non-standard fmt chunk sizes)
48 + let is_wav = path.extension().and_then(|e| e.to_str())
49 + .is_some_and(|e| e.eq_ignore_ascii_case("wav"));
50 + if is_wav {
51 + let reader = hound::WavReader::open(path).ok()?;
52 + let spec = reader.spec();
53 + let n_samples = reader.len() as f64;
54 + let frames = n_samples / spec.channels as f64;
55 + return Some(frames / spec.sample_rate as f64);
56 + }
57 + None
58 + }
59 +
60 + /// Validate that a file extension contains only safe characters.
61 + ///
62 + /// Allows alphanumeric, dots, and hyphens (covers wav, mp3, flac, aiff, ogg,
63 + /// tar.gz, etc.). Rejects path separators, null bytes, and anything else that
64 + /// could be used for directory traversal.
65 + pub fn validate_extension(ext: &str) -> Result<()> {
66 + if !ext.is_empty()
67 + && !ext
68 + .bytes()
69 + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
70 + {
71 + return Err(CoreError::Internal(format!(
72 + "invalid file extension: {ext:?}"
73 + )));
74 + }
75 + Ok(())
76 + }
77 +
78 + /// Validate that a hash string is exactly 64 lowercase hex characters (SHA-256).
79 + ///
80 + /// Delegates to [`crate::SampleHash::validated`] so the rule lives in exactly one
81 + /// place — historically these were two parallel validators that could drift
82 + /// (e.g. on uppercase handling).
83 + pub fn validate_hash(hash: &str) -> Result<()> {
84 + crate::SampleHash::validated(hash).map(|_| ()).map_err(|_| {
85 + CoreError::HashInvalid(format!(
86 + "expected 64 lowercase hex chars, got {:?} ({} chars)",
87 + hash,
88 + hash.len()
89 + ))
90 + })
91 + }
92 +
93 + /// Mark a stored blob read-only (best-effort, cross-platform). A failed chmod
94 + /// must never fail an import — it only weakens the write-through guard.
95 + fn set_blob_readonly(path: &Path) {
96 + if let Ok(meta) = fs::metadata(path) {
97 + let mut perms = meta.permissions();
98 + perms.set_readonly(true);
99 + let _ = fs::set_permissions(path, perms);
100 + }
101 + }
102 +
103 + /// Manages on-disk sample blobs in a flat directory structure, storing files as
104 + /// `{sha256_hex}.{ext}` directly in the root directory.
105 + ///
106 + /// Deduplication via SHA-256: import streams the file through a hasher and skips
107 + /// the copy if a blob with the same hash already exists.
108 + ///
109 + /// Thread-safe: all operations are stateless reads/writes against the filesystem
110 + /// (no interior mutability or locks).
111 + pub struct SampleStore {
112 + root: PathBuf,
113 + }
114 +
115 + impl SampleStore {
116 + /// Create a new sample store, ensuring the root directory exists.
117 + #[instrument(skip_all)]
118 + pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
119 + let root = root.into();
120 + fs::create_dir_all(&root).map_err(|e| io_err(&root, e))?;
121 + Ok(Self { root })
122 + }
123 +
124 + /// Import a file into the store: hash it, copy to content-addressed path,
125 + /// insert into DB. Returns the hex SHA-256 hash.
126 + #[instrument(skip_all)]
127 + pub fn import(&self, path: &Path, db: &Database) -> Result<String> {
128 + let (hash, file_size) = hash_file(path)?;
129 + self.import_hashed(path, &hash, file_size, db)?;
130 + Ok(hash)
131 + }
132 +
133 + /// Import a file whose SHA-256 hash and size were already computed (e.g. by a
134 + /// parallel pre-hash pass). Does the serial side-effecting work — content-
135 + /// addressed blob copy + DB insert + orphan cleanup — exactly as [`import`].
136 + /// Splitting the pure hash out lets the import pipeline hash a batch in
137 + /// parallel while keeping every store/DB mutation serial.
138 + #[instrument(skip_all)]
139 + pub fn import_hashed(
140 + &self,
141 + path: &Path,
142 + hash: &str,
143 + file_size: i64,
144 + db: &Database,
145 + ) -> Result<()> {
146 + // Resolve the blob extension from an existing row if this hash is already
147 + // known. The store is content-addressed (one blob per hash), so identical
148 + // bytes imported under a different extension must reuse the existing blob
149 + // rather than write a second `{hash}.{newext}` file: remove() resolves the
150 + // blob path from the DB row, so any divergent-extension copy would be
151 + // unreachable and leak disk forever. Query without the deleted_at filter so
152 + // the blob naming stays consistent even for soft-deleted rows. Fresh import
153 + // falls back to the incoming file's extension.
154 + // tombstone-ok: point-lookup that intentionally includes tombstoned rows
155 + let ext = match db.conn().query_row(
156 + "SELECT file_extension FROM samples WHERE hash = ?1",
157 + [hash],
158 + |row| row.get::<_, String>(0),
159 + ) {
160 + Ok(existing) => existing,
161 + Err(rusqlite::Error::QueryReturnedNoRows) => crate::util::get_extension(path),
162 + Err(e) => return Err(CoreError::Db(e)),
163 + };
164 + let original_name = clamp_original_name(crate::util::get_filename(path, "unknown"));
165 +
166 + // Probe duration from file headers (cheap, no full decode)
167 + let duration = probe_duration(path);
168 +
169 + // Copy file to store if not already present *and intact*. Write to a
170 + // temp path and atomically rename: a crash / full disk mid-copy must not
171 + // leave a truncated blob at the canonical content-addressed path,
172 + // because dedup would then trust that corrupt blob under this hash
173 + // forever. We also repair an already-present blob whose size doesn't
174 + // match the source (a partial left by a pre-atomic-fix crash): since the
175 + // store is content-addressed, the correct blob's size is exactly the
176 + // source's, so a cheap size check catches truncation without re-hashing.
177 + // Mirrors the atomic temp+rename the sync download path uses. The pid
178 + // suffix keeps two concurrent imports of the same hash from colliding on
179 + // the temp file.
180 + let dest = self.sample_path(hash, &ext)?;
181 + let needs_write = match fs::metadata(&dest) {
182 + Ok(m) => m.len() != file_size as u64,
183 + Err(_) => true,
184 + };
185 + if needs_write {
186 + let tmp = dest.with_file_name(format!("{hash}.{ext}.{}.tmp", std::process::id()));
187 + // Copy *and* hash the bytes in a single pass, fsyncing the temp
188 + // before the rename. `hash` was computed by an earlier pass (batch
189 + // import pre-hashes the whole set, then copies one-by-one much
190 + // later); the source can mutate in that window (DAW re-render,
191 + // cloud-sync client, network share). Copying blindly would land
192 + // wrong bytes at `{hash}.ext` and — because dedup is size-only and
193 + // no read path re-hashes — trust them forever. Verifying the bytes
194 + // we actually wrote against the content address closes that TOCTOU:
195 + // a mismatch discards the temp and fails the import loudly rather
196 + // than corrupting the store.
197 + let copied = copy_hashing(path, &tmp).inspect_err(|_| {
198 + let _ = fs::remove_file(&tmp);
199 + })?;
200 + if copied != hash {
201 + let _ = fs::remove_file(&tmp);
202 + return Err(CoreError::HashMismatch(format!(
203 + "{} changed during import: expected {hash}, copied bytes hash to {copied}",
204 + path.display()
205 + )));
206 + }
207 + if let Err(e) = fs::rename(&tmp, &dest) {
208 + let _ = fs::remove_file(&tmp);
209 + return Err(io_err(&dest, e));
210 + }
211 + // Best-effort fsync of the directory so the rename itself is durable
212 + // (the new dirent survives a crash). Not all filesystems support
213 + // directory fsync; failure here only weakens durability, never the
214 + // import.
215 + if let Some(parent) = dest.parent()
216 + && let Ok(d) = fs::File::open(parent) {
217 + let _ = d.sync_all();
218 + }
219 + // Mark the canonical blob read-only. The VFS mirror exposes store
220 + // blobs to DAWs/file managers via symlinks; a "save in place" would
221 + // otherwise write through and corrupt the SHA-256-named blob,
222 + // silently invalidating dedup for every other placement and device.
223 + // Read-only makes that write fail loudly. Unlink still works (it
224 + // needs write on the directory, not the file). Best-effort: a
225 + // filesystem that rejects the chmod must not fail the import.
226 + set_blob_readonly(&dest);
227 + }
228 +
229 + // Insert into DB (ignore if hash already exists). If this fails after we
230 + // just wrote a fresh blob, that blob is unreferenced — no row points at
231 + // it, and since `needs_write` was true nothing pointed at it before
232 + // either. Unlink it so a DB-write failure (disk full on the WAL, lock
233 + // timeout) can't leak an unreachable blob that no GC path reclaims —
234 + // upholding this module's "never orphan a blob" invariant (see remove()).
235 + // When `needs_write` was false the blob pre-existed and may be shared by
236 + // another row, so it is left untouched.
237 + let now = unix_now();
238 + if let Err(e) = db.conn().execute(
239 + "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration)
240 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
241 + rusqlite::params![hash, original_name, ext, file_size, now, now, duration],
242 + ) {
243 + if needs_write {
244 + let _ = fs::remove_file(&dest);
245 + }
246 + return Err(CoreError::Db(e));
247 + }
248 +
249 + Ok(())
250 + }
251 +
252 + /// Check if a sample file exists in the store.
253 + pub fn exists(&self, hash: &str, ext: &str) -> Result<bool> {
254 + Ok(self.sample_path(hash, ext)?.exists())
255 + }
256 +
257 + /// Get the filesystem path for a sample.
258 + ///
259 + /// Validates that `hash` is exactly 64 lowercase hex characters (SHA-256)
260 + /// to prevent directory traversal or malformed paths.
261 + pub fn sample_path(&self, hash: &str, ext: &str) -> Result<PathBuf> {
262 + validate_hash(hash)?;
263 + validate_extension(ext)?;
264 + Ok(store_blob_path(&self.root, hash, ext))
265 + }
266 +
267 + /// Remove a sample from store and database. CASCADE handles VFS/tag refs.
268 + ///
269 + /// Deletes the file from disk first, then the DB row. If the file delete
270 + /// fails (in-use on Windows, permission denied, etc.), the DB row is left
271 + /// intact so the user can retry; nothing leaks. The reverse order would
272 + /// silently leak orphan blobs on every file-delete failure, accumulating
273 + /// disk waste invisible to the user.
274 + #[instrument(skip_all)]
275 + pub fn remove(&self, hash: &str, db: &Database) -> Result<()> {
276 + // Resolve the extension without the `deleted_at IS NULL` filter: this is
277 + // the hard-delete path, and the row may be a tombstone (purged from the
278 + // Trash view, or swept past its retention window). The blob name is
279 + // content-addressed and identical regardless of tombstone state, so the
280 + // unfiltered lookup is the correct one for locating the file to unlink.
281 + let ext = sample_extension_any(db, hash)?;
282 + let path = self.sample_path(hash, &ext)?;
283 +
284 + // File first. ENOENT is fine — the row was already pointing at nothing.
285 + match fs::remove_file(&path) {
286 + Ok(()) => {}
287 + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
288 + Err(e) => return Err(io_err(&path, e)),
289 + }
290 +
291 + // Then the DB row (CASCADE handles tags, vfs_nodes, etc.).
292 + db.conn()
293 + .execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
294 +
295 + Ok(())
296 + }
297 +
298 + /// Remove samples that are no longer referenced by any VFS node.
299 + ///
300 + /// This is a **local** garbage-collection of disk space, not a cross-device
301 + /// statement: each device has its own set of orphans. Sync triggers are
302 + /// suppressed for the duration so the row deletes never push a `samples`
303 + /// DELETE that would cascade-wipe another device's placements (engine-level
304 + /// CASCADE is not trigger-suppressible — see
305 + /// `docs/design-sample-deletion.md`). The suppression lives here, inside the
306 + /// transaction, so no caller can forget it: the flag flip is invisible to
307 + /// other connections under WAL snapshot isolation, so a concurrent import on
308 + /// the GUI connection still syncs normally.
309 + ///
310 + /// Returns the number of orphaned samples removed.
311 + #[instrument(skip_all)]
312 + pub fn remove_orphaned_samples(&self, db: &Database) -> Result<usize> {
313 + // Skip tombstoned rows — the eventual hard-delete sweep
314 + // (docs/design-sample-deletion.md Phase 4) will get them when the
315 + // retention window expires. Double-processing here would race the
316 + // sweep and pre-emptively destroy still-recoverable data.
317 + let mut stmt = db.conn().prepare(
318 + "SELECT s.hash, s.file_extension
319 + FROM live_samples s
320 + LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash
321 + WHERE vn.id IS NULL",
322 + )?;
323 + let orphans: Vec<(String, String)> = stmt
324 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
325 + .collect::<std::result::Result<Vec<_>, _>>()?;
326 + drop(stmt);
327 +
328 + // Delete orphan rows in a single transaction with sync suppressed.
329 + // Re-check orphan status inside the DELETE so we don't lose a race with
330 + // a concurrent import that linked the sample after the query above —
331 + // and so we only unlink blobs for rows we actually removed (a live
332 + // re-link must keep both row and blob).
333 + //
334 + // The blob unlink runs INSIDE the transaction, while `BEGIN IMMEDIATE`
335 + // still holds the write lock. This is load-bearing: cleanup and import
336 + // hold separate connections (serialized only by SQLite's write lock,
337 + // not the in-process Database mutex), so an unlink performed after
338 + // COMMIT could delete a blob that a concurrent import had already
339 + // re-adopted in the gap (import sees the blob present, re-inserts the
340 + // row, leaves the blob) — leaving a live row pointing at a missing
341 + // file. Holding the write lock across the unlink makes that re-adoption
342 + // unable to commit until the blob is already gone, so the re-import
343 + // re-writes the blob instead. A failed unlink only leaves an orphan
344 + // blob (disk waste, reclaimed by a later re-import or verify sweep) and
345 + // must not roll back the committed row delete, so its error is ignored.
346 + let deleted = db.transaction(|_tx| {
347 + db.conn().execute(
348 + "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'",
349 + [],
350 + )?;
351 + let mut count = 0usize;
352 + for (hash, ext) in &orphans {
353 + let n = db.conn().execute(
354 + "DELETE FROM samples WHERE hash = ?1 \
355 + AND NOT EXISTS (SELECT 1 FROM vfs_nodes WHERE sample_hash = ?1) \
356 + AND deleted_at IS NULL",
357 + [hash],
358 + )?;
359 + if n > 0 {
360 + if let Ok(path) = self.sample_path(hash, ext)
361 + && path.exists()
362 + {
363 + let _ = fs::remove_file(&path);
364 + }
365 + count += 1;
366 + }
367 + }
368 + db.conn().execute(
369 + "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'",
370 + [],
371 + )?;
372 + Ok(count)
373 + })?;
374 +
375 + Ok(deleted)
376 + }
377 +
378 + /// Hard-delete tombstoned samples whose retention window has expired.
379 + ///
380 + /// A sample tombstoned more than `sample_tombstone_retain_days` ago (default
381 + /// 30) is past recovery: this removes its blob and its `samples` row, letting
382 + /// the engine CASCADE clean up placements, tags, and collection memberships.
383 + /// Unlike the local orphan GC, the `samples` DELETE is **not** sync-suppressed
384 + /// — the deletion propagates so the user's other devices converge (each having
385 + /// had the same window to recover). Runs once at startup; the indexed
386 + /// `deleted_at` partial index keeps the scan cheap when nothing has expired.
387 + ///
388 + /// A blob/row removal that fails for one sample is logged and skipped so a
389 + /// single stuck file (in-use on Windows, permission denied) doesn't abort the
390 + /// rest of the sweep. Returns the number of samples hard-deleted.
391 + #[instrument(skip_all)]
392 + pub fn sweep_expired_tombstones(&self, db: &Database) -> Result<usize> {
393 + let cutoff = unix_now() - tombstone_retain_days(db) * 86_400;
394 +
395 + // tombstone-ok: the sweep operates on tombstones by definition
396 + let mut stmt = db.conn().prepare(
397 + "SELECT hash FROM samples WHERE deleted_at IS NOT NULL AND deleted_at < ?1",
398 + )?;
399 + let expired: Vec<String> = stmt
400 + .query_map([cutoff], |row| row.get(0))?
401 + .collect::<std::result::Result<Vec<_>, _>>()?;
402 + drop(stmt);
403 +
404 + let mut removed = 0usize;
405 + for hash in &expired {
406 + match self.remove(hash, db) {
407 + Ok(()) => removed += 1,
408 + Err(e) => tracing::warn!(hash = %hash, "tombstone sweep: failed to purge sample: {e}"),
409 + }
410 + }
411 + Ok(removed)
412 + }
413 +
414 + /// Get the store root directory.
415 + pub fn root(&self) -> &Path {
416 + &self.root
417 + }
418 +
419 + /// Re-hash a stored sample and compare against the expected hash.
420 + ///
421 + /// Returns `Ok(true)` if the file's SHA-256 matches `hash`, `Ok(false)` if
422 + /// it differs. Returns an error if the file cannot be read.
423 + #[instrument(skip_all)]
424 + pub fn verify_sample(&self, hash: &str, ext: &str) -> Result<bool> {
425 + let path = self.sample_path(hash, ext)?;
426 + let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
427 +
428 + let mut hasher = Sha256::new();
429 + let mut buf = [0u8; 8192];
430 + loop {
431 + let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?;
432 + if n == 0 {
433 + break;
434 + }
435 + hasher.update(&buf[..n]);
436 + }
437 + let computed = format!("{:x}", hasher.finalize());
438 +
439 + Ok(computed == hash)
440 + }
441 +
442 + /// Scrub the managed store: re-hash every locally-present blob and report the
443 + /// hashes whose bytes no longer match their content address.
444 + ///
445 + /// This is the runtime home for [`verify_sample`](Self::verify_sample) — the
446 + /// store's headline invariant is "the filename IS the content hash," and a
447 + /// scrub is what confirms that invariant still holds against silent on-disk
448 + /// corruption (bit-rot, an out-of-band edit through the VFS mirror, a
449 + /// truncated blob from a pre-atomic-fix crash). Only managed blobs are
450 + /// checked: loose-files samples live at their `source_path` (covered by
451 + /// [`check_loose_files_integrity`]) and `cloud_only` rows have no local blob
452 + /// to verify. A missing or unreadable blob counts as corrupt (its hash is
453 + /// returned) rather than aborting the whole sweep.
454 + ///
455 + /// Returns `(checked, corrupt_hashes)`.
456 + #[instrument(skip_all)]
457 + pub fn scrub(&self, db: &Database) -> Result<(usize, Vec<String>)> {
458 + let mut stmt = db.conn().prepare(
459 + "SELECT hash, file_extension FROM live_samples \
460 + WHERE source_path IS NULL AND cloud_only = 0",
461 + )?;
462 + let blobs: Vec<(String, String)> = stmt
463 + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
464 + .collect::<std::result::Result<Vec<_>, _>>()?;
465 +
466 + let mut checked = 0;
467 + let mut corrupt = Vec::new();
468 + for (hash, ext) in blobs {
469 + checked += 1;
470 + // A read error (blob missing/unreadable) is itself an integrity
471 + // failure for a managed sample — record it as corrupt rather than
472 + // failing the sweep, so one bad blob doesn't hide the rest.
473 + match self.verify_sample(&hash, &ext) {
474 + Ok(true) => {}
475 + Ok(false) | Err(_) => corrupt.push(hash),
476 + }
477 + }
478 + Ok((checked, corrupt))
479 + }
480 + }
481 +
482 + // --- Sample metadata queries ---
483 +
484 + /// Helper to query a single text column from the samples table by hash.
485 + ///
486 + /// Only fields in the allowlist may be queried; any other value returns an error
487 + /// to prevent SQL injection through the interpolated column name.
488 + fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> {
489 + const ALLOWED_FIELDS: &[&str] = &["file_extension", "original_name"];
490 +
491 + if !ALLOWED_FIELDS.contains(&field) {
492 + return Err(CoreError::Internal(format!(
493 + "query_sample_field: disallowed field {field:?}"
494 + )));
495 + }
496 +
497 + let sql = format!("SELECT {field} FROM live_samples WHERE hash = ?1");
498 + db.conn()
499 + .query_row(&sql, [hash], |row| row.get(0))
500 + .map_err(|e| match e {
Lines truncated