Skip to main content

sipx_sip/
event.rs

1//! Subscriptions and notifications, as decisions (RFC 6665).
2//!
3//! The pure half of the event framework: what a `Subscription-State` says, when a subscription is
4//! over, what a refresh does to its expiry, and whether a package is one this side serves. No
5//! dialog, no clock, no socket — those belong to whoever drives it.
6//!
7//! sipx has had exactly one subscription since `S-9`: the implicit one a REFER creates. That one
8//! works and is not a framework. What makes this a framework is that a *package* is a name and a
9//! body type, and everything else — establishing, refreshing, expiring, terminating, refusing an
10//! unknown one — is the same whichever package it is.
11
12use std::time::Duration;
13
14/// The state of a subscription, as a `Subscription-State` header says it (RFC 6665 §4.1.3).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum State {
17    /// Accepted and authorised. Notifications are flowing.
18    Active,
19    /// Received, but the notifier has not decided yet.
20    ///
21    /// A distinct state rather than a slow `active`, because §4.1.3 makes it one: a subscriber
22    /// that treated `pending` as active would report a presence it has not been granted.
23    Pending,
24    /// Over. Nothing further will arrive on this subscription.
25    Terminated,
26}
27
28impl State {
29    /// The token as it appears in the header.
30    #[must_use]
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Active => "active",
34            Self::Pending => "pending",
35            Self::Terminated => "terminated",
36        }
37    }
38
39    /// The state a token names.
40    #[must_use]
41    pub fn parse(token: &str) -> Option<Self> {
42        [Self::Active, Self::Pending, Self::Terminated]
43            .into_iter()
44            .find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
45    }
46}
47
48/// Why a subscription ended (RFC 6665 §4.1.3).
49///
50/// The distinction that matters to a subscriber is whether to try again, and these are not
51/// interchangeable about it: `deactivated` says re-subscribe now, `probation` says wait,
52/// `rejected` says do not, and `noresource` says there is nothing left to subscribe to.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Reason {
55    /// The subscription ended and the subscriber should re-subscribe immediately.
56    Deactivated,
57    /// Ended for now; re-subscribe after `retry-after`.
58    Probation,
59    /// Refused by policy. Do not re-subscribe.
60    Rejected,
61    /// The subscription simply expired.
62    Timeout,
63    /// The notifier could not continue, for a reason of its own.
64    GiveUp,
65    /// The resource being watched no longer exists.
66    NoResource,
67    /// A subscription the notifier can no longer honour in the terms agreed.
68    Invariant,
69    /// The filter was not one the notifier could apply.
70    BadFilter,
71}
72
73impl Reason {
74    /// The token as it appears in the header.
75    #[must_use]
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::Deactivated => "deactivated",
79            Self::Probation => "probation",
80            Self::Rejected => "rejected",
81            Self::Timeout => "timeout",
82            Self::GiveUp => "giveup",
83            Self::NoResource => "noresource",
84            Self::Invariant => "invariant",
85            Self::BadFilter => "badfilter",
86        }
87    }
88
89    /// The reason a token names.
90    #[must_use]
91    pub fn parse(token: &str) -> Option<Self> {
92        [
93            Self::Deactivated,
94            Self::Probation,
95            Self::Rejected,
96            Self::Timeout,
97            Self::GiveUp,
98            Self::NoResource,
99            Self::Invariant,
100            Self::BadFilter,
101        ]
102        .into_iter()
103        .find(|candidate| token.trim().eq_ignore_ascii_case(candidate.as_str()))
104    }
105
106    /// Whether a subscriber should try again.
107    ///
108    /// §4.1.3 gives each reason its own answer, and collapsing them is how a client either gives up
109    /// on a subscription that was only briefly unavailable, or hammers one it has been refused.
110    #[must_use]
111    pub fn should_resubscribe(self) -> bool {
112        matches!(self, Self::Deactivated | Self::Probation | Self::Timeout)
113    }
114}
115
116/// A parsed `Subscription-State` (RFC 6665 §4.1.3).
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Subscription {
119    /// Which state.
120    pub state: State,
121    /// How long is left, for `active` and `pending`.
122    ///
123    /// §4.1.3 makes it a SHOULD there and meaningless on `terminated`.
124    pub expires: Option<Duration>,
125    /// Why it ended, for `terminated`.
126    pub reason: Option<Reason>,
127    /// How long to wait before re-subscribing, when the reason gives one.
128    pub retry_after: Option<Duration>,
129}
130
131impl Subscription {
132    /// An active subscription with this much left.
133    #[must_use]
134    pub fn active(expires: Duration) -> Self {
135        Self {
136            state: State::Active,
137            expires: Some(expires),
138            reason: None,
139            retry_after: None,
140        }
141    }
142
143    /// A terminated subscription, with the reason it ended.
144    #[must_use]
145    pub fn terminated(reason: Reason) -> Self {
146        Self {
147            state: State::Terminated,
148            expires: None,
149            reason: Some(reason),
150            retry_after: None,
151        }
152    }
153
154    /// Whether this says the subscription is over.
155    #[must_use]
156    pub fn is_terminated(self_: &Self) -> bool {
157        self_.state == State::Terminated
158    }
159
160    /// Read a `Subscription-State` value.
161    #[must_use]
162    pub fn parse(value: &[u8]) -> Option<Self> {
163        let text = String::from_utf8_lossy(value);
164        let mut parts = text.split(';');
165        let state = State::parse(parts.next()?)?;
166        let mut subscription = Self {
167            state,
168            expires: None,
169            reason: None,
170            retry_after: None,
171        };
172        for parameter in parts {
173            let Some((name, value)) = parameter.split_once('=') else {
174                continue;
175            };
176            let (name, value) = (name.trim(), value.trim().trim_matches('"'));
177            if name.eq_ignore_ascii_case("expires") {
178                subscription.expires = value.parse().ok().map(Duration::from_secs);
179            } else if name.eq_ignore_ascii_case("reason") {
180                subscription.reason = Reason::parse(value);
181            } else if name.eq_ignore_ascii_case("retry-after") {
182                subscription.retry_after = value.parse().ok().map(Duration::from_secs);
183            }
184        }
185        Some(subscription)
186    }
187
188    /// Render as a `Subscription-State` value.
189    #[must_use]
190    pub fn to_value(&self) -> String {
191        use std::fmt::Write as _;
192        let mut out = self.state.as_str().to_owned();
193        if self.state != State::Terminated
194            && let Some(expires) = self.expires
195        {
196            let _ = write!(out, ";expires={}", expires.as_secs());
197        }
198        if let Some(reason) = self.reason {
199            let _ = write!(out, ";reason={}", reason.as_str());
200        }
201        if let Some(retry) = self.retry_after {
202            let _ = write!(out, ";retry-after={}", retry.as_secs());
203        }
204        out
205    }
206}
207
208/// The expiry a notifier grants for a requested one (RFC 6665 §4.2.1.1).
209///
210/// "The server MAY shorten the interval but MUST NOT lengthen it" — the same rule REGISTER has, and
211/// for the same reason: the shorter of the two is what both sides can agree on without one of them
212/// believing in a subscription the other has forgotten.
213///
214/// A request for zero is an unsubscribe (§3.1.1) and stays zero however generous the policy.
215#[must_use]
216pub fn granted_expiry(requested: Duration, policy_maximum: Duration) -> Duration {
217    requested.min(policy_maximum)
218}
219
220/// Whether a SUBSCRIBE is asking to end the subscription rather than to have one (§3.1.1).
221///
222/// "A SUBSCRIBE request with an 'Expires' of 0 constitutes a request to unsubscribe from the
223/// matching subscription." It is not a degenerate subscription of no duration — the notifier still
224/// owes a terminating NOTIFY (§4.2.1.4), which is the part that is easy to miss.
225#[must_use]
226pub fn is_unsubscribe(requested: Duration) -> bool {
227    requested.is_zero()
228}
229
230/// The packages a notifier serves, by `Event` name.
231///
232/// A framework rather than a switch statement: a package is a name here, and everything the
233/// framework does — establishing, refreshing, expiring, terminating, refusing — is the same
234/// whichever one it is.
235#[derive(Debug, Clone, Default)]
236pub struct Packages {
237    names: Vec<String>,
238}
239
240impl Packages {
241    /// A notifier that serves nothing yet.
242    #[must_use]
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Serve this package.
248    #[must_use]
249    pub fn with(mut self, name: impl Into<String>) -> Self {
250        let name = name.into();
251        if !self
252            .names
253            .iter()
254            .any(|held| held.eq_ignore_ascii_case(&name))
255        {
256            self.names.push(name);
257        }
258        self
259    }
260
261    /// Whether a package name is one this side serves.
262    ///
263    /// Case-insensitively, and the *template* is what is matched: RFC 6665 §8.2.1 makes an `Event`
264    /// value `package` optionally followed by `.template`, and a notifier that serves `dialog`
265    /// serves `dialog.winfo` requests to the extent of recognising them.
266    #[must_use]
267    pub fn serves(&self, event: &str) -> bool {
268        let package = event.split(';').next().unwrap_or_default().trim();
269        let base = package.split('.').next().unwrap_or_default();
270        self.names
271            .iter()
272            .any(|held| held.eq_ignore_ascii_case(base) || held.eq_ignore_ascii_case(package))
273    }
274
275    /// The names, for an `Allow-Events` header.
276    #[must_use]
277    pub fn names(&self) -> &[String] {
278        &self.names
279    }
280
281    /// The `Allow-Events` value advertising them (RFC 6665 §4.4.5).
282    #[must_use]
283    pub fn allow_events(&self) -> String {
284        self.names.join(", ")
285    }
286}
287
288/// The status a SUBSCRIBE naming an unserved package is refused with (RFC 6665 §4.2.1.1).
289///
290/// 489 and not 400 or 501. It is a specific answer to a specific question — "I do not have that
291/// package" — and a subscriber that gets it knows not to retry, where a 400 tells it its request
292/// was malformed and a 501 that the *method* is unimplemented.
293pub const BAD_EVENT: u16 = 489;
294
295#[cfg(test)]
296#[allow(
297    clippy::unwrap_used,
298    clippy::expect_used,
299    clippy::panic,
300    clippy::indexing_slicing
301)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn a_subscription_state_round_trips() {
307        let active = Subscription::active(Duration::from_secs(3600));
308        assert_eq!(active.to_value(), "active;expires=3600");
309        assert_eq!(Subscription::parse(b"active;expires=3600"), Some(active));
310
311        let ended = Subscription::terminated(Reason::Timeout);
312        assert_eq!(ended.to_value(), "terminated;reason=timeout");
313        assert_eq!(
314            Subscription::parse(b"terminated;reason=timeout"),
315            Some(ended)
316        );
317    }
318
319    /// §4.1.3: `expires` is meaningless on a terminated subscription, and emitting one would
320    /// suggest there is time left on something that is over.
321    #[test]
322    fn a_terminated_state_carries_no_expiry() {
323        let mut ended = Subscription::terminated(Reason::NoResource);
324        ended.expires = Some(Duration::from_secs(60));
325        assert_eq!(ended.to_value(), "terminated;reason=noresource");
326    }
327
328    #[test]
329    fn the_three_states_are_told_apart() {
330        assert_eq!(State::parse("active"), Some(State::Active));
331        assert_eq!(State::parse("PENDING"), Some(State::Pending));
332        assert_eq!(State::parse(" terminated "), Some(State::Terminated));
333        assert_eq!(State::parse("finished"), None);
334        // `pending` is not a slow `active`: a subscriber that conflated them would report a
335        // presence it has not been granted.
336        assert_ne!(State::Pending, State::Active);
337    }
338
339    #[test]
340    fn every_reason_the_rfc_defines_round_trips() {
341        for reason in [
342            Reason::Deactivated,
343            Reason::Probation,
344            Reason::Rejected,
345            Reason::Timeout,
346            Reason::GiveUp,
347            Reason::NoResource,
348            Reason::Invariant,
349            Reason::BadFilter,
350        ] {
351            assert_eq!(Reason::parse(reason.as_str()), Some(reason));
352        }
353        assert_eq!(Reason::parse("because"), None);
354    }
355
356    /// The reasons are not interchangeable about whether to try again, and collapsing them means
357    /// either giving up on a briefly unavailable subscription or hammering a refused one.
358    #[test]
359    fn a_refusal_and_a_timeout_lead_to_different_behaviour() {
360        assert!(Reason::Timeout.should_resubscribe());
361        assert!(Reason::Deactivated.should_resubscribe());
362        assert!(Reason::Probation.should_resubscribe());
363        assert!(!Reason::Rejected.should_resubscribe());
364        assert!(!Reason::NoResource.should_resubscribe());
365    }
366
367    #[test]
368    fn a_retry_after_survives_the_round_trip() {
369        let parsed =
370            Subscription::parse(b"terminated;reason=probation;retry-after=1800").expect("parses");
371        assert_eq!(parsed.reason, Some(Reason::Probation));
372        assert_eq!(parsed.retry_after, Some(Duration::from_secs(1800)));
373        assert!(parsed.reason.expect("a reason").should_resubscribe());
374    }
375
376    /// §4.2.1.1: the notifier "MAY shorten the interval but MUST NOT lengthen it".
377    #[test]
378    fn a_notifier_may_shorten_an_expiry_and_never_lengthen_it() {
379        let hour = Duration::from_secs(3600);
380        let day = Duration::from_secs(86400);
381        assert_eq!(granted_expiry(day, hour), hour, "shortened to the policy");
382        assert_eq!(
383            granted_expiry(hour, day),
384            hour,
385            "a generous policy does not lengthen what was asked for"
386        );
387    }
388
389    /// §3.1.1: `Expires: 0` unsubscribes. It is not a subscription of no duration, and the
390    /// notifier still owes a terminating NOTIFY — the part that is easy to miss.
391    #[test]
392    fn an_expiry_of_zero_is_an_unsubscribe() {
393        assert!(is_unsubscribe(Duration::ZERO));
394        assert!(!is_unsubscribe(Duration::from_secs(1)));
395        assert_eq!(
396            granted_expiry(Duration::ZERO, Duration::from_secs(3600)),
397            Duration::ZERO,
398            "a generous policy must not turn an unsubscribe into a subscription"
399        );
400    }
401
402    #[test]
403    fn a_package_is_served_by_name_whatever_its_parameters() {
404        let packages = Packages::new().with("dialog").with("presence");
405        assert!(packages.serves("dialog"));
406        assert!(packages.serves("DIALOG"));
407        assert!(packages.serves("dialog;call-id=x"));
408        assert!(packages.serves("presence"));
409        assert!(!packages.serves("refer"));
410        assert!(!packages.serves(""));
411    }
412
413    /// §8.2.1: an `Event` value is a package optionally followed by a template.
414    #[test]
415    fn a_template_is_recognised_as_its_package() {
416        let packages = Packages::new().with("dialog");
417        assert!(packages.serves("dialog.winfo"));
418    }
419
420    #[test]
421    fn allow_events_lists_what_is_served_and_a_package_is_not_listed_twice() {
422        let packages = Packages::new()
423            .with("dialog")
424            .with("presence")
425            .with("DIALOG");
426        assert_eq!(packages.allow_events(), "dialog, presence");
427        assert_eq!(packages.names().len(), 2);
428    }
429
430    /// 489 rather than 400 or 501: a specific answer to "I do not have that package", which tells
431    /// a subscriber not to retry where the other two would mislead it.
432    #[test]
433    fn an_unserved_package_has_its_own_status() {
434        assert_eq!(BAD_EVENT, 489);
435    }
436}