Skip to main content

freeswitch_sofia_trace_parser/
types.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::net::SocketAddr;
4
5/// mod_sofia brackets IPv4 like IPv6 (`[198.51.100.7]:5060`); anything that
6/// isn't an ip:port shape yields `None` rather than a guess.
7fn parse_socket_addr(address: &str) -> Option<SocketAddr> {
8    if let Ok(addr) = address.parse() {
9        return Some(addr);
10    }
11    let (ip, port) = address.strip_prefix('[')?.split_once("]:")?;
12    Some(SocketAddr::new(ip.parse().ok()?, port.parse().ok()?))
13}
14
15/// Why a region of the input stream was not parsed into a frame.
16///
17/// Every byte in the input is either parsed or classified with one of these
18/// reasons, enabling byte-level coverage accounting via [`ParseStats`].
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SkipReason {
21    /// Truncated frame at the start of a file, typically from logrotate
22    /// cutting mid-write. Capped at 65,535 bytes.
23    PartialFirstFrame,
24    /// Skip region exceeds 65,535 bytes at file start, indicating the input
25    /// is not a dump file (e.g., compressed or binary data).
26    OversizedFrame,
27    /// Unrecoverable bytes skipped between valid frames mid-stream.
28    MidStreamSkip,
29    /// Logrotate wrote a partial frame tail at the start of the new file.
30    /// Detected by the `\r\n\r\n\x0B\n` suffix pattern.
31    ReplayedFrame,
32    /// Frame at EOF with fewer content bytes than declared in the header.
33    IncompleteFrame,
34    /// Data starts with `recv`/`sent` but fails frame header parsing.
35    InvalidHeader,
36}
37
38impl fmt::Display for SkipReason {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            SkipReason::PartialFirstFrame => f.write_str("partial first frame"),
42            SkipReason::OversizedFrame => f.write_str("oversized frame"),
43            SkipReason::MidStreamSkip => f.write_str("mid-stream skip"),
44            SkipReason::ReplayedFrame => f.write_str("replayed frame (logrotate)"),
45            SkipReason::IncompleteFrame => f.write_str("incomplete frame"),
46            SkipReason::InvalidHeader => f.write_str("invalid header"),
47        }
48    }
49}
50
51/// Controls how much detail the parser records about unparsed regions.
52///
53/// Defaults to `CountOnly` for constant-memory operation. Higher levels
54/// allocate per-region and should only be enabled for diagnostics.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SkipTracking {
57    /// Track only `bytes_read` and `bytes_skipped` counters. No allocation.
58    CountOnly,
59    /// Record offset, length, and reason for each unparsed region.
60    TrackRegions,
61    /// Like `TrackRegions`, but also capture the skipped bytes themselves.
62    CaptureData,
63}
64
65/// A contiguous region of the input that was not parsed into a frame.
66#[derive(Debug, Clone)]
67pub struct UnparsedRegion {
68    /// Byte offset from the start of the input stream.
69    pub offset: u64,
70    /// Number of bytes in this region.
71    pub length: u64,
72    /// Why this region was skipped.
73    pub reason: SkipReason,
74    /// The raw bytes, populated only when [`SkipTracking::CaptureData`] is enabled.
75    pub data: Option<Vec<u8>>,
76}
77
78/// Byte-level parse coverage statistics.
79///
80/// Available from all three iterator levels via `stats()` or `parse_stats()`.
81/// Every byte consumed from the reader is accounted for as either parsed
82/// (`bytes_read - bytes_skipped`) or skipped (`bytes_skipped`).
83#[derive(Debug, Default, Clone)]
84pub struct ParseStats {
85    /// Total bytes consumed from the reader.
86    pub bytes_read: u64,
87    /// Bytes that were skipped (not parsed into frames).
88    pub bytes_skipped: u64,
89    /// Detailed unparsed region records. Only populated when
90    /// [`SkipTracking`] is `TrackRegions` or `CaptureData`.
91    pub unparsed_regions: Vec<UnparsedRegion>,
92}
93
94impl ParseStats {
95    /// Take all accumulated unparsed regions, leaving the list empty.
96    pub fn drain_regions(&mut self) -> Vec<UnparsedRegion> {
97        std::mem::take(&mut self.unparsed_regions)
98    }
99}
100
101/// Whether a frame was received or sent by FreeSWITCH.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum Direction {
104    /// Received from the network.
105    Recv,
106    /// Sent to the network.
107    Sent,
108}
109
110impl fmt::Display for Direction {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Direction::Recv => f.write_str("recv"),
114            Direction::Sent => f.write_str("sent"),
115        }
116    }
117}
118
119impl Direction {
120    /// Returns `"from"` for `Recv`, `"to"` for `Sent`.
121    pub fn preposition(&self) -> &'static str {
122        match self {
123            Direction::Recv => "from",
124            Direction::Sent => "to",
125        }
126    }
127}
128
129/// SIP transport protocol as reported in the frame header.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
131pub enum Transport {
132    /// Transmission Control Protocol.
133    Tcp,
134    /// User Datagram Protocol.
135    Udp,
136    /// Transport Layer Security.
137    Tls,
138    /// WebSocket Secure (RFC 7118).
139    Wss,
140}
141
142impl fmt::Display for Transport {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Transport::Tcp => f.write_str("tcp"),
146            Transport::Udp => f.write_str("udp"),
147            Transport::Tls => f.write_str("tls"),
148            Transport::Wss => f.write_str("wss"),
149        }
150    }
151}
152
153/// Frame timestamp, either time-only or full date+time.
154///
155/// Older FreeSWITCH versions write `HH:MM:SS.usec`, newer versions write
156/// `YYYY-MM-DD HH:MM:SS.usec`. Both formats are supported.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum Timestamp {
159    /// `HH:MM:SS.usec` — no date component.
160    TimeOnly {
161        /// Hour (0-23).
162        hour: u8,
163        /// Minute (0-59).
164        min: u8,
165        /// Second (0-59).
166        sec: u8,
167        /// Microseconds (0-999999).
168        usec: u32,
169    },
170    /// `YYYY-MM-DD HH:MM:SS.usec` — full date and time.
171    DateTime {
172        /// Year.
173        year: u16,
174        /// Month (1-12).
175        month: u8,
176        /// Day (1-31).
177        day: u8,
178        /// Hour (0-23).
179        hour: u8,
180        /// Minute (0-59).
181        min: u8,
182        /// Second (0-59).
183        sec: u8,
184        /// Microseconds (0-999999).
185        usec: u32,
186    },
187}
188
189impl Timestamp {
190    /// Seconds since midnight, ignoring microseconds.
191    pub fn time_of_day_secs(&self) -> u32 {
192        let (h, m, s) = match self {
193            Timestamp::TimeOnly { hour, min, sec, .. } => (*hour, *min, *sec),
194            Timestamp::DateTime { hour, min, sec, .. } => (*hour, *min, *sec),
195        };
196        h as u32 * 3600 + m as u32 * 60 + s as u32
197    }
198
199    /// Tuple suitable for chronological ordering.
200    /// `TimeOnly` timestamps sort before any `DateTime` (year/month/day = 0).
201    pub fn sort_key(&self) -> (u16, u8, u8, u8, u8, u8, u32) {
202        match self {
203            Timestamp::TimeOnly {
204                hour,
205                min,
206                sec,
207                usec,
208            } => (0, 0, 0, *hour, *min, *sec, *usec),
209            Timestamp::DateTime {
210                year,
211                month,
212                day,
213                hour,
214                min,
215                sec,
216                usec,
217            } => (*year, *month, *day, *hour, *min, *sec, *usec),
218        }
219    }
220}
221
222impl fmt::Display for Timestamp {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        match self {
225            Timestamp::TimeOnly {
226                hour,
227                min,
228                sec,
229                usec,
230            } => write!(f, "{hour:02}:{min:02}:{sec:02}.{usec:06}"),
231            Timestamp::DateTime {
232                year,
233                month,
234                day,
235                hour,
236                min,
237                sec,
238                usec,
239            } => write!(
240                f,
241                "{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}.{usec:06}"
242            ),
243        }
244    }
245}
246
247/// A single frame from the dump file (Level 1 output).
248///
249/// Each frame corresponds to one `send()` or `recv()` call logged by
250/// `mod_sofia`. The `byte_count` field is the value FreeSWITCH wrote in the
251/// header; `content` is the actual payload between boundaries.
252#[derive(Debug, Clone)]
253pub struct Frame {
254    /// Whether this frame was received or sent.
255    pub direction: Direction,
256    /// Byte count declared in the frame header.
257    pub byte_count: usize,
258    /// Transport protocol.
259    pub transport: Transport,
260    /// Remote address as `ip:port` (e.g., `"10.0.0.1:5060"`).
261    pub address: String,
262    /// When this frame was logged.
263    pub timestamp: Timestamp,
264    /// Raw frame payload.
265    pub content: Vec<u8>,
266}
267
268impl Frame {
269    /// The remote address as a typed [`SocketAddr`], preserving family and
270    /// port. `None` when the recorded address is not `ip:port`; the raw string
271    /// remains in [`address`](Self::address).
272    pub fn socket_addr(&self) -> Option<SocketAddr> {
273        parse_socket_addr(&self.address)
274    }
275}
276
277/// A reassembled SIP message (Level 2 output).
278///
279/// For TCP, consecutive frames from the same connection are concatenated and
280/// split by Content-Length. For UDP, each frame becomes one message (1:1).
281#[derive(Debug, Clone)]
282pub struct SipMessage {
283    /// Whether this message was received or sent.
284    pub direction: Direction,
285    /// Transport protocol.
286    pub transport: Transport,
287    /// Remote address as `ip:port`.
288    pub address: String,
289    /// Timestamp of the first frame in this message.
290    pub timestamp: Timestamp,
291    /// Reassembled message bytes (headers + body).
292    pub content: Vec<u8>,
293    /// Number of Level 1 frames that were reassembled into this message.
294    pub frame_count: usize,
295}
296
297impl SipMessage {
298    /// The remote address as a typed [`SocketAddr`], preserving family and
299    /// port. `None` when the recorded address is not `ip:port`; the raw string
300    /// remains in [`address`](Self::address).
301    pub fn socket_addr(&self) -> Option<SocketAddr> {
302        parse_socket_addr(&self.address)
303    }
304}
305
306/// SIP request or response first line.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum SipMessageType {
309    /// `METHOD uri SIP/2.0`
310    Request {
311        /// SIP method (e.g., `"INVITE"`, `"BYE"`).
312        method: String,
313        /// Request URI.
314        uri: String,
315    },
316    /// `SIP/2.0 code reason`
317    Response {
318        /// Status code (e.g., 200, 404).
319        code: u16,
320        /// Reason phrase (e.g., `"OK"`, `"Not Found"`).
321        reason: String,
322    },
323}
324
325impl fmt::Display for SipMessageType {
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        match self {
328            SipMessageType::Request { method, uri } => write!(f, "{method} {uri}"),
329            SipMessageType::Response { code, reason } => write!(f, "{code} {reason}"),
330        }
331    }
332}
333
334impl SipMessageType {
335    /// Short description: the method name for requests, `"code reason"` for responses.
336    pub fn summary(&self) -> Cow<'_, str> {
337        match self {
338            SipMessageType::Request { method, .. } => Cow::Borrowed(method),
339            SipMessageType::Response { code, reason } => Cow::Owned(format!("{code} {reason}")),
340        }
341    }
342}
343
344/// Headers in wire order as `(name, value)` pairs. Names preserve original
345/// casing; [`value`](Self::value) is the case-insensitive lookup.
346#[derive(Debug, Clone, Default, PartialEq, Eq)]
347pub struct Headers(pub Vec<(String, String)>);
348
349impl Headers {
350    /// Case-insensitive header lookup, first match in wire order. Compact
351    /// forms are not resolved: ask for the name the message is expected to
352    /// carry.
353    pub fn value(&self, name: &str) -> Option<&str> {
354        self.0
355            .iter()
356            .find(|(k, _)| k.eq_ignore_ascii_case(name))
357            .map(|(_, v)| v.as_str())
358    }
359}
360
361impl std::ops::Deref for Headers {
362    type Target = [(String, String)];
363
364    fn deref(&self) -> &Self::Target {
365        &self.0
366    }
367}
368
369impl std::ops::DerefMut for Headers {
370    fn deref_mut(&mut self) -> &mut Self::Target {
371        &mut self.0
372    }
373}
374
375impl From<Vec<(String, String)>> for Headers {
376    fn from(headers: Vec<(String, String)>) -> Self {
377        Headers(headers)
378    }
379}
380
381impl FromIterator<(String, String)> for Headers {
382    fn from_iter<I: IntoIterator<Item = (String, String)>>(iter: I) -> Self {
383        Headers(iter.into_iter().collect())
384    }
385}
386
387impl<'a> IntoIterator for &'a Headers {
388    type Item = &'a (String, String);
389    type IntoIter = std::slice::Iter<'a, (String, String)>;
390
391    fn into_iter(self) -> Self::IntoIter {
392        self.0.iter()
393    }
394}
395
396/// A fully parsed SIP message (Level 3 output).
397///
398/// Provides typed access to the request/response line, headers, and body.
399/// For JSON content types, [`body_text()`](Self::body_text) unescapes RFC 8259
400/// string sequences. For multipart bodies, [`body_parts()`](Self::body_parts)
401/// splits into individual MIME parts.
402#[derive(Debug, Clone)]
403pub struct ParsedSipMessage {
404    /// Whether this message was received or sent.
405    pub direction: Direction,
406    /// Transport protocol.
407    pub transport: Transport,
408    /// Remote address as `ip:port`.
409    pub address: String,
410    /// When this message was logged.
411    pub timestamp: Timestamp,
412    /// Parsed request or response first line.
413    pub message_type: SipMessageType,
414    /// Message headers in wire order.
415    pub headers: Headers,
416    /// Raw body bytes after the `\r\n\r\n` header terminator.
417    pub body: Vec<u8>,
418    /// Number of Level 1 frames that were reassembled into this message.
419    pub frame_count: usize,
420}
421
422/// A `message/sipfrag` body (RFC 3420): any prefix of a SIP message.
423///
424/// Unlike [`ParsedSipMessage`], every element is optional — a fragment may
425/// carry a start line, headers, a body, or any combination, and needs no
426/// trailing CRLF. It has no transport metadata of its own; that belongs to
427/// the message carrying it.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct SipFragment {
430    /// Request or status line, when the fragment begins with one.
431    pub message_type: Option<SipMessageType>,
432    /// Fragment headers in wire order.
433    pub headers: Headers,
434    /// Body bytes after the `\r\n\r\n` terminator, empty when absent.
435    pub body: Vec<u8>,
436}
437
438impl SipFragment {
439    /// Case-insensitive header lookup, first match in wire order.
440    pub fn header_value(&self, name: &str) -> Option<&str> {
441        self.headers.value(name)
442    }
443
444    /// Returns the Content-Type header value. Checks both `Content-Type` and
445    /// the compact form `c`.
446    pub fn content_type(&self) -> Option<&str> {
447        self.header_value("Content-Type")
448            .or_else(|| self.header_value("c"))
449    }
450}
451
452/// A single part from a multipart MIME body.
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub struct MimePart {
455    /// Part headers in wire order (e.g., Content-Type, Content-ID).
456    pub headers: Headers,
457    /// Part body bytes.
458    pub body: Vec<u8>,
459}
460
461impl MimePart {
462    /// Returns the Content-Type header value, if present.
463    pub fn content_type(&self) -> Option<&str> {
464        self.headers.value("Content-Type")
465    }
466
467    /// Case-insensitive header lookup, first match in wire order.
468    pub fn header_value(&self, name: &str) -> Option<&str> {
469        self.headers.value(name)
470    }
471
472    /// Returns the Content-ID header value, if present.
473    pub fn content_id(&self) -> Option<&str> {
474        self.header_value("Content-ID")
475    }
476
477    /// Returns the Content-Disposition header value, if present.
478    pub fn content_disposition(&self) -> Option<&str> {
479        self.header_value("Content-Disposition")
480    }
481
482    /// Returns the Content-Transfer-Encoding header value, if present. A value
483    /// the caller does not recognize means the part's bytes are not what its
484    /// media type describes.
485    pub fn content_transfer_encoding(&self) -> Option<&str> {
486        self.header_value("Content-Transfer-Encoding")
487    }
488}
489
490impl ParsedSipMessage {
491    /// The remote address as a typed [`SocketAddr`], preserving family and
492    /// port. `None` when the recorded address is not `ip:port`; the raw string
493    /// remains in [`address`](Self::address).
494    pub fn socket_addr(&self) -> Option<SocketAddr> {
495        parse_socket_addr(&self.address)
496    }
497
498    /// Returns the Call-ID header value. Checks both `Call-ID` and
499    /// the compact form `i`.
500    pub fn call_id(&self) -> Option<&str> {
501        self.header_value("Call-ID")
502            .or_else(|| self.header_value("i"))
503    }
504
505    /// Returns the Content-Type header value. Checks both `Content-Type` and
506    /// the compact form `c`.
507    pub fn content_type(&self) -> Option<&str> {
508        self.header_value("Content-Type")
509            .or_else(|| self.header_value("c"))
510    }
511
512    /// Returns the Content-Length header value as `usize`. Checks both
513    /// `Content-Length` and the compact form `l`.
514    pub fn content_length(&self) -> Option<usize> {
515        self.header_value("Content-Length")
516            .or_else(|| self.header_value("l"))
517            .and_then(|v| v.trim().parse().ok())
518    }
519
520    /// Returns the CSeq header value (e.g., `"1 INVITE"`).
521    pub fn cseq(&self) -> Option<&str> {
522        self.header_value("CSeq")
523    }
524
525    /// Returns the SIP method: from the request line for requests,
526    /// or from the CSeq header for responses.
527    pub fn method(&self) -> Option<&str> {
528        match &self.message_type {
529            SipMessageType::Request { method, .. } => Some(method),
530            SipMessageType::Response { .. } => {
531                self.cseq().and_then(|cs| cs.split_whitespace().nth(1))
532            }
533        }
534    }
535
536    /// Raw body bytes interpreted as UTF-8 (lossy). No processing is applied
537    /// regardless of Content-Type.
538    pub fn body_data(&self) -> Cow<'_, str> {
539        String::from_utf8_lossy(&self.body)
540    }
541
542    /// Reconstruct the SIP message as wire-format bytes (first line + headers + body).
543    pub fn to_bytes(&self) -> Vec<u8> {
544        let mut out = Vec::new();
545        match &self.message_type {
546            SipMessageType::Request { method, uri } => {
547                out.extend_from_slice(format!("{method} {uri} SIP/2.0\r\n").as_bytes());
548            }
549            SipMessageType::Response { code, reason } => {
550                out.extend_from_slice(format!("SIP/2.0 {code} {reason}\r\n").as_bytes());
551            }
552        }
553        for (name, value) in &self.headers {
554            out.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
555        }
556        out.extend_from_slice(b"\r\n");
557        if !self.body.is_empty() {
558            out.extend_from_slice(&self.body);
559        }
560        out
561    }
562
563    /// Case-insensitive header lookup, first match in wire order. Compact
564    /// forms are not resolved; the typed accessors above check both names.
565    pub fn header_value(&self, name: &str) -> Option<&str> {
566        self.headers.value(name)
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    fn make_parsed(
575        msg_type: SipMessageType,
576        headers: Vec<(&str, &str)>,
577        body: &[u8],
578    ) -> ParsedSipMessage {
579        ParsedSipMessage {
580            direction: Direction::Recv,
581            transport: Transport::Tcp,
582            address: "10.0.0.1:5060".into(),
583            timestamp: Timestamp::TimeOnly {
584                hour: 12,
585                min: 0,
586                sec: 0,
587                usec: 0,
588            },
589            message_type: msg_type,
590            headers: Headers(
591                headers
592                    .iter()
593                    .map(|(k, v)| (k.to_string(), v.to_string()))
594                    .collect(),
595            ),
596            body: body.to_vec(),
597            frame_count: 1,
598        }
599    }
600
601    fn make_frame(address: &str) -> Frame {
602        Frame {
603            direction: Direction::Recv,
604            byte_count: 0,
605            transport: Transport::Tcp,
606            address: address.into(),
607            timestamp: Timestamp::TimeOnly {
608                hour: 0,
609                min: 0,
610                sec: 0,
611                usec: 0,
612            },
613            content: Vec::new(),
614        }
615    }
616
617    fn make_message(address: &str) -> SipMessage {
618        SipMessage {
619            direction: Direction::Recv,
620            transport: Transport::Tcp,
621            address: address.into(),
622            timestamp: Timestamp::TimeOnly {
623                hour: 0,
624                min: 0,
625                sec: 0,
626                usec: 0,
627            },
628            content: Vec::new(),
629            frame_count: 1,
630        }
631    }
632
633    fn parsed_with_address(address: &str) -> ParsedSipMessage {
634        let mut msg = make_parsed(
635            SipMessageType::Request {
636                method: "OPTIONS".into(),
637                uri: "sip:host".into(),
638            },
639            vec![],
640            b"",
641        );
642        msg.address = address.into();
643        msg
644    }
645
646    #[test]
647    fn socket_addr_ipv4() {
648        let addr = make_frame("10.0.0.1:5060").socket_addr().unwrap();
649        assert!(addr.is_ipv4());
650        assert_eq!(addr.port(), 5060);
651        assert_eq!(addr.ip().to_string(), "10.0.0.1");
652    }
653
654    #[test]
655    fn socket_addr_ipv6_bracketed() {
656        let addr = make_message("[2001:db8::1]:5061").socket_addr().unwrap();
657        assert!(addr.is_ipv6());
658        assert_eq!(addr.port(), 5061);
659        assert_eq!(addr.ip().to_string(), "2001:db8::1");
660    }
661
662    #[test]
663    fn socket_addr_ipv4_bracketed() {
664        let addr = make_frame("[198.51.100.7]:5060").socket_addr().unwrap();
665        assert!(addr.is_ipv4());
666        assert_eq!(addr.port(), 5060);
667        assert_eq!(addr.ip().to_string(), "198.51.100.7");
668    }
669
670    #[test]
671    fn socket_addr_on_parsed_message() {
672        let addr = parsed_with_address("192.0.2.4:5080").socket_addr().unwrap();
673        assert_eq!(addr.port(), 5080);
674    }
675
676    #[test]
677    fn socket_addr_rejects_non_addresses() {
678        for bad in [
679            "345.678.987.654:5060",
680            "10.0.0.1",
681            "host.example.test:5060",
682            "2001:db8::1:5060",
683            "",
684        ] {
685            assert!(
686                make_frame(bad).socket_addr().is_none(),
687                "should not parse: {bad}"
688            );
689            assert!(make_message(bad).socket_addr().is_none());
690            assert!(parsed_with_address(bad).socket_addr().is_none());
691        }
692    }
693
694    #[test]
695    fn to_bytes_request_no_body() {
696        let msg = make_parsed(
697            SipMessageType::Request {
698                method: "OPTIONS".into(),
699                uri: "sip:host".into(),
700            },
701            vec![("Call-ID", "test")],
702            b"",
703        );
704        let bytes = msg.to_bytes();
705        let text = String::from_utf8(bytes).unwrap();
706        assert!(text.starts_with("OPTIONS sip:host SIP/2.0\r\n"));
707        assert!(text.contains("Call-ID: test\r\n"));
708        assert!(text.ends_with("\r\n\r\n"));
709    }
710
711    #[test]
712    fn to_bytes_request_with_body() {
713        let body = b"v=0\r\ns=-\r\n";
714        let msg = make_parsed(
715            SipMessageType::Request {
716                method: "INVITE".into(),
717                uri: "sip:host".into(),
718            },
719            vec![("Call-ID", "test")],
720            body,
721        );
722        let bytes = msg.to_bytes();
723        assert!(bytes.ends_with(body));
724    }
725
726    #[test]
727    fn to_bytes_response() {
728        let msg = make_parsed(
729            SipMessageType::Response {
730                code: 200,
731                reason: "OK".into(),
732            },
733            vec![("Call-ID", "resp-test")],
734            b"",
735        );
736        let bytes = msg.to_bytes();
737        let text = String::from_utf8(bytes).unwrap();
738        assert!(text.starts_with("SIP/2.0 200 OK\r\n"));
739    }
740
741    #[test]
742    fn body_data_valid_utf8() {
743        let msg = make_parsed(
744            SipMessageType::Request {
745                method: "MESSAGE".into(),
746                uri: "sip:host".into(),
747            },
748            vec![],
749            b"hello world",
750        );
751        assert_eq!(&*msg.body_data(), "hello world");
752    }
753
754    #[test]
755    fn body_data_empty() {
756        let msg = make_parsed(
757            SipMessageType::Request {
758                method: "OPTIONS".into(),
759                uri: "sip:host".into(),
760            },
761            vec![],
762            b"",
763        );
764        assert_eq!(&*msg.body_data(), "");
765    }
766
767    #[test]
768    fn body_data_binary() {
769        let msg = make_parsed(
770            SipMessageType::Request {
771                method: "MESSAGE".into(),
772                uri: "sip:host".into(),
773            },
774            vec![],
775            &[0xFF, 0xFE],
776        );
777        assert!(msg.body_data().contains('\u{FFFD}'));
778    }
779
780    #[test]
781    fn body_text_non_json_passthrough() {
782        let msg = make_parsed(
783            SipMessageType::Request {
784                method: "INVITE".into(),
785                uri: "sip:host".into(),
786            },
787            vec![("Content-Type", "application/sdp")],
788            b"v=0\r\ns=-\r\n",
789        );
790        assert_eq!(msg.body_text().as_ref(), msg.body_data().as_ref());
791    }
792
793    #[test]
794    fn body_text_json_unescapes_newlines() {
795        let msg = make_parsed(
796            SipMessageType::Request {
797                method: "NOTIFY".into(),
798                uri: "sip:host".into(),
799            },
800            vec![("Content-Type", "application/json")],
801            br#"{"invite":"INVITE sip:host SIP/2.0\r\nTo: <sip:host>\r\n"}"#,
802        );
803        let text = msg.body_text();
804        assert!(
805            text.contains("INVITE sip:host SIP/2.0\r\nTo: <sip:host>\r\n"),
806            "JSON \\r\\n should be unescaped to actual CRLF, got: {text:?}"
807        );
808    }
809
810    #[test]
811    fn body_text_plus_json_content_type() {
812        let msg = make_parsed(
813            SipMessageType::Request {
814                method: "NOTIFY".into(),
815                uri: "sip:host".into(),
816            },
817            vec![(
818                "Content-Type",
819                "application/emergencyCallData.AbandonedCall+json",
820            )],
821            br#"{"invite":"line1\nline2"}"#,
822        );
823        let text = msg.body_text();
824        assert!(
825            text.contains("line1\nline2"),
826            "application/*+json should trigger unescaping, got: {text:?}"
827        );
828    }
829
830    #[test]
831    fn body_data_preserves_json_escapes() {
832        let raw = br#"{"key":"value\nwith\\escapes"}"#;
833        let msg = make_parsed(
834            SipMessageType::Request {
835                method: "NOTIFY".into(),
836                uri: "sip:host".into(),
837            },
838            vec![("Content-Type", "application/json")],
839            raw,
840        );
841        assert_eq!(
842            msg.body_data().as_ref(),
843            r#"{"key":"value\nwith\\escapes"}"#,
844            "body_data() must preserve raw escapes"
845        );
846    }
847}