Skip to main content

confium_net/
url.rs

1//! Transport URL parsing.
2//!
3//! Transport URLs identify a peer or listening endpoint for a
4//! multi-party protocol session:
5//!
6//! - `inproc://<name>` — in-process channel keyed by name
7//! - `mock://<name>` — deterministic mock transport
8//! - `tcp://<host>:<port>` — future `confium-net-tcp` crate
9//! - `tcp+tls://<host>:<port>` — future TLS-wrapped TCP
10//! - `quic://<host>:<port>` — future `confium-net-quic` crate
11//! - `quic4://<host>:<port>` — IPv4-only QUIC
12//! - `quic6://<host>:<port>` — IPv6-only QUIC
13//! - `ws://<host>:<port>/<path>` — future `confium-net-ws` crate
14//! - `wss://<host>:<port>/<path>` — future TLS-wrapped WebSocket
15//!
16//! This module owns the list of *recognized* scheme names so that
17//! adding a new transport in a separate crate does not require editing
18//! the parser — only registering a [`crate::TransportKind`] that
19//! advertises the scheme. Schemes not in [`KNOWN_SCHEMES`] are rejected
20//! early with a clear error, rather than silently passing through as
21//! "no transport registered".
22
23use snafu::ResultExt;
24use snafu::ensure;
25use url::Url;
26
27use crate::Result;
28use crate::error::InvalidUrlSnafu;
29use crate::error::UnknownSchemeSnafu;
30
31/// Every scheme Confium knows about, built-in or reserved for a
32/// planned sibling crate. A URL whose scheme is not in this list is
33/// rejected at parse time.
34///
35/// Adding a new transport crate that introduces a new scheme means
36/// appending to this list — a single-line edit, not a structural
37/// change to the parser.
38pub const KNOWN_SCHEMES: &[&str] = &[
39    "inproc", "mock", "tcp", "tcp+tls", "noise", "quic", "quic4", "quic6", "ws", "wss",
40];
41
42/// A parsed transport URL.
43///
44/// Thin wrapper around [`url::Url`] that guarantees the scheme is one
45/// Confium recognizes.
46#[derive(Debug, Clone)]
47pub struct TransportUrl {
48    inner: Url,
49}
50
51impl TransportUrl {
52    /// Parse and validate a transport URL string.
53    pub fn parse(input: &str) -> Result<Self> {
54        let url = Url::parse(input).context(InvalidUrlSnafu {
55            url: input.to_string(),
56        })?;
57        ensure!(
58            KNOWN_SCHEMES.contains(&url.scheme()),
59            UnknownSchemeSnafu {
60                scheme: url.scheme().to_string(),
61            }
62        );
63        Ok(Self { inner: url })
64    }
65
66    /// The URL scheme, e.g. `"inproc"`, `"tcp"`.
67    pub fn scheme(&self) -> &str {
68        self.inner.scheme()
69    }
70
71    /// The host of the URL, if present. `inproc` and `mock` URLs encode
72    /// their channel name here (the part after `://`).
73    pub fn host(&self) -> Option<&str> {
74        self.inner.host_str()
75    }
76
77    /// The port of the URL, if specified.
78    pub fn port(&self) -> Option<u16> {
79        self.inner.port()
80    }
81
82    /// The path component (after the host), including a leading `/`.
83    /// Empty for schemes like `inproc` that carry no path.
84    pub fn path(&self) -> &str {
85        self.inner.path()
86    }
87
88    /// The bare channel name for `inproc`/`mock` URLs: the host with no
89    /// port and no path. Returns `None` for schemes that are not
90    /// name-based.
91    pub fn channel_name(&self) -> Option<&str> {
92        match self.scheme() {
93            "inproc" | "mock" => self.inner.host_str(),
94            _ => None,
95        }
96    }
97
98    /// Borrow the underlying [`url::Url`].
99    pub fn as_url(&self) -> &Url {
100        &self.inner
101    }
102}
103
104impl std::fmt::Display for TransportUrl {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        self.inner.fmt(f)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn parses_inproc_url() {
116        let u = TransportUrl::parse("inproc://session-42").unwrap();
117        assert_eq!(u.scheme(), "inproc");
118        assert_eq!(u.channel_name(), Some("session-42"));
119        assert_eq!(u.port(), None);
120    }
121
122    #[test]
123    fn parses_mock_url() {
124        let u = TransportUrl::parse("mock://round-3").unwrap();
125        assert_eq!(u.scheme(), "mock");
126        assert_eq!(u.channel_name(), Some("round-3"));
127    }
128
129    #[test]
130    fn parses_tcp_url_with_port() {
131        let u = TransportUrl::parse("tcp://1.2.3.4:443").unwrap();
132        assert_eq!(u.scheme(), "tcp");
133        assert_eq!(u.host(), Some("1.2.3.4"));
134        assert_eq!(u.port(), Some(443));
135        assert!(u.channel_name().is_none());
136    }
137
138    #[test]
139    fn parses_tcp_tls_url() {
140        let u = TransportUrl::parse("tcp+tls://example.com:443").unwrap();
141        assert_eq!(u.scheme(), "tcp+tls");
142    }
143
144    #[test]
145    fn parses_quic_url() {
146        let u = TransportUrl::parse("quic://node.example:8443").unwrap();
147        assert_eq!(u.scheme(), "quic");
148        assert_eq!(u.port(), Some(8443));
149    }
150
151    #[test]
152    fn parses_ws_and_wss_urls() {
153        let ws = TransportUrl::parse("ws://example.com:80/sess").unwrap();
154        assert_eq!(ws.scheme(), "ws");
155        assert_eq!(ws.path(), "/sess");
156        let wss = TransportUrl::parse("wss://example.com/sess").unwrap();
157        assert_eq!(wss.scheme(), "wss");
158    }
159
160    #[test]
161    fn rejects_unknown_scheme() {
162        let err = TransportUrl::parse("ftp://example.com").unwrap_err();
163        assert!(matches!(
164            err,
165            crate::error::Error::UnknownScheme { ref scheme, .. } if scheme == "ftp"
166        ));
167    }
168
169    #[test]
170    fn rejects_malformed_url() {
171        let err = TransportUrl::parse("not a url at all").unwrap_err();
172        assert!(matches!(err, crate::error::Error::InvalidUrl { .. }));
173    }
174
175    #[test]
176    fn accepts_inproc_with_empty_host() {
177        // url::Url parses "inproc://" but leaves the host absent; the
178        // parser still accepts it (channel_name yields None), which the
179        // inproc transport will reject at connect/listen time. Here we
180        // just confirm parsing does not panic.
181        let u = TransportUrl::parse("inproc://").unwrap();
182        assert_eq!(u.channel_name(), None);
183    }
184
185    #[test]
186    fn display_round_trips() {
187        let s = "inproc://session-42";
188        let u = TransportUrl::parse(s).unwrap();
189        assert_eq!(u.to_string(), s);
190    }
191}