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());
}
/**