Skip to main content

lightstreamer_rs/config/
credentials.rs

1//! Who the session is opened as, and which adapters serve it.
2//!
3//! The protocol groups `LS_user`, `LS_password` and `LS_adapter_set` together
4//! as parameters of one request [`docs/spec/03-requests.md` §2.1]. This module
5//! splits them into two types, which is a choice of this crate rather than a
6//! protocol requirement: the first two are a secret the caller supplies and
7//! this crate must never leak, the third is a routing label with no secrecy at
8//! all. Keeping them apart means the redacting `Debug` on [`Credentials`]
9//! never has to hide something a caller would rather see.
10
11use std::fmt;
12
13use crate::REDACTED;
14use crate::config::ConfigError;
15
16/// The name of the Adapter Set that will serve a session.
17///
18/// A Lightstreamer server hosts one or more **Adapter Sets** — a Metadata
19/// Adapter plus the Data Adapters it fronts. The name selects which one this
20/// session talks to; the server assumes an Adapter Set named `DEFAULT` when
21/// none is given [`docs/spec/03-requests.md` §2.1].
22///
23/// The public demo server used by the specification's own transcripts serves
24/// an Adapter Set named `DEMO` [`docs/spec/02-session-lifecycle.md` §10].
25///
26/// # Examples
27///
28/// ```
29/// use lightstreamer_rs::AdapterSet;
30///
31/// let adapters = AdapterSet::try_new("DEMO")?;
32/// assert_eq!(adapters.as_str(), "DEMO");
33/// # Ok::<(), lightstreamer_rs::ConfigError>(())
34/// ```
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub struct AdapterSet(String);
37
38impl AdapterSet {
39    /// Checks and wraps an Adapter Set name.
40    ///
41    /// # Errors
42    ///
43    /// - [`ConfigError::EmptyAdapterSet`] if the name is empty or only
44    ///   whitespace. Omitting the Adapter Set entirely and naming it the empty
45    ///   string are different requests, and the second is never what a caller
46    ///   means; to get the server's `DEFAULT`, leave the name unset.
47    /// - [`ConfigError::AdapterSetControlCharacter`] if the name contains an
48    ///   ASCII control character. This is a guard of this crate, not a
49    ///   protocol rule: the specification gives no grammar for the name, and
50    ///   the request encoder would percent-escape a stray `CR` faithfully — but
51    ///   a control character in a logical adapter name is a mistake in every
52    ///   case, and failing here beats a server-side rejection.
53    #[must_use = "a checked adapter-set name does nothing unless it is used"]
54    pub fn try_new(name: impl Into<String>) -> Result<Self, ConfigError> {
55        let name = name.into();
56        if name.trim().is_empty() {
57            return Err(ConfigError::EmptyAdapterSet);
58        }
59        if name.chars().any(char::is_control) {
60            return Err(ConfigError::AdapterSetControlCharacter);
61        }
62        Ok(Self(name))
63    }
64
65    /// The name as it goes on the wire.
66    #[must_use]
67    #[inline]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73impl fmt::Display for AdapterSet {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(&self.0)
76    }
77}
78
79/// The user name and password a session is created with.
80///
81/// Both are free strings, interpreted and verified by the server's **Metadata
82/// Adapter** — this crate never inspects them and imposes no format
83/// [`docs/spec/03-requests.md` §2.1]. Many Adapter Sets, including the public
84/// demo one, authenticate nobody and accept [`Credentials::anonymous`].
85///
86/// # Secrecy
87///
88/// This crate never reads credentials from the environment, from a file, or
89/// from anywhere else: you supply them. Neither half is ever logged, neither
90/// appears in any [`Error`](crate::Error) this crate produces, and the
91/// hand-written [`Debug`] implementation redacts **both**, so a `{:?}` of a
92/// config struct that happens to contain one is safe.
93///
94/// The user name is redacted as well as the password because it is not always
95/// the innocuous half: adapters routinely authenticate with an account
96/// identifier or an API key as the user name, and a `Debug` that printed it
97/// would put that in a log by accident. When you want it, ask for it —
98/// [`Credentials::user`] returns it and says so at the call site.
99///
100/// ```
101/// use lightstreamer_rs::Credentials;
102///
103/// let credentials = Credentials::new("alice", "hunter2");
104/// let rendered = format!("{credentials:?}");
105/// assert!(!rendered.contains("hunter2"));
106/// assert!(!rendered.contains("alice"));
107/// ```
108#[derive(Clone, Default, PartialEq, Eq)]
109pub struct Credentials {
110    user: Option<String>,
111    password: Option<String>,
112}
113
114impl Credentials {
115    /// A user name and password pair.
116    #[must_use]
117    pub fn new(user: impl Into<String>, password: impl Into<String>) -> Self {
118        Self {
119            user: Some(user.into()),
120            password: Some(password.into()),
121        }
122    }
123
124    /// No user name and no password.
125    ///
126    /// The Metadata Adapter is still asked to authenticate the session — it
127    /// simply receives a null user name, and may well accept it
128    /// [`docs/spec/03-requests.md` §2.1]. This is what the public demo server
129    /// expects.
130    #[must_use]
131    #[inline]
132    pub const fn anonymous() -> Self {
133        Self {
134            user: None,
135            password: None,
136        }
137    }
138
139    /// A user name with no password, for Adapter Sets that identify a user
140    /// without authenticating one.
141    #[must_use]
142    pub fn user_only(user: impl Into<String>) -> Self {
143        Self {
144            user: Some(user.into()),
145            password: None,
146        }
147    }
148
149    /// The configured user name, if any.
150    #[must_use]
151    #[inline]
152    pub fn user(&self) -> Option<&str> {
153        self.user.as_deref()
154    }
155
156    /// Whether a password was supplied.
157    ///
158    /// There is deliberately no accessor that returns the password itself: it
159    /// goes to the server and nowhere else.
160    #[must_use]
161    #[inline]
162    pub const fn has_password(&self) -> bool {
163        self.password.is_some()
164    }
165
166    /// Splits into the parts the session layer needs.
167    ///
168    /// Internal, so the password leaves this type only on its way to the wire.
169    pub(crate) fn into_parts(self) -> (Option<String>, Option<String>) {
170        (self.user, self.password)
171    }
172}
173
174impl fmt::Debug for Credentials {
175    /// Reports which halves are set and neither of their values.
176    ///
177    /// Written by hand rather than derived so that no `{:?}` anywhere — this
178    /// crate's, the caller's, or a panic message's — can print a credential.
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.debug_struct("Credentials")
181            .field("user", &self.user.as_ref().map(|_| REDACTED))
182            .field("password", &self.password.as_ref().map(|_| REDACTED))
183            .finish()
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_adapter_set_accepts_a_plain_name() {
193        assert!(matches!(
194            AdapterSet::try_new("DEMO"),
195            Ok(set) if set.as_str() == "DEMO"
196        ));
197    }
198
199    #[test]
200    fn test_adapter_set_rejects_an_empty_name() {
201        assert!(matches!(
202            AdapterSet::try_new(""),
203            Err(ConfigError::EmptyAdapterSet)
204        ));
205        assert!(matches!(
206            AdapterSet::try_new("  \t "),
207            Err(ConfigError::EmptyAdapterSet)
208        ));
209    }
210
211    #[test]
212    fn test_adapter_set_rejects_a_control_character() {
213        assert!(matches!(
214            AdapterSet::try_new("DE\r\nMO"),
215            Err(ConfigError::AdapterSetControlCharacter)
216        ));
217        assert!(matches!(
218            AdapterSet::try_new("DE\u{0}MO"),
219            Err(ConfigError::AdapterSetControlCharacter)
220        ));
221    }
222
223    #[test]
224    fn test_credentials_debug_redacts_both_halves() {
225        // A-01: neither half may reach a `{:?}`. The user name is not the
226        // innocuous one — with many adapters it *is* the account identifier.
227        let credentials = Credentials::new("alice", "hunter2");
228        let rendered = format!("{credentials:?}");
229        assert!(!rendered.contains("hunter2"), "{rendered}");
230        assert!(!rendered.contains("alice"), "{rendered}");
231        assert!(rendered.contains(REDACTED), "{rendered}");
232    }
233
234    #[test]
235    fn test_credentials_debug_still_says_which_halves_are_set() {
236        // Redaction must not cost the diagnostic value: "is a password set at
237        // all" is the question a caller debugging an authentication failure
238        // actually asks.
239        let anonymous = format!("{:?}", Credentials::anonymous());
240        assert!(anonymous.contains("None"), "{anonymous}");
241        let user_only = format!("{:?}", Credentials::user_only("alice"));
242        assert!(user_only.contains(REDACTED), "{user_only}");
243        assert!(user_only.contains("None"), "{user_only}");
244    }
245
246    #[test]
247    fn test_a_config_holding_credentials_leaks_nothing_through_debug() {
248        // The realistic path: nobody formats `Credentials`, they format the
249        // whole configuration.
250        let Ok(address) = crate::ServerAddress::try_new("wss://push.example.com") else {
251            unreachable!("the fixture address is valid");
252        };
253        let built = crate::ClientConfig::builder(address)
254            .with_credentials(Credentials::new("alice", "hunter2"))
255            .build();
256        match built {
257            Ok(config) => {
258                let rendered = format!("{config:?}");
259                assert!(!rendered.contains("hunter2"), "{rendered}");
260                assert!(!rendered.contains("alice"), "{rendered}");
261            }
262            Err(error) => panic!("the fixture configuration is valid: {error}"),
263        }
264    }
265
266    #[test]
267    fn test_credentials_anonymous_carries_nothing() {
268        let credentials = Credentials::anonymous();
269        assert_eq!(credentials.user(), None);
270        assert!(!credentials.has_password());
271    }
272
273    #[test]
274    fn test_credentials_user_only_has_no_password() {
275        let credentials = Credentials::user_only("alice");
276        assert_eq!(credentials.user(), Some("alice"));
277        assert!(!credentials.has_password());
278    }
279}