lightstreamer_rs/config/address.rs
1//! The address of the Lightstreamer server.
2
3use std::fmt;
4
5use crate::config::ConfigError;
6
7/// The separator between a URL scheme and its authority.
8const SCHEME_SEPARATOR: &str = "://";
9
10/// The schemes that select a plain-text connection.
11const INSECURE_SCHEMES: [&str; 2] = ["ws", "http"];
12
13/// The schemes that select a TLS connection.
14const SECURE_SCHEMES: [&str; 2] = ["wss", "https"];
15
16/// The characters that end the authority and begin a path, a query or a
17/// fragment.
18const AUTHORITY_TERMINATORS: [char; 3] = ['/', '?', '#'];
19
20/// The address of a Lightstreamer server, parsed and checked at construction.
21///
22/// A Lightstreamer server is reached at a host and port, and this crate speaks
23/// to it over a WebSocket. Both spellings are accepted, because deployments
24/// publish their endpoint either way:
25///
26/// | You write | Meaning |
27/// |---|---|
28/// | `wss://push.example.com` | WebSocket over TLS |
29/// | `https://push.example.com` | the same thing |
30/// | `ws://localhost:8080` | WebSocket, no TLS |
31/// | `http://localhost:8080` | the same thing |
32/// | `wss://[::1]:8080` | an IPv6 literal, which must be bracketed |
33///
34/// # What an address may and may not contain
35///
36/// The address is a **scheme, a host and an optional port**, optionally
37/// followed by a base path. Everything else is refused here rather than
38/// somewhere further down, where the failure would be a connection error
39/// instead of a configuration one:
40///
41/// | Rejected | Why |
42/// |---|---|
43/// | `wss://user:secret@host` | userinfo is a credential, and a credential in an address ends up in every log line that mentions the address |
44/// | `wss://host?a=b`, `wss://host#f` | TLCP defines no query or fragment on the endpoint; carrying one would silently change what is requested |
45/// | `wss://host/a/../b` | a `..` segment makes the composed endpoint depend on path normalisation this crate does not perform |
46/// | `wss://host:0`, `wss://host:99999` | not a port |
47/// | `wss:// host` | not a host |
48///
49/// # The endpoint path
50///
51/// The TLCP endpoint path is **not** part of the address: this crate appends
52/// the `/lightstreamer` path the protocol fixes
53/// [`docs/spec/01-foundations.md` §6.2.1]. An address that already ends in
54/// `/lightstreamer` is accepted and not doubled. A base path is accepted too,
55/// for a server published behind a reverse proxy, and the fixed path is
56/// appended to it — `https://example.com/push` reaches
57/// `/push/lightstreamer`.
58///
59/// # Canonical form
60///
61/// [`ServerAddress::as_str`] returns the address with the scheme lowercased
62/// and any trailing `/` removed; the host is left in the caller's own
63/// spelling. Because userinfo is refused, the canonical form is also the
64/// redacted form: it is safe to log.
65///
66/// # Examples
67///
68/// ```
69/// use lightstreamer_rs::ServerAddress;
70///
71/// let address = ServerAddress::try_new("https://push.lightstreamer.com")?;
72/// assert!(address.is_secure());
73/// # Ok::<(), lightstreamer_rs::ConfigError>(())
74/// ```
75///
76/// A missing scheme is rejected rather than guessed at, because guessing
77/// `https` for an address the caller meant as plain `http` would silently
78/// change the security properties of the connection:
79///
80/// ```
81/// use lightstreamer_rs::ServerAddress;
82///
83/// assert!(ServerAddress::try_new("push.lightstreamer.com").is_err());
84/// ```
85#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub struct ServerAddress {
87 /// The canonical address: scheme lowercased, no trailing `/`, and — by
88 /// construction — no userinfo, query or fragment to redact.
89 text: String,
90 /// Whether the scheme selects TLS.
91 secure: bool,
92}
93
94impl ServerAddress {
95 /// Parses and checks a server address.
96 ///
97 /// # Errors
98 ///
99 /// - [`ConfigError::EmptyServerAddress`] if the text is empty or only
100 /// whitespace.
101 /// - [`ConfigError::MissingScheme`] if there is no `://`.
102 /// - [`ConfigError::UnsupportedScheme`] if the scheme is not one of `ws`,
103 /// `wss`, `http` or `https`.
104 /// - [`ConfigError::MissingHost`] if nothing follows the scheme.
105 /// - [`ConfigError::AddressHasUserinfo`] if the authority carries a
106 /// `user:password@` prefix.
107 /// - [`ConfigError::InvalidHost`] if the host is empty or contains
108 /// whitespace, a control character, or an unbracketed `:`.
109 /// - [`ConfigError::InvalidPort`] if the port is not a number in `1..=65535`.
110 /// - [`ConfigError::InvalidAddressPath`] if a query, a fragment, a `..`
111 /// segment, whitespace or a control character follows the authority.
112 ///
113 /// # Examples
114 ///
115 /// ```
116 /// use lightstreamer_rs::{ConfigError, ServerAddress};
117 ///
118 /// assert!(matches!(
119 /// ServerAddress::try_new("ftp://push.example.com"),
120 /// Err(ConfigError::UnsupportedScheme { .. })
121 /// ));
122 ///
123 /// // A credential in an address would reach every log line that mentions it.
124 /// assert!(matches!(
125 /// ServerAddress::try_new("wss://alice:hunter2@push.example.com"),
126 /// Err(ConfigError::AddressHasUserinfo)
127 /// ));
128 /// ```
129 #[must_use = "a checked address does nothing unless it is used"]
130 pub fn try_new(address: impl Into<String>) -> Result<Self, ConfigError> {
131 let address = address.into();
132 let trimmed = address.trim();
133 if trimmed.is_empty() {
134 return Err(ConfigError::EmptyServerAddress);
135 }
136
137 let Some((scheme, rest)) = trimmed.split_once(SCHEME_SEPARATOR) else {
138 // Redacted even here. Without a scheme there is no authority to
139 // find a userinfo in, but `alice:hunter2@host` is exactly the
140 // shape a caller who forgot the scheme writes, and an error
141 // message is a thing people paste into issues.
142 return Err(ConfigError::MissingScheme {
143 address: without_userinfo(trimmed),
144 });
145 };
146
147 let scheme = scheme.to_ascii_lowercase();
148 let secure = if SECURE_SCHEMES.contains(&scheme.as_str()) {
149 true
150 } else if INSECURE_SCHEMES.contains(&scheme.as_str()) {
151 false
152 } else {
153 return Err(ConfigError::UnsupportedScheme { scheme });
154 };
155
156 // The authority ends where the path, the query or the fragment
157 // begins; a userinfo, if there is one, is inside it.
158 let split = rest.find(AUTHORITY_TERMINATORS).unwrap_or(rest.len());
159 let authority = rest.get(..split).unwrap_or(rest);
160 let path = rest.get(split..).unwrap_or("");
161
162 if authority.is_empty() {
163 return Err(ConfigError::MissingHost {
164 address: without_userinfo(trimmed),
165 });
166 }
167 if authority.contains('@') {
168 return Err(ConfigError::AddressHasUserinfo);
169 }
170 check_authority(authority)?;
171 let path = check_path(path)?;
172
173 Ok(Self {
174 text: format!("{scheme}{SCHEME_SEPARATOR}{authority}{path}"),
175 secure,
176 })
177 }
178
179 /// The address in canonical form: scheme lowercased, no trailing `/`, and
180 /// nothing in it that needs redacting.
181 #[must_use]
182 #[inline]
183 pub fn as_str(&self) -> &str {
184 &self.text
185 }
186
187 /// Whether the connection will be encrypted.
188 ///
189 /// True for the `wss` and `https` schemes, false for `ws` and `http`.
190 #[must_use]
191 #[inline]
192 pub const fn is_secure(&self) -> bool {
193 self.secure
194 }
195}
196
197/// Replaces anything before an `@` with the redaction marker.
198///
199/// Used on the addresses that go **into error messages**, so that a rejected
200/// address cannot carry a credential into whatever log the error reaches. An
201/// address that gets as far as being stored has already been refused if it had
202/// a userinfo at all.
203fn without_userinfo(address: &str) -> String {
204 let (scheme, rest) = match address.split_once(SCHEME_SEPARATOR) {
205 Some((scheme, rest)) => (format!("{scheme}{SCHEME_SEPARATOR}"), rest),
206 None => (String::new(), address),
207 };
208 // Userinfo precedes the first `@`, which itself precedes the path.
209 let end = rest.find(AUTHORITY_TERMINATORS).unwrap_or(rest.len());
210 let authority = rest.get(..end).unwrap_or(rest);
211 let tail = rest.get(end..).unwrap_or("");
212 match authority.rsplit_once('@') {
213 Some((_, host)) => format!("{scheme}{}@{host}{tail}", crate::REDACTED),
214 None => address.to_owned(),
215 }
216}
217
218/// Checks the host and the optional port of an authority with no userinfo.
219fn check_authority(authority: &str) -> Result<(), ConfigError> {
220 // An IPv6 literal must be bracketed, which is also what makes the last
221 // `:` unambiguously the port separator.
222 let (host, port) = match authority.strip_prefix('[') {
223 Some(bracketed) => match bracketed.split_once(']') {
224 Some((host, after)) => (host, after.strip_prefix(':')),
225 None => {
226 return Err(ConfigError::InvalidHost {
227 host: authority.to_owned(),
228 });
229 }
230 },
231 None => match authority.rsplit_once(':') {
232 Some((host, port)) => (host, Some(port)),
233 None => (authority, None),
234 },
235 };
236
237 let bracketed = authority.starts_with('[');
238 if host.is_empty()
239 || host
240 .chars()
241 .any(|c| c.is_whitespace() || c.is_control() || c == '[' || c == ']' || c == '@')
242 || (!bracketed && host.contains(':'))
243 {
244 return Err(ConfigError::InvalidHost {
245 host: host.to_owned(),
246 });
247 }
248
249 if let Some(port) = port
250 && !matches!(port.parse::<u16>(), Ok(1..=u16::MAX))
251 {
252 return Err(ConfigError::InvalidPort {
253 port: port.to_owned(),
254 });
255 }
256 Ok(())
257}
258
259/// Checks whatever follows the authority and returns it in canonical form.
260///
261/// A query and a fragment are refused rather than carried: TLCP fixes the
262/// endpoint path and defines nothing that would be passed this way
263/// [`docs/spec/01-foundations.md` §6.2.1], so anything here is either a
264/// mistake or an attempt to reach a different resource.
265fn check_path(path: &str) -> Result<&str, ConfigError> {
266 if path.starts_with('?') || path.starts_with('#') {
267 return Err(ConfigError::InvalidAddressPath {
268 path: path.to_owned(),
269 });
270 }
271 let invalid = path.contains(['?', '#'])
272 || path.chars().any(|c| c.is_whitespace() || c.is_control())
273 || path.split('/').any(|segment| segment == "..");
274 if invalid {
275 return Err(ConfigError::InvalidAddressPath {
276 path: path.to_owned(),
277 });
278 }
279 Ok(path.trim_end_matches('/'))
280}
281
282impl fmt::Display for ServerAddress {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 f.write_str(&self.text)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_server_address_accepts_every_supported_scheme() {
294 for (address, secure) in [
295 ("ws://localhost:8080", false),
296 ("http://localhost:8080", false),
297 ("wss://push.example.com", true),
298 ("https://push.example.com", true),
299 ] {
300 match ServerAddress::try_new(address) {
301 Ok(parsed) => {
302 assert_eq!(parsed.as_str(), address);
303 assert_eq!(parsed.is_secure(), secure, "{address}");
304 }
305 Err(error) => panic!("{address} was rejected: {error}"),
306 }
307 }
308 }
309
310 #[test]
311 fn test_server_address_scheme_is_case_insensitive() {
312 assert!(matches!(
313 ServerAddress::try_new("WSS://push.example.com"),
314 Ok(address) if address.is_secure()
315 ));
316 }
317
318 #[test]
319 fn test_server_address_trims_surrounding_whitespace() {
320 assert!(matches!(
321 ServerAddress::try_new(" wss://push.example.com "),
322 Ok(address) if address.as_str() == "wss://push.example.com"
323 ));
324 }
325
326 #[test]
327 fn test_server_address_strips_a_trailing_slash() {
328 assert!(matches!(
329 ServerAddress::try_new("wss://push.example.com/"),
330 Ok(address) if address.as_str() == "wss://push.example.com"
331 ));
332 }
333
334 #[test]
335 fn test_server_address_rejects_an_empty_string() {
336 assert!(matches!(
337 ServerAddress::try_new(" "),
338 Err(ConfigError::EmptyServerAddress)
339 ));
340 }
341
342 #[test]
343 fn test_server_address_rejects_a_missing_scheme() {
344 assert!(matches!(
345 ServerAddress::try_new("push.example.com"),
346 Err(ConfigError::MissingScheme { .. })
347 ));
348 }
349
350 #[test]
351 fn test_server_address_rejects_an_unsupported_scheme() {
352 assert!(matches!(
353 ServerAddress::try_new("ftp://push.example.com"),
354 Err(ConfigError::UnsupportedScheme { scheme }) if scheme == "ftp"
355 ));
356 }
357
358 #[test]
359 fn test_server_address_rejects_a_missing_host() {
360 assert!(matches!(
361 ServerAddress::try_new("wss://"),
362 Err(ConfigError::MissingHost { .. })
363 ));
364 assert!(matches!(
365 ServerAddress::try_new("wss:///"),
366 Err(ConfigError::MissingHost { .. })
367 ));
368 }
369
370 #[test]
371 fn test_server_address_displays_what_was_configured() {
372 match ServerAddress::try_new("wss://push.example.com:443") {
373 Ok(address) => assert_eq!(address.to_string(), "wss://push.example.com:443"),
374 Err(error) => panic!("rejected: {error}"),
375 }
376 }
377
378 // -----------------------------------------------------------------------
379 // A-05: every component of the address is parsed and checked
380 // -----------------------------------------------------------------------
381
382 #[test]
383 fn test_server_address_accepts_every_host_form() {
384 for address in [
385 "wss://192.0.2.10",
386 "wss://192.0.2.10:8080",
387 "wss://[2001:db8::1]",
388 "wss://[2001:db8::1]:8080",
389 "wss://push.example.com",
390 "wss://push.example.com:65535",
391 "ws://localhost:1",
392 ] {
393 match ServerAddress::try_new(address) {
394 Ok(parsed) => assert_eq!(parsed.as_str(), address),
395 Err(error) => panic!("{address} was rejected: {error}"),
396 }
397 }
398 }
399
400 #[test]
401 fn test_server_address_rejects_userinfo() {
402 // A credential in an address would otherwise reach the connect span,
403 // the connect error, and whatever log either is written to.
404 for address in [
405 "wss://alice:hunter2@push.example.com",
406 "wss://alice@push.example.com",
407 "wss://alice:hunter2@push.example.com/lightstreamer",
408 ] {
409 assert!(
410 matches!(
411 ServerAddress::try_new(address),
412 Err(ConfigError::AddressHasUserinfo)
413 ),
414 "{address} was accepted"
415 );
416 }
417 }
418
419 #[test]
420 fn test_server_address_never_renders_a_rejected_credential() {
421 // Every rejection path that echoes the address must redact it —
422 // including the one a caller who forgot the scheme entirely reaches.
423 for address in [
424 "wss://alice:hunter2@push.example.com",
425 "alice:hunter2@push.example.com",
426 "wss://alice:hunter2@",
427 ] {
428 match ServerAddress::try_new(address) {
429 Err(error) => {
430 let rendered = error.to_string();
431 assert!(!rendered.contains("hunter2"), "{rendered}");
432 assert!(!rendered.contains("alice"), "{rendered}");
433 }
434 Ok(parsed) => panic!("{address} was accepted as {parsed}"),
435 }
436 }
437 }
438
439 #[test]
440 fn test_server_address_rejects_a_host_that_is_not_one() {
441 for address in [
442 "wss:// push.example.com",
443 "wss://push example.com",
444 "wss://push.example.com\u{7}",
445 "wss://:8080",
446 "wss://[2001:db8::1",
447 // An IPv6 literal must be bracketed, or the last `:` is a port.
448 "wss://2001:db8::1",
449 ] {
450 assert!(
451 matches!(
452 ServerAddress::try_new(address),
453 Err(ConfigError::InvalidHost { .. })
454 ),
455 "{address} was accepted"
456 );
457 }
458 }
459
460 #[test]
461 fn test_server_address_rejects_a_port_that_is_not_one() {
462 for address in [
463 "wss://push.example.com:0",
464 "wss://push.example.com:65536",
465 "wss://push.example.com:99999",
466 "wss://push.example.com:http",
467 "wss://push.example.com:",
468 "wss://push.example.com:-1",
469 "wss://[2001:db8::1]:0",
470 ] {
471 assert!(
472 matches!(
473 ServerAddress::try_new(address),
474 Err(ConfigError::InvalidPort { .. })
475 ),
476 "{address} was accepted"
477 );
478 }
479 }
480
481 #[test]
482 fn test_server_address_rejects_a_query_a_fragment_and_a_traversal() {
483 for address in [
484 "wss://push.example.com?token=secret",
485 "wss://push.example.com#fragment",
486 "wss://push.example.com/push?a=b",
487 "wss://push.example.com/a/../b",
488 "wss://push.example.com/..",
489 "wss://push.example.com/a b",
490 ] {
491 assert!(
492 matches!(
493 ServerAddress::try_new(address),
494 Err(ConfigError::InvalidAddressPath { .. })
495 ),
496 "{address} was accepted"
497 );
498 }
499 }
500
501 #[test]
502 fn test_server_address_keeps_a_base_path_and_canonicalizes_the_scheme() {
503 // A server published behind a reverse proxy: the fixed
504 // `/lightstreamer` path is appended to whatever base path was given
505 // [`docs/spec/01-foundations.md` §6.2.1].
506 for (given, canonical) in [
507 (
508 "HTTPS://push.example.com/push/",
509 "https://push.example.com/push",
510 ),
511 (
512 "wss://push.example.com/lightstreamer",
513 "wss://push.example.com/lightstreamer",
514 ),
515 ("Ws://localhost:8080//", "ws://localhost:8080"),
516 ] {
517 match ServerAddress::try_new(given) {
518 Ok(parsed) => assert_eq!(parsed.as_str(), canonical, "{given}"),
519 Err(error) => panic!("{given} was rejected: {error}"),
520 }
521 }
522 }
523}