OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / dns-txt.test.mjs

153 lines · javascript ref:
// DNS TXT client — the replacement for bare-dns, which segfaults the process on any TXT lookup.
// Run: node test/dns-txt.test.mjs
//
// atproto handles ARE domain names, and measured 2026-08-24 every custom-domain handle sampled
// (pfrazee.com, dholms.xyz, jay.bsky.team, bsky.app) verifies ONLY over TXT — their HTTPS well-known
// is a redirect, a 404 or nothing. So this codec is on the path for most real identities, and the
// tests that matter are the malformed-input ones: it parses bytes from the network.
import {
  parseResolvConf, parseNameserverList, chooseNameservers,
  encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE
} from '../lib/dns-txt.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }
const throws = (fn, m) => { try { fn(); console.error('FAIL (did not throw):', m); process.exit(1) } catch { console.log('  ok -', m); pass++ } }

// ── resolv.conf ──────────────────────────────────────────────────────────────────────────────────
ok(JSON.stringify(parseResolvConf('nameserver 127.0.0.53\noptions edns0\n')) === '["127.0.0.53"]', 'nameserver lines are extracted, options ignored')
ok(parseResolvConf('# nameserver 8.8.8.8\n').length === 0, 'comments are not nameservers')
ok(JSON.stringify(parseResolvConf('nameserver 1.1.1.1\nnameserver 8.8.8.8')) === '["1.1.1.1","8.8.8.8"]', 'order is preserved')
ok(parseResolvConf(null).length === 0, 'garbage in, empty out')

// ── where the servers come from ──────────────────────────────────────────────────────────────────
// Android has no /etc/resolv.conf — netd owns resolution — so a handset found no nameserver and turned
// TXT verification OFF, which by the measurement above means most identities render handle.invalid
// there. FAVELA_NAMESERVERS is how an embedder passes the OS's own resolvers in.
ok(JSON.stringify(parseNameserverList('192.168.1.1,8.8.8.8')) === '["192.168.1.1","8.8.8.8"]', 'a comma list is parsed')
ok(JSON.stringify(parseNameserverList(' 1.1.1.1   8.8.4.4 ')) === '["1.1.1.1","8.8.4.4"]', 'whitespace separates too, and is trimmed')
ok(parseNameserverList('2606:4700:4700::1111').length === 1, 'IPv6 is an address')
// ⛔ Addresses only. This value is handed to connect(); a hostname would need a resolver to resolve,
// which is the thing being obtained, and anything else is junk arriving at a socket call.
ok(parseNameserverList('dns.google').length === 0, 'a hostname is not an address')
ok(parseNameserverList('8.8.8.8:53').length === 0, 'a port is not part of an address here')
ok(parseNameserverList('').length === 0 && parseNameserverList(null).length === 0, 'empty and null yield nothing')
ok(parseNameserverList('...').length === 0, 'punctuation alone is not an address')

const envWins = chooseNameservers({ envValue: '10.0.0.1', resolvConf: 'nameserver 127.0.0.53' })
ok(envWins.source === 'env' && JSON.stringify(envWins.servers) === '["10.0.0.1"]', 'an embedder that names resolvers is not second-guessed')
const file = chooseNameservers({ envValue: '', resolvConf: 'nameserver 127.0.0.53' })
ok(file.source === 'resolv.conf' && file.servers.length === 1, 'no env => the file, exactly as before')
const neither = chooseNameservers({ envValue: null, resolvConf: null })
ok(neither.source === 'none' && neither.servers.length === 0, 'neither => none, and the caller disables the DNS path')
// A junk env value must not SHADOW a working resolv.conf — that would turn verification off on a server
// because someone exported the variable wrong.
ok(chooseNameservers({ envValue: 'dns.google', resolvConf: 'nameserver 1.1.1.1' }).source === 'resolv.conf', 'an unusable env value falls through to the file')

// ── query encoding ───────────────────────────────────────────────────────────────────────────────
const q = encodeQuery('_atproto.pfrazee.com', 0x1234)
ok((q[0] << 8 | q[1]) === 0x1234, 'query carries the id we asked for')
ok((q[2] << 8 | q[3]) === 0x0100, 'recursion desired — the stub resolver does the work')
ok((q[4] << 8 | q[5]) === 1, 'exactly one question')
ok(q[12] === 8 && String.fromCharCode(...q.subarray(13, 21)) === '_atproto', 'first label is length-prefixed')
ok(q[q.length - 4] === 0 && q[q.length - 3] === 16, 'qtype is TXT (16)')
ok(q[q.length - 2] === 0 && q[q.length - 1] === 1, 'qclass is IN (1)')
throws(() => encodeQuery('a'.repeat(64) + '.com', 1), 'a label over 63 bytes is rejected')
throws(() => encodeQuery('', 1), 'an empty name is rejected')
throws(() => encodeQuery('a..b', 1), 'an empty label is rejected')

// ── response decoding ────────────────────────────────────────────────────────────────────────────
// Hand-built response: one question, one TXT answer whose rdata is a single character-string.
function buildResponse ({ id = 0x1234, rcode = 0, txt = ['did=did:plc:abc'], name = '_atproto.x.com' } = {}) {
  const qname = []
  for (const l of name.split('.')) { qname.push(l.length); for (const c of l) qname.push(c.charCodeAt(0)) }
  qname.push(0)
  const rdata = []
  for (const s of txt) { rdata.push(s.length); for (const c of s) rdata.push(c.charCodeAt(0)) }
  const head = [id >> 8, id & 0xff, 0x81, 0x80 | rcode, 0, 1, 0, 1, 0, 0, 0, 0]
  // answer: compression pointer back to the question name, TXT, IN, ttl, rdlength, rdata
  const ans = [0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, rdata.length >> 8, rdata.length & 0xff, ...rdata]
  return Uint8Array.from([...head, ...qname, 0, 16, 0, 1, ...ans])
}

const r = decodeResponse(buildResponse(), 0x1234)
ok(r.rcode === RCODE.NOERROR, 'rcode parsed')
ok(r.answers.length === 1 && r.answers[0][0] === 'did=did:plc:abc', 'TXT record extracted')

const chunked = decodeResponse(buildResponse({ txt: ['did=did:plc:', 'abcdef'] }), 0x1234)
ok(chunked.answers[0].length === 2, 'a chunked TXT record keeps its character-strings separate')
ok(chunked.answers[0].join('') === 'did=did:plc:abcdef', 'and they join back — the shape didFromTxt expects')

ok(decodeResponse(buildResponse({ rcode: RCODE.NXDOMAIN }), 0x1234).rcode === RCODE.NXDOMAIN, 'NXDOMAIN is reported, not thrown')
throws(() => decodeResponse(buildResponse({ id: 0x9999 }), 0x1234), 'a response for a different query id is rejected')

// ── malformed input: this parses bytes off the network ───────────────────────────────────────────
throws(() => decodeResponse(Uint8Array.from([0, 1, 2]), null), 'a response shorter than a header is rejected')
throws(() => decodeResponse('not bytes', null), 'a non-Uint8Array is rejected')
{
  // rdlength claims more bytes than the message holds
  const bad = buildResponse()
  bad[bad.length - 3 - 'did=did:plc:abc'.length] = 0xff
  throws(() => decodeResponse(bad, 0x1234), 'an rdlength past the end of the response is rejected')
}
{
  // a compression pointer that points at itself
  const loop = Uint8Array.from([0, 1, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0, 0xc0, 12])
  throws(() => decodeResponse(loop, 1), 'a self-referential compression pointer does not hang')
}

// ── framing ──────────────────────────────────────────────────────────────────────────────────────
const f = frame(Uint8Array.from([1, 2, 3]))
ok(f.length === 5 && f[0] === 0 && f[1] === 3, 'TCP framing prefixes a two-byte length')

// ── transport, against a fake socket ─────────────────────────────────────────────────────────────
function fakeSocket (script) {
  const handlers = {}
  const sock = {
    on (ev, fn) { (handlers[ev] ||= []).push(fn); return sock },
    write () { setTimeout(() => script(handlers), 0) },
    destroy () { sock.destroyed = true }
  }
  setTimeout(() => (handlers.connect || []).forEach((f) => f()), 0)
  return sock
}
const emit = (handlers, ev, arg) => (handlers[ev] || []).forEach((f) => f(arg))

const resolveOk = createTcpResolveTxt({
  connect: () => fakeSocket((h) => emit(h, 'data', frame(buildResponse({ txt: ['did=did:plc:zz'] })))),
  servers: ['127.0.0.53'],
  nextId: () => 0x1234
})
ok((await resolveOk('_atproto.x.com'))[0][0] === 'did=did:plc:zz', 'a whole response in one chunk resolves')

const resolveSplit = createTcpResolveTxt({
  connect: () => fakeSocket((h) => {
    const full = frame(buildResponse({ txt: ['did=did:plc:split'] }))
    emit(h, 'data', full.subarray(0, 3))
    setTimeout(() => emit(h, 'data', full.subarray(3)), 0)
  }),
  servers: ['127.0.0.53'],
  nextId: () => 0x1234
})
ok((await resolveSplit('_atproto.x.com'))[0][0] === 'did=did:plc:split', 'a response split across TCP chunks is reassembled')

const resolveClosed = createTcpResolveTxt({
  connect: () => fakeSocket((h) => emit(h, 'close')),
  servers: ['127.0.0.53']
})
ok(await resolveClosed('_atproto.x.com').then(() => false, () => true), 'a connection closed mid-response rejects rather than hanging')

const resolveTimeout = createTcpResolveTxt({
  connect: () => fakeSocket(() => {}), // accepts, never answers
  servers: ['127.0.0.53'],
  timeoutMs: 20
})
// The timeout timer is deliberately unref'd so a pending lookup never holds the daemon open, which
// also means node's loop would drain before it fires. This keep-alive is the test's problem, not the
// client's.
const keepAlive = setInterval(() => {}, 5)
ok(await resolveTimeout('_atproto.x.com').then(() => false, (e) => /timed out/.test(e.message)), 'a resolver that never answers times out')
clearInterval(keepAlive)

console.log('\n' + pass + ' assertions passed')