Skip to main content

freeswitch_sofia_trace_parser/sip/
mod.rs

1use std::borrow::Cow;
2
3use sip_header::extract_all_headers;
4
5use crate::finders::CRLF;
6use crate::frame::ParseError;
7use crate::message::MessageIterator;
8use crate::sip::content_type::{extract_boundary, normalize_media_type};
9use crate::sip::json::unescape_json_body;
10use crate::sip::multipart::{is_multipart_type, split_multipart};
11use crate::startline::{bytes_to_str, parse_first_line, parse_first_line_ref, StartLineRef};
12use crate::types::{
13    value_or_compact, Headers, MimePart, ParseStats, ParsedSipMessage, SipFragment, SipMessage,
14    SkipTracking, UnparsedRegion,
15};
16
17pub(crate) mod content_type;
18mod fragment;
19mod json;
20mod multipart;
21#[cfg(test)]
22pub(crate) mod test_support;
23
24pub use content_type::is_json_content_type;
25pub use fragment::parse_sipfrag;
26
27/// Everything a SIP header block plus a body answers, written once. The three
28/// carriers each expose these as inherent methods that delegate here, so a
29/// per-part loop cannot meet a carrier that answers one of them differently.
30pub(crate) trait HasHeaders {
31    fn headers(&self) -> &Headers;
32
33    fn body(&self) -> &[u8];
34
35    fn content_type(&self) -> Option<&str> {
36        value_or_compact(self.headers(), "Content-Type")
37    }
38
39    fn media_type(&self) -> Option<Cow<'_, str>> {
40        self.content_type().map(normalize_media_type)
41    }
42
43    fn is_multipart(&self) -> bool {
44        is_multipart_type(self.content_type())
45    }
46
47    fn multipart_boundary(&self) -> Option<&str> {
48        extract_boundary(self.content_type()?)
49    }
50
51    fn body_parts(&self) -> Option<Vec<MimePart>> {
52        split_multipart(self.content_type(), self.body())
53    }
54
55    fn body_text(&self) -> Cow<'_, str> {
56        match self.content_type() {
57            Some(ct) if is_json_content_type(ct) => Cow::Owned(unescape_json_body(self.body())),
58            _ => String::from_utf8_lossy(self.body()),
59        }
60    }
61
62    fn json_field(&self, key: &str) -> Option<String> {
63        let ct = self.content_type()?;
64        if !is_json_content_type(ct) {
65            return None;
66        }
67        let value: serde_json::Value = serde_json::from_slice(self.body()).ok()?;
68        let obj = value.as_object()?;
69        obj.get(key)?.as_str().map(|s| s.to_string())
70    }
71}
72
73impl HasHeaders for ParsedSipMessage {
74    fn headers(&self) -> &Headers {
75        &self.headers
76    }
77
78    fn body(&self) -> &[u8] {
79        &self.body
80    }
81}
82
83impl HasHeaders for MimePart {
84    fn headers(&self) -> &Headers {
85        &self.headers
86    }
87
88    fn body(&self) -> &[u8] {
89        &self.body
90    }
91}
92
93impl HasHeaders for SipFragment {
94    fn headers(&self) -> &Headers {
95        &self.headers
96    }
97
98    fn body(&self) -> &[u8] {
99        &self.body
100    }
101}
102
103impl SipMessage {
104    /// Parse this reassembled message into a [`ParsedSipMessage`] with typed
105    /// access to the request/status line, headers, and body.
106    pub fn parse(&self) -> Result<ParsedSipMessage, ParseError> {
107        parse_sip_message(self)
108    }
109
110    /// The SIP method read straight from the reassembled bytes: the request
111    /// line for requests, the CSeq header for responses. This is the answer
112    /// [`ParsedSipMessage::method`] gives, without parsing the message.
113    ///
114    /// `None` whenever the bytes leave it in doubt — an invalid start line, a
115    /// response carrying no CSeq, or a CSeq value that is folded or not plain
116    /// ASCII. Filtering on this therefore drops only what it has classified,
117    /// and a message it does classify is one [`parse`](Self::parse) accepts.
118    pub fn method(&self) -> Option<&str> {
119        let first_line_end = CRLF.find(&self.content)?;
120        match parse_first_line_ref(&self.content[..first_line_end]).ok()? {
121            StartLineRef::Request { method, .. } => std::str::from_utf8(method).ok(),
122            StartLineRef::Response { .. } => {
123                let (headers, _) = split_headers_body(&self.content, first_line_end + 2);
124                cseq_method(headers)
125            }
126        }
127    }
128}
129
130/// Read the CSeq method the way `sip_header::extract_all_headers` reads
131/// headers, so the two cannot disagree: LF-separated lines, one optional
132/// trailing CR, stopping at the first blank line — which a bare LF pair can
133/// produce well before the `\r\n\r\n` that bounds the block.
134fn cseq_method(headers: &[u8]) -> Option<&str> {
135    let mut lines = headers.split(|&b| b == b'\n').peekable();
136
137    while let Some(line) = lines.next() {
138        let line = match line {
139            [rest @ .., b'\r'] => rest,
140            rest => rest,
141        };
142        if line.is_empty() {
143            return None;
144        }
145        if matches!(line.first(), Some(b' ' | b'\t')) {
146            continue;
147        }
148        let Some(colon) = memchr::memchr(b':', line) else {
149            continue;
150        };
151        let mut name = &line[..colon];
152        while let [rest @ .., b' ' | b'\t'] = name {
153            name = rest;
154        }
155        if name.contains(&b' ') || !name.eq_ignore_ascii_case(b"CSeq") {
156            continue;
157        }
158
159        if matches!(lines.peek(), Some([b' ' | b'\t', ..])) {
160            return None;
161        }
162        let value = &line[colon + 1..];
163        if !value.is_ascii() {
164            return None;
165        }
166        return std::str::from_utf8(value)
167            .ok()?
168            .split_ascii_whitespace()
169            .nth(1);
170    }
171
172    None
173}
174
175/// Level 3 streaming parser: wraps [`MessageIterator`] and parses each
176/// reassembled message into a [`ParsedSipMessage`].
177///
178/// # Example
179///
180/// ```no_run
181/// use std::fs::File;
182/// use freeswitch_sofia_trace_parser::ParsedMessageIterator;
183///
184/// let file = File::open("profile.dump").unwrap();
185/// for result in ParsedMessageIterator::new(file) {
186///     let msg = result.unwrap();
187///     if let Some(parts) = msg.body_parts() {
188///         for part in &parts {
189///             println!("  {} ({} bytes)",
190///                 part.content_type().unwrap_or("unknown"), part.body.len());
191///         }
192///     }
193/// }
194/// ```
195pub struct ParsedMessageIterator<R> {
196    inner: MessageIterator<R>,
197}
198
199impl<R: std::io::Read> ParsedMessageIterator<R> {
200    /// Create a new parsed message iterator reading from the given source.
201    pub fn new(reader: R) -> Self {
202        ParsedMessageIterator {
203            inner: MessageIterator::new(reader),
204        }
205    }
206
207    /// Enable capturing of skipped bytes in the underlying frame parser;
208    /// `false` selects [`SkipTracking::CountOnly`]. Whichever of this and
209    /// [`skip_tracking`](Self::skip_tracking) is called last wins.
210    pub fn capture_skipped(mut self, enable: bool) -> Self {
211        self.inner = self.inner.capture_skipped(enable);
212        self
213    }
214
215    /// Set the level of detail for unparsed region tracking.
216    pub fn skip_tracking(mut self, tracking: SkipTracking) -> Self {
217        self.inner = self.inner.skip_tracking(tracking);
218        self
219    }
220
221    /// Borrow the accumulated parse statistics.
222    pub fn parse_stats(&self) -> &ParseStats {
223        self.inner.parse_stats()
224    }
225
226    /// Take all accumulated unparsed regions, leaving the list empty.
227    pub fn drain_unparsed(&mut self) -> Vec<UnparsedRegion> {
228        self.inner.drain_unparsed()
229    }
230}
231
232impl<R: std::io::Read> Iterator for ParsedMessageIterator<R> {
233    type Item = Result<ParsedSipMessage, ParseError>;
234
235    fn next(&mut self) -> Option<Self::Item> {
236        let msg = match self.inner.next()? {
237            Ok(m) => m,
238            Err(e) => return Some(Err(e)),
239        };
240        Some(msg.parse())
241    }
242}
243
244fn content_preview(content: &[u8], max_len: usize) -> String {
245    let len = content.len().min(max_len);
246    let s = String::from_utf8_lossy(&content[..len]);
247    let mut out = String::with_capacity(s.len());
248    for c in s.chars() {
249        match c {
250            '\r' => out.push_str("\\r"),
251            '\n' => out.push_str("\\n"),
252            '\t' => out.push_str("\\t"),
253            '\0' => out.push_str("\\0"),
254            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
255            c => out.push(c),
256        }
257    }
258    if content.len() > max_len {
259        out.push_str("...");
260    }
261    out
262}
263
264fn parse_sip_message(msg: &SipMessage) -> Result<ParsedSipMessage, ParseError> {
265    let content = &msg.content;
266
267    if content
268        .iter()
269        .all(|&b| matches!(b, b'\r' | b'\n' | b' ' | b'\t'))
270    {
271        return Err(ParseError::TransportNoise {
272            bytes: content.len(),
273            transport: msg.transport,
274            address: msg.address.clone(),
275        });
276    }
277
278    parse_sip_content(msg, content).map_err(|e| {
279        let reason = match e {
280            ParseError::InvalidMessage(reason) => reason,
281            other => return other,
282        };
283        let preview = content_preview(content, 200);
284        ParseError::InvalidMessage(format!(
285            "{} {}/{} at {} ({} frames, {} bytes): {reason}\n  {preview}",
286            msg.direction,
287            msg.transport,
288            msg.address,
289            msg.timestamp,
290            msg.frame_count,
291            content.len(),
292        ))
293    })
294}
295
296fn parse_sip_content(msg: &SipMessage, content: &[u8]) -> Result<ParsedSipMessage, ParseError> {
297    // Find end of first line
298    let first_line_end = CRLF
299        .find(content)
300        .ok_or_else(|| ParseError::InvalidMessage("no CRLF found".into()))?;
301    let first_line = &content[..first_line_end];
302
303    let message_type = parse_first_line(first_line)?;
304
305    let (header_bytes, body) = split_headers_body(content, first_line_end + 2);
306    let headers = parse_headers(header_bytes);
307
308    Ok(ParsedSipMessage {
309        direction: msg.direction,
310        transport: msg.transport,
311        address: msg.address.clone(),
312        timestamp: msg.timestamp,
313        message_type,
314        headers,
315        body: body.to_vec(),
316        frame_count: msg.frame_count,
317    })
318}
319
320pub(crate) fn parse_headers(data: &[u8]) -> Headers {
321    Headers(extract_all_headers(&bytes_to_str(data)))
322}
323
324/// Split at the first blank line, under the rule `sip_header` reads headers by:
325/// lines end at LF, one trailing CR is stripped, the first empty line ends the
326/// block. No blank line means headers run to the end, no body.
327pub(crate) fn split_headers_body(data: &[u8], headers_start: usize) -> (&[u8], &[u8]) {
328    let start = headers_start.min(data.len());
329    let mut pos = start;
330    while let Some(rel) = memchr::memchr(b'\n', &data[pos..]) {
331        let line = match &data[pos..pos + rel] {
332            [rest @ .., b'\r'] => rest,
333            rest => rest,
334        };
335        if line.is_empty() {
336            return (&data[start..pos], &data[pos + rel + 1..]);
337        }
338        pos += rel + 1;
339    }
340    (&data[start..], &[][..])
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::sip::test_support::make_sip_message;
347    use crate::types::{Direction, SipMessage, SipMessageType, Timestamp, Transport};
348
349    /// `method()` may answer `None` for anything, but never a method the full
350    /// parse disagrees with.
351    fn assert_agrees_with_parse(content: &[u8]) {
352        let msg = make_sip_message(content);
353        if let Some(cheap) = msg.method() {
354            let parsed = msg.parse().expect("classified message must parse");
355            assert_eq!(Some(cheap), parsed.method());
356        }
357    }
358
359    #[test]
360    fn method_from_request_line() {
361        let msg = make_sip_message(b"INVITE sip:user@host SIP/2.0\r\nCSeq: 1 INVITE\r\n\r\n");
362        assert_eq!(msg.method(), Some("INVITE"));
363    }
364
365    #[test]
366    fn method_from_response_cseq() {
367        let msg = make_sip_message(b"SIP/2.0 200 OK\r\nVia: x\r\nCSeq: 42 OPTIONS\r\n\r\n");
368        assert_eq!(msg.method(), Some("OPTIONS"));
369    }
370
371    #[test]
372    fn method_from_response_cseq_name_variants() {
373        let lower = make_sip_message(b"SIP/2.0 200 OK\r\ncseq: 1 BYE\r\n\r\n");
374        assert_eq!(lower.method(), Some("BYE"));
375
376        let padded = make_sip_message(b"SIP/2.0 200 OK\r\nCSeq \t: 1 BYE\r\n\r\n");
377        assert_eq!(padded.method(), Some("BYE"));
378
379        let tabbed = make_sip_message(b"SIP/2.0 200 OK\r\nCSeq:\t1\tBYE\r\n\r\n");
380        assert_eq!(tabbed.method(), Some("BYE"));
381    }
382
383    #[test]
384    fn method_takes_first_cseq_in_wire_order() {
385        let msg = make_sip_message(b"SIP/2.0 200 OK\r\nCSeq: 1 BYE\r\nCSeq: 2 INVITE\r\n\r\n");
386        assert_eq!(msg.method(), Some("BYE"));
387        assert_agrees_with_parse(b"SIP/2.0 200 OK\r\nCSeq: 1 BYE\r\nCSeq: 2 INVITE\r\n\r\n");
388    }
389
390    #[test]
391    fn method_none_on_folded_cseq() {
392        let content = b"SIP/2.0 200 OK\r\nCSeq: 1\r\n INVITE\r\n\r\n";
393        assert_eq!(make_sip_message(content).method(), None);
394        assert_agrees_with_parse(content);
395    }
396
397    #[test]
398    fn method_none_without_cseq() {
399        let msg = make_sip_message(b"SIP/2.0 200 OK\r\nVia: x\r\n\r\nCSeq: 1 INVITE\r\n");
400        assert_eq!(msg.method(), None);
401    }
402
403    #[test]
404    fn method_none_on_malformed_start_line() {
405        assert_eq!(
406            make_sip_message(b"INVITE sip:user@host SIP/3.0\r\nCSeq: 1 INVITE\r\n\r\n").method(),
407            None
408        );
409        assert_eq!(
410            make_sip_message(b"garbage\r\nCSeq: 1 INVITE\r\n\r\n").method(),
411            None
412        );
413        assert_eq!(make_sip_message(b"\r\n\r\n").method(), None);
414    }
415
416    /// The header crate stops at the first blank line as it splits on LF, so a
417    /// bare LF pair ends the header block earlier than `\r\n\r\n` does.
418    #[test]
419    fn method_none_when_cseq_follows_lf_blank_line() {
420        let content = b"SIP/2.0 200 OK\r\nVia: x\n\r\nCSeq: 1 OPTIONS\r\n\r\n";
421        let parsed = make_sip_message(content).parse().unwrap();
422        assert_eq!(
423            parsed.method(),
424            None,
425            "precondition: the parsed side cannot see this CSeq"
426        );
427        assert_eq!(make_sip_message(content).method(), None);
428
429        assert_eq!(parsed.headers.len(), 1);
430        assert_eq!(parsed.header_value("Via"), Some("x"));
431        assert_eq!(parsed.body, b"CSeq: 1 OPTIONS\r\n\r\n");
432
433        let start_line = b"SIP/2.0 200 OK\r\n".len();
434        let (headers, body) = split_headers_body(content, start_line);
435        assert_eq!(headers, b"Via: x\n");
436        assert_eq!(
437            start_line + headers.len() + b"\r\n".len() + body.len(),
438            content.len(),
439            "every byte lands in the start line, the headers, the blank line or the body"
440        );
441    }
442
443    /// `ParsedSipMessage::method` splits the CSeq value on Unicode whitespace.
444    #[test]
445    fn method_none_on_non_ascii_cseq_value() {
446        let content = "SIP/2.0 200 OK\r\nCSeq: 1\u{a0}2 OPTIONS\r\n\r\n".as_bytes();
447        assert_eq!(make_sip_message(content).method(), None);
448        assert_agrees_with_parse(content);
449    }
450
451    #[test]
452    fn method_none_on_transport_noise() {
453        assert_eq!(make_sip_message(b"\r\n\r\n\r\n").method(), None);
454        assert_eq!(make_sip_message(b"").method(), None);
455    }
456
457    #[test]
458    fn non_utf8_header_value_falls_back_to_lossy() {
459        let mut content = b"OPTIONS sip:host SIP/2.0\r\nSubject: caf".to_vec();
460        content.push(0xE9);
461        content.extend_from_slice(b"\r\nContent-Length: 0\r\n\r\n");
462
463        let parsed = make_sip_message(&content).parse().unwrap();
464        assert_eq!(parsed.header_value("Subject"), Some("caf\u{fffd}"));
465    }
466
467    #[test]
468    fn parse_stats_delegates() {
469        let content =
470            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: stats-test\r\nContent-Length: 0\r\n\r\n";
471        let header = format!(
472            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
473            content.len()
474        );
475        let mut data = header.into_bytes();
476        data.extend_from_slice(content);
477        data.extend_from_slice(b"\x0B\n");
478
479        let mut iter = ParsedMessageIterator::new(&data[..]);
480        let parsed: Vec<_> = iter.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
481        assert_eq!(parsed.len(), 1);
482        let stats = iter.parse_stats();
483        assert_eq!(stats.bytes_read, data.len() as u64);
484        assert_eq!(stats.bytes_skipped, 0);
485    }
486
487    #[test]
488    fn parse_options_request() {
489        let content = b"OPTIONS sip:user@host SIP/2.0\r\n\
490            Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-1\r\n\
491            From: <sip:user@host>;tag=abc\r\n\
492            To: <sip:user@host>\r\n\
493            Call-ID: test-call-id@host\r\n\
494            CSeq: 1 OPTIONS\r\n\
495            Content-Length: 0\r\n\
496            \r\n";
497        let msg = make_sip_message(content);
498        let parsed = msg.parse().unwrap();
499
500        assert_eq!(
501            parsed.message_type,
502            SipMessageType::Request {
503                method: "OPTIONS".into(),
504                uri: "sip:user@host".into()
505            }
506        );
507        assert_eq!(parsed.call_id(), Some("test-call-id@host"));
508        assert_eq!(parsed.cseq(), Some("1 OPTIONS"));
509        assert_eq!(parsed.content_length(), Some(0));
510        assert_eq!(parsed.method(), Some("OPTIONS"));
511        assert!(parsed.body.is_empty());
512    }
513
514    #[test]
515    fn parse_200_ok_response() {
516        let content = b"SIP/2.0 200 OK\r\n\
517            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
518            Call-ID: resp-id@host\r\n\
519            CSeq: 1 INVITE\r\n\
520            Content-Length: 0\r\n\
521            \r\n";
522        let msg = make_sip_message(content);
523        let parsed = msg.parse().unwrap();
524
525        assert_eq!(
526            parsed.message_type,
527            SipMessageType::Response {
528                code: 200,
529                reason: "OK".into()
530            }
531        );
532        assert_eq!(parsed.method(), Some("INVITE"));
533    }
534
535    #[test]
536    fn parse_100_trying() {
537        let content = b"SIP/2.0 100 Trying\r\n\
538            Via: SIP/2.0/TCP 10.0.0.1:5060\r\n\
539            Call-ID: trying-id\r\n\
540            CSeq: 42 INVITE\r\n\
541            Content-Length: 0\r\n\
542            \r\n";
543        let msg = make_sip_message(content);
544        let parsed = msg.parse().unwrap();
545
546        assert_eq!(
547            parsed.message_type,
548            SipMessageType::Response {
549                code: 100,
550                reason: "Trying".into()
551            }
552        );
553        assert_eq!(parsed.method(), Some("INVITE"));
554    }
555
556    #[test]
557    fn parse_invite_with_sdp_body() {
558        let body = b"v=0\r\no=- 123 456 IN IP4 10.0.0.1\r\ns=-\r\n";
559        let mut content = Vec::new();
560        content.extend_from_slice(b"INVITE sip:user@host SIP/2.0\r\n");
561        content.extend_from_slice(b"Call-ID: invite-body@host\r\n");
562        content.extend_from_slice(b"CSeq: 1 INVITE\r\n");
563        content.extend_from_slice(b"Content-Type: application/sdp\r\n");
564        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
565        content.extend_from_slice(b"\r\n");
566        content.extend_from_slice(body);
567
568        let msg = make_sip_message(&content);
569        let parsed = msg.parse().unwrap();
570
571        assert_eq!(parsed.method(), Some("INVITE"));
572        assert_eq!(parsed.content_type(), Some("application/sdp"));
573        assert_eq!(parsed.content_length(), Some(body.len()));
574        assert_eq!(parsed.body, body);
575    }
576
577    #[test]
578    fn parse_notify_with_json_body() {
579        let body = br#"{"event":"AbandonedCall","id":"123"}"#;
580        let mut content = Vec::new();
581        content.extend_from_slice(b"NOTIFY sip:user@host SIP/2.0\r\n");
582        content.extend_from_slice(b"Call-ID: notify-json@host\r\n");
583        content.extend_from_slice(b"CSeq: 1 NOTIFY\r\n");
584        content.extend_from_slice(b"Content-Type: application/json\r\n");
585        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
586        content.extend_from_slice(b"\r\n");
587        content.extend_from_slice(body);
588
589        let msg = make_sip_message(&content);
590        let parsed = msg.parse().unwrap();
591
592        assert_eq!(parsed.method(), Some("NOTIFY"));
593        assert_eq!(parsed.content_type(), Some("application/json"));
594        assert_eq!(parsed.body, body);
595    }
596
597    #[test]
598    fn compact_headers() {
599        let content = b"NOTIFY sip:user@host SIP/2.0\r\n\
600            i: compact-call-id\r\n\
601            l: 0\r\n\
602            c: text/plain\r\n\
603            \r\n";
604        let msg = make_sip_message(content);
605        let parsed = msg.parse().unwrap();
606
607        assert_eq!(parsed.call_id(), Some("compact-call-id"));
608        assert_eq!(parsed.content_length(), Some(0));
609        assert_eq!(parsed.content_type(), Some("text/plain"));
610    }
611
612    #[test]
613    fn header_folding() {
614        let content = b"OPTIONS sip:host SIP/2.0\r\n\
615            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
616            Subject: this is a long\r\n \
617            folded header value\r\n\
618            Call-ID: fold-test\r\n\
619            Content-Length: 0\r\n\
620            \r\n";
621        let msg = make_sip_message(content);
622        let parsed = msg.parse().unwrap();
623
624        let subject = parsed
625            .headers
626            .iter()
627            .find(|(k, _)| k == "Subject")
628            .map(|(_, v)| v.as_str());
629        assert_eq!(subject, Some("this is a long folded header value"));
630        assert_eq!(parsed.call_id(), Some("fold-test"));
631    }
632
633    #[test]
634    fn folded_header_no_crlf_leak() {
635        let content = b"OPTIONS sip:host SIP/2.0\r\n\
636            Subject: line1\r\n \
637            line2\r\n\
638            Content-Length: 0\r\n\
639            \r\n";
640        let msg = make_sip_message(content);
641        let parsed = msg.parse().unwrap();
642        let subject = parsed.headers.iter().find(|(k, _)| k == "Subject").unwrap();
643        assert!(!subject.1.contains('\r'), "CRLF leaked: {:?}", subject.1);
644        assert!(!subject.1.contains('\n'), "LF leaked: {:?}", subject.1);
645        assert_eq!(subject.1, "line1 line2");
646    }
647
648    #[test]
649    fn no_body() {
650        let content = b"OPTIONS sip:host SIP/2.0\r\n\
651            Call-ID: nobody\r\n\
652            Content-Length: 0\r\n\
653            \r\n";
654        let msg = make_sip_message(content);
655        let parsed = msg.parse().unwrap();
656        assert!(parsed.body.is_empty());
657    }
658
659    #[test]
660    fn no_blank_line_no_body() {
661        // Malformed: no \r\n\r\n separator
662        let content = b"OPTIONS sip:host SIP/2.0\r\n\
663            Call-ID: no-blank\r\n\
664            Content-Length: 0";
665        let msg = make_sip_message(content);
666        let parsed = msg.parse().unwrap();
667        assert!(parsed.body.is_empty());
668        assert_eq!(parsed.call_id(), Some("no-blank"));
669    }
670
671    #[test]
672    fn preserves_metadata() {
673        let content = b"REGISTER sip:host SIP/2.0\r\n\
674            Call-ID: meta-test\r\n\
675            \r\n";
676        let msg = SipMessage {
677            direction: Direction::Sent,
678            transport: Transport::Tls,
679            address: "[2001:db8::1]:5061".into(),
680            timestamp: Timestamp::DateTime {
681                year: 2026,
682                month: 2,
683                day: 12,
684                hour: 10,
685                min: 30,
686                sec: 0,
687                usec: 123456,
688            },
689            content: content.to_vec(),
690            frame_count: 3,
691        };
692        let parsed = msg.parse().unwrap();
693
694        assert_eq!(parsed.direction, Direction::Sent);
695        assert_eq!(parsed.transport, Transport::Tls);
696        assert_eq!(parsed.address, "[2001:db8::1]:5061");
697        assert_eq!(parsed.frame_count, 3);
698        assert_eq!(
699            parsed.timestamp,
700            Timestamp::DateTime {
701                year: 2026,
702                month: 2,
703                day: 12,
704                hour: 10,
705                min: 30,
706                sec: 0,
707                usec: 123456,
708            }
709        );
710    }
711
712    #[test]
713    fn multiple_same_name_headers() {
714        let content = b"INVITE sip:host SIP/2.0\r\n\
715            Via: SIP/2.0/UDP proxy1:5060\r\n\
716            Via: SIP/2.0/UDP proxy2:5060\r\n\
717            Record-Route: <sip:proxy1>\r\n\
718            Record-Route: <sip:proxy2>\r\n\
719            Call-ID: multi-hdr\r\n\
720            Content-Length: 0\r\n\
721            \r\n";
722        let msg = make_sip_message(content);
723        let parsed = msg.parse().unwrap();
724
725        let via_count = parsed.headers.iter().filter(|(k, _)| k == "Via").count();
726        assert_eq!(via_count, 2);
727
728        let rr_count = parsed
729            .headers
730            .iter()
731            .filter(|(k, _)| k == "Record-Route")
732            .count();
733        assert_eq!(rr_count, 2);
734    }
735
736    #[test]
737    fn header_ordering_preserved() {
738        let content = b"OPTIONS sip:host SIP/2.0\r\n\
739            Via: v1\r\n\
740            From: f1\r\n\
741            To: t1\r\n\
742            Call-ID: order-test\r\n\
743            CSeq: 1 OPTIONS\r\n\
744            \r\n";
745        let msg = make_sip_message(content);
746        let parsed = msg.parse().unwrap();
747
748        let names: Vec<&str> = parsed.headers.iter().map(|(k, _)| k.as_str()).collect();
749        assert_eq!(names, vec!["Via", "From", "To", "Call-ID", "CSeq"]);
750    }
751
752    #[test]
753    fn binary_body() {
754        let body: Vec<u8> = (0..256).map(|i| i as u8).collect();
755        let mut content = Vec::new();
756        content.extend_from_slice(b"MESSAGE sip:host SIP/2.0\r\n");
757        content.extend_from_slice(b"Call-ID: binary-body\r\n");
758        content.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
759        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
760        content.extend_from_slice(b"\r\n");
761        content.extend_from_slice(&body);
762
763        let msg = make_sip_message(&content);
764        let parsed = msg.parse().unwrap();
765
766        assert_eq!(parsed.body, body);
767    }
768
769    #[test]
770    fn error_no_crlf() {
771        let content = b"garbage without any crlf";
772        let msg = make_sip_message(content);
773        let result = msg.parse();
774        assert!(result.is_err());
775    }
776
777    #[test]
778    fn header_value_with_colon() {
779        // SIP URIs in header values contain colons
780        let content = b"INVITE sip:host SIP/2.0\r\n\
781            Contact: <sip:user@10.0.0.1:5060;transport=tcp>\r\n\
782            Call-ID: colon-val\r\n\
783            \r\n";
784        let msg = make_sip_message(content);
785        let parsed = msg.parse().unwrap();
786
787        let contact = parsed
788            .headers
789            .iter()
790            .find(|(k, _)| k == "Contact")
791            .map(|(_, v)| v.as_str());
792        assert_eq!(contact, Some("<sip:user@10.0.0.1:5060;transport=tcp>"));
793    }
794
795    #[test]
796    fn whitespace_around_header_value() {
797        let content = b"OPTIONS sip:host SIP/2.0\r\n\
798            Call-ID:   spaces-around   \r\n\
799            \r\n";
800        let msg = make_sip_message(content);
801        let parsed = msg.parse().unwrap();
802
803        // Leading whitespace should be trimmed, trailing kept (we only trim leading)
804        assert_eq!(parsed.call_id(), Some("spaces-around   "));
805    }
806
807    #[test]
808    fn parsed_message_iterator() {
809        let content =
810            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: iter-test\r\nContent-Length: 0\r\n\r\n";
811        let header = format!(
812            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
813            content.len()
814        );
815        let mut data = header.into_bytes();
816        data.extend_from_slice(content);
817        data.extend_from_slice(b"\x0B\n");
818
819        let parsed: Vec<ParsedSipMessage> = ParsedMessageIterator::new(&data[..])
820            .collect::<Result<Vec<_>, _>>()
821            .unwrap();
822
823        assert_eq!(parsed.len(), 1);
824        assert_eq!(parsed[0].call_id(), Some("iter-test"));
825        assert_eq!(parsed[0].method(), Some("OPTIONS"));
826    }
827
828    #[test]
829    fn whitespace_only_returns_transport_noise() {
830        use crate::frame::ParseError;
831
832        for content in [b"\n".as_slice(), b"\r\n", b"\n\n\n", b" \t\r\n"] {
833            let msg = SipMessage {
834                direction: Direction::Recv,
835                transport: Transport::Tls,
836                address: "[10.0.0.1]:5061".into(),
837                timestamp: Timestamp::TimeOnly {
838                    hour: 0,
839                    min: 0,
840                    sec: 0,
841                    usec: 0,
842                },
843                content: content.to_vec(),
844                frame_count: 1,
845            };
846            let err = msg.parse().unwrap_err();
847            assert!(
848                matches!(err, ParseError::TransportNoise { .. }),
849                "whitespace-only content {:?} should produce TransportNoise, got: {err}",
850                content,
851            );
852        }
853    }
854}