/* BARE_OS_BIN_API 1.0.0 — bump when staged /bin script semantics change (see developer guide). */ /** Shared helpers for drive-resident /bin scripts (prepended before each command). */ function bareStdin(ctx) { return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : '' } /** @param {number} mode @param {'file' | 'directory' | 'symlink'} type */ function bareFormatModeString(mode, type) { const typeChar = type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-' const perm = mode & 0o777 const r = (bit) => (perm & bit ? 'r' : '-') const w = (bit) => (perm & bit ? 'w' : '-') const x = (bit) => (perm & bit ? 'x' : '-') return ( typeChar + r(0o400) + w(0o200) + x(0o100) + r(0o040) + w(0o020) + x(0o010) + r(0o004) + w(0o002) + x(0o001) ) } /** @param {number} mtimeMs @param {number} [nowMs] */ function bareFormatLsMtime(mtimeMs, nowMs) { const now = nowMs != null ? nowMs : Date.now() const d = new Date(mtimeMs) const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] const mon = months[d.getMonth()] const day = String(d.getDate()).padStart(2, ' ') const sixMo = 180 * 24 * 3600 * 1000 if (Math.abs(now - mtimeMs) > sixMo) { const yr = String(d.getFullYear()).padStart(4, ' ') return mon + ' ' + day + ' ' + yr } const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') return mon + ' ' + day + ' ' + hh + ':' + mm } /** @param {number} size */ function barePosixBlocks(size) { return Math.ceil(Number(size) / 512) || 0 } /** * Raw stdout for NUL/binary when **`process.stdout.write`** is missing. * If **`ctx.bareOsBinWrite(Uint8Array|string)`** is set (tests / host), use it. * @param {Record} ctx * @param {string | Uint8Array} chunk * @returns {boolean} */ function bareOsEmitRaw(ctx, chunk) { if (typeof ctx.bareOsBinWrite === 'function') { const b4 = ctx.b4a const u8 = typeof chunk === 'string' ? b4 && typeof b4.from === 'function' ? b4.from(chunk) : new TextEncoder().encode(chunk) : chunk ctx.bareOsBinWrite(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8)) return true } const w = globalThis.process?.stdout?.write if (typeof w === 'function') { w.call(globalThis.process.stdout, chunk) return true } return false } function usage(ctx) { ctx.console.log( 'usage: whois [--json] TARGET\n' + 'Lookup domain, IP, or ASN information using RDAP.\n' + '\n' + ' --json print raw RDAP JSON\n' + ' -h, --help show this help' ) } function parseArgv(ctx, argv) { let json = false const positional = [] for (let i = 1; i < argv.length; i++) { const a = argv[i] if (a === '-h' || a === '--help') return { help: true } if (a === '--json') { json = true continue } if (a.startsWith('-')) { ctx.console.error('whois: unknown option ' + a) ctx.exitCode = 1 return null } positional.push(a) } if (positional.length !== 1) { ctx.console.error('whois: expected exactly one target') ctx.exitCode = 1 return null } return { help: false, json, target: positional[0].trim() } } function classifyTarget(raw) { const target = String(raw || '').trim() if (!target) return null if (/^AS\d+$/i.test(target)) { return { kind: 'asn', query: String(Number(target.slice(2))) } } if (/^\d+$/.test(target)) return { kind: 'asn', query: String(Number(target)) } if (isIpv4(target) || isIpv6(target)) return { kind: 'ip', query: target } if (isLikelyDomain(target)) return { kind: 'domain', query: target.toLowerCase() } return null } function isIpv4(s) { const parts = String(s).split('.') if (parts.length !== 4) return false for (const p of parts) { if (!/^\d+$/.test(p)) return false const n = Number(p) if (n < 0 || n > 255) return false } return true } function isIpv6(s) { const x = String(s) return x.includes(':') && /^[0-9a-fA-F:]+$/.test(x) } function isLikelyDomain(s) { const x = String(s).toLowerCase() if (x.length < 3 || x.length > 253) return false if (!x.includes('.')) return false if (/[^a-z0-9.-]/.test(x)) return false if (x.startsWith('.') || x.endsWith('.')) return false const labels = x.split('.') for (const label of labels) { if (!label || label.length > 63) return false if (label.startsWith('-') || label.endsWith('-')) return false } return true } async function fetchJson(fetchFn, url, timeoutMs) { const ac = new AbortController() const tid = setTimeout(() => ac.abort(), timeoutMs) let res try { res = await fetchFn(url, { method: 'GET', signal: ac.signal }) } finally { clearTimeout(tid) } if (!res || !res.ok) { const status = res ? `${res.status} ${res.statusText || ''}`.trim() : 'unknown' throw new Error('HTTP error from ' + url + ': ' + status) } return await res.json() } function pickBaseUrlFromBootstrap(kind, query, bootstrap) { const services = Array.isArray(bootstrap && bootstrap.services) ? bootstrap.services : [] if (kind === 'domain') return pickDomainBaseUrl(query, services) if (kind === 'asn') return pickAsnBaseUrl(query, services) if (kind === 'ip') return pickIpBaseUrl(query, services) return null } function pickDomainBaseUrl(domain, services) { const labels = String(domain).toLowerCase().split('.') let best = null for (const entry of services) { const keys = Array.isArray(entry && entry[0]) ? entry[0] : [] const urls = Array.isArray(entry && entry[1]) ? entry[1] : [] const url = typeof urls[0] === 'string' ? urls[0] : null if (!url) continue for (const key of keys) { const tld = String(key || '').toLowerCase() if (!tld) continue if (labels[labels.length - 1] === tld && (!best || tld.length > best.key.length)) { best = { key: tld, url } } } } return best ? best.url : null } function pickAsnBaseUrl(asnString, services) { const asn = Number(asnString) if (!Number.isFinite(asn) || asn < 0) return null for (const entry of services) { const ranges = Array.isArray(entry && entry[0]) ? entry[0] : [] const urls = Array.isArray(entry && entry[1]) ? entry[1] : [] const url = typeof urls[0] === 'string' ? urls[0] : null if (!url) continue for (const r of ranges) { const m = String(r).match(/^(\d+)-(\d+)$/) if (!m) continue const lo = Number(m[1]) const hi = Number(m[2]) if (asn >= lo && asn <= hi) return url } } return null } function pickIpBaseUrl(ip, services) { const version = isIpv4(ip) ? 4 : 6 const ipBig = ipToBigInt(ip, version) if (ipBig == null) return null let best = null for (const entry of services) { const cidrs = Array.isArray(entry && entry[0]) ? entry[0] : [] const urls = Array.isArray(entry && entry[1]) ? entry[1] : [] const url = typeof urls[0] === 'string' ? urls[0] : null if (!url) continue for (const cidr of cidrs) { const parsed = parseCidr(String(cidr || '')) if (!parsed || parsed.version !== version) continue if (ipInCidr(ipBig, parsed)) { if (!best || parsed.prefix > best.prefix) best = { prefix: parsed.prefix, url } } } } return best ? best.url : null } function parseCidr(s) { const m = s.match(/^([^/]+)\/(\d+)$/) if (!m) return null const addr = m[1] const prefix = Number(m[2]) const version = isIpv4(addr) ? 4 : isIpv6(addr) ? 6 : 0 if (!version) return null const bits = version === 4 ? 32 : 128 if (prefix < 0 || prefix > bits) return null const base = ipToBigInt(addr, version) if (base == null) return null return { version, base, prefix, bits } } function ipInCidr(ipBig, cidr) { const shift = BigInt(cidr.bits - cidr.prefix) return (ipBig >> shift) === (cidr.base >> shift) } function ipToBigInt(ip, version) { if (version === 4) { const parts = ip.split('.').map((x) => Number(x)) if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null return ( (BigInt(parts[0]) << 24n) | (BigInt(parts[1]) << 16n) | (BigInt(parts[2]) << 8n) | BigInt(parts[3]) ) } const segments = expandIpv6(ip) if (!segments) return null let n = 0n for (const seg of segments) n = (n << 16n) | BigInt(seg) return n } function expandIpv6(ip) { const raw = String(ip) if (!raw.includes(':')) return null const halves = raw.split('::') if (halves.length > 2) return null const left = halves[0] ? halves[0].split(':') : [] const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [] if (!allHex(left) || !allHex(right)) return null const missing = 8 - (left.length + right.length) if (missing < 0 || (halves.length === 1 && missing !== 0)) return null const full = [...left, ...Array(missing).fill('0'), ...right] if (full.length !== 8) return null return full.map((x) => Number.parseInt(x, 16)) } function allHex(parts) { for (const p of parts) { if (!p) return false if (!/^[0-9a-fA-F]{1,4}$/.test(p)) return false } return true } function joinUrl(base, suffix) { return String(base).replace(/\/+$/, '') + '/' + String(suffix).replace(/^\/+/, '') } function firstVCardValue(card, key) { const arr = Array.isArray(card) ? card : [] for (const row of arr) { if (!Array.isArray(row) || row.length < 4) continue if (String(row[0]).toLowerCase() === String(key).toLowerCase()) { return row[3] == null ? '' : String(row[3]) } } return '' } function collectRegistrar(entityList) { const entities = Array.isArray(entityList) ? entityList : [] for (const e of entities) { const roles = Array.isArray(e && e.roles) ? e.roles : [] if (!roles.includes('registrar')) continue const card = Array.isArray(e && e.vcardArray) ? e.vcardArray[1] : [] const fn = firstVCardValue(card, 'fn') const org = firstVCardValue(card, 'org') if (fn || org) return fn || org if (typeof e.handle === 'string' && e.handle) return e.handle } return '' } function printSummary(ctx, kind, query, rdap) { const lines = [] lines.push('Query: ' + query) lines.push('Type: ' + kind) if (rdap && rdap.objectClassName) lines.push('Object: ' + rdap.objectClassName) if (rdap && rdap.handle) lines.push('Handle: ' + rdap.handle) if (rdap && rdap.ldhName) lines.push('Name: ' + rdap.ldhName) else if (rdap && rdap.name) lines.push('Name: ' + rdap.name) if (rdap && rdap.country) lines.push('Country: ' + rdap.country) if (rdap && rdap.ipVersion) lines.push('IP Version: ' + rdap.ipVersion) if (rdap && rdap.startAddress && rdap.endAddress) { lines.push('Range: ' + rdap.startAddress + ' - ' + rdap.endAddress) } if (rdap && rdap.startAutnum != null && rdap.endAutnum != null) { lines.push('ASN Range: ' + rdap.startAutnum + ' - ' + rdap.endAutnum) } const registrar = collectRegistrar(rdap && rdap.entities) if (registrar) lines.push('Registrar: ' + registrar) const statuses = Array.isArray(rdap && rdap.status) ? rdap.status : [] if (statuses.length) lines.push('Status: ' + statuses.join(', ')) const nss = Array.isArray(rdap && rdap.nameservers) ? rdap.nameservers : [] if (nss.length) { const nsNames = nss .map((x) => (x && (x.ldhName || x.unicodeName) ? x.ldhName || x.unicodeName : '')) .filter(Boolean) if (nsNames.length) lines.push('Nameservers: ' + nsNames.join(', ')) } const events = Array.isArray(rdap && rdap.events) ? rdap.events : [] for (const e of events) { const action = String((e && e.eventAction) || '') const date = String((e && e.eventDate) || '') if (action === 'registration' && date) lines.push('Registered: ' + date) if (action === 'expiration' && date) lines.push('Expires: ' + date) if (action === 'last changed' && date) lines.push('Updated: ' + date) } for (const line of lines) ctx.console.log(line) } async function run(ctx, argv) { const parsed = parseArgv(ctx, argv) if (!parsed) return if (parsed.help) return usage(ctx) const target = classifyTarget(parsed.target) if (!target) { ctx.console.error('whois: unsupported target: ' + parsed.target) ctx.exitCode = 1 return } const fetchFn = typeof ctx.httpFetch === 'function' ? ctx.httpFetch : typeof fetch === 'function' ? fetch : null if (!fetchFn) { ctx.console.error('whois: fetch is unavailable in this runtime') ctx.exitCode = 1 return } /** Total wall-clock budget for bootstrap + optional ipv6 bootstrap + RDAP GET (shared across hops). */ const DEFAULT_RDAP_MS = 120000 const MAX_RDAP_MS = 600000 const timeoutRaw = Number.parseInt( String(ctx.vfs?.env?.BARE_OS_WHOIS_TIMEOUT_MS || String(DEFAULT_RDAP_MS)), 10 ) const totalBudgetMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? Math.min(timeoutRaw, MAX_RDAP_MS) : DEFAULT_RDAP_MS const deadlineMs = Date.now() + totalBudgetMs const hopTimeoutMs = () => Math.max(1, deadlineMs - Date.now()) let bootstrapUrl = '' let rdapPath = '' if (target.kind === 'domain') { bootstrapUrl = 'https://data.iana.org/rdap/dns.json' rdapPath = 'domain/' + encodeURIComponent(target.query) } else if (target.kind === 'ip') { bootstrapUrl = 'https://data.iana.org/rdap/ipv4.json' rdapPath = 'ip/' + encodeURIComponent(target.query) } else { bootstrapUrl = 'https://data.iana.org/rdap/asn.json' rdapPath = 'autnum/' + encodeURIComponent(target.query) } try { const bootstrap = await fetchJson(fetchFn, bootstrapUrl, hopTimeoutMs()) let base = pickBaseUrlFromBootstrap(target.kind, target.query, bootstrap) if (!base && target.kind === 'ip' && isIpv6(target.query)) { const ipv6Bootstrap = await fetchJson( fetchFn, 'https://data.iana.org/rdap/ipv6.json', hopTimeoutMs() ) base = pickBaseUrlFromBootstrap(target.kind, target.query, ipv6Bootstrap) } if (!base) { ctx.console.error('whois: no RDAP service found for target: ' + target.query) ctx.exitCode = 1 return } const rdap = await fetchJson(fetchFn, joinUrl(base, rdapPath), hopTimeoutMs()) if (parsed.json) ctx.console.log(JSON.stringify(rdap, null, 2)) else printSummary(ctx, target.kind, target.query, rdap) } catch (err) { const msg = String((err && err.message) || err || '') if (err && err.name === 'AbortError') { ctx.console.error( 'whois: network timeout (RDAP lookup exceeded ' + totalBudgetMs + 'ms)' ) } else if (/allowlist|denylist|HTTP blocked/i.test(msg)) { ctx.console.error('whois: blocked by HTTP policy: ' + msg) } else if (/ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH/i.test(msg)) { ctx.console.error('whois: network unavailable or DNS failure') } else { ctx.console.error('whois: ' + msg) } ctx.exitCode = 1 } }