diff --git a/src/dns.zig b/src/dns.zig index a9c43a0e5c1..a7ae37bda23 100644 --- a/src/dns.zig +++ b/src/dns.zig @@ -286,9 +286,18 @@ pub const GetAddrInfo = struct { .{ "getaddrinfo", .libc }, }); + // `dns.lookup` is specified to behave like getaddrinfo(3), which + // consults nsswitch.conf / /etc/hosts. On Linux the default + // backend used to be `c_ares`, which produced results that did + // not match Node for names defined only in /etc/hosts — see + // https://github.com/oven-sh/bun/issues/29227. + // + // Only `dns.lookup` routes through this default. `dns.resolve*` + // (and all record-type queries) use c-ares directly, matching + // Node's behavior. pub const default: GetAddrInfo.Backend = switch (bun.Environment.os) { .mac, .windows => .system, - else => .c_ares, + else => .libc, }; pub const FromJSError = JSError || error{ diff --git a/test/regression/issue/29227.test.ts b/test/regression/issue/29227.test.ts new file mode 100644 index 00000000000..38992fd558a --- /dev/null +++ b/test/regression/issue/29227.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux } from "harness"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; + +// https://github.com/oven-sh/bun/issues/29227 +// +// On Linux, `dns.lookup()` for a name that only has an IPv4 entry in +// /etc/hosts must return only IPv4, matching Node. Previously Bun's +// default backend returned an extra `::1` entry (and, because the +// default result order is `verbatim`, that `::1` became the single +// result returned by the callback form). +// +// This test requires Linux because it mutates /etc/hosts. The bug is +// Linux-specific — macOS uses LibInfo and Windows uses libuv, both +// already matching Node. +test.skipIf(!isLinux)("dns.lookup respects /etc/hosts and matches Node", async () => { + // Use a random tag so re-runs don't conflict. The tag is long enough + // that it's extremely unlikely to collide with anything on the host. + const tag = "bun-issue-29227-" + Math.random().toString(36).slice(2, 10); + const hostsEntry = `\n127.0.0.1 ${tag}\n`; + + // /etc/hosts is a system file; snapshot-then-restore so a crashed + // test can't leave the system in a bad state. + let original: string; + try { + original = readFileSync("/etc/hosts", "utf8"); + } catch { + // Not writable / not root — skip. CI Linux is root in the container. + return; + } + + try { + appendFileSync("/etc/hosts", hostsEntry); + } catch { + return; + } + + try { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` +const dns = require("node:dns"); +const name = ${JSON.stringify(tag)}; +dns.lookup(name, { all: true }, (err, results) => { + if (err) { console.error("ERR:" + err.code); process.exit(1); } + console.log(JSON.stringify(results)); +}); +dns.lookup(name, (err, address, family) => { + if (err) { console.error("ERR:" + err.code); process.exit(1); } + console.log("single:" + address + ":" + family); +}); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Filter out the ASAN warning that debug builds print to stderr. + const realStderr = stderr + .split("\n") + .filter(l => l && !l.includes("ASAN")) + .join("\n"); + expect(realStderr).toBe(""); + expect(exitCode).toBe(0); + + // Find the `all` array and the single-form line in the output. + const lines = stdout.trim().split("\n"); + const allLine = lines.find(l => l.startsWith("["))!; + const singleLine = lines.find(l => l.startsWith("single:"))!; + + expect(JSON.parse(allLine)).toEqual([{ address: "127.0.0.1", family: 4 }]); + expect(singleLine).toBe("single:127.0.0.1:4"); + } finally { + // Always restore /etc/hosts, even if assertions fail. + writeFileSync("/etc/hosts", original); + } +});