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 */
5export 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
24const 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
36const 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
65const formatIpv4 = (address: bigint): string =>
66 [24n, 16n, 8n, 0n]
67 .map((shift) => Number((address >> shift) & 255n))
68 .join(".");
69
70const 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};