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