| 1 | /** |
| 2 | * Canonicalizes an IPv4 or IPv6 CIDR to its network address. |
| 3 | * Invalid input is returned unchanged; this function is not a validator. |
| 4 | */ |
| 5 | export const canonicalCidr = (cidr: string | undefined): string | undefined => { |
| 6 | if (cidr === undefined) return undefined; |
| 7 | if (/\s/.test(cidr)) return cidr; |
| 8 | const [address, prefixText, extra] = cidr.split("/"); |
| 9 | if (!address || prefixText === undefined || extra !== undefined) return cidr; |
| 10 | if (!/^\d+$/.test(prefixText)) return cidr; |
| 11 | |
| 12 | const ipv6 = address.includes(":"); |
| 13 | const width = ipv6 ? 128 : 32; |
| 14 | const prefix = Number(prefixText); |
| 15 | if (!Number.isInteger(prefix) || prefix > width) return cidr; |
| 16 | const value = ipv6 ? parseIpv6(address) : parseIpv4(address); |
| 17 | if (value === undefined) return cidr; |
| 18 | |
| 19 | const hostBits = BigInt(width - prefix); |
| 20 | const network = (value >> hostBits) << hostBits; |
| 21 | return `${ipv6 ? formatIpv6(network) : formatIpv4(network)}/${prefix}`; |
| 22 | }; |
| 23 | |
| 24 | const parseIpv4 = (address: string): bigint | undefined => { |
| 25 | const octets = address.split("."); |
| 26 | if ( |
| 27 | octets.length !== 4 || |
| 28 | octets.some( |
| 29 | (octet) => !/^(0|[1-9]\d{0,2})$/.test(octet) || Number(octet) > 255, |
| 30 | ) |
| 31 | ) |
| 32 | return undefined; |
| 33 | return octets.reduce((value, octet) => (value << 8n) | BigInt(octet), 0n); |
| 34 | }; |
| 35 | |
| 36 | const parseIpv6 = (address: string): bigint | undefined => { |
| 37 | if (address.includes(".")) { |
| 38 | const lastColon = address.lastIndexOf(":"); |
| 39 | const ipv4 = parseIpv4(address.slice(lastColon + 1)); |
| 40 | if (ipv4 === undefined) return undefined; |
| 41 | address = `${address.slice(0, lastColon + 1)}${(ipv4 >> 16n).toString(16)}:${(ipv4 & 0xffffn).toString(16)}`; |
| 42 | } |
| 43 | |
| 44 | const halves = address.split("::"); |
| 45 | if (halves.length > 2) return undefined; |
| 46 | const left = halves[0] ? halves[0].split(":") : []; |
| 47 | const right = halves[1] ? halves[1].split(":") : []; |
| 48 | const count = left.length + right.length; |
| 49 | if (halves.length === 1 ? count !== 8 : count >= 8) return undefined; |
| 50 | if ([...left, ...right].some((word) => !/^[0-9a-f]{1,4}$/i.test(word))) { |
| 51 | return undefined; |
| 52 | } |
| 53 | |
| 54 | // A double colon must replace at least one 16-bit word. |
| 55 | const words = |
| 56 | halves.length === 1 |
| 57 | ? left |
| 58 | : [...left, ...Array<string>(8 - count).fill("0"), ...right]; |
| 59 | return words.reduce( |
| 60 | (value, word) => (value << 16n) | BigInt(`0x${word}`), |
| 61 | 0n, |
| 62 | ); |
| 63 | }; |
| 64 | |
| 65 | const formatIpv4 = (address: bigint): string => |
| 66 | [24n, 16n, 8n, 0n] |
| 67 | .map((shift) => Number((address >> shift) & 255n)) |
| 68 | .join("."); |
| 69 | |
| 70 | const formatIpv6 = (address: bigint): string => { |
| 71 | const words = Array.from({ length: 8 }, (_, index) => |
| 72 | ((address >> BigInt((7 - index) * 16)) & 0xffffn).toString(16), |
| 73 | ); |
| 74 | let longestStart = -1; |
| 75 | let longestLength = 1; |
| 76 | for (let index = 0; index < words.length; index++) { |
| 77 | if (words[index] !== "0") continue; |
| 78 | let end = index + 1; |
| 79 | while (end < words.length && words[end] === "0") end++; |
| 80 | // Compress the first longest run; never compress a single zero word. |
| 81 | if (end - index > longestLength) { |
| 82 | longestStart = index; |
| 83 | longestLength = end - index; |
| 84 | } |
| 85 | index = end - 1; |
| 86 | } |
| 87 | return longestStart < 0 |
| 88 | ? words.join(":") |
| 89 | : `${words.slice(0, longestStart).join(":")}::${words.slice(longestStart + longestLength).join(":")}`; |
| 90 | }; |