Skip to main content

sip_header/
message.rs

1//! SIP message text extraction utilities.
2//!
3//! Convenience functions for extracting values from raw SIP message text:
4//!
5//! - [`extract_header`] — pull header values with case-insensitive matching,
6//!   header folding (RFC 3261 §7.3.1), and compact forms (RFC 3261 §7.3.3)
7//! - [`extract_request_uri`] — pull the Request-URI from the request line
8//!   (RFC 3261 §7.1)
9//!
10//! Gated behind the `message` feature (enabled by default).
11
12use crate::header::SipHeader;
13
14/// RFC 3261 §7.3.3 compact form equivalences.
15///
16/// Each pair is `(compact_char, canonical_name)`. Used by [`extract_header`]
17/// to match both compact and full header names transparently.
18const COMPACT_FORMS: &[(u8, &str)] = &[
19    (b'a', "Accept-Contact"),
20    (b'b', "Referred-By"),
21    (b'c', "Content-Type"),
22    (b'd', "Request-Disposition"),
23    (b'e', "Content-Encoding"),
24    (b'f', "From"),
25    (b'i', "Call-ID"),
26    (b'j', "Reject-Contact"),
27    (b'k', "Supported"),
28    (b'l', "Content-Length"),
29    (b'm', "Contact"),
30    (b'n', "Identity-Info"),
31    (b'o', "Event"),
32    (b'r', "Refer-To"),
33    (b's', "Subject"),
34    (b't', "To"),
35    (b'u', "Allow-Events"),
36    (b'v', "Via"),
37    (b'x', "Session-Expires"),
38    (b'y', "Identity"),
39];
40
41/// Check if a header name on the wire matches the target name, considering
42/// RFC 3261 §7.3.3 compact forms.
43fn matches_header_name(wire_name: &str, target: &str) -> bool {
44    if wire_name.eq_ignore_ascii_case(target) {
45        return true;
46    }
47    // Find the compact form equivalence for the target
48    let equiv = if target.len() == 1 {
49        let ch = target.as_bytes()[0].to_ascii_lowercase();
50        COMPACT_FORMS
51            .iter()
52            .find(|(c, _)| *c == ch)
53    } else {
54        COMPACT_FORMS
55            .iter()
56            .find(|(_, full)| full.eq_ignore_ascii_case(target))
57    };
58    if let Some(&(compact, full)) = equiv {
59        if wire_name.len() == 1 {
60            wire_name.as_bytes()[0].to_ascii_lowercase() == compact
61        } else {
62            wire_name.eq_ignore_ascii_case(full)
63        }
64    } else {
65        false
66    }
67}
68
69/// Extract a header value from a raw SIP message.
70///
71/// Scans all lines up to the blank line separating headers from the message
72/// body. Header name matching is case-insensitive (RFC 3261 §7.3.5) and
73/// recognizes compact header forms (RFC 3261 §7.3.3): searching for `"From"`
74/// also matches `f:`, and searching for `"f"` also matches `From:`.
75///
76/// Header folding (continuation lines beginning with SP or HTAB) is unfolded
77/// into a single logical value. When a header appears multiple times, values
78/// are concatenated with `, ` (RFC 3261 §7.3.1).
79///
80/// Returns `None` if no header with the given name is found.
81pub fn extract_header(message: &str, name: &str) -> Option<String> {
82    let mut values: Vec<String> = Vec::new();
83    let mut current_match = false;
84
85    for line in message.split('\n') {
86        let line = line
87            .strip_suffix('\r')
88            .unwrap_or(line);
89
90        if line.is_empty() {
91            break;
92        }
93
94        if line.starts_with(' ') || line.starts_with('\t') {
95            if current_match {
96                if let Some(last) = values.last_mut() {
97                    last.push(' ');
98                    last.push_str(line.trim_start());
99                }
100            }
101            continue;
102        }
103
104        current_match = false;
105
106        if let Some((hdr_name, hdr_value)) = line.split_once(':') {
107            let hdr_name = hdr_name.trim_end();
108            // RFC 3261: header names are tokens — no whitespace allowed.
109            // This rejects request/status lines like "INVITE sip:..." where
110            // the text before the first colon contains spaces.
111            if !hdr_name.contains(' ') && matches_header_name(hdr_name, name) {
112                current_match = true;
113                values.push(
114                    hdr_value
115                        .trim_start()
116                        .to_string(),
117                );
118            }
119        }
120    }
121
122    if values.is_empty() {
123        None
124    } else {
125        Some(values.join(", "))
126    }
127}
128
129/// Extract the Request-URI from a SIP request message.
130///
131/// Parses the first line as `Method SP Request-URI SP SIP-Version`
132/// (RFC 3261 Section 7.1) and returns the Request-URI.
133///
134/// Returns `None` for status lines (`SIP/2.0 200 OK`) or if the
135/// request line cannot be parsed.
136pub fn extract_request_uri(message: &str) -> Option<String> {
137    let first_line = message
138        .lines()
139        .next()?;
140    let first_line = first_line
141        .strip_suffix('\r')
142        .unwrap_or(first_line);
143    let mut parts = first_line.split_whitespace();
144    let method = parts.next()?;
145    if method.starts_with("SIP/") {
146        return None;
147    }
148    let uri = parts.next()?;
149    let version = parts.next()?;
150    if parts
151        .next()
152        .is_some()
153    {
154        return None;
155    }
156    if !version.starts_with("SIP/") {
157        return None;
158    }
159    Some(uri.to_string())
160}
161
162impl SipHeader {
163    /// Extract this header's value from a raw SIP message.
164    ///
165    /// Recognizes both the canonical header name and its compact form
166    /// (RFC 3261 §7.3.3). For example, `SipHeader::From.extract_from(msg)`
167    /// matches both `From:` and `f:` lines.
168    pub fn extract_from(&self, message: &str) -> Option<String> {
169        extract_header(message, self.as_str())
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    const SAMPLE_INVITE: &str = "\
178INVITE sip:bob@biloxi.example.com SIP/2.0\r\n\
179Via: SIP/2.0/UDP pc33.atlanta.example.com;branch=z9hG4bK776asdhds\r\n\
180Via: SIP/2.0/UDP bigbox3.site3.atlanta.example.com;branch=z9hG4bKnashds8\r\n\
181Max-Forwards: 70\r\n\
182To: Bob <sip:bob@biloxi.example.com>\r\n\
183From: Alice <sip:alice@atlanta.example.com>;tag=1928301774\r\n\
184Call-ID: a84b4c76e66710@pc33.atlanta.example.com\r\n\
185CSeq: 314159 INVITE\r\n\
186Contact: <sip:alice@pc33.atlanta.example.com>\r\n\
187Content-Type: application/sdp\r\n\
188Content-Length: 142\r\n\
189\r\n\
190v=0\r\n\
191o=alice 2890844526 2890844526 IN IP4 pc33.atlanta.example.com\r\n";
192
193    #[test]
194    fn basic_extraction() {
195        assert_eq!(
196            extract_header(SAMPLE_INVITE, "From"),
197            Some("Alice <sip:alice@atlanta.example.com>;tag=1928301774".into())
198        );
199        assert_eq!(
200            extract_header(SAMPLE_INVITE, "Call-ID"),
201            Some("a84b4c76e66710@pc33.atlanta.example.com".into())
202        );
203        assert_eq!(
204            extract_header(SAMPLE_INVITE, "CSeq"),
205            Some("314159 INVITE".into())
206        );
207    }
208
209    #[test]
210    fn case_insensitive_name() {
211        let expected = Some("Alice <sip:alice@atlanta.example.com>;tag=1928301774".into());
212        assert_eq!(extract_header(SAMPLE_INVITE, "from"), expected);
213        assert_eq!(extract_header(SAMPLE_INVITE, "FROM"), expected);
214        assert_eq!(extract_header(SAMPLE_INVITE, "From"), expected);
215    }
216
217    #[test]
218    fn header_folding() {
219        let msg = concat!(
220            "SIP/2.0 200 OK\r\n",
221            "Subject: I know you're there,\r\n",
222            " pick up the phone\r\n",
223            " and talk to me!\r\n",
224            "\r\n",
225        );
226        assert_eq!(
227            extract_header(msg, "Subject"),
228            Some("I know you're there, pick up the phone and talk to me!".into())
229        );
230    }
231
232    #[test]
233    fn multiple_occurrences_concatenated() {
234        assert_eq!(
235            extract_header(SAMPLE_INVITE, "Via"),
236            Some(
237                "SIP/2.0/UDP pc33.atlanta.example.com;branch=z9hG4bK776asdhds, \
238                 SIP/2.0/UDP bigbox3.site3.atlanta.example.com;branch=z9hG4bKnashds8"
239                    .into()
240            )
241        );
242    }
243
244    #[test]
245    fn stops_at_blank_line() {
246        // Body contains "o=" which looks like it could be a header line
247        assert_eq!(extract_header(SAMPLE_INVITE, "o"), None);
248    }
249
250    #[test]
251    fn bare_lf_line_endings() {
252        let msg = "SIP/2.0 200 OK\n\
253                   From: Alice <sip:alice@host>\n\
254                   To: Bob <sip:bob@host>\n\
255                   \n\
256                   body\n";
257        assert_eq!(
258            extract_header(msg, "From"),
259            Some("Alice <sip:alice@host>".into())
260        );
261    }
262
263    #[test]
264    fn missing_header_returns_none() {
265        assert_eq!(extract_header(SAMPLE_INVITE, "X-Custom"), None);
266    }
267
268    #[test]
269    fn empty_message() {
270        assert_eq!(extract_header("", "From"), None);
271    }
272
273    #[test]
274    fn request_line_not_matched() {
275        // The request line has a colon in the URI but should not match
276        assert_eq!(extract_header(SAMPLE_INVITE, "INVITE sip"), None);
277    }
278
279    #[test]
280    fn value_leading_whitespace_trimmed() {
281        let msg = "SIP/2.0 200 OK\r\n\
282                   From:   Alice <sip:alice@host>\r\n\
283                   \r\n";
284        assert_eq!(
285            extract_header(msg, "From"),
286            Some("Alice <sip:alice@host>".into())
287        );
288    }
289
290    #[test]
291    fn folding_on_multiple_occurrence() {
292        let msg = concat!(
293            "SIP/2.0 200 OK\r\n",
294            "Via: SIP/2.0/UDP first.example.com\r\n",
295            " ;branch=z9hG4bKaaa\r\n",
296            "Via: SIP/2.0/UDP second.example.com;branch=z9hG4bKbbb\r\n",
297            "\r\n",
298        );
299        assert_eq!(
300            extract_header(msg, "Via"),
301            Some(
302                "SIP/2.0/UDP first.example.com ;branch=z9hG4bKaaa, \
303                 SIP/2.0/UDP second.example.com;branch=z9hG4bKbbb"
304                    .into()
305            )
306        );
307    }
308
309    #[test]
310    fn empty_header_value() {
311        let msg = "SIP/2.0 200 OK\r\n\
312                   Subject:\r\n\
313                   From: Alice <sip:alice@host>\r\n\
314                   \r\n";
315        assert_eq!(extract_header(msg, "Subject"), Some(String::new()));
316    }
317
318    #[test]
319    fn tab_folding() {
320        let msg = concat!(
321            "SIP/2.0 200 OK\r\n",
322            "Subject: hello\r\n",
323            "\tworld\r\n",
324            "\r\n",
325        );
326        assert_eq!(extract_header(msg, "Subject"), Some("hello world".into()));
327    }
328
329    // -- Compact form tests (RFC 3261 §7.3.3) --
330
331    #[test]
332    fn compact_form_from() {
333        let msg = "SIP/2.0 200 OK\r\nf: Alice <sip:alice@host>\r\n\r\n";
334        assert_eq!(
335            extract_header(msg, "From"),
336            Some("Alice <sip:alice@host>".into())
337        );
338        assert_eq!(
339            extract_header(msg, "f"),
340            Some("Alice <sip:alice@host>".into())
341        );
342    }
343
344    #[test]
345    fn compact_form_via() {
346        let msg = "SIP/2.0 200 OK\r\nv: SIP/2.0/UDP host\r\n\r\n";
347        assert_eq!(extract_header(msg, "Via"), Some("SIP/2.0/UDP host".into()));
348        assert_eq!(extract_header(msg, "v"), Some("SIP/2.0/UDP host".into()));
349    }
350
351    #[test]
352    fn compact_form_mixed_with_full() {
353        let msg = concat!(
354            "SIP/2.0 200 OK\r\n",
355            "f: Alice <sip:alice@host>;tag=a\r\n",
356            "t: Bob <sip:bob@host>;tag=b\r\n",
357            "i: call-1@host\r\n",
358            "m: <sip:alice@192.0.2.1>\r\n",
359            "Content-Type: application/sdp\r\n",
360            "\r\n",
361        );
362        assert_eq!(
363            extract_header(msg, "From"),
364            Some("Alice <sip:alice@host>;tag=a".into())
365        );
366        assert_eq!(
367            extract_header(msg, "To"),
368            Some("Bob <sip:bob@host>;tag=b".into())
369        );
370        assert_eq!(extract_header(msg, "Call-ID"), Some("call-1@host".into()));
371        assert_eq!(
372            extract_header(msg, "Contact"),
373            Some("<sip:alice@192.0.2.1>".into())
374        );
375        assert_eq!(
376            extract_header(msg, "Content-Type"),
377            Some("application/sdp".into())
378        );
379        assert_eq!(extract_header(msg, "c"), Some("application/sdp".into()));
380    }
381
382    #[test]
383    fn compact_form_case_insensitive() {
384        let msg = "SIP/2.0 200 OK\r\nF: Alice <sip:alice@host>\r\n\r\n";
385        assert_eq!(
386            extract_header(msg, "From"),
387            Some("Alice <sip:alice@host>".into())
388        );
389    }
390
391    #[test]
392    fn compact_form_unknown_single_char() {
393        let msg = "SIP/2.0 200 OK\r\nz: something\r\n\r\n";
394        assert_eq!(extract_header(msg, "z"), Some("something".into()));
395        assert_eq!(extract_header(msg, "From"), None);
396    }
397
398    // -- Integration pipeline tests: extract_header → existing parsers --
399
400    const NG911_INVITE: &str = concat!(
401        "INVITE sip:urn:service:sos@bcf.example.com SIP/2.0\r\n",
402        "Via: SIP/2.0/TLS proxy.example.com;branch=z9hG4bK776\r\n",
403        "From: \"Caller Name\" <sip:+15551234567@orig.example.com>;tag=abc123\r\n",
404        "To: <sip:urn:service:sos@bcf.example.com>\r\n",
405        "Call-ID: ng911-call-42@orig.example.com\r\n",
406        "P-Asserted-Identity: \"EXAMPLE CO\" <sip:+15551234567@198.51.100.1>\r\n",
407        "Call-Info: <urn:emergency:uid:callid:abc:bcf.example.com>;purpose=emergency-CallId,",
408        "<https://adr.example.com/serviceInfo?t=x>;purpose=EmergencyCallData.ServiceInfo\r\n",
409        "Geolocation: <cid:loc-id-1234>, <https://lis.example.com/held/test>\r\n",
410        "Content-Type: application/sdp\r\n",
411        "\r\n",
412        "v=0\r\n",
413    );
414
415    #[test]
416    fn extract_and_parse_call_info() {
417        use crate::call_info::SipCallInfo;
418
419        let raw = extract_header(NG911_INVITE, "Call-Info").unwrap();
420        let ci = SipCallInfo::parse(&raw).unwrap();
421        assert_eq!(ci.len(), 2);
422        assert_eq!(ci.entries()[0].purpose(), Some("emergency-CallId"));
423        assert!(ci
424            .entries()
425            .iter()
426            .any(|e| e.purpose() == Some("EmergencyCallData.ServiceInfo")));
427    }
428
429    #[test]
430    fn extract_and_parse_p_asserted_identity() {
431        use crate::header_addr::SipHeaderAddr;
432
433        let raw = extract_header(NG911_INVITE, "P-Asserted-Identity").unwrap();
434        let pai: SipHeaderAddr = raw
435            .parse()
436            .unwrap();
437        assert_eq!(pai.display_name(), Some("EXAMPLE CO"));
438        assert!(pai
439            .uri()
440            .to_string()
441            .contains("+15551234567"));
442    }
443
444    #[test]
445    fn extract_and_parse_geolocation() {
446        use crate::geolocation::SipGeolocation;
447
448        let raw = extract_header(NG911_INVITE, "Geolocation").unwrap();
449        let geo = SipGeolocation::parse(&raw);
450        assert_eq!(geo.len(), 2);
451        assert_eq!(geo.cid(), Some("loc-id-1234"));
452        assert!(geo
453            .url()
454            .unwrap()
455            .contains("lis.example.com"));
456    }
457
458    #[test]
459    fn extract_and_parse_from_to() {
460        use crate::header_addr::SipHeaderAddr;
461
462        let from_raw = extract_header(NG911_INVITE, "From").unwrap();
463        let from: SipHeaderAddr = from_raw
464            .parse()
465            .unwrap();
466        assert_eq!(from.display_name(), Some("Caller Name"));
467        assert_eq!(from.tag(), Some("abc123"));
468
469        let to_raw = extract_header(NG911_INVITE, "To").unwrap();
470        let to: SipHeaderAddr = to_raw
471            .parse()
472            .unwrap();
473        assert!(to
474            .uri()
475            .to_string()
476            .contains("urn:service:sos"));
477    }
478
479    // -- extract_request_uri tests (RFC 3261 §7.1) --
480
481    #[test]
482    fn extract_request_uri_invite() {
483        let msg = "INVITE urn:service:sos SIP/2.0\r\nTo: <urn:service:sos>\r\n\r\n";
484        assert_eq!(extract_request_uri(msg), Some("urn:service:sos".into()));
485    }
486
487    #[test]
488    fn extract_request_uri_sip() {
489        let msg = "INVITE sip:+15550001234@198.51.100.1:5060 SIP/2.0\r\n\r\n";
490        assert_eq!(
491            extract_request_uri(msg),
492            Some("sip:+15550001234@198.51.100.1:5060".into()),
493        );
494    }
495
496    #[test]
497    fn extract_request_uri_status_line() {
498        let msg = "SIP/2.0 200 OK\r\n\r\n";
499        assert_eq!(extract_request_uri(msg), None);
500    }
501
502    #[test]
503    fn extract_request_uri_empty() {
504        assert_eq!(extract_request_uri(""), None);
505    }
506}