1import { canonicalCidr } from "@/Utils/ip-address.ts";
2import { describe, expect, test } from "alchemy-test";
3
4const valid: [string, string][] = [
5 ["10.0.0.7/16", "10.0.0.0/16"],
6 ["10.0.0.7/016", "10.0.0.0/16"],
7 ["10.0.0.1/024", "10.0.0.0/24"],
8 ["::1/064", "::/64"],
9 ["10.0.0.1/00", "0.0.0.0/0"],
10 ["::1/000", "::/0"],
11 ["192.168.1.255/24", "192.168.1.0/24"],
12 ["172.31.255.255/12", "172.16.0.0/12"],
13 ["192.0.2.129/25", "192.0.2.128/25"],
14 ["192.0.2.131/31", "192.0.2.130/31"],
15 ["192.0.2.131/32", "192.0.2.131/32"],
16 ["255.255.255.255/0", "0.0.0.0/0"],
17 ["255.255.255.255/1", "128.0.0.0/1"],
18 ["255.255.255.255/32", "255.255.255.255/32"],
19 ["0.0.0.0/32", "0.0.0.0/32"],
20 ["::/0", "::/0"],
21 ["::/128", "::/128"],
22 ["::1/128", "::1/128"],
23 ["::1/127", "::/127"],
24 ["FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF/0", "::/0"],
25 ["FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF/1", "8000::/1"],
26 [
27 "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/127",
28 "ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe/127",
29 ],
30 [
31 "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128",
32 "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128",
33 ],
34 ["2001:0DB8:0000:0000:0000:0000:0000:0001/128", "2001:db8::1/128"],
35 ["2001:db8:abcd:1234:5678:9abc:def0:1234/64", "2001:db8:abcd:1234::/64"],
36 ["2001:db8:abcd:1234:ffff:ffff:ffff:ffff/65", "2001:db8:abcd:1234:8000::/65"],
37 ["2001:db8:abcd:1234:ffff:ffff:ffff:ffff/63", "2001:db8:abcd:1234::/63"],
38 ["1:0:0:2:0:0:3:4/128", "1::2:0:0:3:4/128"],
39 ["1:0:0:2:0:0:0:4/128", "1:0:0:2::4/128"],
40 ["0:0:1:2:3:4:0:0/128", "::1:2:3:4:0:0/128"],
41 ["1:2:3:4:5:6:0:0/128", "1:2:3:4:5:6::/128"],
42 ["1:2:3:4:5:6:0:8/128", "1:2:3:4:5:6:0:8/128"],
43 ["1:2:3:4:5:6::8/128", "1:2:3:4:5:6:0:8/128"],
44 ["::ffff:192.0.2.129/128", "::ffff:c000:281/128"],
45 ["::ffff:192.0.2.129/120", "::ffff:c000:200/120"],
46 ["::ffff:192.0.2.129/96", "::ffff:0:0/96"],
47 ["::ffff:192.0.2.129/80", "::/80"],
48 ["::192.0.2.129/120", "::c000:200/120"],
49 ["0:0:0:0:0:FFFF:192.0.2.129/128", "::ffff:c000:281/128"],
50 ["1:2:3:4:5:6:192.0.2.1/128", "1:2:3:4:5:6:c000:201/128"],
51 ["1:2:3:4:5::192.0.2.1/128", "1:2:3:4:5:0:c000:201/128"],
52];
53
54const invalid = [
55 "",
56 "10.0.0.1",
57 "::1",
58 "/24",
59 "/128",
60 "10.0.0.1/",
61 "::1/",
62 "10.0.0.1/33",
63 "::1/129",
64 "10.0.0.1/-1",
65 "::1/-1",
66 "10.0.0.1/+24",
67 "::1/+64",
68 "10.0.0.1/24.0",
69 "::1/6.4",
70 "10.0.0.1/2e1",
71 "::1/0x40",
72 "10.0.0.1/NaN",
73 "::1/Infinity",
74 "10.0.0.1/24/1",
75 "::1/64/1",
76 "10.0.0.1//24",
77 "::1//64",
78 "10.0.0/24",
79 "10.0.0.1.2/24",
80 "256.0.0.1/24",
81 "10.256.0.1/24",
82 "10.0.256.1/24",
83 "10.0.0.256/24",
84 "10..0.1/24",
85 ".10.0.1/24",
86 "10.0.1./24",
87 "-1.0.0.1/24",
88 "+1.0.0.1/24",
89 "1e1.0.0.1/24",
90 "0x0a.0.0.1/24",
91 "010.0.0.1/24",
92 "10.00.0.1/24",
93 "10.0.0.01/24",
94 "10.a.0.1/24",
95 "10.0.0.1/24",
96 "localhost/24",
97 "[::1]/128",
98 "fe80::1%eth0/64",
99 "fe80::1%1/64",
100 ":/64",
101 ":::/64",
102 "::::/64",
103 ":1:2:3:4:5:6:7/64",
104 "1:2:3:4:5:6:7:/64",
105 "1::2::3/64",
106 "1:2:3:4:5:6:7/64",
107 "1:2:3:4:5:6:7:8:9/64",
108 "1:2:3:4:5:6:7:8::/64",
109 "::1:2:3:4:5:6:7:8/64",
110 "1:2:3:4::5:6:7:8/64",
111 "1:2:3:4:5:6:12345:8/128",
112 "1:2:3:4:5:6:gggg:8/128",
113 "1:2:3:4:5:6:0x1:8/128",
114 "1:2:3:4:5:6:+1:8/128",
115 "1:2:3:4:5:6:-1:8/128",
116 "::ffff:256.0.0.1/128",
117 "::ffff:192.0.2/128",
118 "::ffff:192.0.2.1.2/128",
119 "::ffff:192.0.02.1/128",
120 "192.0.2.1::/128",
121 "::192.0.2.1:1/128",
122 "1:2:3:4:5:192.0.2.1/128",
123 "1:2:3:4:5:6:7:192.0.2.1/128",
124 "1:2:3:4:5:6::192.0.2.1/128",
125 "::1:2:3:4:5:6:192.0.2.1/128",
126];
127
128const ipv4Bits = (address: string) =>
129 address
130 .split(".")
131 .map((octet) => Number(octet).toString(2).padStart(8, "0"))
132 .join("");
133
134const ipv6Bits = (address: string) => {
135 const [left, right] = address.split("::");
136 const head = left ? left.split(":") : [];
137 const tail = right ? right.split(":") : [];
138 const words =
139 right === undefined
140 ? head
141 : [
142 ...head,
143 ...Array<string>(8 - head.length - tail.length).fill("0"),
144 ...tail,
145 ];
146 return words
147 .map((word) => parseInt(word, 16).toString(2).padStart(16, "0"))
148 .join("");
149};
150
151describe("canonicalCidr", { tags: ["unit", "local"] }, () => {
152 test("preserves undefined", () => {
153 expect(canonicalCidr(undefined)).toBeUndefined();
154 });
155
156 for (const [input, expected] of valid) {
157 test(`canonicalizes ${input}`, () => {
158 expect(canonicalCidr(input)).toBe(expected);
159 expect(canonicalCidr(expected)).toBe(expected);
160 });
161 }
162
163 for (const input of invalid) {
164 test(`preserves invalid input ${JSON.stringify(input)}`, () => {
165 expect(canonicalCidr(input)).toBe(input);
166 });
167 }
168
169 test("preserves whitespace rather than interpreting a different address", () => {
170 for (const whitespace of [
171 " ",
172 "\t",
173 "\n",
174 "\r",
175 "\r\n",
176 "\u00a0",
177 "\ufeff",
178 ]) {
179 for (const base of ["10.0.0.1/24", "2001:db8::1/64"]) {
180 for (let index = 0; index <= base.length; index++) {
181 const input = base.slice(0, index) + whitespace + base.slice(index);
182 expect(canonicalCidr(input)).toBe(input);
183 }
184 }
185 }
186 });
187
188 test("preserves oversized inputs without throwing", () => {
189 for (const input of [
190 `10.0.0.1/${"9".repeat(1000)}`,
191 `::1/${"9".repeat(1000)}`,
192 `${"1:".repeat(1000)}1/64`,
193 `${"1.".repeat(1000)}1/24`,
194 `${"f".repeat(1000)}::/64`,
195 ])
196 expect(canonicalCidr(input)).toBe(input);
197 });
198
199 test("masks every IPv4 prefix and is idempotent", () => {
200 for (const address of [
201 "0.0.0.0",
202 "255.255.255.255",
203 "170.85.170.85",
204 "85.170.85.170",
205 "192.168.171.205",
206 ]) {
207 const bits = ipv4Bits(address);
208 for (let prefix = 0; prefix <= 32; prefix++) {
209 const result = canonicalCidr(`${address}/${prefix}`)!;
210 const [network, actualPrefix] = result.split("/");
211 expect(actualPrefix).toBe(String(prefix));
212 expect(ipv4Bits(network!)).toBe(bits.slice(0, prefix).padEnd(32, "0"));
213 expect(canonicalCidr(result)).toBe(result);
214 }
215 }
216 });
217
218 test("masks every IPv6 prefix and is idempotent", () => {
219 for (const address of [
220 "0:0:0:0:0:0:0:0",
221 "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
222 "aaaa:5555:aaaa:5555:aaaa:5555:aaaa:5555",
223 "5555:aaaa:5555:aaaa:5555:aaaa:5555:aaaa",
224 "2001:db8:abcd:1234:5678:9abc:def0:1234",
225 ]) {
226 const bits = ipv6Bits(address);
227 for (let prefix = 0; prefix <= 128; prefix++) {
228 const result = canonicalCidr(`${address}/${prefix}`)!;
229 const [network, actualPrefix] = result.split("/");
230 expect(actualPrefix).toBe(String(prefix));
231 expect(ipv6Bits(network!)).toBe(bits.slice(0, prefix).padEnd(128, "0"));
232 expect(canonicalCidr(result)).toBe(result);
233 }
234 }
235 });
236
237 test("handles every IPv4 octet value in every position", () => {
238 for (let index = 0; index < 4; index++) {
239 for (let value = 0; value <= 255; value++) {
240 const octets = [1, 2, 3, 4];
241 octets[index] = value;
242 const input = `${octets.join(".")}/32`;
243 expect(canonicalCidr(input)).toBe(input);
244 }
245 }
246 });
247
248 test("handles every 16-bit IPv6 word in every position, including case and leading zeros", () => {
249 for (let index = 0; index < 8; index++) {
250 for (let value = 0; value <= 65535; value++) {
251 const words = ["1", "2", "3", "4", "5", "6", "7", "8"];
252 words[index] = value.toString(16);
253 const expected = `${words.join(":")}/128`;
254 words[index] = value.toString(16).toUpperCase().padStart(4, "0");
255 expect(canonicalCidr(`${words.join(":")}/128`)).toBe(expected);
256 }
257 }
258 });
259
260 test("normalizes every valid position and length of IPv6 zero compression", () => {
261 for (let start = 0; start < 8; start++) {
262 for (let length = 1; length <= 8 - start; length++) {
263 const words = Array.from({ length: 8 }, (_, index) =>
264 index >= start && index < start + length ? "0" : String(index + 1),
265 );
266 const expanded = words.join(":");
267 const compressed = `${words.slice(0, start).join(":")}::${words.slice(start + length).join(":")}`;
268 const expected = `${length === 1 ? expanded : compressed}/128`;
269 expect(canonicalCidr(`${expanded}/128`)).toBe(expected);
270 expect(canonicalCidr(`${compressed}/128`)).toBe(expected);
271 }
272 }
273 });
274
275 test("chooses the first longest zero run across all 256 IPv6 zero/nonzero word patterns", () => {
276 for (let pattern = 0; pattern < 256; pattern++) {
277 const expanded = Array.from({ length: 8 }, (_, index) =>
278 pattern & (1 << index) ? "0" : String(index + 1),
279 ).join(":");
280 const runs = Array.from(expanded.matchAll(/(?:^|:)0(?::0)+(?=:|$)/g))
281 .map((match) => ({
282 start: match.index + (match[0].startsWith(":") ? 1 : 0),
283 text: match[0].replace(/^:/, ""),
284 }))
285 .sort((a, b) => b.text.length - a.text.length);
286 const longest = runs[0];
287 const expected =
288 longest === undefined
289 ? expanded
290 : `${expanded.slice(0, longest.start).replace(/:$/, "")}::${expanded.slice(longest.start + longest.text.length).replace(/^:/, "")}`;
291 expect(canonicalCidr(`${expanded}/128`)).toBe(`${expected}/128`);
292 }
293 });
294
295 test("masks embedded IPv4 addresses identically to their hexadecimal form at every prefix", () => {
296 for (let prefix = 0; prefix <= 128; prefix++) {
297 expect(canonicalCidr(`::ffff:192.0.2.129/${prefix}`)).toBe(
298 canonicalCidr(`0:0:0:0:0:ffff:c000:281/${prefix}`),
299 );
300 expect(canonicalCidr(`1:2:3:4:5:6:192.0.2.129/${prefix}`)).toBe(
301 canonicalCidr(`1:2:3:4:5:6:c000:281/${prefix}`),
302 );
303 }
304 });
305});