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    ///
160    /// Malformed entries are skipped per RFC 3261 §7.5 error recovery.
161    /// Returns `Err(Empty)` only when all entries fail to parse.
162    pub fn from_entries<'a>(
163        entries: impl IntoIterator<Item = &'a str>,
164    ) -> Result<Self, UriInfoError> {
165        let parsed: Vec<_> = entries
166            .into_iter()
167            .filter_map(|raw| parse_entry(raw).ok())
168            .collect();
169        if parsed.is_empty() {
170            return Err(UriInfoError::Empty);
171        }
172        Ok(Self(parsed))
173    }
174
175    /// The parsed entries as a slice.
176    pub fn entries(&self) -> &[UriInfoEntry] {
177        &self.0
178    }
179
180    /// Consume self and return the entries as a `Vec`.
181    pub fn into_entries(self) -> Vec<UriInfoEntry> {
182        self.0
183    }
184
185    /// Number of entries.
186    pub fn len(&self) -> usize {
187        self.0
188            .len()
189    }
190
191    /// Returns `true` if there are no entries.
192    pub fn is_empty(&self) -> bool {
193        self.0
194            .is_empty()
195    }
196}
197
198impl fmt::Display for UriInfo {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        crate::fmt_joined(f, &self.0, ",")
201    }
202}
203
204impl<'a> IntoIterator for &'a UriInfo {
205    type Item = &'a UriInfoEntry;
206    type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
207
208    fn into_iter(self) -> Self::IntoIter {
209        self.0
210            .iter()
211    }
212}
213
214impl IntoIterator for UriInfo {
215    type Item = UriInfoEntry;
216    type IntoIter = std::vec::IntoIter<UriInfoEntry>;
217
218    fn into_iter(self) -> Self::IntoIter {
219        self.0
220            .into_iter()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    // -- UriInfoEntry tests --
229
230    #[test]
231    fn entry_no_metadata() {
232        let entry = parse_entry("<data>").unwrap();
233        assert_eq!(entry.data, "data");
234        assert!(entry
235            .metadata
236            .is_empty());
237    }
238
239    #[test]
240    fn entry_no_metadata_trailing_semicolon() {
241        let entry = parse_entry("<data>;").unwrap();
242        assert_eq!(entry.data, "data");
243        assert!(entry
244            .metadata
245            .is_empty());
246    }
247
248    #[test]
249    fn entry_no_value_metadata() {
250        let entry = parse_entry("<data>;meta1").unwrap();
251        assert_eq!(
252            entry
253                .metadata
254                .len(),
255            1
256        );
257        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
258    }
259
260    #[test]
261    fn entry_empty_value_metadata() {
262        let entry = parse_entry("<data>;meta1=").unwrap();
263        assert_eq!(
264            entry
265                .metadata
266                .len(),
267            1
268        );
269        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
270    }
271
272    #[test]
273    fn entry_two_metadata_items() {
274        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
275        assert_eq!(entry.data, "data");
276        assert_eq!(
277            entry
278                .metadata
279                .len(),
280            2
281        );
282        assert_eq!(entry.param("meta1"), Some("one"));
283        assert_eq!(entry.param("meta2"), Some("two"));
284    }
285
286    #[test]
287    fn entry_strips_angle_brackets() {
288        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
289        assert_eq!(entry.data, "data");
290    }
291
292    #[test]
293    fn entry_uppercase_metadata_key_lowercased() {
294        let entry = parse_entry("<data>;Meta-1=one").unwrap();
295        assert!(entry
296            .metadata
297            .iter()
298            .all(|(k, _)| k == &k.to_ascii_lowercase()));
299        assert_eq!(entry.param("meta-1"), Some("one"));
300    }
301
302    #[test]
303    fn entry_display_no_trailing_semicolon() {
304        let entry = parse_entry("<data>;").unwrap();
305        let s = entry.to_string();
306        assert!(!s.ends_with(';'));
307    }
308
309    #[test]
310    fn entry_display_metadata_no_trailing_semicolon() {
311        let entry = parse_entry("<data>;meta=one;").unwrap();
312        let s = entry.to_string();
313        assert!(!s.ends_with(';'));
314    }
315
316    #[test]
317    fn entry_display_contains_all_metadata() {
318        let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
319        // Build entry with metadata manually since the URL contains ? and =
320        let mut entry = entry;
321        entry
322            .metadata
323            .push(("meta1".to_string(), "one".to_string()));
324        entry
325            .metadata
326            .push(("meta2".to_string(), "two".to_string()));
327        let s = entry.to_string();
328        assert!(
329            s.matches(';')
330                .count()
331                >= 2
332        );
333    }
334
335    #[test]
336    fn entry_display_no_value_key() {
337        let entry = parse_entry("<data>;flagkey").unwrap();
338        assert_eq!(entry.to_string(), "<data>;flagkey");
339    }
340
341    // -- UriInfo tests --
342
343    const SAMPLE_EMERGENCY: &str = "\
344<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
345<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
346<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
347<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
348
349    const SAMPLE_WITH_SITE: &str = "\
350<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
351<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
352
353    // 8-entry fixture exercising legacy nena- prefix, EIDO purpose, trailing
354    // semicolons, site param, and all 5 ADR subtypes.
355    const SAMPLE_FULL: &str = "\
356<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
357<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
358<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
359<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
360<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
361<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
362<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
363<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
364
365    #[test]
366    fn parse_comma_separated() {
367        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
368        assert_eq!(info.len(), 4);
369        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
370        assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
371    }
372
373    #[test]
374    fn parse_full_fixture_all_entries() {
375        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
376        assert_eq!(info.len(), 8);
377    }
378
379    #[test]
380    fn full_fixture_nena_prefix_callid() {
381        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
382        let entry = info
383            .entries()
384            .iter()
385            .find(|e| e.purpose() == Some("nena-CallId"))
386            .unwrap();
387        assert!(entry
388            .data
389            .contains("callid"));
390    }
391
392    #[test]
393    fn full_fixture_legacy_eido_purpose() {
394        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
395        let eido: Vec<_> = info
396            .entries()
397            .iter()
398            .filter(|e| {
399                e.purpose()
400                    .is_some_and(|p| p.contains("incident_data_object"))
401            })
402            .collect();
403        assert_eq!(eido.len(), 1);
404        assert!(eido[0]
405            .data
406            .contains("EidoRetrievalService"));
407    }
408
409    #[test]
410    fn full_fixture_trailing_semicolon_with_site() {
411        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
412        let with_site: Vec<_> = info
413            .entries()
414            .iter()
415            .filter(|e| {
416                e.param("site")
417                    .is_some()
418            })
419            .collect();
420        assert_eq!(with_site.len(), 1);
421        assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
422    }
423
424    #[test]
425    fn find_by_purpose() {
426        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
427
428        let call_id = info
429            .entries()
430            .iter()
431            .find(|e| e.purpose() == Some("emergency-CallId"))
432            .unwrap();
433        assert!(call_id
434            .data
435            .contains("callid"));
436
437        let incident = info
438            .entries()
439            .iter()
440            .find(|e| e.purpose() == Some("emergency-IncidentId"))
441            .unwrap();
442        assert!(incident
443            .data
444            .contains("incidentid"));
445    }
446
447    #[test]
448    fn param_lookup_by_purpose() {
449        let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
450        let info = UriInfo::parse(legacy).unwrap();
451        assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
452
453        let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
454        let info = UriInfo::parse(modern).unwrap();
455        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
456    }
457
458    #[test]
459    fn filter_entries_by_param() {
460        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
461        let adr: Vec<_> = info
462            .entries()
463            .iter()
464            .filter(|e| {
465                e.purpose()
466                    .is_some_and(|p| p.ends_with("Info"))
467            })
468            .collect();
469        assert_eq!(adr.len(), 2);
470    }
471
472    #[test]
473    fn metadata_param_lookup() {
474        let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
475        assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
476        assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
477        assert!(info.entries()[1]
478            .param("site")
479            .is_none());
480    }
481
482    #[test]
483    fn display_roundtrip() {
484        let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
485        let info = UriInfo::parse(raw).unwrap();
486        assert_eq!(info.to_string(), raw);
487    }
488
489    #[test]
490    fn display_comma_count_matches_entries() {
491        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
492        let s = info.to_string();
493        assert_eq!(
494            s.matches(',')
495                .count()
496                + 1,
497            info.len()
498        );
499    }
500
501    #[test]
502    fn empty_input() {
503        assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
504    }
505
506    #[test]
507    fn parse_tolerates_trailing_comma() {
508        let raw =
509            "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId, ";
510        let info = UriInfo::parse(raw).unwrap();
511        assert_eq!(info.len(), 1);
512        assert_eq!(info.entries()[0].purpose(), Some("emergency-IncidentId"));
513    }
514
515    #[test]
516    fn parse_tolerates_leading_comma() {
517        let raw =
518            ",<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId";
519        let info = UriInfo::parse(raw).unwrap();
520        assert_eq!(info.len(), 1);
521    }
522
523    #[test]
524    fn parse_tolerates_double_comma_between_valids() {
525        let raw = "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId,,<https://adr.example.com/x>;purpose=EmergencyCallData.ProviderInfo";
526        let info = UriInfo::parse(raw).unwrap();
527        assert_eq!(info.len(), 2);
528    }
529
530    #[test]
531    fn parse_fails_only_when_all_entries_bad() {
532        assert!(matches!(UriInfo::parse(",,, "), Err(UriInfoError::Empty)));
533    }
534}