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