📄 main.rs 🔒 a0df8f18…f231a3f1 Se connecter pour télécharger ← Retour
// ⛔ INTERDIT : unwrap_or / unwrap_or_default / unwrap_or_else / unwrap / expect / anyhow / opérateur ? / await (sans match)
//
// linter-temps-constant — détecte les comparaisons `==`/`!=` directes sur des
// identifiants qui ressemblent à des secrets cryptographiques (clé, token,
// hash, hmac...), là où une comparaison à temps constant (`subtle::ConstantTimeEq`,
// `.ct_eq()`) devrait être utilisée à la place. Stdlib uniquement, zéro
// dépendance externe, zéro appel réseau.
//
// Substitution délibérée : ce projet utilise `tracing` pour la journalisation
// structurée d'un serveur long-running (solivram-website) — un outil CLI
// ponctuel comme celui-ci n'a pas besoin de cette infrastructure ; `eprintln!`
// suffit et évite une dépendance externe (voir SKILL.md "Pourquoi Rust").

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

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

const MOTS_SENSIBLES: &[&str] = &[
    "secret", "key", "token", "password", "mdp", "hash", "k_pqc", "hmac",
    "mac", "tag", "signature", "nonce", "salt", "empreinte", "jeton", "cle",
    "credential", "auth",
];

const MARQUEURS_TEMPS_CONSTANT: &[&str] = &["ct_eq", "constanttimeeq", "subtle::", "constant_time"];

#[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: usize,
    extrait: String,
    mot_declencheur: String,
}

fn extraire_tokens(ligne: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut courant = String::new();
    for c in ligne.chars() {
        if c.is_alphanumeric() || c == '_' {
            courant.push(c);
        } else if !courant.is_empty() {
            tokens.push(std::mem::take(&mut courant));
        }
    }
    if !courant.is_empty() {
        tokens.push(courant);
    }
    tokens
}

/// Fonction PURE (aucune I/O) — testable directement. Retourne le mot
/// déclencheur si la ligne contient une comparaison suspecte, None sinon.
fn ligne_suspecte(ligne: &str) -> Option<String> {
    let ligne_visible = ligne.trim();
    if ligne_visible.starts_with("//") {
        return None;
    }
    if !ligne.contains("==") && !ligne.contains("!=") {
        return None;
    }
    let ligne_minuscule = ligne.to_lowercase();
    for marqueur in MARQUEURS_TEMPS_CONSTANT {
        if ligne_minuscule.contains(marqueur) {
            return None;
        }
    }
    let tokens = extraire_tokens(&ligne_minuscule);
    for mot in MOTS_SENSIBLES {
        if tokens.iter().any(|t| t.contains(mot)) {
            return Some((*mot).to_string());
        }
    }
    None
}

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 mut trouvailles = Vec::new();
    for (num_ligne, ligne) in contenu.lines().enumerate() {
        match ligne_suspecte(ligne) {
            Some(mot) => {
                let mut extrait = ligne.trim().to_string();
                if extrait.len() > 120 {
                    extrait.truncate(117);
                    extrait.push_str("...");
                }
                trouvailles.push(Trouvaille {
                    fichier: chemin.display().to_string(),
                    ligne: num_ligne + 1,
                    extrait,
                    mot_declencheur: mot,
                });
            }
            None => {}
        }
    }
    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
            }
        };
        // §9-ok : même raison que ci-dessus — match imbriqué explicite.
        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 linter-temps-constant\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(
            "Aucune comparaison suspecte detectee sur les mots-cles surveilles.\n",
        );
        return rapport;
    }
    for t in trouvailles {
        rapport.push_str(&format!(
            "- **{}** — {}:{}\n  - Extrait : `{}`\n",
            t.mot_declencheur, t.fichier, t.ligne, 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 : linter-temps-constant <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_ligne_suspecte_01_detecte_comparaison_secret() {
        let resultat = ligne_suspecte("if session_token == token_attendu {");
        assert!(resultat.is_some(), "T-LIGNE-01 : doit detecter 'token'");
    }

    #[test]
    fn t_ligne_suspecte_02_ignore_deja_temps_constant() {
        let resultat = ligne_suspecte("if session_token.ct_eq(&token_attendu).into() {");
        assert_eq!(resultat, None, "T-LIGNE-02 : ct_eq deja present, aucun signalement attendu");
    }

    #[test]
    fn t_ligne_suspecte_03_ignore_commentaire() {
        let resultat = ligne_suspecte("// if secret_key == autre_cle {");
        assert_eq!(resultat, None, "T-LIGNE-03 : ligne commentee, aucun signalement attendu");
    }

    #[test]
    fn t_ligne_suspecte_04_ignore_comparaison_non_sensible() {
        let resultat = ligne_suspecte("if compteur == 0 {");
        assert_eq!(resultat, None, "T-LIGNE-04 : comparaison non sensible, aucun signalement attendu");
    }

    #[test]
    fn t_ligne_suspecte_05_detecte_not_equal() {
        let resultat = ligne_suspecte("if hmac_recu != hmac_calcule {");
        assert!(resultat.is_some(), "T-LIGNE-05 : doit detecter 'hmac' sur !=");
    }

    #[test]
    fn t_extraire_tokens_01() {
        let tokens = extraire_tokens("if k_pqc == autre_valeur {");
        assert!(tokens.contains(&"k_pqc".to_string()));
        assert!(tokens.contains(&"autre_valeur".to_string()));
    }

    #[test]
    fn t_generer_rapport_01_vide() {
        let rapport = generer_rapport(&[], 5);
        assert!(rapport.contains("5 fichier"));
        assert!(rapport.contains("0 signalement"));
        assert!(rapport.contains("Aucune comparaison suspecte"));
    }

    #[test]
    fn t_generer_rapport_02_avec_trouvailles() {
        let trouvailles = vec![Trouvaille {
            fichier: "exemple.rs".to_string(),
            ligne: 42,
            extrait: "if secret == autre {".to_string(),
            mot_declencheur: "secret".to_string(),
        }];
        let rapport = generer_rapport(&trouvailles, 1);
        assert!(rapport.contains("exemple.rs:42"));
        assert!(rapport.contains("secret"));
    }
}
10.1 Ko BLAKE3 : a0df8f18…f231a3f1