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