Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/perry-runtime/src/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,13 @@ fn reverse_records_result(name: &str, servers: &[SocketAddr]) -> Result<f64, f64
Ok(ip) => 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();
Expand Down Expand Up @@ -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()
Expand Down
121 changes: 121 additions & 0 deletions crates/perry-runtime/src/dns_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,83 @@ pub fn system_nameservers() -> Vec<SocketAddr> {
]
}

/// 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<IpAddr>,
names: Vec<String>,
}

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<HostsEntry> {
let mut entries: Vec<HostsEntry> = 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::<IpAddr>() 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<Vec<String>> {
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<DnsError> {
match code {
ResponseCode::NoError => None,
Expand Down Expand Up @@ -463,3 +540,47 @@ pub fn reverse(ip: IpAddr, servers: &[SocketAddr]) -> Result<Vec<String>, 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);
}
}
52 changes: 40 additions & 12 deletions test-parity/node-suite/dns/resolve/localhost.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
return new Promise((resolve) => {
fn((err, value) => {
Expand All @@ -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 {
Expand All @@ -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));