diff --git a/CHANGELOG.md b/CHANGELOG.md index d4630f0..4e48a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## `6.x` +- Add address arithmetic methods `next()`, `previous()` and `offset(int $offset)` + to `Contracts\ArithmeticInterface`. Over/underflowing the address space throws + `Exception\OverflowException`. - Split embedding strategies into canonical and non-canonical packers via the new `Strategy\CanonicalEmbeddingInterface` bridge. - Add strict parsing methods to the new `Contracts\FactoryInterface` interface: diff --git a/docs/04-helpers.md b/docs/04-helpers.md index c53634c..2bf1f24 100644 --- a/docs/04-helpers.md +++ b/docs/04-helpers.md @@ -173,6 +173,27 @@ $clientIp = IP::factory('d6be:583:71a4:aa67:b07a::c7'); $hostIp->getCommonCidr($clientIp); // int(60) ``` +### Address Arithmetic + +Arithmetic is performed within the address space of the instance. For an +IPv4-embedded `Multi` address the embedded IPv4 address is stepped and re-packed +according to its embedding strategy (preserving any bits the strategy does not +own), so stepping never silently escapes the embedded range. Crossing either +edge of the address space (for example, `next()` on `255.255.255.255`) throws an +`OverflowException`. + +```php +next()->getDotAddress(); // string("12.34.56.79") +$ip->previous()->getDotAddress(); // string("12.34.56.77") +$ip->offset(256)->getDotAddress(); // string("12.34.57.78") +$ip->offset(-79)->getDotAddress(); // string("12.34.55.255") +``` + ## `IPv6` vs `Multi`? The `Multi` class tries to deal with both IPv4 and IPv6 interchangeably which diff --git a/docs/10-api.md b/docs/10-api.md index 2793867..ddd4d81 100644 --- a/docs/10-api.md +++ b/docs/10-api.md @@ -11,6 +11,9 @@ | `isVersion6()` | `bool` | ✓ | ✓ | ✓ | | `getNetworkIp(int $cidr)` | Static `IpInterface` | ✓ | ✓ | ✓ | | `getBroadcastIp(int $cidr)` | Static `IpInterface` | ✓ | ✓ | ✓ | +| `next()` | Static `IpInterface` | ✓ | ✓ | ✓ | +| `previous()` | Static `IpInterface` | ✓ | ✓ | ✓ | +| `offset(int $offset)` | Static `IpInterface` | ✓ | ✓ | ✓ | | `inRange(IpInterface $ip, int $cidr)` | `bool` | ✓ | ✓ | ✓ | | `getCommonCidr(IpInterface $ip)` | `int` | ✓ | ✓ | ✓ | | `isMapped()` | `bool` | ✓ | ✓ | ✓ | diff --git a/src/AbstractIP.php b/src/AbstractIP.php index 3004e17..57c1b76 100644 --- a/src/AbstractIP.php +++ b/src/AbstractIP.php @@ -114,6 +114,21 @@ public function getBroadcastIp(int $cidr) )); } + public function next() + { + return $this->offset(1); + } + + public function previous() + { + return $this->offset(-1); + } + + public function offset(int $offset) + { + return new static(Binary::addIntegerOffset($this->getBinary(), $offset)); + } + public function inRange(IpInterface $ip, int $cidr): bool { if (!$this->isSameByteLength($ip)) { diff --git a/src/Contracts/ArithmeticInterface.php b/src/Contracts/ArithmeticInterface.php index 13a5322..ba9d299 100644 --- a/src/Contracts/ArithmeticInterface.php +++ b/src/Contracts/ArithmeticInterface.php @@ -28,4 +28,28 @@ public function getNetworkIp(int $cidr); * @return static */ public function getBroadcastIp(int $cidr); + + /** + * Get the Next Address + * + * @throws \Darsyn\IP\Exception\OverflowException + * @return static + */ + public function next(); + + /** + * Get the Previous Address + * + * @throws \Darsyn\IP\Exception\OverflowException + * @return static + */ + public function previous(); + + /** + * Get an Offset Address + * + * @throws \Darsyn\IP\Exception\OverflowException + * @return static + */ + public function offset(int $offset); } diff --git a/src/Version/Multi.php b/src/Version/Multi.php index 4979eeb..6ba4794 100644 --- a/src/Version/Multi.php +++ b/src/Version/Multi.php @@ -65,6 +65,16 @@ private static function packIntoCanonical(EmbeddingStrategyInterface $strategy, return $strategy->pack($binary); } + /** Graceful degradation for a user-defined embedding strategy that doesn't implement the CanonicalEmbeddingInterface. */ + private static function packIntoNonCanonical(EmbeddingStrategyInterface $strategy, string $ipv6, string $ipv4): string + { + if ($strategy instanceof CanonicalEmbeddingInterface) { + return $strategy->packIntoNonCanonical($ipv6, $ipv4); + } + /** @phpstan-ignore method.deprecated */ + return $strategy->pack($ipv4); + } + /** @deprecated Use fromProtocol() or fromBinary() instead. */ public static function factory(string $ip, ?EmbeddingStrategyInterface $strategy = null): self { @@ -234,6 +244,18 @@ public function getBroadcastIp(int $cidr): self return new static(parent::getBroadcastIp($cidr)->getBinary(), clone $this->embeddingStrategy); } + public function offset(int $offset): self + { + if ($this->isEmbedded()) { + $v4 = (new IPv4($this->getShortBinary()))->offset($offset)->getBinary(); + return new static( + self::packIntoNonCanonical($this->embeddingStrategy, $this->getBinary(), $v4), + clone $this->embeddingStrategy + ); + } + return new static(parent::offset($offset)->getBinary(), clone $this->embeddingStrategy); + } + public function inRange(IpInterface $ip, int $cidr): bool { try { diff --git a/tests/DataProvider/IPv4.php b/tests/DataProvider/IPv4.php index 8dabe82..711496b 100644 --- a/tests/DataProvider/IPv4.php +++ b/tests/DataProvider/IPv4.php @@ -121,6 +121,37 @@ public static function getBroadcastIpAddresses() ]; } + /** @return list */ + public static function getOffsetAddresses() + { + return [ + ['12.34.56.78', 1, '12.34.56.79'], + ['12.34.56.78', -1, '12.34.56.77'], + ['12.34.56.78', 0, '12.34.56.78'], + ['12.34.56.78', 256, '12.34.57.78'], + ['12.34.56.255', 1, '12.34.57.0'], + ['12.34.57.0', -1, '12.34.56.255'], + ['0.255.255.255', 1, '1.0.0.0'], + ['1.0.0.0', -1, '0.255.255.255'], + ['255.255.255.254', 1, '255.255.255.255'], + ['0.0.0.1', -1, '0.0.0.0'], + ]; + } + + /** @return list */ + public static function getOffsetOverflowValues() + { + return [ + ['255.255.255.255', 1], + ['255.255.255.255', 2], + ['255.255.255.0', 256], + ['0.0.0.0', -1], + ['0.0.0.0', -2], + ['255.255.255.255', \PHP_INT_MAX], + ['0.0.0.0', \PHP_INT_MIN], + ]; + } + /** @return list */ public static function getValidInRangeIpAddresses() { diff --git a/tests/DataProvider/IPv6.php b/tests/DataProvider/IPv6.php index 38bf707..178327d 100644 --- a/tests/DataProvider/IPv6.php +++ b/tests/DataProvider/IPv6.php @@ -119,6 +119,36 @@ public static function getBroadcastIpAddresses() ]; } + /** @return list */ + public static function getOffsetAddresses() + { + return [ + ['2001:db8::1', 1, '2001:db8::2'], + ['2001:db8::2', -1, '2001:db8::1'], + ['2001:db8::1', 0, '2001:db8::1'], + ['2001:db8::ff', 1, '2001:db8::100'], + ['2001:db8::100', -1, '2001:db8::ff'], + ['2001:db8::ffff', 1, '2001:db8::1:0'], + ['2001:db8::1:0', -1, '2001:db8::ffff'], + ['2001:db8::1', 256, '2001:db8::101'], + ['::', 1, '::1'], + ['::1', -1, '::'], + ]; + } + + /** @return list */ + public static function getOffsetOverflowValues() + { + return [ + ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 1], + ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 2], + ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', \PHP_INT_MAX], + ['::', -1], + ['::', -2], + ['::', \PHP_INT_MIN], + ]; + } + /** @return list */ public static function getValidInRangeIpAddresses() { diff --git a/tests/DataProvider/Multi.php b/tests/DataProvider/Multi.php index 04322d7..4ece5d2 100644 --- a/tests/DataProvider/Multi.php +++ b/tests/DataProvider/Multi.php @@ -182,6 +182,40 @@ public static function getBroadcastIpAddresses() ); } + /** @return list */ + public static function getOffsetAddresses() + { + return [ + // Embedded (IPv4) addresses step within the IPv4 address space. + ['12.34.56.78', 1, '12.34.56.79'], + ['12.34.56.78', -1, '12.34.56.77'], + ['12.34.56.78', 0, '12.34.56.78'], + ['12.34.56.255', 1, '12.34.57.0'], + ['255.255.255.254', 1, '255.255.255.255'], + // Non-embedded (IPv6) addresses step within the IPv6 address space. + ['2001:db8::1', 1, '2001:db8::2'], + ['2001:db8::2', -1, '2001:db8::1'], + ['2001:db8::ff', 1, '2001:db8::100'], + ['2001:db8::1', 256, '2001:db8::101'], + ]; + } + + /** @return list */ + public static function getOffsetOverflowValues() + { + return [ + // Embedded addresses overflow at the edge of the IPv4 space rather + // than escaping into the surrounding IPv6 space. + ['255.255.255.255', 1], + ['255.255.255.255', \PHP_INT_MAX], + ['0.0.0.0', -1], + // Non-embedded addresses overflow at the edge of the IPv6 space. + ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 1], + ['::', -1], + ['::', \PHP_INT_MIN], + ]; + } + /** @return list */ public static function getValidInRangeIpAddresses() { diff --git a/tests/Version/IPv4Test.php b/tests/Version/IPv4Test.php index 4825e6c..45f237f 100644 --- a/tests/Version/IPv4Test.php +++ b/tests/Version/IPv4Test.php @@ -15,6 +15,7 @@ use Darsyn\IP\Exception\InvalidBinaryException; use Darsyn\IP\Exception\InvalidCidrException; use Darsyn\IP\Exception\InvalidIpAddressException; +use Darsyn\IP\Exception\OverflowException; use Darsyn\IP\Exception\WrongVersionException; use Darsyn\IP\Formatter\ConsistentFormatter; use Darsyn\IP\IpInterface; @@ -251,6 +252,41 @@ public function testBroadcastIp(string $expected, int $cidr): void $this->assertSame($expected, $ip->getBroadcastIp($cidr)->getDotAddress()); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv4::getOffsetAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv4DataProvider::class, 'getOffsetAddresses')] + public function testOffset(string $start, int $offset, string $expected): void + { + $result = IP::fromProtocol($start)->offset($offset); + $this->assertInstanceOf(IP::class, $result); + $this->assertSame($expected, $result->getDotAddress()); + } + + /** @test */ + #[PHPUnit\Test] + public function testNextAndPreviousAreOffsetByOne(): void + { + $ip = IP::fromProtocol('12.34.56.78'); + $this->assertSame($ip->offset(1)->getBinary(), $ip->next()->getBinary()); + $this->assertSame($ip->offset(-1)->getBinary(), $ip->previous()->getBinary()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv4::getOffsetOverflowValues() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv4DataProvider::class, 'getOffsetOverflowValues')] + public function testOffsetThrowsExceptionOnOverflow(string $start, int $offset): void + { + $ip = IP::fromProtocol($start); + $this->expectException(OverflowException::class); + $ip->offset($offset); + } + /** * @test * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv4::getValidInRangeIpAddresses() diff --git a/tests/Version/IPv6Test.php b/tests/Version/IPv6Test.php index 3a5b13f..20b43a7 100644 --- a/tests/Version/IPv6Test.php +++ b/tests/Version/IPv6Test.php @@ -15,6 +15,7 @@ use Darsyn\IP\Exception\InvalidBinaryException; use Darsyn\IP\Exception\InvalidCidrException; use Darsyn\IP\Exception\InvalidIpAddressException; +use Darsyn\IP\Exception\OverflowException; use Darsyn\IP\Exception\WrongVersionException; use Darsyn\IP\Formatter\ConsistentFormatter; use Darsyn\IP\Formatter\NativeFormatter; @@ -316,6 +317,41 @@ public function testBroadcastIp(string $expected, int $cidr): void $this->assertSame($expected, $ip->getBroadcastIp($cidr)->getCompactedAddress()); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv6::getOffsetAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv6DataProvider::class, 'getOffsetAddresses')] + public function testOffset(string $start, int $offset, string $expected): void + { + $result = IP::fromProtocol($start)->offset($offset); + $this->assertInstanceOf(IP::class, $result); + $this->assertSame($expected, $result->getCompactedAddress()); + } + + /** @test */ + #[PHPUnit\Test] + public function testNextAndPreviousAreOffsetByOne(): void + { + $ip = IP::fromProtocol('2001:db8::a60:8a2e:370:7334'); + $this->assertSame($ip->offset(1)->getBinary(), $ip->next()->getBinary()); + $this->assertSame($ip->offset(-1)->getBinary(), $ip->previous()->getBinary()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv6::getOffsetOverflowValues() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv6DataProvider::class, 'getOffsetOverflowValues')] + public function testOffsetThrowsExceptionOnOverflow(string $start, int $offset): void + { + $ip = IP::fromProtocol($start); + $this->expectException(OverflowException::class); + $ip->offset($offset); + } + /** * @test * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv6::getValidInRangeIpAddresses() diff --git a/tests/Version/MultiTest.php b/tests/Version/MultiTest.php index e49e340..2e6eea1 100644 --- a/tests/Version/MultiTest.php +++ b/tests/Version/MultiTest.php @@ -16,6 +16,7 @@ use Darsyn\IP\Contracts\VersionIdentityInterface; use Darsyn\IP\Exception\InvalidBinaryException; use Darsyn\IP\Exception\InvalidIpAddressException; +use Darsyn\IP\Exception\OverflowException; use Darsyn\IP\Exception\WrongVersionException; use Darsyn\IP\Formatter\ConsistentFormatter; use Darsyn\IP\IpInterface; @@ -259,6 +260,55 @@ public function testBroadcastIp(string $initial, string $expected, int $cidr): v $this->assertSame($expected, $ip->getBroadcastIp($cidr)->getProtocolAppropriateAddress()); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getOffsetAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getOffsetAddresses')] + public function testOffset(string $start, int $offset, string $expected): void + { + $result = IP::fromProtocol($start)->offset($offset); + $this->assertInstanceOf(IP::class, $result); + $this->assertSame($expected, $result->getProtocolAppropriateAddress()); + } + + /** @test */ + #[PHPUnit\Test] + public function testNextAndPreviousAreOffsetByOne(): void + { + $ip = IP::fromProtocol('12.34.56.78'); + $this->assertSame($ip->offset(1)->getBinary(), $ip->next()->getBinary()); + $this->assertSame($ip->offset(-1)->getBinary(), $ip->previous()->getBinary()); + } + + /** @test */ + #[PHPUnit\Test] + public function testEmbeddedOffsetPreservesNonEmbeddedBits(): void + { + // A non-canonical 6to4 address: embedded IPv4 192.0.2.1 with a non-zero + // interface ID. Stepping must re-pack non-canonically (bits 48-127 kept). + $ip = IP::fromBinary(Binary::fromHex('2002c00002010000dead00000000beef'), new Strategy\Derived()); + $stepped = $ip->offset(1); + $this->assertSame('2002c00002020000dead00000000beef', Binary::toHex($stepped->getBinary())); + // A naive step of the raw 16-byte sequence would instead alter the + // interface ID and leave the embedded IPv4 address untouched. + $this->assertNotSame(Binary::increment($ip->getBinary()), $stepped->getBinary()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getOffsetOverflowValues() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getOffsetOverflowValues')] + public function testOffsetThrowsExceptionOnOverflow(string $start, int $offset): void + { + $ip = IP::fromProtocol($start); + $this->expectException(OverflowException::class); + $ip->offset($offset); + } + /** * @test * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getValidInRangeIpAddresses()