Skip to main content

sip_header/
geolocation.rs

1//! SIP Geolocation header types (RFC 6442).
2
3use std::fmt;
4
5/// A reference extracted from a SIP Geolocation header (RFC 6442).
6///
7/// Each entry is either a `cid:` reference to a MIME body part
8/// (typically containing PIDF-LO XML) or a URL for location dereference.
9///
10/// This crate only parses the header references themselves. Resolving
11/// `cid:` references against the SIP message body (multipart MIME) or
12/// dereferencing HTTP URLs is the caller's responsibility.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum SipGeolocationRef {
16    /// Content-ID reference to a MIME body part (e.g., `cid:uuid`).
17    Cid(String),
18    /// HTTP(S) or other URL for location dereference.
19    Url(String),
20}
21
22impl fmt::Display for SipGeolocationRef {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Cid(id) => write!(f, "<cid:{id}>"),
26            Self::Url(url) => write!(f, "<{url}>"),
27        }
28    }
29}
30
31/// Read `locationValue = LAQUOT locationURI RAQUOT *(SEMI geoloc-param)`
32/// (RFC 6442 ยง4.1), keeping only the URI.
33fn parse_ref(entry: &str) -> Option<SipGeolocationRef> {
34    let (inner, _) = entry
35        .trim()
36        .strip_prefix('<')?
37        .split_once('>')?;
38    if inner.is_empty() {
39        return None;
40    }
41    Some(
42        match inner
43            .get(..4)
44            .filter(|scheme| scheme.eq_ignore_ascii_case("cid:"))
45        {
46            Some(_) => SipGeolocationRef::Cid(inner[4..].to_string()),
47            None => SipGeolocationRef::Url(inner.to_string()),
48        },
49    )
50}
51
52/// Parsed SIP Geolocation header value (RFC 6442).
53///
54/// Contains one or more `<uri>` references, comma-separated. Each reference
55/// is classified as either a `cid:` body-part reference or a dereference URL.
56///
57/// ```
58/// use sip_header::SipGeolocation;
59///
60/// let raw = "<cid:abc-123>, <https://lis.example.com/held/abc>";
61/// let geo = SipGeolocation::parse(raw);
62/// assert_eq!(geo.len(), 2);
63/// assert!(geo.cid().is_some());
64/// assert!(geo.url().is_some());
65/// ```
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SipGeolocation(Vec<SipGeolocationRef>);
68
69impl SipGeolocation {
70    /// Parse a raw Geolocation header value into typed references.
71    ///
72    /// Entries that are not a `<uri>` are skipped.
73    pub fn parse(raw: &str) -> Self {
74        Self::from_entries(crate::split_comma_entries(raw))
75    }
76
77    /// Build from entries a transport already split; each is one
78    /// `locationValue`. Entries that are not a `<uri>` are skipped, and
79    /// geoloc-params after the `>` are dropped.
80    pub fn from_entries<'a>(entries: impl IntoIterator<Item = &'a str>) -> Self {
81        Self(
82            entries
83                .into_iter()
84                .filter_map(parse_ref)
85                .collect(),
86        )
87    }
88
89    /// The parsed references as a slice.
90    pub fn refs(&self) -> &[SipGeolocationRef] {
91        &self.0
92    }
93
94    /// Number of references.
95    pub fn len(&self) -> usize {
96        self.0
97            .len()
98    }
99
100    /// Returns `true` if there are no references.
101    pub fn is_empty(&self) -> bool {
102        self.0
103            .is_empty()
104    }
105
106    /// The first `cid:` reference, if any.
107    pub fn cid(&self) -> Option<&str> {
108        self.0
109            .iter()
110            .find_map(|r| match r {
111                SipGeolocationRef::Cid(id) => Some(id.as_str()),
112                _ => None,
113            })
114    }
115
116    /// The first URL reference, if any.
117    pub fn url(&self) -> Option<&str> {
118        self.0
119            .iter()
120            .find_map(|r| match r {
121                SipGeolocationRef::Url(url) => Some(url.as_str()),
122                _ => None,
123            })
124    }
125
126    /// Iterate over all `cid:` references.
127    pub fn cids(&self) -> impl Iterator<Item = &str> {
128        self.0
129            .iter()
130            .filter_map(|r| match r {
131                SipGeolocationRef::Cid(id) => Some(id.as_str()),
132                _ => None,
133            })
134    }
135
136    /// Iterate over all URL references.
137    pub fn urls(&self) -> impl Iterator<Item = &str> {
138        self.0
139            .iter()
140            .filter_map(|r| match r {
141                SipGeolocationRef::Url(url) => Some(url.as_str()),
142                _ => None,
143            })
144    }
145}
146
147impl fmt::Display for SipGeolocation {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        crate::fmt_joined(f, &self.0, ", ")
150    }
151}
152
153impl<'a> IntoIterator for &'a SipGeolocation {
154    type Item = &'a SipGeolocationRef;
155    type IntoIter = std::slice::Iter<'a, SipGeolocationRef>;
156
157    fn into_iter(self) -> Self::IntoIter {
158        self.0
159            .iter()
160    }
161}
162
163impl IntoIterator for SipGeolocation {
164    type Item = SipGeolocationRef;
165    type IntoIter = std::vec::IntoIter<SipGeolocationRef>;
166
167    fn into_iter(self) -> Self::IntoIter {
168        self.0
169            .into_iter()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_cid_and_url() {
179        let raw = "<cid:32863354-18b4-4069-bd00-7bced5fc6c9b>, <https://lis.example.com/api/v1/held/test>";
180        let geo = SipGeolocation::parse(raw);
181        assert_eq!(geo.len(), 2);
182        assert_eq!(geo.cid(), Some("32863354-18b4-4069-bd00-7bced5fc6c9b"));
183        assert!(geo
184            .url()
185            .unwrap()
186            .contains("lis.example.com"));
187    }
188
189    #[test]
190    fn single_cid() {
191        let geo = SipGeolocation::parse("<cid:abc-123>");
192        assert_eq!(geo.len(), 1);
193        assert_eq!(geo.cid(), Some("abc-123"));
194        assert!(geo
195            .url()
196            .is_none());
197    }
198
199    #[test]
200    fn single_url() {
201        let geo = SipGeolocation::parse("<https://lis.example.com/location>");
202        assert_eq!(geo.len(), 1);
203        assert!(geo
204            .cid()
205            .is_none());
206        assert_eq!(geo.url(), Some("https://lis.example.com/location"));
207    }
208
209    #[test]
210    fn empty_input() {
211        let geo = SipGeolocation::parse("");
212        assert!(geo.is_empty());
213    }
214
215    #[test]
216    fn empty_brackets_skipped() {
217        let geo = SipGeolocation::parse("<>, <cid:test>");
218        assert_eq!(geo.len(), 1);
219        assert_eq!(geo.cid(), Some("test"));
220    }
221
222    #[test]
223    fn display_roundtrip() {
224        let raw = "<cid:abc-123>, <https://lis.example.com/test>";
225        let geo = SipGeolocation::parse(raw);
226        assert_eq!(geo.to_string(), raw);
227    }
228
229    #[test]
230    fn geoloc_params_dropped() {
231        let geo = SipGeolocation::parse("<cid:x@example.com>;inserted-by=y");
232        assert_eq!(
233            geo.refs(),
234            &[SipGeolocationRef::Cid("x@example.com".into())]
235        );
236    }
237
238    #[test]
239    fn comma_inside_brackets_not_split() {
240        let geo = SipGeolocation::parse("<https://example.com/a,b>");
241        assert_eq!(
242            geo.refs(),
243            &[SipGeolocationRef::Url("https://example.com/a,b".into())]
244        );
245    }
246
247    #[test]
248    fn cid_scheme_case_insensitive() {
249        let geo = SipGeolocation::parse("<CID:x@example.com>");
250        assert_eq!(geo.cid(), Some("x@example.com"));
251    }
252
253    #[test]
254    fn unbracketed_entry_skipped() {
255        let geo = SipGeolocation::parse("cid:x@example.com, <https://example.com/loc>");
256        assert_eq!(geo.len(), 1);
257        assert_eq!(geo.url(), Some("https://example.com/loc"));
258    }
259
260    #[test]
261    fn from_entries_matches_parse() {
262        let split =
263            SipGeolocation::from_entries(["<cid:a>;inserted-by=x", "<https://example.com/a,b>"]);
264        let joined = SipGeolocation::parse("<cid:a>;inserted-by=x, <https://example.com/a,b>");
265        assert_eq!(split, joined);
266        assert_eq!(split.len(), 2);
267    }
268
269    #[test]
270    fn multiple_cids() {
271        let raw = "<cid:first>, <cid:second>, <https://example.com/loc>";
272        let geo = SipGeolocation::parse(raw);
273        let cids: Vec<_> = geo
274            .cids()
275            .collect();
276        assert_eq!(cids, vec!["first", "second"]);
277        let urls: Vec<_> = geo
278            .urls()
279            .collect();
280        assert_eq!(urls, vec!["https://example.com/loc"]);
281    }
282}