#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PortMappingError {
Empty,
InvalidPort,
InvalidProtocol,
InvalidMapping,
}
impl fmt::Display for PortMappingError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Docker port mapping cannot be empty"),
Self::InvalidPort => formatter.write_str("invalid Docker port number"),
Self::InvalidProtocol => formatter.write_str("invalid Docker port protocol"),
Self::InvalidMapping => formatter.write_str("invalid Docker port mapping"),
}
}
}
impl Error for PortMappingError {}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PortProtocol {
Tcp,
Udp,
Sctp,
}
impl PortProtocol {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Tcp => "tcp",
Self::Udp => "udp",
Self::Sctp => "sctp",
}
}
}
impl fmt::Display for PortProtocol {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for PortProtocol {
type Err = PortMappingError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"tcp" => Ok(Self::Tcp),
"udp" => Ok(Self::Udp),
"sctp" => Ok(Self::Sctp),
_ => Err(PortMappingError::InvalidProtocol),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PortNumber(u16);
impl PortNumber {
pub const fn new(value: u16) -> Result<Self, PortMappingError> {
if value == 0 {
Err(PortMappingError::InvalidPort)
} else {
Ok(Self(value))
}
}
#[must_use]
pub const fn get(self) -> u16 {
self.0
}
}
impl fmt::Display for PortNumber {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.get())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PortMapping {
host_ip: Option<String>,
host_port: Option<PortNumber>,
container_port: PortNumber,
protocol: PortProtocol,
}
impl PortMapping {
#[must_use]
pub fn new(
host_ip: Option<String>,
host_port: Option<PortNumber>,
container_port: PortNumber,
protocol: PortProtocol,
) -> Self {
Self {
host_ip,
host_port,
container_port,
protocol,
}
}
#[must_use]
pub fn host_ip(&self) -> Option<&str> {
self.host_ip.as_deref()
}
#[must_use]
pub const fn host_port(&self) -> Option<PortNumber> {
self.host_port
}
#[must_use]
pub const fn container_port(&self) -> PortNumber {
self.container_port
}
#[must_use]
pub const fn protocol(&self) -> PortProtocol {
self.protocol
}
}
impl fmt::Display for PortMapping {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(host_ip) = &self.host_ip {
write!(formatter, "{host_ip}:")?;
}
if let Some(host_port) = self.host_port {
write!(formatter, "{host_port}:")?;
}
write!(formatter, "{}/{}", self.container_port, self.protocol)
}
}
impl FromStr for PortMapping {
type Err = PortMappingError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
parse_port_mapping(value)
}
}
impl TryFrom<&str> for PortMapping {
type Error = PortMappingError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
parse_port_mapping(value)
}
}
fn parse_port_mapping(value: &str) -> Result<PortMapping, PortMappingError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(PortMappingError::Empty);
}
let (mapping, protocol) = match trimmed.rsplit_once('/') {
Some((mapping, protocol)) => (mapping, protocol.parse()?),
None => (trimmed, PortProtocol::Tcp),
};
let parts = mapping.split(':').collect::<Vec<_>>();
match parts.as_slice() {
[container] => Ok(PortMapping::new(
None,
None,
parse_port(container)?,
protocol,
)),
[host, container] => Ok(PortMapping::new(
None,
Some(parse_port(host)?),
parse_port(container)?,
protocol,
)),
[host_ip, host, container] => {
validate_host_ip(host_ip)?;
Ok(PortMapping::new(
Some((*host_ip).to_string()),
Some(parse_port(host)?),
parse_port(container)?,
protocol,
))
},
_ => Err(PortMappingError::InvalidMapping),
}
}
fn parse_port(value: &str) -> Result<PortNumber, PortMappingError> {
let parsed = value
.parse::<u16>()
.map_err(|_| PortMappingError::InvalidPort)?;
PortNumber::new(parsed)
}
fn validate_host_ip(value: &str) -> Result<(), PortMappingError> {
if value.is_empty() || value.chars().any(char::is_whitespace) || value.contains(':') {
Err(PortMappingError::InvalidMapping)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{PortMapping, PortProtocol};
#[test]
fn parses_port_mappings() -> Result<(), Box<dyn std::error::Error>> {
let mapping: PortMapping = "127.0.0.1:8080:80/tcp".parse()?;
let udp: PortMapping = "53/udp".parse()?;
assert_eq!(mapping.host_ip(), Some("127.0.0.1"));
assert_eq!(mapping.host_port().map(super::PortNumber::get), Some(8080));
assert_eq!(mapping.container_port().get(), 80);
assert_eq!(udp.protocol(), PortProtocol::Udp);
assert_eq!("8080:80".parse::<PortMapping>()?.to_string(), "8080:80/tcp");
Ok(())
}
}