use std::net::TcpListener;
use serde::{Deserialize, Serialize};
use crate::Result;
pub(crate) const BIND_ADDR: &str = "0.0.0.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortSpec {
pub host: Option<u16>,
pub guest: u16,
}
impl PortSpec {
#[must_use]
pub const fn new(host: u16, guest: u16) -> Self {
Self {
host: Some(host),
guest,
}
}
#[must_use]
pub const fn ephemeral(guest: u16) -> Self {
Self { host: None, guest }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublishedPort {
pub host: u16,
pub guest: u16,
pub bind_addr: String,
}
impl PublishedPort {
#[must_use]
pub fn new(host: u16, guest: u16) -> Self {
Self {
host,
guest,
bind_addr: BIND_ADDR.to_owned(),
}
}
}
pub fn parse_publish_spec(spec: &str) -> Result<PortSpec> {
let (map, proto) = match spec.rsplit_once('/') {
Some((m, p)) => (m, Some(p)),
None => (spec, None),
};
if let Some(p) = proto {
let p = p.to_ascii_lowercase();
if p != "tcp" {
return Err(crate::Error::InvalidConfig(format!(
"unsupported port protocol /{p} in {spec:?}; v1 is TCP only"
)));
}
}
if let Some((host_s, guest_s)) = map.split_once(':') {
let guest: u16 = guest_s
.parse()
.map_err(|_| crate::Error::InvalidConfig(format!("invalid guest port in {spec:?}")))?;
if host_s.is_empty() || host_s == "0" {
return Ok(PortSpec::ephemeral(guest));
}
let host: u16 = host_s
.parse()
.map_err(|_| crate::Error::InvalidConfig(format!("invalid host port in {spec:?}")))?;
if host == 0 {
return Ok(PortSpec::ephemeral(guest));
}
return Ok(PortSpec::new(host, guest));
}
let guest: u16 = map
.parse()
.map_err(|_| crate::Error::InvalidConfig(format!("invalid port spec {spec:?}")))?;
Ok(PortSpec::ephemeral(guest))
}
pub(crate) type PortPairs = Vec<(u16, u16)>;
pub(crate) fn resolve_ports(specs: &[PortSpec]) -> Result<(PortPairs, Vec<PublishedPort>)> {
let mut pairs = Vec::with_capacity(specs.len());
let mut published = Vec::with_capacity(specs.len());
for spec in specs {
let host = match spec.host {
None | Some(0) => probe_ephemeral_port()?,
Some(h) => h,
};
pairs.push((host, spec.guest));
published.push(PublishedPort::new(host, spec.guest));
}
Ok((pairs, published))
}
fn probe_ephemeral_port() -> Result<u16> {
let listener = TcpListener::bind((BIND_ADDR, 0)).map_err(|e| {
crate::Error::Io(std::io::Error::new(
e.kind(),
format!("ephemeral port probe failed: {e}"),
))
})?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
#[must_use]
pub(crate) fn format_port_pairs(pairs: &[(u16, u16)]) -> Vec<String> {
pairs.iter().map(|(h, g)| format!("{h}:{g}")).collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
#[test]
fn parse_publish_variants() {
assert_eq!(
parse_publish_spec("8080:80").unwrap(),
PortSpec::new(8080, 80)
);
assert_eq!(parse_publish_spec("80").unwrap(), PortSpec::ephemeral(80));
assert_eq!(parse_publish_spec("0:80").unwrap(), PortSpec::ephemeral(80));
assert_eq!(parse_publish_spec(":80").unwrap(), PortSpec::ephemeral(80));
assert_eq!(
parse_publish_spec("8080:80/tcp").unwrap(),
PortSpec::new(8080, 80)
);
assert!(parse_publish_spec("8080:80/udp").is_err());
}
#[test]
fn resolve_fixed() {
let (pairs, pubd) = resolve_ports(&[PortSpec::new(18080, 80)]).unwrap();
assert_eq!(pairs, vec![(18080, 80)]);
let p = pubd.first().expect("one published port");
assert_eq!(p.bind_addr, BIND_ADDR);
assert_eq!(p.host, 18080);
}
#[test]
fn resolve_ephemeral_nonzero() {
let (pairs, _) = resolve_ports(&[PortSpec::ephemeral(443)]).unwrap();
assert_eq!(pairs.len(), 1);
let (host, guest) = pairs.first().copied().expect("one pair");
assert_ne!(host, 0);
assert_eq!(guest, 443);
}
}