use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SipCallIdError {
Empty,
NotWord(char),
}
impl fmt::Display for SipCallIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "empty call-id word"),
Self::NotWord(c) => {
write!(
f,
"call-id contains {:?}, not an RFC 3261 word character",
c
)
}
}
}
}
impl std::error::Error for SipCallIdError {}
fn is_word_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.'
| '!'
| '%'
| '*'
| '_'
| '+'
| '`'
| '\''
| '~'
| '('
| ')'
| '<'
| '>'
| ':'
| '\\'
| '"'
| '/'
| '['
| ']'
| '?'
| '{'
| '}'
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SipCallId<'a> {
raw: &'a str,
local: &'a str,
host: Option<&'a str>,
}
impl<'a> SipCallId<'a> {
pub fn parse(raw: &'a str) -> Result<Self, SipCallIdError> {
let (local, host) = match raw.split_once('@') {
Some((local, host)) => (local, Some(host)),
None => (raw, None),
};
for word in std::iter::once(local).chain(host) {
if word.is_empty() {
return Err(SipCallIdError::Empty);
}
if let Some(c) = word
.chars()
.find(|c| !is_word_char(*c))
{
return Err(SipCallIdError::NotWord(c));
}
}
Ok(Self { raw, local, host })
}
pub fn as_str(&self) -> &'a str {
self.raw
}
pub fn local(&self) -> &'a str {
self.local
}
pub fn host(&self) -> Option<&'a str> {
self.host
}
}
impl fmt::Display for SipCallId<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_on_the_single_at() {
let id = SipCallId::parse("a84b4c76e66710@example.com").unwrap();
assert_eq!(id.local(), "a84b4c76e66710");
assert_eq!(id.host(), Some("example.com"));
assert_eq!(id.as_str(), "a84b4c76e66710@example.com");
assert_eq!(id.to_string(), "a84b4c76e66710@example.com");
}
#[test]
fn the_host_word_is_optional() {
let id = SipCallId::parse("f81d4fae7dec11d0a76500a0c91e6bf6").unwrap();
assert_eq!(id.local(), "f81d4fae7dec11d0a76500a0c91e6bf6");
assert_eq!(id.host(), None);
}
#[test]
fn a_second_word_that_is_not_a_host_is_accepted() {
for raw in [
"abc@example.com/1",
"abc@[2001:db8::1]",
"abc@a:b",
"abc@{tag}",
] {
let id = SipCallId::parse(raw).unwrap_or_else(|e| panic!("{raw:?}: {e}"));
assert_eq!(id.local(), "abc");
}
}
#[test]
fn case_is_significant() {
let lower = SipCallId::parse("abc@example.com").unwrap();
let upper = SipCallId::parse("ABC@example.com").unwrap();
assert_ne!(lower, upper);
}
#[test]
fn a_second_at_is_not_a_call_id() {
assert_eq!(SipCallId::parse("a@b@c"), Err(SipCallIdError::NotWord('@')));
}
#[test]
fn an_empty_word_is_not_a_call_id() {
for raw in ["", "abc@", "@example.com"] {
assert_eq!(SipCallId::parse(raw), Err(SipCallIdError::Empty), "{raw:?}");
}
}
#[test]
fn separators_and_whitespace_are_not_word_characters() {
for raw in [
"a;to-tag=t2",
"a,b",
"a b",
"a\r\nSubject: x",
"a@ b",
"a=b",
] {
assert!(SipCallId::parse(raw).is_err(), "accepted {raw:?}");
}
}
}