Skip to main content

a3s_box_core/
port.rs

1//! Port publishing validation.
2//!
3//! a3s-box currently supports Docker-style TCP port publishing in the
4//! `host_port:guest_port[/tcp]` form. Unsupported protocols and bind-specific
5//! host IPs are rejected before a box record is persisted or a VM boots.
6
7/// Supported published-port protocol.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum PortProtocol {
10    /// TCP port publishing.
11    Tcp,
12}
13
14impl PortProtocol {
15    /// String representation used by Docker-compatible output.
16    pub fn as_str(self) -> &'static str {
17        match self {
18            Self::Tcp => "tcp",
19        }
20    }
21}
22
23/// Parsed published-port mapping.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PortMapping {
26    /// Host-side port. `0` means host auto-assignment where supported.
27    pub host_port: u16,
28    /// Guest/container port.
29    pub guest_port: u16,
30    /// Published protocol.
31    pub protocol: PortProtocol,
32}
33
34impl PortMapping {
35    /// Create and validate one TCP port publication.
36    pub fn tcp(host_port: u16, guest_port: u16) -> Result<Self, String> {
37        parse_port_mapping(&format!("{host_port}:{guest_port}"))
38    }
39
40    /// Convert to the normalized runtime `host:guest` format.
41    pub fn runtime_entry(&self) -> String {
42        format!("{}:{}", self.host_port, self.guest_port)
43    }
44}
45
46/// Validate and normalize multiple port mappings to runtime format.
47pub 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
54/// Parse a published-port mapping.
55pub 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}