📄 main.rs 🔒 fdc617a7…941ce268 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-toctou-fichiers — détecte les patrons "check-then-act" (Time-Of-
// Check-To-Time-Of-Use) sur des fichiers : une vérification d'existence
// (`.exists()`, `.is_file()`, `.is_dir()`) suivie, plus loin dans la même
// fonction, d'une opération fichier mutante (écriture, création, suppression,
// renommage) portant sur le MÊME identifiant textuel. Entre la vérification
// et l'action, un autre processus/thread peut avoir modifié l'état du
// fichier — l'hypothèse vérifiée n'est alors plus garantie au moment de
// l'action. Stdlib uniquement, zéro dépendance externe, zéro appel réseau.
//
// Corrélation par IDENTIFIANT TEXTUEL (même limite assumée que la règle
// §32 DashMap du projet qui a produit ce skill : "comparaison de clé
// textuelle, ne résout pas les alias") — pas une analyse de flot de données
// réelle. Voir SKILL.md "Limites connues" pour le détail complet, en
// particulier sur les opérations idempotentes (`create_dir_all`) qui
// matchent le patron sans représenter un vrai risque.
//
// Suivi de profondeur d'accolades/parenthèses caractère par caractère avec
// exclusion des chaînes de caractères (normales + brutes r#"..."#) et des
// commentaires — même technique que les 2 autres skills Rust du projet
// (linter-temps-constant, detecteur-async-bloquant, detecteur-panic-points).
//
// Substitution délibérée (même raison que les autres skills Rust du
// projet) : `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_LIGNE: &str = "toctou-ok";

const MOTIFS_VERIFICATION: &[&str] = &[".exists()", ".is_file()", ".is_dir()"];

const MOTIFS_MUTATION: &[&str] = &[
    "fs::write(",
    "fs::create_dir(",
    "fs::create_dir_all(",
    "fs::remove_file(",
    "fs::remove_dir(",
    "fs::remove_dir_all(",
    "fs::rename(",
    "fs::copy(",
    "File::create(",
    "File::open(",
];

const LARGEUR_FENETRE_MAX: usize = 300;

#[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)]
struct Trouvaille {
    fichier: String,
    ligne_verification: usize,
    ligne_mutation: usize,
    identifiant: String,
    motif_mutation: String,
    extrait_verification: String,
    extrait_mutation: 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
}

/// Construit, pour tout le fichier, un masque "ce caractère appartient-il à
/// du vrai code" (false = à l'intérieur d'une chaîne ou d'un commentaire).
/// Fonction PURE — une seule passe, réutilisée par toutes les recherches.
fn construire_masque_code(caracteres: &[char]) -> Vec<bool> {
    let mut masque = vec![true; caracteres.len()];
    let mut i = 0usize;
    while i < caracteres.len() {
        match sauter_chaine_ou_commentaire(caracteres, i) {
            Some(fin) => {
                let borne = fin.min(caracteres.len());
                for m in masque.iter_mut().take(borne).skip(i) {
                    *m = false;
                }
                i = fin;
            }
            None => i += 1,
        }
    }
    masque
}

/// Recherche la prochaine occurrence de `motif` entre `depart` (inclus) et
/// `limite` (exclue), en ignorant toute position où `masque` vaut false
/// (chaîne/commentaire). Fonction PURE. `limite` est TOUJOURS explicite
/// (jamais implicitement "fin du fichier") — un appel systématiquement
/// borné à la fonction courante plutôt qu'au fichier entier est ce qui
/// évite une ré-analyse coûteuse du reste du fichier à chaque motif testé
/// (voir correction de performance du 2026-07-28 dans SKILL.md).
fn trouver_prochaine_occurrence(
    caracteres: &[char], masque: &[bool], depart: usize, limite: usize, motif: &str,
) -> Option<usize> {
    let borne = limite.min(caracteres.len());
    let mut i = depart;
    while i < borne {
        if masque[i] && correspond_a(motif, caracteres, i) {
            return Some(i);
        }
        i += 1;
    }
    None
}

/// À partir d'une position de départ, avance jusqu'à l'accolade ouvrante
/// d'un bloc (en ignorant les parenthèses d'une signature de fonction, les
/// chaînes et les commentaires), puis retourne les positions de début et de
/// fin du corps (juste après l'accolade ouvrante, juste après la fermante).
/// Fonction PURE.
fn trouver_bornes_bloc(caracteres: &[char], masque: &[bool], depart: usize) -> Option<(usize, usize)> {
    let mut i = depart;
    let mut profondeur_parenthese = 0i32;
    let mut position_ouverture: Option<usize> = None;

    while i < caracteres.len() {
        if !masque[i] {
            i += 1;
            continue;
        }
        match caracteres[i] {
            '(' => profondeur_parenthese += 1,
            ')' => profondeur_parenthese -= 1,
            ';' if profondeur_parenthese == 0 => return None,
            '{' 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 mut profondeur_accolade = 1i32;
    let mut k = debut_corps;
    while k < caracteres.len() {
        if !masque[k] {
            k += 1;
            continue;
        }
        match caracteres[k] {
            '{' => profondeur_accolade += 1,
            '}' => {
                profondeur_accolade -= 1;
                if profondeur_accolade == 0 {
                    return Some((debut_corps, k + 1));
                }
            }
            _ => {}
        }
        k += 1;
    }
    None
}

/// Fonction PURE — retrouve chaque fonction (`fn`/`async fn`/méthode) du
/// fichier : ligne de début, et bornes de son corps.
fn extraire_fonctions(caracteres: &[char], masque: &[bool]) -> Vec<(usize, usize, usize)> {
    let mut fonctions = Vec::new();
    let mut curseur = 0usize;
    while let Some(pos_fn) = trouver_prochaine_occurrence(caracteres, masque, curseur, caracteres.len(), "fn ") {
        let apres_fn = pos_fn + 3;
        let mut apres_nom = apres_fn;
        while apres_nom < caracteres.len()
            && (caracteres[apres_nom].is_alphanumeric() || caracteres[apres_nom] == '_')
        {
            apres_nom += 1;
        }
        match trouver_bornes_bloc(caracteres, masque, apres_nom) {
            Some((debut_corps, fin_corps)) => {
                let ligne_debut = ligne_de_position(caracteres, pos_fn);
                fonctions.push((ligne_debut, debut_corps, fin_corps));
                curseur = fin_corps;
            }
            None => curseur = apres_nom,
        }
    }
    fonctions
}

/// Fonction PURE — à partir de la position du `.` d'un appel comme
/// `chemin.exists()`, remonte pour extraire l'identifiant récepteur
/// (`chemin`, `self.chemin`...). None si le récepteur n'est pas un simple
/// enchaînement d'identifiants (ex. un appel de fonction imbriqué).
fn extraire_identifiant_recepteur(caracteres: &[char], masque: &[bool], position_point: usize) -> Option<String> {
    if position_point == 0 {
        return None;
    }
    let mut debut = position_point;
    while debut > 0 {
        let c = caracteres[debut - 1];
        if masque[debut - 1] && (c.is_alphanumeric() || c == '_' || c == '.') {
            debut -= 1;
        } else {
            break;
        }
    }
    if debut == position_point {
        return None;
    }
    let identifiant: String = caracteres[debut..position_point].iter().collect();
    if identifiant.is_empty() {
        return None;
    }
    Some(identifiant)
}

fn extraire_fenetre(caracteres: &[char], depart: usize, fin_max: usize) -> String {
    let limite = fin_max.min(depart + LARGEUR_FENETRE_MAX).min(caracteres.len());
    let mut fin = depart;
    while fin < limite && caracteres[fin] != ';' {
        fin += 1;
    }
    if fin < limite {
        fin += 1;
    }
    caracteres[depart..fin].iter().collect()
}

/// Fonction PURE — cherche, après la vérification, la mutation la plus
/// proche portant sur le même identifiant textuel (fenêtre bornée à la
/// prochaine instruction, voir LARGEUR_FENETRE_MAX).
fn rechercher_mutation_associee(
    caracteres: &[char], masque: &[bool], identifiant: &str, depart: usize, fin_fonction: usize,
) -> Option<(usize, String)> {
    let mut candidats: Vec<(usize, String)> = Vec::new();
    for motif in MOTIFS_MUTATION {
        let mut curseur = depart;
        while let Some(position) = trouver_prochaine_occurrence(caracteres, masque, curseur, fin_fonction, motif) {
            let fenetre = extraire_fenetre(caracteres, position, fin_fonction);
            if fenetre.contains(identifiant) {
                candidats.push((position, (*motif).to_string()));
            }
            curseur = position + 1;
        }
    }
    candidats.into_iter().min_by_key(|(p, _)| *p)
}

fn ligne_de_position(caracteres: &[char], position: usize) -> usize {
    1 + caracteres[..position].iter().filter(|c| **c == '\n').count()
}

fn extraire_ligne(caracteres: &[char], position: usize) -> String {
    let mut debut = position;
    while debut > 0 && caracteres[debut - 1] != '\n' {
        debut -= 1;
    }
    let mut fin = position;
    while fin < caracteres.len() && caracteres[fin] != '\n' {
        fin += 1;
    }
    let mut extrait: String = caracteres[debut..fin].iter().collect();
    extrait = extrait.trim().to_string();
    if extrait.len() > 120 {
        extrait.truncate(117);
        extrait.push_str("...");
    }
    extrait
}

/// Fonction PURE — cœur de la détection, opère sur le texte déjà en
/// mémoire (aucune I/O), testable directement sur des chaînes de caractères.
fn detecter_toctou(contenu: &str) -> Vec<(usize, usize, String, String, String, String)> {
    let caracteres: Vec<char> = contenu.chars().collect();
    let masque = construire_masque_code(&caracteres);
    let fonctions = extraire_fonctions(&caracteres, &masque);

    let mut resultats = Vec::new();
    for (_ligne_debut_fn, debut_corps, fin_corps) in fonctions {
        for motif_verif in MOTIFS_VERIFICATION {
            let mut curseur = debut_corps;
            while let Some(position) = trouver_prochaine_occurrence(&caracteres, &masque, curseur, fin_corps, motif_verif) {
                curseur = position + 1;
                let identifiant = match extraire_identifiant_recepteur(&caracteres, &masque, position) {
                    Some(id) => id,
                    None => continue,
                };
                let depart_recherche = position + motif_verif.chars().count();
                let mutation = match rechercher_mutation_associee(
                    &caracteres, &masque, &identifiant, depart_recherche, fin_corps,
                ) {
                    Some(m) => m,
                    None => continue,
                };
                let (position_mutation, motif_mutation) = mutation;
                let extrait_mutation = extraire_ligne(&caracteres, position_mutation);
                if extrait_mutation.contains(MARQUEUR_EXEMPTION_LIGNE) {
                    continue;
                }
                resultats.push((
                    ligne_de_position(&caracteres, position),
                    ligne_de_position(&caracteres, position_mutation),
                    identifiant.clone(),
                    motif_mutation,
                    extraire_ligne(&caracteres, position),
                    extrait_mutation,
                ));
            }
        }
    }
    resultats
}

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 trouvailles = detecter_toctou(&contenu)
        .into_iter()
        .map(
            |(ligne_verification, ligne_mutation, identifiant, motif_mutation, extrait_verification, extrait_mutation)| Trouvaille {
                fichier: fichier_str.clone(),
                ligne_verification,
                ligne_mutation,
                identifiant,
                motif_mutation,
                extrait_verification,
                extrait_mutation,
            },
        )
        .collect();
    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();
        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-toctou-fichiers\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 patron check-then-act detecte sur les motifs surveilles.\n");
        return rapport;
    }
    for t in trouvailles {
        rapport.push_str(&format!(
            "- **{}** sur `{}` — {}:{} (verification) → {}:{} (mutation)\n  - Verification : `{}`\n  - Mutation : `{}`\n",
            t.motif_mutation, t.identifiant, t.fichier, t.ligne_verification,
            t.fichier, t.ligne_mutation, t.extrait_verification, t.extrait_mutation
        ));
    }
    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-toctou-fichiers <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_detecte_toctou_simple() {
        let source = "fn f() {\n    if chemin.exists() {\n        fs::remove_file(chemin);\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert_eq!(resultats.len(), 1);
        assert_eq!(resultats[0].2, "chemin");
        assert_eq!(resultats[0].3, "fs::remove_file(");
    }

    #[test]
    fn t_detecte_avec_self() {
        let source = "fn f(&self) {\n    if self.chemin.is_file() {\n        File::open(self.chemin);\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert_eq!(resultats.len(), 1);
        assert_eq!(resultats[0].2, "self.chemin");
    }

    #[test]
    fn t_ignore_identifiant_different() {
        let source = "fn f() {\n    if chemin_a.exists() {\n        fs::remove_file(chemin_b);\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty(), "identifiants differents, aucune correlation attendue");
    }

    #[test]
    fn t_ignore_verification_seule_sans_mutation() {
        let source = "fn f() {\n    if chemin.exists() {\n        println!(\"present\");\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty());
    }

    #[test]
    fn t_ignore_mutation_avant_verification() {
        let source = "fn f() {\n    fs::remove_file(chemin);\n    if chemin.exists() {\n        println!(\"x\");\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty(), "la mutation precede la verification, aucun toctou attendu");
    }

    #[test]
    fn t_ignore_chaine_brute_avec_faux_motif() {
        let source = "fn f() {\n    let doc = r#\"exemple: chemin.exists() puis fs::remove_file(chemin)\"#;\n    let _ = doc;\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty(), "un exemple dans une chaine brute ne doit jamais etre signale");
    }

    #[test]
    fn t_ignore_commentaire() {
        let source = "fn f() {\n    // ancien code : chemin.exists() puis fs::remove_file(chemin)\n    let x = 1;\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty());
    }

    #[test]
    fn t_ignore_marqueur_exemption() {
        let source = "fn f() {\n    if chemin.exists() {\n        fs::remove_file(chemin); // toctou-ok\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty());
    }

    #[test]
    fn t_ignore_fonctions_differentes() {
        let source = "fn a() {\n    if chemin.exists() {}\n}\nfn b() {\n    fs::remove_file(chemin);\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty(), "verification et mutation dans 2 fonctions distinctes, aucune correlation attendue");
    }

    #[test]
    fn t_receveur_appel_de_fonction_ignore() {
        // le récepteur "obtenir_chemin()" contient des parenthèses -> non
        // reconnu comme simple identifiant, ignoré (limite assumée)
        let source = "fn f() {\n    if obtenir_chemin().exists() {\n        fs::remove_file(obtenir_chemin());\n    }\n}\n";
        let resultats = detecter_toctou(source);
        assert!(resultats.is_empty());
    }

    #[test]
    fn t_generer_rapport_01_vide() {
        let rapport = generer_rapport(&[], 6);
        assert!(rapport.contains("6 fichier"));
        assert!(rapport.contains("0 signalement"));
        assert!(rapport.contains("Aucun patron check-then-act"));
    }

    #[test]
    fn t_generer_rapport_02_avec_trouvailles() {
        let trouvailles = vec![Trouvaille {
            fichier: "exemple.rs".to_string(),
            ligne_verification: 3,
            ligne_mutation: 5,
            identifiant: "chemin".to_string(),
            motif_mutation: "fs::remove_file(".to_string(),
            extrait_verification: "if chemin.exists() {".to_string(),
            extrait_mutation: "fs::remove_file(chemin);".to_string(),
        }];
        let rapport = generer_rapport(&trouvailles, 1);
        assert!(rapport.contains("exemple.rs:3"));
        assert!(rapport.contains("exemple.rs:5"));
        assert!(rapport.contains("chemin"));
    }
}
23.1 Ko BLAKE3 : fdc617a7…941ce268