diff --git a/docs/interfaces/7.0/ipv4.md b/docs/interfaces/7.0/ipv4.md index 9fc4236a..520fd91b 100644 --- a/docs/interfaces/7.0/ipv4.md +++ b/docs/interfaces/7.0/ipv4.md @@ -62,6 +62,20 @@ $converter->toOctal('0xc0a821'); // returns "0300.0250.0002.0001" $converter->toHexadecimal('192.168.2.1.'); // returns "0xc0a821" ``` +

since version 7.5.0

+ +The `toDecimal` method will also infer the IPv4 address out of an IPv4 mapped IPv6 address or +from a 6to4 notation and two (2) new dedicated method allow to convert your IPv4 address into +either its IPv4 mapped IPv6 address representation or its 6to4 notation. + +```php +$converter = Converter::fromEnvironment(); +$converter->toDecimal('[2002:0000:0000::]') // returns 0.0.0.0 +$converter->toIPv4MapedIPv6('0xc0a821'); // returns "[::ffff:192.168.2.1]" +$converter->to6to4('0xc0a821'); // returns "[2002:00c0:a821::]" +``` + +

since version 7.2.0

It is possible to determine if a given domain is or can be converted to an IPv4 host the new `Converter::isIpv4` diff --git a/interfaces/CHANGELOG.md b/interfaces/CHANGELOG.md index d181048d..1f4c53f4 100644 --- a/interfaces/CHANGELOG.md +++ b/interfaces/CHANGELOG.md @@ -9,12 +9,15 @@ All Notable changes to `League\Uri\Interfaces` will be documented in this file - `UriInterface::toComponents` returns an associative array containing all URI components values. - `UriInterface::getUsername` returns the encoded user component of the URI. - `UriInterface::getPassword` returns the encoded scheme-specific information about how to gain authorization to access the resource. -- `Uri\IPv6\Converter` allow expanding and compressing IPv6. +- `Uri\IPv6\Converter` allows expanding and compressing IPv6. +- `Uri\IPv4\Converter::to6to4` allows converting an IPv4 into an IPv6 host using the 6to4 notation. +- `Uri\IPv4\Converter::toIPv4MappedIPv6` allows mapping an IPv4 address into an IPv6 one. ### Fixed - Adding Host resolution caching to speed up URI parsing in `UriString` - `UriString::parseAuthority` accepts `Stringable` object as valid input +- `Uri\IPv4\Converter::toDecimal` also handles 6to4 and IPv4 mapped address algorithm. ### Deprecated diff --git a/interfaces/IPv4/Converter.php b/interfaces/IPv4/Converter.php index ecd2422c..fc5dc915 100644 --- a/interfaces/IPv4/Converter.php +++ b/interfaces/IPv4/Converter.php @@ -25,6 +25,9 @@ use function preg_match; use function str_ends_with; use function substr; +use const FILTER_FLAG_IPV4; +use const FILTER_FLAG_IPV6; +use const FILTER_VALIDATE_IP; final class Converter { @@ -43,6 +46,9 @@ final class Converter '/^(?\d+)$/' => 10, ]; + private const IPV6_6TO4_PREFIX = '2002:'; + private const IPV4_MAPPED_PREFIX = '::ffff:'; + private readonly mixed $maxIPv4Number; public function __construct( @@ -95,7 +101,58 @@ public static function fromEnvironment(): self public function isIpv4(Stringable|string|null $host): bool { - return null !== $this->toDecimal($host); + if (null === $host) { + return false; + } + + if (null !== $this->toDecimal($host)) { + return true; + } + + $host = (string) $host; + if (false === filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return false; + } + + $ipAddress = strtolower((string) inet_ntop((string) inet_pton($host))); + if (str_starts_with($ipAddress, self::IPV4_MAPPED_PREFIX)) { + return false !== filter_var(substr($ipAddress, 7), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + } + + if (!str_starts_with($ipAddress, self::IPV6_6TO4_PREFIX)) { + return false; + } + + $hexParts = explode(':', substr($ipAddress, 5, 9)); + + return count($hexParts) > 1 + && false !== long2ip((int) hexdec($hexParts[0]) * 65536 + (int) hexdec($hexParts[1])); + } + + public function to6to4(Stringable|string|null $host): ?string + { + $host = $this->toDecimal($host); + if (null === $host) { + return null; + } + + /** @var array $parts */ + $parts = array_map( + fn (string $part): string => sprintf('%02x', $part), + explode('.', $host) + ); + + return '['.self::IPV6_6TO4_PREFIX . $parts[0] . $parts[1] . ':' . $parts[2] . $parts[3] . '::]'; + } + + public function toIPv4MappedIPv6(Stringable|string|null $host): ?string + { + $host = $this->toDecimal($host); + + return match ($host) { + null => null, + default => '['.self::IPV4_MAPPED_PREFIX.$host.']', + }; } public function toOctal(Stringable|string|null $host): ?string @@ -133,6 +190,29 @@ public function toHexadecimal(Stringable|string|null $host): ?string public function toDecimal(Stringable|string|null $host): ?string { $host = (string) $host; + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + $host = substr($host, 1, -1); + if (false === filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return null; + } + + $ipAddress = strtolower((string) inet_ntop((string) inet_pton($host))); + if (str_starts_with($ipAddress, self::IPV4_MAPPED_PREFIX)) { + return substr($ipAddress, 7); + } + + if (!str_starts_with($ipAddress, self::IPV6_6TO4_PREFIX)) { + return null; + } + + $hexParts = explode(':', substr($ipAddress, 5, 9)); + + return (string) match (true) { + count($hexParts) < 2 => null, + default => long2ip((int) hexdec($hexParts[0]) * 65536 + (int) hexdec($hexParts[1])), + }; + } + if (1 !== preg_match(self::REGEXP_IPV4_HOST, $host)) { return null; } diff --git a/interfaces/IPv4/ConverterTest.php b/interfaces/IPv4/ConverterTest.php index 8db8dcfd..c142ddd4 100644 --- a/interfaces/IPv4/ConverterTest.php +++ b/interfaces/IPv4/ConverterTest.php @@ -27,7 +27,14 @@ public function testParseWithAutoDetectCalculator(?string $input, ?string $expec } #[DataProvider('providerHost')] - public function testConvertToDecimal(string $input, string $decimal, string $octal, string $hexadecimal): void + public function testConvertToDecimal( + string $input, + string $decimal, + string $octal, + string $hexadecimal, + string $sixToFour, + string $ipv4Mapped, + ): void { self::assertSame($octal, Converter::fromGMP()->toOctal($input)); self::assertSame($octal, Converter::fromNative()->toOctal($input)); @@ -40,23 +47,34 @@ public function testConvertToDecimal(string $input, string $decimal, string $oct self::assertSame($hexadecimal, Converter::fromGMP()->toHexadecimal($input)); self::assertSame($hexadecimal, Converter::fromNative()->toHexadecimal($input)); self::assertSame($hexadecimal, Converter::fromBCMath()->toHexadecimal($input)); + + self::assertSame($sixToFour, Converter::fromBCMath()->to6to4($input)); + self::assertSame($sixToFour, Converter::fromNative()->to6to4($input)); + self::assertSame($sixToFour, Converter::fromBCMath()->to6to4($input)); + + self::assertSame($ipv4Mapped, Converter::fromBCMath()->toIPv4MappedIPv6($input)); + self::assertSame($ipv4Mapped, Converter::fromNative()->toIPv4MappedIPv6($input)); + self::assertSame($ipv4Mapped, Converter::fromBCMath()->toIPv4MappedIPv6($input)); + self::assertTrue(Converter::fromEnvironment()->isIpv4($input)); } public static function providerHost(): array { return [ - '0 host' => ['0', '0.0.0.0', '0000.0000.0000.0000', '0x0000'], - 'normal IP' => ['192.168.0.1', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'], - 'normal IP ending with a dot' => ['192.168.0.1.', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'], - 'octal (1)' => ['030052000001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'], - 'octal (2)' => ['0300.0250.0000.0001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'], - 'hexadecimal (1)' => ['0x', '0.0.0.0', '0000.0000.0000.0000', '0x0000'], - 'hexadecimal (2)' => ['0xffffffff', '255.255.255.255', '0377.0377.0377.0377', '0xffffffff'], - 'decimal (1)' => ['3232235521', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801'], - 'decimal (2)' => ['999999999', '59.154.201.255', '0073.0232.0311.0377', '0x3b9ac9ff'], - 'decimal (3)' => ['256', '0.0.1.0', '0000.0000.0001.0000', '0x0010'], - 'decimal (4)' => ['192.168.257', '192.168.1.1', '0300.0250.0001.0001', '0xc0a811'], + '0 host' => ['0', '0.0.0.0', '0000.0000.0000.0000', '0x0000', '[2002:0000:0000::]', '[::ffff:0.0.0.0]'], + 'normal IP' => ['192.168.0.1', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], + 'normal IP ending with a dot' => ['192.168.0.1.', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], + 'octal (1)' => ['030052000001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], + 'octal (2)' => ['0300.0250.0000.0001', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], + 'hexadecimal (1)' => ['0x', '0.0.0.0', '0000.0000.0000.0000', '0x0000', '[2002:0000:0000::]', '[::ffff:0.0.0.0]'], + 'hexadecimal (2)' => ['0xffffffff', '255.255.255.255', '0377.0377.0377.0377', '0xffffffff', '[2002:ffff:ffff::]', '[::ffff:255.255.255.255]'], + 'decimal (1)' => ['3232235521', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], + 'decimal (2)' => ['999999999', '59.154.201.255', '0073.0232.0311.0377', '0x3b9ac9ff', '[2002:3b9a:c9ff::]', '[::ffff:59.154.201.255]'], + 'decimal (3)' => ['256', '0.0.1.0', '0000.0000.0001.0000', '0x0010', '[2002:0000:0100::]', '[::ffff:0.0.1.0]'], + 'decimal (4)' => ['192.168.257', '192.168.1.1', '0300.0250.0001.0001', '0xc0a811', '[2002:c0a8:0101::]', '[::ffff:192.168.1.1]'], + 'IPv4 Mapped to IPv6 notation' => ['[::ffff:192.168.0.1]', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], + 'IPv4 6to4 notation' => ['[2002:c0a8:0001::]', '192.168.0.1', '0300.0250.0000.0001', '0xc0a801', '[2002:c0a8:0001::]', '[::ffff:192.168.0.1]'], ]; } @@ -89,6 +107,7 @@ public static function providerInvalidHost(): array 'invalid host (12)' => ['255.255.256.255'], 'invalid host (13)' => ['0ffaed'], 'invalid host (14)' => ['192.168.1.0x3000000'], + 'invalid host (15)' => ['[::1]'], ]; } } diff --git a/interfaces/IPv6/Converter.php b/interfaces/IPv6/Converter.php index 80488309..336d6c3a 100644 --- a/interfaces/IPv6/Converter.php +++ b/interfaces/IPv6/Converter.php @@ -6,6 +6,7 @@ use Stringable; use ValueError; + use const FILTER_FLAG_IPV6; use const FILTER_VALIDATE_IP; @@ -18,22 +19,28 @@ final class Converter { - public static function compressIp(string $ipv6): string - { - if (false === filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - throw new ValueError('The submitted IP is not a valid IPv6 address.'); - } + /** + * Significant 10 bits of IP to detect Zone ID regular expression pattern. + * + * @var string + */ + private const HOST_ADDRESS_BLOCK = "\xfe\x80"; - return (string) inet_ntop((string) inet_pton($ipv6)); + public static function compressIp(string $ipAddress): string + { + return match (filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + false => throw new ValueError('The submitted IP is not a valid IPv6 address.'), + default => strtolower((string) inet_ntop((string) inet_pton($ipAddress))), + }; } - 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_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 +48,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 +63,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']){ + return ''; + } + + return '['.$components['ipAddress'].match ($components['zoneIdentifier']) { null => '', default => '%'.$components['zoneIdentifier'], }.']'; @@ -82,32 +93,36 @@ 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 (!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..ece53ccb 100644 --- a/interfaces/IPv6/ConverterTest.php +++ b/interfaces/IPv6/ConverterTest.php @@ -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']; diff --git a/uri/BaseUri.php b/uri/BaseUri.php index 34a6cd85..99b5c630 100644 --- a/uri/BaseUri.php +++ b/uri/BaseUri.php @@ -17,7 +17,7 @@ use League\Uri\Contracts\UriAccess; use League\Uri\Contracts\UriInterface; use League\Uri\Exceptions\MissingFeature; -use League\Uri\Idna\Converter; +use League\Uri\Idna\Converter as IdnaConverter; use League\Uri\IPv4\Converter as IPv4Converter; use League\Uri\IPv6\Converter as IPv6Converter; use Psr\Http\Message\UriFactoryInterface; @@ -267,7 +267,15 @@ public function isSameDocument(Stringable|string $uri): bool */ public function hasIdn(): bool { - return Converter::isIdn($this->uri->getHost()); + return IdnaConverter::isIdn($this->uri->getHost()); + } + + /** + * Tells whether the URI contains an IPv4 regardless if it is mapped or native + */ + public function hasIPv4(): bool + { + return IPv4Converter::fromEnvironment()->isIpv4($this->uri->getHost()); } /**