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 all occurrences of a header 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 per occurrence. Each header occurrence is
78/// returned as a separate entry — values are **not** comma-joined, per
79/// RFC 3261 §7.3.1 which forbids joining for Authorization,
80/// Proxy-Authorization, WWW-Authenticate, and Proxy-Authenticate.
81///
82/// Returns an empty `Vec` if no header with the given name is found.
83pub fn extract_header(message: &str, name: &str) -> Vec<String> {
84    let mut values: Vec<String> = Vec::new();
85    let mut current_match = false;
86
87    for line in message.split('\n') {
88        let line = line
89            .strip_suffix('\r')
90            .unwrap_or(line);
91
92        if line.is_empty() {
93            break;
94        }
95
96        if line.starts_with(' ') || line.starts_with('\t') {
97            if current_match {
98                if let Some(last) = values.last_mut() {
99                    last.push(' ');
100                    last.push_str(line.trim_start());
101                }
102            }
103            continue;
104        }
105
106        current_match = false;
107
108        if let Some((hdr_name, hdr_value)) = line.split_once(':') {
109            let hdr_name = hdr_name.trim_end();
110            // RFC 3261: header names are tokens — no whitespace allowed.
111            // This rejects request/status lines like "INVITE sip:..." where
112            // the text before the first colon contains spaces.
113            if !hdr_name.contains(' ') && matches_header_name(hdr_name, name) {
114                current_match = true;
115                values.push(
116                    hdr_value
117                        .trim_start()
118                        .to_string(),
119                );
120            }
121        }
122    }
123
124    values
125}
126
127/// Extract the Request-URI from a SIP request message.
128///
129/// Parses the first line as `Method SP Request-URI SP SIP-Version`
130/// (RFC 3261 Section 7.1) and returns the Request-URI.
131///
132/// Returns `None` for status lines (`SIP/2.0 200 OK`) or if the
133/// request line cannot be parsed.
134pub fn extract_request_uri(message: &str) -> Option<String> {
135    let first_line = message
136        .lines()
137        .next()?;
138    let first_line = first_line
139        .strip_suffix('\r')
140        .unwrap_or(first_line);
141    let mut parts = first_line.split_whitespace();
142    let method = parts.next()?;
143    if method.starts_with("SIP/") {
144        return None;
145    }
146    let uri = parts.next()?;
147    let version = parts.next()?;
148    if parts
149        .next()
150        .is_some()
151    {
152        return None;
153    }
154    if !version.starts_with("SIP/") {
155        return None;
156    }
157    Some(uri.to_string())
158}
159
160impl SipHeader {
161    /// Extract all occurrences of this header from a raw SIP message.
162    ///
163    /// Recognizes both the canonical header name and its compact form
164    /// (RFC 3261 §7.3.3). For example, `SipHeader::From.extract_from(msg)`
165    /// matches both `From:` and `f:` lines.
166    pub fn extract_from(&self, message: &str) -> Vec<String> {
167        extract_header(message, self.as_str())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    const SAMPLE_INVITE: &str = "\
176INVITE sip:bob@biloxi.example.com SIP/2.0\r\n\
177Via: SIP/2.0/UDP pc33.atlanta.example.com;branch=z9hG4bK776asdhds\r\n\
178Via: SIP/2.0/UDP bigbox3.site3.atlanta.example.com;branch=z9hG4bKnashds8\r\n\
179Max-Forwards: 70\r\n\
180To: Bob <sip:bob@biloxi.example.com>\r\n\
181From: Alice <sip:alice@atlanta.example.com>;tag=1928301774\r\n\
182Call-ID: a84b4c76e66710@pc33.atlanta.example.com\r\n\
183CSeq: 314159 INVITE\r\n\
184Contact: <sip:alice@pc33.atlanta.example.com>\r\n\
185Content-Type: application/sdp\r\n\
186Content-Length: 142\r\n\
187\r\n\
188v=0\r\n\
189o=alice 2890844526 2890844526 IN IP4 pc33.atlanta.example.com\r\n";
190
191    #[test]
192    fn basic_extraction() {
193        let from = extract_header(SAMPLE_INVITE, "From");
194        assert_eq!(from.len(), 1);
195        assert_eq!(
196            from[0],
197            "Alice <sip:alice@atlanta.example.com>;tag=1928301774"
198        );
199
200        let call_id = extract_header(SAMPLE_INVITE, "Call-ID");
201        assert_eq!(call_id.len(), 1);
202        assert_eq!(call_id[0], "a84b4c76e66710@pc33.atlanta.example.com");
203
204        let cseq = extract_header(SAMPLE_INVITE, "CSeq");
205        assert_eq!(cseq.len(), 1);
206        assert_eq!(cseq[0], "314159 INVITE");
207    }
208
209    #[test]
210    fn case_insensitive_name() {
211        let expected = "Alice <sip:alice@atlanta.example.com>;tag=1928301774";
212        assert_eq!(extract_header(SAMPLE_INVITE, "from")[0], expected);
213        assert_eq!(extract_header(SAMPLE_INVITE, "FROM")[0], expected);
214        assert_eq!(extract_header(SAMPLE_INVITE, "From")[0], 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        let result = extract_header(msg, "Subject");
227        assert_eq!(result.len(), 1);
228        assert_eq!(
229            result[0],
230            "I know you're there, pick up the phone and talk to me!"
231        );
232    }
233
234    #[test]
235    fn multiple_occurrences_separate() {
236        let via = extract_header(SAMPLE_INVITE, "Via");
237        assert_eq!(via.len(), 2);
238        assert_eq!(
239            via[0],
240            "SIP/2.0/UDP pc33.atlanta.example.com;branch=z9hG4bK776asdhds"
241        );
242        assert_eq!(
243            via[1],
244            "SIP/2.0/UDP bigbox3.site3.atlanta.example.com;branch=z9hG4bKnashds8"
245        );
246    }
247
248    #[test]
249    fn stops_at_blank_line() {
250        assert!(extract_header(SAMPLE_INVITE, "o").is_empty());
251    }
252
253    #[test]
254    fn bare_lf_line_endings() {
255        let msg = "SIP/2.0 200 OK\n\
256                   From: Alice <sip:alice@host>\n\
257                   To: Bob <sip:bob@host>\n\
258                   \n\
259                   body\n";
260        let from = extract_header(msg, "From");
261        assert_eq!(from.len(), 1);
262        assert_eq!(from[0], "Alice <sip:alice@host>");
263    }
264
265    #[test]
266    fn missing_header_returns_empty() {
267        assert!(extract_header(SAMPLE_INVITE, "X-Custom").is_empty());
268    }
269
270    #[test]
271    fn empty_message() {
272        assert!(extract_header("", "From").is_empty());
273    }
274
275    #[test]
276    fn request_line_not_matched() {
277        assert!(extract_header(SAMPLE_INVITE, "INVITE sip").is_empty());
278    }
279
280    #[test]
281    fn value_leading_whitespace_trimmed() {
282        let msg = "SIP/2.0 200 OK\r\n\
283                   From:   Alice <sip:alice@host>\r\n\
284                   \r\n";
285        let from = extract_header(msg, "From");
286        assert_eq!(from.len(), 1);
287        assert_eq!(from[0], "Alice <sip:alice@host>");
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        let via = extract_header(msg, "Via");
300        assert_eq!(via.len(), 2);
301        assert_eq!(via[0], "SIP/2.0/UDP first.example.com ;branch=z9hG4bKaaa");
302        assert_eq!(via[1], "SIP/2.0/UDP second.example.com;branch=z9hG4bKbbb");
303    }
304
305    #[test]
306    fn empty_header_value() {
307        let msg = "SIP/2.0 200 OK\r\n\
308                   Subject:\r\n\
309                   From: Alice <sip:alice@host>\r\n\
310                   \r\n";
311        let subject = extract_header(msg, "Subject");
312        assert_eq!(subject.len(), 1);
313        assert_eq!(subject[0], "");
314    }
315
316    #[test]
317    fn tab_folding() {
318        let msg = concat!(
319            "SIP/2.0 200 OK\r\n",
320            "Subject: hello\r\n",
321            "\tworld\r\n",
322            "\r\n",
323        );
324        let subject = extract_header(msg, "Subject");
325        assert_eq!(subject.len(), 1);
326        assert_eq!(subject[0], "hello world");
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!(extract_header(msg, "From")[0], "Alice <sip:alice@host>");
335        assert_eq!(extract_header(msg, "f")[0], "Alice <sip:alice@host>");
336    }
337
338    #[test]
339    fn compact_form_via() {
340        let msg = "SIP/2.0 200 OK\r\nv: SIP/2.0/UDP host\r\n\r\n";
341        assert_eq!(extract_header(msg, "Via")[0], "SIP/2.0/UDP host");
342        assert_eq!(extract_header(msg, "v")[0], "SIP/2.0/UDP host");
343    }
344
345    #[test]
346    fn compact_form_mixed_with_full() {
347        let msg = concat!(
348            "SIP/2.0 200 OK\r\n",
349            "f: Alice <sip:alice@host>;tag=a\r\n",
350            "t: Bob <sip:bob@host>;tag=b\r\n",
351            "i: call-1@host\r\n",
352            "m: <sip:alice@192.0.2.1>\r\n",
353            "Content-Type: application/sdp\r\n",
354            "\r\n",
355        );
356        assert_eq!(
357            extract_header(msg, "From")[0],
358            "Alice <sip:alice@host>;tag=a"
359        );
360        assert_eq!(extract_header(msg, "To")[0], "Bob <sip:bob@host>;tag=b");
361        assert_eq!(extract_header(msg, "Call-ID")[0], "call-1@host");
362        assert_eq!(extract_header(msg, "Contact")[0], "<sip:alice@192.0.2.1>");
363        assert_eq!(extract_header(msg, "Content-Type")[0], "application/sdp");
364        assert_eq!(extract_header(msg, "c")[0], "application/sdp");
365    }
366
367    #[test]
368    fn compact_form_case_insensitive() {
369        let msg = "SIP/2.0 200 OK\r\nF: Alice <sip:alice@host>\r\n\r\n";
370        assert_eq!(extract_header(msg, "From")[0], "Alice <sip:alice@host>");
371    }
372
373    #[test]
374    fn compact_form_unknown_single_char() {
375        let msg = "SIP/2.0 200 OK\r\nz: something\r\n\r\n";
376        assert_eq!(extract_header(msg, "z")[0], "something");
377        assert!(extract_header(msg, "From").is_empty());
378    }
379
380    // -- Integration pipeline tests: extract_header → existing parsers --
381
382    const NG911_INVITE: &str = concat!(
383        "INVITE sip:urn:service:sos@bcf.example.com SIP/2.0\r\n",
384        "Via: SIP/2.0/TLS proxy.example.com;branch=z9hG4bK776\r\n",
385        "From: \"Caller Name\" <sip:+15551234567@orig.example.com>;tag=abc123\r\n",
386        "To: <sip:urn:service:sos@bcf.example.com>\r\n",
387        "Call-ID: ng911-call-42@orig.example.com\r\n",
388        "P-Asserted-Identity: \"EXAMPLE CO\" <sip:+15551234567@198.51.100.1>\r\n",
389        "Call-Info: <urn:emergency:uid:callid:abc:bcf.example.com>;purpose=emergency-CallId,",
390        "<https://adr.example.com/serviceInfo?t=x>;purpose=EmergencyCallData.ServiceInfo\r\n",
391        "Geolocation: <cid:loc-id-1234>, <https://lis.example.com/held/test>\r\n",
392        "Content-Type: application/sdp\r\n",
393        "\r\n",
394        "v=0\r\n",
395    );
396
397    #[test]
398    fn extract_and_parse_call_info() {
399        use crate::uri_info::UriInfo;
400
401        let raw = extract_header(NG911_INVITE, "Call-Info");
402        assert_eq!(raw.len(), 1);
403        let ci = UriInfo::parse(&raw[0]).unwrap();
404        assert_eq!(ci.len(), 2);
405        assert_eq!(ci.entries()[0].purpose(), Some("emergency-CallId"));
406        assert!(ci
407            .entries()
408            .iter()
409            .any(|e| e.purpose() == Some("EmergencyCallData.ServiceInfo")));
410    }
411
412    #[test]
413    fn extract_and_parse_p_asserted_identity() {
414        use crate::header_addr::SipHeaderAddr;
415
416        let raw = extract_header(NG911_INVITE, "P-Asserted-Identity");
417        assert_eq!(raw.len(), 1);
418        let pai: SipHeaderAddr = raw[0]
419            .parse()
420            .unwrap();
421        assert_eq!(pai.display_name(), Some("EXAMPLE CO"));
422        assert!(pai
423            .uri()
424            .to_string()
425            .contains("+15551234567"));
426    }
427
428    #[test]
429    fn extract_and_parse_multi_pai() {
430        use crate::header_addr::SipHeaderAddr;
431
432        let msg = concat!(
433            "INVITE sip:sos@psap.example.com SIP/2.0\r\n",
434            "P-Asserted-Identity: \"EXAMPLE CO\" <sip:+15551234567@198.51.100.1>\r\n",
435            "P-Asserted-Identity: <tel:+15551234567>\r\n",
436            "\r\n",
437        );
438        let raw = extract_header(msg, "P-Asserted-Identity");
439        assert_eq!(raw.len(), 2);
440        let pai0: SipHeaderAddr = raw[0]
441            .parse()
442            .unwrap();
443        assert_eq!(pai0.display_name(), Some("EXAMPLE CO"));
444        let pai1: SipHeaderAddr = raw[1]
445            .parse()
446            .unwrap();
447        assert!(pai1
448            .uri()
449            .to_string()
450            .contains("+15551234567"));
451    }
452
453    #[test]
454    fn extract_and_parse_geolocation() {
455        use crate::geolocation::SipGeolocation;
456
457        let raw = extract_header(NG911_INVITE, "Geolocation");
458        assert_eq!(raw.len(), 1);
459        let geo = SipGeolocation::parse(&raw[0]);
460        assert_eq!(geo.len(), 2);
461        assert_eq!(geo.cid(), Some("loc-id-1234"));
462        assert!(geo
463            .url()
464            .unwrap()
465            .contains("lis.example.com"));
466    }
467
468    #[test]
469    fn extract_and_parse_from_to() {
470        use crate::header_addr::SipHeaderAddr;
471
472        let from_raw = extract_header(NG911_INVITE, "From");
473        assert_eq!(from_raw.len(), 1);
474        let from: SipHeaderAddr = from_raw[0]
475            .parse()
476            .unwrap();
477        assert_eq!(from.display_name(), Some("Caller Name"));
478        assert_eq!(from.tag(), Some("abc123"));
479
480        let to_raw = extract_header(NG911_INVITE, "To");
481        assert_eq!(to_raw.len(), 1);
482        let to: SipHeaderAddr = to_raw[0]
483            .parse()
484            .unwrap();
485        assert!(to
486            .uri()
487            .to_string()
488            .contains("urn:service:sos"));
489    }
490
491    // -- extract_request_uri tests (RFC 3261 §7.1) --
492
493    #[test]
494    fn extract_request_uri_invite() {
495        let msg = "INVITE urn:service:sos SIP/2.0\r\nTo: <urn:service:sos>\r\n\r\n";
496        assert_eq!(extract_request_uri(msg), Some("urn:service:sos".into()));
497    }
498
499    #[test]
500    fn extract_request_uri_sip() {
501        let msg = "INVITE sip:+15550001234@198.51.100.1:5060 SIP/2.0\r\n\r\n";
502        assert_eq!(
503            extract_request_uri(msg),
504            Some("sip:+15550001234@198.51.100.1:5060".into()),
505        );
506    }
507
508    #[test]
509    fn extract_request_uri_status_line() {
510        let msg = "SIP/2.0 200 OK\r\n\r\n";
511        assert_eq!(extract_request_uri(msg), None);
512    }
513
514    #[test]
515    fn extract_request_uri_empty() {
516        assert_eq!(extract_request_uri(""), None);
517    }
518}