/** * Classify a free-text search query into a VRP lookup target. * * The backend VRP lookup (`GET /runs/{id}/vrps/lookup`) accepts exactly one of * `ip` / `prefix`, plus an optional `asn` filter. An ASN-only query cannot be * served by the API — the UI surfaces that explicitly instead of guessing. */ export type NetworkQuery = | { kind: "ip"; ip: string; asn?: number } | { kind: "prefix"; prefix: string; asn?: number } | { kind: "asn"; asn: number } | { kind: "none" }; const ASN_MAX = 4_294_967_295; /** "AS45090" / "as45090" / "45090" → ASN, else null. */ export function parseAsn(input: string): number | null { const match = /^(?:as)?(\d{1,10})$/i.exec(input.trim()); if (!match) return null; const asn = Number(match[1]); return Number.isSafeInteger(asn) && asn <= ASN_MAX ? asn : null; } function isIpv4Octets(addr: string): boolean { const parts = addr.split("."); return ( parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255) ); } function isIpv6(addr: string): boolean { if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false; if ((addr.match(/::/g) ?? []).length > 1) return false; const isGroup = (g: string, isLast: boolean) => isLast && g.includes(".") ? isIpv4Octets(g) // embedded IPv4 tail, e.g. "::ffff:192.0.2.1" : /^[0-9a-fA-F]{1,4}$/.test(g); const ipv4TailWeight = (groups: string[]) => groups.length > 0 && groups[groups.length - 1].includes(".") ? 1 : 0; if (addr.includes("::")) { const [head, tail] = addr.split("::"); const headGroups = head ? head.split(":") : []; const tailGroups = tail ? tail.split(":") : []; // "::" compresses at least one group; an IPv4 tail counts as two groups. const effective = headGroups.length + tailGroups.length + ipv4TailWeight(tailGroups); if (effective > 7) return false; return [...headGroups, ...tailGroups].every((g, i, all) => isGroup(g, i === all.length - 1), ); } const groups = addr.split(":"); const expected = groups[groups.length - 1]?.includes(".") ? 7 : 8; if (groups.length !== expected) return false; return groups.every((g, i) => isGroup(g, i === groups.length - 1)); } /** Bare IPv4/IPv6 address (no "/") → normalized string, else null. */ export function parseIpAddress(input: string): string | null { const s = input.trim(); if (s.includes("/")) return null; if (isIpv4Octets(s)) return s; if (s.includes(":") && isIpv6(s)) return s; return null; } function parsePrefixParts(s: string): [string, number] | null { const slash = s.indexOf("/"); if (slash < 0 || slash !== s.lastIndexOf("/")) return null; const addr = s.slice(0, slash); const lenRaw = s.slice(slash + 1); if (!/^\d{1,3}$/.test(lenRaw)) return null; return [addr, Number(lenRaw)]; } /** "addr/len" CIDR with family-correct length → normalized string, else null. */ export function parsePrefix(input: string): string | null { const s = input.trim(); const parts = parsePrefixParts(s); if (!parts) return null; const [addr, len] = parts; if (isIpv4Octets(addr)) return len <= 32 ? `${addr}/${len}` : null; if (addr.includes(":") && isIpv6(addr)) return len <= 128 ? `${addr}/${len}` : null; return null; } /** * Classify a query string. Precedence: CIDR prefix → bare IP → ASN → none. * ("192.0.2.1" also parses as digits+ dots only; ASN requires pure digits, so * no overlap. A bare number is always treated as an ASN.) * * Two whitespace-separated tokens are read as a combined query — one network * token (IP or prefix) plus one ASN token (`AS13335` or `13335`), in either * order — yielding an ip/prefix query carrying an `asn` filter. */ export function classifyNetworkQuery(input: string): NetworkQuery { const s = input.trim(); if (!s) return { kind: "none" }; const tokens = s.split(/\s+/); if (tokens.length === 2) { for (const [netToken, asnToken] of [ [tokens[0], tokens[1]], [tokens[1], tokens[0]], ] as const) { const asn = parseAsn(asnToken); if (asn === null) continue; const prefix = parsePrefix(netToken); if (prefix) return { kind: "prefix", prefix, asn }; const ip = parseIpAddress(netToken); if (ip) return { kind: "ip", ip, asn }; } return { kind: "none" }; } if (tokens.length > 2) return { kind: "none" }; const prefix = parsePrefix(s); if (prefix) return { kind: "prefix", prefix }; const ip = parseIpAddress(s); if (ip) return { kind: "ip", ip }; const asn = parseAsn(s); if (asn !== null) return { kind: "asn", asn }; return { kind: "none" }; }