Skip to main content

freeswitch_types/commands/endpoint/
sofia.rs

1use std::borrow::Cow;
2use std::ops::Range;
3
4use super::{after_prefix, check_expression_field, check_field, undeliverable, EndpointFieldFault};
5use crate::commands::originate::OriginateError;
6use crate::commands::variables::Variables;
7use crate::switch_passes::originate_legs::splits_into_threads;
8
9/// SIP endpoint via a named profile: `sofia/{profile}/{destination}`.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12#[cfg_attr(feature = "serde", serde(try_from = "config::SofiaEndpoint"))]
13#[non_exhaustive]
14pub struct SofiaEndpoint {
15    /// SIP profile name (e.g. `internal`, `external`).
16    pub profile: String,
17    /// SIP URI or destination number.
18    pub destination: String,
19    /// Per-channel variables prepended as `{key=value}`.
20    #[cfg_attr(
21        feature = "serde",
22        serde(default, skip_serializing_if = "Option::is_none")
23    )]
24    pub variables: Option<Variables>,
25}
26
27/// SIP endpoint via a configured gateway:
28/// `sofia/gateway/[{profile}::]{gateway}/{destination}`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "serde", serde(try_from = "config::SofiaGateway"))]
32#[non_exhaustive]
33pub struct SofiaGateway {
34    /// Gateway name as configured in the SIP profile.
35    pub gateway: String,
36    /// Destination number or SIP user part.
37    pub destination: String,
38    /// SIP profile name to qualify the gateway lookup.
39    #[cfg_attr(
40        feature = "serde",
41        serde(default, skip_serializing_if = "Option::is_none")
42    )]
43    pub profile: Option<String>,
44    /// Per-channel variables prepended as `{key=value}`.
45    #[cfg_attr(
46        feature = "serde",
47        serde(default, skip_serializing_if = "Option::is_none")
48    )]
49    pub variables: Option<Variables>,
50}
51
52/// Runtime expression resolving registered SIP contacts:
53/// `${sofia_contact([profile/]user@domain)}`.
54///
55/// The library produces the expression string; a dialplan application or the
56/// `expand` API evaluates it, and `originate` over the API never does.
57///
58/// The special profile `"*"` makes FreeSWITCH iterate over every loaded
59/// SIP profile to find the user's registration, instead of searching a
60/// single named profile (see `sofia_contact_function` in
61/// `mod_sofia.c` where `strcmp(profile_name, "*")` skips the single-profile
62/// lookup and falls through to the all-profiles hash iteration).
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65#[cfg_attr(feature = "serde", serde(try_from = "config::SofiaContact"))]
66#[non_exhaustive]
67pub struct SofiaContact {
68    /// User part of the contact lookup.
69    pub user: String,
70    /// Domain for the contact lookup.
71    pub domain: String,
72    /// SIP profile name, or `"*"` for all profiles.
73    #[cfg_attr(
74        feature = "serde",
75        serde(default, skip_serializing_if = "Option::is_none")
76    )]
77    pub profile: Option<String>,
78    /// Per-channel variables prepended as `{key=value}`.
79    #[cfg_attr(
80        feature = "serde",
81        serde(default, skip_serializing_if = "Option::is_none")
82    )]
83    pub variables: Option<Variables>,
84}
85
86/// `SWITCH_URL_UNSAFE`: a byte of it ahead of the user's `@` makes `protect_dest_uri` rewrite.
87pub(super) const URL_UNSAFE: &[u8] = b"\r\n #%&+:;<=>?@[\\]^`{|}\"";
88
89/// Whether `protect_dest_uri` rewrites a sofia destination before mod_sofia reads it, which
90/// moves where the dialed user ends.
91///
92/// `Suppressed` when `sofia_suppress_url_encoding` is true for the leg: set in the dial
93/// string's `<>`, `{}` or `[]` block, or exported by the originating channel. A variable
94/// only `set` on the originating channel, or a global, never reaches the leg. Otherwise
95/// `Encoded`, the switch's default. The dial string cannot settle it alone, so the caller
96/// names it.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum DestinationEncoding {
100    /// `sofia_suppress_url_encoding` unset or false.
101    Encoded,
102    /// `sofia_suppress_url_encoding` true.
103    Suppressed,
104}
105
106impl SofiaEndpoint {
107    /// Create a new SIP profile endpoint.
108    pub fn new(profile: impl Into<String>, destination: impl Into<String>) -> Self {
109        Self {
110            profile: profile.into(),
111            destination: destination.into(),
112            variables: None,
113        }
114    }
115
116    /// The user this leg dials, unencoded, cut from `destination` where mod_sofia cuts it.
117    ///
118    /// See [`DestinationEncoding`] for which to pass: `1000^2000@pbx.example.com` dials
119    /// `1000^2000` when `Encoded`, and `1000` with a `2000` To override when `Suppressed`.
120    /// Without `@` it is the registered user the profile looks up. `None` for a scheme with no
121    /// `@`, or an empty user. Owned only where the switch rebuilds text around a `/`.
122    /// `sip_destination_prefix`, `sip_invite_tel_params` and `sip_invite_req_uri` are not
123    /// applied.
124    pub fn user(&self, encoding: DestinationEncoding) -> Option<Cow<'_, str>> {
125        dialed_user(&self.destination, encoding, DialPath::Profile)
126    }
127}
128
129impl SofiaGateway {
130    /// Create a new SIP gateway endpoint.
131    pub fn new(gateway: impl Into<String>, destination: impl Into<String>) -> Self {
132        Self {
133            gateway: gateway.into(),
134            destination: destination.into(),
135            profile: None,
136            variables: None,
137        }
138    }
139
140    /// Set the SIP profile qualifier.
141    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
142        self.profile = Some(profile.into());
143        self
144    }
145
146    /// The user this leg dials, unencoded, cut from `destination` where mod_sofia cuts it.
147    ///
148    /// See [`DestinationEncoding`] for which to pass: `1000^2000@pbx.example.com` dials
149    /// `1000^2000` when `Encoded`, and `1000` with a `2000` To override when `Suppressed`.
150    /// Without `@` it is the whole destination, the switch appending the gateway's proxy host.
151    /// `;` parameters are dropped unless `Encoded` folds them into the user, and `Suppressed`
152    /// keeps a `sip:` scheme as text. `None` for an empty user. Owned only where the switch
153    /// rebuilds text around a `/`. The gateway's `destination-prefix`, `sip_invite_tel_params`
154    /// and `sip_invite_req_uri` are not applied.
155    pub fn user(&self, encoding: DestinationEncoding) -> Option<Cow<'_, str>> {
156        dialed_user(&self.destination, encoding, DialPath::Gateway)
157    }
158}
159
160#[derive(Clone, Copy)]
161enum DialPath {
162    Profile,
163    Gateway,
164}
165
166/// The user `sofia_outgoing_channel` dials from `destination`, read behind a deliverable profile
167/// or gateway.
168fn dialed_user(
169    destination: &str,
170    encoding: DestinationEncoding,
171    path: DialPath,
172) -> Option<Cow<'_, str>> {
173    let (text, encoded) = match encoding {
174        DestinationEncoding::Encoded => protect_dest_uri(destination),
175        DestinationEncoding::Suppressed => (Cow::Borrowed(destination), None),
176    };
177    let find = |byte: u8, from: usize, end: usize| {
178        (from..end).find(|&at| {
179            text.as_bytes()[at] == byte
180                && !encoded
181                    .as_ref()
182                    .is_some_and(|user| user.contains(&at))
183        })
184    };
185    let end = find(b'^', 0, text.len()).unwrap_or(text.len());
186    let user = match path {
187        DialPath::Profile => {
188            let scheme = match &encoded {
189                Some(user) if user.start == 0 => 0,
190                _ => scheme_len(&text[..end]),
191            };
192            match (scheme, find(b'@', 0, end)) {
193                (0, Some(at)) => 0..at,
194                (0, None) => 0..find(b'%', 0, end).unwrap_or(end),
195                (len, at) => len..at?,
196            }
197        }
198        DialPath::Gateway => {
199            let end = find(b';', 0, end).unwrap_or(end);
200            0..find(b'@', 0, end).unwrap_or(end)
201        }
202    };
203    if user.is_empty() {
204        return None;
205    }
206    Some(match text {
207        Cow::Borrowed(text) => Cow::Borrowed(&text[user]),
208        Cow::Owned(text) => Cow::Owned(text[user].to_owned()),
209    })
210}
211
212/// Length of a `sip:` or `sips:` scheme opening `text`, in any case, else 0.
213fn scheme_len(text: &str) -> usize {
214    ["sip:", "sips:"]
215        .into_iter()
216        .find(|scheme| {
217            text.get(..scheme.len())
218                .is_some_and(|head| head.eq_ignore_ascii_case(scheme))
219        })
220        .map_or(0, str::len)
221}
222
223/// `destination` as `protect_dest_uri` leaves it behind a deliverable profile or gateway, with the
224/// span of the user it encodes, where no later cut reads a separator.
225fn protect_dest_uri(destination: &str) -> (Cow<'_, str>, Option<Range<usize>>) {
226    let unchanged = (Cow::Borrowed(destination), None);
227    if !destination.contains('@') {
228        return unchanged;
229    }
230    let segment_start = destination
231        .rfind('/')
232        .map_or(0, |slash| slash + 1);
233    let (prefix, segment) = destination.split_at(segment_start);
234    let ahead = segment
235        .split_once('@')
236        .map_or(segment, |(ahead, _)| ahead);
237    if !ahead
238        .bytes()
239        .any(|byte| URL_UNSAFE.contains(&byte))
240    {
241        return unchanged;
242    }
243    let scheme = scheme_len(segment);
244    let rest = &segment[scheme..];
245    let Some(at) = rest.find('@') else {
246        return (
247            Cow::Borrowed(&destination[..segment_start.saturating_sub(1)]),
248            None,
249        );
250    };
251    let user = prefix.len()..prefix.len() + at;
252    let text = match (scheme, prefix.is_empty()) {
253        (0, _) => Cow::Borrowed(destination),
254        (_, true) => Cow::Borrowed(rest),
255        (_, false) => Cow::Owned(format!("{prefix}{rest}")),
256    };
257    (text, Some(user))
258}
259
260impl SofiaContact {
261    /// Create a new sofia_contact runtime expression.
262    pub fn new(user: impl Into<String>, domain: impl Into<String>) -> Self {
263        Self {
264            user: user.into(),
265            domain: domain.into(),
266            profile: None,
267            variables: None,
268        }
269    }
270
271    /// Set the SIP profile for the contact lookup.
272    ///
273    /// Pass `"*"` to search all loaded SIP profiles for the user's
274    /// registration instead of a single named profile.
275    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
276        self.profile = Some(profile.into());
277        self
278    }
279}
280
281impl_dial_string_with_variables!(SofiaEndpoint, write_module_text, |this| format!(
282    "sofia/{}/{}",
283    this.profile, this.destination
284));
285
286impl_dial_string_with_variables!(
287    SofiaGateway,
288    write_module_text,
289    |this| match &this.profile {
290        Some(p) => format!("sofia/gateway/{p}::{}/{}", this.gateway, this.destination),
291        None => format!("sofia/gateway/{}/{}", this.gateway, this.destination),
292    }
293);
294
295impl_dial_string_with_variables!(SofiaContact, write_expression, |this| match &this.profile {
296    Some(p) => format!("${{sofia_contact({p}/{}@{})}}", this.user, this.domain),
297    None => format!("${{sofia_contact({}@{})}}", this.user, this.domain),
298});
299
300impl SofiaEndpoint {
301    /// Refuse a profile `sofia_outgoing_channel` cuts at `/` or `^` or reads as the gateway path,
302    /// and one carrying `@`, where `protect_dest_uri` reads the text as a URI and may cut it.
303    pub(crate) fn check_deliverable(&self) -> Result<(), OriginateError> {
304        check_field("sofia", "profile", &self.profile, &["/", "^", "@"])?;
305        if self
306            .profile
307            .eq_ignore_ascii_case("gateway")
308        {
309            return Err(undeliverable(
310                "sofia",
311                "profile",
312                EndpointFieldFault::ReadsAsGateway,
313            ));
314        }
315        check_field("sofia", "destination", &self.destination, &[])
316    }
317
318    pub(crate) fn parse_bare(text: &str) -> Result<Self, OriginateError> {
319        let rest = after_prefix(text, "sofia/", "sofia")?;
320        let (profile, destination) = rest
321            .split_once('/')
322            .ok_or_else(|| {
323                OriginateError::ParseError("sofia endpoint needs profile/destination".into())
324            })?;
325        let ep = Self::new(profile, destination);
326        ep.check_deliverable()?;
327        Ok(ep)
328    }
329}
330
331impl SofiaGateway {
332    /// Refuse a gateway or profile `sofia_outgoing_channel` cuts at `/` or `^`, and a `::` the
333    /// `profile::gateway` lookup key would read at another place.
334    pub(crate) fn check_deliverable(&self) -> Result<(), OriginateError> {
335        const KIND: &str = "sofia gateway";
336        let gateway_splits: &[&str] = match self.profile {
337            Some(_) => &["/", "^", "@"],
338            None => &["/", "^", "@", "::"],
339        };
340        check_field(KIND, "gateway", &self.gateway, gateway_splits)?;
341        if let Some(profile) = &self.profile {
342            check_field(KIND, "profile", profile, &["/", "^", "@", "::"])?;
343            if profile.ends_with(':') {
344                return Err(undeliverable(
345                    KIND,
346                    "profile",
347                    EndpointFieldFault::ModuleSeparator("::"),
348                ));
349            }
350            if splits_into_threads(&format!("{profile}::{}", self.gateway)) {
351                let field = if profile.ends_with(":_") {
352                    "profile"
353                } else {
354                    "gateway"
355                };
356                return Err(undeliverable(
357                    KIND,
358                    field,
359                    EndpointFieldFault::EnterpriseSeparator,
360                ));
361            }
362        }
363        check_field(KIND, "destination", &self.destination, &[])
364    }
365
366    pub(crate) fn parse_bare(text: &str) -> Result<Self, OriginateError> {
367        let rest = after_prefix(text, "sofia/gateway/", "sofia gateway")?;
368        let (gateway_part, destination) = rest
369            .split_once('/')
370            .ok_or_else(|| {
371                OriginateError::ParseError("sofia gateway needs gateway/destination".into())
372            })?;
373        let ep = match gateway_part.split_once("::") {
374            Some((profile, gateway)) => Self::new(gateway, destination).with_profile(profile),
375            None => Self::new(gateway_part, destination),
376        };
377        ep.check_deliverable()?;
378        Ok(ep)
379    }
380}
381
382impl SofiaContact {
383    /// Refuse what `sofia_contact_function` splits at its first `~`, `/` and `@` and a `/` after
384    /// the domain, an empty domain or profile it replaces, and what a pass ahead of it reads.
385    pub(crate) fn check_deliverable(&self) -> Result<(), OriginateError> {
386        const KIND: &str = "sofia_contact";
387        let user_splits: &[&str] = match self.profile {
388            Some(_) => &["~", "@"],
389            None => &["~", "@", "/"],
390        };
391        check_expression_field(KIND, "user", &self.user, user_splits)?;
392        for (field, value) in [
393            ("domain", Some(&self.domain)),
394            (
395                "profile",
396                self.profile
397                    .as_ref(),
398            ),
399        ] {
400            let Some(value) = value else {
401                continue;
402            };
403            check_expression_field(KIND, field, value, &["~", "/"])?;
404            if value.is_empty() {
405                return Err(undeliverable(
406                    KIND,
407                    field,
408                    EndpointFieldFault::EmptyReadsAsDefault,
409                ));
410            }
411        }
412        Ok(())
413    }
414
415    pub(crate) fn parse_bare(text: &str) -> Result<Self, OriginateError> {
416        let inner = text
417            .strip_prefix("${sofia_contact(")
418            .and_then(|r| r.strip_suffix(")}"))
419            .ok_or_else(|| OriginateError::ParseError("not a sofia_contact expression".into()))?;
420        let (profile, user_at_domain) = if let Some((p, rest)) = inner.split_once('/') {
421            (Some(p.to_string()), rest)
422        } else {
423            (None, inner)
424        };
425        let (user, domain) = user_at_domain
426            .split_once('@')
427            .ok_or_else(|| OriginateError::ParseError("sofia_contact needs user@domain".into()))?;
428        let ep = Self {
429            user: user.into(),
430            domain: domain.into(),
431            profile,
432            variables: None,
433        };
434        ep.check_deliverable()?;
435        Ok(ep)
436    }
437}
438
439impl_endpoint_parse!(from_str: SofiaEndpoint, SofiaGateway);
440impl_endpoint_parse!(expression_from_str: SofiaContact => "sofia_contact");
441
442impl_endpoint_parse!(config:
443    SofiaEndpoint {
444        profile: String,
445        destination: String,
446        ;
447        variables: Option<Variables>,
448    }
449    SofiaContact {
450        user: String,
451        domain: String,
452        ;
453        profile: Option<String>,
454        variables: Option<Variables>,
455    }
456    SofiaGateway {
457        gateway: String,
458        destination: String,
459        ;
460        profile: Option<String>,
461        variables: Option<Variables>,
462    }
463);
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use crate::commands::variables::VariablesType;
469
470    // --- SofiaEndpoint ---
471
472    #[test]
473    fn sofia_endpoint_display() {
474        let ep = SofiaEndpoint {
475            profile: "internal".into(),
476            destination: "1000@example.com".into(),
477            variables: None,
478        };
479        assert_eq!(ep.to_string(), "sofia/internal/1000@example.com");
480    }
481
482    #[test]
483    fn sofia_endpoint_display_with_variables() {
484        let mut vars = Variables::new(VariablesType::Default);
485        vars.insert("originate_timeout", "30");
486        let ep = SofiaEndpoint {
487            profile: "internal".into(),
488            destination: "1000@example.com".into(),
489            variables: Some(vars),
490        };
491        assert_eq!(
492            ep.to_string(),
493            "{originate_timeout=30}sofia/internal/1000@example.com"
494        );
495    }
496
497    #[test]
498    fn sofia_endpoint_from_str() {
499        let ep: SofiaEndpoint = "sofia/internal/1000@example.com"
500            .parse()
501            .unwrap();
502        assert_eq!(ep.profile, "internal");
503        assert_eq!(ep.destination, "1000@example.com");
504        assert!(ep
505            .variables
506            .is_none());
507    }
508
509    #[test]
510    fn sofia_endpoint_from_str_with_variables() {
511        let ep: SofiaEndpoint = "{originate_timeout=30}sofia/internal/1000@example.com"
512            .parse()
513            .unwrap();
514        assert_eq!(ep.profile, "internal");
515        assert_eq!(ep.destination, "1000@example.com");
516        assert_eq!(
517            ep.variables
518                .unwrap()
519                .get("originate_timeout"),
520            Some("30")
521        );
522    }
523
524    #[test]
525    fn sofia_endpoint_round_trip() {
526        let ep = SofiaEndpoint {
527            profile: "external".into(),
528            destination: "sip:user@host:5060".into(),
529            variables: None,
530        };
531        let s = ep.to_string();
532        let parsed: SofiaEndpoint = s
533            .parse()
534            .unwrap();
535        assert_eq!(parsed, ep);
536    }
537
538    #[test]
539    fn serde_sofia_endpoint() {
540        let ep = SofiaEndpoint {
541            profile: "internal".into(),
542            destination: "1000@example.com".into(),
543            variables: None,
544        };
545        let json = serde_json::to_string(&ep).unwrap();
546        let parsed: SofiaEndpoint = serde_json::from_str(&json).unwrap();
547        assert_eq!(parsed, ep);
548    }
549
550    #[test]
551    fn serde_sofia_endpoint_with_variables() {
552        let mut vars = Variables::new(VariablesType::Default);
553        vars.insert("originate_timeout", "30");
554        let ep = SofiaEndpoint {
555            profile: "internal".into(),
556            destination: "1000@example.com".into(),
557            variables: Some(vars),
558        };
559        let json = serde_json::to_string(&ep).unwrap();
560        let parsed: SofiaEndpoint = serde_json::from_str(&json).unwrap();
561        assert_eq!(parsed, ep);
562    }
563
564    // --- SofiaGateway ---
565
566    #[test]
567    fn sofia_gateway_display() {
568        let ep = SofiaGateway {
569            gateway: "my_provider".into(),
570            destination: "18005551234".into(),
571            profile: None,
572            variables: None,
573        };
574        assert_eq!(ep.to_string(), "sofia/gateway/my_provider/18005551234");
575    }
576
577    #[test]
578    fn sofia_gateway_display_with_profile() {
579        let ep = SofiaGateway {
580            gateway: "my_provider".into(),
581            destination: "18005551234".into(),
582            profile: Some("external".into()),
583            variables: None,
584        };
585        assert_eq!(
586            ep.to_string(),
587            "sofia/gateway/external::my_provider/18005551234"
588        );
589    }
590
591    #[test]
592    fn sofia_gateway_display_with_variables() {
593        let mut vars = Variables::new(VariablesType::Default);
594        vars.insert("sip_h_X-Custom", "value");
595        let ep = SofiaGateway {
596            gateway: "gw1".into(),
597            destination: "1234".into(),
598            profile: None,
599            variables: Some(vars),
600        };
601        assert_eq!(
602            ep.to_string(),
603            "{sip_h_X-Custom=value}sofia/gateway/gw1/1234"
604        );
605    }
606
607    #[test]
608    fn sofia_gateway_from_str() {
609        let ep: SofiaGateway = "sofia/gateway/my_provider/18005551234"
610            .parse()
611            .unwrap();
612        assert_eq!(ep.gateway, "my_provider");
613        assert_eq!(ep.destination, "18005551234");
614        assert!(ep
615            .profile
616            .is_none());
617    }
618
619    #[test]
620    fn sofia_gateway_from_str_with_profile() {
621        let ep: SofiaGateway = "sofia/gateway/external::my_provider/18005551234"
622            .parse()
623            .unwrap();
624        assert_eq!(ep.gateway, "my_provider");
625        assert_eq!(ep.destination, "18005551234");
626        assert_eq!(
627            ep.profile
628                .as_deref(),
629            Some("external")
630        );
631    }
632
633    #[test]
634    fn sofia_gateway_round_trip() {
635        let ep = SofiaGateway {
636            gateway: "carrier".into(),
637            destination: "15551234567".into(),
638            profile: Some("external".into()),
639            variables: None,
640        };
641        let s = ep.to_string();
642        let parsed: SofiaGateway = s
643            .parse()
644            .unwrap();
645        assert_eq!(parsed, ep);
646    }
647
648    #[test]
649    fn serde_sofia_gateway() {
650        let ep = SofiaGateway {
651            gateway: "my_provider".into(),
652            destination: "18005551234".into(),
653            profile: None,
654            variables: None,
655        };
656        let json = serde_json::to_string(&ep).unwrap();
657        let parsed: SofiaGateway = serde_json::from_str(&json).unwrap();
658        assert_eq!(parsed, ep);
659    }
660
661    #[test]
662    fn serde_sofia_gateway_with_profile() {
663        let ep = SofiaGateway {
664            gateway: "my_provider".into(),
665            destination: "18005551234".into(),
666            profile: Some("external".into()),
667            variables: None,
668        };
669        let json = serde_json::to_string(&ep).unwrap();
670        let parsed: SofiaGateway = serde_json::from_str(&json).unwrap();
671        assert_eq!(parsed, ep);
672    }
673
674    // --- SofiaContact ---
675
676    #[test]
677    fn sofia_contact_display() {
678        let ep = SofiaContact {
679            user: "1000".into(),
680            domain: "example.com".into(),
681            profile: None,
682            variables: None,
683        };
684        assert_eq!(ep.to_string(), "${sofia_contact(1000@example.com)}");
685    }
686
687    #[test]
688    fn sofia_contact_display_with_profile() {
689        let ep = SofiaContact {
690            user: "1000".into(),
691            domain: "example.com".into(),
692            profile: Some("internal".into()),
693            variables: None,
694        };
695        assert_eq!(
696            ep.to_string(),
697            "${sofia_contact(internal/1000@example.com)}"
698        );
699    }
700
701    #[test]
702    fn sofia_contact_display_all_profiles() {
703        let ep = SofiaContact {
704            user: "1000".into(),
705            domain: "example.com".into(),
706            profile: Some("*".into()),
707            variables: None,
708        };
709        assert_eq!(ep.to_string(), "${sofia_contact(*/1000@example.com)}");
710    }
711
712    #[test]
713    fn sofia_contact_display_with_variables() {
714        let mut vars = Variables::new(VariablesType::Default);
715        vars.insert("presence_id", "1000@example.com");
716        let ep = SofiaContact {
717            user: "1000".into(),
718            domain: "example.com".into(),
719            profile: None,
720            variables: Some(vars),
721        };
722        assert_eq!(
723            ep.to_string(),
724            "{presence_id=1000@example.com}${sofia_contact(1000@example.com)}"
725        );
726    }
727
728    #[test]
729    fn sofia_contact_from_str() {
730        let ep = SofiaContact::parse_bare("${sofia_contact(1000@example.com)}").unwrap();
731        assert_eq!(ep.user, "1000");
732        assert_eq!(ep.domain, "example.com");
733        assert!(ep
734            .profile
735            .is_none());
736    }
737
738    #[test]
739    fn sofia_contact_from_str_with_profile() {
740        let ep = SofiaContact::parse_bare("${sofia_contact(internal/1000@example.com)}").unwrap();
741        assert_eq!(ep.user, "1000");
742        assert_eq!(ep.domain, "example.com");
743        assert_eq!(
744            ep.profile
745                .as_deref(),
746            Some("internal")
747        );
748    }
749
750    #[test]
751    fn sofia_contact_round_trip() {
752        let ep = SofiaContact {
753            user: "bob".into(),
754            domain: "example.com".into(),
755            profile: Some("*".into()),
756            variables: None,
757        };
758        let s = ep.to_string();
759        let parsed = SofiaContact::parse_bare(&s).unwrap();
760        assert_eq!(parsed, ep);
761    }
762
763    #[test]
764    fn sofia_contact_profile_with_at_sign() {
765        let ep = SofiaContact::new("1000", "example.com").with_profile("user@realm");
766        let s = ep.to_string();
767        assert_eq!(s, "${sofia_contact(user@realm/1000@example.com)}");
768        // Round-trip: the first @ is in profile, the parser splits on / first
769        // then finds @ in the user_at_domain part
770        let parsed = SofiaContact::parse_bare(&s).unwrap();
771        assert_eq!(
772            parsed
773                .profile
774                .as_deref(),
775            Some("user@realm")
776        );
777        assert_eq!(parsed.user, "1000");
778        assert_eq!(parsed.domain, "example.com");
779    }
780
781    #[test]
782    fn serde_sofia_contact() {
783        let ep = SofiaContact {
784            user: "1000".into(),
785            domain: "example.com".into(),
786            profile: Some("*".into()),
787            variables: None,
788        };
789        let json = serde_json::to_string(&ep).unwrap();
790        let parsed: SofiaContact = serde_json::from_str(&json).unwrap();
791        assert_eq!(parsed, ep);
792    }
793
794    // --- user() ---
795
796    type UserCase = (&'static str, Option<&'static str>, Option<&'static str>);
797
798    fn assert_users(
799        cases: &[UserCase],
800        user: impl Fn(&str, DestinationEncoding) -> Option<String>,
801    ) {
802        for &(destination, encoded, suppressed) in cases {
803            assert_eq!(
804                user(destination, DestinationEncoding::Encoded).as_deref(),
805                encoded,
806                "{destination:?} encoded"
807            );
808            assert_eq!(
809                user(destination, DestinationEncoding::Suppressed).as_deref(),
810                suppressed,
811                "{destination:?} suppressed"
812            );
813        }
814    }
815
816    #[test]
817    fn sofia_endpoint_user() {
818        assert_users(
819            &[
820                (
821                    "5550001234@pbx.example.com",
822                    Some("5550001234"),
823                    Some("5550001234"),
824                ),
825                ("sip:1000@pbx.example.com:5060", Some("1000"), Some("1000")),
826                ("SIPS:1000@pbx.example.com", Some("1000"), Some("1000")),
827                ("1000^2000@pbx.example.com", Some("1000^2000"), Some("1000")),
828                ("1000@pbx.example.com^2000", Some("1000"), Some("1000")),
829                ("1000%pbx.example.com", Some("1000"), Some("1000")),
830                ("1000", Some("1000"), Some("1000")),
831                ("sip:pbx.example.com", None, None),
832                ("a@b@pbx.example.com", Some("a"), Some("a")),
833                ("x@pbx.example.com/z w", Some("x"), Some("x")),
834                ("a/sip:b@pbx.example.com", Some("a/b"), Some("a/sip:b")),
835                ("@pbx.example.com", None, None),
836                (
837                    "+15550001234;npdi=yes@pbx.example.com",
838                    Some("+15550001234;npdi=yes"),
839                    Some("+15550001234;npdi=yes"),
840                ),
841            ],
842            |destination, encoding| {
843                SofiaEndpoint::new("internal", destination)
844                    .user(encoding)
845                    .map(Cow::into_owned)
846            },
847        );
848    }
849
850    #[test]
851    fn sofia_gateway_user() {
852        assert_users(
853            &[
854                ("5550009999", Some("5550009999"), Some("5550009999")),
855                (
856                    "+15550001234@agent.example.com",
857                    Some("+15550001234"),
858                    Some("+15550001234"),
859                ),
860                ("1000;transport=tcp", Some("1000"), Some("1000")),
861                ("1000;x=a@b.example.com", Some("1000;x=a"), Some("1000")),
862                ("sip:1000@example.com", Some("1000"), Some("sip:1000")),
863                ("1000^2000@example.com", Some("1000^2000"), Some("1000")),
864                ("", None, None),
865            ],
866            |destination, encoding| {
867                SofiaGateway::new("montreal", destination)
868                    .with_profile("voipms")
869                    .user(encoding)
870                    .map(Cow::into_owned)
871            },
872        );
873    }
874}