Skip to main content

dynomite/conf/
endpoint.rs

1//! `listen` / `dyn_listen` / `stats_listen` endpoint parsing.
2//!
3//! Endpoints are stringly-typed in YAML; we parse them into a typed
4//! [`ConfListen`] preserving the original `pname` (the raw string) and
5//! the host / port pieces. Both `host:port` and `[ipv6]:port` syntaxes
6//! are accepted, plus bare IPv6 addresses split at the rightmost colon.
7//! Unix socket paths starting with `/` are also accepted.
8
9use std::fmt;
10use std::net::IpAddr;
11
12use serde::de::{self, Deserializer, Visitor};
13use serde::{Deserialize, Serialize};
14
15use super::error::ConfError;
16
17/// A parsed `listen:` / `dyn_listen:` / `stats_listen:` endpoint.
18///
19/// Resolution to a `sockinfo` is intentionally not represented here
20/// because address resolution is deferred to the runtime layer.
21///
22/// # Examples
23///
24/// ```
25/// use dynomite::conf::ConfListen;
26/// let l = ConfListen::parse("listen", "127.0.0.1:8102").unwrap();
27/// assert_eq!(l.name(), "127.0.0.1");
28/// assert_eq!(l.port(), 8102);
29/// ```
30#[derive(Debug, Clone, Eq, PartialEq)]
31pub struct ConfListen {
32    pname: String,
33    name: String,
34    port: u16,
35    kind: EndpointKind,
36}
37
38/// Address family of a [`ConfListen`].
39///
40/// # Examples
41///
42/// ```
43/// use dynomite::conf::{ConfListen, EndpointKind};
44/// assert_eq!(
45///     ConfListen::parse("listen", "[::1]:8101").unwrap().kind(),
46///     EndpointKind::V6,
47/// );
48/// assert_eq!(
49///     ConfListen::parse("listen", "/tmp/d.sock").unwrap().kind(),
50///     EndpointKind::UnixPath,
51/// );
52/// ```
53#[derive(Debug, Clone, Copy, Eq, PartialEq)]
54pub enum EndpointKind {
55    /// IPv4 numeric address.
56    V4,
57    /// IPv6 numeric address.
58    V6,
59    /// DNS hostname; resolution is deferred.
60    Hostname,
61    /// Filesystem path to a Unix domain socket.
62    UnixPath,
63}
64
65impl ConfListen {
66    /// Parse a raw endpoint string for the named directive.
67    ///
68    /// `field` names the directive; it is folded into the error so
69    /// callers can produce helpful diagnostics.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use dynomite::conf::{ConfListen, EndpointKind};
75    /// let l = ConfListen::parse("dyn_listen", "node-1.example.com:8101").unwrap();
76    /// assert_eq!(l.kind(), EndpointKind::Hostname);
77    /// assert_eq!(l.port(), 8101);
78    /// assert!(ConfListen::parse("dyn_listen", "node-1.example.com").is_err());
79    /// ```
80    pub fn parse(field: &'static str, raw: &str) -> Result<Self, ConfError> {
81        if raw.is_empty() {
82            return Err(ConfError::BadAddr {
83                field,
84                value: raw.to_string(),
85                reason: "empty value".to_string(),
86            });
87        }
88        if raw.starts_with('/') {
89            return Ok(Self {
90                pname: raw.to_string(),
91                name: raw.to_string(),
92                port: 0,
93                kind: EndpointKind::UnixPath,
94            });
95        }
96
97        let (host, port_str) = split_host_port(raw).ok_or_else(|| ConfError::BadAddr {
98            field,
99            value: raw.to_string(),
100            reason: "missing 'host:port' separator".to_string(),
101        })?;
102
103        let port: u16 = match port_str.parse::<u16>() {
104            Ok(p) if p > 0 => p,
105            Ok(_) | Err(_) => {
106                return Err(ConfError::BadAddr {
107                    field,
108                    value: raw.to_string(),
109                    reason: "port must be a number in 1..=65535".to_string(),
110                });
111            }
112        };
113
114        let kind = classify_host(host).ok_or_else(|| ConfError::BadAddr {
115            field,
116            value: raw.to_string(),
117            reason: "host portion is empty or malformed".to_string(),
118        })?;
119
120        Ok(Self {
121            pname: raw.to_string(),
122            name: host.to_string(),
123            port,
124            kind,
125        })
126    }
127
128    /// The original textual value (`name:port`).
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use dynomite::conf::ConfListen;
134    /// let l = ConfListen::parse("listen", "127.0.0.1:8102").unwrap();
135    /// assert_eq!(l.pname(), "127.0.0.1:8102");
136    /// ```
137    pub fn pname(&self) -> &str {
138        &self.pname
139    }
140
141    /// The host portion (without surrounding brackets, if any).
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use dynomite::conf::ConfListen;
147    /// let l = ConfListen::parse("listen", "[::1]:8101").unwrap();
148    /// assert_eq!(l.name(), "::1");
149    /// ```
150    pub fn name(&self) -> &str {
151        &self.name
152    }
153
154    /// The port number; `0` for Unix socket paths.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use dynomite::conf::ConfListen;
160    /// assert_eq!(ConfListen::parse("listen", "127.0.0.1:8102").unwrap().port(), 8102);
161    /// assert_eq!(ConfListen::parse("listen", "/tmp/d.sock").unwrap().port(), 0);
162    /// ```
163    pub fn port(&self) -> u16 {
164        self.port
165    }
166
167    /// The endpoint kind classification.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use dynomite::conf::{ConfListen, EndpointKind};
173    /// let l = ConfListen::parse("listen", "127.0.0.1:8102").unwrap();
174    /// assert_eq!(l.kind(), EndpointKind::V4);
175    /// ```
176    pub fn kind(&self) -> EndpointKind {
177        self.kind
178    }
179
180    /// Build a [`ConfListen`] from an already-typed [`std::net::SocketAddr`].
181    ///
182    /// The embed `ServerBuilder` calls this for the `listen:` /
183    /// `dyn_listen:` / `stats_listen:` setters because
184    /// [`ConfListen::parse`] (which drives the YAML pathway) rejects
185    /// port zero, while the embed surface accepts port zero with the
186    /// kernel-ephemeral-port semantics. Crate-private to keep the
187    /// public YAML invariant intact.
188    pub(crate) fn from_socket_addr(addr: std::net::SocketAddr) -> Self {
189        let (host, kind) = match addr {
190            std::net::SocketAddr::V4(v4) => (v4.ip().to_string(), EndpointKind::V4),
191            std::net::SocketAddr::V6(v6) => (v6.ip().to_string(), EndpointKind::V6),
192        };
193        let pname = match addr {
194            std::net::SocketAddr::V4(_) => format!("{host}:{}", addr.port()),
195            std::net::SocketAddr::V6(_) => format!("[{host}]:{}", addr.port()),
196        };
197        Self {
198            pname,
199            name: host,
200            port: addr.port(),
201            kind,
202        }
203    }
204}
205
206impl fmt::Display for ConfListen {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        f.write_str(&self.pname)
209    }
210}
211
212impl Serialize for ConfListen {
213    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
214        ser.serialize_str(&self.pname)
215    }
216}
217
218impl<'de> Deserialize<'de> for ConfListen {
219    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
220        struct V;
221        impl Visitor<'_> for V {
222            type Value = ConfListen;
223            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224                f.write_str("a 'host:port' or '[ipv6]:port' endpoint string")
225            }
226            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
227                ConfListen::parse("listen", v).map_err(|e| E::custom(e.to_string()))
228            }
229        }
230        de.deserialize_str(V)
231    }
232}
233
234/// Split a `host:port` (or `[ipv6]:port`) string into its two halves.
235///
236/// Returns `None` if no colon separates the parts. For bracketed IPv6
237/// addresses, the brackets are stripped from the returned host slice.
238fn split_host_port(raw: &str) -> Option<(&str, &str)> {
239    if let Some(rest) = raw.strip_prefix('[') {
240        let close = rest.find(']')?;
241        let host = &rest[..close];
242        let after = &rest[close + 1..];
243        let port = after.strip_prefix(':')?;
244        if host.is_empty() || port.is_empty() {
245            return None;
246        }
247        return Some((host, port));
248    }
249
250    let idx = raw.rfind(':')?;
251    let (host, port) = raw.split_at(idx);
252    let port = &port[1..];
253    if host.is_empty() || port.is_empty() {
254        return None;
255    }
256    Some((host, port))
257}
258
259fn classify_host(host: &str) -> Option<EndpointKind> {
260    if host.is_empty() {
261        return None;
262    }
263    if let Ok(ip) = host.parse::<IpAddr>() {
264        return Some(match ip {
265            IpAddr::V4(_) => EndpointKind::V4,
266            IpAddr::V6(_) => EndpointKind::V6,
267        });
268    }
269    if host
270        .bytes()
271        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_')
272    {
273        Some(EndpointKind::Hostname)
274    } else {
275        None
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn ipv4_host_port() {
285        let l = ConfListen::parse("listen", "127.0.0.1:8102").unwrap();
286        assert_eq!(l.name(), "127.0.0.1");
287        assert_eq!(l.port(), 8102);
288        assert_eq!(l.kind(), EndpointKind::V4);
289        assert_eq!(l.to_string(), "127.0.0.1:8102");
290    }
291
292    #[test]
293    fn ipv6_bracketed() {
294        let l = ConfListen::parse("listen", "[::1]:8101").unwrap();
295        assert_eq!(l.name(), "::1");
296        assert_eq!(l.port(), 8101);
297        assert_eq!(l.kind(), EndpointKind::V6);
298    }
299
300    #[test]
301    fn hostname_accepted() {
302        let l = ConfListen::parse("listen", "node-1.example.com:22222").unwrap();
303        assert_eq!(l.name(), "node-1.example.com");
304        assert_eq!(l.port(), 22222);
305        assert_eq!(l.kind(), EndpointKind::Hostname);
306    }
307
308    #[test]
309    fn unix_path_accepted() {
310        let l = ConfListen::parse("listen", "/tmp/dynomite.sock").unwrap();
311        assert_eq!(l.kind(), EndpointKind::UnixPath);
312        assert_eq!(l.port(), 0);
313    }
314
315    #[test]
316    fn missing_port_rejected() {
317        assert!(ConfListen::parse("listen", "127.0.0.1").is_err());
318        assert!(ConfListen::parse("listen", "127.0.0.1:").is_err());
319    }
320
321    #[test]
322    fn out_of_range_port_rejected() {
323        assert!(ConfListen::parse("listen", "127.0.0.1:0").is_err());
324        assert!(ConfListen::parse("listen", "127.0.0.1:99999").is_err());
325    }
326
327    #[test]
328    fn malformed_ipv6_rejected() {
329        assert!(ConfListen::parse("listen", "[::1:8101").is_err());
330        assert!(ConfListen::parse("listen", "[]:8101").is_err());
331    }
332}