• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

markrogoyski / ipv4-subnet-calculator-php / 22473410450

27 Feb 2026 04:56AM UTC coverage: 82.311% (-15.6%) from 97.956%
22473410450

push

github

markrogoyski
Changes for version 5.0.0.

577 of 701 new or added lines in 13 files covered. (82.31%)

577 of 701 relevant lines covered (82.31%)

71.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

11.11
/src/Subnets.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace IPv4;
6

7
use IPv4\Internal\CidrBlock;
8
use IPv4\Internal\IPv4;
9

10
/**
11
 * Collection operations on arrays of Subnet instances.
12
 *
13
 * Provides methods for working with multiple subnets:
14
 *  - Aggregate multiple subnets into minimal CIDR blocks
15
 *  - Summarize subnets into a single supernet
16
 *
17
 * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - CIDR
18
 */
19
final class Subnets
20
{
21
    /**
22
     * Aggregate multiple subnets into the smallest possible supernet(s).
23
     *
24
     * Combines contiguous subnets into larger summary routes.
25
     * Overlapping and duplicate subnets are handled by removing redundant entries.
26
     *
27
     * @param Subnet[] $subnets Subnets to aggregate
28
     *
29
     * @return Subnet[] Aggregated supernets
30
     *
31
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - CIDR
32
     */
33
    public static function aggregate(array $subnets): array
34
    {
35
        if (empty($subnets)) {
27✔
36
            return [];
1✔
37
        }
38

39
        $blocks = self::collectBlocks($subnets);
26✔
40
        $blocks = self::removeContainedBlocks($blocks);
26✔
41
        $blocks = self::mergeAdjacentBlocks($blocks);
26✔
42

43
        return self::blocksToSubnets($blocks);
26✔
44
    }
45

46
    /**
47
     * Find the smallest single supernet that contains all given subnets.
48
     *
49
     * Unlike aggregate(), this always returns a single subnet but may include
50
     * addresses not in any of the input subnets.
51
     *
52
     * @param Subnet[] $subnets Subnets to summarize
53
     *
54
     * @return Subnet The smallest supernet containing all inputs
55
     *
56
     * @throws \InvalidArgumentException If the subnet array is empty
57
     *
58
     * @link https://datatracker.ietf.org/doc/html/rfc4632 RFC 4632 - CIDR
59
     */
60
    public static function summarize(array $subnets): Subnet
61
    {
62
        self::assertNonEmpty($subnets);
13✔
63

64
        if ($single = self::summarizeSingle($subnets)) {
12✔
65
            return $single;
1✔
66
        }
67

68
        $span = self::findAddressSpan($subnets);
11✔
69
        $prefix = self::findSmallestCoveringPrefix($span['min'], $span['max']);
11✔
70
        $start = self::alignToPrefix($span['min'], $prefix);
11✔
71

72
        return self::subnetFromStartAndPrefix($start, $prefix);
11✔
73
    }
74

75
    /**
76
     * Assert that the subnet array is not empty.
77
     *
78
     * @param Subnet[] $subnets
79
     *
80
     * @throws \InvalidArgumentException If the array is empty
81
     */
82
    private static function assertNonEmpty(array $subnets): void
83
    {
NEW
84
        if (empty($subnets)) {
×
NEW
85
            throw new \InvalidArgumentException(
×
NEW
86
                'Cannot summarize empty subnet array'
×
NEW
87
            );
×
88
        }
89
    }
90

91
    /**
92
     * Return a normalized subnet if the array contains exactly one element.
93
     *
94
     * @param Subnet[] $subnets
95
     *
96
     * @return Subnet|null The single subnet, or null if multiple subnets
97
     */
98
    private static function summarizeSingle(array $subnets): ?Subnet
99
    {
NEW
100
        if (\count($subnets) !== 1) {
×
NEW
101
            return null;
×
102
        }
103

NEW
104
        $subnet = $subnets[0];
×
105

NEW
106
        return new Subnet((string) $subnet->networkAddress(), $subnet->networkSize());
×
107
    }
108

109
    /**
110
     * Find the minimum and maximum addresses spanned by the subnets.
111
     *
112
     * @param Subnet[] $subnets Non-empty array of subnets
113
     *
114
     * @return array{min: int, max: int}
115
     */
116
    private static function findAddressSpan(array $subnets): array
117
    {
NEW
118
        $first = true;
×
NEW
119
        $min = 0;
×
NEW
120
        $max = 0;
×
121

NEW
122
        foreach ($subnets as $subnet) {
×
NEW
123
            $start = $subnet->networkAddress()->asInteger();
×
NEW
124
            $end = $start + $subnet->addressCount() - 1;
×
125

NEW
126
            if ($first) {
×
NEW
127
                $min = $start;
×
NEW
128
                $max = $end;
×
NEW
129
                $first = false;
×
130
            } else {
NEW
131
                if ($start < $min) {
×
NEW
132
                    $min = $start;
×
133
                }
NEW
134
                if ($end > $max) {
×
NEW
135
                    $max = $end;
×
136
                }
137
            }
138
        }
139

NEW
140
        return ['min' => $min, 'max' => $max];
×
141
    }
142

143
    /**
144
     * Find the smallest CIDR prefix that can cover the address span.
145
     *
146
     * @param int $min Minimum address in the span
147
     * @param int $max Maximum address in the span
148
     *
149
     * @return int The CIDR prefix (0-32)
150
     *
151
     * @throws \InvalidArgumentException If no valid prefix can cover the span
152
     */
153
    private static function findSmallestCoveringPrefix(int $min, int $max): int
154
    {
NEW
155
        for ($prefix = 32; $prefix >= 0; $prefix--) {
×
NEW
156
            $blockSize = $prefix === 0 ? IPv4::ADDRESS_SPACE : (1 << (32 - $prefix));
×
NEW
157
            $networkStart = ((int) ($min / $blockSize)) * $blockSize;
×
NEW
158
            $networkEnd = $networkStart + $blockSize - 1;
×
159

NEW
160
            if ($networkStart <= $min && $networkEnd >= $max) {
×
NEW
161
                return $prefix;
×
162
            }
163
        }
164

NEW
165
        throw new \InvalidArgumentException(
×
NEW
166
            'Cannot summarize: no valid supernet found'
×
NEW
167
        );
×
168
    }
169

170
    /**
171
     * Align an address to a CIDR prefix boundary.
172
     *
173
     * @param int $address The address to align
174
     * @param int $prefix  The CIDR prefix
175
     *
176
     * @return int The aligned network start address
177
     */
178
    private static function alignToPrefix(int $address, int $prefix): int
179
    {
NEW
180
        $blockSize = $prefix === 0 ? IPv4::ADDRESS_SPACE : (1 << (32 - $prefix));
×
181

NEW
182
        return ((int) ($address / $blockSize)) * $blockSize;
×
183
    }
184

185
    /**
186
     * Create a subnet from a start address and prefix.
187
     *
188
     * @param int $start  Network start address as integer
189
     * @param int $prefix CIDR prefix (0-32)
190
     *
191
     * @return Subnet
192
     */
193
    private static function subnetFromStartAndPrefix(int $start, int $prefix): Subnet
194
    {
NEW
195
        return new Subnet(self::unsignedToIp($start), $prefix);
×
196
    }
197

198
    /**
199
     * Convert subnets into sorted CIDR blocks.
200
     *
201
     * @param Subnet[] $subnets
202
     *
203
     * @return CidrBlock[]
204
     */
205
    private static function collectBlocks(array $subnets): array
206
    {
NEW
207
        $blocks = [];
×
NEW
208
        foreach ($subnets as $subnet) {
×
NEW
209
            $start = $subnet->networkAddress()->asInteger();
×
NEW
210
            $blocks[] = new CidrBlock($start, $subnet->networkSize());
×
211
        }
212

NEW
213
        \usort($blocks, self::compareByStart(...));
×
214

NEW
215
        return $blocks;
×
216
    }
217

218
    /**
219
     * Remove blocks fully contained by another block.
220
     *
221
     * @param CidrBlock[] $blocks
222
     *
223
     * @return CidrBlock[]
224
     */
225
    private static function removeContainedBlocks(array $blocks): array
226
    {
NEW
227
        $filtered = [];
×
NEW
228
        foreach ($blocks as $block) {
×
NEW
229
            $isContained = false;
×
NEW
230
            foreach ($filtered as $existing) {
×
NEW
231
                if ($block->startInt() >= $existing->startInt() && $block->endInt() <= $existing->endInt()) {
×
NEW
232
                    $isContained = true;
×
NEW
233
                    break;
×
234
                }
235
            }
NEW
236
            if (!$isContained) {
×
NEW
237
                $filtered = \array_filter(
×
NEW
238
                    $filtered,
×
NEW
239
                    fn(CidrBlock $existing) => !(
×
NEW
240
                        $existing->startInt() >= $block->startInt()
×
NEW
241
                        && $existing->endInt() <= $block->endInt()
×
NEW
242
                    )
×
NEW
243
                );
×
NEW
244
                $filtered[] = $block;
×
245
            }
246
        }
247

NEW
248
        \usort($filtered, self::compareByStart(...));
×
249

NEW
250
        return $filtered;
×
251
    }
252

253
    /**
254
     * Merge adjacent blocks with identical prefixes.
255
     *
256
     * @param CidrBlock[] $blocks
257
     *
258
     * @return CidrBlock[]
259
     */
260
    private static function mergeAdjacentBlocks(array $blocks): array
261
    {
NEW
262
        $merged = true;
×
NEW
263
        while ($merged) {
×
NEW
264
            $merged = false;
×
NEW
265
            $newBlocks = [];
×
NEW
266
            $used = [];
×
267

NEW
268
            for ($i = 0; $i < \count($blocks); $i++) {
×
NEW
269
                if (isset($used[$i])) {
×
NEW
270
                    continue;
×
271
                }
272

NEW
273
                $current = $blocks[$i];
×
NEW
274
                $foundMerge = false;
×
275

NEW
276
                for ($j = $i + 1; $j < \count($blocks); $j++) {
×
NEW
277
                    $other = $blocks[$j];
×
278

NEW
279
                    if ($current->prefix() === $other->prefix() && $current->prefix() >= 1) {
×
NEW
280
                        $prefix = $current->prefix();
×
NEW
281
                        $currentEnd = $current->endInt();
×
282

NEW
283
                        if ($currentEnd + 1 === $other->startInt()) {
×
NEW
284
                            $newPrefix = $prefix - 1;
×
NEW
285
                            $newBlockSize = $newPrefix === 0 ? IPv4::ADDRESS_SPACE : (1 << (32 - $newPrefix));
×
286

NEW
287
                            if (($current->startInt() % $newBlockSize) === 0) {
×
NEW
288
                                $newBlocks[] = new CidrBlock($current->startInt(), $newPrefix);
×
NEW
289
                                $used[$i] = true;
×
NEW
290
                                $used[$j] = true;
×
NEW
291
                                $merged = true;
×
NEW
292
                                $foundMerge = true;
×
NEW
293
                                break;
×
294
                            }
295
                        }
296
                    }
297
                }
298

NEW
299
                if (!$foundMerge) {
×
NEW
300
                    $newBlocks[] = $current;
×
NEW
301
                    $used[$i] = true;
×
302
                }
303
            }
304

NEW
305
            $blocks = $newBlocks;
×
NEW
306
            \usort($blocks, self::compareByStart(...));
×
307
        }
308

NEW
309
        return $blocks;
×
310
    }
311

312
    /**
313
     * Convert blocks into Subnet objects.
314
     *
315
     * @param CidrBlock[] $blocks
316
     *
317
     * @return Subnet[]
318
     */
319
    private static function blocksToSubnets(array $blocks): array
320
    {
NEW
321
        $result = [];
×
NEW
322
        foreach ($blocks as $block) {
×
NEW
323
            $ip = self::unsignedToIp($block->startInt());
×
NEW
324
            $result[] = new Subnet($ip, $block->prefix());
×
325
        }
326

NEW
327
        return $result;
×
328
    }
329

330
    /**
331
     * Sort blocks by start ascending, then by end descending.
332
     */
333
    private static function compareByStart(CidrBlock $a, CidrBlock $b): int
334
    {
NEW
335
        $startComparison = $a->startInt() <=> $b->startInt();
×
NEW
336
        if ($startComparison !== 0) {
×
NEW
337
            return $startComparison;
×
338
        }
339

NEW
340
        return $b->endInt() <=> $a->endInt();
×
341
    }
342

343
    /**
344
     * Convert IP integer to dotted quad.
345
     *
346
     * @param int $ip
347
     *
348
     * @return string
349
     */
350
    private static function unsignedToIp(int $ip): string
351
    {
NEW
352
        if ($ip < 0 || $ip > IPv4::MAX_ADDRESS) {
×
NEW
353
            throw new \InvalidArgumentException(
×
NEW
354
                "IP integer out of range: {$ip}"
×
NEW
355
            );
×
356
        }
357

NEW
358
        return \long2ip($ip);
×
359
    }
360
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc