1use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum SipCallIdError {
9 Empty,
11 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
32fn 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#[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 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 pub fn as_str(&self) -> &'a str {
120 self.raw
121 }
122
123 pub fn local(&self) -> &'a str {
125 self.local
126 }
127
128 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]
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 #[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 #[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}