1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum PortMappingError {
10 Empty,
12 InvalidPort,
14 InvalidProtocol,
16 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub enum PortProtocol {
36 Tcp,
38 Udp,
40 Sctp,
42}
43
44impl PortProtocol {
45 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct PortNumber(u16);
78
79impl PortNumber {
80 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 #[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#[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 #[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 #[must_use]
130 pub fn host_ip(&self) -> Option<&str> {
131 self.host_ip.as_deref()
132 }
133
134 #[must_use]
136 pub const fn host_port(&self) -> Option<PortNumber> {
137 self.host_port
138 }
139
140 #[must_use]
142 pub const fn container_port(&self) -> PortNumber {
143 self.container_port
144 }
145
146 #[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}