Skip to main content

use_docker_port/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when a Docker port mapping is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PortMappingError {
10    /// The mapping was empty after trimming.
11    Empty,
12    /// A port number was missing or outside `1..=65535`.
13    InvalidPort,
14    /// The protocol suffix was not recognized.
15    InvalidProtocol,
16    /// The mapping had too many fields or unsupported host syntax.
17    InvalidMapping,
18}
19
20impl fmt::Display for PortMappingError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Empty => formatter.write_str("Docker port mapping cannot be empty"),
24            Self::InvalidPort => formatter.write_str("invalid Docker port number"),
25            Self::InvalidProtocol => formatter.write_str("invalid Docker port protocol"),
26            Self::InvalidMapping => formatter.write_str("invalid Docker port mapping"),
27        }
28    }
29}
30
31impl Error for PortMappingError {}
32
33/// A Docker port protocol label.
34#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub enum PortProtocol {
36    /// TCP.
37    Tcp,
38    /// UDP.
39    Udp,
40    /// SCTP.
41    Sctp,
42}
43
44impl PortProtocol {
45    /// Returns the lowercase protocol label.
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Tcp => "tcp",
50            Self::Udp => "udp",
51            Self::Sctp => "sctp",
52        }
53    }
54}
55
56impl fmt::Display for PortProtocol {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter.write_str(self.as_str())
59    }
60}
61
62impl FromStr for PortProtocol {
63    type Err = PortMappingError;
64
65    fn from_str(value: &str) -> Result<Self, Self::Err> {
66        match value.trim().to_ascii_lowercase().as_str() {
67            "tcp" => Ok(Self::Tcp),
68            "udp" => Ok(Self::Udp),
69            "sctp" => Ok(Self::Sctp),
70            _ => Err(PortMappingError::InvalidProtocol),
71        }
72    }
73}
74
75/// A validated TCP/UDP/SCTP port number.
76#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct PortNumber(u16);
78
79impl PortNumber {
80    /// Creates a port number in `1..=65535`.
81    pub const fn new(value: u16) -> Result<Self, PortMappingError> {
82        if value == 0 {
83            Err(PortMappingError::InvalidPort)
84        } else {
85            Ok(Self(value))
86        }
87    }
88
89    /// Returns the numeric port.
90    #[must_use]
91    pub const fn get(self) -> u16 {
92        self.0
93    }
94}
95
96impl fmt::Display for PortNumber {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(formatter, "{}", self.get())
99    }
100}
101
102/// A Docker port mapping.
103#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct PortMapping {
105    host_ip: Option<String>,
106    host_port: Option<PortNumber>,
107    container_port: PortNumber,
108    protocol: PortProtocol,
109}
110
111impl PortMapping {
112    /// Creates a mapping from validated parts.
113    #[must_use]
114    pub fn new(
115        host_ip: Option<String>,
116        host_port: Option<PortNumber>,
117        container_port: PortNumber,
118        protocol: PortProtocol,
119    ) -> Self {
120        Self {
121            host_ip,
122            host_port,
123            container_port,
124            protocol,
125        }
126    }
127
128    /// Returns the optional host IP.
129    #[must_use]
130    pub fn host_ip(&self) -> Option<&str> {
131        self.host_ip.as_deref()
132    }
133
134    /// Returns the optional host port.
135    #[must_use]
136    pub const fn host_port(&self) -> Option<PortNumber> {
137        self.host_port
138    }
139
140    /// Returns the container port.
141    #[must_use]
142    pub const fn container_port(&self) -> PortNumber {
143        self.container_port
144    }
145
146    /// Returns the protocol.
147    #[must_use]
148    pub const fn protocol(&self) -> PortProtocol {
149        self.protocol
150    }
151}
152
153impl fmt::Display for PortMapping {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        if let Some(host_ip) = &self.host_ip {
156            write!(formatter, "{host_ip}:")?;
157        }
158        if let Some(host_port) = self.host_port {
159            write!(formatter, "{host_port}:")?;
160        }
161        write!(formatter, "{}/{}", self.container_port, self.protocol)
162    }
163}
164
165impl FromStr for PortMapping {
166    type Err = PortMappingError;
167
168    fn from_str(value: &str) -> Result<Self, Self::Err> {
169        parse_port_mapping(value)
170    }
171}
172
173impl TryFrom<&str> for PortMapping {
174    type Error = PortMappingError;
175
176    fn try_from(value: &str) -> Result<Self, Self::Error> {
177        parse_port_mapping(value)
178    }
179}
180
181fn parse_port_mapping(value: &str) -> Result<PortMapping, PortMappingError> {
182    let trimmed = value.trim();
183    if trimmed.is_empty() {
184        return Err(PortMappingError::Empty);
185    }
186    let (mapping, protocol) = match trimmed.rsplit_once('/') {
187        Some((mapping, protocol)) => (mapping, protocol.parse()?),
188        None => (trimmed, PortProtocol::Tcp),
189    };
190    let parts = mapping.split(':').collect::<Vec<_>>();
191    match parts.as_slice() {
192        [container] => Ok(PortMapping::new(
193            None,
194            None,
195            parse_port(container)?,
196            protocol,
197        )),
198        [host, container] => Ok(PortMapping::new(
199            None,
200            Some(parse_port(host)?),
201            parse_port(container)?,
202            protocol,
203        )),
204        [host_ip, host, container] => {
205            validate_host_ip(host_ip)?;
206            Ok(PortMapping::new(
207                Some((*host_ip).to_string()),
208                Some(parse_port(host)?),
209                parse_port(container)?,
210                protocol,
211            ))
212        },
213        _ => Err(PortMappingError::InvalidMapping),
214    }
215}
216
217fn parse_port(value: &str) -> Result<PortNumber, PortMappingError> {
218    let parsed = value
219        .parse::<u16>()
220        .map_err(|_| PortMappingError::InvalidPort)?;
221    PortNumber::new(parsed)
222}
223
224fn validate_host_ip(value: &str) -> Result<(), PortMappingError> {
225    if value.is_empty() || value.chars().any(char::is_whitespace) || value.contains(':') {
226        Err(PortMappingError::InvalidMapping)
227    } else {
228        Ok(())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{PortMapping, PortProtocol};
235
236    #[test]
237    fn parses_port_mappings() -> Result<(), Box<dyn std::error::Error>> {
238        let mapping: PortMapping = "127.0.0.1:8080:80/tcp".parse()?;
239        let udp: PortMapping = "53/udp".parse()?;
240
241        assert_eq!(mapping.host_ip(), Some("127.0.0.1"));
242        assert_eq!(mapping.host_port().map(super::PortNumber::get), Some(8080));
243        assert_eq!(mapping.container_port().get(), 80);
244        assert_eq!(udp.protocol(), PortProtocol::Udp);
245        assert_eq!("8080:80".parse::<PortMapping>()?.to_string(), "8080:80/tcp");
246        Ok(())
247    }
248}