Skip to main content

uptrakit_wire/
close_reason.rs

1//! WebSocket close reason enum shared between the controller (sender)
2//! and services (receiver).
3//!
4//! Using a typed enum instead of string constants ensures that:
5//! - Adding a new reason triggers exhaustive-match compile errors at every call site
6//! - The receiver stores `Option<CloseReason>` instead of `Option<String>`
7//! - IDE navigation, rename-refactor, and usage search all work precisely
8//!
9//! The wire format is unchanged: [`Display`] produces identical strings to the
10//! former constants, and [`FromStr`] parses them back. Unknown strings from
11//! future controller versions become [`CloseReason::Unknown`].
12
13use std::fmt;
14use std::str::FromStr;
15
16/// Reason included in a WebSocket close frame by the controller.
17///
18/// Known variants map 1:1 to the wire strings sent in close frames.
19/// [`Unknown`](Self::Unknown) provides forward compatibility for strings
20/// not yet recognized by the receiver.
21#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum CloseReason {
24    /// The service's TLS certificate was rotated by the controller.
25    CertificateRotated,
26    /// The service's TLS certificate was revoked.
27    CertificateRevoked,
28    /// No valid certificate was presented during the TLS handshake.
29    NoValidCertificate,
30    /// An internal server error occurred.
31    InternalError,
32    /// The presented certificate is not recognized by the controller.
33    CertificateNotRecognized,
34    /// The service has been deactivated by an administrator.
35    ServiceDeactivated,
36    /// The service has not been approved yet.
37    ServiceNotApproved,
38    /// The service was not found in the controller's database.
39    ServiceNotFound,
40    /// The enrollment handshake timed out.
41    EnrollmentTimeout,
42    /// The service exceeded the connection rate limit.
43    RateLimitExceeded,
44    /// The service sent an unexpected or malformed protocol message.
45    ///
46    /// Used when the service violates the expected message sequence, for
47    /// example by sending a message other than `Register` as the first
48    /// frame after authentication completes.
49    ProtocolError,
50    /// A newer connection from the same service superseded this one.
51    Superseded,
52    /// A close reason string not recognized by this build.
53    ///
54    /// Provides forward compatibility: a newer controller may send reasons
55    /// that an older service does not yet know about.
56    Unknown(String),
57}
58
59impl CloseReason {
60    /// Returns the wire-format string for this close reason.
61    pub fn as_str(&self) -> &str {
62        match self {
63            Self::CertificateRotated => "certificate rotated",
64            Self::CertificateRevoked => "certificate revoked",
65            Self::NoValidCertificate => "no valid certificate",
66            Self::InternalError => "internal error",
67            Self::CertificateNotRecognized => "certificate not recognized",
68            Self::ServiceDeactivated => "service deactivated",
69            Self::ServiceNotApproved => "service not approved",
70            Self::ServiceNotFound => "service not found",
71            Self::EnrollmentTimeout => "enrollment timeout",
72            Self::RateLimitExceeded => "rate limit exceeded",
73            Self::ProtocolError => "protocol error",
74            Self::Superseded => "superseded by new connection",
75            Self::Unknown(s) => s,
76        }
77    }
78}
79
80impl fmt::Display for CloseReason {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86/// Error returned when parsing a close reason string fails.
87///
88/// In practice this error is never returned because [`CloseReason::Unknown`]
89/// catches all unrecognized strings, but the type exists to satisfy the
90/// [`FromStr`] trait contract and project conventions.
91#[derive(Debug, thiserror::Error)]
92#[error("invalid close reason")]
93pub struct ParseCloseReasonError;
94
95impl FromStr for CloseReason {
96    type Err = ParseCloseReasonError;
97
98    fn from_str(s: &str) -> Result<Self, Self::Err> {
99        Ok(match s {
100            "certificate rotated" => Self::CertificateRotated,
101            "certificate revoked" => Self::CertificateRevoked,
102            "no valid certificate" => Self::NoValidCertificate,
103            "internal error" => Self::InternalError,
104            "certificate not recognized" => Self::CertificateNotRecognized,
105            "service deactivated" => Self::ServiceDeactivated,
106            "service not approved" => Self::ServiceNotApproved,
107            "service not found" => Self::ServiceNotFound,
108            "enrollment timeout" => Self::EnrollmentTimeout,
109            "rate limit exceeded" => Self::RateLimitExceeded,
110            "protocol error" => Self::ProtocolError,
111            "superseded by new connection" => Self::Superseded,
112            other => Self::Unknown(other.to_string()),
113        })
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    /// All known variants with their expected wire strings.
122    const KNOWN_VARIANTS: &[(CloseReason, &str)] = &[
123        (CloseReason::CertificateRotated, "certificate rotated"),
124        (CloseReason::CertificateRevoked, "certificate revoked"),
125        (CloseReason::NoValidCertificate, "no valid certificate"),
126        (CloseReason::InternalError, "internal error"),
127        (
128            CloseReason::CertificateNotRecognized,
129            "certificate not recognized",
130        ),
131        (CloseReason::ServiceDeactivated, "service deactivated"),
132        (CloseReason::ServiceNotApproved, "service not approved"),
133        (CloseReason::ServiceNotFound, "service not found"),
134        (CloseReason::EnrollmentTimeout, "enrollment timeout"),
135        (CloseReason::RateLimitExceeded, "rate limit exceeded"),
136        (CloseReason::Superseded, "superseded by new connection"),
137    ];
138
139    #[test]
140    fn display_produces_wire_strings() {
141        for (variant, expected) in KNOWN_VARIANTS {
142            assert_eq!(variant.to_string(), *expected);
143        }
144    }
145
146    #[test]
147    fn as_str_matches_display() {
148        for (variant, expected) in KNOWN_VARIANTS {
149            assert_eq!(variant.as_str(), *expected);
150        }
151    }
152
153    #[test]
154    fn from_str_roundtrip_known_variants() {
155        for (variant, wire_str) in KNOWN_VARIANTS {
156            let parsed: CloseReason = wire_str.parse().expect("parse should succeed");
157            assert_eq!(&parsed, variant);
158            assert_eq!(parsed.to_string(), *wire_str);
159        }
160    }
161
162    #[test]
163    fn from_str_unknown_passthrough() {
164        let parsed: CloseReason = "some future reason".parse().expect("parse should succeed");
165        assert_eq!(
166            parsed,
167            CloseReason::Unknown("some future reason".to_string())
168        );
169        assert_eq!(parsed.to_string(), "some future reason");
170        assert_eq!(parsed.as_str(), "some future reason");
171    }
172
173    #[test]
174    fn from_str_empty_string() {
175        let parsed: CloseReason = "".parse().expect("parse should succeed");
176        assert_eq!(parsed, CloseReason::Unknown(String::new()));
177    }
178
179    #[test]
180    fn equality_known_variants() {
181        assert_eq!(
182            CloseReason::CertificateRotated,
183            CloseReason::CertificateRotated
184        );
185        assert_ne!(
186            CloseReason::CertificateRotated,
187            CloseReason::CertificateRevoked
188        );
189    }
190
191    #[test]
192    fn equality_unknown_variants() {
193        assert_eq!(
194            CloseReason::Unknown("x".to_string()),
195            CloseReason::Unknown("x".to_string())
196        );
197        assert_ne!(
198            CloseReason::Unknown("x".to_string()),
199            CloseReason::Unknown("y".to_string())
200        );
201    }
202
203    #[test]
204    fn clone_works() {
205        let original = CloseReason::CertificateRotated;
206        let cloned = original.clone();
207        assert_eq!(original, cloned);
208
209        let original = CloseReason::Unknown("test".to_string());
210        let cloned = original.clone();
211        assert_eq!(original, cloned);
212    }
213}