From 47e61f7f3b821f2c2caf5b8522dfe2885049f610 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Mon, 30 Mar 2026 12:10:44 +0300 Subject: [PATCH 1/2] Added consistent formatting with deno fmt --- .github/workflows/codeql.yml | 2 +- .idea/compiler.xml | 2 +- .../Copyright_Cloudnode___LGPL_3_0.xml | 7 +- .idea/copyright/profiles_settings.xml | 2 +- .idea/dictionaries/project.xml | 2 +- .idea/modules.xml | 7 +- .idea/runConfigurations/build.xml | 9 +- .idea/runConfigurations/test.xml | 9 +- .idea/vcs.xml | 2 +- README.md | 37 +- docs/.vitepress/config.ts | 53 +- src/IP.ts | 12 +- src/IPAddress.ts | 134 ++-- src/IPv4.ts | 164 ++-- src/IPv6.ts | 261 ++++--- src/Network.ts | 30 +- src/Subnet.ts | 610 ++++++++------- src/SubnetList.ts | 362 ++++----- src/index.ts | 14 +- tests/IPAddress.test.ts | 78 +- tests/IPv4.test.ts | 296 ++++---- tests/IPv6.test.ts | 381 +++++----- tests/Subnet.test.ts | 717 +++++++++--------- tests/SubnetList.test.ts | 472 ++++++------ vite.config.ts | 22 +- 25 files changed, 1929 insertions(+), 1756 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e7094c..4461be1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -11,7 +11,7 @@ on: jobs: analyze: name: Analyse (${{ matrix.language }}) - runs-on: 'ubuntu-latest' + runs-on: "ubuntu-latest" timeout-minutes: 360 permissions: security-events: write diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 8ca546d..38d1d55 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/.idea/copyright/Copyright_Cloudnode___LGPL_3_0.xml b/.idea/copyright/Copyright_Cloudnode___LGPL_3_0.xml index 2004c3c..78c898e 100644 --- a/.idea/copyright/Copyright_Cloudnode___LGPL_3_0.xml +++ b/.idea/copyright/Copyright_Cloudnode___LGPL_3_0.xml @@ -1,6 +1,9 @@ - - \ No newline at end of file + diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml index f37a35c..66a4eb1 100644 --- a/.idea/copyright/profiles_settings.xml +++ b/.idea/copyright/profiles_settings.xml @@ -4,4 +4,4 @@ - \ No newline at end of file + diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index d903933..0017d2c 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -4,4 +4,4 @@ ffff - \ No newline at end of file + diff --git a/.idea/modules.xml b/.idea/modules.xml index f8e1373..4fc0c37 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,10 @@ - + - \ No newline at end of file + diff --git a/.idea/runConfigurations/build.xml b/.idea/runConfigurations/build.xml index 0a4f98f..dce8a2a 100644 --- a/.idea/runConfigurations/build.xml +++ b/.idea/runConfigurations/build.xml @@ -1,5 +1,10 @@ - + @@ -9,4 +14,4 @@ - \ No newline at end of file + diff --git a/.idea/runConfigurations/test.xml b/.idea/runConfigurations/test.xml index b46fef0..41b0eea 100644 --- a/.idea/runConfigurations/test.xml +++ b/.idea/runConfigurations/test.xml @@ -1,5 +1,10 @@ - + @@ -9,4 +14,4 @@ - \ No newline at end of file + diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..5ace414 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -3,4 +3,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index bd63d22..01e528e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ [![CI](https://github.com/cloudnode-pro/ip/actions/workflows/ci.yml/badge.svg)](https://github.com/cloudnode-pro/ip/actions/workflows/ci.yml) ![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen) -A modern, object-oriented TypeScript library for representing and performing arithmetic on IP addresses and subnets. +A modern, object-oriented TypeScript library for representing and performing +arithmetic on IP addresses and subnets. [**Documentation — API Reference**](https://ip.cldn.pro) @@ -25,7 +26,7 @@ npm install @cldn/ip Import and use: ```ts -import {IPv4, IPv6, Subnet} from "@cldn/ip"; +import { IPv4, IPv6, Subnet } from "@cldn/ip"; ``` ### Deno @@ -33,39 +34,41 @@ import {IPv4, IPv6, Subnet} from "@cldn/ip"; Import the package from npm using the standard prefix: ```ts -import {IPv4, IPv6, Subnet} from "npm:@cldn/ip"; +import { IPv4, IPv6, Subnet } from "npm:@cldn/ip"; ``` ### Browsers -For browser usage, it is recommended to use a bundler like [Vite](https://vitejs.dev/), -or [Webpack](https://webpack.js.org/). If you are using a bundler, follow the same usage as for Node.js. +For browser usage, it is recommended to use a bundler like +[Vite](https://vitejs.dev/), or [Webpack](https://webpack.js.org/). If you are +using a bundler, follow the same usage as for Node.js. -Alternatively, you can import the library as -a [JavaScript module](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) +Alternatively, you can import the library as a +[JavaScript module](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) from [ESM>CDN](https://esm.sh/): ```html - ``` ## Features - Object-oriented representation of IPv4 and IPv6 addresses, and subnets. -- Comprehensive subnet arithmetic operations (e.g., containment, splitting, merging). +- Comprehensive subnet arithmetic operations (e.g., containment, splitting, + merging). - Support for CIDR notation for defining and parsing subnets. - Easy definition and manipulation of networks and collections of subnets. - Support for IPv4-mapped IPv6 addresses. - Fully documented, fully typed, and thoroughly tested with 100% coverage. -- Zero dependencies; compatible with frontend and backend environments without requiring polyfills. +- Zero dependencies; compatible with frontend and backend environments without + requiring polyfills. ## Example ```ts -import {IPv4, Subnet} from "@cldn/ip"; +import { IPv4, Subnet } from "@cldn/ip"; // Parse IPv4 address const ip = IPv4.fromString("213.0.113.42"); @@ -80,13 +83,15 @@ console.log(subnet.contains(ip)); // true ## Contact -For bugs, or feature requests, please use [GitHub Issues](https://github.com/cloudnode-pro/ip/issues). +For bugs, or feature requests, please use +[GitHub Issues](https://github.com/cloudnode-pro/ip/issues). -For real-time chat or community discussions, join our Matrix -space: [#community\:cloudnode.pro](https://matrix.to/#/%23community:cloudnode.pro). +For real-time chat or community discussions, join our Matrix space: +[#community\:cloudnode.pro](https://matrix.to/#/%23community:cloudnode.pro). ## Licence Copyright © 2024–2025 Cloudnode OÜ. -This project is licensed under the terms of the [LGPL-3.0](https://github.com/cloudnode-pro/ip/blob/master/COPYING) licence. +This project is licensed under the terms of the +[LGPL-3.0](https://github.com/cloudnode-pro/ip/blob/master/COPYING) licence. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 46b2be8..074854b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,37 +1,38 @@ -import {defineConfig} from "vitepress"; +import { defineConfig } from "vitepress"; import typedocSidebar from "../api/typedoc-sidebar.json"; // https://vitepress.dev/reference/site-config export default defineConfig({ - title: "@cldn/ip", - description: "Documentation", - cleanUrls: true, - themeConfig: { - nav: [ - {text: "API Reference", link: "/api/"}, - ], + title: "@cldn/ip", + description: "Documentation", + cleanUrls: true, + themeConfig: { + nav: [ + { text: "API Reference", link: "/api/" }, + ], - sidebar: [ - { - text: "API", - items: typedocSidebar, - }, - ], + sidebar: [ + { + text: "API", + items: typedocSidebar, + }, + ], - outline: [2, 3], + outline: [2, 3], - socialLinks: [ - {icon: "github", link: "https://github.com/cloudnode-pro/ip"}, - {icon: "matrix", link: "https://matrix.to/#/@cloudnode:matrix.org"}, - ], + socialLinks: [ + { icon: "github", link: "https://github.com/cloudnode-pro/ip" }, + { icon: "matrix", link: "https://matrix.to/#/@cloudnode:matrix.org" }, + ], - search: { - provider: "local", - }, + search: { + provider: "local", + }, - footer: { - copyright: `Copyright © 2024–2025 Cloudnode OÜ.`, - message: `Released under the LGPL-3.0 licence.` - } + footer: { + copyright: `Copyright © 2024–2025 Cloudnode OÜ.`, + message: + `Released under the LGPL-3.0 licence.`, }, + }, }); diff --git a/src/IP.ts b/src/IP.ts index 910b8c3..d0f7b6f 100644 --- a/src/IP.ts +++ b/src/IP.ts @@ -14,8 +14,8 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see */ -import {IPv4} from "./IPv4.js"; -import {IPv6} from "./IPv6.js"; +import { IPv4 } from "./IPv4.js"; +import { IPv6 } from "./IPv6.js"; /** * An {@link IPv4} or {@link IPv6} address. @@ -23,8 +23,8 @@ import {IPv6} from "./IPv6.js"; export type IP = IPv4 | IPv6; export namespace IP { - /** - * An {@link IPv4} or {@link IPv6} address class. - */ - export type Class = typeof IPv4 | typeof IPv6; + /** + * An {@link IPv4} or {@link IPv6} address class. + */ + export type Class = typeof IPv4 | typeof IPv6; } diff --git a/src/IPAddress.ts b/src/IPAddress.ts index 1d546c6..8cfe344 100644 --- a/src/IPAddress.ts +++ b/src/IPAddress.ts @@ -14,7 +14,7 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see . */ -import {IP, IPv4, IPv6} from "./index.js"; +import { IP, IPv4, IPv6 } from "./index.js"; /** * Represents an Internet Protocol (IP) address. @@ -22,77 +22,81 @@ import {IP, IPv4, IPv6} from "./index.js"; * @sealed */ export abstract class IPAddress { - /** - * Integer representation of the IP address. - */ - public readonly value: bigint; + /** + * Integer representation of the IP address. + */ + public readonly value: bigint; - /** - * Creates a new IP address instance. - * - * @param value Integer representation of the IP address. - * @internal - */ - protected constructor(value: bigint) { - this.value = value; - } + /** + * Creates a new IP address instance. + * + * @param value Integer representation of the IP address. + * @internal + */ + protected constructor(value: bigint) { + this.value = value; + } - /** - * Creates an IP address from a string. - * - * @param ip String representation of the IP address. - * @param [resolveMapped=false] Whether to resolve IPv4-mapped IPv6 addresses (see {@link IPv6.hasMappedIPv4}). - * @throws {@link !RangeError} If the string is not a valid IPv4 or IPv6 address. - */ - public static fromString(ip: string, resolveMapped = false): IP { - if (ip.includes(":")) { - const ipv6 = IPv6.fromString(ip); - if (resolveMapped && ipv6.hasMappedIPv4()) - return ipv6.getMappedIPv4(); - return ipv6; - } - return IPv4.fromString(ip); + /** + * Creates an IP address from a string. + * + * @param ip String representation of the IP address. + * @param [resolveMapped=false] Whether to resolve IPv4-mapped IPv6 addresses (see {@link IPv6.hasMappedIPv4}). + * @throws {@link !RangeError} If the string is not a valid IPv4 or IPv6 address. + */ + public static fromString(ip: string, resolveMapped = false): IP { + if (ip.includes(":")) { + const ipv6 = IPv6.fromString(ip); + if (resolveMapped && ipv6.hasMappedIPv4()) { + return ipv6.getMappedIPv4(); + } + return ipv6; } + return IPv4.fromString(ip); + } - /** - * Returns the binary representation of the IP address. - */ - public abstract binary(): ArrayBufferView; + /** + * Returns the binary representation of the IP address. + */ + public abstract binary(): ArrayBufferView; - /** - * Checks if the given address is equal to this address. - * - * @param other Address to compare. - */ - public equals(other: IPAddress): boolean { - return other instanceof this.constructor && other.value === this.value; - } + /** + * Checks if the given address is equal to this address. + * + * @param other Address to compare. + */ + public equals(other: IPAddress): boolean { + return other instanceof this.constructor && other.value === this.value; + } - /** - * Returns the IP address as a bigint or string primitive. - * - * @param hint Preferred primitive type. - */ - public [Symbol.toPrimitive](hint: "number" | "string" | "default"): bigint | string { - if (hint === "string") - return this.toString(); - return this.value; + /** + * Returns the IP address as a bigint or string primitive. + * + * @param hint Preferred primitive type. + */ + public [Symbol.toPrimitive]( + hint: "number" | "string" | "default", + ): bigint | string { + if (hint === "string") { + return this.toString(); } + return this.value; + } - /** - * Returns the IP address as a string. - */ - public abstract toString(): string; + /** + * Returns the IP address as a string. + */ + public abstract toString(): string; - /** - * Returns a new IP address offset by the given amount from this address. - * - * @example ip.offset(1) // Returns the next IP address. - * @example ip.offset(-1) // Returns the previous IP address. - * @example IPAddress.fromString("203.0.113.42").offset(-18) // Returns 203.0.113.24. - * - * @param offset Number of steps to offset, positive or negative. - * @throws {@link !TypeError} If the resulting address is outside the IP address family range. - */ - public abstract offset(offset: number | bigint): IPAddress; + /** + * Returns a new IP address offset by the given amount from this address. + * + * @example ip.offset(1) // Returns the next IP address. + * @example ip.offset(-1) // Returns the previous IP address. + * @example IPAddress.fromString("203.0.113.42").offset(-18) // Returns 203.0.113.24. + * + * @param offset Number of steps to offset, positive or negative. + * @throws {@link !TypeError} If the resulting address is outside the IP address family range. + */ + public abstract offset(offset: number | bigint): IPAddress; } diff --git a/src/IPv4.ts b/src/IPv4.ts index 7666b2f..be82cd8 100644 --- a/src/IPv4.ts +++ b/src/IPv4.ts @@ -14,98 +14,110 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see . */ -import {IPAddress} from "./index.js"; +import { IPAddress } from "./index.js"; /** * Represents an Internet Protocol version 4 (IPv4) address. */ export class IPv4 extends IPAddress { - /** - * Regular expression for testing IPv4 addresses in dotted-decimal string notation. - */ - public static REGEX = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/; + /** + * Regular expression for testing IPv4 addresses in dotted-decimal string notation. + */ + public static REGEX = + /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/; - /** - * Bit length of IPv4 addresses. - */ - public static BIT_LENGTH = 32; + /** + * Bit length of IPv4 addresses. + */ + public static BIT_LENGTH = 32; - /** - * Creates a new IPv4 address instance. - * - * @param value 32-bit unsigned integer. - * @throws {@link !TypeError} If the value is not a 32-bit unsigned integer. - */ - public constructor(value: bigint); + /** + * Creates a new IPv4 address instance. + * + * @param value 32-bit unsigned integer. + * @throws {@link !TypeError} If the value is not a 32-bit unsigned integer. + */ + public constructor(value: bigint); - /** - * Creates a new IPv4 address instance. - * - * @param value 32-bit unsigned number. - * @throws {@link !TypeError} If the value is not a 32-bit unsigned integer. - */ - public constructor(value: number); + /** + * Creates a new IPv4 address instance. + * + * @param value 32-bit unsigned number. + * @throws {@link !TypeError} If the value is not a 32-bit unsigned integer. + */ + public constructor(value: number); - public constructor(value: number | bigint) { - const int = BigInt(value); - if (int < 0n || int > (1n << BigInt(IPv4.BIT_LENGTH)) - 1n) - throw new TypeError("Expected 32-bit unsigned integer, got " + int.constructor.name + " " + int.toString(10)); - super(int); + public constructor(value: number | bigint) { + const int = BigInt(value); + if (int < 0n || int > (1n << BigInt(IPv4.BIT_LENGTH)) - 1n) { + throw new TypeError( + "Expected 32-bit unsigned integer, got " + int.constructor.name + " " + + int.toString(10), + ); } + super(int); + } - /** - * Creates an IPv4 address instance from octets. - * - * @param octets Typed array of 4 octets. - * @throws {@link !RangeError} If the number of octets is not 4. - */ - public static fromBinary(octets: Uint8Array): IPv4 { - if (octets.length !== 4) throw new RangeError("Expected 4 octets, got " + octets.length); - - return new IPv4( - ( - octets[0]! << 24 | - octets[1]! << 16 | - octets[2]! << 8 | - octets[3]! - ) >>> 0 - ); + /** + * Creates an IPv4 address instance from octets. + * + * @param octets Typed array of 4 octets. + * @throws {@link !RangeError} If the number of octets is not 4. + */ + public static fromBinary(octets: Uint8Array): IPv4 { + if (octets.length !== 4) { + throw new RangeError("Expected 4 octets, got " + octets.length); } - /** - * Creates an IPv4 address instance from a string. - * - * @param ip String representation of an IPv4 address. - * @throws {@link !RangeError} If the string is not a valid IPv4 address. - */ - public static override fromString(ip: string): IPv4 { - const octets = ip.split(".", 4).map(octet => Number.parseInt(octet, 10)); - if (octets.some(octet => Number.isNaN(octet) || octet < 0 || octet > 255)) - throw new RangeError("Expected valid IPv4 address, got " + ip); + return new IPv4( + ( + octets[0]! << 24 | + octets[1]! << 16 | + octets[2]! << 8 | + octets[3]! + ) >>> 0, + ); + } - return IPv4.fromBinary(new Uint8Array(octets)); + /** + * Creates an IPv4 address instance from a string. + * + * @param ip String representation of an IPv4 address. + * @throws {@link !RangeError} If the string is not a valid IPv4 address. + */ + public static override fromString(ip: string): IPv4 { + const octets = ip.split(".", 4).map((octet) => Number.parseInt(octet, 10)); + if ( + octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255) + ) { + throw new RangeError("Expected valid IPv4 address, got " + ip); } - /** - * Returns the 4 octets of the IPv4 address. - */ - public override binary(): Uint8Array { - return new Uint8Array([ - (this.value >> 24n) & 0xFFn, - (this.value >> 16n) & 0xFFn, - (this.value >> 8n) & 0xFFn, - this.value & 0xFFn, - ].map(Number)); - } + return IPv4.fromBinary(new Uint8Array(octets)); + } - /** - * Returns the IP address as a string in dotted-decimal notation. - */ - public override toString(): string { - return Array.from(this.binary()).map(octet => octet.toString(10)).join("."); - } + /** + * Returns the 4 octets of the IPv4 address. + */ + public override binary(): Uint8Array { + return new Uint8Array([ + (this.value >> 24n) & 0xFFn, + (this.value >> 16n) & 0xFFn, + (this.value >> 8n) & 0xFFn, + this.value & 0xFFn, + ].map(Number)); + } - public override offset(offset: bigint | number): IPv4 { - return new IPv4(this.value + BigInt(offset)); - } + /** + * Returns the IP address as a string in dotted-decimal notation. + */ + public override toString(): string { + return Array.from(this.binary()).map((octet) => octet.toString(10)).join( + ".", + ); + } + + public override offset(offset: bigint | number): IPv4 { + return new IPv4(this.value + BigInt(offset)); + } } diff --git a/src/IPv6.ts b/src/IPv6.ts index 8d7ef13..101283e 100644 --- a/src/IPv6.ts +++ b/src/IPv6.ts @@ -14,147 +14,160 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see . */ -import {IPAddress, IPv4, Subnet} from "./index.js"; +import { IPAddress, IPv4, Subnet } from "./index.js"; /** * Represents an Internet Protocol version 6 (IPv6) address. */ export class IPv6 extends IPAddress { - /** - * Bit length of IPv6 addresses. - */ - public static BIT_LENGTH = 128; + /** + * Bit length of IPv6 addresses. + */ + public static BIT_LENGTH = 128; - /** - * Creates a new IPv6 address instance. - * - * @param value 128-bit unsigned integer. - * @throws {@link !TypeError} If the value is not a 128-bit unsigned integer. - */ - public constructor(value: bigint) { - if (value < 0n || value > ((1n << BigInt(IPv6.BIT_LENGTH)) - 1n)) - throw new TypeError("Expected 128-bit unsigned integer, got " + value.constructor.name + " 0x" + value.toString(16)); - super(value); + /** + * Creates a new IPv6 address instance. + * + * @param value 128-bit unsigned integer. + * @throws {@link !TypeError} If the value is not a 128-bit unsigned integer. + */ + public constructor(value: bigint) { + if (value < 0n || value > ((1n << BigInt(IPv6.BIT_LENGTH)) - 1n)) { + throw new TypeError( + "Expected 128-bit unsigned integer, got " + value.constructor.name + + " 0x" + value.toString(16), + ); } + super(value); + } - /** - * Creates an IPv6 address instance from hextets. - * - * @param hextets Typed array of 8 hextets. - * @throws {@link !RangeError} If the number of hextets is not 8. - */ - public static fromBinary(hextets: Uint16Array): IPv6 { - if (hextets.length !== 8) throw new RangeError("Expected 8 hextets, got " + hextets.length); - - return new IPv6( - BigInt(hextets[0]!) << 112n | - BigInt(hextets[1]!) << 96n | - BigInt(hextets[2]!) << 80n | - BigInt(hextets[3]!) << 64n | - BigInt(hextets[4]!) << 48n | - BigInt(hextets[5]!) << 32n | - BigInt(hextets[6]!) << 16n | - BigInt(hextets[7]!) - ); + /** + * Creates an IPv6 address instance from hextets. + * + * @param hextets Typed array of 8 hextets. + * @throws {@link !RangeError} If the number of hextets is not 8. + */ + public static fromBinary(hextets: Uint16Array): IPv6 { + if (hextets.length !== 8) { + throw new RangeError("Expected 8 hextets, got " + hextets.length); } - /** - * Creates an IPv6 address instance from a string. - * - * @param ip String representation of an IPv6 address. - * @throws {@link !RangeError} If the string is not a valid IPv6 address. - */ - public static override fromString(ip: string): IPv6 { - const parts = ip.split("::", 2); - const hextestStart = parts[0]! === "" - ? [] - : parts[0]!.split(":").flatMap(IPv6.parseHextet); - const hextestEnd = parts[1] === undefined || parts[1] === "" - ? [] - : parts[1].split(":").flatMap(IPv6.parseHextet); - if ( - hextestStart.some(hextet => Number.isNaN(hextet) || hextet < 0 || hextet > 0xFFFF) || - hextestEnd.some(hextet => Number.isNaN(hextet) || hextet < 0 || hextet > 0xFFFF) || - (parts.length === 2 && hextestStart.length + hextestEnd.length > 6) || - (parts.length < 2 && hextestStart.length + hextestEnd.length !== 8) - ) throw new RangeError("Expected valid IPv6 address, got " + ip); + return new IPv6( + BigInt(hextets[0]!) << 112n | + BigInt(hextets[1]!) << 96n | + BigInt(hextets[2]!) << 80n | + BigInt(hextets[3]!) << 64n | + BigInt(hextets[4]!) << 48n | + BigInt(hextets[5]!) << 32n | + BigInt(hextets[6]!) << 16n | + BigInt(hextets[7]!), + ); + } - const hextets = new Uint16Array(8); - hextets.set(hextestStart, 0); - hextets.set(hextestEnd, 8 - hextestEnd.length); + /** + * Creates an IPv6 address instance from a string. + * + * @param ip String representation of an IPv6 address. + * @throws {@link !RangeError} If the string is not a valid IPv6 address. + */ + public static override fromString(ip: string): IPv6 { + const parts = ip.split("::", 2); + const hextestStart = parts[0]! === "" + ? [] + : parts[0]!.split(":").flatMap(IPv6.parseHextet); + const hextestEnd = parts[1] === undefined || parts[1] === "" + ? [] + : parts[1].split(":").flatMap(IPv6.parseHextet); + if ( + hextestStart.some((hextet) => + Number.isNaN(hextet) || hextet < 0 || hextet > 0xFFFF + ) || + hextestEnd.some((hextet) => + Number.isNaN(hextet) || hextet < 0 || hextet > 0xFFFF + ) || + (parts.length === 2 && hextestStart.length + hextestEnd.length > 6) || + (parts.length < 2 && hextestStart.length + hextestEnd.length !== 8) + ) throw new RangeError("Expected valid IPv6 address, got " + ip); - return IPv6.fromBinary(hextets); - } + const hextets = new Uint16Array(8); + hextets.set(hextestStart, 0); + hextets.set(hextestEnd, 8 - hextestEnd.length); - /** - * Parses a string hextet into unsigned 16-bit integer. - * - * @param hextet String representation of a hextet. - * @internal - */ - private static parseHextet(hextet: string): number | number[] { - if (IPv4.REGEX.test(hextet)) { - const ip = IPv4.fromString(hextet).binary(); - return [ - (ip[0]! << 8) | ip[1]!, - (ip[2]! << 8) | ip[3]! - ] - } - return Number.parseInt(hextet, 16); - } + return IPv6.fromBinary(hextets); + } - /** - * Returns the 8 hextets of the IPv6 address. - */ - public binary(): Uint16Array { - return new Uint16Array([ - (this.value >> 112n) & 0xFFFFn, - (this.value >> 96n) & 0xFFFFn, - (this.value >> 80n) & 0xFFFFn, - (this.value >> 64n) & 0xFFFFn, - (this.value >> 48n) & 0xFFFFn, - (this.value >> 32n) & 0xFFFFn, - (this.value >> 16n) & 0xFFFFn, - this.value & 0xFFFFn - ].map(Number)); + /** + * Parses a string hextet into unsigned 16-bit integer. + * + * @param hextet String representation of a hextet. + * @internal + */ + private static parseHextet(hextet: string): number | number[] { + if (IPv4.REGEX.test(hextet)) { + const ip = IPv4.fromString(hextet).binary(); + return [ + (ip[0]! << 8) | ip[1]!, + (ip[2]! << 8) | ip[3]!, + ]; } + return Number.parseInt(hextet, 16); + } - /** - * Checks whether this IPv6 address is an IPv4-mapped IPv6 address as defined by the `::ffff:0:0/96` prefix. This - * method does not detect other IPv4 embedding or tunnelling formats. - */ - public hasMappedIPv4(): boolean { - return Subnet.IPV4_MAPPED_IPV6.contains(this); - } + /** + * Returns the 8 hextets of the IPv6 address. + */ + public binary(): Uint16Array { + return new Uint16Array([ + (this.value >> 112n) & 0xFFFFn, + (this.value >> 96n) & 0xFFFFn, + (this.value >> 80n) & 0xFFFFn, + (this.value >> 64n) & 0xFFFFn, + (this.value >> 48n) & 0xFFFFn, + (this.value >> 32n) & 0xFFFFn, + (this.value >> 16n) & 0xFFFFn, + this.value & 0xFFFFn, + ].map(Number)); + } - /** - * Returns the IPv4-mapped IPv6 address. - * - * @returns The IPv4 address from the least significant 32 bits of this IPv6 address. - * @see {@link IPv6#hasMappedIPv4} - */ - public getMappedIPv4(): IPv4 { - const bin = this.binary().slice(-2); - return IPv4.fromBinary(new Uint8Array([ - (bin[0]! >> 8) & 0xFF, - bin[0]! & 0xFF, - (bin[1]! >> 8) & 0xFF, - bin[1]! & 0xFF - ])); - } + /** + * Checks whether this IPv6 address is an IPv4-mapped IPv6 address as defined by the `::ffff:0:0/96` prefix. This + * method does not detect other IPv4 embedding or tunnelling formats. + */ + public hasMappedIPv4(): boolean { + return Subnet.IPV4_MAPPED_IPV6.contains(this); + } - /** - * Returns the IP address as a string in colon-hexadecimal notation. - */ - public override toString(): string { - const str = Array.from(this.binary()).map(octet => octet.toString(16)).join(":"); - const longest = str.match(/(?:^|:)0(?::0)+(?:$|:)/g) - ?.reduce((acc, cur) => cur.length > acc.length ? cur : acc, "") ?? null; - if (longest === null) return str; - return str.replace(longest, "::"); - } + /** + * Returns the IPv4-mapped IPv6 address. + * + * @returns The IPv4 address from the least significant 32 bits of this IPv6 address. + * @see {@link IPv6#hasMappedIPv4} + */ + public getMappedIPv4(): IPv4 { + const bin = this.binary().slice(-2); + return IPv4.fromBinary( + new Uint8Array([ + (bin[0]! >> 8) & 0xFF, + bin[0]! & 0xFF, + (bin[1]! >> 8) & 0xFF, + bin[1]! & 0xFF, + ]), + ); + } - public override offset(offset: bigint | number): IPv6 { - return new IPv6(this.value + BigInt(offset)); - } + /** + * Returns the IP address as a string in colon-hexadecimal notation. + */ + public override toString(): string { + const str = Array.from(this.binary()).map((octet) => octet.toString(16)) + .join(":"); + const longest = str.match(/(?:^|:)0(?::0)+(?:$|:)/g) + ?.reduce((acc, cur) => cur.length > acc.length ? cur : acc, "") ?? null; + if (longest === null) return str; + return str.replace(longest, "::"); + } + + public override offset(offset: bigint | number): IPv6 { + return new IPv6(this.value + BigInt(offset)); + } } diff --git a/src/Network.ts b/src/Network.ts index c9397be..c586c38 100644 --- a/src/Network.ts +++ b/src/Network.ts @@ -14,26 +14,26 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see */ -import {IP} from "./IP.js"; +import { IP } from "./IP.js"; /** * A network of IP addresses. */ export interface Network extends Iterable { - /** - * Determines whether this network contains the provided IP address. - * - * @param address IP address to check. - */ - contains(address: T): boolean; + /** + * Determines whether this network contains the provided IP address. + * + * @param address IP address to check. + */ + contains(address: T): boolean; - /** - * Returns the exact number of IP addresses in this network. - */ - size(): bigint; + /** + * Returns the exact number of IP addresses in this network. + */ + size(): bigint; - /** - * Iterates over all IP addresses in this network. - */ - [Symbol.iterator](): Iterator; + /** + * Iterates over all IP addresses in this network. + */ + [Symbol.iterator](): Iterator; } diff --git a/src/Subnet.ts b/src/Subnet.ts index b8da753..5b9527d 100644 --- a/src/Subnet.ts +++ b/src/Subnet.ts @@ -14,7 +14,7 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see . */ -import {IP, IPAddress, Network} from "./index.js"; +import { IP, IPAddress, Network } from "./index.js"; /** * Represents an address range subnetwork of Internet Protocol (IP) addresses. @@ -22,315 +22,355 @@ import {IP, IPAddress, Network} from "./index.js"; * @typeParam T IP address family. */ export class Subnet implements Network { - /** - * Subnet for IPv4-mapped IPv6 addresses (`::ffff:0.0.0.0/96`). - */ - public static readonly IPV4_MAPPED_IPV6 = Subnet.fromCIDR("::ffff:0.0.0.0/96"); - - /** - * Subnet address (i.e. the first IP address of the network). - */ - public readonly address: T; - - /** - * Bit length of the subnet prefix. - */ - public readonly prefix: number; - - /** - * IP address constructor. - * - * @internal - */ - public readonly _AddressClass: IP.Class; - - /** - * Creates a new subnet instance. - * - * @param address IP address. - * @param prefix Bit length of the subnet prefix. - * @throws {@link !RangeError} If the prefix is greater than the IP address family bit length, or if negative. - */ - public constructor(address: T, prefix: number) { - this._AddressClass = address.constructor as IP.Class; - if (prefix < 0) - throw new RangeError("Expected positive prefix, got " + prefix); - if (prefix > this._AddressClass.BIT_LENGTH) - throw new RangeError(`Expected prefix less than ${this._AddressClass.BIT_LENGTH}, got ${prefix}`); - this.prefix = prefix; - this.address = new this._AddressClass(address.value & this.netmask()) as T; + /** + * Subnet for IPv4-mapped IPv6 addresses (`::ffff:0.0.0.0/96`). + */ + public static readonly IPV4_MAPPED_IPV6 = Subnet.fromCIDR( + "::ffff:0.0.0.0/96", + ); + + /** + * Subnet address (i.e. the first IP address of the network). + */ + public readonly address: T; + + /** + * Bit length of the subnet prefix. + */ + public readonly prefix: number; + + /** + * IP address constructor. + * + * @internal + */ + public readonly _AddressClass: IP.Class; + + /** + * Creates a new subnet instance. + * + * @param address IP address. + * @param prefix Bit length of the subnet prefix. + * @throws {@link !RangeError} If the prefix is greater than the IP address family bit length, or if negative. + */ + public constructor(address: T, prefix: number) { + this._AddressClass = address.constructor as IP.Class; + if (prefix < 0) { + throw new RangeError("Expected positive prefix, got " + prefix); } - - /** - * Creates a subnet from a string in CIDR notation. - * - * @param cidr String in CIDR notation. - * @throws {@link !RangeError} If the address or prefix is invalid. - */ - public static fromCIDR(cidr: string): Subnet { - const parts = cidr.split("/", 2); - if (parts.length !== 2) throw new RangeError("Expected CIDR notation, got " + cidr); - const prefix = Number.parseInt(parts[1]!, 10); - if (Number.isNaN(prefix)) throw new RangeError("Expected numeric prefix, got " + parts[1]); - return new Subnet(IPAddress.fromString(parts[0]!) as T, prefix); + if (prefix > this._AddressClass.BIT_LENGTH) { + throw new RangeError( + `Expected prefix less than ${this._AddressClass.BIT_LENGTH}, got ${prefix}`, + ); } - - /** - * Creates an array of subnets to represent an arbitrary range of IP addresses. - * - * @param start Starting IP address. - * @param end Ending IP address. - */ - public static range(start: T, end: T): Subnet[] { - const AddressClass = start.constructor as IP.Class; - const result: Subnet[] = []; - - let current = start.value; - - while (current <= end.value) { - let tz = 0; - while (tz < AddressClass.BIT_LENGTH && ((current >> BigInt(tz)) & 1n) === 0n) - ++tz; - const prefixAlign = AddressClass.BIT_LENGTH - tz; - - let remaining = end.value - current + 1n; - let maxBlockBits = 0; - while (remaining > 1n) { - remaining >>= 1n; - ++maxBlockBits; - } - const prefixRange = AddressClass.BIT_LENGTH - maxBlockBits; - - const subnet = new Subnet(new AddressClass(current) as T, Math.max(prefixAlign, prefixRange)); - result.push(subnet); - current += subnet.size(); - } - - return result; + this.prefix = prefix; + this.address = new this._AddressClass(address.value & this.netmask()) as T; + } + + /** + * Creates a subnet from a string in CIDR notation. + * + * @param cidr String in CIDR notation. + * @throws {@link !RangeError} If the address or prefix is invalid. + */ + public static fromCIDR(cidr: string): Subnet { + const parts = cidr.split("/", 2); + if (parts.length !== 2) { + throw new RangeError("Expected CIDR notation, got " + cidr); } - - /** - * Returns the network mask of this subnet. - */ - public netmask(): bigint { - return ((1n << BigInt(this.prefix)) - 1n) << BigInt(this._AddressClass.BIT_LENGTH - this.prefix); + const prefix = Number.parseInt(parts[1]!, 10); + if (Number.isNaN(prefix)) { + throw new RangeError("Expected numeric prefix, got " + parts[1]); } - - /** - * Returns the wildcard (host) mask—the inverse of the {@link netmask}. - */ - public wildcard(): bigint { - return (1n << BigInt(this._AddressClass.BIT_LENGTH - this.prefix)) - 1n; + return new Subnet(IPAddress.fromString(parts[0]!) as T, prefix); + } + + /** + * Creates an array of subnets to represent an arbitrary range of IP addresses. + * + * @param start Starting IP address. + * @param end Ending IP address. + */ + public static range(start: T, end: T): Subnet[] { + const AddressClass = start.constructor as IP.Class; + const result: Subnet[] = []; + + let current = start.value; + + while (current <= end.value) { + let tz = 0; + while ( + tz < AddressClass.BIT_LENGTH && ((current >> BigInt(tz)) & 1n) === 0n + ) { + ++tz; + } + const prefixAlign = AddressClass.BIT_LENGTH - tz; + + let remaining = end.value - current + 1n; + let maxBlockBits = 0; + while (remaining > 1n) { + remaining >>= 1n; + ++maxBlockBits; + } + const prefixRange = AddressClass.BIT_LENGTH - maxBlockBits; + + const subnet = new Subnet( + new AddressClass(current) as T, + Math.max(prefixAlign, prefixRange), + ); + result.push(subnet); + current += subnet.size(); } - /** - * Returns the address at the specific index. - * - * @param index Zero-based index of the address to be returned. Negative index counts from the end of the subnet. - * @returns The address in the subnet matching the given index. Always returns `undefined` if `index < -size()` or - * `index >= size()`. - */ - public at(index: 0 | 0n | -1 | -1n): T; - - /** - * Returns the address at the specific index. - * - * @param index Zero-based index of the address to be returned. Negative index counts from the end of the subnet. - * @returns The address in the subnet matching the given index. Always returns `undefined` if `index < -size()` or - * `index >= size()`. - */ - public at(index: bigint): T | undefined; - - /** - * Returns the address at the specific index. - * - * @param index Zero-based index of the address to be returned. Negative index counts from the end of the subnet. - * @returns The address in the subnet matching the given index. Always returns `undefined` if `index < -size()` or - * `index >= size()`. - */ - public at(index: number): T | undefined; - - public at(a: bigint | number): T | undefined { - const index = BigInt(a); - if (index < -this.size() || index >= this.size()) return undefined; - if (index < 0) - return this.at(this.size() + index); - return new this._AddressClass(this.address.value + BigInt(index)) as T; + return result; + } + + /** + * Returns the network mask of this subnet. + */ + public netmask(): bigint { + return ((1n << BigInt(this.prefix)) - 1n) << + BigInt(this._AddressClass.BIT_LENGTH - this.prefix); + } + + /** + * Returns the wildcard (host) mask—the inverse of the {@link netmask}. + */ + public wildcard(): bigint { + return (1n << BigInt(this._AddressClass.BIT_LENGTH - this.prefix)) - 1n; + } + + /** + * Returns the address at the specific index. + * + * @param index Zero-based index of the address to be returned. Negative index counts from the end of the subnet. + * @returns The address in the subnet matching the given index. Always returns `undefined` if `index < -size()` or + * `index >= size()`. + */ + public at(index: 0 | 0n | -1 | -1n): T; + + /** + * Returns the address at the specific index. + * + * @param index Zero-based index of the address to be returned. Negative index counts from the end of the subnet. + * @returns The address in the subnet matching the given index. Always returns `undefined` if `index < -size()` or + * `index >= size()`. + */ + public at(index: bigint): T | undefined; + + /** + * Returns the address at the specific index. + * + * @param index Zero-based index of the address to be returned. Negative index counts from the end of the subnet. + * @returns The address in the subnet matching the given index. Always returns `undefined` if `index < -size()` or + * `index >= size()`. + */ + public at(index: number): T | undefined; + + public at(a: bigint | number): T | undefined { + const index = BigInt(a); + if (index < -this.size() || index >= this.size()) return undefined; + if (index < 0) { + return this.at(this.size() + index); } - - /** - * Determines whether the provided address is contained within this subnet. - * - * @param address IP address to check. - */ - public contains(address: T): boolean { - return address instanceof this._AddressClass && (address.value & this.netmask()) === this.address.value; + return new this._AddressClass(this.address.value + BigInt(index)) as T; + } + + /** + * Determines whether the provided address is contained within this subnet. + * + * @param address IP address to check. + */ + public contains(address: T): boolean { + return address instanceof this._AddressClass && + (address.value & this.netmask()) === this.address.value; + } + + /** + * Determines whether the provided subnet is fully contained within this subnet. + * + * @param subnet Subnet to check. + */ + public containsSubnet(subnet: Subnet): boolean { + return this.prefix <= subnet.prefix && this.contains(subnet.address); + } + + /** + * Returns the exact number of addresses in this subnet. + */ + public size(): bigint { + return this.wildcard() + 1n; + } + + /** + * Iterates all IP addresses in this subnet. + * + * **NOTE**: This can be slow for large subnets. + */ + public *addresses(): IterableIterator { + const end = this.address.value + this.size(); + for (let i = this.address.value; i < end; ++i) { + yield new this._AddressClass(i) as T; } - - /** - * Determines whether the provided subnet is fully contained within this subnet. - * - * @param subnet Subnet to check. - */ - public containsSubnet(subnet: Subnet): boolean { - return this.prefix <= subnet.prefix && this.contains(subnet.address); + } + + /** + * Iterates all IP addresses in this subnet. + * + * **NOTE**: This can be slow for large subnets. + */ + public [Symbol.iterator](): Iterator { + return this.addresses(); + } + + /** + * Creates a set containing all IP addresses in this subnet. + * + * **NOTE**: This can be slow for large subnets. + */ + public set(): Set { + return new Set(this.addresses()); + } + + /** + * Returns the string representation of this subnet in CIDR notation. + * + * @example "203.0.113.0/24" + */ + public toString(): string { + return this.address.toString() + "/" + this.prefix; + } + + /** + * Checks if this subnet is adjacent to another subnet. + * + * @param subnet Subnet to check. + * @throws {@link !TypeError} If the provided subnet is not of the same family. + */ + public isAdjacent(subnet: Subnet): boolean { + if (this._AddressClass !== subnet._AddressClass) { + throw new TypeError( + `Expected ${this._AddressClass.name} subnet, but got ${subnet._AddressClass.name}.`, + ); } - /** - * Returns the exact number of addresses in this subnet. - */ - public size(): bigint { - return this.wildcard() + 1n; + return (this.address.value + this.size() === subnet.address.value) || + (subnet.address.value + subnet.size() === this.address.value); + } + + /** + * Checks whether another subnet can be merged with this subnet. + * + * @param subnet Subnet to check. + */ + public canMerge(subnet: Subnet): boolean { + if (this.prefix !== subnet.prefix) { + return false; } - - /** - * Iterates all IP addresses in this subnet. - * - * **NOTE**: This can be slow for large subnets. - */ - public* addresses(): IterableIterator { - const end = this.address.value + this.size(); - for (let i = this.address.value; i < end; ++i) - yield new this._AddressClass(i) as T; + try { + return this.isAdjacent(subnet); + } catch { + return false; } - - /** - * Iterates all IP addresses in this subnet. - * - * **NOTE**: This can be slow for large subnets. - */ - public [Symbol.iterator](): Iterator { - return this.addresses(); + } + + /** + * Creates a larger subnet by merging with an adjacent subnet of the same family and size. + * + * @param subnet Subnet to merge with. + * @throws {@link !TypeError} If the subnet is not of the same family or size. + * @throws {@link !RangeError} If the subnet is not adjacent to this subnet. + */ + public merge(subnet: Subnet): Subnet { + if (!this.isAdjacent(subnet)) { + throw new RangeError( + `${subnet.toString()} is not adjacent to ${this.toString()}.`, + ); } - - /** - * Creates a set containing all IP addresses in this subnet. - * - * **NOTE**: This can be slow for large subnets. - */ - public set(): Set { - return new Set(this.addresses()); + if (this.prefix !== subnet.prefix) { + throw new TypeError( + `Expected /${this.prefix} subnet, but got /${subnet.prefix}.`, + ); } - /** - * Returns the string representation of this subnet in CIDR notation. - * - * @example "203.0.113.0/24" - */ - public toString(): string { - return this.address.toString() + "/" + this.prefix; + return new Subnet( + this.address.value < subnet.address.value ? this.address : subnet.address, + this.prefix - 1, + ); + } + + /** + * Splits this subnet into as many subnets of the specified prefix length as possible. + * + * @param prefix Prefix length of the resulting subnets. + * @throws {@link !RangeError} If the prefix is smaller than the current prefix, or over the IP address + * family bit length. + */ + public split(prefix: number): Subnet[] { + if (prefix < this.prefix || prefix > this._AddressClass.BIT_LENGTH) { + throw new RangeError( + `Expected prefix in the range [${ + this.prefix + 1 + }, ${this._AddressClass.BIT_LENGTH}], got ${prefix}.`, + ); } - /** - * Checks if this subnet is adjacent to another subnet. - * - * @param subnet Subnet to check. - * @throws {@link !TypeError} If the provided subnet is not of the same family. - */ - public isAdjacent(subnet: Subnet): boolean { - if (this._AddressClass !== subnet._AddressClass) - throw new TypeError(`Expected ${this._AddressClass.name} subnet, but got ${subnet._AddressClass.name}.`); - - return (this.address.value + this.size() === subnet.address.value) - || (subnet.address.value + subnet.size() === this.address.value); + const length = 1 << (prefix - this.prefix); + const size = this.size() / BigInt(length); + + return Array.from({ length }, (_, i) => + new Subnet( + new this._AddressClass(this.address.value + BigInt(i) * size) as T, + prefix, + )); + } + + /** + * Subtracts a subnet from this subnet. + * + * @param subnet Subnet to exclude. + * @throws {@link !TypeError} If the subnet is not of the same family. + * @returns An array of subnets representing the portions of this subnet that do not overlap with the given subnet. + * Returns an empty array if the given subnet fully covers this subnet. + */ + public subtract(subnet: Subnet): Subnet[] { + if (this._AddressClass !== subnet._AddressClass) { + throw new TypeError( + `Expected ${this._AddressClass.name} subnet, but got ${subnet._AddressClass.name}.`, + ); } - /** - * Checks whether another subnet can be merged with this subnet. - * - * @param subnet Subnet to check. - */ - public canMerge(subnet: Subnet): boolean { - if (this.prefix !== subnet.prefix) - return false; - try { - return this.isAdjacent(subnet); - } - catch { - return false; - } + if (subnet.containsSubnet(this)) { + return []; } - /** - * Creates a larger subnet by merging with an adjacent subnet of the same family and size. - * - * @param subnet Subnet to merge with. - * @throws {@link !TypeError} If the subnet is not of the same family or size. - * @throws {@link !RangeError} If the subnet is not adjacent to this subnet. - */ - public merge(subnet: Subnet): Subnet { - if (!this.isAdjacent(subnet)) - throw new RangeError(`${subnet.toString()} is not adjacent to ${this.toString()}.`); - if (this.prefix !== subnet.prefix) - throw new TypeError(`Expected /${this.prefix} subnet, but got /${subnet.prefix}.`); - - return new Subnet( - this.address.value < subnet.address.value - ? this.address - : subnet.address, - this.prefix - 1, - ); + if (!this.containsSubnet(subnet)) { + return [this]; } - /** - * Splits this subnet into as many subnets of the specified prefix length as possible. - * - * @param prefix Prefix length of the resulting subnets. - * @throws {@link !RangeError} If the prefix is smaller than the current prefix, or over the IP address - * family bit length. - */ - public split(prefix: number): Subnet[] { - if (prefix < this.prefix || prefix > this._AddressClass.BIT_LENGTH) - throw new RangeError( - `Expected prefix in the range [${this.prefix + 1}, ${this._AddressClass.BIT_LENGTH}], got ${prefix}.`, - ); - - const length = 1 << (prefix - this.prefix); - const size = this.size() / BigInt(length); - - return Array.from({length}, (_, i) => new Subnet( - new this._AddressClass(this.address.value + BigInt(i) * size) as T, - prefix, - )); - } - - /** - * Subtracts a subnet from this subnet. - * - * @param subnet Subnet to exclude. - * @throws {@link !TypeError} If the subnet is not of the same family. - * @returns An array of subnets representing the portions of this subnet that do not overlap with the given subnet. - * Returns an empty array if the given subnet fully covers this subnet. - */ - public subtract(subnet: Subnet): Subnet[] { - if (this._AddressClass !== subnet._AddressClass) - throw new TypeError(`Expected ${this._AddressClass.name} subnet, but got ${subnet._AddressClass.name}.`); - - if (subnet.containsSubnet(this)) - return []; - - if (!this.containsSubnet(subnet)) - return [this]; + const result: Subnet[] = []; - const result: Subnet[] = []; - - if (this.address.value < subnet.address.value) - result.push(...Subnet.range(this.address, subnet.address.offset(-1) as T)); - - if (subnet.at(-1).value < this.at(-1).value) - result.push(...Subnet.range(subnet.at(-1).offset(1) as T, this.at(-1))); - - return result; + if (this.address.value < subnet.address.value) { + result.push( + ...Subnet.range(this.address, subnet.address.offset(-1) as T), + ); } - /** - * Checks if the given subnets are equal. - * - * @param subnet Subnet to compare. - */ - public equals(subnet: Subnet): boolean { - return this._AddressClass === subnet._AddressClass - && this.address.equals(subnet.address) - && this.prefix === subnet.prefix; + if (subnet.at(-1).value < this.at(-1).value) { + result.push(...Subnet.range(subnet.at(-1).offset(1) as T, this.at(-1))); } + + return result; + } + + /** + * Checks if the given subnets are equal. + * + * @param subnet Subnet to compare. + */ + public equals(subnet: Subnet): boolean { + return this._AddressClass === subnet._AddressClass && + this.address.equals(subnet.address) && + this.prefix === subnet.prefix; + } } diff --git a/src/SubnetList.ts b/src/SubnetList.ts index 85452e8..9b94e33 100644 --- a/src/SubnetList.ts +++ b/src/SubnetList.ts @@ -14,195 +14,201 @@ * You should have received a copy of the GNU Lesser General Public License along with @cldn/ip. * If not, see . */ -import {IP, IPAddress, Network, Subnet} from "./index.js"; +import { IP, IPAddress, Network, Subnet } from "./index.js"; /** * Represents a network comprised of subnets. */ export class SubnetList implements Network { - /** - * A network of reserved subnets. This network does not contain publicly routable IP addresses. - */ - public static readonly BOGON = new SubnetList([ - // IPv4 - Subnet.fromCIDR("0.0.0.0/8"), - Subnet.fromCIDR("10.0.0.0/8"), - Subnet.fromCIDR("100.64.0.0/10"), - Subnet.fromCIDR("127.0.0.0/8"), - Subnet.fromCIDR("169.254.0.0/16"), - Subnet.fromCIDR("172.16.0.0/12"), - Subnet.fromCIDR("192.0.0.0/24"), - Subnet.fromCIDR("192.0.2.0/24"), - Subnet.fromCIDR("192.88.99.0/24"), - Subnet.fromCIDR("192.168.0.0/16"), - Subnet.fromCIDR("198.18.0.0/15"), - Subnet.fromCIDR("198.51.100.0/24"), - Subnet.fromCIDR("203.0.113.0/24"), - Subnet.fromCIDR("224.0.0.0/4"), - Subnet.fromCIDR("233.252.0.0/24"), - Subnet.fromCIDR("240.0.0.0/4"), - Subnet.fromCIDR("255.255.255.255/32"), - // IPv6 - Subnet.fromCIDR("::/128"), - Subnet.fromCIDR("::1/128"), - Subnet.fromCIDR("64:ff9b:1::/48"), - Subnet.fromCIDR("100::/64"), - Subnet.fromCIDR("2001:20::/28"), - Subnet.fromCIDR("2001:db8::/32"), - Subnet.fromCIDR("3fff::/20"), - Subnet.fromCIDR("5f00::/16"), - Subnet.fromCIDR("fc00::/7"), - Subnet.fromCIDR("fe80::/10"), - ]); - - readonly #subnets: Subnet[] = []; - - /** - * Creates a new network. - * - * @param [subnets] Initial subnets to add to this network. - */ - public constructor(subnets?: Iterable>) { - if (subnets) - for (const subnet of subnets) - this.add(subnet); + /** + * A network of reserved subnets. This network does not contain publicly routable IP addresses. + */ + public static readonly BOGON = new SubnetList([ + // IPv4 + Subnet.fromCIDR("0.0.0.0/8"), + Subnet.fromCIDR("10.0.0.0/8"), + Subnet.fromCIDR("100.64.0.0/10"), + Subnet.fromCIDR("127.0.0.0/8"), + Subnet.fromCIDR("169.254.0.0/16"), + Subnet.fromCIDR("172.16.0.0/12"), + Subnet.fromCIDR("192.0.0.0/24"), + Subnet.fromCIDR("192.0.2.0/24"), + Subnet.fromCIDR("192.88.99.0/24"), + Subnet.fromCIDR("192.168.0.0/16"), + Subnet.fromCIDR("198.18.0.0/15"), + Subnet.fromCIDR("198.51.100.0/24"), + Subnet.fromCIDR("203.0.113.0/24"), + Subnet.fromCIDR("224.0.0.0/4"), + Subnet.fromCIDR("233.252.0.0/24"), + Subnet.fromCIDR("240.0.0.0/4"), + Subnet.fromCIDR("255.255.255.255/32"), + // IPv6 + Subnet.fromCIDR("::/128"), + Subnet.fromCIDR("::1/128"), + Subnet.fromCIDR("64:ff9b:1::/48"), + Subnet.fromCIDR("100::/64"), + Subnet.fromCIDR("2001:20::/28"), + Subnet.fromCIDR("2001:db8::/32"), + Subnet.fromCIDR("3fff::/20"), + Subnet.fromCIDR("5f00::/16"), + Subnet.fromCIDR("fc00::/7"), + Subnet.fromCIDR("fe80::/10"), + ]); + + readonly #subnets: Subnet[] = []; + + /** + * Creates a new network. + * + * @param [subnets] Initial subnets to add to this network. + */ + public constructor(subnets?: Iterable>) { + if (subnets) { + for (const subnet of subnets) { + this.add(subnet); + } } + } - public* [Symbol.iterator](): Iterator { - for (const subnet of this.#subnets.values()) - for (const address of subnet) - yield address; + public *[Symbol.iterator](): Iterator { + for (const subnet of this.#subnets.values()) { + for (const address of subnet) { + yield address; + } } - - /** - * Adds a subnet to this network. - * - * @param subnet Subnet to add. - * @returns Whether the subnet was not already fully in the network. - */ - public add(subnet: Subnet): boolean; - - /** - * Adds an IP address to this network. - * - * @param ip IP address to add. - * @returns Whether the IP address was not already in the network. - */ - public add(ip: IP): boolean; - - /** - * Adds a subnet list to this network. - * - * @param subnets Network of subnets to add. - */ - public add(subnets: SubnetList): void; - - public add(a: Subnet | IP | SubnetList): boolean | void { - if (a instanceof Subnet) { - if (this.hasSubnet(a)) return false; - - const subSubnet = this.#subnets.findIndex(s => a.containsSubnet(s)); - if (subSubnet !== -1) { - this.#subnets.splice(subSubnet, 1, a); - this.optimise(); - return true; - } - - this.#subnets.push(a); - this.optimise(); - return true; - } - else if (a instanceof IPAddress) { - const Class = a.constructor as IP.Class; - return this.add(new Subnet(a, Class.BIT_LENGTH)); - } - else { - for (const subnet of a.#subnets) - this.add(subnet); - this.optimise(); - return; - } - } - - /** - * Removes a subnet from this network. - * - * @param subnet Subnet to remove. - * @returns Whether the subnet was found and removed. - */ - public remove(subnet: Subnet) { - for (const [index, s] of this.#subnets.entries()) { - if (s.equals(subnet)) { - this.#subnets.splice(index, 1); - return true; - } - if (s.containsSubnet(subnet)) { - this.#subnets.splice(index, 1, ...s.subtract(subnet)); - return true; - } - } - return false; + } + + /** + * Adds a subnet to this network. + * + * @param subnet Subnet to add. + * @returns Whether the subnet was not already fully in the network. + */ + public add(subnet: Subnet): boolean; + + /** + * Adds an IP address to this network. + * + * @param ip IP address to add. + * @returns Whether the IP address was not already in the network. + */ + public add(ip: IP): boolean; + + /** + * Adds a subnet list to this network. + * + * @param subnets Network of subnets to add. + */ + public add(subnets: SubnetList): void; + + public add(a: Subnet | IP | SubnetList): boolean | void { + if (a instanceof Subnet) { + if (this.hasSubnet(a)) return false; + + const subSubnet = this.#subnets.findIndex((s) => a.containsSubnet(s)); + if (subSubnet !== -1) { + this.#subnets.splice(subSubnet, 1, a); + this.optimise(); + return true; + } + + this.#subnets.push(a); + this.optimise(); + return true; + } else if (a instanceof IPAddress) { + const Class = a.constructor as IP.Class; + return this.add(new Subnet(a, Class.BIT_LENGTH)); + } else { + for (const subnet of a.#subnets) { + this.add(subnet); + } + this.optimise(); + return; } - - /** - * Checks if a subnet is in this network. - * - * @param subnet Subnet to check. - */ - public hasSubnet(subnet: Subnet): boolean { - return this.#subnets.some(s => s.containsSubnet(subnet)); + } + + /** + * Removes a subnet from this network. + * + * @param subnet Subnet to remove. + * @returns Whether the subnet was found and removed. + */ + public remove(subnet: Subnet) { + for (const [index, s] of this.#subnets.entries()) { + if (s.equals(subnet)) { + this.#subnets.splice(index, 1); + return true; + } + if (s.containsSubnet(subnet)) { + this.#subnets.splice(index, 1, ...s.subtract(subnet)); + return true; + } } - - /** - * Returns all subnets in this network. - */ - public subnets(): Subnet[] { - return Array.from(this.#subnets); - } - - /** - * Checks if an IP address is in this network. - * - * @param address IP address to check. - */ - public contains(address: IP): boolean { - for (const subnet of this.#subnets.values()) if (subnet.contains(address)) return true; - return false; + return false; + } + + /** + * Checks if a subnet is in this network. + * + * @param subnet Subnet to check. + */ + public hasSubnet(subnet: Subnet): boolean { + return this.#subnets.some((s) => s.containsSubnet(subnet)); + } + + /** + * Returns all subnets in this network. + */ + public subnets(): Subnet[] { + return Array.from(this.#subnets); + } + + /** + * Checks if an IP address is in this network. + * + * @param address IP address to check. + */ + public contains(address: IP): boolean { + for (const subnet of this.#subnets.values()) { + if (subnet.contains(address)) return true; } - - /** - * Returns the number of addresses in this network. - */ - public size(): bigint { - let size = 0n; - for (const subnet of this.#subnets.values()) size += subnet.size(); - return size; + return false; + } + + /** + * Returns the number of addresses in this network. + */ + public size(): bigint { + let size = 0n; + for (const subnet of this.#subnets.values()) size += subnet.size(); + return size; + } + + /** + * Optimises memory by merging adjacent subnets. + */ + private optimise(): void { + this.#subnets.sort((a, b) => + a._AddressClass.BIT_LENGTH - b._AddressClass.BIT_LENGTH || + b.prefix - a.prefix || + (a.address.value < b.address.value ? -1 : 1) + ); + + let merged = false; + + for (let i = 0; i + 1 < this.#subnets.length;) { + const left = this.#subnets.at(i)!; + const right = this.#subnets.at(i + 1)!; + + if (!left.canMerge(right)) { + ++i; + continue; + } + + merged = true; + this.#subnets.splice(i, 2, left.merge(right)); } - /** - * Optimises memory by merging adjacent subnets. - */ - private optimise(): void { - this.#subnets.sort((a, b) => a._AddressClass.BIT_LENGTH - b._AddressClass.BIT_LENGTH - || b.prefix - a.prefix - || (a.address.value < b.address.value ? -1 : 1), - ); - - let merged = false; - - for (let i = 0; i + 1 < this.#subnets.length;) { - const left = this.#subnets.at(i)!; - const right = this.#subnets.at(i + 1)!; - - if (!left.canMerge(right)) { - ++i; - continue; - } - - merged = true; - this.#subnets.splice(i, 2, left.merge(right)); - } - - if (merged) this.optimise(); - } + if (merged) this.optimise(); + } } diff --git a/src/index.ts b/src/index.ts index f39bb48..6e9e183 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,10 @@ * If not, see . */ -export type {IP} from "./IP.js"; -export {IPAddress} from "./IPAddress.js"; -export {IPv4} from "./IPv4.js"; -export {IPv6} from "./IPv6.js"; -export type {Network} from "./Network.js"; -export {Subnet} from "./Subnet.js"; -export {SubnetList} from "./SubnetList.js"; +export type { IP } from "./IP.js"; +export { IPAddress } from "./IPAddress.js"; +export { IPv4 } from "./IPv4.js"; +export { IPv6 } from "./IPv6.js"; +export type { Network } from "./Network.js"; +export { Subnet } from "./Subnet.js"; +export { SubnetList } from "./SubnetList.js"; diff --git a/tests/IPAddress.test.ts b/tests/IPAddress.test.ts index 5fb55b0..094b6a8 100644 --- a/tests/IPAddress.test.ts +++ b/tests/IPAddress.test.ts @@ -1,49 +1,49 @@ -import {describe, expect, it} from "vitest"; -import {IPAddress, IPv4, IPv6} from "../src/index.js"; +import { describe, expect, it } from "vitest"; +import { IPAddress, IPv4, IPv6 } from "../src/index.js"; describe("IPAddress", () => { - describe("static fromString", () => { - it("creates IPv4 from IPv4 string", () => { - const ip = IPAddress.fromString("192.0.2.0"); - expect(ip).toBeInstanceOf(IPv4); - expect(ip.toString()).toBe("192.0.2.0"); - }); + describe("static fromString", () => { + it("creates IPv4 from IPv4 string", () => { + const ip = IPAddress.fromString("192.0.2.0"); + expect(ip).toBeInstanceOf(IPv4); + expect(ip.toString()).toBe("192.0.2.0"); + }); - it("creates IPv6 from IPv6 string", () => { - const ip = IPAddress.fromString("2001:db8::"); - expect(ip).toBeInstanceOf(IPv6); - expect(ip.toString()).toBe("2001:db8::"); - }); + it("creates IPv6 from IPv6 string", () => { + const ip = IPAddress.fromString("2001:db8::"); + expect(ip).toBeInstanceOf(IPv6); + expect(ip.toString()).toBe("2001:db8::"); + }); - it("creates IPv4 from IPv4 string with resolveMapped=false", () => { - const ip = IPAddress.fromString("192.0.2.0"); - expect(ip).toBeInstanceOf(IPv4); - expect(ip.toString()).toBe("192.0.2.0"); - }); + it("creates IPv4 from IPv4 string with resolveMapped=false", () => { + const ip = IPAddress.fromString("192.0.2.0"); + expect(ip).toBeInstanceOf(IPv4); + expect(ip.toString()).toBe("192.0.2.0"); + }); - it("creates IPv6 from IPv6 string with resolveMapped=true", () => { - const ip = IPAddress.fromString("2001:db8::", true); - expect(ip).toBeInstanceOf(IPv6); - expect(ip.toString()).toBe("2001:db8::"); - }); + it("creates IPv6 from IPv6 string with resolveMapped=true", () => { + const ip = IPAddress.fromString("2001:db8::", true); + expect(ip).toBeInstanceOf(IPv6); + expect(ip.toString()).toBe("2001:db8::"); + }); - it("creates IPv4 from IPv4-mapped IPv6 string with resolveMapped=true", () => { - const ip = IPAddress.fromString("::ffff:192.0.2.0", true); - expect(ip).toBeInstanceOf(IPv4); - expect(ip.toString()).toBe("192.0.2.0"); - }); + it("creates IPv4 from IPv4-mapped IPv6 string with resolveMapped=true", () => { + const ip = IPAddress.fromString("::ffff:192.0.2.0", true); + expect(ip).toBeInstanceOf(IPv4); + expect(ip.toString()).toBe("192.0.2.0"); + }); - it("creates IPv6 from IPv4-mapped IPv6 string with resolveMapped=false", () => { - const ip = IPAddress.fromString("::ffff:192.0.2.0"); - expect(ip).toBeInstanceOf(IPv6); - expect(ip.toString()).toBe("::ffff:c000:200"); - }); + it("creates IPv6 from IPv4-mapped IPv6 string with resolveMapped=false", () => { + const ip = IPAddress.fromString("::ffff:192.0.2.0"); + expect(ip).toBeInstanceOf(IPv6); + expect(ip.toString()).toBe("::ffff:c000:200"); + }); - it("throws RangeError for invalid string", () => { - expect(() => IPAddress.fromString("not an ip")).toThrow(RangeError); - expect(() => IPAddress.fromString("invalid:ip")).toThrow(RangeError); - expect(() => IPAddress.fromString("not.an.ip")).toThrow(RangeError); - expect(() => IPAddress.fromString("")).toThrow(RangeError); - }); + it("throws RangeError for invalid string", () => { + expect(() => IPAddress.fromString("not an ip")).toThrow(RangeError); + expect(() => IPAddress.fromString("invalid:ip")).toThrow(RangeError); + expect(() => IPAddress.fromString("not.an.ip")).toThrow(RangeError); + expect(() => IPAddress.fromString("")).toThrow(RangeError); }); + }); }); diff --git a/tests/IPv4.test.ts b/tests/IPv4.test.ts index 9794135..7732141 100644 --- a/tests/IPv4.test.ts +++ b/tests/IPv4.test.ts @@ -1,150 +1,154 @@ -import {describe, expect, it} from "vitest"; -import {IPv4, IPv6} from "../src/index.js"; +import { describe, expect, it } from "vitest"; +import { IPv4, IPv6 } from "../src/index.js"; describe("IPv4", () => { - describe("static BIT_LENGTH", () => { - it("is 32", () => { - expect(IPv4.BIT_LENGTH).toBe(32); - }); - }); - - describe("constructor", () => { - it("constructs from a valid number", () => { - const ip = new IPv4(0xC0000200); - expect(ip.value).toBe(0xC0000200n); - expect(ip.toString()).toBe("192.0.2.0"); - }); - - it("constructs from a valid bigint", () => { - const ip = new IPv4(0xC6336410n); - expect(ip.value).toBe(0xC6336410n); - expect(ip.toString()).toBe("198.51.100.16"); - }); - - it("constructs from 0", () => { - const ip = new IPv4(0); - expect(ip.value).toBe(0n); - expect(ip.toString()).toBe("0.0.0.0"); - }); - - it("constructs from 2^32 - 1", () => { - const ip = new IPv4(2 ** 32 - 1); - expect(ip.value).toBe(2n ** 32n - 1n); - expect(ip.toString()).toBe("255.255.255.255"); - }); - - it("throws TypeError for value < 0", () => { - expect(() => new IPv4(-1)).toThrow(TypeError); - expect(() => new IPv4(-1n)).toThrow(TypeError); - }); - - it("throws TypeError for value > 2^32 - 1", () => { - expect(() => new IPv4(2 ** 32)).toThrow(TypeError); - expect(() => new IPv4(2n ** 32n)).toThrow(TypeError); - }); - }); - - describe("static fromBinary", () => { - it("creates IPv4 from 4-octet array", () => { - const ip = IPv4.fromBinary(new Uint8Array([192, 0, 2, 0])); - expect(ip.toString()).toBe("192.0.2.0"); - }); - - it("throws RangeError if array has less than 4 octets", () => { - expect(() => IPv4.fromBinary(new Uint8Array([192, 0, 2]))).toThrow(RangeError); - }); - - it("throws RangeError if array has more than 4 octets", () => { - expect(() => IPv4.fromBinary(new Uint8Array([192, 0, 2, 1, 0]))).toThrow(RangeError); - }); - }); - - describe("static fromString", () => { - it("creates IPv4 from valid string", () => { - const ip = IPv4.fromString("203.0.113.5"); - expect(ip.value).toBe(0xCB007105n); - }); - - it("throws RangeError for invalid IP string", () => { - expect(() => IPv4.fromString("256.0.0.0")).toThrow(RangeError); - expect(() => IPv4.fromString("10")).toThrow(RangeError); - expect(() => IPv4.fromString("invalid.ip.string")).toThrow(RangeError); - }); - }); - - describe("binary", () => { - it("returns correct 4-octet array", () => { - const ip = IPv4.fromString("192.0.2.5"); - expect(Array.from(ip.binary())).toEqual([192, 0, 2, 5]); - }); - }); - - describe("[Symbol.toPrimitive]", () => { - it("returns dotted-decimal string when hint is string", () => { - const ip = IPv4.fromString("192.0.2.0"); - expect(`${ip}`).toBe("192.0.2.0"); - }); - - it("returns number when hint is not string", () => { - const ip = IPv4.fromString("192.0.2.0"); - expect(1n + (ip as unknown as bigint)).toBe(0xC0000201n); - expect(BigInt(ip as unknown as bigint)).toBe(0xC0000200n); - }); - }); - - describe("toString", () => { - it("returns dotted-decimal string", () => { - const ip = IPv4.fromString("203.0.113.42"); - expect(ip.toString()).toBe("203.0.113.42"); - }); - }); - - describe("equals", () => { - it("returns true for two IPv4 instances with the same address", () => { - const a = IPv4.fromString("198.51.100.10"); - const b = IPv4.fromString("198.51.100.10"); - expect(a.equals(b)).toBe(true); - }); - - it("returns false for two IPv4 instances with different addresses", () => { - const a = IPv4.fromString("198.51.100.10"); - const b = IPv4.fromString("198.51.100.11"); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when compared with a different IPAddress subclass", () => { - const a = IPv4.fromString("198.51.100.10"); - const b = IPv6.fromString("::c633:640a"); - - expect(a.equals(b)).toBe(false); - }); - }); - - describe("offset", () => { - it("offsets positively", () => { - const ip = IPv4.fromString("192.0.2.1"); - expect(ip.offset(1).toString()).toBe("192.0.2.2"); - expect(ip.offset(1n).toString()).toBe("192.0.2.2"); - expect(ip.offset(104030719).toString()).toBe("198.51.100.0"); - expect(ip.offset(104030719n).toString()).toBe("198.51.100.0"); - }); - - it("offsets negatively", () => { - const ip = IPv4.fromString("203.0.113.1"); - expect(ip.offset(-1).toString()).toBe("203.0.113.0"); - expect(ip.offset(-1n).toString()).toBe("203.0.113.0"); - expect(ip.offset(-80547073).toString()).toBe("198.51.100.0"); - expect(ip.offset(-80547073n).toString()).toBe("198.51.100.0"); - }); - - it("throws TypeError if offset IP < 0", () => { - const ip = IPv4.fromString("192.0.2.0"); - expect(() => ip.offset(-3221225985)).toThrow(TypeError); - }); - - it("throws TypeError if offset IP > 2^32 - 1", () => { - const ip = IPv4.fromString("203.0.113.0"); - expect(() => ip.offset(889163520)).toThrow(TypeError); - }); + describe("static BIT_LENGTH", () => { + it("is 32", () => { + expect(IPv4.BIT_LENGTH).toBe(32); }); + }); + + describe("constructor", () => { + it("constructs from a valid number", () => { + const ip = new IPv4(0xC0000200); + expect(ip.value).toBe(0xC0000200n); + expect(ip.toString()).toBe("192.0.2.0"); + }); + + it("constructs from a valid bigint", () => { + const ip = new IPv4(0xC6336410n); + expect(ip.value).toBe(0xC6336410n); + expect(ip.toString()).toBe("198.51.100.16"); + }); + + it("constructs from 0", () => { + const ip = new IPv4(0); + expect(ip.value).toBe(0n); + expect(ip.toString()).toBe("0.0.0.0"); + }); + + it("constructs from 2^32 - 1", () => { + const ip = new IPv4(2 ** 32 - 1); + expect(ip.value).toBe(2n ** 32n - 1n); + expect(ip.toString()).toBe("255.255.255.255"); + }); + + it("throws TypeError for value < 0", () => { + expect(() => new IPv4(-1)).toThrow(TypeError); + expect(() => new IPv4(-1n)).toThrow(TypeError); + }); + + it("throws TypeError for value > 2^32 - 1", () => { + expect(() => new IPv4(2 ** 32)).toThrow(TypeError); + expect(() => new IPv4(2n ** 32n)).toThrow(TypeError); + }); + }); + + describe("static fromBinary", () => { + it("creates IPv4 from 4-octet array", () => { + const ip = IPv4.fromBinary(new Uint8Array([192, 0, 2, 0])); + expect(ip.toString()).toBe("192.0.2.0"); + }); + + it("throws RangeError if array has less than 4 octets", () => { + expect(() => IPv4.fromBinary(new Uint8Array([192, 0, 2]))).toThrow( + RangeError, + ); + }); + + it("throws RangeError if array has more than 4 octets", () => { + expect(() => IPv4.fromBinary(new Uint8Array([192, 0, 2, 1, 0]))).toThrow( + RangeError, + ); + }); + }); + + describe("static fromString", () => { + it("creates IPv4 from valid string", () => { + const ip = IPv4.fromString("203.0.113.5"); + expect(ip.value).toBe(0xCB007105n); + }); + + it("throws RangeError for invalid IP string", () => { + expect(() => IPv4.fromString("256.0.0.0")).toThrow(RangeError); + expect(() => IPv4.fromString("10")).toThrow(RangeError); + expect(() => IPv4.fromString("invalid.ip.string")).toThrow(RangeError); + }); + }); + + describe("binary", () => { + it("returns correct 4-octet array", () => { + const ip = IPv4.fromString("192.0.2.5"); + expect(Array.from(ip.binary())).toEqual([192, 0, 2, 5]); + }); + }); + + describe("[Symbol.toPrimitive]", () => { + it("returns dotted-decimal string when hint is string", () => { + const ip = IPv4.fromString("192.0.2.0"); + expect(`${ip}`).toBe("192.0.2.0"); + }); + + it("returns number when hint is not string", () => { + const ip = IPv4.fromString("192.0.2.0"); + expect(1n + (ip as unknown as bigint)).toBe(0xC0000201n); + expect(BigInt(ip as unknown as bigint)).toBe(0xC0000200n); + }); + }); + + describe("toString", () => { + it("returns dotted-decimal string", () => { + const ip = IPv4.fromString("203.0.113.42"); + expect(ip.toString()).toBe("203.0.113.42"); + }); + }); + + describe("equals", () => { + it("returns true for two IPv4 instances with the same address", () => { + const a = IPv4.fromString("198.51.100.10"); + const b = IPv4.fromString("198.51.100.10"); + expect(a.equals(b)).toBe(true); + }); + + it("returns false for two IPv4 instances with different addresses", () => { + const a = IPv4.fromString("198.51.100.10"); + const b = IPv4.fromString("198.51.100.11"); + expect(a.equals(b)).toBe(false); + }); + + it("returns false when compared with a different IPAddress subclass", () => { + const a = IPv4.fromString("198.51.100.10"); + const b = IPv6.fromString("::c633:640a"); + + expect(a.equals(b)).toBe(false); + }); + }); + + describe("offset", () => { + it("offsets positively", () => { + const ip = IPv4.fromString("192.0.2.1"); + expect(ip.offset(1).toString()).toBe("192.0.2.2"); + expect(ip.offset(1n).toString()).toBe("192.0.2.2"); + expect(ip.offset(104030719).toString()).toBe("198.51.100.0"); + expect(ip.offset(104030719n).toString()).toBe("198.51.100.0"); + }); + + it("offsets negatively", () => { + const ip = IPv4.fromString("203.0.113.1"); + expect(ip.offset(-1).toString()).toBe("203.0.113.0"); + expect(ip.offset(-1n).toString()).toBe("203.0.113.0"); + expect(ip.offset(-80547073).toString()).toBe("198.51.100.0"); + expect(ip.offset(-80547073n).toString()).toBe("198.51.100.0"); + }); + + it("throws TypeError if offset IP < 0", () => { + const ip = IPv4.fromString("192.0.2.0"); + expect(() => ip.offset(-3221225985)).toThrow(TypeError); + }); + + it("throws TypeError if offset IP > 2^32 - 1", () => { + const ip = IPv4.fromString("203.0.113.0"); + expect(() => ip.offset(889163520)).toThrow(TypeError); + }); + }); }); diff --git a/tests/IPv6.test.ts b/tests/IPv6.test.ts index 29c8a3f..db8c881 100644 --- a/tests/IPv6.test.ts +++ b/tests/IPv6.test.ts @@ -1,174 +1,215 @@ -import {describe, expect, it} from "vitest"; -import {IPv4, IPv6} from "../src/index.js"; +import { describe, expect, it } from "vitest"; +import { IPv4, IPv6 } from "../src/index.js"; describe("IPv6", () => { - describe("static BIT_LENGTH", () => { - it("is 128", () => { - expect(IPv6.BIT_LENGTH).toBe(128); - }); - }); - - describe("constructor", () => { - it("constructs from a valid 128-bit bigint", () => { - const ip = new IPv6(0x20010db8000000000000000000000001n); - expect(ip.value).toBe(0x20010db8000000000000000000000001n); - expect(ip.toString()).toBe("2001:db8::1"); - }); - - it("throws TypeError if value is negative", () => { - expect(() => new IPv6(-1n)).toThrow(TypeError); - }); - - it("throws TypeError if value > 2^128 - 1", () => { - expect(() => new IPv6(2n ** 128n)).toThrow(TypeError); - }); - }); - - describe("static fromBinary", () => { - it("creates IPv6 from 8 hextets", () => { - const ip = IPv6.fromBinary(new Uint16Array([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1])); - expect(ip.toString()).toBe("2001:db8::1"); - }); - - it("throws RangeError if array has less than 8 hextets", () => { - expect(() => IPv6.fromBinary(new Uint16Array([0x2001, 0xdb8, 0, 0, 0, 0, 0x1]))).toThrow(RangeError); - }); - - it("throws RangeError if array has more than 8 hextets", () => { - expect(() => IPv6.fromBinary(new Uint16Array([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0, 0x1]))) - .toThrow(RangeError); - }); - }); - - describe("static fromString", () => { - it("creates IPv6 from valid compressed string", () => { - const ip = IPv6.fromString("2001:db8::5"); - expect(ip.toString()).toBe("2001:db8::5"); - }); - - it("creates IPv6 from valid full string", () => { - const ip = IPv6.fromString("2001:0db8:0000:0000:0000:0000:0000:0001"); - expect(ip.toString()).toBe("2001:db8::1"); - }); - - it("throws RangeError for invalid IPv6 string", () => { - expect(() => IPv6.fromString("invalid::ip")).toThrow(RangeError); - expect(() => IPv6.fromString("12345::")).toThrow(RangeError); - expect(() => IPv6.fromString("2001:0db8:0000:0000:0000:0000:0000:ffff2")).toThrow(RangeError); - expect(() => IPv6.fromString("2001:0db8:0000:0000:0000:0000:0001")).toThrow(RangeError); - }); - }); - - describe("binary", () => { - it("returns correct 8-hextet array", () => { - expect(Array.from(IPv6.fromString("2001:db8::").binary())).toEqual([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0]); - expect(Array.from(IPv6.fromString("2001:db8:dead:beef::1337:cafe").binary())) - .toEqual([0x2001, 0xdb8, 0xdead, 0xbeef, 0, 0, 0x1337, 0xcafe]); - }); - }); - - describe("hasMappedIPv4", () => { - it("returns true for ::ffff:192.0.2.0", () => { - const ip = IPv6.fromString("::ffff:192.0.2.0"); - expect(ip.hasMappedIPv4()).toBe(true); - }); - - it("returns true for ::ffff:c633:642a", () => { - const ip = IPv6.fromString("::ffff:c633:642a"); - expect(ip.hasMappedIPv4()).toBe(true); - }); - - it("returns false for non-mapped IPv6 address", () => { - const ip = IPv6.fromString("2001:db8::1"); - expect(ip.hasMappedIPv4()).toBe(false); - }); - }); - - describe("getMappedIPv4", () => { - it("returns the mapped IPv4 address from ::ffff:192.0.2.0", () => { - const ip = IPv6.fromString("::ffff:192.0.2.0"); - const mapped = ip.getMappedIPv4(); - expect(mapped.toString()).toBe("192.0.2.0"); - }); - it("returns the mapped IPv4 address from ::ffff:c633:642a", () => { - const ip = IPv6.fromString("::ffff:c633:642a"); - const mapped = ip.getMappedIPv4(); - expect(mapped.toString()).toBe("198.51.100.42"); - }); - }); - - describe("toString", () => { - it("returns canonical string form", () => { - const ip = IPv6.fromString("2001:0db8:0000:0000:0000:0000:0000:0001"); - expect(ip.toString()).toBe("2001:db8::1"); - }); - - it("returns full uncompressed IPv6 string when no zeros to compress", () => { - const ip = IPv6.fromString("2001:0db8:1234:5678:9abc:def:0123:4567"); - expect(ip.toString()).toBe("2001:db8:1234:5678:9abc:def:123:4567"); - }); - - it("collapses the first longest zero group", () => { - const hextets = new Uint16Array([0x2001, 0xdb8, 0, 0, 0x1, 0, 0, 0x1]); - const ip = IPv6.fromBinary(hextets); - expect(ip.toString()).toBe("2001:db8::1:0:0:1"); - }); - - it("does not compress a single zero hextet", () => { - const hextets = new Uint16Array([0x2001, 0xdb8, 0, 0x1, 0, 0x1234, 0xabcd, 0x5678]); - const ip = IPv6.fromBinary(hextets); - expect(ip.toString()).toBe("2001:db8:0:1:0:1234:abcd:5678"); - }); - - it("returns :: for the unspecified address", () => { - const ip = new IPv6(0n); - expect(ip.toString()).toBe("::"); - }); - }); - - describe("equals", () => { - it("returns true for two equal IPv6 addresses", () => { - const a = IPv6.fromString("2001:db8::5"); - const b = IPv6.fromString("2001:db8::5"); - expect(a.equals(b)).toBe(true); - }); - - it("returns false for different IPv6 addresses", () => { - const a = IPv6.fromString("2001:db8::1"); - const b = IPv6.fromString("2001:db8::2"); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when compared with different IPAddress subclass", () => { - const a = IPv6.fromString("::cb00:71ff"); - const b = IPv4.fromString("203.0.113.255"); - expect(a.equals(b)).toBe(false); - }); - }); - - describe("offset", () => { - it("offsets positively", () => { - expect(IPv6.fromString("2001:db8::").offset(1).toString()).toBe("2001:db8::1"); - expect(IPv6.fromString("2001:db8::").offset(5634002667680789686395290983n).toString()) - .toBe("2001:db8:1234:5678:9abc:def:123:4567"); - }); - - it("offsets negatively", () => { - expect(IPv6.fromString("2001:db8::1").offset(-1).toString()).toBe("2001:db8::"); - expect(IPv6.fromString("2001:db8::cafe:babe").offset(-270593984n).toString()) - .toBe("2001:db8::badd:cafe"); - }); - - it("throws TypeError if offset IP < 0", () => { - expect(() => IPv6.fromString("::").offset(-1)).toThrow(TypeError); - expect(() => IPv6.fromString("2001:db8::cafe:babe") - .offset(-42540766411282592856903984955059518143n)).toThrow(TypeError); - }); - - it("throws TypeError if offset IP > 2^128 - 1", () => { - expect(() => new IPv6(2n ** 128n - 1n).offset(1)).toThrow(TypeError); - expect(() => IPv6.fromString("2001:db8::dead:beef") - .offset(297741600509655870606470622476378456337n)).toThrow(TypeError); - }); + describe("static BIT_LENGTH", () => { + it("is 128", () => { + expect(IPv6.BIT_LENGTH).toBe(128); }); + }); + + describe("constructor", () => { + it("constructs from a valid 128-bit bigint", () => { + const ip = new IPv6(0x20010db8000000000000000000000001n); + expect(ip.value).toBe(0x20010db8000000000000000000000001n); + expect(ip.toString()).toBe("2001:db8::1"); + }); + + it("throws TypeError if value is negative", () => { + expect(() => new IPv6(-1n)).toThrow(TypeError); + }); + + it("throws TypeError if value > 2^128 - 1", () => { + expect(() => new IPv6(2n ** 128n)).toThrow(TypeError); + }); + }); + + describe("static fromBinary", () => { + it("creates IPv6 from 8 hextets", () => { + const ip = IPv6.fromBinary( + new Uint16Array([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1]), + ); + expect(ip.toString()).toBe("2001:db8::1"); + }); + + it("throws RangeError if array has less than 8 hextets", () => { + expect(() => + IPv6.fromBinary(new Uint16Array([0x2001, 0xdb8, 0, 0, 0, 0, 0x1])) + ).toThrow(RangeError); + }); + + it("throws RangeError if array has more than 8 hextets", () => { + expect(() => + IPv6.fromBinary(new Uint16Array([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0, 0x1])) + ) + .toThrow(RangeError); + }); + }); + + describe("static fromString", () => { + it("creates IPv6 from valid compressed string", () => { + const ip = IPv6.fromString("2001:db8::5"); + expect(ip.toString()).toBe("2001:db8::5"); + }); + + it("creates IPv6 from valid full string", () => { + const ip = IPv6.fromString("2001:0db8:0000:0000:0000:0000:0000:0001"); + expect(ip.toString()).toBe("2001:db8::1"); + }); + + it("throws RangeError for invalid IPv6 string", () => { + expect(() => IPv6.fromString("invalid::ip")).toThrow(RangeError); + expect(() => IPv6.fromString("12345::")).toThrow(RangeError); + expect(() => IPv6.fromString("2001:0db8:0000:0000:0000:0000:0000:ffff2")) + .toThrow(RangeError); + expect(() => IPv6.fromString("2001:0db8:0000:0000:0000:0000:0001")) + .toThrow(RangeError); + }); + }); + + describe("binary", () => { + it("returns correct 8-hextet array", () => { + expect(Array.from(IPv6.fromString("2001:db8::").binary())).toEqual([ + 0x2001, + 0xdb8, + 0, + 0, + 0, + 0, + 0, + 0, + ]); + expect( + Array.from(IPv6.fromString("2001:db8:dead:beef::1337:cafe").binary()), + ) + .toEqual([0x2001, 0xdb8, 0xdead, 0xbeef, 0, 0, 0x1337, 0xcafe]); + }); + }); + + describe("hasMappedIPv4", () => { + it("returns true for ::ffff:192.0.2.0", () => { + const ip = IPv6.fromString("::ffff:192.0.2.0"); + expect(ip.hasMappedIPv4()).toBe(true); + }); + + it("returns true for ::ffff:c633:642a", () => { + const ip = IPv6.fromString("::ffff:c633:642a"); + expect(ip.hasMappedIPv4()).toBe(true); + }); + + it("returns false for non-mapped IPv6 address", () => { + const ip = IPv6.fromString("2001:db8::1"); + expect(ip.hasMappedIPv4()).toBe(false); + }); + }); + + describe("getMappedIPv4", () => { + it("returns the mapped IPv4 address from ::ffff:192.0.2.0", () => { + const ip = IPv6.fromString("::ffff:192.0.2.0"); + const mapped = ip.getMappedIPv4(); + expect(mapped.toString()).toBe("192.0.2.0"); + }); + it("returns the mapped IPv4 address from ::ffff:c633:642a", () => { + const ip = IPv6.fromString("::ffff:c633:642a"); + const mapped = ip.getMappedIPv4(); + expect(mapped.toString()).toBe("198.51.100.42"); + }); + }); + + describe("toString", () => { + it("returns canonical string form", () => { + const ip = IPv6.fromString("2001:0db8:0000:0000:0000:0000:0000:0001"); + expect(ip.toString()).toBe("2001:db8::1"); + }); + + it("returns full uncompressed IPv6 string when no zeros to compress", () => { + const ip = IPv6.fromString("2001:0db8:1234:5678:9abc:def:0123:4567"); + expect(ip.toString()).toBe("2001:db8:1234:5678:9abc:def:123:4567"); + }); + + it("collapses the first longest zero group", () => { + const hextets = new Uint16Array([0x2001, 0xdb8, 0, 0, 0x1, 0, 0, 0x1]); + const ip = IPv6.fromBinary(hextets); + expect(ip.toString()).toBe("2001:db8::1:0:0:1"); + }); + + it("does not compress a single zero hextet", () => { + const hextets = new Uint16Array([ + 0x2001, + 0xdb8, + 0, + 0x1, + 0, + 0x1234, + 0xabcd, + 0x5678, + ]); + const ip = IPv6.fromBinary(hextets); + expect(ip.toString()).toBe("2001:db8:0:1:0:1234:abcd:5678"); + }); + + it("returns :: for the unspecified address", () => { + const ip = new IPv6(0n); + expect(ip.toString()).toBe("::"); + }); + }); + + describe("equals", () => { + it("returns true for two equal IPv6 addresses", () => { + const a = IPv6.fromString("2001:db8::5"); + const b = IPv6.fromString("2001:db8::5"); + expect(a.equals(b)).toBe(true); + }); + + it("returns false for different IPv6 addresses", () => { + const a = IPv6.fromString("2001:db8::1"); + const b = IPv6.fromString("2001:db8::2"); + expect(a.equals(b)).toBe(false); + }); + + it("returns false when compared with different IPAddress subclass", () => { + const a = IPv6.fromString("::cb00:71ff"); + const b = IPv4.fromString("203.0.113.255"); + expect(a.equals(b)).toBe(false); + }); + }); + + describe("offset", () => { + it("offsets positively", () => { + expect(IPv6.fromString("2001:db8::").offset(1).toString()).toBe( + "2001:db8::1", + ); + expect( + IPv6.fromString("2001:db8::").offset(5634002667680789686395290983n) + .toString(), + ) + .toBe("2001:db8:1234:5678:9abc:def:123:4567"); + }); + + it("offsets negatively", () => { + expect(IPv6.fromString("2001:db8::1").offset(-1).toString()).toBe( + "2001:db8::", + ); + expect( + IPv6.fromString("2001:db8::cafe:babe").offset(-270593984n).toString(), + ) + .toBe("2001:db8::badd:cafe"); + }); + + it("throws TypeError if offset IP < 0", () => { + expect(() => IPv6.fromString("::").offset(-1)).toThrow(TypeError); + expect(() => + IPv6.fromString("2001:db8::cafe:babe") + .offset(-42540766411282592856903984955059518143n) + ).toThrow(TypeError); + }); + + it("throws TypeError if offset IP > 2^128 - 1", () => { + expect(() => new IPv6(2n ** 128n - 1n).offset(1)).toThrow(TypeError); + expect(() => + IPv6.fromString("2001:db8::dead:beef") + .offset(297741600509655870606470622476378456337n) + ).toThrow(TypeError); + }); + }); }); diff --git a/tests/Subnet.test.ts b/tests/Subnet.test.ts index abeb00b..353d1ec 100644 --- a/tests/Subnet.test.ts +++ b/tests/Subnet.test.ts @@ -1,363 +1,392 @@ -import {describe, expect, it} from "vitest"; -import {IPv4, IPv6, Subnet} from "../src/index.js"; +import { describe, expect, it } from "vitest"; +import { IPv4, IPv6, Subnet } from "../src/index.js"; describe("Subnet", () => { - describe("constructor", () => { - it("creates a subnet with valid IPv4 address and prefix", () => { - const addr = IPv4.fromString("192.0.2.0"); - const subnet = new Subnet(addr, 24); - expect(subnet.address.equals(addr)).toBe(true); - expect(subnet.prefix).toBe(24); - }); - - it("creates a subnet with misaligned address", () => { - const addr = IPv4.fromString("192.0.2.42"); - const subnet = new Subnet(addr, 24); - expect(subnet.address.equals(IPv4.fromString("192.0.2.0"))).toBe(true); - }); - - it("throws RangeError if prefix length exceeds IP bit length", () => { - expect(() => new Subnet(IPv4.fromString("192.0.2.0"), 33)).toThrow(RangeError); - expect(() => new Subnet(IPv6.fromString("2001:db8::"), 129)).toThrow(RangeError); - }); - - it("throws RangeError if prefix length is negative", () => { - expect(() => new Subnet(IPv4.fromString("192.0.2.0"), -1)).toThrow(RangeError); - }); - }); - - describe("static fromCIDR", () => { - it("creates subnet from valid IPv4 CIDR string", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - expect(subnet.address.toString()).toBe("192.0.2.0"); - expect(subnet.prefix).toBe(24); - }); - - it("throws RangeError on invalid CIDR string", () => { - expect(() => Subnet.fromCIDR("192.0.2.0/33")).toThrow(RangeError); - expect(() => Subnet.fromCIDR("192.0.2.0")).toThrow(RangeError); - expect(() => Subnet.fromCIDR("192.0.2.0/no")).toThrow(RangeError); - expect(() => Subnet.fromCIDR("10/8")).toThrow(RangeError); - expect(() => Subnet.fromCIDR("10.0/16")).toThrow(RangeError); - expect(() => Subnet.fromCIDR("invalid/cidr")).toThrow(RangeError); - }); - }); - - describe("static range", () => { - it("creates subnet from /24 IPv4 range", () => { - const subnets = Subnet.range( - IPv4.fromString("192.0.2.0"), - IPv4.fromString("192.0.2.255") - ); - expect(subnets.map(s => s.toString())).toEqual(["192.0.2.0/24"]); - }); - - it("creates subnet from misaligned IPv4 range", () => { - const subnets = Subnet.range( - IPv4.fromString("192.0.2.0"), - IPv4.fromString("198.51.100.255") - ); - - // from https://account.arin.net/public/cidrCalculator - expect(subnets.map(s => s.toString())).toEqual([ - "192.0.2.0/23", - "192.0.4.0/22", - "192.0.8.0/21", - "192.0.16.0/20", - "192.0.32.0/19", - "192.0.64.0/18", - "192.0.128.0/17", - "192.1.0.0/16", - "192.2.0.0/15", - "192.4.0.0/14", - "192.8.0.0/13", - "192.16.0.0/12", - "192.32.0.0/11", - "192.64.0.0/10", - "192.128.0.0/9", - "193.0.0.0/8", - "194.0.0.0/7", - "196.0.0.0/7", - "198.0.0.0/11", - "198.32.0.0/12", - "198.48.0.0/15", - "198.50.0.0/16", - "198.51.0.0/18", - "198.51.64.0/19", - "198.51.96.0/22", - "198.51.100.0/24", - ]); - }); - }); - - describe("netmask", () => { - it("returns correct netmask for IPv4 /24", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - expect(subnet.netmask()).toBe(0xffffff00n); - }); - }); - - describe("wildcard", () => { - it("returns correct wildcard for IPv4 /24", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - expect(subnet.wildcard()).toBe(0xffn); - }); - }); - - describe("size", () => { - it("returns correct size for IPv4 /24 subnet", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - expect(subnet.size()).toBe(256n); - }); - - it("returns correct size for IPv6 /32 subnet", () => { - const subnet = Subnet.fromCIDR("2001:db8::/32"); - expect(subnet.size()).toBe(2n ** 96n); - }); - }); - - describe("contains", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - - it("contains address inside subnet", () => { - const addr = IPv4.fromString("192.0.2.42"); - expect(subnet.contains(addr)).toBe(true); - }); - - it("does not contain address outside subnet", () => { - const addr = IPv4.fromString("198.51.100.10"); - expect(subnet.contains(addr)).toBe(false); - }); - }); - - describe("containsSubnet", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - - it("contains smaller subnet inside", () => { - const smaller = Subnet.fromCIDR("192.0.2.128/25"); - expect(subnet.containsSubnet(smaller)).toBe(true); - }); - - it("does not contain larger subnet", () => { - const larger = Subnet.fromCIDR("192.0.2.0/23"); - expect(subnet.containsSubnet(larger)).toBe(false); - }); - - it("does not contain adjacent subnet", () => { - const adjacent = Subnet.fromCIDR("192.0.3.0/25"); - expect(subnet.containsSubnet(adjacent)).toBe(false); - }); - }); - - describe("at", () => { - const subnet = Subnet.fromCIDR("192.0.2.4/30"); - - it("returns first address at index 0", () => { - expect(subnet.at(0).toString()).toBe("192.0.2.4"); - }); - - it("returns second address at index 1", () => { - expect(subnet.at(1)?.toString()).toBe("192.0.2.5"); - }); - - it("returns last address at index -1", () => { - expect(subnet.at(-1).toString()).toBe("192.0.2.7"); - }); - - it("returns penultimate address at index -2", () => { - expect(subnet.at(-2)?.toString()).toBe("192.0.2.6"); - }); - - it("returns undefined for out-of-range positive index", () => { - expect(subnet.at(4)).toBeUndefined(); - }); - - it("returns undefined for out-of-range negative index", () => { - expect(subnet.at(-5)).toBeUndefined(); - }); + describe("constructor", () => { + it("creates a subnet with valid IPv4 address and prefix", () => { + const addr = IPv4.fromString("192.0.2.0"); + const subnet = new Subnet(addr, 24); + expect(subnet.address.equals(addr)).toBe(true); + expect(subnet.prefix).toBe(24); }); - describe("addresses", () => { - const subnet = Subnet.fromCIDR("192.0.2.8/30"); + it("creates a subnet with misaligned address", () => { + const addr = IPv4.fromString("192.0.2.42"); + const subnet = new Subnet(addr, 24); + expect(subnet.address.equals(IPv4.fromString("192.0.2.0"))).toBe(true); + }); + + it("throws RangeError if prefix length exceeds IP bit length", () => { + expect(() => new Subnet(IPv4.fromString("192.0.2.0"), 33)).toThrow( + RangeError, + ); + expect(() => new Subnet(IPv6.fromString("2001:db8::"), 129)).toThrow( + RangeError, + ); + }); - it("iterates over all addresses", () => { - const ips = Array.from(subnet.addresses()).map(ip => ip.toString()); - expect(ips).toEqual(["192.0.2.8", "192.0.2.9", "192.0.2.10", "192.0.2.11"]); - }); + it("throws RangeError if prefix length is negative", () => { + expect(() => new Subnet(IPv4.fromString("192.0.2.0"), -1)).toThrow( + RangeError, + ); }); + }); - describe("[Symbol.iterator]", () => { - const subnet = Subnet.fromCIDR("198.51.100.40/30"); - it("iterator works with for...of", () => { - const ips: string[] = []; - for (const ip of subnet) - ips.push(ip.toString()); - expect(ips).toEqual(["198.51.100.40", "198.51.100.41", "198.51.100.42", "198.51.100.43"]); - }); - }) + describe("static fromCIDR", () => { + it("creates subnet from valid IPv4 CIDR string", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + expect(subnet.address.toString()).toBe("192.0.2.0"); + expect(subnet.prefix).toBe(24); + }); - describe("set", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/30"); + it("throws RangeError on invalid CIDR string", () => { + expect(() => Subnet.fromCIDR("192.0.2.0/33")).toThrow(RangeError); + expect(() => Subnet.fromCIDR("192.0.2.0")).toThrow(RangeError); + expect(() => Subnet.fromCIDR("192.0.2.0/no")).toThrow(RangeError); + expect(() => Subnet.fromCIDR("10/8")).toThrow(RangeError); + expect(() => Subnet.fromCIDR("10.0/16")).toThrow(RangeError); + expect(() => Subnet.fromCIDR("invalid/cidr")).toThrow(RangeError); + }); + }); + + describe("static range", () => { + it("creates subnet from /24 IPv4 range", () => { + const subnets = Subnet.range( + IPv4.fromString("192.0.2.0"), + IPv4.fromString("192.0.2.255"), + ); + expect(subnets.map((s) => s.toString())).toEqual(["192.0.2.0/24"]); + }); - it("creates a set of all addresses", () => { - const set = subnet.set(); - expect(set.size).toBe(4); - const arr = Array.from(set).map(ip => ip.toString()); - expect(arr).toEqual(["192.0.2.0", "192.0.2.1", "192.0.2.2", "192.0.2.3"]); - }); + it("creates subnet from misaligned IPv4 range", () => { + const subnets = Subnet.range( + IPv4.fromString("192.0.2.0"), + IPv4.fromString("198.51.100.255"), + ); + + // from https://account.arin.net/public/cidrCalculator + expect(subnets.map((s) => s.toString())).toEqual([ + "192.0.2.0/23", + "192.0.4.0/22", + "192.0.8.0/21", + "192.0.16.0/20", + "192.0.32.0/19", + "192.0.64.0/18", + "192.0.128.0/17", + "192.1.0.0/16", + "192.2.0.0/15", + "192.4.0.0/14", + "192.8.0.0/13", + "192.16.0.0/12", + "192.32.0.0/11", + "192.64.0.0/10", + "192.128.0.0/9", + "193.0.0.0/8", + "194.0.0.0/7", + "196.0.0.0/7", + "198.0.0.0/11", + "198.32.0.0/12", + "198.48.0.0/15", + "198.50.0.0/16", + "198.51.0.0/18", + "198.51.64.0/19", + "198.51.96.0/22", + "198.51.100.0/24", + ]); }); + }); - describe("toString", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); + describe("netmask", () => { + it("returns correct netmask for IPv4 /24", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + expect(subnet.netmask()).toBe(0xffffff00n); + }); + }); + + describe("wildcard", () => { + it("returns correct wildcard for IPv4 /24", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + expect(subnet.wildcard()).toBe(0xffn); + }); + }); + + describe("size", () => { + it("returns correct size for IPv4 /24 subnet", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + expect(subnet.size()).toBe(256n); + }); - it("returns CIDR notation string", () => { - expect(subnet.toString()).toBe("192.0.2.0/24"); - }); + it("returns correct size for IPv6 /32 subnet", () => { + const subnet = Subnet.fromCIDR("2001:db8::/32"); + expect(subnet.size()).toBe(2n ** 96n); }); + }); - describe("isAdjacent", () => { - const subnet1 = Subnet.fromCIDR("192.0.2.0/25"); - const subnet2 = Subnet.fromCIDR("192.0.2.128/25"); - const subnet3 = Subnet.fromCIDR("198.51.100.0/25"); + describe("contains", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); - it("returns true for adjacent subnets", () => { - expect(subnet1.isAdjacent(subnet2)).toBe(true); - }); + it("contains address inside subnet", () => { + const addr = IPv4.fromString("192.0.2.42"); + expect(subnet.contains(addr)).toBe(true); + }); + + it("does not contain address outside subnet", () => { + const addr = IPv4.fromString("198.51.100.10"); + expect(subnet.contains(addr)).toBe(false); + }); + }); + + describe("containsSubnet", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + + it("contains smaller subnet inside", () => { + const smaller = Subnet.fromCIDR("192.0.2.128/25"); + expect(subnet.containsSubnet(smaller)).toBe(true); + }); + + it("does not contain larger subnet", () => { + const larger = Subnet.fromCIDR("192.0.2.0/23"); + expect(subnet.containsSubnet(larger)).toBe(false); + }); + + it("does not contain adjacent subnet", () => { + const adjacent = Subnet.fromCIDR("192.0.3.0/25"); + expect(subnet.containsSubnet(adjacent)).toBe(false); + }); + }); - it("returns false for non-adjacent subnets", () => { - expect(subnet1.isAdjacent(subnet3)).toBe(false); - }); + describe("at", () => { + const subnet = Subnet.fromCIDR("192.0.2.4/30"); + + it("returns first address at index 0", () => { + expect(subnet.at(0).toString()).toBe("192.0.2.4"); + }); + + it("returns second address at index 1", () => { + expect(subnet.at(1)?.toString()).toBe("192.0.2.5"); + }); + + it("returns last address at index -1", () => { + expect(subnet.at(-1).toString()).toBe("192.0.2.7"); + }); + + it("returns penultimate address at index -2", () => { + expect(subnet.at(-2)?.toString()).toBe("192.0.2.6"); + }); + + it("returns undefined for out-of-range positive index", () => { + expect(subnet.at(4)).toBeUndefined(); + }); + + it("returns undefined for out-of-range negative index", () => { + expect(subnet.at(-5)).toBeUndefined(); + }); + }); + + describe("addresses", () => { + const subnet = Subnet.fromCIDR("192.0.2.8/30"); + + it("iterates over all addresses", () => { + const ips = Array.from(subnet.addresses()).map((ip) => ip.toString()); + expect(ips).toEqual([ + "192.0.2.8", + "192.0.2.9", + "192.0.2.10", + "192.0.2.11", + ]); + }); + }); + + describe("[Symbol.iterator]", () => { + const subnet = Subnet.fromCIDR("198.51.100.40/30"); + it("iterator works with for...of", () => { + const ips: string[] = []; + for (const ip of subnet) { + ips.push(ip.toString()); + } + expect(ips).toEqual([ + "198.51.100.40", + "198.51.100.41", + "198.51.100.42", + "198.51.100.43", + ]); + }); + }); + + describe("set", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/30"); + + it("creates a set of all addresses", () => { + const set = subnet.set(); + expect(set.size).toBe(4); + const arr = Array.from(set).map((ip) => ip.toString()); + expect(arr).toEqual(["192.0.2.0", "192.0.2.1", "192.0.2.2", "192.0.2.3"]); + }); + }); + + describe("toString", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + + it("returns CIDR notation string", () => { + expect(subnet.toString()).toBe("192.0.2.0/24"); + }); + }); + + describe("isAdjacent", () => { + const subnet1 = Subnet.fromCIDR("192.0.2.0/25"); + const subnet2 = Subnet.fromCIDR("192.0.2.128/25"); + const subnet3 = Subnet.fromCIDR("198.51.100.0/25"); + + it("returns true for adjacent subnets", () => { + expect(subnet1.isAdjacent(subnet2)).toBe(true); + }); + + it("returns false for non-adjacent subnets", () => { + expect(subnet1.isAdjacent(subnet3)).toBe(false); + }); + + it("throws TypeError for different families", () => { + const ipv6Subnet = Subnet.fromCIDR("2001:db8::/64"); + expect(() => subnet1.isAdjacent(ipv6Subnet)).toThrow(TypeError); + }); + }); + + describe("canMerge", () => { + const subnet1 = Subnet.fromCIDR("192.0.2.0/25"); + const subnet2 = Subnet.fromCIDR("192.0.2.128/25"); + const subnet3 = Subnet.fromCIDR("192.0.2.0/26"); + + it("returns true for adjacent subnets of same size", () => { + expect(subnet1.canMerge(subnet2)).toBe(true); + }); + + it("returns false for subnets with different prefix length", () => { + expect(subnet1.canMerge(subnet3)).toBe(false); + }); + + it("returns false for different families", () => { + const ipv6Subnet = Subnet.fromCIDR("2001:db8::/25"); + expect(subnet1.canMerge(ipv6Subnet)).toBe(false); + }); + }); + + describe("merge", () => { + const subnet1 = Subnet.fromCIDR("192.0.2.0/25"); + const subnet2 = Subnet.fromCIDR("192.0.2.128/25"); + + it("merges adjacent subnets of same size", () => { + const merged = subnet1.merge(subnet2); + expect(merged.prefix).toBe(24); + expect(merged.address.toString()).toBe("192.0.2.0"); + }); + + it("merges when this is the upper neighbour", () => { + const merged = subnet2.merge(subnet1); + expect(merged.prefix).toBe(24); + expect(merged.address.toString()).toBe("192.0.2.0"); + }); + + it("throws TypeError for different families", () => { + const ipv6Subnet = Subnet.fromCIDR("2001:db8::/25"); + expect(() => subnet1.merge(ipv6Subnet)).toThrow(TypeError); + }); + + it("throws TypeError for different sizes", () => { + const subnet3 = Subnet.fromCIDR("192.0.2.128/26"); + expect(() => subnet1.merge(subnet3)).toThrow(TypeError); + }); + + it("throws RangeError for non-adjacent subnets", () => { + const subnet3 = Subnet.fromCIDR("198.51.100.0/25"); + expect(() => subnet1.merge(subnet3)).toThrow(RangeError); + }); + }); + + describe("split", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + + it("splits subnet into smaller subnets", () => { + const splits = subnet.split(26).map((s) => s.toString()); + expect(splits).toEqual([ + "192.0.2.0/26", + "192.0.2.64/26", + "192.0.2.128/26", + "192.0.2.192/26", + ]); + }); + + it("throws RangeError if prefix length is smaller than current", () => { + expect(() => subnet.split(16)).toThrow(RangeError); + }); + + it("throws RangeError if prefix length exceeds IP bit length", () => { + expect(() => subnet.split(33)).toThrow(RangeError); + }); + }); + + describe("subtract", () => { + const subnet = Subnet.fromCIDR("192.0.2.0/24"); + + it("subtracts a subnet fully contained", () => { + const toSubtract = Subnet.fromCIDR("192.0.2.0/26"); + const result = subnet.subtract(toSubtract).map((s) => s.toString()); + expect(result).toEqual([ + "192.0.2.64/26", + "192.0.2.128/25", + ]); + }); + + it("subtracts subnet not aligned with base address", () => { + const toSubtract = Subnet.fromCIDR("192.0.2.128/26"); + const result = subnet.subtract(toSubtract).map((s) => s.toString()); + expect(result).toEqual([ + "192.0.2.0/25", + "192.0.2.192/26", + ]); + }); + + it("returns self when subtracting non-overlapping subnet", () => { + const toSubtract = Subnet.fromCIDR("198.51.100.0/25"); + const result = subnet.subtract(toSubtract); + expect(result).toEqual([subnet]); + }); + + it("returns empty array when fully covered", () => { + expect(subnet.subtract(Subnet.fromCIDR("192.0.2.0/24"))).toHaveLength(0); + expect(subnet.subtract(Subnet.fromCIDR("0.0.0.0/0"))).toHaveLength(0); + }); + + it("throws TypeError for different family", () => { + const ipv6Subnet = Subnet.fromCIDR("2001:db8::/64"); + expect(() => subnet.subtract(ipv6Subnet)).toThrow(TypeError); + }); + }); + + describe("equals", () => { + it("returns true for identical subnets", () => { + expect( + Subnet.fromCIDR("192.0.2.0/24").equals(Subnet.fromCIDR("192.0.2.0/24")), + ) + .toBe(true); + expect( + Subnet.fromCIDR("2001:db8::/64").equals( + Subnet.fromCIDR("2001:db8::/64"), + ), + ) + .toBe(true); + }); - it("throws TypeError for different families", () => { - const ipv6Subnet = Subnet.fromCIDR("2001:db8::/64"); - expect(() => subnet1.isAdjacent(ipv6Subnet)).toThrow(TypeError); - }); - }); - - describe("canMerge", () => { - const subnet1 = Subnet.fromCIDR("192.0.2.0/25"); - const subnet2 = Subnet.fromCIDR("192.0.2.128/25"); - const subnet3 = Subnet.fromCIDR("192.0.2.0/26"); - - it("returns true for adjacent subnets of same size", () => { - expect(subnet1.canMerge(subnet2)).toBe(true); - }); - - it("returns false for subnets with different prefix length", () => { - expect(subnet1.canMerge(subnet3)).toBe(false); - }); - - it("returns false for different families", () => { - const ipv6Subnet = Subnet.fromCIDR("2001:db8::/25"); - expect(subnet1.canMerge(ipv6Subnet)).toBe(false); - }); - }); - - describe("merge", () => { - const subnet1 = Subnet.fromCIDR("192.0.2.0/25"); - const subnet2 = Subnet.fromCIDR("192.0.2.128/25"); - - it("merges adjacent subnets of same size", () => { - const merged = subnet1.merge(subnet2); - expect(merged.prefix).toBe(24); - expect(merged.address.toString()).toBe("192.0.2.0"); - }); - - it("merges when this is the upper neighbour", () => { - const merged = subnet2.merge(subnet1); - expect(merged.prefix).toBe(24); - expect(merged.address.toString()).toBe("192.0.2.0"); - }); - - it("throws TypeError for different families", () => { - const ipv6Subnet = Subnet.fromCIDR("2001:db8::/25"); - expect(() => subnet1.merge(ipv6Subnet)).toThrow(TypeError); - }); - - it("throws TypeError for different sizes", () => { - const subnet3 = Subnet.fromCIDR("192.0.2.128/26"); - expect(() => subnet1.merge(subnet3)).toThrow(TypeError); - }); - - it("throws RangeError for non-adjacent subnets", () => { - const subnet3 = Subnet.fromCIDR("198.51.100.0/25"); - expect(() => subnet1.merge(subnet3)).toThrow(RangeError); - }); - }); - - describe("split", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - - it("splits subnet into smaller subnets", () => { - const splits = subnet.split(26).map(s => s.toString()); - expect(splits).toEqual([ - "192.0.2.0/26", - "192.0.2.64/26", - "192.0.2.128/26", - "192.0.2.192/26", - ]); - }); - - it("throws RangeError if prefix length is smaller than current", () => { - expect(() => subnet.split(16)).toThrow(RangeError); - }); - - it("throws RangeError if prefix length exceeds IP bit length", () => { - expect(() => subnet.split(33)).toThrow(RangeError); - }); - }); - - describe("subtract", () => { - const subnet = Subnet.fromCIDR("192.0.2.0/24"); - - it("subtracts a subnet fully contained", () => { - const toSubtract = Subnet.fromCIDR("192.0.2.0/26"); - const result = subnet.subtract(toSubtract).map(s => s.toString()); - expect(result).toEqual([ - "192.0.2.64/26", - "192.0.2.128/25", - ]); - }); - - it("subtracts subnet not aligned with base address", () => { - const toSubtract = Subnet.fromCIDR("192.0.2.128/26"); - const result = subnet.subtract(toSubtract).map(s => s.toString()); - expect(result).toEqual([ - "192.0.2.0/25", - "192.0.2.192/26", - ]); - }); - - it("returns self when subtracting non-overlapping subnet", () => { - const toSubtract = Subnet.fromCIDR("198.51.100.0/25"); - const result = subnet.subtract(toSubtract); - expect(result).toEqual([subnet]); - }); - - it("returns empty array when fully covered", () => { - expect(subnet.subtract(Subnet.fromCIDR("192.0.2.0/24"))).toHaveLength(0); - expect(subnet.subtract(Subnet.fromCIDR("0.0.0.0/0"))).toHaveLength(0); - }); - - it("throws TypeError for different family", () => { - const ipv6Subnet = Subnet.fromCIDR("2001:db8::/64"); - expect(() => subnet.subtract(ipv6Subnet)).toThrow(TypeError); - }); - }); - - describe("equals", () => { - it("returns true for identical subnets", () => { - expect(Subnet.fromCIDR("192.0.2.0/24").equals(Subnet.fromCIDR("192.0.2.0/24"))) - .toBe(true); - expect(Subnet.fromCIDR("2001:db8::/64").equals(Subnet.fromCIDR("2001:db8::/64"))) - .toBe(true); - }); - - it("returns false for different subnets", () => { - expect(Subnet.fromCIDR("192.0.2.0/24").equals(Subnet.fromCIDR("192.0.2.0/25"))) - .toBe(false); - expect(Subnet.fromCIDR("192.0.2.0/24").equals(Subnet.fromCIDR("2001:db8::/64"))) - .toBe(false); - }); + it("returns false for different subnets", () => { + expect( + Subnet.fromCIDR("192.0.2.0/24").equals(Subnet.fromCIDR("192.0.2.0/25")), + ) + .toBe(false); + expect( + Subnet.fromCIDR("192.0.2.0/24").equals( + Subnet.fromCIDR("2001:db8::/64"), + ), + ) + .toBe(false); }); + }); }); diff --git a/tests/SubnetList.test.ts b/tests/SubnetList.test.ts index 8c7dcfb..011e482 100644 --- a/tests/SubnetList.test.ts +++ b/tests/SubnetList.test.ts @@ -1,239 +1,241 @@ -import {describe, expect, it} from "vitest"; -import {IPv4, IPv6, Subnet, SubnetList} from "../src/index.js"; +import { describe, expect, it } from "vitest"; +import { IPv4, IPv6, Subnet, SubnetList } from "../src/index.js"; describe("SubnetList", () => { - describe("constructor", () => { - it("creates empty SubnetList", () => { - const list = new SubnetList(); - expect(list.size()).toBe(0n); - expect(list.subnets()).toHaveLength(0); - }); - - it("initialises with provided subnets", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const s3 = Subnet.fromCIDR("198.51.100.0/24"); - const list = new SubnetList([s2, s1, s3]); - expect(list.hasSubnet(s1)).toBe(true); - expect(list.hasSubnet(s2)).toBe(true); - expect(list.hasSubnet(s3)).toBe(true); - expect(list.size()).toBeLessThanOrEqual(s1.size() + s2.size() + s3.size()); - }); - - it("optimises subnets to minimise memory usage", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const s3 = Subnet.fromCIDR("198.51.100.0/24"); - const list = new SubnetList([s1, s2, s3]); - expect(list.subnets().map(s => s.toString())).toEqual([ - "192.0.2.0/24", - "198.51.100.0/24", - ]); - }) - }); - - describe("add", () => { - it("adds a subnet and returns true if new", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const s3 = Subnet.fromCIDR("198.51.100.0/24"); - const list = new SubnetList([s1, s2]); - const initialSize = list.size(); - expect(list.add(s3)).toBe(true); - expect(list.hasSubnet(s3)).toBe(true); - expect(list.size()).toBe(initialSize + s3.size()); - }); - - it("adds a subnet that expands an existing one and returns true", () => { - const s1 = Subnet.fromCIDR("192.0.2.128/25"); - const s2 = Subnet.fromCIDR("192.0.2.0/24"); - const list = new SubnetList([s1]); - expect(list.add(s2)).toBe(true); - expect(list.hasSubnet(s2)).toBe(true); - expect(list.size()).toBe(s2.size()); - }); - - it("returns false when adding a duplicate subnet", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const list = new SubnetList([s1]); - expect(list.add(s1)).toBe(false); - }); - - it("returns false when adding a subnet that is already contained", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/24"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const list = new SubnetList([s1]); - expect(list.add(s2)).toBe(false); - }); - - it("adds an IP address as a /32 subnet", () => { - const ip = IPv4.fromString("192.0.2.5"); - const list = new SubnetList(); - expect(list.add(ip)).toBe(true); - expect(list.subnets().map(s => s.toString())).toEqual(["192.0.2.5/32"]); - expect(list.contains(ip)).toBe(true); - }); - - it("returns false when adding duplicate IP address", () => { - const ip = IPv4.fromString("192.0.2.5"); - const list = new SubnetList([Subnet.fromCIDR("192.0.2.5/32")]); - expect(list.add(ip)).toBe(false); - }); - - it("adds all subnets from another SubnetList", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/24"); - const s2 = Subnet.fromCIDR("198.51.100.0/24"); - const other = new SubnetList([s1, s2]); - const list = new SubnetList(); - list.add(other); - expect(list.hasSubnet(s1)).toBe(true); - expect(list.hasSubnet(s2)).toBe(true); - expect(list.size()).toBeLessThanOrEqual(s1.size() + s2.size()); - }); - }); - - describe("remove", () => { - it("removes existing subnet and returns true", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const list = new SubnetList([s1]); - expect(list.remove(s1)).toBe(true); - expect(list.hasSubnet(s1)).toBe(false); - expect(list.size()).toBe(0n); - }); - - it("removes subnet partially covered by a larger one", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const s3 = Subnet.fromCIDR("192.0.2.0/24"); - const list = new SubnetList([s3]); - expect(list.remove(s2)).toBe(true); - expect(list.hasSubnet(s1)).toBe(true); - expect(list.hasSubnet(s2)).toBe(false); - expect(list.size()).toBe(s1.size()); - }); - - it("returns false when removing subnet not present", () => { - const s1 = Subnet.fromCIDR("203.0.113.0/24"); - const list = new SubnetList([Subnet.fromCIDR("192.0.2.0/25")]); - expect(list.remove(s1)).toBe(false); - }); - }); - - describe("hasSubnet", () => { - it("returns true if subnet is in the list", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const list = new SubnetList([s1]); - expect(list.hasSubnet(s1)).toBe(true); - }); - - it("returns true if subnet is part of a larger one contained in the list", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/24"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const list = new SubnetList([s1]); - expect(list.hasSubnet(s2)).toBe(true); - }); - - it("returns false if subnet is not in the list", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const list = new SubnetList([Subnet.fromCIDR("198.51.100.0/24")]); - expect(list.hasSubnet(s1)).toBe(false); - }); - }); - - describe("subnets", () => { - it("returns all subnets", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const list = new SubnetList([s1, s2]); - const all = list.subnets(); - for (const s of [s1, s2]) { - const found = all.some(x => x.containsSubnet(s)); - expect(found).toBe(true); - } - const total = all.reduce((sum, s) => sum + s.size(), 0n); - expect(total).toBe(s1.size() + s2.size()); - }); - }); - - describe("contains", () => { - it("returns true for IPs within any subnet", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/24"); - const s2 = Subnet.fromCIDR("198.51.100.0/24"); - const list = new SubnetList([s1, s2]); - expect(list.contains(IPv4.fromString("192.0.2.78"))).toBe(true); - expect(list.contains(IPv4.fromString("198.51.100.69"))).toBe(true); - }); - - it("returns false for IPs outside all subnets", () => { - const ip = IPv4.fromString("198.51.100.1"); - const s = Subnet.fromCIDR("192.0.2.0/24"); - const list = new SubnetList([s]); - expect(list.contains(ip)).toBe(false); - }); - }); - - describe("size", () => { - it("returns sum of all subnet sizes", () => { - const s1 = Subnet.fromCIDR("192.0.2.0/25"); - const s2 = Subnet.fromCIDR("192.0.2.128/25"); - const s3 = Subnet.fromCIDR("203.0.113.0/24"); - const list = new SubnetList([s1, s2, s3]); - expect(list.size()).toBe(s1.size() + s2.size() + s3.size()); - }); - - it("returns 0 when empty", () => { - const list = new SubnetList(); - expect(list.size()).toBe(0n); - }); - }); - - describe("[Symbol.iterator]", () => { - it("iterates over all IPs in all subnets", () => { - const s1 = Subnet.fromCIDR("192.0.2.64/30"); - const s2 = Subnet.fromCIDR("192.0.2.24/30"); - const list = new SubnetList([s1, s2]); - const ips = Array.from(list).map(ip => ip.toString()); - expect(ips).toEqual([ - "192.0.2.24", - "192.0.2.25", - "192.0.2.26", - "192.0.2.27", - "192.0.2.64", - "192.0.2.65", - "192.0.2.66", - "192.0.2.67", - ]); - }); - - it("iterates zero times when empty", () => { - const list = new SubnetList(); - expect(Array.from(list)).toHaveLength(0); - }); - }); - - describe("static BOGON", () => { - it("is a SubnetList instance", () => { - expect(SubnetList.BOGON).toBeInstanceOf(SubnetList); - }); - - it("contains a common IPv4 bogon address", () => { - const ip = IPv4.fromString("192.0.2.42"); - expect(SubnetList.BOGON.contains(ip)).toBe(true); - }); - - it("contains a common IPv6 bogon address", () => { - const ip = IPv6.fromString("2001:db8::cafe:babe"); - expect(SubnetList.BOGON.contains(ip)).toBe(true); - }); - - it("does not contain a public IPv4 address", () => { - const ip = IPv4.fromString("1.1.1.1"); - expect(SubnetList.BOGON.contains(ip)).toBe(false); - }); - - it("does not contain a public IPv6 address", () => { - const ip = IPv6.fromString("2606:4700:4700::1111"); - expect(SubnetList.BOGON.contains(ip)).toBe(false); - }); + describe("constructor", () => { + it("creates empty SubnetList", () => { + const list = new SubnetList(); + expect(list.size()).toBe(0n); + expect(list.subnets()).toHaveLength(0); }); + + it("initialises with provided subnets", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const s3 = Subnet.fromCIDR("198.51.100.0/24"); + const list = new SubnetList([s2, s1, s3]); + expect(list.hasSubnet(s1)).toBe(true); + expect(list.hasSubnet(s2)).toBe(true); + expect(list.hasSubnet(s3)).toBe(true); + expect(list.size()).toBeLessThanOrEqual( + s1.size() + s2.size() + s3.size(), + ); + }); + + it("optimises subnets to minimise memory usage", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const s3 = Subnet.fromCIDR("198.51.100.0/24"); + const list = new SubnetList([s1, s2, s3]); + expect(list.subnets().map((s) => s.toString())).toEqual([ + "192.0.2.0/24", + "198.51.100.0/24", + ]); + }); + }); + + describe("add", () => { + it("adds a subnet and returns true if new", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const s3 = Subnet.fromCIDR("198.51.100.0/24"); + const list = new SubnetList([s1, s2]); + const initialSize = list.size(); + expect(list.add(s3)).toBe(true); + expect(list.hasSubnet(s3)).toBe(true); + expect(list.size()).toBe(initialSize + s3.size()); + }); + + it("adds a subnet that expands an existing one and returns true", () => { + const s1 = Subnet.fromCIDR("192.0.2.128/25"); + const s2 = Subnet.fromCIDR("192.0.2.0/24"); + const list = new SubnetList([s1]); + expect(list.add(s2)).toBe(true); + expect(list.hasSubnet(s2)).toBe(true); + expect(list.size()).toBe(s2.size()); + }); + + it("returns false when adding a duplicate subnet", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const list = new SubnetList([s1]); + expect(list.add(s1)).toBe(false); + }); + + it("returns false when adding a subnet that is already contained", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/24"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const list = new SubnetList([s1]); + expect(list.add(s2)).toBe(false); + }); + + it("adds an IP address as a /32 subnet", () => { + const ip = IPv4.fromString("192.0.2.5"); + const list = new SubnetList(); + expect(list.add(ip)).toBe(true); + expect(list.subnets().map((s) => s.toString())).toEqual(["192.0.2.5/32"]); + expect(list.contains(ip)).toBe(true); + }); + + it("returns false when adding duplicate IP address", () => { + const ip = IPv4.fromString("192.0.2.5"); + const list = new SubnetList([Subnet.fromCIDR("192.0.2.5/32")]); + expect(list.add(ip)).toBe(false); + }); + + it("adds all subnets from another SubnetList", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/24"); + const s2 = Subnet.fromCIDR("198.51.100.0/24"); + const other = new SubnetList([s1, s2]); + const list = new SubnetList(); + list.add(other); + expect(list.hasSubnet(s1)).toBe(true); + expect(list.hasSubnet(s2)).toBe(true); + expect(list.size()).toBeLessThanOrEqual(s1.size() + s2.size()); + }); + }); + + describe("remove", () => { + it("removes existing subnet and returns true", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const list = new SubnetList([s1]); + expect(list.remove(s1)).toBe(true); + expect(list.hasSubnet(s1)).toBe(false); + expect(list.size()).toBe(0n); + }); + + it("removes subnet partially covered by a larger one", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const s3 = Subnet.fromCIDR("192.0.2.0/24"); + const list = new SubnetList([s3]); + expect(list.remove(s2)).toBe(true); + expect(list.hasSubnet(s1)).toBe(true); + expect(list.hasSubnet(s2)).toBe(false); + expect(list.size()).toBe(s1.size()); + }); + + it("returns false when removing subnet not present", () => { + const s1 = Subnet.fromCIDR("203.0.113.0/24"); + const list = new SubnetList([Subnet.fromCIDR("192.0.2.0/25")]); + expect(list.remove(s1)).toBe(false); + }); + }); + + describe("hasSubnet", () => { + it("returns true if subnet is in the list", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const list = new SubnetList([s1]); + expect(list.hasSubnet(s1)).toBe(true); + }); + + it("returns true if subnet is part of a larger one contained in the list", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/24"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const list = new SubnetList([s1]); + expect(list.hasSubnet(s2)).toBe(true); + }); + + it("returns false if subnet is not in the list", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const list = new SubnetList([Subnet.fromCIDR("198.51.100.0/24")]); + expect(list.hasSubnet(s1)).toBe(false); + }); + }); + + describe("subnets", () => { + it("returns all subnets", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const list = new SubnetList([s1, s2]); + const all = list.subnets(); + for (const s of [s1, s2]) { + const found = all.some((x) => x.containsSubnet(s)); + expect(found).toBe(true); + } + const total = all.reduce((sum, s) => sum + s.size(), 0n); + expect(total).toBe(s1.size() + s2.size()); + }); + }); + + describe("contains", () => { + it("returns true for IPs within any subnet", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/24"); + const s2 = Subnet.fromCIDR("198.51.100.0/24"); + const list = new SubnetList([s1, s2]); + expect(list.contains(IPv4.fromString("192.0.2.78"))).toBe(true); + expect(list.contains(IPv4.fromString("198.51.100.69"))).toBe(true); + }); + + it("returns false for IPs outside all subnets", () => { + const ip = IPv4.fromString("198.51.100.1"); + const s = Subnet.fromCIDR("192.0.2.0/24"); + const list = new SubnetList([s]); + expect(list.contains(ip)).toBe(false); + }); + }); + + describe("size", () => { + it("returns sum of all subnet sizes", () => { + const s1 = Subnet.fromCIDR("192.0.2.0/25"); + const s2 = Subnet.fromCIDR("192.0.2.128/25"); + const s3 = Subnet.fromCIDR("203.0.113.0/24"); + const list = new SubnetList([s1, s2, s3]); + expect(list.size()).toBe(s1.size() + s2.size() + s3.size()); + }); + + it("returns 0 when empty", () => { + const list = new SubnetList(); + expect(list.size()).toBe(0n); + }); + }); + + describe("[Symbol.iterator]", () => { + it("iterates over all IPs in all subnets", () => { + const s1 = Subnet.fromCIDR("192.0.2.64/30"); + const s2 = Subnet.fromCIDR("192.0.2.24/30"); + const list = new SubnetList([s1, s2]); + const ips = Array.from(list).map((ip) => ip.toString()); + expect(ips).toEqual([ + "192.0.2.24", + "192.0.2.25", + "192.0.2.26", + "192.0.2.27", + "192.0.2.64", + "192.0.2.65", + "192.0.2.66", + "192.0.2.67", + ]); + }); + + it("iterates zero times when empty", () => { + const list = new SubnetList(); + expect(Array.from(list)).toHaveLength(0); + }); + }); + + describe("static BOGON", () => { + it("is a SubnetList instance", () => { + expect(SubnetList.BOGON).toBeInstanceOf(SubnetList); + }); + + it("contains a common IPv4 bogon address", () => { + const ip = IPv4.fromString("192.0.2.42"); + expect(SubnetList.BOGON.contains(ip)).toBe(true); + }); + + it("contains a common IPv6 bogon address", () => { + const ip = IPv6.fromString("2001:db8::cafe:babe"); + expect(SubnetList.BOGON.contains(ip)).toBe(true); + }); + + it("does not contain a public IPv4 address", () => { + const ip = IPv4.fromString("1.1.1.1"); + expect(SubnetList.BOGON.contains(ip)).toBe(false); + }); + + it("does not contain a public IPv6 address", () => { + const ip = IPv6.fromString("2606:4700:4700::1111"); + expect(SubnetList.BOGON.contains(ip)).toBe(false); + }); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 40d8bb8..c944b49 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,16 @@ -import {defineConfig} from "vitest/config"; +import { defineConfig } from "vitest/config"; -declare const process: {env: Record} +declare const process: { env: Record }; export default defineConfig({ - test: { - reporters: process.env.GITHUB_ACTIONS ? "default" : ["default", "html"], - coverage: { - reporter: process.env.GITHUB_ACTIONS ? "text" : ["text", "html"], - include: ["src/**"], - thresholds: { - 100: true, - }, - }, + test: { + reporters: process.env.GITHUB_ACTIONS ? "default" : ["default", "html"], + coverage: { + reporter: process.env.GITHUB_ACTIONS ? "text" : ["text", "html"], + include: ["src/**"], + thresholds: { + 100: true, + }, }, + }, }); From 5f2d5fa9634c7617724020758778d0287fe2e610 Mon Sep 17 00:00:00 2001 From: Zefir Kirilov Date: Mon, 30 Mar 2026 12:12:34 +0300 Subject: [PATCH 2/2] enforce formatting with CI --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 264c7a4..f4c4e36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: - name: Check types run: deno check src tests --sloppy-imports + - name: Check formatting + run: deno fmt --check + - name: Test run: deno task test