Skip to main content

freeswitch_sofia_trace_parser/
sip.rs

1use std::borrow::Cow;
2use std::sync::LazyLock;
3
4use memchr::memmem;
5
6use crate::frame::ParseError;
7use crate::message::MessageIterator;
8use crate::types::{
9    MimePart, ParseStats, ParsedSipMessage, SipMessage, SipMessageType, SkipTracking,
10    UnparsedRegion,
11};
12
13static CRLF: LazyLock<memmem::Finder<'static>> = LazyLock::new(|| memmem::Finder::new(b"\r\n"));
14static CRLFCRLF: LazyLock<memmem::Finder<'static>> =
15    LazyLock::new(|| memmem::Finder::new(b"\r\n\r\n"));
16
17impl SipMessage {
18    pub fn parse(&self) -> Result<ParsedSipMessage, ParseError> {
19        parse_sip_message(self)
20    }
21}
22
23pub struct ParsedMessageIterator<R> {
24    inner: MessageIterator<R>,
25}
26
27impl<R: std::io::Read> ParsedMessageIterator<R> {
28    pub fn new(reader: R) -> Self {
29        ParsedMessageIterator {
30            inner: MessageIterator::new(reader),
31        }
32    }
33
34    pub fn capture_skipped(mut self, enable: bool) -> Self {
35        self.inner = self.inner.capture_skipped(enable);
36        self
37    }
38
39    pub fn skip_tracking(mut self, tracking: SkipTracking) -> Self {
40        self.inner = self.inner.skip_tracking(tracking);
41        self
42    }
43
44    pub fn parse_stats(&self) -> &ParseStats {
45        self.inner.parse_stats()
46    }
47
48    pub fn parse_stats_mut(&mut self) -> &mut ParseStats {
49        self.inner.parse_stats_mut()
50    }
51
52    pub fn drain_unparsed(&mut self) -> Vec<UnparsedRegion> {
53        self.inner.drain_unparsed()
54    }
55}
56
57impl<R: std::io::Read> Iterator for ParsedMessageIterator<R> {
58    type Item = Result<ParsedSipMessage, ParseError>;
59
60    fn next(&mut self) -> Option<Self::Item> {
61        let msg = match self.inner.next()? {
62            Ok(m) => m,
63            Err(e) => return Some(Err(e)),
64        };
65        Some(msg.parse())
66    }
67}
68
69fn content_preview(content: &[u8], max_len: usize) -> String {
70    use std::fmt::Write;
71    let len = content.len().min(max_len);
72    let s = String::from_utf8_lossy(&content[..len]);
73    let mut out = String::with_capacity(s.len());
74    for c in s.chars() {
75        match c {
76            '\r' => out.push_str("\\r"),
77            '\n' => out.push_str("\\n"),
78            '\t' => out.push_str("\\t"),
79            '\0' => out.push_str("\\0"),
80            c if c.is_control() => {
81                let _ = write!(out, "\\x{:02x}", c as u32);
82            }
83            c => out.push(c),
84        }
85    }
86    if content.len() > max_len {
87        out.push_str("...");
88    }
89    out
90}
91
92fn parse_sip_message(msg: &SipMessage) -> Result<ParsedSipMessage, ParseError> {
93    let content = &msg.content;
94
95    if content
96        .iter()
97        .all(|&b| matches!(b, b'\r' | b'\n' | b' ' | b'\t'))
98    {
99        return Err(ParseError::TransportNoise {
100            bytes: content.len(),
101            transport: msg.transport,
102            address: msg.address.clone(),
103        });
104    }
105
106    parse_sip_content(msg, content).map_err(|e| {
107        let reason = match e {
108            ParseError::InvalidMessage(reason) => reason,
109            other => return other,
110        };
111        let preview = content_preview(content, 200);
112        ParseError::InvalidMessage(format!(
113            "{} {}/{} at {} ({} frames, {} bytes): {reason}\n  {preview}",
114            msg.direction,
115            msg.transport,
116            msg.address,
117            msg.timestamp,
118            msg.frame_count,
119            content.len(),
120        ))
121    })
122}
123
124fn parse_sip_content(msg: &SipMessage, content: &[u8]) -> Result<ParsedSipMessage, ParseError> {
125    // Find end of first line
126    let first_line_end = CRLF
127        .find(content)
128        .ok_or_else(|| ParseError::InvalidMessage("no CRLF found".into()))?;
129    let first_line = &content[..first_line_end];
130
131    let message_type = parse_first_line(first_line)?;
132
133    // Find end of headers
134    let header_end = CRLFCRLF.find(content);
135    let (headers, body) = match header_end {
136        Some(pos) if pos > first_line_end + 1 => {
137            let header_bytes = &content[first_line_end + 2..pos];
138            let body = &content[pos + 4..];
139            (header_bytes, body)
140        }
141        Some(pos) => {
142            let body = &content[pos + 4..];
143            (&[][..], body)
144        }
145        None => {
146            // No blank line — entire content after first line is headers, no body
147            let header_bytes = &content[first_line_end + 2..];
148            (header_bytes, &[][..])
149        }
150    };
151
152    let headers = parse_headers(headers);
153
154    Ok(ParsedSipMessage {
155        direction: msg.direction,
156        transport: msg.transport,
157        address: msg.address.clone(),
158        timestamp: msg.timestamp,
159        message_type,
160        headers,
161        body: body.to_vec(),
162        frame_count: msg.frame_count,
163    })
164}
165
166fn parse_first_line(line: &[u8]) -> Result<SipMessageType, ParseError> {
167    if line.starts_with(b"SIP/2.0 ") {
168        return parse_status_line(line);
169    }
170    parse_request_line(line)
171}
172
173fn parse_status_line(line: &[u8]) -> Result<SipMessageType, ParseError> {
174    // SIP/2.0 <code> <reason>
175    let after_version = &line[8..]; // skip "SIP/2.0 "
176
177    let space = memchr::memchr(b' ', after_version)
178        .ok_or_else(|| ParseError::InvalidMessage("no space after status code".into()))?;
179    let code_bytes = &after_version[..space];
180    let code: u16 = std::str::from_utf8(code_bytes)
181        .map_err(|_| ParseError::InvalidMessage("non-UTF-8 status code".into()))?
182        .parse()
183        .map_err(|_| ParseError::InvalidMessage("invalid status code".into()))?;
184
185    let reason = &after_version[space + 1..];
186    let reason = bytes_to_string(reason);
187
188    Ok(SipMessageType::Response { code, reason })
189}
190
191fn is_sip_token(b: &[u8]) -> bool {
192    !b.is_empty()
193        && b.iter()
194            .all(|&c| c.is_ascii_alphanumeric() || b"-._!%*+'~".contains(&c))
195}
196
197fn parse_request_line(line: &[u8]) -> Result<SipMessageType, ParseError> {
198    // <METHOD> <URI> SIP/2.0
199    let first_space = memchr::memchr(b' ', line)
200        .ok_or_else(|| ParseError::InvalidMessage("no space in request line".into()))?;
201    let method = &line[..first_space];
202
203    if !is_sip_token(method) {
204        return Err(ParseError::InvalidMessage(format!(
205            "invalid SIP method: {:?}",
206            String::from_utf8_lossy(method)
207        )));
208    }
209    let rest = &line[first_space + 1..];
210
211    let last_space = memchr::memrchr(b' ', rest)
212        .ok_or_else(|| ParseError::InvalidMessage("no SIP version in request line".into()))?;
213    let version = &rest[last_space + 1..];
214    if version != b"SIP/2.0" {
215        return Err(ParseError::InvalidMessage(format!(
216            "expected SIP/2.0, got {:?}",
217            String::from_utf8_lossy(version)
218        )));
219    }
220    let uri = &rest[..last_space];
221
222    let method = bytes_to_string(method);
223    let uri = bytes_to_string(uri);
224
225    Ok(SipMessageType::Request { method, uri })
226}
227
228fn bytes_to_string(b: &[u8]) -> String {
229    match std::str::from_utf8(b) {
230        Ok(s) => s.to_owned(),
231        Err(_) => String::from_utf8_lossy(b).into_owned(),
232    }
233}
234
235fn parse_headers(data: &[u8]) -> Vec<(String, String)> {
236    let mut headers = Vec::new();
237    if data.is_empty() {
238        return headers;
239    }
240
241    let mut pos = 0;
242    while pos < data.len() {
243        let line_end = CRLF.find(&data[pos..]).unwrap_or(data.len() - pos);
244        let mut line = &data[pos..pos + line_end];
245        pos += line_end + 2; // skip \r\n
246
247        // Handle header folding (continuation lines start with SP or HT)
248        while pos < data.len() && (data[pos] == b' ' || data[pos] == b'\t') {
249            let next_end = CRLF.find(&data[pos..]).unwrap_or(data.len() - pos);
250            // Extend line to include continuation
251            line = &data[line.as_ptr() as usize - data.as_ptr() as usize..pos + next_end];
252            pos += next_end + 2;
253        }
254
255        if line.is_empty() {
256            continue;
257        }
258
259        if let Some(colon) = memchr::memchr(b':', line) {
260            let name = &line[..colon];
261            let value = if colon + 1 < line.len() {
262                trim_header_value(&line[colon + 1..])
263            } else {
264                &[]
265            };
266            headers.push((bytes_to_string(name), bytes_to_string(value)));
267        }
268    }
269
270    headers
271}
272
273fn trim_header_value(b: &[u8]) -> &[u8] {
274    let start = b
275        .iter()
276        .position(|&c| c != b' ' && c != b'\t')
277        .unwrap_or(b.len());
278    &b[start..]
279}
280
281impl ParsedSipMessage {
282    pub fn is_multipart(&self) -> bool {
283        self.content_type()
284            .map(|ct| ct.to_ascii_lowercase().starts_with("multipart/"))
285            .unwrap_or(false)
286    }
287
288    pub fn multipart_boundary(&self) -> Option<&str> {
289        let ct = self.content_type()?;
290        extract_boundary(ct)
291    }
292
293    pub fn body_parts(&self) -> Option<Vec<MimePart>> {
294        let boundary = self.multipart_boundary()?;
295        Some(parse_multipart_body(&self.body, boundary))
296    }
297
298    pub fn body_text(&self) -> Cow<'_, str> {
299        if let Some(ct) = self.content_type() {
300            if is_json_content_type(ct) {
301                return Cow::Owned(unescape_json_body(&self.body));
302            }
303        }
304        self.body_data()
305    }
306
307    pub fn json_field(&self, key: &str) -> Option<String> {
308        let ct = self.content_type()?;
309        if !is_json_content_type(ct) {
310            return None;
311        }
312        let value: serde_json::Value = serde_json::from_slice(&self.body).ok()?;
313        let obj = value.as_object()?;
314        obj.get(key)?.as_str().map(|s| s.to_string())
315    }
316}
317
318fn is_json_content_type(ct: &str) -> bool {
319    let media_type = ct.split(';').next().unwrap_or("").trim();
320    let lower = media_type.to_ascii_lowercase();
321    lower == "application/json" || (lower.starts_with("application/") && lower.ends_with("+json"))
322}
323
324fn unescape_json_body(input: &[u8]) -> String {
325    let s = String::from_utf8_lossy(input);
326    let mut out = String::with_capacity(s.len());
327    let mut chars = s.chars();
328
329    while let Some(c) = chars.next() {
330        if c != '\\' {
331            out.push(c);
332            continue;
333        }
334        match chars.next() {
335            Some('"') => out.push('"'),
336            Some('\\') => out.push('\\'),
337            Some('/') => out.push('/'),
338            Some('b') => out.push('\x08'),
339            Some('f') => out.push('\x0C'),
340            Some('n') => out.push('\n'),
341            Some('r') => out.push('\r'),
342            Some('t') => out.push('\t'),
343            Some('u') => unescape_unicode(&mut chars, &mut out),
344            Some(other) => {
345                out.push('\\');
346                out.push(other);
347            }
348            None => out.push('\\'),
349        }
350    }
351    out
352}
353
354fn unescape_unicode(chars: &mut std::str::Chars<'_>, out: &mut String) {
355    let hex: String = chars.by_ref().take(4).collect();
356    let Some(code_point) = parse_hex4(&hex) else {
357        out.push_str("\\u");
358        out.push_str(&hex);
359        return;
360    };
361
362    if (0xD800..=0xDBFF).contains(&code_point) {
363        let mut peek = chars.clone();
364        if peek.next() == Some('\\') && peek.next() == Some('u') {
365            let hex2: String = peek.by_ref().take(4).collect();
366            if let Some(low) = parse_hex4(&hex2) {
367                if (0xDC00..=0xDFFF).contains(&low) {
368                    let combined =
369                        0x10000 + ((code_point as u32 - 0xD800) << 10) + (low as u32 - 0xDC00);
370                    if let Some(ch) = char::from_u32(combined) {
371                        out.push(ch);
372                        *chars = peek;
373                        return;
374                    }
375                }
376            }
377        }
378        out.push_str("\\u");
379        out.push_str(&hex);
380    } else if let Some(ch) = char::from_u32(code_point as u32) {
381        out.push(ch);
382    } else {
383        out.push_str("\\u");
384        out.push_str(&hex);
385    }
386}
387
388fn parse_hex4(hex: &str) -> Option<u16> {
389    if hex.len() == 4 {
390        u16::from_str_radix(hex, 16).ok()
391    } else {
392        None
393    }
394}
395
396fn extract_boundary(content_type: &str) -> Option<&str> {
397    let lower = content_type.to_ascii_lowercase();
398    let idx = lower.find("boundary=")?;
399    let after = &content_type[idx + 9..];
400
401    if let Some(after_quote) = after.strip_prefix('"') {
402        let end_quote = after_quote.find('"')?;
403        Some(&after_quote[..end_quote])
404    } else {
405        let end = after.find(';').unwrap_or(after.len());
406        let boundary = after[..end].trim();
407        if boundary.is_empty() {
408            None
409        } else {
410            Some(boundary)
411        }
412    }
413}
414
415fn parse_multipart_body(body: &[u8], boundary: &str) -> Vec<MimePart> {
416    let open_delim = format!("--{boundary}");
417    let open_bytes = open_delim.as_bytes();
418
419    let mut parts = Vec::new();
420
421    // Find the first opening delimiter
422    let mut pos = match memmem::find(body, open_bytes) {
423        Some(p) => p + open_bytes.len(),
424        None => return parts,
425    };
426
427    // Check for close delimiter immediately
428    if body[pos..].starts_with(b"--") {
429        return parts;
430    }
431
432    // Skip CRLF after delimiter
433    if body[pos..].starts_with(b"\r\n") {
434        pos += 2;
435    }
436
437    while let Some(next) = memmem::find(&body[pos..], open_bytes) {
438        // Part content: strip trailing CRLF before delimiter
439        let mut end = pos + next;
440        if end >= 2 && body[end - 2] == b'\r' && body[end - 1] == b'\n' {
441            end -= 2;
442        }
443
444        parts.push(parse_mime_part(&body[pos..end]));
445
446        // Move past delimiter
447        pos = pos + next + open_bytes.len();
448
449        // Check for close delimiter
450        if body[pos..].starts_with(b"--") {
451            break;
452        }
453
454        // Skip CRLF after delimiter
455        if body[pos..].starts_with(b"\r\n") {
456            pos += 2;
457        }
458    }
459
460    parts
461}
462
463fn parse_mime_part(data: &[u8]) -> MimePart {
464    match memmem::find(data, b"\r\n\r\n") {
465        Some(pos) => {
466            let header_bytes = &data[..pos];
467            let body = &data[pos + 4..];
468            let headers = parse_headers(header_bytes);
469            MimePart {
470                headers,
471                body: body.to_vec(),
472            }
473        }
474        None => {
475            // Could be headers-only or body-only.
476            // If first line has a colon, treat as headers with no body.
477            let first_line_end = memmem::find(data, b"\r\n").unwrap_or(data.len());
478            if memchr::memchr(b':', &data[..first_line_end]).is_some() {
479                let headers = parse_headers(data);
480                MimePart {
481                    headers,
482                    body: Vec::new(),
483                }
484            } else {
485                MimePart {
486                    headers: Vec::new(),
487                    body: data.to_vec(),
488                }
489            }
490        }
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::types::{Direction, SipMessage, Timestamp, Transport};
498
499    fn make_sip_message(content: &[u8]) -> SipMessage {
500        SipMessage {
501            direction: Direction::Recv,
502            transport: Transport::Udp,
503            address: "10.0.0.1:5060".into(),
504            timestamp: Timestamp::TimeOnly {
505                hour: 12,
506                min: 0,
507                sec: 0,
508                usec: 0,
509            },
510            content: content.to_vec(),
511            frame_count: 1,
512        }
513    }
514
515    #[test]
516    fn parse_stats_delegates() {
517        let content =
518            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: stats-test\r\nContent-Length: 0\r\n\r\n";
519        let header = format!(
520            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
521            content.len()
522        );
523        let mut data = header.into_bytes();
524        data.extend_from_slice(content);
525        data.extend_from_slice(b"\x0B\n");
526
527        let mut iter = ParsedMessageIterator::new(&data[..]);
528        let parsed: Vec<_> = iter.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
529        assert_eq!(parsed.len(), 1);
530        let stats = iter.parse_stats();
531        assert_eq!(stats.bytes_read, data.len() as u64);
532        assert_eq!(stats.bytes_skipped, 0);
533    }
534
535    #[test]
536    fn parse_options_request() {
537        let content = b"OPTIONS sip:user@host SIP/2.0\r\n\
538            Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-1\r\n\
539            From: <sip:user@host>;tag=abc\r\n\
540            To: <sip:user@host>\r\n\
541            Call-ID: test-call-id@host\r\n\
542            CSeq: 1 OPTIONS\r\n\
543            Content-Length: 0\r\n\
544            \r\n";
545        let msg = make_sip_message(content);
546        let parsed = msg.parse().unwrap();
547
548        assert_eq!(
549            parsed.message_type,
550            SipMessageType::Request {
551                method: "OPTIONS".into(),
552                uri: "sip:user@host".into()
553            }
554        );
555        assert_eq!(parsed.call_id(), Some("test-call-id@host"));
556        assert_eq!(parsed.cseq(), Some("1 OPTIONS"));
557        assert_eq!(parsed.content_length(), Some(0));
558        assert_eq!(parsed.method(), Some("OPTIONS"));
559        assert!(parsed.body.is_empty());
560    }
561
562    #[test]
563    fn parse_200_ok_response() {
564        let content = b"SIP/2.0 200 OK\r\n\
565            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
566            Call-ID: resp-id@host\r\n\
567            CSeq: 1 INVITE\r\n\
568            Content-Length: 0\r\n\
569            \r\n";
570        let msg = make_sip_message(content);
571        let parsed = msg.parse().unwrap();
572
573        assert_eq!(
574            parsed.message_type,
575            SipMessageType::Response {
576                code: 200,
577                reason: "OK".into()
578            }
579        );
580        assert_eq!(parsed.method(), Some("INVITE"));
581    }
582
583    #[test]
584    fn parse_100_trying() {
585        let content = b"SIP/2.0 100 Trying\r\n\
586            Via: SIP/2.0/TCP 10.0.0.1:5060\r\n\
587            Call-ID: trying-id\r\n\
588            CSeq: 42 INVITE\r\n\
589            Content-Length: 0\r\n\
590            \r\n";
591        let msg = make_sip_message(content);
592        let parsed = msg.parse().unwrap();
593
594        assert_eq!(
595            parsed.message_type,
596            SipMessageType::Response {
597                code: 100,
598                reason: "Trying".into()
599            }
600        );
601        assert_eq!(parsed.method(), Some("INVITE"));
602    }
603
604    #[test]
605    fn parse_invite_with_sdp_body() {
606        let body = b"v=0\r\no=- 123 456 IN IP4 10.0.0.1\r\ns=-\r\n";
607        let mut content = Vec::new();
608        content.extend_from_slice(b"INVITE sip:user@host SIP/2.0\r\n");
609        content.extend_from_slice(b"Call-ID: invite-body@host\r\n");
610        content.extend_from_slice(b"CSeq: 1 INVITE\r\n");
611        content.extend_from_slice(b"Content-Type: application/sdp\r\n");
612        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
613        content.extend_from_slice(b"\r\n");
614        content.extend_from_slice(body);
615
616        let msg = make_sip_message(&content);
617        let parsed = msg.parse().unwrap();
618
619        assert_eq!(parsed.method(), Some("INVITE"));
620        assert_eq!(parsed.content_type(), Some("application/sdp"));
621        assert_eq!(parsed.content_length(), Some(body.len()));
622        assert_eq!(parsed.body, body);
623    }
624
625    #[test]
626    fn parse_notify_with_json_body() {
627        let body = br#"{"event":"AbandonedCall","id":"123"}"#;
628        let mut content = Vec::new();
629        content.extend_from_slice(b"NOTIFY sip:user@host SIP/2.0\r\n");
630        content.extend_from_slice(b"Call-ID: notify-json@host\r\n");
631        content.extend_from_slice(b"CSeq: 1 NOTIFY\r\n");
632        content.extend_from_slice(b"Content-Type: application/json\r\n");
633        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
634        content.extend_from_slice(b"\r\n");
635        content.extend_from_slice(body);
636
637        let msg = make_sip_message(&content);
638        let parsed = msg.parse().unwrap();
639
640        assert_eq!(parsed.method(), Some("NOTIFY"));
641        assert_eq!(parsed.content_type(), Some("application/json"));
642        assert_eq!(parsed.body, body);
643    }
644
645    #[test]
646    fn compact_headers() {
647        let content = b"NOTIFY sip:user@host SIP/2.0\r\n\
648            i: compact-call-id\r\n\
649            l: 0\r\n\
650            c: text/plain\r\n\
651            \r\n";
652        let msg = make_sip_message(content);
653        let parsed = msg.parse().unwrap();
654
655        assert_eq!(parsed.call_id(), Some("compact-call-id"));
656        assert_eq!(parsed.content_length(), Some(0));
657        assert_eq!(parsed.content_type(), Some("text/plain"));
658    }
659
660    #[test]
661    fn header_folding() {
662        let content = b"OPTIONS sip:host SIP/2.0\r\n\
663            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
664            Subject: this is a long\r\n \
665            folded header value\r\n\
666            Call-ID: fold-test\r\n\
667            Content-Length: 0\r\n\
668            \r\n";
669        let msg = make_sip_message(content);
670        let parsed = msg.parse().unwrap();
671
672        let subject = parsed
673            .headers
674            .iter()
675            .find(|(k, _)| k == "Subject")
676            .map(|(_, v)| v.as_str());
677        assert!(
678            subject.unwrap().contains("folded header value"),
679            "folded header should be reconstructed: {:?}",
680            subject
681        );
682        assert_eq!(parsed.call_id(), Some("fold-test"));
683    }
684
685    #[test]
686    fn no_body() {
687        let content = b"OPTIONS sip:host SIP/2.0\r\n\
688            Call-ID: nobody\r\n\
689            Content-Length: 0\r\n\
690            \r\n";
691        let msg = make_sip_message(content);
692        let parsed = msg.parse().unwrap();
693        assert!(parsed.body.is_empty());
694    }
695
696    #[test]
697    fn no_blank_line_no_body() {
698        // Malformed: no \r\n\r\n separator
699        let content = b"OPTIONS sip:host SIP/2.0\r\n\
700            Call-ID: no-blank\r\n\
701            Content-Length: 0";
702        let msg = make_sip_message(content);
703        let parsed = msg.parse().unwrap();
704        assert!(parsed.body.is_empty());
705        assert_eq!(parsed.call_id(), Some("no-blank"));
706    }
707
708    #[test]
709    fn preserves_metadata() {
710        let content = b"REGISTER sip:host SIP/2.0\r\n\
711            Call-ID: meta-test\r\n\
712            \r\n";
713        let msg = SipMessage {
714            direction: Direction::Sent,
715            transport: Transport::Tls,
716            address: "[2001:db8::1]:5061".into(),
717            timestamp: Timestamp::DateTime {
718                year: 2026,
719                month: 2,
720                day: 12,
721                hour: 10,
722                min: 30,
723                sec: 0,
724                usec: 123456,
725            },
726            content: content.to_vec(),
727            frame_count: 3,
728        };
729        let parsed = msg.parse().unwrap();
730
731        assert_eq!(parsed.direction, Direction::Sent);
732        assert_eq!(parsed.transport, Transport::Tls);
733        assert_eq!(parsed.address, "[2001:db8::1]:5061");
734        assert_eq!(parsed.frame_count, 3);
735        assert_eq!(
736            parsed.timestamp,
737            Timestamp::DateTime {
738                year: 2026,
739                month: 2,
740                day: 12,
741                hour: 10,
742                min: 30,
743                sec: 0,
744                usec: 123456,
745            }
746        );
747    }
748
749    #[test]
750    fn multiple_same_name_headers() {
751        let content = b"INVITE sip:host SIP/2.0\r\n\
752            Via: SIP/2.0/UDP proxy1:5060\r\n\
753            Via: SIP/2.0/UDP proxy2:5060\r\n\
754            Record-Route: <sip:proxy1>\r\n\
755            Record-Route: <sip:proxy2>\r\n\
756            Call-ID: multi-hdr\r\n\
757            Content-Length: 0\r\n\
758            \r\n";
759        let msg = make_sip_message(content);
760        let parsed = msg.parse().unwrap();
761
762        let via_count = parsed.headers.iter().filter(|(k, _)| k == "Via").count();
763        assert_eq!(via_count, 2);
764
765        let rr_count = parsed
766            .headers
767            .iter()
768            .filter(|(k, _)| k == "Record-Route")
769            .count();
770        assert_eq!(rr_count, 2);
771    }
772
773    #[test]
774    fn header_ordering_preserved() {
775        let content = b"OPTIONS sip:host SIP/2.0\r\n\
776            Via: v1\r\n\
777            From: f1\r\n\
778            To: t1\r\n\
779            Call-ID: order-test\r\n\
780            CSeq: 1 OPTIONS\r\n\
781            \r\n";
782        let msg = make_sip_message(content);
783        let parsed = msg.parse().unwrap();
784
785        let names: Vec<&str> = parsed.headers.iter().map(|(k, _)| k.as_str()).collect();
786        assert_eq!(names, vec!["Via", "From", "To", "Call-ID", "CSeq"]);
787    }
788
789    #[test]
790    fn status_line_with_long_reason() {
791        let content = b"SIP/2.0 486 Busy Here\r\n\
792            Call-ID: busy\r\n\
793            \r\n";
794        let msg = make_sip_message(content);
795        let parsed = msg.parse().unwrap();
796
797        assert_eq!(
798            parsed.message_type,
799            SipMessageType::Response {
800                code: 486,
801                reason: "Busy Here".into()
802            }
803        );
804    }
805
806    #[test]
807    fn request_with_complex_uri() {
808        let content = b"INVITE sip:+15551234567@gateway.example.com;transport=tcp SIP/2.0\r\n\
809            Call-ID: complex-uri\r\n\
810            \r\n";
811        let msg = make_sip_message(content);
812        let parsed = msg.parse().unwrap();
813
814        assert_eq!(
815            parsed.message_type,
816            SipMessageType::Request {
817                method: "INVITE".into(),
818                uri: "sip:+15551234567@gateway.example.com;transport=tcp".into()
819            }
820        );
821    }
822
823    #[test]
824    fn binary_body() {
825        let body: Vec<u8> = (0..256).map(|i| i as u8).collect();
826        let mut content = Vec::new();
827        content.extend_from_slice(b"MESSAGE sip:host SIP/2.0\r\n");
828        content.extend_from_slice(b"Call-ID: binary-body\r\n");
829        content.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
830        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
831        content.extend_from_slice(b"\r\n");
832        content.extend_from_slice(&body);
833
834        let msg = make_sip_message(&content);
835        let parsed = msg.parse().unwrap();
836
837        assert_eq!(parsed.body, body);
838    }
839
840    #[test]
841    fn error_no_crlf() {
842        let content = b"garbage without any crlf";
843        let msg = make_sip_message(content);
844        let result = msg.parse();
845        assert!(result.is_err());
846    }
847
848    #[test]
849    fn error_no_space_in_request_line() {
850        let content = b"INVALID\r\n\r\n";
851        let msg = make_sip_message(content);
852        let result = msg.parse();
853        assert!(result.is_err());
854    }
855
856    #[test]
857    fn parse_request_rejects_xml_method() {
858        let content =
859            b"</confInfo:conference-info>NOTIFY sip:user@host SIP/2.0\r\nContent-Length: 0\r\n\r\n";
860        let msg = make_sip_message(content);
861        assert!(msg.parse().is_err(), "should reject XML-prefixed method");
862    }
863
864    #[test]
865    fn parse_request_rejects_method_with_angle_brackets() {
866        let content = b"<xml>BYE sip:host SIP/2.0\r\n\r\n";
867        let msg = make_sip_message(content);
868        assert!(msg.parse().is_err());
869    }
870
871    #[test]
872    fn parse_request_accepts_extension_method() {
873        let content = b"CUSTOM-METHOD sip:host SIP/2.0\r\nContent-Length: 0\r\n\r\n";
874        let msg = make_sip_message(content);
875        let parsed = msg.parse().unwrap();
876        assert_eq!(
877            parsed.message_type,
878            SipMessageType::Request {
879                method: "CUSTOM-METHOD".into(),
880                uri: "sip:host".into()
881            }
882        );
883    }
884
885    #[test]
886    fn header_value_with_colon() {
887        // SIP URIs in header values contain colons
888        let content = b"INVITE sip:host SIP/2.0\r\n\
889            Contact: <sip:user@10.0.0.1:5060;transport=tcp>\r\n\
890            Call-ID: colon-val\r\n\
891            \r\n";
892        let msg = make_sip_message(content);
893        let parsed = msg.parse().unwrap();
894
895        let contact = parsed
896            .headers
897            .iter()
898            .find(|(k, _)| k == "Contact")
899            .map(|(_, v)| v.as_str());
900        assert_eq!(contact, Some("<sip:user@10.0.0.1:5060;transport=tcp>"));
901    }
902
903    #[test]
904    fn whitespace_around_header_value() {
905        let content = b"OPTIONS sip:host SIP/2.0\r\n\
906            Call-ID:   spaces-around   \r\n\
907            \r\n";
908        let msg = make_sip_message(content);
909        let parsed = msg.parse().unwrap();
910
911        // Leading whitespace should be trimmed, trailing kept (we only trim leading)
912        assert_eq!(parsed.call_id(), Some("spaces-around   "));
913    }
914
915    #[test]
916    fn parsed_message_iterator() {
917        let content =
918            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: iter-test\r\nContent-Length: 0\r\n\r\n";
919        let header = format!(
920            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
921            content.len()
922        );
923        let mut data = header.into_bytes();
924        data.extend_from_slice(content);
925        data.extend_from_slice(b"\x0B\n");
926
927        let parsed: Vec<ParsedSipMessage> = ParsedMessageIterator::new(&data[..])
928            .collect::<Result<Vec<_>, _>>()
929            .unwrap();
930
931        assert_eq!(parsed.len(), 1);
932        assert_eq!(parsed[0].call_id(), Some("iter-test"));
933        assert_eq!(parsed[0].method(), Some("OPTIONS"));
934    }
935
936    // --- Multipart tests ---
937
938    fn make_multipart_invite(boundary: &str, parts: &[(&str, &[u8])]) -> SipMessage {
939        let mut body = Vec::new();
940        for (ct, content) in parts {
941            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
942            body.extend_from_slice(format!("Content-Type: {ct}\r\n").as_bytes());
943            body.extend_from_slice(b"\r\n");
944            body.extend_from_slice(content);
945            body.extend_from_slice(b"\r\n");
946        }
947        body.extend_from_slice(format!("--{boundary}--").as_bytes());
948
949        let mut content = Vec::new();
950        content.extend_from_slice(b"INVITE sip:urn:service:sos@esrp.example.com SIP/2.0\r\n");
951        content.extend_from_slice(b"Call-ID: multipart-test@host\r\n");
952        content.extend_from_slice(b"CSeq: 1 INVITE\r\n");
953        content.extend_from_slice(
954            format!("Content-Type: multipart/mixed;boundary={boundary}\r\n").as_bytes(),
955        );
956        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
957        content.extend_from_slice(b"\r\n");
958        content.extend_from_slice(&body);
959
960        make_sip_message(&content)
961    }
962
963    #[test]
964    fn multipart_sdp_and_pidf() {
965        let sdp = b"v=0\r\no=- 123 456 IN IP4 10.0.0.1\r\ns=-\r\n";
966        let pidf = b"<?xml version=\"1.0\"?>\r\n<presence xmlns=\"urn:ietf:params:xml:ns:pidf\"/>";
967        let msg = make_multipart_invite(
968            "unique-boundary-1",
969            &[("application/sdp", sdp), ("application/pidf+xml", pidf)],
970        );
971        let parsed = msg.parse().unwrap();
972
973        assert!(parsed.is_multipart());
974        assert_eq!(parsed.multipart_boundary(), Some("unique-boundary-1"));
975
976        let parts = parsed.body_parts().unwrap();
977        assert_eq!(parts.len(), 2);
978
979        assert_eq!(parts[0].content_type(), Some("application/sdp"));
980        assert_eq!(parts[0].body, sdp);
981
982        assert_eq!(parts[1].content_type(), Some("application/pidf+xml"));
983        assert_eq!(parts[1].body, pidf);
984    }
985
986    #[test]
987    fn multipart_sdp_and_eido() {
988        let sdp = b"v=0\r\no=- 1 1 IN IP4 10.0.0.1\r\ns=-\r\n\
989            c=IN IP4 10.0.0.1\r\nt=0 0\r\nm=audio 8000 RTP/AVP 0\r\n";
990        let eido = b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n\
991            <eido:EmergencyCallData xmlns:eido=\"urn:nena:xml:ns:EmergencyCallData\">\r\n\
992            <eido:IncidentId>INC-2026-001</eido:IncidentId>\r\n\
993            </eido:EmergencyCallData>";
994        let msg = make_multipart_invite(
995            "ng911-boundary",
996            &[
997                ("application/sdp", sdp),
998                ("application/emergencyCallData.eido+xml", eido),
999            ],
1000        );
1001        let parsed = msg.parse().unwrap();
1002        let parts = parsed.body_parts().unwrap();
1003        assert_eq!(parts.len(), 2);
1004
1005        let sdp_part = parts
1006            .iter()
1007            .find(|p| p.content_type() == Some("application/sdp"));
1008        assert!(sdp_part.is_some());
1009        assert_eq!(sdp_part.unwrap().body, sdp);
1010
1011        let eido_part = parts
1012            .iter()
1013            .find(|p| p.content_type().is_some_and(|ct| ct.contains("eido")));
1014        assert!(eido_part.is_some());
1015        assert_eq!(eido_part.unwrap().body, eido);
1016    }
1017
1018    #[test]
1019    fn multipart_three_parts_sdp_pidf_eido() {
1020        let sdp = b"v=0\r\ns=-\r\n";
1021        let pidf = b"<presence/>";
1022        let eido = b"<EmergencyCallData/>";
1023        let msg = make_multipart_invite(
1024            "tri-part",
1025            &[
1026                ("application/sdp", sdp),
1027                ("application/pidf+xml", pidf),
1028                ("application/emergencyCallData.eido+xml", eido),
1029            ],
1030        );
1031        let parsed = msg.parse().unwrap();
1032        let parts = parsed.body_parts().unwrap();
1033        assert_eq!(parts.len(), 3);
1034        assert_eq!(parts[0].content_type(), Some("application/sdp"));
1035        assert_eq!(parts[1].content_type(), Some("application/pidf+xml"));
1036        assert_eq!(
1037            parts[2].content_type(),
1038            Some("application/emergencyCallData.eido+xml")
1039        );
1040    }
1041
1042    #[test]
1043    fn multipart_quoted_boundary() {
1044        let sdp = b"v=0\r\n";
1045        let pidf = b"<presence/>";
1046
1047        let mut body = Vec::new();
1048        body.extend_from_slice(b"--quoted-boundary\r\n");
1049        body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n");
1050        body.extend_from_slice(sdp);
1051        body.extend_from_slice(b"\r\n--quoted-boundary\r\n");
1052        body.extend_from_slice(b"Content-Type: application/pidf+xml\r\n\r\n");
1053        body.extend_from_slice(pidf);
1054        body.extend_from_slice(b"\r\n--quoted-boundary--");
1055
1056        let mut content = Vec::new();
1057        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1058        content.extend_from_slice(b"Call-ID: quoted-bnd@host\r\n");
1059        content
1060            .extend_from_slice(b"Content-Type: multipart/mixed; boundary=\"quoted-boundary\"\r\n");
1061        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1062        content.extend_from_slice(b"\r\n");
1063        content.extend_from_slice(&body);
1064
1065        let msg = make_sip_message(&content);
1066        let parsed = msg.parse().unwrap();
1067
1068        assert_eq!(parsed.multipart_boundary(), Some("quoted-boundary"));
1069        let parts = parsed.body_parts().unwrap();
1070        assert_eq!(parts.len(), 2);
1071        assert_eq!(parts[0].body, sdp);
1072        assert_eq!(parts[1].body, pidf);
1073    }
1074
1075    #[test]
1076    fn multipart_with_preamble() {
1077        let sdp = b"v=0\r\n";
1078
1079        let mut body = Vec::new();
1080        body.extend_from_slice(b"This is the preamble. It should be ignored.\r\n");
1081        body.extend_from_slice(b"--boundary-pre\r\n");
1082        body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n");
1083        body.extend_from_slice(sdp);
1084        body.extend_from_slice(b"\r\n--boundary-pre--");
1085
1086        let mut content = Vec::new();
1087        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1088        content.extend_from_slice(b"Call-ID: preamble@host\r\n");
1089        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=boundary-pre\r\n");
1090        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1091        content.extend_from_slice(b"\r\n");
1092        content.extend_from_slice(&body);
1093
1094        let msg = make_sip_message(&content);
1095        let parsed = msg.parse().unwrap();
1096        let parts = parsed.body_parts().unwrap();
1097        assert_eq!(parts.len(), 1);
1098        assert_eq!(parts[0].body, sdp);
1099    }
1100
1101    #[test]
1102    fn multipart_part_with_multiple_headers() {
1103        let eido = b"<EmergencyCallData/>";
1104
1105        let mut body = Vec::new();
1106        body.extend_from_slice(b"--hdr-boundary\r\n");
1107        body.extend_from_slice(b"Content-Type: application/emergencyCallData.eido+xml\r\n");
1108        body.extend_from_slice(b"Content-ID: <eido@example.com>\r\n");
1109        body.extend_from_slice(b"Content-Disposition: by-reference\r\n");
1110        body.extend_from_slice(b"\r\n");
1111        body.extend_from_slice(eido);
1112        body.extend_from_slice(b"\r\n--hdr-boundary--");
1113
1114        let mut content = Vec::new();
1115        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1116        content.extend_from_slice(b"Call-ID: multi-hdr-part@host\r\n");
1117        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=hdr-boundary\r\n");
1118        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1119        content.extend_from_slice(b"\r\n");
1120        content.extend_from_slice(&body);
1121
1122        let msg = make_sip_message(&content);
1123        let parsed = msg.parse().unwrap();
1124        let parts = parsed.body_parts().unwrap();
1125        assert_eq!(parts.len(), 1);
1126        assert_eq!(
1127            parts[0].content_type(),
1128            Some("application/emergencyCallData.eido+xml")
1129        );
1130        assert_eq!(parts[0].content_id(), Some("<eido@example.com>"));
1131        assert_eq!(parts[0].content_disposition(), Some("by-reference"));
1132        assert_eq!(parts[0].body, eido);
1133    }
1134
1135    #[test]
1136    fn not_multipart_returns_none() {
1137        let content = b"INVITE sip:host SIP/2.0\r\n\
1138            Call-ID: not-multi@host\r\n\
1139            Content-Type: application/sdp\r\n\
1140            Content-Length: 4\r\n\
1141            \r\n\
1142            v=0\n";
1143        let msg = make_sip_message(content);
1144        let parsed = msg.parse().unwrap();
1145
1146        assert!(!parsed.is_multipart());
1147        assert!(parsed.multipart_boundary().is_none());
1148        assert!(parsed.body_parts().is_none());
1149    }
1150
1151    #[test]
1152    fn multipart_empty_body() {
1153        let mut content = Vec::new();
1154        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1155        content.extend_from_slice(b"Call-ID: empty-multi@host\r\n");
1156        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=empty\r\n");
1157        content.extend_from_slice(b"Content-Length: 9\r\n");
1158        content.extend_from_slice(b"\r\n");
1159        content.extend_from_slice(b"--empty--");
1160
1161        let msg = make_sip_message(&content);
1162        let parsed = msg.parse().unwrap();
1163        let parts = parsed.body_parts().unwrap();
1164        assert!(parts.is_empty());
1165    }
1166
1167    #[test]
1168    fn extract_boundary_unquoted() {
1169        assert_eq!(
1170            extract_boundary("multipart/mixed;boundary=foo-bar"),
1171            Some("foo-bar")
1172        );
1173    }
1174
1175    #[test]
1176    fn extract_boundary_quoted() {
1177        assert_eq!(
1178            extract_boundary("multipart/mixed; boundary=\"foo-bar\""),
1179            Some("foo-bar")
1180        );
1181    }
1182
1183    #[test]
1184    fn extract_boundary_with_extra_params() {
1185        assert_eq!(
1186            extract_boundary("multipart/mixed; boundary=foo;charset=utf-8"),
1187            Some("foo")
1188        );
1189    }
1190
1191    #[test]
1192    fn extract_boundary_case_insensitive() {
1193        assert_eq!(
1194            extract_boundary("multipart/mixed;BOUNDARY=abc"),
1195            Some("abc")
1196        );
1197    }
1198
1199    #[test]
1200    fn extract_boundary_missing() {
1201        assert_eq!(extract_boundary("multipart/mixed"), None);
1202    }
1203
1204    #[test]
1205    fn multipart_part_no_headers() {
1206        let raw_body = b"just raw content";
1207
1208        let mut body = Vec::new();
1209        body.extend_from_slice(b"--no-hdr\r\n");
1210        body.extend_from_slice(raw_body);
1211        body.extend_from_slice(b"\r\n--no-hdr--");
1212
1213        let mut content = Vec::new();
1214        content.extend_from_slice(b"MESSAGE sip:host SIP/2.0\r\n");
1215        content.extend_from_slice(b"Call-ID: no-hdr-part@host\r\n");
1216        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=no-hdr\r\n");
1217        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1218        content.extend_from_slice(b"\r\n");
1219        content.extend_from_slice(&body);
1220
1221        let msg = make_sip_message(&content);
1222        let parsed = msg.parse().unwrap();
1223        let parts = parsed.body_parts().unwrap();
1224        assert_eq!(parts.len(), 1);
1225        assert!(parts[0].content_type().is_none());
1226        assert!(parts[0].headers.is_empty());
1227        assert_eq!(parts[0].body, raw_body);
1228    }
1229
1230    // --- is_json_content_type tests ---
1231
1232    #[test]
1233    fn is_json_content_type_application_json() {
1234        assert!(is_json_content_type("application/json"));
1235    }
1236
1237    #[test]
1238    fn is_json_content_type_plus_json() {
1239        assert!(is_json_content_type(
1240            "application/emergencyCallData.AbandonedCall+json"
1241        ));
1242    }
1243
1244    #[test]
1245    fn is_json_content_type_with_params() {
1246        assert!(is_json_content_type("application/json; charset=utf-8"));
1247    }
1248
1249    #[test]
1250    fn is_json_content_type_case_insensitive() {
1251        assert!(is_json_content_type("Application/JSON"));
1252    }
1253
1254    #[test]
1255    fn is_json_content_type_not_text_plain() {
1256        assert!(!is_json_content_type("text/plain"));
1257    }
1258
1259    #[test]
1260    fn is_json_content_type_not_multipart() {
1261        assert!(!is_json_content_type("multipart/mixed;boundary=foo"));
1262    }
1263
1264    #[test]
1265    fn is_json_content_type_not_sdp() {
1266        assert!(!is_json_content_type("application/sdp"));
1267    }
1268
1269    // --- unescape_json_body tests ---
1270
1271    #[test]
1272    fn unescape_json_basic_escapes() {
1273        let input = br#"{"key":"line1\r\nline2\ttab\"\\"}"#;
1274        let result = unescape_json_body(input);
1275        assert!(
1276            result.contains("line1\r\nline2\ttab\"\\"),
1277            "basic escapes not unescaped: {result:?}"
1278        );
1279    }
1280
1281    #[test]
1282    fn unescape_json_slash_and_control() {
1283        let input = br#"{"a":"\/path","b":"\b\f"}"#;
1284        let result = unescape_json_body(input);
1285        assert!(result.contains("/path"), "\\/ should become /");
1286        assert!(result.contains('\x08'), "\\b should become backspace");
1287        assert!(result.contains('\x0C'), "\\f should become form feed");
1288    }
1289
1290    #[test]
1291    fn unescape_json_unicode_basic() {
1292        // \u0041 = 'A'
1293        let input = br#"{"x":"\u0041"}"#;
1294        let result = unescape_json_body(input);
1295        assert!(
1296            result.contains('A'),
1297            "\\u0041 should become 'A': {result:?}"
1298        );
1299    }
1300
1301    #[test]
1302    fn unescape_json_unicode_surrogate_pair() {
1303        // U+1F600 (grinning face) = \uD83D\uDE00
1304        let input = br#"{"emoji":"\uD83D\uDE00"}"#;
1305        let result = unescape_json_body(input);
1306        assert!(
1307            result.contains('\u{1F600}'),
1308            "surrogate pair should produce U+1F600: {result:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn unescape_json_passthrough_non_escape() {
1314        let input = b"no escapes here";
1315        let result = unescape_json_body(input);
1316        assert_eq!(result, "no escapes here");
1317    }
1318
1319    // --- json_field tests ---
1320
1321    #[test]
1322    fn json_field_extract_string() {
1323        let body = br#"{"event":"AbandonedCall","id":"123"}"#;
1324        let mut content = Vec::new();
1325        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
1326        content.extend_from_slice(b"Call-ID: jf-test@host\r\n");
1327        content.extend_from_slice(b"Content-Type: application/json\r\n");
1328        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1329        content.extend_from_slice(b"\r\n");
1330        content.extend_from_slice(body);
1331
1332        let msg = make_sip_message(&content);
1333        let parsed = msg.parse().unwrap();
1334
1335        assert_eq!(
1336            parsed.json_field("event"),
1337            Some("AbandonedCall".to_string())
1338        );
1339        assert_eq!(parsed.json_field("id"), Some("123".to_string()));
1340    }
1341
1342    #[test]
1343    fn json_field_missing_key() {
1344        let body = br#"{"event":"AbandonedCall"}"#;
1345        let mut content = Vec::new();
1346        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
1347        content.extend_from_slice(b"Call-ID: jf-miss@host\r\n");
1348        content.extend_from_slice(b"Content-Type: application/json\r\n");
1349        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1350        content.extend_from_slice(b"\r\n");
1351        content.extend_from_slice(body);
1352
1353        let msg = make_sip_message(&content);
1354        let parsed = msg.parse().unwrap();
1355
1356        assert_eq!(parsed.json_field("nonexistent"), None);
1357    }
1358
1359    #[test]
1360    fn json_field_non_string_value() {
1361        let body = br#"{"count":42,"active":true}"#;
1362        let mut content = Vec::new();
1363        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
1364        content.extend_from_slice(b"Call-ID: jf-nonstr@host\r\n");
1365        content.extend_from_slice(b"Content-Type: application/json\r\n");
1366        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1367        content.extend_from_slice(b"\r\n");
1368        content.extend_from_slice(body);
1369
1370        let msg = make_sip_message(&content);
1371        let parsed = msg.parse().unwrap();
1372
1373        assert_eq!(parsed.json_field("count"), None);
1374        assert_eq!(parsed.json_field("active"), None);
1375    }
1376
1377    #[test]
1378    fn json_field_non_json_content_type() {
1379        let body = br#"{"event":"AbandonedCall"}"#;
1380        let mut content = Vec::new();
1381        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
1382        content.extend_from_slice(b"Call-ID: jf-nonjson@host\r\n");
1383        content.extend_from_slice(b"Content-Type: text/plain\r\n");
1384        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1385        content.extend_from_slice(b"\r\n");
1386        content.extend_from_slice(body);
1387
1388        let msg = make_sip_message(&content);
1389        let parsed = msg.parse().unwrap();
1390
1391        assert_eq!(parsed.json_field("event"), None);
1392    }
1393
1394    #[test]
1395    fn json_field_unescapes_value() {
1396        let body = br#"{"invite":"INVITE sip:host\r\nTo: <sip:host>\r\n"}"#;
1397        let mut content = Vec::new();
1398        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
1399        content.extend_from_slice(b"Call-ID: jf-unescape@host\r\n");
1400        content.extend_from_slice(b"Content-Type: application/json\r\n");
1401        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1402        content.extend_from_slice(b"\r\n");
1403        content.extend_from_slice(body);
1404
1405        let msg = make_sip_message(&content);
1406        let parsed = msg.parse().unwrap();
1407
1408        let invite = parsed.json_field("invite").unwrap();
1409        assert!(
1410            invite.contains("INVITE sip:host\r\nTo: <sip:host>\r\n"),
1411            "json_field should return unescaped string: {invite:?}"
1412        );
1413    }
1414
1415    #[test]
1416    fn json_field_plus_json_content_type() {
1417        let body = br#"{"cancelTimestamp":"2025-12-14T05:35:03.269Z"}"#;
1418        let mut content = Vec::new();
1419        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
1420        content.extend_from_slice(b"Call-ID: jf-plus@host\r\n");
1421        content.extend_from_slice(
1422            b"Content-Type: application/emergencyCallData.AbandonedCall+json\r\n",
1423        );
1424        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1425        content.extend_from_slice(b"\r\n");
1426        content.extend_from_slice(body);
1427
1428        let msg = make_sip_message(&content);
1429        let parsed = msg.parse().unwrap();
1430
1431        assert_eq!(
1432            parsed.json_field("cancelTimestamp"),
1433            Some("2025-12-14T05:35:03.269Z".to_string())
1434        );
1435    }
1436
1437    #[test]
1438    fn whitespace_only_returns_transport_noise() {
1439        use crate::frame::ParseError;
1440
1441        for content in [b"\n".as_slice(), b"\r\n", b"\n\n\n", b" \t\r\n"] {
1442            let msg = SipMessage {
1443                direction: Direction::Recv,
1444                transport: Transport::Tls,
1445                address: "[10.0.0.1]:5061".into(),
1446                timestamp: Timestamp::TimeOnly {
1447                    hour: 0,
1448                    min: 0,
1449                    sec: 0,
1450                    usec: 0,
1451                },
1452                content: content.to_vec(),
1453                frame_count: 1,
1454            };
1455            let err = msg.parse().unwrap_err();
1456            assert!(
1457                matches!(err, ParseError::TransportNoise { .. }),
1458                "whitespace-only content {:?} should produce TransportNoise, got: {err}",
1459                content,
1460            );
1461        }
1462    }
1463}