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