From a078b7506434ad3a653d502aff08f20ca9a5eae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 12 Jun 2026 16:25:00 +0200 Subject: [PATCH] feat(runtime): node:dns hosts-file reverse lookups (c-ares parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dns.reverse() went straight to a PTR query, but Node's c-ares uses "files first" lookup order: /etc/hosts entries that share an address or hostname are merged into one entry, and HostentToNames reports the merged entry's aliases (every name except the canonical first one). On machines where the upstream resolver has no PTR for 127.0.0.1 this made dns.reverse('127.0.0.1') reject with ENOTFOUND (an unhandled rejection in the dns/resolve parity test) while node returned the hosts-file aliases. - dns_resolver.rs: parse_hosts_file with c-ares merge semantics (merge by shared address or case-insensitive hostname), plus hosts_file_names(ip) used before falling through to PTR; unit tests for merge/comment/case behavior. - dns.rs: reverse consults the hosts file first and returns aliases; lookupService uses the canonical hosts-file name for non-loopback addresses. PERRY_DETERMINISTIC_NET=1 still bypasses both. - test-parity/node-suite/dns/resolve/localhost.ts: print record-or-error-code summaries instead of assuming the local resolver answers A/AAAA for localhost — node itself crashed with a TypeError on resolvers that return ENODATA, making the test unpassable regardless of Perry's behavior. The rewrite asserts the same resolution semantics with environment-independent output. node-suite dns: 5/6 -> 6/6 locally. --- crates/perry-runtime/src/dns.rs | 13 +- crates/perry-runtime/src/dns_resolver.rs | 121 ++++++++++++++++++ .../node-suite/dns/resolve/localhost.ts | 52 ++++++-- 3 files changed, 173 insertions(+), 13 deletions(-) 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));