Skip to main content

freeswitch_sofia_trace_parser/
types.rs

1use std::borrow::Cow;
2use std::fmt;
3
4/// Why a region of the input stream was not parsed into a frame.
5///
6/// Every byte in the input is either parsed or classified with one of these
7/// reasons, enabling byte-level coverage accounting via [`ParseStats`].
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum SkipReason {
10    /// Truncated frame at the start of a file, typically from logrotate
11    /// cutting mid-write. Capped at 65,535 bytes.
12    PartialFirstFrame,
13    /// Skip region exceeds 65,535 bytes at file start, indicating the input
14    /// is not a dump file (e.g., compressed or binary data).
15    OversizedFrame,
16    /// Unrecoverable bytes skipped between valid frames mid-stream.
17    MidStreamSkip,
18    /// Logrotate wrote a partial frame tail at the start of the new file.
19    /// Detected by the `\r\n\r\n\x0B\n` suffix pattern.
20    ReplayedFrame,
21    /// Frame at EOF with fewer content bytes than declared in the header.
22    IncompleteFrame,
23    /// Data starts with `recv`/`sent` but fails frame header parsing.
24    InvalidHeader,
25}
26
27impl fmt::Display for SkipReason {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            SkipReason::PartialFirstFrame => f.write_str("partial first frame"),
31            SkipReason::OversizedFrame => f.write_str("oversized frame"),
32            SkipReason::MidStreamSkip => f.write_str("mid-stream skip"),
33            SkipReason::ReplayedFrame => f.write_str("replayed frame (logrotate)"),
34            SkipReason::IncompleteFrame => f.write_str("incomplete frame"),
35            SkipReason::InvalidHeader => f.write_str("invalid header"),
36        }
37    }
38}
39
40/// Controls how much detail the parser records about unparsed regions.
41///
42/// Defaults to `CountOnly` for constant-memory operation. Higher levels
43/// allocate per-region and should only be enabled for diagnostics.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum SkipTracking {
46    /// Track only `bytes_read` and `bytes_skipped` counters. No allocation.
47    CountOnly,
48    /// Record offset, length, and reason for each unparsed region.
49    TrackRegions,
50    /// Like `TrackRegions`, but also capture the skipped bytes themselves.
51    CaptureData,
52}
53
54/// A contiguous region of the input that was not parsed into a frame.
55#[derive(Debug, Clone)]
56pub struct UnparsedRegion {
57    /// Byte offset from the start of the input stream.
58    pub offset: u64,
59    /// Number of bytes in this region.
60    pub length: u64,
61    /// Why this region was skipped.
62    pub reason: SkipReason,
63    /// The raw bytes, populated only when [`SkipTracking::CaptureData`] is enabled.
64    pub data: Option<Vec<u8>>,
65}
66
67/// Byte-level parse coverage statistics.
68///
69/// Available from all three iterator levels via `stats()` or `parse_stats()`.
70/// Every byte consumed from the reader is accounted for as either parsed
71/// (`bytes_read - bytes_skipped`) or skipped (`bytes_skipped`).
72#[derive(Debug, Default, Clone)]
73pub struct ParseStats {
74    /// Total bytes consumed from the reader.
75    pub bytes_read: u64,
76    /// Bytes that were skipped (not parsed into frames).
77    pub bytes_skipped: u64,
78    /// Detailed unparsed region records. Only populated when
79    /// [`SkipTracking`] is `TrackRegions` or `CaptureData`.
80    pub unparsed_regions: Vec<UnparsedRegion>,
81}
82
83impl ParseStats {
84    /// Take all accumulated unparsed regions, leaving the list empty.
85    pub fn drain_regions(&mut self) -> Vec<UnparsedRegion> {
86        std::mem::take(&mut self.unparsed_regions)
87    }
88}
89
90/// Whether a frame was received or sent by FreeSWITCH.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum Direction {
93    /// Received from the network.
94    Recv,
95    /// Sent to the network.
96    Sent,
97}
98
99impl fmt::Display for Direction {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Direction::Recv => f.write_str("recv"),
103            Direction::Sent => f.write_str("sent"),
104        }
105    }
106}
107
108impl Direction {
109    /// Returns `"from"` for `Recv`, `"to"` for `Sent`.
110    pub fn preposition(&self) -> &'static str {
111        match self {
112            Direction::Recv => "from",
113            Direction::Sent => "to",
114        }
115    }
116}
117
118/// SIP transport protocol as reported in the frame header.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum Transport {
121    /// Transmission Control Protocol.
122    Tcp,
123    /// User Datagram Protocol.
124    Udp,
125    /// Transport Layer Security.
126    Tls,
127    /// WebSocket Secure (RFC 7118).
128    Wss,
129}
130
131impl fmt::Display for Transport {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Transport::Tcp => f.write_str("tcp"),
135            Transport::Udp => f.write_str("udp"),
136            Transport::Tls => f.write_str("tls"),
137            Transport::Wss => f.write_str("wss"),
138        }
139    }
140}
141
142/// Frame timestamp, either time-only or full date+time.
143///
144/// Older FreeSWITCH versions write `HH:MM:SS.usec`, newer versions write
145/// `YYYY-MM-DD HH:MM:SS.usec`. Both formats are supported.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Timestamp {
148    /// `HH:MM:SS.usec` — no date component.
149    TimeOnly {
150        /// Hour (0-23).
151        hour: u8,
152        /// Minute (0-59).
153        min: u8,
154        /// Second (0-59).
155        sec: u8,
156        /// Microseconds (0-999999).
157        usec: u32,
158    },
159    /// `YYYY-MM-DD HH:MM:SS.usec` — full date and time.
160    DateTime {
161        /// Year.
162        year: u16,
163        /// Month (1-12).
164        month: u8,
165        /// Day (1-31).
166        day: u8,
167        /// Hour (0-23).
168        hour: u8,
169        /// Minute (0-59).
170        min: u8,
171        /// Second (0-59).
172        sec: u8,
173        /// Microseconds (0-999999).
174        usec: u32,
175    },
176}
177
178impl Timestamp {
179    /// Seconds since midnight, ignoring microseconds.
180    pub fn time_of_day_secs(&self) -> u32 {
181        let (h, m, s) = match self {
182            Timestamp::TimeOnly { hour, min, sec, .. } => (*hour, *min, *sec),
183            Timestamp::DateTime { hour, min, sec, .. } => (*hour, *min, *sec),
184        };
185        h as u32 * 3600 + m as u32 * 60 + s as u32
186    }
187
188    /// Tuple suitable for chronological ordering.
189    /// `TimeOnly` timestamps sort before any `DateTime` (year/month/day = 0).
190    pub fn sort_key(&self) -> (u16, u8, u8, u8, u8, u8, u32) {
191        match self {
192            Timestamp::TimeOnly {
193                hour,
194                min,
195                sec,
196                usec,
197            } => (0, 0, 0, *hour, *min, *sec, *usec),
198            Timestamp::DateTime {
199                year,
200                month,
201                day,
202                hour,
203                min,
204                sec,
205                usec,
206            } => (*year, *month, *day, *hour, *min, *sec, *usec),
207        }
208    }
209}
210
211impl fmt::Display for Timestamp {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        match self {
214            Timestamp::TimeOnly {
215                hour,
216                min,
217                sec,
218                usec,
219            } => write!(f, "{hour:02}:{min:02}:{sec:02}.{usec:06}"),
220            Timestamp::DateTime {
221                year,
222                month,
223                day,
224                hour,
225                min,
226                sec,
227                usec,
228            } => write!(
229                f,
230                "{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}.{usec:06}"
231            ),
232        }
233    }
234}
235
236/// A single frame from the dump file (Level 1 output).
237///
238/// Each frame corresponds to one `send()` or `recv()` call logged by
239/// `mod_sofia`. The `byte_count` field is the value FreeSWITCH wrote in the
240/// header; `content` is the actual payload between boundaries.
241#[derive(Debug, Clone)]
242pub struct Frame {
243    /// Whether this frame was received or sent.
244    pub direction: Direction,
245    /// Byte count declared in the frame header.
246    pub byte_count: usize,
247    /// Transport protocol.
248    pub transport: Transport,
249    /// Remote address as `ip:port` (e.g., `"10.0.0.1:5060"`).
250    pub address: String,
251    /// When this frame was logged.
252    pub timestamp: Timestamp,
253    /// Raw frame payload.
254    pub content: Vec<u8>,
255}
256
257/// A reassembled SIP message (Level 2 output).
258///
259/// For TCP, consecutive frames from the same connection are concatenated and
260/// split by Content-Length. For UDP, each frame becomes one message (1:1).
261#[derive(Debug, Clone)]
262pub struct SipMessage {
263    /// Whether this message was received or sent.
264    pub direction: Direction,
265    /// Transport protocol.
266    pub transport: Transport,
267    /// Remote address as `ip:port`.
268    pub address: String,
269    /// Timestamp of the first frame in this message.
270    pub timestamp: Timestamp,
271    /// Reassembled message bytes (headers + body).
272    pub content: Vec<u8>,
273    /// Number of Level 1 frames that were reassembled into this message.
274    pub frame_count: usize,
275}
276
277/// SIP request or response first line.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum SipMessageType {
280    /// `METHOD uri SIP/2.0`
281    Request {
282        /// SIP method (e.g., `"INVITE"`, `"BYE"`).
283        method: String,
284        /// Request URI.
285        uri: String,
286    },
287    /// `SIP/2.0 code reason`
288    Response {
289        /// Status code (e.g., 200, 404).
290        code: u16,
291        /// Reason phrase (e.g., `"OK"`, `"Not Found"`).
292        reason: String,
293    },
294}
295
296impl fmt::Display for SipMessageType {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        match self {
299            SipMessageType::Request { method, uri } => write!(f, "{method} {uri}"),
300            SipMessageType::Response { code, reason } => write!(f, "{code} {reason}"),
301        }
302    }
303}
304
305impl SipMessageType {
306    /// Short description: the method name for requests, `"code reason"` for responses.
307    pub fn summary(&self) -> Cow<'_, str> {
308        match self {
309            SipMessageType::Request { method, .. } => Cow::Borrowed(method),
310            SipMessageType::Response { code, reason } => Cow::Owned(format!("{code} {reason}")),
311        }
312    }
313}
314
315/// A fully parsed SIP message (Level 3 output).
316///
317/// Provides typed access to the request/response line, headers, and body.
318/// For JSON content types, [`body_text()`](Self::body_text) unescapes RFC 8259
319/// string sequences. For multipart bodies, [`body_parts()`](Self::body_parts)
320/// splits into individual MIME parts.
321#[derive(Debug, Clone)]
322pub struct ParsedSipMessage {
323    /// Whether this message was received or sent.
324    pub direction: Direction,
325    /// Transport protocol.
326    pub transport: Transport,
327    /// Remote address as `ip:port`.
328    pub address: String,
329    /// When this message was logged.
330    pub timestamp: Timestamp,
331    /// Parsed request or response first line.
332    pub message_type: SipMessageType,
333    /// Headers in wire order as `(name, value)` pairs. Names preserve
334    /// original casing; lookups are case-insensitive.
335    pub headers: Vec<(String, String)>,
336    /// Raw body bytes after the `\r\n\r\n` header terminator.
337    pub body: Vec<u8>,
338    /// Number of Level 1 frames that were reassembled into this message.
339    pub frame_count: usize,
340}
341
342/// A single part from a multipart MIME body.
343#[derive(Debug, Clone)]
344pub struct MimePart {
345    /// MIME part headers (e.g., Content-Type, Content-ID).
346    pub headers: Vec<(String, String)>,
347    /// Part body bytes.
348    pub body: Vec<u8>,
349}
350
351impl MimePart {
352    /// Returns the Content-Type header value, if present.
353    pub fn content_type(&self) -> Option<&str> {
354        self.headers
355            .iter()
356            .find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
357            .map(|(_, v)| v.as_str())
358    }
359
360    fn header_value(&self, name: &str) -> Option<&str> {
361        let name_lower = name.to_ascii_lowercase();
362        self.headers
363            .iter()
364            .find(|(k, _)| k.to_ascii_lowercase() == name_lower)
365            .map(|(_, v)| v.as_str())
366    }
367
368    /// Returns the Content-ID header value, if present.
369    pub fn content_id(&self) -> Option<&str> {
370        self.header_value("Content-ID")
371    }
372
373    /// Returns the Content-Disposition header value, if present.
374    pub fn content_disposition(&self) -> Option<&str> {
375        self.header_value("Content-Disposition")
376    }
377}
378
379impl ParsedSipMessage {
380    /// Returns the Call-ID header value. Checks both `Call-ID` and
381    /// the compact form `i`.
382    pub fn call_id(&self) -> Option<&str> {
383        self.header_value("Call-ID")
384            .or_else(|| self.header_value("i"))
385    }
386
387    /// Returns the Content-Type header value. Checks both `Content-Type` and
388    /// the compact form `c`.
389    pub fn content_type(&self) -> Option<&str> {
390        self.header_value("Content-Type")
391            .or_else(|| self.header_value("c"))
392    }
393
394    /// Returns the Content-Length header value as `usize`. Checks both
395    /// `Content-Length` and the compact form `l`.
396    pub fn content_length(&self) -> Option<usize> {
397        self.header_value("Content-Length")
398            .or_else(|| self.header_value("l"))
399            .and_then(|v| v.trim().parse().ok())
400    }
401
402    /// Returns the CSeq header value (e.g., `"1 INVITE"`).
403    pub fn cseq(&self) -> Option<&str> {
404        self.header_value("CSeq")
405    }
406
407    /// Returns the SIP method: from the request line for requests,
408    /// or from the CSeq header for responses.
409    pub fn method(&self) -> Option<&str> {
410        match &self.message_type {
411            SipMessageType::Request { method, .. } => Some(method),
412            SipMessageType::Response { .. } => {
413                self.cseq().and_then(|cs| cs.split_whitespace().nth(1))
414            }
415        }
416    }
417
418    /// Raw body bytes interpreted as UTF-8 (lossy). No processing is applied
419    /// regardless of Content-Type.
420    pub fn body_data(&self) -> Cow<'_, str> {
421        String::from_utf8_lossy(&self.body)
422    }
423
424    /// Reconstruct the SIP message as wire-format bytes (first line + headers + body).
425    pub fn to_bytes(&self) -> Vec<u8> {
426        let mut out = Vec::new();
427        match &self.message_type {
428            SipMessageType::Request { method, uri } => {
429                out.extend_from_slice(format!("{method} {uri} SIP/2.0\r\n").as_bytes());
430            }
431            SipMessageType::Response { code, reason } => {
432                out.extend_from_slice(format!("SIP/2.0 {code} {reason}\r\n").as_bytes());
433            }
434        }
435        for (name, value) in &self.headers {
436            out.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
437        }
438        out.extend_from_slice(b"\r\n");
439        if !self.body.is_empty() {
440            out.extend_from_slice(&self.body);
441        }
442        out
443    }
444
445    fn header_value(&self, name: &str) -> Option<&str> {
446        let name_lower = name.to_ascii_lowercase();
447        self.headers
448            .iter()
449            .find(|(k, _)| k.to_ascii_lowercase() == name_lower)
450            .map(|(_, v)| v.as_str())
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn make_parsed(
459        msg_type: SipMessageType,
460        headers: Vec<(&str, &str)>,
461        body: &[u8],
462    ) -> ParsedSipMessage {
463        ParsedSipMessage {
464            direction: Direction::Recv,
465            transport: Transport::Tcp,
466            address: "10.0.0.1:5060".into(),
467            timestamp: Timestamp::TimeOnly {
468                hour: 12,
469                min: 0,
470                sec: 0,
471                usec: 0,
472            },
473            message_type: msg_type,
474            headers: headers
475                .iter()
476                .map(|(k, v)| (k.to_string(), v.to_string()))
477                .collect(),
478            body: body.to_vec(),
479            frame_count: 1,
480        }
481    }
482
483    #[test]
484    fn to_bytes_request_no_body() {
485        let msg = make_parsed(
486            SipMessageType::Request {
487                method: "OPTIONS".into(),
488                uri: "sip:host".into(),
489            },
490            vec![("Call-ID", "test")],
491            b"",
492        );
493        let bytes = msg.to_bytes();
494        let text = String::from_utf8(bytes).unwrap();
495        assert!(text.starts_with("OPTIONS sip:host SIP/2.0\r\n"));
496        assert!(text.contains("Call-ID: test\r\n"));
497        assert!(text.ends_with("\r\n\r\n"));
498    }
499
500    #[test]
501    fn to_bytes_request_with_body() {
502        let body = b"v=0\r\ns=-\r\n";
503        let msg = make_parsed(
504            SipMessageType::Request {
505                method: "INVITE".into(),
506                uri: "sip:host".into(),
507            },
508            vec![("Call-ID", "test")],
509            body,
510        );
511        let bytes = msg.to_bytes();
512        assert!(bytes.ends_with(body));
513    }
514
515    #[test]
516    fn to_bytes_response() {
517        let msg = make_parsed(
518            SipMessageType::Response {
519                code: 200,
520                reason: "OK".into(),
521            },
522            vec![("Call-ID", "resp-test")],
523            b"",
524        );
525        let bytes = msg.to_bytes();
526        let text = String::from_utf8(bytes).unwrap();
527        assert!(text.starts_with("SIP/2.0 200 OK\r\n"));
528    }
529
530    #[test]
531    fn body_data_valid_utf8() {
532        let msg = make_parsed(
533            SipMessageType::Request {
534                method: "MESSAGE".into(),
535                uri: "sip:host".into(),
536            },
537            vec![],
538            b"hello world",
539        );
540        assert_eq!(&*msg.body_data(), "hello world");
541    }
542
543    #[test]
544    fn body_data_empty() {
545        let msg = make_parsed(
546            SipMessageType::Request {
547                method: "OPTIONS".into(),
548                uri: "sip:host".into(),
549            },
550            vec![],
551            b"",
552        );
553        assert_eq!(&*msg.body_data(), "");
554    }
555
556    #[test]
557    fn body_data_binary() {
558        let msg = make_parsed(
559            SipMessageType::Request {
560                method: "MESSAGE".into(),
561                uri: "sip:host".into(),
562            },
563            vec![],
564            &[0xFF, 0xFE],
565        );
566        assert!(msg.body_data().contains('\u{FFFD}'));
567    }
568
569    #[test]
570    fn body_text_non_json_passthrough() {
571        let msg = make_parsed(
572            SipMessageType::Request {
573                method: "INVITE".into(),
574                uri: "sip:host".into(),
575            },
576            vec![("Content-Type", "application/sdp")],
577            b"v=0\r\ns=-\r\n",
578        );
579        assert_eq!(msg.body_text().as_ref(), msg.body_data().as_ref());
580    }
581
582    #[test]
583    fn body_text_json_unescapes_newlines() {
584        let msg = make_parsed(
585            SipMessageType::Request {
586                method: "NOTIFY".into(),
587                uri: "sip:host".into(),
588            },
589            vec![("Content-Type", "application/json")],
590            br#"{"invite":"INVITE sip:host SIP/2.0\r\nTo: <sip:host>\r\n"}"#,
591        );
592        let text = msg.body_text();
593        assert!(
594            text.contains("INVITE sip:host SIP/2.0\r\nTo: <sip:host>\r\n"),
595            "JSON \\r\\n should be unescaped to actual CRLF, got: {text:?}"
596        );
597    }
598
599    #[test]
600    fn body_text_plus_json_content_type() {
601        let msg = make_parsed(
602            SipMessageType::Request {
603                method: "NOTIFY".into(),
604                uri: "sip:host".into(),
605            },
606            vec![(
607                "Content-Type",
608                "application/emergencyCallData.AbandonedCall+json",
609            )],
610            br#"{"invite":"line1\nline2"}"#,
611        );
612        let text = msg.body_text();
613        assert!(
614            text.contains("line1\nline2"),
615            "application/*+json should trigger unescaping, got: {text:?}"
616        );
617    }
618
619    #[test]
620    fn body_data_preserves_json_escapes() {
621        let raw = br#"{"key":"value\nwith\\escapes"}"#;
622        let msg = make_parsed(
623            SipMessageType::Request {
624                method: "NOTIFY".into(),
625                uri: "sip:host".into(),
626            },
627            vec![("Content-Type", "application/json")],
628            raw,
629        );
630        assert_eq!(
631            msg.body_data().as_ref(),
632            r#"{"key":"value\nwith\\escapes"}"#,
633            "body_data() must preserve raw escapes"
634        );
635    }
636}