Skip to main content

netscli_core/dns/
reverse.rs

1use 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    // hickory 0.26: `Lookup` is now flat; extract the first PTR's Name
26    // and convert to UTF-8 manually instead of `.iter().next().to_utf8()`.
27    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
34/// Reverse-resolve an IP address to a hostname.
35///
36/// - On Windows, `ping -a` often resolves names via LLMNR/NetBIOS even when
37///   no PTR records exist.
38/// - On other platforms, this falls back to a DNS PTR lookup.
39///
40/// Both paths run their result through `normalize_hostname` so callers get
41/// consistent output regardless of the OS we're on.
42pub 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
57/// Trim a resolved name, and reject one carrying a control character.
58///
59/// Every reverse-lookup result funnels through here, so this is the one
60/// place that can guarantee the property for all consumers — plain-text
61/// CLI, `--json`, the TUI, the desktop app and the MCP server alike.
62///
63/// It matters because of the Windows branch. [`reverse_lookup_windows_ping`]
64/// lifts the name out of `ping -a` stdout, and `ping -a` resolves through
65/// LLMNR and NetBIOS, where the name is whatever a device on the local link
66/// decided to call itself — raw bytes, escaped by nothing. The whitespace
67/// tokenisation there does not remove `ESC`, so a device naming itself
68/// `\x1b[2K\x1b[1A` could repaint the row above its own in `netscli
69/// discover --resolve` output and forge another host's line.
70///
71/// The pure-DNS path happens to be safe already: hickory's `Label` Display
72/// octal-escapes control bytes. But that is a dependency's behaviour, not a
73/// property this code holds, and it does not cover the Windows route.
74///
75/// Rejecting rather than sanitising: a hostname containing a control
76/// character is malformed by any definition, so "no name resolved" is the
77/// honest answer and leaves nothing mangled to display. Callers already
78/// handle `None`.
79fn 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    // Only IPv4 is supported by the ping parsing below.
91    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    // Absolute System32 path rather than a bare name -- see
98    // `common::system_tools` for the planting measurement behind this.
99    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    // Only scan the first few lines — `ping -a` emits the hostname in its
113    // opening "Pinging <name> [<ip>] ..." banner. Later reply lines (e.g.
114    // "Reply from 192.168.1.5: bytes=32 ..." on some locales) can also
115    // contain `[ip]` and would otherwise misparse as a hostname.
116    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        // Take the last whitespace token before the "[ip]" segment as the
126        // candidate hostname. This is locale-tolerant: English "Pinging X [ip]",
127        // Spanish "Haciendo ping a X [ip] con 32 ...", etc.
128        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 the "hostname" is actually just the IP literal, there was no
137        // reverse-resolution — fall through to PTR lookup.
138        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        // The Windows `ping -a` route returns LLMNR/NetBIOS names as raw
173        // bytes. A device that names itself with a cursor-up plus
174        // line-erase can repaint the row above its own in `discover
175        // --resolve` output, forging another host's line.
176        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        // OSC 52 writes the operator's clipboard; it needs the BEL too.
179        assert_eq!(
180            normalize_hostname("host\u{1b}]52;c;cHduZWQK\u{7}".to_string()),
181            None
182        );
183        // A bare newline would fabricate an extra output line.
184        assert_eq!(normalize_hostname("real\nfake".to_string()), None);
185    }
186
187    #[test]
188    fn normalize_hostname_keeps_legitimate_unicode() {
189        // Rejecting control characters must not reject IDN hostnames.
190        assert_eq!(
191            normalize_hostname("münchen.example.com".to_string()),
192            Some("münchen.example.com".to_string())
193        );
194    }
195}