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    /// The value could not be decoded into entries (transport or framing
81    /// failure before per-entry parsing).
82    Malformed(String),
83}
84
85impl fmt::Display for UriInfoError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Empty => write!(f, "empty URI-info header value"),
89            Self::MissingAngleBrackets(raw) => {
90                write!(f, "missing angle brackets in URI-info entry: {raw}")
91            }
92            Self::Malformed(reason) => write!(f, "malformed URI-info value: {reason}"),
93        }
94    }
95}
96
97impl std::error::Error for UriInfoError {}
98
99fn parse_entry(raw: &str) -> Result<UriInfoEntry, UriInfoError> {
100    let raw = raw.trim();
101    if raw.is_empty() {
102        return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
103    }
104
105    // Split on first ';' to separate the URI from parameters.
106    // This avoids issues with ';' inside URIs before the parameter section.
107    let (data_part, metadata_part) = match raw.split_once(';') {
108        Some((d, m)) => (d, Some(m)),
109        None => (raw, None),
110    };
111
112    let data = data_part
113        .trim()
114        .trim_matches(|c| c == '<' || c == '>')
115        .to_string();
116    if data.is_empty() {
117        return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
118    }
119
120    let mut metadata = Vec::new();
121    if let Some(meta_str) = metadata_part {
122        if !meta_str.is_empty() {
123            for segment in meta_str.split(';') {
124                let segment = segment.trim();
125                if segment.is_empty() {
126                    continue;
127                }
128                if let Some((key, value)) = segment.split_once('=') {
129                    metadata.push((
130                        key.trim()
131                            .to_ascii_lowercase(),
132                        value
133                            .trim()
134                            .to_string(),
135                    ));
136                } else {
137                    metadata.push((segment.to_ascii_lowercase(), String::new()));
138                }
139            }
140        }
141    }
142
143    Ok(UriInfoEntry { data, metadata })
144}
145
146use crate::split_comma_entries;
147
148impl UriInfo {
149    /// Parse a comma-separated `<absoluteURI> *(SEMI generic-param)` value.
150    pub fn parse(raw: &str) -> Result<Self, UriInfoError> {
151        let raw = raw.trim();
152        if raw.is_empty() {
153            return Err(UriInfoError::Empty);
154        }
155        Self::from_entries(split_comma_entries(raw))
156    }
157
158    /// Build from pre-split header entries.
159    ///
160    /// Each entry should be a single `<uri>;param=value` string. Use this
161    /// when entries have already been split by an external mechanism (e.g.
162    /// a transport-specific array encoding).
163    ///
164    /// Malformed entries are skipped per RFC 3261 §7.5 error recovery.
165    /// Returns `Err(Empty)` only when all entries fail to parse.
166    pub fn from_entries<'a>(
167        entries: impl IntoIterator<Item = &'a str>,
168    ) -> Result<Self, UriInfoError> {
169        let parsed: Vec<_> = entries
170            .into_iter()
171            .filter_map(|raw| parse_entry(raw).ok())
172            .collect();
173        if parsed.is_empty() {
174            return Err(UriInfoError::Empty);
175        }
176        Ok(Self(parsed))
177    }
178
179    /// The parsed entries as a slice.
180    pub fn entries(&self) -> &[UriInfoEntry] {
181        &self.0
182    }
183
184    /// Consume self and return the entries as a `Vec`.
185    pub fn into_entries(self) -> Vec<UriInfoEntry> {
186        self.0
187    }
188
189    /// Number of entries.
190    pub fn len(&self) -> usize {
191        self.0
192            .len()
193    }
194
195    /// Returns `true` if there are no entries.
196    pub fn is_empty(&self) -> bool {
197        self.0
198            .is_empty()
199    }
200}
201
202impl fmt::Display for UriInfo {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        crate::fmt_joined(f, &self.0, ",")
205    }
206}
207
208impl<'a> IntoIterator for &'a UriInfo {
209    type Item = &'a UriInfoEntry;
210    type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
211
212    fn into_iter(self) -> Self::IntoIter {
213        self.0
214            .iter()
215    }
216}
217
218impl IntoIterator for UriInfo {
219    type Item = UriInfoEntry;
220    type IntoIter = std::vec::IntoIter<UriInfoEntry>;
221
222    fn into_iter(self) -> Self::IntoIter {
223        self.0
224            .into_iter()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    // -- UriInfoEntry tests --
233
234    #[test]
235    fn entry_no_metadata() {
236        let entry = parse_entry("<data>").unwrap();
237        assert_eq!(entry.data, "data");
238        assert!(entry
239            .metadata
240            .is_empty());
241    }
242
243    #[test]
244    fn entry_no_metadata_trailing_semicolon() {
245        let entry = parse_entry("<data>;").unwrap();
246        assert_eq!(entry.data, "data");
247        assert!(entry
248            .metadata
249            .is_empty());
250    }
251
252    #[test]
253    fn entry_no_value_metadata() {
254        let entry = parse_entry("<data>;meta1").unwrap();
255        assert_eq!(
256            entry
257                .metadata
258                .len(),
259            1
260        );
261        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
262    }
263
264    #[test]
265    fn entry_empty_value_metadata() {
266        let entry = parse_entry("<data>;meta1=").unwrap();
267        assert_eq!(
268            entry
269                .metadata
270                .len(),
271            1
272        );
273        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
274    }
275
276    #[test]
277    fn entry_two_metadata_items() {
278        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
279        assert_eq!(entry.data, "data");
280        assert_eq!(
281            entry
282                .metadata
283                .len(),
284            2
285        );
286        assert_eq!(entry.param("meta1"), Some("one"));
287        assert_eq!(entry.param("meta2"), Some("two"));
288    }
289
290    #[test]
291    fn entry_strips_angle_brackets() {
292        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
293        assert_eq!(entry.data, "data");
294    }
295
296    #[test]
297    fn entry_uppercase_metadata_key_lowercased() {
298        let entry = parse_entry("<data>;Meta-1=one").unwrap();
299        assert!(entry
300            .metadata
301            .iter()
302            .all(|(k, _)| k == &k.to_ascii_lowercase()));
303        assert_eq!(entry.param("meta-1"), Some("one"));
304    }
305
306    #[test]
307    fn entry_display_no_trailing_semicolon() {
308        let entry = parse_entry("<data>;").unwrap();
309        let s = entry.to_string();
310        assert!(!s.ends_with(';'));
311    }
312
313    #[test]
314    fn entry_display_metadata_no_trailing_semicolon() {
315        let entry = parse_entry("<data>;meta=one;").unwrap();
316        let s = entry.to_string();
317        assert!(!s.ends_with(';'));
318    }
319
320    #[test]
321    fn entry_display_contains_all_metadata() {
322        let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
323        // Build entry with metadata manually since the URL contains ? and =
324        let mut entry = entry;
325        entry
326            .metadata
327            .push(("meta1".to_string(), "one".to_string()));
328        entry
329            .metadata
330            .push(("meta2".to_string(), "two".to_string()));
331        let s = entry.to_string();
332        assert!(
333            s.matches(';')
334                .count()
335                >= 2
336        );
337    }
338
339    #[test]
340    fn entry_display_no_value_key() {
341        let entry = parse_entry("<data>;flagkey").unwrap();
342        assert_eq!(entry.to_string(), "<data>;flagkey");
343    }
344
345    // -- UriInfo tests --
346
347    const SAMPLE_EMERGENCY: &str = "\
348<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
349<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
350<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
351<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
352
353    const SAMPLE_WITH_SITE: &str = "\
354<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
355<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
356
357    // 8-entry fixture exercising legacy nena- prefix, EIDO purpose, trailing
358    // semicolons, site param, and all 5 ADR subtypes.
359    const SAMPLE_FULL: &str = "\
360<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
361<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
362<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
363<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
364<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
365<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
366<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
367<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
368
369    #[test]
370    fn parse_comma_separated() {
371        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
372        assert_eq!(info.len(), 4);
373        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
374        assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
375    }
376
377    #[test]
378    fn parse_full_fixture_all_entries() {
379        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
380        assert_eq!(info.len(), 8);
381    }
382
383    #[test]
384    fn full_fixture_nena_prefix_callid() {
385        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
386        let entry = info
387            .entries()
388            .iter()
389            .find(|e| e.purpose() == Some("nena-CallId"))
390            .unwrap();
391        assert!(entry
392            .data
393            .contains("callid"));
394    }
395
396    #[test]
397    fn full_fixture_legacy_eido_purpose() {
398        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
399        let eido: Vec<_> = info
400            .entries()
401            .iter()
402            .filter(|e| {
403                e.purpose()
404                    .is_some_and(|p| p.contains("incident_data_object"))
405            })
406            .collect();
407        assert_eq!(eido.len(), 1);
408        assert!(eido[0]
409            .data
410            .contains("EidoRetrievalService"));
411    }
412
413    #[test]
414    fn full_fixture_trailing_semicolon_with_site() {
415        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
416        let with_site: Vec<_> = info
417            .entries()
418            .iter()
419            .filter(|e| {
420                e.param("site")
421                    .is_some()
422            })
423            .collect();
424        assert_eq!(with_site.len(), 1);
425        assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
426    }
427
428    #[test]
429    fn find_by_purpose() {
430        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
431
432        let call_id = info
433            .entries()
434            .iter()
435            .find(|e| e.purpose() == Some("emergency-CallId"))
436            .unwrap();
437        assert!(call_id
438            .data
439            .contains("callid"));
440
441        let incident = info
442            .entries()
443            .iter()
444            .find(|e| e.purpose() == Some("emergency-IncidentId"))
445            .unwrap();
446        assert!(incident
447            .data
448            .contains("incidentid"));
449    }
450
451    #[test]
452    fn param_lookup_by_purpose() {
453        let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
454        let info = UriInfo::parse(legacy).unwrap();
455        assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
456
457        let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
458        let info = UriInfo::parse(modern).unwrap();
459        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
460    }
461
462    #[test]
463    fn filter_entries_by_param() {
464        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
465        let adr: Vec<_> = info
466            .entries()
467            .iter()
468            .filter(|e| {
469                e.purpose()
470                    .is_some_and(|p| p.ends_with("Info"))
471            })
472            .collect();
473        assert_eq!(adr.len(), 2);
474    }
475
476    #[test]
477    fn metadata_param_lookup() {
478        let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
479        assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
480        assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
481        assert!(info.entries()[1]
482            .param("site")
483            .is_none());
484    }
485
486    #[test]
487    fn display_roundtrip() {
488        let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
489        let info = UriInfo::parse(raw).unwrap();
490        assert_eq!(info.to_string(), raw);
491    }
492
493    #[test]
494    fn display_comma_count_matches_entries() {
495        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
496        let s = info.to_string();
497        assert_eq!(
498            s.matches(',')
499                .count()
500                + 1,
501            info.len()
502        );
503    }
504
505    #[test]
506    fn empty_input() {
507        assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
508    }
509
510    #[test]
511    fn parse_tolerates_trailing_comma() {
512        let raw =
513            "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId, ";
514        let info = UriInfo::parse(raw).unwrap();
515        assert_eq!(info.len(), 1);
516        assert_eq!(info.entries()[0].purpose(), Some("emergency-IncidentId"));
517    }
518
519    #[test]
520    fn parse_tolerates_leading_comma() {
521        let raw =
522            ",<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId";
523        let info = UriInfo::parse(raw).unwrap();
524        assert_eq!(info.len(), 1);
525    }
526
527    #[test]
528    fn parse_tolerates_double_comma_between_valids() {
529        let raw = "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId,,<https://adr.example.com/x>;purpose=EmergencyCallData.ProviderInfo";
530        let info = UriInfo::parse(raw).unwrap();
531        assert_eq!(info.len(), 2);
532    }
533
534    #[test]
535    fn parse_fails_only_when_all_entries_bad() {
536        assert!(matches!(UriInfo::parse(",,, "), Err(UriInfoError::Empty)));
537    }
538
539    // -- Error variant tests --
540
541    #[test]
542    fn malformed_display() {
543        let e = UriInfoError::Malformed("too many entries".to_string());
544        assert_eq!(e.to_string(), "malformed URI-info value: too many entries");
545    }
546}