Skip to main content

sip_header/
uri_info.rs

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