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!(
91                    f,
92                    "missing angle brackets in URI-info entry ({} bytes)",
93                    raw.len()
94                )
95            }
96            Self::Malformed(reason) => write!(f, "malformed URI-info value: {reason}"),
97        }
98    }
99}
100
101impl std::error::Error for UriInfoError {}
102
103fn parse_entry(raw: &str) -> Result<UriInfoEntry, UriInfoError> {
104    let raw = raw.trim();
105    if raw.is_empty() {
106        return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
107    }
108
109    let bracketed = raw
110        .strip_prefix('<')
111        .and_then(|s| s.split_once('>'))
112        .filter(|(_, rest)| {
113            let rest = rest.trim_start();
114            rest.is_empty() || rest.starts_with(';')
115        });
116    // Unbracketed or junk after `>` (not RFC 3261 §20.9 grammar): data runs
117    // to the first `;` with stray brackets stripped.
118    let (data, params) = bracketed.unwrap_or_else(|| {
119        let (data, params) = raw
120            .split_once(';')
121            .unwrap_or((raw, ""));
122        (
123            data.trim()
124                .trim_matches(|c| c == '<' || c == '>'),
125            params,
126        )
127    });
128    if data.is_empty() {
129        return Err(UriInfoError::MissingAngleBrackets(raw.to_string()));
130    }
131
132    let metadata = crate::parse_params(params)
133        .into_iter()
134        .map(|p| {
135            (
136                p.key
137                    .to_ascii_lowercase(),
138                p.value
139                    .unwrap_or("")
140                    .to_string(),
141            )
142        })
143        .collect();
144
145    Ok(UriInfoEntry {
146        data: data.to_string(),
147        metadata,
148    })
149}
150
151use crate::split_comma_entries;
152
153impl UriInfo {
154    /// Parse a comma-separated `<absoluteURI> *(SEMI generic-param)` value.
155    pub fn parse(raw: &str) -> Result<Self, UriInfoError> {
156        let raw = raw.trim();
157        if raw.is_empty() {
158            return Err(UriInfoError::Empty);
159        }
160        Self::from_entries(split_comma_entries(raw))
161    }
162
163    /// Build from pre-split header entries.
164    ///
165    /// Each entry should be a single `<uri>;param=value` string. Use this
166    /// when entries have already been split by an external mechanism (e.g.
167    /// a transport-specific array encoding).
168    ///
169    /// Entries that fail to parse are skipped; returns `Err(Empty)` only
170    /// when none parse.
171    pub fn from_entries<'a>(
172        entries: impl IntoIterator<Item = &'a str>,
173    ) -> Result<Self, UriInfoError> {
174        let parsed: Vec<_> = entries
175            .into_iter()
176            .filter_map(|raw| parse_entry(raw).ok())
177            .collect();
178        if parsed.is_empty() {
179            return Err(UriInfoError::Empty);
180        }
181        Ok(Self(parsed))
182    }
183
184    /// The parsed entries as a slice.
185    pub fn entries(&self) -> &[UriInfoEntry] {
186        &self.0
187    }
188
189    /// Consume self and return the entries as a `Vec`.
190    pub fn into_entries(self) -> Vec<UriInfoEntry> {
191        self.0
192    }
193
194    /// Number of entries.
195    pub fn len(&self) -> usize {
196        self.0
197            .len()
198    }
199
200    /// Returns `true` if there are no entries.
201    pub fn is_empty(&self) -> bool {
202        self.0
203            .is_empty()
204    }
205}
206
207impl fmt::Display for UriInfo {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        crate::fmt_joined(f, &self.0, ",")
210    }
211}
212
213impl<'a> IntoIterator for &'a UriInfo {
214    type Item = &'a UriInfoEntry;
215    type IntoIter = std::slice::Iter<'a, UriInfoEntry>;
216
217    fn into_iter(self) -> Self::IntoIter {
218        self.0
219            .iter()
220    }
221}
222
223impl IntoIterator for UriInfo {
224    type Item = UriInfoEntry;
225    type IntoIter = std::vec::IntoIter<UriInfoEntry>;
226
227    fn into_iter(self) -> Self::IntoIter {
228        self.0
229            .into_iter()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    // -- UriInfoEntry tests --
238
239    #[test]
240    fn entry_no_metadata() {
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_metadata_trailing_semicolon() {
250        let entry = parse_entry("<data>;").unwrap();
251        assert_eq!(entry.data, "data");
252        assert!(entry
253            .metadata
254            .is_empty());
255    }
256
257    #[test]
258    fn entry_no_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_empty_value_metadata() {
271        let entry = parse_entry("<data>;meta1=").unwrap();
272        assert_eq!(
273            entry
274                .metadata
275                .len(),
276            1
277        );
278        assert_eq!(entry.metadata[0], ("meta1".to_string(), String::new()));
279    }
280
281    #[test]
282    fn entry_two_metadata_items() {
283        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
284        assert_eq!(entry.data, "data");
285        assert_eq!(
286            entry
287                .metadata
288                .len(),
289            2
290        );
291        assert_eq!(entry.param("meta1"), Some("one"));
292        assert_eq!(entry.param("meta2"), Some("two"));
293    }
294
295    #[test]
296    fn entry_strips_angle_brackets() {
297        let entry = parse_entry("<data>;meta1=one;meta2=two;").unwrap();
298        assert_eq!(entry.data, "data");
299    }
300
301    #[test]
302    fn entry_uppercase_metadata_key_lowercased() {
303        let entry = parse_entry("<data>;Meta-1=one").unwrap();
304        assert!(entry
305            .metadata
306            .iter()
307            .all(|(k, _)| k == &k.to_ascii_lowercase()));
308        assert_eq!(entry.param("meta-1"), Some("one"));
309    }
310
311    #[test]
312    fn entry_display_no_trailing_semicolon() {
313        let entry = parse_entry("<data>;").unwrap();
314        let s = entry.to_string();
315        assert!(!s.ends_with(';'));
316    }
317
318    #[test]
319    fn entry_display_metadata_no_trailing_semicolon() {
320        let entry = parse_entry("<data>;meta=one;").unwrap();
321        let s = entry.to_string();
322        assert!(!s.ends_with(';'));
323    }
324
325    #[test]
326    fn entry_display_contains_all_metadata() {
327        let entry = parse_entry("<http://somedata/?arg=123>").unwrap();
328        // Build entry with metadata manually since the URL contains ? and =
329        let mut entry = entry;
330        entry
331            .metadata
332            .push(("meta1".to_string(), "one".to_string()));
333        entry
334            .metadata
335            .push(("meta2".to_string(), "two".to_string()));
336        let s = entry.to_string();
337        assert!(
338            s.matches(';')
339                .count()
340                >= 2
341        );
342    }
343
344    #[test]
345    fn entry_display_no_value_key() {
346        let entry = parse_entry("<data>;flagkey").unwrap();
347        assert_eq!(entry.to_string(), "<data>;flagkey");
348    }
349
350    // -- UriInfo tests --
351
352    const SAMPLE_EMERGENCY: &str = "\
353<urn:emergency:uid:callid:20250401080740945abc123:bcf.example.com>;purpose=emergency-CallId,\
354<urn:emergency:uid:incidentid:20250401080740945def456:bcf.example.com>;purpose=emergency-IncidentId,\
355<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=abc>;purpose=EmergencyCallData.ProviderInfo,\
356<https://adr.example.com/api/v1/adr/call/serviceInfo?token=ghi>;purpose=EmergencyCallData.ServiceInfo";
357
358    const SAMPLE_WITH_SITE: &str = "\
359<urn:emergency:uid:callid:test:bcf.example.com>;purpose=emergency-CallId;site=bcf.example.com,\
360<urn:emergency:uid:incidentid:test:bcf.example.com>;purpose=emergency-IncidentId";
361
362    // 8-entry fixture exercising legacy nena- prefix, EIDO purpose, trailing
363    // semicolons, site param, and all 5 ADR subtypes.
364    const SAMPLE_FULL: &str = "\
365<urn:nena:callid:20190912100022147abc:bcf1.example.com>;purpose=nena-CallId,\
366<https://eido.psap.example.com/EidoRetrievalService/urn:nena:incidentid:test>;purpose=emergency_incident_data_object,\
367<urn:nena:incidentid:20190912100022147def:bcf1.example.com>;purpose=nena-IncidentId,\
368<https://adr.example.com/api/v1/adr/call/providerInfo/access?token=a>;purpose=EmergencyCallData.ProviderInfo,\
369<https://adr.example.com/api/v1/adr/call/providerInfo/telecom?token=b>;purpose=EmergencyCallData.ProviderInfo;site=bcf.example.com;,\
370<https://adr.example.com/api/v1/adr/call/serviceInfo?token=c>;purpose=EmergencyCallData.ServiceInfo,\
371<https://adr.example.com/api/v1/adr/call/subscriberInfo?token=d>;purpose=EmergencyCallData.SubscriberInfo,\
372<https://adr.example.com/api/v1/adr/call/comment?token=e>;purpose=EmergencyCallData.Comment";
373
374    #[test]
375    fn parse_comma_separated() {
376        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
377        assert_eq!(info.len(), 4);
378        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
379        assert_eq!(info.entries()[1].purpose(), Some("emergency-IncidentId"));
380    }
381
382    #[test]
383    fn parse_full_fixture_all_entries() {
384        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
385        assert_eq!(info.len(), 8);
386    }
387
388    #[test]
389    fn full_fixture_nena_prefix_callid() {
390        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
391        let entry = info
392            .entries()
393            .iter()
394            .find(|e| e.purpose() == Some("nena-CallId"))
395            .unwrap();
396        assert!(entry
397            .data
398            .contains("callid"));
399    }
400
401    #[test]
402    fn full_fixture_legacy_eido_purpose() {
403        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
404        let eido: Vec<_> = info
405            .entries()
406            .iter()
407            .filter(|e| {
408                e.purpose()
409                    .is_some_and(|p| p.contains("incident_data_object"))
410            })
411            .collect();
412        assert_eq!(eido.len(), 1);
413        assert!(eido[0]
414            .data
415            .contains("EidoRetrievalService"));
416    }
417
418    #[test]
419    fn full_fixture_trailing_semicolon_with_site() {
420        let info = UriInfo::parse(SAMPLE_FULL).unwrap();
421        let with_site: Vec<_> = info
422            .entries()
423            .iter()
424            .filter(|e| {
425                e.param("site")
426                    .is_some()
427            })
428            .collect();
429        assert_eq!(with_site.len(), 1);
430        assert_eq!(with_site[0].param("site"), Some("bcf.example.com"));
431    }
432
433    #[test]
434    fn find_by_purpose() {
435        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
436
437        let call_id = info
438            .entries()
439            .iter()
440            .find(|e| e.purpose() == Some("emergency-CallId"))
441            .unwrap();
442        assert!(call_id
443            .data
444            .contains("callid"));
445
446        let incident = info
447            .entries()
448            .iter()
449            .find(|e| e.purpose() == Some("emergency-IncidentId"))
450            .unwrap();
451        assert!(incident
452            .data
453            .contains("incidentid"));
454    }
455
456    #[test]
457    fn param_lookup_by_purpose() {
458        let legacy = "<urn:nena:callid:test:example.ca>;purpose=nena-CallId";
459        let info = UriInfo::parse(legacy).unwrap();
460        assert_eq!(info.entries()[0].purpose(), Some("nena-CallId"));
461
462        let modern = "<urn:emergency:uid:callid:test:example.ca>;purpose=emergency-CallId";
463        let info = UriInfo::parse(modern).unwrap();
464        assert_eq!(info.entries()[0].purpose(), Some("emergency-CallId"));
465    }
466
467    #[test]
468    fn filter_entries_by_param() {
469        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
470        let adr: Vec<_> = info
471            .entries()
472            .iter()
473            .filter(|e| {
474                e.purpose()
475                    .is_some_and(|p| p.ends_with("Info"))
476            })
477            .collect();
478        assert_eq!(adr.len(), 2);
479    }
480
481    #[test]
482    fn metadata_param_lookup() {
483        let info = UriInfo::parse(SAMPLE_WITH_SITE).unwrap();
484        assert_eq!(info.entries()[0].param("site"), Some("bcf.example.com"));
485        assert_eq!(info.entries()[0].param("purpose"), Some("emergency-CallId"));
486        assert!(info.entries()[1]
487            .param("site")
488            .is_none());
489    }
490
491    #[test]
492    fn display_roundtrip() {
493        let raw = "<urn:example:test>;purpose=test-purpose;site=example.com";
494        let info = UriInfo::parse(raw).unwrap();
495        assert_eq!(info.to_string(), raw);
496    }
497
498    #[test]
499    fn display_comma_count_matches_entries() {
500        let info = UriInfo::parse(SAMPLE_EMERGENCY).unwrap();
501        let s = info.to_string();
502        assert_eq!(
503            s.matches(',')
504                .count()
505                + 1,
506            info.len()
507        );
508    }
509
510    #[test]
511    fn empty_input() {
512        assert!(matches!(UriInfo::parse(""), Err(UriInfoError::Empty)));
513    }
514
515    #[test]
516    fn parse_tolerates_trailing_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        assert_eq!(info.entries()[0].purpose(), Some("emergency-IncidentId"));
522    }
523
524    #[test]
525    fn parse_tolerates_leading_comma() {
526        let raw =
527            ",<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId";
528        let info = UriInfo::parse(raw).unwrap();
529        assert_eq!(info.len(), 1);
530    }
531
532    #[test]
533    fn parse_tolerates_double_comma_between_valids() {
534        let raw = "<urn:emergency:uid:incidentid:abc:bcf.example.com>;purpose=emergency-IncidentId,,<https://adr.example.com/x>;purpose=EmergencyCallData.ProviderInfo";
535        let info = UriInfo::parse(raw).unwrap();
536        assert_eq!(info.len(), 2);
537    }
538
539    #[test]
540    fn parse_fails_only_when_all_entries_bad() {
541        assert!(matches!(UriInfo::parse(",,, "), Err(UriInfoError::Empty)));
542    }
543
544    #[test]
545    fn semicolon_inside_brackets_stays_in_data() {
546        let entry = parse_entry("<sip:a@example.com;lr>;purpose=icon").unwrap();
547        assert_eq!(entry.data, "sip:a@example.com;lr");
548        assert_eq!(entry.purpose(), Some("icon"));
549        assert_eq!(entry.to_string(), "<sip:a@example.com;lr>;purpose=icon");
550    }
551
552    #[test]
553    fn quoted_param_keeps_semicolon_and_quotes() {
554        let entry = parse_entry(r#"<https://example.com/a>;note="x;y";Purpose=info"#).unwrap();
555        assert_eq!(entry.data, "https://example.com/a");
556        assert_eq!(
557            entry.metadata,
558            vec![
559                ("note".to_string(), r#""x;y""#.to_string()),
560                ("purpose".to_string(), "info".to_string()),
561            ]
562        );
563        assert_eq!(
564            entry.to_string(),
565            r#"<https://example.com/a>;note="x;y";purpose=info"#
566        );
567    }
568
569    #[test]
570    fn sws_around_params() {
571        let entry = parse_entry("<urn:example:1> ; purpose = icon ; flag").unwrap();
572        assert_eq!(
573            entry.metadata,
574            vec![
575                ("purpose".to_string(), "icon".to_string()),
576                ("flag".to_string(), String::new()),
577            ]
578        );
579    }
580
581    #[test]
582    fn unbracketed_and_trailing_junk_keep_fallback() {
583        let entry = parse_entry("urn:example:1;purpose=icon").unwrap();
584        assert_eq!(entry.data, "urn:example:1");
585        assert_eq!(entry.purpose(), Some("icon"));
586
587        let entry = parse_entry("<urn:example:1>junk;purpose=icon").unwrap();
588        assert_eq!(entry.data, "urn:example:1>junk");
589        assert_eq!(entry.purpose(), Some("icon"));
590
591        let entry = parse_entry("<urn:example:1;purpose=icon").unwrap();
592        assert_eq!(entry.data, "urn:example:1");
593        assert_eq!(entry.purpose(), Some("icon"));
594    }
595
596    #[test]
597    fn empty_brackets_rejected() {
598        assert!(parse_entry("<>").is_err());
599        assert!(parse_entry("<>;purpose=icon").is_err());
600    }
601
602    #[test]
603    fn missing_angle_brackets_display_omits_input() {
604        let e = UriInfoError::MissingAngleBrackets("secret".to_string());
605        assert!(!e
606            .to_string()
607            .contains("secret"));
608    }
609
610    // -- Error variant tests --
611
612    #[test]
613    fn malformed_display() {
614        let e = UriInfoError::Malformed("too many entries".to_string());
615        assert_eq!(e.to_string(), "malformed URI-info value: too many entries");
616    }
617}