import { createHash } from 'node:crypto'; import { lookup } from 'node:dns/promises'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; import { BRAND_MARKS } from './generated-brand-marks.mjs'; import { throwDiagnosticError } from './diagnostics.mjs'; import { esc, textUnits } from './utils.mjs'; const COLLECTIONS = Object.freeze({ architecture: 'components', workflow: 'nodes', sequence: 'participants', dataflow: 'nodes', lifecycle: 'states', }); const MARK_BY_LOOKUP = new Map(); const MARK_BY_DOMAIN = new Map(); const RESOLVED_BY_NODE = new WeakMap(); const RESOLVED_MARK = Symbol('archify.brandMark'); const MAX_HTML_BYTES = 256 * 1024; const MAX_IMAGE_BYTES = 1024 * 1024; const MAX_CAPTURE_CONCURRENCY = 3; const DEFAULT_CAPTURE_TIMEOUT_MS = 8000; const USER_AGENT = 'Archify/2.15 brand-preview'; function lookupForms(value) { const raw = String(value ?? '').trim().toLocaleLowerCase('en-US'); if (!raw) return []; const dashed = raw.replace(/[\s_]+/g, '-'); const compact = raw.replace(/[\s_.-]+/g, ''); return [...new Set([raw, dashed, compact])]; } for (const mark of BRAND_MARKS) { for (const value of [mark.id, mark.title, ...mark.aliases]) { for (const form of lookupForms(value)) { if (!MARK_BY_LOOKUP.has(form)) MARK_BY_LOOKUP.set(form, mark); } } for (const domain of mark.domains) MARK_BY_DOMAIN.set(domain, mark); } function asUrl(value) { try { const url = new URL(String(value)); return ['https:', 'http:'].includes(url.protocol) ? url : null; } catch { return null; } } function domainMark(hostname) { const host = hostname.toLocaleLowerCase('en-US').replace(/\.$/, ''); const candidates = [...MARK_BY_DOMAIN.entries()] .filter(([domain]) => host === domain || host.endsWith(`.${domain}`)) .sort(([left], [right]) => right.length - left.length); return candidates[0]?.[1] || null; } export function findBrandMark(value) { const url = asUrl(value); if (url) return domainMark(url.hostname); for (const form of lookupForms(value)) { const mark = MARK_BY_LOOKUP.get(form); if (mark) return mark; } return null; } export function listBrandMarks(query = '') { const needle = String(query).trim().toLocaleLowerCase('en-US'); return BRAND_MARKS.filter((mark) => { if (!needle) return true; return [mark.id, mark.title, mark.category, ...mark.aliases, ...mark.domains] .some((value) => String(value).toLocaleLowerCase('en-US').includes(needle)); }).map(({ path, ...mark }) => mark); } function ipv4Private(address) { const parts = address.split('.').map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true; const [a, b, c] = parts; return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 88 && c === 99) || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113); } function ipv6Private(address) { const normalized = address.toLocaleLowerCase('en-US').split('%')[0]; if (normalized === '::' || normalized === '::1') return true; if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('ff') || /^fe[89ab]/.test(normalized)) return true; if (normalized.startsWith('64:ff9b:') || normalized.startsWith('100:') || normalized.startsWith('2001:db8:') || normalized.startsWith('2002:')) return true; const mappedDotted = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mappedDotted) return ipv4Private(mappedDotted[1]); const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); if (mappedHex) { const high = Number.parseInt(mappedHex[1], 16); const low = Number.parseInt(mappedHex[2], 16); return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`); } const compatibleHex = normalized.match(/^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); if (compatibleHex) { const high = Number.parseInt(compatibleHex[1], 16); const low = Number.parseInt(compatibleHex[2], 16); return ipv4Private(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`); } return false; } export function isPrivateBrandAddress(address) { const family = net.isIP(address); return family === 4 ? ipv4Private(address) : (family === 6 ? ipv6Private(address) : true); } function validateUrlShape(url, allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1') { if (!['https:', 'http:'].includes(url.protocol)) throw new Error('only HTTP(S) brand links are supported'); if (url.username || url.password) throw new Error('brand links cannot contain credentials'); const expectedPort = url.protocol === 'https:' ? '443' : '80'; if (!allowPrivate && url.port && url.port !== expectedPort) { throw new Error('brand links must use a standard web port'); } const host = url.hostname.toLocaleLowerCase('en-US').replace(/\.$/, '').replace(/^\[|\]$/g, ''); if (!allowPrivate && (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local'))) { throw new Error('private brand links are not fetched'); } return host; } function beforeDeadline(promise, deadline) { const remaining = deadline - Date.now(); if (remaining <= 0) return Promise.reject(new Error('brand capture timed out')); return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('brand capture timed out')), remaining); timer.unref?.(); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); }, ); }); } async function resolveRequestTarget(url, deadline) { const allowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE === '1'; const host = validateUrlShape(url, allowPrivate); const directFamily = net.isIP(host); const addresses = directFamily ? [{ address: host, family: directFamily }] : await beforeDeadline(lookup(host, { all: true, verbatim: true }), deadline); if (!addresses.length || (!allowPrivate && addresses.some(({ address }) => isPrivateBrandAddress(address)))) { throw new Error('private brand links are not fetched'); } return addresses[0]; } function timeoutSignal(milliseconds) { if (typeof AbortSignal.timeout === 'function') return AbortSignal.timeout(milliseconds); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), milliseconds); timer.unref?.(); return controller.signal; } function captureTimeoutMilliseconds() { const configured = Number(process.env.ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS); if (!Number.isFinite(configured)) return DEFAULT_CAPTURE_TIMEOUT_MS; return Math.max(100, Math.min(30000, Math.round(configured))); } function requestPinned(url, accept, target, deadline) { return new Promise((resolve, reject) => { const transport = url.protocol === 'https:' ? https : http; const request = transport.request(url, { method: 'GET', signal: timeoutSignal(Math.max(1, Math.min(4500, deadline - Date.now()))), headers: { accept, 'user-agent': USER_AGENT }, // Reuse the exact public address that passed validation. This closes the // DNS-rebinding gap between checking a hostname and opening its socket. lookup(_hostname, options, callback) { if (options?.all) callback(null, [target]); else callback(null, target.address, target.family); }, }, (response) => { const status = response.statusCode || 0; resolve({ status, ok: status >= 200 && status < 300, headers: { get(name) { const value = response.headers[String(name).toLocaleLowerCase('en-US')]; return Array.isArray(value) ? value.join(', ') : (value ?? null); }, }, body: response, }); }); request.on('error', reject); request.end(); }); } async function checkedFetch(input, accept, deadline) { let current = new URL(input); for (let redirects = 0; redirects <= 3; redirects += 1) { if (Date.now() >= deadline) throw new Error('brand capture timed out'); const target = await resolveRequestTarget(current, deadline); const response = await requestPinned(current, accept, target, deadline); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get('location'); response.body.resume(); if (!location || redirects === 3) throw new Error('brand link redirected too many times'); current = new URL(location, current); continue; } if (!response.ok) { response.body.resume(); throw new Error(`brand link returned HTTP ${response.status}`); } return { response, finalUrl: current }; } throw new Error('brand link redirected too many times'); } async function readLimited(response, maximum) { const declared = Number(response.headers.get('content-length')); if (Number.isFinite(declared) && declared > maximum) { response.body?.destroy?.(); throw new Error('brand asset is too large'); } if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') { const chunks = []; let total = 0; for await (const value of response.body) { total += value.byteLength; if (total > maximum) { response.body.destroy?.(); throw new Error('brand asset is too large'); } chunks.push(Buffer.from(value)); } return Buffer.concat(chunks, total); } if (!response.body?.getReader) { const buffer = Buffer.from(await response.arrayBuffer()); if (buffer.length > maximum) throw new Error('brand asset is too large'); return buffer; } const reader = response.body.getReader(); const chunks = []; let total = 0; while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > maximum) { await reader.cancel(); throw new Error('brand asset is too large'); } chunks.push(Buffer.from(value)); } return Buffer.concat(chunks, total); } function attribute(tag, name) { const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i')); return match ? (match[1] ?? match[2] ?? match[3] ?? '') : ''; } function iconCandidates(html, pageUrl) { const candidates = []; for (const match of html.matchAll(/]*>/gi)) { const tag = match[0]; const rel = attribute(tag, 'rel').toLocaleLowerCase('en-US').split(/\s+/); if (!rel.some((value) => value === 'icon' || value === 'apple-touch-icon' || value === 'mask-icon')) continue; const href = attribute(tag, 'href'); if (!href) continue; try { const url = new URL(href, pageUrl); if (!['https:', 'http:'].includes(url.protocol)) continue; const type = attribute(tag, 'type').toLocaleLowerCase('en-US'); const sizes = attribute(tag, 'sizes'); const area = [...sizes.matchAll(/(\d+)x(\d+)/gi)] .reduce((best, size) => Math.max(best, Number(size[1]) * Number(size[2])), 0); const score = (type.includes('svg') || /\.svg(?:$|[?#])/i.test(url.href) ? 1000000 : 0) + (rel.includes('apple-touch-icon') ? 500000 : 0) + area; candidates.push({ url, score }); } catch { // A malformed icon candidate is ignored; the deterministic fallback remains available. } } candidates.sort((left, right) => right.score - left.score); const fallback = new URL('/favicon.ico', pageUrl); const unique = new Map(candidates.map((candidate) => [candidate.url.href, candidate])); unique.delete(fallback.href); return [...unique.values()].slice(0, 5).concat({ url: fallback, score: -1 }); } async function imageData(response) { const contentType = (response.headers.get('content-type') || '').split(';')[0].trim().toLocaleLowerCase('en-US'); const allowed = new Set([ 'image/png', 'image/jpeg', 'image/webp', 'image/x-icon', 'image/vnd.microsoft.icon', ]); if (!allowed.has(contentType)) { response.body?.destroy?.(); throw new Error(`unsupported brand image type ${contentType || 'unknown'}`); } const buffer = await readLimited(response, MAX_IMAGE_BYTES); const signatureMatches = contentType === 'image/png' ? buffer.length >= 45 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) && buffer.readUInt32BE(8) === 13 && buffer.toString('ascii', 12, 16) === 'IHDR' && buffer.readUInt32BE(16) > 0 && buffer.readUInt32BE(20) > 0 && buffer.toString('ascii', buffer.length - 8, buffer.length - 4) === 'IEND' : (contentType === 'image/jpeg' ? buffer.length >= 20 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff && buffer.at(-2) === 0xff && buffer.at(-1) === 0xd9 : (contentType === 'image/webp' ? buffer.length >= 16 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP' && buffer.readUInt32LE(4) + 8 <= buffer.length : buffer.length >= 22 && buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0 && buffer.readUInt16LE(4) > 0 && 6 + buffer.readUInt16LE(4) * 16 <= buffer.length)); if (!signatureMatches) throw new Error(`brand asset bytes do not match ${contentType}`); return { dataUrl: `data:${contentType};base64,${buffer.toString('base64')}`, sha256: createHash('sha256').update(buffer).digest('hex'), contentType, }; } async function captureRemoteBrand(value, deadline = Date.now() + captureTimeoutMilliseconds()) { const sourceUrl = new URL(value); const fallback = (reason) => ({ id: sourceUrl.hostname, title: sourceUrl.hostname, category: 'link', kind: 'fallback', status: 'unavailable', sourceUrl: sourceUrl.href, reason, }); try { const page = await checkedFetch(sourceUrl, 'text/html,application/xhtml+xml,image/*;q=0.8', deadline); const pageType = (page.response.headers.get('content-type') || '').toLocaleLowerCase('en-US'); if (pageType.startsWith('image/')) { const image = await imageData(page.response); return { id: sourceUrl.hostname, title: sourceUrl.hostname, category: 'link', kind: 'remote', status: 'captured', sourceUrl: sourceUrl.href, resolvedUrl: page.finalUrl.href, ...image, }; } if (!pageType.includes('text/html') && !pageType.includes('application/xhtml+xml')) { page.response.body?.destroy?.(); return fallback('linked page is not HTML'); } const html = (await readLimited(page.response, MAX_HTML_BYTES)).toString('utf8'); const iconErrors = []; for (const candidate of iconCandidates(html, page.finalUrl)) { try { const fetched = await checkedFetch(candidate.url, 'image/*', deadline); const image = await imageData(fetched.response); return { id: sourceUrl.hostname, title: sourceUrl.hostname, category: 'link', kind: 'remote', status: 'captured', sourceUrl: sourceUrl.href, resolvedUrl: fetched.finalUrl.href, ...image, }; } catch (error) { iconErrors.push(error); // Try the next declared favicon before using the generic link mark. } } const usefulError = iconErrors.find((error) => /unsupported brand image type/i.test(error?.message)) || iconErrors.at(-1); return fallback(usefulError?.message || 'no usable site icon was found'); } catch (error) { return fallback(error.message); } } export async function captureBrandReference(value) { const url = asUrl(value); if (!url) throw new Error('brand capture requires one HTTP(S) URL'); validateUrlShape(url); const preset = findBrandMark(url.href); if (preset) return { brand: preset.id, resolved: { ...preset, kind: 'preset', status: 'preset' } }; const resolved = await captureRemoteBrand(url.href); if (resolved.status !== 'captured' || !resolved.sha256) { throw new Error(`brand capture failed: ${resolved.reason || 'no usable site icon was found'}`); } return { brand: { url: url.href, sha256: resolved.sha256 }, resolved, }; } function remoteBrand(value, cache, deadline) { const key = new URL(value).href; if (!cache.has(key)) cache.set(key, captureRemoteBrand(key, deadline)); return cache.get(key); } function suggestions(value) { const needle = lookupForms(value)[0] || ''; return BRAND_MARKS.map((mark) => ({ id: mark.id, score: lookupForms(mark.id).some((form) => form.includes(needle) || needle.includes(form)) ? 0 : 1, })).sort((left, right) => left.score - right.score || left.id.localeCompare(right.id)) .slice(0, 5) .map((entry) => entry.id); } async function mapConcurrent(values, limit, visit) { let cursor = 0; const workers = Array.from({ length: Math.min(limit, values.length) }, async () => { while (cursor < values.length) { const index = cursor; cursor += 1; await visit(values[index], index); } }); await Promise.all(workers); } export async function prepareDiagramBrandMarks(diagramType, diagram) { const collection = COLLECTIONS[diagramType]; const nodes = collection && Array.isArray(diagram[collection]) ? diagram[collection] : []; const unknown = []; const remoteByUrl = new Map(); const deadline = Date.now() + captureTimeoutMilliseconds(); await mapConcurrent(nodes, MAX_CAPTURE_CONCURRENCY, async (node, index) => { if (!node.brand) return; if (typeof node.brand === 'object') { const url = asUrl(node.brand.url); const resolved = url ? await remoteBrand(url.href, remoteByUrl, deadline) : null; if (!resolved || resolved.status !== 'captured') { unknown.push(`/${collection}/${index}/brand could not reproduce the pinned capture: ${resolved?.reason || 'invalid URL'}`); return; } if (resolved.sha256 !== node.brand.sha256) { unknown.push(`/${collection}/${index}/brand digest changed: expected ${node.brand.sha256}, received ${resolved.sha256}`); return; } node[RESOLVED_MARK] = resolved; RESOLVED_BY_NODE.set(node, resolved); return; } const preset = findBrandMark(node.brand); if (preset) { const resolved = { ...preset, kind: 'preset', status: 'preset', sourceUrl: preset.provenance.source }; node[RESOLVED_MARK] = resolved; RESOLVED_BY_NODE.set(node, resolved); return; } const url = asUrl(node.brand); if (url) { unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is an unpinned URL; capture it first with \`archify brands capture ${url.href} --json\``); return; } unknown.push(`/${collection}/${index}/brand ${JSON.stringify(node.brand)} is not a built-in brand; closest IDs: ${suggestions(node.brand).join(', ')}`); }); if (unknown.length) { throwDiagnosticError(`Brand mark validation failed:\n- ${unknown.join('\n- ')}`, unknown.map((message) => ({ code: message.includes('is an unpinned URL') ? 'brand/unpinned-url' : (message.includes('digest changed') ? 'brand/digest-mismatch' : (message.includes('could not reproduce') ? 'brand/capture-unavailable' : 'brand/unknown')), severity: 'error', message, subject: { diagramType, collection }, evidence: {}, supportedFixes: message.includes('is an unpinned URL') ? ['run `archify brands capture --json` and author the returned digest-pinned brand object'] : ['choose an ID from `archify brands`', 'run `archify brands capture --json` for an unknown official site'], }))); } } export function brandMarkFor(node) { return node?.[RESOLVED_MARK] || RESOLVED_BY_NODE.get(node) || null; } export function brandMetadataFor(node) { const mark = brandMarkFor(node); return mark ? { brand: mark.title, brandId: mark.id, brandStatus: mark.status, brandSource: mark.sourceUrl, } : {}; } export function brandLabelFitWidth(node, width) { return brandMarkFor(node) ? Math.max(1, width - 48) : width; } export function brandTopRailProblem(node, width, minimumFontSize, subject = 'Node') { if (!brandMarkFor(node)) return null; const available = width - 48; const required = textUnits(node.label) * minimumFontSize * 0.6; if (available >= required) return null; return `${subject} "${node.id}" brand top rail leaves ${Math.max(0, available)}px for its label, but ` + `"${node.label}" needs ~${Math.ceil(required)}px at the ${minimumFontSize}px legible minimum — widen the node or shorten the label.`; } function markAttrs(mark) { return [ `data-brand-mark="${esc(mark.id)}"`, `data-brand-title="${esc(mark.title)}"`, `data-brand-status="${esc(mark.status)}"`, mark.sourceUrl ? `data-brand-source="${esc(mark.sourceUrl)}"` : '', mark.sha256 ? `data-brand-sha256="${esc(mark.sha256)}"` : '', ].filter(Boolean).join(' '); } export function renderBrandMark(node, { x, y, size = 16 } = {}) { const mark = brandMarkFor(node); if (!mark) return ''; const inset = 3; let content; if (mark.kind === 'preset') { const scale = (size - inset * 2) / mark.viewBox; content = ``; } else if (mark.kind === 'remote') { content = ``; } else { const scale = size / 20; content = ``; } return ``; }