From 39d5179f293f2d4d73510fec118b1b914e904351 Mon Sep 17 00:00:00 2001 From: c0remusic Date: Mon, 13 Jul 2026 10:37:12 +0200 Subject: [PATCH] Harden Sift filing and security checks --- .github/workflows/build.yml | 9 + docs/skills-registre.md | 12 + .../plans/2026-07-13-review-fixes.md | 108 +++++++ package.json | 1 + scripts/check-tauri-security.mjs | 17 + src-tauri/src/db.rs | 32 +- src-tauri/src/dedup.rs | 11 +- src-tauri/src/ecartes.rs | 100 +++++- src-tauri/src/encode.rs | 34 +- src-tauri/src/filing.rs | 293 +++++++++++++----- src-tauri/src/fingerprint.rs | 24 +- src-tauri/src/ipc.rs | 42 ++- src-tauri/src/tagging.rs | 19 +- src-tauri/tauri.conf.json | 4 +- src-tauri/tests/characterization.rs | 28 +- 15 files changed, 564 insertions(+), 170 deletions(-) create mode 100644 docs/skills-registre.md create mode 100644 docs/superpowers/plans/2026-07-13-review-fixes.md create mode 100644 scripts/check-tauri-security.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc2bc34..b543d7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,9 +36,18 @@ jobs: - name: Install npm deps run: npm ci + - name: Check Tauri security policy + run: npm run check:tauri-security + - name: Fetch ffmpeg sidecar run: npm run fetch-ffmpeg + - name: Generate audio fixtures + run: node scripts/make-fixtures.mjs + + - name: Test Rust + run: cargo test --manifest-path src-tauri/Cargo.toml + - name: Build Tauri app (unsigned) run: npm run tauri build diff --git a/docs/skills-registre.md b/docs/skills-registre.md new file mode 100644 index 0000000..f91152c --- /dev/null +++ b/docs/skills-registre.md @@ -0,0 +1,12 @@ +# Registre des skills + +Ce registre consigne uniquement les skills dont la pertinence a été vérifiée pour ce dépôt. + +| Domaine | Skill | Verdict | Usage | +|---|---|---|---| +| Instructions agent | `claude-md-management:claude-md-improver` | Pertinente | Auditer les fichiers `CLAUDE.md`, vérifier leur exactitude contre le dépôt et proposer des améliorations ciblées avant toute modification. | +| Revue de code | `clean-code` | Pertinente | Évaluer lisibilité, responsabilités, gestion d'erreurs, odeurs de code et qualité des tests dans le backend Rust et le frontend TypeScript/JavaScript. | +| Correction de bugs | `superpowers:systematic-debugging` | Pertinente | Tracer les incohérences DB/fichiers jusqu'à leur cause et vérifier chaque hypothèse avant modification. | +| Tests de régression | `superpowers:test-driven-development` | Pertinente | Écrire et observer les tests en échec avant chaque correction de comportement. | +| Code existant | `working-with-legacy-code` | Pertinente | Ajouter des points de test ciblés autour des chemins non couverts avant modification. | +| Livraison | `superpowers:verification-before-completion` | Pertinente | Exiger des commandes fraîches de tests, formatage et build avant toute déclaration de réussite. | diff --git a/docs/superpowers/plans/2026-07-13-review-fixes.md b/docs/superpowers/plans/2026-07-13-review-fixes.md new file mode 100644 index 0000000..6921829 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-review-fixes.md @@ -0,0 +1,108 @@ +# Code Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Corriger les six constats de la revue du checkout `main@397c70d` sans modifier le comportement fonctionnel nominal. + +**Architecture:** Les écritures SQLite liées entre elles deviennent transactionnelles et les effets fichiers sont compensés lorsqu'une transaction échoue. La purge ne masque plus les erreurs de suppression. Les migrations deviennent atomiques. La CI génère ses fixtures et exécute les tests. Le protocole asset Tauri part d'un scope vide et autorise uniquement les fichiers réellement exposés à l'interface. + +**Tech Stack:** Rust 2021, rusqlite 0.32, Tauri 2.11, TypeScript/Vite, GitHub Actions. + +## Global Constraints + +- Conserver les signatures IPC frontend existantes. +- Ne jamais écraser un fichier pendant une compensation. +- Ajouter un test de régression observé en échec avant chaque correction Rust. +- Ne pas ajouter de dépendance. + +--- + +### Task 1: Atomicité du rangement et de la corbeille + +**Files:** +- Modify: `src-tauri/src/filing.rs` + +**Interfaces:** +- Consumes: `actions::record`, `save_metadata`, `rollback_fs`. +- Produces: `commit_file`, `reject_track` et `trash_track` atomiques côté SQLite, avec compensation filesystem. + +- [ ] Ajouter un test où un trigger SQLite fait échouer `save_metadata`; vérifier l'absence d'action, le statut `pending` et le retour du fichier à sa source. +- [ ] Exécuter ce test et constater l'échec sur l'état partiellement persisté. +- [ ] Encapsuler les écritures de `commit_file` dans `unchecked_transaction`, puis compenser le filesystem si la transaction échoue. +- [ ] Ajouter puis observer en échec un test où le changement de statut `trash` est refusé. +- [ ] Rendre `trash_track` transactionnel et restaurer le fichier si la transaction échoue; rendre `reject_track` transactionnel. +- [ ] Réexécuter les tests ciblés. + +### Task 2: Purge fiable + +**Files:** +- Modify: `src-tauri/src/ecartes.rs` + +**Interfaces:** +- Consumes: journal `actions` et lignes `tracks` existantes. +- Produces: `purge_trash` qui ne marque jamais purgé un fichier dont la suppression a échoué. + +- [ ] Ajouter un test avec un chemin pointant vers un répertoire, que `remove_file` refuse. +- [ ] Exécuter le test et constater que l'état est actuellement marqué `purged`. +- [ ] Propager les erreurs autres que `NotFound` et grouper les deux mises à jour SQLite de chaque ligne dans une transaction. +- [ ] Réexécuter les tests ciblés. + +### Task 3: Migrations atomiques + +**Files:** +- Modify: `src-tauri/src/db.rs` + +**Interfaces:** +- Produces: `apply_migration(conn, sql, version)` atomique, utilisée par `run_migrations`. + +- [ ] Ajouter un test de migration contenant une création de table suivie d'un SQL invalide. +- [ ] Exécuter le test et constater l'absence du helper attendu. +- [ ] Implémenter le helper avec transaction, mise à jour de `user_version` et commit unique. +- [ ] Vérifier que la table partielle et la version restent absentes après échec. + +### Task 4: Tests audio exécutés en CI + +**Files:** +- Modify: `.github/workflows/build.yml` +- Modify: `src-tauri/tests/characterization.rs` +- Modify: `src-tauri/src/encode.rs` +- Modify: `src-tauri/src/dedup.rs` +- Modify: `src-tauri/src/fingerprint.rs` +- Modify: `src-tauri/src/tagging.rs` +- Modify: `src-tauri/src/filing.rs` + +**Interfaces:** +- Consumes: `npm run fetch-ffmpeg`, `node scripts/make-fixtures.mjs`. +- Produces: CI qui génère les fixtures et exécute `cargo test`; tests obligatoires qui échouent si une fixture générée manque. + +- [ ] Ajouter les étapes génération puis test à la CI. +- [ ] Remplacer les retours silencieux des fixtures générées par des échecs explicites; conserver l'anchor utilisateur optionnelle. +- [ ] Générer localement le sidecar et les fixtures, puis exécuter les tests. + +### Task 5: Scope asset Tauri minimal + +**Files:** +- Create: `scripts/check-tauri-security.mjs` +- Modify: `package.json` +- Modify: `src-tauri/tauri.conf.json` +- Modify: `src-tauri/src/ipc.rs` + +**Interfaces:** +- Consumes: `Manager::asset_protocol_scope().allow_file` de Tauri 2.11.2. +- Produces: scope statique vide, CSP sans exécution inline/eval, autorisation dynamique des seuls fichiers audio servis. + +- [ ] Ajouter un contrôle Node qui refuse le wildcard asset et les directives script dangereuses. +- [ ] Exécuter le contrôle et constater son échec. +- [ ] Vider le scope statique, durcir `script-src` et autoriser dynamiquement les fichiers validés par les commandes IPC. +- [ ] Exécuter le contrôle et les builds frontend/Rust. + +### Task 6: Vérification finale + +**Files:** +- Modify only if a verification exposes a defect caused by these changes. + +- [ ] Exécuter `cargo fmt` puis `cargo fmt --check`. +- [ ] Exécuter `npx tsc --noEmit` et `npm run build`. +- [ ] Exécuter `cargo test --manifest-path src-tauri/Cargo.toml`. +- [ ] Exécuter `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`. +- [ ] Relire `git diff --check` et le diff complet. diff --git a/package.json b/package.json index 6b87d8c..d8a1666 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "check:tauri-security": "node scripts/check-tauri-security.mjs", "preview": "vite preview", "tauri": "tauri", "fetch-ffmpeg": "node scripts/fetch-ffmpeg.mjs" diff --git a/scripts/check-tauri-security.mjs b/scripts/check-tauri-security.mjs new file mode 100644 index 0000000..9a61d52 --- /dev/null +++ b/scripts/check-tauri-security.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const config = JSON.parse(readFileSync(new URL("../src-tauri/tauri.conf.json", import.meta.url), "utf8")); +const security = config.app?.security ?? {}; +const scope = security.assetProtocol?.scope ?? []; +const csp = security.csp ?? ""; +const scriptSrc = csp + .split(";") + .map((directive) => directive.trim()) + .find((directive) => directive.startsWith("script-src ")) ?? ""; + +assert.ok(!scope.includes("**"), "asset protocol must not expose the whole filesystem"); +assert.ok(!scriptSrc.includes("'unsafe-inline'"), "script-src must not allow unsafe-inline"); +assert.ok(!scriptSrc.includes("'unsafe-eval'"), "script-src must not allow unsafe-eval"); + +console.log("Tauri asset scope and script CSP are restricted"); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 6de9ca2..98d8cce 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -92,6 +92,14 @@ const MIGRATIONS: &[&str] = &[ "#, ]; +/// Applies one schema migration and its version marker atomically. +fn apply_migration(conn: &Connection, sql: &str, version: i64) -> rusqlite::Result<()> { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", version)?; + tx.commit() +} + /// Applies any migrations the DB hasn't seen yet, tracked via PRAGMA user_version. /// Idempotent: running twice is a no-op the second time. pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> { @@ -99,8 +107,7 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> { for (i, sql) in MIGRATIONS.iter().enumerate() { let version = (i + 1) as i64; if version > current { - conn.execute_batch(sql)?; - conn.execute_batch(&format!("PRAGMA user_version = {version}"))?; + apply_migration(conn, sql, version)?; } } Ok(()) @@ -159,6 +166,27 @@ mod tests { assert_eq!(table_count(&conn).unwrap(), 6); } + #[test] + fn failed_migration_rolls_back_schema_and_version() { + let conn = Connection::open_in_memory().unwrap(); + let result = apply_migration( + &conn, + "CREATE TABLE partial_migration(id INTEGER); THIS IS NOT SQL;", + 1, + ); + + assert!(result.is_err()); + let partial_tables: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='partial_migration'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(partial_tables, 0); + assert_eq!(schema_version(&conn).unwrap(), 0); + } + #[test] fn migrations_reach_v2() { let conn = Connection::open_in_memory().unwrap(); diff --git a/src-tauri/src/dedup.rs b/src-tauri/src/dedup.rs index 210ad9e..ad2b14c 100644 --- a/src-tauri/src/dedup.rs +++ b/src-tauri/src/dedup.rs @@ -210,20 +210,17 @@ mod tests { assert_eq!(m.kind, "name"); } - fn fixture(name: &str) -> Option { + fn fixture(name: &str) -> String { let p = format!("fixtures/{name}"); - std::path::Path::new(&p).exists().then_some(p) + assert!(std::path::Path::new(&p).is_file(), "missing generated fixture {p}"); + p } #[test] fn find_duplicate_confirms_by_sound() { // Two encodings of the same recording, named to share a name key → name match AND // sound match → kind "both". - let (Some(mp3), Some(flac)) = (fixture("real_320.mp3"), fixture("real_lossless.flac")) - else { - eprintln!("skip: no fixtures"); - return; - }; + let (mp3, flac) = (fixture("real_320.mp3"), fixture("real_lossless.flac")); crate::ffmpeg::init_ffmpeg_path(); let conn = db(); let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/ecartes.rs b/src-tauri/src/ecartes.rs index 0282700..11024b1 100644 --- a/src-tauri/src/ecartes.rs +++ b/src-tauri/src/ecartes.rs @@ -84,10 +84,20 @@ pub fn restore_track(conn: &Connection, track_id: i64) -> Result<(), String> { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } std::fs::rename(&to, &from).map_err(|e| e.to_string())?; - conn.execute("UPDATE actions SET undone=1 WHERE id=?1", params![action_id]) - .map_err(|e| e.to_string())?; - conn.execute("UPDATE tracks SET status='pending' WHERE id=?1", params![track_id]) - .map_err(|e| e.to_string())?; + let db_result: Result<(), String> = (|| { + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + tx.execute("UPDATE actions SET undone=1 WHERE id=?1", params![action_id]) + .map_err(|e| e.to_string())?; + tx.execute("UPDATE tracks SET status='pending' WHERE id=?1", params![track_id]) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(()) + })(); + if let Err(error) = db_result { + std::fs::rename(&from, &to) + .map_err(|rollback| format!("{error}; failed to return file to trash: {rollback}"))?; + return Err(error); + } Ok(()) } @@ -109,12 +119,18 @@ pub fn purge_trash(conn: &Connection) -> Result { let mut n = 0; for (tid, aid, to) in rows { if let Some(p) = &to { - let _ = std::fs::remove_file(p); + if let Err(error) = std::fs::remove_file(p) { + if error.kind() != std::io::ErrorKind::NotFound { + return Err(format!("delete trashed file {p}: {error}")); + } + } } - conn.execute("UPDATE actions SET undone=1 WHERE id=?1", params![aid]) + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + tx.execute("UPDATE actions SET undone=1 WHERE id=?1", params![aid]) .map_err(|e| e.to_string())?; - conn.execute("UPDATE tracks SET status='purged' WHERE id=?1", params![tid]) + tx.execute("UPDATE tracks SET status='purged' WHERE id=?1", params![tid]) .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; n += 1; } // Sweep any trashed track without a live trash action (orphaned journal) so it doesn't @@ -173,6 +189,45 @@ mod tests { assert_eq!(status, "pending"); } + #[test] + fn restore_retrashes_file_when_status_update_fails() { + let conn = db(); + let dir = tempfile::tempdir().unwrap(); + let original = dir.path().join("original.mp3"); + let trash = dir.path().join("trash.mp3"); + std::fs::write(&trash, b"audio").unwrap(); + conn.execute( + "INSERT INTO tracks(path, status) VALUES(?1, 'trash')", + params![original.to_str().unwrap()], + ) + .unwrap(); + let track_id = conn.last_insert_rowid(); + let action_id = crate::actions::record( + &conn, + "b1", + Some(track_id), + "trash", + Some(original.to_str().unwrap()), + Some(trash.to_str().unwrap()), + ) + .unwrap(); + conn.execute_batch( + "CREATE TRIGGER fail_restore_status BEFORE UPDATE OF status ON tracks + WHEN NEW.status='pending' + BEGIN SELECT RAISE(FAIL, 'restore status blocked'); END;", + ) + .unwrap(); + + assert!(restore_track(&conn, track_id).is_err()); + + assert!(!original.exists(), "failed restore must not leave the file at its original path"); + assert!(trash.exists(), "failed restore must put the file back in trash"); + let undone: i64 = conn + .query_row("SELECT undone FROM actions WHERE id=?1", [action_id], |r| r.get(0)) + .unwrap(); + assert_eq!(undone, 0); + } + #[test] fn restore_blocked_when_origin_occupied() { let conn = db(); @@ -206,4 +261,35 @@ mod tests { let status: String = conn.query_row("SELECT status FROM tracks WHERE id=?1", params![tid], |r| r.get(0)).unwrap(); assert_eq!(status, "purged"); } + + #[test] + fn purge_keeps_track_live_when_file_deletion_fails() { + let conn = db(); + let dir = tempfile::tempdir().unwrap(); + let undeletable = dir.path().join("not-a-file"); + std::fs::create_dir_all(&undeletable).unwrap(); + conn.execute("INSERT INTO tracks(path, status) VALUES('orig.mp3','trash')", []) + .unwrap(); + let track_id = conn.last_insert_rowid(); + let action_id = crate::actions::record( + &conn, + "b1", + Some(track_id), + "trash", + Some("orig.mp3"), + Some(undeletable.to_str().unwrap()), + ) + .unwrap(); + + assert!(purge_trash(&conn).is_err()); + + let status: String = conn + .query_row("SELECT status FROM tracks WHERE id=?1", [track_id], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "trash"); + let undone: i64 = conn + .query_row("SELECT undone FROM actions WHERE id=?1", [action_id], |r| r.get(0)) + .unwrap(); + assert_eq!(undone, 0); + } } diff --git a/src-tauri/src/encode.rs b/src-tauri/src/encode.rs index a5dfac0..e2fb2e8 100644 --- a/src-tauri/src/encode.rs +++ b/src-tauri/src/encode.rs @@ -162,13 +162,10 @@ pub fn encode(src: &str, dst: &str, target: Target) -> Result<(), EncodeError> { mod tests { use super::*; - fn fixture(name: &str) -> Option { + fn fixture(name: &str) -> String { let p = format!("fixtures/{name}"); - if std::path::Path::new(&p).exists() { - Some(p) - } else { - None - } + assert!(std::path::Path::new(&p).is_file(), "missing generated fixture {p}"); + p } #[test] @@ -188,10 +185,7 @@ mod tests { #[test] fn encodes_flac_to_conformant_wav() { - let Some(src) = fixture("real_lossless.flac") else { - eprintln!("skip: no fixture"); - return; - }; + let src = fixture("real_lossless.flac"); crate::ffmpeg::init_ffmpeg_path(); let dir = tempfile::tempdir().unwrap(); let dst = dir.path().join("out.wav"); @@ -210,29 +204,20 @@ mod tests { #[test] fn mp3_is_conformant_to_mp3_target() { - let Some(p) = fixture("real_320.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let p = fixture("real_320.mp3"); assert!(is_conformant(&p, Target::Mp3320)); } #[test] fn flac_is_not_conformant_to_either_target() { - let Some(p) = fixture("real_lossless.flac") else { - eprintln!("skip: no fixture"); - return; - }; + let p = fixture("real_lossless.flac"); assert!(!is_conformant(&p, Target::Mp3320)); // wrong codec assert!(!is_conformant(&p, Target::Aiff1644)); // wrong container } #[test] fn encodes_flac_to_conformant_aiff() { - let Some(src) = fixture("real_lossless.flac") else { - eprintln!("skip: no fixture"); - return; - }; + let src = fixture("real_lossless.flac"); crate::ffmpeg::init_ffmpeg_path(); // point ffmpeg-sidecar at the bundled dev binary let dir = tempfile::tempdir().unwrap(); let dst = dir.path().join("out.aiff"); @@ -244,10 +229,7 @@ mod tests { #[test] fn encodes_flac_to_mp3_320() { - let Some(src) = fixture("real_lossless.flac") else { - eprintln!("skip: no fixture"); - return; - }; + let src = fixture("real_lossless.flac"); crate::ffmpeg::init_ffmpeg_path(); // point ffmpeg-sidecar at the bundled dev binary let dir = tempfile::tempdir().unwrap(); let dst = dir.path().join("out.mp3"); diff --git a/src-tauri/src/filing.rs b/src-tauri/src/filing.rs index 92ccef0..be8f941 100644 --- a/src-tauri/src/filing.rs +++ b/src-tauri/src/filing.rs @@ -116,21 +116,6 @@ fn trash_file_fs(root: &Path, track_id: i64, source: &str) -> Result Result { - let dest = trash_file_fs(root, track_id, source)?; - actions::record(conn, batch_id, Some(track_id), "trash", Some(source), Some(&dest)) - .map_err(|e| FilingError::Db(e.to_string()))?; - Ok(dest) -} - /// Persist canonical metadata for a track (upsert into `metadata`). fn save_metadata(conn: &Connection, track_id: i64, c: &Canonical) -> Result<(), FilingError> { conn.execute( @@ -247,47 +232,70 @@ pub fn execute_file(plan: &FilePlan) -> Result, FilingError> { } /// Reverse phase-2 filesystem effects (newest first) — used when phase 3 cannot commit. -fn rollback_fs(log: &[FsLog]) { +fn rollback_fs(log: &[FsLog]) -> Result<(), FilingError> { + let mut errors = Vec::new(); for fs in log.iter().rev() { - match fs.kind { + let result = match fs.kind { "move" | "trash" => { - let _ = std::fs::rename(&fs.to, &fs.from); - } - "convert" => { - let _ = std::fs::remove_file(&fs.to); + if Path::new(&fs.from).exists() { + Err(format!("rollback destination occupied: {}", fs.from)) + } else { + std::fs::rename(&fs.to, &fs.from).map_err(|error| error.to_string()) + } } - _ => {} + "convert" => match std::fs::remove_file(&fs.to) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + }, + other => Err(format!("unknown filesystem action: {other}")), + }; + if let Err(error) = result { + errors.push(format!("{} {} -> {}: {error}", fs.kind, fs.to, fs.from)); } } + if errors.is_empty() { + Ok(()) + } else { + Err(FilingError::Io(errors.join("; "))) + } } /// Phase 3 (under the DB lock): journal the effects + mark the track filed. On any DB error, /// reverse the filesystem effects and the partial journal so nothing is left half-filed. pub fn commit_file(conn: &Connection, plan: &FilePlan, log: Vec) -> Result { - let undo = |conn: &Connection| { - rollback_fs(&log); - let _ = conn.execute("DELETE FROM actions WHERE batch_id=?1", params![plan.batch_id]); - }; - for fs in &log { - if let Err(e) = - actions::record(conn, &plan.batch_id, Some(plan.track_id), fs.kind, Some(&fs.from), Some(&fs.to)) - { - undo(conn); - return Err(FilingError::Db(e.to_string())); - } - } let conf = match plan.canonical.confidence { naming::Confidence::Green => "green", naming::Confidence::Yellow => "yellow", }; - if let Err(e) = conn.execute( - "UPDATE tracks SET status='filed', folder=?2, target_format=?3, confidence=?4 WHERE id=?1", - params![plan.track_id, plan.bin_rel, target_str(plan.target), conf], - ) { - undo(conn); - return Err(FilingError::Db(e.to_string())); + let db_result: Result<(), FilingError> = (|| { + let tx = conn.unchecked_transaction()?; + for fs in &log { + actions::record( + &tx, + &plan.batch_id, + Some(plan.track_id), + fs.kind, + Some(&fs.from), + Some(&fs.to), + )?; + } + tx.execute( + "UPDATE tracks SET status='filed', folder=?2, target_format=?3, confidence=?4 WHERE id=?1", + params![plan.track_id, plan.bin_rel, target_str(plan.target), conf], + )?; + save_metadata(&tx, plan.track_id, &plan.canonical)?; + tx.commit()?; + Ok(()) + })(); + if let Err(error) = db_result { + if let Err(rollback) = rollback_fs(&log) { + return Err(FilingError::Io(format!( + "{error}; filesystem rollback failed: {rollback}" + ))); + } + return Err(error); } - save_metadata(conn, plan.track_id, &plan.canonical)?; Ok(FileResult { path: plan.dest.clone(), batch_id: plan.batch_id.clone() }) } @@ -340,9 +348,10 @@ pub fn file_batch( pub fn reject_track(conn: &Connection, track_id: i64) -> Result<(), FilingError> { let source = track_path(conn, track_id)?; let batch_id = new_batch_id(track_id); - actions::record(conn, &batch_id, Some(track_id), "reject", Some(&source), None) - .map_err(|e| FilingError::Db(e.to_string()))?; - conn.execute("UPDATE tracks SET status='resourcing' WHERE id=?1", params![track_id])?; + let tx = conn.unchecked_transaction()?; + actions::record(&tx, &batch_id, Some(track_id), "reject", Some(&source), None)?; + tx.execute("UPDATE tracks SET status='resourcing' WHERE id=?1", params![track_id])?; + tx.commit()?; Ok(()) } @@ -350,8 +359,20 @@ pub fn reject_track(conn: &Connection, track_id: i64) -> Result<(), FilingError> pub fn trash_track(conn: &Connection, root: &Path, track_id: i64) -> Result<(), FilingError> { let source = track_path(conn, track_id)?; let batch_id = new_batch_id(track_id); - move_to_trash(conn, root, track_id, &batch_id, &source)?; - conn.execute("UPDATE tracks SET status='trash' WHERE id=?1", params![track_id])?; + let dest = trash_file_fs(root, track_id, &source)?; + let db_result: Result<(), FilingError> = (|| { + let tx = conn.unchecked_transaction()?; + actions::record(&tx, &batch_id, Some(track_id), "trash", Some(&source), Some(&dest))?; + tx.execute("UPDATE tracks SET status='trash' WHERE id=?1", params![track_id])?; + tx.commit()?; + Ok(()) + })(); + if let Err(error) = db_result { + std::fs::rename(&dest, &source).map_err(|rollback| { + FilingError::Io(format!("{error}; failed to restore trashed file: {rollback}")) + })?; + return Err(error); + } Ok(()) } @@ -365,18 +386,15 @@ mod tests { conn } - fn fixture(name: &str) -> Option { + fn fixture(name: &str) -> String { let p = format!("fixtures/{name}"); - if std::path::Path::new(&p).exists() { - Some(p) - } else { - None - } + assert!(std::path::Path::new(&p).is_file(), "missing generated fixture {p}"); + p } /// Copy a fixture into `dir` and insert a pending track row pointing at the copy. - fn seed_track(conn: &Connection, dir: &Path, fixture_name: &str, as_name: &str) -> Option<(i64, std::path::PathBuf)> { - let src = fixture(fixture_name)?; + fn seed_track(conn: &Connection, dir: &Path, fixture_name: &str, as_name: &str) -> (i64, std::path::PathBuf) { + let src = fixture(fixture_name); let copy = dir.join(as_name); std::fs::copy(&src, ©).unwrap(); conn.execute( @@ -384,17 +402,14 @@ mod tests { params![copy.to_str().unwrap()], ) .unwrap(); - Some((conn.last_insert_rowid(), copy)) + (conn.last_insert_rowid(), copy) } #[test] fn reconcile_track_reads_filename_when_tags_absent() { let conn = db(); let dir = tempfile::tempdir().unwrap(); - let Some((id, _)) = seed_track(&conn, dir.path(), "real_lossless.flac", "Robert Owens - Bring Down the Walls.flac") else { - eprintln!("skip: no fixture"); - return; - }; + let (id, _) = seed_track(&conn, dir.path(), "real_lossless.flac", "Robert Owens - Bring Down the Walls.flac"); let c = reconcile_track(&conn, id).unwrap(); assert_eq!(c.artist, "Robert Owens"); assert_eq!(c.title, "Bring Down the Walls"); @@ -406,10 +421,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("lib"); std::fs::create_dir_all(root.join("House")).unwrap(); - let Some((id, src)) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let (id, src) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3"); let res = file_track(&conn, &root, "{artist} - {title}", id, "House", None, Some(Canonical { artist: "Larry Heard".into(), title: "Can You Feel It".into(), version: None, @@ -435,10 +447,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("lib"); std::fs::create_dir_all(root.join("House")).unwrap(); - let Some((id, src)) = seed_track(&conn, dir.path(), "real_lossless.flac", "src.flac") else { - eprintln!("skip: no fixture"); - return; - }; + let (id, src) = seed_track(&conn, dir.path(), "real_lossless.flac", "src.flac"); crate::ffmpeg::init_ffmpeg_path(); let res = file_track(&conn, &root, "{artist} - {title}", id, "House", None, Some(Canonical { @@ -463,10 +472,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("lib"); std::fs::create_dir_all(&root).unwrap(); - let Some((id, _)) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let (id, _) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3"); let err = file_track(&conn, &root, "{artist} - {title}", id, "", Some(Target::Aiff1644), Some(Canonical { artist: "X".into(), title: "Y".into(), version: None, confidence: crate::naming::Confidence::Green, })); @@ -477,10 +483,7 @@ mod tests { fn reject_track_sets_resourcing_and_records() { let conn = db(); let dir = tempfile::tempdir().unwrap(); - let Some((id, _)) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let (id, _) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3"); reject_track(&conn, id).unwrap(); let status: String = conn.query_row("SELECT status FROM tracks WHERE id=?1", params![id], |r| r.get(0)).unwrap(); assert_eq!(status, "resourcing"); @@ -494,14 +497,146 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("lib"); std::fs::create_dir_all(&root).unwrap(); - let Some((id, src)) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let (id, src) = seed_track(&conn, dir.path(), "real_320.mp3", "src.mp3"); trash_track(&conn, &root, id).unwrap(); assert!(!src.exists()); let status: String = conn.query_row("SELECT status FROM tracks WHERE id=?1", params![id], |r| r.get(0)).unwrap(); assert_eq!(status, "trash"); assert!(root.join(".sift-trash").read_dir().unwrap().count() >= 1); } + + #[test] + fn commit_file_rolls_back_db_and_files_when_metadata_write_fails() { + let conn = db(); + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.mp3"); + let dest = dir.path().join("House/dest.mp3"); + std::fs::create_dir_all(dest.parent().unwrap()).unwrap(); + std::fs::write(&dest, b"filed").unwrap(); + conn.execute( + "INSERT INTO tracks(path, status) VALUES(?1, 'pending')", + params![source.to_str().unwrap()], + ) + .unwrap(); + let track_id = conn.last_insert_rowid(); + conn.execute_batch( + "CREATE TRIGGER fail_metadata BEFORE INSERT ON metadata + BEGIN SELECT RAISE(FAIL, 'metadata blocked'); END;", + ) + .unwrap(); + let plan = FilePlan { + track_id, + batch_id: "failing-commit".into(), + source: source.to_string_lossy().into_owned(), + dest: dest.to_string_lossy().into_owned(), + conformant: true, + target: Target::Mp3320, + canonical: Canonical { + artist: "Artist".into(), + title: "Title".into(), + version: None, + confidence: crate::naming::Confidence::Green, + }, + bin_rel: "House".into(), + root: dir.path().to_path_buf(), + }; + let log = vec![FsLog { + kind: "move", + from: plan.source.clone(), + to: plan.dest.clone(), + }]; + + assert!(commit_file(&conn, &plan, log).is_err()); + + assert!(source.exists(), "filesystem move must be compensated"); + assert!(!dest.exists(), "failed destination must be removed"); + let status: String = conn + .query_row("SELECT status FROM tracks WHERE id=?1", [track_id], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "pending"); + let actions: i64 = conn + .query_row( + "SELECT count(*) FROM actions WHERE batch_id='failing-commit'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(actions, 0); + } + + #[test] + fn commit_file_reports_failed_filesystem_rollback() { + let conn = db(); + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source.flac"); + let undeletable_dest = dir.path().join("converted-output"); + std::fs::create_dir_all(&undeletable_dest).unwrap(); + conn.execute( + "INSERT INTO tracks(path, status) VALUES(?1, 'pending')", + params![source.to_str().unwrap()], + ) + .unwrap(); + let track_id = conn.last_insert_rowid(); + conn.execute_batch( + "CREATE TRIGGER fail_metadata_rollback BEFORE INSERT ON metadata + BEGIN SELECT RAISE(FAIL, 'metadata blocked'); END;", + ) + .unwrap(); + let plan = FilePlan { + track_id, + batch_id: "failed-rollback".into(), + source: source.to_string_lossy().into_owned(), + dest: undeletable_dest.to_string_lossy().into_owned(), + conformant: false, + target: Target::Aiff1644, + canonical: Canonical { + artist: "Artist".into(), + title: "Title".into(), + version: None, + confidence: crate::naming::Confidence::Green, + }, + bin_rel: "House".into(), + root: dir.path().to_path_buf(), + }; + let log = vec![FsLog { + kind: "convert", + from: plan.source.clone(), + to: plan.dest.clone(), + }]; + + let error = commit_file(&conn, &plan, log).unwrap_err().to_string(); + + assert!(error.contains("filesystem rollback failed"), "{error}"); + assert!(undeletable_dest.is_dir()); + } + + #[test] + fn trash_track_restores_file_when_status_update_fails() { + let conn = db(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("library"); + let source = dir.path().join("source.mp3"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&source, b"audio").unwrap(); + conn.execute( + "INSERT INTO tracks(path, status) VALUES(?1, 'pending')", + params![source.to_str().unwrap()], + ) + .unwrap(); + let track_id = conn.last_insert_rowid(); + conn.execute_batch( + "CREATE TRIGGER fail_trash_status BEFORE UPDATE OF status ON tracks + WHEN NEW.status='trash' + BEGIN SELECT RAISE(FAIL, 'trash status blocked'); END;", + ) + .unwrap(); + + assert!(trash_track(&conn, &root, track_id).is_err()); + + assert!(source.exists(), "failed trash must restore the source file"); + let actions: i64 = conn + .query_row("SELECT count(*) FROM actions WHERE track_id=?1", [track_id], |r| r.get(0)) + .unwrap(); + assert_eq!(actions, 0); + } } diff --git a/src-tauri/src/fingerprint.rs b/src-tauri/src/fingerprint.rs index 7824d44..c51cacc 100644 --- a/src-tauri/src/fingerprint.rs +++ b/src-tauri/src/fingerprint.rs @@ -72,21 +72,15 @@ pub fn similarity(a: &[u32], b: &[u32]) -> f32 { mod tests { use super::*; - fn fixture(name: &str) -> Option { + fn fixture(name: &str) -> String { let p = format!("fixtures/{name}"); - if std::path::Path::new(&p).exists() { - Some(p) - } else { - None - } + assert!(std::path::Path::new(&p).is_file(), "missing generated fixture {p}"); + p } #[test] fn fingerprint_is_deterministic_and_self_identical() { - let Some(p) = fixture("real_320.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let p = fixture("real_320.mp3"); crate::ffmpeg::init_ffmpeg_path(); let a = compute_for_path(&p).expect("fingerprint"); let b = compute_for_path(&p).expect("fingerprint"); @@ -98,10 +92,7 @@ mod tests { fn same_source_different_encode_matches() { // real_320.mp3 is the 320k MP3 of real_lossless.flac — same recording, two encodings. // This is the core M5 promise: detect the dupe across format/name. - let (Some(p1), Some(p2)) = (fixture("real_320.mp3"), fixture("real_lossless.flac")) else { - eprintln!("skip: no fixtures"); - return; - }; + let (p1, p2) = (fixture("real_320.mp3"), fixture("real_lossless.flac")); crate::ffmpeg::init_ffmpeg_path(); let a = compute_for_path(&p1).expect("fp1"); let b = compute_for_path(&p2).expect("fp2"); @@ -112,10 +103,7 @@ mod tests { #[test] fn different_audio_below_threshold() { // sweep (real) vs a steady dual-mono tone — clearly different audio. - let (Some(p1), Some(p2)) = (fixture("real_320.mp3"), fixture("dual_mono.wav")) else { - eprintln!("skip: no fixtures"); - return; - }; + let (p1, p2) = (fixture("real_320.mp3"), fixture("dual_mono.wav")); crate::ffmpeg::init_ffmpeg_path(); let a = compute_for_path(&p1).expect("fp1"); let b = compute_for_path(&p2).expect("fp2"); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 60225cf..a970cda 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -4,6 +4,14 @@ use serde::Serialize; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State}; +fn allow_asset_file(app: &AppHandle, path: &str) -> Result<(), String> { + let path = std::path::Path::new(path); + if !path.is_file() { + return Err(format!("asset file not found: {}", path.display())); + } + app.asset_protocol_scope().allow_file(path).map_err(|e| e.to_string()) +} + #[derive(Serialize)] pub struct AppInfo { pub name: String, @@ -93,13 +101,19 @@ pub fn remove_source( } #[tauri::command] -pub fn list_queue(conn: State<'_, Mutex>) -> Result, String> { +pub fn list_queue( + app: AppHandle, + conn: State<'_, Mutex>, +) -> Result, String> { let conn = conn.lock().map_err(|e| e.to_string())?; let mut items = queue::list_pending(&conn).map_err(|e| e.to_string())?; // Annotate name-duplicate items so the queue can badge them before they're opened. let dups = crate::dedup::name_dups(&conn).map_err(|e| e.to_string())?; for it in &mut items { it.dup = dups.contains(&it.id); + if let Err(error) = allow_asset_file(&app, &it.path) { + log::warn!("queue asset scope: {error}"); + } } Ok(items) } @@ -224,6 +238,7 @@ pub fn analysis_progress( /// file-read / decode oracle on any path on disk. #[tauri::command] pub fn analyze_path( + app: AppHandle, conn: State<'_, Mutex>, path: String, with_spectrogram: bool, @@ -239,6 +254,7 @@ pub fn analyze_path( if !known { return Err("unknown track path".into()); } + allow_asset_file(&app, &path)?; // Serve the cached report instantly (no re-decode), except when a spectrogram is // requested (computed on demand, not cached). if !with_spectrogram { @@ -277,7 +293,25 @@ pub fn analyze_path( /// mp3/wav/flac/m4a/ogg directly, but NOT AIFF — so for .aif/.aiff we transcode once to a /// cached temp WAV and return that. The caller wraps the result with convertFileSrc. #[tauri::command] -pub fn playback_url(path: String) -> Result { +pub fn playback_url( + app: AppHandle, + conn: State<'_, Mutex>, + path: String, +) -> Result { + { + let conn = conn.lock().map_err(|e| e.to_string())?; + let known = conn + .query_row( + "SELECT 1 FROM tracks WHERE path=?1 LIMIT 1", + rusqlite::params![path], + |_| Ok(()), + ) + .is_ok(); + if !known { + return Err("unknown track path".into()); + } + } + allow_asset_file(&app, &path)?; let ext = std::path::Path::new(&path) .extension() .and_then(|e| e.to_str()) @@ -305,7 +339,9 @@ pub fn playback_url(path: String) -> Result { crate::encode::encode(&path, &out.to_string_lossy(), crate::encode::Target::Wav1644) .map_err(|e| e.to_string())?; } - Ok(out.to_string_lossy().to_string()) + let out = out.to_string_lossy().to_string(); + allow_asset_file(&app, &out)?; + Ok(out) } /// Open an external URL in the user's default browser (used by the Écartés buy links). diff --git a/src-tauri/src/tagging.rs b/src-tauri/src/tagging.rs index 459b33f..b81fb95 100644 --- a/src-tauri/src/tagging.rs +++ b/src-tauri/src/tagging.rs @@ -53,21 +53,15 @@ mod tests { use lofty::probe::Probe; use lofty::tag::ItemKey; - fn fixture(name: &str) -> Option { + fn fixture(name: &str) -> String { let p = format!("fixtures/{name}"); - if std::path::Path::new(&p).exists() { - Some(p) - } else { - None - } + assert!(std::path::Path::new(&p).is_file(), "missing generated fixture {p}"); + p } #[test] fn writes_and_reads_back_artist_title() { - let Some(src) = fixture("real_320.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let src = fixture("real_320.mp3"); let dir = tempfile::tempdir().unwrap(); let dst = dir.path().join("tagged.mp3"); std::fs::copy(&src, &dst).unwrap(); @@ -83,10 +77,7 @@ mod tests { #[test] fn read_artist_title_after_write() { - let Some(src) = fixture("real_320.mp3") else { - eprintln!("skip: no fixture"); - return; - }; + let src = fixture("real_320.mp3"); let dir = tempfile::tempdir().unwrap(); let dst = dir.path().join("rt.mp3"); std::fs::copy(&src, &dst).unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9a2054c..95efe7c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,10 +23,10 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost blob:; connect-src 'self' ipc: http://ipc.localhost asset: http://asset.localhost ws: wss: http://localhost:5173", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost blob:; connect-src 'self' ipc: http://ipc.localhost asset: http://asset.localhost ws://localhost:5173 http://localhost:5173", "assetProtocol": { "enable": true, - "scope": ["**"] + "scope": [] } } }, diff --git a/src-tauri/tests/characterization.rs b/src-tauri/tests/characterization.rs index c313d27..4a893e9 100644 --- a/src-tauri/tests/characterization.rs +++ b/src-tauri/tests/characterization.rs @@ -1,16 +1,17 @@ //! End-to-end M2a characterization on fabricated fixtures. Pins verdict + signal behavior. -//! Skips gracefully if fixtures are missing (run `node scripts/make-fixtures.mjs`). +//! Generated fixtures are mandatory (run `node scripts/make-fixtures.mjs`). use sift_lib::analysis::{analyze, Rail, Verdict}; use std::path::Path; -fn fixture(name: &str) -> Option { +fn fixture(name: &str) -> String { let p = format!("fixtures/{name}"); - if Path::new(&p).exists() { Some(p) } else { None } + assert!(Path::new(&p).is_file(), "missing generated fixture {p}; run node scripts/make-fixtures.mjs"); + p } #[test] fn real_lossless_flac_is_ok() { - let Some(p) = fixture("real_lossless.flac") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("real_lossless.flac"); let r = analyze(&p, false).expect("analyze"); assert_eq!(r.declared_rail, Rail::Lossless); assert!(r.cutoff_hz > 18000.0, "full-band cutoff, got {}", r.cutoff_hz); @@ -19,7 +20,7 @@ fn real_lossless_flac_is_ok() { #[test] fn fake_lossless_flac_is_fake() { - let Some(p) = fixture("fake_lossless.flac") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("fake_lossless.flac"); let r = analyze(&p, false).expect("analyze"); assert_eq!(r.declared_rail, Rail::Lossless); assert!(r.cutoff_hz < 18000.0, "transcoded cliff, got {}", r.cutoff_hz); @@ -28,7 +29,7 @@ fn fake_lossless_flac_is_fake() { #[test] fn honest_320_mp3_is_not_fake() { - let Some(p) = fixture("real_320.mp3") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("real_320.mp3"); let r = analyze(&p, false).expect("analyze"); assert_eq!(r.declared_rail, Rail::Lossy); assert_eq!(r.verdict, Verdict::Ok, "lossy is never fake via cutoff path"); @@ -36,7 +37,7 @@ fn honest_320_mp3_is_not_fake() { #[test] fn over_encoded_320_is_fake() { - let Some(p) = fixture("over_encoded_320.mp3") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("over_encoded_320.mp3"); let r = analyze(&p, false).expect("analyze"); assert_eq!(r.declared_rail, Rail::Lossy); assert!(r.cutoff_hz < 18500.0, "real cutoff well below a genuine 320, got {}", r.cutoff_hz); @@ -45,14 +46,14 @@ fn over_encoded_320_is_fake() { #[test] fn truncated_wav_is_flagged() { - let Some(p) = fixture("truncated.wav") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("truncated.wav"); let r = analyze(&p, false).expect("analyze"); assert!(r.truncated, "abrupt 1.5 s cut should flag truncation"); } #[test] fn silence_pad_measured() { - let Some(p) = fixture("silence_pad.wav") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("silence_pad.wav"); let r = analyze(&p, false).expect("analyze"); assert!(r.silence_head_ms >= 800 && r.silence_head_ms <= 1200, "head {}", r.silence_head_ms); assert!(r.silence_tail_ms >= 1300 && r.silence_tail_ms <= 1700, "tail {}", r.silence_tail_ms); @@ -60,7 +61,7 @@ fn silence_pad_measured() { #[test] fn dual_mono_detected() { - let Some(p) = fixture("dual_mono.wav") else { eprintln!("skip: no fixture"); return; }; + let p = fixture("dual_mono.wav"); let r = analyze(&p, false).expect("analyze"); assert!(r.dual_mono, "duplicated-mono stereo should be dual_mono"); assert!(r.phase_correlation > 0.99, "corr {}", r.phase_correlation); @@ -69,7 +70,10 @@ fn dual_mono_detected() { // Authentic anchors (only run if the user dropped real files in fixtures/) #[test] fn anchor_real_lossless_not_fake() { - let Some(p) = fixture("anchor_real_lossless.flac") else { return; }; - let r = analyze(&p, false).expect("analyze"); + let p = "fixtures/anchor_real_lossless.flac"; + if !Path::new(p).is_file() { + return; + } + let r = analyze(p, false).expect("analyze"); assert_ne!(r.verdict, Verdict::Fake); }