use std::time::Duration;
const PROBE_TIMEOUT: Duration = Duration::from_millis(300);
pub fn normalize_listen(raw: &str) -> Result<String, String> {
let s = raw.trim();
if s.is_empty() {
return Err("listen address is empty (expected host:port)".to_string());
}
if let Ok(addr) = s.parse::<std::net::SocketAddr>() {
if addr.port() == 0 {
return Err(port_zero_error());
}
return Ok(addr.to_string());
}
let Some((host, port)) = s.rsplit_once(':') else {
return Err(format!(
"invalid listen address \"{s}\": expected host:port"
));
};
let host = host.trim();
if host.is_empty() {
return Err(format!("invalid listen address \"{s}\": host is empty"));
}
let port: u16 = port
.trim()
.parse()
.map_err(|_| format!("invalid listen address \"{s}\": port must be 1-65535"))?;
if port == 0 {
return Err(port_zero_error());
}
Ok(format!("{host}:{port}"))
}
fn port_zero_error() -> String {
"port 0 is not allowed (the kernel would pick a different port than the one shown)".to_string()
}
pub fn base_url(listen: &str) -> String {
let (host, port) = match listen.rsplit_once(':') {
Some((host, port)) => (host, port),
None => (listen, ""),
};
let host = match host {
"0.0.0.0" | "*" => "127.0.0.1",
"[::]" | "::" => "[::1]",
h => h,
};
format!("http://{host}:{port}/v1")
}
pub fn is_loopback_host(host: &str) -> bool {
let trimmed = host
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
matches!(
trimmed.as_str(),
"localhost" | "127.0.0.1" | "::1" | "0:0:0:0:0:0:0:1"
)
}
pub fn url_is_loopback(url: &str) -> bool {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|host| is_loopback_host(&host))
}
pub fn pick_free_port() -> Result<u16, String> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|e| format!("cannot find a free loopback port: {e}"))?;
listener
.local_addr()
.map(|addr| addr.port())
.map_err(|e| format!("cannot read the port the kernel assigned: {e}"))
}
pub async fn is_port_listening(host: &str, port: u16) -> bool {
matches!(
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect((host, port))).await,
Ok(Ok(_))
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
fn loopback_host_detection() {
for host in [
"localhost",
"LOCALHOST",
"127.0.0.1",
"::1",
"[::1]",
"0:0:0:0:0:0:0:1",
] {
assert!(is_loopback_host(host), "{host} 应判为回环");
}
for host in ["", "example.com", "10.0.0.1", "0.0.0.0", "127.0.0.2"] {
assert!(!is_loopback_host(host), "{host} 不应判为回环");
}
}
#[test]
fn url_loopback_detection() {
assert!(url_is_loopback("ws://127.0.0.1:9222/devtools/browser/x"));
assert!(url_is_loopback("ws://[::1]:9222/devtools/browser/x"));
assert!(!url_is_loopback("ws://example.com:9222/devtools/browser/x"));
assert!(!url_is_loopback("ws://10.0.0.5:9222/x"));
assert!(!url_is_loopback("not a url"));
}
#[test]
fn normalize_listen_accepts_host_port_forms() {
assert_eq!(
normalize_listen("127.0.0.1:8765").unwrap(),
"127.0.0.1:8765"
);
assert_eq!(normalize_listen(" 0.0.0.0:80 ").unwrap(), "0.0.0.0:80");
assert_eq!(
normalize_listen("localhost:8765").unwrap(),
"localhost:8765"
);
assert_eq!(normalize_listen("[::1]:8765").unwrap(), "[::1]:8765");
}
#[test]
fn base_url_derives_openai_compatible_endpoint() {
assert_eq!(base_url("127.0.0.1:8765"), "http://127.0.0.1:8765/v1");
assert_eq!(base_url("localhost:8765"), "http://localhost:8765/v1");
assert_eq!(base_url("[::1]:8765"), "http://[::1]:8765/v1");
assert_eq!(base_url("0.0.0.0:8765"), "http://127.0.0.1:8765/v1");
assert_eq!(base_url("[::]:8765"), "http://[::1]:8765/v1");
}
#[test]
fn normalize_listen_rejects_bad_forms() {
for bad in [
"",
"8765",
"127.0.0.1",
":8765",
"127.0.0.1:",
"127.0.0.1:0",
"127.0.0.1:99999",
] {
assert!(normalize_listen(bad).is_err(), "{bad} 应被拒绝");
}
}
#[test]
fn pick_free_port_returns_a_bindable_port() {
let port = pick_free_port().unwrap();
assert!(port > 0);
std::net::TcpListener::bind(("127.0.0.1", port)).unwrap();
}
#[tokio::test]
async fn is_port_listening_follows_the_socket() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
assert!(is_port_listening("127.0.0.1", port).await);
drop(listener);
let deadline = Instant::now() + Duration::from_secs(10);
while is_port_listening("127.0.0.1", port).await {
assert!(
Instant::now() < deadline,
"端口 {port} 已松开却仍显示在监听"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}