Skip to main content

zerodds_pcap/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-pcap` library — parsing primitives.
5//!
6//! Crate `zerodds-pcap`. Safety classification: **COMFORT**.
7//! Offline pcap-/RTPS-Decoder; pure parsing, kein Runtime-Pfad.
8//!
9//! Wir implementieren einen leichten libpcap-File-Reader (LE und BE
10//! Magic-Words) und einen "best-effort" RTPS-Locator: jeder packet-
11//! payload wird nach dem `RTPS`-Magic durchsucht; ab dem Treffer
12//! versuchen wir [`zerodds_rtps::datagram::decode_datagram`].
13//!
14//! Damit funktioniert das Tool für **alle gängigen pcap-Captures**
15//! (Ethernet/IP/UDP/RTPS) ohne dass wir L2/L3/L4-Parsing
16//! implementieren müssen — die UDP-Payload startet immer mit `RTPS`.
17
18#![warn(missing_docs)]
19#![allow(clippy::module_name_repetitions)]
20
21/// Pcap libpcap-Format Magic, Little-Endian Variante.
22pub const PCAP_MAGIC_LE: u32 = 0xA1B2_C3D4;
23/// Pcap libpcap-Format Magic, Big-Endian Variante.
24pub const PCAP_MAGIC_BE: u32 = 0xD4C3_B2A1;
25/// Pcap libpcap-Format Magic, nanosecond-precision LE.
26pub const PCAP_MAGIC_NS_LE: u32 = 0xA1B2_3C4D;
27/// RTPS-Magic (`'R','T','P','S'`).
28pub const RTPS_MAGIC: &[u8; 4] = b"RTPS";
29
30/// Pcap-File-Header (24 Bytes).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct PcapFileHeader {
33    /// Magic-Word (siehe [`PCAP_MAGIC_LE`]).
34    pub magic: u32,
35    /// Major-Version. Standard: 2.
36    pub version_major: u16,
37    /// Minor-Version. Standard: 4.
38    pub version_minor: u16,
39    /// Snapshot-Length (DoS-Cap pro packet).
40    pub snaplen: u32,
41    /// Datalink-Layer-Type (1 = Ethernet, 113 = LinuxSLL, 0 = NULL).
42    pub linktype: u32,
43}
44
45/// Per-Record Pcap-Header (16 Bytes).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct PcapRecordHeader {
48    /// Timestamp-Sekunden.
49    pub ts_sec: u32,
50    /// Timestamp-Microsekunden (oder ns je nach Magic).
51    pub ts_usec: u32,
52    /// Captured-Length (≤ snaplen).
53    pub incl_len: u32,
54    /// Original-Length im Wire.
55    pub orig_len: u32,
56}
57
58/// Parse-Fehler.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum PcapError {
61    /// Datei zu kurz für Header.
62    TooShort,
63    /// Magic-Word nicht erkannt.
64    BadMagic(u32),
65    /// `incl_len` größer als verbleibende Bytes.
66    TruncatedRecord,
67}
68
69impl std::fmt::Display for PcapError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::TooShort => write!(f, "pcap file too short for header"),
73            Self::BadMagic(m) => write!(f, "unknown pcap magic 0x{m:08x}"),
74            Self::TruncatedRecord => write!(f, "pcap record exceeds remaining bytes"),
75        }
76    }
77}
78
79impl std::error::Error for PcapError {}
80
81/// Liest den Pcap-File-Header. Liefert (`header`, `is_big_endian`).
82///
83/// # Errors
84/// [`PcapError::TooShort`] oder [`PcapError::BadMagic`].
85pub fn parse_file_header(bytes: &[u8]) -> Result<(PcapFileHeader, bool), PcapError> {
86    if bytes.len() < 24 {
87        return Err(PcapError::TooShort);
88    }
89    let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
90    let big_endian = match magic {
91        PCAP_MAGIC_LE | PCAP_MAGIC_NS_LE => false,
92        PCAP_MAGIC_BE => true,
93        m => return Err(PcapError::BadMagic(m)),
94    };
95    let read_u16 = |off: usize| -> u16 {
96        if big_endian {
97            u16::from_be_bytes([bytes[off], bytes[off + 1]])
98        } else {
99            u16::from_le_bytes([bytes[off], bytes[off + 1]])
100        }
101    };
102    let read_u32 = |off: usize| -> u32 {
103        if big_endian {
104            u32::from_be_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
105        } else {
106            u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
107        }
108    };
109    Ok((
110        PcapFileHeader {
111            magic,
112            version_major: read_u16(4),
113            version_minor: read_u16(6),
114            snaplen: read_u32(16),
115            linktype: read_u32(20),
116        },
117        big_endian,
118    ))
119}
120
121/// Iterator über pcap-Records. Liefert pro Aufruf
122/// `Some((header, payload_slice))`.
123pub struct PcapIter<'a> {
124    bytes: &'a [u8],
125    pos: usize,
126    big_endian: bool,
127}
128
129impl<'a> PcapIter<'a> {
130    /// Konstruiert den Iterator. Erwartet, dass [`parse_file_header`]
131    /// bereits erfolgreich war.
132    #[must_use]
133    pub fn new(bytes: &'a [u8], big_endian: bool) -> Self {
134        Self {
135            bytes,
136            pos: 24,
137            big_endian,
138        }
139    }
140
141    /// Liefert nächsten Record oder `None`.
142    ///
143    /// # Errors
144    /// [`PcapError::TruncatedRecord`] wenn `incl_len` über die Datei hinausragt.
145    pub fn next_record(&mut self) -> Result<Option<(PcapRecordHeader, &'a [u8])>, PcapError> {
146        if self.pos + 16 > self.bytes.len() {
147            return Ok(None);
148        }
149        let read_u32 = |b: &[u8], off: usize, be: bool| -> u32 {
150            if be {
151                u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
152            } else {
153                u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
154            }
155        };
156        let h = PcapRecordHeader {
157            ts_sec: read_u32(self.bytes, self.pos, self.big_endian),
158            ts_usec: read_u32(self.bytes, self.pos + 4, self.big_endian),
159            incl_len: read_u32(self.bytes, self.pos + 8, self.big_endian),
160            orig_len: read_u32(self.bytes, self.pos + 12, self.big_endian),
161        };
162        let data_start = self.pos + 16;
163        let data_end = data_start
164            .checked_add(h.incl_len as usize)
165            .ok_or(PcapError::TruncatedRecord)?;
166        if data_end > self.bytes.len() {
167            return Err(PcapError::TruncatedRecord);
168        }
169        self.pos = data_end;
170        Ok(Some((h, &self.bytes[data_start..data_end])))
171    }
172}
173
174/// Sucht den ersten `RTPS`-Magic im `payload` und liefert den
175/// resultierenden Offset (oder `None`).
176///
177/// Damit überspringen wir L2/L3/L4-Header (Ethernet/IP/UDP) ohne sie
178/// explizit zu parsen.
179#[must_use]
180pub fn find_rtps_offset(payload: &[u8]) -> Option<usize> {
181    payload.windows(4).position(|w| w == RTPS_MAGIC)
182}
183
184/// Sub-command des Pcap-CLIs.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum Command {
187    /// `parse <FILE>` — drucke RTPS-Submessages pro Frame.
188    Parse(FileArgs),
189    /// `stats <FILE>` — drucke nur Aggregat-Counts pro Submessage-Typ.
190    Stats(FileArgs),
191}
192
193/// Argumente für `parse` und `stats`.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct FileArgs {
196    /// Pfad zum pcap-File.
197    pub file: String,
198}
199
200/// Parse-Fehler beim CLI.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ParseError {
203    /// Kein subcommand.
204    Missing,
205    /// Unbekanntes subcommand.
206    Unknown(String),
207    /// FILE-arg fehlt.
208    MissingFile,
209}
210
211impl std::fmt::Display for ParseError {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        match self {
214            Self::Missing => write!(f, "no sub-command given"),
215            Self::Unknown(s) => write!(f, "unknown sub-command: {s}"),
216            Self::MissingFile => write!(f, "missing FILE argument"),
217        }
218    }
219}
220
221impl std::error::Error for ParseError {}
222
223/// Parst `args` (typisch `env::args().skip(1)`) zu einem [`Command`].
224///
225/// # Errors
226/// Siehe [`ParseError`].
227pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
228    let sub = args.first().ok_or(ParseError::Missing)?;
229    match sub.as_str() {
230        "parse" => {
231            let file = args.get(1).ok_or(ParseError::MissingFile)?.clone();
232            Ok(Command::Parse(FileArgs { file }))
233        }
234        "stats" => {
235            let file = args.get(1).ok_or(ParseError::MissingFile)?.clone();
236            Ok(Command::Stats(FileArgs { file }))
237        }
238        other => Err(ParseError::Unknown(other.to_string())),
239    }
240}
241
242#[cfg(test)]
243#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
244mod tests {
245    use super::*;
246
247    fn s(args: &[&str]) -> Vec<String> {
248        args.iter().map(|s| (*s).to_string()).collect()
249    }
250
251    fn make_pcap_le(records: &[&[u8]]) -> Vec<u8> {
252        let mut out = Vec::new();
253        // File header: magic LE, vmajor=2, vminor=4, thiszone=0, sigfigs=0, snaplen=65535, linktype=1
254        out.extend_from_slice(&PCAP_MAGIC_LE.to_le_bytes());
255        out.extend_from_slice(&2u16.to_le_bytes());
256        out.extend_from_slice(&4u16.to_le_bytes());
257        out.extend_from_slice(&0u32.to_le_bytes()); // thiszone
258        out.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
259        out.extend_from_slice(&65535u32.to_le_bytes());
260        out.extend_from_slice(&1u32.to_le_bytes());
261        for rec in records {
262            out.extend_from_slice(&0u32.to_le_bytes()); // ts_sec
263            out.extend_from_slice(&0u32.to_le_bytes()); // ts_usec
264            out.extend_from_slice(&(rec.len() as u32).to_le_bytes()); // incl_len
265            out.extend_from_slice(&(rec.len() as u32).to_le_bytes()); // orig_len
266            out.extend_from_slice(rec);
267        }
268        out
269    }
270
271    #[test]
272    fn parse_file_header_le() {
273        let pcap = make_pcap_le(&[]);
274        let (h, be) = parse_file_header(&pcap).unwrap();
275        assert_eq!(h.magic, PCAP_MAGIC_LE);
276        assert_eq!(h.version_major, 2);
277        assert_eq!(h.linktype, 1);
278        assert!(!be);
279    }
280
281    #[test]
282    fn parse_file_header_too_short() {
283        assert!(matches!(
284            parse_file_header(&[1, 2, 3]),
285            Err(PcapError::TooShort)
286        ));
287    }
288
289    #[test]
290    fn parse_file_header_bad_magic() {
291        let mut bytes = vec![0u8; 24];
292        bytes[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
293        assert!(matches!(
294            parse_file_header(&bytes),
295            Err(PcapError::BadMagic(_))
296        ));
297    }
298
299    #[test]
300    fn iter_returns_records() {
301        let pcap = make_pcap_le(&[b"hello-record-1", b"hello-record-2"]);
302        let (_h, be) = parse_file_header(&pcap).unwrap();
303        let mut iter = PcapIter::new(&pcap, be);
304        let (_h1, payload1) = iter.next_record().unwrap().unwrap();
305        assert_eq!(payload1, b"hello-record-1");
306        let (_h2, payload2) = iter.next_record().unwrap().unwrap();
307        assert_eq!(payload2, b"hello-record-2");
308        assert!(iter.next_record().unwrap().is_none());
309    }
310
311    #[test]
312    fn find_rtps_offset_no_match() {
313        assert!(find_rtps_offset(b"plain text without magic").is_none());
314    }
315
316    #[test]
317    fn find_rtps_offset_finds_after_header() {
318        let mut payload = vec![0u8; 42]; // simulate L2+L3+L4 header
319        payload.extend_from_slice(b"RTPS\x02\x05\x01\x10");
320        let off = find_rtps_offset(&payload).unwrap();
321        assert_eq!(off, 42);
322    }
323
324    #[test]
325    fn parse_args_parse_subcommand() {
326        let cmd = parse_args(&s(&["parse", "x.pcap"])).unwrap();
327        assert_eq!(
328            cmd,
329            Command::Parse(FileArgs {
330                file: "x.pcap".into()
331            })
332        );
333    }
334
335    #[test]
336    fn parse_args_stats_subcommand() {
337        let cmd = parse_args(&s(&["stats", "y.pcap"])).unwrap();
338        assert_eq!(
339            cmd,
340            Command::Stats(FileArgs {
341                file: "y.pcap".into()
342            })
343        );
344    }
345
346    #[test]
347    fn parse_args_missing_file() {
348        assert!(matches!(
349            parse_args(&s(&["parse"])),
350            Err(ParseError::MissingFile)
351        ));
352    }
353
354    #[test]
355    fn parse_args_unknown() {
356        assert!(matches!(
357            parse_args(&s(&["foo"])),
358            Err(ParseError::Unknown(_))
359        ));
360    }
361}