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