Skip to main content

sipx_ua/
packages.rs

1//! The two event packages that report state sipx already keeps (RFC 4235, RFC 3680).
2//!
3//! Dialogs and registrations. Both come first among packages for the same reason: sipx *has* this
4//! state already — a dialog store and a registration lease — so they exercise [`crate::subscribe`]
5//! without also needing a state model of their own. Presence does need one, which is why it is a
6//! separate story.
7//!
8//! Together they are what a busy-lamp field on a desk phone subscribes to: `dialog` says whether a
9//! line is ringing or in a call, `reg` says whether the phone is registered at all.
10//! **Supported** (`S-35`): [`sipx_call::Notifier`](https://docs.rs/sipx-call/latest/sipx_call/struct.Notifier.html)
11//! selects these package documents through the live endpoint dispatcher. Breaking changes receive
12//! migration guidance while sipx remains pre-1.0.
13//!
14
15use std::fmt::Write as _;
16
17/// The MIME type a `dialog` notification carries (RFC 4235 §4).
18pub const DIALOG_INFO_TYPE: &str = "application/dialog-info+xml";
19/// The MIME type a `reg` notification carries (RFC 3680 §4).
20pub const REGINFO_TYPE: &str = "application/reginfo+xml";
21
22/// Where a dialog has got to (RFC 4235 §3.7.1).
23///
24/// The five states of the RFC's own state machine, and they are not decoration: a watcher renders
25/// `early` as "ringing" and `confirmed` as "on a call", so collapsing them is a busy-lamp field
26/// that lights up at the wrong time.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DialogState {
29    /// The UAC has sent an INVITE and heard nothing.
30    Trying,
31    /// A provisional arrived without a tag, so there is no dialog identifier yet.
32    Proceeding,
33    /// A provisional with a tag: an early dialog exists.
34    Early,
35    /// A 2xx arrived. The call is up.
36    Confirmed,
37    /// Cancelled, rejected, ended with a BYE, timed out or replaced.
38    Terminated,
39}
40
41impl DialogState {
42    /// The token as it appears in the document.
43    #[must_use]
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Self::Trying => "trying",
47            Self::Proceeding => "proceeding",
48            Self::Early => "early",
49            Self::Confirmed => "confirmed",
50            Self::Terminated => "terminated",
51        }
52    }
53}
54
55/// Which side started the dialog (RFC 4235 §4.1).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Direction {
58    /// This endpoint placed the call.
59    Initiator,
60    /// It received it.
61    Recipient,
62}
63
64impl Direction {
65    /// The token as it appears in the document.
66    #[must_use]
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Self::Initiator => "initiator",
70            Self::Recipient => "recipient",
71        }
72    }
73}
74
75/// One dialog, as a watcher sees it.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Dialog {
78    /// An identifier for this dialog within the document.
79    pub id: String,
80    /// Where it has got to.
81    pub state: DialogState,
82    /// Which side started it.
83    pub direction: Direction,
84}
85
86/// The `dialog` event package (RFC 4235).
87///
88/// Holds one watcher's view: the dialogs, and the version counter that view is up to.
89#[derive(Debug)]
90pub struct DialogWatch {
91    entity: String,
92    version: u32,
93    /// Whether the next document is the first, which must be `full`.
94    sent_full: bool,
95}
96
97impl DialogWatch {
98    /// A watch on this address of record.
99    #[must_use]
100    pub fn new(entity: impl Into<String>) -> Self {
101        Self {
102            entity: entity.into(),
103            version: 0,
104            sent_full: false,
105        }
106    }
107
108    /// The `Event` package name.
109    #[must_use]
110    pub fn package() -> &'static str {
111        "dialog"
112    }
113
114    /// The version the next document will carry.
115    #[must_use]
116    pub fn version(&self) -> u32 {
117        self.version
118    }
119
120    /// The next document for this watcher.
121    ///
122    /// **The first is always `full` and the rest are `partial`** (§4.1). A watcher that joined
123    /// mid-call is given the whole picture once and told about changes after that; sending only
124    /// changes from the start would leave it inferring a state nobody ever described.
125    ///
126    /// The version is per *subscription*, not per resource (§4.1): two watchers of the same set of
127    /// dialogs each count from zero, and sharing a counter would make one of them see gaps.
128    pub fn document(&mut self, dialogs: &[Dialog]) -> String {
129        let full = !self.sent_full;
130        self.sent_full = true;
131        let version = self.version;
132        // Saturating rather than wrapping: §4.1 requires a non-negative 32-bit integer, and a
133        // counter that wrapped to zero would look to a watcher like a new subscription.
134        self.version = self.version.saturating_add(1);
135
136        let mut out = String::with_capacity(256);
137        out.push_str("<?xml version=\"1.0\"?>\n");
138        let _ = write!(
139            out,
140            "<dialog-info xmlns=\"urn:ietf:params:xml:ns:dialog-info\" version=\"{version}\" \
141             state=\"{}\" entity=\"{}\">",
142            if full { "full" } else { "partial" },
143            escape(&self.entity)
144        );
145        for dialog in dialogs {
146            let _ = write!(
147                out,
148                "\n  <dialog id=\"{}\" direction=\"{}\">\n    <state>{}</state>\n  </dialog>",
149                escape(&dialog.id),
150                dialog.direction.as_str(),
151                dialog.state.as_str()
152            );
153        }
154        out.push_str("\n</dialog-info>\n");
155        out
156    }
157}
158
159/// What happened to a registered contact (RFC 3680 §5.1).
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ContactEvent {
162    /// A new binding.
163    Registered,
164    /// An existing one, refreshed.
165    Refreshed,
166    /// It ran out of time.
167    Expired,
168    /// It was removed deliberately.
169    Unregistered,
170}
171
172impl ContactEvent {
173    /// The token as it appears in the document.
174    #[must_use]
175    pub fn as_str(self) -> &'static str {
176        match self {
177            Self::Registered => "registered",
178            Self::Refreshed => "refreshed",
179            Self::Expired => "expired",
180            Self::Unregistered => "unregistered",
181        }
182    }
183
184    /// Whether the contact is usable after this event.
185    ///
186    /// The distinction a watcher acts on: `expired` and `unregistered` mean the contact is gone,
187    /// and the two are kept apart because *why* it went is what a display says — "lost its
188    /// connection" reads differently from "logged out".
189    #[must_use]
190    pub fn still_bound(self) -> bool {
191        matches!(self, Self::Registered | Self::Refreshed)
192    }
193}
194
195/// One registered contact, as a watcher sees it.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Contact {
198    /// An identifier for this contact within the document.
199    pub id: String,
200    /// The contact URI.
201    pub uri: String,
202    /// What just happened to it.
203    pub event: ContactEvent,
204}
205
206/// The `reg` event package (RFC 3680).
207#[derive(Debug)]
208pub struct RegistrationWatch {
209    entity: String,
210    version: u32,
211    sent_full: bool,
212}
213
214impl RegistrationWatch {
215    /// A watch on this address of record.
216    #[must_use]
217    pub fn new(entity: impl Into<String>) -> Self {
218        Self {
219            entity: entity.into(),
220            version: 0,
221            sent_full: false,
222        }
223    }
224
225    /// The `Event` package name.
226    #[must_use]
227    pub fn package() -> &'static str {
228        "reg"
229    }
230
231    /// The version the next document will carry.
232    #[must_use]
233    pub fn version(&self) -> u32 {
234        self.version
235    }
236
237    /// The next document for this watcher, with the same full-then-partial discipline.
238    pub fn document(&mut self, contacts: &[Contact]) -> String {
239        let full = !self.sent_full;
240        self.sent_full = true;
241        let version = self.version;
242        self.version = self.version.saturating_add(1);
243
244        let mut out = String::with_capacity(256);
245        out.push_str("<?xml version=\"1.0\"?>\n");
246        let _ = write!(
247            out,
248            "<reginfo xmlns=\"urn:ietf:params:xml:ns:reginfo\" version=\"{version}\" \
249             state=\"{}\">",
250            if full { "full" } else { "partial" }
251        );
252        let bound = contacts.iter().any(|contact| contact.event.still_bound());
253        let _ = write!(
254            out,
255            "\n  <registration aor=\"{}\" id=\"0\" state=\"{}\">",
256            escape(&self.entity),
257            if bound { "active" } else { "terminated" }
258        );
259        for contact in contacts {
260            let _ = write!(
261                out,
262                "\n    <contact id=\"{}\" state=\"{}\" event=\"{}\">\n      <uri>{}</uri>\n    </contact>",
263                escape(&contact.id),
264                if contact.event.still_bound() {
265                    "active"
266                } else {
267                    "terminated"
268                },
269                contact.event.as_str(),
270                escape(&contact.uri)
271            );
272        }
273        out.push_str("\n  </registration>\n</reginfo>\n");
274        out
275    }
276}
277
278/// Escape the five characters XML cannot carry literally.
279///
280/// A SIP URI can contain `&` in its parameters, and an unescaped one makes the document
281/// unparseable — a watcher then sees nothing at all rather than a slightly wrong dialog.
282fn escape(value: &str) -> String {
283    let mut out = String::with_capacity(value.len());
284    for character in value.chars() {
285        match character {
286            '&' => out.push_str("&amp;"),
287            '<' => out.push_str("&lt;"),
288            '>' => out.push_str("&gt;"),
289            '"' => out.push_str("&quot;"),
290            '\'' => out.push_str("&apos;"),
291            other => out.push(other),
292        }
293    }
294    out
295}
296
297#[cfg(test)]
298#[allow(
299    clippy::unwrap_used,
300    clippy::expect_used,
301    clippy::panic,
302    clippy::indexing_slicing
303)]
304mod tests {
305    use super::*;
306
307    fn ringing() -> Dialog {
308        Dialog {
309            id: "d1".to_owned(),
310            state: DialogState::Early,
311            direction: Direction::Recipient,
312        }
313    }
314
315    /// §4.1: "Versions start at 0, and increment by one for each new document sent to a
316    /// subscriber", and the first document is `full`.
317    #[test]
318    fn the_first_document_is_full_and_the_rest_are_partial() {
319        let mut watch = DialogWatch::new("sip:alice@sipx.test");
320        let first = watch.document(&[ringing()]);
321        assert!(first.contains("version=\"0\""), "{first}");
322        assert!(first.contains("state=\"full\""), "{first}");
323
324        let second = watch.document(&[ringing()]);
325        assert!(second.contains("version=\"1\""), "{second}");
326        assert!(
327            second.contains("state=\"partial\""),
328            "a watcher is given the whole picture once and told about changes after: {second}"
329        );
330    }
331
332    /// §4.1: versions are "scoped within a subscription". Two watchers each count from zero, and
333    /// sharing a counter would make one of them see gaps it cannot explain.
334    #[test]
335    fn two_watchers_each_count_from_zero() {
336        let mut one = DialogWatch::new("sip:alice@sipx.test");
337        let mut other = DialogWatch::new("sip:alice@sipx.test");
338        let _ = one.document(&[ringing()]);
339        let _ = one.document(&[ringing()]);
340        assert_eq!(one.version(), 2);
341        assert_eq!(other.version(), 0, "a second watcher starts its own count");
342        assert!(other.document(&[ringing()]).contains("version=\"0\""));
343    }
344
345    #[test]
346    fn the_version_increases_monotonically() {
347        let mut watch = DialogWatch::new("sip:alice@sipx.test");
348        let mut seen = Vec::new();
349        for _ in 0..5u32 {
350            let document = watch.document(&[ringing()]);
351            // From the `dialog-info` element, not from `<?xml version="1.0"?>` — which is the
352            // first `version="` in the document and is not the one that counts.
353            let version: u32 = document
354                .split("<dialog-info")
355                .nth(1)
356                .and_then(|element| element.split("version=\"").nth(1))
357                .and_then(|rest| rest.split('"').next())
358                .and_then(|text| text.parse().ok())
359                .expect("a version on the dialog-info element");
360            seen.push(version);
361        }
362        assert_eq!(seen, vec![0, 1, 2, 3, 4]);
363    }
364
365    /// The story's failing-first test.
366    ///
367    /// A watcher sees a call ring and then end. The states are what a busy-lamp field renders, so
368    /// `early` and `confirmed` reaching it in the right order *is* the feature.
369    #[test]
370    fn a_watcher_sees_a_dialog_reach_confirmed_and_then_terminate() {
371        let mut watch = DialogWatch::new("sip:alice@sipx.test");
372
373        let ringing = watch.document(&[Dialog {
374            id: "d1".to_owned(),
375            state: DialogState::Early,
376            direction: Direction::Recipient,
377        }]);
378        assert!(ringing.contains("<state>early</state>"), "{ringing}");
379
380        let answered = watch.document(&[Dialog {
381            id: "d1".to_owned(),
382            state: DialogState::Confirmed,
383            direction: Direction::Recipient,
384        }]);
385        assert!(answered.contains("<state>confirmed</state>"), "{answered}");
386
387        let ended = watch.document(&[Dialog {
388            id: "d1".to_owned(),
389            state: DialogState::Terminated,
390            direction: Direction::Recipient,
391        }]);
392        assert!(ended.contains("<state>terminated</state>"), "{ended}");
393
394        // And the same dialog throughout, or a watcher would see three calls rather than one.
395        for document in [&ringing, &answered, &ended] {
396            assert!(document.contains("id=\"d1\""), "{document}");
397        }
398    }
399
400    #[test]
401    fn a_dialog_document_names_its_namespace_and_entity() {
402        let mut watch = DialogWatch::new("sip:alice@sipx.test");
403        let document = watch.document(&[]);
404        assert!(
405            document.contains("xmlns=\"urn:ietf:params:xml:ns:dialog-info\""),
406            "{document}"
407        );
408        assert!(
409            document.contains("entity=\"sip:alice@sipx.test\""),
410            "{document}"
411        );
412        assert_eq!(DIALOG_INFO_TYPE, "application/dialog-info+xml");
413    }
414
415    #[test]
416    fn every_dialog_state_has_the_spelling_the_rfc_gives() {
417        for (state, token) in [
418            (DialogState::Trying, "trying"),
419            (DialogState::Proceeding, "proceeding"),
420            (DialogState::Early, "early"),
421            (DialogState::Confirmed, "confirmed"),
422            (DialogState::Terminated, "terminated"),
423        ] {
424            assert_eq!(state.as_str(), token);
425        }
426    }
427
428    /// RFC 3680: the `reg` package reports per-contact state and the event that changed it.
429    #[test]
430    fn a_registration_document_reports_the_event_that_changed_a_contact() {
431        let mut watch = RegistrationWatch::new("sip:alice@sipx.test");
432        let document = watch.document(&[Contact {
433            id: "c1".to_owned(),
434            uri: "sip:alice@192.0.2.5".to_owned(),
435            event: ContactEvent::Registered,
436        }]);
437        assert!(
438            document.contains("xmlns=\"urn:ietf:params:xml:ns:reginfo\""),
439            "{document}"
440        );
441        assert!(document.contains("event=\"registered\""), "{document}");
442        assert!(document.contains("state=\"active\""), "{document}");
443        assert!(
444            document.contains("<uri>sip:alice@192.0.2.5</uri>"),
445            "{document}"
446        );
447        assert_eq!(REGINFO_TYPE, "application/reginfo+xml");
448    }
449
450    /// `expired` and `unregistered` both mean gone, and are kept apart because *why* it went is
451    /// what a display says: "lost its connection" reads differently from "logged out".
452    #[test]
453    fn an_expired_contact_and_an_unregistered_one_are_both_gone_and_not_the_same() {
454        assert!(!ContactEvent::Expired.still_bound());
455        assert!(!ContactEvent::Unregistered.still_bound());
456        assert!(ContactEvent::Registered.still_bound());
457        assert!(ContactEvent::Refreshed.still_bound());
458        assert_ne!(ContactEvent::Expired, ContactEvent::Unregistered);
459
460        let mut watch = RegistrationWatch::new("sip:alice@sipx.test");
461        let document = watch.document(&[Contact {
462            id: "c1".to_owned(),
463            uri: "sip:alice@192.0.2.5".to_owned(),
464            event: ContactEvent::Expired,
465        }]);
466        assert!(document.contains("event=\"expired\""), "{document}");
467        assert!(
468            document.contains("state=\"terminated\""),
469            "a contact that expired is not active: {document}"
470        );
471    }
472
473    /// A SIP URI can carry `&` in its parameters, and an unescaped one makes the whole document
474    /// unparseable — a watcher then sees nothing at all rather than a slightly wrong dialog.
475    #[test]
476    fn a_uri_containing_xml_metacharacters_does_not_break_the_document() {
477        let mut watch = RegistrationWatch::new("sip:alice@sipx.test");
478        let document = watch.document(&[Contact {
479            id: "c1".to_owned(),
480            uri: "sip:alice@host?X=1&Y=<2>".to_owned(),
481            event: ContactEvent::Registered,
482        }]);
483        assert!(document.contains("&amp;"), "{document}");
484        assert!(document.contains("&lt;2&gt;"), "{document}");
485        assert!(
486            !document.contains("Y=<2>"),
487            "the raw angle brackets must not survive: {document}"
488        );
489    }
490
491    #[test]
492    fn both_packages_are_named_the_way_a_subscriber_asks_for_them() {
493        assert_eq!(DialogWatch::package(), "dialog");
494        assert_eq!(RegistrationWatch::package(), "reg");
495    }
496}