netscli_core/dns/
reverse.rs1use hickory_resolver::proto::rr::RData;
2use std::net::IpAddr;
3use std::time::Duration;
4#[cfg(windows)]
5use std::{process::Stdio, str::FromStr};
6#[cfg(windows)]
7use tokio::process::Command;
8use tokio::time::timeout;
9
10use super::resolver::shared_resolver;
11use crate::error::{Error, Result};
12
13pub async fn reverse_lookup_timeout(ip: IpAddr, timeout_ms: u64) -> Result<Option<String>> {
14 let resolver = shared_resolver()?;
15 let resp = match timeout(
16 Duration::from_millis(timeout_ms),
17 resolver.reverse_lookup(ip),
18 )
19 .await
20 {
21 Ok(Ok(r)) => r,
22 Ok(Err(e)) => return Err(Error::dns(format!("reverse lookup failed: {e}"))),
23 Err(_) => return Err(Error::Timeout(timeout_ms)),
24 };
25 let name = resp.answers().iter().find_map(|r| match &r.data {
28 RData::PTR(ptr) => Some(ptr.0.to_utf8()),
29 _ => None,
30 });
31 Ok(name.filter(|s| !s.is_empty()))
32}
33
34pub async fn reverse_lookup_best_effort_timeout(ip: IpAddr, timeout_ms: u64) -> Option<String> {
43 #[cfg(windows)]
44 if let Some(name) = reverse_lookup_windows_ping(ip, timeout_ms).await {
45 if let Some(normalized) = normalize_hostname(name) {
46 return Some(normalized);
47 }
48 }
49
50 reverse_lookup_timeout(ip, timeout_ms)
51 .await
52 .ok()
53 .flatten()
54 .and_then(normalize_hostname)
55}
56
57fn normalize_hostname(name: String) -> Option<String> {
80 let name = name.trim().trim_end_matches('.').trim();
81 if name.is_empty() || name.chars().any(char::is_control) {
82 None
83 } else {
84 Some(name.to_string())
85 }
86}
87
88#[cfg(windows)]
89async fn reverse_lookup_windows_ping(ip: IpAddr, timeout_ms: u64) -> Option<String> {
90 if !matches!(ip, IpAddr::V4(_)) {
92 return None;
93 }
94
95 let ip_s = ip.to_string();
96 let wait_ms = timeout_ms.saturating_add(250);
97 let mut cmd = Command::new(crate::common::system_tool("ping"));
100 cmd.args(["-a", "-n", "1", "-w", &timeout_ms.to_string(), &ip_s])
101 .stdout(Stdio::piped())
102 .stderr(Stdio::piped())
103 .kill_on_drop(true);
104
105 let output = match timeout(Duration::from_millis(wait_ms), cmd.output()).await {
106 Ok(Ok(out)) => out,
107 _ => return None,
108 };
109
110 let stdout = String::from_utf8_lossy(&output.stdout);
111
112 for line in stdout.lines().take(4) {
117 if !line.contains('[') || !line.contains(']') {
118 continue;
119 }
120 let Some(before) = line.split('[').next() else {
121 continue;
122 };
123 let before = before.trim();
124
125 let Some(candidate) = before.split_whitespace().last() else {
129 continue;
130 };
131 let candidate = candidate.trim().trim_end_matches('.');
132 if candidate.is_empty() {
133 continue;
134 }
135
136 if IpAddr::from_str(candidate).is_ok() {
139 return None;
140 }
141
142 return Some(candidate.to_string());
143 }
144
145 None
146}
147
148#[cfg(test)]
149mod tests {
150 use super::normalize_hostname;
151
152 #[test]
153 fn normalize_hostname_drops_trailing_dot_and_ws() {
154 assert_eq!(
155 normalize_hostname("host.example.com.".to_string()),
156 Some("host.example.com".to_string())
157 );
158 assert_eq!(
159 normalize_hostname(" host.example.com ".to_string()),
160 Some("host.example.com".to_string())
161 );
162 }
163
164 #[test]
165 fn normalize_hostname_rejects_empty() {
166 assert_eq!(normalize_hostname("".to_string()), None);
167 assert_eq!(normalize_hostname(".".to_string()), None);
168 }
169
170 #[test]
171 fn normalize_hostname_rejects_control_characters() {
172 assert_eq!(normalize_hostname("\u{1b}[2K\u{1b}[1A".to_string()), None);
177 assert_eq!(normalize_hostname("evil\u{1b}[31mhost".to_string()), None);
178 assert_eq!(
180 normalize_hostname("host\u{1b}]52;c;cHduZWQK\u{7}".to_string()),
181 None
182 );
183 assert_eq!(normalize_hostname("real\nfake".to_string()), None);
185 }
186
187 #[test]
188 fn normalize_hostname_keeps_legitimate_unicode() {
189 assert_eq!(
191 normalize_hostname("münchen.example.com".to_string()),
192 Some("münchen.example.com".to_string())
193 );
194 }
195}