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}