diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -110,6 +110,8 @@ 'PhutilBufferedIteratorTestCase' => 'utils/__tests__/PhutilBufferedIteratorTestCase.php', 'PhutilBugtraqParser' => 'parser/PhutilBugtraqParser.php', 'PhutilBugtraqParserTestCase' => 'parser/__tests__/PhutilBugtraqParserTestCase.php', + 'PhutilCIDRBlock' => 'ip/PhutilCIDRBlock.php', + 'PhutilCIDRList' => 'ip/PhutilCIDRList.php', 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilCLikeCodeSnippetContextFreeGrammar.php', 'PhutilCallbackFilterIterator' => 'utils/PhutilCallbackFilterIterator.php', 'PhutilChannel' => 'channel/PhutilChannel.php', @@ -177,6 +179,8 @@ 'PhutilHangForeverDaemon' => 'daemon/torture/PhutilHangForeverDaemon.php', 'PhutilHelpArgumentWorkflow' => 'parser/argument/workflow/PhutilHelpArgumentWorkflow.php', 'PhutilHgsprintfTestCase' => 'xsprintf/__tests__/PhutilHgsprintfTestCase.php', + 'PhutilIPAddress' => 'ip/PhutilIPAddress.php', + 'PhutilIPAddressTestCase' => 'ip/__tests__/PhutilIPAddressTestCase.php', 'PhutilInRequestKeyValueCache' => 'cache/PhutilInRequestKeyValueCache.php', 'PhutilInfrastructureTestCase' => '__tests__/PhutilInfrastructureTestCase.php', 'PhutilInteractiveEditor' => 'console/PhutilInteractiveEditor.php', @@ -554,6 +558,8 @@ 'PhutilBufferedIterator' => 'Iterator', 'PhutilBufferedIteratorTestCase' => 'PhutilTestCase', 'PhutilBugtraqParserTestCase' => 'PhutilTestCase', + 'PhutilCIDRBlock' => 'Phobject', + 'PhutilCIDRList' => 'Phobject', 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar', 'PhutilCallbackFilterIterator' => 'FilterIterator', 'PhutilChannelChannel' => 'PhutilChannel', @@ -597,6 +603,8 @@ 'PhutilHangForeverDaemon' => 'PhutilTortureTestDaemon', 'PhutilHelpArgumentWorkflow' => 'PhutilArgumentWorkflow', 'PhutilHgsprintfTestCase' => 'PhutilTestCase', + 'PhutilIPAddress' => 'Phobject', + 'PhutilIPAddressTestCase' => 'PhutilTestCase', 'PhutilInRequestKeyValueCache' => 'PhutilKeyValueCache', 'PhutilInfrastructureTestCase' => 'PhutilTestCase', 'PhutilInvalidRuleParserGeneratorException' => 'PhutilParserGeneratorException', diff --git a/src/ip/PhutilCIDRBlock.php b/src/ip/PhutilCIDRBlock.php new file mode 100644 --- /dev/null +++ b/src/ip/PhutilCIDRBlock.php @@ -0,0 +1,75 @@ + + } + + public static function newBlock($in) { + if ($in instanceof PhutilCIDRBlock) { + return $in; + } + + return self::newFromString($in); + } + + private static function newFromString($str) { + if (!preg_match('(^[\d.]+/[\d]+\z)', $str)) { + throw new Exception( + pht( + 'CIDR block "%s" is not formatted correctly. Expected an IP block '. + 'in CIDR notation, like "172.30.0.0/16".', + $str)); + } + + list($ip, $mask) = explode('/', $str); + + $ip = PhutilIPAddress::newAddress($ip); + + // These rules are for IPv4; some day we'll handle IPv6 too. + + if (preg_match('/^0\d/', $mask)) { + throw new Exception( + pht( + 'CIDR block "%s" is not formatted correctly. The IP block mask '. + '("%s") must not have leading zeroes.', + $str, + $mask)); + } + + $bits = (int)$mask; + if ($bits < 0 || $bits > 32) { + throw new Exception( + pht( + 'CIDR block "%s" is not formatted correctly. The IP block mask '. + '("%s") must mask between 0 and 32 bits, inclusive.', + $str, + $mask)); + } + + $obj = new PhutilCIDRBlock(); + $obj->ip = $ip; + $obj->bits = $bits; + + return $obj; + } + + public function containsAddress($address) { + $address = PhutilIPAddress::newAddress($address); + + $block_bits = $this->ip->toBits(); + $address_bits = $address->toBits(); + + return (strncmp($block_bits, $address_bits, $this->bits) === 0); + } + +} diff --git a/src/ip/PhutilCIDRList.php b/src/ip/PhutilCIDRList.php new file mode 100644 --- /dev/null +++ b/src/ip/PhutilCIDRList.php @@ -0,0 +1,40 @@ +containsAddrsss('172.30.0.1'); + */ +final class PhutilCIDRList extends Phobject { + + private $blocks; + + private function __construct() { + // + } + + public static function newList(array $blocks) { + foreach ($blocks as $key => $block) { + $blocks[$key] = PhutilCIDRBlock::newBlock($block); + } + + $obj = new PhutilCIDRList(); + $obj->blocks = $blocks; + return $obj; + } + + public function containsAddress($address) { + foreach ($this->blocks as $block) { + if ($block->containsAddress($address)) { + return true; + } + } + + return false; + } + +} diff --git a/src/ip/PhutilIPAddress.php b/src/ip/PhutilIPAddress.php new file mode 100644 --- /dev/null +++ b/src/ip/PhutilIPAddress.php @@ -0,0 +1,87 @@ + + } + + public static function newAddress($in) { + if ($in instanceof PhutilIPAddress) { + return $in; + } + + return self::newFromString($in); + } + + private static function newFromString($str) { + $matches = null; + $ok = preg_match('(^(\d+)\.(\d+)\.(\d+).(\d+)\z)', $str, $matches); + if (!$ok) { + throw new Exception( + pht( + 'IP address "%s" is not properly formatted. Expected an IP '. + 'address like "23.45.67.89".', + $str)); + } + + $parts = array_slice($matches, 1); + foreach ($parts as $part) { + if (preg_match('/^0\d/', $part)) { + throw new Exception( + pht( + 'IP address "%s" is not properly formatted. Address segments '. + 'should have no leading zeroes, but segment "%s" has a leading '. + 'zero.', + $str, + $part)); + } + + $value = (int)$part; + if ($value < 0 || $value > 255) { + throw new Exception( + pht( + 'IP address "%s" is not properly formatted. Address segments '. + 'should be between 0 and 255, inclusive, but segment "%s" has '. + 'a value outside of this range.', + $str, + $part)); + } + } + + $obj = new PhutilIPAddress(); + $obj->ip = $str; + + return $obj; + } + + public function toBits() { + if ($this->bits === null) { + $bits = ''; + foreach (explode('.', $this->ip) as $part) { + $value = (int)$part; + for ($ii = 7; $ii >= 0; $ii--) { + $mask = (1 << $ii); + if (($value & $mask) === $mask) { + $bits .= '1'; + } else { + $bits .= '0'; + } + } + } + + $this->bits = $bits; + } + + return $this->bits; + } + +} diff --git a/src/ip/__tests__/PhutilIPAddressTestCase.php b/src/ip/__tests__/PhutilIPAddressTestCase.php new file mode 100644 --- /dev/null +++ b/src/ip/__tests__/PhutilIPAddressTestCase.php @@ -0,0 +1,152 @@ + true, + + // No nonsense. + '1.2.3' => false, + 'duck' => false, + '' => false, + '1 2 3 4' => false, + '.' => false, + '1.2.3.4.' => false, + '1..3.4' => false, + + // No leading zeroes. + '0.0.0.0' => true, + '0.0.0.01' => false, + + // No segments > 255. + '255.255.255.255' => true, + '255.255.255.256' => false, + ); + + foreach ($cases as $input => $expect) { + $caught = null; + try { + PhutilIPAddress::newAddress($input); + } catch (Exception $ex) { + $caught = $ex; + } + + $this->assertEqual( + $expect, + !($caught instanceof Exception), + 'PhutilIPAddress['.$input.']'); + } + } + + public function testIPAddressToBits() { + $cases = array( + '0.0.0.0' => '00000000000000000000000000000000', + '255.255.255.255' => '11111111111111111111111111111111', + '255.0.0.0' => '11111111000000000000000000000000', + '0.0.0.1' => '00000000000000000000000000000001', + '0.0.0.2' => '00000000000000000000000000000010', + '0.0.0.3' => '00000000000000000000000000000011', + ); + + foreach ($cases as $input => $expect) { + $actual = PhutilIPAddress::newAddress($input)->toBits(); + $this->assertEqual( + $expect, + $actual, + 'PhutilIPAddress['.$input.']->toBits()'); + } + } + + public function testValidCIDRBlocks() { + $cases = array( + // Valid block. + '1.0.0.0/16' => true, + + // No nonsense. + 'duck' => false, + '1/2/3' => false, + '23/0.0.0.0' => false, + '0.0.0.0/0.0.0.0' => false, + + // No leading zeroes. + '1.0.0.0/4' => true, + '1.0.0.0/04' => false, + + // No out-of-range masks. + '1.0.0.0/32' => true, + '1.0.0.0/33' => false, + ); + + foreach ($cases as $input => $expect) { + $caught = null; + try { + PhutilCIDRBlock::newBlock($input); + } catch (Exception $ex) { + $caught = $ex; + } + + $this->assertEqual( + $expect, + !($caught instanceof Exception), + 'PhutilCIDRBlock['.$input.']'); + } + } + + public function testCIDRBlockContains() { + $cases = array( + '0.0.0.0/0' => array( + '0.0.0.0' => true, + '1.1.1.1' => true, + '2.3.4.5' => true, + ), + '0.0.0.2/32' => array( + '0.0.0.1' => false, + '0.0.0.2' => true, + '0.0.0.3' => false, + ), + '172.30.0.0/16' => array( + '172.29.255.255' => false, + '172.30.0.0' => true, + '172.30.255.255' => true, + '172.31.0.0' => false, + ), + ); + + foreach ($cases as $input_block => $tests) { + $block = PhutilCIDRBlock::newBlock($input_block); + foreach ($tests as $input => $expect) { + $this->assertEqual( + $expect, + $block->containsAddress($input), + 'PhutilCIDRBlock['.$input_block.']->containsAddress('.$input.')'); + } + } + } + + public function testCIDRList() { + $list = array( + '172.30.0.0/16', + '127.0.0.3/32', + ); + + $cases = array( + '0.0.0.0' => false, + '172.30.0.5' => true, + '127.0.0.2' => false, + '127.0.0.3' => true, + ); + + $list = PhutilCIDRList::newList($list); + + foreach ($cases as $input => $expect) { + $this->assertEqual( + $expect, + $list->containsAddress($input), + 'PhutilCIDRList->containsAddress('.$input.')'); + } + } + + +}