use hickory_resolver::proto::rr::RData;
use std::net::IpAddr;
use std::time::Duration;
#[cfg(windows)]
use std::{process::Stdio, str::FromStr};
#[cfg(windows)]
use tokio::process::Command;
use tokio::time::timeout;
use super::resolver::shared_resolver;
use crate::error::{Error, Result};
pub async fn reverse_lookup_timeout(ip: IpAddr, timeout_ms: u64) -> Result<Option<String>> {
let resolver = shared_resolver()?;
let resp = match timeout(
Duration::from_millis(timeout_ms),
resolver.reverse_lookup(ip),
)
.await
{
Ok(Ok(r)) => r,
Ok(Err(e)) => return Err(Error::dns(format!("reverse lookup failed: {e}"))),
Err(_) => return Err(Error::Timeout(timeout_ms)),
};
let name = resp.answers().iter().find_map(|r| match &r.data {
RData::PTR(ptr) => Some(ptr.0.to_utf8()),
_ => None,
});
Ok(name.filter(|s| !s.is_empty()))
}
pub async fn reverse_lookup_best_effort_timeout(ip: IpAddr, timeout_ms: u64) -> Option<String> {
#[cfg(windows)]
if let Some(name) = reverse_lookup_windows_ping(ip, timeout_ms).await {
if let Some(normalized) = normalize_hostname(name) {
return Some(normalized);
}
}
reverse_lookup_timeout(ip, timeout_ms)
.await
.ok()
.flatten()
.and_then(normalize_hostname)
}
pub(crate) fn normalize_hostname(name: String) -> Option<String> {
let name = name.trim().trim_end_matches('.').trim();
if name.is_empty() || name.chars().any(char::is_control) {
None
} else {
Some(name.to_string())
}
}
#[cfg(windows)]
async fn reverse_lookup_windows_ping(ip: IpAddr, timeout_ms: u64) -> Option<String> {
if !matches!(ip, IpAddr::V4(_)) {
return None;
}
let ip_s = ip.to_string();
let wait_ms = timeout_ms.saturating_add(250);
let mut cmd = Command::new(crate::common::system_tool("ping"));
cmd.args(["-a", "-n", "1", "-w", &timeout_ms.to_string(), &ip_s])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let output = match timeout(Duration::from_millis(wait_ms), cmd.output()).await {
Ok(Ok(out)) => out,
_ => return None,
};
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines().take(4) {
if !line.contains('[') || !line.contains(']') {
continue;
}
let Some(before) = line.split('[').next() else {
continue;
};
let before = before.trim();
let Some(candidate) = before.split_whitespace().last() else {
continue;
};
let candidate = candidate.trim().trim_end_matches('.');
if candidate.is_empty() {
continue;
}
if IpAddr::from_str(candidate).is_ok() {
return None;
}
return Some(candidate.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::normalize_hostname;
#[test]
fn normalize_hostname_drops_trailing_dot_and_ws() {
assert_eq!(
normalize_hostname("host.example.com.".to_string()),
Some("host.example.com".to_string())
);
assert_eq!(
normalize_hostname(" host.example.com ".to_string()),
Some("host.example.com".to_string())
);
}
#[test]
fn normalize_hostname_rejects_empty() {
assert_eq!(normalize_hostname("".to_string()), None);
assert_eq!(normalize_hostname(".".to_string()), None);
}
#[test]
fn normalize_hostname_rejects_control_characters() {
assert_eq!(normalize_hostname("\u{1b}[2K\u{1b}[1A".to_string()), None);
assert_eq!(normalize_hostname("evil\u{1b}[31mhost".to_string()), None);
assert_eq!(
normalize_hostname("host\u{1b}]52;c;cHduZWQK\u{7}".to_string()),
None
);
assert_eq!(normalize_hostname("real\nfake".to_string()), None);
}
#[test]
fn normalize_hostname_keeps_legitimate_unicode() {
assert_eq!(
normalize_hostname("münchen.example.com".to_string()),
Some("münchen.example.com".to_string())
);
}
}