Skip to main content

monocoque_core/
endpoint.rs

1//! Endpoint abstraction for transport-agnostic socket addressing.
2//!
3//! Provides unified addressing for TCP and IPC transports with parsing support.
4
5use std::fmt;
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::str::FromStr;
9
10/// Transport endpoint address.
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum Endpoint {
13    /// TCP transport: `tcp://host:port`
14    Tcp(SocketAddr),
15    /// IPC transport (Unix domain socket): `ipc:///path/to/socket`
16    #[cfg(unix)]
17    Ipc(PathBuf),
18    /// In-process transport: `inproc://name`
19    Inproc(String),
20}
21
22impl Endpoint {
23    /// Parse an endpoint from a string.
24    ///
25    /// Supported formats:
26    /// - `tcp://127.0.0.1:5555`
27    /// - `tcp://[::1]:5555` (IPv6)
28    /// - `ipc:///tmp/socket.sock` (Unix only)
29    /// - `inproc://name`
30    ///
31    /// # Examples
32    ///
33    /// ```
34    /// use monocoque_core::endpoint::Endpoint;
35    ///
36    /// let endpoint = Endpoint::parse("tcp://127.0.0.1:5555").unwrap();
37    /// assert!(matches!(endpoint, Endpoint::Tcp(_)));
38    ///
39    /// # #[cfg(unix)]
40    /// # {
41    /// let endpoint = Endpoint::parse("ipc:///tmp/test.sock").unwrap();
42    /// assert!(matches!(endpoint, Endpoint::Ipc(_)));
43    /// # }
44    ///
45    /// let endpoint = Endpoint::parse("inproc://my-endpoint").unwrap();
46    /// assert!(matches!(endpoint, Endpoint::Inproc(_)));
47    /// ```
48    pub fn parse(s: &str) -> Result<Self, EndpointError> {
49        s.parse()
50    }
51
52    /// Returns true if this is a TCP endpoint.
53    #[must_use]
54    pub const fn is_tcp(&self) -> bool {
55        matches!(self, Self::Tcp(_))
56    }
57
58    /// Returns true if this is an IPC endpoint.
59    #[cfg(unix)]
60    #[must_use]
61    pub const fn is_ipc(&self) -> bool {
62        matches!(self, Self::Ipc(_))
63    }
64
65    /// Returns true if this is an inproc endpoint.
66    #[must_use]
67    pub const fn is_inproc(&self) -> bool {
68        matches!(self, Self::Inproc(_))
69    }
70}
71
72impl FromStr for Endpoint {
73    type Err = EndpointError;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        if let Some(addr) = s.strip_prefix("tcp://") {
77            let socket_addr = addr
78                .parse::<SocketAddr>()
79                .map_err(|_| EndpointError::InvalidTcpAddress(addr.to_string()))?;
80            Ok(Self::Tcp(socket_addr))
81        } else if let Some(path) = s.strip_prefix("ipc://") {
82            #[cfg(unix)]
83            {
84                if !path.starts_with('/') {
85                    return Err(EndpointError::InvalidScheme(s.to_string()));
86                }
87                Ok(Self::Ipc(PathBuf::from(path)))
88            }
89            #[cfg(not(unix))]
90            {
91                Err(EndpointError::IpcNotSupported)
92            }
93        } else if let Some(name) = s.strip_prefix("inproc://") {
94            if name.is_empty() {
95                Err(EndpointError::InvalidInprocName(
96                    "inproc name cannot be empty".to_string(),
97                ))
98            } else if name.chars().any(char::is_control) {
99                // Match the inproc transport's own validation
100                // (inproc::validate_and_extract_name): reject NUL and other
101                // control characters so a name cannot smuggle them past this
102                // parser and diverge from what the transport accepts.
103                Err(EndpointError::InvalidInprocName(
104                    "inproc name cannot contain control characters".to_string(),
105                ))
106            } else {
107                Ok(Self::Inproc(name.to_string()))
108            }
109        } else {
110            Err(EndpointError::InvalidScheme(s.to_string()))
111        }
112    }
113}
114
115impl fmt::Display for Endpoint {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::Tcp(addr) => write!(f, "tcp://{addr}"),
119            #[cfg(unix)]
120            Self::Ipc(path) => write!(f, "ipc://{}", path.display()),
121            Self::Inproc(name) => write!(f, "inproc://{name}"),
122        }
123    }
124}
125
126/// Errors that can occur when parsing or using endpoints.
127#[derive(Debug, thiserror::Error)]
128pub enum EndpointError {
129    #[error("Invalid scheme in endpoint: {0} (expected tcp://, ipc://, or inproc://)")]
130    InvalidScheme(String),
131
132    #[error("Invalid TCP address: {0}")]
133    InvalidTcpAddress(String),
134
135    #[error("Invalid inproc name: {0}")]
136    InvalidInprocName(String),
137
138    #[error("IPC transport not supported on this platform")]
139    IpcNotSupported,
140
141    #[error("I/O error: {0}")]
142    Io(#[from] std::io::Error),
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_parse_tcp_ipv4() {
151        let endpoint = Endpoint::parse("tcp://127.0.0.1:5555").unwrap();
152        assert!(matches!(endpoint, Endpoint::Tcp(_)));
153        assert_eq!(endpoint.to_string(), "tcp://127.0.0.1:5555");
154    }
155
156    #[test]
157    fn test_parse_tcp_ipv6() {
158        let endpoint = Endpoint::parse("tcp://[::1]:5555").unwrap();
159        assert!(matches!(endpoint, Endpoint::Tcp(_)));
160    }
161
162    #[cfg(unix)]
163    #[test]
164    fn test_parse_ipc() {
165        let endpoint = Endpoint::parse("ipc:///tmp/test.sock").unwrap();
166        assert!(matches!(endpoint, Endpoint::Ipc(_)));
167        assert_eq!(endpoint.to_string(), "ipc:///tmp/test.sock");
168    }
169
170    #[cfg(unix)]
171    #[test]
172    fn test_invalid_ipc_relative_path() {
173        let result = Endpoint::parse("ipc://relative.sock");
174        assert!(matches!(result, Err(EndpointError::InvalidScheme(_))));
175    }
176
177    #[test]
178    fn test_invalid_scheme() {
179        let result = Endpoint::parse("http://127.0.0.1:5555");
180        assert!(matches!(result, Err(EndpointError::InvalidScheme(_))));
181    }
182
183    #[test]
184    fn test_invalid_tcp_address() {
185        let result = Endpoint::parse("tcp://invalid:port");
186        assert!(matches!(result, Err(EndpointError::InvalidTcpAddress(_))));
187    }
188
189    #[test]
190    fn test_parse_inproc() {
191        let endpoint = Endpoint::parse("inproc://my-endpoint").unwrap();
192        assert!(matches!(endpoint, Endpoint::Inproc(_)));
193        assert_eq!(endpoint.to_string(), "inproc://my-endpoint");
194    }
195
196    #[test]
197    fn test_invalid_inproc_empty() {
198        let result = Endpoint::parse("inproc://");
199        assert!(matches!(result, Err(EndpointError::InvalidInprocName(_))));
200    }
201
202    #[test]
203    fn test_invalid_inproc_control_characters() {
204        // Must reject the same inputs the inproc transport rejects, including an
205        // embedded NUL, so the two cannot diverge.
206        for bad in ["inproc://tenant\0shadow", "inproc://a\tb", "inproc://x\ny"] {
207            assert!(
208                matches!(
209                    Endpoint::parse(bad),
210                    Err(EndpointError::InvalidInprocName(_))
211                ),
212                "control character in {bad:?} should be rejected"
213            );
214        }
215    }
216}