From 3f3577fd2766c531ecbc8e6d40d423ff9b9f15c2 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Thu, 25 Jul 2024 00:05:27 +0200 Subject: [PATCH] Add support for IPv4-IPv6 conversion --- components/ModifierTest.php | 2 +- docs/interfaces/7.0/ipv6.md | 6 +++ interfaces/IPv6/Converter.php | 79 +++++++++++++++++++++++-------- interfaces/IPv6/ConverterTest.php | 4 +- 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/components/ModifierTest.php b/components/ModifierTest.php index 3996ae90..f5947fdb 100644 --- a/components/ModifierTest.php +++ b/components/ModifierTest.php @@ -883,7 +883,7 @@ public static function ipv6NormalizationUriProvider(): iterable yield 'no change happen with a IPv4 host' => [ 'inputUri' => 'https://127.0.0.1/foo/bar', 'compressedUri' => 'https://127.0.0.1/foo/bar', - 'expandedUri' => 'https://127.0.0.1/foo/bar', + 'expandedUri' => 'https://[0000:0000:0000:0000:0000:ffff:7f00:0001]/foo/bar', ]; yield 'IPv6 gets expanded if needed' => [ diff --git a/docs/interfaces/7.0/ipv6.md b/docs/interfaces/7.0/ipv6.md index a2140561..374eaa2b 100644 --- a/docs/interfaces/7.0/ipv6.md +++ b/docs/interfaces/7.0/ipv6.md @@ -39,6 +39,12 @@ echo Converter::expand('[1050:0000:0000:0000:0005:0000:300c:326b]'); // returns [1050:0000:0000:0000:0005:0000:300c:326b] ``` +The compress method can also be used to convert a IPv6-mapped IPv4 address into a IPv4 +address in its decimal form. Conversely, presented with an IPv4 address, +the `Converted::expand` method will do the opposite and convert the +IPv4 address into its IPv6-mapped IPv4 address representation in long form. + + To complement the host related methods the class also provide stricter IPv6 compress and expand methods using the `Converter::compressIp` and `Converter::expandId` methods. Those methods will throw if the submitted value is not a valid IPv6 representation. diff --git a/interfaces/IPv6/Converter.php b/interfaces/IPv6/Converter.php index 80488309..bb745583 100644 --- a/interfaces/IPv6/Converter.php +++ b/interfaces/IPv6/Converter.php @@ -18,22 +18,51 @@ final class Converter { - public static function compressIp(string $ipv6): string + private const IPV4_MAPPED_PREFIX = '::ffff:'; + private const IPV6_6TO4_PREFIX = '2002:'; + + /** + * Significant 10 bits of IP to detect Zone ID regular expression pattern. + * + * @var string + */ + private const HOST_ADDRESS_BLOCK = "\xfe\x80"; + + public static function compressIp(string $ipAddress): string { - if (false === filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + if (false === filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { throw new ValueError('The submitted IP is not a valid IPv6 address.'); } - return (string) inet_ntop((string) inet_pton($ipv6)); + $ipAddress = strtolower((string) inet_ntop((string) inet_pton($ipAddress))); + if (str_starts_with($ipAddress, self::IPV4_MAPPED_PREFIX)) { + return substr($ipAddress, 7); + } + + if (!str_starts_with($ipAddress, self::IPV6_6TO4_PREFIX)) { + return $ipAddress; + } + + $hexPart = substr($ipAddress, 5, 9); + $hexParts = explode(':', $hexPart); + + return (string) match (true) { + count($hexParts) < 2 => $ipAddress, + default => long2ip((int) hexdec($hexParts[0]) * 65536 + (int) hexdec($hexParts[1])), + }; } - public static function expandIp(string $ipv6): string + public static function expandIp(string $ipAddress): string { - if (false === filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + if (false !== filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipAddress = self::IPV4_MAPPED_PREFIX.$ipAddress; + } + + if (false === filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { throw new ValueError('The submitted IP is not a valid IPv6 address.'); } - $hex = (array) unpack("H*hex", (string) inet_pton($ipv6)); + $hex = (array) unpack("H*hex", (string) inet_pton($ipAddress)); return implode(':', str_split(strtolower($hex['hex'] ?? ''), 4)); } @@ -41,14 +70,14 @@ public static function expandIp(string $ipv6): string public static function compress(Stringable|string|null $host): ?string { $components = self::parse($host); - if (null === $components['ipv6']) { + if (null === $components['ipAddress']) { return match ($host) { null => $host, default => (string) $host, }; } - $components['ipv6'] = self::compressIp($components['ipv6']); + $components['ipAddress'] = self::compressIp($components['ipAddress']); return self::build($components); } @@ -56,24 +85,28 @@ public static function compress(Stringable|string|null $host): ?string public static function expand(Stringable|string|null $host): ?string { $components = self::parse($host); - if (null === $components['ipv6']) { + if (null === $components['ipAddress']) { return match ($host) { null => $host, default => (string) $host, }; } - $components['ipv6'] = self::expandIp($components['ipv6']); + $components['ipAddress'] = self::expandIp($components['ipAddress']); return self::build($components); } private static function build(array $components): string { - $components['ipv6'] ??= null; + $components['ipAddress'] ??= null; $components['zoneIdentifier'] ??= null; - return '['.$components['ipv6'].match ($components['zoneIdentifier']) { + if (null !== $components['ipAddress'] && false !== filter_var($components['ipAddress'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $components['ipAddress']; + } + + return '['.$components['ipAddress'].match ($components['zoneIdentifier']) { null => '', default => '%'.$components['zoneIdentifier'], }.']'; @@ -82,32 +115,40 @@ private static function build(array $components): string /**] * @param Stringable|string|null $host * - * @return array{ipv6:?string, zoneIdentifier:?string} + * @return array{ipAddress:string|null, zoneIdentifier:string|null} */ private static function parse(Stringable|string|null $host): array { if ($host === null) { - return ['ipv6' => null, 'zoneIdentifier' => null]; + return ['ipAddress' => null, 'zoneIdentifier' => null]; } $host = (string) $host; if ($host === '') { - return ['ipv6' => null, 'zoneIdentifier' => null]; + return ['ipAddress' => null, 'zoneIdentifier' => null]; + } + + if (false !== filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return ['ipAddress' => self::IPV4_MAPPED_PREFIX.$host, 'zoneIdentifier' => null]; } if (!str_starts_with($host, '[')) { - return ['ipv6' => null, 'zoneIdentifier' => null]; + return ['ipAddress' => null, 'zoneIdentifier' => null]; } if (!str_ends_with($host, ']')) { - return ['ipv6' => null, 'zoneIdentifier' => null]; + return ['ipAddress' => null, 'zoneIdentifier' => null]; } [$ipv6, $zoneIdentifier] = explode('%', substr($host, 1, -1), 2) + [1 => null]; if (false === filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - return ['ipv6' => null, 'zoneIdentifier' => null]; + return ['ipAddress' => null, 'zoneIdentifier' => null]; } - return ['ipv6' => $ipv6, 'zoneIdentifier' => $zoneIdentifier]; + return match (true) { + null === $zoneIdentifier, + is_string($ipv6) && str_starts_with((string)inet_pton($ipv6), self::HOST_ADDRESS_BLOCK) => ['ipAddress' => $ipv6, 'zoneIdentifier' => $zoneIdentifier], + default => ['ipAddress' => null, 'zoneIdentifier' => null], + }; } } diff --git a/interfaces/IPv6/ConverterTest.php b/interfaces/IPv6/ConverterTest.php index 48aee71a..af057a7d 100644 --- a/interfaces/IPv6/ConverterTest.php +++ b/interfaces/IPv6/ConverterTest.php @@ -32,7 +32,7 @@ public static function ipv6NormalizationUriProvider(): iterable yield 'no change happen with a IPv4 ipv6' => [ 'ipv6' => '127.0.0.1', 'ipv6Compressed' => '127.0.0.1', - 'ipv6Expanded' => '127.0.0.1', + 'ipv6Expanded' => '[0000:0000:0000:0000:0000:ffff:7f00:0001]', ]; yield 'IPv6 gets expanded if needed' => [ @@ -68,8 +68,6 @@ public static function invalidIpv6(): iterable { yield 'hostname' => ['invalidIp' => 'example.com']; - yield 'IPv4' => ['invalidIp' => '127.0.0.2']; - yield 'ip future' => ['invalidIp' => '[v42.fdfsffd]']; yield 'IPv6 with zoneIdentifier' => ['invalidIp' => 'fe80::a%25en1'];