From 566e7ccbb1e9f91174b55ad5afcb577460f4cc6a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 08:10:53 +0000 Subject: [PATCH] feat: answer CNAME, MX and TXT from the record set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pit publishes four record types. This bridge answered one of them: the socket gated on A/AAAA and returned an honest empty NOERROR for everything else, so an owner could publish an MX, see it listed on their names, and get nothing back from `dig MX` — with no way to tell a record that is not served from one they typed wrong. Answered now, out of the `?records=1` the registry already exposes: - TXT is split into 255-byte strings on BYTE boundaries. Long values are normal rather than exceptional (a DKIM key never fits in one), and splitting on characters cuts a multi-byte one in half so neither piece decodes. - MX carries its preference first, as the wire format requires. - A record whose rdata will not encode is dropped rather than throwing. This reads JSON over HTTP from a registry that may be a different version than the bridge, and one bad row must not take down the answers beside it. - Oversized replies are trimmed to what fits in 512 bytes and marked truncated, rather than dropping every answer: this bridge speaks UDP only, so a client that retries over TCP finds nobody home. A published CNAME now also answers the address question it was invisible to. A name pointed at a hostname has no address to give, and targetAddress returns null for one on purpose — the bridge does not do clearnet DNS — so it was NODATA and looked exactly like a typo. Handing back the CNAME lets the client chase it through its own resolver, which is the party that may. That second lookup happens only when the name is here and has no address, so a name that already resolves pays nothing for it. `records` appears on a resolveName result only when it was asked for, so every existing caller sees the shape it always did. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 16 ++- lib/dns.mjs | 202 ++++++++++++++++++++++++-- package.json | 2 +- test/dns-records.test.mjs | 295 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 13 deletions(-) create mode 100644 test/dns-records.test.mjs diff --git a/README.md b/README.md index 76ffc53..fa18767 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,19 @@ The Moshpit registry speaks HTTP, not DNS. `pit.moshcode.sh` answers redirects tabs works, but redirecting is not resolving, and nothing outside that browser benefits from it. -This is the bridge: a tiny authoritative resolver that answers A queries for -Moshpit endings out of the registry's HTTP API, and is deliberately silent about -everything else. +This is the bridge: a tiny authoritative resolver that answers Moshpit endings +out of the registry's HTTP API, and is deliberately silent about everything +else. + +It answers `AAAA` and `A` from the name's target, and `CNAME`, `MX` and `TXT` +from the records its owner publishes in the Pit. Question types it does not +serve get an empty `NOERROR` rather than `NXDOMAIN` — the name exists, there is +just nothing to say about that question, and denying the name outright would +take its address down with it. + +A name pointed at a hostname rather than an address answers with the `CNAME` it +published, for the client to chase through its own resolver. This bridge never +does clearnet DNS itself. ## One suffix, not your whole resolver diff --git a/lib/dns.mjs b/lib/dns.mjs index e26b931..d08dd3a 100644 --- a/lib/dns.mjs +++ b/lib/dns.mjs @@ -29,10 +29,38 @@ export const DEFAULT_TIMEOUT_MS = 4000; export const TYPE_A = 1; export const TYPE_AAAA = 28; +export const TYPE_CNAME = 5; +export const TYPE_MX = 15; +export const TYPE_TXT = 16; const CLASS_IN = 1; const RCODE_OK = 0; const RCODE_NXDOMAIN = 3; +/** + * The question types answered out of the registry's record set, mapped to the + * name the registry calls them. + * + * Address questions are not in here. They are answered from `target`, which the + * registry keeps in step with the address records and which this bridge has + * read since before records existed — routing them through here would change + * how a name already resolving today gets its answer, to arrive at the same + * address. + */ +export const RECORD_TYPES = new Map([ + [TYPE_CNAME, "CNAME"], + [TYPE_MX, "MX"], + [TYPE_TXT, "TXT"], +]); + +/** + * What fits in a UDP answer without EDNS. + * + * 512 bytes is the floor every resolver accepts. Beyond it a datagram may be + * dropped by a middlebox rather than delivered short, so the reply is trimmed + * to what fits and marked truncated instead of being sent oversized and lost. + */ +export const UDP_SAFE_BYTES = 512; + /* ---------------------------------------------------------------- wire codec */ /** Encode a hostname as DNS labels. */ @@ -141,6 +169,108 @@ function ipv6(address) { return buf; } + +/** + * TXT rdata: one or more length-prefixed strings. + * + * Split at 255 bytes because that is the largest a single DNS character-string + * can be, and long TXT values are normal rather than exceptional — a DKIM key + * does not fit in one and is always carried as several. A client joins them + * back together, so the split is invisible above the wire. + * + * Split on bytes, not characters: a multi-byte character straddling the + * boundary would be cut in half and neither piece would decode. + */ +export function rdataTxt(value) { + const bytes = Buffer.from(String(value), "utf8"); + if (!bytes.length) return Buffer.from([0]); + const chunks = []; + for (let i = 0; i < bytes.length; i += 255) { + const chunk = bytes.subarray(i, i + 255); + chunks.push(Buffer.concat([Buffer.from([chunk.length]), chunk])); + } + return Buffer.concat(chunks); +} + +/** MX rdata: a 16-bit preference, then the exchange as labels. */ +export function rdataMx(priority, value) { + const preference = Buffer.alloc(2); + preference.writeUInt16BE(Math.min(65_535, Math.max(0, Number(priority) || 0)), 0); + return Buffer.concat([preference, encodeName(value)]); +} + +/** + * The rdata for one record from the registry, or null when it cannot be + * encoded. + * + * Null rather than a throw: one malformed record must not take down the answer + * for the ones beside it that are fine. The registry validates on the way in, + * so this is the second line — it is reading data over HTTP from a service that + * may be a different version than this bridge. + */ +export function encodeRdata(record) { + try { + if (record?.type === "TXT") return rdataTxt(record.value); + if (record?.type === "MX") return rdataMx(record.priority, record.value); + if (record?.type === "CNAME") return encodeName(record.value); + if (record?.type === "AAAA") return ipv6(record.value); + if (record?.type === "A") return ipv4(record.value); + } catch { + return null; + } + return null; +} + +const TYPE_NUMBERS = new Map([["A", TYPE_A], ["CNAME", TYPE_CNAME], ["MX", TYPE_MX], + ["TXT", TYPE_TXT], ["AAAA", TYPE_AAAA]]); + +/** + * A response carrying whole records rather than a bare address. + * + * Answers are fitted to `limit` and TC is set only if something was left out. A + * name with nine MX records should hand back the seven that fit and say it was + * truncated, not nothing at all — this bridge speaks UDP only, so a client that + * retries over TCP finds no one listening. + * + * `exists` carries the same NODATA/NXDOMAIN distinction buildResponse draws: a + * name with no TXT record still exists, and answering NXDOMAIN would deny it + * for every other type at once. + */ +export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TTL, exists = true, limit = UDP_SAFE_BYTES } = {}) { + const question = buf.subarray(12, query.questionEnd); + const encoded = []; + let dropped = false; + let size = 12 + question.length; + + for (const record of records) { + const rdata = encodeRdata(record); + const type = TYPE_NUMBERS.get(record?.type); + if (!rdata || !type) continue; + const answer = Buffer.alloc(12); + answer.writeUInt16BE(0xc00c, 0); // the question's name, by pointer + answer.writeUInt16BE(type, 2); + answer.writeUInt16BE(CLASS_IN, 4); + // The record's own TTL when it has one. An owner who set 60 on an address + // that moves meant it, and overriding it with the bridge's default would + // quietly hold the old answer for longer than they asked. + answer.writeUInt32BE(Number.isFinite(record.ttl) ? Math.max(0, Math.floor(record.ttl)) : ttl, 6); + answer.writeUInt16BE(rdata.length, 10); + + if (size + answer.length + rdata.length > limit) { dropped = true; continue; } + size += answer.length + rdata.length; + encoded.push(answer, rdata); + } + + const answers = encoded.length / 2; + const head = header(query.id, { + rcode: answers || exists ? RCODE_OK : RCODE_NXDOMAIN, + answers, + recursionDesired: query.recursionDesired, + }); + if (dropped) head.writeUInt16BE(head.readUInt16BE(2) | 0x0200, 2); // TC + return Buffer.concat([head, question, ...encoded]); +} + /** * Build an address-record response for the family the query asked for. * @@ -248,28 +378,63 @@ export async function fetchTlds({ */ export async function resolveName( name, - { registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}, + { registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, records = false } = {}, ) { const parsed = parseRegistryName(name); if (!parsed) return { status: "not-a-name", target: null }; try { + // `&records=1` only when the question needs the whole set. Every address + // lookup on the machine comes through here, and the registry does a second + // query to answer it — a browser opening a page must not pay for records it + // will never read. const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/resolve?name=${encodeURIComponent( `${parsed.label}.${parsed.tld}`, - )}`; + )}${records ? "&records=1" : ""}`; const { res, json } = await fetchJsonWithTimeout(fetchImpl, url, timeoutMs); if (!res.ok) return { status: "unreachable", target: null }; const claimed = typeof json?.name_registered === "boolean" ? json.name_registered : json?.registered; if (typeof claimed !== "boolean") return { status: "unreachable", target: null }; + // The `records` key appears only when it was asked for. Every caller that + // wants an address deep-compares this shape, and an empty array they never + // requested is a difference they would have to be taught to ignore. + const found = records ? { records: Array.isArray(json.records) ? json.records : [] } : {}; const target = typeof json.target === "string" && json.target ? json.target : null; - if (target) return { status: "live", target }; - return { status: "parked", target: null, registered: claimed }; + if (target) return { status: "live", target, ...found }; + return { status: "parked", target: null, registered: claimed, ...found }; } catch { return { status: "unreachable", target: null }; } } +/** + * The records of one type a name publishes, and whether the name is here. + * + * Both halves matter and they are not the same question: a name with no MX + * record still exists, so the answer is NODATA, while a name nobody holds is + * NXDOMAIN. Collapsing them would let a missing MX deny the name's address too. + */ +export async function answerRecords(name, options = {}) { + const { type } = options; + const result = await resolveName(name, { ...options, records: true }); + const exists = result.status === "live" || result.status === "parked"; + if (!exists || !type) return { exists, records: [] }; + return { exists, records: (result.records || []).filter((r) => r?.type === type) }; +} + +/** + * Is an address question on this name worth a second look for a CNAME? + * + * True when the name is here and has no address to give. A CNAME is the one + * thing that can still answer such a question, and finding out costs another + * round trip to the registry — so it is asked only on the path that would + * otherwise return nothing at all, never on a name that already has an address. + */ +export function mayHaveCname({ exists, address }) { + return Boolean(exists) && !address; +} + /* -------------------------------------------------------------------- server */ /** @@ -350,18 +515,37 @@ export function createServer(options = {}) { if (!query) return; // malformed, or a response — say nothing at all let address = null; let exists = false; - // Only address questions can be answered with an address; everything else - // (MX, TXT, HTTPS) gets an honest empty NOERROR rather than a lie. It still - // has to be looked up: a browser asks HTTPS/SVCB beside every A and AAAA, + let reply = null; + + // Three shapes of question. CNAME, MX and TXT are answered from the record + // set; addresses are answered from `target` as they always have been; + // everything else (HTTPS/SVCB and the rest) still gets an honest empty + // NOERROR rather than a lie — a browser asks HTTPS beside every A and AAAA, // and NXDOMAIN to that one denies the name for the whole page load. - if (query.class === CLASS_IN) { + const wanted = query.class === CLASS_IN ? RECORD_TYPES.get(query.type) : null; + if (wanted) { + const found = await answerRecords(query.name, { ...options, type: wanted }).catch(() => null); + exists = Boolean(found?.exists); + reply = buildRecordResponse(query, msg, found?.records || [], { ttl, exists }); + } else if (query.class === CLASS_IN) { const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA; const policy = await answerPolicy(query.name, { ...options, wantsAddress }).catch(() => null); if (policy) ({ exists, address } = policy); + // A name that is here with no address to give may still have published a + // CNAME, which is the one record that can answer an address question. + // Handing it back lets the client chase it through its own resolver — the + // only party here that may do clearnet DNS — instead of the NODATA that + // made a pointed name look broken. + if (wantsAddress && mayHaveCname(policy || {})) { + const found = await answerRecords(query.name, { ...options, type: "CNAME" }).catch(() => null); + if (found?.records?.length) { + reply = buildRecordResponse(query, msg, found.records, { ttl, exists }); + } + } } onQuery({ name: query.name, type: query.type, address }); try { - socket.send(buildResponse(query, msg, address, ttl, exists), rinfo.port, rinfo.address); + socket.send(reply || buildResponse(query, msg, address, ttl, exists), rinfo.port, rinfo.address); } catch { /* client vanished — nothing useful to do */ } diff --git a/package.json b/package.json index 3026159..913a020 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moshcoder/moshpit-dns", - "version": "0.2.1", + "version": "0.3.0", "type": "module", "license": "MIT", "description": "Resolve Moshpit names on this machine. A tiny DNS bridge for .whatever endings, routed per-suffix so the rest of the internet is untouched.", diff --git a/test/dns-records.test.mjs b/test/dns-records.test.mjs new file mode 100644 index 0000000..481a0ae --- /dev/null +++ b/test/dns-records.test.mjs @@ -0,0 +1,295 @@ +/** + * The record types the bridge answers beyond an address. + * + * A name could publish an MX and a TXT in the registry while `dig MX` came + * back empty, because the bridge gated on A/AAAA and returned NODATA for + * everything else. The records existed and nothing served them. + * + * So these tests read the rdata back off the wire rather than trusting an + * answer count: a TXT record split at the wrong byte, or an MX with its + * preference in the wrong order, produces a reply of exactly the right shape + * that no client can use. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; + +import { + answerRecords, buildRecordResponse, createServer, encodeName, parseQuery, + rdataMx, rdataTxt, TYPE_AAAA, TYPE_CNAME, TYPE_MX, TYPE_TXT, +} from "../lib/dns.mjs"; + +const TYPE_A = 1; +const RCODE_OK = 0; +const RCODE_NXDOMAIN = 3; + +function query(name, { id = 0x1234, type = TYPE_A, cls = 1, rd = true } = {}) { + const head = Buffer.alloc(12); + head.writeUInt16BE(id, 0); + head.writeUInt16BE(rd ? 0x0100 : 0, 2); + head.writeUInt16BE(1, 4); + const tail = Buffer.alloc(4); + tail.writeUInt16BE(type, 0); + tail.writeUInt16BE(cls, 2); + return Buffer.concat([head, encodeName(name), tail]); +} + +const rcode = (reply) => reply.readUInt16BE(2) & 0x0f; +const count = (reply) => reply.readUInt16BE(6); +const truncated = (reply) => Boolean(reply.readUInt16BE(2) & 0x0200); + +/** Labels out of an rdata buffer — the encoder never writes a pointer. */ +function readName(buf, offset = 0) { + const labels = []; + let i = offset; + for (;;) { + const len = buf[i]; + if (len === undefined) throw new Error("truncated"); + if (len === 0) return { name: labels.join("."), offset: i + 1 }; + i += 1; + labels.push(buf.toString("ascii", i, i + len)); + i += len; + } +} + +/** Every answer in a reply, decoded far enough to be checked. */ +function readAnswers(reply, name) { + const questionEnd = 12 + encodeName(name).length + 4; + const found = []; + let i = questionEnd; + for (let n = 0; n < count(reply); n++) { + assert.equal(reply.readUInt16BE(i), 0xc00c, "answer does not point at the question's name"); + const type = reply.readUInt16BE(i + 2); + const ttl = reply.readUInt32BE(i + 6); + const length = reply.readUInt16BE(i + 10); + const rdata = reply.subarray(i + 12, i + 12 + length); + const record = { type, ttl }; + if (type === TYPE_TXT) { + // Rejoined the way a client does: the 255-byte split is invisible above + // the wire, so a value that survives the round trip must come back whole. + const parts = []; + let at = 0; + while (at < rdata.length) { + const len = rdata[at]; + parts.push(rdata.toString("utf8", at + 1, at + 1 + len)); + at += 1 + len; + } + record.value = parts.join(""); + } else if (type === TYPE_MX) { + record.priority = rdata.readUInt16BE(0); + record.value = readName(rdata, 2).name; + } else if (type === TYPE_CNAME) { + record.value = readName(rdata).name; + } else { + record.value = rdata; + } + found.push(record); + i += 12 + length; + } + return found; +} + +/** A registry answering /api/moshpit/resolve, with or without `&records=1`. */ +function registry({ target = null, records = [], registered = true } = {}) { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + const wants = url.includes("records=1"); + return { + ok: true, + json: async () => ({ + name_registered: registered, + target, + ...(wants ? { records } : {}), + }), + }; + }; + return { fetchImpl, calls }; +} + +async function ask(server, buf) { + const client = dgram.createSocket("udp4"); + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no reply")), 5000); + client.once("message", (msg) => { clearTimeout(timer); resolve(msg); }); + client.send(buf, server.port, "127.0.0.1"); + }); + } finally { + client.close(); + } +} + +async function serve(t, { fetchImpl }, extra = {}) { + const server = await createServer({ port: 0, parkingAddress: "198.51.100.9", fetchImpl, ...extra }); + t.after(() => server.close()); + return server; +} + +/* ------------------------------------------------------------------ encoders */ + +test("TXT longer than 255 bytes is split into strings a client rejoins", () => { + const value = "k".repeat(600); + const rdata = rdataTxt(value); + // 600 bytes as 255 + 255 + 90, each with its own length byte. + assert.equal(rdata.length, 600 + 3); + assert.equal(rdata[0], 255); + assert.equal(rdata[256], 255); + assert.equal(rdata[512], 90); +}); + +test("TXT splits on bytes, so a multi-byte character is not cut in half", () => { + // 200 three-byte characters: the 255-byte boundary lands mid-character if the + // split counts characters instead of bytes, and neither half then decodes. + const value = "🤘".repeat(60); + const rdata = rdataTxt(value); + const parts = []; + let at = 0; + while (at < rdata.length) { + const len = rdata[at]; + parts.push(rdata.subarray(at + 1, at + 1 + len)); + at += 1 + len; + } + assert.equal(Buffer.concat(parts).toString("utf8"), value, "the value did not survive the split"); +}); + +test("MX puts the preference first, as the wire format requires", () => { + const rdata = rdataMx(20, "mx.example.com"); + assert.equal(rdata.readUInt16BE(0), 20); + assert.equal(readName(rdata, 2).name, "mx.example.com"); +}); + +test("a record with rdata that cannot be encoded is dropped, not fatal", () => { + const buf = query("blue.eggs", { type: TYPE_MX }); + const parsed = parseQuery(buf); + const reply = buildRecordResponse(buf && parsed, buf, [ + { type: "MX", value: "mx.example.com", ttl: 300, priority: 10 }, + { type: "AAAA", value: "not-an-address", ttl: 300, priority: null }, + ]); + // The good one still answers. One bad row from a registry running a different + // version must not take the reply down with it. + assert.equal(count(reply), 1); + assert.equal(readAnswers(reply, "blue.eggs")[0].value, "mx.example.com"); +}); + +/* -------------------------------------------------------------- over the wire */ + +test("an MX question is answered from the record set, preference and all", async (t) => { + const server = await serve(t, registry({ + target: "2606:4700:4700::1111", + records: [ + { type: "MX", value: "mx1.example.com", ttl: 300, priority: 10 }, + { type: "MX", value: "mx2.example.com", ttl: 300, priority: 20 }, + ], + })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_MX })); + + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 2); + assert.deepEqual(readAnswers(reply, "blue.eggs").map((r) => [r.priority, r.value]), [ + [10, "mx1.example.com"], + [20, "mx2.example.com"], + ]); +}); + +test("a TXT question comes back with the value intact", async (t) => { + const value = "v=spf1 include:example.com -all"; + const server = await serve(t, registry({ records: [{ type: "TXT", value, ttl: 60, priority: null }] })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_TXT })); + + const [answer] = readAnswers(reply, "blue.eggs"); + assert.equal(answer.value, value); + // The owner's TTL, not the bridge's default: they set 60 on purpose. + assert.equal(answer.ttl, 60); +}); + +test("a name with no record of the type asked for is NODATA, not NXDOMAIN", async (t) => { + const server = await serve(t, registry({ records: [{ type: "TXT", value: "hi", ttl: 300, priority: null }] })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_MX })); + + // The name is here — denying it would deny its address too. + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 0); +}); + +test("an unregistered name under a claimed ending is parked, not denied", async (t) => { + // The bridge's existing verdict for a name with no target, and record + // questions have to agree with it: parking is a name waiting to be pointed, + // and NXDOMAIN on the TXT question would deny the parking address the A + // question is about to hand back. + const server = await serve(t, registry({ target: null, records: [] })); + const reply = await ask(server, query("nobody.eggs", { type: TYPE_TXT })); + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 0); +}); + +test("a record question about something that is not a name at all is NXDOMAIN", async (t) => { + // Three labels cannot be a Moshpit name — the namespace is one level deep — + // so there is nothing here to be waiting to be pointed. + const server = await serve(t, registry({ records: [] })); + const reply = await ask(server, query("not.a.name", { type: TYPE_TXT })); + assert.equal(rcode(reply), RCODE_NXDOMAIN); +}); + +test("a published CNAME answers the address question that had nothing to say", async (t) => { + // The name's target is a hostname, so there is no address to hand back — + // which used to be NODATA and looked identical to a typo. + const server = await serve(t, registry({ + target: "box.example.com", + records: [{ type: "CNAME", value: "box.example.com", ttl: 300, priority: null }], + })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_A })); + + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 1); + const [answer] = readAnswers(reply, "blue.eggs"); + assert.equal(answer.type, TYPE_CNAME); + assert.equal(answer.value, "box.example.com"); +}); + +test("a name with an address never pays for the CNAME lookup", async (t) => { + const reg = registry({ target: "2606:4700:4700::1111" }); + const server = await serve(t, reg); + await ask(server, query("blue.eggs", { type: TYPE_AAAA })); + + assert.equal(reg.calls.length, 1, "an address question made a second round trip"); + assert.equal(reg.calls.filter((u) => u.includes("records=1")).length, 0, + "an address question asked the registry for records it would not read"); +}); + +test("an oversized answer is trimmed and marked truncated, not sent whole", async (t) => { + // Twenty MX records is far past what fits in a 512-byte datagram. + const records = Array.from({ length: 20 }, (_, i) => ({ + type: "MX", value: `mx${i}.averyveryverylongmailhostname.example.com`, ttl: 300, priority: i, + })); + const server = await serve(t, registry({ records })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_MX })); + + assert.ok(reply.length <= 512, `reply was ${reply.length} bytes`); + assert.ok(count(reply) > 0, "trimming dropped every answer instead of what did not fit"); + assert.ok(count(reply) < 20, "nothing was actually dropped, so this proves nothing"); + assert.ok(truncated(reply), "TC was not set, so the client cannot tell it got a partial answer"); +}); + +/* ---------------------------------------------------------------------- policy */ + +test("answerRecords separates 'no such record' from 'no such name'", async () => { + // Here, with a TXT but no MX: NODATA, which must not deny the name. + const here = registry({ records: [{ type: "TXT", value: "hi", ttl: 300, priority: null }] }); + assert.deepEqual(await answerRecords("blue.eggs", { fetchImpl: here.fetchImpl, type: "MX" }), + { exists: true, records: [] }); + + // Not a name this registry can be asked about at all. + const reg = registry({ records: [] }); + assert.deepEqual(await answerRecords("not.a.name", { fetchImpl: reg.fetchImpl, type: "MX" }), + { exists: false, records: [] }); + assert.equal(reg.calls.length, 0, "a name it could reject on sight still cost a round trip"); +}); + +test("answerRecords asks the registry for the record set exactly once", async () => { + const reg = registry({ records: [] }); + await answerRecords("blue.eggs", { fetchImpl: reg.fetchImpl, type: "TXT" }); + assert.equal(reg.calls.length, 1); + assert.match(reg.calls[0], /records=1/); +}); +