diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e48a36..233d26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## `6.x` +- Expand `Contracts\OutputInterface`: `getOctets()`, `getSegments()`, and + canonical `toString()` (deferable method for Stringable equivalent). - Add address arithmetic methods `next()`, `previous()` and `offset(int $offset)` to `Contracts\ArithmeticInterface`. Over/underflowing the address space throws `Exception\OverflowException`. diff --git a/docs/03-overview.md b/docs/03-overview.md index b38d758..5bc7c84 100644 --- a/docs/03-overview.md +++ b/docs/03-overview.md @@ -245,12 +245,43 @@ $ip = IP::factory('80.111.111.112'); $ip->getBinary(); // string("Poop") ``` +### Octets + +`getOctets()` returns the individual bytes of the binary string as an array of +integers between `0` and `255` (four entries for an IPv4 address, sixteen for +IPv6). Calling it on an instance of `Multi` that contains a version 4 address +returns the four octets of the embedded IPv4 address. + +```php +getOctets(); // array(127, 0, 0, 1) +``` + +### Segments + +`getSegments()` returns the eight 16-bit segments (hextets) of an IPv6 address +as an array of integers between `0` and `65535`. It is only available for the +`IPv6` and `Multi` classes; calling it on an instance of `Multi` that contains a +version 4 address will result in a `WrongVersionException` being thrown. + +```php +getSegments(); // array(8193, 3512, 0, 0, 0, 0, 0, 1) +``` + ## String Casting -Previous versions of this documentation specified that string casting for IP -objects was enabled to get the binary string, but that was unfortunately untrue. -Now, string casting is enabled for all version classes and the `__toString()` -method is promised in `Darsyn\IP\IpInterface`: +The canonical method for casting to a string is `toString()`. +`\Stringable` is a PHP 8 feature and not available on all supported PHP versions, +so `__toString()` is implemented independently (deferring to `toString()`). The +returned string is in protocol-appropriate notation and can be re-parsed via +`fromProtocol()`. - String casting the `IPv4` class is the equivalent of `$ip->getDotAddress()`. - String casting the `IPv6` class is the equivalent of diff --git a/docs/10-api.md b/docs/10-api.md index ddd4d81..a09d1fa 100644 --- a/docs/10-api.md +++ b/docs/10-api.md @@ -4,6 +4,8 @@ |---------------------------------------|----------------------|------|------|-------| | `factory(string $ip, [$strategy])` | Static `IpInterface` | ✓ | ✓ | ✓ | | `getBinary()` | `string` | ✓ | ✓ | ✓ | +| `getOctets()` | `list` | ✓ | ✓ | ✓ | +| `toString()` | `string` | ✓ | ✓ | ✓ | | `equals(IpInterface $ip)` | `bool` | ✓ | ✓ | ✓ | | `getVersion()` | `int` | ✓ | ✓ | ✓ | | `isVersion(int $version)` | `bool` | ✓ | ✓ | ✓ | @@ -35,6 +37,7 @@ | `getCompactedAddress()` | `string` | | ✓ | ✓ | | `getExpandedAddress()` | `string` | | ✓ | ✓ | | `getCompactedAddress()` | `string` | | ✓ | ✓ | +| `getSegments()` | `list` | | ✓ | ✓ | | `getMulticastScope()` | `?int` | | ✓ | ✓ | | `isUniqueLocal()` | `bool` | | ✓ | ✓ | | `isUnicast()` | `bool` | | ✓ | ✓ | diff --git a/src/AbstractIP.php b/src/AbstractIP.php index 57c1b76..8768e89 100644 --- a/src/AbstractIP.php +++ b/src/AbstractIP.php @@ -74,6 +74,15 @@ final public function getBinary(): string return $this->ip; } + public function getOctets(): array + { + $octets = []; + foreach (MbString::split($this->getBinary()) as $byte) { + $octets[] = \ord($byte); + } + return $octets; + } + public function equals(IpInterface $ip): bool { return $this->getBinary() === $ip->getBinary(); diff --git a/src/Contracts/Output6Interface.php b/src/Contracts/Output6Interface.php index 9c7adc0..769dcf1 100644 --- a/src/Contracts/Output6Interface.php +++ b/src/Contracts/Output6Interface.php @@ -28,4 +28,12 @@ public function getCompactedAddress(): string; * @throws \Darsyn\IP\Exception\IpException */ public function getExpandedAddress(): string; + + /** + * Get the IP address as an array of the eight 16-bit segments (hextets). + * + * @throws \Darsyn\IP\Exception\WrongVersionException for multi-embedded IPv4 addresses + * @return list> + */ + public function getSegments(): array; } diff --git a/src/Contracts/OutputInterface.php b/src/Contracts/OutputInterface.php index 9c44b93..754a804 100644 --- a/src/Contracts/OutputInterface.php +++ b/src/Contracts/OutputInterface.php @@ -12,6 +12,22 @@ interface OutputInterface /** Get Binary Representation */ public function getBinary(): string; + /** + * Get the IP address as an array of octets; the individual bytes of the + * address (four for an IPv4 address, sixteen for IPv6). + * + * @return list> + */ + public function getOctets(): array; + + /** + * Get the IP address as a string in its protocol-appropriate notation. + * + * This is the canonical string form regardless of version, re-parseable via + * the fromProtocol() named constructor. + */ + public function toString(): string; + /** Implement string casting for IP objects. */ public function __toString(): string; } diff --git a/src/Formatter/ConsistentFormatter.php b/src/Formatter/ConsistentFormatter.php index c81d299..8321538 100644 --- a/src/Formatter/ConsistentFormatter.php +++ b/src/Formatter/ConsistentFormatter.php @@ -25,7 +25,7 @@ public function ntop(string $binary): string private function ntopVersion6(string $binary): string { $hex = Binary::toHex($binary); - $parts = \str_split($hex, 4); + $parts = MbString::split($hex, 4); $zeroes = \array_map(static function ($part) { return '0000' === $part; }, $parts); diff --git a/src/Util/Binary.php b/src/Util/Binary.php index 2859cac..f0cf2ba 100644 --- a/src/Util/Binary.php +++ b/src/Util/Binary.php @@ -37,7 +37,7 @@ public static function fromHumanReadable(string $asciiBinarySequence): string } return '' === $asciiBinarySequence ? '' : static::fromHex(\implode('', \array_map(static function ($byteRepresentation) { return MbString::padString(\dechex((int) \bindec($byteRepresentation)), 2, '0', \STR_PAD_LEFT); - }, \function_exists('mb_str_split') ? \mb_str_split($asciiBinarySequence, 8, '8bit') : \str_split($asciiBinarySequence, 8)))); + }, MbString::split($asciiBinarySequence, 8)))); } /** @throws \InvalidArgumentException */ @@ -46,7 +46,7 @@ public static function toHumanReadable(string $binary): string $hex = static::toHex($binary); return \implode('', \array_map(static function ($character) { return MbString::padString(\decbin((int) \hexdec($character)), 8, '0', \STR_PAD_LEFT); - }, \function_exists('mb_str_split') ? \mb_str_split($hex, 2, '8bit') : \str_split($hex, 2))); + }, MbString::split($hex, 2))); } /** diff --git a/src/Util/MbString.php b/src/Util/MbString.php index 321a0e5..2b3ccd6 100644 --- a/src/Util/MbString.php +++ b/src/Util/MbString.php @@ -27,6 +27,24 @@ public static function subString(string $str, int $start, ?int $length = null): : \substr($str, $start) . ''; } + /** + * Split a string into fixed-length byte chunks (the mbstring-overload-safe + * equivalent of `str_split()`). + * + * @param int<1, max> $length + * @return list + */ + public static function split(string $str, int $length = 1): array + { + if ('' === $str) { + // str_split of an empty string returns `['']` on PHP 7.1-7.3, and `[]` on PHP 7.4 + return []; + } + return \function_exists('\\mb_str_split') + ? \mb_str_split($str, $length, '8bit') + : \str_split($str, $length); + } + /** * PHP doesn't have a function for multibyte string padding. This should suffice in case * PHP's internal string functions have been overloaded by the mbstring extension. diff --git a/src/Version/IPv4.php b/src/Version/IPv4.php index 516ac5a..1e0e7fe 100644 --- a/src/Version/IPv4.php +++ b/src/Version/IPv4.php @@ -249,8 +249,13 @@ public function isFutureReserved(): bool && $this->inRange(new self(Binary::fromHex('f0000000')), 4); } - public function __toString(): string + public function toString(): string { return $this->getDotAddress(); } + + public function __toString(): string + { + return $this->toString(); + } } diff --git a/src/Version/IPv6.php b/src/Version/IPv6.php index 112bf75..739e665 100644 --- a/src/Version/IPv6.php +++ b/src/Version/IPv6.php @@ -146,6 +146,15 @@ public function getCompactedAddress(/* ?ProtocolFormatterInterface $formatter = } } + public function getSegments(): array + { + $segments = []; + foreach (MbString::split($this->getBinary(), 2) as $word) { + $segments[] = (\ord($word[0]) << 8) + \ord($word[1]); + } + return $segments; + } + public function getVersion(): int { return 6; @@ -294,8 +303,13 @@ private function isIetfProtocolAssignment(): bool && !$this->inRange(new self(Binary::fromHex('20010030000000000000000000000000')), 28); } - public function __toString(): string + public function toString(): string { return $this->getCompactedAddress(); } + + public function __toString(): string + { + return $this->toString(); + } } diff --git a/src/Version/Multi.php b/src/Version/Multi.php index 6ba4794..779743a 100644 --- a/src/Version/Multi.php +++ b/src/Version/Multi.php @@ -209,6 +209,21 @@ public function getDotAddress(/* ?ProtocolFormatterInterface $formatter = null * throw new Exception\WrongVersionException(4, 6, (string) $this); } + public function getOctets(): array + { + return $this->isEmbedded() + ? (new IPv4($this->getShortBinary()))->getOctets() + : parent::getOctets(); + } + + public function getSegments(): array + { + if ($this->isEmbedded()) { + throw new Exception\WrongVersionException(6, 4, (string) $this); + } + return parent::getSegments(); + } + public function getVersion(): int { return $this->isEmbedded() ? 4 : 6; @@ -424,8 +439,13 @@ private function isVersion4CompatibleWithCurrentStrategy(IpInterface $ip): bool return $this->isVersion4() && $ip->isVersion4() && $this->embeddingStrategy->isEmbedded($ip->getBinary()); } - public function __toString(): string + public function toString(): string { return $this->getProtocolAppropriateAddress(); } + + public function __toString(): string + { + return $this->toString(); + } } diff --git a/tests/DataProvider/IPv4.php b/tests/DataProvider/IPv4.php index 711496b..15bfdf3 100644 --- a/tests/DataProvider/IPv4.php +++ b/tests/DataProvider/IPv4.php @@ -51,6 +51,17 @@ public static function getValidIpAddresses() return \array_merge(self::getValidBinarySequences(), self::getValidProtocolIpAddresses()); } + /** @return list>}> */ + public static function getOctetAddresses() + { + return [ + ['119.14.113.44', [119, 14, 113, 44]], + ['192.168.1.254', [192, 168, 1, 254]], + ['0.0.0.0', [0, 0, 0, 0]], + ['255.255.255.255', [255, 255, 255, 255]], + ]; + } + /** @return list */ public static function getInvalidIpAddresses() { diff --git a/tests/DataProvider/IPv6.php b/tests/DataProvider/IPv6.php index 178327d..515758d 100644 --- a/tests/DataProvider/IPv6.php +++ b/tests/DataProvider/IPv6.php @@ -50,6 +50,26 @@ public static function getValidIpAddresses() return \array_merge(self::getValidBinarySequences(), self::getValidProtocolIpAddresses()); } + /** @return list>}> */ + public static function getOctetAddresses() + { + return [ + ['2001:db8::a60:8a2e:370:7334', [32, 1, 13, 184, 0, 0, 0, 0, 10, 96, 138, 46, 3, 112, 115, 52]], + ['::', [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], + ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]], + ]; + } + + /** @return list>}> */ + public static function getSegmentAddresses() + { + return [ + ['2001:db8::a60:8a2e:370:7334', [8193, 3512, 0, 0, 2656, 35374, 880, 29492]], + ['::', [0, 0, 0, 0, 0, 0, 0, 0]], + ['ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', [65535, 65535, 65535, 65535, 65535, 65535, 65535, 65535]], + ]; + } + /** @return list */ public static function getInvalidIpAddresses() { diff --git a/tests/DataProvider/Multi.php b/tests/DataProvider/Multi.php index 4ece5d2..0a91e37 100644 --- a/tests/DataProvider/Multi.php +++ b/tests/DataProvider/Multi.php @@ -66,6 +66,28 @@ public static function getValidIpAddresses() return \array_merge(self::getValidBinarySequences(), self::getValidProtocolIpAddresses()); } + /** @return list>}> */ + public static function getOctetAddresses() + { + return [ + // Embedded (Mapped) addresses report the four octets of the embedded IPv4 address. + ['119.14.113.44', [119, 14, 113, 44]], + ['12.34.56.78', [12, 34, 56, 78]], + // Non-embedded addresses report all sixteen octets. + ['2001:db8::a60:8a2e:370:7334', [32, 1, 13, 184, 0, 0, 0, 0, 10, 96, 138, 46, 3, 112, 115, 52]], + ['::1', [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]], + ]; + } + + /** @return list>}> */ + public static function getSegmentAddresses() + { + return [ + ['2001:db8::a60:8a2e:370:7334', [8193, 3512, 0, 0, 2656, 35374, 880, 29492]], + ['::1', [0, 0, 0, 0, 0, 0, 0, 1]], + ]; + } + /** @return list */ public static function getValidIpVersion4Addresses() { diff --git a/tests/Version/IPv4Test.php b/tests/Version/IPv4Test.php index 45f237f..32b7498 100644 --- a/tests/Version/IPv4Test.php +++ b/tests/Version/IPv4Test.php @@ -540,6 +540,33 @@ public function testStringCasting(string $value, string $expectedHex, string $ex $this->assertSame($expectedDot, (string) $ip); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv4::getValidProtocolIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv4DataProvider::class, 'getValidProtocolIpAddresses')] + public function testToStringReturnsCanonicalNotation(string $value, string $expectedHex, string $expectedDot): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($expectedDot, $ip->toString()); + $this->assertSame((string) $ip, $ip->toString()); + $this->assertSame($ip->getBinary(), IP::fromProtocol($ip->toString())->getBinary()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv4::getOctetAddresses() + * @param list> $expectedOctets + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv4DataProvider::class, 'getOctetAddresses')] + public function testGetOctets(string $value, array $expectedOctets): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($expectedOctets, $ip->getOctets()); + } + /** @test */ #[PHPUnit\Test] public function testPerCallFormatterOverridesGlobal(): void diff --git a/tests/Version/IPv6Test.php b/tests/Version/IPv6Test.php index 20b43a7..e8b9572 100644 --- a/tests/Version/IPv6Test.php +++ b/tests/Version/IPv6Test.php @@ -604,6 +604,46 @@ public function testStringCasting(string $value, string $hex, string $expanded, $this->assertSame($compacted, (string) $ip); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv6::getValidProtocolIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv6DataProvider::class, 'getValidProtocolIpAddresses')] + public function testToStringReturnsCanonicalNotation(string $value, string $hex, string $expanded, string $compacted): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($compacted, $ip->toString()); + $this->assertSame((string) $ip, $ip->toString()); + $this->assertSame($ip->getBinary(), IP::fromProtocol($ip->toString())->getBinary()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv6::getOctetAddresses() + * @param list> $expectedOctets + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv6DataProvider::class, 'getOctetAddresses')] + public function testGetOctets(string $value, array $expectedOctets): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($expectedOctets, $ip->getOctets()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\IPv6::getSegmentAddresses() + * @param list> $expectedSegments + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(IPv6DataProvider::class, 'getSegmentAddresses')] + public function testGetSegments(string $value, array $expectedSegments): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($expectedSegments, $ip->getSegments()); + } + /** @test */ #[PHPUnit\Test] public function testPerCallFormatterOverridesGlobal(): void diff --git a/tests/Version/MultiTest.php b/tests/Version/MultiTest.php index 2e6eea1..d703344 100644 --- a/tests/Version/MultiTest.php +++ b/tests/Version/MultiTest.php @@ -596,6 +596,72 @@ public function testStringCasting(string $value, string $hex, string $expanded, : $this->assertSame($compacted, (string) $ip); } + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getValidProtocolIpAddresses() + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getValidProtocolIpAddresses')] + public function testToStringReturnsProtocolAppropriateNotation(string $value, string $hex, string $expanded, string $compacted, ?string $dot): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($dot ?? $compacted, $ip->toString()); + $this->assertSame((string) $ip, $ip->toString()); + $this->assertSame($ip->getBinary(), IP::fromProtocol($ip->toString())->getBinary()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getOctetAddresses() + * @param list> $expectedOctets + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getOctetAddresses')] + public function testGetOctets(string $value, array $expectedOctets): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($expectedOctets, $ip->getOctets()); + } + + /** + * @test + * @dataProvider \Darsyn\IP\Tests\DataProvider\Multi::getSegmentAddresses() + * @param list> $expectedSegments + */ + #[PHPUnit\Test] + #[PHPUnit\DataProviderExternal(MultiDataProvider::class, 'getSegmentAddresses')] + public function testGetSegmentsForNonEmbeddedAddress(string $value, array $expectedSegments): void + { + $ip = IP::fromProtocol($value); + $this->assertSame($expectedSegments, $ip->getSegments()); + } + + /** @test */ + #[PHPUnit\Test] + public function testGetSegmentsThrowsExceptionForEmbeddedAddress(): void + { + $ip = IP::fromProtocol('119.14.113.44'); + $this->expectException(WrongVersionException::class); + $ip->getSegments(); + } + + /** @test */ + #[PHPUnit\Test] + public function testGetOctetsForEmbeddedAddressWithNonDefaultStrategy(): void + { + $ip = IP::fromProtocol('12.34.56.78', new Strategy\Derived()); + $this->assertSame([12, 34, 56, 78], $ip->getOctets()); + } + + /** @test */ + #[PHPUnit\Test] + public function testGetSegmentsThrowsExceptionForEmbeddedAddressWithNonDefaultStrategy(): void + { + $ip = IP::fromProtocol('12.34.56.78', new Strategy\Derived()); + $this->expectException(WrongVersionException::class); + $ip->getSegments(); + } + /** @test */ #[PHPUnit\Test] public function testPerCallFormatterOverridesGlobalForProtocolAppropriateAddress(): void