Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions docs/skills-registre.md
Original file line number Diff line number Diff line change
@@ -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. |
108 changes: 108 additions & 0 deletions docs/superpowers/plans/2026-07-13-review-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions scripts/check-tauri-security.mjs
Original file line number Diff line number Diff line change
@@ -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");
32 changes: 30 additions & 2 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,22 @@ 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<()> {
let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
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(())
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 4 additions & 7 deletions src-tauri/src/dedup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,20 +210,17 @@ mod tests {
assert_eq!(m.kind, "name");
}

fn fixture(name: &str) -> Option<String> {
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();
Expand Down
100 changes: 93 additions & 7 deletions src-tauri/src/ecartes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand All @@ -109,12 +119,18 @@ pub fn purge_trash(conn: &Connection) -> Result<usize, String> {
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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
Loading