1. L’organisation d’un projet
analyse-journal/
├── Cargo.toml
├── Cargo.lock
├── src/
│ ├── main.rs ← court : lit les arguments, appelle la bibliothèque, affiche
│ ├── lib.rs ← déclare les modules et ce qui est public
│ ├── modele.rs ← les types de données
│ ├── analyse.rs ← la logique, sans affichage ni fichier
│ └── sortie.rs ← la mise en forme des résultats
├── tests/
│ └── integration.rs ← tests qui utilisent la bibliothèque comme un client
└── exemples/
└── auth.log ← des données de test
// src/lib.rs
pub mod modele;
pub mod analyse;
pub mod sortie;
// src/main.rs
use analyse_journal::analyse; // le nom du paquet, tirets remplacés par _
fn main() {
// …
}
2. Les dépendances
Les bibliothèques Rust, appelées crates, sont publiées sur crates.io. On les ajoute avec Cargo :
$ cargo add clap --features derive
$ cargo add serde --features derive
$ cargo add serde_json csv anyhow
# Cargo.toml
[package]
name = "analyse-journal"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
csv = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
| Écriture | Versions acceptées |
|---|---|
"1" ou "^1.0" | Toute version 1.x.y (mises à jour compatibles) |
"~1.4" | 1.4.x seulement |
"=1.4.2" | Exactement 1.4.2 |
Le fichier Cargo.lock enregistre la version exacte de chaque dépendance, y compris les dépendances des dépendances. Pour un programme, on le conserve dans Git : tout le monde compile alors exactement le même code.
| Crate | Rôle |
|---|---|
clap | Analyse des arguments de la ligne de commande |
serde, serde_json, csv | Conversion entre structures Rust et JSON ou CSV |
anyhow | Gestion d’erreurs simple pour une application |
thiserror | Définition d’énumérations d’erreurs pour une bibliothèque |
regex | Expressions régulières |
chrono | Dates et heures |
rand | Nombres aléatoires, y compris cryptographiques |
tokio, axum, reqwest | Asynchrone, serveur web, client HTTP (niveau 7) |
3. Lire les arguments
La bibliothèque standard fournit les arguments bruts :
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
// args[0] est le nom du programme
if args.len() < 2 {
eprintln!("usage : {} <fichier>", args[0]);
std::process::exit(2);
}
println!("fichier demandé : {}", args[1]);
}
Dès qu’il y a des options, clap fait tout le travail : analyse, validation, messages d’erreur et aide générée.
use clap::{Parser, ValueEnum};
use std::path::PathBuf;
#[derive(Clone, Copy, ValueEnum)]
enum Format {
Texte,
Json,
Csv,
}
/// Analyse un journal d'authentification et repère les attaques par force brute.
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// Fichier journal à analyser
fichier: PathBuf,
/// Nombre d'échecs à partir duquel une adresse est suspecte
#[arg(short, long, default_value_t = 5)]
seuil: u32,
/// Format de sortie
#[arg(short, long, value_enum, default_value_t = Format::Texte)]
format: Format,
/// Affiche le détail de chaque ligne ignorée
#[arg(short, long)]
verbeux: bool,
}
fn main() {
let args = Args::parse();
println!("{:?} seuil={} verbeux={}", args.fichier, args.seuil, args.verbeux);
}
$ cargo run -- --help
Analyse un journal d'authentification et repère les attaques par force brute
Usage: analyse-journal [OPTIONS] <FICHIER>
Arguments:
<FICHIER> Fichier journal à analyser
Options:
-s, --seuil <SEUIL> Nombre d'échecs à partir duquel une adresse est suspecte [default: 5]
-f, --format <FORMAT> Format de sortie [default: texte] [possible values: texte, json, csv]
-v, --verbeux Affiche le détail de chaque ligne ignorée
-h, --help Print help
-V, --version Print version
$ cargo run -- exemples/auth.log --seuil 3 --format json
Le -- sépare les options de Cargo de celles du programme. Les commentaires à trois barres /// deviennent le texte de l’aide.
4. Sérialiser avec serde
Sérialiser, c’est transformer une structure en texte (JSON, CSV…) ; désérialiser, l’inverse. Avec serde, il suffit d’un derive :
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Poste {
nom: String,
salle: u16,
ram_go: u32,
#[serde(default)] // champ absent du JSON → valeur par défaut
logiciels: Vec<String>,
}
fn main() -> Result<(), serde_json::Error> {
let json = r#"[
{"nom": "PC-207-01", "salle": 207, "ram_go": 16, "logiciels": ["VS Code", "Wireshark"]},
{"nom": "PC-112-04", "salle": 112, "ram_go": 8}
]"#;
let postes: Vec<Poste> = serde_json::from_str(json)?;
for p in &postes {
println!("{} : {} logiciel(s)", p.nom, p.logiciels.len());
}
let petits: Vec<&Poste> = postes.iter().filter(|p| p.ram_go < 16).collect();
println!("{}", serde_json::to_string_pretty(&petits)?);
Ok(())
}
// Lire et écrire du CSV
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Ligne {
nom: String,
salle: u16,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut lecteur = csv::ReaderBuilder::new().delimiter(b';').from_path("postes.csv")?;
let mut ecrivain = csv::WriterBuilder::new().delimiter(b';').from_path("salle207.csv")?;
for resultat in lecteur.deserialize() {
let ligne: Ligne = resultat?; // une ligne mal formée donne une erreur précise
if ligne.salle == 207 {
ecrivain.serialize(&ligne)?;
}
}
ecrivain.flush()?;
Ok(())
}
La désérialisation vérifie les données : un champ manquant, une salle qui n’est pas un nombre ou qui dépasse u16 produisent une erreur, avec la ligne et la colonne en cause. Les types choisis au niveau 1 deviennent ainsi un contrôle automatique des fichiers reçus.
5. Les erreurs d’une application
Au niveau 4, on a défini ses propres énumérations d’erreurs : c’est la bonne pratique pour une bibliothèque, dont l’appelant doit pouvoir distinguer les cas. Pour une application, on veut surtout un message clair avec son contexte : c’est le rôle d’anyhow.
use anyhow::{bail, Context, Result};
use std::fs;
fn lire_config(chemin: &str) -> Result<u32> {
let texte = fs::read_to_string(chemin)
.with_context(|| format!("impossible de lire la configuration {chemin}"))?;
let seuil: u32 = texte
.trim()
.parse()
.context("la configuration doit contenir un nombre entier")?;
if seuil == 0 {
bail!("le seuil ne peut pas valoir 0");
}
Ok(seuil)
}
fn main() {
if let Err(e) = lire_config("seuil.conf") {
eprintln!("Erreur : {e:#}");
std::process::exit(1);
}
}
Erreur : impossible de lire la configuration seuil.conf: No such file or directory (os error 2)
| Code de sortie | Signification usuelle |
|---|---|
| 0 | Succès |
| 1 | Erreur générale |
| 2 | Mauvaise utilisation (arguments invalides) — c’est ce que renvoie clap |
| autre | À documenter : par exemple 3 pour « anomalie détectée » |
Le code de sortie permet à un script, une tâche planifiée ou un outil de supervision de savoir automatiquement si le programme a réussi, sans lire son affichage.
6. La qualité avant de livrer
| Commande | Rôle |
|---|---|
cargo fmt | Met en forme selon le style officiel : plus de débats de présentation |
cargo clippy -- -D warnings | Des centaines de vérifications de style et d’erreurs probables ; les avertissements deviennent bloquants |
cargo test | Tests unitaires, tests d’intégration du dossier tests/, exemples de la documentation |
cargo doc --open | Génère la documentation HTML à partir des commentaires /// |
cargo audit | Recherche de vulnérabilités connues dans les dépendances |
cargo build --release | L’exécutable final, optimisé |
// tests/integration.rs : on teste la bibliothèque comme un utilisateur externe
use analyse_journal::analyse::analyser;
#[test]
fn detecte_force_brute() {
let journal = "2026-09-24T08:00:01 FAIL user=admin ip=203.0.113.9\n".repeat(6);
let rapport = analyser(journal.lines(), 5);
assert_eq!(rapport.suspectes.len(), 1);
}
7. Projet : l’analyseur de journal d’authentification
Ce projet relie développement et administration : on écrit l’outil qu’un administrateur système utiliserait pour repérer une attaque par force brute dans un journal de connexions.
2026-09-24T08:12:03 OK user=lina ip=10.0.0.12
2026-09-24T08:12:40 FAIL user=admin ip=203.0.113.9
2026-09-24T08:12:41 FAIL user=admin ip=203.0.113.9
2026-09-24T08:12:41 FAIL user=root ip=203.0.113.9
ligne corrompue ####
2026-09-24T08:13:02 FAIL user=sam ip=10.0.0.15
2026-09-24T08:13:05 OK user=sam ip=10.0.0.15
8. Bilan
Exercices corrigés
Ces corrigés sont écrits comme de vrais projets Cargo. Crée le projet, recopie les fichiers indiqués et lance cargo run -- --help. Les versions des crates évoluent : si une API a changé, le message du compilateur et la documentation sur docs.rs t’indiqueront la nouvelle écriture.
Exercice 6.1 — Un clone de wc
Voir le corrigé
// cargo new compte && cargo add clap --features derive
use clap::Parser;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
/// Compte les lignes, mots et caractères de fichiers texte.
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// Fichiers à analyser
#[arg(required = true)]
fichiers: Vec<PathBuf>,
/// N'afficher que les lignes
#[arg(short = 'l')]
lignes: bool,
/// N'afficher que les mots
#[arg(short = 'w')]
mots: bool,
/// N'afficher que les caractères
#[arg(short = 'c')]
caracteres: bool,
}
#[derive(Default, Clone, Copy)]
struct Compte {
lignes: usize,
mots: usize,
caracteres: usize,
}
fn compter(texte: &str) -> Compte {
Compte {
lignes: texte.lines().count(),
mots: texte.split_whitespace().count(),
caracteres: texte.chars().count(),
}
}
fn afficher(c: Compte, nom: &str, args: &Args) {
let tout = !(args.lignes || args.mots || args.caracteres);
let mut colonnes = Vec::new();
if tout || args.lignes { colonnes.push(format!("{:>8}", c.lignes)); }
if tout || args.mots { colonnes.push(format!("{:>8}", c.mots)); }
if tout || args.caracteres { colonnes.push(format!("{:>8}", c.caracteres)); }
println!("{} {nom}", colonnes.join(""));
}
fn main() -> ExitCode {
let args = Args::parse();
let mut total = Compte::default();
let mut echec = false;
for chemin in &args.fichiers {
match fs::read_to_string(chemin) {
Ok(texte) => {
let c = compter(&texte);
total.lignes += c.lignes;
total.mots += c.mots;
total.caracteres += c.caracteres;
afficher(c, &chemin.display().to_string(), &args);
}
Err(e) => {
eprintln!("compte: {}: {e}", chemin.display());
echec = true;
}
}
}
if args.fichiers.len() > 1 {
afficher(total, "total", &args);
}
if echec { ExitCode::FAILURE } else { ExitCode::SUCCESS }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compte_simple() {
let c = compter("un deux\ntrois\n");
assert_eq!((c.lignes, c.mots, c.caracteres), (2, 3, 14));
}
#[test]
fn accents_en_caracteres() {
assert_eq!(compter("été").caracteres, 3);
}
}
Explication
main renvoie un ExitCode : c’est la façon propre de choisir le code de sortie sans appeler process::exit, qui interromprait le programme sans libérer les ressources. La fonction compter ne dépend ni des fichiers ni de l’affichage : elle se teste directement.
Exercice 6.2 — Inventaire JSON vers CSV
Voir le corrigé
// cargo add clap --features derive ; cargo add serde --features derive ; cargo add serde_json csv anyhow
use anyhow::{Context, Result};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Parser)]
struct Args {
/// Inventaire au format JSON
inventaire: PathBuf,
/// Seuil de mémoire en Go
#[arg(long, default_value_t = 8)]
ram_min: u32,
/// Fichier CSV produit
#[arg(short, long, default_value = "a_renouveler.csv")]
sortie: PathBuf,
}
#[derive(Debug, Deserialize, Serialize)]
struct Poste {
nom: String,
salle: u16,
ram_go: u32,
}
fn main() -> Result<()> {
let args = Args::parse();
let texte = std::fs::read_to_string(&args.inventaire)
.with_context(|| format!("lecture de {}", args.inventaire.display()))?;
let mut postes: Vec<Poste> = serde_json::from_str(&texte)
.with_context(|| format!("{} n'est pas un inventaire JSON valide", args.inventaire.display()))?;
postes.retain(|p| p.ram_go < args.ram_min);
postes.sort_by(|a, b| a.salle.cmp(&b.salle).then_with(|| a.nom.cmp(&b.nom)));
let mut w = csv::WriterBuilder::new().delimiter(b';').from_path(&args.sortie)?;
for p in &postes {
w.serialize(p)?;
}
w.flush()?;
println!("{} poste(s) sous {} Go → {}", postes.len(), args.ram_min, args.sortie.display());
Ok(())
}
Explication
Quand main renvoie un anyhow::Result, une erreur est affichée avec toute sa chaîne de contexte, par exemple : inventaire.json n’est pas un inventaire JSON valide, puis la cause : EOF while parsing a value at line 2 column 0. Le tri combine deux critères avec then_with.
Projet : l’analyseur de journal d’authentification
Voir le corrigé
// src/modele.rs
use serde::Serialize;
use std::collections::BTreeSet;
use std::net::IpAddr;
#[derive(Debug, PartialEq)]
pub struct Evenement {
pub horodatage: String,
pub succes: bool,
pub utilisateur: String,
pub ip: IpAddr,
}
#[derive(Debug, Default, Serialize)]
pub struct StatIp {
pub ip: String,
pub echecs: u32,
pub succes: u32,
pub comptes: BTreeSet<String>,
pub critique: bool,
}
#[derive(Debug, Default, Serialize)]
pub struct Rapport {
pub lignes_lues: usize,
pub lignes_invalides: Vec<usize>,
pub suspectes: Vec<StatIp>,
}
// src/analyse.rs
use crate::modele::{Evenement, Rapport, StatIp};
use std::collections::HashMap;
use std::net::IpAddr;
const LONGUEUR_MAX: usize = 1024;
/// Remplace les caractères de contrôle, pour un affichage sans danger.
pub fn nettoyer(texte: &str) -> String {
texte.chars().map(|c| if c.is_control() { '?' } else { c }).collect()
}
/// Analyse une ligne : « horodatage OK|FAIL user=nom ip=adresse ».
pub fn analyser_ligne(ligne: &str) -> Option<Evenement> {
if ligne.len() > LONGUEUR_MAX {
return None;
}
let mut champs = ligne.split_whitespace();
let horodatage = champs.next()?.to_string();
let succes = match champs.next()? {
"OK" => true,
"FAIL" => false,
_ => return None,
};
let utilisateur = nettoyer(champs.next()?.strip_prefix("user=")?);
let ip: IpAddr = champs.next()?.strip_prefix("ip=")?.parse().ok()?;
if champs.next().is_some() || utilisateur.is_empty() {
return None;
}
Some(Evenement { horodatage, succes, utilisateur, ip })
}
pub fn analyser<'a>(lignes: impl Iterator<Item = &'a str>, seuil: u32) -> Rapport {
let mut rapport = Rapport::default();
let mut par_ip: HashMap<IpAddr, StatIp> = HashMap::new();
for (i, ligne) in lignes.enumerate() {
rapport.lignes_lues += 1;
let Some(ev) = analyser_ligne(ligne) else {
rapport.lignes_invalides.push(i + 1);
continue;
};
let stat = par_ip.entry(ev.ip).or_insert_with(|| StatIp {
ip: ev.ip.to_string(),
..Default::default()
});
if ev.succes {
stat.succes += 1;
} else {
stat.echecs += 1;
stat.comptes.insert(ev.utilisateur);
}
}
let mut suspectes: Vec<StatIp> = par_ip
.into_values()
.filter(|s| s.echecs >= seuil)
.map(|mut s| {
s.critique = s.succes > 0;
s
})
.collect();
suspectes.sort_by(|a, b| b.echecs.cmp(&a.echecs).then_with(|| a.ip.cmp(&b.ip)));
rapport.suspectes = suspectes;
rapport
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ligne_valide() {
let e = analyser_ligne("2026-09-24T08:12:40 FAIL user=admin ip=203.0.113.9").unwrap();
assert!(!e.succes);
assert_eq!(e.utilisateur, "admin");
}
#[test]
fn ip_invalide_refusee() {
assert!(analyser_ligne("2026-09-24T08:12:40 FAIL user=admin ip=999.1.1.1").is_none());
}
#[test]
fn statut_inconnu_refuse() {
assert!(analyser_ligne("2026-09-24T08:12:40 MAYBE user=a ip=10.0.0.1").is_none());
}
#[test]
fn champ_en_trop_refuse() {
assert!(analyser_ligne("t OK user=a ip=10.0.0.1 bonus").is_none());
}
#[test]
fn caracteres_de_controle_neutralises() {
let e = analyser_ligne("t FAIL user=ad\u{1b}[31mmin ip=10.0.0.1").unwrap();
assert!(!e.utilisateur.contains('\u{1b}'));
}
#[test]
fn ligne_trop_longue() {
assert!(analyser_ligne(&"x".repeat(5000)).is_none());
}
#[test]
fn critique_si_succes_apres_echecs() {
let j = "t FAIL user=a ip=10.0.0.9\nt FAIL user=a ip=10.0.0.9\nt OK user=a ip=10.0.0.9";
let r = analyser(j.lines(), 2);
assert_eq!(r.suspectes.len(), 1);
assert!(r.suspectes[0].critique);
}
}
// src/sortie.rs
use crate::modele::Rapport;
use anyhow::Result;
pub fn texte(r: &Rapport) {
println!("{} ligne(s) lue(s), {} invalide(s)", r.lignes_lues, r.lignes_invalides.len());
if r.suspectes.is_empty() {
println!("Aucune adresse suspecte.");
return;
}
println!("{:<18} {:>7} {:>7} comptes visés", "Adresse", "Échecs", "Succès");
for s in &r.suspectes {
let alerte = if s.critique { " ← CRITIQUE" } else { "" };
let comptes: Vec<&str> = s.comptes.iter().map(String::as_str).collect();
println!("{:<18} {:>7} {:>7} {}{alerte}", s.ip, s.echecs, s.succes, comptes.join(", "));
}
}
pub fn json(r: &Rapport) -> Result<()> {
println!("{}", serde_json::to_string_pretty(r)?);
Ok(())
}
pub fn csv(r: &Rapport) -> Result<()> {
let mut w = csv::WriterBuilder::new().delimiter(b';').from_writer(std::io::stdout());
w.write_record(["ip", "echecs", "succes", "comptes", "critique"])?;
for s in &r.suspectes {
let comptes: Vec<&str> = s.comptes.iter().map(String::as_str).collect();
w.write_record([
s.ip.clone(),
s.echecs.to_string(),
s.succes.to_string(),
comptes.join(","),
s.critique.to_string(),
])?;
}
w.flush()?;
Ok(())
}
// src/lib.rs
pub mod analyse;
pub mod modele;
pub mod sortie;
// src/main.rs
use analyse_journal::{analyse, sortie};
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Clone, Copy, ValueEnum)]
enum Format { Texte, Json, Csv }
/// Analyse un journal d'authentification et repère les attaques par force brute.
#[derive(Parser)]
#[command(version, about)]
struct Args {
/// Fichier journal à analyser
fichier: PathBuf,
/// Nombre d'échecs à partir duquel une adresse est suspecte
#[arg(short, long, default_value_t = 5)]
seuil: u32,
/// Format de sortie
#[arg(short, long, value_enum, default_value_t = Format::Texte)]
format: Format,
/// Affiche les numéros des lignes ignorées
#[arg(short, long)]
verbeux: bool,
}
fn executer(args: &Args) -> Result<bool> {
let fichier = File::open(&args.fichier)
.with_context(|| format!("impossible d'ouvrir {}", args.fichier.display()))?;
let lignes: Vec<String> = BufReader::new(fichier)
.lines()
.collect::<Result<_, _>>()
.context("lecture du journal interrompue")?;
let rapport = analyse::analyser(lignes.iter().map(String::as_str), args.seuil);
if args.verbeux {
for n in &rapport.lignes_invalides {
eprintln!("ligne {n} ignorée : format non reconnu");
}
}
match args.format {
Format::Texte => sortie::texte(&rapport),
Format::Json => sortie::json(&rapport)?,
Format::Csv => sortie::csv(&rapport)?,
}
Ok(!rapport.suspectes.is_empty())
}
fn main() -> ExitCode {
let args = Args::parse();
match executer(&args) {
Ok(false) => ExitCode::SUCCESS,
Ok(true) => ExitCode::from(3),
Err(e) => {
eprintln!("Erreur : {e:#}");
ExitCode::FAILURE
}
}
}
$ cargo run -- exemples/auth.log --seuil 3
7 ligne(s) lue(s), 1 invalide(s)
Adresse Échecs Succès comptes visés
203.0.113.9 3 0 admin, root
Explication
L’analyse d’une ligne s’appuie sur ? appliqué à des Option : la moindre anomalie (champ manquant, préfixe absent, adresse invalide) fait renvoyer None, en une ligne. IpAddr valide l’adresse, IPv4 comme IPv6. BTreeSet range les comptes visés sans doublon et dans l’ordre alphabétique. La bibliothèque ne fait aucun affichage : la même fonction analyser pourra servir dans une API web au niveau 7.
Pour aller plus loin
Étape suivante naturelle : une option --depuis pour n’analyser que les dernières heures, avec la crate chrono, et un fonctionnement en continu qui lit le journal au fur et à mesure qu’il grandit, comme tail -f.
🧠 Testez vos connaissances
5 questions. Répondez puis cliquez sur « Corriger ». Rien n’est enregistré : c’est de l’auto-évaluation.
© 2026 Bruno Jousseaume. Ce cours est publié sous licence CC BY-NC-SA 4.0 : vous pouvez le partager et l’adapter en citant l’auteur et un lien vers cette page, sans usage commercial, et en partageant vos adaptations sous la même licence.