Skip to main content

sip_header/
call_info.rs

1//! SIP Call-Info header parser (RFC 3261 §20.9).
2
3use std::fmt;
4
5/// One entry from a SIP Call-Info header: `<uri>;key=value;key=value`.
6///
7/// The data field contains the URI stripped of angle brackets.
8/// Metadata keys are stored lowercased; values are preserved as-is.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct SipCallInfoEntry {
11    /// The URI or data inside the angle brackets, with brackets stripped.
12    pub data: String,
13    /// Semicolon-delimited parameters as `(key, value)` pairs.
14    /// Keys are lowercased at parse time; values are preserved as-is.
15    /// A key with no `=` sign is stored with an empty string value.
16    pub metadata: Vec<(String, String)>,
17}
18
19impl SipCallInfoEntry {
20    /// Look up a metadata parameter by key (case-insensitive).
21    pub fn param(&self, key: &str) -> Option<&str> {
22        self.metadata
23            .iter()
24            .find_map(|(k, v)| {
25                if k.eq_ignore_ascii_case(key) {
26                    Some(v.as_str())
27                } else {
28                    None
29                }
30            })
31    }
32
33    /// The `purpose` parameter value, if present.
34    pub fn purpose(&self) -> Option<&str> {
35        self.param("purpose")
36    }
37}
38
39impl fmt::Display for SipCallInfoEntry {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        write!(f, "<{}>", self.data)?;
42        for (key, value) in &self.metadata {
43            if value.is_empty() {
44                write!(f, ";{key}")?;
45            } else {
46                write!(f, ";{key}={value}")?;
47            }
48        }
49        Ok(())
50    }
51}
52
53/// Parsed SIP Call-Info header value. Contains zero or more entries.
54///
55/// ```
56/// use sip_header::SipCallInfo;
57///
58/// let raw = "<urn:example:call:123>;purpose=emergency-CallId,<https://example.com/data>;purpose=EmergencyCallData.ServiceInfo";
59/// let info = SipCallInfo::parse(raw).unwrap();
60/// assert_eq!(info.entries().len(), 2);
61/// assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
62/// ```
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct SipCallInfo(Vec<SipCallInfoEntry>);
65
66/// Errors from parsing a SIP Call-Info header value.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum SipCallInfoError {
69    /// The input string was empty or whitespace-only.
70    Empty,
71    /// An entry was found without angle brackets around the URI.
72    MissingAngleBrackets(String),
73}
74
75impl fmt::Display for SipCallInfoError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            Self::Empty => write!(f, "empty Call-Info header"),
79            Self::MissingAngleBrackets(raw) => {
80                write!(f, "missing angle brackets in Call-Info entry: {raw}")
81            }
82        }
83    }
84}
85
86impl std::error::Error for SipCallInfoError {}
87
88fn parse_entry(raw: &str) -> Result<SipCallInfoEntry, SipCallInfoError> {
89    let raw = raw.trim();
90    if raw.is_empty() {
91        return Err(SipCallInfoError::MissingAngleBrackets(raw.to_string()));
92    }
93
94    // Split on first ';' to separate the URI from parameters.
95    // This avoids issues with ';' inside URIs before the parameter section.
96    let (data_part, metadata_part) = match raw.split_once(';') {
97        Some((d, m)) => (d, Some(m)),
98        None => (raw, None),
99    };
100
101    let data = data_part
102        .trim()
103        .trim_matches(|c| c == '<' || c == '>')
104        .to_string();
105    if data.is_empty() {
106        return Err(SipCallInfoError::MissingAngleBrackets(raw.to_string()));
107    }
108
109    let mut metadata = Vec::new();
110    if let Some(meta_str) = metadata_part {
111        if !meta_str.is_empty() {
112            for segment in meta_str.split(';') {
113                let segment = segment.trim();
114                if segment.is_empty() {
115                    continue;
116                }
117                if let Some((key, value)) = segment.split_once('=') {
118                    metadata.push((
119                        key.trim()
120                            .to_ascii_lowercase(),
121                        value
122                            .trim()
123                            .to_string(),
124                    ));
125                } else {
126                    metadata.push((segment.to_ascii_lowercase(), String::new()));
127                }
128            }
129        }
130    }
131
132    Ok(SipCallInfoEntry { data, metadata })
133}
134
135use crate::split_comma_entries;
136
137impl SipCallInfo {
138    /// Parse a standard comma-separated Call-Info header value (RFC 3261 §20.9).
139    pub fn parse(raw: &str) -> Result<Self, SipCallInfoError> {
140        let raw = raw.trim();
141        if raw.is_empty() {
142            return Err(SipCallInfoError::Empty);
143        }
144        Self::from_entries(split_comma_entries(raw))
145    }
146
147    /// Build from pre-split header entries.
148    ///
149    /// Each entry should be a single `<uri>;param=value` string. Use this
150    /// when entries have already been split by an external mechanism (e.g.
151    /// a transport-specific array encoding).
152    pub fn from_entries<'a>(
153        entries: impl IntoIterator<Item = &'a str>,
154    ) -> Result<Self, SipCallInfoError> {
155        let entries: Vec<_> = entries
156            .into_iter()
157            .map(parse_entry)
158            .collect::<Result<_, _>>()?;
159        if entries.is_empty() {
160            return Err(SipCallInfoError::Empty);
161        }
162        Ok(Self(entries))
163    }
164
165    /// The parsed entries as a slice.
166    pub fn entries(&self) -> &[SipCallInfoEntry] {
167        &self.0
168    }
169
170    /// Consume self and return the entries as a `Vec`.
171    pub fn into_entries(self) -> Vec<SipCallInfoEntry> {
172        self.0
173    }
174
175    /// Number of entries.
176    pub fn len(&self) -> usize {
177        self.0
178            .len()
179    }
180
181    /// Returns `true` if there are no entries.
182    pub fn is_empty(&self) -> bool {
183        self.0
184            .is_empty()
185    }
186}
187
188impl fmt::Display for SipCallInfo {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        crate::fmt_joined(f, &self.0, ",")
191    }
192}
193
194impl<'a> IntoIterator for &'a SipCallInfo {
195    type Item = &'a SipCallInfoEntry;
196    type IntoIter = std::slice::Iter<'a, SipCallInfoEntry>;
197
198    fn into_iter(self) -> Self::IntoIter {
199        self.0
200            .iter()
201    }
202}
203
204impl IntoIterator for SipCallInfo {
205    type Item = SipCallInfoEntry;
206    type IntoIter = std::vec::IntoIter<SipCallInfoEntry>;
207
208    fn into_iter(self) -> Self::IntoIter {
209        self.0
210            .into_iter()
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    // -- SipCallInfoEntry tests --
219
220    #[test]
221    fn entry_no_metadata() {
222        let entry = parse_entry("<data>").unwrap();
223        assert_eq!(entry.data, "data");
224        assert!(entry
225            .metadata
226            .is_empty());
227    }
228
229    #[test]
230    fn entry_no_metadata_trailing_semicolon() {
231        let entry = parse_entry("<data>;").unwrap();
232        assert_eq!(entry.data, "data");
233        assert!(entry
234            .metadata
235            .is_empty());
236    }
237
238    #[test]
239    fn entry_no_value_metadata() {
240        let entry = parse_entry("<data>;meta1").unwrap();
241        assert_eq!(
242            entry
243                .metadata
244                .len(),
245            1
246        );
247        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
248    }
249
250    #[test]
251    fn entry_empty_value_metadata() {
252        let entry = parse_entry("<data>;meta1=").unwrap();
253        assert_eq!(
254            entry
255                .metadata
256                .len(),
257            1
258        );
259        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
260    }
261
262    #[test]
263    fn entry_two_metadata_items() {
264        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
265        assert_eq!(entry.data, "data");
266        assert_eq!(
267            entry
268                .metadata
269                .len(),
270            2
271        );
272        assert_eq!(entry.param("meta1"), Some("one"));
273        assert_eq!(entry.param("meta2"), Some("two"));
274    }
275
276    #[test]
277    fn entry_strips_angle_brackets() {
278        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
279        assert_eq!(entry.data, "data");
280    }
281
282    #[test]
283    fn entry_uppercase_metadata_key_lowercased() {
284        let entry = parse_entry("<data>;Meta-1=one").unwrap();
285        assert!(entry
286            .metadata
287            .iter()
288            .all(|(k, _)| k == &k.to_ascii_lowercase()));
289        assert_eq!(entry.param("meta-1"), Some("one"));
290    }
291
292    #[test]
293    fn entry_display_no_trailing_semicolon() {
294        let entry = parse_entry("<data>;").unwrap();
295        let s = entry.to_string();
296        assert!(!s.ends_with(';'));
297    }
298
299    #[test]
300    fn entry_display_metadata_no_trailing_semicolon() {
301        let entry = parse_entry("<data>;meta=one;").unwrap();
302        let s = entry.to_string();
303        assert!(!s.ends_with(';'));
304    }
305
306    #[test]
307    fn entry_display_contains_all_metadata() {
308        let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
309        // Build entry with metadata manually since the URL contains ? and =
310        let mut entry = entry;
311        entry
312            .metadata
313            .push(("meta1".to_string(), "one".to_string()));
314        entry
315            .metadata
316            .push(("meta2".to_string(), "two".to_string()));
317        let s = entry.to_string();
318        assert!(
319            s.matches(';')
320                .count()
321                >= 2
322        );
323    }
324
325    #[test]
326    fn entry_display_no_value_key() {
327        let entry = parse_entry("<data>;flagkey").unwrap();
328        assert_eq!(entry.to_string(), "<data>;flagkey");
329    }
330
331    // -- SipCallInfo tests --
332
333    const SAMPLE_EMERGENCY: &str = "\
334<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
335<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
336<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
337<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
338
339    const SAMPLE_WITH_SITE: &str = "\
340<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
341<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
342
343    // 8-entry fixture exercising legacy nena- prefix, EIDO purpose, trailing
344    // semicolons, site param, and all 5 ADR subtypes.
345    const SAMPLE_FULL: &str = "\
346<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
347<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
348<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
349<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
350<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
351<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
352<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
353<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
354
355    #[test]
356    fn parse_comma_separated() {
357        let info = SipCallInfo::parse(SAMPLE_EMERGENCY).unwrap();
358        assert_eq!(info.len(), 4);
359        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
360        assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
361    }
362
363    #[test]
364    fn parse_full_fixture_all_entries() {
365        let info = SipCallInfo::parse(SAMPLE_FULL).unwrap();
366        assert_eq!(info.len(), 8);
367    }
368
369    #[test]
370    fn full_fixture_nena_prefix_callid() {
371        let info = SipCallInfo::parse(SAMPLE_FULL).unwrap();
372        let entry = info
373            .entries()
374            .iter()
375            .find(|e| e.purpose() == Some("nena-CallId"))
376            .unwrap();
377        assert!(entry
378            .data
379            .contains("callid"));
380    }
381
382    #[test]
383    fn full_fixture_legacy_eido_purpose() {
384        let info = SipCallInfo::parse(SAMPLE_FULL).unwrap();
385        let eido: Vec<_> = info
386            .entries()
387            .iter()
388            .filter(|e| {
389                e.purpose()
390                    .is_some_and(|p| p.contains("incident_data_object"))
391            })
392            .collect();
393        assert_eq!(eido.len(), 1);
394        assert!(eido[0]
395            .data
396            .contains("EidoRetrievalService"));
397    }
398
399    #[test]
400    fn full_fixture_trailing_semicolon_with_site() {
401        let info = SipCallInfo::parse(SAMPLE_FULL).unwrap();
402        let with_site: Vec<_> = info
403            .entries()
404            .iter()
405            .filter(|e| {
406                e.param("site")
407                    .is_some()
408            })
409            .collect();
410        assert_eq!(with_site.len(), 1);
411        assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
412    }
413
414    #[test]
415    fn find_by_purpose() {
416        let info = SipCallInfo::parse(SAMPLE_EMERGENCY).unwrap();
417
418        let call_id = info
419            .entries()
420            .iter()
421            .find(|e| e.purpose() == Some("emergency-CallId"))
422            .unwrap();
423        assert!(call_id
424            .data
425            .contains("callid"));
426
427        let incident = info
428            .entries()
429            .iter()
430            .find(|e| e.purpose() == Some("emergency-IncidentId"))
431            .unwrap();
432        assert!(incident
433            .data
434            .contains("incidentid"));
435    }
436
437    #[test]
438    fn param_lookup_by_purpose() {
439        let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
440        let info = SipCallInfo::parse(legacy).unwrap();
441        assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
442
443        let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
444        let info = SipCallInfo::parse(modern).unwrap();
445        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
446    }
447
448    #[test]
449    fn filter_entries_by_param() {
450        let info = SipCallInfo::parse(SAMPLE_EMERGENCY).unwrap();
451        let adr: Vec<_> = info
452            .entries()
453            .iter()
454            .filter(|e| {
455                e.purpose()
456                    .is_some_and(|p| p.ends_with("Info"))
457            })
458            .collect();
459        assert_eq!(adr.len(), 2);
460    }
461
462    #[test]
463    fn metadata_param_lookup() {
464        let info = SipCallInfo::parse(SAMPLE_WITH_SITE).unwrap();
465        assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
466        assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
467        assert!(info.entries()[1]
468            .param("site")
469            .is_none());
470    }
471
472    #[test]
473    fn display_roundtrip() {
474        let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
475        let info = SipCallInfo::parse(raw).unwrap();
476        assert_eq!(info.to_string(), raw);
477    }
478
479    #[test]
480    fn display_comma_count_matches_entries() {
481        let info = SipCallInfo::parse(SAMPLE_EMERGENCY).unwrap();
482        let s = info.to_string();
483        assert_eq!(
484            s.matches(',')
485                .count()
486                + 1,
487            info.len()
488        );
489    }
490
491    #[test]
492    fn empty_input() {
493        assert!(matches!(
494            SipCallInfo::parse(""),
495            Err(SipCallInfoError::Empty)
496        ));
497    }
498}