1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum PortProtocol {
10 Tcp,
12}
13
14impl PortProtocol {
15 pub fn as_str(self) -> &'static str {
17 match self {
18 Self::Tcp => "tcp",
19 }
20 }
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PortMapping {
26 pub host_port: u16,
28 pub guest_port: u16,
30 pub protocol: PortProtocol,
32}
33
34impl PortMapping {
35 pub fn tcp(host_port: u16, guest_port: u16) -> Result<Self, String> {
37 parse_port_mapping(&format!("{host_port}:{guest_port}"))
38 }
39
40 pub fn runtime_entry(&self) -> String {
42 format!("{}:{}", self.host_port, self.guest_port)
43 }
44}
45
46pub fn normalize_port_maps(entries: &[String]) -> Result<Vec<String>, String> {
48 entries
49 .iter()
50 .map(|entry| parse_port_mapping(entry).map(|mapping| mapping.runtime_entry()))
51 .collect()
52}
53
54pub fn parse_port_mapping(input: &str) -> Result<PortMapping, String> {
56 let input = input.trim();
57 if input.is_empty() {
58 return Err("Invalid port mapping: value must not be empty".to_string());
59 }
60
61 let mut protocol_split = input.split('/');
62 let port_part = protocol_split.next().unwrap_or_default();
63 let protocol = match protocol_split.next() {
64 None => PortProtocol::Tcp,
65 Some(value) if value.eq_ignore_ascii_case("tcp") => PortProtocol::Tcp,
66 Some("") => {
67 return Err(format!(
68 "Invalid port mapping '{input}': protocol must not be empty"
69 ));
70 }
71 Some(value) => {
72 return Err(format!(
73 "Unsupported port mapping protocol '{value}' in '{input}'; only TCP is supported"
74 ));
75 }
76 };
77 if protocol_split.next().is_some() {
78 return Err(format!(
79 "Invalid port mapping '{input}': expected host_port:guest_port[/tcp]"
80 ));
81 }
82
83 let parts: Vec<&str> = port_part.split(':').collect();
84 if parts.len() != 2 {
85 return Err(format!(
86 "Invalid port mapping '{input}': expected host_port:guest_port[/tcp]; bind-specific host IPs, single-port shorthand, and port ranges are not supported"
87 ));
88 }
89
90 let host_port = parse_port(input, parts[0], "host", true)?;
91 let guest_port = parse_port(input, parts[1], "guest", false)?;
92
93 Ok(PortMapping {
94 host_port,
95 guest_port,
96 protocol,
97 })
98}
99
100fn parse_port(input: &str, value: &str, label: &str, allow_zero: bool) -> Result<u16, String> {
101 if value.is_empty() {
102 return Err(format!(
103 "Invalid port mapping '{input}': {label} port must not be empty"
104 ));
105 }
106 if value.contains('-') {
107 return Err(format!(
108 "Invalid port mapping '{input}': {label} port ranges are not supported"
109 ));
110 }
111
112 let port = value.parse::<u16>().map_err(|_| {
113 format!("Invalid port mapping '{input}': {label} port '{value}' must be 0..=65535")
114 })?;
115 if port == 0 && !allow_zero {
116 return Err(format!(
117 "Invalid port mapping '{input}': guest port must be 1..=65535"
118 ));
119 }
120 Ok(port)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn test_parse_port_mapping_host_guest() {
129 let mapping = parse_port_mapping("8080:80").unwrap();
130
131 assert_eq!(mapping.host_port, 8080);
132 assert_eq!(mapping.guest_port, 80);
133 assert_eq!(mapping.protocol, PortProtocol::Tcp);
134 assert_eq!(mapping.protocol.as_str(), "tcp");
135 assert_eq!(mapping.runtime_entry(), "8080:80");
136 }
137
138 #[test]
139 fn test_typed_tcp_mapping_validates_guest_port() {
140 assert_eq!(PortMapping::tcp(0, 8080).unwrap().runtime_entry(), "0:8080");
141 assert!(PortMapping::tcp(8080, 0).is_err());
142 }
143
144 #[test]
145 fn test_parse_port_mapping_tcp_suffix_is_normalized() {
146 let mapping = parse_port_mapping("8080:80/tcp").unwrap();
147
148 assert_eq!(mapping.runtime_entry(), "8080:80");
149 }
150
151 #[test]
152 fn test_parse_port_mapping_allows_host_port_zero() {
153 let mapping = parse_port_mapping("0:8080").unwrap();
154
155 assert_eq!(mapping.host_port, 0);
156 assert_eq!(mapping.guest_port, 8080);
157 }
158
159 #[test]
160 fn test_normalize_port_maps() {
161 let entries = vec!["8080:80/tcp".to_string(), "8443:443".to_string()];
162
163 let normalized = normalize_port_maps(&entries).unwrap();
164
165 assert_eq!(normalized, vec!["8080:80", "8443:443"]);
166 }
167
168 #[test]
169 fn test_parse_port_mapping_rejects_udp() {
170 let error = parse_port_mapping("8080:80/udp").unwrap_err();
171
172 assert!(error.contains("only TCP is supported"));
173 }
174
175 #[test]
176 fn test_parse_port_mapping_rejects_host_ip() {
177 let error = parse_port_mapping("127.0.0.1:8080:80").unwrap_err();
178
179 assert!(error.contains("bind-specific host IPs"));
180 }
181
182 #[test]
183 fn test_parse_port_mapping_rejects_single_port() {
184 let error = parse_port_mapping("80").unwrap_err();
185
186 assert!(error.contains("single-port shorthand"));
187 }
188
189 #[test]
190 fn test_parse_port_mapping_rejects_guest_zero() {
191 let error = parse_port_mapping("8080:0").unwrap_err();
192
193 assert!(error.contains("guest port"));
194 }
195
196 #[test]
197 fn test_parse_port_mapping_rejects_ranges() {
198 let error = parse_port_mapping("8000-8010:80").unwrap_err();
199
200 assert!(error.contains("ranges"));
201 }
202}