use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum ContainerPort {
Tcp(u16),
Udp(u16),
Sctp(u16),
}
impl std::fmt::Display for ContainerPort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContainerPort::Tcp(p) => write!(f, "{p}/tcp"),
ContainerPort::Udp(p) => write!(f, "{p}/udp"),
ContainerPort::Sctp(p) => write!(f, "{p}/sctp"),
}
}
}
impl std::str::FromStr for ContainerPort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (port, proto) = match s.rsplit_once('/') {
Some((p, proto)) => (p, proto),
None => (s, "tcp"),
};
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("invalid port: {port}"));
}
let port: u16 = port
.parse()
.map_err(|e: std::num::ParseIntError| e.to_string())?;
match proto {
"tcp" => Ok(ContainerPort::Tcp(port)),
"udp" => Ok(ContainerPort::Udp(port)),
"sctp" => Ok(ContainerPort::Sctp(port)),
other => Err(format!("unknown protocol: {other}")),
}
}
}
pub trait IntoContainerPort {
fn tcp(self) -> ContainerPort;
fn udp(self) -> ContainerPort;
fn sctp(self) -> ContainerPort;
}
impl IntoContainerPort for u16 {
fn tcp(self) -> ContainerPort {
ContainerPort::Tcp(self)
}
fn udp(self) -> ContainerPort {
ContainerPort::Udp(self)
}
fn sctp(self) -> ContainerPort {
ContainerPort::Sctp(self)
}
}
impl From<u16> for ContainerPort {
fn from(port: u16) -> Self {
ContainerPort::Tcp(port)
}
}
impl ContainerPort {
pub fn as_u16(self) -> u16 {
match self {
ContainerPort::Tcp(p) | ContainerPort::Udp(p) | ContainerPort::Sctp(p) => p,
}
}
pub fn as_str(self) -> &'static str {
match self {
ContainerPort::Tcp(_) => "tcp",
ContainerPort::Udp(_) => "udp",
ContainerPort::Sctp(_) => "sctp",
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Ports {
ipv4_mapping: BTreeMap<ContainerPort, u16>,
ipv6_mapping: BTreeMap<ContainerPort, u16>,
}
impl Ports {
pub fn map_to_host_port_ipv4(&self, container_port: impl Into<ContainerPort>) -> Option<u16> {
self.ipv4_mapping.get(&container_port.into()).copied()
}
pub fn map_to_host_port_ipv6(&self, container_port: impl Into<ContainerPort>) -> Option<u16> {
self.ipv6_mapping.get(&container_port.into()).copied()
}
#[cfg(feature = "http_wait_plain")]
pub(crate) fn first_container_port(&self) -> Option<ContainerPort> {
self.ipv4_mapping
.keys()
.next()
.or_else(|| self.ipv6_mapping.keys().next())
.copied()
}
pub(crate) fn add_mapping(&mut self, container_port: ContainerPort, host_port: u16) {
self.ipv4_mapping.insert(container_port, host_port);
}
#[cfg_attr(all(target_os = "linux", not(test)), expect(dead_code))]
pub(crate) fn add_ipv6_mapping(&mut self, container_port: ContainerPort, host_port: u16) {
self.ipv6_mapping.insert(container_port, host_port);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "http_wait_plain")]
#[test]
fn first_container_port_is_deterministic_min_key() {
let mut ports = Ports::default();
ports.add_mapping(ContainerPort::Tcp(8080), 18080);
ports.add_mapping(ContainerPort::Tcp(80), 18000);
ports.add_mapping(ContainerPort::Udp(53), 1053);
assert_eq!(
ports.first_container_port(),
Some(ContainerPort::Tcp(80)),
"Tcp(80) が Udp / より大きい Tcp より先に選ばれること"
);
let mut v6_only = Ports::default();
v6_only.add_ipv6_mapping(ContainerPort::Tcp(443), 8443);
v6_only.add_ipv6_mapping(ContainerPort::Tcp(80), 8080);
assert_eq!(
v6_only.first_container_port(),
Some(ContainerPort::Tcp(80)),
"IPv6 のみでも最小キーが選ばれること"
);
}
#[test]
fn container_port_from_str_rejects_leading_plus() {
assert!(
"+80".parse::<ContainerPort>().is_err(),
"+80 は拒否されること"
);
assert!(
"+80/tcp".parse::<ContainerPort>().is_err(),
"+80/tcp は拒否されること"
);
}
#[test]
fn container_port_from_str_rejects_invalid_inputs() {
assert!("".parse::<ContainerPort>().is_err(), "空は拒否されること");
assert!(
"abc/tcp".parse::<ContainerPort>().is_err(),
"非数字ポートは拒否されること"
);
assert!(
"80/foo".parse::<ContainerPort>().is_err(),
"未知プロトコルは拒否されること"
);
assert!(
"99999/tcp".parse::<ContainerPort>().is_err(),
"u16 超過は拒否されること"
);
}
}