Skip to content

Commit

Permalink
Add support for IPv4-IPv6 conversion
Browse files Browse the repository at this point in the history
  • Loading branch information
nyamsprod committed Jul 24, 2024
1 parent 54795f4 commit 3f3577f
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 23 deletions.
2 changes: 1 addition & 1 deletion components/ModifierTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
6 changes: 6 additions & 0 deletions docs/interfaces/7.0/ipv6.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
79 changes: 60 additions & 19 deletions interfaces/IPv6/Converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,62 +18,95 @@

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));
}

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);
}

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'],
}.']';
Expand All @@ -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],
};
}
}
4 changes: 1 addition & 3 deletions interfaces/IPv6/ConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down Expand Up @@ -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'];
Expand Down

0 comments on commit 3f3577f

Please sign in to comment.