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    #[test]
161    fn a_second_word_that_is_not_a_host_is_accepted() {
162        for raw in [
163            "abc@example.com/1",
164            "abc@[2001:db8::1]",
165            "abc@a:b",
166            "abc@{tag}",
167        ] {
168            let id = SipCallId::parse(raw).unwrap_or_else(|e| panic!("{raw:?}: {e}"));
169            assert_eq!(id.local(), "abc");
170        }
171    }
172
173    /// Byte-by-byte comparison (RFC 3261 section 8.1.1.4): two values differing
174    /// only in case are different Call-IDs.
175    #[test]
176    fn case_is_significant() {
177        let lower = SipCallId::parse("abc@example.com").unwrap();
178        let upper = SipCallId::parse("ABC@example.com").unwrap();
179        assert_ne!(lower, upper);
180    }
181
182    #[test]
183    fn a_second_at_is_not_a_call_id() {
184        assert_eq!(SipCallId::parse("a@b@c"), Err(SipCallIdError::NotWord('@')));
185    }
186
187    #[test]
188    fn an_empty_word_is_not_a_call_id() {
189        for raw in ["", "abc@", "@example.com"] {
190            assert_eq!(SipCallId::parse(raw), Err(SipCallIdError::Empty), "{raw:?}");
191        }
192    }
193
194    /// The separators that would let a value smuggle a parameter, a list entry
195    /// or a second header line past a consumer that re-serializes it.
196    #[test]
197    fn separators_and_whitespace_are_not_word_characters() {
198        for raw in [
199            "a;to-tag=t2",
200            "a,b",
201            "a b",
202            "a\r\nSubject: x",
203            "a@ b",
204            "a=b",
205        ] {
206            assert!(SipCallId::parse(raw).is_err(), "accepted {raw:?}");
207        }
208    }
209}