// ⛔ 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());
}
}