max / audiofiles
7 files changed,
+115 insertions,
-20 deletions
| @@ -429,9 +429,8 @@ pub fn auto_apply_library(db: &Database, k: usize) -> Result<usize> { | |||
| 429 | 429 | } | |
| 430 | 430 | let hashes: Vec<String> = { | |
| 431 | 431 | let mut stmt = db.conn().prepare( | |
| 432 | - | "SELECT s.hash FROM samples s | |
| 433 | - | JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1 | |
| 434 | - | WHERE s.deleted_at IS NULL", | |
| 432 | + | "SELECT s.hash FROM live_samples s | |
| 433 | + | JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1", | |
| 435 | 434 | )?; | |
| 436 | 435 | stmt.query_map([FEATURE_VERSION], |row| row.get(0))? | |
| 437 | 436 | .collect::<std::result::Result<Vec<_>, _>>()? |
| @@ -429,9 +429,8 @@ pub fn load_analysis(db: &Database, hash: &str) -> Option<AnalysisResult> { | |||
| 429 | 429 | #[instrument(skip_all)] | |
| 430 | 430 | pub fn samples_needing_features(db: &Database) -> Result<Vec<(String, String)>, CoreError> { | |
| 431 | 431 | let mut stmt = db.conn().prepare( | |
| 432 | - | "SELECT s.hash, s.file_extension FROM samples s | |
| 433 | - | WHERE s.deleted_at IS NULL | |
| 434 | - | AND NOT EXISTS ( | |
| 432 | + | "SELECT s.hash, s.file_extension FROM live_samples s | |
| 433 | + | WHERE NOT EXISTS ( | |
| 435 | 434 | SELECT 1 FROM sample_features f | |
| 436 | 435 | WHERE f.hash = s.hash AND f.feat_version = ?1)", | |
| 437 | 436 | )?; |
| @@ -291,9 +291,8 @@ pub fn apply_ml_suggestions(db: &Database, hash: &str, head: &TrainedHead) -> Re | |||
| 291 | 291 | pub fn auto_apply_library(db: &Database, head: &TrainedHead) -> Result<usize> { | |
| 292 | 292 | let hashes: Vec<String> = { | |
| 293 | 293 | let mut stmt = db.conn().prepare( | |
| 294 | - | "SELECT s.hash FROM samples s | |
| 295 | - | JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1 | |
| 296 | - | WHERE s.deleted_at IS NULL", | |
| 294 | + | "SELECT s.hash FROM live_samples s | |
| 295 | + | JOIN sample_features f ON f.hash = s.hash AND f.feat_version = ?1", | |
| 297 | 296 | )?; | |
| 298 | 297 | stmt.query_map([FEATURE_VERSION], |row| row.get(0))? | |
| 299 | 298 | .collect::<std::result::Result<Vec<_>, _>>()? |
| @@ -1780,7 +1780,7 @@ impl Database { | |||
| 1780 | 1780 | /// like every other sample read site. | |
| 1781 | 1781 | pub fn storage_stats(&self) -> Result<(u64, u64), DbError> { | |
| 1782 | 1782 | let (count, total): (u64, u64) = self.conn.query_row( | |
| 1783 | - | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM samples WHERE deleted_at IS NULL", | |
| 1783 | + | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples", | |
| 1784 | 1784 | [], | |
| 1785 | 1785 | |row| Ok((row.get(0)?, row.get(1)?)), | |
| 1786 | 1786 | )?; | |
| @@ -1792,8 +1792,11 @@ impl Database { | |||
| 1792 | 1792 | /// same VFS counts once. Used by the sync panel's per-VFS toggle rows so | |
| 1793 | 1793 | /// the user can see how much would upload before enabling blob sync. | |
| 1794 | 1794 | pub fn vfs_storage_stats(&self, vfs_id: i64) -> Result<(u64, u64), DbError> { | |
| 1795 | + | // Soft-delete keeps vfs placements, so a tombstoned sample would still be | |
| 1796 | + | // counted/summed here and inflate the "would upload" estimate; read through | |
| 1797 | + | // live_samples to exclude it. | |
| 1795 | 1798 | let (count, total): (u64, u64) = self.conn.query_row( | |
| 1796 | - | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM samples \ | |
| 1799 | + | "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \ | |
| 1797 | 1800 | WHERE hash IN (\ | |
| 1798 | 1801 | SELECT DISTINCT sample_hash FROM vfs_nodes \ | |
| 1799 | 1802 | WHERE vfs_id = ? AND sample_hash IS NOT NULL\ |
| @@ -372,7 +372,7 @@ pub fn load_context(db: &Database, hash: &str) -> Result<Option<RuleContext>> { | |||
| 372 | 372 | let base = conn | |
| 373 | 373 | .query_row( | |
| 374 | 374 | "SELECT original_name, file_extension, file_size, source_path | |
| 375 | - | FROM samples WHERE hash = ?1 AND deleted_at IS NULL", | |
| 375 | + | FROM live_samples WHERE hash = ?1", | |
| 376 | 376 | [hash], | |
| 377 | 377 | |row| { | |
| 378 | 378 | Ok(( |
| @@ -148,6 +148,7 @@ impl SampleStore { | |||
| 148 | 148 | // unreachable and leak disk forever. Query without the deleted_at filter so | |
| 149 | 149 | // the blob naming stays consistent even for soft-deleted rows. Fresh import | |
| 150 | 150 | // falls back to the incoming file's extension. | |
| 151 | + | // tombstone-ok: point-lookup that intentionally includes tombstoned rows | |
| 151 | 152 | let ext = match db.conn().query_row( | |
| 152 | 153 | "SELECT file_extension FROM samples WHERE hash = ?1", | |
| 153 | 154 | [hash], | |
| @@ -300,9 +301,9 @@ impl SampleStore { | |||
| 300 | 301 | // sweep and pre-emptively destroy still-recoverable data. | |
| 301 | 302 | let mut stmt = db.conn().prepare( | |
| 302 | 303 | "SELECT s.hash, s.file_extension | |
| 303 | - | FROM samples s | |
| 304 | + | FROM live_samples s | |
| 304 | 305 | LEFT JOIN vfs_nodes vn ON s.hash = vn.sample_hash | |
| 305 | - | WHERE vn.id IS NULL AND s.deleted_at IS NULL", | |
| 306 | + | WHERE vn.id IS NULL", | |
| 306 | 307 | )?; | |
| 307 | 308 | let orphans: Vec<(String, String)> = stmt | |
| 308 | 309 | .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? | |
| @@ -376,6 +377,7 @@ impl SampleStore { | |||
| 376 | 377 | pub fn sweep_expired_tombstones(&self, db: &Database) -> Result<usize> { | |
| 377 | 378 | let cutoff = unix_now() - tombstone_retain_days(db) * 86_400; | |
| 378 | 379 | ||
| 380 | + | // tombstone-ok: the sweep operates on tombstones by definition | |
| 379 | 381 | let mut stmt = db.conn().prepare( | |
| 380 | 382 | "SELECT hash FROM samples WHERE deleted_at IS NOT NULL AND deleted_at < ?1", | |
| 381 | 383 | )?; | |
| @@ -438,7 +440,7 @@ fn query_sample_field(db: &Database, hash: &str, field: &str) -> Result<String> | |||
| 438 | 440 | ))); | |
| 439 | 441 | } | |
| 440 | 442 | ||
| 441 | - | let sql = format!("SELECT {field} FROM samples WHERE hash = ?1 AND deleted_at IS NULL"); | |
| 443 | + | let sql = format!("SELECT {field} FROM live_samples WHERE hash = ?1"); | |
| 442 | 444 | db.conn() | |
| 443 | 445 | .query_row(&sql, [hash], |row| row.get(0)) | |
| 444 | 446 | .map_err(|e| match e { | |
| @@ -458,6 +460,7 @@ pub fn sample_extension(db: &Database, hash: &str) -> Result<String> { | |||
| 458 | 460 | /// rows. Used by the hard-delete and sweep paths, which must resolve the blob | |
| 459 | 461 | /// path for soft-deleted rows that `sample_extension` deliberately hides. | |
| 460 | 462 | fn sample_extension_any(db: &Database, hash: &str) -> Result<String> { | |
| 463 | + | // tombstone-ok: point-lookup that must resolve blobs for tombstoned rows too | |
| 461 | 464 | db.conn() | |
| 462 | 465 | .query_row( | |
| 463 | 466 | "SELECT file_extension FROM samples WHERE hash = ?1", | |
| @@ -496,6 +499,7 @@ pub struct TombstonedSample { | |||
| 496 | 499 | ||
| 497 | 500 | /// List every tombstoned sample, most recently deleted first. | |
| 498 | 501 | pub fn tombstoned_samples(db: &Database) -> Result<Vec<TombstonedSample>> { | |
| 502 | + | // tombstone-ok: this IS the tombstone listing (Trash view) | |
| 499 | 503 | let mut stmt = db.conn().prepare( | |
| 500 | 504 | "SELECT hash, original_name, file_extension, file_size, deleted_at | |
| 501 | 505 | FROM samples | |
| @@ -581,7 +585,7 @@ fn clamp_original_name(name: String) -> String { | |||
| 581 | 585 | pub fn sample_source_path(db: &Database, hash: &str) -> Result<Option<String>> { | |
| 582 | 586 | db.conn() | |
| 583 | 587 | .query_row( | |
| 584 | - | "SELECT source_path FROM samples WHERE hash = ?1 AND deleted_at IS NULL", | |
| 588 | + | "SELECT source_path FROM live_samples WHERE hash = ?1", | |
| 585 | 589 | [hash], | |
| 586 | 590 | |row| row.get(0), | |
| 587 | 591 | ) | |
| @@ -726,7 +730,7 @@ pub fn relocate_sample( | |||
| 726 | 730 | /// exists vs. does not exist on disk. | |
| 727 | 731 | pub fn check_loose_files_integrity(db: &Database) -> Result<(usize, usize)> { | |
| 728 | 732 | let mut stmt = db.conn().prepare( | |
| 729 | - | "SELECT source_path FROM samples WHERE source_path IS NOT NULL AND deleted_at IS NULL", | |
| 733 | + | "SELECT source_path FROM live_samples WHERE source_path IS NOT NULL", | |
| 730 | 734 | )?; | |
| 731 | 735 | let paths: Vec<String> = stmt | |
| 732 | 736 | .query_map([], |row| row.get(0))? | |
| @@ -762,8 +766,8 @@ pub fn relocate_missing_loose_files( | |||
| 762 | 766 | // 1. Gather missing samples — hash, basename of stored source_path, and | |
| 763 | 767 | // the recorded file_size (used for cheap pre-filter before re-hashing). | |
| 764 | 768 | let mut stmt = db.conn().prepare( | |
| 765 | - | "SELECT hash, source_path, file_size FROM samples \ | |
| 766 | - | WHERE source_path IS NOT NULL AND deleted_at IS NULL", | |
| 769 | + | "SELECT hash, source_path, file_size FROM live_samples \ | |
| 770 | + | WHERE source_path IS NOT NULL", | |
| 767 | 771 | )?; | |
| 768 | 772 | let rows: Vec<(String, String, i64)> = stmt | |
| 769 | 773 | .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? | |
| @@ -867,7 +871,7 @@ pub fn relocate_missing_loose_files( | |||
| 867 | 871 | /// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc. | |
| 868 | 872 | pub fn purge_missing_loose_files(db: &Database) -> Result<usize> { | |
| 869 | 873 | let mut stmt = db.conn().prepare( | |
| 870 | - | "SELECT hash, source_path FROM samples WHERE source_path IS NOT NULL AND deleted_at IS NULL", | |
| 874 | + | "SELECT hash, source_path FROM live_samples WHERE source_path IS NOT NULL", | |
| 871 | 875 | )?; | |
| 872 | 876 | let rows: Vec<(String, String)> = stmt | |
| 873 | 877 | .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? |
| @@ -0,0 +1,91 @@ | |||
| 1 | + | //! CHRONIC #2 enforcement seal (ultra-fuzz run-6 --deep). | |
| 2 | + | //! | |
| 3 | + | //! The tombstone filter (`deleted_at IS NULL`) was opt-in per query and drifted | |
| 4 | + | //! across runs: a new read of the raw `samples` table that forgot the filter | |
| 5 | + | //! leaked deleted samples into tag/collection/mirror views. The `live_samples` | |
| 6 | + | //! view centralizes the filter, but a view alone does not *prevent* a new raw | |
| 7 | + | //! `FROM samples` listing from drifting back in. | |
| 8 | + | //! | |
| 9 | + | //! This test makes that drift a build failure: every production read of the raw | |
| 10 | + | //! `samples` table must either go through `live_samples` (auto-filtered) or carry | |
| 11 | + | //! an explicit `tombstone-ok: <reason>` justification within 3 lines. A new | |
| 12 | + | //! unfiltered `FROM samples` listing read fails here until the author routes it | |
| 13 | + | //! through the view or marks it — a visible, reviewed decision, not a silent | |
| 14 | + | //! regression. | |
| 15 | + | //! | |
| 16 | + | //! Exemptions, by construction: | |
| 17 | + | //! - `DELETE FROM samples` (a write, not a read). | |
| 18 | + | //! - The `CREATE VIEW live_samples` definition itself. | |
| 19 | + | //! - `#[cfg(test)]` modules (they legitimately assert raw table state). This | |
| 20 | + | //! assumes the repo convention of test modules at file end. | |
| 21 | + | ||
| 22 | + | use std::fs; | |
| 23 | + | use std::path::{Path, PathBuf}; | |
| 24 | + | ||
| 25 | + | /// True if `line` reads from the raw `samples` table (word-bounded), i.e. not | |
| 26 | + | /// `live_samples` and not `samples_*` (e.g. `samples_fts`, `sample_features`). | |
| 27 | + | fn reads_raw_samples(line: &str) -> bool { | |
| 28 | + | const NEEDLE: &str = "FROM samples"; | |
| 29 | + | let mut idx = 0; | |
| 30 | + | while let Some(pos) = line[idx..].find(NEEDLE) { | |
| 31 | + | let after = idx + pos + NEEDLE.len(); | |
| 32 | + | let boundary = line.as_bytes().get(after); | |
| 33 | + | let is_word = boundary.is_some_and(|&c| c.is_ascii_alphanumeric() || c == b'_'); | |
| 34 | + | if !is_word { | |
| 35 | + | return true; // exact table `samples`, not `samples_...` | |
| 36 | + | } | |
| 37 | + | idx = after; | |
| 38 | + | } | |
| 39 | + | false | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | #[test] | |
| 43 | + | fn tombstone_filter_seal() { | |
| 44 | + | let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); | |
| 45 | + | let mut stack = vec![src]; | |
| 46 | + | let mut violations: Vec<String> = Vec::new(); | |
| 47 | + | ||
| 48 | + | while let Some(dir) = stack.pop() { | |
| 49 | + | for entry in fs::read_dir(&dir).unwrap() { | |
| 50 | + | let path: PathBuf = entry.unwrap().path(); | |
| 51 | + | if path.is_dir() { | |
| 52 | + | stack.push(path); | |
| 53 | + | continue; | |
| 54 | + | } | |
| 55 | + | if path.extension().and_then(|e| e.to_str()) != Some("rs") { | |
| 56 | + | continue; | |
| 57 | + | } | |
| 58 | + | let text = fs::read_to_string(&path).unwrap(); | |
| 59 | + | let lines: Vec<&str> = text.lines().collect(); | |
| 60 | + | // Test modules (conventionally at file end) legitimately read raw rows. | |
| 61 | + | let end = lines | |
| 62 | + | .iter() | |
| 63 | + | .position(|l| l.contains("#[cfg(test)]")) | |
| 64 | + | .unwrap_or(lines.len()); | |
| 65 | + | ||
| 66 | + | for (i, line) in lines[..end].iter().enumerate() { | |
| 67 | + | if !reads_raw_samples(line) || line.contains("DELETE FROM samples") { | |
| 68 | + | continue; | |
| 69 | + | } | |
| 70 | + | let lo = i.saturating_sub(3); | |
| 71 | + | let window = lines[lo..=i].join("\n"); | |
| 72 | + | if window.contains("tombstone-ok") || window.contains("CREATE VIEW") { | |
| 73 | + | continue; | |
| 74 | + | } | |
| 75 | + | let rel = path | |
| 76 | + | .strip_prefix(env!("CARGO_MANIFEST_DIR")) | |
| 77 | + | .unwrap_or(&path); | |
| 78 | + | violations.push(format!("{}:{} {}", rel.display(), i + 1, line.trim())); | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | assert!( | |
| 84 | + | violations.is_empty(), | |
| 85 | + | "CHRONIC #2 seal: raw `FROM samples` read(s) without `live_samples` or a \ | |
| 86 | + | `tombstone-ok` justification:\n {}\n\nRoute visible-sample reads through \ | |
| 87 | + | `FROM live_samples`, or add a `tombstone-ok: <reason>` comment within 3 \ | |
| 88 | + | lines if the raw access is intentional (point-lookup, tombstone sweep).", | |
| 89 | + | violations.join("\n ") | |
| 90 | + | ); | |
| 91 | + | } |