diff --git a/crates/perry-runtime/src/dns.rs b/crates/perry-runtime/src/dns.rs index 0edbf6ae07..850bcb1f64 100644 --- a/crates/perry-runtime/src/dns.rs +++ b/crates/perry-runtime/src/dns.rs @@ -1081,6 +1081,13 @@ fn reverse_records_result(name: &str, servers: &[SocketAddr]) -> Result ip, Err(_) => return Err(invalid_address_error(str_value(name))), }; + // c-ares checks the hosts file before DNS ("fb" lookup order) and Node's + // HostentToNames reports only h_aliases — every merged-entry name except + // the canonical first one. Match that before falling through to PTR. + if let Some(names) = dns_resolver::hosts_file_names(ip) { + let aliases: Vec<&str> = names.iter().skip(1).map(String::as_str).collect(); + return Ok(string_array_value(&aliases)); + } match dns_resolver::reverse(ip, servers) { Ok(names) => { let refs: Vec<&str> = names.iter().map(String::as_str).collect(); @@ -1322,9 +1329,13 @@ fn lookup_service_result(address: &str, port: u16) -> Result<(String, String), f // getnameinfo-style: reverse-resolve the host (numeric address when there // is no PTR record), and map the port to a service name. Loopback resolves // to "localhost" like getnameinfo does via /etc/hosts (a PTR query to the - // upstream resolver would not know the loopback zone). + // upstream resolver would not know the loopback zone). Non-loopback + // addresses with a hosts-file entry get the canonical (first) name. let hostname = if ip.is_loopback() { "localhost".to_string() + } else if let Some(name) = dns_resolver::hosts_file_names(ip).and_then(|n| n.into_iter().next()) + { + name } else { dns_resolver::reverse(ip, &configured_servers()) .ok() diff --git a/crates/perry-runtime/src/dns_resolver.rs b/crates/perry-runtime/src/dns_resolver.rs index 1099a89c18..82bb1d8f4a 100644 --- a/crates/perry-runtime/src/dns_resolver.rs +++ b/crates/perry-runtime/src/dns_resolver.rs @@ -182,6 +182,83 @@ pub fn system_nameservers() -> Vec { ] } +/// One merged hosts-file entry. c-ares merges `/etc/hosts` lines that share +/// an address or a hostname into a single entry (so `127.0.0.1 localhost` +/// and `::1 localhost` become one entry with both addresses); the first name +/// is the canonical hostname and the rest are aliases. +struct HostsEntry { + addrs: Vec, + names: Vec, +} + +impl HostsEntry { + fn has_name(&self, name: &str) -> bool { + self.names.iter().any(|n| n.eq_ignore_ascii_case(name)) + } +} + +fn parse_hosts_file(content: &str) -> Vec { + let mut entries: Vec = Vec::new(); + for line in content.lines() { + let line = line.split('#').next().unwrap_or(""); + let mut tokens = line.split_whitespace(); + let Some(addr_token) = tokens.next() else { + continue; + }; + let Ok(addr) = addr_token.parse::() else { + continue; + }; + let names: Vec<&str> = tokens.collect(); + if names.is_empty() { + continue; + } + let target = entries + .iter() + .position(|e| e.addrs.contains(&addr) || names.iter().any(|n| e.has_name(n))); + match target { + Some(i) => { + let entry = &mut entries[i]; + if !entry.addrs.contains(&addr) { + entry.addrs.push(addr); + } + for name in names { + if !entry.has_name(name) { + entry.names.push(name.to_string()); + } + } + } + None => entries.push(HostsEntry { + addrs: vec![addr], + names: names.iter().map(|s| s.to_string()).collect(), + }), + } + } + entries +} + +fn hosts_file_path() -> std::path::PathBuf { + #[cfg(windows)] + { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); + return std::path::PathBuf::from(format!("{root}\\System32\\drivers\\etc\\hosts")); + } + #[cfg(not(windows))] + std::path::PathBuf::from("/etc/hosts") +} + +/// The hosts-file names for `ip`, mirroring c-ares's "files first" lookup +/// order for `gethostbyaddr` (which backs `dns.reverse`). Returns the merged +/// entry's full name list (canonical name first), or `None` when the file +/// has no entry for the address — the caller then falls through to a real +/// PTR query like c-ares does. +pub fn hosts_file_names(ip: IpAddr) -> Option> { + let content = std::fs::read_to_string(hosts_file_path()).ok()?; + parse_hosts_file(&content) + .into_iter() + .find(|e| e.addrs.contains(&ip)) + .map(|e| e.names) +} + fn rcode_to_error(code: ResponseCode) -> Option { match code { ResponseCode::NoError => None, @@ -463,3 +540,47 @@ pub fn reverse(ip: IpAddr, servers: &[SocketAddr]) -> Result, DnsErr } Ok(names) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts_merge_by_address_and_name() { + // 127.0.0.1 lines merge by address; the ::1 line merges into the same + // entry through the shared "localhost" name (c-ares semantics). + let entries = parse_hosts_file( + "127.0.0.1 localhost\n\ + 127.0.0.1 proxy.dev # trailing comment\n\ + ::1 localhost\n\ + 127.0.0.1 alias.local\n", + ); + assert_eq!(entries.len(), 1); + let entry = &entries[0]; + assert!(entry.addrs.contains(&"127.0.0.1".parse().unwrap())); + assert!(entry.addrs.contains(&"::1".parse().unwrap())); + assert_eq!(entry.names, vec!["localhost", "proxy.dev", "alias.local"]); + } + + #[test] + fn hosts_separate_entries_stay_separate() { + let entries = parse_hosts_file( + "# comment line\n\ + 1.2.3.4 www.example.com example.com\n\ + \n\ + 5.6.7.8 other.test\n\ + not-an-ip ignored.example\n", + ); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].names, vec!["www.example.com", "example.com"]); + assert_eq!(entries[1].names, vec!["other.test"]); + } + + #[test] + fn hosts_name_matching_is_case_insensitive() { + let entries = parse_hosts_file("1.2.3.4 Example.COM\n5.6.7.8 example.com\n"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].names, vec!["Example.COM"]); + assert_eq!(entries[0].addrs.len(), 2); + } +} diff --git a/test-parity/node-suite/dns/resolve/localhost.ts b/test-parity/node-suite/dns/resolve/localhost.ts index 5de6a46db3..d495aaef5a 100644 --- a/test-parity/node-suite/dns/resolve/localhost.ts +++ b/test-parity/node-suite/dns/resolve/localhost.ts @@ -1,6 +1,11 @@ import * as dns from "node:dns"; import * as dnsPromises from "node:dns/promises"; +// resolve* answers depend on the machine's configured nameserver (some +// resolvers answer A/AAAA for "localhost", some return ENODATA/NXDOMAIN), so +// every check prints either the matched record or the error code instead of +// assuming success — node and Perry must agree either way. + function callbackCall(fn: (cb: (err: any, value: any) => void) => void): Promise { return new Promise((resolve) => { fn((err, value) => { @@ -9,8 +14,15 @@ function callbackCall(fn: (cb: (err: any, value: any) => void) => void): Promise }); } -function strings(value: unknown): boolean { - return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string"); +function summarize(err: any, value: unknown, expected: string): string { + if (err) return "err:" + err.code; + if (!Array.isArray(value)) return "value:" + typeof value; + return value.includes(expected) ? "has " + expected : JSON.stringify(value); +} + +function reverseSummary(err: any, value: unknown): string { + if (err) return "err:" + err.code; + return JSON.stringify(value); } function thrownShape(label: string, fn: () => void): void { @@ -26,19 +38,35 @@ const callback4 = await callbackCall((cb) => dns.resolve4("localhost", cb)); const callback6 = await callbackCall((cb) => dns.resolve6("localhost", cb)); const callbackA = await callbackCall((cb) => dns.resolve("localhost", "A", cb)); const callbackReverse = await callbackCall((cb) => dns.reverse("127.0.0.1", cb)); -console.log("callback resolve4:", callback4.err === null, callback4.value.includes("127.0.0.1")); -console.log("callback resolve6:", callback6.err === null, callback6.value.includes("::1")); -console.log("callback resolve A:", callbackA.err === null, callbackA.value.includes("127.0.0.1")); -console.log("callback reverse:", callbackReverse.err === null, strings(callbackReverse.value)); +console.log("callback resolve4:", summarize(callback4.err, callback4.value, "127.0.0.1")); +console.log("callback resolve6:", summarize(callback6.err, callback6.value, "::1")); +console.log("callback resolve A:", summarize(callbackA.err, callbackA.value, "127.0.0.1")); +console.log("callback reverse:", reverseSummary(callbackReverse.err, callbackReverse.value)); -const promise4 = await dnsPromises.resolve4("localhost"); -const promiseReverse = await dnsPromises.reverse("127.0.0.1"); -console.log("promise resolve4:", promise4.includes("127.0.0.1")); -console.log("promise reverse:", strings(promiseReverse)); +let promise4: string; +try { + promise4 = summarize(null, await dnsPromises.resolve4("localhost"), "127.0.0.1"); +} catch (e: any) { + promise4 = "err:" + e.code; +} +console.log("promise resolve4:", promise4); + +let promiseReverse: string; +try { + promiseReverse = reverseSummary(null, await dnsPromises.reverse("127.0.0.1")); +} catch (e: any) { + promiseReverse = "err:" + e.code; +} +console.log("promise reverse:", promiseReverse); const promiseResolver = new dnsPromises.Resolver(); -const resolver4 = await promiseResolver.resolve4("localhost"); -console.log("promise resolver resolve4:", resolver4.includes("127.0.0.1")); +let resolver4: string; +try { + resolver4 = summarize(null, await promiseResolver.resolve4("localhost"), "127.0.0.1"); +} catch (e: any) { + resolver4 = "err:" + e.code; +} +console.log("promise resolver resolve4:", resolver4); thrownShape("callback bad rrtype", () => dns.resolve("localhost", "BAD", () => {})); thrownShape("promise bad rrtype", () => dnsPromises.resolve("localhost", "BAD" as any));