Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions docs/04-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +176 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-canonical bit preservation caveat missing from docs

The statement "preserving any bits the strategy does not own" is only accurate for embedding strategies that implement CanonicalEmbeddingInterface. For a legacy or user-defined strategy that implements only EmbeddingStrategyInterface, packIntoNonCanonical falls back to calling the deprecated pack($ipv4), which creates a fully canonical address and silently discards any non-canonical bits (SLA ID, interface ID, etc.) — the same graceful-degradation behaviour already present in getNetworkIp and getBroadcastIp.

Adding a short qualifier (e.g. "…re-packed according to its embedding strategy (preserving any bits the strategy does not own, provided the strategy implements CanonicalEmbeddingInterface)…") would prevent users with custom strategies from being surprised.

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
<?php
use Darsyn\IP\Version\IPv4 as IP;

$ip = IP::fromProtocol('12.34.56.78');
// Step forwards or backwards within the address space.
$ip->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
Expand Down
3 changes: 3 additions & 0 deletions docs/10-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | ✓ | ✓ | ✓ |
Expand Down
15 changes: 15 additions & 0 deletions src/AbstractIP.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
24 changes: 24 additions & 0 deletions src/Contracts/ArithmeticInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
22 changes: 22 additions & 0 deletions src/Version/Multi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions tests/DataProvider/IPv4.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,37 @@ public static function getBroadcastIpAddresses()
];
}

/** @return list<array{string, int, string}> */
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<array{string, int}> */
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<array{string, string, int}> */
public static function getValidInRangeIpAddresses()
{
Expand Down
30 changes: 30 additions & 0 deletions tests/DataProvider/IPv6.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,36 @@ public static function getBroadcastIpAddresses()
];
}

/** @return list<array{string, int, string}> */
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<array{string, int}> */
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<array{string, string, int}> */
public static function getValidInRangeIpAddresses()
{
Expand Down
34 changes: 34 additions & 0 deletions tests/DataProvider/Multi.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,40 @@ public static function getBroadcastIpAddresses()
);
}

/** @return list<array{string, int, string}> */
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<array{string, int}> */
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<array{string, string, int}> */
public static function getValidInRangeIpAddresses()
{
Expand Down
36 changes: 36 additions & 0 deletions tests/Version/IPv4Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
36 changes: 36 additions & 0 deletions tests/Version/IPv6Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
Loading