📄 main.rs 🔒 40f9b1bd…f65871a4 Se connecter pour télécharger ← Retour
// ⛔ INTERDIT : unwrap_or / unwrap_or_default / unwrap_or_else / unwrap / expect / anyhow / opérateur ? / await (sans match)
//
// detecteur-async-bloquant — détecte les appels bloquants (I/O fichier std,
// hachage Argon2/BLAKE3, verrou std::sync::Mutex, thread::sleep, transactions
// redb...) à l'intérieur du corps d'une fonction `async fn`, quand cette
// fonction ne délègue jamais l'appel à `tokio::task::spawn_blocking`. Un tel
// appel bloque le thread du runtime tokio et peut geler tout le serveur sous
// charge. Stdlib uniquement, zéro dépendance externe, zéro appel réseau.
//
// Analyse par motifs textuels avec suivi de profondeur d'accolades/parenthèses
// et exclusion des chaînes de caractères (normales et brutes r#"..."#) et des
// commentaires (// et /* */) — PAS un AST complet (voir SKILL.md "Limites
// connues"). L'exclusion des chaînes brutes est déterminante en pratique :
// un payload JSON littéral dans une chaîne brute, très courant dans du code
// serveur, contient des accolades qui fausseraient le suivi de profondeur
// sans cette exclusion.
//
// Substitution délibérée (même raison que linter-temps-constant) : `eprintln!`
// remplace `tracing` — outil CLI ponctuel, pas un serveur long-running.

use std::fs;
use std::path::{Path, PathBuf};

const DOSSIERS_IGNORES: &[&str] = &["target", ".git", "node_modules"];

const MARQUEUR_EXEMPTION_FONCTION: &str = "spawn_blocking";
const MARQUEUR_EXEMPTION_LIGNE: &str = "async-bloquant-ok";

const MOTIFS_BLOQUANTS: &[&str] = &[
    "std::fs::",
    "fs::read",
    "fs::write",
    "fs::create_dir",
    "fs::remove_",
    "fs::metadata",
    "fs::copy",
    "fs::rename",
    "thread::sleep(",
    "argon2",
    "blake3::",
    "reqwest::blocking",
    "begin_write(",
    "begin_read(",
];

#[derive(Debug)]
enum Erreur {
    ArgumentManquant,
    DossierInvalide(String),
    LectureFichier { chemin: String, source: std::io::Error },
}

impl std::fmt::Display for Erreur {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Erreur::ArgumentManquant => write!(f, "argument dossier_cible manquant"),
            Erreur::DossierInvalide(chemin) => write!(f, "dossier invalide : {chemin}"),
            Erreur::LectureFichier { chemin, source } => {
                write!(f, "lecture de {chemin} echouee : {source}")
            }
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
struct FonctionAsync {
    nom: String,
    ligne_debut: usize,
    corps: String,
}

#[derive(Debug, Clone)]
struct Trouvaille {
    fichier: String,
    fonction: String,
    ligne: usize,
    extrait: String,
    motif_declencheur: String,
}

fn correspond_a(motif: &str, caracteres: &[char], position: usize) -> bool {
    let motif_car: Vec<char> = motif.chars().collect();
    if position + motif_car.len() > caracteres.len() {
        return false;
    }
    caracteres[position..position + motif_car.len()] == motif_car[..]
}

/// Si la position `i` est le début d'une chaîne de caractères (normale ou
/// brute) ou d'un commentaire (`//` ou `/* */`), retourne la position juste
/// après la fin de cette construction. Sinon None. Fonction PURE.
fn sauter_chaine_ou_commentaire(caracteres: &[char], i: usize) -> Option<usize> {
    if correspond_a("//", caracteres, i) {
        let mut j = i;
        while j < caracteres.len() && caracteres[j] != '\n' {
            j += 1;
        }
        return Some(j);
    }
    if correspond_a("/*", caracteres, i) {
        let mut j = i + 2;
        while j + 1 < caracteres.len() && !(caracteres[j] == '*' && caracteres[j + 1] == '/') {
            j += 1;
        }
        return Some((j + 2).min(caracteres.len()));
    }
    if caracteres[i] == 'r' {
        let mut nb_dieses = 0usize;
        let mut j = i + 1;
        while j < caracteres.len() && caracteres[j] == '#' {
            nb_dieses += 1;
            j += 1;
        }
        if j < caracteres.len() && caracteres[j] == '"' {
            j += 1;
            loop {
                if j >= caracteres.len() {
                    return Some(j);
                }
                if caracteres[j] == '"' {
                    let mut compte = 0usize;
                    let mut k = j + 1;
                    while k < caracteres.len() && caracteres[k] == '#' && compte < nb_dieses {
                        compte += 1;
                        k += 1;
                    }
                    if compte == nb_dieses {
                        return Some(k);
                    }
                }
                j += 1;
            }
        }
    }
    if caracteres[i] == '"' {
        let mut j = i + 1;
        while j < caracteres.len() {
            if caracteres[j] == '\\' {
                j += 2;
                continue;
            }
            if caracteres[j] == '"' {
                return Some(j + 1);
            }
            j += 1;
        }
        return Some(j);
    }
    None
}

/// À partir de la position suivant le nom de fonction, avance jusqu'à
/// l'accolade ouvrante du corps (en ignorant les parenthèses de la
/// signature, les chaînes de caractères et les commentaires), puis retourne
/// le texte du corps, la position juste après l'accolade fermante, et la
/// ligne de l'accolade ouvrante. Fonction PURE.
fn trouver_corps_fonction(caracteres: &[char], depart: usize) -> Option<(String, usize, usize)> {
    let mut i = depart;
    let mut profondeur_parenthese = 0i32;
    let mut position_ouverture: Option<usize> = None;

    while i < caracteres.len() {
        match sauter_chaine_ou_commentaire(caracteres, i) {
            Some(apres) => {
                i = apres;
                continue;
            }
            None => {}
        }
        match caracteres[i] {
            '(' => profondeur_parenthese += 1,
            ')' => profondeur_parenthese -= 1,
            ';' if profondeur_parenthese == 0 => return None, // déclaration sans corps (trait)
            '{' if profondeur_parenthese == 0 => {
                position_ouverture = Some(i);
                break;
            }
            _ => {}
        }
        i += 1;
    }

    let debut_corps = match position_ouverture {
        Some(p) => p + 1,
        None => return None,
    };
    let ligne_ouverture = 1 + caracteres[..debut_corps].iter().filter(|c| **c == '\n').count();

    let mut profondeur_accolade = 1i32;
    let mut k = debut_corps;
    while k < caracteres.len() {
        match sauter_chaine_ou_commentaire(caracteres, k) {
            Some(apres) => {
                k = apres;
                continue;
            }
            None => {}
        }
        match caracteres[k] {
            '{' => profondeur_accolade += 1,
            '}' => {
                profondeur_accolade -= 1;
                if profondeur_accolade == 0 {
                    let corps: String = caracteres[debut_corps..k].iter().collect();
                    return Some((corps, k + 1, ligne_ouverture));
                }
            }
            _ => {}
        }
        k += 1;
    }
    None // accolade fermante jamais trouvée (fichier tronqué/invalide) — ignorée
}

/// Fonction PURE (aucune I/O) — retrouve, dans le texte complet d'un fichier,
/// chaque fonction `async fn` avec son nom, la ligne de son accolade
/// ouvrante et le texte de son corps.
fn extraire_fonctions_async(contenu: &str) -> Vec<FonctionAsync> {
    let caracteres: Vec<char> = contenu.chars().collect();
    let mut fonctions = Vec::new();
    let mut i = 0usize;
    while i < caracteres.len() {
        if correspond_a("async fn ", &caracteres, i) {
            let mut j = i + "async fn ".len();
            while j < caracteres.len() && caracteres[j].is_whitespace() {
                j += 1;
            }
            let debut_nom = j;
            while j < caracteres.len() && (caracteres[j].is_alphanumeric() || caracteres[j] == '_') {
                j += 1;
            }
            let nom: String = caracteres[debut_nom..j].iter().collect();

            match trouver_corps_fonction(&caracteres, j) {
                Some((corps, fin, ligne_debut)) => {
                    fonctions.push(FonctionAsync { nom, ligne_debut, corps });
                    i = fin;
                    continue;
                }
                None => {
                    i = j; // signature sans corps (trait) ou fichier tronqué — ignorée
                    continue;
                }
            }
        }
        i += 1;
    }
    fonctions
}

/// Fonction PURE — retourne le motif déclencheur si la ligne contient un
/// appel bloquant probable, None sinon.
fn ligne_bloquante(ligne: &str) -> Option<String> {
    let ligne_visible = ligne.trim();
    if ligne_visible.starts_with("//") {
        return None;
    }
    if ligne.contains(MARQUEUR_EXEMPTION_LIGNE) {
        return None;
    }
    // tokio::fs::* est l'équivalent ASYNC (non bloquant) de std::fs::* — mêmes
    // noms de méthode par conception (remplacement direct). Retirer ce préfixe
    // avant le filtrage évite de confondre "tokio::fs::create_dir_all" (sain)
    // avec "fs::create_dir" (motif bloquant surveillé).
    let ligne_minuscule = ligne.to_lowercase().replace("tokio::fs::", "");
    for motif in MOTIFS_BLOQUANTS {
        if ligne_minuscule.contains(motif) {
            return Some((*motif).to_string());
        }
    }
    if ligne.contains(".lock()") && !ligne.contains(".await") {
        return Some(".lock() sans .await (Mutex std probable)".to_string());
    }
    None
}

/// Fonction PURE — analyse le corps d'UNE fonction async déjà extraite.
/// Exemption totale si `spawn_blocking` apparaît n'importe où dans le corps
/// (convention du projet : l'appel bloquant est alors délégué explicitement).
fn analyser_fonction_async(fonction: &FonctionAsync, fichier: &str) -> Vec<Trouvaille> {
    if fonction.corps.contains(MARQUEUR_EXEMPTION_FONCTION) {
        return Vec::new();
    }
    let mut trouvailles = Vec::new();
    for (indice_relatif, ligne) in fonction.corps.lines().enumerate() {
        match ligne_bloquante(ligne) {
            Some(motif) => {
                let mut extrait = ligne.trim().to_string();
                if extrait.len() > 120 {
                    extrait.truncate(117);
                    extrait.push_str("...");
                }
                trouvailles.push(Trouvaille {
                    fichier: fichier.to_string(),
                    fonction: fonction.nom.clone(),
                    ligne: fonction.ligne_debut + indice_relatif,
                    extrait,
                    motif_declencheur: motif,
                });
            }
            None => {}
        }
    }
    trouvailles
}

fn scanner_fichier(chemin: &Path) -> Result<Vec<Trouvaille>, Erreur> {
    let contenu = match fs::read_to_string(chemin) {
        Ok(c) => c,
        Err(e) => {
            return Err(Erreur::LectureFichier {
                chemin: chemin.display().to_string(),
                source: e,
            });
        }
    };
    let fichier_str = chemin.display().to_string();
    let mut trouvailles = Vec::new();
    for fonction in extraire_fonctions_async(&contenu) {
        trouvailles.append(&mut analyser_fonction_async(&fonction, &fichier_str));
    }
    Ok(trouvailles)
}

fn iter_fichiers_rust(racine: &Path, resultats: &mut Vec<PathBuf>) {
    let entrees = match fs::read_dir(racine) {
        Ok(e) => e,
        Err(e) => {
            eprintln!("Avertissement : lecture dossier {} echouee : {e}", racine.display());
            return;
        }
    };
    for entree_res in entrees {
        let entree = match entree_res {
            Ok(e) => e,
            Err(e) => {
                eprintln!("Avertissement : entree de dossier illisible : {e}");
                continue;
            }
        };
        let chemin = entree.path();
        // §9-ok : match imbriqué explicite (pas de .and_then() chaîné) — évite
        // toute ambiguïté avec un éventuel unwrap_or masqué.
        let nom = match chemin.file_name() {
            Some(n) => match n.to_str() {
                Some(s) => s,
                None => continue,
            },
            None => continue,
        };
        if DOSSIERS_IGNORES.contains(&nom) {
            continue;
        }
        let type_fichier = match entree.file_type() {
            Ok(ft) => ft,
            Err(e) => {
                eprintln!("Avertissement : type de {} inconnu : {e}", chemin.display());
                continue; // §27-ok — intentionnel : ce fichier est ignoré, le parcours des autres continue
            }
        };
        let est_rs = match chemin.extension() {
            Some(ext) => match ext.to_str() {
                Some(s) => s == "rs",
                None => false, // §5-ok — extension non UTF-8 valide : jamais un fichier .rs légitime
            },
            None => false, // §5-ok — pas d'extension du tout : jamais un fichier .rs
        };
        if type_fichier.is_dir() {
            iter_fichiers_rust(&chemin, resultats);
        } else if est_rs {
            resultats.push(chemin);
        }
    }
}

fn generer_rapport(trouvailles: &[Trouvaille], nb_fichiers: usize) -> String {
    let mut rapport = String::new();
    rapport.push_str("# Rapport detecteur-async-bloquant\n\n");
    rapport.push_str(&format!(
        "{nb_fichiers} fichier(s) .rs examine(s) — {} signalement(s) au total.\n\n",
        trouvailles.len()
    ));
    if trouvailles.is_empty() {
        rapport.push_str(
            "Aucun appel bloquant probable detecte dans une fonction async sans spawn_blocking.\n",
        );
        return rapport;
    }
    for t in trouvailles {
        rapport.push_str(&format!(
            "- **{}** — {}:{} (fonction `{}`)\n  - Extrait : `{}`\n",
            t.motif_declencheur, t.fichier, t.ligne, t.fonction, t.extrait
        ));
    }
    rapport
}

fn executer(dossier_cible: &str) -> Result<String, Erreur> {
    let racine = Path::new(dossier_cible);
    if !racine.is_dir() {
        return Err(Erreur::DossierInvalide(dossier_cible.to_string()));
    }
    let mut fichiers = Vec::new();
    iter_fichiers_rust(racine, &mut fichiers);

    let mut toutes_trouvailles = Vec::new();
    for chemin in &fichiers {
        match scanner_fichier(chemin) {
            Ok(mut t) => toutes_trouvailles.append(&mut t),
            Err(e) => {
                eprintln!("Avertissement : {e}");
                // §27-ok — intentionnel : un fichier illisible est ignoré, le scan des autres continue
            }
        }
    }
    Ok(generer_rapport(&toutes_trouvailles, fichiers.len()))
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let dossier_cible = match args.get(1) {
        Some(d) => d.clone(),
        None => {
            eprintln!("Erreur : {}", Erreur::ArgumentManquant);
            eprintln!("Usage : detecteur-async-bloquant <dossier_cible>");
            std::process::exit(1);
        }
    };
    match executer(&dossier_cible) {
        Ok(rapport) => println!("{rapport}"),
        Err(e) => {
            eprintln!("Erreur : {e}");
            std::process::exit(1); // §27-ok — std::process::exit diverge, aucune exécution ne suit
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn t_extraction_01_fonction_simple() {
        let source = "async fn charger() {\n    let x = 1;\n}\n";
        let fonctions = extraire_fonctions_async(source);
        assert_eq!(fonctions.len(), 1);
        assert_eq!(fonctions[0].nom, "charger");
        assert!(fonctions[0].corps.contains("let x = 1;"));
    }

    #[test]
    fn t_extraction_02_ignore_chaine_brute_avec_accolades() {
        let source = "async fn envoyer() {\n    let payload = r#\"{\"t\":\"x\"}\"#;\n    std::fs::write(\"a\", payload);\n}\n";
        let fonctions = extraire_fonctions_async(source);
        assert_eq!(fonctions.len(), 1, "la chaine brute ne doit pas fausser le comptage d'accolades");
        assert!(fonctions[0].corps.contains("std::fs::write"));
    }

    #[test]
    fn t_extraction_03_ignore_commentaire_avec_accolade() {
        let source = "async fn f() {\n    // ancien code : if x { y }\n    let z = 2;\n}\n";
        let fonctions = extraire_fonctions_async(source);
        assert_eq!(fonctions.len(), 1);
        assert!(fonctions[0].corps.contains("let z = 2;"));
    }

    #[test]
    fn t_extraction_04_fonction_sync_non_capturee() {
        let source = "fn charger() {\n    std::fs::read(\"a\");\n}\n";
        let fonctions = extraire_fonctions_async(source);
        assert!(fonctions.is_empty(), "une fonction non-async ne doit jamais etre analysee");
    }

    #[test]
    fn t_extraction_05_trait_sans_corps_ignore() {
        let source = "trait T {\n    async fn methode(&self) -> Result<(), E>;\n}\n";
        let fonctions = extraire_fonctions_async(source);
        assert!(fonctions.is_empty(), "une declaration de trait sans corps ne doit jamais etre capturee");
    }

    #[test]
    fn t_ligne_bloquante_01_detecte_fs() {
        assert!(ligne_bloquante("    std::fs::write(chemin, donnees)?;").is_some());
    }

    #[test]
    fn t_ligne_bloquante_02_ignore_commentaire() {
        assert_eq!(ligne_bloquante("// std::fs::write(chemin, donnees);"), None);
    }

    #[test]
    fn t_ligne_bloquante_03_ignore_marqueur_exemption() {
        assert_eq!(ligne_bloquante("std::fs::write(c, d); // async-bloquant-ok"), None);
    }

    #[test]
    fn t_ligne_bloquante_04_lock_sans_await_detecte() {
        assert!(ligne_bloquante("let garde = mon_mutex.lock();").is_some());
    }

    #[test]
    fn t_ligne_bloquante_05_lock_avec_await_ignore() {
        assert_eq!(ligne_bloquante("let garde = mon_mutex.lock().await;"), None);
    }

    #[test]
    fn t_ligne_bloquante_06_argon2_detecte() {
        assert!(ligne_bloquante("let hash = Argon2::default().hash_password(pwd, &sel)?;").is_some());
    }

    #[test]
    fn t_ligne_bloquante_07_ligne_normale_ignoree() {
        assert_eq!(ligne_bloquante("let total = a + b;"), None);
    }

    #[test]
    fn t_ligne_bloquante_08_tokio_fs_non_bloquant_ignore() {
        assert_eq!(ligne_bloquante("tokio::fs::create_dir_all(dir).await?;"), None);
        assert_eq!(ligne_bloquante("tokio::fs::rename(tmp, dest).await?;"), None);
    }

    #[test]
    fn t_ligne_bloquante_09_std_fs_toujours_detecte_a_cote_de_tokio_fs() {
        // tokio::fs:: est retiré du filtrage, mais un vrai std::fs:: sur la
        // même ligne doit rester détecté.
        assert!(ligne_bloquante("let _ = std::fs::remove_file(&path); tokio::fs::rename(a, b);").is_some());
    }

    #[test]
    fn t_analyse_01_exemption_totale_si_spawn_blocking() {
        let fonction = FonctionAsync {
            nom: "f".to_string(),
            ligne_debut: 1,
            corps: "let r = tokio::task::spawn_blocking(move || std::fs::read(\"a\")).await;".to_string(),
        };
        let trouvailles = analyser_fonction_async(&fonction, "test.rs");
        assert!(trouvailles.is_empty(), "spawn_blocking present dans le corps : aucun signalement attendu");
    }

    #[test]
    fn t_analyse_02_signale_sans_spawn_blocking() {
        let fonction = FonctionAsync {
            nom: "f".to_string(),
            ligne_debut: 10,
            corps: "std::fs::write(\"a\", \"b\").unwrap();".to_string(),
        };
        let trouvailles = analyser_fonction_async(&fonction, "test.rs");
        assert_eq!(trouvailles.len(), 1);
        assert_eq!(trouvailles[0].ligne, 10);
        assert_eq!(trouvailles[0].fonction, "f");
    }

    #[test]
    fn t_generer_rapport_01_vide() {
        let rapport = generer_rapport(&[], 3);
        assert!(rapport.contains("3 fichier"));
        assert!(rapport.contains("0 signalement"));
        assert!(rapport.contains("Aucun appel bloquant"));
    }

    #[test]
    fn t_generer_rapport_02_avec_trouvailles() {
        let trouvailles = vec![Trouvaille {
            fichier: "exemple.rs".to_string(),
            fonction: "charger".to_string(),
            ligne: 12,
            extrait: "std::fs::read(\"a\");".to_string(),
            motif_declencheur: "std::fs::".to_string(),
        }];
        let rapport = generer_rapport(&trouvailles, 1);
        assert!(rapport.contains("exemple.rs:12"));
        assert!(rapport.contains("charger"));
    }

    #[test]
    fn t_pipeline_complet_fonction_imbriquee_dans_appel_ok() {
        // scénario réaliste : appel bloquant délégué via spawn_blocking, à
        // l'intérieur d'une fonction async — aucun signalement attendu.
        let source = "async fn creer_utilisateur(donnees: Donnees) -> Result<(), Erreur> {\n    \
            let hash = tokio::task::spawn_blocking(move || Argon2::default().hash_password(donnees.mdp.as_bytes(), &sel)).await;\n    \
            Ok(())\n}\n";
        let fonctions = extraire_fonctions_async(source);
        assert_eq!(fonctions.len(), 1);
        let trouvailles = analyser_fonction_async(&fonctions[0], "test.rs");
        assert!(trouvailles.is_empty());
    }
}
20.7 Ko BLAKE3 : 40f9b1bd…f65871a4