Skip to main content

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}