From e46e0708b793e5667f1f59b5a1871f043d0d6aff Mon Sep 17 00:00:00 2001 From: cyprien Date: Sat, 15 Aug 2026 23:55:04 +0200 Subject: [PATCH] fix(examples): resoudre le LINKTYPE par paquet dans scan_pcaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un pcapng peut decrire plusieurs interfaces, chacune avec son LINKTYPE, et chaque paquet designe la sienne par interface_id. scan_pcaps appelait get_datalink() une seule fois par fichier puis bouclait sur while let Ok(packet) : le LINKTYPE de la premiere interface etait applique a tout le fichier, et la lecture sortait au premier changement d'encapsulation. Silencieusement — le fichier affichait zero trame. La lecture passe de libpcap a pcap-file, deja dependance principale de la crate, qui donne acces aux blocs de description d'interface. packet_parser prend deja le LinkType en parametre : c'etait bien l'iteration qui etait en cause, pas la crate. Trois fichiers du corpus etaient concernes, pas seulement celui de l'issue : The-Ultimate-PCAP.pcapng 0 -> 51328 capwap-only.pcapng 0 -> 2 capwap-association-valid.pcapng 0 -> 2 Les trois comptes correspondent exactement a tshark. Une comparaison avant/apres sur tout le corpus confirme qu'aucun autre fichier ne change. Une erreur de lecture est desormais distinguee d'une fin de fichier et signalee, fichier par fichier puis en recapitulatif final : un total de corpus bati sur des lectures amputees ne se presente plus comme complet. Le chemin pcap classique utilise next_raw_packet : next_packet rejette orig_len > snap_len comme invalide, ce qui est plus strict que libpcap et que la realite d'une trame tronquee a la capture. Cette validation faisait tomber une capture de 3584 trames a 4 — regression detectee par la comparaison de corpus, avant commit. Les erreurs L2 portent enfin leur LINKTYPE, ce qui repond au point 3 de l'issue : les 10667 echecs de The-Ultimate-PCAP viennent tous du LINKTYPE 274, IEEE 802.3br mPackets, seule des quatre encapsulations du fichier a n'avoir pas de decodeur. Closes #74 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 31 ++++++ examples/scan_pcaps.rs | 215 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 229 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b0968..2f399bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,37 @@ Le format suit l'esprit de [Keep a Changelog](https://keepachangelog.com/fr/1.1. ### Interne +- `examples/scan_pcaps.rs` lit desormais les captures avec `pcap-file` au lieu + de libpcap, et resout le LINKTYPE **par paquet** au lieu d'appeler + `get_datalink()` une seule fois par fichier. + + Un pcapng peut decrire plusieurs interfaces, chacune avec son LINKTYPE, et + chaque paquet designe la sienne par `interface_id`. L'ancienne boucle + appliquait le LINKTYPE de la premiere interface a tout le fichier et sortait + sur le premier `Err`, si bien que la capture s'arretait au premier changement + d'encapsulation — silencieusement, en affichant zero trame. + + Trois fichiers du corpus etaient concernes, pas un seul : + `The-Ultimate-PCAP.pcapng` (0 -> **51 328** trames), `capwap-only.pcapng` + (0 -> 2) et `capwap-association-valid.pcapng` (0 -> 2). Les trois comptes + correspondent exactement a ceux de tshark. Aucun autre fichier du corpus ne + change de compte (issue #74). + + Une lecture interrompue par une erreur est desormais distinguee d'une fin de + fichier et signalee, fichier par fichier puis en recapitulatif : un total de + corpus bati sur des lectures amputees ne se presente plus comme complet. + + Le chemin pcap classique utilise `next_raw_packet` : `next_packet` rejette + `orig_len > snap_len` comme invalide, ce qui est plus strict que libpcap et + que la realite d'une trame tronquee a la capture — cette validation faisait + tomber une capture de 3 584 trames a 4. + +- Les erreurs de couche liaison rapportees par `scan_pcaps` portent leur + LINKTYPE. Sur `The-Ultimate-PCAP.pcapng`, cela montre que les 10 667 echecs + proviennent tous du LINKTYPE 274 — **IEEE 802.3br mPackets**, le seul des + quatre encapsulations du fichier qui n'ait pas de decodeur. Les trois autres + (Ethernet 39 557, Linux SLL v1 1 067, Raw IP 37) sont decodees. + - `Cargo.toml` passe d'une liste `exclude` a une liste `include`, fail-closed par construction. Chaque release crates.io embarquait jusqu'ici cinq `.DS_Store`, `.codex`, `analyse.md`, `sprint_0{1,2}.md`, `oui.csv`, diff --git a/examples/scan_pcaps.rs b/examples/scan_pcaps.rs index c974013..58eb520 100644 --- a/examples/scan_pcaps.rs +++ b/examples/scan_pcaps.rs @@ -12,12 +12,32 @@ //! Avec `--focus`, chaque trame détectée comme PROTO est détaillée //! (n° de trame, ports, premiers octets du payload) pour auditer les //! faux positifs. +//! +//! ## Pourquoi ne pas passer par libpcap +//! +//! Un pcapng peut décrire **plusieurs interfaces**, chacune avec son propre +//! LINKTYPE, et chaque paquet référence la sienne par `interface_id`. Lire un +//! tel fichier avec un unique `get_datalink()` en début de fichier revient à +//! appliquer le LINKTYPE de la première interface à tous les paquets ; la +//! lecture s'interrompt au premier changement, et le fichier compte alors zéro +//! trame sans que rien ne le signale (issue #74). +//! +//! `pcap-file` donne accès aux blocs de description d'interface, donc au +//! LINKTYPE réel de chaque paquet. `packet_parser::parse` prend justement le +//! `LinkType` en paramètre : c'est l'itération qui était en cause, pas la +//! crate. use packet_parser::{LinkType, parse}; -use pcap::Capture; +use pcap_file::pcap::PcapReader; +use pcap_file::pcapng::{Block, PcapNgReader}; use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; +/// Premier mot d'un Section Header Block, qui identifie un pcapng. +const PCAPNG_SHB_MAGIC: u32 = 0x0A0D_0D0A; + fn collect_pcaps(path: &Path, out: &mut Vec) { if path.is_dir() { let mut entries: Vec<_> = match std::fs::read_dir(path) { @@ -36,6 +56,146 @@ fn collect_pcaps(path: &Path, out: &mut Vec) { } } +/// Issue de la lecture d'un fichier. +/// +/// `truncated_by` est le point clé : une lecture qui s'arrête sur une erreur +/// n'est pas une lecture complète, et l'ancienne boucle `while let Ok(..)` +/// confondait les deux. Un fichier partiellement lu doit le dire. +#[derive(Default)] +struct ReadOutcome { + frames: usize, + truncated_by: Option, +} + +/// Applique `on_packet` à chaque paquet du fichier, avec le `LinkType` de +/// l'interface dont il provient. +fn for_each_packet( + path: &Path, + on_packet: &mut impl FnMut(LinkType, &[u8]), +) -> std::io::Result { + let mut file = BufReader::new(File::open(path)?); + + let mut magic = [0u8; 4]; + file.read_exact(&mut magic)?; + file.seek(SeekFrom::Start(0))?; + + // Le SHB est le seul bloc dont le type est fixe et connu d'avance ; tout + // le reste (pcap classique, quel que soit son boutisme ou sa resolution + // temporelle) est delegue a PcapReader, qui lit son propre en-tete. + if u32::from_be_bytes(magic) == PCAPNG_SHB_MAGIC { + Ok(read_pcapng(file, on_packet)) + } else { + Ok(read_pcap(file, on_packet)) + } +} + +fn read_pcapng( + reader: BufReader, + on_packet: &mut impl FnMut(LinkType, &[u8]), +) -> ReadOutcome { + let mut outcome = ReadOutcome::default(); + + let mut reader = match PcapNgReader::new(reader) { + Ok(r) => r, + Err(e) => { + outcome.truncated_by = Some(format!("en-tete pcapng illisible ({e})")); + return outcome; + } + }; + + // LINKTYPE par interface, indexe par `interface_id`. Une nouvelle section + // remet la numerotation a zero (RFC pcapng §4.2). + let mut linktypes: Vec = Vec::new(); + + // `next_block` rend `None` en fin de fichier et `Some(Err)` sur erreur de + // lecture : ce sont deux choses differentes. Sur erreur, on s'arrete — le + // parseur sous-jacent ne consomme pas les octets fautifs, donc reessayer + // bouclerait — mais on le signale au lieu de faire passer une lecture + // amputee pour une lecture complete. + while let Some(block) = reader.next_block() { + let block = match block { + Ok(block) => block, + Err(e) => { + outcome.truncated_by = Some(format!( + "bloc illisible apres {} trames ({e})", + outcome.frames + )); + break; + } + }; + + match block { + Block::SectionHeader(_) => linktypes.clear(), + Block::InterfaceDescription(idb) => { + linktypes.push(LinkType::from(u32::from(idb.linktype))); + } + Block::EnhancedPacket(epb) => { + if let Some(&link_type) = linktypes.get(epb.interface_id as usize) { + outcome.frames += 1; + on_packet(link_type, &epb.data); + } + } + // Bloc obsolete (pcapng pre-1.0), conserve par certains outils. + Block::Packet(pb) => { + if let Some(&link_type) = linktypes.get(pb.interface_id as usize) { + outcome.frames += 1; + on_packet(link_type, &pb.data); + } + } + // Le Simple Packet Block ne porte pas d'`interface_id` : il n'est + // valide que si la section decrit une seule interface. + Block::SimplePacket(spb) => { + if let [link_type] = linktypes[..] { + outcome.frames += 1; + on_packet(link_type, &spb.data); + } + } + _ => {} + } + } + + outcome +} + +fn read_pcap(reader: BufReader, on_packet: &mut impl FnMut(LinkType, &[u8])) -> ReadOutcome { + let mut outcome = ReadOutcome::default(); + + let mut reader = match PcapReader::new(reader) { + Ok(r) => r, + Err(e) => { + outcome.truncated_by = Some(format!("en-tete pcap illisible ({e})")); + return outcome; + } + }; + + // Un pcap classique n'a qu'un seul LINKTYPE, dans son en-tete de fichier. + let link_type = LinkType::from(u32::from(reader.header().datalink)); + + // `next_raw_packet` plutot que `next_packet` : ce dernier rejette + // `orig_len > snap_len` comme invalide, ce qui est trop strict. Une trame + // tronquee a la capture a legitimement une longueur de fil superieure au + // snaplen declare, et libpcap l'accepte. Sur le corpus, cette validation + // faisait tomber une capture de 3 584 trames a 4. On ne lit de toute + // facon que `data`. + while let Some(packet) = reader.next_raw_packet() { + match packet { + Ok(packet) => { + outcome.frames += 1; + on_packet(link_type, &packet.data); + } + Err(e) => { + outcome.truncated_by = Some(format!( + "paquet illisible apres {} trames ({e})", + outcome.frames + )); + break; + } + } + } + + outcome +} + fn main() -> Result<(), Box> { let mut args: Vec = std::env::args().skip(1).collect(); let focus = args @@ -57,25 +217,22 @@ fn main() -> Result<(), Box> { } let mut grand_total: BTreeMap = BTreeMap::new(); + let mut incomplets = Vec::new(); for file in &files { - let mut cap = match Capture::from_file(file) { - Ok(c) => c, - Err(e) => { - eprintln!("{}: illisible ({e})", file.display()); - continue; - } - }; - - let link_type = LinkType::from(cap.get_datalink().0 as u32); - let mut tally: BTreeMap = BTreeMap::new(); let mut frame_no = 0usize; - while let Ok(packet) = cap.next_packet() { + + let mut on_packet = |link_type: LinkType, data: &[u8]| { frame_no += 1; - let Ok(flow) = parse(link_type, packet.data) else { - *tally.entry("".into()).or_default() += 1; - continue; + // L'erreur porte son LINKTYPE : sur une capture multi-interfaces, + // un total d'erreurs anonyme ne dit pas s'il manque un decodeur de + // liaison ou si les trames sont reellement corrompues. + let Ok(flow) = parse(link_type, data) else { + *tally + .entry(format!("")) + .or_default() += 1; + return; }; // La détection applicative pertinente est celle du flux le plus @@ -114,9 +271,21 @@ fn main() -> Result<(), Box> { } *tally.entry(label).or_default() += 1; - } + }; - println!("\n== {} ({frame_no} trames)", file.display()); + let outcome = match for_each_packet(file, &mut on_packet) { + Ok(outcome) => outcome, + Err(e) => { + eprintln!("{}: illisible ({e})", file.display()); + continue; + } + }; + + println!("\n== {} ({} trames)", file.display(), outcome.frames); + if let Some(raison) = &outcome.truncated_by { + println!(" ⚠ lecture incomplete : {raison}"); + incomplets.push(file.display().to_string()); + } for (proto, count) in &tally { println!(" {proto:<12} {count}"); *grand_total.entry(proto.clone()).or_default() += count; @@ -128,5 +297,17 @@ fn main() -> Result<(), Box> { println!(" {proto:<12} {count}"); } + // Un total de corpus construit sur des lectures amputees est trompeur : + // c'est exactement ce que faisait l'ancienne boucle, en silence. + if !incomplets.is_empty() { + println!( + "\n⚠ {} fichier(s) lus incompletement, les totaux ci-dessus sont partiels :", + incomplets.len() + ); + for file in &incomplets { + println!(" {file}"); + } + } + Ok(()) }