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        offset: msg.offset,
314        message_type,
315        headers,
316        body: body.to_vec(),
317        frame_count: msg.frame_count,
318    })
319}
320
321pub(crate) fn parse_headers(data: &[u8]) -> Headers {
322    Headers::from(extract_all_headers(&bytes_to_str(data)))
323}
324
325/// Split at the first blank line, under the rule `sip_header` reads headers by:
326/// lines end at LF, one trailing CR is stripped, the first empty line ends the
327/// block. No blank line means headers run to the end, no body.
328pub(crate) fn split_headers_body(data: &[u8], headers_start: usize) -> (&[u8], &[u8]) {
329    let start = headers_start.min(data.len());
330    let mut pos = start;
331    while let Some(rel) = memchr::memchr(b'\n', &data[pos..]) {
332        let line = match &data[pos..pos + rel] {
333            [rest @ .., b'\r'] => rest,
334            rest => rest,
335        };
336        if line.is_empty() {
337            return (&data[start..pos], &data[pos + rel + 1..]);
338        }
339        pos += rel + 1;
340    }
341    (&data[start..], &[][..])
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::sip::test_support::make_sip_message;
348    use crate::types::{Direction, SipMessage, SipMessageType, Timestamp, Transport};
349
350    /// `method()` may answer `None` for anything, but never a method the full
351    /// parse disagrees with.
352    fn assert_agrees_with_parse(content: &[u8]) {
353        let msg = make_sip_message(content);
354        if let Some(cheap) = msg.method() {
355            let parsed = msg.parse().expect("classified message must parse");
356            assert_eq!(Some(cheap), parsed.method());
357        }
358    }
359
360    #[test]
361    fn method_from_request_line() {
362        let msg = make_sip_message(b"INVITE sip:user@host SIP/2.0\r\nCSeq: 1 INVITE\r\n\r\n");
363        assert_eq!(msg.method(), Some("INVITE"));
364    }
365
366    #[test]
367    fn method_from_response_cseq() {
368        let msg = make_sip_message(b"SIP/2.0 200 OK\r\nVia: x\r\nCSeq: 42 OPTIONS\r\n\r\n");
369        assert_eq!(msg.method(), Some("OPTIONS"));
370    }
371
372    #[test]
373    fn method_from_response_cseq_name_variants() {
374        let lower = make_sip_message(b"SIP/2.0 200 OK\r\ncseq: 1 BYE\r\n\r\n");
375        assert_eq!(lower.method(), Some("BYE"));
376
377        let padded = make_sip_message(b"SIP/2.0 200 OK\r\nCSeq \t: 1 BYE\r\n\r\n");
378        assert_eq!(padded.method(), Some("BYE"));
379
380        let tabbed = make_sip_message(b"SIP/2.0 200 OK\r\nCSeq:\t1\tBYE\r\n\r\n");
381        assert_eq!(tabbed.method(), Some("BYE"));
382    }
383
384    #[test]
385    fn method_takes_first_cseq_in_wire_order() {
386        let msg = make_sip_message(b"SIP/2.0 200 OK\r\nCSeq: 1 BYE\r\nCSeq: 2 INVITE\r\n\r\n");
387        assert_eq!(msg.method(), Some("BYE"));
388        assert_agrees_with_parse(b"SIP/2.0 200 OK\r\nCSeq: 1 BYE\r\nCSeq: 2 INVITE\r\n\r\n");
389    }
390
391    #[test]
392    fn method_none_on_folded_cseq() {
393        let content = b"SIP/2.0 200 OK\r\nCSeq: 1\r\n INVITE\r\n\r\n";
394        assert_eq!(make_sip_message(content).method(), None);
395        assert_agrees_with_parse(content);
396    }
397
398    #[test]
399    fn method_none_without_cseq() {
400        let msg = make_sip_message(b"SIP/2.0 200 OK\r\nVia: x\r\n\r\nCSeq: 1 INVITE\r\n");
401        assert_eq!(msg.method(), None);
402    }
403
404    #[test]
405    fn method_none_on_malformed_start_line() {
406        assert_eq!(
407            make_sip_message(b"INVITE sip:user@host SIP/3.0\r\nCSeq: 1 INVITE\r\n\r\n").method(),
408            None
409        );
410        assert_eq!(
411            make_sip_message(b"garbage\r\nCSeq: 1 INVITE\r\n\r\n").method(),
412            None
413        );
414        assert_eq!(make_sip_message(b"\r\n\r\n").method(), None);
415    }
416
417    /// The header crate stops at the first blank line as it splits on LF, so a
418    /// bare LF pair ends the header block earlier than `\r\n\r\n` does.
419    #[test]
420    fn method_none_when_cseq_follows_lf_blank_line() {
421        let content = b"SIP/2.0 200 OK\r\nVia: x\n\r\nCSeq: 1 OPTIONS\r\n\r\n";
422        let parsed = make_sip_message(content).parse().unwrap();
423        assert_eq!(
424            parsed.method(),
425            None,
426            "precondition: the parsed side cannot see this CSeq"
427        );
428        assert_eq!(make_sip_message(content).method(), None);
429
430        assert_eq!(parsed.headers.len(), 1);
431        assert_eq!(parsed.header_value("Via"), Some("x"));
432        assert_eq!(parsed.body, b"CSeq: 1 OPTIONS\r\n\r\n");
433
434        let start_line = b"SIP/2.0 200 OK\r\n".len();
435        let (headers, body) = split_headers_body(content, start_line);
436        assert_eq!(headers, b"Via: x\n");
437        assert_eq!(
438            start_line + headers.len() + b"\r\n".len() + body.len(),
439            content.len(),
440            "every byte lands in the start line, the headers, the blank line or the body"
441        );
442    }
443
444    /// `ParsedSipMessage::method` splits the CSeq value on Unicode whitespace.
445    #[test]
446    fn method_none_on_non_ascii_cseq_value() {
447        let content = "SIP/2.0 200 OK\r\nCSeq: 1\u{a0}2 OPTIONS\r\n\r\n".as_bytes();
448        assert_eq!(make_sip_message(content).method(), None);
449        assert_agrees_with_parse(content);
450    }
451
452    #[test]
453    fn method_none_on_transport_noise() {
454        assert_eq!(make_sip_message(b"\r\n\r\n\r\n").method(), None);
455        assert_eq!(make_sip_message(b"").method(), None);
456    }
457
458    #[test]
459    fn non_utf8_header_value_falls_back_to_lossy() {
460        let mut content = b"OPTIONS sip:host SIP/2.0\r\nSubject: caf".to_vec();
461        content.push(0xE9);
462        content.extend_from_slice(b"\r\nContent-Length: 0\r\n\r\n");
463
464        let parsed = make_sip_message(&content).parse().unwrap();
465        assert_eq!(parsed.header_value("Subject"), Some("caf\u{fffd}"));
466    }
467
468    #[test]
469    fn parse_stats_delegates() {
470        let content =
471            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: stats-test\r\nContent-Length: 0\r\n\r\n";
472        let header = format!(
473            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
474            content.len()
475        );
476        let mut data = header.into_bytes();
477        data.extend_from_slice(content);
478        data.extend_from_slice(b"\x0B\n");
479
480        let mut iter = ParsedMessageIterator::new(&data[..]);
481        let parsed: Vec<_> = iter.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
482        assert_eq!(parsed.len(), 1);
483        let stats = iter.parse_stats();
484        assert_eq!(stats.bytes_read, data.len() as u64);
485        assert_eq!(stats.bytes_skipped, 0);
486    }
487
488    #[test]
489    fn parse_options_request() {
490        let content = b"OPTIONS sip:user@host SIP/2.0\r\n\
491            Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-1\r\n\
492            From: <sip:user@host>;tag=abc\r\n\
493            To: <sip:user@host>\r\n\
494            Call-ID: test-call-id@host\r\n\
495            CSeq: 1 OPTIONS\r\n\
496            Content-Length: 0\r\n\
497            \r\n";
498        let msg = make_sip_message(content);
499        let parsed = msg.parse().unwrap();
500
501        assert_eq!(
502            parsed.message_type,
503            SipMessageType::Request {
504                method: "OPTIONS".into(),
505                uri: "sip:user@host".into()
506            }
507        );
508        assert_eq!(parsed.call_id(), Some("test-call-id@host"));
509        assert_eq!(parsed.cseq(), Some("1 OPTIONS"));
510        assert_eq!(parsed.content_length(), Some(0));
511        assert_eq!(parsed.method(), Some("OPTIONS"));
512        assert!(parsed.body.is_empty());
513    }
514
515    #[test]
516    fn parse_200_ok_response() {
517        let content = b"SIP/2.0 200 OK\r\n\
518            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
519            Call-ID: resp-id@host\r\n\
520            CSeq: 1 INVITE\r\n\
521            Content-Length: 0\r\n\
522            \r\n";
523        let msg = make_sip_message(content);
524        let parsed = msg.parse().unwrap();
525
526        assert_eq!(
527            parsed.message_type,
528            SipMessageType::Response {
529                code: 200,
530                reason: "OK".into()
531            }
532        );
533        assert_eq!(parsed.method(), Some("INVITE"));
534    }
535
536    #[test]
537    fn parse_100_trying() {
538        let content = b"SIP/2.0 100 Trying\r\n\
539            Via: SIP/2.0/TCP 10.0.0.1:5060\r\n\
540            Call-ID: trying-id\r\n\
541            CSeq: 42 INVITE\r\n\
542            Content-Length: 0\r\n\
543            \r\n";
544        let msg = make_sip_message(content);
545        let parsed = msg.parse().unwrap();
546
547        assert_eq!(
548            parsed.message_type,
549            SipMessageType::Response {
550                code: 100,
551                reason: "Trying".into()
552            }
553        );
554        assert_eq!(parsed.method(), Some("INVITE"));
555    }
556
557    #[test]
558    fn parse_invite_with_sdp_body() {
559        let body = b"v=0\r\no=- 123 456 IN IP4 10.0.0.1\r\ns=-\r\n";
560        let mut content = Vec::new();
561        content.extend_from_slice(b"INVITE sip:user@host SIP/2.0\r\n");
562        content.extend_from_slice(b"Call-ID: invite-body@host\r\n");
563        content.extend_from_slice(b"CSeq: 1 INVITE\r\n");
564        content.extend_from_slice(b"Content-Type: application/sdp\r\n");
565        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
566        content.extend_from_slice(b"\r\n");
567        content.extend_from_slice(body);
568
569        let msg = make_sip_message(&content);
570        let parsed = msg.parse().unwrap();
571
572        assert_eq!(parsed.method(), Some("INVITE"));
573        assert_eq!(parsed.content_type(), Some("application/sdp"));
574        assert_eq!(parsed.content_length(), Some(body.len()));
575        assert_eq!(parsed.body, body);
576    }
577
578    #[test]
579    fn parse_notify_with_json_body() {
580        let body = br#"{"event":"AbandonedCall","id":"123"}"#;
581        let mut content = Vec::new();
582        content.extend_from_slice(b"NOTIFY sip:user@host SIP/2.0\r\n");
583        content.extend_from_slice(b"Call-ID: notify-json@host\r\n");
584        content.extend_from_slice(b"CSeq: 1 NOTIFY\r\n");
585        content.extend_from_slice(b"Content-Type: application/json\r\n");
586        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
587        content.extend_from_slice(b"\r\n");
588        content.extend_from_slice(body);
589
590        let msg = make_sip_message(&content);
591        let parsed = msg.parse().unwrap();
592
593        assert_eq!(parsed.method(), Some("NOTIFY"));
594        assert_eq!(parsed.content_type(), Some("application/json"));
595        assert_eq!(parsed.body, body);
596    }
597
598    #[test]
599    fn compact_headers() {
600        let content = b"NOTIFY sip:user@host SIP/2.0\r\n\
601            i: compact-call-id\r\n\
602            l: 0\r\n\
603            c: text/plain\r\n\
604            \r\n";
605        let msg = make_sip_message(content);
606        let parsed = msg.parse().unwrap();
607
608        assert_eq!(parsed.call_id(), Some("compact-call-id"));
609        assert_eq!(parsed.content_length(), Some(0));
610        assert_eq!(parsed.content_type(), Some("text/plain"));
611    }
612
613    #[test]
614    fn header_folding() {
615        let content = b"OPTIONS sip:host SIP/2.0\r\n\
616            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
617            Subject: this is a long\r\n \
618            folded header value\r\n\
619            Call-ID: fold-test\r\n\
620            Content-Length: 0\r\n\
621            \r\n";
622        let msg = make_sip_message(content);
623        let parsed = msg.parse().unwrap();
624
625        let subject = parsed
626            .headers
627            .iter()
628            .find(|(k, _)| k == "Subject")
629            .map(|(_, v)| v.as_str());
630        assert_eq!(subject, Some("this is a long folded header value"));
631        assert_eq!(parsed.call_id(), Some("fold-test"));
632    }
633
634    #[test]
635    fn folded_header_no_crlf_leak() {
636        let content = b"OPTIONS sip:host SIP/2.0\r\n\
637            Subject: line1\r\n \
638            line2\r\n\
639            Content-Length: 0\r\n\
640            \r\n";
641        let msg = make_sip_message(content);
642        let parsed = msg.parse().unwrap();
643        let subject = parsed.headers.iter().find(|(k, _)| k == "Subject").unwrap();
644        assert!(!subject.1.contains('\r'), "CRLF leaked: {:?}", subject.1);
645        assert!(!subject.1.contains('\n'), "LF leaked: {:?}", subject.1);
646        assert_eq!(subject.1, "line1 line2");
647    }
648
649    #[test]
650    fn no_body() {
651        let content = b"OPTIONS sip:host SIP/2.0\r\n\
652            Call-ID: nobody\r\n\
653            Content-Length: 0\r\n\
654            \r\n";
655        let msg = make_sip_message(content);
656        let parsed = msg.parse().unwrap();
657        assert!(parsed.body.is_empty());
658    }
659
660    #[test]
661    fn no_blank_line_no_body() {
662        // Malformed: no \r\n\r\n separator
663        let content = b"OPTIONS sip:host SIP/2.0\r\n\
664            Call-ID: no-blank\r\n\
665            Content-Length: 0";
666        let msg = make_sip_message(content);
667        let parsed = msg.parse().unwrap();
668        assert!(parsed.body.is_empty());
669        assert_eq!(parsed.call_id(), Some("no-blank"));
670    }
671
672    #[test]
673    fn preserves_metadata() {
674        let content = b"REGISTER sip:host SIP/2.0\r\n\
675            Call-ID: meta-test\r\n\
676            \r\n";
677        let msg = SipMessage {
678            direction: Direction::Sent,
679            transport: Transport::Tls,
680            address: "[2001:db8::1]:5061".into(),
681            timestamp: Timestamp::DateTime {
682                year: 2026,
683                month: 2,
684                day: 12,
685                hour: 10,
686                min: 30,
687                sec: 0,
688                usec: 123456,
689            },
690            content: content.to_vec(),
691            offset: 0,
692            frame_count: 3,
693        };
694        let parsed = msg.parse().unwrap();
695
696        assert_eq!(parsed.direction, Direction::Sent);
697        assert_eq!(parsed.transport, Transport::Tls);
698        assert_eq!(parsed.address, "[2001:db8::1]:5061");
699        assert_eq!(parsed.frame_count, 3);
700        assert_eq!(
701            parsed.timestamp,
702            Timestamp::DateTime {
703                year: 2026,
704                month: 2,
705                day: 12,
706                hour: 10,
707                min: 30,
708                sec: 0,
709                usec: 123456,
710            }
711        );
712    }
713
714    #[test]
715    fn multiple_same_name_headers() {
716        let content = b"INVITE sip:host SIP/2.0\r\n\
717            Via: SIP/2.0/UDP proxy1:5060\r\n\
718            Via: SIP/2.0/UDP proxy2:5060\r\n\
719            Record-Route: <sip:proxy1>\r\n\
720            Record-Route: <sip:proxy2>\r\n\
721            Call-ID: multi-hdr\r\n\
722            Content-Length: 0\r\n\
723            \r\n";
724        let msg = make_sip_message(content);
725        let parsed = msg.parse().unwrap();
726
727        let via_count = parsed.headers.iter().filter(|(k, _)| k == "Via").count();
728        assert_eq!(via_count, 2);
729
730        let rr_count = parsed
731            .headers
732            .iter()
733            .filter(|(k, _)| k == "Record-Route")
734            .count();
735        assert_eq!(rr_count, 2);
736    }
737
738    #[test]
739    fn header_ordering_preserved() {
740        let content = b"OPTIONS sip:host SIP/2.0\r\n\
741            Via: v1\r\n\
742            From: f1\r\n\
743            To: t1\r\n\
744            Call-ID: order-test\r\n\
745            CSeq: 1 OPTIONS\r\n\
746            \r\n";
747        let msg = make_sip_message(content);
748        let parsed = msg.parse().unwrap();
749
750        let names: Vec<&str> = parsed.headers.iter().map(|(k, _)| k.as_str()).collect();
751        assert_eq!(names, vec!["Via", "From", "To", "Call-ID", "CSeq"]);
752    }
753
754    #[test]
755    fn binary_body() {
756        let body: Vec<u8> = (0..256).map(|i| i as u8).collect();
757        let mut content = Vec::new();
758        content.extend_from_slice(b"MESSAGE sip:host SIP/2.0\r\n");
759        content.extend_from_slice(b"Call-ID: binary-body\r\n");
760        content.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
761        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
762        content.extend_from_slice(b"\r\n");
763        content.extend_from_slice(&body);
764
765        let msg = make_sip_message(&content);
766        let parsed = msg.parse().unwrap();
767
768        assert_eq!(parsed.body, body);
769    }
770
771    #[test]
772    fn error_no_crlf() {
773        let content = b"garbage without any crlf";
774        let msg = make_sip_message(content);
775        let result = msg.parse();
776        assert!(result.is_err());
777    }
778
779    #[test]
780    fn header_value_with_colon() {
781        // SIP URIs in header values contain colons
782        let content = b"INVITE sip:host SIP/2.0\r\n\
783            Contact: <sip:user@10.0.0.1:5060;transport=tcp>\r\n\
784            Call-ID: colon-val\r\n\
785            \r\n";
786        let msg = make_sip_message(content);
787        let parsed = msg.parse().unwrap();
788
789        let contact = parsed
790            .headers
791            .iter()
792            .find(|(k, _)| k == "Contact")
793            .map(|(_, v)| v.as_str());
794        assert_eq!(contact, Some("<sip:user@10.0.0.1:5060;transport=tcp>"));
795    }
796
797    #[test]
798    fn whitespace_around_header_value() {
799        let content = b"OPTIONS sip:host SIP/2.0\r\n\
800            Call-ID:   spaces-around   \r\n\
801            \r\n";
802        let msg = make_sip_message(content);
803        let parsed = msg.parse().unwrap();
804
805        // Leading whitespace should be trimmed, trailing kept (we only trim leading)
806        assert_eq!(parsed.call_id(), Some("spaces-around   "));
807    }
808
809    #[test]
810    fn parsed_message_iterator() {
811        let content =
812            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: iter-test\r\nContent-Length: 0\r\n\r\n";
813        let header = format!(
814            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
815            content.len()
816        );
817        let mut data = header.into_bytes();
818        data.extend_from_slice(content);
819        data.extend_from_slice(b"\x0B\n");
820
821        let parsed: Vec<ParsedSipMessage> = ParsedMessageIterator::new(&data[..])
822            .collect::<Result<Vec<_>, _>>()
823            .unwrap();
824
825        assert_eq!(parsed.len(), 1);
826        assert_eq!(parsed[0].call_id(), Some("iter-test"));
827        assert_eq!(parsed[0].method(), Some("OPTIONS"));
828    }
829
830    #[test]
831    fn whitespace_only_returns_transport_noise() {
832        use crate::frame::ParseError;
833
834        for content in [b"\n".as_slice(), b"\r\n", b"\n\n\n", b" \t\r\n"] {
835            let msg = SipMessage {
836                direction: Direction::Recv,
837                transport: Transport::Tls,
838                address: "[10.0.0.1]:5061".into(),
839                timestamp: Timestamp::TimeOnly {
840                    hour: 0,
841                    min: 0,
842                    sec: 0,
843                    usec: 0,
844                },
845                content: content.to_vec(),
846                offset: 0,
847                frame_count: 1,
848            };
849            let err = msg.parse().unwrap_err();
850            assert!(
851                matches!(err, ParseError::TransportNoise { .. }),
852                "whitespace-only content {:?} should produce TransportNoise, got: {err}",
853                content,
854            );
855        }
856    }
857}