| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { assertTrue } from "@effect/vitest/utils" |
| 3 | import { Equal, Hash, Result, Schema } from "effect" |
| 4 | import * as IpInterface from "effect/net/IpInterface" |
| 5 | import * as IpNetwork from "effect/net/IpNetwork" |
| 6 | import * as NetAddress from "effect/net/NetAddress" |
| 7 | import * as fc from "fast-check" |
| 8 | import { inspect } from "node:util" |
| 9 | |
| 10 | const success = <A>(result: Result.Result<A, unknown>): A => { |
| 11 | assertTrue(Result.isSuccess(result), "expected Success") |
| 12 | return result.success |
| 13 | } |
| 14 | |
| 15 | const failure = <E>(result: Result.Result<unknown, E>): E => { |
| 16 | assertTrue(Result.isFailure(result), "expected Failure") |
| 17 | return result.failure |
| 18 | } |
| 19 | |
| 20 | const ip = (input: string): NetAddress.IpAddress => success(NetAddress.ipFromString(input)) |
| 21 | const network = (input: string): IpNetwork.IpNetwork => success(IpNetwork.fromString(input)) |
| 22 | |
| 23 | describe("IpNetwork", () => { |
| 24 | it("serializes and inspects canonical strings", () => { |
| 25 | for (const expected of ["192.0.2.0/24", "2001:db8::/32"]) { |
| 26 | const address = IpNetwork.fromStringUnsafe(expected) |
| 27 | assert.strictEqual(address.toJSON(), expected) |
| 28 | assert.strictEqual(JSON.stringify(address), JSON.stringify(expected)) |
| 29 | assert.strictEqual(inspect(address), expected) |
| 30 | } |
| 31 | }) |
| 32 | |
| 33 | it("retains the address supplied to checked constructors", () => { |
| 34 | const address = NetAddress.ipv4Loopback |
| 35 | assert.strictEqual(failure(IpNetwork.make(address, 33)).input, address) |
| 36 | assert.strictEqual(failure(IpNetwork.make(address, 8)).input, address) |
| 37 | assert.strictEqual(failure(IpNetwork.fromAddress(address, 33)).input, address) |
| 38 | }) |
| 39 | |
| 40 | it("forwards errors from address and prefix validation", () => { |
| 41 | for (const parse of [IpNetwork.fromString, IpNetwork.ipv4FromString]) { |
| 42 | assert.strictEqual(failure(parse("1.2.3.4/+24")).input, "1.2.3.4/+24") |
| 43 | assert.strictEqual(failure(parse("010.2.3.4/24")).input, "010.2.3.4") |
| 44 | assert.deepStrictEqual(failure(parse("1.2.3.4/33")).input, ip("1.2.3.4")) |
| 45 | } |
| 46 | for (const parse of [IpNetwork.fromString, IpNetwork.ipv6FromString]) { |
| 47 | assert.strictEqual(failure(parse("::ffff:192.000.2.1/128")).input, "192.000.2.1") |
| 48 | } |
| 49 | }) |
| 50 | |
| 51 | it("constructs immutable canonical networks and preserves family identity", () => { |
| 52 | const ipv4 = IpNetwork.makeUnsafe(NetAddress.ipv4Unspecified, 0) |
| 53 | const ipv6 = IpNetwork.makeUnsafe(NetAddress.ipv6Unspecified, 0) |
| 54 | assert.isTrue(Object.isFrozen(ipv4)) |
| 55 | assert.isTrue(Object.isFrozen(ipv6)) |
| 56 | assert.isTrue(IpNetwork.isIpv4Network(ipv4)) |
| 57 | assert.isTrue(IpNetwork.isIpv6Network(ipv6)) |
| 58 | assert.strictEqual(ipv4._tag, "IpNetwork") |
| 59 | assert.strictEqual(ipv6._tag, "IpNetwork") |
| 60 | assert.isFalse(IpNetwork.isIpv4Network(ipv6)) |
| 61 | assert.isFalse(IpNetwork.isIpv6Network(ipv4)) |
| 62 | assert.isTrue(IpNetwork.isIpNetwork(ipv4)) |
| 63 | assert.isFalse(Equal.equals(ipv4, ipv6)) |
| 64 | assert.strictEqual(ipv4.toString(), "0.0.0.0/0") |
| 65 | assert.strictEqual(ipv6.toString(), "::/0") |
| 66 | }) |
| 67 | |
| 68 | it("rejects invalid prefix lengths and host bits", () => { |
| 69 | const ipv4 = ip("10.1.2.3") |
| 70 | const ipv6 = ip("2001:db8::1") |
| 71 | for (const prefix of [1.5, Number.NaN, Number.POSITIVE_INFINITY, 33]) { |
| 72 | failure(IpNetwork.make(ipv4, prefix)) |
| 73 | failure(IpNetwork.fromAddress(ipv4, prefix)) |
| 74 | } |
| 75 | failure(IpNetwork.make(ipv6, 129)) |
| 76 | assert.deepStrictEqual(failure(IpNetwork.fromString("10.1.2.3/8")).input, ipv4) |
| 77 | failure(IpNetwork.make(ipv4, 8)) |
| 78 | failure(IpNetwork.make(ipv6, 32)) |
| 79 | |
| 80 | const error = failure(IpNetwork.make(ipv4, -1)) |
| 81 | assert.strictEqual(error._tag, "NetAddressError") |
| 82 | assert.strictEqual(error.message, "prefix length must be an integer from 0 through 32") |
| 83 | }) |
| 84 | |
| 85 | it("explicitly truncates byte-aligned and partial-byte host addresses", () => { |
| 86 | assert.strictEqual(IpNetwork.format(success(IpNetwork.fromAddress(ip("10.1.2.3"), 8))), "10.0.0.0/8") |
| 87 | assert.strictEqual(IpNetwork.format(success(IpNetwork.fromAddress(ip("192.0.2.255"), 25))), "192.0.2.128/25") |
| 88 | assert.strictEqual( |
| 89 | IpNetwork.format(success(IpNetwork.fromAddress(ip("2001:db8:0:1:ffff::1"), 63))), |
| 90 | "2001:db8::/63" |
| 91 | ) |
| 92 | assert.strictEqual( |
| 93 | IpNetwork.format(success(IpNetwork.fromAddress(ip("2001:db8:0:1:ffff::1"), 65))), |
| 94 | "2001:db8:0:1:8000::/65" |
| 95 | ) |
| 96 | }) |
| 97 | |
| 98 | it("requires derived addresses to revalidate refinements", () => { |
| 99 | const multicast = NetAddress.ipv4FromBytesUnsafe(new Uint8Array([224, 0, 0, 1])) |
| 100 | if (!NetAddress.isMulticast(multicast)) throw new Error("expected multicast test address") |
| 101 | const direct = IpNetwork.fromAddressUnsafe(multicast, 0) |
| 102 | const viaInterface = IpNetwork.fromInterface(IpInterface.makeUnsafe(multicast, 0)) |
| 103 | for (const derived of [direct, viaInterface]) { |
| 104 | assert.strictEqual(NetAddress.formatIp(derived.address), "0.0.0.0") |
| 105 | assert.isFalse(NetAddress.isMulticast(derived.address)) |
| 106 | const last = IpNetwork.lastAddress(derived) |
| 107 | assert.strictEqual(NetAddress.formatIp(last), "255.255.255.255") |
| 108 | assert.isFalse(NetAddress.isMulticast(last)) |
| 109 | } |
| 110 | |
| 111 | const unspecifiedNetwork = IpNetwork.makeUnsafe(NetAddress.ipv4Unspecified, 0) |
| 112 | const broadcast = IpNetwork.lastAddress(unspecifiedNetwork) |
| 113 | assert.isFalse(NetAddress.isUnspecified(broadcast)) |
| 114 | assert.isTrue(NetAddress.isBroadcast(broadcast)) |
| 115 | |
| 116 | const loopback = ip("::1") |
| 117 | assert.isTrue(NetAddress.isLoopback(loopback)) |
| 118 | if (NetAddress.isLoopback(loopback)) { |
| 119 | const directLoopback = IpNetwork.fromAddressUnsafe(loopback, 64) |
| 120 | const interfaceLoopback = IpNetwork.fromInterface(IpInterface.makeUnsafe(loopback, 64)) |
| 121 | for (const derived of [directLoopback, interfaceLoopback]) { |
| 122 | assert.strictEqual(NetAddress.formatIp(derived.address), "::") |
| 123 | assert.isFalse(NetAddress.isLoopback(derived.address)) |
| 124 | assert.isTrue(NetAddress.isUnspecified(derived.address)) |
| 125 | } |
| 126 | } |
| 127 | }) |
| 128 | |
| 129 | it("parses strict CIDR and formats canonical address text", () => { |
| 130 | assert.strictEqual(IpNetwork.format(network("192.0.2.0/24")), "192.0.2.0/24") |
| 131 | assert.strictEqual( |
| 132 | IpNetwork.format(network("2001:0DB8:0000:0000:0000:0000:0000:0000/32")), |
| 133 | "2001:db8::/32" |
| 134 | ) |
| 135 | assert.strictEqual(IpNetwork.format(network("::ffff:192.0.2.0/120")), "::ffff:192.0.2.0/120") |
| 136 | }) |
| 137 | |
| 138 | it("rejects malformed and non-network CIDR input", () => { |
| 139 | for ( |
| 140 | const input of [ |
| 141 | "1.2.3.4", |
| 142 | "1.2.3.4/", |
| 143 | "/24", |
| 144 | "1.2.3.4/24/1", |
| 145 | "1.2.3.4/+24", |
| 146 | "1.2.3.4/024", |
| 147 | "1.2.3.4/24 ", |
| 148 | "1.2.3.4/1e1", |
| 149 | "1.2.3.4/33", |
| 150 | "10.1.2.3/8", |
| 151 | "2001:db8::1/32" |
| 152 | ] |
| 153 | ) { |
| 154 | failure(IpNetwork.fromString(input)) |
| 155 | } |
| 156 | const parseError = failure(IpNetwork.fromString("1.2.3.4/+24")) |
| 157 | assert.strictEqual(parseError.message, "prefix length must be an unpadded ASCII decimal integer") |
| 158 | failure(IpNetwork.ipv4FromString("::/0")) |
| 159 | failure(IpNetwork.ipv6FromString("0.0.0.0/0")) |
| 160 | }) |
| 161 | |
| 162 | it("uses numeric equality and matching hashes", () => { |
| 163 | const first = network("2001:0DB8:0:0:0:0:0:0/32") |
| 164 | const second = network("2001:db8::/32") |
| 165 | assert.isTrue(Equal.equals(first, second)) |
| 166 | assert.strictEqual(Hash.hash(first), Hash.hash(second)) |
| 167 | assert.isFalse(Equal.equals(first, network("2001:db8::/33"))) |
| 168 | }) |
| 169 | |
| 170 | it("computes first, last, and exact address counts", () => { |
| 171 | const ipv4All = network("0.0.0.0/0") |
| 172 | const ipv6All = network("::/0") |
| 173 | assert.strictEqual(NetAddress.formatIp(IpNetwork.firstAddress(ipv4All)), "0.0.0.0") |
| 174 | assert.strictEqual(NetAddress.formatIp(IpNetwork.lastAddress(ipv4All)), "255.255.255.255") |
| 175 | assert.strictEqual(IpNetwork.addressCount(ipv4All), BigInt(1) << BigInt(32)) |
| 176 | assert.strictEqual(NetAddress.formatIp(IpNetwork.lastAddress(ipv6All)), "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff") |
| 177 | assert.strictEqual(IpNetwork.addressCount(ipv6All), BigInt(1) << BigInt(128)) |
| 178 | |
| 179 | const ipv4Pair = network("192.0.2.0/31") |
| 180 | assert.strictEqual(NetAddress.formatIp(IpNetwork.lastAddress(ipv4Pair)), "192.0.2.1") |
| 181 | assert.strictEqual(IpNetwork.addressCount(ipv4Pair), BigInt(2)) |
| 182 | assert.strictEqual(NetAddress.formatIp(IpNetwork.lastAddress(network("192.0.2.128/25"))), "192.0.2.255") |
| 183 | const ipv6Pair = network("2001:db8::/127") |
| 184 | assert.strictEqual(NetAddress.formatIp(IpNetwork.lastAddress(ipv6Pair)), "2001:db8::1") |
| 185 | assert.strictEqual(IpNetwork.addressCount(ipv6Pair), BigInt(2)) |
| 186 | assert.strictEqual( |
| 187 | NetAddress.formatIp(IpNetwork.lastAddress(network("2001:db8:0:1:8000::/65"))), |
| 188 | "2001:db8:0:1:ffff:ffff:ffff:ffff" |
| 189 | ) |
| 190 | |
| 191 | const ipv4Host = network("255.255.255.255/32") |
| 192 | assert.strictEqual(NetAddress.formatIp(IpNetwork.lastAddress(ipv4Host)), "255.255.255.255") |
| 193 | assert.strictEqual(IpNetwork.addressCount(ipv4Host), BigInt(1)) |
| 194 | }) |
| 195 | |
| 196 | it("checks address containment in data-first and data-last forms", () => { |
| 197 | const self = network("192.0.2.0/25") |
| 198 | assert.isTrue(IpNetwork.contains(self, ip("192.0.2.0"))) |
| 199 | assert.isTrue(IpNetwork.contains(self, ip("192.0.2.64"))) |
| 200 | assert.isTrue(IpNetwork.contains(ip("192.0.2.127"))(self)) |
| 201 | assert.isFalse(IpNetwork.contains(self, ip("192.0.1.255"))) |
| 202 | assert.isFalse(IpNetwork.contains(self, ip("192.0.2.128"))) |
| 203 | assert.isFalse(IpNetwork.contains(self, ip("::ffff:192.0.2.1"))) |
| 204 | }) |
| 205 | |
| 206 | it("checks network containment and overlap", () => { |
| 207 | const parent = network("2001:db8::/63") |
| 208 | const child = network("2001:db8:0:1:8000::/65") |
| 209 | const sibling = network("2001:db8:0:1::/65") |
| 210 | const disjoint = network("2001:db8:0:2::/63") |
| 211 | assert.isTrue(IpNetwork.containsNetwork(parent, parent)) |
| 212 | assert.isTrue(IpNetwork.containsNetwork(child)(parent)) |
| 213 | assert.isFalse(IpNetwork.containsNetwork(child, parent)) |
| 214 | assert.isTrue(IpNetwork.overlaps(parent, child)) |
| 215 | assert.isTrue(IpNetwork.overlaps(child, parent)) |
| 216 | assert.isFalse(IpNetwork.overlaps(child, sibling)) |
| 217 | assert.isFalse(IpNetwork.overlaps(parent, disjoint)) |
| 218 | assert.isFalse(IpNetwork.overlaps(parent, network("0.0.0.0/0"))) |
| 219 | }) |
| 220 | |
| 221 | it("provides checked unsafe wrappers", () => { |
| 222 | assert.strictEqual(IpNetwork.format(IpNetwork.fromAddressUnsafe(ip("10.1.2.3"), 8)), "10.0.0.0/8") |
| 223 | assert.strictEqual(IpNetwork.format(IpNetwork.fromStringUnsafe("10.0.0.0/8")), "10.0.0.0/8") |
| 224 | assert.throws(() => IpNetwork.makeUnsafe(ip("10.1.2.3"), 8)) |
| 225 | assert.throws(() => IpNetwork.fromAddressUnsafe(ip("10.1.2.3"), 33)) |
| 226 | assert.throws(() => IpNetwork.fromStringUnsafe("10.1.2.3/8")) |
| 227 | }) |
| 228 | |
| 229 | it("decodes declarations and canonically encodes Schema strings", () => { |
| 230 | const ipv6 = Schema.decodeUnknownSync(Schema.Ipv6NetworkFromString)( |
| 231 | "2001:0DB8:0000:0000:0000:0000:0000:0000/32" |
| 232 | ) |
| 233 | assert.strictEqual(Schema.encodeSync(Schema.Ipv6NetworkFromString)(ipv6), "2001:db8::/32") |
| 234 | assert.strictEqual( |
| 235 | IpNetwork.format(Schema.decodeUnknownSync(Schema.IpNetworkFromString)("192.0.2.0/24")), |
| 236 | "192.0.2.0/24" |
| 237 | ) |
| 238 | assert.throws(() => Schema.decodeUnknownSync(Schema.IpNetworkFromString)("192.0.2.1/24")) |
| 239 | assert.throws(() => |
| 240 | Schema.decodeUnknownSync(Schema.IpNetwork)({ _tag: "IpNetwork", address: ip("192.0.2.0"), prefixLength: 24 }) |
| 241 | ) |
| 242 | }) |
| 243 | |
| 244 | it("matches independent range models for generated networks", () => { |
| 245 | fc.assert(fc.property( |
| 246 | fc.tuple( |
| 247 | fc.integer({ min: 0, max: 255 }), |
| 248 | fc.integer({ min: 0, max: 255 }), |
| 249 | fc.integer({ min: 0, max: 255 }), |
| 250 | fc.integer({ min: 0, max: 255 }), |
| 251 | fc.integer({ min: 0, max: 32 }) |
| 252 | ), |
| 253 | ([a, b, c, d, prefix]) => { |
| 254 | const address = success(NetAddress.ipv4FromOctets([a, b, c, d])) |
| 255 | const value = success(IpNetwork.fromAddress(address, prefix)) |
| 256 | const input = (((a * 256 + b) * 256 + c) * 256 + d) >>> 0 |
| 257 | const hostBits = 32 - prefix |
| 258 | const hostMask = hostBits === 32 ? 0xffffffff : 2 ** hostBits - 1 |
| 259 | const first = (input & ~hostMask) >>> 0 |
| 260 | const last = (first | hostMask) >>> 0 |
| 261 | const parsed = success(IpNetwork.fromString(IpNetwork.format(value))) |
| 262 | const firstAddress = IpNetwork.firstAddress(value) |
| 263 | const lastAddress = IpNetwork.lastAddress(value) |
| 264 | if (!NetAddress.isIpv4Address(firstAddress) || !NetAddress.isIpv4Address(lastAddress)) { |
| 265 | assert.fail("expected IPv4 bounds") |
| 266 | } |
| 267 | assert.isTrue(Equal.equals(parsed, value)) |
| 268 | assert.deepStrictEqual(NetAddress.ipv4ToOctets(firstAddress), [ |
| 269 | first >>> 24, |
| 270 | (first >>> 16) & 0xff, |
| 271 | (first >>> 8) & 0xff, |
| 272 | first & 0xff |
| 273 | ]) |
| 274 | assert.deepStrictEqual(NetAddress.ipv4ToOctets(lastAddress), [ |
| 275 | last >>> 24, |
| 276 | (last >>> 16) & 0xff, |
| 277 | (last >>> 8) & 0xff, |
| 278 | last & 0xff |
| 279 | ]) |
| 280 | assert.strictEqual(IpNetwork.addressCount(value), BigInt(2) ** BigInt(hostBits)) |
| 281 | assert.isTrue(IpNetwork.contains(value, address)) |
| 282 | } |
| 283 | )) |
| 284 | |
| 285 | fc.assert(fc.property( |
| 286 | fc.tuple( |
| 287 | fc.array(fc.integer({ min: 0, max: 0xffff }), { minLength: 8, maxLength: 8 }), |
| 288 | fc.integer({ min: 0, max: 128 }) |
| 289 | ), |
| 290 | ([segments, prefix]) => { |
| 291 | const address = success(NetAddress.ipv6FromSegments( |
| 292 | segments as [number, number, number, number, number, number, number, number] |
| 293 | )) |
| 294 | const value = success(IpNetwork.fromAddress(address, prefix)) |
| 295 | const input = segments.reduce((value, segment) => (value << BigInt(16)) | BigInt(segment), BigInt(0)) |
| 296 | const hostBits = BigInt(128 - prefix) |
| 297 | const hostMask = (BigInt(1) << hostBits) - BigInt(1) |
| 298 | const toBigInt = (address: NetAddress.Ipv6Address) => |
| 299 | NetAddress.ipv6ToSegments(address).reduce( |
| 300 | (value, segment) => (value << BigInt(16)) | BigInt(segment), |
| 301 | BigInt(0) |
| 302 | ) |
| 303 | const firstAddress = IpNetwork.firstAddress(value) |
| 304 | const lastAddress = IpNetwork.lastAddress(value) |
| 305 | if (!NetAddress.isIpv6Address(firstAddress) || !NetAddress.isIpv6Address(lastAddress)) { |
| 306 | assert.fail("expected IPv6 bounds") |
| 307 | } |
| 308 | assert.isTrue(Equal.equals(success(IpNetwork.fromString(IpNetwork.format(value))), value)) |
| 309 | assert.strictEqual(toBigInt(firstAddress), input & ~hostMask) |
| 310 | assert.strictEqual(toBigInt(lastAddress), input | hostMask) |
| 311 | assert.strictEqual(IpNetwork.addressCount(value), BigInt(1) << hostBits) |
| 312 | assert.isTrue(IpNetwork.contains(value, address)) |
| 313 | } |
| 314 | )) |
| 315 | }) |
| 316 | }) |