Skip to main content

podbox/
ports.rs

1use std::net::{SocketAddr, TcpListener, UdpSocket};
2
3/// A host port that is already occupied by another process.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct PortConflict {
6    /// The host port that could not be bound.
7    pub port: u16,
8    /// `tcp` or `udp`.
9    pub proto: &'static str,
10    /// The bind address that failed, e.g. `0.0.0.0:3000` or `[::]:3000`.
11    pub bind: String,
12}
13
14impl std::fmt::Display for PortConflict {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        write!(f, "{} {}", self.proto, self.bind)
17    }
18}
19
20/// Check every published host port for an existing listener on the host.
21///
22/// Mirrors how `podman --network pasta` binds published ports: a wildcard
23/// bind is attempted on both IPv4 and IPv6 (dual-stack). Testing `0.0.0.0`
24/// and `[::]` therefore catches listeners on either address family, including
25/// IPv6-only sockets such as `[::1]:<port>`.
26pub fn check_host_ports(ports: &[String]) -> Vec<PortConflict> {
27    let mut conflicts = Vec::new();
28    for spec in ports {
29        let Some((bind_addrs, host_port)) = parse_port_spec(spec) else {
30            continue;
31        };
32        for addr in bind_addrs {
33            for proto in ["tcp", "udp"] {
34                if addr_occupied(addr, proto) {
35                    conflicts.push(PortConflict {
36                        port: host_port,
37                        proto,
38                        bind: addr.to_string(),
39                    });
40                }
41            }
42        }
43    }
44    conflicts
45}
46
47/// Parse `hostPort:containerPort` or `ip:hostPort:containerPort` into the
48/// host bind addresses to test plus the host port.
49fn parse_port_spec(spec: &str) -> Option<(Vec<SocketAddr>, u16)> {
50    let parts: Vec<&str> = spec.split(':').collect();
51    let (ip, host_port_str) = match parts.as_slice() {
52        [host_port, _container] => (None, host_port),
53        [ip, host_port, _container] => (Some(*ip), host_port),
54        _ => return None,
55    };
56    let host_port: u16 = host_port_str.parse().ok()?;
57    let addrs = match ip {
58        Some(ip) => vec![format!("{ip}:{host_port}").parse().ok()?],
59        None => vec![
60            format!("0.0.0.0:{host_port}").parse().ok()?,
61            format!("[::]:{host_port}").parse().ok()?,
62        ],
63    };
64    Some((addrs, host_port))
65}
66
67/// True if binding `addr` for `proto` fails (port already occupied).
68fn addr_occupied(addr: SocketAddr, proto: &str) -> bool {
69    match proto {
70        "tcp" => TcpListener::bind(addr).is_err(),
71        "udp" => UdpSocket::bind(addr).is_err(),
72        _ => false,
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn parses_wildcard_spec() {
82        let (addrs, port) = parse_port_spec("3000:3000").unwrap();
83        assert_eq!(port, 3000);
84        assert_eq!(addrs.len(), 2);
85        let ipv4: std::net::IpAddr = "0.0.0.0".parse().unwrap();
86        assert_eq!(addrs[0].ip(), ipv4);
87        assert!(addrs[1].is_ipv6());
88    }
89
90    #[test]
91    fn parses_ip_spec() {
92        let (addrs, port) = parse_port_spec("127.0.0.1:8080:80").unwrap();
93        assert_eq!(port, 8080);
94        assert_eq!(addrs.len(), 1);
95        let ipv4: std::net::IpAddr = "127.0.0.1".parse().unwrap();
96        assert_eq!(addrs[0].ip(), ipv4);
97    }
98
99    #[test]
100    fn rejects_malformed_spec() {
101        assert!(parse_port_spec("not-a-port").is_none());
102        assert!(parse_port_spec("a:b:c:d").is_none());
103    }
104
105    #[test]
106    fn detects_existing_tcp_listener() {
107        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
108        let port = listener.local_addr().unwrap().port();
109        let conflicts = check_host_ports(&[format!("{port}:{port}")]);
110        assert!(
111            conflicts.iter().any(|c| c.proto == "tcp" && c.port == port),
112            "expected a TCP conflict for port {port}, got {conflicts:?}"
113        );
114    }
115
116    #[test]
117    fn detects_existing_udp_bind() {
118        let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
119        let port = sock.local_addr().unwrap().port();
120        let conflicts = check_host_ports(&[format!("{port}:{port}")]);
121        assert!(
122            conflicts.iter().any(|c| c.proto == "udp" && c.port == port),
123            "expected a UDP conflict for port {port}, got {conflicts:?}"
124        );
125    }
126
127    #[test]
128    fn free_port_has_no_conflict() {
129        let conflicts = check_host_ports(&["39999:39999".into()]);
130        assert!(
131            conflicts.is_empty(),
132            "expected no conflicts for an unused port, got {conflicts:?}"
133        );
134    }
135}