├── shim ├── override.attribute.php └── deprecated.attribute.php ├── .gitignore ├── src ├── Address.php ├── Network.php ├── IPAddress.php ├── NetworkAddress.php ├── CIDRAddress.php ├── IPv4 │ ├── CIDRv4Address.php │ ├── IPv4Address.php │ └── IPv4Network.php ├── IPv6 │ ├── CIDRv6Address.php │ ├── IPv6Address.php │ └── IPv6Network.php ├── Assert.php └── MACAddress.php ├── infection.json.dist ├── phpunit.xml.dist ├── psalm.xml ├── .github ├── actions │ └── composer │ │ └── composer │ │ ├── install │ │ ├── run.sh │ │ └── action.yaml │ │ └── determine-cache-directory │ │ └── action.yaml └── workflows │ └── ci.yml ├── CHANGELOG.md ├── tests ├── IPAddressTest.php ├── CIDRAddressTest.php ├── NetworkAddressTest.php ├── IPv4 │ ├── IPv4NetworkTest.php │ ├── CIDRv4AddressTest.php │ └── IPv4AddressTest.php ├── IPv6 │ ├── CIDRv6AddressTest.php │ ├── IPv6AddressTest.php │ └── IPv6NetworkTest.php └── MACAddressTest.php ├── CONTRIBUTING.md ├── phpcs.xml.dist ├── composer.json ├── README.md └── LICENSE.md /shim/override.attribute.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | 15 | ./src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/actions/composer/composer/install/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | dependencies="${COMPOSER_INSTALL_DEPENDENCIES}" 4 | 5 | if [[ ${dependencies} == "lowest" ]]; then 6 | composer update --no-interaction --no-progress --prefer-lowest 7 | 8 | exit $? 9 | fi 10 | 11 | if [[ ${dependencies} == "locked" ]]; then 12 | composer install --no-interaction --no-progress 13 | 14 | exit $? 15 | fi 16 | 17 | if [[ ${dependencies} == "highest" ]]; then 18 | composer update --no-interaction --no-progress 19 | 20 | exit $? 21 | fi 22 | 23 | echo "::error::The value for the \"dependencies\" input needs to be one of \"lowest\", \"locked\"', \"highest\"' - got \"${dependencies}\" instead." 24 | 25 | exit 1 26 | -------------------------------------------------------------------------------- /src/IPAddress.php: -------------------------------------------------------------------------------- 1 | > $GITHUB_ENV" 17 | -------------------------------------------------------------------------------- /src/NetworkAddress.php: -------------------------------------------------------------------------------- 1 | prefixLength; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/IPAddressTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 19 | IPAddress::fromString('hello world'); 20 | } 21 | 22 | public static function provideFromStringTestData(): iterable 23 | { 24 | return [ 25 | 'ip v4' => ['127.0.0.1', IPv4Address::class], 26 | 'ip v6' => ['fe80::a65:78:0:22', IPv6Address::class], 27 | ]; 28 | } 29 | 30 | #[DataProvider('provideFromStringTestData')] 31 | public function testShouldConstructFromString( 32 | string $address, 33 | string $expectedClass, 34 | string|null $expectedToString = null, 35 | ): void { 36 | $result = IPAddress::fromString($address); 37 | 38 | self::assertInstanceOf($expectedClass, $result); 39 | self::assertSame($expectedToString ?? $address, $result->toString()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/CIDRAddressTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 19 | CIDRAddress::fromString('hello world'); 20 | } 21 | 22 | public static function provideFromStringTestData(): iterable 23 | { 24 | return [ 25 | 'cidr v4' => ['192.168.0.0/24', CIDRv4Address::class], 26 | 'cidr v6' => ['fe80::/64', CIDRv6Address::class], 27 | ]; 28 | } 29 | 30 | #[DataProvider('provideFromStringTestData')] 31 | public function testShouldConstructFromString( 32 | string $address, 33 | string $expectedClass, 34 | string|null $expectedToString = null, 35 | ): void { 36 | $result = CIDRAddress::fromString($address); 37 | 38 | self::assertInstanceOf($expectedClass, $result); 39 | self::assertSame($expectedToString ?? $address, $result->toString()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Contributions in any form are welcome. 4 | 5 | ## Reporting bugs 6 | 7 | If you'd like to report a bug you may open an issue on github. 8 | 9 | ## Contributing Code 10 | 11 | When contributing code, you are doing this under the terms of the 12 | [GNU Lesser General Public License Version 3](http://www.gnu.org/licenses/lgpl-3.0.html) (LGPL) and you 13 | agree that your contribution will fall under this license as well. You also agree that you 14 | will not contribute code that is copyright of a third party or infringes the LGPL of this project. 15 | 16 | To contribute code, fork this repository on github, make your changes and open a pull request. 17 | When your change fixes a bug, open the pull request against the `master` branch. For all other changes 18 | the pull request must be targeted to the `develop` branch. 19 | 20 | Make sure the following requirements are met: 21 | 22 | - The changes must pass CI which includes: 23 | - Unit tests 24 | - Infection tests 25 | - Static analysis 26 | - Coding style checks 27 | - Your changes are covered by tests. 28 | - Update the changelog (CHANGELOG.md) accordingly. Changes that do not 29 | affect library users (i.e. CI config) may be omitted. 30 | - When you submit a feature describe what it does and what 31 | the use case is. State an example for better understanding 32 | if necessary. 33 | - Provide documentation for new features 34 | 35 | Please note that we may decide to reject your contribution, especially when 36 | these requirements are not met. 37 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | src 14 | tests 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | tests/* 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luka/network-address-types", 3 | "description": "Provides typed value objects for network addresses (IP v4/v6 and MAC)", 4 | "type": "library", 5 | "license": "LGPL-3.0", 6 | "authors": [ 7 | { 8 | "name": "Axel Helmert", 9 | "email": "axel.helmert@luka.de" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "LUKA\\Network\\": "src/" 15 | } 16 | }, 17 | "autoload-dev": { 18 | "psr-4": { 19 | "LUKATest\\Network\\": "tests/" 20 | }, 21 | "classmap": [ 22 | "shim/override.attribute.php", 23 | "shim/deprecated.attribute.php" 24 | ] 25 | }, 26 | "require": { 27 | "php": "8.2 - 8.4", 28 | "ext-gmp": "*", 29 | "ext-json": "*" 30 | }, 31 | "require-dev": { 32 | "doctrine/coding-standard": "^13.0.1", 33 | "phpunit/phpunit": "^10.5", 34 | "vimeo/psalm": "^6" 35 | }, 36 | "scripts": { 37 | "test": "phpunit --testdox", 38 | "analyse": "psalm", 39 | "cs-fix": "phpcbf", 40 | "cs-check": [ 41 | "mkdir -p .build/php_codesniffer/", 42 | "phpcs" 43 | ], 44 | "infection-test": "infection --min-msi=84 --min-covered-msi=86", 45 | "check": [ 46 | "@test", 47 | "@analyse", 48 | "@cs-check" 49 | ] 50 | }, 51 | "config": { 52 | "sort-packages": true, 53 | "allow-plugins": { 54 | "dealerdirect/phpcodesniffer-composer-installer": true 55 | } 56 | }, 57 | "extra": { 58 | "branch-alias": { 59 | "dev-master": "1.2.x-dev", 60 | "dev-develop": "1.3.x-dev" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/NetworkAddressTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 22 | NetworkAddress::fromString('hello world'); 23 | } 24 | 25 | /** @return iterable */ 26 | public static function provideFromStringTestData(): iterable 27 | { 28 | return [ 29 | 'ip v4' => ['127.0.0.1', IPv4Address::class], 30 | 'ip v6' => ['fe80::a65:78:0:22', IPv6Address::class], 31 | 'cidr v4' => ['192.168.0.0/24', CIDRv4Address::class], 32 | 'cidr v6' => ['fe80::/64', CIDRv6Address::class], 33 | 'mac (colon)' => ['28:e5:65:78:00:22', MACAddress::class], 34 | 'mac (dash)' => ['28-e5-65-78-00-22', MACAddress::class, '28:e5:65:78:00:22'], 35 | 'mac (no delim)' => ['28e565780022', MACAddress::class, '28:e5:65:78:00:22'], 36 | ]; 37 | } 38 | 39 | /** 40 | * @param class-string $expectedClass 41 | * 42 | * @return void 43 | */ 44 | #[DataProvider('provideFromStringTestData')] 45 | public function testShouldConstructFromString( 46 | string $address, 47 | string $expectedClass, 48 | string|null $expectedToString = null, 49 | ): void { 50 | $result = NetworkAddress::fromString($address); 51 | 52 | self::assertInstanceOf($expectedClass, $result); 53 | self::assertSame($expectedToString ?? $address, $result->toString()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/IPv4/CIDRv4Address.php: -------------------------------------------------------------------------------- 1 | address; 51 | } 52 | 53 | #[Override] 54 | public function toNetwork(): IPv4Network 55 | { 56 | return new IPv4Network($this); 57 | } 58 | 59 | #[Override] 60 | public function toString(): string 61 | { 62 | return sprintf('%s/%d', $this->address->toString(), $this->prefixLength); 63 | } 64 | 65 | #[Override] 66 | public function equals(Address $other): bool 67 | { 68 | return $other instanceof self 69 | && $this->address->equals($other->address) 70 | && $this->prefixLength === $other->prefixLength; 71 | } 72 | 73 | #[Override] 74 | public function jsonSerialize(): string 75 | { 76 | return $this->toString(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/IPv6/CIDRv6Address.php: -------------------------------------------------------------------------------- 1 | address->toString(), $this->prefixLength); 51 | } 52 | 53 | #[Override] 54 | public function equals(Address $other): bool 55 | { 56 | return $other instanceof self 57 | && $this->address->equals($other->address) 58 | && $this->prefixLength === $other->prefixLength; 59 | } 60 | 61 | #[Override] 62 | public function toAddress(): IPv6Address 63 | { 64 | return $this->address; 65 | } 66 | 67 | #[Override] 68 | public function toNetwork(): IPv6Network 69 | { 70 | return new IPv6Network($this); 71 | } 72 | 73 | #[Override] 74 | public function jsonSerialize(): string 75 | { 76 | return $this->toString(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/IPv4/IPv4Address.php: -------------------------------------------------------------------------------- 1 | address); 57 | } 58 | 59 | #[Override] 60 | public function equals(Address $other): bool 61 | { 62 | return $other instanceof self 63 | && $this->address === $other->address; 64 | } 65 | 66 | public function isNull(): bool 67 | { 68 | return $this->address === 0; 69 | } 70 | 71 | public function toInt(): int 72 | { 73 | return $this->address; 74 | } 75 | 76 | #[Override] 77 | public function toByteString(): string 78 | { 79 | return pack('N', $this->address); 80 | } 81 | 82 | #[Override] 83 | public function jsonSerialize(): string 84 | { 85 | return $this->toString(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/IPv4/IPv4Network.php: -------------------------------------------------------------------------------- 1 | getPrefixLength(); 22 | $this->netmask = -1 << 32 - $prefixLength & 0xffffffff; 23 | $this->cidr = new CIDRv4Address( 24 | new IPv4Address( 25 | $cidr->toAddress()->toInt() & $this->netmask, 26 | ), 27 | $prefixLength, 28 | ); 29 | } 30 | 31 | #[Override] 32 | public function toString(): string 33 | { 34 | return $this->cidr->toString(); 35 | } 36 | 37 | #[Override] 38 | public function equals(Address $other): bool 39 | { 40 | return $other instanceof self 41 | && $this->cidr->equals($other->cidr); 42 | } 43 | 44 | #[Override] 45 | public function getRangeMinAddress(): CIDRv4Address 46 | { 47 | $prefix = $this->cidr->getPrefixLength(); 48 | 49 | return $prefix > 31 50 | ? $this->cidr 51 | : new CIDRv4Address( 52 | new IPv4Address($this->cidr->toAddress()->toInt() + 1), 53 | $prefix, 54 | ); 55 | } 56 | 57 | #[Override] 58 | public function getRangeMaxAddress(): CIDRv4Address 59 | { 60 | $prefix = $this->cidr->getPrefixLength(); 61 | 62 | if ($prefix > 30) { 63 | return $prefix === 31 ? $this->getRangeMinAddress() : $this->cidr; 64 | } 65 | 66 | $max = 0xfffffffe & ~$this->netmask; 67 | 68 | return new CIDRv4Address( 69 | new IPv4Address($this->cidr->toAddress()->toInt() | $max), 70 | $prefix, 71 | ); 72 | } 73 | 74 | #[Override] 75 | public function containsAddress(IPAddress $address): bool 76 | { 77 | return $address instanceof IPv4Address 78 | && $this->cidr->toAddress()->toInt() === ($address->toInt() & $this->netmask); 79 | } 80 | 81 | #[Override] 82 | public function toCidrAddress(): CIDRv4Address 83 | { 84 | return $this->cidr; 85 | } 86 | 87 | public function toBroadcastAddress(): CIDRv4Address 88 | { 89 | return new CIDRv4Address( 90 | new IPv4Address( 91 | $this->cidr->toAddress()->toInt() | (0xffffffff & ~$this->netmask), 92 | ), 93 | $this->cidr->getPrefixLength(), 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/IPv6/IPv6Address.php: -------------------------------------------------------------------------------- 1 | address, 1, GMP_MSW_FIRST), 71 | 16, 72 | "\x00", 73 | STR_PAD_LEFT, 74 | ); 75 | } 76 | 77 | #[Override] 78 | public function toString(): string 79 | { 80 | if ($this->isNull()) { 81 | return '::'; 82 | } 83 | 84 | $formatted = inet_ntop($this->toByteString()); 85 | assert($formatted !== false); 86 | 87 | return $formatted; 88 | } 89 | 90 | #[Override] 91 | public function equals(Address $other): bool 92 | { 93 | return $other instanceof self 94 | && gmp_cmp($this->address, $other->address) === 0; 95 | } 96 | 97 | public function isNull(): bool 98 | { 99 | return gmp_cmp($this->address, '0') === 0; 100 | } 101 | 102 | public function toNumber(): GMP 103 | { 104 | return $this->address; 105 | } 106 | 107 | #[Override] 108 | public function jsonSerialize(): string 109 | { 110 | return $this->toString(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/IPv4/IPv4NetworkTest.php: -------------------------------------------------------------------------------- 1 | toString()); 24 | self::assertEquals($expected, $subject->toCidrAddress()->toString()); 25 | } 26 | 27 | public function testShouldContainAddressesWithinTheSamePrefix(): void 28 | { 29 | self::assertTrue( 30 | (new IPv4Network(CIDRv4Address::fromString('88.154.76.23/24'))) 31 | ->containsAddress(IPv4Address::fromString('88.154.76.23')), 32 | ); 33 | } 34 | 35 | public static function provideRangeTestData(): iterable 36 | { 37 | $data = [ 38 | '88.154.76.23/24' => ['88.154.76.1/24', '88.154.76.254/24'], 39 | '88.154.76.0/24' => ['88.154.76.1/24', '88.154.76.254/24'], 40 | '88.154.76.23/8' => ['88.0.0.1/8', '88.255.255.254/8'], 41 | '88.154.76.23/32' => ['88.154.76.23/32', '88.154.76.23/32'], 42 | '88.154.76.23/31' => ['88.154.76.23/31', '88.154.76.23/31'], 43 | ]; 44 | 45 | foreach ($data as $input => $range) { 46 | yield $input => [$input, ...$range]; 47 | } 48 | } 49 | 50 | #[DataProvider('provideRangeTestData')] 51 | public function testShouldProvideCorrectAddressRange(string $input, string $min, string $max): void 52 | { 53 | $subject = new IPv4Network(CIDRv4Address::fromString($input)); 54 | 55 | self::assertSame($min, $subject->getRangeMinAddress()->toString()); 56 | self::assertSame($max, $subject->getRangeMaxAddress()->toString()); 57 | } 58 | 59 | public function testShouldCompareEquality(): void 60 | { 61 | $first = new IPv4Network(CIDRv4Address::fromString('127.0.0.1/8')); 62 | $second = new IPv4Network(CIDRv4Address::fromString('127.0.0.1/8')); 63 | 64 | self::assertNotSame($first, $second); 65 | self::assertTrue($first->equals($second)); 66 | } 67 | 68 | public static function provideInequalityTestData(): iterable 69 | { 70 | return [ 71 | 'different network' => ['127.0.0.1/8', new IPv4Network(CIDRv4Address::fromString('127.0.0.1/16'))], 72 | 'different type' => ['127.0.0.1/8', new IPv6Network(CIDRv6Address::fromString('::1/10'))], 73 | 'cidr' => ['127.0.0.1/8', CIDRv4Address::fromString('127.0.0.1/8')], 74 | ]; 75 | } 76 | 77 | #[DataProvider('provideInequalityTestData')] 78 | public function testShouldNotMatchInequality(string $subject, Address $other): void 79 | { 80 | self::assertFalse((new IPv4Network(CIDRv4Address::fromString($subject)))->equals($other)); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/IPv6/IPv6Network.php: -------------------------------------------------------------------------------- 1 | getPrefixLength(); 33 | $this->netmask = gmp_init(str_pad(str_repeat('1', $prefix), 128, '0', STR_PAD_RIGHT), 2); 34 | $this->cidr = new CIDRv6Address( 35 | new IPv6Address( 36 | gmp_and( 37 | $cidr->toAddress()->toNumber(), 38 | $this->netmask, 39 | ), 40 | ), 41 | $prefix, 42 | ); 43 | } 44 | 45 | #[Override] 46 | public function toString(): string 47 | { 48 | return $this->cidr->toString(); 49 | } 50 | 51 | #[Override] 52 | public function equals(Address $other): bool 53 | { 54 | return $other instanceof self 55 | && $this->cidr->equals($other->cidr) 56 | && gmp_cmp($this->netmask, $other->netmask) === 0; 57 | } 58 | 59 | #[Override] 60 | public function getRangeMinAddress(): CIDRv6Address 61 | { 62 | $prefix = $this->cidr->getPrefixLength(); 63 | 64 | return $prefix === 128 65 | ? $this->cidr 66 | : new CIDRv6Address( 67 | new IPv6Address( 68 | gmp_add( 69 | $this->cidr->toAddress()->toNumber(), 70 | gmp_init('1', 2), 71 | ), 72 | ), 73 | $prefix, 74 | ); 75 | } 76 | 77 | #[Override] 78 | public function getRangeMaxAddress(): CIDRAddress 79 | { 80 | $prefix = $this->cidr->getPrefixLength(); 81 | 82 | return $prefix === 128 83 | ? $this->cidr 84 | : new CIDRv6Address( 85 | new IPv6Address( 86 | gmp_add( 87 | $this->cidr->toAddress()->toNumber(), 88 | gmp_init(str_repeat('1', 128 - $prefix), 2), 89 | ), 90 | ), 91 | $prefix, 92 | ); 93 | } 94 | 95 | #[Override] 96 | public function containsAddress(IPAddress $address): bool 97 | { 98 | return $address instanceof IPv6Address 99 | && gmp_cmp( 100 | gmp_and( 101 | $address->toNumber(), 102 | $this->netmask, 103 | ), 104 | $this->cidr->toAddress()->toNumber(), 105 | ) === 0; 106 | } 107 | 108 | #[Override] 109 | public function toCidrAddress(): CIDRAddress 110 | { 111 | return $this->cidr; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Assert.php: -------------------------------------------------------------------------------- 1 | = $min && $value <= $max) { 51 | return; 52 | } 53 | 54 | self::throwInvalidArgument($message ?? 'Expected %d to be in range of %d - %d.', $value, $min, $max); 55 | } 56 | 57 | /** @psalm-pure */ 58 | public static function contains(string $value, string $substring, string|null $message = null): void 59 | { 60 | if (strpos($value, $substring) !== false) { 61 | return; 62 | } 63 | 64 | self::throwInvalidArgument($message ?? 'Expected "%s" to contain "%s".', $value, $substring); 65 | } 66 | 67 | /** 68 | * @psalm-pure 69 | * @psalm-assert numeric $value 70 | */ 71 | public static function integerish(string|int $value, string|null $message = null): void 72 | { 73 | // phpcs:disable SlevomatCodingStandard.Operators.DisallowEqualOperators 74 | if (is_numeric($value) && $value == (int)$value) { 75 | return; 76 | } 77 | 78 | // phpcs:enable 79 | self::throwInvalidArgument($message ?? 'Expected "%s" to be an integer-like value', $value); 80 | } 81 | 82 | /** @psalm-pure */ 83 | public static function lessThanEq(int $value, int $max, string|null $message = null): void 84 | { 85 | if ($value <= $max) { 86 | return; 87 | } 88 | 89 | self::throwInvalidArgument($message ?? 'Expected %d to be less than %d', $value, $max); 90 | } 91 | 92 | /** 93 | * @param non-empty-string $regex 94 | * 95 | * @psalm-pure 96 | */ 97 | public static function regex(string $value, string $regex, string|null $message = null): void 98 | { 99 | if (preg_match($regex, $value)) { 100 | return; 101 | } 102 | 103 | self::throwInvalidArgument($message ?? 'Expected "%s" to match "%s".', $value, $regex); 104 | } 105 | 106 | /** @psalm-pure */ 107 | private static function throwInvalidArgument(string $message, float|int|string ...$args): void 108 | { 109 | throw new InvalidArgumentException( 110 | vsprintf($message, $args), 111 | ); 112 | } 113 | 114 | /** @psalm-pure */ 115 | private static function typeStringFor(mixed $value): string 116 | { 117 | return is_object($value) ? $value::class : gettype($value); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/IPv4/CIDRv4AddressTest.php: -------------------------------------------------------------------------------- 1 | [$ip]; 31 | } 32 | } 33 | 34 | public static function provideInvalidValues(): iterable 35 | { 36 | $list = [ 37 | 'a.7.s.s', 38 | 'localhost', 39 | '127.0.0/66', 40 | '10.0.5.256/24', 41 | '10.0.256.2', 42 | '86.134.56.253/32.5', 43 | '86.134.56.253/32/5', 44 | '', 45 | '10.0.5.153/128', 46 | ]; 47 | 48 | foreach ($list as $ip) { 49 | yield 'value: ' . $ip => [$ip]; 50 | } 51 | } 52 | 53 | #[DataProvider('provideValidValues')] 54 | public function testShouldAcceptValidAddresses(string $fixture): void 55 | { 56 | $subject = CIDRv4Address::fromString($fixture); 57 | self::assertSame($fixture, $subject->toString()); 58 | } 59 | 60 | #[DataProvider('provideInvalidValues')] 61 | public function testShouldThrowOnInvalidAddresses(string $fixture): void 62 | { 63 | $this->expectException(InvalidArgumentException::class); 64 | CIDRv4Address::fromString($fixture); 65 | } 66 | 67 | public function testShouldBeConvertibleToIpAddress(): void 68 | { 69 | self::assertTrue( 70 | CIDRv4Address::fromString('128.6.119.56/24') 71 | ->toAddress() 72 | ->equals(IPv4Address::fromString('128.6.119.56')), 73 | ); 74 | } 75 | 76 | public function testShouldBeConvertibleToNetwork(): void 77 | { 78 | $subject = CIDRv4Address::fromString('128.6.119.56/24'); 79 | self::assertTrue($subject->toNetwork()->equals(new IPv4Network($subject))); 80 | } 81 | 82 | public function testShouldMatchEquality(): void 83 | { 84 | $subject = CIDRv4Address::fromString('128.6.119.56/24'); 85 | $other = CIDRv4Address::fromString('128.6.119.56/24'); 86 | 87 | self::assertNotSame($other, $subject); 88 | self::assertTrue($subject->equals($other)); 89 | } 90 | 91 | public static function provideUnequalAddresses(): iterable 92 | { 93 | return [ 94 | 'different prefix' => ['128.6.119.56/24', CIDRv4Address::fromString('128.6.119.56/25')], 95 | 'different address' => ['128.6.119.56/24', CIDRv4Address::fromString('128.6.119.57/24')], 96 | 'different type (MAC)' => ['128.6.119.56/24', MACAddress::generateRandomAddress()], 97 | 'different type (IP v6)' => ['127.0.0.1/8', IPv6Address::fromString('::1')], 98 | ]; 99 | } 100 | 101 | #[DataProvider('provideUnequalAddresses')] 102 | public function testShouldNotMatchInequality(string $address, Address $other): void 103 | { 104 | $subject = CIDRv4Address::fromString($address); 105 | 106 | self::assertNotSame($other, $subject); 107 | self::assertFalse($subject->equals($other)); 108 | } 109 | 110 | public function testShouldExposePrefixLength(): void 111 | { 112 | $subject = CIDRv4Address::fromString('10.0.0.0/12'); 113 | 114 | self::assertSame(12, $subject->getPrefixLength()); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/IPv6/CIDRv6AddressTest.php: -------------------------------------------------------------------------------- 1 | ['2004:6fe8::/64', '2004:6fe8::/64'], 24 | '2004:6fe8::4/64' => ['2004:6fe8::4/64', '2004:6fe8::4/64'], 25 | '::1/8' => ['::1/8', '::1/8'], 26 | ]; 27 | } 28 | 29 | #[DataProvider('provideValidStringInput')] 30 | public function testShouldConstructFromString(string $input, string $expected): void 31 | { 32 | $subject = CIDRv6Address::fromString($input); 33 | self::assertSame($expected, $subject->toString()); 34 | } 35 | 36 | public static function provideInvalidStringInput(): iterable 37 | { 38 | return [ 39 | 'empty string' => [''], 40 | 'missing length' => ['200f:65::'], 41 | 'empty length' => ['200f:65::/'], 42 | 'prefix to large' => ['200f:65::/129'], 43 | 'non-numeric length' => ['200f:65::/f'], 44 | 'empty address' => ['/64'], 45 | ]; 46 | } 47 | 48 | #[DataProvider('provideInvalidStringInput')] 49 | public function testShouldThrowInvalidArgumentOnInvalidStringInput(string $input): void 50 | { 51 | $this->expectException(InvalidArgumentException::class); 52 | CIDRv6Address::fromString($input); 53 | } 54 | 55 | public function testShouldBeConvertableToIpAddress(): void 56 | { 57 | self::assertSame( 58 | '2004:6fe8::4', 59 | CIDRv6Address::fromString('2004:6fe8::4/64') 60 | ->toAddress() 61 | ->toString(), 62 | ); 63 | } 64 | 65 | public function testShouldMatchEquality(): void 66 | { 67 | $subject = CIDRv6Address::fromString('2004:6fe8::4/64'); 68 | $other = CIDRv6Address::fromString('2004:6fe8::4/64'); 69 | 70 | self::assertNotSame($subject, $other); 71 | self::assertTrue($subject->equals($other)); 72 | } 73 | 74 | public static function provideUnequalAddresses(): iterable 75 | { 76 | return [ 77 | 'different prefix' => ['2004:6fe8::4/64', CIDRv6Address::fromString('2004:6fe8::4/65')], 78 | 'different address' => ['2004:6fe8::4/64', CIDRv6Address::fromString('2004:6fe8::5/64')], 79 | 'different type (MAC)' => ['2004:6fe8::4/64', MACAddress::generateRandomAddress()], 80 | 'different type (IP v6)' => ['::1/8', IPv6Address::fromString('::1')], 81 | ]; 82 | } 83 | 84 | #[DataProvider('provideUnequalAddresses')] 85 | public function testShouldNotMatchInequality(string $input, Address $other): void 86 | { 87 | $subject = CIDRv6Address::fromString($input); 88 | 89 | self::assertFalse($subject->equals($other)); 90 | } 91 | 92 | public function testShouldBeConvertibleToNetwork(): void 93 | { 94 | $subject = CIDRv6Address::fromString('2004:6fe8::4/64'); 95 | 96 | self::assertSame( 97 | '2004:6fe8::/64', 98 | $subject->toNetwork()->toString(), 99 | ); 100 | } 101 | 102 | public function testShouldBeJsonSerializable(): void 103 | { 104 | $subject = CIDRv6Address::fromString('2004:6fe8::4/64'); 105 | $json = json_encode($subject); 106 | 107 | self::assertSame( 108 | $subject->toString(), 109 | CIDRv6Address::fromString(json_decode($json))->toString(), 110 | ); 111 | } 112 | 113 | public function testShouldExposePrefixLength(): void 114 | { 115 | $subject = CIDRv6Address::fromString('2004:6fe8::/82'); 116 | self::assertSame(82, $subject->getPrefixLength()); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/MACAddress.php: -------------------------------------------------------------------------------- 1 | vendorId = bin2hex(substr($binary, 0, 3)); 50 | } 51 | 52 | /** 53 | * @psalm-return self 54 | * 55 | * @psalm-pure 56 | */ 57 | #[Override] 58 | public static function fromString(string $address): self 59 | { 60 | Assert::regex($address, self::MAC_ADDRESS_FORMAT, 'Invalid mac address: "%s"'); 61 | 62 | return new self(gmp_init(strtr($address, [':' => '', '-' => '']), 16)); 63 | } 64 | 65 | /** 66 | * Generate a random mac address with a prefix 67 | * 68 | * The prefix is omitted or empty, this method will ensure that the generated 69 | * Address is flagged as a local one. 70 | * 71 | * @param string $prefix Prefix as hex number, 3 bytes at maximum. 72 | */ 73 | public static function generateRandomAddress(string $prefix = ''): self 74 | { 75 | if ($prefix === '') { 76 | return new MACAddress( 77 | gmp_or( 78 | gmp_init(bin2hex(random_bytes(6)), 16), 79 | gmp_init('020000000000', 16), 80 | ), 81 | ); 82 | } 83 | 84 | Assert::regex($prefix, '/^([a-f0-9]{2}){0,3}$/i', 'Invalid mac address prefix: "%s"'); 85 | 86 | $prefixLength = strlen($prefix); 87 | $randomLength = 6 - (int)($prefixLength / 2); 88 | 89 | assert($randomLength > 0); 90 | 91 | return new MACAddress( 92 | gmp_add( 93 | gmp_init($prefix . str_repeat('0', 12 - $prefixLength), 16), 94 | gmp_init(bin2hex(random_bytes($randomLength)), 16), 95 | ), 96 | ); 97 | } 98 | 99 | #[Override] 100 | public function toString(): string 101 | { 102 | $hex = str_pad(gmp_strval($this->address, 16), 12, '0', STR_PAD_LEFT); 103 | $segments = []; 104 | 105 | for ($offset = 0; $offset < 12; $offset += 2) { 106 | $segments[] = substr($hex, $offset, 2); 107 | } 108 | 109 | return implode(':', $segments); 110 | } 111 | 112 | public function toByteString(): string 113 | { 114 | return gmp_export($this->address, 1, GMP_MSW_FIRST); 115 | } 116 | 117 | #[Override] 118 | public function equals(Address $other): bool 119 | { 120 | return $other instanceof self 121 | && gmp_cmp($this->address, $other->address) === 0; 122 | } 123 | 124 | #[Override] 125 | public function jsonSerialize(): string 126 | { 127 | return $this->toString(); 128 | } 129 | 130 | public function getVendorID(): string 131 | { 132 | return $this->vendorId; 133 | } 134 | 135 | public function isLocal(): bool 136 | { 137 | return gmp_testbit($this->address, 41); 138 | } 139 | 140 | public function isBroadCast(): bool 141 | { 142 | return gmp_cmp($this->address, gmp_init('ffffffffffff', 16)) === 0; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /tests/IPv4/IPv4AddressTest.php: -------------------------------------------------------------------------------- 1 | [$ip]; 32 | } 33 | } 34 | 35 | public static function provideInvalidIps(): iterable 36 | { 37 | $list = [ 38 | 'a.7.s.s', 39 | 'localhost', 40 | '127.0.0', 41 | '10.0.5.256', 42 | '10.0.256.2', 43 | '10.256.0.2', 44 | '256.0.0.2', 45 | '10.896.0.2', 46 | '86.1340.56.263', 47 | '', 48 | '127001', 49 | ]; 50 | 51 | foreach ($list as $ip) { 52 | yield 'value: ' . $ip => [$ip]; 53 | } 54 | } 55 | 56 | #[DataProvider('provideValidIps')] 57 | public function testShouldAcceptValidAddresses(string $fixture): void 58 | { 59 | $subject = IPv4Address::fromString($fixture); 60 | self::assertSame($fixture, $subject->toString()); 61 | } 62 | 63 | #[DataProvider('provideInvalidIps')] 64 | public function testShouldThrowOnInvalidAddresses(string $fixture): void 65 | { 66 | $this->expectException(InvalidArgumentException::class); 67 | IPv4Address::fromString($fixture); 68 | } 69 | 70 | public function testShouldCompareEquality(): void 71 | { 72 | $first = IPv4Address::fromString('192.168.55.7'); 73 | $second = IPv4Address::fromString('192.168.55.7'); 74 | 75 | self::assertNotSame($first, $second); 76 | self::assertTrue($first->equals($second)); 77 | } 78 | 79 | public static function provideInequalityTestData(): iterable 80 | { 81 | return [ 82 | 'different address' => ['127.0.0.1', IPv4Address::fromString('127.0.0.2')], 83 | 'different type' => ['127.0.0.1', IPv6Address::fromString('::1')], 84 | 'cidr' => ['127.0.0.1', CIDRv4Address::fromString('127.0.0.1/8')], 85 | ]; 86 | } 87 | 88 | #[DataProvider('provideInequalityTestData')] 89 | public function testShouldNotMatchInequality(string $subject, Address $other): void 90 | { 91 | self::assertFalse(IPv4Address::fromString($subject)->equals($other)); 92 | } 93 | 94 | public function testShouldConvertToInteger(): void 95 | { 96 | $int = IPv4Address::fromString('127.0.0.1')->toInt(); 97 | 98 | self::assertSame(0x7f000001, $int); 99 | self::assertSame( 100 | '127.0.0.1', 101 | (new IPv4Address($int))->toString(), 102 | ); 103 | } 104 | 105 | public function testShouldExportBinaryValue(): void 106 | { 107 | $expected = '255.0.0.5'; 108 | $bytes = IPv4Address::fromString($expected)->toByteString(); 109 | 110 | self::assertNotSame($expected, $bytes); 111 | self::assertSame($expected, IPv4Address::fromByteString($bytes)->toString()); 112 | } 113 | 114 | public function testShouldIdentifyNullAddress(): void 115 | { 116 | self::assertTrue(IPv4Address::fromString('0.0.0.0')->isNull()); 117 | self::assertFalse(IPv4Address::fromString('127.0.0.1')->isNull()); 118 | } 119 | 120 | public function testShouldBeJsonSerializable(): void 121 | { 122 | $subject = IPv4Address::fromString('192.168.0.33'); 123 | $json = json_encode($subject); 124 | 125 | self::assertTrue( 126 | $subject->equals( 127 | IPv4Address::fromString(json_decode($json)), 128 | ), 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/IPv6/IPv6AddressTest.php: -------------------------------------------------------------------------------- 1 | ['::1', '::1'], 27 | '::' => ['::', '::'], 28 | 'a6f4:56::' => ['a6f4:56::', 'a6f4:56::'], 29 | '0:0:0:0:0:0:0:1' => ['0:0:0:0:0:0:0:1', '::1'], 30 | 'fe80::a65:78:0:22' => ['fe80::a65:78:0:22', 'fe80::a65:78:0:22'], 31 | ]; 32 | } 33 | 34 | #[DataProvider('provideValidAddressStrings')] 35 | public function testShouldConstructFromString(string $input, string $expected): void 36 | { 37 | $subject = IPv6Address::fromString($input); 38 | self::assertSame($expected, $subject->toString()); 39 | } 40 | 41 | public static function provideInvalidAddressStrings(): iterable 42 | { 43 | return [ 44 | 'multiple zero omits' => ['fe80::7::22'], 45 | 'too many segments' => ['fe80:0:0:0:0:0:0:0:1'], 46 | 'non-hex digits' => ['fe80::z6:1'], 47 | 'segment too large' => ['fe80::f0893:1'], 48 | ]; 49 | } 50 | 51 | #[DataProvider('provideInvalidAddressStrings')] 52 | public function testShouldThrowInvalidArgumentOnInvalidAddressStringInput(string $input): void 53 | { 54 | $this->expectException(InvalidArgumentException::class); 55 | IPv6Address::fromString($input); 56 | } 57 | 58 | public function testShouldExportCorrectByteString(): void 59 | { 60 | self::assertSame( 61 | str_pad("\x01", 16, "\x00", STR_PAD_LEFT), 62 | IPv6Address::fromString('::1')->toByteString(), 63 | ); 64 | } 65 | 66 | public function testShouldRestoreFromBinary(): void 67 | { 68 | self::assertSame( 69 | '::1', 70 | IPv6Address::fromBinary("\x01")->toString(), 71 | ); 72 | } 73 | 74 | public function testShouldMatchEquality(): void 75 | { 76 | $subject = IPv6Address::fromString('fe80::a65:78:0:22'); 77 | $other = IPv6Address::fromString('fe80::a65:78:0:22'); 78 | 79 | self::assertNotSame($subject, $other); 80 | self::assertTrue($subject->equals($other)); 81 | } 82 | 83 | public static function provideInequalityTestData(): iterable 84 | { 85 | return [ 86 | 'different address' => ['fe80::a65:78:0:22', IPv6Address::fromString('fe80::a65:78:0:21')], 87 | 'different type' => ['::1', IPv4Address::fromString('127.0.0.1')], 88 | 'cidr' => ['::1', CIDRv6Address::fromString('::1/64')], 89 | ]; 90 | } 91 | 92 | #[DataProvider('provideInequalityTestData')] 93 | public function testShouldNotMatchInequality(string $subject, Address $other): void 94 | { 95 | self::assertFalse(IPv6Address::fromString($subject)->equals($other)); 96 | } 97 | 98 | public function testShouldExportNumericValue(): void 99 | { 100 | $subject = IPv6Address::fromString('fe80::a65:78:0:22'); 101 | $number = $subject->toNumber(); 102 | 103 | self::assertTrue($subject->equals(new IPv6Address($number))); 104 | } 105 | 106 | public function testShouldIdentifyNullAddress(): void 107 | { 108 | self::assertTrue(IPv6Address::fromString('::')->isNull()); 109 | self::assertFalse(IPv6Address::fromString('fe80::a65:78:0:22')->isNull()); 110 | } 111 | 112 | public function testShouldBeJsonSerializable(): void 113 | { 114 | $subject = IPv6Address::fromString('fe80::a65:78:0:22'); 115 | $json = json_encode($subject); 116 | 117 | self::assertSame( 118 | $subject->toString(), 119 | IPv6Address::fromString(json_decode($json))->toString(), 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/MACAddressTest.php: -------------------------------------------------------------------------------- 1 | isLocal()); 22 | self::assertFalse(MACAddress::fromString('dc:0e:a1:6e:08:c2')->isLocal()); 23 | } 24 | 25 | public function testShouldExportTheVendorId(): void 26 | { 27 | self::assertSame('dc0ea1', MACAddress::fromString('dc:0e:a1:6e:08:c2')->getVendorID()); 28 | } 29 | 30 | public static function provideInvalidStringInput(): iterable 31 | { 32 | return [ 33 | 'empty string' => [''], 34 | 'too many bytes' => ['02:75:00:56:4f:a8:c2'], 35 | 'invalid characters' => ['02:75:00:z6:4f:a8'], 36 | 'arbitary string' => ['hello world'], 37 | ]; 38 | } 39 | 40 | #[DataProvider('provideInvalidStringInput')] 41 | public function testShouldThrowWhenConstructedFromInvalidStringAddress(string $input): void 42 | { 43 | $this->expectException(InvalidArgumentException::class); 44 | MACAddress::fromString($input); 45 | } 46 | 47 | public function testShouldStringifyColonSeparatedNotation(): void 48 | { 49 | self::assertSame('dc:0e:a1:6e:08:c2', MACAddress::fromString('dc0ea16e08c2')->toString()); 50 | self::assertSame('dc:0e:a1:6e:08:c2', MACAddress::fromString('dc-0e-a1-6e-08-c2')->toString()); 51 | } 52 | 53 | public function testShouldIdentifyBroadcastAddress(): void 54 | { 55 | self::assertTrue(MACAddress::fromString('ff:ff:ff:ff:ff:ff')->isBroadCast()); 56 | self::assertFalse(MACAddress::fromString('dc:0e:a1:6e:08:c2')->isBroadCast()); 57 | } 58 | 59 | public function testShouldBeJsonSerializable(): void 60 | { 61 | $subject = MACAddress::generateRandomAddress(); 62 | $json = json_encode($subject); 63 | 64 | self::assertSame( 65 | $subject->toString(), 66 | MACAddress::fromString(json_decode($json))->toString(), 67 | ); 68 | } 69 | 70 | public function testShouldGenerateRandomAddresses(): void 71 | { 72 | $first = MACAddress::generateRandomAddress(); 73 | $second = MACAddress::generateRandomAddress(); 74 | 75 | self::assertNotSame( 76 | $first->toString(), 77 | $second->toString(), 78 | ); 79 | } 80 | 81 | public function testShouldGenerateRandomAddressesWithPrefix(): void 82 | { 83 | $prefix = '200000'; 84 | $first = MACAddress::generateRandomAddress($prefix); 85 | $second = MACAddress::generateRandomAddress($prefix); 86 | 87 | self::assertNotSame( 88 | $first->toString(), 89 | $second->toString(), 90 | ); 91 | 92 | self::assertStringStartsWith('20:00:00', $first->toString()); 93 | self::assertStringStartsWith('20:00:00', $second->toString()); 94 | } 95 | 96 | public function testShouldExportCorrectByteString(): void 97 | { 98 | self::assertSame( 99 | "\x20\x00\x00\x00\x00\x01", 100 | MACAddress::fromString('20:00:00:00:00:01')->toByteString(), 101 | ); 102 | } 103 | 104 | public function testShouldCompareEquality(): void 105 | { 106 | $first = MACAddress::fromString('20:00:00:00:00:01'); 107 | $second = MACAddress::fromString('20:00:00:00:00:01'); 108 | 109 | self::assertNotSame($first, $second); 110 | self::assertTrue($first->equals($second)); 111 | } 112 | 113 | public static function provideInequalityTestData(): iterable 114 | { 115 | return [ 116 | 'different address' => ['20:00:00:00:00:01', MACAddress::fromString('20:00:00:00:00:02')], 117 | 'different type' => ['20:00:00:00:00:01', IPv6Address::fromString('::1')], 118 | ]; 119 | } 120 | 121 | #[DataProvider('provideInequalityTestData')] 122 | public function testShouldNotMatchInequality(string $subject, Address $other): void 123 | { 124 | self::assertFalse(MACAddress::fromString($subject)->equals($other)); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/IPv6/IPv6NetworkTest.php: -------------------------------------------------------------------------------- 1 | ['2004:6fe8::/64', '2004:6fe8::/64'], 24 | '2004:6fe8::4/64' => ['2004:6fe8::4/64', '2004:6fe8::/64'], 25 | 'ff02::1:2/8' => ['ff02::1:2/8', 'ff00::/8'], 26 | '2004:6fe8::4/0' => ['2004:6fe8::4/0', '::/0'], 27 | '2004:6fe8::4/128' => ['2004:6fe8::4/128', '2004:6fe8::4/128'], 28 | ]; 29 | } 30 | 31 | #[DataProvider('provideNormalizePrefixTestData')] 32 | public function testShouldNormalizeCidrPrefix(string $input, string $expected): void 33 | { 34 | $subject = new IPv6Network(CIDRv6Address::fromString($input)); 35 | self::assertSame($expected, $subject->toString()); 36 | self::assertSame($expected, $subject->toCidrAddress()->toString()); 37 | } 38 | 39 | public static function provideRangeTestData(): iterable 40 | { 41 | $data = [ 42 | '200e:cafe:5::/120' => ['200e:cafe:5::1/120', '200e:cafe:5::ff/120'], 43 | '200e:cafe:5::15/120' => ['200e:cafe:5::1/120', '200e:cafe:5::ff/120'], 44 | '200e:cafe:5::1/128' => ['200e:cafe:5::1/128', '200e:cafe:5::1/128'], 45 | '200e:cafe:5::1/127' => ['200e:cafe:5::1/127', '200e:cafe:5::1/127'], 46 | '200e:cafe:5::1/126' => ['200e:cafe:5::1/126', '200e:cafe:5::3/126'], 47 | ]; 48 | 49 | foreach ($data as $input => $range) { 50 | yield $input => [$input, ...$range]; 51 | } 52 | } 53 | 54 | #[DataProvider('provideRangeTestData')] 55 | public function testShouldProvideNetworkRange(string $input, string $min, string $max): void 56 | { 57 | $subject = new IPv6Network(CIDRv6Address::fromString($input)); 58 | 59 | self::assertSame($min, $subject->getRangeMinAddress()->toString()); 60 | self::assertSame($max, $subject->getRangeMaxAddress()->toString()); 61 | } 62 | 63 | public static function provideContainsAddressTestData(): iterable 64 | { 65 | return [ 66 | '200e:cafe:5::/120' => ['200e:cafe:5::/120', '200e:cafe:5::15'], 67 | '200e:cafe:5::/64' => ['200e:cafe:5::/64', '200e:cafe:5::56a4:7f54'], 68 | ]; 69 | } 70 | 71 | #[DataProvider('provideContainsAddressTestData')] 72 | public function testShouldContainMatchingAddress(string $network, string $address): void 73 | { 74 | $subject = new IPv6Network(CIDRv6Address::fromString($network)); 75 | 76 | self::assertTrue($subject->containsAddress(IPv6Address::fromString($address))); 77 | } 78 | 79 | public static function provideNotContainsAddressTestData(): iterable 80 | { 81 | return [ 82 | 'not in prefix' => ['200e:cafe:5::/120', IPv6Address::fromString('200e:cafe:5::115')], 83 | 'different address type' => ['200e:cafe:5::/64', IPv4Address::fromString('129.0.0.1')], 84 | ]; 85 | } 86 | 87 | #[DataProvider('provideNotContainsAddressTestData')] 88 | public function testShouldNotContainNonMatchingAddress(string $network, IPAddress $address): void 89 | { 90 | $subject = new IPv6Network(CIDRv6Address::fromString($network)); 91 | 92 | self::assertFalse($subject->containsAddress($address)); 93 | } 94 | 95 | public function testShouldCompareEquality(): void 96 | { 97 | $first = new IPv6Network(CIDRv6Address::fromString('::1/10')); 98 | $second = new IPv6Network(CIDRv6Address::fromString('::1/10')); 99 | 100 | self::assertNotSame($first, $second); 101 | self::assertTrue($first->equals($second)); 102 | } 103 | 104 | /** @return iterable */ 105 | public static function provideInequalityTestData(): iterable 106 | { 107 | return [ 108 | 'different network' => ['::1/10', new IPv6Network(CIDRv6Address::fromString('::1/8'))], 109 | 'different type' => ['::1/10', new IPv4Network(CIDRv4Address::fromString('127.0.0.1/8'))], 110 | 'cidr' => ['::1/10', CIDRv6Address::fromString('::1/10')], 111 | ]; 112 | } 113 | 114 | #[DataProvider('provideInequalityTestData')] 115 | public function testShouldNotMatchInequality(string $subject, Address $other): void 116 | { 117 | self::assertFalse((new IPv6Network(CIDRv6Address::fromString($subject)))->equals($other)); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Network Address Types for PHP 2 | 3 | [![Test Coverage](https://api.codeclimate.com/v1/badges/0d4cfe36cf57a502bb8d/test_coverage)](https://codeclimate.com/github/lukanetconsult/network-address-types/test_coverage) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/0d4cfe36cf57a502bb8d/maintainability)](https://codeclimate.com/github/lukanetconsult/network-address-types/maintainability) 5 | [![Infection MSI](https://badge.stryker-mutator.io/github.com/lukanetconsult/network-address-types/master)](https://infection.github.io) 6 | 7 | This library provides types to handle the most common network addresses. 8 | The following address types are supported: 9 | 10 | - MAC addresses in the following forms: 11 | - Colon separated (`00:00:00:00:00:00`) 12 | - Dash separated (`00-00-00-00-00-00`) 13 | - Without separator (`000000000000`) 14 | - IPv4 (ex: `127.0.0.1`) 15 | - IPv6 (ex: `::1`) 16 | - CIDR addresses for IPv4 and IPv6 (ex: `129.0.0.1/12`, `ff80:e::/64`) 17 | 18 | ## Installation 19 | 20 | Installation can be performed with composer: 21 | 22 | ```bash 23 | composer require luka/network-address-types 24 | ``` 25 | 26 | ### Requirements 27 | 28 | - PHP 29 | - 7.4 (until version 1.1.0) 30 | - 8.0 31 | - 8.1 32 | - 8.2 33 | - `ext-gmp` for handling IPv6 calculations 34 | - `ext-json` as it supports json_encode by implementing `JSONSerializable` 35 | 36 | # Getting started 37 | 38 | ```php 39 | use LUKA\Network\NetworkAddress; 40 | 41 | $address = NetworkAddress::fromString('127.0.0.1'); 42 | assert($address instanceof \LUKA\Network\IPv4\IPv4Address); 43 | $address = NetworkAddress::fromString('127.0.0.1/8'); 44 | assert($address instanceof \LUKA\Network\IPv4\CIDRv4Address); 45 | $address = NetworkAddress::fromString('::1'); 46 | assert($address instanceof \LUKA\Network\IPv6\IPv6Address); 47 | $address = NetworkAddress::fromString('ff80::1/64'); 48 | assert($address instanceof \LUKA\Network\IPv6\CIDRv6Address); 49 | $address = NetworkAddress::fromString('84:34:ff:ff:ff:ff'); 50 | assert($address instanceof \LUKA\Network\MACAddress); 51 | ``` 52 | 53 | # Serialization 54 | 55 | All types can be constructed from strings and can therefore be converted 56 | to strings as well with the `toString()` method. 57 | 58 | ```php 59 | use LUKA\Network\NetworkAddress; 60 | 61 | assert('::1' === NetworkAddress::fromString('::1')->toString()); 62 | ``` 63 | 64 | When converting addresses to strings, they will be normalized for the corresponding type: 65 | 66 | ```php 67 | use LUKA\Network\NetworkAddress; 68 | 69 | assert('::1' === NetworkAddress::fromString('0:0:0:0::1')->toString()); 70 | assert('00:00:00:00:00:00' === NetworkAddress::fromString('00-00-00-00-00-00')->toString()); 71 | ``` 72 | 73 | ## JSON 74 | 75 | All address types implement the `JSONSerializable` interface, and can therefore be used with 76 | `json_encode()` directly. 77 | 78 | ## Binary 79 | 80 | IP and MAC addresses can also be converted to the corresponding byte sequence. (for example to storing them in 81 | a `BINARY` database field). 82 | 83 | They can also be constructed from this byte sequence with the static `fromByteString()` method of the corresponding 84 | class. 85 | 86 | # Address Comparison 87 | 88 | ## Compare equality 89 | 90 | Each address implements an `equals()` method to compare it to other 91 | network addresses. 92 | 93 | Addresses are considered equal when they are from the same type and contain 94 | the same value: 95 | 96 | ```php 97 | use LUKA\Network\NetworkAddress; 98 | 99 | assert( 100 | true === NetworkAddress::fromString('::1') 101 | ->equals(NetworkAddress::fromString('::1')) 102 | ); 103 | 104 | // Value mismatch: 105 | assert( 106 | false === NetworkAddress::fromString('::1') 107 | ->equals(NetworkAddress::fromString('::2')) 108 | ); 109 | 110 | // Type mismatch (different IP version): 111 | assert( 112 | false === NetworkAddress::fromString('::1') 113 | ->equals(NetworkAddress::fromString('127.0.0.1')) 114 | ); 115 | 116 | // Type mismatch (cidr vs non-cidr) 117 | assert( 118 | false === NetworkAddress::fromString('192.168.0.5') 119 | ->equals(NetworkAddress::fromString('192.168.0.5/24')) 120 | ); 121 | ``` 122 | 123 | ## Comparing addresses to networks 124 | 125 | CIDR addresses allow to obtain the corresponding network to the denoted address. 126 | With this network you can check if an IP address is within this network. 127 | 128 | ```php 129 | use LUKA\Network\NetworkAddress; 130 | 131 | $cidr = NetworkAddress::fromString('192.168.0.7/8'); 132 | $network = $cidr->toNetwork(); 133 | 134 | assert(true === $network->containsAddress(NetworkAddress::fromString('192.168.0.1'))); 135 | assert(true === $network->containsAddress(NetworkAddress::fromString('192.45.0.2'))); 136 | assert(false === $network->containsAddress(NetworkAddress::fromString('127.10.0.1'))); 137 | assert(false === $network->containsAddress(NetworkAddress::fromString('ff80::5'))); 138 | ``` 139 | 140 | This will work for IPv6 as well: 141 | 142 | ```php 143 | use LUKA\Network\NetworkAddress; 144 | 145 | $cidr = NetworkAddress::fromString('ff80::/64'); 146 | $network = $cidr->toNetwork(); 147 | 148 | assert(true === $network->containsAddress(NetworkAddress::fromString('ff80::1'))); 149 | assert(true === $network->containsAddress(NetworkAddress::fromString('ff80::10:e5:7'))); 150 | assert(false === $network->containsAddress(NetworkAddress::fromString('ff80:e::1'))); 151 | assert(false === $network->containsAddress(NetworkAddress::fromString('127.0.0.1'))); 152 | ``` 153 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### GNU LESSER GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | This version of the GNU Lesser General Public License incorporates the 12 | terms and conditions of version 3 of the GNU General Public License, 13 | supplemented by the additional permissions listed below. 14 | 15 | #### 0. Additional Definitions. 16 | 17 | As used herein, "this License" refers to version 3 of the GNU Lesser 18 | General Public License, and the "GNU GPL" refers to version 3 of the 19 | GNU General Public License. 20 | 21 | "The Library" refers to a covered work governed by this License, other 22 | than an Application or a Combined Work as defined below. 23 | 24 | An "Application" is any work that makes use of an interface provided 25 | by the Library, but which is not otherwise based on the Library. 26 | Defining a subclass of a class defined by the Library is deemed a mode 27 | of using an interface provided by the Library. 28 | 29 | A "Combined Work" is a work produced by combining or linking an 30 | Application with the Library. The particular version of the Library 31 | with which the Combined Work was made is also called the "Linked 32 | Version". 33 | 34 | The "Minimal Corresponding Source" for a Combined Work means the 35 | Corresponding Source for the Combined Work, excluding any source code 36 | for portions of the Combined Work that, considered in isolation, are 37 | based on the Application, and not on the Linked Version. 38 | 39 | The "Corresponding Application Code" for a Combined Work means the 40 | object code and/or source code for the Application, including any data 41 | and utility programs needed for reproducing the Combined Work from the 42 | Application, but excluding the System Libraries of the Combined Work. 43 | 44 | #### 1. Exception to Section 3 of the GNU GPL. 45 | 46 | You may convey a covered work under sections 3 and 4 of this License 47 | without being bound by section 3 of the GNU GPL. 48 | 49 | #### 2. Conveying Modified Versions. 50 | 51 | If you modify a copy of the Library, and, in your modifications, a 52 | facility refers to a function or data to be supplied by an Application 53 | that uses the facility (other than as an argument passed when the 54 | facility is invoked), then you may convey a copy of the modified 55 | version: 56 | 57 | - a) under this License, provided that you make a good faith effort 58 | to ensure that, in the event an Application does not supply the 59 | function or data, the facility still operates, and performs 60 | whatever part of its purpose remains meaningful, or 61 | - b) under the GNU GPL, with none of the additional permissions of 62 | this License applicable to that copy. 63 | 64 | #### 3. Object Code Incorporating Material from Library Header Files. 65 | 66 | The object code form of an Application may incorporate material from a 67 | header file that is part of the Library. You may convey such object 68 | code under terms of your choice, provided that, if the incorporated 69 | material is not limited to numerical parameters, data structure 70 | layouts and accessors, or small macros, inline functions and templates 71 | (ten or fewer lines in length), you do both of the following: 72 | 73 | - a) Give prominent notice with each copy of the object code that 74 | the Library is used in it and that the Library and its use are 75 | covered by this License. 76 | - b) Accompany the object code with a copy of the GNU GPL and this 77 | license document. 78 | 79 | #### 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, taken 82 | together, effectively do not restrict modification of the portions of 83 | the Library contained in the Combined Work and reverse engineering for 84 | debugging such modifications, if you also do each of the following: 85 | 86 | - a) Give prominent notice with each copy of the Combined Work that 87 | the Library is used in it and that the Library and its use are 88 | covered by this License. 89 | - b) Accompany the Combined Work with a copy of the GNU GPL and this 90 | license document. 91 | - c) For a Combined Work that displays copyright notices during 92 | execution, include the copyright notice for the Library among 93 | these notices, as well as a reference directing the user to the 94 | copies of the GNU GPL and this license document. 95 | - d) Do one of the following: 96 | - 0) Convey the Minimal Corresponding Source under the terms of 97 | this License, and the Corresponding Application Code in a form 98 | suitable for, and under terms that permit, the user to 99 | recombine or relink the Application with a modified version of 100 | the Linked Version to produce a modified Combined Work, in the 101 | manner specified by section 6 of the GNU GPL for conveying 102 | Corresponding Source. 103 | - 1) Use a suitable shared library mechanism for linking with 104 | the Library. A suitable mechanism is one that (a) uses at run 105 | time a copy of the Library already present on the user's 106 | computer system, and (b) will operate properly with a modified 107 | version of the Library that is interface-compatible with the 108 | Linked Version. 109 | - e) Provide Installation Information, but only if you would 110 | otherwise be required to provide such information under section 6 111 | of the GNU GPL, and only to the extent that such information is 112 | necessary to install and execute a modified version of the 113 | Combined Work produced by recombining or relinking the Application 114 | with a modified version of the Linked Version. (If you use option 115 | 4d0, the Installation Information must accompany the Minimal 116 | Corresponding Source and Corresponding Application Code. If you 117 | use option 4d1, you must provide the Installation Information in 118 | the manner specified by section 6 of the GNU GPL for conveying 119 | Corresponding Source.) 120 | 121 | #### 5. Combined Libraries. 122 | 123 | You may place library facilities that are a work based on the Library 124 | side by side in a single library together with other library 125 | facilities that are not Applications and are not covered by this 126 | License, and convey such a combined library under terms of your 127 | choice, if you do both of the following: 128 | 129 | - a) Accompany the combined library with a copy of the same work 130 | based on the Library, uncombined with any other library 131 | facilities, conveyed under the terms of this License. 132 | - b) Give prominent notice with the combined library that part of it 133 | is a work based on the Library, and explaining where to find the 134 | accompanying uncombined form of the same work. 135 | 136 | #### 6. Revised Versions of the GNU Lesser General Public License. 137 | 138 | The Free Software Foundation may publish revised and/or new versions 139 | of the GNU Lesser General Public License from time to time. Such new 140 | versions will be similar in spirit to the present version, but may 141 | differ in detail to address new problems or concerns. 142 | 143 | Each version is given a distinguishing version number. If the Library 144 | as you received it specifies that a certain numbered version of the 145 | GNU Lesser General Public License "or any later version" applies to 146 | it, you have the option of following the terms and conditions either 147 | of that published version or of any later version published by the 148 | Free Software Foundation. If the Library as you received it does not 149 | specify a version number of the GNU Lesser General Public License, you 150 | may choose any version of the GNU Lesser General Public License ever 151 | published by the Free Software Foundation. 152 | 153 | If the Library as you received it specifies that a proxy can decide 154 | whether future versions of the GNU Lesser General Public License shall 155 | apply, that proxy's public statement of acceptance of any version is 156 | permanent authorization for you to choose that version for the 157 | Library. 158 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions 2 | 3 | name: "CI" 4 | 5 | on: 6 | pull_request: null 7 | push: 8 | branches: 9 | - "master" 10 | 11 | env: 12 | PHP_EXTENSIONS: "dom filter gmp json libxml mbstring pcre phar reflection simplexml spl tokenizer xml xmlwriter" 13 | 14 | jobs: 15 | coding-standards: 16 | name: "Coding Standards" 17 | runs-on: "ubuntu-latest" 18 | 19 | strategy: 20 | matrix: 21 | php-version: 22 | - "8.4" 23 | 24 | dependencies: 25 | - "locked" 26 | 27 | steps: 28 | - name: "Checkout" 29 | uses: "actions/checkout@v2.3.4" 30 | 31 | - name: "Install PHP with extensions" 32 | uses: "shivammathur/setup-php@2.9.0" 33 | with: 34 | coverage: "none" 35 | extensions: "${{ env.PHP_EXTENSIONS }}" 36 | php-version: "${{ matrix.php-version }}" 37 | 38 | - name: "Validate composer.json and composer.lock" 39 | run: "composer validate" 40 | 41 | - name: "Determine composer cache directory" 42 | uses: "./.github/actions/composer/composer/determine-cache-directory" 43 | 44 | - name: "Cache dependencies installed with composer" 45 | uses: "actions/cache@v4.2.3" 46 | with: 47 | path: "${{ env.COMPOSER_CACHE_DIR }}" 48 | key: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('composer.lock') }}" 49 | restore-keys: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-" 50 | 51 | - name: "Install ${{ matrix.dependencies }} dependencies with composer" 52 | uses: "./.github/actions/composer/composer/install" 53 | with: 54 | dependencies: "${{ matrix.dependencies }}" 55 | 56 | - name: "Create cache directory for squizlabs/php_codesniffer" 57 | run: "mkdir -p .build/php_codesniffer" 58 | 59 | - name: "Cache cache directory for squizlabs/php_codesniffer" 60 | uses: "actions/cache@v4.2.3" 61 | with: 62 | path: ".build/php_codesniffer" 63 | key: "php-${{ matrix.php-version }}-php_codesniffer-${{ github.sha }}" 64 | restore-keys: "php-${{ matrix.php-version }}-php_codesniffer-" 65 | 66 | - name: "Run squizlabs/php_codesniffer" 67 | run: "vendor/bin/phpcs" 68 | 69 | static-code-analysis: 70 | name: "Static Code Analysis" 71 | 72 | runs-on: "ubuntu-latest" 73 | 74 | strategy: 75 | matrix: 76 | php-version: 77 | - "8.4" 78 | 79 | dependencies: 80 | - "locked" 81 | 82 | steps: 83 | - name: "Checkout" 84 | uses: "actions/checkout@v2.3.4" 85 | 86 | - name: "Install PHP with extensions" 87 | uses: "shivammathur/setup-php@2.9.0" 88 | with: 89 | coverage: "none" 90 | extensions: "${{ env.PHP_EXTENSIONS }}" 91 | php-version: "${{ matrix.php-version }}" 92 | 93 | - name: "Determine composer cache directory" 94 | uses: "./.github/actions/composer/composer/determine-cache-directory" 95 | 96 | - name: "Cache dependencies installed with composer" 97 | uses: "actions/cache@v4.2.3" 98 | with: 99 | path: "${{ env.COMPOSER_CACHE_DIR }}" 100 | key: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('composer.lock') }}" 101 | restore-keys: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-" 102 | 103 | - name: "Install ${{ matrix.dependencies }} dependencies with composer" 104 | uses: "./.github/actions/composer/composer/install" 105 | with: 106 | dependencies: "${{ matrix.dependencies }}" 107 | 108 | - name: "Create cache directory for vimeo/psalm" 109 | run: "mkdir -p .build/psalm" 110 | 111 | - name: "Cache cache directory for vimeo/psalm" 112 | uses: "actions/cache@v4.2.3" 113 | with: 114 | path: ".build/psalm" 115 | key: "php-${{ matrix.php-version }}-psalm-${{ github.sha }}" 116 | restore-keys: "php-${{ matrix.php-version }}-psalm-" 117 | 118 | - name: "Run vimeo/psalm" 119 | run: "vendor/bin/psalm --config=psalm.xml --diff --show-info=false --stats --threads=4" 120 | 121 | tests: 122 | name: "Tests" 123 | 124 | runs-on: "ubuntu-latest" 125 | 126 | env: 127 | COLLECT_COVERAGE_VERSION: "8.4" 128 | 129 | strategy: 130 | matrix: 131 | php-version: 132 | - "8.2" 133 | - "8.3" 134 | - "8.4" 135 | 136 | dependencies: 137 | - "lowest" 138 | - "highest" 139 | 140 | include: 141 | - php-version: "8.4" 142 | dependencies: "locked" 143 | 144 | steps: 145 | - name: "Checkout" 146 | uses: "actions/checkout@v2.3.4" 147 | 148 | - name: "Install PHP with extensions" 149 | uses: "shivammathur/setup-php@2.9.0" 150 | if: "!(matrix.php-version == env.COLLECT_COVERAGE_VERSION && matrix.dependencies == 'locked')" 151 | with: 152 | coverage: "none" 153 | extensions: "${{ env.PHP_EXTENSIONS }}" 154 | php-version: "${{ matrix.php-version }}" 155 | 156 | - name: "Install PHP with extensions and coverage" 157 | uses: "shivammathur/setup-php@2.9.0" 158 | if: "matrix.php-version == env.COLLECT_COVERAGE_VERSION && matrix.dependencies == 'locked'" 159 | with: 160 | coverage: "xdebug" 161 | extensions: "${{ env.PHP_EXTENSIONS }}" 162 | php-version: "${{ matrix.php-version }}" 163 | 164 | - name: "Set up problem matchers for phpunit/phpunit" 165 | run: "echo \"::add-matcher::${{ runner.tool_cache }}/phpunit.json\"" 166 | 167 | - name: "Determine composer cache directory" 168 | uses: "./.github/actions/composer/composer/determine-cache-directory" 169 | 170 | - name: "Cache dependencies installed with composer" 171 | uses: "actions/cache@v4.2.3" 172 | with: 173 | path: "${{ env.COMPOSER_CACHE_DIR }}" 174 | key: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('composer.lock') }}" 175 | restore-keys: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-" 176 | 177 | - name: "Install ${{ matrix.dependencies }} dependencies with composer" 178 | uses: "./.github/actions/composer/composer/install" 179 | with: 180 | dependencies: "${{ matrix.dependencies }}" 181 | 182 | - name: "PHPUnit" 183 | if: "!(matrix.php-version == env.COLLECT_COVERAGE_VERSION && matrix.dependencies == 'locked')" 184 | run: "vendor/bin/phpunit --configuration=phpunit.xml.dist" 185 | 186 | - name: "PHPUnit with code coverage" 187 | if: "matrix.php-version == env.COLLECT_COVERAGE_VERSION && matrix.dependencies == 'locked'" 188 | run: "vendor/bin/phpunit --configuration=phpunit.xml.dist --coverage-clover=.build/phpunit/logs/clover.xml" 189 | 190 | - name: "Send code coverage report to codeclimate.com" 191 | if: "matrix.php-version == env.COLLECT_COVERAGE_VERSION && matrix.dependencies == 'locked'" 192 | env: 193 | CC_TEST_REPORTER_ID: "${{ secrets.CC_TEST_REPORTER_ID }}" 194 | uses: "paambaati/codeclimate-action@v9.0.0" 195 | continue-on-error: true 196 | with: 197 | coverageLocations: "${{ github.workspace }}/.build/phpunit/logs/clover.xml" 198 | 199 | mutation-tests: 200 | name: "Mutation Tests" 201 | 202 | runs-on: "ubuntu-latest" 203 | 204 | strategy: 205 | matrix: 206 | php-version: 207 | - "8.4" 208 | 209 | dependencies: 210 | - "locked" 211 | 212 | steps: 213 | - name: "Checkout" 214 | uses: "actions/checkout@v2.3.4" 215 | 216 | - name: "Install PHP with extensions" 217 | uses: "shivammathur/setup-php@2.9.0" 218 | with: 219 | coverage: "xdebug" 220 | extensions: "${{ env.PHP_EXTENSIONS }}" 221 | php-version: "${{ matrix.php-version }}" 222 | tools: "infection" 223 | 224 | - name: "Determine composer cache directory" 225 | uses: "./.github/actions/composer/composer/determine-cache-directory" 226 | 227 | - name: "Cache dependencies installed with composer" 228 | uses: "actions/cache@v4.2.3" 229 | with: 230 | path: "${{ env.COMPOSER_CACHE_DIR }}" 231 | key: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('composer.lock') }}" 232 | restore-keys: "php-${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-" 233 | 234 | - name: "Install ${{ matrix.dependencies }} dependencies with composer" 235 | uses: "./.github/actions/composer/composer/install" 236 | with: 237 | dependencies: "${{ matrix.dependencies }}" 238 | 239 | - name: "Run mutation tests with Xdebug and infection/infection" 240 | run: "infection --configuration=infection.json.dist" 241 | env: 242 | STRYKER_DASHBOARD_API_KEY: "${{ secrets.STRYKER_DASHBOARD_API_KEY }}" 243 | --------------------------------------------------------------------------------