Skip to content

Commit 74d5d9d

Browse files
authored
feat(net/unstable): add matchSubnets on @std/net (#6786)
1 parent 590b165 commit 74d5d9d

File tree

2 files changed

+388
-9
lines changed

2 files changed

+388
-9
lines changed

net/unstable_ip.ts

Lines changed: 276 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@
2323
export function isIPv4(addr: string): boolean {
2424
const octets = addr.split(".");
2525

26-
return octets.length === 4 && octets.every((octet) => {
27-
const n = Number(octet);
28-
return n >= 0 && n <= 255 && !isNaN(n);
29-
});
26+
return (
27+
octets.length === 4 &&
28+
octets.every((octet) => {
29+
const n = Number(octet);
30+
return n >= 0 && n <= 255 && !isNaN(n);
31+
})
32+
);
3033
}
3134

3235
/**
@@ -75,8 +78,273 @@ export function isIPv6(addr: string): boolean {
7578
hextets.splice(idx, 0, "");
7679
}
7780

78-
return hextets.length === 8 && hextets.every((hextet) => {
79-
const n = hextet === "" ? 0 : parseInt(hextet, 16);
80-
return n >= 0 && n <= 65535 && !isNaN(n);
81-
});
81+
return (
82+
hextets.length === 8 &&
83+
hextets.every((hextet) => {
84+
const n = hextet === "" ? 0 : parseInt(hextet, 16);
85+
return n >= 0 && n <= 65535 && !isNaN(n);
86+
})
87+
);
88+
}
89+
90+
/**
91+
* Checks if an IP address matches a subnet or specific IP address.
92+
*
93+
* @experimental **UNSTABLE**: New API, yet to be vetted.
94+
*
95+
* @param addr The IP address to check (IPv4 or IPv6)
96+
* @param subnetOrIps The subnet in CIDR notation (e.g., "192.168.1.0/24") or a specific IP address
97+
* @returns true if the IP address matches the subnet or IP, false otherwise
98+
* @example Check if the address is a IPv6
99+
*
100+
* ```ts
101+
* import { matchSubnets } from "@std/net/unstable-ip"
102+
* import { assert, assertFalse } from "@std/assert"
103+
*
104+
* assert(matchSubnets("192.168.1.10", ["192.168.1.0/24"]));
105+
* assertFalse(matchSubnets("192.168.2.10", ["192.168.1.0/24"]));
106+
*
107+
* assert(matchSubnets("2001:db8::ffff", ["2001:db8::/64"]));
108+
* assertFalse(matchSubnets("2001:db9::1", ["2001:db8::/64"]));
109+
* ```
110+
*/
111+
export function matchSubnets(addr: string, subnetOrIps: string[]): boolean {
112+
if (!isValidIP(addr)) {
113+
return false;
114+
}
115+
116+
for (const subnetOrIp of subnetOrIps) {
117+
if (matchSubnet(addr, subnetOrIp)) {
118+
return true;
119+
}
120+
}
121+
122+
return false;
123+
}
124+
125+
function matchSubnet(addr: string, subnet: string): boolean {
126+
// If the subnet doesn't contain "/", treat it as a specific IP address
127+
if (!subnet.includes("/")) {
128+
return addr === subnet;
129+
}
130+
131+
// Parse subnet into IP address and prefix length
132+
const [subnetIP, prefixLengthStr] = subnet.split("/");
133+
if (
134+
!subnetIP ||
135+
subnetIP === "" ||
136+
!prefixLengthStr ||
137+
prefixLengthStr === ""
138+
) {
139+
return false;
140+
}
141+
142+
// Check if both IP and subnet are the same type (IPv4 or IPv6)
143+
const ipIsV4 = isIPv4(addr);
144+
const subnetIsV4 = isIPv4(subnetIP);
145+
146+
// IP and subnet must be the same version (both IPv4 or both IPv6)
147+
if (ipIsV4 !== subnetIsV4) {
148+
return false;
149+
}
150+
151+
// Delegate to the appropriate subnet matching function
152+
if (ipIsV4) {
153+
return matchIPv4Subnet(addr, subnet);
154+
} else {
155+
return matchIPv6Subnet(addr, subnet);
156+
}
157+
}
158+
159+
function isValidIP(ip: string): boolean {
160+
return isIPv4(ip) || isIPv6(ip);
161+
}
162+
163+
/**
164+
* Checks if an IPv4 address matches a subnet or specific IPv4 address.
165+
*
166+
* @experimental **UNSTABLE**: New API, yet to be vetted.
167+
*
168+
* @param addr The IP address to check (IPv4)
169+
* @param subnet The subnet in CIDR notation (e.g., "192.168.1.0/24") or a specific IP address
170+
* @returns true if the IP address matches the subnet or IP, false otherwise
171+
* @example Check if the address is a IPv6
172+
*
173+
* ```ts
174+
* import { matchIPv4Subnet } from "@std/net/unstable-ip"
175+
* import { assert, assertFalse } from "@std/assert"
176+
*
177+
* assert(matchIPv4Subnet("192.168.1.10", "192.168.1.0/24"));
178+
* assertFalse(matchIPv4Subnet("192.168.2.10", "192.168.1.0/24"));
179+
* ```
180+
*/
181+
export function matchIPv4Subnet(addr: string, subnet: string): boolean {
182+
const [subnetIP, prefixLengthStr] = subnet.split("/");
183+
184+
const prefix = parseInt(prefixLengthStr!, 10);
185+
if (isNaN(prefix)) {
186+
return false;
187+
}
188+
189+
if (
190+
!subnetIP ||
191+
subnetIP === "" ||
192+
!prefixLengthStr ||
193+
prefixLengthStr === ""
194+
) {
195+
return false;
196+
}
197+
198+
if (prefix < 0 || prefix > 32) {
199+
return false;
200+
}
201+
202+
// Special case: /0 matches all IPv4 addresses
203+
if (prefix === 0) {
204+
return true;
205+
}
206+
207+
const ipBytes = addr.split(".").map(Number);
208+
const subnetBytes = subnetIP.split(".").map(Number);
209+
210+
if (ipBytes.length !== 4 || subnetBytes.length !== 4) {
211+
return false;
212+
}
213+
214+
const mask = (0xffffffff << (32 - prefix)) >>> 0;
215+
216+
const ipInt = (ipBytes[0]! << 24) |
217+
(ipBytes[1]! << 16) |
218+
(ipBytes[2]! << 8) |
219+
ipBytes[3]!;
220+
const subnetInt = (subnetBytes[0]! << 24) |
221+
(subnetBytes[1]! << 16) |
222+
(subnetBytes[2]! << 8) |
223+
subnetBytes[3]!;
224+
225+
return ((ipInt >>> 0) & mask) === ((subnetInt >>> 0) & mask);
226+
}
227+
228+
/**
229+
* Checks if an IPv6 address matches a subnet or specific IPv6 address.
230+
*
231+
* @experimental **UNSTABLE**: New API, yet to be vetted.
232+
*
233+
* @param addr The IP address to check (IPv6)
234+
* @param subnet The subnet in CIDR notation (e.g., "2001:db8::/64") or a specific IP address
235+
* @returns true if the IP address matches the subnet or IP, false otherwise
236+
* @example Check if the address is a IPv6
237+
*
238+
* ```ts
239+
* import { matchIPv6Subnet } from "@std/net/unstable-ip"
240+
* import { assert, assertFalse } from "@std/assert"
241+
*
242+
* assert(matchIPv6Subnet("2001:db8::ffff", "2001:db8::/64"));
243+
* assertFalse(matchIPv6Subnet("2001:db9::1", "2001:db8::/64"));
244+
* ```
245+
*/
246+
export function matchIPv6Subnet(addr: string, subnet: string): boolean {
247+
const [subnetIP, prefixLengthStr] = subnet.split("/");
248+
249+
const prefix = parseInt(prefixLengthStr!, 10);
250+
if (isNaN(prefix)) {
251+
return false;
252+
}
253+
254+
if (
255+
!subnetIP ||
256+
subnetIP === "" ||
257+
!prefixLengthStr ||
258+
prefixLengthStr === ""
259+
) {
260+
return false;
261+
}
262+
263+
if (prefix < 0 || prefix > 128) {
264+
return false;
265+
}
266+
267+
if (prefix === 0) {
268+
return true;
269+
}
270+
271+
const ipExpanded = expandIPv6(addr);
272+
const subnetExpanded = expandIPv6(subnetIP);
273+
274+
if (!ipExpanded || !subnetExpanded) {
275+
return false;
276+
}
277+
278+
const ipBytes = ipv6ToBytes(ipExpanded);
279+
const subnetBytes = ipv6ToBytes(subnetExpanded);
280+
281+
const fullBytes = Math.floor(prefix / 8);
282+
const remainingBits = prefix % 8;
283+
284+
for (let i = 0; i < fullBytes; i++) {
285+
if (ipBytes[i] !== subnetBytes[i]) {
286+
return false;
287+
}
288+
}
289+
290+
if (remainingBits > 0) {
291+
const mask = 0xff << (8 - remainingBits);
292+
const ipByte = ipBytes[fullBytes]!;
293+
const subnetByte = subnetBytes[fullBytes]!;
294+
return (ipByte & mask) === (subnetByte & mask);
295+
}
296+
297+
return true;
298+
}
299+
300+
function expandIPv6(addr: string): string | null {
301+
if (addr.includes(".")) {
302+
const parts = addr.split(":");
303+
const ipv4Part = parts.pop();
304+
if (!ipv4Part) {
305+
return null;
306+
}
307+
const ipv4Bytes = ipv4Part!.split(".").map(Number);
308+
if (ipv4Bytes.length !== 4) {
309+
return null;
310+
}
311+
const ipv4Hex =
312+
((ipv4Bytes[0]! << 8) | ipv4Bytes[1]!).toString(16).padStart(4, "0") +
313+
":" +
314+
((ipv4Bytes[2]! << 8) | ipv4Bytes[3]!).toString(16).padStart(4, "0");
315+
addr = parts.join(":") + ":" + ipv4Hex;
316+
}
317+
318+
let expanded = addr;
319+
320+
// Handle ::
321+
if (expanded.includes("::")) {
322+
const parts = expanded.split("::");
323+
const leftParts = parts[0] ? parts[0].split(":") : [];
324+
const rightParts = parts[1] ? parts[1].split(":") : [];
325+
const missingParts = 8 - leftParts.length - rightParts.length;
326+
327+
expanded = leftParts
328+
.concat(new Array(missingParts).fill("0"))
329+
.concat(rightParts)
330+
.join(":");
331+
}
332+
333+
// Pad each hextet to 4 digits
334+
return expanded
335+
.split(":")
336+
.map((hextet) => hextet.padStart(4, "0"))
337+
.join(":");
338+
}
339+
340+
function ipv6ToBytes(expandedIPv6: string): number[] {
341+
const hextets = expandedIPv6.split(":");
342+
const bytes: number[] = [];
343+
344+
for (const hextet of hextets) {
345+
const value = parseInt(hextet, 16);
346+
bytes.push((value >> 8) & 0xff, value & 0xff);
347+
}
348+
349+
return bytes;
82350
}

0 commit comments

Comments
 (0)