Skip to main content

freeswitch_sofia_trace_parser/
sip.rs

1use std::borrow::Cow;
2use std::sync::LazyLock;
3
4use memchr::memmem;
5use sip_header::extract_all_headers;
6
7use crate::frame::ParseError;
8use crate::message::MessageIterator;
9use crate::types::{
10    Headers, MimePart, ParseStats, ParsedSipMessage, SipFragment, SipMessage, SipMessageType,
11    SkipTracking, UnparsedRegion,
12};
13
14static CRLF: LazyLock<memmem::Finder<'static>> = LazyLock::new(|| memmem::Finder::new(b"\r\n"));
15static CRLFCRLF: LazyLock<memmem::Finder<'static>> =
16    LazyLock::new(|| memmem::Finder::new(b"\r\n\r\n"));
17
18impl SipMessage {
19    /// Parse this reassembled message into a [`ParsedSipMessage`] with typed
20    /// access to the request/status line, headers, and body.
21    pub fn parse(&self) -> Result<ParsedSipMessage, ParseError> {
22        parse_sip_message(self)
23    }
24}
25
26/// Level 3 streaming parser: wraps [`MessageIterator`] and parses each
27/// reassembled message into a [`ParsedSipMessage`].
28///
29/// # Example
30///
31/// ```no_run
32/// use std::fs::File;
33/// use freeswitch_sofia_trace_parser::ParsedMessageIterator;
34///
35/// let file = File::open("profile.dump").unwrap();
36/// for result in ParsedMessageIterator::new(file) {
37///     let msg = result.unwrap();
38///     if let Some(parts) = msg.body_parts() {
39///         for part in &parts {
40///             println!("  {} ({} bytes)",
41///                 part.content_type().unwrap_or("unknown"), part.body.len());
42///         }
43///     }
44/// }
45/// ```
46pub struct ParsedMessageIterator<R> {
47    inner: MessageIterator<R>,
48}
49
50impl<R: std::io::Read> ParsedMessageIterator<R> {
51    /// Create a new parsed message iterator reading from the given source.
52    pub fn new(reader: R) -> Self {
53        ParsedMessageIterator {
54            inner: MessageIterator::new(reader),
55        }
56    }
57
58    /// Enable capturing of skipped bytes in the underlying frame parser.
59    pub fn capture_skipped(mut self, enable: bool) -> Self {
60        self.inner = self.inner.capture_skipped(enable);
61        self
62    }
63
64    /// Set the level of detail for unparsed region tracking.
65    pub fn skip_tracking(mut self, tracking: SkipTracking) -> Self {
66        self.inner = self.inner.skip_tracking(tracking);
67        self
68    }
69
70    /// Borrow the accumulated parse statistics.
71    pub fn parse_stats(&self) -> &ParseStats {
72        self.inner.parse_stats()
73    }
74
75    /// Mutably borrow the parse statistics.
76    pub fn parse_stats_mut(&mut self) -> &mut ParseStats {
77        self.inner.parse_stats_mut()
78    }
79
80    /// Take all accumulated unparsed regions, leaving the list empty.
81    pub fn drain_unparsed(&mut self) -> Vec<UnparsedRegion> {
82        self.inner.drain_unparsed()
83    }
84}
85
86impl<R: std::io::Read> Iterator for ParsedMessageIterator<R> {
87    type Item = Result<ParsedSipMessage, ParseError>;
88
89    fn next(&mut self) -> Option<Self::Item> {
90        let msg = match self.inner.next()? {
91            Ok(m) => m,
92            Err(e) => return Some(Err(e)),
93        };
94        Some(msg.parse())
95    }
96}
97
98fn content_preview(content: &[u8], max_len: usize) -> String {
99    use std::fmt::Write;
100    let len = content.len().min(max_len);
101    let s = String::from_utf8_lossy(&content[..len]);
102    let mut out = String::with_capacity(s.len());
103    for c in s.chars() {
104        match c {
105            '\r' => out.push_str("\\r"),
106            '\n' => out.push_str("\\n"),
107            '\t' => out.push_str("\\t"),
108            '\0' => out.push_str("\\0"),
109            c if c.is_control() => {
110                let _ = write!(out, "\\x{:02x}", c as u32);
111            }
112            c => out.push(c),
113        }
114    }
115    if content.len() > max_len {
116        out.push_str("...");
117    }
118    out
119}
120
121fn parse_sip_message(msg: &SipMessage) -> Result<ParsedSipMessage, ParseError> {
122    let content = &msg.content;
123
124    if content
125        .iter()
126        .all(|&b| matches!(b, b'\r' | b'\n' | b' ' | b'\t'))
127    {
128        return Err(ParseError::TransportNoise {
129            bytes: content.len(),
130            transport: msg.transport,
131            address: msg.address.clone(),
132        });
133    }
134
135    parse_sip_content(msg, content).map_err(|e| {
136        let reason = match e {
137            ParseError::InvalidMessage(reason) => reason,
138            other => return other,
139        };
140        let preview = content_preview(content, 200);
141        ParseError::InvalidMessage(format!(
142            "{} {}/{} at {} ({} frames, {} bytes): {reason}\n  {preview}",
143            msg.direction,
144            msg.transport,
145            msg.address,
146            msg.timestamp,
147            msg.frame_count,
148            content.len(),
149        ))
150    })
151}
152
153fn parse_sip_content(msg: &SipMessage, content: &[u8]) -> Result<ParsedSipMessage, ParseError> {
154    // Find end of first line
155    let first_line_end = CRLF
156        .find(content)
157        .ok_or_else(|| ParseError::InvalidMessage("no CRLF found".into()))?;
158    let first_line = &content[..first_line_end];
159
160    let message_type = parse_first_line(first_line)?;
161
162    let (header_bytes, body) = split_headers_body(content, first_line_end + 2);
163    let headers = parse_headers(header_bytes);
164
165    Ok(ParsedSipMessage {
166        direction: msg.direction,
167        transport: msg.transport,
168        address: msg.address.clone(),
169        timestamp: msg.timestamp,
170        message_type,
171        headers,
172        body: body.to_vec(),
173        frame_count: msg.frame_count,
174    })
175}
176
177fn parse_first_line(line: &[u8]) -> Result<SipMessageType, ParseError> {
178    if line.starts_with(b"SIP/2.0 ") {
179        return parse_status_line(line);
180    }
181    parse_request_line(line)
182}
183
184fn parse_status_line(line: &[u8]) -> Result<SipMessageType, ParseError> {
185    // SIP/2.0 <code> <reason>
186    let after_version = &line[8..]; // skip "SIP/2.0 "
187
188    let space = memchr::memchr(b' ', after_version)
189        .ok_or_else(|| ParseError::InvalidMessage("no space after status code".into()))?;
190    let code_bytes = &after_version[..space];
191    let code: u16 = std::str::from_utf8(code_bytes)
192        .map_err(|_| ParseError::InvalidMessage("non-UTF-8 status code".into()))?
193        .parse()
194        .map_err(|_| ParseError::InvalidMessage("invalid status code".into()))?;
195
196    let reason = &after_version[space + 1..];
197    let reason = bytes_to_string(reason);
198
199    Ok(SipMessageType::Response { code, reason })
200}
201
202fn is_sip_token(b: &[u8]) -> bool {
203    !b.is_empty()
204        && b.iter()
205            .all(|&c| c.is_ascii_alphanumeric() || b"-._!%*+'~".contains(&c))
206}
207
208/// A syntactically valid header first line: a nonempty SIP token, optionally
209/// followed by HCOLON whitespace (SP / HTAB), then a colon.
210fn is_header_line(line: &[u8]) -> bool {
211    let Some(colon) = memchr::memchr(b':', line) else {
212        return false;
213    };
214    let mut name = &line[..colon];
215    while let [rest @ .., b' ' | b'\t'] = name {
216        name = rest;
217    }
218    is_sip_token(name)
219}
220
221fn parse_request_line(line: &[u8]) -> Result<SipMessageType, ParseError> {
222    // <METHOD> <URI> SIP/2.0
223    let first_space = memchr::memchr(b' ', line)
224        .ok_or_else(|| ParseError::InvalidMessage("no space in request line".into()))?;
225    let method = &line[..first_space];
226
227    if !is_sip_token(method) {
228        return Err(ParseError::InvalidMessage(format!(
229            "invalid SIP method: {:?}",
230            String::from_utf8_lossy(method)
231        )));
232    }
233    let rest = &line[first_space + 1..];
234
235    let last_space = memchr::memrchr(b' ', rest)
236        .ok_or_else(|| ParseError::InvalidMessage("no SIP version in request line".into()))?;
237    let version = &rest[last_space + 1..];
238    if version != b"SIP/2.0" {
239        return Err(ParseError::InvalidMessage(format!(
240            "expected SIP/2.0, got {:?}",
241            String::from_utf8_lossy(version)
242        )));
243    }
244    let uri = &rest[..last_space];
245
246    let method = bytes_to_string(method);
247    let uri = bytes_to_string(uri);
248
249    Ok(SipMessageType::Request { method, uri })
250}
251
252fn bytes_to_string(b: &[u8]) -> String {
253    if b.is_ascii() {
254        // SAFETY: ASCII is a subset of UTF-8; is_ascii() guarantees all bytes < 128
255        unsafe { String::from_utf8_unchecked(b.to_vec()) }
256    } else {
257        String::from_utf8_lossy(b).into_owned()
258    }
259}
260
261fn parse_headers(data: &[u8]) -> Headers {
262    let text = String::from_utf8_lossy(data);
263    Headers(extract_all_headers(&text))
264}
265
266/// Split at the `\r\n\r\n` header terminator. A terminator before
267/// `headers_start` means the blank line terminates the start line: no headers,
268/// body follows. No terminator means headers run to the end, no body.
269fn split_headers_body(data: &[u8], headers_start: usize) -> (&[u8], &[u8]) {
270    match CRLFCRLF.find(data) {
271        Some(pos) if pos >= headers_start => (&data[headers_start..pos], &data[pos + 4..]),
272        Some(pos) => (&[][..], &data[pos + 4..]),
273        None => (&data[headers_start.min(data.len())..], &[][..]),
274    }
275}
276
277/// Parse a `message/sipfrag` body (RFC 3420) — any prefix of a SIP message.
278///
279/// The start line is optional: a fragment that begins with a header is parsed
280/// from the headers down. The trailing CRLF is optional too, so a bare status
281/// line parses. Fails only when the first line is neither a start line nor a
282/// header, or the input is empty.
283pub fn parse_sipfrag(data: &[u8]) -> Result<SipFragment, ParseError> {
284    if data.is_empty() {
285        return Err(ParseError::InvalidMessage("empty sipfrag".into()));
286    }
287
288    let first_line_end = CRLF.find(data).unwrap_or(data.len());
289    let mut first_line = &data[..first_line_end];
290    // A bare trailing terminator from an LF-only writer is not part of the
291    // start line; a full CRLF is already excluded by the find above.
292    if let [rest @ .., b'\n'] = first_line {
293        first_line = rest;
294    }
295    if let [rest @ .., b'\r'] = first_line {
296        first_line = rest;
297    }
298
299    let (message_type, headers_start) = match parse_first_line(first_line) {
300        Ok(mt) => (Some(mt), (first_line_end + 2).min(data.len())),
301        Err(e) => {
302            if !is_header_line(first_line) {
303                return Err(e);
304            }
305            (None, 0)
306        }
307    };
308
309    let (header_bytes, body) = split_headers_body(data, headers_start);
310
311    Ok(SipFragment {
312        message_type,
313        headers: parse_headers(header_bytes),
314        body: body.to_vec(),
315    })
316}
317
318fn is_multipart_type(content_type: Option<&str>) -> bool {
319    content_type
320        .map(|ct| normalize_media_type(ct).starts_with("multipart/"))
321        .unwrap_or(false)
322}
323
324/// A declared boundary that yields no parts is not a split: reporting it as
325/// one empty makes a body vanish from a per-part loop.
326fn split_multipart(content_type: Option<&str>, body: &[u8]) -> Option<Vec<MimePart>> {
327    let boundary = extract_boundary(content_type?)?;
328    let parts = parse_multipart_body(body, boundary);
329    (!parts.is_empty()).then_some(parts)
330}
331
332impl SipFragment {
333    /// Content-Type with parameters stripped and lowercased, e.g.
334    /// `application/sdp` from `Application/SDP; charset=utf-8`. Use this to
335    /// dispatch on the type rather than matching the raw header value.
336    pub fn media_type(&self) -> Option<Cow<'_, str>> {
337        self.content_type().map(normalize_media_type)
338    }
339}
340
341impl MimePart {
342    /// Content-Type with parameters stripped and lowercased, e.g.
343    /// `application/sdp` from `Application/SDP; charset=utf-8`. Use this to
344    /// dispatch on the type rather than matching the raw header value.
345    pub fn media_type(&self) -> Option<Cow<'_, str>> {
346        self.content_type().map(normalize_media_type)
347    }
348
349    /// Returns `true` if this part's Content-Type starts with `multipart/`.
350    pub fn is_multipart(&self) -> bool {
351        is_multipart_type(self.content_type())
352    }
353
354    /// Extract the MIME boundary string from this part's Content-Type header.
355    pub fn multipart_boundary(&self) -> Option<&str> {
356        extract_boundary(self.content_type()?)
357    }
358
359    /// Parse this part's body as a `message/sipfrag` (RFC 3420).
360    ///
361    /// Does not check the Content-Type: dispatch on [`media_type`](Self::media_type)
362    /// first, then call this for the parts that claim to be fragments.
363    pub fn parse_sipfrag(&self) -> Result<SipFragment, ParseError> {
364        parse_sipfrag(&self.body)
365    }
366
367    /// Split a nested multipart part into its own [`MimePart`]s.
368    /// Returns `None` when this part carries no boundary or that boundary
369    /// yields no parts — either way, keep the part's own bytes.
370    ///
371    /// Descends exactly one level: a grandchild multipart comes back as a part
372    /// with its `multipart/*` type intact, to be split by another explicit
373    /// call. Depth is the caller's decision.
374    pub fn body_parts(&self) -> Option<Vec<MimePart>> {
375        split_multipart(self.content_type(), &self.body)
376    }
377}
378
379impl ParsedSipMessage {
380    /// Content-Type with parameters stripped and lowercased, e.g.
381    /// `multipart/mixed` from `multipart/mixed;boundary=abc`. Use this to
382    /// dispatch on the type rather than matching the raw header value.
383    pub fn media_type(&self) -> Option<Cow<'_, str>> {
384        self.content_type().map(normalize_media_type)
385    }
386
387    /// Returns `true` if the Content-Type starts with `multipart/`.
388    pub fn is_multipart(&self) -> bool {
389        is_multipart_type(self.content_type())
390    }
391
392    /// Extract the MIME boundary string from the Content-Type header.
393    pub fn multipart_boundary(&self) -> Option<&str> {
394        extract_boundary(self.content_type()?)
395    }
396
397    /// Split a multipart body into individual [`MimePart`]s.
398    /// Returns `None` when the Content-Type carries no `boundary` parameter or
399    /// that boundary yields no parts.
400    pub fn body_parts(&self) -> Option<Vec<MimePart>> {
401        split_multipart(self.content_type(), &self.body)
402    }
403
404    /// The body as parts, whatever its Content-Type: the multipart children
405    /// when it splits, otherwise a single part carrying the message's own
406    /// `Content-*` headers. Empty when there is no body.
407    ///
408    /// That single part is fabricated — a non-multipart body has no per-part
409    /// header block on the wire — so its headers are copied down from the
410    /// message, compact forms expanded, `Content-Length` excluded. A part
411    /// split from a real multipart body carries only what the sender wrote
412    /// there, and nothing is copied into it.
413    ///
414    /// A body that claims `multipart/*` but does not split — no boundary
415    /// parameter, or one that never appears in the body — comes back as that
416    /// one part, still typed `multipart/*`. A caller that only handles types it
417    /// recognizes then sees an unknown type rather than nothing at all.
418    ///
419    /// Descends one level only; nested multipart parts are split by calling
420    /// [`MimePart::body_parts`] on them.
421    pub fn body_as_parts(&self) -> Vec<MimePart> {
422        if self.body.is_empty() {
423            return Vec::new();
424        }
425        if let Some(parts) = self.body_parts() {
426            return parts;
427        }
428
429        let mut headers: Vec<(String, String)> = Vec::new();
430        if let Some(ct) = self.content_type() {
431            headers.push(("Content-Type".to_string(), ct.to_string()));
432        }
433        for (name, value) in &self.headers {
434            let Some(canonical) = canonical_body_header(name) else {
435                continue;
436            };
437            if headers
438                .iter()
439                .any(|(k, _)| k.eq_ignore_ascii_case(canonical))
440            {
441                continue;
442            }
443            headers.push((canonical.to_string(), value.clone()));
444        }
445        vec![MimePart {
446            headers: Headers(headers),
447            body: self.body.clone(),
448        }]
449    }
450
451    /// Content-type-aware body text. For JSON content types (`application/json`
452    /// and `application/*+json`), unescapes RFC 8259 string sequences
453    /// (`\r\n` to CRLF, `\t` to tab, `\uXXXX` to Unicode). Passthrough for
454    /// all other content types.
455    pub fn body_text(&self) -> Cow<'_, str> {
456        if let Some(ct) = self.content_type() {
457            if is_json_content_type(ct) {
458                return Cow::Owned(unescape_json_body(&self.body));
459            }
460        }
461        self.body_data()
462    }
463
464    /// Parse the body as JSON and return the unescaped string value of a
465    /// top-level key. Returns `None` if the content type is not JSON, the
466    /// body is invalid JSON, the key is missing, or the value is not a string.
467    pub fn json_field(&self, key: &str) -> Option<String> {
468        let ct = self.content_type()?;
469        if !is_json_content_type(ct) {
470            return None;
471        }
472        let value: serde_json::Value = serde_json::from_slice(&self.body).ok()?;
473        let obj = value.as_object()?;
474        obj.get(key)?.as_str().map(|s| s.to_string())
475    }
476}
477
478/// Strip parameters from a Content-Type value and normalize to lowercase.
479/// Borrows when the type/subtype is already lowercase and unpadded.
480fn normalize_media_type(ct: &str) -> Cow<'_, str> {
481    let base = ct.split(';').next().unwrap_or("").trim();
482    if base.bytes().any(|b| b.is_ascii_uppercase()) {
483        Cow::Owned(base.to_ascii_lowercase())
484    } else {
485        Cow::Borrowed(base)
486    }
487}
488
489/// Canonical name of a body-describing header, `None` otherwise. Excludes
490/// `Content-Length`: it goes stale once a consumer rewrites the part.
491fn canonical_body_header(name: &str) -> Option<&str> {
492    if name.eq_ignore_ascii_case("c") {
493        return Some("Content-Type");
494    }
495    if name.eq_ignore_ascii_case("e") {
496        return Some("Content-Encoding");
497    }
498    if name.eq_ignore_ascii_case("l") || name.eq_ignore_ascii_case("Content-Length") {
499        return None;
500    }
501    name.as_bytes()
502        .get(..8)
503        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"content-"))
504        .then_some(name)
505}
506
507/// Returns `true` for `application/json` and any `application/*+json` subtype.
508/// Case-insensitive; media type parameters are ignored.
509pub fn is_json_content_type(ct: &str) -> bool {
510    let media_type = normalize_media_type(ct);
511    media_type == "application/json"
512        || (media_type.starts_with("application/") && media_type.ends_with("+json"))
513}
514
515fn unescape_json_body(input: &[u8]) -> String {
516    let s = String::from_utf8_lossy(input);
517    let mut out = String::with_capacity(s.len());
518    let mut chars = s.chars();
519
520    while let Some(c) = chars.next() {
521        if c != '\\' {
522            out.push(c);
523            continue;
524        }
525        match chars.next() {
526            Some('"') => out.push('"'),
527            Some('\\') => out.push('\\'),
528            Some('/') => out.push('/'),
529            Some('b') => out.push('\x08'),
530            Some('f') => out.push('\x0C'),
531            Some('n') => out.push('\n'),
532            Some('r') => out.push('\r'),
533            Some('t') => out.push('\t'),
534            Some('u') => unescape_unicode(&mut chars, &mut out),
535            Some(other) => {
536                out.push('\\');
537                out.push(other);
538            }
539            None => out.push('\\'),
540        }
541    }
542    out
543}
544
545fn unescape_unicode(chars: &mut std::str::Chars<'_>, out: &mut String) {
546    let hex: String = chars.by_ref().take(4).collect();
547    let Some(code_point) = parse_hex4(&hex) else {
548        out.push_str("\\u");
549        out.push_str(&hex);
550        return;
551    };
552
553    if (0xD800..=0xDBFF).contains(&code_point) {
554        let mut peek = chars.clone();
555        if peek.next() == Some('\\') && peek.next() == Some('u') {
556            let hex2: String = peek.by_ref().take(4).collect();
557            if let Some(low) = parse_hex4(&hex2) {
558                if (0xDC00..=0xDFFF).contains(&low) {
559                    let combined =
560                        0x10000 + ((code_point as u32 - 0xD800) << 10) + (low as u32 - 0xDC00);
561                    if let Some(ch) = char::from_u32(combined) {
562                        out.push(ch);
563                        *chars = peek;
564                        return;
565                    }
566                }
567            }
568        }
569        out.push_str("\\u");
570        out.push_str(&hex);
571    } else if let Some(ch) = char::from_u32(code_point as u32) {
572        out.push(ch);
573    } else {
574        out.push_str("\\u");
575        out.push_str(&hex);
576    }
577}
578
579fn parse_hex4(hex: &str) -> Option<u16> {
580    if hex.len() == 4 {
581        u16::from_str_radix(hex, 16).ok()
582    } else {
583        None
584    }
585}
586
587fn extract_boundary(content_type: &str) -> Option<&str> {
588    let lower = content_type.to_ascii_lowercase();
589    let idx = lower.find("boundary=")?;
590    let after = &content_type[idx + 9..];
591
592    if let Some(after_quote) = after.strip_prefix('"') {
593        let end_quote = after_quote.find('"')?;
594        Some(&after_quote[..end_quote])
595    } else {
596        let end = after.find(';').unwrap_or(after.len());
597        let boundary = after[..end].trim();
598        if boundary.is_empty() {
599            None
600        } else {
601            Some(boundary)
602        }
603    }
604}
605
606/// What follows a matched `--boundary` token, deciding whether the match is a
607/// real RFC 2046 delimiter line and where the next part's content starts.
608enum BoundaryTail {
609    /// Open delimiter; the value is the byte count from the end of the token
610    /// (transport padding plus CRLF) to the start of the part content.
611    Open(usize),
612    Close,
613    /// Input ends inside the delimiter line itself (truncated dump).
614    End,
615}
616
617/// Classify the bytes after a `--boundary` token. `None` means the match is
618/// not a delimiter line at all — e.g. boundary `b` matched inside `--b2`.
619fn boundary_tail(rest: &[u8]) -> Option<BoundaryTail> {
620    if rest.starts_with(b"--") {
621        return Some(BoundaryTail::Close);
622    }
623    let pad = rest
624        .iter()
625        .position(|&b| b != b' ' && b != b'\t')
626        .unwrap_or(rest.len());
627    match &rest[pad..] {
628        [] => Some(BoundaryTail::End),
629        [b'\r', b'\n', ..] => Some(BoundaryTail::Open(pad + 2)),
630        _ => None,
631    }
632}
633
634/// Next RFC 2046 delimiter line at or after `from`: `--boundary` at body
635/// offset 0 (no preamble) or immediately after a CRLF. `part_end` is where the
636/// preceding part's content stops — the CRLF belongs to the delimiter line.
637fn next_delimiter(
638    body: &[u8],
639    from: usize,
640    dash_boundary: &[u8],
641    anchored: &memmem::Finder<'_>,
642) -> Option<(usize, usize, BoundaryTail)> {
643    if from == 0 && body.starts_with(dash_boundary) {
644        if let Some(tail) = boundary_tail(&body[dash_boundary.len()..]) {
645            return Some((0, dash_boundary.len(), tail));
646        }
647    }
648    let mut search = from;
649    while let Some(rel) = anchored.find(&body[search..]) {
650        let crlf = search + rel;
651        let token_end = crlf + 2 + dash_boundary.len();
652        if let Some(tail) = boundary_tail(&body[token_end..]) {
653            return Some((crlf, token_end, tail));
654        }
655        search = crlf + 1;
656    }
657    None
658}
659
660fn parse_multipart_body(body: &[u8], boundary: &str) -> Vec<MimePart> {
661    let mut pattern = Vec::with_capacity(boundary.len() + 4);
662    pattern.extend_from_slice(b"\r\n--");
663    pattern.extend_from_slice(boundary.as_bytes());
664    let anchored = memmem::Finder::new(&pattern);
665    let dash_boundary = &pattern[2..];
666
667    let mut parts = Vec::new();
668
669    let Some((_, token_end, tail)) = next_delimiter(body, 0, dash_boundary, &anchored) else {
670        return parts;
671    };
672    let mut cursor = match tail {
673        BoundaryTail::Open(skip) => token_end + skip,
674        // The body opens with the close delimiter, or truncates inside the
675        // first delimiter line: no parts.
676        BoundaryTail::Close | BoundaryTail::End => return parts,
677    };
678
679    loop {
680        match next_delimiter(body, cursor, dash_boundary, &anchored) {
681            Some((part_end, token_end, BoundaryTail::Open(skip))) => {
682                parts.push(parse_mime_part(&body[cursor..part_end]));
683                cursor = token_end + skip;
684            }
685            Some((part_end, _, BoundaryTail::Close | BoundaryTail::End)) => {
686                parts.push(parse_mime_part(&body[cursor..part_end]));
687                break;
688            }
689            // Truncated before the close delimiter: the trailing bytes are
690            // the final part, never silently dropped.
691            None => {
692                parts.push(parse_mime_part(&body[cursor..]));
693                break;
694            }
695        }
696    }
697    parts
698}
699
700fn parse_mime_part(data: &[u8]) -> MimePart {
701    match CRLFCRLF.find(data) {
702        Some(pos) => {
703            let header_bytes = &data[..pos];
704            let body = &data[pos + 4..];
705            let headers = parse_headers(header_bytes);
706            MimePart {
707                headers,
708                body: body.to_vec(),
709            }
710        }
711        None => {
712            // Could be headers-only or body-only.
713            // If first line has a colon, treat as headers with no body.
714            let first_line_end = CRLF.find(data).unwrap_or(data.len());
715            if memchr::memchr(b':', &data[..first_line_end]).is_some() {
716                let headers = parse_headers(data);
717                MimePart {
718                    headers,
719                    body: Vec::new(),
720                }
721            } else {
722                MimePart {
723                    headers: Headers::default(),
724                    body: data.to_vec(),
725                }
726            }
727        }
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use crate::types::{Direction, SipMessage, Timestamp, Transport};
735
736    fn make_sip_message(content: &[u8]) -> SipMessage {
737        SipMessage {
738            direction: Direction::Recv,
739            transport: Transport::Udp,
740            address: "10.0.0.1:5060".into(),
741            timestamp: Timestamp::TimeOnly {
742                hour: 12,
743                min: 0,
744                sec: 0,
745                usec: 0,
746            },
747            content: content.to_vec(),
748            frame_count: 1,
749        }
750    }
751
752    #[test]
753    fn parse_stats_delegates() {
754        let content =
755            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: stats-test\r\nContent-Length: 0\r\n\r\n";
756        let header = format!(
757            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
758            content.len()
759        );
760        let mut data = header.into_bytes();
761        data.extend_from_slice(content);
762        data.extend_from_slice(b"\x0B\n");
763
764        let mut iter = ParsedMessageIterator::new(&data[..]);
765        let parsed: Vec<_> = iter.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
766        assert_eq!(parsed.len(), 1);
767        let stats = iter.parse_stats();
768        assert_eq!(stats.bytes_read, data.len() as u64);
769        assert_eq!(stats.bytes_skipped, 0);
770    }
771
772    #[test]
773    fn parse_options_request() {
774        let content = b"OPTIONS sip:user@host SIP/2.0\r\n\
775            Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-1\r\n\
776            From: <sip:user@host>;tag=abc\r\n\
777            To: <sip:user@host>\r\n\
778            Call-ID: test-call-id@host\r\n\
779            CSeq: 1 OPTIONS\r\n\
780            Content-Length: 0\r\n\
781            \r\n";
782        let msg = make_sip_message(content);
783        let parsed = msg.parse().unwrap();
784
785        assert_eq!(
786            parsed.message_type,
787            SipMessageType::Request {
788                method: "OPTIONS".into(),
789                uri: "sip:user@host".into()
790            }
791        );
792        assert_eq!(parsed.call_id(), Some("test-call-id@host"));
793        assert_eq!(parsed.cseq(), Some("1 OPTIONS"));
794        assert_eq!(parsed.content_length(), Some(0));
795        assert_eq!(parsed.method(), Some("OPTIONS"));
796        assert!(parsed.body.is_empty());
797    }
798
799    #[test]
800    fn parse_200_ok_response() {
801        let content = b"SIP/2.0 200 OK\r\n\
802            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
803            Call-ID: resp-id@host\r\n\
804            CSeq: 1 INVITE\r\n\
805            Content-Length: 0\r\n\
806            \r\n";
807        let msg = make_sip_message(content);
808        let parsed = msg.parse().unwrap();
809
810        assert_eq!(
811            parsed.message_type,
812            SipMessageType::Response {
813                code: 200,
814                reason: "OK".into()
815            }
816        );
817        assert_eq!(parsed.method(), Some("INVITE"));
818    }
819
820    #[test]
821    fn parse_100_trying() {
822        let content = b"SIP/2.0 100 Trying\r\n\
823            Via: SIP/2.0/TCP 10.0.0.1:5060\r\n\
824            Call-ID: trying-id\r\n\
825            CSeq: 42 INVITE\r\n\
826            Content-Length: 0\r\n\
827            \r\n";
828        let msg = make_sip_message(content);
829        let parsed = msg.parse().unwrap();
830
831        assert_eq!(
832            parsed.message_type,
833            SipMessageType::Response {
834                code: 100,
835                reason: "Trying".into()
836            }
837        );
838        assert_eq!(parsed.method(), Some("INVITE"));
839    }
840
841    #[test]
842    fn parse_invite_with_sdp_body() {
843        let body = b"v=0\r\no=- 123 456 IN IP4 10.0.0.1\r\ns=-\r\n";
844        let mut content = Vec::new();
845        content.extend_from_slice(b"INVITE sip:user@host SIP/2.0\r\n");
846        content.extend_from_slice(b"Call-ID: invite-body@host\r\n");
847        content.extend_from_slice(b"CSeq: 1 INVITE\r\n");
848        content.extend_from_slice(b"Content-Type: application/sdp\r\n");
849        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
850        content.extend_from_slice(b"\r\n");
851        content.extend_from_slice(body);
852
853        let msg = make_sip_message(&content);
854        let parsed = msg.parse().unwrap();
855
856        assert_eq!(parsed.method(), Some("INVITE"));
857        assert_eq!(parsed.content_type(), Some("application/sdp"));
858        assert_eq!(parsed.content_length(), Some(body.len()));
859        assert_eq!(parsed.body, body);
860    }
861
862    #[test]
863    fn parse_notify_with_json_body() {
864        let body = br#"{"event":"AbandonedCall","id":"123"}"#;
865        let mut content = Vec::new();
866        content.extend_from_slice(b"NOTIFY sip:user@host SIP/2.0\r\n");
867        content.extend_from_slice(b"Call-ID: notify-json@host\r\n");
868        content.extend_from_slice(b"CSeq: 1 NOTIFY\r\n");
869        content.extend_from_slice(b"Content-Type: application/json\r\n");
870        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
871        content.extend_from_slice(b"\r\n");
872        content.extend_from_slice(body);
873
874        let msg = make_sip_message(&content);
875        let parsed = msg.parse().unwrap();
876
877        assert_eq!(parsed.method(), Some("NOTIFY"));
878        assert_eq!(parsed.content_type(), Some("application/json"));
879        assert_eq!(parsed.body, body);
880    }
881
882    #[test]
883    fn compact_headers() {
884        let content = b"NOTIFY sip:user@host SIP/2.0\r\n\
885            i: compact-call-id\r\n\
886            l: 0\r\n\
887            c: text/plain\r\n\
888            \r\n";
889        let msg = make_sip_message(content);
890        let parsed = msg.parse().unwrap();
891
892        assert_eq!(parsed.call_id(), Some("compact-call-id"));
893        assert_eq!(parsed.content_length(), Some(0));
894        assert_eq!(parsed.content_type(), Some("text/plain"));
895    }
896
897    #[test]
898    fn header_folding() {
899        let content = b"OPTIONS sip:host SIP/2.0\r\n\
900            Via: SIP/2.0/UDP 10.0.0.1:5060\r\n\
901            Subject: this is a long\r\n \
902            folded header value\r\n\
903            Call-ID: fold-test\r\n\
904            Content-Length: 0\r\n\
905            \r\n";
906        let msg = make_sip_message(content);
907        let parsed = msg.parse().unwrap();
908
909        let subject = parsed
910            .headers
911            .iter()
912            .find(|(k, _)| k == "Subject")
913            .map(|(_, v)| v.as_str());
914        assert_eq!(subject, Some("this is a long folded header value"));
915        assert_eq!(parsed.call_id(), Some("fold-test"));
916    }
917
918    #[test]
919    fn folded_header_no_crlf_leak() {
920        let content = b"OPTIONS sip:host SIP/2.0\r\n\
921            Subject: line1\r\n \
922            line2\r\n\
923            Content-Length: 0\r\n\
924            \r\n";
925        let msg = make_sip_message(content);
926        let parsed = msg.parse().unwrap();
927        let subject = parsed.headers.iter().find(|(k, _)| k == "Subject").unwrap();
928        assert!(!subject.1.contains('\r'), "CRLF leaked: {:?}", subject.1);
929        assert!(!subject.1.contains('\n'), "LF leaked: {:?}", subject.1);
930        assert_eq!(subject.1, "line1 line2");
931    }
932
933    #[test]
934    fn no_body() {
935        let content = b"OPTIONS sip:host SIP/2.0\r\n\
936            Call-ID: nobody\r\n\
937            Content-Length: 0\r\n\
938            \r\n";
939        let msg = make_sip_message(content);
940        let parsed = msg.parse().unwrap();
941        assert!(parsed.body.is_empty());
942    }
943
944    #[test]
945    fn no_blank_line_no_body() {
946        // Malformed: no \r\n\r\n separator
947        let content = b"OPTIONS sip:host SIP/2.0\r\n\
948            Call-ID: no-blank\r\n\
949            Content-Length: 0";
950        let msg = make_sip_message(content);
951        let parsed = msg.parse().unwrap();
952        assert!(parsed.body.is_empty());
953        assert_eq!(parsed.call_id(), Some("no-blank"));
954    }
955
956    #[test]
957    fn preserves_metadata() {
958        let content = b"REGISTER sip:host SIP/2.0\r\n\
959            Call-ID: meta-test\r\n\
960            \r\n";
961        let msg = SipMessage {
962            direction: Direction::Sent,
963            transport: Transport::Tls,
964            address: "[2001:db8::1]:5061".into(),
965            timestamp: Timestamp::DateTime {
966                year: 2026,
967                month: 2,
968                day: 12,
969                hour: 10,
970                min: 30,
971                sec: 0,
972                usec: 123456,
973            },
974            content: content.to_vec(),
975            frame_count: 3,
976        };
977        let parsed = msg.parse().unwrap();
978
979        assert_eq!(parsed.direction, Direction::Sent);
980        assert_eq!(parsed.transport, Transport::Tls);
981        assert_eq!(parsed.address, "[2001:db8::1]:5061");
982        assert_eq!(parsed.frame_count, 3);
983        assert_eq!(
984            parsed.timestamp,
985            Timestamp::DateTime {
986                year: 2026,
987                month: 2,
988                day: 12,
989                hour: 10,
990                min: 30,
991                sec: 0,
992                usec: 123456,
993            }
994        );
995    }
996
997    #[test]
998    fn multiple_same_name_headers() {
999        let content = b"INVITE sip:host SIP/2.0\r\n\
1000            Via: SIP/2.0/UDP proxy1:5060\r\n\
1001            Via: SIP/2.0/UDP proxy2:5060\r\n\
1002            Record-Route: <sip:proxy1>\r\n\
1003            Record-Route: <sip:proxy2>\r\n\
1004            Call-ID: multi-hdr\r\n\
1005            Content-Length: 0\r\n\
1006            \r\n";
1007        let msg = make_sip_message(content);
1008        let parsed = msg.parse().unwrap();
1009
1010        let via_count = parsed.headers.iter().filter(|(k, _)| k == "Via").count();
1011        assert_eq!(via_count, 2);
1012
1013        let rr_count = parsed
1014            .headers
1015            .iter()
1016            .filter(|(k, _)| k == "Record-Route")
1017            .count();
1018        assert_eq!(rr_count, 2);
1019    }
1020
1021    #[test]
1022    fn header_ordering_preserved() {
1023        let content = b"OPTIONS sip:host SIP/2.0\r\n\
1024            Via: v1\r\n\
1025            From: f1\r\n\
1026            To: t1\r\n\
1027            Call-ID: order-test\r\n\
1028            CSeq: 1 OPTIONS\r\n\
1029            \r\n";
1030        let msg = make_sip_message(content);
1031        let parsed = msg.parse().unwrap();
1032
1033        let names: Vec<&str> = parsed.headers.iter().map(|(k, _)| k.as_str()).collect();
1034        assert_eq!(names, vec!["Via", "From", "To", "Call-ID", "CSeq"]);
1035    }
1036
1037    #[test]
1038    fn status_line_with_long_reason() {
1039        let content = b"SIP/2.0 486 Busy Here\r\n\
1040            Call-ID: busy\r\n\
1041            \r\n";
1042        let msg = make_sip_message(content);
1043        let parsed = msg.parse().unwrap();
1044
1045        assert_eq!(
1046            parsed.message_type,
1047            SipMessageType::Response {
1048                code: 486,
1049                reason: "Busy Here".into()
1050            }
1051        );
1052    }
1053
1054    #[test]
1055    fn request_with_complex_uri() {
1056        let content = b"INVITE sip:+15551234567@gateway.example.com;transport=tcp SIP/2.0\r\n\
1057            Call-ID: complex-uri\r\n\
1058            \r\n";
1059        let msg = make_sip_message(content);
1060        let parsed = msg.parse().unwrap();
1061
1062        assert_eq!(
1063            parsed.message_type,
1064            SipMessageType::Request {
1065                method: "INVITE".into(),
1066                uri: "sip:+15551234567@gateway.example.com;transport=tcp".into()
1067            }
1068        );
1069    }
1070
1071    #[test]
1072    fn binary_body() {
1073        let body: Vec<u8> = (0..256).map(|i| i as u8).collect();
1074        let mut content = Vec::new();
1075        content.extend_from_slice(b"MESSAGE sip:host SIP/2.0\r\n");
1076        content.extend_from_slice(b"Call-ID: binary-body\r\n");
1077        content.extend_from_slice(b"Content-Type: application/octet-stream\r\n");
1078        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1079        content.extend_from_slice(b"\r\n");
1080        content.extend_from_slice(&body);
1081
1082        let msg = make_sip_message(&content);
1083        let parsed = msg.parse().unwrap();
1084
1085        assert_eq!(parsed.body, body);
1086    }
1087
1088    #[test]
1089    fn error_no_crlf() {
1090        let content = b"garbage without any crlf";
1091        let msg = make_sip_message(content);
1092        let result = msg.parse();
1093        assert!(result.is_err());
1094    }
1095
1096    #[test]
1097    fn error_no_space_in_request_line() {
1098        let content = b"INVALID\r\n\r\n";
1099        let msg = make_sip_message(content);
1100        let result = msg.parse();
1101        assert!(result.is_err());
1102    }
1103
1104    #[test]
1105    fn parse_request_rejects_xml_method() {
1106        let content =
1107            b"</confInfo:conference-info>NOTIFY sip:user@host SIP/2.0\r\nContent-Length: 0\r\n\r\n";
1108        let msg = make_sip_message(content);
1109        assert!(msg.parse().is_err(), "should reject XML-prefixed method");
1110    }
1111
1112    #[test]
1113    fn parse_request_rejects_method_with_angle_brackets() {
1114        let content = b"<xml>BYE sip:host SIP/2.0\r\n\r\n";
1115        let msg = make_sip_message(content);
1116        assert!(msg.parse().is_err());
1117    }
1118
1119    #[test]
1120    fn parse_request_accepts_extension_method() {
1121        let content = b"CUSTOM-METHOD sip:host SIP/2.0\r\nContent-Length: 0\r\n\r\n";
1122        let msg = make_sip_message(content);
1123        let parsed = msg.parse().unwrap();
1124        assert_eq!(
1125            parsed.message_type,
1126            SipMessageType::Request {
1127                method: "CUSTOM-METHOD".into(),
1128                uri: "sip:host".into()
1129            }
1130        );
1131    }
1132
1133    #[test]
1134    fn header_value_with_colon() {
1135        // SIP URIs in header values contain colons
1136        let content = b"INVITE sip:host SIP/2.0\r\n\
1137            Contact: <sip:user@10.0.0.1:5060;transport=tcp>\r\n\
1138            Call-ID: colon-val\r\n\
1139            \r\n";
1140        let msg = make_sip_message(content);
1141        let parsed = msg.parse().unwrap();
1142
1143        let contact = parsed
1144            .headers
1145            .iter()
1146            .find(|(k, _)| k == "Contact")
1147            .map(|(_, v)| v.as_str());
1148        assert_eq!(contact, Some("<sip:user@10.0.0.1:5060;transport=tcp>"));
1149    }
1150
1151    #[test]
1152    fn whitespace_around_header_value() {
1153        let content = b"OPTIONS sip:host SIP/2.0\r\n\
1154            Call-ID:   spaces-around   \r\n\
1155            \r\n";
1156        let msg = make_sip_message(content);
1157        let parsed = msg.parse().unwrap();
1158
1159        // Leading whitespace should be trimmed, trailing kept (we only trim leading)
1160        assert_eq!(parsed.call_id(), Some("spaces-around   "));
1161    }
1162
1163    #[test]
1164    fn parsed_message_iterator() {
1165        let content =
1166            b"OPTIONS sip:host SIP/2.0\r\nCall-ID: iter-test\r\nContent-Length: 0\r\n\r\n";
1167        let header = format!(
1168            "recv {} bytes from udp/10.0.0.1:5060 at 00:00:00.000000:\n",
1169            content.len()
1170        );
1171        let mut data = header.into_bytes();
1172        data.extend_from_slice(content);
1173        data.extend_from_slice(b"\x0B\n");
1174
1175        let parsed: Vec<ParsedSipMessage> = ParsedMessageIterator::new(&data[..])
1176            .collect::<Result<Vec<_>, _>>()
1177            .unwrap();
1178
1179        assert_eq!(parsed.len(), 1);
1180        assert_eq!(parsed[0].call_id(), Some("iter-test"));
1181        assert_eq!(parsed[0].method(), Some("OPTIONS"));
1182    }
1183
1184    // --- Multipart tests ---
1185
1186    fn make_multipart_invite(boundary: &str, parts: &[(&str, &[u8])]) -> SipMessage {
1187        let mut body = Vec::new();
1188        for (ct, content) in parts {
1189            body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
1190            body.extend_from_slice(format!("Content-Type: {ct}\r\n").as_bytes());
1191            body.extend_from_slice(b"\r\n");
1192            body.extend_from_slice(content);
1193            body.extend_from_slice(b"\r\n");
1194        }
1195        body.extend_from_slice(format!("--{boundary}--").as_bytes());
1196
1197        let mut content = Vec::new();
1198        content.extend_from_slice(b"INVITE sip:urn:service:sos@esrp.example.com SIP/2.0\r\n");
1199        content.extend_from_slice(b"Call-ID: multipart-test@host\r\n");
1200        content.extend_from_slice(b"CSeq: 1 INVITE\r\n");
1201        content.extend_from_slice(
1202            format!("Content-Type: multipart/mixed;boundary={boundary}\r\n").as_bytes(),
1203        );
1204        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1205        content.extend_from_slice(b"\r\n");
1206        content.extend_from_slice(&body);
1207
1208        make_sip_message(&content)
1209    }
1210
1211    #[test]
1212    fn multipart_sdp_and_pidf() {
1213        let sdp = b"v=0\r\no=- 123 456 IN IP4 10.0.0.1\r\ns=-\r\n";
1214        let pidf = b"<?xml version=\"1.0\"?>\r\n<presence xmlns=\"urn:ietf:params:xml:ns:pidf\"/>";
1215        let msg = make_multipart_invite(
1216            "unique-boundary-1",
1217            &[("application/sdp", sdp), ("application/pidf+xml", pidf)],
1218        );
1219        let parsed = msg.parse().unwrap();
1220
1221        assert!(parsed.is_multipart());
1222        assert_eq!(parsed.multipart_boundary(), Some("unique-boundary-1"));
1223
1224        let parts = parsed.body_parts().unwrap();
1225        assert_eq!(parts.len(), 2);
1226
1227        assert_eq!(parts[0].content_type(), Some("application/sdp"));
1228        assert_eq!(parts[0].body, sdp);
1229
1230        assert_eq!(parts[1].content_type(), Some("application/pidf+xml"));
1231        assert_eq!(parts[1].body, pidf);
1232    }
1233
1234    #[test]
1235    fn multipart_sdp_and_eido() {
1236        let sdp = b"v=0\r\no=- 1 1 IN IP4 10.0.0.1\r\ns=-\r\n\
1237            c=IN IP4 10.0.0.1\r\nt=0 0\r\nm=audio 8000 RTP/AVP 0\r\n";
1238        let eido = b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n\
1239            <eido:EmergencyCallData xmlns:eido=\"urn:nena:xml:ns:EmergencyCallData\">\r\n\
1240            <eido:IncidentId>INC-2026-001</eido:IncidentId>\r\n\
1241            </eido:EmergencyCallData>";
1242        let msg = make_multipart_invite(
1243            "ng911-boundary",
1244            &[
1245                ("application/sdp", sdp),
1246                ("application/emergencyCallData.eido+xml", eido),
1247            ],
1248        );
1249        let parsed = msg.parse().unwrap();
1250        let parts = parsed.body_parts().unwrap();
1251        assert_eq!(parts.len(), 2);
1252
1253        let sdp_part = parts
1254            .iter()
1255            .find(|p| p.content_type() == Some("application/sdp"));
1256        assert!(sdp_part.is_some());
1257        assert_eq!(sdp_part.unwrap().body, sdp);
1258
1259        let eido_part = parts
1260            .iter()
1261            .find(|p| p.content_type().is_some_and(|ct| ct.contains("eido")));
1262        assert!(eido_part.is_some());
1263        assert_eq!(eido_part.unwrap().body, eido);
1264    }
1265
1266    #[test]
1267    fn multipart_three_parts_sdp_pidf_eido() {
1268        let sdp = b"v=0\r\ns=-\r\n";
1269        let pidf = b"<presence/>";
1270        let eido = b"<EmergencyCallData/>";
1271        let msg = make_multipart_invite(
1272            "tri-part",
1273            &[
1274                ("application/sdp", sdp),
1275                ("application/pidf+xml", pidf),
1276                ("application/emergencyCallData.eido+xml", eido),
1277            ],
1278        );
1279        let parsed = msg.parse().unwrap();
1280        let parts = parsed.body_parts().unwrap();
1281        assert_eq!(parts.len(), 3);
1282        assert_eq!(parts[0].content_type(), Some("application/sdp"));
1283        assert_eq!(parts[1].content_type(), Some("application/pidf+xml"));
1284        assert_eq!(
1285            parts[2].content_type(),
1286            Some("application/emergencyCallData.eido+xml")
1287        );
1288    }
1289
1290    #[test]
1291    fn multipart_quoted_boundary() {
1292        let sdp = b"v=0\r\n";
1293        let pidf = b"<presence/>";
1294
1295        let mut body = Vec::new();
1296        body.extend_from_slice(b"--quoted-boundary\r\n");
1297        body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n");
1298        body.extend_from_slice(sdp);
1299        body.extend_from_slice(b"\r\n--quoted-boundary\r\n");
1300        body.extend_from_slice(b"Content-Type: application/pidf+xml\r\n\r\n");
1301        body.extend_from_slice(pidf);
1302        body.extend_from_slice(b"\r\n--quoted-boundary--");
1303
1304        let mut content = Vec::new();
1305        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1306        content.extend_from_slice(b"Call-ID: quoted-bnd@host\r\n");
1307        content
1308            .extend_from_slice(b"Content-Type: multipart/mixed; boundary=\"quoted-boundary\"\r\n");
1309        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1310        content.extend_from_slice(b"\r\n");
1311        content.extend_from_slice(&body);
1312
1313        let msg = make_sip_message(&content);
1314        let parsed = msg.parse().unwrap();
1315
1316        assert_eq!(parsed.multipart_boundary(), Some("quoted-boundary"));
1317        let parts = parsed.body_parts().unwrap();
1318        assert_eq!(parts.len(), 2);
1319        assert_eq!(parts[0].body, sdp);
1320        assert_eq!(parts[1].body, pidf);
1321    }
1322
1323    #[test]
1324    fn multipart_with_preamble() {
1325        let sdp = b"v=0\r\n";
1326
1327        let mut body = Vec::new();
1328        body.extend_from_slice(b"This is the preamble. It should be ignored.\r\n");
1329        body.extend_from_slice(b"--boundary-pre\r\n");
1330        body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n");
1331        body.extend_from_slice(sdp);
1332        body.extend_from_slice(b"\r\n--boundary-pre--");
1333
1334        let mut content = Vec::new();
1335        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1336        content.extend_from_slice(b"Call-ID: preamble@host\r\n");
1337        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=boundary-pre\r\n");
1338        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1339        content.extend_from_slice(b"\r\n");
1340        content.extend_from_slice(&body);
1341
1342        let msg = make_sip_message(&content);
1343        let parsed = msg.parse().unwrap();
1344        let parts = parsed.body_parts().unwrap();
1345        assert_eq!(parts.len(), 1);
1346        assert_eq!(parts[0].body, sdp);
1347    }
1348
1349    #[test]
1350    fn multipart_part_with_multiple_headers() {
1351        let eido = b"<EmergencyCallData/>";
1352
1353        let mut body = Vec::new();
1354        body.extend_from_slice(b"--hdr-boundary\r\n");
1355        body.extend_from_slice(b"Content-Type: application/emergencyCallData.eido+xml\r\n");
1356        body.extend_from_slice(b"Content-ID: <eido@example.com>\r\n");
1357        body.extend_from_slice(b"Content-Disposition: by-reference\r\n");
1358        body.extend_from_slice(b"\r\n");
1359        body.extend_from_slice(eido);
1360        body.extend_from_slice(b"\r\n--hdr-boundary--");
1361
1362        let mut content = Vec::new();
1363        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1364        content.extend_from_slice(b"Call-ID: multi-hdr-part@host\r\n");
1365        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=hdr-boundary\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        let parts = parsed.body_parts().unwrap();
1373        assert_eq!(parts.len(), 1);
1374        assert_eq!(
1375            parts[0].content_type(),
1376            Some("application/emergencyCallData.eido+xml")
1377        );
1378        assert_eq!(parts[0].content_id(), Some("<eido@example.com>"));
1379        assert_eq!(parts[0].content_disposition(), Some("by-reference"));
1380        assert_eq!(parts[0].body, eido);
1381    }
1382
1383    #[test]
1384    fn not_multipart_returns_none() {
1385        let content = b"INVITE sip:host SIP/2.0\r\n\
1386            Call-ID: not-multi@host\r\n\
1387            Content-Type: application/sdp\r\n\
1388            Content-Length: 4\r\n\
1389            \r\n\
1390            v=0\n";
1391        let msg = make_sip_message(content);
1392        let parsed = msg.parse().unwrap();
1393
1394        assert!(!parsed.is_multipart());
1395        assert!(parsed.multipart_boundary().is_none());
1396        assert!(parsed.body_parts().is_none());
1397    }
1398
1399    #[test]
1400    fn multipart_empty_body() {
1401        let mut content = Vec::new();
1402        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1403        content.extend_from_slice(b"Call-ID: empty-multi@host\r\n");
1404        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=empty\r\n");
1405        content.extend_from_slice(b"Content-Length: 9\r\n");
1406        content.extend_from_slice(b"\r\n");
1407        content.extend_from_slice(b"--empty--");
1408
1409        let msg = make_sip_message(&content);
1410        let parsed = msg.parse().unwrap();
1411        assert!(parsed.body_parts().is_none());
1412
1413        let parts = parsed.body_as_parts();
1414        assert_eq!(parts.len(), 1);
1415        assert_eq!(parts[0].body, b"--empty--");
1416    }
1417
1418    #[test]
1419    fn extract_boundary_unquoted() {
1420        assert_eq!(
1421            extract_boundary("multipart/mixed;boundary=foo-bar"),
1422            Some("foo-bar")
1423        );
1424    }
1425
1426    #[test]
1427    fn extract_boundary_quoted() {
1428        assert_eq!(
1429            extract_boundary("multipart/mixed; boundary=\"foo-bar\""),
1430            Some("foo-bar")
1431        );
1432    }
1433
1434    #[test]
1435    fn extract_boundary_with_extra_params() {
1436        assert_eq!(
1437            extract_boundary("multipart/mixed; boundary=foo;charset=utf-8"),
1438            Some("foo")
1439        );
1440    }
1441
1442    #[test]
1443    fn extract_boundary_case_insensitive() {
1444        assert_eq!(
1445            extract_boundary("multipart/mixed;BOUNDARY=abc"),
1446            Some("abc")
1447        );
1448    }
1449
1450    #[test]
1451    fn extract_boundary_missing() {
1452        assert_eq!(extract_boundary("multipart/mixed"), None);
1453    }
1454
1455    #[test]
1456    fn multipart_part_no_headers() {
1457        let raw_body = b"just raw content";
1458
1459        let mut body = Vec::new();
1460        body.extend_from_slice(b"--no-hdr\r\n");
1461        body.extend_from_slice(raw_body);
1462        body.extend_from_slice(b"\r\n--no-hdr--");
1463
1464        let mut content = Vec::new();
1465        content.extend_from_slice(b"MESSAGE sip:host SIP/2.0\r\n");
1466        content.extend_from_slice(b"Call-ID: no-hdr-part@host\r\n");
1467        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=no-hdr\r\n");
1468        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1469        content.extend_from_slice(b"\r\n");
1470        content.extend_from_slice(&body);
1471
1472        let msg = make_sip_message(&content);
1473        let parsed = msg.parse().unwrap();
1474        let parts = parsed.body_parts().unwrap();
1475        assert_eq!(parts.len(), 1);
1476        assert!(parts[0].content_type().is_none());
1477        assert!(parts[0].headers.is_empty());
1478        assert_eq!(parts[0].body, raw_body);
1479    }
1480
1481    // --- media_type tests ---
1482
1483    fn make_with_content_type(header: &str) -> ParsedSipMessage {
1484        let content = format!("INVITE sip:host SIP/2.0\r\n{header}\r\nCall-ID: mt@host\r\n\r\n");
1485        make_sip_message(content.as_bytes()).parse().unwrap()
1486    }
1487
1488    #[test]
1489    fn media_type_strips_parameters() {
1490        let parsed = make_with_content_type("Content-Type: multipart/mixed;boundary=abc");
1491        assert_eq!(parsed.media_type().as_deref(), Some("multipart/mixed"));
1492    }
1493
1494    #[test]
1495    fn media_type_lowercases() {
1496        let parsed = make_with_content_type("Content-Type: Application/SDP");
1497        assert_eq!(parsed.media_type().as_deref(), Some("application/sdp"));
1498    }
1499
1500    #[test]
1501    fn media_type_trims_whitespace() {
1502        let parsed = make_with_content_type("Content-Type: application/sdp ; charset=utf-8");
1503        assert_eq!(parsed.media_type().as_deref(), Some("application/sdp"));
1504    }
1505
1506    #[test]
1507    fn media_type_compact_form() {
1508        let parsed = make_with_content_type("c: application/pidf+xml");
1509        assert_eq!(parsed.media_type().as_deref(), Some("application/pidf+xml"));
1510    }
1511
1512    #[test]
1513    fn media_type_absent() {
1514        let parsed = make_with_content_type("Subject: none");
1515        assert_eq!(parsed.media_type(), None);
1516    }
1517
1518    #[test]
1519    fn media_type_on_mime_part() {
1520        let msg = make_multipart_invite(
1521            "mt-boundary",
1522            &[("Application/SDP; charset=utf-8", b"v=0\r\n")],
1523        );
1524        let parsed = msg.parse().unwrap();
1525        let parts = parsed.body_parts().unwrap();
1526        assert_eq!(parts[0].media_type().as_deref(), Some("application/sdp"));
1527    }
1528
1529    // --- body_as_parts tests ---
1530
1531    #[test]
1532    fn body_as_parts_wraps_non_multipart() {
1533        let body = b"v=0\r\no=- 1 1 IN IP4 10.0.0.1\r\n";
1534        let mut content = Vec::new();
1535        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1536        content.extend_from_slice(b"Call-ID: abp-plain@host\r\n");
1537        content.extend_from_slice(b"Content-Type: application/sdp\r\n");
1538        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1539        content.extend_from_slice(b"\r\n");
1540        content.extend_from_slice(body);
1541
1542        let parsed = make_sip_message(&content).parse().unwrap();
1543        let parts = parsed.body_as_parts();
1544        assert_eq!(parts.len(), 1);
1545        assert_eq!(parts[0].media_type().as_deref(), Some("application/sdp"));
1546        assert_eq!(parts[0].body, body);
1547    }
1548
1549    #[test]
1550    fn body_as_parts_matches_body_parts_for_multipart() {
1551        let msg = make_multipart_invite(
1552            "abp-multi",
1553            &[
1554                ("application/sdp", b"v=0\r\n"),
1555                ("application/pidf+xml", b"<presence/>"),
1556            ],
1557        );
1558        let parsed = msg.parse().unwrap();
1559        let all = parsed.body_as_parts();
1560        let split = parsed.body_parts().unwrap();
1561        assert_eq!(all.len(), split.len());
1562        for (a, b) in all.iter().zip(split.iter()) {
1563            assert_eq!(a.headers, b.headers);
1564            assert_eq!(a.body, b.body);
1565        }
1566    }
1567
1568    #[test]
1569    fn body_as_parts_empty_body() {
1570        let content = b"OPTIONS sip:host SIP/2.0\r\n\
1571            Call-ID: abp-empty@host\r\n\
1572            Content-Length: 0\r\n\
1573            \r\n";
1574        let parsed = make_sip_message(content).parse().unwrap();
1575        assert!(parsed.body_as_parts().is_empty());
1576    }
1577
1578    #[test]
1579    fn body_as_parts_multipart_without_boundary() {
1580        let body = b"--something\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n";
1581        let mut content = Vec::new();
1582        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1583        content.extend_from_slice(b"Call-ID: abp-nobnd@host\r\n");
1584        content.extend_from_slice(b"Content-Type: multipart/mixed\r\n");
1585        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1586        content.extend_from_slice(b"\r\n");
1587        content.extend_from_slice(body);
1588
1589        let parsed = make_sip_message(&content).parse().unwrap();
1590        let parts = parsed.body_as_parts();
1591        assert_eq!(
1592            parts.len(),
1593            1,
1594            "unsplittable multipart must surface as one part"
1595        );
1596        assert_eq!(parts[0].media_type().as_deref(), Some("multipart/mixed"));
1597        assert_eq!(parts[0].body, body);
1598    }
1599
1600    #[test]
1601    fn body_as_parts_boundary_not_in_body() {
1602        let body = b"--other\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n--other--";
1603        let parsed = parsed_with_headers(
1604            "abp-mismatch",
1605            &["Content-Type: multipart/mixed;boundary=declared"],
1606            body,
1607        );
1608        let parts = parsed.body_as_parts();
1609        assert_eq!(
1610            parts.len(),
1611            1,
1612            "a boundary absent from the body is not a split"
1613        );
1614        assert_eq!(parts[0].media_type().as_deref(), Some("multipart/mixed"));
1615        assert_eq!(parts[0].body, body);
1616        assert!(parsed.body_parts().is_none());
1617    }
1618
1619    #[test]
1620    fn body_as_parts_truncated_multipart() {
1621        let body = b"--trunc\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n";
1622        let parsed = parsed_with_headers(
1623            "abp-trunc",
1624            &["Content-Type: multipart/mixed;boundary=trunc"],
1625            body,
1626        );
1627        let parts = parsed.body_as_parts();
1628        assert_eq!(
1629            parts.len(),
1630            1,
1631            "a body cut off before the closing delimiter must not vanish"
1632        );
1633        assert_eq!(parts[0].content_type(), Some("application/sdp"));
1634        assert_eq!(parts[0].body, b"v=0\r\n");
1635        assert!(parsed.body_parts().is_some());
1636    }
1637
1638    #[test]
1639    fn multipart_truncated_trailing_part() {
1640        let body = b"--b\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n\
1641            --b\r\nContent-Type: application/pidf+xml\r\n\r\n<presence";
1642        let parts = parse_multipart_body(body, "b");
1643        assert_eq!(parts.len(), 2);
1644        assert_eq!(parts[0].body, b"v=0");
1645        assert_eq!(parts[1].content_type(), Some("application/pidf+xml"));
1646        assert_eq!(parts[1].body, b"<presence");
1647    }
1648
1649    #[test]
1650    fn multipart_truncated_inside_close_delimiter() {
1651        let body = b"--b\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n--b";
1652        let parts = parse_multipart_body(body, "b");
1653        assert_eq!(parts.len(), 1);
1654        assert_eq!(parts[0].body, b"v=0");
1655    }
1656
1657    #[test]
1658    fn multipart_preamble_substring_no_false_part() {
1659        let body = b"preamble mentions --b in passing\r\n\
1660            --b\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n--b--";
1661        let parts = parse_multipart_body(body, "b");
1662        assert_eq!(parts.len(), 1);
1663        assert_eq!(parts[0].content_type(), Some("application/sdp"));
1664        assert_eq!(parts[0].body, b"v=0");
1665    }
1666
1667    #[test]
1668    fn multipart_boundary_prefix_collision() {
1669        let body =
1670            b"--b\r\nContent-Type: text/plain\r\n\r\nouter\r\n--b2\r\ninner text\r\n--b2--\r\n--b--";
1671        let parts = parse_multipart_body(body, "b");
1672        assert_eq!(parts.len(), 1);
1673        assert_eq!(parts[0].body, b"outer\r\n--b2\r\ninner text\r\n--b2--");
1674    }
1675
1676    #[test]
1677    fn multipart_delimiter_transport_padding() {
1678        let body = b"--b \t\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n\
1679            --b  \r\nContent-Type: application/pidf+xml\r\n\r\n<presence/>\r\n--b--";
1680        let parts = parse_multipart_body(body, "b");
1681        assert_eq!(parts.len(), 2);
1682        assert_eq!(parts[0].body, b"v=0");
1683        assert_eq!(parts[1].body, b"<presence/>");
1684    }
1685
1686    #[test]
1687    fn multipart_no_preamble_delimiter_at_offset_zero() {
1688        let body = b"--b\r\nContent-Type: application/sdp\r\n\r\nv=0\r\n--b--";
1689        let parts = parse_multipart_body(body, "b");
1690        assert_eq!(parts.len(), 1);
1691        assert_eq!(parts[0].body, b"v=0");
1692    }
1693
1694    // --- content headers copied onto the synthetic part ---
1695
1696    fn parsed_with_headers(call_id: &str, headers: &[&str], body: &[u8]) -> ParsedSipMessage {
1697        let mut content = Vec::new();
1698        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1699        content.extend_from_slice(format!("Call-ID: {call_id}@host\r\n").as_bytes());
1700        for header in headers {
1701            content.extend_from_slice(header.as_bytes());
1702            content.extend_from_slice(b"\r\n");
1703        }
1704        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1705        content.extend_from_slice(b"\r\n");
1706        content.extend_from_slice(body);
1707        make_sip_message(&content).parse().unwrap()
1708    }
1709
1710    fn part_header<'a>(part: &'a MimePart, name: &str) -> Option<&'a str> {
1711        part.headers
1712            .iter()
1713            .find(|(k, _)| k.eq_ignore_ascii_case(name))
1714            .map(|(_, v)| v.as_str())
1715    }
1716
1717    #[test]
1718    fn synthetic_part_carries_transfer_encoding() {
1719        let parsed = parsed_with_headers(
1720            "abp-cte",
1721            &[
1722                "Content-Type: application/pidf+xml",
1723                "Content-Transfer-Encoding: base64",
1724            ],
1725            b"PD94bWwgdmVyc2lvbj0iMS4wIj8+",
1726        );
1727        let parts = parsed.body_as_parts();
1728        assert_eq!(
1729            parts[0].content_transfer_encoding(),
1730            Some("base64"),
1731            "a per-part consumer must see the encoding the message declared"
1732        );
1733    }
1734
1735    #[test]
1736    fn synthetic_part_carries_disposition_and_id() {
1737        let parsed = parsed_with_headers(
1738            "abp-cd",
1739            &[
1740                "Content-Type: application/sdp",
1741                "Content-Disposition: session",
1742                "Content-ID: <sdp@host>",
1743            ],
1744            b"v=0\r\n",
1745        );
1746        let parts = parsed.body_as_parts();
1747        assert_eq!(parts[0].content_disposition(), Some("session"));
1748        assert_eq!(parts[0].content_id(), Some("<sdp@host>"));
1749    }
1750
1751    #[test]
1752    fn synthetic_part_canonicalizes_compact_content_encoding() {
1753        let parsed = parsed_with_headers(
1754            "abp-compact-e",
1755            &["Content-Type: application/sdp", "e: gzip"],
1756            b"v=0\r\n",
1757        );
1758        let parts = parsed.body_as_parts();
1759        assert_eq!(
1760            part_header(&parts[0], "Content-Encoding"),
1761            Some("gzip"),
1762            "compact form must arrive under the canonical name"
1763        );
1764    }
1765
1766    #[test]
1767    fn synthetic_part_canonicalizes_compact_content_type() {
1768        let parsed = parsed_with_headers("abp-compact-c", &["c: application/sdp"], b"v=0\r\n");
1769        let parts = parsed.body_as_parts();
1770        assert_eq!(parts[0].media_type().as_deref(), Some("application/sdp"));
1771        assert_eq!(
1772            parts[0]
1773                .headers
1774                .iter()
1775                .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
1776                .count(),
1777            1
1778        );
1779    }
1780
1781    #[test]
1782    fn synthetic_part_content_type_matches_the_message() {
1783        let parsed = parsed_with_headers(
1784            "abp-both-ct",
1785            &["c: text/plain", "Content-Type: application/sdp"],
1786            b"v=0\r\n",
1787        );
1788        let parts = parsed.body_as_parts();
1789        assert_eq!(parts[0].content_type(), parsed.content_type());
1790        assert_eq!(
1791            parts[0]
1792                .headers
1793                .iter()
1794                .filter(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
1795                .count(),
1796            1
1797        );
1798    }
1799
1800    #[test]
1801    fn synthetic_part_omits_content_length() {
1802        let parsed = parsed_with_headers("abp-len", &["Content-Type: application/sdp"], b"v=0\r\n");
1803        let parts = parsed.body_as_parts();
1804        assert_eq!(part_header(&parts[0], "Content-Length"), None);
1805        assert_eq!(part_header(&parts[0], "l"), None);
1806    }
1807
1808    #[test]
1809    fn synthetic_part_copies_only_content_headers() {
1810        let parsed = parsed_with_headers(
1811            "abp-other",
1812            &[
1813                "Content-Type: application/sdp",
1814                "Subject: not a body header",
1815            ],
1816            b"v=0\r\n",
1817        );
1818        let parts = parsed.body_as_parts();
1819        assert_eq!(part_header(&parts[0], "Subject"), None);
1820        assert_eq!(part_header(&parts[0], "Call-ID"), None);
1821    }
1822
1823    #[test]
1824    fn wire_parts_get_no_fabricated_headers() {
1825        let msg = make_multipart_invite("wire-hdrs", &[("application/sdp", b"v=0\r\n")]);
1826        let mut content = msg.content.clone();
1827        let insert_at = CRLF.find(&content).unwrap() + 2;
1828        content.splice(
1829            insert_at..insert_at,
1830            b"Content-Transfer-Encoding: base64\r\n".iter().copied(),
1831        );
1832        let parsed = make_sip_message(&content).parse().unwrap();
1833        let parts = parsed.body_as_parts();
1834        assert_eq!(parts.len(), 1);
1835        assert_eq!(
1836            parts[0].content_transfer_encoding(),
1837            None,
1838            "a wire part carries what the sender wrote, nothing copied down"
1839        );
1840    }
1841
1842    // --- nested multipart (caller-driven descent) ---
1843
1844    /// INVITE whose body is a multipart carrying SDP beside a nested
1845    /// multipart/mixed that holds the PIDF-LO.
1846    fn make_nested_multipart_invite() -> SipMessage {
1847        let mut inner = Vec::new();
1848        inner.extend_from_slice(b"--inner\r\n");
1849        inner.extend_from_slice(b"Content-Type: application/pidf+xml\r\n\r\n");
1850        inner.extend_from_slice(b"<presence/>");
1851        inner.extend_from_slice(b"\r\n--inner--");
1852
1853        let mut body = Vec::new();
1854        body.extend_from_slice(b"--outer\r\n");
1855        body.extend_from_slice(b"Content-Type: application/sdp\r\n\r\n");
1856        body.extend_from_slice(b"v=0\r\n");
1857        body.extend_from_slice(b"\r\n--outer\r\n");
1858        body.extend_from_slice(b"Content-Type: multipart/mixed;boundary=inner\r\n\r\n");
1859        body.extend_from_slice(&inner);
1860        body.extend_from_slice(b"\r\n--outer--");
1861
1862        let mut content = Vec::new();
1863        content.extend_from_slice(b"INVITE sip:host SIP/2.0\r\n");
1864        content.extend_from_slice(b"Call-ID: nested@host\r\n");
1865        content.extend_from_slice(b"Content-Type: multipart/mixed;boundary=outer\r\n");
1866        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
1867        content.extend_from_slice(b"\r\n");
1868        content.extend_from_slice(&body);
1869
1870        make_sip_message(&content)
1871    }
1872
1873    #[test]
1874    fn nested_multipart_not_flattened() {
1875        let parsed = make_nested_multipart_invite().parse().unwrap();
1876        let parts = parsed.body_as_parts();
1877        assert_eq!(parts.len(), 2);
1878        assert_eq!(parts[0].media_type().as_deref(), Some("application/sdp"));
1879        assert_eq!(parts[1].media_type().as_deref(), Some("multipart/mixed"));
1880    }
1881
1882    #[test]
1883    fn nested_multipart_explicit_descent() {
1884        let parsed = make_nested_multipart_invite().parse().unwrap();
1885        let outer = parsed.body_as_parts();
1886        let nested = &outer[1];
1887
1888        assert!(nested.is_multipart());
1889        assert_eq!(nested.multipart_boundary(), Some("inner"));
1890
1891        let inner = nested.body_parts().unwrap();
1892        assert_eq!(inner.len(), 1);
1893        assert_eq!(
1894            inner[0].media_type().as_deref(),
1895            Some("application/pidf+xml")
1896        );
1897        assert_eq!(inner[0].body, b"<presence/>");
1898    }
1899
1900    #[test]
1901    fn non_multipart_part_has_no_children() {
1902        let parsed = make_nested_multipart_invite().parse().unwrap();
1903        let sdp_part = &parsed.body_as_parts()[0];
1904        assert!(!sdp_part.is_multipart());
1905        assert!(sdp_part.multipart_boundary().is_none());
1906        assert!(sdp_part.body_parts().is_none());
1907    }
1908
1909    // --- sipfrag tests ---
1910
1911    #[test]
1912    fn sipfrag_status_line_with_crlf() {
1913        let frag = parse_sipfrag(b"SIP/2.0 200 OK\r\n").unwrap();
1914        assert_eq!(
1915            frag.message_type,
1916            Some(SipMessageType::Response {
1917                code: 200,
1918                reason: "OK".into()
1919            })
1920        );
1921        assert!(frag.headers.is_empty());
1922        assert!(frag.body.is_empty());
1923    }
1924
1925    #[test]
1926    fn sipfrag_status_line_without_trailing_crlf() {
1927        let frag = parse_sipfrag(b"SIP/2.0 183 Session Progress").unwrap();
1928        assert_eq!(
1929            frag.message_type,
1930            Some(SipMessageType::Response {
1931                code: 183,
1932                reason: "Session Progress".into()
1933            })
1934        );
1935    }
1936
1937    #[test]
1938    fn sipfrag_headers_only() {
1939        let frag = parse_sipfrag(b"To: <sip:user@host>\r\nCSeq: 1 INVITE\r\n").unwrap();
1940        assert_eq!(frag.message_type, None);
1941        assert_eq!(frag.headers.len(), 2);
1942        assert_eq!(frag.headers[0].0, "To");
1943        assert_eq!(frag.headers[0].1, "<sip:user@host>");
1944        assert_eq!(frag.headers[1].1, "1 INVITE");
1945    }
1946
1947    #[test]
1948    fn sipfrag_header_value_is_case_insensitive() {
1949        let frag = parse_sipfrag(b"To: <sip:user@host>\r\nCSeq: 1 INVITE\r\n").unwrap();
1950        assert_eq!(frag.header_value("cseq"), Some("1 INVITE"));
1951        assert_eq!(frag.header_value("To"), Some("<sip:user@host>"));
1952        assert_eq!(frag.header_value("Call-ID"), None);
1953    }
1954
1955    #[test]
1956    fn sipfrag_headers_only_without_trailing_crlf() {
1957        let frag = parse_sipfrag(b"To: <sip:user@host>").unwrap();
1958        assert_eq!(frag.message_type, None);
1959        assert_eq!(frag.headers.len(), 1);
1960        assert_eq!(frag.headers[0].1, "<sip:user@host>");
1961    }
1962
1963    #[test]
1964    fn sipfrag_start_line_headers_and_body() {
1965        let data = b"SIP/2.0 200 OK\r\n\
1966            Content-Type: application/sdp\r\n\
1967            \r\n\
1968            v=0\r\n";
1969        let frag = parse_sipfrag(data).unwrap();
1970        assert_eq!(
1971            frag.message_type,
1972            Some(SipMessageType::Response {
1973                code: 200,
1974                reason: "OK".into()
1975            })
1976        );
1977        assert_eq!(frag.headers.len(), 1);
1978        assert_eq!(frag.body, b"v=0\r\n");
1979    }
1980
1981    #[test]
1982    fn sipfrag_request_start_line() {
1983        let frag = parse_sipfrag(b"INVITE sip:user@host SIP/2.0\r\nCSeq: 2 INVITE\r\n").unwrap();
1984        assert_eq!(
1985            frag.message_type,
1986            Some(SipMessageType::Request {
1987                method: "INVITE".into(),
1988                uri: "sip:user@host".into()
1989            })
1990        );
1991        assert_eq!(frag.headers.len(), 1);
1992    }
1993
1994    #[test]
1995    fn sipfrag_garbage_is_error() {
1996        assert!(parse_sipfrag(b"just some text without a colon").is_err());
1997        assert!(parse_sipfrag(b"").is_err());
1998    }
1999
2000    #[test]
2001    fn sipfrag_content_type_and_media_type() {
2002        let frag = parse_sipfrag(
2003            b"SIP/2.0 200 OK\r\nContent-Type: Application/SDP; charset=utf-8\r\n\r\nv=0",
2004        )
2005        .unwrap();
2006        assert_eq!(frag.content_type(), Some("Application/SDP; charset=utf-8"));
2007        assert_eq!(frag.media_type().as_deref(), Some("application/sdp"));
2008
2009        let compact = parse_sipfrag(b"SIP/2.0 200 OK\r\nc: text/plain\r\n\r\nhi").unwrap();
2010        assert_eq!(compact.content_type(), Some("text/plain"));
2011    }
2012
2013    #[test]
2014    fn sipfrag_malformed_start_line_with_colon_is_error() {
2015        assert!(parse_sipfrag(b"INVITE sip:host SIP/1.0\r\n").is_err());
2016    }
2017
2018    #[test]
2019    fn sipfrag_lf_only_status_line() {
2020        let frag = parse_sipfrag(b"SIP/2.0 200 OK\n").unwrap();
2021        assert!(matches!(
2022            frag.message_type,
2023            Some(SipMessageType::Response { code: 200, ref reason }) if reason == "OK"
2024        ));
2025        assert!(frag.headers.is_empty());
2026        assert!(frag.body.is_empty());
2027    }
2028
2029    #[test]
2030    fn sipfrag_from_mime_part() {
2031        let body = b"SIP/2.0 100 Trying\r\n";
2032        let msg = make_multipart_invite("frag-boundary", &[("message/sipfrag", body)]);
2033        let parsed = msg.parse().unwrap();
2034        let parts = parsed.body_as_parts();
2035        assert_eq!(parts[0].media_type().as_deref(), Some("message/sipfrag"));
2036
2037        let frag = parts[0].parse_sipfrag().unwrap();
2038        assert_eq!(
2039            frag.message_type,
2040            Some(SipMessageType::Response {
2041                code: 100,
2042                reason: "Trying".into()
2043            })
2044        );
2045    }
2046
2047    // --- is_json_content_type tests ---
2048
2049    #[test]
2050    fn is_json_content_type_application_json() {
2051        assert!(is_json_content_type("application/json"));
2052    }
2053
2054    #[test]
2055    fn is_json_content_type_plus_json() {
2056        assert!(is_json_content_type(
2057            "application/emergencyCallData.AbandonedCall+json"
2058        ));
2059    }
2060
2061    #[test]
2062    fn is_json_content_type_with_params() {
2063        assert!(is_json_content_type("application/json; charset=utf-8"));
2064    }
2065
2066    #[test]
2067    fn is_json_content_type_case_insensitive() {
2068        assert!(is_json_content_type("Application/JSON"));
2069    }
2070
2071    #[test]
2072    fn is_json_content_type_not_text_plain() {
2073        assert!(!is_json_content_type("text/plain"));
2074    }
2075
2076    #[test]
2077    fn is_json_content_type_not_multipart() {
2078        assert!(!is_json_content_type("multipart/mixed;boundary=foo"));
2079    }
2080
2081    #[test]
2082    fn is_json_content_type_not_sdp() {
2083        assert!(!is_json_content_type("application/sdp"));
2084    }
2085
2086    // --- unescape_json_body tests ---
2087
2088    #[test]
2089    fn unescape_json_basic_escapes() {
2090        let input = br#"{"key":"line1\r\nline2\ttab\"\\"}"#;
2091        let result = unescape_json_body(input);
2092        assert!(
2093            result.contains("line1\r\nline2\ttab\"\\"),
2094            "basic escapes not unescaped: {result:?}"
2095        );
2096    }
2097
2098    #[test]
2099    fn unescape_json_slash_and_control() {
2100        let input = br#"{"a":"\/path","b":"\b\f"}"#;
2101        let result = unescape_json_body(input);
2102        assert!(result.contains("/path"), "\\/ should become /");
2103        assert!(result.contains('\x08'), "\\b should become backspace");
2104        assert!(result.contains('\x0C'), "\\f should become form feed");
2105    }
2106
2107    #[test]
2108    fn unescape_json_unicode_basic() {
2109        // \u0041 = 'A'
2110        let input = br#"{"x":"\u0041"}"#;
2111        let result = unescape_json_body(input);
2112        assert!(
2113            result.contains('A'),
2114            "\\u0041 should become 'A': {result:?}"
2115        );
2116    }
2117
2118    #[test]
2119    fn unescape_json_unicode_surrogate_pair() {
2120        // U+1F600 (grinning face) = \uD83D\uDE00
2121        let input = br#"{"emoji":"\uD83D\uDE00"}"#;
2122        let result = unescape_json_body(input);
2123        assert!(
2124            result.contains('\u{1F600}'),
2125            "surrogate pair should produce U+1F600: {result:?}"
2126        );
2127    }
2128
2129    #[test]
2130    fn unescape_json_passthrough_non_escape() {
2131        let input = b"no escapes here";
2132        let result = unescape_json_body(input);
2133        assert_eq!(result, "no escapes here");
2134    }
2135
2136    // --- json_field tests ---
2137
2138    #[test]
2139    fn json_field_extract_string() {
2140        let body = br#"{"event":"AbandonedCall","id":"123"}"#;
2141        let mut content = Vec::new();
2142        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
2143        content.extend_from_slice(b"Call-ID: jf-test@host\r\n");
2144        content.extend_from_slice(b"Content-Type: application/json\r\n");
2145        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
2146        content.extend_from_slice(b"\r\n");
2147        content.extend_from_slice(body);
2148
2149        let msg = make_sip_message(&content);
2150        let parsed = msg.parse().unwrap();
2151
2152        assert_eq!(
2153            parsed.json_field("event"),
2154            Some("AbandonedCall".to_string())
2155        );
2156        assert_eq!(parsed.json_field("id"), Some("123".to_string()));
2157    }
2158
2159    #[test]
2160    fn json_field_missing_key() {
2161        let body = br#"{"event":"AbandonedCall"}"#;
2162        let mut content = Vec::new();
2163        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
2164        content.extend_from_slice(b"Call-ID: jf-miss@host\r\n");
2165        content.extend_from_slice(b"Content-Type: application/json\r\n");
2166        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
2167        content.extend_from_slice(b"\r\n");
2168        content.extend_from_slice(body);
2169
2170        let msg = make_sip_message(&content);
2171        let parsed = msg.parse().unwrap();
2172
2173        assert_eq!(parsed.json_field("nonexistent"), None);
2174    }
2175
2176    #[test]
2177    fn json_field_non_string_value() {
2178        let body = br#"{"count":42,"active":true}"#;
2179        let mut content = Vec::new();
2180        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
2181        content.extend_from_slice(b"Call-ID: jf-nonstr@host\r\n");
2182        content.extend_from_slice(b"Content-Type: application/json\r\n");
2183        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
2184        content.extend_from_slice(b"\r\n");
2185        content.extend_from_slice(body);
2186
2187        let msg = make_sip_message(&content);
2188        let parsed = msg.parse().unwrap();
2189
2190        assert_eq!(parsed.json_field("count"), None);
2191        assert_eq!(parsed.json_field("active"), None);
2192    }
2193
2194    #[test]
2195    fn json_field_non_json_content_type() {
2196        let body = br#"{"event":"AbandonedCall"}"#;
2197        let mut content = Vec::new();
2198        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
2199        content.extend_from_slice(b"Call-ID: jf-nonjson@host\r\n");
2200        content.extend_from_slice(b"Content-Type: text/plain\r\n");
2201        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
2202        content.extend_from_slice(b"\r\n");
2203        content.extend_from_slice(body);
2204
2205        let msg = make_sip_message(&content);
2206        let parsed = msg.parse().unwrap();
2207
2208        assert_eq!(parsed.json_field("event"), None);
2209    }
2210
2211    #[test]
2212    fn json_field_unescapes_value() {
2213        let body = br#"{"invite":"INVITE sip:host\r\nTo: <sip:host>\r\n"}"#;
2214        let mut content = Vec::new();
2215        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
2216        content.extend_from_slice(b"Call-ID: jf-unescape@host\r\n");
2217        content.extend_from_slice(b"Content-Type: application/json\r\n");
2218        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
2219        content.extend_from_slice(b"\r\n");
2220        content.extend_from_slice(body);
2221
2222        let msg = make_sip_message(&content);
2223        let parsed = msg.parse().unwrap();
2224
2225        let invite = parsed.json_field("invite").unwrap();
2226        assert!(
2227            invite.contains("INVITE sip:host\r\nTo: <sip:host>\r\n"),
2228            "json_field should return unescaped string: {invite:?}"
2229        );
2230    }
2231
2232    #[test]
2233    fn json_field_plus_json_content_type() {
2234        let body = br#"{"cancelTimestamp":"2025-12-14T05:35:03.269Z"}"#;
2235        let mut content = Vec::new();
2236        content.extend_from_slice(b"NOTIFY sip:host SIP/2.0\r\n");
2237        content.extend_from_slice(b"Call-ID: jf-plus@host\r\n");
2238        content.extend_from_slice(
2239            b"Content-Type: application/emergencyCallData.AbandonedCall+json\r\n",
2240        );
2241        content.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
2242        content.extend_from_slice(b"\r\n");
2243        content.extend_from_slice(body);
2244
2245        let msg = make_sip_message(&content);
2246        let parsed = msg.parse().unwrap();
2247
2248        assert_eq!(
2249            parsed.json_field("cancelTimestamp"),
2250            Some("2025-12-14T05:35:03.269Z".to_string())
2251        );
2252    }
2253
2254    #[test]
2255    fn whitespace_only_returns_transport_noise() {
2256        use crate::frame::ParseError;
2257
2258        for content in [b"\n".as_slice(), b"\r\n", b"\n\n\n", b" \t\r\n"] {
2259            let msg = SipMessage {
2260                direction: Direction::Recv,
2261                transport: Transport::Tls,
2262                address: "[10.0.0.1]:5061".into(),
2263                timestamp: Timestamp::TimeOnly {
2264                    hour: 0,
2265                    min: 0,
2266                    sec: 0,
2267                    usec: 0,
2268                },
2269                content: content.to_vec(),
2270                frame_count: 1,
2271            };
2272            let err = msg.parse().unwrap_err();
2273            assert!(
2274                matches!(err, ParseError::TransportNoise { .. }),
2275                "whitespace-only content {:?} should produce TransportNoise, got: {err}",
2276                content,
2277            );
2278        }
2279    }
2280}