1use std::net::{SocketAddr, TcpListener, UdpSocket};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct PortConflict {
6 pub port: u16,
8 pub proto: &'static str,
10 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
20pub 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
47fn 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
67fn 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}