diff --git a/.travis.yml b/.travis.yml index 41921e3..459e852 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,8 @@ - 7.0 - 7.1 - 7.2 - - hhvm # ignore errors, see below + - 7.3 +# - hhvm # requires legacy phpunit & ignore errors, see below # lock distro so new future defaults will not break the build dist: trusty @@ -17,6 +18,8 @@ include: - php: 5.3 dist: precise + - php: hhvm + install: composer require phpunit/phpunit:^5 --dev --no-interaction allow_failures: - php: hhvm diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c06569..4f651a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,89 @@ # Changelog + +## 1.2.0 (2019-08-15) + +* Feature: Add `TcpTransportExecutor` to send DNS queries over TCP/IP connection, + add `SelectiveTransportExecutor` to retry with TCP if UDP is truncated and + automatically select transport protocol when no explicit `udp://` or `tcp://` scheme is given in `Factory`. + (#145, #146, #147 and #148 by @clue) + +* Feature: Support escaping literal dots and special characters in domain names. + (#144 by @clue) + +## 1.1.0 (2019-07-18) + +* Feature: Support parsing `CAA` and `SSHFP` records. + (#141 and #142 by @clue) + +* Feature: Add `ResolverInterface` as common interface for `Resolver` class. + (#139 by @clue) + +* Fix: Add missing private property definitions and + remove unneeded dependency on `react/stream`. + (#140 and #143 by @clue) + +## 1.0.0 (2019-07-11) + +* First stable LTS release, now following [SemVer](https://semver.org/). + We'd like to emphasize that this component is production ready and battle-tested. + We plan to support all long-term support (LTS) releases for at least 24 months, + so you have a rock-solid foundation to build on top of. + +This update involves a number of BC breaks due to dropped support for +deprecated functionality and some internal API cleanup. We've tried hard to +avoid BC breaks where possible and minimize impact otherwise. We expect that +most consumers of this package will actually not be affected by any BC +breaks, see below for more details: + +* BC break: Delete all deprecated APIs, use `Query` objects for `Message` questions + instead of nested arrays and increase code coverage to 100%. + (#130 by @clue) + +* BC break: Move `$nameserver` from `ExecutorInterface` to `UdpTransportExecutor`, + remove advanced/internal `UdpTransportExecutor` args for `Parser`/`BinaryDumper` and + add API documentation for `ExecutorInterface`. + (#135, #137 and #138 by @clue) + +* BC break: Replace `HeaderBag` attributes with simple `Message` properties. + (#132 by @clue) + +* BC break: Mark all `Record` attributes as required, add documentation vs `Query`. + (#136 by @clue) + +* BC break: Mark all classes as final to discourage inheritance + (#134 by @WyriHaximus) + +## 0.4.19 (2019-07-10) + +* Feature: Avoid garbage references when DNS resolution rejects on legacy PHP <= 5.6. + (#133 by @clue) + +## 0.4.18 (2019-09-07) + +* Feature / Fix: Implement `CachingExecutor` using cache TTL, deprecate old `CachedExecutor`, + respect TTL from response records when caching and do not cache truncated responses. + (#129 by @clue) + +* Feature: Limit cache size to 256 last responses by default. + (#127 by @clue) + +* Feature: Cooperatively resolve hosts to avoid running same query concurrently. + (#125 by @clue) + +## 0.4.17 (2019-04-01) + +* Feature: Support parsing `authority` and `additional` records from DNS response. + (#123 by @clue) + +* Feature: Support dumping records as part of outgoing binary DNS message. + (#124 by @clue) + +* Feature: Forward compatibility with upcoming Cache v0.6 and Cache v1.0 + (#121 by @clue) + +* Improve test suite to add forward compatibility with PHPUnit 7, + test against PHP 7.3 and use legacy PHPUnit 5 on legacy HHVM. + (#122 by @clue) ## 0.4.16 (2018-11-11) diff --git a/README.md b/README.md index 043b937..fecbd11 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,13 @@ * [Basic usage](#basic-usage) * [Caching](#caching) * [Custom cache adapter](#custom-cache-adapter) -* [Resolver](#resolver) +* [ResolverInterface](#resolverinterface) * [resolve()](#resolve) * [resolveAll()](#resolveall) * [Advanced usage](#advanced-usage) * [UdpTransportExecutor](#udptransportexecutor) + * [TcpTransportExecutor](#tcptransportexecutor) + * [SelectiveTransportExecutor](#selectivetransportexecutor) * [HostsFileExecutor](#hostsfileexecutor) * [Install](#install) * [Tests](#tests) @@ -111,7 +113,9 @@ See also the wiki for possible [cache implementations](https://github.com/reactphp/react/wiki/Users#cache-implementations). -## Resolver +## ResolverInterface + + ### resolve() @@ -208,10 +212,9 @@ ```php $loop = Factory::create(); -$executor = new UdpTransportExecutor($loop); +$executor = new UdpTransportExecutor('8.8.8.8:53', $loop); $executor->query( - '8.8.8.8:53', new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) )->then(function (Message $message) { foreach ($message->answers as $answer) { @@ -229,7 +232,7 @@ ```php $executor = new TimeoutExecutor( - new UdpTransportExecutor($loop), + new UdpTransportExecutor($nameserver, $loop), 3.0, $loop ); @@ -242,9 +245,29 @@ ```php $executor = new RetryExecutor( new TimeoutExecutor( - new UdpTransportExecutor($loop), + new UdpTransportExecutor($nameserver, $loop), 3.0, $loop + ) +); +``` + +Note that this executor is entirely async and as such allows you to execute +any number of queries concurrently. You should probably limit the number of +concurrent queries in your application or you're very likely going to face +rate limitations and bans on the resolver end. For many common applications, +you may want to avoid sending the same query multiple times when the first +one is still pending, so you will likely want to use this in combination with +a `CoopExecutor` like this: + +```php +$executor = new CoopExecutor( + new RetryExecutor( + new TimeoutExecutor( + new UdpTransportExecutor($nameserver, $loop), + 3.0, + $loop + ) ) ); ``` @@ -255,6 +278,127 @@ packages. Higher-level components should take advantage of the Datagram component instead of reimplementing this socket logic from scratch. +### TcpTransportExecutor + +The `TcpTransportExecutor` class can be used to +send DNS queries over a TCP/IP stream transport. + +This is one of the main classes that send a DNS query to your DNS server. + +For more advanced usages one can utilize this class directly. +The following example looks up the `IPv6` address for `reactphp.org`. + +```php +$loop = Factory::create(); +$executor = new TcpTransportExecutor('8.8.8.8:53', $loop); + +$executor->query( + new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) +)->then(function (Message $message) { + foreach ($message->answers as $answer) { + echo 'IPv6: ' . $answer->data . PHP_EOL; + } +}, 'printf'); + +$loop->run(); +``` + +See also [example #92](examples). + +Note that this executor does not implement a timeout, so you will very likely +want to use this in combination with a `TimeoutExecutor` like this: + +```php +$executor = new TimeoutExecutor( + new TcpTransportExecutor($nameserver, $loop), + 3.0, + $loop +); +``` + +Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP +transport, so you do not necessarily have to implement any retry logic. + +Note that this executor is entirely async and as such allows you to execute +queries concurrently. The first query will establish a TCP/IP socket +connection to the DNS server which will be kept open for a short period. +Additional queries will automatically reuse this existing socket connection +to the DNS server, will pipeline multiple requests over this single +connection and will keep an idle connection open for a short period. The +initial TCP/IP connection overhead may incur a slight delay if you only send +occasional queries – when sending a larger number of concurrent queries over +an existing connection, it becomes increasingly more efficient and avoids +creating many concurrent sockets like the UDP-based executor. You may still +want to limit the number of (concurrent) queries in your application or you +may be facing rate limitations and bans on the resolver end. For many common +applications, you may want to avoid sending the same query multiple times +when the first one is still pending, so you will likely want to use this in +combination with a `CoopExecutor` like this: + +```php +$executor = new CoopExecutor( + new TimeoutExecutor( + new TcpTransportExecutor($nameserver, $loop), + 3.0, + $loop + ) +); +``` + +> Internally, this class uses PHP's TCP/IP sockets and does not take advantage + of [react/socket](https://github.com/reactphp/socket) purely for + organizational reasons to avoid a cyclic dependency between the two + packages. Higher-level components should take advantage of the Socket + component instead of reimplementing this socket logic from scratch. + +### SelectiveTransportExecutor + +The `SelectiveTransportExecutor` class can be used to +Send DNS queries over a UDP or TCP/IP stream transport. + +This class will automatically choose the correct transport protocol to send +a DNS query to your DNS server. It will always try to send it over the more +efficient UDP transport first. If this query yields a size related issue +(truncated messages), it will retry over a streaming TCP/IP transport. + +For more advanced usages one can utilize this class directly. +The following example looks up the `IPv6` address for `reactphp.org`. + +```php +$executor = new SelectiveTransportExecutor($udpExecutor, $tcpExecutor); + +$executor->query( + new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) +)->then(function (Message $message) { + foreach ($message->answers as $answer) { + echo 'IPv6: ' . $answer->data . PHP_EOL; + } +}, 'printf'); +``` + +Note that this executor only implements the logic to select the correct +transport for the given DNS query. Implementing the correct transport logic, +implementing timeouts and any retry logic is left up to the given executors, +see also [`UdpTransportExecutor`](#udptransportexecutor) and +[`TcpTransportExecutor`](#tcptransportexecutor) for more details. + +Note that this executor is entirely async and as such allows you to execute +any number of queries concurrently. You should probably limit the number of +concurrent queries in your application or you're very likely going to face +rate limitations and bans on the resolver end. For many common applications, +you may want to avoid sending the same query multiple times when the first +one is still pending, so you will likely want to use this in combination with +a `CoopExecutor` like this: + +```php +$executor = new CoopExecutor( + new SelectiveTransportExecutor( + $datagramExecutor, + $streamExecutor + ) +); +``` + ### HostsFileExecutor Note that the above `UdpTransportExecutor` class always performs an actual DNS query. @@ -264,11 +408,10 @@ ```php $hosts = \React\Dns\Config\HostsFile::loadFromPathBlocking(); -$executor = new UdpTransportExecutor($loop); +$executor = new UdpTransportExecutor('8.8.8.8:53', $loop); $executor = new HostsFileExecutor($hosts, $executor); $executor->query( - '8.8.8.8:53', new Query('localhost', Message::TYPE_A, Message::CLASS_IN) ); ``` @@ -278,10 +421,11 @@ The recommended way to install this library is [through Composer](https://getcomposer.org). [New to Composer?](https://getcomposer.org/doc/00-intro.md) +This project follows [SemVer](https://semver.org/). This will install the latest supported version: ```bash -$ composer require react/dns:^0.4.16 +$ composer require react/dns:^1.2 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. diff --git a/composer.json b/composer.json index 40010c2..5c0e47a 100644 --- a/composer.json +++ b/composer.json @@ -5,15 +5,14 @@ "license": "MIT", "require": { "php": ">=5.3.0", - "react/cache": "^0.5 || ^0.4 || ^0.3", - "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3.5", - "react/promise": "^2.1 || ^1.2.1", - "react/promise-timer": "^1.2", - "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.5" + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.0 || ^0.5", + "react/promise": "^2.7 || ^1.2.1", + "react/promise-timer": "^1.2" }, "require-dev": { "clue/block-react": "^1.2", - "phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35" }, "autoload": { "psr-4": { "React\\Dns\\": "src" } diff --git a/examples/12-all-types.php b/examples/12-all-types.php index 438ee86..adffa00 100644 --- a/examples/12-all-types.php +++ b/examples/12-all-types.php @@ -1,4 +1,7 @@ query('8.8.8.8:53', $ipv4Query)->then(function (Message $message) { +$executor->query($ipv4Query)->then(function (Message $message) { foreach ($message->answers as $answer) { echo 'IPv4: ' . $answer->data . PHP_EOL; } }, 'printf'); -$executor->query('8.8.8.8:53', $ipv6Query)->then(function (Message $message) { +$executor->query($ipv6Query)->then(function (Message $message) { foreach ($message->answers as $answer) { echo 'IPv6: ' . $answer->data . PHP_EOL; } diff --git a/examples/92-query-any.php b/examples/92-query-any.php index dcc14ae..3b98fe2 100644 --- a/examples/92-query-any.php +++ b/examples/92-query-any.php @@ -1,21 +1,24 @@ query('8.8.8.8:53', $any)->then(function (Message $message) { +$executor->query($any)->then(function (Message $message) { foreach ($message->answers as $answer) { /* @var $answer Record */ @@ -49,14 +52,24 @@ $data = implode(' ', $data); break; case Message::TYPE_SRV: - // SRV records contains priority, weight, port and target, dump structure here + // SRV records contain priority, weight, port and target, dump structure here $type = 'SRV'; $data = json_encode($data); + break; + case Message::TYPE_SSHFP: + // SSHFP records contain algorithm, fingerprint type and hex fingerprint value + $type = 'SSHFP'; + $data = implode(' ', $data); break; case Message::TYPE_SOA: // SOA records contain structured data, dump structure here $type = 'SOA'; $data = json_encode($data); + break; + case Message::TYPE_CAA: + // CAA records contains flag, tag and value + $type = 'CAA'; + $data = $data['flag'] . ' ' . $data['tag'] . ' "' . $data['value'] . '"'; break; default: // unknown type uses HEX format diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 13d3fab..04d426b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,6 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" - syntaxCheck="false" bootstrap="vendor/autoload.php" > diff --git a/src/BadServerException.php b/src/BadServerException.php index 3bf50f1..3d95213 100644 --- a/src/BadServerException.php +++ b/src/BadServerException.php @@ -2,6 +2,6 @@ namespace React\Dns; -class BadServerException extends \Exception +final class BadServerException extends \Exception { } diff --git a/src/Config/Config.php b/src/Config/Config.php index c82635d..37ae91d 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -4,7 +4,7 @@ use RuntimeException; -class Config +final class Config { /** * Loads the system DNS configuration diff --git a/src/Config/FilesystemFactory.php b/src/Config/FilesystemFactory.php deleted file mode 100644 index 68cec3e..0000000 --- a/src/Config/FilesystemFactory.php +++ /dev/null @@ -1,73 +0,0 @@ -loop = $loop; - } - - public function create($filename) - { - return $this - ->loadEtcResolvConf($filename) - ->then(array($this, 'parseEtcResolvConf')); - } - - /** - * @param string $contents - * @return Promise - * @deprecated see Config instead - */ - public function parseEtcResolvConf($contents) - { - return Promise\resolve(Config::loadResolvConfBlocking( - 'data://text/plain;base64,' . base64_encode($contents) - )); - } - - public function loadEtcResolvConf($filename) - { - if (!file_exists($filename)) { - return Promise\reject(new \InvalidArgumentException("The filename for /etc/resolv.conf given does not exist: $filename")); - } - - try { - $deferred = new Deferred(); - - $fd = fopen($filename, 'r'); - stream_set_blocking($fd, 0); - - $contents = ''; - - $stream = class_exists('React\Stream\ReadableResourceStream') ? new ReadableResourceStream($fd, $this->loop) : new Stream($fd, $this->loop); - $stream->on('data', function ($data) use (&$contents) { - $contents .= $data; - }); - $stream->on('end', function () use (&$contents, $deferred) { - $deferred->resolve($contents); - }); - $stream->on('error', function ($error) use ($deferred) { - $deferred->reject($error); - }); - - return $deferred->promise(); - } catch (\Exception $e) { - return Promise\reject($e); - } - } -} diff --git a/src/Config/HostsFile.php b/src/Config/HostsFile.php index 5b6277e..c9e66da 100644 --- a/src/Config/HostsFile.php +++ b/src/Config/HostsFile.php @@ -72,6 +72,8 @@ return new self($contents); } + + private $contents; /** * Instantiate new hosts file with the given hosts file contents diff --git a/src/Model/HeaderBag.php b/src/Model/HeaderBag.php deleted file mode 100644 index 0093bd3..0000000 --- a/src/Model/HeaderBag.php +++ /dev/null @@ -1,59 +0,0 @@ - 0, - 'anCount' => 0, - 'nsCount' => 0, - 'arCount' => 0, - 'qr' => 0, - 'opcode' => Message::OPCODE_QUERY, - 'aa' => 0, - 'tc' => 0, - 'rd' => 0, - 'ra' => 0, - 'z' => 0, - 'rcode' => Message::RCODE_OK, - ); - - /** - * @deprecated unused, exists for BC only - */ - public $data = ''; - - public function get($name) - { - return isset($this->attributes[$name]) ? $this->attributes[$name] : null; - } - - public function set($name, $value) - { - $this->attributes[$name] = $value; - } - - public function isQuery() - { - return 0 === $this->attributes['qr']; - } - - public function isResponse() - { - return 1 === $this->attributes['qr']; - } - - public function isTruncated() - { - return 1 === $this->attributes['tc']; - } - - public function populateCounts(Message $message) - { - $this->attributes['qdCount'] = count($message->questions); - $this->attributes['anCount'] = count($message->answers); - $this->attributes['nsCount'] = count($message->authority); - $this->attributes['arCount'] = count($message->additional); - } -} diff --git a/src/Model/Message.php b/src/Model/Message.php index 167344d..df42385 100644 --- a/src/Model/Message.php +++ b/src/Model/Message.php @@ -4,7 +4,12 @@ use React\Dns\Query\Query; -class Message +/** + * This class represents an outgoing query message or an incoming response message + * + * @link https://tools.ietf.org/html/rfc1035#section-4.1.1 + */ +final class Message { const TYPE_A = 1; const TYPE_NS = 2; @@ -15,7 +20,9 @@ const TYPE_TXT = 16; const TYPE_AAAA = 28; const TYPE_SRV = 33; + const TYPE_SSHFP = 44; const TYPE_ANY = 255; + const TYPE_CAA = 257; const CLASS_IN = 1; @@ -39,10 +46,9 @@ public static function createRequestForQuery(Query $query) { $request = new Message(); - $request->header->set('id', self::generateId()); - $request->header->set('rd', 1); - $request->questions[] = (array) $query; - $request->prepare(); + $request->id = self::generateId(); + $request->rd = true; + $request->questions[] = $query; return $request; } @@ -57,19 +63,15 @@ public static function createResponseWithAnswersForQuery(Query $query, array $answers) { $response = new Message(); - $response->header->set('id', self::generateId()); - $response->header->set('qr', 1); - $response->header->set('opcode', Message::OPCODE_QUERY); - $response->header->set('rd', 1); - $response->header->set('rcode', Message::RCODE_OK); + $response->id = self::generateId(); + $response->qr = true; + $response->rd = true; - $response->questions[] = (array) $query; + $response->questions[] = $query; foreach ($answers as $record) { $response->answers[] = $record; } - - $response->prepare(); return $response; } @@ -100,55 +102,86 @@ return mt_rand(0, 0xffff); } - public $header; - public $questions = array(); - public $answers = array(); - public $authority = array(); - public $additional = array(); - /** - * @deprecated still used internally for BC reasons, should not be used externally. - */ - public $data = ''; - - /** - * @deprecated still used internally for BC reasons, should not be used externally. - */ - public $consumed = 0; - - public function __construct() - { - $this->header = new HeaderBag(); - } - - /** - * Returns the 16 bit message ID + * The 16 bit message ID * * The response message ID has to match the request message ID. This allows * the receiver to verify this is the correct response message. An outside * attacker may try to inject fake responses by "guessing" the message ID, * so this should use a proper CSPRNG to avoid possible cache poisoning. * - * @return int + * @var int 16 bit message ID * @see self::generateId() */ - public function getId() - { - return $this->header->get('id'); - } + public $id = 0; /** - * Returns the response code (RCODE) + * @var bool Query/Response flag, query=false or response=true + */ + public $qr = false; + + /** + * @var int specifies the kind of query (4 bit), see self::OPCODE_* constants + * @see self::OPCODE_QUERY + */ + public $opcode = self::OPCODE_QUERY; + + /** * - * @return int see self::RCODE_* constants + * @var bool Authoritative Answer */ - public function getResponseCode() - { - return $this->header->get('rcode'); - } + public $aa = false; - public function prepare() - { - $this->header->populateCounts($this); - } + /** + * @var bool TrunCation + */ + public $tc = false; + + /** + * @var bool Recursion Desired + */ + public $rd = false; + + /** + * @var bool Recursion Available + */ + public $ra = false; + + /** + * @var int response code (4 bit), see self::RCODE_* constants + * @see self::RCODE_OK + */ + public $rcode = Message::RCODE_OK; + + /** + * An array of Query objects + * + * ```php + * $questions = array( + * new Query( + * 'reactphp.org', + * Message::TYPE_A, + * Message::CLASS_IN + * ) + * ); + * ``` + * + * @var Query[] + */ + public $questions = array(); + + /** + * @var Record[] + */ + public $answers = array(); + + /** + * @var Record[] + */ + public $authority = array(); + + /** + * @var Record[] + */ + public $additional = array(); } diff --git a/src/Model/Record.php b/src/Model/Record.php index 7507fcb..4c357ed 100644 --- a/src/Model/Record.php +++ b/src/Model/Record.php @@ -2,7 +2,16 @@ namespace React\Dns\Model; -class Record +/** + * This class represents a single resulting record in a response message + * + * It uses a structure similar to `\React\Dns\Query\Query`, but does include + * fields for resulting TTL and resulting record data (IPs etc.). + * + * @link https://tools.ietf.org/html/rfc1035#section-4.1.3 + * @see \React\Dns\Query\Query + */ +final class Record { /** * @var string hostname without trailing dot, for example "reactphp.org" @@ -34,10 +43,13 @@ * * - A: * IPv4 address string, for example "192.168.1.1". + * * - AAAA: * IPv6 address string, for example "::1". + * * - CNAME / PTR / NS: * The hostname without trailing dot, for example "reactphp.org". + * * - TXT: * List of string values, for example `["v=spf1 include:example.com"]`. * This is commonly a list with only a single string value, but this @@ -49,6 +61,7 @@ * suggests using key-value pairs such as `["name=test","version=1"]`, but * interpretation of this is not enforced and left up to consumers of this * library (used for DNS-SD/Zeroconf and others). + * * - MX: * Mail server priority (UINT16) and target hostname without trailing dot, * for example `{"priority":10,"target":"mx.example.com"}`. @@ -57,6 +70,7 @@ * referred to as exchange). If a response message contains multiple * records of this type, targets should be sorted by priority (lowest * first) - this is left up to consumers of this library (used for SMTP). + * * - SRV: * Service priority (UINT16), service weight (UINT16), service port (UINT16) * and target hostname without trailing dot, for example @@ -69,12 +83,25 @@ * randomly according to their weight - this is left up to consumers of * this library, see also [RFC 2782](https://tools.ietf.org/html/rfc2782) * for more details. + * + * - SSHFP: + * Includes algorithm (UNIT8), fingerprint type (UNIT8) and fingerprint + * value as lower case hex string, for example: + * `{"algorithm":1,"type":1,"fingerprint":"0123456789abcdef..."}` + * See also https://www.iana.org/assignments/dns-sshfp-rr-parameters/dns-sshfp-rr-parameters.xhtml + * for algorithm and fingerprint type assignments. + * * - SOA: * Includes master hostname without trailing dot, responsible person email * as hostname without trailing dot and serial, refresh, retry, expire and * minimum times in seconds (UINT32 each), for example: * `{"mname":"ns.example.com","rname":"hostmaster.example.com","serial": * 2018082601,"refresh":3600,"retry":1800,"expire":60000,"minimum":3600}`. + * + * - CAA: + * Includes flag (UNIT8), tag string and value string, for example: + * `{"flag":128,"tag":"issue","value":"letsencrypt.org"}` + * * - Any other unknown type: * An opaque binary string containing the RDATA as transported in the DNS * record. For forwards compatibility, you should not rely on this format @@ -87,7 +114,14 @@ */ public $data; - public function __construct($name, $type, $class, $ttl = 0, $data = null) + /** + * @param string $name + * @param int $type + * @param int $class + * @param int $ttl + * @param string|string[]|array $data + */ + public function __construct($name, $type, $class, $ttl, $data) { $this->name = $name; $this->type = $type; diff --git a/src/Protocol/BinaryDumper.php b/src/Protocol/BinaryDumper.php index 35d6ae6..74d0b72 100644 --- a/src/Protocol/BinaryDumper.php +++ b/src/Protocol/BinaryDumper.php @@ -3,60 +3,187 @@ namespace React\Dns\Protocol; use React\Dns\Model\Message; -use React\Dns\Model\HeaderBag; +use React\Dns\Model\Record; +use React\Dns\Query\Query; -class BinaryDumper +final class BinaryDumper { + /** + * @param Message $message + * @return string + */ public function toBinary(Message $message) { $data = ''; - $data .= $this->headerToBinary($message->header); + $data .= $this->headerToBinary($message); $data .= $this->questionToBinary($message->questions); + $data .= $this->recordsToBinary($message->answers); + $data .= $this->recordsToBinary($message->authority); + $data .= $this->recordsToBinary($message->additional); return $data; } - private function headerToBinary(HeaderBag $header) + /** + * @param Message $message + * @return string + */ + private function headerToBinary(Message $message) { $data = ''; - $data .= pack('n', $header->get('id')); + $data .= pack('n', $message->id); $flags = 0x00; - $flags = ($flags << 1) | $header->get('qr'); - $flags = ($flags << 4) | $header->get('opcode'); - $flags = ($flags << 1) | $header->get('aa'); - $flags = ($flags << 1) | $header->get('tc'); - $flags = ($flags << 1) | $header->get('rd'); - $flags = ($flags << 1) | $header->get('ra'); - $flags = ($flags << 3) | $header->get('z'); - $flags = ($flags << 4) | $header->get('rcode'); + $flags = ($flags << 1) | ($message->qr ? 1 : 0); + $flags = ($flags << 4) | $message->opcode; + $flags = ($flags << 1) | ($message->aa ? 1 : 0); + $flags = ($flags << 1) | ($message->tc ? 1 : 0); + $flags = ($flags << 1) | ($message->rd ? 1 : 0); + $flags = ($flags << 1) | ($message->ra ? 1 : 0); + $flags = ($flags << 3) | 0; // skip unused zero bit + $flags = ($flags << 4) | $message->rcode; $data .= pack('n', $flags); - $data .= pack('n', $header->get('qdCount')); - $data .= pack('n', $header->get('anCount')); - $data .= pack('n', $header->get('nsCount')); - $data .= pack('n', $header->get('arCount')); + $data .= pack('n', count($message->questions)); + $data .= pack('n', count($message->answers)); + $data .= pack('n', count($message->authority)); + $data .= pack('n', count($message->additional)); return $data; } + /** + * @param Query[] $questions + * @return string + */ private function questionToBinary(array $questions) { $data = ''; foreach ($questions as $question) { - $labels = explode('.', $question['name']); - foreach ($labels as $label) { - $data .= chr(strlen($label)).$label; - } - $data .= "\x00"; - - $data .= pack('n*', $question['type'], $question['class']); + $data .= $this->domainNameToBinary($question->name); + $data .= pack('n*', $question->type, $question->class); } return $data; } + + /** + * @param Record[] $records + * @return string + */ + private function recordsToBinary(array $records) + { + $data = ''; + + foreach ($records as $record) { + /* @var $record Record */ + switch ($record->type) { + case Message::TYPE_A: + case Message::TYPE_AAAA: + $binary = \inet_pton($record->data); + break; + case Message::TYPE_CNAME: + case Message::TYPE_NS: + case Message::TYPE_PTR: + $binary = $this->domainNameToBinary($record->data); + break; + case Message::TYPE_TXT: + $binary = $this->textsToBinary($record->data); + break; + case Message::TYPE_MX: + $binary = \pack( + 'n', + $record->data['priority'] + ); + $binary .= $this->domainNameToBinary($record->data['target']); + break; + case Message::TYPE_SRV: + $binary = \pack( + 'n*', + $record->data['priority'], + $record->data['weight'], + $record->data['port'] + ); + $binary .= $this->domainNameToBinary($record->data['target']); + break; + case Message::TYPE_SOA: + $binary = $this->domainNameToBinary($record->data['mname']); + $binary .= $this->domainNameToBinary($record->data['rname']); + $binary .= \pack( + 'N*', + $record->data['serial'], + $record->data['refresh'], + $record->data['retry'], + $record->data['expire'], + $record->data['minimum'] + ); + break; + case Message::TYPE_CAA: + $binary = \pack( + 'C*', + $record->data['flag'], + \strlen($record->data['tag']) + ); + $binary .= $record->data['tag']; + $binary .= $record->data['value']; + break; + case Message::TYPE_SSHFP: + $binary = \pack( + 'CCH*', + $record->data['algorithm'], + $record->data['type'], + $record->data['fingerprint'] + ); + break; + default: + // RDATA is already stored as binary value for unknown record types + $binary = $record->data; + } + + $data .= $this->domainNameToBinary($record->name); + $data .= \pack('nnNn', $record->type, $record->class, $record->ttl, \strlen($binary)); + $data .= $binary; + } + + return $data; + } + + /** + * @param string[] $texts + * @return string + */ + private function textsToBinary(array $texts) + { + $data = ''; + foreach ($texts as $text) { + $data .= \chr(\strlen($text)) . $text; + } + return $data; + } + + /** + * @param string $host + * @return string + */ + private function domainNameToBinary($host) + { + if ($host === '') { + return "\0"; + } + + // break up domain name at each dot that is not preceeded by a backslash (escaped notation) + return $this->textsToBinary( + \array_map( + 'stripcslashes', + \preg_split( + '/(?data = $data; + $message->consumed = null; + if ($this->parse($data, $message) !== $message) { throw new InvalidArgumentException('Unable to parse binary message'); } + unset($message->data, $message->consumed); + return $message; } - /** - * @deprecated unused, exists for BC only - * @codeCoverageIgnore - */ - public function parseChunk($data, Message $message) - { - return $this->parse($data, $message); - } - private function parse($data, Message $message) - { - $message->data .= $data; - - if (!$message->header->get('id')) { - if (!$this->parseHeader($message)) { - return; - } - } - - if ($message->header->get('qdCount') != count($message->questions)) { - if (!$this->parseQuestion($message)) { - return; - } - } - - if ($message->header->get('anCount') != count($message->answers)) { - if (!$this->parseAnswer($message)) { - return; - } - } - - return $message; - } - - public function parseHeader(Message $message) { if (!isset($message->data[12 - 1])) { return; } - $header = substr($message->data, 0, 12); + list($id, $fields, $qdCount, $anCount, $nsCount, $arCount) = array_values(unpack('n*', substr($message->data, 0, 12))); $message->consumed += 12; - list($id, $fields, $qdCount, $anCount, $nsCount, $arCount) = array_values(unpack('n*', $header)); - - $rcode = $fields & bindec('1111'); - $z = ($fields >> 4) & bindec('111'); - $ra = ($fields >> 7) & 1; - $rd = ($fields >> 8) & 1; - $tc = ($fields >> 9) & 1; - $aa = ($fields >> 10) & 1; - $opcode = ($fields >> 11) & bindec('1111'); - $qr = ($fields >> 15) & 1; - - $vars = compact('id', 'qdCount', 'anCount', 'nsCount', 'arCount', - 'qr', 'opcode', 'aa', 'tc', 'rd', 'ra', 'z', 'rcode'); - - - foreach ($vars as $name => $value) { - $message->header->set($name, $value); + $message->id = $id; + $message->rcode = $fields & 0xf; + $message->ra = (($fields >> 7) & 1) === 1; + $message->rd = (($fields >> 8) & 1) === 1; + $message->tc = (($fields >> 9) & 1) === 1; + $message->aa = (($fields >> 10) & 1) === 1; + $message->opcode = ($fields >> 11) & 0xf; + $message->qr = (($fields >> 15) & 1) === 1; + + // parse all questions + for ($i = $qdCount; $i > 0; --$i) { + $question = $this->parseQuestion($message); + if ($question === null) { + return; + } else { + $message->questions[] = $question; + } + } + + // parse all answer records + for ($i = $anCount; $i > 0; --$i) { + $record = $this->parseRecord($message); + if ($record === null) { + return; + } else { + $message->answers[] = $record; + } + } + + // parse all authority records + for ($i = $nsCount; $i > 0; --$i) { + $record = $this->parseRecord($message); + if ($record === null) { + return; + } else { + $message->authority[] = $record; + } + } + + // parse all additional records + for ($i = $arCount; $i > 0; --$i) { + $record = $this->parseRecord($message); + if ($record === null) { + return; + } else { + $message->additional[] = $record; + } } return $message; } - public function parseQuestion(Message $message) + /** + * @param Message $message + * @return ?Query + */ + private function parseQuestion(Message $message) { $consumed = $message->consumed; @@ -110,27 +117,25 @@ $message->consumed = $consumed; - $message->questions[] = array( - 'name' => implode('.', $labels), - 'type' => $type, - 'class' => $class, + return new Query( + implode('.', $labels), + $type, + $class ); - - if ($message->header->get('qdCount') != count($message->questions)) { - return $this->parseQuestion($message); - } - - return $message; - } - - public function parseAnswer(Message $message) + } + + /** + * @param Message $message + * @return ?Record returns parsed Record on success or null if data is invalid/incomplete + */ + private function parseRecord(Message $message) { $consumed = $message->consumed; list($name, $consumed) = $this->readDomain($message->data, $consumed); if ($name === null || !isset($message->data[$consumed + 10 - 1])) { - return; + return null; } list($type, $class) = array_values(unpack('n*', substr($message->data, $consumed, 4))); @@ -148,7 +153,7 @@ $consumed += 2; if (!isset($message->data[$consumed + $rdLength - 1])) { - return; + return null; } $rdata = null; @@ -193,6 +198,18 @@ 'weight' => $weight, 'port' => $port, 'target' => $target + ); + } + } elseif (Message::TYPE_SSHFP === $type) { + if ($rdLength > 2) { + list($algorithm, $hash) = \array_values(\unpack('C*', \substr($message->data, $consumed, 2))); + $fingerprint = \bin2hex(\substr($message->data, $consumed + 2, $rdLength - 2)); + $consumed += $rdLength; + + $rdata = array( + 'algorithm' => $algorithm, + 'type' => $hash, + 'fingerprint' => $fingerprint ); } } elseif (Message::TYPE_SOA === $type) { @@ -213,6 +230,22 @@ 'minimum' => $minimum ); } + } elseif (Message::TYPE_CAA === $type) { + if ($rdLength > 3) { + list($flag, $tagLength) = array_values(unpack('C*', substr($message->data, $consumed, 2))); + + if ($tagLength > 0 && $rdLength - 2 - $tagLength > 0) { + $tag = substr($message->data, $consumed + 2, $tagLength); + $value = substr($message->data, $consumed + 2 + $tagLength, $rdLength - 2 - $tagLength); + $consumed += $rdLength; + + $rdata = array( + 'flag' => $flag, + 'tag' => $tag, + 'value' => $value + ); + } + } } else { // unknown types simply parse rdata as an opaque binary string $rdata = substr($message->data, $consumed, $rdLength); @@ -221,20 +254,12 @@ // ensure parsing record data consumes expact number of bytes indicated in record length if ($consumed !== $expected || $rdata === null) { - return; + return null; } $message->consumed = $consumed; - $record = new Record($name, $type, $class, $ttl, $rdata); - - $message->answers[] = $record; - - if ($message->header->get('anCount') != count($message->answers)) { - return $this->parseAnswer($message); - } - - return $message; + return new Record($name, $type, $class, $ttl, $rdata); } private function readDomain($data, $consumed) @@ -245,7 +270,19 @@ return array(null, null); } - return array(implode('.', $labels), $consumed); + // use escaped notation for each label part, then join using dots + return array( + \implode( + '.', + \array_map( + function ($label) { + return \addcslashes($label, "\0..\40.\177"); + }, + $labels + ) + ), + $consumed + ); } private function readLabels($data, $consumed) @@ -294,59 +331,4 @@ return array($labels, $consumed); } - - /** - * @deprecated unused, exists for BC only - * @codeCoverageIgnore - */ - public function isEndOfLabels($data, $consumed) - { - $length = ord(substr($data, $consumed, 1)); - return 0 === $length; - } - - /** - * @deprecated unused, exists for BC only - * @codeCoverageIgnore - */ - public function getCompressedLabel($data, $consumed) - { - list($nameOffset, $consumed) = $this->getCompressedLabelOffset($data, $consumed); - list($labels) = $this->readLabels($data, $nameOffset); - - return array($labels, $consumed); - } - - /** - * @deprecated unused, exists for BC only - * @codeCoverageIgnore - */ - public function isCompressedLabel($data, $consumed) - { - $mask = 0xc000; // 1100000000000000 - list($peek) = array_values(unpack('n', substr($data, $consumed, 2))); - - return (bool) ($peek & $mask); - } - - /** - * @deprecated unused, exists for BC only - * @codeCoverageIgnore - */ - public function getCompressedLabelOffset($data, $consumed) - { - $mask = 0x3fff; // 0011111111111111 - list($peek) = array_values(unpack('n', substr($data, $consumed, 2))); - - return array($peek & $mask, $consumed + 2); - } - - /** - * @deprecated unused, exists for BC only - * @codeCoverageIgnore - */ - public function signedLongToUnsignedLong($i) - { - return $i & 0x80000000 ? $i - 0xffffffff : $i; - } } diff --git a/src/Query/CachedExecutor.php b/src/Query/CachedExecutor.php deleted file mode 100644 index 285936d..0000000 --- a/src/Query/CachedExecutor.php +++ /dev/null @@ -1,55 +0,0 @@ -executor = $executor; - $this->cache = $cache; - } - - public function query($nameserver, Query $query) - { - $executor = $this->executor; - $cache = $this->cache; - - return $this->cache - ->lookup($query) - ->then( - function ($cachedRecords) use ($query) { - return Message::createResponseWithAnswersForQuery($query, $cachedRecords); - }, - function () use ($executor, $cache, $nameserver, $query) { - return $executor - ->query($nameserver, $query) - ->then(function ($response) use ($cache, $query) { - $cache->storeResponseMessage($query->currentTime, $response); - return $response; - }); - } - ); - } - - /** - * @deprecated unused, exists for BC only - */ - public function buildResponse(Query $query, array $cachedRecords) - { - return Message::createResponseWithAnswersForQuery($query, $cachedRecords); - } - - /** - * @deprecated unused, exists for BC only - */ - protected function generateId() - { - return mt_rand(0, 0xffff); - } -} diff --git a/src/Query/CachingExecutor.php b/src/Query/CachingExecutor.php new file mode 100644 index 0000000..9e0bec0 --- /dev/null +++ b/src/Query/CachingExecutor.php @@ -0,0 +1,88 @@ +executor = $executor; + $this->cache = $cache; + } + + public function query(Query $query) + { + $id = $query->name . ':' . $query->type . ':' . $query->class; + $cache = $this->cache; + $that = $this; + $executor = $this->executor; + + $pending = $cache->get($id); + return new Promise(function ($resolve, $reject) use ($query, $id, $cache, $executor, &$pending, $that) { + $pending->then( + function ($message) use ($query, $id, $cache, $executor, &$pending, $that) { + // return cached response message on cache hit + if ($message !== null) { + return $message; + } + + // perform DNS lookup if not already cached + return $pending = $executor->query($query)->then( + function (Message $message) use ($cache, $id, $that) { + // DNS response message received => store in cache when not truncated and return + if (!$message->tc) { + $cache->set($id, $message, $that->ttl($message)); + } + + return $message; + } + ); + } + )->then($resolve, function ($e) use ($reject, &$pending) { + $reject($e); + $pending = null; + }); + }, function ($_, $reject) use (&$pending, $query) { + $reject(new \RuntimeException('DNS query for ' . $query->name . ' has been cancelled')); + $pending->cancel(); + $pending = null; + }); + } + + /** + * @param Message $message + * @return int + * @internal + */ + public function ttl(Message $message) + { + // select TTL from answers (should all be the same), use smallest value if available + // @link https://tools.ietf.org/html/rfc2181#section-5.2 + $ttl = null; + foreach ($message->answers as $answer) { + if ($ttl === null || $answer->ttl < $ttl) { + $ttl = $answer->ttl; + } + } + + if ($ttl === null) { + $ttl = self::TTL; + } + + return $ttl; + } +} diff --git a/src/Query/CancellationException.php b/src/Query/CancellationException.php index ac30f4c..5432b36 100644 --- a/src/Query/CancellationException.php +++ b/src/Query/CancellationException.php @@ -2,6 +2,6 @@ namespace React\Dns\Query; -class CancellationException extends \RuntimeException +final class CancellationException extends \RuntimeException { } diff --git a/src/Query/CoopExecutor.php b/src/Query/CoopExecutor.php new file mode 100644 index 0000000..93c97d4 --- /dev/null +++ b/src/Query/CoopExecutor.php @@ -0,0 +1,92 @@ +executor = $base; + } + + public function query(Query $query) + { + $key = $this->serializeQueryToIdentity($query); + if (isset($this->pending[$key])) { + // same query is already pending, so use shared reference to pending query + $promise = $this->pending[$key]; + ++$this->counts[$key]; + } else { + // no such query pending, so start new query and keep reference until it's fulfilled or rejected + $promise = $this->executor->query($query); + $this->pending[$key] = $promise; + $this->counts[$key] = 1; + + $pending =& $this->pending; + $counts =& $this->counts; + $promise->then(function () use ($key, &$pending, &$counts) { + unset($pending[$key], $counts[$key]); + }, function () use ($key, &$pending, &$counts) { + unset($pending[$key], $counts[$key]); + }); + } + + // Return a child promise awaiting the pending query. + // Cancelling this child promise should only cancel the pending query + // when no other child promise is awaiting the same query. + $pending =& $this->pending; + $counts =& $this->counts; + return new Promise(function ($resolve, $reject) use ($promise) { + $promise->then($resolve, $reject); + }, function () use (&$promise, $key, $query, &$pending, &$counts) { + if (--$counts[$key] < 1) { + unset($pending[$key], $counts[$key]); + $promise->cancel(); + $promise = null; + } + throw new \RuntimeException('DNS query for ' . $query->name . ' has been cancelled'); + }); + } + + private function serializeQueryToIdentity(Query $query) + { + return sprintf('%s:%s:%s', $query->name, $query->type, $query->class); + } +} diff --git a/src/Query/Executor.php b/src/Query/Executor.php deleted file mode 100644 index 40f6bb4..0000000 --- a/src/Query/Executor.php +++ /dev/null @@ -1,160 +0,0 @@ -loop = $loop; - $this->parser = $parser; - $this->dumper = $dumper; - $this->timeout = $timeout; - } - - public function query($nameserver, Query $query) - { - $request = Message::createRequestForQuery($query); - - $queryData = $this->dumper->toBinary($request); - $transport = strlen($queryData) > 512 ? 'tcp' : 'udp'; - - return $this->doQuery($nameserver, $transport, $queryData, $query->name); - } - - /** - * @deprecated unused, exists for BC only - */ - public function prepareRequest(Query $query) - { - return Message::createRequestForQuery($query); - } - - public function doQuery($nameserver, $transport, $queryData, $name) - { - // we only support UDP right now - if ($transport !== 'udp') { - return Promise\reject(new \RuntimeException( - 'DNS query for ' . $name . ' failed: Requested transport "' . $transport . '" not available, only UDP is supported in this version' - )); - } - - $that = $this; - $parser = $this->parser; - $loop = $this->loop; - - // UDP connections are instant, so try this without a timer - try { - $conn = $this->createConnection($nameserver, $transport); - } catch (\Exception $e) { - return Promise\reject(new \RuntimeException('DNS query for ' . $name . ' failed: ' . $e->getMessage(), 0, $e)); - } - - $deferred = new Deferred(function ($resolve, $reject) use (&$timer, $loop, &$conn, $name) { - $reject(new CancellationException(sprintf('DNS query for %s has been cancelled', $name))); - - if ($timer !== null) { - $loop->cancelTimer($timer); - } - $conn->close(); - }); - - $timer = null; - if ($this->timeout !== null) { - $timer = $this->loop->addTimer($this->timeout, function () use (&$conn, $name, $deferred) { - $conn->close(); - $deferred->reject(new TimeoutException(sprintf("DNS query for %s timed out", $name))); - }); - } - - $conn->on('data', function ($data) use ($conn, $parser, $deferred, $timer, $loop, $name) { - $conn->end(); - if ($timer !== null) { - $loop->cancelTimer($timer); - } - - try { - $response = $parser->parseMessage($data); - } catch (\Exception $e) { - $deferred->reject($e); - return; - } - - if ($response->header->isTruncated()) { - $deferred->reject(new \RuntimeException('DNS query for ' . $name . ' failed: The server returned a truncated result for a UDP query, but retrying via TCP is currently not supported')); - return; - } - - $deferred->resolve($response); - }); - $conn->write($queryData); - - return $deferred->promise(); - } - - /** - * @deprecated unused, exists for BC only - */ - protected function generateId() - { - return mt_rand(0, 0xffff); - } - - /** - * @param string $nameserver - * @param string $transport - * @return \React\Stream\DuplexStreamInterface - */ - protected function createConnection($nameserver, $transport) - { - $fd = @stream_socket_client("$transport://$nameserver", $errno, $errstr, 0, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); - if ($fd === false) { - throw new \RuntimeException('Unable to connect to DNS server: ' . $errstr, $errno); - } - - // Instantiate stream instance around this stream resource. - // This ought to be replaced with a datagram socket in the future. - // Temporary work around for Windows 10: buffer whole UDP response - // @coverageIgnoreStart - if (!class_exists('React\Stream\Stream')) { - // prefer DuplexResourceStream as of react/stream v0.7.0 - $conn = new DuplexResourceStream($fd, $this->loop, -1); - } else { - // use legacy Stream class for react/stream < v0.7.0 - $conn = new Stream($fd, $this->loop); - $conn->bufferSize = null; - } - // @coverageIgnoreEnd - - return $conn; - } -} diff --git a/src/Query/ExecutorInterface.php b/src/Query/ExecutorInterface.php index 2f7a635..b356dc6 100644 --- a/src/Query/ExecutorInterface.php +++ b/src/Query/ExecutorInterface.php @@ -4,5 +4,40 @@ interface ExecutorInterface { - public function query($nameserver, Query $query); + /** + * Executes a query and will return a response message + * + * It returns a Promise which either fulfills with a response + * `React\Dns\Model\Message` on success or rejects with an `Exception` if + * the query is not successful. A response message may indicate an error + * condition in its `rcode`, but this is considered a valid response message. + * + * ```php + * $executor->query($query)->then( + * function (React\Dns\Model\Message $response) { + * // response message successfully received + * var_dump($response->rcode, $response->answers); + * }, + * function (Exception $error) { + * // failed to query due to $error + * } + * ); + * ``` + * + * The returned Promise MUST be implemented in such a way that it can be + * cancelled when it is still pending. Cancelling a pending promise MUST + * reject its value with an Exception. It SHOULD clean up any underlying + * resources and references as applicable. + * + * ```php + * $promise = $executor->query($query); + * + * $promise->cancel(); + * ``` + * + * @param Query $query + * @return \React\Promise\PromiseInterface<\React\Dns\Model\Message,\Exception> + * resolves with response message on success or rejects with an Exception on error + */ + public function query(Query $query); } diff --git a/src/Query/HostsFileExecutor.php b/src/Query/HostsFileExecutor.php index 0ca58be..d6e2d93 100644 --- a/src/Query/HostsFileExecutor.php +++ b/src/Query/HostsFileExecutor.php @@ -8,13 +8,13 @@ use React\Promise; /** - * Resolves hosts from the givne HostsFile or falls back to another executor + * Resolves hosts from the given HostsFile or falls back to another executor * * If the host is found in the hosts file, it will not be passed to the actual * DNS executor. If the host is not found in the hosts file, it will be passed * to the DNS executor as a fallback. */ -class HostsFileExecutor implements ExecutorInterface +final class HostsFileExecutor implements ExecutorInterface { private $hosts; private $fallback; @@ -25,7 +25,7 @@ $this->fallback = $fallback; } - public function query($nameserver, Query $query) + public function query(Query $query) { if ($query->class === Message::CLASS_IN && ($query->type === Message::TYPE_A || $query->type === Message::TYPE_AAAA)) { // forward lookup for type A or AAAA @@ -61,7 +61,7 @@ } } - return $this->fallback->query($nameserver, $query); + return $this->fallback->query($query); } private function getIpFromHost($host) diff --git a/src/Query/Query.php b/src/Query/Query.php index 058a78d..7885023 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -2,32 +2,41 @@ namespace React\Dns\Query; -class Query +/** + * This class represents a single question in a query/response message + * + * It uses a structure similar to `\React\Dns\Message\Record`, but does not + * contain fields for resulting TTL and resulting record data (IPs etc.). + * + * @link https://tools.ietf.org/html/rfc1035#section-4.1.2 + * @see \React\Dns\Message\Record + */ +final class Query { + /** + * @var string query name, i.e. hostname to look up + */ public $name; + + /** + * @var int query type (aka QTYPE), see Message::TYPE_* constants + */ public $type; + + /** + * @var int query class (aka QCLASS), see Message::CLASS_IN constant + */ public $class; /** - * @deprecated still used internally for BC reasons, should not be used externally. + * @param string $name query name, i.e. hostname to look up + * @param int $type query type, see Message::TYPE_* constants + * @param int $class query class, see Message::CLASS_IN constant */ - public $currentTime; - - /** - * @param string $name query name, i.e. hostname to look up - * @param int $type query type, see Message::TYPE_* constants - * @param int $class query class, see Message::CLASS_IN constant - * @param int|null $currentTime (deprecated) still used internally, should not be passed explicitly anymore. - */ - public function __construct($name, $type, $class, $currentTime = null) + public function __construct($name, $type, $class) { - if($currentTime === null) { - $currentTime = time(); - } - $this->name = $name; $this->type = $type; $this->class = $class; - $this->currentTime = $currentTime; } } diff --git a/src/Query/RecordBag.php b/src/Query/RecordBag.php deleted file mode 100644 index 26007c3..0000000 --- a/src/Query/RecordBag.php +++ /dev/null @@ -1,26 +0,0 @@ -records[] = array($currentTime + $record->ttl, $record); - } - - public function all() - { - return array_values(array_map( - function ($value) { - list($expiresAt, $record) = $value; - return $record; - }, - $this->records - )); - } -} diff --git a/src/Query/RecordCache.php b/src/Query/RecordCache.php deleted file mode 100644 index 85eaffd..0000000 --- a/src/Query/RecordCache.php +++ /dev/null @@ -1,123 +0,0 @@ -cache = $cache; - } - - /** - * Looks up the cache if there's a cached answer for the given query - * - * @param Query $query - * @return PromiseInterface Promise resolves with array of Record objects on sucess - * or rejects with mixed values when query is not cached already. - */ - public function lookup(Query $query) - { - $id = $this->serializeQueryToIdentity($query); - - $expiredAt = $this->expiredAt; - - return $this->cache - ->get($id) - ->then(function ($value) use ($query, $expiredAt) { - // cache 0.5+ resolves with null on cache miss, return explicit cache miss here - if ($value === null) { - return Promise\reject(); - } - - /* @var $recordBag RecordBag */ - $recordBag = unserialize($value); - - // reject this cache hit if the query was started before the time we expired the cache? - // todo: this is a legacy left over, this value is never actually set, so this never applies. - // todo: this should probably validate the cache time instead. - if (null !== $expiredAt && $expiredAt <= $query->currentTime) { - return Promise\reject(); - } - - return $recordBag->all(); - }); - } - - /** - * Stores all records from this response message in the cache - * - * @param int $currentTime - * @param Message $message - * @uses self::storeRecord() - */ - public function storeResponseMessage($currentTime, Message $message) - { - foreach ($message->answers as $record) { - $this->storeRecord($currentTime, $record); - } - } - - /** - * Stores a single record from a response message in the cache - * - * @param int $currentTime - * @param Record $record - */ - public function storeRecord($currentTime, Record $record) - { - $id = $this->serializeRecordToIdentity($record); - - $cache = $this->cache; - - $this->cache - ->get($id) - ->then( - function ($value) { - if ($value === null) { - // cache 0.5+ cache miss resolves with null, return empty bag here - return new RecordBag(); - } - - // reuse existing bag on cache hit to append new record to it - return unserialize($value); - }, - function ($e) { - // legacy cache < 0.5 cache miss rejects promise, return empty bag here - return new RecordBag(); - } - ) - ->then(function (RecordBag $recordBag) use ($id, $currentTime, $record, $cache) { - // add a record to the existing (possibly empty) record bag and save to cache - $recordBag->set($currentTime, $record); - $cache->set($id, serialize($recordBag)); - }); - } - - public function expire($currentTime) - { - $this->expiredAt = $currentTime; - } - - public function serializeQueryToIdentity(Query $query) - { - return sprintf('%s:%s:%s', $query->name, $query->type, $query->class); - } - - public function serializeRecordToIdentity(Record $record) - { - return sprintf('%s:%s:%s', $record->name, $record->type, $record->class); - } -} diff --git a/src/Query/RetryExecutor.php b/src/Query/RetryExecutor.php index 46e2ef9..3f7b893 100644 --- a/src/Query/RetryExecutor.php +++ b/src/Query/RetryExecutor.php @@ -5,7 +5,7 @@ use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; -class RetryExecutor implements ExecutorInterface +final class RetryExecutor implements ExecutorInterface { private $executor; private $retries; @@ -16,12 +16,12 @@ $this->retries = $retries; } - public function query($nameserver, Query $query) + public function query(Query $query) { - return $this->tryQuery($nameserver, $query, $this->retries); + return $this->tryQuery($query, $this->retries); } - public function tryQuery($nameserver, Query $query, $retries) + public function tryQuery(Query $query, $retries) { $deferred = new Deferred(function () use (&$promise) { if ($promise instanceof CancellablePromiseInterface) { @@ -35,7 +35,7 @@ }; $executor = $this->executor; - $errorback = function ($e) use ($deferred, &$promise, $nameserver, $query, $success, &$errorback, &$retries, $executor) { + $errorback = function ($e) use ($deferred, &$promise, $query, $success, &$errorback, &$retries, $executor) { if (!$e instanceof TimeoutException) { $errorback = null; $deferred->reject($e); @@ -62,14 +62,14 @@ $r->setValue($e, $trace); } else { --$retries; - $promise = $executor->query($nameserver, $query)->then( + $promise = $executor->query($query)->then( $success, $errorback ); } }; - $promise = $this->executor->query($nameserver, $query)->then( + $promise = $this->executor->query($query)->then( $success, $errorback ); diff --git a/src/Query/SelectiveTransportExecutor.php b/src/Query/SelectiveTransportExecutor.php new file mode 100644 index 0000000..0f0ca5d --- /dev/null +++ b/src/Query/SelectiveTransportExecutor.php @@ -0,0 +1,85 @@ +query( + * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) + * )->then(function (Message $message) { + * foreach ($message->answers as $answer) { + * echo 'IPv6: ' . $answer->data . PHP_EOL; + * } + * }, 'printf'); + * ``` + * + * Note that this executor only implements the logic to select the correct + * transport for the given DNS query. Implementing the correct transport logic, + * implementing timeouts and any retry logic is left up to the given executors, + * see also [`UdpTransportExecutor`](#udptransportexecutor) and + * [`TcpTransportExecutor`](#tcptransportexecutor) for more details. + * + * Note that this executor is entirely async and as such allows you to execute + * any number of queries concurrently. You should probably limit the number of + * concurrent queries in your application or you're very likely going to face + * rate limitations and bans on the resolver end. For many common applications, + * you may want to avoid sending the same query multiple times when the first + * one is still pending, so you will likely want to use this in combination with + * a `CoopExecutor` like this: + * + * ```php + * $executor = new CoopExecutor( + * new SelectiveTransportExecutor( + * $datagramExecutor, + * $streamExecutor + * ) + * ); + * ``` + */ +class SelectiveTransportExecutor implements ExecutorInterface +{ + private $datagramExecutor; + private $streamExecutor; + + public function __construct(ExecutorInterface $datagramExecutor, ExecutorInterface $streamExecutor) + { + $this->datagramExecutor = $datagramExecutor; + $this->streamExecutor = $streamExecutor; + } + + public function query(Query $query) + { + $stream = $this->streamExecutor; + $pending = $this->datagramExecutor->query($query); + + return new Promise(function ($resolve, $reject) use (&$pending, $stream, $query) { + $pending->then( + $resolve, + function ($e) use (&$pending, $stream, $query, $resolve, $reject) { + if ($e->getCode() === (\defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90)) { + $pending = $stream->query($query)->then($resolve, $reject); + } else { + $reject($e); + } + } + ); + }, function () use (&$pending) { + $pending->cancel(); + $pending = null; + }); + } +} diff --git a/src/Query/TcpTransportExecutor.php b/src/Query/TcpTransportExecutor.php new file mode 100644 index 0000000..4ec232e --- /dev/null +++ b/src/Query/TcpTransportExecutor.php @@ -0,0 +1,346 @@ +query( + * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) + * )->then(function (Message $message) { + * foreach ($message->answers as $answer) { + * echo 'IPv6: ' . $answer->data . PHP_EOL; + * } + * }, 'printf'); + * + * $loop->run(); + * ``` + * + * See also [example #92](examples). + * + * Note that this executor does not implement a timeout, so you will very likely + * want to use this in combination with a `TimeoutExecutor` like this: + * + * ```php + * $executor = new TimeoutExecutor( + * new TcpTransportExecutor($nameserver, $loop), + * 3.0, + * $loop + * ); + * ``` + * + * Unlike the `UdpTransportExecutor`, this class uses a reliable TCP/IP + * transport, so you do not necessarily have to implement any retry logic. + * + * Note that this executor is entirely async and as such allows you to execute + * queries concurrently. The first query will establish a TCP/IP socket + * connection to the DNS server which will be kept open for a short period. + * Additional queries will automatically reuse this existing socket connection + * to the DNS server, will pipeline multiple requests over this single + * connection and will keep an idle connection open for a short period. The + * initial TCP/IP connection overhead may incur a slight delay if you only send + * occasional queries – when sending a larger number of concurrent queries over + * an existing connection, it becomes increasingly more efficient and avoids + * creating many concurrent sockets like the UDP-based executor. You may still + * want to limit the number of (concurrent) queries in your application or you + * may be facing rate limitations and bans on the resolver end. For many common + * applications, you may want to avoid sending the same query multiple times + * when the first one is still pending, so you will likely want to use this in + * combination with a `CoopExecutor` like this: + * + * ```php + * $executor = new CoopExecutor( + * new TimeoutExecutor( + * new TcpTransportExecutor($nameserver, $loop), + * 3.0, + * $loop + * ) + * ); + * ``` + * + * > Internally, this class uses PHP's TCP/IP sockets and does not take advantage + * of [react/socket](https://github.com/reactphp/socket) purely for + * organizational reasons to avoid a cyclic dependency between the two + * packages. Higher-level components should take advantage of the Socket + * component instead of reimplementing this socket logic from scratch. + */ +class TcpTransportExecutor implements ExecutorInterface +{ + private $nameserver; + private $loop; + private $parser; + private $dumper; + + /** + * @var ?resource + */ + private $socket; + + /** + * @var Deferred[] + */ + private $pending = array(); + + /** + * @var string[] + */ + private $names = array(); + + /** + * Maximum idle time when socket is current unused (i.e. no pending queries outstanding) + * + * If a new query is to be sent during the idle period, we can reuse the + * existing socket without having to wait for a new socket connection. + * This uses a rather small, hard-coded value to not keep any unneeded + * sockets open and to not keep the loop busy longer than needed. + * + * A future implementation may take advantage of `edns-tcp-keepalive` to keep + * the socket open for longer periods. This will likely require explicit + * configuration because this may consume additional resources and also keep + * the loop busy for longer than expected in some applications. + * + * @var float + * @link https://tools.ietf.org/html/rfc7766#section-6.2.1 + * @link https://tools.ietf.org/html/rfc7828 + */ + private $idlePeriod = 0.001; + + /** + * @var ?\React\EventLoop\TimerInterface + */ + private $idleTimer; + + private $writeBuffer = ''; + private $writePending = false; + + private $readBuffer = ''; + private $readPending = false; + + /** + * @param string $nameserver + * @param LoopInterface $loop + */ + public function __construct($nameserver, LoopInterface $loop) + { + if (\strpos($nameserver, '[') === false && \substr_count($nameserver, ':') >= 2 && \strpos($nameserver, '://') === false) { + // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets + $nameserver = '[' . $nameserver . ']'; + } + + $parts = \parse_url((\strpos($nameserver, '://') === false ? 'tcp://' : '') . $nameserver); + if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'tcp' || !\filter_var(\trim($parts['host'], '[]'), \FILTER_VALIDATE_IP)) { + throw new \InvalidArgumentException('Invalid nameserver address given'); + } + + $this->nameserver = $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53); + $this->loop = $loop; + $this->parser = new Parser(); + $this->dumper = new BinaryDumper(); + } + + public function query(Query $query) + { + $request = Message::createRequestForQuery($query); + + // keep shuffing message ID to avoid using the same message ID for two pending queries at the same time + while (isset($this->pending[$request->id])) { + $request->id = \mt_rand(0, 0xffff); // @codeCoverageIgnore + } + + $queryData = $this->dumper->toBinary($request); + $length = \strlen($queryData); + if ($length > 0xffff) { + return \React\Promise\reject(new \RuntimeException( + 'DNS query for ' . $query->name . ' failed: Query too large for TCP transport' + )); + } + + $queryData = \pack('n', $length) . $queryData; + + if ($this->socket === null) { + // create async TCP/IP connection (may take a while) + $socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0, \STREAM_CLIENT_CONNECT | \STREAM_CLIENT_ASYNC_CONNECT); + if ($socket === false) { + return \React\Promise\reject(new \RuntimeException( + 'DNS query for ' . $query->name . ' failed: Unable to connect to DNS server (' . $errstr . ')', + $errno + )); + } + + // set socket to non-blocking and wait for it to become writable (connection success/rejected) + \stream_set_blocking($socket, false); + $this->socket = $socket; + } + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + + // wait for socket to become writable to actually write out data + $this->writeBuffer .= $queryData; + if (!$this->writePending) { + $this->writePending = true; + $this->loop->addWriteStream($this->socket, array($this, 'handleWritable')); + } + + $names =& $this->names; + $that = $this; + $deferred = new Deferred(function () use ($that, &$names, $request) { + // remove from list of pending names, but remember pending query + $name = $names[$request->id]; + unset($names[$request->id]); + $that->checkIdle(); + + throw new CancellationException('DNS query for ' . $name . ' has been cancelled'); + }); + + $this->pending[$request->id] = $deferred; + $this->names[$request->id] = $query->name; + + return $deferred->promise(); + } + + /** + * @internal + */ + public function handleWritable() + { + if ($this->readPending === false) { + $name = @\stream_socket_get_name($this->socket, true); + if ($name === false) { + $this->closeError('Connection to DNS server rejected'); + return; + } + + $this->readPending = true; + $this->loop->addReadStream($this->socket, array($this, 'handleRead')); + } + + $written = @\fwrite($this->socket, $this->writeBuffer); + if ($written === false || $written === 0) { + $this->closeError('Unable to write to closed socket'); + return; + } + + if (isset($this->writeBuffer[$written])) { + $this->writeBuffer = \substr($this->writeBuffer, $written); + } else { + $this->loop->removeWriteStream($this->socket); + $this->writePending = false; + $this->writeBuffer = ''; + } + } + + /** + * @internal + */ + public function handleRead() + { + // read one chunk of data from the DNS server + // any error is fatal, this is a stream of TCP/IP data + $chunk = @\fread($this->socket, 65536); + if ($chunk === false || $chunk === '') { + $this->closeError('Connection to DNS server lost'); + return; + } + + // reassemble complete message by concatenating all chunks. + $this->readBuffer .= $chunk; + + // response message header contains at least 12 bytes + while (isset($this->readBuffer[11])) { + // read response message length from first 2 bytes and ensure we have length + data in buffer + list(, $length) = \unpack('n', $this->readBuffer); + if (!isset($this->readBuffer[$length + 1])) { + return; + } + + $data = \substr($this->readBuffer, 2, $length); + $this->readBuffer = (string)substr($this->readBuffer, $length + 2); + + try { + $response = $this->parser->parseMessage($data); + } catch (\Exception $e) { + // reject all pending queries if we received an invalid message from remote server + $this->closeError('Invalid message received from DNS server'); + return; + } + + // reject all pending queries if we received an unexpected response ID or truncated response + if (!isset($this->pending[$response->id]) || $response->tc) { + $this->closeError('Invalid response message received from DNS server'); + return; + } + + $deferred = $this->pending[$response->id]; + unset($this->pending[$response->id], $this->names[$response->id]); + + $deferred->resolve($response); + + $this->checkIdle(); + } + } + + /** + * @internal + * @param string $reason + */ + public function closeError($reason) + { + $this->readBuffer = ''; + if ($this->readPending) { + $this->loop->removeReadStream($this->socket); + $this->readPending = false; + } + + $this->writeBuffer = ''; + if ($this->writePending) { + $this->loop->removeWriteStream($this->socket); + $this->writePending = false; + } + + if ($this->idleTimer !== null) { + $this->loop->cancelTimer($this->idleTimer); + $this->idleTimer = null; + } + + @\fclose($this->socket); + $this->socket = null; + + foreach ($this->names as $id => $name) { + $this->pending[$id]->reject(new \RuntimeException( + 'DNS query for ' . $name . ' failed: ' . $reason + )); + } + $this->pending = $this->names = array(); + } + + /** + * @internal + */ + public function checkIdle() + { + if ($this->idleTimer === null && !$this->names) { + $that = $this; + $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () use ($that) { + $that->closeError('Idle timeout'); + }); + } + } +} diff --git a/src/Query/TimeoutException.php b/src/Query/TimeoutException.php index 90bf806..109b0a9 100644 --- a/src/Query/TimeoutException.php +++ b/src/Query/TimeoutException.php @@ -2,6 +2,6 @@ namespace React\Dns\Query; -class TimeoutException extends \Exception +final class TimeoutException extends \Exception { } diff --git a/src/Query/TimeoutExecutor.php b/src/Query/TimeoutExecutor.php index 6a44888..5cee480 100644 --- a/src/Query/TimeoutExecutor.php +++ b/src/Query/TimeoutExecutor.php @@ -7,7 +7,7 @@ use React\Promise\CancellablePromiseInterface; use React\Promise\Timer; -class TimeoutExecutor implements ExecutorInterface +final class TimeoutExecutor implements ExecutorInterface { private $executor; private $loop; @@ -20,9 +20,9 @@ $this->timeout = $timeout; } - public function query($nameserver, Query $query) + public function query(Query $query) { - return Timer\timeout($this->executor->query($nameserver, $query), $this->timeout, $this->loop)->then(null, function ($e) use ($query) { + return Timer\timeout($this->executor->query($query), $this->timeout, $this->loop)->then(null, function ($e) use ($query) { if ($e instanceof Timer\TimeoutException) { $e = new TimeoutException(sprintf("DNS query for %s timed out", $query->name), 0, $e); } diff --git a/src/Query/UdpTransportExecutor.php b/src/Query/UdpTransportExecutor.php index 16d638c..62ac218 100644 --- a/src/Query/UdpTransportExecutor.php +++ b/src/Query/UdpTransportExecutor.php @@ -19,10 +19,9 @@ * * ```php * $loop = Factory::create(); - * $executor = new UdpTransportExecutor($loop); + * $executor = new UdpTransportExecutor('8.8.8.8:53', $loop); * * $executor->query( - * '8.8.8.8:53', * new Query($name, Message::TYPE_AAAA, Message::CLASS_IN) * )->then(function (Message $message) { * foreach ($message->answers as $answer) { @@ -40,7 +39,7 @@ * * ```php * $executor = new TimeoutExecutor( - * new UdpTransportExecutor($loop), + * new UdpTransportExecutor($nameserver, $loop), * 3.0, * $loop * ); @@ -53,9 +52,29 @@ * ```php * $executor = new RetryExecutor( * new TimeoutExecutor( - * new UdpTransportExecutor($loop), + * new UdpTransportExecutor($nameserver, $loop), * 3.0, * $loop + * ) + * ); + * ``` + * + * Note that this executor is entirely async and as such allows you to execute + * any number of queries concurrently. You should probably limit the number of + * concurrent queries in your application or you're very likely going to face + * rate limitations and bans on the resolver end. For many common applications, + * you may want to avoid sending the same query multiple times when the first + * one is still pending, so you will likely want to use this in combination with + * a `CoopExecutor` like this: + * + * ```php + * $executor = new CoopExecutor( + * new RetryExecutor( + * new TimeoutExecutor( + * new UdpTransportExecutor($nameserver, $loop), + * 3.0, + * $loop + * ) * ) * ); * ``` @@ -66,44 +85,49 @@ * packages. Higher-level components should take advantage of the Datagram * component instead of reimplementing this socket logic from scratch. */ -class UdpTransportExecutor implements ExecutorInterface +final class UdpTransportExecutor implements ExecutorInterface { + private $nameserver; private $loop; private $parser; private $dumper; /** - * @param LoopInterface $loop - * @param null|Parser $parser optional/advanced: DNS protocol parser to use - * @param null|BinaryDumper $dumper optional/advanced: DNS protocol dumper to use + * @param string $nameserver + * @param LoopInterface $loop */ - public function __construct(LoopInterface $loop, Parser $parser = null, BinaryDumper $dumper = null) + public function __construct($nameserver, LoopInterface $loop) { - if ($parser === null) { - $parser = new Parser(); - } - if ($dumper === null) { - $dumper = new BinaryDumper(); + if (\strpos($nameserver, '[') === false && \substr_count($nameserver, ':') >= 2 && \strpos($nameserver, '://') === false) { + // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets + $nameserver = '[' . $nameserver . ']'; } + $parts = \parse_url((\strpos($nameserver, '://') === false ? 'udp://' : '') . $nameserver); + if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'udp' || !\filter_var(\trim($parts['host'], '[]'), \FILTER_VALIDATE_IP)) { + throw new \InvalidArgumentException('Invalid nameserver address given'); + } + + $this->nameserver = 'udp://' . $parts['host'] . ':' . (isset($parts['port']) ? $parts['port'] : 53); $this->loop = $loop; - $this->parser = $parser; - $this->dumper = $dumper; + $this->parser = new Parser(); + $this->dumper = new BinaryDumper(); } - public function query($nameserver, Query $query) + public function query(Query $query) { $request = Message::createRequestForQuery($query); $queryData = $this->dumper->toBinary($request); if (isset($queryData[512])) { return \React\Promise\reject(new \RuntimeException( - 'DNS query for ' . $query->name . ' failed: Query too large for UDP transport' + 'DNS query for ' . $query->name . ' failed: Query too large for UDP transport', + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 )); } // UDP connections are instant, so try connection without a loop or timeout - $socket = @\stream_socket_client("udp://$nameserver", $errno, $errstr, 0); + $socket = @\stream_socket_client($this->nameserver, $errno, $errstr, 0); if ($socket === false) { return \React\Promise\reject(new \RuntimeException( 'DNS query for ' . $query->name . ' failed: Unable to connect to DNS server (' . $errstr . ')', @@ -140,7 +164,7 @@ // ignore and await next if we received an unexpected response ID // this may as well be a fake response from an attacker (possible cache poisoning) - if ($response->getId() !== $request->getId()) { + if ($response->id !== $request->id) { return; } @@ -148,8 +172,11 @@ $loop->removeReadStream($socket); \fclose($socket); - if ($response->header->isTruncated()) { - $deferred->reject(new \RuntimeException('DNS query for ' . $query->name . ' failed: The server returned a truncated result for a UDP query, but retrying via TCP is currently not supported')); + if ($response->tc) { + $deferred->reject(new \RuntimeException( + 'DNS query for ' . $query->name . ' failed: The server returned a truncated result for a UDP query', + \defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 90 + )); return; } diff --git a/src/RecordNotFoundException.php b/src/RecordNotFoundException.php index 0028413..3b70274 100644 --- a/src/RecordNotFoundException.php +++ b/src/RecordNotFoundException.php @@ -2,6 +2,6 @@ namespace React\Dns; -class RecordNotFoundException extends \Exception +final class RecordNotFoundException extends \Exception { } diff --git a/src/Resolver/Factory.php b/src/Resolver/Factory.php index 7b66e1d..1750aa8 100644 --- a/src/Resolver/Factory.php +++ b/src/Resolver/Factory.php @@ -5,35 +5,49 @@ use React\Cache\ArrayCache; use React\Cache\CacheInterface; use React\Dns\Config\HostsFile; -use React\Dns\Query\CachedExecutor; +use React\Dns\Query\CachingExecutor; +use React\Dns\Query\CoopExecutor; use React\Dns\Query\ExecutorInterface; use React\Dns\Query\HostsFileExecutor; -use React\Dns\Query\RecordCache; use React\Dns\Query\RetryExecutor; +use React\Dns\Query\SelectiveTransportExecutor; +use React\Dns\Query\TcpTransportExecutor; use React\Dns\Query\TimeoutExecutor; use React\Dns\Query\UdpTransportExecutor; use React\EventLoop\LoopInterface; -class Factory +final class Factory { + /** + * @param string $nameserver + * @param LoopInterface $loop + * @return \React\Dns\Resolver\ResolverInterface + */ public function create($nameserver, LoopInterface $loop) { - $nameserver = $this->addPortToServerIfMissing($nameserver); - $executor = $this->decorateHostsFileExecutor($this->createRetryExecutor($loop)); + $executor = $this->decorateHostsFileExecutor($this->createExecutor($nameserver, $loop)); - return new Resolver($nameserver, $executor); + return new Resolver($executor); } + /** + * @param string $nameserver + * @param LoopInterface $loop + * @param ?CacheInterface $cache + * @return \React\Dns\Resolver\ResolverInterface + */ public function createCached($nameserver, LoopInterface $loop, CacheInterface $cache = null) { + // default to keeping maximum of 256 responses in cache unless explicitly given if (!($cache instanceof CacheInterface)) { - $cache = new ArrayCache(); + $cache = new ArrayCache(256); } - $nameserver = $this->addPortToServerIfMissing($nameserver); - $executor = $this->decorateHostsFileExecutor($this->createCachedExecutor($loop, $cache)); + $executor = $this->createExecutor($nameserver, $loop); + $executor = new CachingExecutor($executor, $cache); + $executor = $this->decorateHostsFileExecutor($executor); - return new Resolver($nameserver, $executor); + return new Resolver($executor); } /** @@ -66,36 +80,44 @@ return $executor; } - protected function createExecutor(LoopInterface $loop) + private function createExecutor($nameserver, LoopInterface $loop) + { + $parts = \parse_url($nameserver); + + if (isset($parts['scheme']) && $parts['scheme'] === 'tcp') { + $executor = $this->createTcpExecutor($nameserver, $loop); + } elseif (isset($parts['scheme']) && $parts['scheme'] === 'udp') { + $executor = $this->createUdpExecutor($nameserver, $loop); + } else { + $executor = new SelectiveTransportExecutor( + $this->createUdpExecutor($nameserver, $loop), + $this->createTcpExecutor($nameserver, $loop) + ); + } + + return new CoopExecutor($executor); + } + + private function createTcpExecutor($nameserver, LoopInterface $loop) { return new TimeoutExecutor( - new UdpTransportExecutor($loop), + new TcpTransportExecutor($nameserver, $loop), 5.0, $loop ); } - protected function createRetryExecutor(LoopInterface $loop) + private function createUdpExecutor($nameserver, LoopInterface $loop) { - return new RetryExecutor($this->createExecutor($loop)); - } - - protected function createCachedExecutor(LoopInterface $loop, CacheInterface $cache) - { - return new CachedExecutor($this->createRetryExecutor($loop), new RecordCache($cache)); - } - - protected function addPortToServerIfMissing($nameserver) - { - if (strpos($nameserver, '[') === false && substr_count($nameserver, ':') >= 2) { - // several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets - $nameserver = '[' . $nameserver . ']'; - } - // assume a dummy scheme when checking for the port, otherwise parse_url() fails - if (parse_url('dummy://' . $nameserver, PHP_URL_PORT) === null) { - $nameserver .= ':53'; - } - - return $nameserver; + return new RetryExecutor( + new TimeoutExecutor( + new UdpTransportExecutor( + $nameserver, + $loop + ), + 5.0, + $loop + ) + ); } } diff --git a/src/Resolver/Resolver.php b/src/Resolver/Resolver.php index 8690972..71a9b93 100644 --- a/src/Resolver/Resolver.php +++ b/src/Resolver/Resolver.php @@ -6,57 +6,19 @@ use React\Dns\Query\ExecutorInterface; use React\Dns\Query\Query; use React\Dns\RecordNotFoundException; -use React\Promise\PromiseInterface; -class Resolver +/** + * @see ResolverInterface for the base interface + */ +final class Resolver implements ResolverInterface { - private $nameserver; private $executor; - public function __construct($nameserver, ExecutorInterface $executor) + public function __construct(ExecutorInterface $executor) { - $this->nameserver = $nameserver; $this->executor = $executor; } - /** - * Resolves the given $domain name to a single IPv4 address (type `A` query). - * - * ```php - * $resolver->resolve('reactphp.org')->then(function ($ip) { - * echo 'IP for reactphp.org is ' . $ip . PHP_EOL; - * }); - * ``` - * - * This is one of the main methods in this package. It sends a DNS query - * for the given $domain name to your DNS server and returns a single IP - * address on success. - * - * If the DNS server sends a DNS response message that contains more than - * one IP address for this query, it will randomly pick one of the IP - * addresses from the response. If you want the full list of IP addresses - * or want to send a different type of query, you should use the - * [`resolveAll()`](#resolveall) method instead. - * - * If the DNS server sends a DNS response message that indicates an error - * code, this method will reject with a `RecordNotFoundException`. Its - * message and code can be used to check for the response code. - * - * If the DNS communication fails and the server does not respond with a - * valid response message, this message will reject with an `Exception`. - * - * Pending DNS queries can be cancelled by cancelling its pending promise like so: - * - * ```php - * $promise = $resolver->resolve('reactphp.org'); - * - * $promise->cancel(); - * ``` - * - * @param string $domain - * @return PromiseInterface Returns a promise which resolves with a single IP address on success or - * rejects with an Exception on error. - */ public function resolve($domain) { return $this->resolveAll($domain, Message::TYPE_A)->then(function (array $ips) { @@ -64,73 +26,16 @@ }); } - /** - * Resolves all record values for the given $domain name and query $type. - * - * ```php - * $resolver->resolveAll('reactphp.org', Message::TYPE_A)->then(function ($ips) { - * echo 'IPv4 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; - * }); - * - * $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { - * echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; - * }); - * ``` - * - * This is one of the main methods in this package. It sends a DNS query - * for the given $domain name to your DNS server and returns a list with all - * record values on success. - * - * If the DNS server sends a DNS response message that contains one or more - * records for this query, it will return a list with all record values - * from the response. You can use the `Message::TYPE_*` constants to control - * which type of query will be sent. Note that this method always returns a - * list of record values, but each record value type depends on the query - * type. For example, it returns the IPv4 addresses for type `A` queries, - * the IPv6 addresses for type `AAAA` queries, the hostname for type `NS`, - * `CNAME` and `PTR` queries and structured data for other queries. See also - * the `Record` documentation for more details. - * - * If the DNS server sends a DNS response message that indicates an error - * code, this method will reject with a `RecordNotFoundException`. Its - * message and code can be used to check for the response code. - * - * If the DNS communication fails and the server does not respond with a - * valid response message, this message will reject with an `Exception`. - * - * Pending DNS queries can be cancelled by cancelling its pending promise like so: - * - * ```php - * $promise = $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA); - * - * $promise->cancel(); - * ``` - * - * @param string $domain - * @return PromiseInterface Returns a promise which resolves with all record values on success or - * rejects with an Exception on error. - */ public function resolveAll($domain, $type) { $query = new Query($domain, $type, Message::CLASS_IN); $that = $this; return $this->executor->query( - $this->nameserver, $query )->then(function (Message $response) use ($query, $that) { return $that->extractValues($query, $response); }); - } - - /** - * @deprecated unused, exists for BC only - */ - public function extractAddress(Query $query, Message $response) - { - $addresses = $this->extractValues($query, $response); - - return $addresses[array_rand($addresses)]; } /** @@ -145,7 +50,7 @@ public function extractValues(Query $query, Message $response) { // reject if response code indicates this is an error response message - $code = $response->getResponseCode(); + $code = $response->rcode; if ($code !== Message::RCODE_OK) { switch ($code) { case Message::RCODE_FORMAT_ERROR: @@ -183,14 +88,6 @@ } return array_values($addresses); - } - - /** - * @deprecated unused, exists for BC only - */ - public function resolveAliases(array $answers, $name) - { - return $this->valuesByNameAndType($answers, $name, Message::TYPE_A); } /** diff --git a/src/Resolver/ResolverInterface.php b/src/Resolver/ResolverInterface.php new file mode 100644 index 0000000..fe937dc --- /dev/null +++ b/src/Resolver/ResolverInterface.php @@ -0,0 +1,94 @@ +resolve('reactphp.org')->then(function ($ip) { + * echo 'IP for reactphp.org is ' . $ip . PHP_EOL; + * }); + * ``` + * + * This is one of the main methods in this package. It sends a DNS query + * for the given $domain name to your DNS server and returns a single IP + * address on success. + * + * If the DNS server sends a DNS response message that contains more than + * one IP address for this query, it will randomly pick one of the IP + * addresses from the response. If you want the full list of IP addresses + * or want to send a different type of query, you should use the + * [`resolveAll()`](#resolveall) method instead. + * + * If the DNS server sends a DNS response message that indicates an error + * code, this method will reject with a `RecordNotFoundException`. Its + * message and code can be used to check for the response code. + * + * If the DNS communication fails and the server does not respond with a + * valid response message, this message will reject with an `Exception`. + * + * Pending DNS queries can be cancelled by cancelling its pending promise like so: + * + * ```php + * $promise = $resolver->resolve('reactphp.org'); + * + * $promise->cancel(); + * ``` + * + * @param string $domain + * @return \React\Promise\PromiseInterface + * resolves with a single IP address on success or rejects with an Exception on error. + */ + public function resolve($domain); + + /** + * Resolves all record values for the given $domain name and query $type. + * + * ```php + * $resolver->resolveAll('reactphp.org', Message::TYPE_A)->then(function ($ips) { + * echo 'IPv4 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; + * }); + * + * $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then(function ($ips) { + * echo 'IPv6 addresses for reactphp.org ' . implode(', ', $ips) . PHP_EOL; + * }); + * ``` + * + * This is one of the main methods in this package. It sends a DNS query + * for the given $domain name to your DNS server and returns a list with all + * record values on success. + * + * If the DNS server sends a DNS response message that contains one or more + * records for this query, it will return a list with all record values + * from the response. You can use the `Message::TYPE_*` constants to control + * which type of query will be sent. Note that this method always returns a + * list of record values, but each record value type depends on the query + * type. For example, it returns the IPv4 addresses for type `A` queries, + * the IPv6 addresses for type `AAAA` queries, the hostname for type `NS`, + * `CNAME` and `PTR` queries and structured data for other queries. See also + * the `Record` documentation for more details. + * + * If the DNS server sends a DNS response message that indicates an error + * code, this method will reject with a `RecordNotFoundException`. Its + * message and code can be used to check for the response code. + * + * If the DNS communication fails and the server does not respond with a + * valid response message, this message will reject with an `Exception`. + * + * Pending DNS queries can be cancelled by cancelling its pending promise like so: + * + * ```php + * $promise = $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA); + * + * $promise->cancel(); + * ``` + * + * @param string $domain + * @return \React\Promise\PromiseInterface + * Resolves with all record values on success or rejects with an Exception on error. + */ + public function resolveAll($domain, $type); +} diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php index 8020408..31ae1f9 100644 --- a/tests/Config/ConfigTest.php +++ b/tests/Config/ConfigTest.php @@ -99,7 +99,11 @@ public function testLoadsFromWmicOnWindows() { if (DIRECTORY_SEPARATOR !== '\\') { - $this->markTestSkipped('Only on Windows'); + // WMIC is Windows-only tool and not supported on other platforms + // Unix is our main platform, so we don't want to report a skipped test here (yellow) + // $this->markTestSkipped('Only on Windows'); + $this->expectOutputString(''); + return; } $config = Config::loadWmicBlocking(); diff --git a/tests/Config/FilesystemFactoryTest.php b/tests/Config/FilesystemFactoryTest.php deleted file mode 100644 index bb9eac7..0000000 --- a/tests/Config/FilesystemFactoryTest.php +++ /dev/null @@ -1,70 +0,0 @@ -getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - $factory = new FilesystemFactory($loop); - $factory->parseEtcResolvConf($contents)->then(function ($config) use (&$capturedConfig) { - $capturedConfig = $config; - }); - - $this->assertNotNull($capturedConfig); - $this->assertSame($expected, $capturedConfig->nameservers); - } - - /** @test */ - public function createShouldLoadStuffFromFilesystem() - { - $this->markTestIncomplete('Filesystem API is incomplete'); - - $expected = array('8.8.8.8'); - - $triggerListener = null; - $capturedConfig = null; - - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - $loop - ->expects($this->once()) - ->method('addReadStream') - ->will($this->returnCallback(function ($stream, $listener) use (&$triggerListener) { - $triggerListener = function () use ($stream, $listener) { - call_user_func($listener, $stream); - }; - })); - - $factory = new FilesystemFactory($loop); - $factory->create(__DIR__.'/../Fixtures/etc/resolv.conf')->then(function ($config) use (&$capturedConfig) { - $capturedConfig = $config; - }); - - $triggerListener(); - - $this->assertNotNull($capturedConfig); - $this->assertSame($expected, $capturedConfig->nameservers); - } -} diff --git a/tests/FunctionalResolverTest.php b/tests/FunctionalResolverTest.php index 7b6a37b..d4d437c 100644 --- a/tests/FunctionalResolverTest.php +++ b/tests/FunctionalResolverTest.php @@ -47,12 +47,53 @@ /** * @group internet */ + public function testResolveGoogleOverUdpResolves() + { + $factory = new Factory($this->loop); + $this->resolver = $factory->create('udp://8.8.8.8', $this->loop); + + $promise = $this->resolver->resolve('google.com'); + $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $this->loop->run(); + } + + /** + * @group internet + */ + public function testResolveGoogleOverTcpResolves() + { + $factory = new Factory($this->loop); + $this->resolver = $factory->create('tcp://8.8.8.8', $this->loop); + + $promise = $this->resolver->resolve('google.com'); + $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $this->loop->run(); + } + + /** + * @group internet + */ public function testResolveAllGoogleMxResolvesWithCache() { $factory = new Factory(); $this->resolver = $factory->createCached('8.8.8.8', $this->loop); $promise = $this->resolver->resolveAll('google.com', Message::TYPE_MX); + $promise->then($this->expectCallableOnceWith($this->isType('array')), $this->expectCallableNever()); + + $this->loop->run(); + } + /** + * @group internet + */ + public function testResolveAllGoogleCaaResolvesWithCache() + { + $factory = new Factory(); + $this->resolver = $factory->createCached('8.8.8.8', $this->loop); + + $promise = $this->resolver->resolveAll('google.com', Message::TYPE_CAA); $promise->then($this->expectCallableOnceWith($this->isType('array')), $this->expectCallableNever()); $this->loop->run(); @@ -98,4 +139,74 @@ $promise = $this->resolver->resolve('google.com'); $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); } + + public function testResolveShouldNotCauseGarbageReferencesWhenUsingInvalidNameserver() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $factory = new Factory(); + $this->resolver = $factory->create('255.255.255.255', $this->loop); + + gc_collect_cycles(); + + $promise = $this->resolver->resolve('google.com'); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testResolveCachedShouldNotCauseGarbageReferencesWhenUsingInvalidNameserver() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $factory = new Factory(); + $this->resolver = $factory->createCached('255.255.255.255', $this->loop); + + gc_collect_cycles(); + + $promise = $this->resolver->resolve('google.com'); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCancelResolveShouldNotCauseGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $factory = new Factory(); + $this->resolver = $factory->create('127.0.0.1', $this->loop); + + gc_collect_cycles(); + + $promise = $this->resolver->resolve('google.com'); + $promise->cancel(); + $promise = null; + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCancelResolveCachedShouldNotCauseGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $factory = new Factory(); + $this->resolver = $factory->createCached('127.0.0.1', $this->loop); + + gc_collect_cycles(); + + $promise = $this->resolver->resolve('google.com'); + $promise->cancel(); + $promise = null; + + $this->assertEquals(0, gc_collect_cycles()); + } } diff --git a/tests/Model/MessageTest.php b/tests/Model/MessageTest.php index cf3d890..a52f659 100644 --- a/tests/Model/MessageTest.php +++ b/tests/Model/MessageTest.php @@ -10,22 +10,20 @@ { public function testCreateRequestDesiresRecusion() { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); $request = Message::createRequestForQuery($query); - $this->assertTrue($request->header->isQuery()); - $this->assertSame(1, $request->header->get('rd')); + $this->assertFalse($request->qr); + $this->assertTrue($request->rd); } public function testCreateResponseWithNoAnswers() { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); $answers = array(); $request = Message::createResponseWithAnswersForQuery($query, $answers); - $this->assertFalse($request->header->isQuery()); - $this->assertTrue($request->header->isResponse()); - $this->assertEquals(0, $request->header->get('anCount')); - $this->assertEquals(Message::RCODE_OK, $request->getResponseCode()); + $this->assertTrue($request->qr); + $this->assertEquals(Message::RCODE_OK, $request->rcode); } } diff --git a/tests/Protocol/BinaryDumperTest.php b/tests/Protocol/BinaryDumperTest.php index bf60ca9..fc36026 100644 --- a/tests/Protocol/BinaryDumperTest.php +++ b/tests/Protocol/BinaryDumperTest.php @@ -3,31 +3,31 @@ namespace React\Tests\Dns\Protocol; use PHPUnit\Framework\TestCase; +use React\Dns\Model\Message; +use React\Dns\Model\Record; use React\Dns\Protocol\BinaryDumper; -use React\Dns\Model\Message; +use React\Dns\Query\Query; class BinaryDumperTest extends TestCase { - public function testRequestToBinary() + public function testToBinaryRequestMessage() { $data = ""; $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io $data .= "00 01 00 01"; // question: type A, class IN - $expected = $this->formatHexDump(str_replace(' ', '', $data), 2); + $expected = $this->formatHexDump($data); $request = new Message(); - $request->header->set('id', 0x7262); - $request->header->set('rd', 1); - - $request->questions[] = array( - 'name' => 'igor.io', - 'type' => Message::TYPE_A, - 'class' => Message::CLASS_IN, - ); - - $request->prepare(); + $request->id = 0x7262; + $request->rd = true; + + $request->questions[] = new Query( + 'igor.io', + Message::TYPE_A, + Message::CLASS_IN + ); $dumper = new BinaryDumper(); $data = $dumper->toBinary($request); @@ -36,6 +36,283 @@ $this->assertSame($expected, $data); } + public function testToBinaryRequestMessageWithCustomOptForEdns0() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 01"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 01 00 01"; // question: type A, class IN + $data .= "00"; // additional: (empty hostname) + $data .= "00 29 03 e8 00 00 00 00 00 00 "; // additional: type OPT, class UDP size, TTL 0, no RDATA + + $expected = $this->formatHexDump($data); + + $request = new Message(); + $request->id = 0x7262; + $request->rd = true; + + $request->questions[] = new Query( + 'igor.io', + Message::TYPE_A, + Message::CLASS_IN + ); + + $request->additional[] = new Record('', 41, 1000, 0, ''); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($request); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + + public function testToBinaryResponseMessageWithoutRecords() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 01 00 01"; // question: type A, class IN + + $expected = $this->formatHexDump($data); + + $response = new Message(); + $response->id = 0x7262; + $response->rd = true; + $response->rcode = Message::RCODE_OK; + + $response->questions[] = new Query( + 'igor.io', + Message::TYPE_A, + Message::CLASS_IN + ); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($response); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + + public function testToBinaryForResponseWithSRVRecord() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 01 00 00 00 00"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 21 00 01"; // question: type SRV, class IN + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 21 00 01"; // answer: type SRV, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 0c"; // answer: rdlength 12 + $data .= "00 0a 00 14 1f 90 04 74 65 73 74 00"; // answer: rdata priority 10, weight 20, port 8080 test + + $expected = $this->formatHexDump($data); + + $response = new Message(); + $response->id = 0x7262; + $response->rd = true; + $response->rcode = Message::RCODE_OK; + + $response->questions[] = new Query( + 'igor.io', + Message::TYPE_SRV, + Message::CLASS_IN + ); + + $response->answers[] = new Record('igor.io', Message::TYPE_SRV, Message::CLASS_IN, 86400, array( + 'priority' => 10, + 'weight' => 20, + 'port' => 8080, + 'target' => 'test' + )); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($response); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + + public function testToBinaryForResponseWithSOARecord() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 01 00 00 00 00"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 06 00 01"; // question: type SOA, class IN + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 06 00 01"; // answer: type SOA, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 27"; // answer: rdlength 39 + $data .= "02 6e 73 05 68 65 6c 6c 6f 00"; // answer: rdata ns.hello (mname) + $data .= "01 65 05 68 65 6c 6c 6f 00"; // answer: rdata e.hello (rname) + $data .= "78 49 28 d5 00 00 2a 30 00 00 0e 10"; // answer: rdata 2018060501, 10800, 3600 + $data .= "00 09 3e 68 00 00 0e 10"; // answer: 605800, 3600 + + $expected = $this->formatHexDump($data); + + $response = new Message(); + $response->id = 0x7262; + $response->rd = true; + $response->rcode = Message::RCODE_OK; + + $response->questions[] = new Query( + 'igor.io', + Message::TYPE_SOA, + Message::CLASS_IN + ); + + $response->answers[] = new Record('igor.io', Message::TYPE_SOA, Message::CLASS_IN, 86400, array( + 'mname' => 'ns.hello', + 'rname' => 'e.hello', + 'serial' => 2018060501, + 'refresh' => 10800, + 'retry' => 3600, + 'expire' => 605800, + 'minimum' => 3600 + )); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($response); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + + public function testToBinaryForResponseWithPTRRecordWithSpecialCharactersEscaped() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 01 00 00 00 00"; // header + $data .= "08 5f 70 72 69 6e 74 65 72 04 5f 74 63 70 06 64 6e 73 2d 73 64 03 6f 72 67 00"; // question: _printer._tcp.dns-sd.org + $data .= "00 0c 00 01"; // question: type PTR, class IN + $data .= "08 5f 70 72 69 6e 74 65 72 04 5f 74 63 70 06 64 6e 73 2d 73 64 03 6f 72 67 00"; // answer: _printer._tcp.dns-sd.org + $data .= "00 0c 00 01"; // answer: type PTR, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 2f"; // answer: rdlength 47 + $data .= "14 33 72 64 2e 20 46 6c 6f 6f 72 20 43 6f 70 79 20 52 6f 6f 6d"; // answer: answer: rdata "3rd. Floor Copy Room" … + $data .= "08 5f 70 72 69 6e 74 65 72 04 5f 74 63 70 06 64 6e 73 2d 73 64 03 6f 72 67 00"; // answer: … "._printer._tcp.dns-sd.org" + + $expected = $this->formatHexDump($data); + + $response = new Message(); + $response->id = 0x7262; + $response->rd = true; + $response->rcode = Message::RCODE_OK; + + $response->questions[] = new Query( + '_printer._tcp.dns-sd.org', + Message::TYPE_PTR, + Message::CLASS_IN + ); + + $response->answers[] = new Record( + '_printer._tcp.dns-sd.org', + Message::TYPE_PTR, + Message::CLASS_IN, + 86400, + '3rd\.\ Floor\ Copy\ Room._printer._tcp.dns-sd.org' + ); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($response); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + + public function testToBinaryForResponseWithMultipleAnswerRecords() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 06 00 00 00 00"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 ff 00 01"; // question: type ANY, class IN + + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 01 00 01 00 00 00 00 00 04"; // answer: type A, class IN, TTL 0, 4 bytes + $data .= "7f 00 00 01"; // answer: 127.0.0.1 + + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 1c 00 01 00 00 00 00 00 10"; // question: type AAAA, class IN, TTL 0, 16 bytes + $data .= "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01"; // answer: ::1 + + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 10 00 01 00 00 00 00 00 0c"; // answer: type TXT, class IN, TTL 0, 12 bytes + $data .= "05 68 65 6c 6c 6f 05 77 6f 72 6c 64"; // answer: hello, world + + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 0f 00 01 00 00 00 00 00 03"; // answer: type MX, class IN, TTL 0, 3 bytes + $data .= "00 00 00"; // answer: … priority 0, no target + + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io … + $data .= "01 01 00 01 00 00 00 00 00 16"; // answer: type CAA, class IN, TTL 0, 22 bytes + $data .= "00 05 69 73 73 75 65"; // answer: 0 issue … + $data .= "6c 65 74 73 65 6e 63 72 79 70 74 2e 6f 72 67"; // answer: … letsencrypt.org + + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io … + $data .= "00 2c 00 01 00 00 00 00 00 06"; // answer: type SSHFP, class IN, TTL 0, 6 bytes + $data .= "01 01 69 ac 09 0c"; // answer: algorithm 1 (RSA), type 1 (SHA-1), fingerprint "69ac090c" + + $expected = $this->formatHexDump($data); + + $response = new Message(); + $response->id = 0x7262; + $response->rd = true; + $response->rcode = Message::RCODE_OK; + + $response->questions[] = new Query( + 'igor.io', + Message::TYPE_ANY, + Message::CLASS_IN + ); + + $response->answers[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 0, '127.0.0.1'); + $response->answers[] = new Record('igor.io', Message::TYPE_AAAA, Message::CLASS_IN, 0, '::1'); + $response->answers[] = new Record('igor.io', Message::TYPE_TXT, Message::CLASS_IN, 0, array('hello', 'world')); + $response->answers[] = new Record('igor.io', Message::TYPE_MX, Message::CLASS_IN, 0, array('priority' => 0, 'target' => '')); + $response->answers[] = new Record('igor.io', Message::TYPE_CAA, Message::CLASS_IN, 0, array('flag' => 0, 'tag' => 'issue', 'value' => 'letsencrypt.org')); + $response->answers[] = new Record('igor.io', Message::TYPE_SSHFP, Message::CLASS_IN, 0, array('algorithm' => 1, 'type' => '1', 'fingerprint' => '69ac090c')); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($response); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + + public function testToBinaryForResponseWithAnswerAndAdditionalRecord() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 01 00 00 00 01"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 02 00 01"; // question: type NS, class IN + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 02 00 01 00 00 00 00 00 0d"; // answer: type NS, class IN, TTL 0, 10 bytes + $data .= "07 65 78 61 6d 70 6c 65 03 63 6f 6d 00"; // answer: example.com + $data .= "07 65 78 61 6d 70 6c 65 03 63 6f 6d 00"; // additional: example.com + $data .= "00 01 00 01 00 00 00 00 00 04"; // additional: type A, class IN, TTL 0, 4 bytes + $data .= "7f 00 00 01"; // additional: 127.0.0.1 + + $expected = $this->formatHexDump($data); + + $response = new Message(); + $response->id = 0x7262; + $response->rd = true; + $response->rcode = Message::RCODE_OK; + + $response->questions[] = new Query( + 'igor.io', + Message::TYPE_NS, + Message::CLASS_IN + ); + + $response->answers[] = new Record('igor.io', Message::TYPE_NS, Message::CLASS_IN, 0, 'example.com'); + $response->additional[] = new Record('example.com', Message::TYPE_A, Message::CLASS_IN, 0, '127.0.0.1'); + + $dumper = new BinaryDumper(); + $data = $dumper->toBinary($response); + $data = $this->convertBinaryToHexDump($data); + + $this->assertSame($expected, $data); + } + private function convertBinaryToHexDump($input) { return $this->formatHexDump(implode('', unpack('H*', $input))); @@ -43,6 +320,6 @@ private function formatHexDump($input) { - return implode(' ', str_split($input, 2)); + return implode(' ', str_split(str_replace(' ', '', $input), 2)); } } diff --git a/tests/Protocol/ParserTest.php b/tests/Protocol/ParserTest.php index aed4e45..6ff66a9 100644 --- a/tests/Protocol/ParserTest.php +++ b/tests/Protocol/ParserTest.php @@ -42,25 +42,22 @@ $request = $this->parser->parseMessage($data); - $header = $request->header; - $this->assertSame(0x7262, $header->get('id')); - $this->assertSame(1, $header->get('qdCount')); - $this->assertSame(0, $header->get('anCount')); - $this->assertSame(0, $header->get('nsCount')); - $this->assertSame(0, $header->get('arCount')); - $this->assertSame(0, $header->get('qr')); - $this->assertSame(Message::OPCODE_QUERY, $header->get('opcode')); - $this->assertSame(0, $header->get('aa')); - $this->assertSame(0, $header->get('tc')); - $this->assertSame(1, $header->get('rd')); - $this->assertSame(0, $header->get('ra')); - $this->assertSame(0, $header->get('z')); - $this->assertSame(Message::RCODE_OK, $header->get('rcode')); + $this->assertFalse(isset($request->data)); + $this->assertFalse(isset($request->consumed)); + + $this->assertSame(0x7262, $request->id); + $this->assertSame(false, $request->qr); + $this->assertSame(Message::OPCODE_QUERY, $request->opcode); + $this->assertSame(false, $request->aa); + $this->assertSame(false, $request->tc); + $this->assertSame(true, $request->rd); + $this->assertSame(false, $request->ra); + $this->assertSame(Message::RCODE_OK, $request->rcode); $this->assertCount(1, $request->questions); - $this->assertSame('igor.io', $request->questions[0]['name']); - $this->assertSame(Message::TYPE_A, $request->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $request->questions[0]['class']); + $this->assertSame('igor.io', $request->questions[0]->name); + $this->assertSame(Message::TYPE_A, $request->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $request->questions[0]->class); } public function testParseResponse() @@ -79,25 +76,19 @@ $response = $this->parser->parseMessage($data); - $header = $response->header; - $this->assertSame(0x7262, $header->get('id')); - $this->assertSame(1, $header->get('qdCount')); - $this->assertSame(1, $header->get('anCount')); - $this->assertSame(0, $header->get('nsCount')); - $this->assertSame(0, $header->get('arCount')); - $this->assertSame(1, $header->get('qr')); - $this->assertSame(Message::OPCODE_QUERY, $header->get('opcode')); - $this->assertSame(0, $header->get('aa')); - $this->assertSame(0, $header->get('tc')); - $this->assertSame(1, $header->get('rd')); - $this->assertSame(1, $header->get('ra')); - $this->assertSame(0, $header->get('z')); - $this->assertSame(Message::RCODE_OK, $header->get('rcode')); + $this->assertSame(0x7262, $response->id); + $this->assertSame(true, $response->qr); + $this->assertSame(Message::OPCODE_QUERY, $response->opcode); + $this->assertSame(false, $response->aa); + $this->assertSame(false, $response->tc); + $this->assertSame(true, $response->rd); + $this->assertSame(true, $response->ra); + $this->assertSame(Message::RCODE_OK, $response->rcode); $this->assertCount(1, $response->questions); - $this->assertSame('igor.io', $response->questions[0]['name']); - $this->assertSame(Message::TYPE_A, $response->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $response->questions[0]['class']); + $this->assertSame('igor.io', $response->questions[0]->name); + $this->assertSame(Message::TYPE_A, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -107,9 +98,10 @@ $this->assertSame('178.79.169.131', $response->answers[0]->data); } - public function testParseQuestionWithTwoQuestions() - { - $data = ""; + public function testParseRequestWithTwoQuestions() + { + $data = ""; + $data .= "72 62 01 00 00 02 00 00 00 00 00 00"; // header $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io $data .= "00 01 00 01"; // question: type A, class IN $data .= "03 77 77 77 04 69 67 6f 72 02 69 6f 00"; // question: www.igor.io @@ -117,19 +109,15 @@ $data = $this->convertTcpDumpToBinary($data); - $request = new Message(); - $request->header->set('qdCount', 2); - $request->data = $data; - - $this->parser->parseQuestion($request); + $request = $this->parser->parseMessage($data); $this->assertCount(2, $request->questions); - $this->assertSame('igor.io', $request->questions[0]['name']); - $this->assertSame(Message::TYPE_A, $request->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $request->questions[0]['class']); - $this->assertSame('www.igor.io', $request->questions[1]['name']); - $this->assertSame(Message::TYPE_A, $request->questions[1]['type']); - $this->assertSame(Message::CLASS_IN, $request->questions[1]['class']); + $this->assertSame('igor.io', $request->questions[0]->name); + $this->assertSame(Message::TYPE_A, $request->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $request->questions[0]->class); + $this->assertSame('www.igor.io', $request->questions[1]->name); + $this->assertSame(Message::TYPE_A, $request->questions[1]->type); + $this->assertSame(Message::CLASS_IN, $request->questions[1]->class); } public function testParseAnswerWithInlineData() @@ -141,13 +129,7 @@ $data .= "00 04"; // answer: rdlength 4 $data .= "b2 4f a9 83"; // answer: rdata 178.79.169.131 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -166,13 +148,7 @@ $data .= "00 04"; // answer: rdlength 4 $data .= "b2 4f a9 83"; // answer: rdata 178.79.169.131 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -191,13 +167,7 @@ $data .= "00 04"; // answer: rdlength 4 $data .= "b2 4f a9 83"; // answer: rdata 178.79.169.131 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -216,13 +186,7 @@ $data .= "00 04"; // answer: rdlength 4 $data .= "b2 4f a9 83"; // answer: rdata 178.79.169.131 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -241,13 +205,7 @@ $data .= "00 05"; // answer: rdlength 5 $data .= "68 65 6c 6c 6f"; // answer: rdata "hello" - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -275,9 +233,9 @@ $response = $this->parser->parseMessage($data); $this->assertCount(1, $response->questions); - $this->assertSame('mail.google.com', $response->questions[0]['name']); - $this->assertSame(Message::TYPE_CNAME, $response->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $response->questions[0]['class']); + $this->assertSame('mail.google.com', $response->questions[0]->name); + $this->assertSame(Message::TYPE_CNAME, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); $this->assertCount(1, $response->answers); $this->assertSame('mail.google.com', $response->answers[0]->name); @@ -303,25 +261,19 @@ $response = $this->parser->parseMessage($data); - $header = $response->header; - $this->assertSame(0xcd72, $header->get('id')); - $this->assertSame(1, $header->get('qdCount')); - $this->assertSame(1, $header->get('anCount')); - $this->assertSame(0, $header->get('nsCount')); - $this->assertSame(0, $header->get('arCount')); - $this->assertSame(1, $header->get('qr')); - $this->assertSame(Message::OPCODE_QUERY, $header->get('opcode')); - $this->assertSame(0, $header->get('aa')); - $this->assertSame(0, $header->get('tc')); - $this->assertSame(1, $header->get('rd')); - $this->assertSame(1, $header->get('ra')); - $this->assertSame(0, $header->get('z')); - $this->assertSame(Message::RCODE_OK, $header->get('rcode')); + $this->assertSame(0xcd72, $response->id); + $this->assertSame(true, $response->qr); + $this->assertSame(Message::OPCODE_QUERY, $response->opcode); + $this->assertSame(false, $response->aa); + $this->assertSame(false, $response->tc); + $this->assertSame(true, $response->rd); + $this->assertSame(true, $response->ra); + $this->assertSame(Message::RCODE_OK, $response->rcode); $this->assertCount(1, $response->questions); - $this->assertSame('google.com', $response->questions[0]['name']); - $this->assertSame(Message::TYPE_AAAA, $response->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $response->questions[0]['class']); + $this->assertSame('google.com', $response->questions[0]->name); + $this->assertSame(Message::TYPE_AAAA, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); $this->assertCount(1, $response->answers); $this->assertSame('google.com', $response->answers[0]->name); @@ -340,13 +292,7 @@ $data .= "00 06"; // answer: rdlength 6 $data .= "05 68 65 6c 6c 6f"; // answer: rdata length 5: hello - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -365,13 +311,7 @@ $data .= "00 0C"; // answer: rdlength 12 $data .= "05 68 65 6c 6c 6f 05 77 6f 72 6c 64"; // answer: rdata length 5: hello, length 5: world - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -390,13 +330,7 @@ $data .= "00 09"; // answer: rdlength 9 $data .= "00 0a 05 68 65 6c 6c 6f 00"; // answer: rdata priority 10: hello - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -415,13 +349,7 @@ $data .= "00 0C"; // answer: rdlength 12 $data .= "00 0a 00 14 1F 90 04 74 65 73 74 00"; // answer: rdata priority 10, weight 20, port 8080 test - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -439,7 +367,7 @@ ); } - public function testParseResponseWithTwoAnswers() + public function testParseMessageResponseWithTwoAnswers() { $data = ""; $data .= "bc 73 81 80 00 01 00 02 00 00 00 00"; // header @@ -462,9 +390,9 @@ $response = $this->parser->parseMessage($data); $this->assertCount(1, $response->questions); - $this->assertSame('io.whois-servers.net', $response->questions[0]['name']); - $this->assertSame(Message::TYPE_A, $response->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $response->questions[0]['class']); + $this->assertSame('io.whois-servers.net', $response->questions[0]->name); + $this->assertSame(Message::TYPE_A, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); $this->assertCount(2, $response->answers); @@ -481,6 +409,95 @@ $this->assertSame('193.223.78.152', $response->answers[1]->data); } + public function testParseMessageResponseWithTwoAuthorityRecords() + { + $data = ""; + $data .= "bc 73 81 80 00 01 00 00 00 02 00 00"; // header + $data .= "02 69 6f 0d 77 68 6f 69 73 2d 73 65 72 76 65 72 73 03 6e 65 74 00"; + // question: io.whois-servers.net + $data .= "00 01 00 01"; // question: type A, class IN + $data .= "c0 0c"; // authority: offset pointer to io.whois-servers.net + $data .= "00 05 00 01"; // authority: type CNAME, class IN + $data .= "00 00 00 29"; // authority: ttl 41 + $data .= "00 0e"; // authority: rdlength 14 + $data .= "05 77 68 6f 69 73 03 6e 69 63 02 69 6f 00"; // authority: rdata whois.nic.io + $data .= "c0 32"; // authority: offset pointer to whois.nic.io + $data .= "00 01 00 01"; // authority: type CNAME, class IN + $data .= "00 00 0d f7"; // authority: ttl 3575 + $data .= "00 04"; // authority: rdlength 4 + $data .= "c1 df 4e 98"; // authority: rdata 193.223.78.152 + + $data = $this->convertTcpDumpToBinary($data); + + $response = $this->parser->parseMessage($data); + + $this->assertCount(1, $response->questions); + $this->assertSame('io.whois-servers.net', $response->questions[0]->name); + $this->assertSame(Message::TYPE_A, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); + + $this->assertCount(0, $response->answers); + + $this->assertCount(2, $response->authority); + + $this->assertSame('io.whois-servers.net', $response->authority[0]->name); + $this->assertSame(Message::TYPE_CNAME, $response->authority[0]->type); + $this->assertSame(Message::CLASS_IN, $response->authority[0]->class); + $this->assertSame(41, $response->authority[0]->ttl); + $this->assertSame('whois.nic.io', $response->authority[0]->data); + + $this->assertSame('whois.nic.io', $response->authority[1]->name); + $this->assertSame(Message::TYPE_A, $response->authority[1]->type); + $this->assertSame(Message::CLASS_IN, $response->authority[1]->class); + $this->assertSame(3575, $response->authority[1]->ttl); + $this->assertSame('193.223.78.152', $response->authority[1]->data); + } + + public function testParseMessageResponseWithAnswerAndAdditionalRecord() + { + $data = ""; + $data .= "bc 73 81 80 00 01 00 01 00 00 00 01"; // header + $data .= "02 69 6f 0d 77 68 6f 69 73 2d 73 65 72 76 65 72 73 03 6e 65 74 00"; + // question: io.whois-servers.net + $data .= "00 01 00 01"; // question: type A, class IN + $data .= "c0 0c"; // answer: offset pointer to io.whois-servers.net + $data .= "00 05 00 01"; // answer: type CNAME, class IN + $data .= "00 00 00 29"; // answer: ttl 41 + $data .= "00 0e"; // answer: rdlength 14 + $data .= "05 77 68 6f 69 73 03 6e 69 63 02 69 6f 00"; // answer: rdata whois.nic.io + $data .= "c0 32"; // additional: offset pointer to whois.nic.io + $data .= "00 01 00 01"; // additional: type CNAME, class IN + $data .= "00 00 0d f7"; // additional: ttl 3575 + $data .= "00 04"; // additional: rdlength 4 + $data .= "c1 df 4e 98"; // additional: rdata 193.223.78.152 + + $data = $this->convertTcpDumpToBinary($data); + + $response = $this->parser->parseMessage($data); + + $this->assertCount(1, $response->questions); + $this->assertSame('io.whois-servers.net', $response->questions[0]->name); + $this->assertSame(Message::TYPE_A, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); + + $this->assertCount(1, $response->answers); + + $this->assertSame('io.whois-servers.net', $response->answers[0]->name); + $this->assertSame(Message::TYPE_CNAME, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(41, $response->answers[0]->ttl); + $this->assertSame('whois.nic.io', $response->answers[0]->data); + + $this->assertCount(0, $response->authority); + $this->assertCount(1, $response->additional); + + $this->assertSame('whois.nic.io', $response->additional[0]->name); + $this->assertSame(Message::TYPE_A, $response->additional[0]->type); + $this->assertSame(Message::CLASS_IN, $response->additional[0]->class); + $this->assertSame(3575, $response->additional[0]->ttl); + $this->assertSame('193.223.78.152', $response->additional[0]->data); + } + public function testParseNSResponse() { $data = ""; @@ -490,13 +507,7 @@ $data .= "00 07"; // answer: rdlength 7 $data .= "05 68 65 6c 6c 6f 00"; // answer: rdata hello - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -504,6 +515,25 @@ $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); $this->assertSame(86400, $response->answers[0]->ttl); $this->assertSame('hello', $response->answers[0]->data); + } + + public function testParseSSHFPResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 2c 00 01"; // answer: type SSHFP, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 06"; // answer: rdlength 6 + $data .= "01 01 69 ac 09 0c"; // answer: algorithm 1 (RSA), type 1 (SHA-1), fingerprint "69ac090c" + + $response = $this->parseAnswer($data); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_SSHFP, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame(array('algorithm' => 1, 'type' => 1, 'fingerprint' => '69ac090c'), $response->answers[0]->data); } public function testParseSOAResponse() @@ -518,13 +548,7 @@ $data .= "78 49 28 D5 00 00 2a 30 00 00 0e 10"; // answer: rdata 2018060501, 10800, 3600 $data .= "00 09 3a 80 00 00 0e 10"; // answer: 605800, 3600 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); + $response = $this->parseAnswer($data); $this->assertCount(1, $response->answers); $this->assertSame('igor.io', $response->answers[0]->name); @@ -545,6 +569,26 @@ ); } + public function testParseCAAResponse() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "01 01 00 01"; // answer: type CAA, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 16"; // answer: rdlength 22 + $data .= "00 05 69 73 73 75 65"; // answer: rdata 0, issue + $data .= "6c 65 74 73 65 6e 63 72 79 70 74 2e 6f 72 67"; // answer: letsencrypt.org + + $response = $this->parseAnswer($data); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_CAA, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86400, $response->answers[0]->ttl); + $this->assertSame(array('flag' => 0, 'tag' => 'issue', 'value' => 'letsencrypt.org'), $response->answers[0]->data); + } + public function testParsePTRResponse() { $data = ""; @@ -563,25 +607,19 @@ $response = $this->parser->parseMessage($data); - $header = $response->header; - $this->assertSame(0x5dd8, $header->get('id')); - $this->assertSame(1, $header->get('qdCount')); - $this->assertSame(1, $header->get('anCount')); - $this->assertSame(0, $header->get('nsCount')); - $this->assertSame(0, $header->get('arCount')); - $this->assertSame(1, $header->get('qr')); - $this->assertSame(Message::OPCODE_QUERY, $header->get('opcode')); - $this->assertSame(0, $header->get('aa')); - $this->assertSame(0, $header->get('tc')); - $this->assertSame(1, $header->get('rd')); - $this->assertSame(1, $header->get('ra')); - $this->assertSame(0, $header->get('z')); - $this->assertSame(Message::RCODE_OK, $header->get('rcode')); + $this->assertSame(0x5dd8, $response->id); + $this->assertSame(true, $response->qr); + $this->assertSame(Message::OPCODE_QUERY, $response->opcode); + $this->assertSame(false, $response->aa); + $this->assertSame(false, $response->tc); + $this->assertSame(true, $response->rd); + $this->assertSame(true, $response->ra); + $this->assertSame(Message::RCODE_OK, $response->rcode); $this->assertCount(1, $response->questions); - $this->assertSame('4.4.8.8.in-addr.arpa', $response->questions[0]['name']); - $this->assertSame(Message::TYPE_PTR, $response->questions[0]['type']); - $this->assertSame(Message::CLASS_IN, $response->questions[0]['class']); + $this->assertSame('4.4.8.8.in-addr.arpa', $response->questions[0]->name); + $this->assertSame(Message::TYPE_PTR, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); $this->assertCount(1, $response->answers); $this->assertSame('4.4.8.8.in-addr.arpa', $response->answers[0]->name); @@ -589,6 +627,36 @@ $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); $this->assertSame(86399, $response->answers[0]->ttl); $this->assertSame('google-public-dns-b.google.com', $response->answers[0]->data); + } + + public function testParsePTRResponseWithSpecialCharactersEscaped() + { + $data = ""; + $data .= "5d d8 81 80 00 01 00 01 00 00 00 00"; // header + $data .= "08 5f 70 72 69 6e 74 65 72 04 5f 74 63 70 06 64 6e 73 2d 73 64 03 6f 72 67 00"; // question: _printer._tcp.dns-sd.org + $data .= "00 0c 00 01"; // question: type PTR, class IN + $data .= "c0 0c"; // answer: offset pointer to rdata + $data .= "00 0c 00 01"; // answer: type PTR, class IN + $data .= "00 01 51 7f"; // answer: ttl 86399 + $data .= "00 17"; // answer: rdlength 23 + $data .= "14 33 72 64 2e 20 46 6c 6f 6f 72 20 43 6f 70 79 20 52 6f 6f 6d"; // answer: rdata "3rd. Floor Copy Room" … + $data .= "c0 0c"; // answer: offset pointer to rdata + + $data = $this->convertTcpDumpToBinary($data); + + $response = $this->parser->parseMessage($data); + + $this->assertCount(1, $response->questions); + $this->assertSame('_printer._tcp.dns-sd.org', $response->questions[0]->name); + $this->assertSame(Message::TYPE_PTR, $response->questions[0]->type); + $this->assertSame(Message::CLASS_IN, $response->questions[0]->class); + + $this->assertCount(1, $response->answers); + $this->assertSame('_printer._tcp.dns-sd.org', $response->answers[0]->name); + $this->assertSame(Message::TYPE_PTR, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(86399, $response->answers[0]->ttl); + $this->assertSame('3rd\.\ Floor\ Copy\ Room._printer._tcp.dns-sd.org', $response->answers[0]->data); } /** @@ -700,6 +768,38 @@ $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io $data .= "00 01 00 01"; // question: type A, class IN $data .= "c0 0c"; // answer: offset pointer to igor.io + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseMessageResponseWithIncompleteAuthorityRecordThrows() + { + $data = ""; + $data .= "72 62 81 80 00 01 00 00 00 01 00 00"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 01 00 01"; // question: type A, class IN + $data .= "c0 0c"; // authority: offset pointer to igor.io + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseMessageResponseWithIncompleteAdditionalRecordThrows() + { + $data = ""; + $data .= "72 62 81 80 00 01 00 00 00 00 00 01"; // header + $data .= "04 69 67 6f 72 02 69 6f 00"; // question: igor.io + $data .= "00 01 00 01"; // question: type A, class IN + $data .= "c0 0c"; // additional: offset pointer to igor.io $data = $this->convertTcpDumpToBinary($data); @@ -725,6 +825,9 @@ $this->parser->parseMessage($data); } + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidNSResponseWhereDomainNameIsMissing() { $data = ""; @@ -733,17 +836,12 @@ $data .= "00 01 51 80"; // answer: ttl 86400 $data .= "00 00"; // answer: rdlength 0 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidAResponseWhereIPIsMissing() { $data = ""; @@ -752,17 +850,12 @@ $data .= "00 01 51 80"; // answer: ttl 86400 $data .= "00 00"; // answer: rdlength 0 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidAAAAResponseWhereIPIsMissing() { $data = ""; @@ -771,17 +864,12 @@ $data .= "00 01 51 80"; // answer: ttl 86400 $data .= "00 00"; // answer: rdlength 0 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidTXTResponseWhereTxtChunkExceedsLimit() { $data = ""; @@ -791,17 +879,12 @@ $data .= "00 06"; // answer: rdlength 6 $data .= "06 68 65 6c 6c 6f 6f"; // answer: rdata length 6: helloo - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidMXResponseWhereDomainNameIsIncomplete() { $data = ""; @@ -811,17 +894,12 @@ $data .= "00 08"; // answer: rdlength 8 $data .= "00 0a 05 68 65 6c 6c 6f"; // answer: rdata priority 10: hello (missing label end) - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidMXResponseWhereDomainNameIsMissing() { $data = ""; @@ -831,17 +909,12 @@ $data .= "00 02"; // answer: rdlength 2 $data .= "00 0a"; // answer: rdata priority 10 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidSRVResponseWhereDomainNameIsIncomplete() { $data = ""; @@ -851,17 +924,12 @@ $data .= "00 0b"; // answer: rdlength 11 $data .= "00 0a 00 14 1F 90 04 74 65 73 74"; // answer: rdata priority 10, weight 20, port 8080 test (missing label end) - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidSRVResponseWhereDomainNameIsMissing() { $data = ""; @@ -871,17 +939,27 @@ $data .= "00 06"; // answer: rdlength 6 $data .= "00 0a 00 14 1F 90"; // answer: rdata priority 10, weight 20, port 8080 - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); - } - + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidSSHFPResponseWhereRecordIsTooSmall() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 2c 00 01"; // answer: type SSHFP, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 02"; // answer: rdlength 2 + $data .= "01 01"; // answer: algorithm 1 (RSA), type 1 (SHA), missing fingerprint + + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ public function testParseInvalidSOAResponseWhereFlagsAreMissing() { $data = ""; @@ -892,15 +970,52 @@ $data .= "02 6e 73 05 68 65 6c 6c 6f 00"; // answer: rdata ns.hello (mname) $data .= "01 65 05 68 65 6c 6c 6f 00"; // answer: rdata e.hello (rname) - $data = $this->convertTcpDumpToBinary($data); - - $response = new Message(); - $response->header->set('anCount', 1); - $response->data = $data; - - $this->parser->parseAnswer($response); - - $this->assertCount(0, $response->answers); + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidCAAResponseEmtpyData() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "01 01 00 01"; // answer: type CAA, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 00"; // answer: rdlength 0 + + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidCAAResponseMissingValue() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "01 01 00 01"; // answer: type CAA, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 07"; // answer: rdlength 22 + $data .= "00 05 69 73 73 75 65"; // answer: rdata 0, issue + + $this->parseAnswer($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidCAAResponseIncompleteTag() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "01 01 00 01"; // answer: type CAA, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 0c"; // answer: rdlength 22 + $data .= "00 ff 69 73 73 75 65"; // answer: rdata 0, issue (incomplete due to invalid tag length) + $data .= "68 65 6c 6c 6f"; // answer: hello + + $this->parseAnswer($data); } private function convertTcpDumpToBinary($input) @@ -909,4 +1024,14 @@ return pack('H*', str_replace(' ', '', $input)); } + + private function parseAnswer($answerData) + { + $data = "72 62 81 80 00 00 00 01 00 00 00 00"; // header with one answer only + $data .= $answerData; + + $data = $this->convertTcpDumpToBinary($data); + + return $this->parser->parseMessage($data); + } } diff --git a/tests/Query/CachedExecutorTest.php b/tests/Query/CachedExecutorTest.php deleted file mode 100644 index d08ed05..0000000 --- a/tests/Query/CachedExecutorTest.php +++ /dev/null @@ -1,100 +0,0 @@ -createExecutorMock(); - $executor - ->expects($this->once()) - ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnValue($this->createPromiseMock())); - - $cache = $this->getMockBuilder('React\Dns\Query\RecordCache') - ->disableOriginalConstructor() - ->getMock(); - $cache - ->expects($this->once()) - ->method('lookup') - ->will($this->returnValue(Promise\reject())); - $cachedExecutor = new CachedExecutor($executor, $cache); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $cachedExecutor->query('8.8.8.8', $query); - } - - /** - * @covers React\Dns\Query\CachedExecutor - * @test - */ - public function callingQueryTwiceShouldUseCachedResult() - { - $cachedRecords = array(new Record('igor.io', Message::TYPE_A, Message::CLASS_IN)); - - $executor = $this->createExecutorMock(); - $executor - ->expects($this->once()) - ->method('query') - ->will($this->callQueryCallbackWithAddress('178.79.169.131')); - - $cache = $this->getMockBuilder('React\Dns\Query\RecordCache') - ->disableOriginalConstructor() - ->getMock(); - $cache - ->expects($this->at(0)) - ->method('lookup') - ->with($this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnValue(Promise\reject())); - $cache - ->expects($this->at(1)) - ->method('storeResponseMessage') - ->with($this->isType('integer'), $this->isInstanceOf('React\Dns\Model\Message')); - $cache - ->expects($this->at(2)) - ->method('lookup') - ->with($this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnValue(Promise\resolve($cachedRecords))); - - $cachedExecutor = new CachedExecutor($executor, $cache); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $cachedExecutor->query('8.8.8.8', $query, function () {}, function () {}); - $cachedExecutor->query('8.8.8.8', $query, function () {}, function () {}); - } - - private function callQueryCallbackWithAddress($address) - { - return $this->returnCallback(function ($nameserver, $query) use ($address) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); - $response->answers[] = new Record($query->name, $query->type, $query->class, 3600, $address); - - return Promise\resolve($response); - }); - } - - private function createExecutorMock() - { - return $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); - } - - private function createPromiseMock() - { - return $this->getMockBuilder('React\Promise\PromiseInterface')->getMock(); - } -} diff --git a/tests/Query/CachingExecutorTest.php b/tests/Query/CachingExecutorTest.php new file mode 100644 index 0000000..b0aa4c2 --- /dev/null +++ b/tests/Query/CachingExecutorTest.php @@ -0,0 +1,183 @@ +getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->never())->method('query'); + + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn(new Promise(function () { })); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testQueryWillReturnPendingPromiseWhenCacheReturnsMissAndWillSendSameQueryToFallbackExecutor() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->once())->method('query')->with($query)->willReturn(new Promise(function () { })); + + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->willReturn(\React\Promise\resolve(null)); + + $executor = new CachingExecutor($fallback, $cache); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testQueryWillReturnResolvedPromiseWhenCacheReturnsHitWithoutSendingQueryToFallbackExecutor() + { + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->never())->method('query'); + + $message = new Message(); + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn(\React\Promise\resolve($message)); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableOnceWith($message), $this->expectCallableNever()); + } + + public function testQueryWillReturnResolvedPromiseWhenCacheReturnsMissAndFallbackExecutorResolvesAndSaveMessageToCacheWithMinimumTtlFromRecord() + { + $message = new Message(); + $message->answers[] = new Record('reactphp.org', Message::TYPE_A, Message::CLASS_IN, 3700, '127.0.0.1'); + $message->answers[] = new Record('reactphp.org', Message::TYPE_A, Message::CLASS_IN, 3600, '127.0.0.1'); + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->once())->method('query')->willReturn(\React\Promise\resolve($message)); + + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn(\React\Promise\resolve(null)); + $cache->expects($this->once())->method('set')->with('reactphp.org:1:1', $message, 3600); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableOnceWith($message), $this->expectCallableNever()); + } + + public function testQueryWillReturnResolvedPromiseWhenCacheReturnsMissAndFallbackExecutorResolvesAndSaveMessageToCacheWithDefaultTtl() + { + $message = new Message(); + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->once())->method('query')->willReturn(\React\Promise\resolve($message)); + + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn(\React\Promise\resolve(null)); + $cache->expects($this->once())->method('set')->with('reactphp.org:1:1', $message, 60); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableOnceWith($message), $this->expectCallableNever()); + } + + public function testQueryWillReturnResolvedPromiseWhenCacheReturnsMissAndFallbackExecutorResolvesWithTruncatedResponseButShouldNotSaveTruncatedMessageToCache() + { + $message = new Message(); + $message->tc = true; + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->once())->method('query')->willReturn(\React\Promise\resolve($message)); + + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn(\React\Promise\resolve(null)); + $cache->expects($this->never())->method('set'); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableOnceWith($message), $this->expectCallableNever()); + } + + public function testQueryWillReturnRejectedPromiseWhenCacheReturnsMissAndFallbackExecutorRejects() + { + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->once())->method('query')->willReturn(\React\Promise\reject(new \RuntimeException())); + + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->willReturn(\React\Promise\resolve(null)); + + $executor = new CachingExecutor($fallback, $cache); + + $promise = $executor->query($query); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + } + + public function testCancelQueryWillReturnRejectedPromiseAndCancelPendingPromiseFromCache() + { + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->never())->method('query'); + + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn($pending); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + } + + public function testCancelQueryWillReturnRejectedPromiseAndCancelPendingPromiseFromFallbackExecutorWhenCacheReturnsMiss() + { + $pending = new Promise(function () { }, $this->expectCallableOnce()); + $fallback = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $fallback->expects($this->once())->method('query')->willReturn($pending); + + $deferred = new Deferred(); + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $cache->expects($this->once())->method('get')->with('reactphp.org:1:1')->willReturn($deferred->promise()); + + $executor = new CachingExecutor($fallback, $cache); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + $deferred->resolve(null); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + } +} diff --git a/tests/Query/CoopExecutorTest.php b/tests/Query/CoopExecutorTest.php new file mode 100644 index 0000000..3e5074e --- /dev/null +++ b/tests/Query/CoopExecutorTest.php @@ -0,0 +1,233 @@ +getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->with($query)->willReturn($pending); + $connector = new CoopExecutor($base); + + $connector->query($query); + } + + public function testQueryOnceWillResolveWhenBaseExecutorResolves() + { + $message = new Message(); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn(\React\Promise\resolve($message)); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $promise = $connector->query($query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + + $promise->then($this->expectCallableOnceWith($message)); + } + + public function testQueryOnceWillRejectWhenBaseExecutorRejects() + { + $exception = new RuntimeException(); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn(\React\Promise\reject($exception)); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $promise = $connector->query($query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + + $promise->then(null, $this->expectCallableOnceWith($exception)); + } + + public function testQueryTwoDifferentQueriesWillPassExactQueryToBaseExecutorTwice() + { + $pending = new Promise(function () { }); + $query1 = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $query2 = new Query('reactphp.org', Message::TYPE_AAAA, Message::CLASS_IN); + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->exactly(2))->method('query')->withConsecutive( + array($query1), + array($query2) + )->willReturn($pending); + $connector = new CoopExecutor($base); + + $connector->query($query1); + $connector->query($query2); + } + + public function testQueryTwiceWillPassExactQueryToBaseExecutorOnceWhenQueryIsStillPending() + { + $pending = new Promise(function () { }); + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->with($query)->willReturn($pending); + $connector = new CoopExecutor($base); + + $connector->query($query); + $connector->query($query); + } + + public function testQueryTwiceWillPassExactQueryToBaseExecutorTwiceWhenFirstQueryIsAlreadyResolved() + { + $deferred = new Deferred(); + $pending = new Promise(function () { }); + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->exactly(2))->method('query')->with($query)->willReturnOnConsecutiveCalls($deferred->promise(), $pending); + + $connector = new CoopExecutor($base); + + $connector->query($query); + + $deferred->resolve(new Message()); + + $connector->query($query); + } + + public function testQueryTwiceWillPassExactQueryToBaseExecutorTwiceWhenFirstQueryIsAlreadyRejected() + { + $deferred = new Deferred(); + $pending = new Promise(function () { }); + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->exactly(2))->method('query')->with($query)->willReturnOnConsecutiveCalls($deferred->promise(), $pending); + + $connector = new CoopExecutor($base); + + $connector->query($query); + + $deferred->reject(new RuntimeException()); + + $connector->query($query); + } + + public function testCancelQueryWillCancelPromiseFromBaseExecutorAndReject() + { + $promise = new Promise(function () { }, $this->expectCallableOnce()); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn($promise); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $promise = $connector->query($query); + + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testCancelOneQueryWhenOtherQueryIsStillPendingWillNotCancelPromiseFromBaseExecutorAndRejectCancelled() + { + $promise = new Promise(function () { }, $this->expectCallableNever()); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn($promise); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $promise1 = $connector->query($query); + $promise2 = $connector->query($query); + + $promise1->cancel(); + + $promise1->then(null, $this->expectCallableOnce()); + $promise2->then(null, $this->expectCallableNever()); + } + + public function testCancelSecondQueryWhenFirstQueryIsStillPendingWillNotCancelPromiseFromBaseExecutorAndRejectCancelled() + { + $promise = new Promise(function () { }, $this->expectCallableNever()); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn($promise); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $promise1 = $connector->query($query); + $promise2 = $connector->query($query); + + $promise2->cancel(); + + $promise2->then(null, $this->expectCallableOnce()); + $promise1->then(null, $this->expectCallableNever()); + } + + public function testCancelAllPendingQueriesWillCancelPromiseFromBaseExecutorAndRejectCancelled() + { + $promise = new Promise(function () { }, $this->expectCallableOnce()); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn($promise); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + $promise1 = $connector->query($query); + $promise2 = $connector->query($query); + + $promise1->cancel(); + $promise2->cancel(); + + $promise1->then(null, $this->expectCallableOnce()); + $promise2->then(null, $this->expectCallableOnce()); + } + + public function testQueryTwiceWillQueryBaseExecutorTwiceIfFirstQueryHasAlreadyBeenCancelledWhenSecondIsStarted() + { + $promise = new Promise(function () { }, $this->expectCallableOnce()); + $pending = new Promise(function () { }); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->exactly(2))->method('query')->willReturnOnConsecutiveCalls($promise, $pending); + $connector = new CoopExecutor($base); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise1 = $connector->query($query); + $promise1->cancel(); + + $promise2 = $connector->query($query); + + $promise1->then(null, $this->expectCallableOnce()); + + $promise2->then(null, $this->expectCallableNever()); + } + + public function testCancelQueryShouldNotCauseGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $deferred = new Deferred(function () { + throw new \RuntimeException(); + }); + + $base = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $base->expects($this->once())->method('query')->willReturn($deferred->promise()); + $connector = new CoopExecutor($base); + + gc_collect_cycles(); + + $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); + + $promise = $connector->query($query); + $promise->cancel(); + $promise = null; + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/Query/ExecutorTest.php b/tests/Query/ExecutorTest.php deleted file mode 100644 index 0d7ac1d..0000000 --- a/tests/Query/ExecutorTest.php +++ /dev/null @@ -1,308 +0,0 @@ -loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - $this->parser = $this->getMockBuilder('React\Dns\Protocol\Parser')->getMock(); - $this->dumper = new BinaryDumper(); - - $this->executor = new Executor($this->loop, $this->parser, $this->dumper); - } - - /** @test */ - public function queryShouldCreateUdpRequest() - { - $timer = $this->createTimerMock(); - $this->loop - ->expects($this->any()) - ->method('addTimer') - ->will($this->returnValue($timer)); - - $this->executor = $this->createExecutorMock(); - $this->executor - ->expects($this->once()) - ->method('createConnection') - ->with('8.8.8.8:53', 'udp') - ->will($this->returnNewConnectionMock(false)); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $this->executor->query('8.8.8.8:53', $query); - } - - /** @test */ - public function resolveShouldRejectIfRequestIsLargerThan512Bytes() - { - $query = new Query(str_repeat('a', 512).'.igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); - - $this->setExpectedException('RuntimeException', 'DNS query for ' . $query->name . ' failed: Requested transport "tcp" not available, only UDP is supported in this version'); - Block\await($promise, $this->loop); - } - - /** @test */ - public function resolveShouldCloseConnectionWhenCancelled() - { - $conn = $this->createConnectionMock(false); - $conn->expects($this->once())->method('close'); - - $timer = $this->createTimerMock(); - $this->loop - ->expects($this->any()) - ->method('addTimer') - ->will($this->returnValue($timer)); - - $this->executor = $this->createExecutorMock(); - $this->executor - ->expects($this->once()) - ->method('createConnection') - ->with('8.8.8.8:53', 'udp') - ->will($this->returnValue($conn)); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); - - $promise->cancel(); - - $this->setExpectedException('React\Dns\Query\CancellationException', 'DNS query for igor.io has been cancelled'); - Block\await($promise, $this->loop); - } - - /** @test */ - public function resolveShouldNotStartOrCancelTimerWhenCancelledWithTimeoutIsNull() - { - $this->loop - ->expects($this->never()) - ->method('addTimer'); - - $this->executor = new Executor($this->loop, $this->parser, $this->dumper, null); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('127.0.0.1:53', $query); - - $promise->cancel(); - - $this->setExpectedException('React\Dns\Query\CancellationException', 'DNS query for igor.io has been cancelled'); - Block\await($promise, $this->loop); - } - - /** @test */ - public function resolveShouldRejectIfResponseIsTruncated() - { - $timer = $this->createTimerMock(); - - $this->loop - ->expects($this->any()) - ->method('addTimer') - ->will($this->returnValue($timer)); - - $this->parser - ->expects($this->once()) - ->method('parseMessage') - ->will($this->returnTruncatedResponse()); - - $this->executor = $this->createExecutorMock(); - $this->executor - ->expects($this->once()) - ->method('createConnection') - ->with('8.8.8.8:53', 'udp') - ->will($this->returnNewConnectionMock()); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $this->executor->query('8.8.8.8:53', $query); - } - - /** @test */ - public function resolveShouldFailIfUdpThrow() - { - $this->loop - ->expects($this->never()) - ->method('addTimer'); - - $this->parser - ->expects($this->never()) - ->method('parseMessage'); - - $this->executor = $this->createExecutorMock(); - $this->executor - ->expects($this->once()) - ->method('createConnection') - ->with('8.8.8.8:53', 'udp') - ->will($this->throwException(new \Exception('Nope'))); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); - - $this->setExpectedException('RuntimeException', 'DNS query for igor.io failed: Nope'); - Block\await($promise, $this->loop); - } - - /** @test */ - public function resolveShouldCancelTimerWhenFullResponseIsReceived() - { - $conn = $this->createConnectionMock(); - - $this->parser - ->expects($this->once()) - ->method('parseMessage') - ->will($this->returnStandardResponse()); - - $this->executor = $this->createExecutorMock(); - $this->executor - ->expects($this->at(0)) - ->method('createConnection') - ->with('8.8.8.8:53', 'udp') - ->will($this->returnNewConnectionMock()); - - - $timer = $this->createTimerMock(); - - $this->loop - ->expects($this->once()) - ->method('addTimer') - ->with(5, $this->isInstanceOf('Closure')) - ->will($this->returnValue($timer)); - - $this->loop - ->expects($this->once()) - ->method('cancelTimer') - ->with($timer); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $this->executor->query('8.8.8.8:53', $query); - } - - /** @test */ - public function resolveShouldCloseConnectionOnTimeout() - { - $this->executor = $this->createExecutorMock(); - $this->executor - ->expects($this->at(0)) - ->method('createConnection') - ->with('8.8.8.8:53', 'udp') - ->will($this->returnNewConnectionMock(false)); - - $timer = $this->createTimerMock(); - - $this->loop - ->expects($this->never()) - ->method('cancelTimer'); - - $this->loop - ->expects($this->once()) - ->method('addTimer') - ->with(5, $this->isInstanceOf('Closure')) - ->will($this->returnCallback(function ($time, $callback) use (&$timerCallback, $timer) { - $timerCallback = $callback; - return $timer; - })); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); - - $this->assertNotNull($timerCallback); - $timerCallback(); - - $this->setExpectedException('React\Dns\Query\TimeoutException', 'DNS query for igor.io timed out'); - Block\await($promise, $this->loop); - } - - private function returnStandardResponse() - { - $that = $this; - $callback = function ($data) use ($that) { - $response = new Message(); - $that->convertMessageToStandardResponse($response); - return $response; - }; - - return $this->returnCallback($callback); - } - - private function returnTruncatedResponse() - { - $that = $this; - $callback = function ($data) use ($that) { - $response = new Message(); - $that->convertMessageToTruncatedResponse($response); - return $response; - }; - - return $this->returnCallback($callback); - } - - public function convertMessageToStandardResponse(Message $response) - { - $response->header->set('qr', 1); - $response->questions[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN); - $response->answers[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131'); - $response->prepare(); - - return $response; - } - - public function convertMessageToTruncatedResponse(Message $response) - { - $this->convertMessageToStandardResponse($response); - $response->header->set('tc', 1); - $response->prepare(); - - return $response; - } - - private function returnNewConnectionMock($emitData = true) - { - $conn = $this->createConnectionMock($emitData); - - $callback = function () use ($conn) { - return $conn; - }; - - return $this->returnCallback($callback); - } - - private function createConnectionMock($emitData = true) - { - $conn = $this->getMockBuilder('React\Stream\DuplexStreamInterface')->getMock(); - $conn - ->expects($this->any()) - ->method('on') - ->with('data', $this->isInstanceOf('Closure')) - ->will($this->returnCallback(function ($name, $callback) use ($emitData) { - $emitData && $callback(null); - })); - - return $conn; - } - - private function createTimerMock() - { - return $this->getMockBuilder( - interface_exists('React\EventLoop\TimerInterface') ? 'React\EventLoop\TimerInterface' : 'React\EventLoop\Timer\TimerInterface' - )->getMock(); - } - - private function createExecutorMock() - { - return $this->getMockBuilder('React\Dns\Query\Executor') - ->setConstructorArgs(array($this->loop, $this->parser, $this->dumper)) - ->setMethods(array('createConnection')) - ->getMock(); - } -} diff --git a/tests/Query/HostsFileExecutorTest.php b/tests/Query/HostsFileExecutorTest.php index 70d877e..455bafe 100644 --- a/tests/Query/HostsFileExecutorTest.php +++ b/tests/Query/HostsFileExecutorTest.php @@ -25,7 +25,7 @@ $this->hosts->expects($this->never())->method('getIpsForHost'); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_MX, Message::CLASS_IN, 0)); + $this->executor->query(new Query('google.com', Message::TYPE_MX, Message::CLASS_IN)); } public function testFallsBackIfNoIpsWereFound() @@ -33,7 +33,7 @@ $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array()); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_A, Message::CLASS_IN, 0)); + $this->executor->query(new Query('google.com', Message::TYPE_A, Message::CLASS_IN)); } public function testReturnsResponseMessageIfIpsWereFound() @@ -41,7 +41,7 @@ $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('127.0.0.1')); $this->fallback->expects($this->never())->method('query'); - $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_A, Message::CLASS_IN, 0)); + $ret = $this->executor->query(new Query('google.com', Message::TYPE_A, Message::CLASS_IN)); } public function testFallsBackIfNoIpv4Matches() @@ -49,7 +49,7 @@ $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('::1')); $this->fallback->expects($this->once())->method('query'); - $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_A, Message::CLASS_IN, 0)); + $ret = $this->executor->query(new Query('google.com', Message::TYPE_A, Message::CLASS_IN)); } public function testReturnsResponseMessageIfIpv6AddressesWereFound() @@ -57,7 +57,7 @@ $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('::1')); $this->fallback->expects($this->never())->method('query'); - $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_AAAA, Message::CLASS_IN, 0)); + $ret = $this->executor->query(new Query('google.com', Message::TYPE_AAAA, Message::CLASS_IN)); } public function testFallsBackIfNoIpv6Matches() @@ -65,7 +65,7 @@ $this->hosts->expects($this->once())->method('getIpsForHost')->willReturn(array('127.0.0.1')); $this->fallback->expects($this->once())->method('query'); - $ret = $this->executor->query('8.8.8.8', new Query('google.com', Message::TYPE_AAAA, Message::CLASS_IN, 0)); + $ret = $this->executor->query(new Query('google.com', Message::TYPE_AAAA, Message::CLASS_IN)); } public function testDoesReturnReverseIpv4Lookup() @@ -73,7 +73,7 @@ $this->hosts->expects($this->once())->method('getHostsForIp')->with('127.0.0.1')->willReturn(array('localhost')); $this->fallback->expects($this->never())->method('query'); - $this->executor->query('8.8.8.8', new Query('1.0.0.127.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('1.0.0.127.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN)); } public function testFallsBackIfNoReverseIpv4Matches() @@ -81,7 +81,7 @@ $this->hosts->expects($this->once())->method('getHostsForIp')->with('127.0.0.1')->willReturn(array()); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('1.0.0.127.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('1.0.0.127.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN)); } public function testDoesReturnReverseIpv6Lookup() @@ -89,7 +89,7 @@ $this->hosts->expects($this->once())->method('getHostsForIp')->with('2a02:2e0:3fe:100::6')->willReturn(array('ip6-localhost')); $this->fallback->expects($this->never())->method('query'); - $this->executor->query('8.8.8.8', new Query('6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.e.f.3.0.0.e.2.0.2.0.a.2.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('6.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.e.f.3.0.0.e.2.0.2.0.a.2.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN)); } public function testFallsBackForInvalidAddress() @@ -97,7 +97,7 @@ $this->hosts->expects($this->never())->method('getHostsForIp'); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('example.com', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('example.com', Message::TYPE_PTR, Message::CLASS_IN)); } public function testReverseFallsBackForInvalidIpv4Address() @@ -105,7 +105,7 @@ $this->hosts->expects($this->never())->method('getHostsForIp'); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('::1.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('::1.in-addr.arpa', Message::TYPE_PTR, Message::CLASS_IN)); } public function testReverseFallsBackForInvalidLengthIpv6Address() @@ -113,7 +113,7 @@ $this->hosts->expects($this->never())->method('getHostsForIp'); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('abcd.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('abcd.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN)); } public function testReverseFallsBackForInvalidHexIpv6Address() @@ -121,6 +121,6 @@ $this->hosts->expects($this->never())->method('getHostsForIp'); $this->fallback->expects($this->once())->method('query'); - $this->executor->query('8.8.8.8', new Query('zZz.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN, 0)); + $this->executor->query(new Query('zZz.ip6.arpa', Message::TYPE_PTR, Message::CLASS_IN)); } } diff --git a/tests/Query/RecordBagTest.php b/tests/Query/RecordBagTest.php deleted file mode 100644 index c0615be..0000000 --- a/tests/Query/RecordBagTest.php +++ /dev/null @@ -1,83 +0,0 @@ -assertSame(array(), $recordBag->all()); - } - - /** - * @covers React\Dns\Query\RecordBag - * @test - */ - public function setShouldSetTheValue() - { - $currentTime = 1345656451; - - $recordBag = new RecordBag(); - $recordBag->set($currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600)); - - $records = $recordBag->all(); - $this->assertCount(1, $records); - $this->assertSame('igor.io', $records[0]->name); - $this->assertSame(Message::TYPE_A, $records[0]->type); - $this->assertSame(Message::CLASS_IN, $records[0]->class); - } - - /** - * @covers React\Dns\Query\RecordBag - * @test - */ - public function setShouldAcceptMxRecord() - { - $currentTime = 1345656451; - - $recordBag = new RecordBag(); - $recordBag->set($currentTime, new Record('igor.io', Message::TYPE_MX, Message::CLASS_IN, 3600, array('priority' => 10, 'target' => 'igor.io'))); - - $records = $recordBag->all(); - $this->assertCount(1, $records); - $this->assertSame('igor.io', $records[0]->name); - $this->assertSame(Message::TYPE_MX, $records[0]->type); - $this->assertSame(Message::CLASS_IN, $records[0]->class); - $this->assertSame(array('priority' => 10, 'target' => 'igor.io'), $records[0]->data); - } - - /** - * @covers React\Dns\Query\RecordBag - * @test - */ - public function setShouldSetManyValues() - { - $currentTime = 1345656451; - - $recordBag = new RecordBag(); - $recordBag->set($currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - $recordBag->set($currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.132')); - - $records = $recordBag->all(); - $this->assertCount(2, $records); - $this->assertSame('igor.io', $records[0]->name); - $this->assertSame(Message::TYPE_A, $records[0]->type); - $this->assertSame(Message::CLASS_IN, $records[0]->class); - $this->assertSame('178.79.169.131', $records[0]->data); - $this->assertSame('igor.io', $records[1]->name); - $this->assertSame(Message::TYPE_A, $records[1]->type); - $this->assertSame(Message::CLASS_IN, $records[1]->class); - $this->assertSame('178.79.169.132', $records[1]->data); - } -} diff --git a/tests/Query/RecordCacheTest.php b/tests/Query/RecordCacheTest.php deleted file mode 100644 index 263db83..0000000 --- a/tests/Query/RecordCacheTest.php +++ /dev/null @@ -1,193 +0,0 @@ -getMockBuilder('React\Cache\CacheInterface')->getMock(); - $base->expects($this->once())->method('get')->willReturn(\React\Promise\resolve(null)); - - $cache = new RecordCache($base); - $promise = $cache->lookup($query); - - $this->assertInstanceOf('React\Promise\RejectedPromise', $promise); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function lookupOnLegacyCacheMissShouldReturnNull() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - - $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); - $base->expects($this->once())->method('get')->willReturn(\React\Promise\reject()); - - $cache = new RecordCache($base); - $promise = $cache->lookup($query); - - $this->assertInstanceOf('React\Promise\RejectedPromise', $promise); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function storeRecordPendingCacheDoesNotSetCache() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $pending = new Promise(function () { }); - - $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); - $base->expects($this->once())->method('get')->willReturn($pending); - $base->expects($this->never())->method('set'); - - $cache = new RecordCache($base); - $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function storeRecordOnNewCacheMissSetsCache() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - - $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); - $base->expects($this->once())->method('get')->willReturn(\React\Promise\resolve(null)); - $base->expects($this->once())->method('set')->with($this->isType('string'), $this->isType('string')); - - $cache = new RecordCache($base); - $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function storeRecordOnOldCacheMissSetsCache() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - - $base = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); - $base->expects($this->once())->method('get')->willReturn(\React\Promise\reject()); - $base->expects($this->once())->method('set')->with($this->isType('string'), $this->isType('string')); - - $cache = new RecordCache($base); - $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function storeRecordShouldMakeLookupSucceed() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - - $cache = new RecordCache(new ArrayCache()); - $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - $promise = $cache->lookup($query); - - $this->assertInstanceOf('React\Promise\FulfilledPromise', $promise); - $cachedRecords = $this->getPromiseValue($promise); - - $this->assertCount(1, $cachedRecords); - $this->assertSame('178.79.169.131', $cachedRecords[0]->data); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function storeTwoRecordsShouldReturnBoth() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - - $cache = new RecordCache(new ArrayCache()); - $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - $cache->storeRecord($query->currentTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.132')); - $promise = $cache->lookup($query); - - $this->assertInstanceOf('React\Promise\FulfilledPromise', $promise); - $cachedRecords = $this->getPromiseValue($promise); - - $this->assertCount(2, $cachedRecords); - $this->assertSame('178.79.169.131', $cachedRecords[0]->data); - $this->assertSame('178.79.169.132', $cachedRecords[1]->data); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function storeResponseMessageShouldStoreAllAnswerValues() - { - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - - $response = new Message(); - $response->answers[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131'); - $response->answers[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.132'); - $response->prepare(); - - $cache = new RecordCache(new ArrayCache()); - $cache->storeResponseMessage($query->currentTime, $response); - $promise = $cache->lookup($query); - - $this->assertInstanceOf('React\Promise\FulfilledPromise', $promise); - $cachedRecords = $this->getPromiseValue($promise); - - $this->assertCount(2, $cachedRecords); - $this->assertSame('178.79.169.131', $cachedRecords[0]->data); - $this->assertSame('178.79.169.132', $cachedRecords[1]->data); - } - - /** - * @covers React\Dns\Query\RecordCache - * @test - */ - public function expireShouldExpireDeadRecords() - { - $cachedTime = 1345656451; - $currentTime = $cachedTime + 3605; - - $cache = new RecordCache(new ArrayCache()); - $cache->storeRecord($cachedTime, new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131')); - $cache->expire($currentTime); - - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, $currentTime); - $promise = $cache->lookup($query); - - $this->assertInstanceOf('React\Promise\RejectedPromise', $promise); - } - - private function getPromiseValue(PromiseInterface $promise) - { - $capturedValue = null; - - $promise->then(function ($value) use (&$capturedValue) { - $capturedValue = $value; - }); - - return $capturedValue; - } -} diff --git a/tests/Query/RetryExecutorTest.php b/tests/Query/RetryExecutorTest.php index 7e44a08..f15f64c 100644 --- a/tests/Query/RetryExecutorTest.php +++ b/tests/Query/RetryExecutorTest.php @@ -24,13 +24,13 @@ $executor ->expects($this->once()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->with($this->isInstanceOf('React\Dns\Query\Query')) ->will($this->returnValue($this->expectPromiseOnce())); $retryExecutor = new RetryExecutor($executor, 2); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query); } /** @@ -45,12 +45,12 @@ $executor ->expects($this->exactly(2)) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->with($this->isInstanceOf('React\Dns\Query\Query')) ->will($this->onConsecutiveCalls( - $this->returnCallback(function ($domain, $query) { + $this->returnCallback(function ($query) { return Promise\reject(new TimeoutException("timeout")); }), - $this->returnCallback(function ($domain, $query) use ($response) { + $this->returnCallback(function ($query) use ($response) { return Promise\resolve($response); }) )); @@ -65,8 +65,8 @@ $retryExecutor = new RetryExecutor($executor, 2); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query)->then($callback, $errorback); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query)->then($callback, $errorback); } /** @@ -79,8 +79,8 @@ $executor ->expects($this->exactly(3)) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($domain, $query) { + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { return Promise\reject(new TimeoutException("timeout")); })); @@ -94,8 +94,8 @@ $retryExecutor = new RetryExecutor($executor, 2); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query)->then($callback, $errorback); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query)->then($callback, $errorback); } /** @@ -108,8 +108,8 @@ $executor ->expects($this->once()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($domain, $query) { + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { return Promise\reject(new \Exception); })); @@ -123,8 +123,8 @@ $retryExecutor = new RetryExecutor($executor, 2); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query)->then($callback, $errorback); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query)->then($callback, $errorback); } /** @@ -139,8 +139,8 @@ $executor ->expects($this->once()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($domain, $query) use (&$cancelled) { + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) use (&$cancelled) { $deferred = new Deferred(function ($resolve, $reject) use (&$cancelled) { ++$cancelled; $reject(new CancellationException('Cancelled')); @@ -152,8 +152,8 @@ $retryExecutor = new RetryExecutor($executor, 2); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $retryExecutor->query($query); $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); @@ -175,10 +175,10 @@ $executor ->expects($this->exactly(2)) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->with($this->isInstanceOf('React\Dns\Query\Query')) ->will($this->onConsecutiveCalls( $this->returnValue($deferred->promise()), - $this->returnCallback(function ($domain, $query) use (&$cancelled) { + $this->returnCallback(function ($query) use (&$cancelled) { $deferred = new Deferred(function ($resolve, $reject) use (&$cancelled) { ++$cancelled; $reject(new CancellationException('Cancelled')); @@ -190,8 +190,8 @@ $retryExecutor = new RetryExecutor($executor, 2); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $retryExecutor->query($query); $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); @@ -217,14 +217,14 @@ $executor ->expects($this->once()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->with($this->isInstanceOf('React\Dns\Query\Query')) ->willReturn(Promise\resolve($this->createStandardResponse())); $retryExecutor = new RetryExecutor($executor, 0); gc_collect_cycles(); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query); $this->assertEquals(0, gc_collect_cycles()); } @@ -243,14 +243,14 @@ $executor ->expects($this->any()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->with($this->isInstanceOf('React\Dns\Query\Query')) ->willReturn(Promise\reject(new TimeoutException("timeout"))); $retryExecutor = new RetryExecutor($executor, 0); gc_collect_cycles(); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query); $this->assertEquals(0, gc_collect_cycles()); } @@ -273,14 +273,14 @@ $executor ->expects($this->once()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->with($this->isInstanceOf('React\Dns\Query\Query')) ->willReturn($deferred->promise()); $retryExecutor = new RetryExecutor($executor, 0); gc_collect_cycles(); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $retryExecutor->query($query); $promise->cancel(); $promise = null; @@ -301,16 +301,16 @@ $executor ->expects($this->once()) ->method('query') - ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($domain, $query) { + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { return Promise\reject(new \Exception); })); $retryExecutor = new RetryExecutor($executor, 2); gc_collect_cycles(); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $retryExecutor->query('8.8.8.8', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $retryExecutor->query($query); $this->assertEquals(0, gc_collect_cycles()); } @@ -339,10 +339,9 @@ protected function createStandardResponse() { $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN); + $response->qr = true; + $response->questions[] = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); $response->answers[] = new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131'); - $response->prepare(); return $response; } diff --git a/tests/Query/SelectiveTransportExecutorTest.php b/tests/Query/SelectiveTransportExecutorTest.php new file mode 100644 index 0000000..d9d22b2 --- /dev/null +++ b/tests/Query/SelectiveTransportExecutorTest.php @@ -0,0 +1,220 @@ +datagram = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + $this->stream = $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock(); + + $this->executor = new SelectiveTransportExecutor($this->datagram, $this->stream); + } + + public function testQueryResolvesWhenDatagramTransportResolvesWithoutUsingStreamTransport() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $response = new Message(); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\resolve($response)); + + $this->stream + ->expects($this->never()) + ->method('query'); + + $promise = $this->executor->query($query); + + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testQueryResolvesWhenStreamTransportResolvesAfterDatagramTransportRejectsWithSizeError() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $response = new Message(); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\reject(new \RuntimeException('', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90))); + + $this->stream + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\resolve($response)); + + $promise = $this->executor->query($query); + + $promise->then($this->expectCallableOnceWith($response)); + } + + public function testQueryRejectsWhenDatagramTransportRejectsWithRuntimeExceptionWithoutUsingStreamTransport() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\reject(new \RuntimeException())); + + $this->stream + ->expects($this->never()) + ->method('query'); + + $promise = $this->executor->query($query); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryRejectsWhenStreamTransportRejectsAfterDatagramTransportRejectsWithSizeError() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\reject(new \RuntimeException('', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90))); + + $this->stream + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\reject(new \RuntimeException())); + + $promise = $this->executor->query($query); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testCancelPromiseWillCancelPromiseFromDatagramExecutor() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(new Promise(function () {}, $this->expectCallableOnce())); + + $promise = $this->executor->query($query); + $promise->cancel(); + } + + public function testCancelPromiseWillCancelPromiseFromStreamExecutorWhenDatagramExecutorRejectedWithTruncatedResponse() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $deferred = new Deferred(); + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn($deferred->promise()); + + $this->stream + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(new Promise(function () {}, $this->expectCallableOnce())); + + $promise = $this->executor->query($query); + $deferred->reject(new \RuntimeException('', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90)); + $promise->cancel(); + } + + public function testCancelPromiseShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(new Promise(function () {}, function () { + throw new \RuntimeException('Cancelled'); + })); + + gc_collect_cycles(); + $promise = $this->executor->query($query); + $promise->cancel(); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCancelPromiseAfterTruncatedResponseShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $deferred = new Deferred(); + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn($deferred->promise()); + + $this->stream + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(new Promise(function () {}, function () { + throw new \RuntimeException('Cancelled'); + })); + + gc_collect_cycles(); + $promise = $this->executor->query($query); + $deferred->reject(new \RuntimeException('', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90)); + $promise->cancel(); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testRejectedPromiseAfterTruncatedResponseShouldNotCreateAnyGarbageReferences() + { + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + + $this->datagram + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\reject(new \RuntimeException('', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90))); + + $this->stream + ->expects($this->once()) + ->method('query') + ->with($query) + ->willReturn(\React\Promise\reject(new \RuntimeException())); + + gc_collect_cycles(); + $promise = $this->executor->query($query); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/Query/TcpTransportExecutorTest.php b/tests/Query/TcpTransportExecutorTest.php new file mode 100644 index 0000000..f15866d --- /dev/null +++ b/tests/Query/TcpTransportExecutorTest.php @@ -0,0 +1,811 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $executor = new TcpTransportExecutor($input, $loop); + + $ref = new \ReflectionProperty($executor, 'nameserver'); + $ref->setAccessible(true); + $value = $ref->getValue($executor); + + $this->assertEquals($expected, $value); + } + + public static function provideDefaultPortProvider() + { + return array( + array( + '8.8.8.8', + '8.8.8.8:53' + ), + array( + '1.2.3.4:5', + '1.2.3.4:5' + ), + array( + 'tcp://1.2.3.4', + '1.2.3.4:53' + ), + array( + 'tcp://1.2.3.4:53', + '1.2.3.4:53' + ), + array( + '::1', + '[::1]:53' + ), + array( + '[::1]:53', + '[::1]:53' + ) + ); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCtorShouldThrowWhenNameserverAddressIsInvalid() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + new TcpTransportExecutor('///', $loop); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCtorShouldThrowWhenNameserverAddressContainsHostname() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + new TcpTransportExecutor('localhost', $loop); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCtorShouldThrowWhenNameserverSchemeIsInvalid() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + new TcpTransportExecutor('udp://1.2.3.4', $loop); + } + + public function testQueryRejectsIfMessageExceedsMaximumMessageSize() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addWriteStream'); + + $executor = new TcpTransportExecutor('8.8.8.8:53', $loop); + + $query = new Query('google.' . str_repeat('.com', 60000), Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryRejectsIfServerConnectionFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addWriteStream'); + + $executor = new TcpTransportExecutor('::1', $loop); + + $ref = new \ReflectionProperty($executor, 'nameserver'); + $ref->setAccessible(true); + $ref->setValue($executor, '///'); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryRejectsOnCancellationWithoutClosingSocketButStartsIdleTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->never())->method('removeWriteStream'); + $loop->expects($this->never())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + $promise->cancel(); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testTriggerIdleTimerAfterQueryRejectedOnCancellationWillCloseSocket() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->never())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $timerCallback = null; + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->callback(function ($cb) use (&$timerCallback) { + $timerCallback = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + $promise->cancel(); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + $promise->then(null, $this->expectCallableOnce()); + + // trigger idle timer + $this->assertNotNull($timerCallback); + $timerCallback(); + } + + public function testQueryRejectsOnCancellationWithoutClosingSocketAndWithoutStartingIdleTimerWhenOtherQueryIsStillPending() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->never())->method('removeWriteStream'); + $loop->expects($this->never())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop->expects($this->never())->method('addTimer'); + $loop->expects($this->never())->method('cancelTimer'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise1 = $executor->query($query); + $promise2 = $executor->query($query); + $promise2->cancel(); + + $promise1->then($this->expectCallableNever(), $this->expectCallableNever()); + $promise2->then(null, $this->expectCallableOnce()); + } + + public function testQueryAgainAfterPreviousWasCancelledReusesExistingSocket() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->never())->method('removeWriteStream'); + $loop->expects($this->never())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + $promise->cancel(); + + $executor->query($query); + } + + public function testQueryRejectsWhenServerIsNotListening() + { + $loop = Factory::create(); + + $executor = new TcpTransportExecutor('127.0.0.1:1', $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.01, $loop); + if ($wait) { + \Clue\React\Block\sleep(0.2, $loop); + } + + $this->assertFalse($wait); + } + + public function testQueryStaysPendingWhenClientCanNotSendExcessiveMessageInOneChunkWhenServerClosesSocket() + { + $writableCallback = null; + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream')->with($this->anything(), $this->callback(function ($cb) use (&$writableCallback) { + $writableCallback = $cb; + return true; + })); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->never())->method('removeWriteStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google' . str_repeat('.com', 10000), Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $client = stream_socket_accept($server); + fclose($client); + + $executor->handleWritable(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + + $ref = new \ReflectionProperty($executor, 'writePending'); + $ref->setAccessible(true); + $writePending = $ref->getValue($executor); + + $this->assertTrue($writePending); + } + + public function testQueryRejectsWhenServerClosesConnection() + { + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop) { + $client = stream_socket_accept($server); + fclose($client); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.01, $loop); + if ($wait) { + \Clue\React\Block\sleep(0.2, $loop); + } + + $this->assertFalse($wait); + } + + public function testQueryKeepsPendingIfServerSendsIncompleteMessageLength() + { + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop) { + $client = stream_socket_accept($server); + $loop->addReadStream($client, function ($client) use ($loop) { + $loop->removeReadStream($client); + fwrite($client, "\x00"); + }); + + // keep reference to client to avoid disconnecting + $loop->addTimer(1, function () use ($client) { }); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.2, $loop); + $this->assertTrue($wait); + } + + public function testQueryKeepsPendingIfServerSendsIncompleteMessageBody() + { + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop) { + $client = stream_socket_accept($server); + $loop->addReadStream($client, function ($client) use ($loop) { + $loop->removeReadStream($client); + fwrite($client, "\x00\xff" . "some incomplete message data"); + }); + + // keep reference to client to avoid disconnecting + $loop->addTimer(1, function () use ($client) { }); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.2, $loop); + $this->assertTrue($wait); + } + + public function testQueryRejectsWhenServerSendsInvalidMessage() + { + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop) { + $client = stream_socket_accept($server); + $loop->addReadStream($client, function ($client) use ($loop) { + $loop->removeReadStream($client); + fwrite($client, "\x00\x0f" . 'invalid message'); + }); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.01, $loop); + if ($wait) { + \Clue\React\Block\sleep(0.2, $loop); + } + + $this->assertFalse($wait); + } + + public function testQueryRejectsWhenServerSendsInvalidId() + { + $parser = new Parser(); + $dumper = new BinaryDumper(); + + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop, $parser, $dumper) { + $client = stream_socket_accept($server); + $loop->addReadStream($client, function ($client) use ($loop, $parser, $dumper) { + $loop->removeReadStream($client); + $data = fread($client, 512); + + list(, $length) = unpack('n', substr($data, 0, 2)); + assert(strlen($data) - 2 === $length); + $data = substr($data, 2); + + $message = $parser->parseMessage($data); + $message->id = 0; + + $data = $dumper->toBinary($message); + $data = pack('n', strlen($data)) . $data; + + fwrite($client, $data); + }); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.01, $loop); + if ($wait) { + \Clue\React\Block\sleep(0.2, $loop); + } + + $this->assertFalse($wait); + } + + public function testQueryRejectsIfServerSendsTruncatedResponse() + { + $parser = new Parser(); + $dumper = new BinaryDumper(); + + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop, $parser, $dumper) { + $client = stream_socket_accept($server); + $loop->addReadStream($client, function ($client) use ($loop, $parser, $dumper) { + $loop->removeReadStream($client); + $data = fread($client, 512); + + list(, $length) = unpack('n', substr($data, 0, 2)); + assert(strlen($data) - 2 === $length); + $data = substr($data, 2); + + $message = $parser->parseMessage($data); + $message->tc = true; + + $data = $dumper->toBinary($message); + $data = pack('n', strlen($data)) . $data; + + fwrite($client, $data); + }); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $wait = true; + $executor->query($query)->then( + null, + function ($e) use (&$wait) { + $wait = false; + throw $e; + } + ); + + \Clue\React\Block\sleep(0.01, $loop); + if ($wait) { + \Clue\React\Block\sleep(0.2, $loop); + } + + $this->assertFalse($wait); + } + + public function testQueryResolvesIfServerSendsValidResponse() + { + $loop = Factory::create(); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $loop->addReadStream($server, function ($server) use ($loop) { + $client = stream_socket_accept($server); + $loop->addReadStream($client, function ($client) use ($loop) { + $loop->removeReadStream($client); + $data = fread($client, 512); + + list(, $length) = unpack('n', substr($data, 0, 2)); + assert(strlen($data) - 2 === $length); + + fwrite($client, $data); + }); + }); + + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + $response = \Clue\React\Block\await($promise, $loop, 0.2); + + $this->assertInstanceOf('React\Dns\Model\Message', $response); + } + + public function testQueryRejectsIfSocketIsClosedAfterPreviousQueryThatWasStillPending() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->exactly(2))->method('addWriteStream'); + $loop->expects($this->exactly(2))->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->once())->method('removeReadStream'); + + $loop->expects($this->never())->method('addTimer'); + $loop->expects($this->never())->method('cancelTimer'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise1 = $executor->query($query); + + $client = stream_socket_accept($server); + + $executor->handleWritable(); + + // manually close socket before processing second write + $ref = new \ReflectionProperty($executor, 'socket'); + $ref->setAccessible(true); + $socket = $ref->getValue($executor); + fclose($socket); + fclose($client); + + $promise2 = $executor->query($query); + + $executor->handleWritable(); + + $promise1->then(null, $this->expectCallableOnce()); + $promise2->then(null, $this->expectCallableOnce()); + } + + public function testQueryResolvesIfServerSendsBackResponseMessageAndWillStartIdleTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything()); + $loop->expects($this->never())->method('cancelTimer'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + // use outgoing buffer as response message + $ref = new \ReflectionProperty($executor, 'writeBuffer'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $client = stream_socket_accept($server); + fwrite($client, $data); + + $executor->handleWritable(); + $executor->handleRead(); + + $promise->then($this->expectCallableOnce()); + } + + public function testQueryResolvesIfServerSendsBackResponseMessageAfterCancellingQueryAndWillStartIdleTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything())->willReturn($timer); + $loop->expects($this->never())->method('cancelTimer'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + $promise->cancel(); + + // use outgoing buffer as response message + $ref = new \ReflectionProperty($executor, 'writeBuffer'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $client = stream_socket_accept($server); + fwrite($client, $data); + + $executor->handleWritable(); + $executor->handleRead(); + + //$promise->then(null, $this->expectCallableOnce()); + } + + public function testQueryResolvesIfServerSendsBackResponseMessageAfterCancellingOtherQueryAndWillStartIdleTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything()); + $loop->expects($this->never())->method('cancelTimer'); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + // use outgoing buffer as response message + $ref = new \ReflectionProperty($executor, 'writeBuffer'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $client = stream_socket_accept($server); + fwrite($client, $data); + + $another = $executor->query($query); + $another->cancel(); + + $executor->handleWritable(); + $executor->handleRead(); + + $promise->then($this->expectCallableOnce()); + } + + public function testTriggerIdleTimerAfterPreviousQueryResolvedWillCloseIdleSocketConnection() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->once())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $timerCallback = null; + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->callback(function ($cb) use (&$timerCallback) { + $timerCallback = $cb; + return true; + }))->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + // use outgoing buffer as response message + $ref = new \ReflectionProperty($executor, 'writeBuffer'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $client = stream_socket_accept($server); + fwrite($client, $data); + + $executor->handleWritable(); + $executor->handleRead(); + + $promise->then($this->expectCallableOnce()); + + // trigger idle timer + $this->assertNotNull($timerCallback); + $timerCallback(); + } + + public function testClosingConnectionAfterPreviousQueryResolvedWillCancelIdleTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->once())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + // use outgoing buffer as response message + $ref = new \ReflectionProperty($executor, 'writeBuffer'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $client = stream_socket_accept($server); + fwrite($client, $data); + + $executor->handleWritable(); + $executor->handleRead(); + + $promise->then($this->expectCallableOnce()); + + // trigger connection close condition + fclose($client); + $executor->handleRead(); + } + + public function testQueryAgainAfterPreviousQueryResolvedWillReuseSocketAndCancelIdleTimer() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->exactly(2))->method('addWriteStream'); + $loop->expects($this->once())->method('removeWriteStream'); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->never())->method('removeReadStream'); + + $timer = $this->getMockBuilder('React\EventLoop\TimerInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(0.001, $this->anything())->willReturn($timer); + $loop->expects($this->once())->method('cancelTimer')->with($timer); + + $server = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($server, false); + $executor = new TcpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + // use outgoing buffer as response message + $ref = new \ReflectionProperty($executor, 'writeBuffer'); + $ref->setAccessible(true); + $data = $ref->getValue($executor); + + $client = stream_socket_accept($server); + fwrite($client, $data); + + $executor->handleWritable(); + $executor->handleRead(); + + $promise->then($this->expectCallableOnce()); + + // trigger second query + $executor->query($query); + } +} diff --git a/tests/Query/TimeoutExecutorTest.php b/tests/Query/TimeoutExecutorTest.php index 0d37fb4..1f2d30c 100644 --- a/tests/Query/TimeoutExecutorTest.php +++ b/tests/Query/TimeoutExecutorTest.php @@ -29,7 +29,7 @@ $this->wrapped ->expects($this->once()) ->method('query') - ->will($this->returnCallback(function ($domain, $query) use (&$cancelled) { + ->will($this->returnCallback(function ($query) use (&$cancelled) { $deferred = new Deferred(function ($resolve, $reject) use (&$cancelled) { ++$cancelled; $reject(new CancellationException('Cancelled')); @@ -38,8 +38,8 @@ return $deferred->promise(); })); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $this->executor->query($query); $this->assertEquals(0, $cancelled); $promise->cancel(); @@ -55,8 +55,8 @@ ->method('query') ->willReturn(Promise\resolve('0.0.0.0')); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $this->executor->query($query); $promise->then($this->expectCallableOnce(), $this->expectCallableNever()); } @@ -68,8 +68,8 @@ ->method('query') ->willReturn(Promise\reject(new \RuntimeException())); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $promise = $this->executor->query('8.8.8.8:53', $query); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $promise = $this->executor->query($query); $promise->then($this->expectCallableNever(), $this->expectCallableOnceWith(new \RuntimeException())); } @@ -83,7 +83,7 @@ $this->wrapped ->expects($this->once()) ->method('query') - ->will($this->returnCallback(function ($domain, $query) use (&$cancelled) { + ->will($this->returnCallback(function ($query) use (&$cancelled) { $deferred = new Deferred(function ($resolve, $reject) use (&$cancelled) { ++$cancelled; $reject(new CancellationException('Cancelled')); @@ -103,8 +103,8 @@ $this->attribute($this->equalTo('DNS query for igor.io timed out'), 'message') )); - $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN, 1345656451); - $this->executor->query('8.8.8.8:53', $query)->then($callback, $errorback); + $query = new Query('igor.io', Message::TYPE_A, Message::CLASS_IN); + $this->executor->query($query)->then($callback, $errorback); $this->assertEquals(0, $cancelled); diff --git a/tests/Query/UdpTransportExecutorTest.php b/tests/Query/UdpTransportExecutorTest.php index f7222dc..d01ab3e 100644 --- a/tests/Query/UdpTransportExecutorTest.php +++ b/tests/Query/UdpTransportExecutorTest.php @@ -12,66 +12,152 @@ class UdpTransportExecutorTest extends TestCase { + /** + * @dataProvider provideDefaultPortProvider + * @param string $input + * @param string $expected + */ + public function testCtorShouldAcceptNameserverAddresses($input, $expected) + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $executor = new UdpTransportExecutor($input, $loop); + + $ref = new \ReflectionProperty($executor, 'nameserver'); + $ref->setAccessible(true); + $value = $ref->getValue($executor); + + $this->assertEquals($expected, $value); + } + + public static function provideDefaultPortProvider() + { + return array( + array( + '8.8.8.8', + 'udp://8.8.8.8:53' + ), + array( + '1.2.3.4:5', + 'udp://1.2.3.4:5' + ), + array( + 'udp://1.2.3.4', + 'udp://1.2.3.4:53' + ), + array( + 'udp://1.2.3.4:53', + 'udp://1.2.3.4:53' + ), + array( + '::1', + 'udp://[::1]:53' + ), + array( + '[::1]:53', + 'udp://[::1]:53' + ) + ); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCtorShouldThrowWhenNameserverAddressIsInvalid() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + new UdpTransportExecutor('///', $loop); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCtorShouldThrowWhenNameserverAddressContainsHostname() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + new UdpTransportExecutor('localhost', $loop); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testCtorShouldThrowWhenNameserverSchemeIsInvalid() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + new UdpTransportExecutor('tcp://1.2.3.4', $loop); + } + public function testQueryRejectsIfMessageExceedsUdpSize() { $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->never())->method('addReadStream'); - $dumper = $this->getMockBuilder('React\Dns\Protocol\BinaryDumper')->getMock(); - $dumper->expects($this->once())->method('toBinary')->willReturn(str_repeat('.', 513)); - - $executor = new UdpTransportExecutor($loop, null, $dumper); - - $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); - $promise = $executor->query('8.8.8.8:53', $query); + $executor = new UdpTransportExecutor('8.8.8.8:53', $loop); + + $query = new Query('google.' . str_repeat('.com', 200), Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->setExpectedException('RuntimeException', '', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90); + throw $exception; + } + + public function testQueryRejectsIfServerConnectionFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $executor = new UdpTransportExecutor('::1', $loop); + + $ref = new \ReflectionProperty($executor, 'nameserver'); + $ref->setAccessible(true); + $ref->setValue($executor, '///'); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); $promise->then(null, $this->expectCallableOnce()); } - public function testQueryRejectsIfServerConnectionFails() - { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - $loop->expects($this->never())->method('addReadStream'); - - $executor = new UdpTransportExecutor($loop); - - $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); - $promise = $executor->query('///', $query); + /** + * @group internet + */ + public function testQueryRejectsOnCancellation() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream'); + $loop->expects($this->once())->method('removeReadStream'); + + $executor = new UdpTransportExecutor('8.8.8.8:53', $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + $promise = $executor->query($query); + $promise->cancel(); $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); $promise->then(null, $this->expectCallableOnce()); } - /** - * @group internet - */ - public function testQueryRejectsOnCancellation() - { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - $loop->expects($this->once())->method('addReadStream'); - $loop->expects($this->once())->method('removeReadStream'); - - $executor = new UdpTransportExecutor($loop); - - $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); - $promise = $executor->query('8.8.8.8:53', $query); - $promise->cancel(); - - $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); - $promise->then(null, $this->expectCallableOnce()); - } - public function testQueryKeepsPendingIfServerRejectsNetworkPacket() { $loop = Factory::create(); - $executor = new UdpTransportExecutor($loop); + $executor = new UdpTransportExecutor('127.0.0.1:1', $loop); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); $wait = true; - $promise = $executor->query('127.0.0.1:1', $query)->then( + $promise = $executor->query($query)->then( null, function ($e) use (&$wait) { $wait = false; @@ -83,7 +169,7 @@ $this->assertTrue($wait); } - public function testQueryKeepsPendingIfServerSendInvalidMessage() + public function testQueryKeepsPendingIfServerSendsInvalidMessage() { $loop = Factory::create(); @@ -94,12 +180,12 @@ }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($loop); + $executor = new UdpTransportExecutor($address, $loop); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); $wait = true; - $promise = $executor->query($address, $query)->then( + $promise = $executor->query($query)->then( null, function ($e) use (&$wait) { $wait = false; @@ -111,7 +197,7 @@ $this->assertTrue($wait); } - public function testQueryKeepsPendingIfServerSendInvalidId() + public function testQueryKeepsPendingIfServerSendsInvalidId() { $parser = new Parser(); $dumper = new BinaryDumper(); @@ -123,18 +209,18 @@ $data = stream_socket_recvfrom($server, 512, 0, $peer); $message = $parser->parseMessage($data); - $message->header->set('id', 0); + $message->id = 0; stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($loop, $parser, $dumper); + $executor = new UdpTransportExecutor($address, $loop); $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); $wait = true; - $promise = $executor->query($address, $query)->then( + $promise = $executor->query($query)->then( null, function ($e) use (&$wait) { $wait = false; @@ -158,32 +244,20 @@ $data = stream_socket_recvfrom($server, 512, 0, $peer); $message = $parser->parseMessage($data); - $message->header->set('tc', 1); + $message->tc = true; stream_socket_sendto($server, $dumper->toBinary($message), 0, $peer); }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($loop, $parser, $dumper); - - $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); - - $wait = true; - $promise = $executor->query($address, $query)->then( - null, - function ($e) use (&$wait) { - $wait = false; - throw $e; - } - ); - - // run loop for short period to ensure we detect connection ICMP rejection error - \Clue\React\Block\sleep(0.01, $loop); - if ($wait) { - \Clue\React\Block\sleep(0.2, $loop); - } - - $this->assertFalse($wait); + $executor = new UdpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); + + $this->setExpectedException('RuntimeException', '', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90); + \Clue\React\Block\await($promise, $loop, 0.1); } public function testQueryResolvesIfServerSendsValidResponse() @@ -203,11 +277,11 @@ }); $address = stream_socket_get_name($server, false); - $executor = new UdpTransportExecutor($loop, $parser, $dumper); - - $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); - - $promise = $executor->query($address, $query); + $executor = new UdpTransportExecutor($address, $loop); + + $query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN); + + $promise = $executor->query($query); $response = \Clue\React\Block\await($promise, $loop, 0.2); $this->assertInstanceOf('React\Dns\Model\Message', $response); diff --git a/tests/Resolver/FactoryTest.php b/tests/Resolver/FactoryTest.php index acaeac0..1f201bf 100644 --- a/tests/Resolver/FactoryTest.php +++ b/tests/Resolver/FactoryTest.php @@ -19,78 +19,163 @@ $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); } - /** @test */ - public function createWithoutPortShouldCreateResolverWithDefaultPort() - { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - - $factory = new Factory(); - $resolver = $factory->create('8.8.8.8', $loop); - - $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); - $this->assertSame('8.8.8.8:53', $this->getResolverPrivateMemberValue($resolver, 'nameserver')); - } - - /** @test */ - public function createCachedShouldCreateResolverWithCachedExecutor() - { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - - $factory = new Factory(); - $resolver = $factory->createCached('8.8.8.8:53', $loop); - - $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); - $executor = $this->getResolverPrivateExecutor($resolver); - $this->assertInstanceOf('React\Dns\Query\CachedExecutor', $executor); - $recordCache = $this->getCachedExecutorPrivateMemberValue($executor, 'cache'); - $recordCacheCache = $this->getRecordCachePrivateMemberValue($recordCache, 'cache'); - $this->assertInstanceOf('React\Cache\CacheInterface', $recordCacheCache); - $this->assertInstanceOf('React\Cache\ArrayCache', $recordCacheCache); - } - - /** @test */ - public function createCachedShouldCreateResolverWithCachedExecutorWithCustomCache() - { - $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - - $factory = new Factory(); - $resolver = $factory->createCached('8.8.8.8:53', $loop, $cache); - - $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); - $executor = $this->getResolverPrivateExecutor($resolver); - $this->assertInstanceOf('React\Dns\Query\CachedExecutor', $executor); - $recordCache = $this->getCachedExecutorPrivateMemberValue($executor, 'cache'); - $recordCacheCache = $this->getRecordCachePrivateMemberValue($recordCache, 'cache'); - $this->assertInstanceOf('React\Cache\CacheInterface', $recordCacheCache); - $this->assertSame($cache, $recordCacheCache); + + /** @test */ + public function createWithoutSchemeShouldCreateResolverWithSelectiveUdpAndTcpExecutorStack() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $factory = new Factory(); + $resolver = $factory->create('8.8.8.8:53', $loop); + + $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); + + $coopExecutor = $this->getResolverPrivateExecutor($resolver); + + $this->assertInstanceOf('React\Dns\Query\CoopExecutor', $coopExecutor); + + $ref = new \ReflectionProperty($coopExecutor, 'executor'); + $ref->setAccessible(true); + $selectiveExecutor = $ref->getValue($coopExecutor); + + $this->assertInstanceOf('React\Dns\Query\SelectiveTransportExecutor', $selectiveExecutor); + + // udp below: + + $ref = new \ReflectionProperty($selectiveExecutor, 'datagramExecutor'); + $ref->setAccessible(true); + $retryExecutor = $ref->getValue($selectiveExecutor); + + $this->assertInstanceOf('React\Dns\Query\RetryExecutor', $retryExecutor); + + $ref = new \ReflectionProperty($retryExecutor, 'executor'); + $ref->setAccessible(true); + $timeoutExecutor = $ref->getValue($retryExecutor); + + $this->assertInstanceOf('React\Dns\Query\TimeoutExecutor', $timeoutExecutor); + + $ref = new \ReflectionProperty($timeoutExecutor, 'executor'); + $ref->setAccessible(true); + $udpExecutor = $ref->getValue($timeoutExecutor); + + $this->assertInstanceOf('React\Dns\Query\UdpTransportExecutor', $udpExecutor); + + // tcp below: + + $ref = new \ReflectionProperty($selectiveExecutor, 'streamExecutor'); + $ref->setAccessible(true); + $timeoutExecutor = $ref->getValue($selectiveExecutor); + + $this->assertInstanceOf('React\Dns\Query\TimeoutExecutor', $timeoutExecutor); + + $ref = new \ReflectionProperty($timeoutExecutor, 'executor'); + $ref->setAccessible(true); + $tcpExecutor = $ref->getValue($timeoutExecutor); + + $this->assertInstanceOf('React\Dns\Query\TcpTransportExecutor', $tcpExecutor); + } + + /** @test */ + public function createWithUdpSchemeShouldCreateResolverWithUdpExecutorStack() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $factory = new Factory(); + $resolver = $factory->create('udp://8.8.8.8:53', $loop); + + $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); + + $coopExecutor = $this->getResolverPrivateExecutor($resolver); + + $this->assertInstanceOf('React\Dns\Query\CoopExecutor', $coopExecutor); + + $ref = new \ReflectionProperty($coopExecutor, 'executor'); + $ref->setAccessible(true); + $retryExecutor = $ref->getValue($coopExecutor); + + $this->assertInstanceOf('React\Dns\Query\RetryExecutor', $retryExecutor); + + $ref = new \ReflectionProperty($retryExecutor, 'executor'); + $ref->setAccessible(true); + $timeoutExecutor = $ref->getValue($retryExecutor); + + $this->assertInstanceOf('React\Dns\Query\TimeoutExecutor', $timeoutExecutor); + + $ref = new \ReflectionProperty($timeoutExecutor, 'executor'); + $ref->setAccessible(true); + $udpExecutor = $ref->getValue($timeoutExecutor); + + $this->assertInstanceOf('React\Dns\Query\UdpTransportExecutor', $udpExecutor); + } + + /** @test */ + public function createWithTcpSchemeShouldCreateResolverWithTcpExecutorStack() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $factory = new Factory(); + $resolver = $factory->create('tcp://8.8.8.8:53', $loop); + + $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); + + $coopExecutor = $this->getResolverPrivateExecutor($resolver); + + $this->assertInstanceOf('React\Dns\Query\CoopExecutor', $coopExecutor); + + $ref = new \ReflectionProperty($coopExecutor, 'executor'); + $ref->setAccessible(true); + $timeoutExecutor = $ref->getValue($coopExecutor); + + $this->assertInstanceOf('React\Dns\Query\TimeoutExecutor', $timeoutExecutor); + + $ref = new \ReflectionProperty($timeoutExecutor, 'executor'); + $ref->setAccessible(true); + $tcpExecutor = $ref->getValue($timeoutExecutor); + + $this->assertInstanceOf('React\Dns\Query\TcpTransportExecutor', $tcpExecutor); } /** * @test - * @dataProvider factoryShouldAddDefaultPortProvider + * @expectedException InvalidArgumentException */ - public function factoryShouldAddDefaultPort($input, $expected) - { - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); - - $factory = new Factory(); - $resolver = $factory->create($input, $loop); - - $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); - $this->assertSame($expected, $this->getResolverPrivateMemberValue($resolver, 'nameserver')); - } - - public static function factoryShouldAddDefaultPortProvider() - { - return array( - array('8.8.8.8', '8.8.8.8:53'), - array('1.2.3.4:5', '1.2.3.4:5'), - array('localhost', 'localhost:53'), - array('localhost:1234', 'localhost:1234'), - array('::1', '[::1]:53'), - array('[::1]:53', '[::1]:53') - ); + public function createShouldThrowWhenNameserverIsInvalid() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $factory = new Factory(); + $factory->create('///', $loop); + } + + /** @test */ + public function createCachedShouldCreateResolverWithCachingExecutor() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $factory = new Factory(); + $resolver = $factory->createCached('8.8.8.8:53', $loop); + + $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); + $executor = $this->getResolverPrivateExecutor($resolver); + $this->assertInstanceOf('React\Dns\Query\CachingExecutor', $executor); + $cache = $this->getCachingExecutorPrivateMemberValue($executor, 'cache'); + $this->assertInstanceOf('React\Cache\ArrayCache', $cache); + } + + /** @test */ + public function createCachedShouldCreateResolverWithCachingExecutorWithCustomCache() + { + $cache = $this->getMockBuilder('React\Cache\CacheInterface')->getMock(); + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $factory = new Factory(); + $resolver = $factory->createCached('8.8.8.8:53', $loop, $cache); + + $this->assertInstanceOf('React\Dns\Resolver\Resolver', $resolver); + $executor = $this->getResolverPrivateExecutor($resolver); + $this->assertInstanceOf('React\Dns\Query\CachingExecutor', $executor); + $cacheProperty = $this->getCachingExecutorPrivateMemberValue($executor, 'cache'); + $this->assertSame($cache, $cacheProperty); } private function getResolverPrivateExecutor($resolver) @@ -115,17 +200,10 @@ return $reflector->getValue($resolver); } - private function getCachedExecutorPrivateMemberValue($resolver, $field) - { - $reflector = new \ReflectionProperty('React\Dns\Query\CachedExecutor', $field); + private function getCachingExecutorPrivateMemberValue($resolver, $field) + { + $reflector = new \ReflectionProperty('React\Dns\Query\CachingExecutor', $field); $reflector->setAccessible(true); return $reflector->getValue($resolver); } - - private function getRecordCachePrivateMemberValue($resolver, $field) - { - $reflector = new \ReflectionProperty('React\Dns\Query\RecordCache', $field); - $reflector->setAccessible(true); - return $reflector->getValue($resolver); - } } diff --git a/tests/Resolver/ResolveAliasesTest.php b/tests/Resolver/ResolveAliasesTest.php index a9b8608..98901be 100644 --- a/tests/Resolver/ResolveAliasesTest.php +++ b/tests/Resolver/ResolveAliasesTest.php @@ -2,27 +2,31 @@ namespace React\Tests\Dns\Resolver; -use PHPUnit\Framework\TestCase; +use React\Tests\Dns\TestCase; use React\Dns\Resolver\Resolver; -use React\Dns\Query\Query; use React\Dns\Model\Message; use React\Dns\Model\Record; class ResolveAliasesTest extends TestCase { /** - * @covers React\Dns\Resolver\Resolver::resolveAliases - * @covers React\Dns\Resolver\Resolver::valuesByNameAndType * @dataProvider provideAliasedAnswers */ public function testResolveAliases(array $expectedAnswers, array $answers, $name) { + $message = new Message(); + foreach ($answers as $answer) { + $message->answers[] = $answer; + } + $executor = $this->createExecutorMock(); - $resolver = new Resolver('8.8.8.8:53', $executor); + $executor->expects($this->once())->method('query')->willReturn(\React\Promise\resolve($message)); - $answers = $resolver->resolveAliases($answers, $name); + $resolver = new Resolver($executor); - $this->assertEquals($expectedAnswers, $answers); + $answers = $resolver->resolveAll($name, Message::TYPE_A); + + $answers->then($this->expectCallableOnceWith($expectedAnswers), null); } public function provideAliasedAnswers() @@ -50,14 +54,6 @@ new Record('igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131'), new Record('foo.igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131'), new Record('bar.igor.io', Message::TYPE_A, Message::CLASS_IN, 3600, '178.79.169.131'), - ), - 'igor.io', - ), - array( - array(), - array( - new Record('foo.igor.io', Message::TYPE_A, Message::CLASS_IN), - new Record('bar.igor.io', Message::TYPE_A, Message::CLASS_IN), ), 'igor.io', ), diff --git a/tests/Resolver/ResolverTest.php b/tests/Resolver/ResolverTest.php index 661386d..da7429a 100644 --- a/tests/Resolver/ResolverTest.php +++ b/tests/Resolver/ResolverTest.php @@ -19,17 +19,17 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query($query->name, $query->type, $query->class); $response->answers[] = new Record($query->name, $query->type, $query->class, 3600, '178.79.169.131'); return Promise\resolve($response); })); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolve('igor.io')->then($this->expectCallableOnceWith('178.79.169.131')); } @@ -40,17 +40,17 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query($query->name, $query->type, $query->class); $response->answers[] = new Record($query->name, $query->type, $query->class, 3600, '::1'); return Promise\resolve($response); })); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then($this->expectCallableOnceWith(array('::1'))); } @@ -61,18 +61,18 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query($query->name, $query->type, $query->class); $response->answers[] = new Record($query->name, Message::TYPE_TXT, $query->class, 3600, array('ignored')); $response->answers[] = new Record($query->name, $query->type, $query->class, 3600, '::1'); return Promise\resolve($response); })); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then($this->expectCallableOnceWith(array('::1'))); } @@ -83,20 +83,19 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query($query->name, $query->type, $query->class); $response->answers[] = new Record($query->name, Message::TYPE_CNAME, $query->class, 3600, 'example.com'); $response->answers[] = new Record('example.com', $query->type, $query->class, 3600, '::1'); $response->answers[] = new Record('example.com', $query->type, $query->class, 3600, '::2'); - $response->prepare(); - - return Promise\resolve($response); - })); - - $resolver = new Resolver('8.8.8.8:53', $executor); + + return Promise\resolve($response); + })); + + $resolver = new Resolver($executor); $resolver->resolveAll('reactphp.org', Message::TYPE_AAAA)->then( $this->expectCallableOnceWith($this->equalTo(array('::1', '::2'))) ); @@ -109,17 +108,17 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record('Blog.wyrihaximus.net', $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query('Blog.wyrihaximus.net', $query->type, $query->class); $response->answers[] = new Record('Blog.wyrihaximus.net', $query->type, $query->class, 3600, '178.79.169.131'); return Promise\resolve($response); })); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolve('blog.wyrihaximus.net')->then($this->expectCallableOnceWith('178.79.169.131')); } @@ -130,11 +129,11 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query($query->name, $query->type, $query->class); $response->answers[] = new Record('foo.bar', $query->type, $query->class, 3600, '178.79.169.131'); return Promise\resolve($response); @@ -142,7 +141,7 @@ $errback = $this->expectCallableOnceWith($this->isInstanceOf('React\Dns\RecordNotFoundException')); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolve('igor.io')->then($this->expectCallableNever(), $errback); } @@ -155,11 +154,11 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) { - $response = new Message(); - $response->header->set('qr', 1); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) { + $response = new Message(); + $response->qr = true; + $response->questions[] = new Query($query->name, $query->type, $query->class); return Promise\resolve($response); })); @@ -168,7 +167,7 @@ return ($param instanceof RecordNotFoundException && $param->getCode() === 0 && $param->getMessage() === 'DNS query for igor.io did not return a valid answer (NOERROR / NODATA)'); })); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolve('igor.io')->then($this->expectCallableNever(), $errback); } @@ -212,12 +211,12 @@ $executor ->expects($this->once()) ->method('query') - ->with($this->anything(), $this->isInstanceOf('React\Dns\Query\Query')) - ->will($this->returnCallback(function ($nameserver, $query) use ($code) { - $response = new Message(); - $response->header->set('qr', 1); - $response->header->set('rcode', $code); - $response->questions[] = new Record($query->name, $query->type, $query->class); + ->with($this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($query) use ($code) { + $response = new Message(); + $response->qr = true; + $response->rcode = $code; + $response->questions[] = new Query($query->name, $query->type, $query->class); return Promise\resolve($response); })); @@ -226,24 +225,10 @@ return ($param instanceof RecordNotFoundException && $param->getCode() === $code && $param->getMessage() === $expectedMessage); })); - $resolver = new Resolver('8.8.8.8:53', $executor); + $resolver = new Resolver($executor); $resolver->resolve('example.com')->then($this->expectCallableNever(), $errback); } - public function testLegacyExtractAddress() - { - $executor = $this->createExecutorMock(); - $resolver = new Resolver('8.8.8.8:53', $executor); - - $query = new Query('reactphp.org', Message::TYPE_A, Message::CLASS_IN); - $response = Message::createResponseWithAnswersForQuery($query, array( - new Record('reactphp.org', Message::TYPE_A, Message::CLASS_IN, 3600, '1.2.3.4') - )); - - $ret = $resolver->extractAddress($query, $response); - $this->assertEquals('1.2.3.4', $ret); - } - private function createExecutorMock() { return $this->getMockBuilder('React\Dns\Query\ExecutorInterface')->getMock();