| … | … | @@ -31,9 +31,10 @@ import { createApInbox } from '../lib/ap-inbox.js' |
| 31 | 31 | import { tidNow } from 'atproto-store/lib/tid.js' // rkey minting for like/follow records |
| 32 | 32 | import { strongRef, parseAtUri } from '../lib/strongref.js' |
| 33 | 33 | import { resolveVerifyKey } from '../lib/plc.js' |
| 34 | import bareDns from 'bare-dns' | |
| 34 | import net from 'bare-net' | |
| 35 | 35 | import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js' |
| 36 | import { createHandleResolver, bareResolveTxt } from '../lib/handles.js' | |
| 36 | import { createHandleResolver } from '../lib/handles.js' | |
| 37 | import { createTcpResolveTxt, parseResolvConf } from '../lib/dns-txt.js' | |
| 37 | 38 | import { createFollowerLens } from '../lib/followers.js' |
| 38 | 39 | import { createApFollowers } from '../lib/ap-followers.js' |
| 39 | 40 | import { createConstellationClient } from '../lib/constellation.js' |
| … | … | @@ -278,10 +279,37 @@ async function getRecordStore (did) { |
| 278 | 279 | // bidirectionally before anything renders it. Both mechanisms are wired: /.well-known/atproto-did |
| 279 | 280 | // carries both handles we serve (measured), DNS TXT is the spec's alternative. Unverified -> |
| 280 | 281 | // 'handle.invalid', so a DID claiming someone else's handle can never render as that person. |
| 282 | // | |
| 283 | // ⛔ NOT bare-dns. `bare_dns_resolve_txt` hands c-ares a pointer into a JS arraybuffer that nothing | |
| 284 | // references, so any GC during a query frees it and the callback faults — a SIGSEGV, uncatchable from | |
| 285 | // JS, which took the public backend down 819 times in a week. The addon is statically linked into the | |
| 286 | // bare runtime, so it cannot be fixed by patching a dependency. See lib/dns-txt.js. | |
| 287 | // | |
| 288 | // This is NOT an optional path. Measured 2026-08-24: every custom-domain handle sampled — pfrazee.com, | |
| 289 | // dholms.xyz, jay.bsky.team, bsky.app — verifies ONLY over DNS TXT, because their /.well-known is a | |
| 290 | // redirect, a 404 or nothing. Hosted subdomains (*.bsky.social, our own *.7rnx.net) are the reverse. | |
| 291 | // Without TXT, most of the interesting identities on the network render handle.invalid. | |
| 292 | // | |
| 293 | // Same resolver as before: whatever /etc/resolv.conf points at, which is what c-ares defaulted to. No | |
| 294 | // third-party DoH, because handle verification is the last place to hand a stranger the list of every | |
| 295 | // identity we look up. | |
| 296 | const nameservers = (() => { | |
| 297 | try { | |
| 298 | return parseResolvConf(fs.readFileSync('/etc/resolv.conf', 'utf8')) | |
| 299 | } catch { | |
| 300 | return [] | |
| 301 | } | |
| 302 | })() | |
| 303 | if (nameservers.length === 0) { | |
| 304 | console.log('[favela] no nameserver in /etc/resolv.conf — DNS TXT handle verification is OFF') | |
| 305 | } | |
| 281 | 306 | const handleResolver = createHandleResolver({ |
| 282 | 307 | plc: new PlcDirectory({ fetchImpl: bareFetch }), |
| 283 | 308 | doFetch: bareFetch, |
| 284 | resolveTxt: bareResolveTxt(bareDns) | |
| 309 | // null disables the DNS path rather than pointing it at nothing. | |
| 310 | resolveTxt: nameservers.length | |
| 311 | ? createTcpResolveTxt({ connect: (opts) => net.createConnection(opts), servers: nameservers }) | |
| 312 | : null | |
| 285 | 313 | }) |
| 286 | 314 | // isForeign gates whether the appview may CREATE record bytes: for a DID THE LOCAL HPP HOSTS, hpp is the |
| 287 | 315 | // authority and the sole author of those bytes, so an absent file means "not materialised here yet", not |
| … | … | @@ -0,0 +1,250 @@ |
| 1 | // dns-txt.js — a minimal DNS TXT client, because bare-dns segfaults the process. | |
| 2 | // | |
| 3 | // WHY THIS EXISTS. atproto handles ARE domain names, and a handle is only displayable once it | |
| 4 | // verifies BACK to the DID (see handles.js). There are two mechanisms and we try HTTPS well-known | |
| 5 | // first, but measured 2026-08-24, every custom-domain handle sampled verifies ONLY by DNS TXT — | |
| 6 | // pfrazee.com, dholms.xyz, jay.bsky.team and bsky.app all return did= over TXT while their | |
| 7 | // /.well-known/atproto-did is a redirect, a 404 or nothing. Hosted subdomains (*.bsky.social, our own | |
| 8 | // *.7rnx.net) are the reverse. So TXT is not a fallback for rare names; it is the primary mechanism | |
| 9 | // for most of the interesting identities on the network, and without it they render handle.invalid. | |
| 10 | // | |
| 11 | // bare-dns cannot be used for it. `bare_dns_resolve_txt` hands c-ares a pointer into a JS arraybuffer | |
| 12 | // that nothing references, so any GC during the query frees it and the callback faults: | |
| 13 | // | |
| 14 | // #0 js_open_handle_scope #1 bare_dns__on_resolve_txt #2 ares_query_dnsrec_cb ... #7 uv_run | |
| 15 | // | |
| 16 | // That is a SIGSEGV, not an exception — uncatchable, and it took the public backend down 819 times in | |
| 17 | // a week. The addon is statically linked into the bare runtime, so it cannot be fixed by patching a | |
| 18 | // dependency; it needs a runtime rebuild. Until then, this. | |
| 19 | // | |
| 20 | // DNS over TCP (RFC 7766), not UDP. Every resolver supports it including the systemd-resolved stub, | |
| 21 | // it removes the 512-byte truncation dance entirely, and bare-net is already present — adding | |
| 22 | // bare-dgram would mean adding another native addon, which is the class of thing that put us here. | |
| 23 | // | |
| 24 | // PURE + INJECTED, like handles.js: the codec below is plain Uint8Array work with no I/O so it runs | |
| 25 | // under node in the test suite, and the transport takes `connect` as a parameter. | |
| 26 | ||
| 27 | const TYPE_TXT = 16 | |
| 28 | const CLASS_IN = 1 | |
| 29 | const HEADER_LEN = 12 | |
| 30 | const MAX_NAME_LEN = 255 | |
| 31 | const MAX_LABEL_LEN = 63 | |
| 32 | // A compressed name could otherwise point at itself and spin forever. The bound is arbitrary but far | |
| 33 | // above any legitimate chain. | |
| 34 | const MAX_POINTER_JUMPS = 64 | |
| 35 | ||
| 36 | export const RCODE = { NOERROR: 0, FORMERR: 1, SERVFAIL: 2, NXDOMAIN: 3, NOTIMP: 4, REFUSED: 5 } | |
| 37 | ||
| 38 | // `nameserver 127.0.0.53` lines, in file order. Everything else in resolv.conf is ignored: we only | |
| 39 | // need somewhere to send the query, and options/search are the stub resolver's business. | |
| 40 | export function parseResolvConf (text) { | |
| 41 | if (typeof text !== 'string') return [] | |
| 42 | const out = [] | |
| 43 | for (const raw of text.split('\n')) { | |
| 44 | const line = raw.trim() | |
| 45 | if (!line || line.startsWith('#') || line.startsWith(';')) continue | |
| 46 | const m = line.match(/^nameserver\s+(\S+)$/i) | |
| 47 | if (m) out.push(m[1]) | |
| 48 | } | |
| 49 | return out | |
| 50 | } | |
| 51 | ||
| 52 | export function encodeQuery (name, id) { | |
| 53 | if (typeof name !== 'string' || !name) throw new Error('name required') | |
| 54 | const labels = name.replace(/\.$/, '').split('.') | |
| 55 | let qnameLen = 1 // the terminating root label | |
| 56 | for (const l of labels) { | |
| 57 | const bytes = utf8(l) | |
| 58 | if (bytes.length === 0) throw new Error('empty label in ' + name) | |
| 59 | if (bytes.length > MAX_LABEL_LEN) throw new Error('label too long in ' + name) | |
| 60 | qnameLen += 1 + bytes.length | |
| 61 | } | |
| 62 | if (qnameLen > MAX_NAME_LEN) throw new Error('name too long: ' + name) | |
| 63 | ||
| 64 | const buf = new Uint8Array(HEADER_LEN + qnameLen + 4) | |
| 65 | const view = new DataView(buf.buffer) | |
| 66 | ||
| 67 | view.setUint16(0, id & 0xffff) | |
| 68 | view.setUint16(2, 0x0100) // RD — we want the resolver to do the work | |
| 69 | view.setUint16(4, 1) // one question | |
| 70 | // ancount / nscount / arcount stay zero | |
| 71 | ||
| 72 | let o = HEADER_LEN | |
| 73 | for (const l of labels) { | |
| 74 | const bytes = utf8(l) | |
| 75 | buf[o++] = bytes.length | |
| 76 | buf.set(bytes, o) | |
| 77 | o += bytes.length | |
| 78 | } | |
| 79 | buf[o++] = 0 | |
| 80 | ||
| 81 | view.setUint16(o, TYPE_TXT); o += 2 | |
| 82 | view.setUint16(o, CLASS_IN) | |
| 83 | ||
| 84 | return buf | |
| 85 | } | |
| 86 | ||
| 87 | // -> { id, rcode, answers } where answers is an array per TXT record of its character-strings, which | |
| 88 | // is the shape didFromTxt() already expects (it joins arrays before matching). | |
| 89 | export function decodeResponse (buf, expectedId = null) { | |
| 90 | if (!(buf instanceof Uint8Array)) throw new Error('response must be a Uint8Array') | |
| 91 | if (buf.length < HEADER_LEN) throw new Error('response shorter than a DNS header') | |
| 92 | const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) | |
| 93 | ||
| 94 | const id = view.getUint16(0) | |
| 95 | // A response for a different query is not our answer. Cheap, and it is the only thing standing | |
| 96 | // between us and an off-path answer on a shared socket. | |
| 97 | if (expectedId !== null && id !== expectedId) throw new Error('response id mismatch') | |
| 98 | ||
| 99 | const flags = view.getUint16(2) | |
| 100 | const rcode = flags & 0x0f | |
| 101 | const qdcount = view.getUint16(4) | |
| 102 | const ancount = view.getUint16(6) | |
| 103 | ||
| 104 | let o = HEADER_LEN | |
| 105 | for (let i = 0; i < qdcount; i++) { | |
| 106 | o = skipName(buf, o) | |
| 107 | o += 4 // qtype + qclass | |
| 108 | if (o > buf.length) throw new Error('truncated question section') | |
| 109 | } | |
| 110 | ||
| 111 | const answers = [] | |
| 112 | for (let i = 0; i < ancount; i++) { | |
| 113 | if (o >= buf.length) break | |
| 114 | o = skipName(buf, o) | |
| 115 | if (o + 10 > buf.length) throw new Error('truncated answer header') | |
| 116 | const type = view.getUint16(o); o += 2 | |
| 117 | o += 2 // class | |
| 118 | o += 4 // ttl | |
| 119 | const rdlength = view.getUint16(o); o += 2 | |
| 120 | if (o + rdlength > buf.length) throw new Error('rdlength past end of response') | |
| 121 | ||
| 122 | if (type === TYPE_TXT) { | |
| 123 | const chunks = [] | |
| 124 | let p = o | |
| 125 | const end = o + rdlength | |
| 126 | while (p < end) { | |
| 127 | const len = buf[p++] | |
| 128 | if (p + len > end) throw new Error('TXT character-string past end of rdata') | |
| 129 | chunks.push(ascii(buf.subarray(p, p + len))) | |
| 130 | p += len | |
| 131 | } | |
| 132 | answers.push(chunks) | |
| 133 | } | |
| 134 | o += rdlength | |
| 135 | } | |
| 136 | ||
| 137 | return { id, rcode, answers } | |
| 138 | } | |
| 139 | ||
| 140 | // Names may be compressed as a pointer to an earlier offset. We never need the name itself here — | |
| 141 | // only to step over it — so this resolves pointers just far enough to find where the name ends. | |
| 142 | function skipName (buf, offset) { | |
| 143 | let o = offset | |
| 144 | let jumps = 0 | |
| 145 | while (true) { | |
| 146 | if (o >= buf.length) throw new Error('name past end of response') | |
| 147 | const len = buf[o] | |
| 148 | if (len === 0) return o + 1 | |
| 149 | if ((len & 0xc0) === 0xc0) { | |
| 150 | // A pointer is two bytes and the name ENDS there, whatever it points at. | |
| 151 | if (o + 2 > buf.length) throw new Error('truncated compression pointer') | |
| 152 | if (++jumps > MAX_POINTER_JUMPS) throw new Error('too many compression pointers') | |
| 153 | return o + 2 | |
| 154 | } | |
| 155 | if ((len & 0xc0) !== 0) throw new Error('reserved label type') | |
| 156 | o += 1 + len | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | function utf8 (s) { | |
| 161 | const out = [] | |
| 162 | for (const ch of unescape(encodeURIComponent(s))) out.push(ch.charCodeAt(0)) | |
| 163 | return Uint8Array.from(out) | |
| 164 | } | |
| 165 | ||
| 166 | function ascii (bytes) { | |
| 167 | let s = '' | |
| 168 | for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]) | |
| 169 | return s | |
| 170 | } | |
| 171 | ||
| 172 | // DNS over TCP frames each message with a two-byte big-endian length. | |
| 173 | export function frame (msg) { | |
| 174 | const out = new Uint8Array(2 + msg.length) | |
| 175 | new DataView(out.buffer).setUint16(0, msg.length) | |
| 176 | out.set(msg, 2) | |
| 177 | return out | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * Build the resolveTxt(name) -> string[][] that handles.js takes. | |
| 182 | * | |
| 183 | * `connect` is injected (bare-net's createConnection in the daemon, a fake in tests) so none of this | |
| 184 | * needs a socket to be tested. | |
| 185 | */ | |
| 186 | export function createTcpResolveTxt ({ | |
| 187 | connect, | |
| 188 | servers = ['127.0.0.1'], | |
| 189 | port = 53, | |
| 190 | timeoutMs = 5000, | |
| 191 | nextId = () => Math.floor(Math.random() * 0xffff) | |
| 192 | } = {}) { | |
| 193 | if (typeof connect !== 'function') throw new Error('createTcpResolveTxt requires connect') | |
| 194 | if (!Array.isArray(servers) || servers.length === 0) throw new Error('at least one server required') | |
| 195 | ||
| 196 | return function resolveTxt (name) { | |
| 197 | return new Promise((resolve, reject) => { | |
| 198 | const id = nextId() | |
| 199 | const query = frame(encodeQuery(name, id)) | |
| 200 | ||
| 201 | const socket = connect({ host: servers[0], port }) | |
| 202 | ||
| 203 | let settled = false | |
| 204 | let expected = -1 | |
| 205 | let received = new Uint8Array(0) | |
| 206 | ||
| 207 | const finish = (err, value) => { | |
| 208 | if (settled) return | |
| 209 | settled = true | |
| 210 | clearTimeout(timer) | |
| 211 | try { socket.destroy() } catch {} | |
| 212 | err ? reject(err) : resolve(value) | |
| 213 | } | |
| 214 | ||
| 215 | // A resolver that accepts the connection and never answers would otherwise hang the caller | |
| 216 | // forever — which is the failure mode that made the bare-dns bug so expensive to notice. | |
| 217 | const timer = setTimeout(() => finish(new Error('DNS query timed out after ' + timeoutMs + 'ms')), timeoutMs) | |
| 218 | if (typeof timer.unref === 'function') timer.unref() | |
| 219 | ||
| 220 | socket.on('error', (err) => finish(err)) | |
| 221 | socket.on('close', () => finish(new Error('DNS connection closed before a complete response'))) | |
| 222 | ||
| 223 | socket.on('data', (chunk) => { | |
| 224 | const next = new Uint8Array(received.length + chunk.length) | |
| 225 | next.set(received, 0) | |
| 226 | next.set(chunk, received.length) | |
| 227 | received = next | |
| 228 | ||
| 229 | if (expected < 0) { | |
| 230 | if (received.length < 2) return | |
| 231 | expected = (received[0] << 8) | received[1] | |
| 232 | } | |
| 233 | if (received.length < 2 + expected) return | |
| 234 | ||
| 235 | try { | |
| 236 | const { rcode, answers } = decodeResponse(received.subarray(2, 2 + expected), id) | |
| 237 | // NXDOMAIN and an empty answer set are both "no record", which is a verdict, not a failure. | |
| 238 | if (rcode !== RCODE.NOERROR && rcode !== RCODE.NXDOMAIN) { | |
| 239 | return finish(new Error('DNS rcode ' + rcode)) | |
| 240 | } | |
| 241 | finish(null, answers) | |
| 242 | } catch (err) { | |
| 243 | finish(err) | |
| 244 | } | |
| 245 | }) | |
| 246 | ||
| 247 | socket.on('connect', () => socket.write(query)) | |
| 248 | }) | |
| 249 | } | |
| 250 | } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-web", |
| 3 | "version": "0.3.71", | |
| 4 | "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.", | |
| 3 | "version": "0.3.72", | |
| 4 | "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "bin/favela-web.js", |
| 7 | 7 | "bin": { |
| … | … | @@ -55,7 +55,7 @@ |
| 55 | 55 | "spike:seam": "node spike/atcute-seam.mjs", |
| 56 | 56 | "spike:p2p": "node spike/p2p-read.mjs", |
| 57 | 57 | "web": "bare bin/favela-web.js", |
| 58 | "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/notifications.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs", | |
| 58 | "test": "node test/render.test.mjs && node test/static-assets.test.mjs && node test/resize-plan.test.mjs && node test/notifications.test.mjs && node test/ap-followers.test.mjs && node test/reach.test.mjs && node test/strip-vectors.test.mjs && node test/edit.test.mjs && node test/facets.test.mjs && node test/mutes.test.mjs && node test/search.test.mjs && node test/threadgate.test.mjs && node test/bookmarks.test.mjs && node test/postgate.test.mjs && node test/lists.test.mjs && node test/cli-reachable.test.mjs && node test/strongref.test.mjs && node test/lens-import.test.mjs && node test/lens-thread-extract.test.mjs && node test/fronts-diag.test.mjs && node test/gate-policy.test.mjs && node test/sessions.test.mjs && node test/check.test.mjs && node test/auth.test.mjs && node test/browser-auth-client.test.mjs && node test/communities.test.mjs && node test/feedgen.test.mjs && node test/feed-reactor.test.mjs && node test/labeler-export.test.mjs && node test/label-firehose.test.mjs && node test/pairing.test.mjs && node test/writer.test.mjs && node test/blob-serve.test.mjs && node test/blobs.test.mjs && node test/spacedust.test.mjs && node test/spacedust-failclosed.test.mjs && node test/link-drain.test.mjs && node test/record-verify.test.mjs && node test/constellation.test.mjs && node test/retention.test.mjs && node test/hosted-dids.test.mjs && node test/repair-bytes.test.mjs && node test/repo-sync.test.mjs && node test/blob-refs.test.mjs && node test/inbound.test.mjs && node test/followers.test.mjs && node test/repair-strongrefs.test.mjs && node test/conformance.test.mjs && node test/dns-txt.test.mjs", | |
| 59 | 59 | "test:ws": "bare test/subscribe-labels-ws.test.mjs", |
| 60 | 60 | "test:bare": "bare test/appview.test.mjs && bare test/appview-ingest.test.mjs && bare test/appview-dedup.test.mjs && bare test/appview-no-clobber.test.mjs && bare test/appview-blocks.test.mjs && bare test/appview-thread.test.mjs && bare test/appview-author-filter.test.mjs && bare test/appview-collections.test.mjs", |
| 61 | 61 | "spike:pack": "bare spike/mobile-pack-probe.mjs", |
| … | … | @@ -70,9 +70,9 @@ |
| 70 | 70 | "atproto-room": "file:../atproto-room", |
| 71 | 71 | "atproto-store": "file:../atproto-store", |
| 72 | 72 | "b4a": "^1.8.1", |
| 73 | "bare-dns": "^2.1.4", | |
| 74 | 73 | "bare-fetch": "^3.0.1", |
| 75 | 74 | "bare-http1": "^4.0.0", |
| 75 | "bare-net": "^2.3.2", | |
| 76 | 76 | "bare-process": "^4.0.0", |
| 77 | 77 | "bare-ws": "^3.0.0", |
| 78 | 78 | "blind-pairing": "^2.3.1", |
| … | … | @@ -0,0 +1,127 @@ |
| 1 | // DNS TXT client — the replacement for bare-dns, which segfaults the process on any TXT lookup. | |
| 2 | // Run: node test/dns-txt.test.mjs | |
| 3 | // | |
| 4 | // atproto handles ARE domain names, and measured 2026-08-24 every custom-domain handle sampled | |
| 5 | // (pfrazee.com, dholms.xyz, jay.bsky.team, bsky.app) verifies ONLY over TXT — their HTTPS well-known | |
| 6 | // is a redirect, a 404 or nothing. So this codec is on the path for most real identities, and the | |
| 7 | // tests that matter are the malformed-input ones: it parses bytes from the network. | |
| 8 | import { | |
| 9 | parseResolvConf, encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE | |
| 10 | } from '../lib/dns-txt.js' | |
| 11 | ||
| 12 | let pass = 0 | |
| 13 | const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ } | |
| 14 | const throws = (fn, m) => { try { fn(); console.error('FAIL (did not throw):', m); process.exit(1) } catch { console.log(' ok -', m); pass++ } } | |
| 15 | ||
| 16 | // ── resolv.conf ────────────────────────────────────────────────────────────────────────────────── | |
| 17 | ok(JSON.stringify(parseResolvConf('nameserver 127.0.0.53\noptions edns0\n')) === '["127.0.0.53"]', 'nameserver lines are extracted, options ignored') | |
| 18 | ok(parseResolvConf('# nameserver 8.8.8.8\n').length === 0, 'comments are not nameservers') | |
| 19 | 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') | |
| 20 | ok(parseResolvConf(null).length === 0, 'garbage in, empty out') | |
| 21 | ||
| 22 | // ── query encoding ─────────────────────────────────────────────────────────────────────────────── | |
| 23 | const q = encodeQuery('_atproto.pfrazee.com', 0x1234) | |
| 24 | ok((q[0] << 8 | q[1]) === 0x1234, 'query carries the id we asked for') | |
| 25 | ok((q[2] << 8 | q[3]) === 0x0100, 'recursion desired — the stub resolver does the work') | |
| 26 | ok((q[4] << 8 | q[5]) === 1, 'exactly one question') | |
| 27 | ok(q[12] === 8 && String.fromCharCode(...q.subarray(13, 21)) === '_atproto', 'first label is length-prefixed') | |
| 28 | ok(q[q.length - 4] === 0 && q[q.length - 3] === 16, 'qtype is TXT (16)') | |
| 29 | ok(q[q.length - 2] === 0 && q[q.length - 1] === 1, 'qclass is IN (1)') | |
| 30 | throws(() => encodeQuery('a'.repeat(64) + '.com', 1), 'a label over 63 bytes is rejected') | |
| 31 | throws(() => encodeQuery('', 1), 'an empty name is rejected') | |
| 32 | throws(() => encodeQuery('a..b', 1), 'an empty label is rejected') | |
| 33 | ||
| 34 | // ── response decoding ──────────────────────────────────────────────────────────────────────────── | |
| 35 | // Hand-built response: one question, one TXT answer whose rdata is a single character-string. | |
| 36 | function buildResponse ({ id = 0x1234, rcode = 0, txt = ['did=did:plc:abc'], name = '_atproto.x.com' } = {}) { | |
| 37 | const qname = [] | |
| 38 | for (const l of name.split('.')) { qname.push(l.length); for (const c of l) qname.push(c.charCodeAt(0)) } | |
| 39 | qname.push(0) | |
| 40 | const rdata = [] | |
| 41 | for (const s of txt) { rdata.push(s.length); for (const c of s) rdata.push(c.charCodeAt(0)) } | |
| 42 | const head = [id >> 8, id & 0xff, 0x81, 0x80 | rcode, 0, 1, 0, 1, 0, 0, 0, 0] | |
| 43 | // answer: compression pointer back to the question name, TXT, IN, ttl, rdlength, rdata | |
| 44 | const ans = [0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, rdata.length >> 8, rdata.length & 0xff, ...rdata] | |
| 45 | return Uint8Array.from([...head, ...qname, 0, 16, 0, 1, ...ans]) | |
| 46 | } | |
| 47 | ||
| 48 | const r = decodeResponse(buildResponse(), 0x1234) | |
| 49 | ok(r.rcode === RCODE.NOERROR, 'rcode parsed') | |
| 50 | ok(r.answers.length === 1 && r.answers[0][0] === 'did=did:plc:abc', 'TXT record extracted') | |
| 51 | ||
| 52 | const chunked = decodeResponse(buildResponse({ txt: ['did=did:plc:', 'abcdef'] }), 0x1234) | |
| 53 | ok(chunked.answers[0].length === 2, 'a chunked TXT record keeps its character-strings separate') | |
| 54 | ok(chunked.answers[0].join('') === 'did=did:plc:abcdef', 'and they join back — the shape didFromTxt expects') | |
| 55 | ||
| 56 | ok(decodeResponse(buildResponse({ rcode: RCODE.NXDOMAIN }), 0x1234).rcode === RCODE.NXDOMAIN, 'NXDOMAIN is reported, not thrown') | |
| 57 | throws(() => decodeResponse(buildResponse({ id: 0x9999 }), 0x1234), 'a response for a different query id is rejected') | |
| 58 | ||
| 59 | // ── malformed input: this parses bytes off the network ─────────────────────────────────────────── | |
| 60 | throws(() => decodeResponse(Uint8Array.from([0, 1, 2]), null), 'a response shorter than a header is rejected') | |
| 61 | throws(() => decodeResponse('not bytes', null), 'a non-Uint8Array is rejected') | |
| 62 | { | |
| 63 | // rdlength claims more bytes than the message holds | |
| 64 | const bad = buildResponse() | |
| 65 | bad[bad.length - 3 - 'did=did:plc:abc'.length] = 0xff | |
| 66 | throws(() => decodeResponse(bad, 0x1234), 'an rdlength past the end of the response is rejected') | |
| 67 | } | |
| 68 | { | |
| 69 | // a compression pointer that points at itself | |
| 70 | const loop = Uint8Array.from([0, 1, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0, 0xc0, 12]) | |
| 71 | throws(() => decodeResponse(loop, 1), 'a self-referential compression pointer does not hang') | |
| 72 | } | |
| 73 | ||
| 74 | // ── framing ────────────────────────────────────────────────────────────────────────────────────── | |
| 75 | const f = frame(Uint8Array.from([1, 2, 3])) | |
| 76 | ok(f.length === 5 && f[0] === 0 && f[1] === 3, 'TCP framing prefixes a two-byte length') | |
| 77 | ||
| 78 | // ── transport, against a fake socket ───────────────────────────────────────────────────────────── | |
| 79 | function fakeSocket (script) { | |
| 80 | const handlers = {} | |
| 81 | const sock = { | |
| 82 | on (ev, fn) { (handlers[ev] ||= []).push(fn); return sock }, | |
| 83 | write () { setTimeout(() => script(handlers), 0) }, | |
| 84 | destroy () { sock.destroyed = true } | |
| 85 | } | |
| 86 | setTimeout(() => (handlers.connect || []).forEach((f) => f()), 0) | |
| 87 | return sock | |
| 88 | } | |
| 89 | const emit = (handlers, ev, arg) => (handlers[ev] || []).forEach((f) => f(arg)) | |
| 90 | ||
| 91 | const resolveOk = createTcpResolveTxt({ | |
| 92 | connect: () => fakeSocket((h) => emit(h, 'data', frame(buildResponse({ txt: ['did=did:plc:zz'] })))), | |
| 93 | servers: ['127.0.0.53'], | |
| 94 | nextId: () => 0x1234 | |
| 95 | }) | |
| 96 | ok((await resolveOk('_atproto.x.com'))[0][0] === 'did=did:plc:zz', 'a whole response in one chunk resolves') | |
| 97 | ||
| 98 | const resolveSplit = createTcpResolveTxt({ | |
| 99 | connect: () => fakeSocket((h) => { | |
| 100 | const full = frame(buildResponse({ txt: ['did=did:plc:split'] })) | |
| 101 | emit(h, 'data', full.subarray(0, 3)) | |
| 102 | setTimeout(() => emit(h, 'data', full.subarray(3)), 0) | |
| 103 | }), | |
| 104 | servers: ['127.0.0.53'], | |
| 105 | nextId: () => 0x1234 | |
| 106 | }) | |
| 107 | ok((await resolveSplit('_atproto.x.com'))[0][0] === 'did=did:plc:split', 'a response split across TCP chunks is reassembled') | |
| 108 | ||
| 109 | const resolveClosed = createTcpResolveTxt({ | |
| 110 | connect: () => fakeSocket((h) => emit(h, 'close')), | |
| 111 | servers: ['127.0.0.53'] | |
| 112 | }) | |
| 113 | ok(await resolveClosed('_atproto.x.com').then(() => false, () => true), 'a connection closed mid-response rejects rather than hanging') | |
| 114 | ||
| 115 | const resolveTimeout = createTcpResolveTxt({ | |
| 116 | connect: () => fakeSocket(() => {}), // accepts, never answers | |
| 117 | servers: ['127.0.0.53'], | |
| 118 | timeoutMs: 20 | |
| 119 | }) | |
| 120 | // The timeout timer is deliberately unref'd so a pending lookup never holds the daemon open, which | |
| 121 | // also means node's loop would drain before it fires. This keep-alive is the test's problem, not the | |
| 122 | // client's. | |
| 123 | const keepAlive = setInterval(() => {}, 5) | |
| 124 | ok(await resolveTimeout('_atproto.x.com').then(() => false, (e) => /timed out/.test(e.message)), 'a resolver that never answers times out') | |
| 125 | clearInterval(keepAlive) | |
| 126 | ||
| 127 | console.log('\n' + pass + ' assertions passed') |