diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index e64cc82..ed3559f 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,4 +1,5 @@ -import { destinationFor, moshpitBypassHosts, moshpitConfig, parseRegistryName } from './moshpit.js'; +import { destinationFor, moshpitBypassHosts, moshpitConfig } from './moshpit.js'; +import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js'; // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel @@ -305,18 +306,27 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // This is what makes the Moshpit settings on the options page actually do // something: until now they were written to storage and never read. // -// Two hooks, because "does clearnet answer for this name?" is only knowable at -// two different moments: +// Which namespace a hostname belongs to is decided by its ENDING, not by +// whether DNS failed. See tlds.js for why: a resolver that hijacks NXDOMAIN +// answers for `blue.eggs` too, so "DNS failed" is a signal we do not reliably +// get, and on those connections the whole namespace silently stopped working. // -// onErrorOccurred — DNS came up empty (ERR_NAME_NOT_RESOLVED). This is the -// backfill path, and the ONLY one active in the default 'clearnet' mode, so -// someone who has never heard of Moshpit gets ordinary browsing plus a -// rescued error page. Nothing that already works is touched. +// So there are two territories, and a hostname is in exactly one: // -// onBeforeNavigate — consulted ONLY in 'moshpit' mode, where a registered -// name is meant to win even though clearnet has an answer. It costs a -// registry round-trip before navigation, which is why the default mode -// never goes near it. +// An ending only Moshpit could own (`.eggs` — not IANA's, not reserved). +// Clearnet cannot legitimately answer for it, so resolution runs in BOTH +// modes and does not wait for a DNS error that may never come. This is the +// path that a hijacking resolver used to swallow. +// +// A real or reserved ending (`.com`, `.onion`, `.local`). Ordinary browsing, +// and the default mode never touches the registry for it — no round-trip, +// no added latency, nothing on the wire. Only the opt-in 'moshpit' mode +// consults the registry here, because only it lets a registered name +// override a working clearnet domain, and that is what it costs. +// +// onErrorOccurred still backfills a real ending whose DNS genuinely failed — +// that is an honest signal when we get it, and it is how a `.com` that nobody +// registered can still fall through to Moshpit. // // No redirect loop: every destination we send a tab to (pit.moshcode.sh/n/…, // app.moshcode.sh/pit) has three labels, so parseRegistryName rejects it and @@ -327,11 +337,13 @@ const DNS_FAILED = new Set([ 'net::ERR_NAME_RESOLUTION_FAILED', ]); -function moshpitHostname(url) { +// The hostname of a top-level http(s) navigation. Whether it is ours to touch +// is routeForNavigation's call, not this one's. +function navigationHostname(url) { try { const u = new URL(url); if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; - return parseRegistryName(u.hostname) ? u.hostname : ''; + return u.hostname; } catch { return ''; } @@ -348,20 +360,28 @@ async function sendTabTo(tabId, url) { chrome.webNavigation?.onErrorOccurred.addListener(async (details) => { if (details.frameId !== 0) return; // top-level navigations only if (!DNS_FAILED.has(details.error)) return; - const hostname = moshpitHostname(details.url); - if (!hostname) return; - const dest = await destinationFor(hostname, false); + const hostname = navigationHostname(details.url); + const route = routeForDnsFailure(hostname); + if (!route.resolve) return; + const dest = await destinationFor(hostname, route.clearnetResolves); if (dest) await sendTabTo(details.tabId, dest); }); chrome.webNavigation?.onBeforeNavigate.addListener(async (details) => { if (details.frameId !== 0) return; - const hostname = moshpitHostname(details.url); - if (!hostname) return; - // The default mode must never pre-empt a working clearnet domain — bail out - // before the registry is ever contacted. - const { mode } = await moshpitConfig(); - if (mode !== 'moshpit') return; - const dest = await destinationFor(hostname, true); + const hostname = navigationHostname(details.url); + + // The territory is decided from the hostname alone, so an ordinary navigation + // to a real ending costs one Set lookup — no storage read, no registry call. + // Only 'clearnet' has an answer that depends on the mode, so only it pays for + // reading the mode. + const territory = territoryOf(hostname); + if (territory === 'none' || territory === 'reserved') return; + const mode = territory === 'clearnet' ? (await moshpitConfig()).mode : 'clearnet'; + + const route = routeForNavigation(hostname, mode); + if (!route.resolve) return; + + const dest = await destinationFor(hostname, route.clearnetResolves); if (dest) await sendTabTo(details.tabId, dest); }); diff --git a/apps/desktop/extensions/ai-sidebar/moshpit-routing.js b/apps/desktop/extensions/ai-sidebar/moshpit-routing.js new file mode 100644 index 0000000..d9373f2 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/moshpit-routing.js @@ -0,0 +1,78 @@ +// Whether a navigation is Moshpit's business, and what to tell the policy. +// +// Split out of background.js so it can be tested without standing up a service +// worker: background.js has top-level Tor and proxy work that has nothing to do +// with name resolution. What is left there is the two listeners and this call. +// +// The decision this makes used to be made by DNS — see tlds.js for why that was +// unsound on a resolver that hijacks NXDOMAIN. +import { parseRegistryName } from './moshpit.js'; +import { isMoshpitOnlyNamespace, isReservedNamespace } from './tlds.js'; + +/** + * Which territory a hostname is in. Total: every hostname is in exactly one. + * + * 'none' — not a Moshpit-shaped hostname at all (wrong label count, an + * IP, a port, a dash). Never ours. + * 'reserved' — `.onion`, `.local` and friends: answered by something that is + * neither clearnet nor Moshpit, and must not reach the registry + * in either mode. + * 'moshpit' — an ending only Moshpit could own. Clearnet cannot answer for + * it, whatever the resolver said. + * 'clearnet' — a real ending. Ordinary browsing. + */ +export function territoryOf(hostname) { + if (!hostname || !parseRegistryName(hostname)) return 'none'; + if (isReservedNamespace(hostname)) return 'reserved'; + if (isMoshpitOnlyNamespace(hostname)) return 'moshpit'; + return 'clearnet'; +} + +/** + * What to do with a navigation we are about to let through. + * + * Returns `{ resolve: false, why }` to leave the tab alone, or + * `{ resolve: true, clearnetResolves, why }` to run the Moshpit policy — where + * `clearnetResolves` is what we know about clearnet, not what DNS claimed. + */ +export function routeForNavigation(hostname, mode) { + switch (territoryOf(hostname)) { + case 'none': + return { resolve: false, why: 'not a Moshpit-shaped hostname' }; + case 'reserved': + // .onion above all: asking the registry would carry the address out over + // clearnet, because the pit's hosts bypass the SOCKS proxy. + return { resolve: false, why: 'reserved ending — answered by neither clearnet nor Moshpit' }; + case 'moshpit': + // Clearnet cannot own this ending, so whatever DNS returned for it was + // not an answer. Both modes resolve it; neither waits for a DNS error. + return { resolve: true, clearnetResolves: false, why: 'ending only Moshpit can own' }; + default: + if (mode !== 'moshpit') { + // The default mode leaves a real ending to clearnet, and — the point of + // returning here — never spends a registry round-trip on it. + return { resolve: false, why: 'real ending, and Moshpit is set to backfill only' }; + } + return { resolve: true, clearnetResolves: true, why: 'moshpit mode may override a real ending' }; + } +} + +/** + * The same question for a navigation that already failed DNS. + * + * Only a real ending is actionable here: a Moshpit-only ending was handled + * before the request went out, and running again on the error would race that + * redirect. + */ +export function routeForDnsFailure(hostname) { + switch (territoryOf(hostname)) { + case 'clearnet': + return { resolve: true, clearnetResolves: false, why: 'real ending, and clearnet genuinely had no answer' }; + case 'moshpit': + return { resolve: false, why: 'already handled before the request went out' }; + case 'reserved': + return { resolve: false, why: 'reserved ending — answered by neither clearnet nor Moshpit' }; + default: + return { resolve: false, why: 'not a Moshpit-shaped hostname' }; + } +} diff --git a/apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js b/apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js new file mode 100644 index 0000000..eb3c0f2 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/moshpit-routing.test.js @@ -0,0 +1,101 @@ +// What each navigation is routed to, and — as much as it matters — what it +// costs. These are the regressions the DNS-based version could not express. +import { describe, expect, it } from 'vitest'; + +import { routeForDnsFailure, routeForNavigation, territoryOf } from './moshpit-routing.js'; + +const ONION = 'duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion'; + +describe('territoryOf', () => { + it('sorts every hostname into exactly one territory', () => { + expect(territoryOf('blue.eggs')).toBe('moshpit'); + expect(territoryOf('google.com')).toBe('clearnet'); + expect(territoryOf(ONION)).toBe('reserved'); + expect(territoryOf('printer.local')).toBe('reserved'); + expect(territoryOf('a.b.c')).toBe('none'); // three labels + expect(territoryOf('1.2.3.4')).toBe('none'); // an IP + expect(territoryOf('localhost')).toBe('none'); // no ending + expect(territoryOf('my-site.eggs')).toBe('none'); // a dash — the registry rejects it + expect(territoryOf('')).toBe('none'); + }); +}); + +describe('a Moshpit name resolves even when the resolver lies about it', () => { + // The bug: an NXDOMAIN-hijacking resolver answers for blue.eggs, so + // ERR_NAME_NOT_RESOLVED never fires and the old code never ran at all. + it('resolves in the DEFAULT mode, without waiting for a DNS error', () => { + const r = routeForNavigation('blue.eggs', 'clearnet'); + expect(r.resolve).toBe(true); + // The whole point: we assert clearnet has no answer regardless of DNS. + expect(r.clearnetResolves).toBe(false); + }); + + it('resolves in moshpit mode the same way', () => { + expect(routeForNavigation('blue.eggs', 'moshpit')).toMatchObject({ + resolve: true, clearnetResolves: false, + }); + }); + + it('does not run again on the DNS error, which would race the redirect', () => { + expect(routeForDnsFailure('blue.eggs').resolve).toBe(false); + }); +}); + +describe('ordinary browsing costs nothing', () => { + it('leaves a real ending alone in the default mode', () => { + const r = routeForNavigation('google.com', 'clearnet'); + expect(r.resolve).toBe(false); + expect(r.why).toMatch(/backfill only/); + }); + + it('still lets moshpit mode override a real ending — that is what it is for', () => { + expect(routeForNavigation('google.com', 'moshpit')).toMatchObject({ + resolve: true, clearnetResolves: true, + }); + }); + + it('backfills a real ending whose DNS honestly failed', () => { + expect(routeForDnsFailure('nothing.com')).toMatchObject({ + resolve: true, clearnetResolves: false, + }); + }); +}); + +describe('a .onion address never reaches the registry', () => { + // It is two alphanumeric labels, so the shape test alone accepts it. The + // registry hosts bypass the SOCKS proxy, so a lookup would carry the onion + // address out over clearnet. + it('is left alone in both modes', () => { + for (const mode of ['clearnet', 'moshpit']) { + const r = routeForNavigation(ONION, mode); + expect(r.resolve, mode).toBe(false); + expect(r.why, mode).toMatch(/reserved/); + } + }); + + it('is left alone on a DNS failure too', () => { + expect(routeForDnsFailure(ONION).resolve).toBe(false); + }); + + it('applies to the other reserved endings as well', () => { + for (const h of ['printer.local', 'box.lan', 'app.internal', 'foo.test']) { + expect(routeForNavigation(h, 'moshpit').resolve, h).toBe(false); + } + }); +}); + +describe('the decision is total', () => { + it('returns a usable shape for every combination', () => { + const hosts = ['blue.eggs', 'google.com', ONION, 'a.b.c', '', 'localhost', '1.2.3.4']; + for (const h of hosts) { + for (const mode of ['clearnet', 'moshpit', undefined]) { + for (const fn of [routeForNavigation, routeForDnsFailure]) { + const r = fn(h, mode); + expect(typeof r.resolve, `${fn.name} ${h} ${mode}`).toBe('boolean'); + expect(typeof r.why, `${fn.name} ${h} ${mode}`).toBe('string'); + if (r.resolve) expect(typeof r.clearnetResolves).toBe('boolean'); + } + } + } + }); +}); diff --git a/apps/desktop/extensions/ai-sidebar/tld-data.js b/apps/desktop/extensions/ai-sidebar/tld-data.js new file mode 100644 index 0000000..f596c97 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/tld-data.js @@ -0,0 +1,1447 @@ +// GENERATED by scripts/update-tlds.mjs — do not edit by hand. +// IANA tlds-alpha-by-domain.txt, version 2026080300. +// +// Every ending that exists on the real internet. Used to decide whether clearnet +// could ever legitimately answer for a hostname; see tlds.js for the policy. +export const IANA_TLD_VERSION = '2026080300'; + +export const IANA_TLDS = [ + 'aaa', + 'aarp', + 'abb', + 'abbott', + 'abbvie', + 'abc', + 'able', + 'abogado', + 'abudhabi', + 'ac', + 'academy', + 'accenture', + 'accountant', + 'accountants', + 'aco', + 'actor', + 'ad', + 'ads', + 'adult', + 'ae', + 'aeg', + 'aero', + 'aetna', + 'af', + 'afl', + 'africa', + 'ag', + 'agakhan', + 'agency', + 'ai', + 'aig', + 'airbus', + 'airforce', + 'airtel', + 'akdn', + 'al', + 'alibaba', + 'alipay', + 'allfinanz', + 'allstate', + 'ally', + 'alsace', + 'alstom', + 'am', + 'amazon', + 'americanexpress', + 'americanfamily', + 'amex', + 'amfam', + 'amica', + 'amsterdam', + 'analytics', + 'android', + 'anquan', + 'anz', + 'ao', + 'aol', + 'apartments', + 'app', + 'apple', + 'aq', + 'aquarelle', + 'ar', + 'arab', + 'aramco', + 'archi', + 'army', + 'arpa', + 'art', + 'arte', + 'as', + 'asda', + 'asia', + 'associates', + 'at', + 'athleta', + 'attorney', + 'au', + 'auction', + 'audi', + 'audible', + 'audio', + 'auspost', + 'author', + 'auto', + 'autos', + 'aw', + 'aws', + 'ax', + 'axa', + 'az', + 'azure', + 'ba', + 'baby', + 'baidu', + 'banamex', + 'band', + 'bank', + 'bar', + 'barcelona', + 'barclaycard', + 'barclays', + 'barefoot', + 'bargains', + 'baseball', + 'basketball', + 'bauhaus', + 'bayern', + 'bb', + 'bbc', + 'bbt', + 'bbva', + 'bcg', + 'bcn', + 'bd', + 'be', + 'beats', + 'beauty', + 'beer', + 'berlin', + 'best', + 'bestbuy', + 'bet', + 'bf', + 'bg', + 'bh', + 'bharti', + 'bi', + 'bible', + 'bid', + 'bike', + 'bing', + 'bingo', + 'bio', + 'biz', + 'bj', + 'black', + 'blackfriday', + 'blockbuster', + 'blog', + 'bloomberg', + 'blue', + 'bm', + 'bms', + 'bmw', + 'bn', + 'bnpparibas', + 'bo', + 'boats', + 'boehringer', + 'bofa', + 'bom', + 'bond', + 'boo', + 'book', + 'booking', + 'bosch', + 'bostik', + 'boston', + 'bot', + 'boutique', + 'box', + 'br', + 'bradesco', + 'bridgestone', + 'broadway', + 'broker', + 'brother', + 'brussels', + 'bs', + 'bt', + 'build', + 'builders', + 'business', + 'buy', + 'buzz', + 'bv', + 'bw', + 'by', + 'bz', + 'bzh', + 'ca', + 'cab', + 'cafe', + 'cal', + 'call', + 'calvinklein', + 'cam', + 'camera', + 'camp', + 'canon', + 'capetown', + 'capital', + 'capitalone', + 'car', + 'caravan', + 'cards', + 'care', + 'career', + 'careers', + 'cars', + 'casa', + 'case', + 'cash', + 'casino', + 'cat', + 'catering', + 'catholic', + 'cba', + 'cbn', + 'cbre', + 'cc', + 'cd', + 'center', + 'ceo', + 'cern', + 'cf', + 'cfa', + 'cfd', + 'cg', + 'ch', + 'chanel', + 'channel', + 'charity', + 'chase', + 'chat', + 'cheap', + 'chintai', + 'christmas', + 'chrome', + 'church', + 'ci', + 'cipriani', + 'circle', + 'cisco', + 'citadel', + 'citi', + 'citic', + 'city', + 'ck', + 'cl', + 'claims', + 'cleaning', + 'click', + 'clinic', + 'clinique', + 'clothing', + 'cloud', + 'club', + 'clubmed', + 'cm', + 'cn', + 'co', + 'coach', + 'codes', + 'coffee', + 'college', + 'cologne', + 'com', + 'commbank', + 'community', + 'company', + 'compare', + 'computer', + 'comsec', + 'condos', + 'construction', + 'consulting', + 'contact', + 'contractors', + 'cooking', + 'cool', + 'coop', + 'corsica', + 'country', + 'coupon', + 'coupons', + 'courses', + 'cpa', + 'cr', + 'credit', + 'creditcard', + 'creditunion', + 'cricket', + 'crown', + 'crs', + 'cruise', + 'cruises', + 'cu', + 'cuisinella', + 'cv', + 'cw', + 'cx', + 'cy', + 'cymru', + 'cyou', + 'cz', + 'dad', + 'dance', + 'data', + 'date', + 'dating', + 'datsun', + 'day', + 'dclk', + 'dds', + 'de', + 'deal', + 'dealer', + 'deals', + 'degree', + 'delivery', + 'dell', + 'deloitte', + 'delta', + 'democrat', + 'dental', + 'dentist', + 'desi', + 'design', + 'dev', + 'dhl', + 'diamonds', + 'diet', + 'digital', + 'direct', + 'directory', + 'discount', + 'discover', + 'dish', + 'diy', + 'dj', + 'dk', + 'dm', + 'dnp', + 'do', + 'docs', + 'doctor', + 'dog', + 'domains', + 'dot', + 'download', + 'drive', + 'dtv', + 'dubai', + 'dupont', + 'durban', + 'dvag', + 'dvr', + 'dz', + 'earth', + 'eat', + 'ec', + 'eco', + 'edeka', + 'edu', + 'education', + 'ee', + 'eg', + 'email', + 'emerck', + 'energy', + 'engineer', + 'engineering', + 'enterprises', + 'epson', + 'equipment', + 'er', + 'ericsson', + 'erni', + 'es', + 'esq', + 'estate', + 'et', + 'eu', + 'eurovision', + 'eus', + 'events', + 'exchange', + 'expert', + 'exposed', + 'express', + 'extraspace', + 'fage', + 'fail', + 'fairwinds', + 'faith', + 'family', + 'fan', + 'fans', + 'farm', + 'farmers', + 'fashion', + 'fast', + 'fedex', + 'feedback', + 'ferrari', + 'ferrero', + 'fi', + 'fidelity', + 'fido', + 'film', + 'final', + 'finance', + 'financial', + 'fire', + 'firestone', + 'firmdale', + 'fish', + 'fishing', + 'fit', + 'fitness', + 'fj', + 'fk', + 'flickr', + 'flights', + 'flir', + 'florist', + 'flowers', + 'fly', + 'fm', + 'fo', + 'foo', + 'food', + 'football', + 'ford', + 'forex', + 'forsale', + 'forum', + 'foundation', + 'fox', + 'fr', + 'free', + 'fresenius', + 'frl', + 'frogans', + 'frontier', + 'ftr', + 'fujitsu', + 'fun', + 'fund', + 'furniture', + 'futbol', + 'fyi', + 'ga', + 'gal', + 'gallery', + 'gallo', + 'gallup', + 'game', + 'games', + 'gap', + 'garden', + 'gay', + 'gb', + 'gbiz', + 'gd', + 'gdn', + 'ge', + 'gea', + 'gent', + 'genting', + 'george', + 'gf', + 'gg', + 'ggee', + 'gh', + 'gi', + 'gift', + 'gifts', + 'gives', + 'giving', + 'gl', + 'glass', + 'gle', + 'global', + 'globo', + 'gm', + 'gmail', + 'gmbh', + 'gmo', + 'gmx', + 'gn', + 'godaddy', + 'gold', + 'goldpoint', + 'golf', + 'goodyear', + 'goog', + 'google', + 'gop', + 'got', + 'gov', + 'gp', + 'gq', + 'gr', + 'grainger', + 'graphics', + 'gratis', + 'green', + 'gripe', + 'grocery', + 'group', + 'gs', + 'gt', + 'gu', + 'gucci', + 'guge', + 'guide', + 'guitars', + 'guru', + 'gw', + 'gy', + 'hair', + 'hamburg', + 'hangout', + 'haus', + 'hbo', + 'hdfc', + 'hdfcbank', + 'health', + 'healthcare', + 'help', + 'helsinki', + 'here', + 'hermes', + 'hiphop', + 'hisamitsu', + 'hitachi', + 'hiv', + 'hk', + 'hkt', + 'hm', + 'hn', + 'hockey', + 'holdings', + 'holiday', + 'homedepot', + 'homegoods', + 'homes', + 'homesense', + 'honda', + 'horse', + 'hospital', + 'host', + 'hosting', + 'hot', + 'hotels', + 'hotmail', + 'house', + 'how', + 'hr', + 'hsbc', + 'ht', + 'hu', + 'hughes', + 'hyatt', + 'hyundai', + 'ibm', + 'icbc', + 'ice', + 'icu', + 'id', + 'ie', + 'ieee', + 'ifm', + 'ikano', + 'il', + 'im', + 'imamat', + 'imdb', + 'immo', + 'immobilien', + 'in', + 'inc', + 'industries', + 'infiniti', + 'info', + 'ing', + 'ink', + 'institute', + 'insurance', + 'insure', + 'int', + 'international', + 'intuit', + 'investments', + 'io', + 'ipiranga', + 'iq', + 'ir', + 'irish', + 'is', + 'ismaili', + 'ist', + 'istanbul', + 'it', + 'itau', + 'itv', + 'jaguar', + 'java', + 'jcb', + 'je', + 'jeep', + 'jetzt', + 'jewelry', + 'jio', + 'jll', + 'jm', + 'jmp', + 'jnj', + 'jo', + 'jobs', + 'joburg', + 'jot', + 'joy', + 'jp', + 'jpmorgan', + 'jprs', + 'juegos', + 'juniper', + 'kaufen', + 'kddi', + 'ke', + 'kerryhotels', + 'kerryproperties', + 'kfh', + 'kg', + 'kh', + 'ki', + 'kia', + 'kids', + 'kim', + 'kindle', + 'kitchen', + 'kiwi', + 'km', + 'kn', + 'koeln', + 'komatsu', + 'kosher', + 'kp', + 'kpmg', + 'kpn', + 'kr', + 'krd', + 'kred', + 'kuokgroup', + 'kw', + 'ky', + 'kyoto', + 'kz', + 'la', + 'lacaixa', + 'lamborghini', + 'lamer', + 'land', + 'landrover', + 'lanxess', + 'lasalle', + 'lat', + 'latino', + 'latrobe', + 'law', + 'lawyer', + 'lb', + 'lc', + 'lds', + 'lease', + 'leclerc', + 'lefrak', + 'legal', + 'lego', + 'lexus', + 'lgbt', + 'li', + 'lidl', + 'life', + 'lifeinsurance', + 'lifestyle', + 'lighting', + 'like', + 'lilly', + 'limited', + 'limo', + 'lincoln', + 'link', + 'live', + 'living', + 'lk', + 'llc', + 'llp', + 'loan', + 'loans', + 'locker', + 'locus', + 'lol', + 'london', + 'lotte', + 'lotto', + 'love', + 'lpl', + 'lplfinancial', + 'lr', + 'ls', + 'lt', + 'ltd', + 'ltda', + 'lu', + 'lundbeck', + 'luxe', + 'luxury', + 'lv', + 'ly', + 'ma', + 'madrid', + 'maif', + 'maison', + 'makeup', + 'man', + 'management', + 'mango', + 'map', + 'market', + 'marketing', + 'markets', + 'marriott', + 'marshalls', + 'mattel', + 'mba', + 'mc', + 'mckinsey', + 'md', + 'me', + 'med', + 'media', + 'meet', + 'melbourne', + 'meme', + 'memorial', + 'men', + 'menu', + 'merck', + 'merckmsd', + 'mg', + 'mh', + 'miami', + 'microsoft', + 'mil', + 'mini', + 'mint', + 'mit', + 'mitsubishi', + 'mk', + 'ml', + 'mlb', + 'mls', + 'mm', + 'mma', + 'mn', + 'mo', + 'mobi', + 'mobile', + 'moda', + 'moe', + 'moi', + 'mom', + 'monash', + 'money', + 'monster', + 'mormon', + 'mortgage', + 'moscow', + 'moto', + 'motorcycles', + 'mov', + 'movie', + 'mp', + 'mq', + 'mr', + 'ms', + 'msd', + 'mt', + 'mtn', + 'mtr', + 'mu', + 'museum', + 'music', + 'mv', + 'mw', + 'mx', + 'my', + 'mz', + 'na', + 'nab', + 'nagoya', + 'name', + 'navy', + 'nba', + 'nc', + 'ne', + 'nec', + 'net', + 'netbank', + 'netflix', + 'network', + 'neustar', + 'new', + 'news', + 'next', + 'nextdirect', + 'nexus', + 'nf', + 'nfl', + 'ng', + 'ngo', + 'nhk', + 'ni', + 'nico', + 'nike', + 'nikon', + 'ninja', + 'nissan', + 'nissay', + 'nl', + 'no', + 'nokia', + 'norton', + 'now', + 'nowruz', + 'nowtv', + 'np', + 'nr', + 'nra', + 'nrw', + 'ntt', + 'nu', + 'nyc', + 'nz', + 'obi', + 'observer', + 'office', + 'okinawa', + 'olayan', + 'olayangroup', + 'ollo', + 'om', + 'omega', + 'one', + 'ong', + 'onl', + 'online', + 'ooo', + 'open', + 'oracle', + 'orange', + 'org', + 'organic', + 'origins', + 'osaka', + 'otsuka', + 'ott', + 'ovh', + 'pa', + 'page', + 'panasonic', + 'paris', + 'pars', + 'partners', + 'parts', + 'party', + 'pay', + 'pccw', + 'pe', + 'pet', + 'pf', + 'pfizer', + 'pg', + 'ph', + 'pharmacy', + 'phd', + 'philips', + 'phone', + 'photo', + 'photography', + 'photos', + 'physio', + 'pics', + 'pictet', + 'pictures', + 'pid', + 'pin', + 'ping', + 'pink', + 'pioneer', + 'pizza', + 'pk', + 'pl', + 'place', + 'play', + 'playstation', + 'plumbing', + 'plus', + 'pm', + 'pn', + 'pnc', + 'pohl', + 'poker', + 'politie', + 'porn', + 'post', + 'pr', + 'praxi', + 'press', + 'prime', + 'pro', + 'prod', + 'productions', + 'prof', + 'progressive', + 'promo', + 'properties', + 'property', + 'protection', + 'pru', + 'prudential', + 'ps', + 'pt', + 'pub', + 'pw', + 'pwc', + 'py', + 'qa', + 'qpon', + 'quebec', + 'quest', + 'racing', + 'radio', + 're', + 'read', + 'realestate', + 'realtor', + 'realty', + 'recipes', + 'red', + 'redumbrella', + 'rehab', + 'reise', + 'reisen', + 'reit', + 'reliance', + 'ren', + 'rent', + 'rentals', + 'repair', + 'report', + 'republican', + 'rest', + 'restaurant', + 'review', + 'reviews', + 'rexroth', + 'rich', + 'richardli', + 'ricoh', + 'ril', + 'rio', + 'rip', + 'ro', + 'rocks', + 'rodeo', + 'rogers', + 'room', + 'rs', + 'rsvp', + 'ru', + 'rugby', + 'ruhr', + 'run', + 'rw', + 'rwe', + 'ryukyu', + 'sa', + 'saarland', + 'safe', + 'safety', + 'sakura', + 'sale', + 'salon', + 'samsclub', + 'samsung', + 'sandvik', + 'sandvikcoromant', + 'sanofi', + 'sap', + 'sarl', + 'sas', + 'save', + 'saxo', + 'sb', + 'sbi', + 'sbs', + 'sc', + 'scb', + 'schaeffler', + 'schmidt', + 'scholarships', + 'school', + 'schule', + 'schwarz', + 'science', + 'scot', + 'sd', + 'se', + 'search', + 'seat', + 'secure', + 'security', + 'seek', + 'select', + 'sener', + 'services', + 'seven', + 'sew', + 'sex', + 'sexy', + 'sfr', + 'sg', + 'sh', + 'shangrila', + 'sharp', + 'shell', + 'shia', + 'shiksha', + 'shoes', + 'shop', + 'shopping', + 'shouji', + 'show', + 'si', + 'silk', + 'sina', + 'singles', + 'site', + 'sj', + 'sk', + 'ski', + 'skin', + 'sky', + 'skype', + 'sl', + 'sling', + 'sm', + 'smart', + 'smile', + 'sn', + 'sncf', + 'so', + 'soccer', + 'social', + 'softbank', + 'software', + 'sohu', + 'solar', + 'solutions', + 'song', + 'sony', + 'soy', + 'spa', + 'space', + 'sport', + 'spot', + 'sr', + 'srl', + 'ss', + 'st', + 'stada', + 'staples', + 'star', + 'statebank', + 'statefarm', + 'stc', + 'stcgroup', + 'stockholm', + 'storage', + 'store', + 'stream', + 'studio', + 'study', + 'style', + 'su', + 'sucks', + 'supplies', + 'supply', + 'support', + 'surf', + 'surgery', + 'suzuki', + 'sv', + 'swatch', + 'swiss', + 'sx', + 'sy', + 'sydney', + 'systems', + 'sz', + 'tab', + 'taipei', + 'talk', + 'taobao', + 'target', + 'tatamotors', + 'tatar', + 'tattoo', + 'tax', + 'taxi', + 'tc', + 'tci', + 'td', + 'tdk', + 'team', + 'tech', + 'technology', + 'tel', + 'temasek', + 'tennis', + 'teva', + 'tf', + 'tg', + 'th', + 'thd', + 'theater', + 'theatre', + 'tiaa', + 'tickets', + 'tienda', + 'tips', + 'tires', + 'tirol', + 'tj', + 'tjmaxx', + 'tjx', + 'tk', + 'tkmaxx', + 'tl', + 'tm', + 'tmall', + 'tn', + 'to', + 'today', + 'tokyo', + 'tools', + 'top', + 'toray', + 'toshiba', + 'total', + 'tours', + 'town', + 'toyota', + 'toys', + 'tr', + 'trade', + 'trading', + 'training', + 'travel', + 'travelers', + 'travelersinsurance', + 'trust', + 'trv', + 'tt', + 'tube', + 'tui', + 'tunes', + 'tushu', + 'tv', + 'tvs', + 'tw', + 'tz', + 'ua', + 'ubank', + 'ubs', + 'ug', + 'uk', + 'unicom', + 'university', + 'uno', + 'uol', + 'ups', + 'us', + 'uy', + 'uz', + 'va', + 'vacations', + 'vana', + 'vanguard', + 'vc', + 've', + 'vegas', + 'ventures', + 'verisign', + 'versicherung', + 'vet', + 'vg', + 'vi', + 'viajes', + 'video', + 'vig', + 'viking', + 'villas', + 'vin', + 'vip', + 'virgin', + 'visa', + 'vision', + 'viva', + 'vivo', + 'vlaanderen', + 'vn', + 'vodka', + 'volvo', + 'vote', + 'voting', + 'voto', + 'voyage', + 'vu', + 'wales', + 'walmart', + 'walter', + 'wang', + 'wanggou', + 'watch', + 'watches', + 'weather', + 'weatherchannel', + 'web', + 'webcam', + 'weber', + 'website', + 'wed', + 'wedding', + 'weibo', + 'weir', + 'wf', + 'whoswho', + 'wien', + 'wiki', + 'williamhill', + 'win', + 'windows', + 'wine', + 'winners', + 'wme', + 'woodside', + 'work', + 'works', + 'world', + 'wow', + 'ws', + 'wtc', + 'wtf', + 'xbox', + 'xerox', + 'xihuan', + 'xin', + 'xn--11b4c3d', + 'xn--1ck2e1b', + 'xn--1qqw23a', + 'xn--2scrj9c', + 'xn--30rr7y', + 'xn--3bst00m', + 'xn--3ds443g', + 'xn--3e0b707e', + 'xn--3hcrj9c', + 'xn--3pxu8k', + 'xn--42c2d9a', + 'xn--45br5cyl', + 'xn--45brj9c', + 'xn--45q11c', + 'xn--4dbrk0ce', + 'xn--4gbrim', + 'xn--54b7fta0cc', + 'xn--55qw42g', + 'xn--55qx5d', + 'xn--5su34j936bgsg', + 'xn--5tzm5g', + 'xn--6frz82g', + 'xn--6qq986b3xl', + 'xn--80adxhks', + 'xn--80ao21a', + 'xn--80aqecdr1a', + 'xn--80asehdb', + 'xn--80aswg', + 'xn--8y0a063a', + 'xn--90a3ac', + 'xn--90ae', + 'xn--90ais', + 'xn--9dbq2a', + 'xn--9et52u', + 'xn--9krt00a', + 'xn--b4w605ferd', + 'xn--bck1b9a5dre4c', + 'xn--c1avg', + 'xn--c2br7g', + 'xn--cck2b3b', + 'xn--cckwcxetd', + 'xn--cg4bki', + 'xn--clchc0ea0b2g2a9gcd', + 'xn--czr694b', + 'xn--czrs0t', + 'xn--czru2d', + 'xn--d1acj3b', + 'xn--d1alf', + 'xn--e1a4c', + 'xn--eckvdtc9d', + 'xn--efvy88h', + 'xn--fct429k', + 'xn--fhbei', + 'xn--fiq228c5hs', + 'xn--fiq64b', + 'xn--fiqs8s', + 'xn--fiqz9s', + 'xn--fjq720a', + 'xn--flw351e', + 'xn--fpcrj9c3d', + 'xn--fzc2c9e2c', + 'xn--fzys8d69uvgm', + 'xn--g2xx48c', + 'xn--gckr3f0f', + 'xn--gecrj9c', + 'xn--gk3at1e', + 'xn--h2breg3eve', + 'xn--h2brj9c', + 'xn--h2brj9c8c', + 'xn--hxt814e', + 'xn--i1b6b1a6a2e', + 'xn--imr513n', + 'xn--io0a7i', + 'xn--j1aef', + 'xn--j1amh', + 'xn--j6w193g', + 'xn--jlq480n2rg', + 'xn--jvr189m', + 'xn--kcrx77d1x4a', + 'xn--kprw13d', + 'xn--kpry57d', + 'xn--kput3i', + 'xn--l1acc', + 'xn--lgbbat1ad8j', + 'xn--mgb9awbf', + 'xn--mgba3a3ejt', + 'xn--mgba3a4f16a', + 'xn--mgba7c0bbn0a', + 'xn--mgbaam7a8h', + 'xn--mgbab2bd', + 'xn--mgbah1a3hjkrd', + 'xn--mgbai9azgqp6j', + 'xn--mgbayh7gpa', + 'xn--mgbbh1a', + 'xn--mgbbh1a71e', + 'xn--mgbc0a9azcg', + 'xn--mgbca7dzdo', + 'xn--mgbcpq6gpa1a', + 'xn--mgberp4a5d4ar', + 'xn--mgbgu82a', + 'xn--mgbi4ecexp', + 'xn--mgbpl2fh', + 'xn--mgbt3dhd', + 'xn--mgbtx2b', + 'xn--mgbx4cd0ab', + 'xn--mix891f', + 'xn--mk1bu44c', + 'xn--mxtq1m', + 'xn--ngbc5azd', + 'xn--ngbe9e0a', + 'xn--ngbrx', + 'xn--node', + 'xn--nqv7f', + 'xn--nqv7fs00ema', + 'xn--nyqy26a', + 'xn--o3cw4h', + 'xn--ogbpf8fl', + 'xn--otu796d', + 'xn--p1acf', + 'xn--p1ai', + 'xn--pgbs0dh', + 'xn--pssy2u', + 'xn--q7ce6a', + 'xn--q9jyb4c', + 'xn--qcka1pmc', + 'xn--qxa6a', + 'xn--qxam', + 'xn--rhqv96g', + 'xn--rovu88b', + 'xn--rvc1e0am3e', + 'xn--s9brj9c', + 'xn--ses554g', + 'xn--t60b56a', + 'xn--tckwe', + 'xn--tiq49xqyj', + 'xn--unup4y', + 'xn--vermgensberater-ctb', + 'xn--vermgensberatung-pwb', + 'xn--vhquv', + 'xn--vuq861b', + 'xn--w4r85el8fhu5dnra', + 'xn--w4rs40l', + 'xn--wgbh1c', + 'xn--wgbl6a', + 'xn--xhq521b', + 'xn--xkc2al3hye2a', + 'xn--xkc2dl3a5ee0h', + 'xn--y9a3aq', + 'xn--yfro4i67o', + 'xn--ygbi2ammx', + 'xn--zfr164b', + 'xxx', + 'xyz', + 'yachts', + 'yahoo', + 'yamaxun', + 'yandex', + 'ye', + 'yodobashi', + 'yoga', + 'yokohama', + 'you', + 'youtube', + 'yt', + 'yun', + 'za', + 'zappos', + 'zara', + 'zero', + 'zip', + 'zm', + 'zone', + 'zuerich', + 'zw', +]; diff --git a/apps/desktop/extensions/ai-sidebar/tlds.js b/apps/desktop/extensions/ai-sidebar/tlds.js new file mode 100644 index 0000000..c259153 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/tlds.js @@ -0,0 +1,104 @@ +// Which namespace an ending belongs to. +// +// Moshpit resolution used to infer "clearnet has no answer for this name" from +// a DNS error (ERR_NAME_NOT_RESOLVED). That inference is only sound on a +// resolver that reports failure honestly, and many do not: NXDOMAIN hijacking +// resolvers answer EVERY nonexistent name with a wildcard ad host. On such a +// connection `blue.eggs` resolves, the error never fires, and every Moshpit +// name lands on the hijacker's page — indistinguishable from the namespace not +// working, and unfixable from inside the browser as long as DNS is the signal. +// +// The ending is a better signal, and it is one we hold locally: clearnet can +// only ever answer for an ending that actually exists on the real internet. +// `.eggs` is not in IANA's list and never will be by accident, so a resolver +// that answers for `blue.eggs` is lying no matter what it returns. That makes +// the decision independent of the network the user happens to be on. +import { IANA_TLDS, IANA_TLD_VERSION } from './tld-data.js'; + +export { IANA_TLD_VERSION }; + +const ICANN = new Set(IANA_TLDS); + +/** + * Endings that are neither ICANN's nor Moshpit's. + * + * These resolve outside ordinary DNS, so they fail the "is it in IANA's list" + * test and would otherwise be treated as Moshpit names. Each one would break + * something real: + * + * onion — the big one. A v3 address is 56 alphanumeric characters plus + * `.onion`: exactly two labels, letters and digits only, so it satisfies + * parseRegistryName and would be sent to the registry before every Tor + * navigation. The pit's hosts deliberately bypass the SOCKS proxy, so that + * lookup would leave over clearnet carrying the onion address being + * visited — a deanonymization leak, not merely a wasted request. + * + * local / localhost / test / invalid / example — reserved by RFC 6761 for + * mDNS, loopback, testing and documentation. + * + * internal / home / lan / corp / intranet / alt — the private-use endings + * people actually put on home routers and office networks (`.internal` and + * `.alt` are the standardized ones; the rest are long-standing practice). + * + * A Moshpit ending will never be one of these, because the registry cannot + * hand out an ending that resolvers already treat as special. + */ +export const RESERVED_TLDS = new Set([ + 'onion', + 'local', 'localhost', 'test', 'invalid', 'example', + 'internal', 'alt', 'home', 'lan', 'corp', 'intranet', +]); + +/** The ending of a hostname, lowercased and de-rooted. '' when there isn't one. */ +export function tldOf(hostname) { + const host = String(hostname || '').trim().toLowerCase().replace(/\.$/, ''); + if (!host || host.includes(':')) return ''; + const i = host.lastIndexOf('.'); + return i === -1 ? '' : host.slice(i + 1); +} + +/** Does this ending exist on the real internet? */ +export function isIcannTld(tld) { + return ICANN.has(String(tld || '').trim().toLowerCase()); +} + +/** Is this ending reserved for something that is neither clearnet nor Moshpit? */ +export function isReservedTld(tld) { + return RESERVED_TLDS.has(String(tld || '').trim().toLowerCase()); +} + +/** + * Could clearnet legitimately answer for this hostname? + * + * True for a real ending, and — deliberately — true for a reserved one too: + * `.onion` and `.local` are answered by something other than the Moshpit + * registry, so as far as this policy is concerned they are already spoken for. + * The one case that returns false is an ending nobody but Moshpit could own. + */ +export function clearnetCanAnswer(hostname) { + const tld = tldOf(hostname); + if (!tld) return true; // no ending at all — not ours to redirect + return isIcannTld(tld) || isReservedTld(tld); +} + +/** + * Is this hostname in the part of the namespace only Moshpit can own? + * + * The caller still has to run parseRegistryName: this answers "is the ending + * Moshpit's", not "is the whole hostname a well-formed Moshpit name". + */ +export function isMoshpitOnlyNamespace(hostname) { + return !clearnetCanAnswer(hostname); +} + +/** + * Is this hostname answered by something that is neither clearnet nor Moshpit? + * + * Callers use this to drop a navigation before it reaches the registry at all, + * in EITHER mode. For `.onion` that is not an optimization: the registry hosts + * bypass the SOCKS proxy, so a lookup here would carry the onion address out + * over clearnet. + */ +export function isReservedNamespace(hostname) { + return isReservedTld(tldOf(hostname)); +} diff --git a/apps/desktop/extensions/ai-sidebar/tlds.test.js b/apps/desktop/extensions/ai-sidebar/tlds.test.js new file mode 100644 index 0000000..a2b09f2 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/tlds.test.js @@ -0,0 +1,116 @@ +// Which namespace an ending belongs to. +// +// The interesting cases are the ones that used to be decided by DNS: a +// hijacking resolver answers for every name, so anything that asked "did DNS +// fail?" got the wrong answer on those connections. These tests ask the ending +// instead, which is the same answer on every network. +import { describe, expect, it } from 'vitest'; + +import { + IANA_TLD_VERSION, + RESERVED_TLDS, + clearnetCanAnswer, + isIcannTld, + isMoshpitOnlyNamespace, + isReservedNamespace, + isReservedTld, + tldOf, +} from './tlds.js'; +import { IANA_TLDS } from './tld-data.js'; + +describe('the generated IANA list', () => { + it('is the real list, not a truncated one', () => { + expect(IANA_TLDS.length).toBeGreaterThan(1000); + expect(IANA_TLD_VERSION).toMatch(/^\d{10}$/); + }); + + it('is lowercase, sorted, and free of duplicates', () => { + expect(IANA_TLDS).toEqual([...IANA_TLDS].map((t) => t.toLowerCase())); + expect(IANA_TLDS).toEqual([...IANA_TLDS].sort()); + expect(new Set(IANA_TLDS).size).toBe(IANA_TLDS.length); + }); + + it('carries the endings the world actually uses', () => { + for (const t of ['com', 'org', 'net', 'io', 'dev', 'sh', 'uk', 'de', 'jp']) { + expect(isIcannTld(t), t).toBe(true); + } + }); + + it('carries internationalized endings too', () => { + expect(IANA_TLDS.some((t) => t.startsWith('xn--'))).toBe(true); + }); + + it('does not contain the Moshpit endings — the whole fix depends on it', () => { + for (const t of ['eggs', 'oranges', 'moshpit']) { + expect(isIcannTld(t), t).toBe(false); + } + }); +}); + +describe('tldOf', () => { + it('takes the last label, case- and root-insensitively', () => { + expect(tldOf('blue.eggs')).toBe('eggs'); + expect(tldOf('A.EGGS.')).toBe('eggs'); + expect(tldOf('deep.sub.example.com')).toBe('com'); + }); + + it('has nothing to return for a hostname with no ending', () => { + for (const h of ['', 'localhost', 'eggs', null, undefined]) { + expect(tldOf(h), String(h)).toBe(''); + } + }); + + it('refuses a host:port rather than reading the port as an ending', () => { + expect(tldOf('blue.eggs:8080')).toBe(''); + }); +}); + +describe('clearnet vs Moshpit territory', () => { + it('puts a real ending in clearnet, where the registry is never consulted', () => { + for (const h of ['google.com', 'a.org', 'x.dev']) { + expect(clearnetCanAnswer(h), h).toBe(true); + expect(isMoshpitOnlyNamespace(h), h).toBe(false); + } + }); + + it('puts an ending nobody else could own in Moshpit — regardless of DNS', () => { + // This is the hijack case: the resolver answers, and it is still not + // clearnet's name to answer for. + for (const h of ['blue.eggs', 'california.oranges', 'mosh.eggs']) { + expect(clearnetCanAnswer(h), h).toBe(false); + expect(isMoshpitOnlyNamespace(h), h).toBe(true); + } + }); + + it('never claims a hostname with no ending', () => { + for (const h of ['localhost', '', 'eggs']) { + expect(isMoshpitOnlyNamespace(h), h).toBe(false); + } + }); +}); + +describe('reserved endings stay out of the registry entirely', () => { + it('treats a real v3 onion address as Tor’s, not Moshpit’s', () => { + const onion = 'duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion'; + expect(isReservedNamespace(onion)).toBe(true); + // The bug this closes: it is two alphanumeric labels, so the shape test + // alone would have sent it to the registry over clearnet. + expect(isMoshpitOnlyNamespace(onion)).toBe(false); + }); + + it('leaves local and private-network endings alone', () => { + for (const t of ['local', 'localhost', 'test', 'invalid', 'example', + 'internal', 'alt', 'home', 'lan', 'corp', 'intranet']) { + expect(isReservedTld(t), t).toBe(true); + expect(isMoshpitOnlyNamespace(`host.${t}`), t).toBe(false); + } + }); + + it('does not reserve an ending IANA already delegates', () => { + // A reserved entry that IANA later delegates would quietly shadow a real + // TLD, so the two sets must stay disjoint. + for (const t of RESERVED_TLDS) { + expect(isIcannTld(t), t).toBe(false); + } + }); +}); diff --git a/scripts/update-tlds.mjs b/scripts/update-tlds.mjs new file mode 100644 index 0000000..b44192f --- /dev/null +++ b/scripts/update-tlds.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Regenerates the ICANN TLD list the extension uses to tell "this ending exists +// on the real internet" from "this ending only exists in Moshpit". +// +// node scripts/update-tlds.mjs +// +// Source of truth is IANA's own list. It changes rarely (a handful of new +// endings a year, and the odd retirement), so this is a manual chore rather +// than a build step — a network fetch in the build would make a release depend +// on data.iana.org being up. +// +// tlds.test.js asserts the generated file still parses and still holds the +// endings we depend on; it deliberately does NOT re-fetch, so the suite stays +// offline and deterministic. +import { writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SOURCE = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const out = join(root, 'apps/desktop/extensions/ai-sidebar/tld-data.js'); + +const res = await fetch(SOURCE); +if (!res.ok) { + console.error(`fetch ${SOURCE} failed: ${res.status}`); + process.exit(1); +} +const text = await res.text(); + +// The first line is a comment carrying IANA's own version stamp. Keep it: it is +// how you tell a stale list from a current one without diffing 1400 strings. +const [header] = text.split('\n'); +const version = /Version (\d+)/.exec(header)?.[1] ?? 'unknown'; + +const tlds = text + .split('\n') + .map((l) => l.trim().toLowerCase()) + .filter((l) => l && !l.startsWith('#')) + .sort(); + +if (tlds.length < 1000) { + console.error(`refusing to write ${tlds.length} TLDs — that is not the real list`); + process.exit(1); +} + +const body = `// GENERATED by scripts/update-tlds.mjs — do not edit by hand. +// IANA tlds-alpha-by-domain.txt, version ${version}. +// +// Every ending that exists on the real internet. Used to decide whether clearnet +// could ever legitimately answer for a hostname; see tlds.js for the policy. +export const IANA_TLD_VERSION = '${version}'; + +export const IANA_TLDS = ${JSON.stringify(tlds, null, 0).replace(/","/g, "','").replace(/^\["/, "[\n '").replace(/"\]$/, "',\n]").replace(/','/g, "',\n '")}; +`; + +writeFileSync(out, body); +console.log(`wrote ${tlds.length} TLDs (IANA version ${version}) to ${out.replace(root + '/', '')}`);