Skip to main content

sip_header/
message.rs

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