diff --git a/net/unstable_ip.ts b/net/unstable_ip.ts index d1a8025bdc87..d19ccf62f864 100644 --- a/net/unstable_ip.ts +++ b/net/unstable_ip.ts @@ -23,10 +23,13 @@ export function isIPv4(addr: string): boolean { const octets = addr.split("."); - return octets.length === 4 && octets.every((octet) => { - const n = Number(octet); - return n >= 0 && n <= 255 && !isNaN(n); - }); + return ( + octets.length === 4 && + octets.every((octet) => { + const n = Number(octet); + return n >= 0 && n <= 255 && !isNaN(n); + }) + ); } /** @@ -75,8 +78,273 @@ export function isIPv6(addr: string): boolean { hextets.splice(idx, 0, ""); } - return hextets.length === 8 && hextets.every((hextet) => { - const n = hextet === "" ? 0 : parseInt(hextet, 16); - return n >= 0 && n <= 65535 && !isNaN(n); - }); + return ( + hextets.length === 8 && + hextets.every((hextet) => { + const n = hextet === "" ? 0 : parseInt(hextet, 16); + return n >= 0 && n <= 65535 && !isNaN(n); + }) + ); +} + +/** + * Checks if an IP address matches a subnet or specific IP address. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param addr The IP address to check (IPv4 or IPv6) + * @param subnetOrIps The subnet in CIDR notation (e.g., "192.168.1.0/24") or a specific IP address + * @returns true if the IP address matches the subnet or IP, false otherwise + * @example Check if the address is a IPv6 + * + * ```ts + * import { matchSubnets } from "@std/net/unstable-ip" + * import { assert, assertFalse } from "@std/assert" + * + * assert(matchSubnets("192.168.1.10", ["192.168.1.0/24"])); + * assertFalse(matchSubnets("192.168.2.10", ["192.168.1.0/24"])); + * + * assert(matchSubnets("2001:db8::ffff", ["2001:db8::/64"])); + * assertFalse(matchSubnets("2001:db9::1", ["2001:db8::/64"])); + * ``` + */ +export function matchSubnets(addr: string, subnetOrIps: string[]): boolean { + if (!isValidIP(addr)) { + return false; + } + + for (const subnetOrIp of subnetOrIps) { + if (matchSubnet(addr, subnetOrIp)) { + return true; + } + } + + return false; +} + +function matchSubnet(addr: string, subnet: string): boolean { + // If the subnet doesn't contain "/", treat it as a specific IP address + if (!subnet.includes("/")) { + return addr === subnet; + } + + // Parse subnet into IP address and prefix length + const [subnetIP, prefixLengthStr] = subnet.split("/"); + if ( + !subnetIP || + subnetIP === "" || + !prefixLengthStr || + prefixLengthStr === "" + ) { + return false; + } + + // Check if both IP and subnet are the same type (IPv4 or IPv6) + const ipIsV4 = isIPv4(addr); + const subnetIsV4 = isIPv4(subnetIP); + + // IP and subnet must be the same version (both IPv4 or both IPv6) + if (ipIsV4 !== subnetIsV4) { + return false; + } + + // Delegate to the appropriate subnet matching function + if (ipIsV4) { + return matchIPv4Subnet(addr, subnet); + } else { + return matchIPv6Subnet(addr, subnet); + } +} + +function isValidIP(ip: string): boolean { + return isIPv4(ip) || isIPv6(ip); +} + +/** + * Checks if an IPv4 address matches a subnet or specific IPv4 address. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param addr The IP address to check (IPv4) + * @param subnet The subnet in CIDR notation (e.g., "192.168.1.0/24") or a specific IP address + * @returns true if the IP address matches the subnet or IP, false otherwise + * @example Check if the address is a IPv6 + * + * ```ts + * import { matchIPv4Subnet } from "@std/net/unstable-ip" + * import { assert, assertFalse } from "@std/assert" + * + * assert(matchIPv4Subnet("192.168.1.10", "192.168.1.0/24")); + * assertFalse(matchIPv4Subnet("192.168.2.10", "192.168.1.0/24")); + * ``` + */ +export function matchIPv4Subnet(addr: string, subnet: string): boolean { + const [subnetIP, prefixLengthStr] = subnet.split("/"); + + const prefix = parseInt(prefixLengthStr!, 10); + if (isNaN(prefix)) { + return false; + } + + if ( + !subnetIP || + subnetIP === "" || + !prefixLengthStr || + prefixLengthStr === "" + ) { + return false; + } + + if (prefix < 0 || prefix > 32) { + return false; + } + + // Special case: /0 matches all IPv4 addresses + if (prefix === 0) { + return true; + } + + const ipBytes = addr.split(".").map(Number); + const subnetBytes = subnetIP.split(".").map(Number); + + if (ipBytes.length !== 4 || subnetBytes.length !== 4) { + return false; + } + + const mask = (0xffffffff << (32 - prefix)) >>> 0; + + const ipInt = (ipBytes[0]! << 24) | + (ipBytes[1]! << 16) | + (ipBytes[2]! << 8) | + ipBytes[3]!; + const subnetInt = (subnetBytes[0]! << 24) | + (subnetBytes[1]! << 16) | + (subnetBytes[2]! << 8) | + subnetBytes[3]!; + + return ((ipInt >>> 0) & mask) === ((subnetInt >>> 0) & mask); +} + +/** + * Checks if an IPv6 address matches a subnet or specific IPv6 address. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param addr The IP address to check (IPv6) + * @param subnet The subnet in CIDR notation (e.g., "2001:db8::/64") or a specific IP address + * @returns true if the IP address matches the subnet or IP, false otherwise + * @example Check if the address is a IPv6 + * + * ```ts + * import { matchIPv6Subnet } from "@std/net/unstable-ip" + * import { assert, assertFalse } from "@std/assert" + * + * assert(matchIPv6Subnet("2001:db8::ffff", "2001:db8::/64")); + * assertFalse(matchIPv6Subnet("2001:db9::1", "2001:db8::/64")); + * ``` + */ +export function matchIPv6Subnet(addr: string, subnet: string): boolean { + const [subnetIP, prefixLengthStr] = subnet.split("/"); + + const prefix = parseInt(prefixLengthStr!, 10); + if (isNaN(prefix)) { + return false; + } + + if ( + !subnetIP || + subnetIP === "" || + !prefixLengthStr || + prefixLengthStr === "" + ) { + return false; + } + + if (prefix < 0 || prefix > 128) { + return false; + } + + if (prefix === 0) { + return true; + } + + const ipExpanded = expandIPv6(addr); + const subnetExpanded = expandIPv6(subnetIP); + + if (!ipExpanded || !subnetExpanded) { + return false; + } + + const ipBytes = ipv6ToBytes(ipExpanded); + const subnetBytes = ipv6ToBytes(subnetExpanded); + + const fullBytes = Math.floor(prefix / 8); + const remainingBits = prefix % 8; + + for (let i = 0; i < fullBytes; i++) { + if (ipBytes[i] !== subnetBytes[i]) { + return false; + } + } + + if (remainingBits > 0) { + const mask = 0xff << (8 - remainingBits); + const ipByte = ipBytes[fullBytes]!; + const subnetByte = subnetBytes[fullBytes]!; + return (ipByte & mask) === (subnetByte & mask); + } + + return true; +} + +function expandIPv6(addr: string): string | null { + if (addr.includes(".")) { + const parts = addr.split(":"); + const ipv4Part = parts.pop(); + if (!ipv4Part) { + return null; + } + const ipv4Bytes = ipv4Part!.split(".").map(Number); + if (ipv4Bytes.length !== 4) { + return null; + } + const ipv4Hex = + ((ipv4Bytes[0]! << 8) | ipv4Bytes[1]!).toString(16).padStart(4, "0") + + ":" + + ((ipv4Bytes[2]! << 8) | ipv4Bytes[3]!).toString(16).padStart(4, "0"); + addr = parts.join(":") + ":" + ipv4Hex; + } + + let expanded = addr; + + // Handle :: + if (expanded.includes("::")) { + const parts = expanded.split("::"); + const leftParts = parts[0] ? parts[0].split(":") : []; + const rightParts = parts[1] ? parts[1].split(":") : []; + const missingParts = 8 - leftParts.length - rightParts.length; + + expanded = leftParts + .concat(new Array(missingParts).fill("0")) + .concat(rightParts) + .join(":"); + } + + // Pad each hextet to 4 digits + return expanded + .split(":") + .map((hextet) => hextet.padStart(4, "0")) + .join(":"); +} + +function ipv6ToBytes(expandedIPv6: string): number[] { + const hextets = expandedIPv6.split(":"); + const bytes: number[] = []; + + for (const hextet of hextets) { + const value = parseInt(hextet, 16); + bytes.push((value >> 8) & 0xff, value & 0xff); + } + + return bytes; } diff --git a/net/unstable_ip_test.ts b/net/unstable_ip_test.ts index 1e60342e855d..58be493078ef 100644 --- a/net/unstable_ip_test.ts +++ b/net/unstable_ip_test.ts @@ -1,6 +1,12 @@ // Copyright 2018-2025 the Deno authors. MIT license. -import { isIPv4, isIPv6 } from "./unstable_ip.ts"; +import { + isIPv4, + isIPv6, + matchIPv4Subnet, + matchIPv6Subnet, + matchSubnets, +} from "./unstable_ip.ts"; import { assertEquals } from "@std/assert"; Deno.test("isIPv4()", () => { @@ -46,3 +52,108 @@ Deno.test("isIPv6()", () => { assertEquals(isIPv6(addr), expected); } }); + +Deno.test("matchSubnets()", () => { + const mixed = [ + "192.168.1.0/24", + "10.0.0.0/8", + "2001:db8::/32", + "172.16.0.100", // exact IP + ]; + + const list = [ + // Multiple and mixed subnets + { addr: "192.168.1.50", subnets: mixed, expected: true }, + { addr: "10.5.5.5", subnets: mixed, expected: true }, + { addr: "172.16.0.100", subnets: mixed, expected: true }, + { addr: "172.16.0.101", subnets: mixed, expected: false }, + { addr: "8.8.8.8", subnets: mixed, expected: false }, + { addr: "2001:db8:1234::1", subnets: mixed, expected: true }, + { addr: "2001:db9::1", subnets: mixed, expected: false }, + + // Invalid inputs + { addr: "invalid-ip", subnets: ["192.168.1.0/24"], expected: false }, + { addr: "192.168.1.10", subnets: ["invalid-subnet"], expected: false }, + { addr: "192.168.1.10", subnets: ["192.168.1.0/33"], expected: false }, + { addr: "192.168.1.10", subnets: ["192.168.1.0/AA"], expected: false }, + { addr: "192.168.1.10", subnets: ["192.168.1.0/"], expected: false }, + { addr: "2001:db8::1", subnets: ["2001:db8::/129"], expected: false }, + { addr: "2001:db8::1", subnets: ["2001:db8::/"], expected: false }, + { addr: "192.168.1.10", subnets: [], expected: false }, + ]; + + for (const { addr, subnets, expected } of list) { + assertEquals(matchSubnets(addr, subnets), expected); + } +}); + +Deno.test("matchIPv4Subnet()", () => { + const list = [ + { addr: "192.168.1.10", subnet: "192.168.1.0/24", expected: true }, + { addr: "192.168.1.11", subnet: "/32", expected: false }, + { addr: "192.168.1", subnet: "192.168.1/32", expected: false }, + { addr: "192.168.1.1", subnet: "192.168.1.0/", expected: false }, + { addr: "192.168.1.1", subnet: "192.168.1.0/33", expected: false }, + { addr: "192.168.1.1", subnet: "192.168.1.0/0", expected: true }, + ]; + + for (const { addr, subnet, expected } of list) { + assertEquals(matchIPv4Subnet(addr, subnet), expected); + } +}); + +Deno.test("matchIPv6Subnet()", () => { + const list = [ + // Basic functionality + { addr: "2001:db8::1", subnet: "2001:db8::/64", expected: true }, + + // Invalid prefix lengths + { addr: "2001:db8::1", subnet: "2001:db8::/129", expected: false }, + { addr: "2001:db8::1", subnet: "/129", expected: false }, + { addr: "2001:db8::1", subnet: "2001:db8::/", expected: false }, + + // Invalid address formats + { addr: "2001:db8", subnet: "2001:db8::/64", expected: false }, + { addr: "2001:db8::1", subnet: "2001:db8", expected: false }, + + // expandIPv6 edge cases + { addr: "2001:db8::192.168.1", subnet: "2001:db8::/64", expected: false }, + { addr: "gggg::1", subnet: "2001:db8::/64", expected: false }, + { addr: "invalid", subnet: "2001:db8::/64", expected: false }, + + // Zero prefix (matches all) + { addr: "2001:db8::1", subnet: "::/0", expected: true }, + + // Remaining bits test + { addr: "2001:db8::1", subnet: "2001:db8::/121", expected: true }, + + // Additional coverage cases + { addr: "2001:db8::", subnet: "2001:db8::/64", expected: true }, + { addr: "::", subnet: "::/128", expected: true }, + + // Additional edge cases + { addr: "2001:db8::1", subnet: "2001:db8::1/-1", expected: false }, + { addr: "2001:db8::1", subnet: "2001:db8::1/abc", expected: false }, + + //IPv6 with embedded IPv4 + { + addr: "2001:db8::ffff:192.168.1.1.1", + subnet: "2001:db8::/64", + expected: false, + }, + { + addr: "2001:db8::ffff:192.168.1", + subnet: "2001:db8::/64", + expected: false, + }, + { + addr: "::ffff:192.168.1.1:", + subnet: "::ffff:0.0.0.0/128", + expected: false, + }, + ]; + + for (const { addr, subnet, expected } of list) { + assertEquals(matchIPv6Subnet(addr, subnet), expected); + } +});