Skip to main content

sip_header/
call_id.rs

1//! `Call-ID` (RFC 3261 section 20.8).
2
3use std::fmt;
4
5/// Why a value is not a `callid`.
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum SipCallIdError {
9    /// A word of the value is empty.
10    Empty,
11    /// A character outside the `word` production.
12    NotWord(char),
13}
14
15impl fmt::Display for SipCallIdError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Empty => write!(f, "empty call-id word"),
19            Self::NotWord(c) => {
20                write!(
21                    f,
22                    "call-id contains {:?}, not an RFC 3261 word character",
23                    c
24                )
25            }
26        }
27    }
28}
29
30impl std::error::Error for SipCallIdError {}
31
32/// RFC 3261 section 25.1 `word`.
33fn is_word_char(c: char) -> bool {
34    c.is_ascii_alphanumeric()
35        || matches!(
36            c,
37            '-' | '.'
38                | '!'
39                | '%'
40                | '*'
41                | '_'
42                | '+'
43                | '`'
44                | '\''
45                | '~'
46                | '('
47                | ')'
48                | '<'
49                | '>'
50                | ':'
51                | '\\'
52                | '"'
53                | '/'
54                | '['
55                | ']'
56                | '?'
57                | '{'
58                | '}'
59        )
60}
61
62/// A `Call-ID` value, split on the one `@` its grammar admits.
63///
64/// `callid = word [ "@" word ]` (RFC 3261 section 25.1). `@` is not a `word`
65/// character, so the split is unambiguous and a second `@` makes the value
66/// invalid rather than choosing a side.
67///
68/// [`host`](Self::host) returns the second `word`, which section 8.1.1.4 leaves
69/// optional and does not require to be a hostname. Callers that need one parse
70/// it themselves; a value that carries something else is still a valid Call-ID.
71///
72/// ```
73/// use sip_header::SipCallId;
74///
75/// let id = SipCallId::parse("a84b4c76e66710@example.com")?;
76/// assert_eq!(id.local(), "a84b4c76e66710");
77/// assert_eq!(id.host(), Some("example.com"));
78///
79/// let bare = SipCallId::parse("f81d4fae7dec11d0a76500a0c91e6bf6")?;
80/// assert_eq!(bare.host(), None);
81/// # Ok::<(), sip_header::SipCallIdError>(())
82/// ```
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub struct SipCallId<'a> {
85    raw: &'a str,
86    local: &'a str,
87    host: Option<&'a str>,
88}
89
90impl<'a> SipCallId<'a> {
91    /// Parse a `Call-ID` header value.
92    ///
93    /// Errors on an empty word or a character outside `word` — including a
94    /// second `@`, whitespace, and the separators that would let a value carry
95    /// a parameter or a second header line.
96    pub fn parse(raw: &'a str) -> Result<Self, SipCallIdError> {
97        let (local, host) = match raw.split_once('@') {
98            Some((local, host)) => (local, Some(host)),
99            None => (raw, None),
100        };
101        for word in std::iter::once(local).chain(host) {
102            if word.is_empty() {
103                return Err(SipCallIdError::Empty);
104            }
105            if let Some(c) = word
106                .chars()
107                .find(|c| !is_word_char(*c))
108            {
109                return Err(SipCallIdError::NotWord(c));
110            }
111        }
112        Ok(Self { raw, local, host })
113    }
114
115    /// The value as parsed.
116    ///
117    /// Call-IDs are compared byte-by-byte (RFC 3261 section 8.1.1.4), so this
118    /// is the form to compare and to hash.
119    pub fn as_str(&self) -> &'a str {
120        self.raw
121    }
122
123    /// The first `word` — everything before the `@`, or the whole value.
124    pub fn local(&self) -> &'a str {
125        self.local
126    }
127
128    /// The second `word`, if the value carries one.
129    pub fn host(&self) -> Option<&'a str> {
130        self.host
131    }
132}
133
134impl fmt::Display for SipCallId<'_> {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(self.raw)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn splits_on_the_single_at() {
146        let id = SipCallId::parse("a84b4c76e66710@example.com").unwrap();
147        assert_eq!(id.local(), "a84b4c76e66710");
148        assert_eq!(id.host(), Some("example.com"));
149        assert_eq!(id.as_str(), "a84b4c76e66710@example.com");
150        assert_eq!(id.to_string(), "a84b4c76e66710@example.com");
151    }
152
153    #[test]
154    fn the_host_word_is_optional() {
155        let id = SipCallId::parse("f81d4fae7dec11d0a76500a0c91e6bf6").unwrap();
156        assert_eq!(id.local(), "f81d4fae7dec11d0a76500a0c91e6bf6");
157        assert_eq!(id.host(), None);
158    }
159
160    /// `word` admits characters no host may carry, and a value using them is
161    /// still a Call-ID — rejecting it would refuse traffic the grammar allows.
162    #[test]
163    fn a_second_word_that_is_not_a_host_is_accepted() {
164        for raw in [
165            "abc@example.com/1",
166            "abc@[2001:db8::1]",
167            "abc@a:b",
168            "abc@{tag}",
169        ] {
170            let id = SipCallId::parse(raw).unwrap_or_else(|e| panic!("{raw:?}: {e}"));
171            assert_eq!(id.local(), "abc");
172        }
173    }
174
175    /// Byte-by-byte comparison (RFC 3261 section 8.1.1.4): two values differing
176    /// only in case are different Call-IDs.
177    #[test]
178    fn case_is_significant() {
179        let lower = SipCallId::parse("abc@example.com").unwrap();
180        let upper = SipCallId::parse("ABC@example.com").unwrap();
181        assert_ne!(lower, upper);
182    }
183
184    #[test]
185    fn a_second_at_is_not_a_call_id() {
186        assert_eq!(SipCallId::parse("a@b@c"), Err(SipCallIdError::NotWord('@')));
187    }
188
189    #[test]
190    fn an_empty_word_is_not_a_call_id() {
191        for raw in ["", "abc@", "@example.com"] {
192            assert_eq!(SipCallId::parse(raw), Err(SipCallIdError::Empty), "{raw:?}");
193        }
194    }
195
196    /// The separators that would let a value smuggle a parameter, a list entry
197    /// or a second header line past a consumer that re-serializes it.
198    #[test]
199    fn separators_and_whitespace_are_not_word_characters() {
200        for raw in [
201            "a;to-tag=t2",
202            "a,b",
203            "a b",
204            "a\r\nSubject: x",
205            "a@ b",
206            "a=b",
207        ] {
208            assert!(SipCallId::parse(raw).is_err(), "accepted {raw:?}");
209        }
210    }
211}