use std::borrow::Cow;
use std::ops::Range;
use super::{after_prefix, check_expression_field, check_field, undeliverable, EndpointFieldFault};
use crate::commands::originate::OriginateError;
use crate::commands::variables::Variables;
use crate::switch_passes::originate_legs::splits_into_threads;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "config::SofiaEndpoint"))]
#[non_exhaustive]
pub struct SofiaEndpoint {
pub profile: String,
pub destination: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub variables: Option<Variables>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "config::SofiaGateway"))]
#[non_exhaustive]
pub struct SofiaGateway {
pub gateway: String,
pub destination: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub profile: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub variables: Option<Variables>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "config::SofiaContact"))]
#[non_exhaustive]
pub struct SofiaContact {
pub user: String,
pub domain: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub profile: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub variables: Option<Variables>,
}
pub(super) const URL_UNSAFE: &[u8] = b"\r\n #%&+:;<=>?@[\\]^`{|}\"";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DestinationEncoding {
Encoded,
Suppressed,
}
impl SofiaEndpoint {
pub fn new(profile: impl Into<String>, destination: impl Into<String>) -> Self {
Self {
profile: profile.into(),
destination: destination.into(),
variables: None,
}
}
pub fn user(&self, encoding: DestinationEncoding) -> Option<Cow<'_, str>> {
dialed_user(&self.destination, encoding, DialPath::Profile)
}
}
impl SofiaGateway {
pub fn new(gateway: impl Into<String>, destination: impl Into<String>) -> Self {
Self {
gateway: gateway.into(),
destination: destination.into(),
profile: None,
variables: None,
}
}
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn user(&self, encoding: DestinationEncoding) -> Option<Cow<'_, str>> {
dialed_user(&self.destination, encoding, DialPath::Gateway)
}
}
#[derive(Clone, Copy)]
enum DialPath {
Profile,
Gateway,
}
fn dialed_user(
destination: &str,
encoding: DestinationEncoding,
path: DialPath,
) -> Option<Cow<'_, str>> {
let (text, encoded) = match encoding {
DestinationEncoding::Encoded => protect_dest_uri(destination),
DestinationEncoding::Suppressed => (Cow::Borrowed(destination), None),
};
let find = |byte: u8, from: usize, end: usize| {
(from..end).find(|&at| {
text.as_bytes()[at] == byte
&& !encoded
.as_ref()
.is_some_and(|user| user.contains(&at))
})
};
let end = find(b'^', 0, text.len()).unwrap_or(text.len());
let user = match path {
DialPath::Profile => {
let scheme = match &encoded {
Some(user) if user.start == 0 => 0,
_ => scheme_len(&text[..end]),
};
match (scheme, find(b'@', 0, end)) {
(0, Some(at)) => 0..at,
(0, None) => 0..find(b'%', 0, end).unwrap_or(end),
(len, at) => len..at?,
}
}
DialPath::Gateway => {
let end = find(b';', 0, end).unwrap_or(end);
0..find(b'@', 0, end).unwrap_or(end)
}
};
if user.is_empty() {
return None;
}
Some(match text {
Cow::Borrowed(text) => Cow::Borrowed(&text[user]),
Cow::Owned(text) => Cow::Owned(text[user].to_owned()),
})
}
fn scheme_len(text: &str) -> usize {
["sip:", "sips:"]
.into_iter()
.find(|scheme| {
text.get(..scheme.len())
.is_some_and(|head| head.eq_ignore_ascii_case(scheme))
})
.map_or(0, str::len)
}
fn protect_dest_uri(destination: &str) -> (Cow<'_, str>, Option<Range<usize>>) {
let unchanged = (Cow::Borrowed(destination), None);
if !destination.contains('@') {
return unchanged;
}
let segment_start = destination
.rfind('/')
.map_or(0, |slash| slash + 1);
let (prefix, segment) = destination.split_at(segment_start);
let ahead = segment
.split_once('@')
.map_or(segment, |(ahead, _)| ahead);
if !ahead
.bytes()
.any(|byte| URL_UNSAFE.contains(&byte))
{
return unchanged;
}
let scheme = scheme_len(segment);
let rest = &segment[scheme..];
let Some(at) = rest.find('@') else {
return (
Cow::Borrowed(&destination[..segment_start.saturating_sub(1)]),
None,
);
};
let user = prefix.len()..prefix.len() + at;
let text = match (scheme, prefix.is_empty()) {
(0, _) => Cow::Borrowed(destination),
(_, true) => Cow::Borrowed(rest),
(_, false) => Cow::Owned(format!("{prefix}{rest}")),
};
(text, Some(user))
}
impl SofiaContact {
pub fn new(user: impl Into<String>, domain: impl Into<String>) -> Self {
Self {
user: user.into(),
domain: domain.into(),
profile: None,
variables: None,
}
}
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
}
impl_dial_string_with_variables!(SofiaEndpoint, write_module_text, |this| format!(
"sofia/{}/{}",
this.profile, this.destination
));
impl_dial_string_with_variables!(
SofiaGateway,
write_module_text,
|this| match &this.profile {
Some(p) => format!("sofia/gateway/{p}::{}/{}", this.gateway, this.destination),
None => format!("sofia/gateway/{}/{}", this.gateway, this.destination),
}
);
impl_dial_string_with_variables!(SofiaContact, write_expression, |this| match &this.profile {
Some(p) => format!("${{sofia_contact({p}/{}@{})}}", this.user, this.domain),
None => format!("${{sofia_contact({}@{})}}", this.user, this.domain),
});
impl SofiaEndpoint {
pub(crate) fn check_deliverable(&self) -> Result<(), OriginateError> {
check_field("sofia", "profile", &self.profile, &["/", "^", "@"])?;
if self
.profile
.eq_ignore_ascii_case("gateway")
{
return Err(undeliverable(
"sofia",
"profile",
EndpointFieldFault::ReadsAsGateway,
));
}
check_field("sofia", "destination", &self.destination, &[])
}
pub(crate) fn parse_bare(text: &str) -> Result<Self, OriginateError> {
let rest = after_prefix(text, "sofia/", "sofia")?;
let (profile, destination) = rest
.split_once('/')
.ok_or_else(|| {
OriginateError::ParseError("sofia endpoint needs profile/destination".into())
})?;
let ep = Self::new(profile, destination);
ep.check_deliverable()?;
Ok(ep)
}
}
impl SofiaGateway {
pub(crate) fn check_deliverable(&self) -> Result<(), OriginateError> {
const KIND: &str = "sofia gateway";
let gateway_splits: &[&str] = match self.profile {
Some(_) => &["/", "^", "@"],
None => &["/", "^", "@", "::"],
};
check_field(KIND, "gateway", &self.gateway, gateway_splits)?;
if let Some(profile) = &self.profile {
check_field(KIND, "profile", profile, &["/", "^", "@", "::"])?;
if profile.ends_with(':') {
return Err(undeliverable(
KIND,
"profile",
EndpointFieldFault::ModuleSeparator("::"),
));
}
if splits_into_threads(&format!("{profile}::{}", self.gateway)) {
let field = if profile.ends_with(":_") {
"profile"
} else {
"gateway"
};
return Err(undeliverable(
KIND,
field,
EndpointFieldFault::EnterpriseSeparator,
));
}
}
check_field(KIND, "destination", &self.destination, &[])
}
pub(crate) fn parse_bare(text: &str) -> Result<Self, OriginateError> {
let rest = after_prefix(text, "sofia/gateway/", "sofia gateway")?;
let (gateway_part, destination) = rest
.split_once('/')
.ok_or_else(|| {
OriginateError::ParseError("sofia gateway needs gateway/destination".into())
})?;
let ep = match gateway_part.split_once("::") {
Some((profile, gateway)) => Self::new(gateway, destination).with_profile(profile),
None => Self::new(gateway_part, destination),
};
ep.check_deliverable()?;
Ok(ep)
}
}
impl SofiaContact {
pub(crate) fn check_deliverable(&self) -> Result<(), OriginateError> {
const KIND: &str = "sofia_contact";
let user_splits: &[&str] = match self.profile {
Some(_) => &["~", "@"],
None => &["~", "@", "/"],
};
check_expression_field(KIND, "user", &self.user, user_splits)?;
for (field, value) in [
("domain", Some(&self.domain)),
(
"profile",
self.profile
.as_ref(),
),
] {
let Some(value) = value else {
continue;
};
check_expression_field(KIND, field, value, &["~", "/"])?;
if value.is_empty() {
return Err(undeliverable(
KIND,
field,
EndpointFieldFault::EmptyReadsAsDefault,
));
}
}
Ok(())
}
pub(crate) fn parse_bare(text: &str) -> Result<Self, OriginateError> {
let inner = text
.strip_prefix("${sofia_contact(")
.and_then(|r| r.strip_suffix(")}"))
.ok_or_else(|| OriginateError::ParseError("not a sofia_contact expression".into()))?;
let (profile, user_at_domain) = if let Some((p, rest)) = inner.split_once('/') {
(Some(p.to_string()), rest)
} else {
(None, inner)
};
let (user, domain) = user_at_domain
.split_once('@')
.ok_or_else(|| OriginateError::ParseError("sofia_contact needs user@domain".into()))?;
let ep = Self {
user: user.into(),
domain: domain.into(),
profile,
variables: None,
};
ep.check_deliverable()?;
Ok(ep)
}
}
impl_endpoint_parse!(from_str: SofiaEndpoint, SofiaGateway);
impl_endpoint_parse!(expression_from_str: SofiaContact => "sofia_contact");
impl_endpoint_parse!(config:
SofiaEndpoint {
profile: String,
destination: String,
;
variables: Option<Variables>,
}
SofiaContact {
user: String,
domain: String,
;
profile: Option<String>,
variables: Option<Variables>,
}
SofiaGateway {
gateway: String,
destination: String,
;
profile: Option<String>,
variables: Option<Variables>,
}
);
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::variables::VariablesType;
#[test]
fn sofia_endpoint_display() {
let ep = SofiaEndpoint {
profile: "internal".into(),
destination: "1000@example.com".into(),
variables: None,
};
assert_eq!(ep.to_string(), "sofia/internal/1000@example.com");
}
#[test]
fn sofia_endpoint_display_with_variables() {
let mut vars = Variables::new(VariablesType::Default);
vars.insert("originate_timeout", "30");
let ep = SofiaEndpoint {
profile: "internal".into(),
destination: "1000@example.com".into(),
variables: Some(vars),
};
assert_eq!(
ep.to_string(),
"{originate_timeout=30}sofia/internal/1000@example.com"
);
}
#[test]
fn sofia_endpoint_from_str() {
let ep: SofiaEndpoint = "sofia/internal/1000@example.com"
.parse()
.unwrap();
assert_eq!(ep.profile, "internal");
assert_eq!(ep.destination, "1000@example.com");
assert!(ep
.variables
.is_none());
}
#[test]
fn sofia_endpoint_from_str_with_variables() {
let ep: SofiaEndpoint = "{originate_timeout=30}sofia/internal/1000@example.com"
.parse()
.unwrap();
assert_eq!(ep.profile, "internal");
assert_eq!(ep.destination, "1000@example.com");
assert_eq!(
ep.variables
.unwrap()
.get("originate_timeout"),
Some("30")
);
}
#[test]
fn sofia_endpoint_round_trip() {
let ep = SofiaEndpoint {
profile: "external".into(),
destination: "sip:user@host:5060".into(),
variables: None,
};
let s = ep.to_string();
let parsed: SofiaEndpoint = s
.parse()
.unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn serde_sofia_endpoint() {
let ep = SofiaEndpoint {
profile: "internal".into(),
destination: "1000@example.com".into(),
variables: None,
};
let json = serde_json::to_string(&ep).unwrap();
let parsed: SofiaEndpoint = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn serde_sofia_endpoint_with_variables() {
let mut vars = Variables::new(VariablesType::Default);
vars.insert("originate_timeout", "30");
let ep = SofiaEndpoint {
profile: "internal".into(),
destination: "1000@example.com".into(),
variables: Some(vars),
};
let json = serde_json::to_string(&ep).unwrap();
let parsed: SofiaEndpoint = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn sofia_gateway_display() {
let ep = SofiaGateway {
gateway: "my_provider".into(),
destination: "18005551234".into(),
profile: None,
variables: None,
};
assert_eq!(ep.to_string(), "sofia/gateway/my_provider/18005551234");
}
#[test]
fn sofia_gateway_display_with_profile() {
let ep = SofiaGateway {
gateway: "my_provider".into(),
destination: "18005551234".into(),
profile: Some("external".into()),
variables: None,
};
assert_eq!(
ep.to_string(),
"sofia/gateway/external::my_provider/18005551234"
);
}
#[test]
fn sofia_gateway_display_with_variables() {
let mut vars = Variables::new(VariablesType::Default);
vars.insert("sip_h_X-Custom", "value");
let ep = SofiaGateway {
gateway: "gw1".into(),
destination: "1234".into(),
profile: None,
variables: Some(vars),
};
assert_eq!(
ep.to_string(),
"{sip_h_X-Custom=value}sofia/gateway/gw1/1234"
);
}
#[test]
fn sofia_gateway_from_str() {
let ep: SofiaGateway = "sofia/gateway/my_provider/18005551234"
.parse()
.unwrap();
assert_eq!(ep.gateway, "my_provider");
assert_eq!(ep.destination, "18005551234");
assert!(ep
.profile
.is_none());
}
#[test]
fn sofia_gateway_from_str_with_profile() {
let ep: SofiaGateway = "sofia/gateway/external::my_provider/18005551234"
.parse()
.unwrap();
assert_eq!(ep.gateway, "my_provider");
assert_eq!(ep.destination, "18005551234");
assert_eq!(
ep.profile
.as_deref(),
Some("external")
);
}
#[test]
fn sofia_gateway_round_trip() {
let ep = SofiaGateway {
gateway: "carrier".into(),
destination: "15551234567".into(),
profile: Some("external".into()),
variables: None,
};
let s = ep.to_string();
let parsed: SofiaGateway = s
.parse()
.unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn serde_sofia_gateway() {
let ep = SofiaGateway {
gateway: "my_provider".into(),
destination: "18005551234".into(),
profile: None,
variables: None,
};
let json = serde_json::to_string(&ep).unwrap();
let parsed: SofiaGateway = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn serde_sofia_gateway_with_profile() {
let ep = SofiaGateway {
gateway: "my_provider".into(),
destination: "18005551234".into(),
profile: Some("external".into()),
variables: None,
};
let json = serde_json::to_string(&ep).unwrap();
let parsed: SofiaGateway = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn sofia_contact_display() {
let ep = SofiaContact {
user: "1000".into(),
domain: "example.com".into(),
profile: None,
variables: None,
};
assert_eq!(ep.to_string(), "${sofia_contact(1000@example.com)}");
}
#[test]
fn sofia_contact_display_with_profile() {
let ep = SofiaContact {
user: "1000".into(),
domain: "example.com".into(),
profile: Some("internal".into()),
variables: None,
};
assert_eq!(
ep.to_string(),
"${sofia_contact(internal/1000@example.com)}"
);
}
#[test]
fn sofia_contact_display_all_profiles() {
let ep = SofiaContact {
user: "1000".into(),
domain: "example.com".into(),
profile: Some("*".into()),
variables: None,
};
assert_eq!(ep.to_string(), "${sofia_contact(*/1000@example.com)}");
}
#[test]
fn sofia_contact_display_with_variables() {
let mut vars = Variables::new(VariablesType::Default);
vars.insert("presence_id", "1000@example.com");
let ep = SofiaContact {
user: "1000".into(),
domain: "example.com".into(),
profile: None,
variables: Some(vars),
};
assert_eq!(
ep.to_string(),
"{presence_id=1000@example.com}${sofia_contact(1000@example.com)}"
);
}
#[test]
fn sofia_contact_from_str() {
let ep = SofiaContact::parse_bare("${sofia_contact(1000@example.com)}").unwrap();
assert_eq!(ep.user, "1000");
assert_eq!(ep.domain, "example.com");
assert!(ep
.profile
.is_none());
}
#[test]
fn sofia_contact_from_str_with_profile() {
let ep = SofiaContact::parse_bare("${sofia_contact(internal/1000@example.com)}").unwrap();
assert_eq!(ep.user, "1000");
assert_eq!(ep.domain, "example.com");
assert_eq!(
ep.profile
.as_deref(),
Some("internal")
);
}
#[test]
fn sofia_contact_round_trip() {
let ep = SofiaContact {
user: "bob".into(),
domain: "example.com".into(),
profile: Some("*".into()),
variables: None,
};
let s = ep.to_string();
let parsed = SofiaContact::parse_bare(&s).unwrap();
assert_eq!(parsed, ep);
}
#[test]
fn sofia_contact_profile_with_at_sign() {
let ep = SofiaContact::new("1000", "example.com").with_profile("user@realm");
let s = ep.to_string();
assert_eq!(s, "${sofia_contact(user@realm/1000@example.com)}");
let parsed = SofiaContact::parse_bare(&s).unwrap();
assert_eq!(
parsed
.profile
.as_deref(),
Some("user@realm")
);
assert_eq!(parsed.user, "1000");
assert_eq!(parsed.domain, "example.com");
}
#[test]
fn serde_sofia_contact() {
let ep = SofiaContact {
user: "1000".into(),
domain: "example.com".into(),
profile: Some("*".into()),
variables: None,
};
let json = serde_json::to_string(&ep).unwrap();
let parsed: SofiaContact = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ep);
}
type UserCase = (&'static str, Option<&'static str>, Option<&'static str>);
fn assert_users(
cases: &[UserCase],
user: impl Fn(&str, DestinationEncoding) -> Option<String>,
) {
for &(destination, encoded, suppressed) in cases {
assert_eq!(
user(destination, DestinationEncoding::Encoded).as_deref(),
encoded,
"{destination:?} encoded"
);
assert_eq!(
user(destination, DestinationEncoding::Suppressed).as_deref(),
suppressed,
"{destination:?} suppressed"
);
}
}
#[test]
fn sofia_endpoint_user() {
assert_users(
&[
(
"5550001234@pbx.example.com",
Some("5550001234"),
Some("5550001234"),
),
("sip:1000@pbx.example.com:5060", Some("1000"), Some("1000")),
("SIPS:1000@pbx.example.com", Some("1000"), Some("1000")),
("1000^2000@pbx.example.com", Some("1000^2000"), Some("1000")),
("1000@pbx.example.com^2000", Some("1000"), Some("1000")),
("1000%pbx.example.com", Some("1000"), Some("1000")),
("1000", Some("1000"), Some("1000")),
("sip:pbx.example.com", None, None),
("a@b@pbx.example.com", Some("a"), Some("a")),
("x@pbx.example.com/z w", Some("x"), Some("x")),
("a/sip:b@pbx.example.com", Some("a/b"), Some("a/sip:b")),
("@pbx.example.com", None, None),
(
"+15550001234;npdi=yes@pbx.example.com",
Some("+15550001234;npdi=yes"),
Some("+15550001234;npdi=yes"),
),
],
|destination, encoding| {
SofiaEndpoint::new("internal", destination)
.user(encoding)
.map(Cow::into_owned)
},
);
}
#[test]
fn sofia_gateway_user() {
assert_users(
&[
("5550009999", Some("5550009999"), Some("5550009999")),
(
"+15550001234@agent.example.com",
Some("+15550001234"),
Some("+15550001234"),
),
("1000;transport=tcp", Some("1000"), Some("1000")),
("1000;x=a@b.example.com", Some("1000;x=a"), Some("1000")),
("sip:1000@example.com", Some("1000"), Some("sip:1000")),
("1000^2000@example.com", Some("1000^2000"), Some("1000")),
("", None, None),
],
|destination, encoding| {
SofiaGateway::new("montreal", destination)
.with_profile("voipms")
.user(encoding)
.map(Cow::into_owned)
},
);
}
}