Skip to main content

use_tcp/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::net::IpAddr;
5
6const TCP_SERVICES: &[(&str, u16)] = &[
7    ("ftp", 21),
8    ("ssh", 22),
9    ("smtp", 25),
10    ("dns", 53),
11    ("http", 80),
12    ("pop3", 110),
13    ("imap", 143),
14    ("ldap", 389),
15    ("https", 443),
16    ("mysql", 3306),
17    ("postgres", 5432),
18    ("redis", 6379),
19];
20
21/// Stores a normalized TCP endpoint.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TcpEndpoint {
24    /// Endpoint host component.
25    pub host: String,
26    /// Endpoint port component.
27    pub port: u16,
28}
29
30fn parse_port(input: &str) -> Option<u16> {
31    if input.is_empty() {
32        None
33    } else {
34        input.parse::<u16>().ok()
35    }
36}
37
38fn is_valid_label(label: &str) -> bool {
39    !label.is_empty()
40        && label.len() <= 63
41        && !label.starts_with('-')
42        && !label.ends_with('-')
43        && label
44            .chars()
45            .all(|character| character.is_ascii_alphanumeric() || character == '-')
46}
47
48fn normalize_host(input: &str) -> Option<String> {
49    if input.eq_ignore_ascii_case("localhost") {
50        return Some(String::from("localhost"));
51    }
52
53    if let Ok(address) = input.parse::<IpAddr>() {
54        return Some(address.to_string());
55    }
56
57    let normalized = input.trim_end_matches('.').to_ascii_lowercase();
58
59    if normalized.is_empty()
60        || normalized.len() > 253
61        || normalized.contains(':')
62        || normalized.contains('/')
63        || normalized.chars().any(char::is_whitespace)
64    {
65        return None;
66    }
67
68    if normalized.split('.').all(is_valid_label) {
69        Some(normalized)
70    } else {
71        None
72    }
73}
74
75fn split_raw_endpoint(input: &str) -> Option<(String, u16)> {
76    let trimmed = input.trim();
77
78    if trimmed.is_empty() {
79        return None;
80    }
81
82    if trimmed.starts_with('[') {
83        let closing_index = trimmed.find(']')?;
84        let host = &trimmed[1..closing_index];
85        let port = trimmed.get(closing_index + 2..)?;
86
87        if !trimmed[closing_index + 1..].starts_with(':') {
88            return None;
89        }
90
91        match host.parse::<IpAddr>() {
92            Ok(IpAddr::V6(address)) => Some((address.to_string(), parse_port(port)?)),
93            _ => None,
94        }
95    } else {
96        let (host, port) = trimmed.rsplit_once(':')?;
97
98        if host.is_empty() || host.contains(':') {
99            return None;
100        }
101
102        Some((normalize_host(host)?, parse_port(port)?))
103    }
104}
105
106/// Parses a TCP endpoint from a host-and-port string.
107pub fn parse_tcp_endpoint(input: &str) -> Option<TcpEndpoint> {
108    let (host, port) = split_raw_endpoint(input)?;
109
110    Some(TcpEndpoint { host, port })
111}
112
113/// Formats a TCP endpoint with IPv6 bracket handling.
114pub fn format_tcp_endpoint(endpoint: &TcpEndpoint) -> String {
115    match endpoint.host.parse::<IpAddr>() {
116        Ok(IpAddr::V6(address)) => format!("[{address}]:{}", endpoint.port),
117        _ => format!("{}:{}", endpoint.host, endpoint.port),
118    }
119}
120
121/// Looks up a default TCP port for a common service name.
122pub fn default_tcp_port(service: &str) -> Option<u16> {
123    let normalized = service.trim().to_ascii_lowercase();
124
125    TCP_SERVICES
126        .iter()
127        .find(|(candidate_service, _)| *candidate_service == normalized)
128        .map(|(_, port)| *port)
129}
130
131/// Looks up a common TCP service name for a port.
132pub fn tcp_service_name(port: u16) -> Option<&'static str> {
133    TCP_SERVICES
134        .iter()
135        .find(|(_, candidate_port)| *candidate_port == port)
136        .map(|(service, _)| *service)
137}
138
139/// Returns `true` when the port matches one of the known TCP services.
140pub fn is_common_tcp_port(port: u16) -> bool {
141    tcp_service_name(port).is_some()
142}