@highstate/common 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/{chunk-WFWXDYUX.js → chunk-X5BK6JSN.js} +877 -194
  2. package/dist/chunk-X5BK6JSN.js.map +1 -0
  3. package/dist/highstate.manifest.json +12 -2
  4. package/dist/index.js +1 -1
  5. package/dist/units/databases/etcd-patch/index.js +20 -0
  6. package/dist/units/databases/etcd-patch/index.js.map +1 -0
  7. package/dist/units/databases/existing-etcd/index.js +14 -0
  8. package/dist/units/databases/existing-etcd/index.js.map +1 -0
  9. package/dist/units/databases/existing-mariadb/index.js +2 -2
  10. package/dist/units/databases/existing-mariadb/index.js.map +1 -1
  11. package/dist/units/databases/existing-mongodb/index.js +2 -2
  12. package/dist/units/databases/existing-mongodb/index.js.map +1 -1
  13. package/dist/units/databases/existing-postgresql/index.js +2 -2
  14. package/dist/units/databases/existing-postgresql/index.js.map +1 -1
  15. package/dist/units/databases/existing-redis/index.js +2 -2
  16. package/dist/units/databases/existing-redis/index.js.map +1 -1
  17. package/dist/units/databases/existing-s3/index.js +18 -0
  18. package/dist/units/databases/existing-s3/index.js.map +1 -0
  19. package/dist/units/databases/mariadb-patch/index.js +24 -0
  20. package/dist/units/databases/mariadb-patch/index.js.map +1 -0
  21. package/dist/units/databases/mongodb-patch/index.js +24 -0
  22. package/dist/units/databases/mongodb-patch/index.js.map +1 -0
  23. package/dist/units/databases/postgresql-patch/index.js +24 -0
  24. package/dist/units/databases/postgresql-patch/index.js.map +1 -0
  25. package/dist/units/databases/redis-patch/index.js +27 -0
  26. package/dist/units/databases/redis-patch/index.js.map +1 -0
  27. package/dist/units/databases/s3-patch/index.js +25 -0
  28. package/dist/units/databases/s3-patch/index.js.map +1 -0
  29. package/dist/units/dns/record-set/index.js +14 -20
  30. package/dist/units/dns/record-set/index.js.map +1 -1
  31. package/dist/units/existing-server/index.js +3 -4
  32. package/dist/units/existing-server/index.js.map +1 -1
  33. package/dist/units/network/address-space/index.js +20 -0
  34. package/dist/units/network/address-space/index.js.map +1 -0
  35. package/dist/units/network/endpoint-filter/index.js +15 -0
  36. package/dist/units/network/endpoint-filter/index.js.map +1 -0
  37. package/dist/units/network/l3-endpoint/index.js +2 -2
  38. package/dist/units/network/l3-endpoint/index.js.map +1 -1
  39. package/dist/units/network/l4-endpoint/index.js +2 -2
  40. package/dist/units/network/l4-endpoint/index.js.map +1 -1
  41. package/dist/units/network/l7-endpoint/index.js +12 -0
  42. package/dist/units/network/l7-endpoint/index.js.map +1 -0
  43. package/dist/units/script/index.js +1 -1
  44. package/dist/units/server-patch/index.js +9 -12
  45. package/dist/units/server-patch/index.js.map +1 -1
  46. package/dist/units/ssh/key-pair/index.js +1 -1
  47. package/package.json +64 -10
  48. package/src/shared/command.ts +1 -1
  49. package/src/shared/dns.ts +11 -93
  50. package/src/shared/files.ts +3 -3
  51. package/src/shared/impl-ref.ts +4 -0
  52. package/src/shared/index.ts +2 -0
  53. package/src/shared/network/address-space.spec.ts +114 -0
  54. package/src/shared/network/address-space.ts +364 -0
  55. package/src/shared/network/address.spec.ts +109 -0
  56. package/src/shared/network/address.ts +119 -0
  57. package/src/shared/network/endpoints.spec.ts +249 -0
  58. package/src/shared/network/endpoints.ts +608 -0
  59. package/src/shared/network/index.ts +4 -0
  60. package/src/shared/network/ip.ts +236 -0
  61. package/src/shared/network/subnet.spec.ts +62 -0
  62. package/src/shared/network/subnet.ts +137 -0
  63. package/src/shared/ssh.ts +1 -1
  64. package/src/shared/tls.ts +21 -5
  65. package/src/shared/utils.ts +93 -0
  66. package/src/units/databases/etcd-patch/index.ts +23 -0
  67. package/src/units/databases/existing-etcd/index.ts +11 -0
  68. package/src/units/databases/existing-mariadb/index.ts +1 -1
  69. package/src/units/databases/existing-mongodb/index.ts +1 -1
  70. package/src/units/databases/existing-postgresql/index.ts +1 -1
  71. package/src/units/databases/existing-redis/index.ts +1 -1
  72. package/src/units/databases/existing-s3/index.ts +1 -1
  73. package/src/units/databases/mariadb-patch/index.ts +27 -0
  74. package/src/units/databases/mongodb-patch/index.ts +27 -0
  75. package/src/units/databases/postgresql-patch/index.ts +27 -0
  76. package/src/units/databases/redis-patch/index.ts +32 -0
  77. package/src/units/databases/s3-patch/index.ts +28 -0
  78. package/src/units/dns/record-set/index.ts +15 -20
  79. package/src/units/existing-server/index.ts +3 -4
  80. package/src/units/network/address-space/index.ts +20 -0
  81. package/src/units/network/endpoint-filter/index.ts +5 -5
  82. package/src/units/network/l3-endpoint/index.ts +2 -2
  83. package/src/units/network/l4-endpoint/index.ts +2 -2
  84. package/src/units/network/l7-endpoint/index.ts +2 -2
  85. package/src/units/remote-file/index.ts +12 -5
  86. package/src/units/server-patch/index.ts +10 -13
  87. package/dist/chunk-WFWXDYUX.js.map +0 -1
  88. package/dist/units/server-dns/index.js +0 -26
  89. package/dist/units/server-dns/index.js.map +0 -1
  90. package/src/shared/network.ts +0 -413
  91. package/src/units/server-dns/index.ts +0 -26
@@ -0,0 +1,364 @@
1
+ import { check } from "@highstate/contract"
2
+ import { network } from "@highstate/library"
3
+ import { addressToCidr } from "./address"
4
+ import { cidrBlockSize, ipToString, parseCidr, parseIp, subnetBaseFromCidr } from "./ip"
5
+ import { subnetToString } from "./subnet"
6
+
7
+ export type AddressSpaceArgs = {
8
+ /**
9
+ * The list of addresses to include in the address space.
10
+ *
11
+ * Supports:
12
+ * - Other address space entities;
13
+ * - Address entities;
14
+ * - Subnet entities;
15
+ * - L3 endpoint entities;
16
+ * - String representations of addresses, subnets, or ranges.
17
+ *
18
+ * The supported formats for strings are:
19
+ * - Single IP address (e.g., `192.168.1.1`);
20
+ * - CIDR notation (e.g., `192.168.1.1/24`);
21
+ * - Dash notation (e.g., `192.168.1.1-192.168.1.254`).
22
+ *
23
+ * The addresses can be a mix of IPv4 and IPv6.
24
+ */
25
+ included: InputAddressSpace[]
26
+
27
+ /**
28
+ * The list of addresses to exclude from the `addresses` list.
29
+ *
30
+ * The supported formats are the same as in `addresses`.
31
+ */
32
+ excluded?: InputAddressSpace[]
33
+ }
34
+
35
+ export type InputAddressSpace =
36
+ | network.AddressSpace
37
+ | network.Address
38
+ | network.Subnet
39
+ | network.L3Endpoint
40
+ | string
41
+
42
+ type IpFamily = network.AddressType
43
+
44
+ type IpRange = {
45
+ family: IpFamily
46
+ start: bigint
47
+ endExclusive: bigint
48
+ }
49
+
50
+ /**
51
+ * Creates an address space from a list of addresses/subnets/ranges.
52
+ *
53
+ * @param included The list of addresses to include in the address space.
54
+ * @param excluded The list of addresses to exclude from the `included` list.
55
+ * @returns The created address space entity.
56
+ */
57
+ export function createAddressSpace({
58
+ included,
59
+ excluded = [],
60
+ }: AddressSpaceArgs): network.AddressSpace {
61
+ const includedRanges = included.flatMap(resolveInputToRanges)
62
+ const excludedRanges = excluded.flatMap(resolveInputToRanges)
63
+
64
+ const normalized = normalizeRanges(includedRanges, excludedRanges)
65
+ const subnets = rangesToCanonicalSubnets(normalized)
66
+
67
+ return { subnets }
68
+ }
69
+
70
+ function resolveInputToRanges(input: InputAddressSpace): IpRange[] {
71
+ if (typeof input === "string") {
72
+ return [rangeFromString(input)]
73
+ }
74
+
75
+ if (check(network.addressSpaceEntity.schema, input)) {
76
+ return input.subnets.flatMap(subnet => [rangeFromCidr(subnetToString(subnet))])
77
+ }
78
+
79
+ if (check(network.subnetEntity.schema, input)) {
80
+ return [rangeFromCidr(subnetToString(input))]
81
+ }
82
+
83
+ if (check(network.addressEntity.schema, input)) {
84
+ return [rangeFromCidr(addressToCidr(input))]
85
+ }
86
+
87
+ if (check(network.l3EndpointEntity.schema, input)) {
88
+ if (input.type === "hostname") {
89
+ return []
90
+ }
91
+
92
+ const cidr = (input as unknown as { cidr?: unknown }).cidr
93
+ if (typeof cidr === "string") {
94
+ return [rangeFromCidr(cidr)]
95
+ }
96
+
97
+ const address = (input as unknown as { address?: unknown }).address
98
+ if (typeof address === "string") {
99
+ const parsed = parseIp(address)
100
+ const prefixLength = parsed.type === "ipv4" ? 32 : 128
101
+ return [
102
+ rangeFromParsedCidr({ family: parsed.type, base: parsed.value, prefix: prefixLength }),
103
+ ]
104
+ }
105
+
106
+ if (check(network.addressEntity.schema, address)) {
107
+ return [rangeFromCidr(addressToCidr(address))]
108
+ }
109
+
110
+ throw new Error("Invalid L3 endpoint: missing address information")
111
+ }
112
+
113
+ throw new Error("Unsupported address space input")
114
+ }
115
+
116
+ function rangeFromString(value: string): IpRange {
117
+ const trimmed = value.trim()
118
+ if (!trimmed) {
119
+ throw new Error("Empty address string")
120
+ }
121
+
122
+ if (trimmed.includes("-")) {
123
+ const [left, right, ...rest] = trimmed.split("-")
124
+ if (!left || !right || rest.length > 0) {
125
+ throw new Error(`Invalid range: "${value}"`)
126
+ }
127
+
128
+ const start = parseIp(left.trim())
129
+ const end = parseIp(right.trim())
130
+
131
+ if (start.type !== end.type) {
132
+ throw new Error(`Range must not mix IPv4 and IPv6: "${value}"`)
133
+ }
134
+
135
+ const min = start.value <= end.value ? start.value : end.value
136
+ const max = start.value <= end.value ? end.value : start.value
137
+
138
+ return {
139
+ family: start.type,
140
+ start: min,
141
+ endExclusive: max + 1n,
142
+ }
143
+ }
144
+
145
+ if (trimmed.includes("/")) {
146
+ return rangeFromCidr(trimmed)
147
+ }
148
+
149
+ const parsed = parseIp(trimmed)
150
+ return {
151
+ family: parsed.type,
152
+ start: parsed.value,
153
+ endExclusive: parsed.value + 1n,
154
+ }
155
+ }
156
+
157
+ function rangeFromCidr(cidr: string): IpRange {
158
+ const parsed = parseCidr(cidr)
159
+ return rangeFromParsedCidr({
160
+ family: parsed.type,
161
+ base: subnetBaseFromCidr(parsed),
162
+ prefix: parsed.prefixLength,
163
+ })
164
+ }
165
+
166
+ function rangeFromParsedCidr(parsed: { family: IpFamily; base: bigint; prefix: number }): IpRange {
167
+ const size = cidrBlockSize(parsed.family, parsed.prefix)
168
+
169
+ return {
170
+ family: parsed.family,
171
+ start: parsed.base,
172
+ endExclusive: parsed.base + size,
173
+ }
174
+ }
175
+
176
+ function normalizeRanges(included: IpRange[], excluded: IpRange[]): IpRange[] {
177
+ const includedByFamily = splitByFamily(included)
178
+ const excludedByFamily = splitByFamily(excluded)
179
+
180
+ const normalizedV4 = subtractMergedRanges(
181
+ mergeRanges(includedByFamily.v4),
182
+ mergeRanges(excludedByFamily.v4),
183
+ )
184
+ const normalizedV6 = subtractMergedRanges(
185
+ mergeRanges(includedByFamily.v6),
186
+ mergeRanges(excludedByFamily.v6),
187
+ )
188
+
189
+ return [...normalizedV4, ...normalizedV6]
190
+ }
191
+
192
+ function splitByFamily(ranges: IpRange[]): { v4: IpRange[]; v6: IpRange[] } {
193
+ const v4: IpRange[] = []
194
+ const v6: IpRange[] = []
195
+
196
+ for (const range of ranges) {
197
+ if (range.start >= range.endExclusive) continue
198
+
199
+ if (range.family === "ipv4") {
200
+ v4.push(range)
201
+ } else {
202
+ v6.push(range)
203
+ }
204
+ }
205
+
206
+ return { v4, v6 }
207
+ }
208
+
209
+ function mergeRanges(ranges: IpRange[]): IpRange[] {
210
+ const sorted = [...ranges].sort((a, b) => {
211
+ if (a.start === b.start) {
212
+ return a.endExclusive < b.endExclusive ? -1 : a.endExclusive > b.endExclusive ? 1 : 0
213
+ }
214
+ return a.start < b.start ? -1 : 1
215
+ })
216
+
217
+ const merged: IpRange[] = []
218
+
219
+ for (const current of sorted) {
220
+ const last = merged.at(-1)
221
+ if (!last) {
222
+ merged.push({ ...current })
223
+ continue
224
+ }
225
+
226
+ if (current.start <= last.endExclusive) {
227
+ if (current.endExclusive > last.endExclusive) {
228
+ last.endExclusive = current.endExclusive
229
+ }
230
+ continue
231
+ }
232
+
233
+ merged.push({ ...current })
234
+ }
235
+
236
+ return merged
237
+ }
238
+
239
+ function subtractMergedRanges(included: IpRange[], excluded: IpRange[]): IpRange[] {
240
+ if (included.length === 0) return []
241
+ if (excluded.length === 0) return included
242
+
243
+ const result: IpRange[] = []
244
+ let j = 0
245
+
246
+ for (const incOriginal of included) {
247
+ const inc: IpRange = { ...incOriginal }
248
+
249
+ while (j < excluded.length && excluded[j]!.endExclusive <= inc.start) {
250
+ j++
251
+ }
252
+
253
+ while (j < excluded.length) {
254
+ const exc = excluded[j]!
255
+
256
+ if (exc.start >= inc.endExclusive) {
257
+ break
258
+ }
259
+
260
+ if (exc.start <= inc.start) {
261
+ inc.start = inc.start < exc.endExclusive ? exc.endExclusive : inc.start
262
+ if (inc.start >= inc.endExclusive) {
263
+ break
264
+ }
265
+
266
+ if (exc.endExclusive <= inc.start) {
267
+ j++
268
+ }
269
+ continue
270
+ }
271
+
272
+ result.push({
273
+ family: inc.family,
274
+ start: inc.start,
275
+ endExclusive: exc.start,
276
+ })
277
+
278
+ inc.start = exc.endExclusive
279
+ if (inc.start >= inc.endExclusive) {
280
+ break
281
+ }
282
+ }
283
+
284
+ if (inc.start < inc.endExclusive) {
285
+ result.push(inc)
286
+ }
287
+ }
288
+
289
+ return result
290
+ }
291
+
292
+ function rangesToCanonicalSubnets(ranges: IpRange[]): network.Subnet[] {
293
+ const subnets: network.Subnet[] = []
294
+
295
+ for (const range of ranges) {
296
+ for (const cidr of rangeToCidrs(range)) {
297
+ const baseAddress = ipToString(cidr.family, cidr.base)
298
+
299
+ subnets.push({
300
+ type: cidr.family,
301
+ baseAddress,
302
+ prefixLength: cidr.prefix,
303
+ })
304
+ }
305
+ }
306
+
307
+ return sortSubnetsCanonical(subnets)
308
+ }
309
+
310
+ function sortSubnetsCanonical(subnets: network.Subnet[]): network.Subnet[] {
311
+ return [...subnets].sort((a, b) => {
312
+ const aFamily = a.type
313
+ const bFamily = b.type
314
+ if (aFamily !== bFamily) {
315
+ return aFamily === "ipv4" ? -1 : 1
316
+ }
317
+
318
+ const aBase = parseIp(a.baseAddress).value
319
+ const bBase = parseIp(b.baseAddress).value
320
+
321
+ if (aBase !== bBase) {
322
+ return aBase < bBase ? -1 : 1
323
+ }
324
+
325
+ return a.prefixLength - b.prefixLength
326
+ })
327
+ }
328
+
329
+ function rangeToCidrs(range: IpRange): Array<{ family: IpFamily; base: bigint; prefix: number }> {
330
+ const bits = range.family === "ipv4" ? 32 : 128
331
+ const result: Array<{ family: IpFamily; base: bigint; prefix: number }> = []
332
+
333
+ let current = range.start
334
+ while (current < range.endExclusive) {
335
+ const remaining = range.endExclusive - current
336
+ const maxAligned = current === 0n ? 1n << BigInt(bits) : current & -current
337
+ const maxByRemaining = highestPowerOfTwoAtMost(remaining)
338
+
339
+ const blockSize = maxAligned < maxByRemaining ? maxAligned : maxByRemaining
340
+ const prefix = bits - bitLength(blockSize) + 1
341
+
342
+ result.push({
343
+ family: range.family,
344
+ base: current,
345
+ prefix,
346
+ })
347
+
348
+ current += blockSize
349
+ }
350
+
351
+ return result
352
+ }
353
+
354
+ function highestPowerOfTwoAtMost(value: bigint): bigint {
355
+ if (value <= 0n) {
356
+ throw new Error("Value must be positive")
357
+ }
358
+
359
+ return 1n << BigInt(bitLength(value) - 1)
360
+ }
361
+
362
+ function bitLength(value: bigint): number {
363
+ return value.toString(2).length
364
+ }
@@ -0,0 +1,109 @@
1
+ import { network } from "@highstate/library"
2
+ import { describe, expect, it } from "vitest"
3
+ import { addressToCidr, doesAddressBelongToSubnet, mergeAddresses, parseAddress } from "./address"
4
+ import { subnetToString } from "./subnet"
5
+
6
+ describe("parseAddress", () => {
7
+ it("parses an IPv4 address string", () => {
8
+ const result = parseAddress("10.0.0.1")
9
+
10
+ expect(result.type).toBe("ipv4")
11
+ expect(result.value).toBe("10.0.0.1")
12
+ expect(addressToCidr(result)).toBe("10.0.0.1/32")
13
+ expect(subnetToString(result.subnet)).toBe("10.0.0.1/32")
14
+ expect(result.subnet.prefixLength).toBe(32)
15
+
16
+ expect(network.addressEntity.schema.safeParse(result).success).toBe(true)
17
+ })
18
+
19
+ it("parses an IPv4 CIDR string as address/prefix", () => {
20
+ const result = parseAddress("10.0.0.7/24")
21
+
22
+ expect(result.type).toBe("ipv4")
23
+ expect(result.value).toBe("10.0.0.7")
24
+ expect(addressToCidr(result)).toBe("10.0.0.7/24")
25
+ expect(subnetToString(result.subnet)).toBe("10.0.0.0/24")
26
+ expect(result.subnet.baseAddress).toBe("10.0.0.0")
27
+ expect(result.subnet.prefixLength).toBe(24)
28
+ })
29
+
30
+ it("canonicalizes IPv6 address strings", () => {
31
+ const result = parseAddress("2001:db8:0:0:0:0:0:1")
32
+
33
+ expect(result.type).toBe("ipv6")
34
+ expect(result.value).toBe("2001:db8::1")
35
+ expect(addressToCidr(result)).toBe("2001:db8::1/128")
36
+ expect(subnetToString(result.subnet)).toBe("2001:db8::1/128")
37
+ expect(result.subnet.prefixLength).toBe(128)
38
+ })
39
+
40
+ it("throws on invalid address", () => {
41
+ expect(() => parseAddress("not-an-ip")).toThrow(/Invalid/)
42
+ })
43
+
44
+ it("throws on invalid prefix", () => {
45
+ expect(() => parseAddress("10.0.0.1/33")).toThrow(/Invalid CIDR prefix length/)
46
+ })
47
+ })
48
+
49
+ describe("doesAddressBelongToSubnet", () => {
50
+ it("returns true when an IPv4 address belongs to the subnet", () => {
51
+ const address = parseAddress("10.0.0.5")
52
+ const subnet = parseAddress("10.0.0.0/24").subnet
53
+
54
+ expect(doesAddressBelongToSubnet(address, subnet)).toBe(true)
55
+ })
56
+
57
+ it("returns false when an IPv4 address does not belong to the subnet", () => {
58
+ const address = parseAddress("10.0.1.5")
59
+ const subnet = parseAddress("10.0.0.0/24").subnet
60
+
61
+ expect(doesAddressBelongToSubnet(address, subnet)).toBe(false)
62
+ })
63
+
64
+ it("treats /0 as containing every address", () => {
65
+ const address = parseAddress("10.123.45.67")
66
+ const subnet = parseAddress("0.0.0.0/0").subnet
67
+
68
+ expect(doesAddressBelongToSubnet(address, subnet)).toBe(true)
69
+ })
70
+
71
+ it("returns true when an IPv6 address belongs to the subnet", () => {
72
+ const address = parseAddress("2001:db8::1")
73
+ const subnet = parseAddress("2001:db8::/64").subnet
74
+
75
+ expect(doesAddressBelongToSubnet(address, subnet)).toBe(true)
76
+ })
77
+
78
+ it("returns false when an IPv6 address does not belong to the subnet", () => {
79
+ const address = parseAddress("2001:db9::1")
80
+ const subnet = parseAddress("2001:db8::/64").subnet
81
+
82
+ expect(doesAddressBelongToSubnet(address, subnet)).toBe(false)
83
+ })
84
+
85
+ it("returns false for mismatched address and subnet types", () => {
86
+ const address = parseAddress("10.0.0.1")
87
+ const ipv6Subnet = parseAddress("2001:db8::/64").subnet
88
+
89
+ expect(doesAddressBelongToSubnet(address, ipv6Subnet)).toBe(false)
90
+ })
91
+ })
92
+
93
+ describe("mergeAddresses", () => {
94
+ it("dedupes by cidr", () => {
95
+ const a = parseAddress("10.0.0.1")
96
+ const b = parseAddress("10.0.0.1")
97
+
98
+ expect(mergeAddresses([a, b])).toEqual([b])
99
+ })
100
+
101
+ it("keeps stable order of last occurrences", () => {
102
+ const a1 = parseAddress("10.0.0.1")
103
+ const b1 = parseAddress("10.0.0.2")
104
+ const a2 = parseAddress("10.0.0.1")
105
+ const c1 = parseAddress("10.0.0.3")
106
+
107
+ expect(mergeAddresses([a1, b1, a2, c1])).toEqual([a2, b1, c1])
108
+ })
109
+ })
@@ -0,0 +1,119 @@
1
+ import { check } from "@highstate/contract"
2
+ import { network } from "@highstate/library"
3
+ import { ipToString, parseCidr, parseIp, subnetBaseFromCidr } from "./ip"
4
+
5
+ export type InputAddress = network.Address | string
6
+
7
+ /**
8
+ * Parses and normalizes the given address string.
9
+ * If Address entity is given, it is returned as-is.
10
+ *
11
+ * @param address The address to parse.
12
+ * @returns The normalized Address entity.
13
+ */
14
+ export function parseAddress(address: InputAddress): network.Address {
15
+ if (check(network.addressEntity.schema, address)) {
16
+ return address
17
+ }
18
+
19
+ const input = address.trim()
20
+ if (!input) {
21
+ throw new Error("Empty address string")
22
+ }
23
+
24
+ const parsed = input.includes("/") ? parseCidr(input) : parseCidrFromIp(input)
25
+ const canonicalAddress = ipToString(parsed.type, parsed.ip)
26
+
27
+ const subnetBase = subnetBaseFromCidr(parsed)
28
+ const subnetBaseAddress = ipToString(parsed.type, subnetBase)
29
+
30
+ const result: network.Address = {
31
+ type: parsed.type,
32
+ value: canonicalAddress,
33
+ subnet: {
34
+ type: parsed.type,
35
+ baseAddress: subnetBaseAddress,
36
+ prefixLength: parsed.prefixLength,
37
+ },
38
+ }
39
+
40
+ const validated = network.addressEntity.schema.safeParse(result)
41
+ if (!validated.success) {
42
+ throw new Error(`Invalid address "${input}": ${validated.error.message}`)
43
+ }
44
+
45
+ return validated.data
46
+ }
47
+
48
+ /**
49
+ * Converts an address entity to its full CIDR string representation.
50
+ *
51
+ * The result format is `<address>/<prefix-length>`.
52
+ *
53
+ * @param address The address entity.
54
+ * @returns The CIDR string representation.
55
+ */
56
+ export function addressToCidr(address: network.Address): string {
57
+ return `${address.value}/${address.subnet.prefixLength}`
58
+ }
59
+
60
+ /**
61
+ * Checks whether the given address belongs to the specified subnet.
62
+ *
63
+ * @param address The address to check.
64
+ * @param subnet The subnet to check against.
65
+ * @returns True if the address belongs to the subnet, false otherwise.
66
+ */
67
+ export function doesAddressBelongToSubnet(
68
+ address: network.Address,
69
+ subnet: network.Subnet,
70
+ ): boolean {
71
+ if (address.type !== subnet.type) {
72
+ return false
73
+ }
74
+
75
+ const addressIp = parseIp(address.value)
76
+ const subnetBaseIp = parseIp(subnet.baseAddress)
77
+
78
+ if (addressIp.type !== subnet.type || subnetBaseIp.type !== subnet.type) {
79
+ return false
80
+ }
81
+
82
+ const bits = subnet.type === "ipv4" ? 32 : 128
83
+ if (subnet.prefixLength === 0) {
84
+ return true
85
+ }
86
+
87
+ const mask = ((1n << BigInt(subnet.prefixLength)) - 1n) << BigInt(bits - subnet.prefixLength)
88
+
89
+ return (addressIp.value & mask) === (subnetBaseIp.value & mask)
90
+ }
91
+
92
+ /**
93
+ * Merges duplicate addresses by their canonical CIDR key.
94
+ *
95
+ * If multiple addresses share the same `cidr`, the last one wins.
96
+ *
97
+ * @param addresses The list of addresses to merge.
98
+ * @returns The merged list of addresses with duplicates removed.
99
+ */
100
+ export function mergeAddresses(addresses: network.Address[]): network.Address[] {
101
+ const mergedMap = new Map<string, network.Address>()
102
+
103
+ for (const address of addresses) {
104
+ mergedMap.set(addressToCidr(address), address)
105
+ }
106
+
107
+ return Array.from(mergedMap.values())
108
+ }
109
+
110
+ function parseCidrFromIp(value: string) {
111
+ const parsed = parseIp(value)
112
+ const prefixLength = parsed.type === "ipv4" ? 32 : 128
113
+
114
+ return {
115
+ type: parsed.type,
116
+ ip: parsed.value,
117
+ prefixLength,
118
+ }
119
+ }