diff --git a/CHANGELOG.md b/CHANGELOG.md index d16f2f5..8c06569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,19 @@ # Changelog + +## 0.4.16 (2018-11-11) + +* Feature: Improve promise cancellation for DNS lookup retries and clean up any garbage references. + (#118 by @clue) + +* Fix: Reject parsing malformed DNS response messages such as incomplete DNS response messages, + malformed record data or malformed compressed domain name labels. + (#115 and #117 by @clue) + +* Fix: Fix interpretation of TTL as UINT32 with most significant bit unset. + (#116 by @clue) + +* Fix: Fix caching advanced MX/SRV/TXT/SOA structures. + (#112 by @clue) ## 0.4.15 (2018-07-02) @@ -14,7 +29,7 @@ * Feature: Support parsing `NS`, `TXT`, `MX`, `SOA` and `SRV` records. (#104, #105, #106, #107 and #108 by @clue) -* Feature: Add support for `Message::TYPE_ANY` parse unknown types as binary data. +* Feature: Add support for `Message::TYPE_ANY` and parse unknown types as binary data. (#104 by @clue) * Feature: Improve error messages for failed queries and improve documentation. diff --git a/README.md b/README.md index 05078c6..043b937 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ This will install the latest supported version: ```bash -$ composer require react/dns:^0.4.15 +$ composer require react/dns:^0.4.16 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. diff --git a/src/Model/Record.php b/src/Model/Record.php index 035bc6e..7507fcb 100644 --- a/src/Model/Record.php +++ b/src/Model/Record.php @@ -20,7 +20,8 @@ public $class; /** - * @var int maximum TTL in seconds (UINT16) + * @var int maximum TTL in seconds (UINT32, most significant bit always unset) + * @link https://tools.ietf.org/html/rfc2181#section-8 */ public $ttl; diff --git a/src/Protocol/Parser.php b/src/Protocol/Parser.php index a503957..56cc949 100644 --- a/src/Protocol/Parser.php +++ b/src/Protocol/Parser.php @@ -32,6 +32,7 @@ /** * @deprecated unused, exists for BC only + * @codeCoverageIgnore */ public function parseChunk($data, Message $message) { @@ -65,7 +66,7 @@ public function parseHeader(Message $message) { - if (strlen($message->data) < 12) { + if (!isset($message->data[12 - 1])) { return; } @@ -96,19 +97,11 @@ public function parseQuestion(Message $message) { - if (strlen($message->data) < 2) { - return; - } - $consumed = $message->consumed; list($labels, $consumed) = $this->readLabels($message->data, $consumed); - if (null === $labels) { - return; - } - - if (strlen($message->data) - $consumed < 4) { + if ($labels === null || !isset($message->data[$consumed + 4 - 1])) { return; } @@ -132,19 +125,11 @@ public function parseAnswer(Message $message) { - if (strlen($message->data) < 2) { - return; - } - $consumed = $message->consumed; - list($labels, $consumed) = $this->readLabels($message->data, $consumed); - - if (null === $labels) { - return; - } - - if (strlen($message->data) - $consumed < 10) { + list($name, $consumed) = $this->readDomain($message->data, $consumed); + + if ($name === null || !isset($message->data[$consumed + 10 - 1])) { return; } @@ -154,72 +139,93 @@ list($ttl) = array_values(unpack('N', substr($message->data, $consumed, 4))); $consumed += 4; + // TTL is a UINT32 that must not have most significant bit set for BC reasons + if ($ttl < 0 || $ttl >= 1 << 31) { + $ttl = 0; + } + list($rdLength) = array_values(unpack('n', substr($message->data, $consumed, 2))); $consumed += 2; + if (!isset($message->data[$consumed + $rdLength - 1])) { + return; + } + $rdata = null; - - if (Message::TYPE_A === $type || Message::TYPE_AAAA === $type) { - $ip = substr($message->data, $consumed, $rdLength); - $consumed += $rdLength; - - $rdata = inet_ntop($ip); + $expected = $consumed + $rdLength; + + if (Message::TYPE_A === $type) { + if ($rdLength === 4) { + $rdata = inet_ntop(substr($message->data, $consumed, $rdLength)); + $consumed += $rdLength; + } + } elseif (Message::TYPE_AAAA === $type) { + if ($rdLength === 16) { + $rdata = inet_ntop(substr($message->data, $consumed, $rdLength)); + $consumed += $rdLength; + } } elseif (Message::TYPE_CNAME === $type || Message::TYPE_PTR === $type || Message::TYPE_NS === $type) { - list($bodyLabels, $consumed) = $this->readLabels($message->data, $consumed); - - $rdata = implode('.', $bodyLabels); + list($rdata, $consumed) = $this->readDomain($message->data, $consumed); } elseif (Message::TYPE_TXT === $type) { $rdata = array(); - $remaining = $rdLength; - while ($remaining) { + while ($consumed < $expected) { $len = ord($message->data[$consumed]); - $rdata[] = substr($message->data, $consumed + 1, $len); + $rdata[] = (string)substr($message->data, $consumed + 1, $len); $consumed += $len + 1; - $remaining -= $len + 1; } } elseif (Message::TYPE_MX === $type) { - list($priority) = array_values(unpack('n', substr($message->data, $consumed, 2))); - list($bodyLabels, $consumed) = $this->readLabels($message->data, $consumed + 2); - - $rdata = array( - 'priority' => $priority, - 'target' => implode('.', $bodyLabels) - ); + if ($rdLength > 2) { + list($priority) = array_values(unpack('n', substr($message->data, $consumed, 2))); + list($target, $consumed) = $this->readDomain($message->data, $consumed + 2); + + $rdata = array( + 'priority' => $priority, + 'target' => $target + ); + } } elseif (Message::TYPE_SRV === $type) { - list($priority, $weight, $port) = array_values(unpack('n*', substr($message->data, $consumed, 6))); - list($bodyLabels, $consumed) = $this->readLabels($message->data, $consumed + 6); - - $rdata = array( - 'priority' => $priority, - 'weight' => $weight, - 'port' => $port, - 'target' => implode('.', $bodyLabels) - ); + if ($rdLength > 6) { + list($priority, $weight, $port) = array_values(unpack('n*', substr($message->data, $consumed, 6))); + list($target, $consumed) = $this->readDomain($message->data, $consumed + 6); + + $rdata = array( + 'priority' => $priority, + 'weight' => $weight, + 'port' => $port, + 'target' => $target + ); + } } elseif (Message::TYPE_SOA === $type) { - list($primaryLabels, $consumed) = $this->readLabels($message->data, $consumed); - list($mailLabels, $consumed) = $this->readLabels($message->data, $consumed); - list($serial, $refresh, $retry, $expire, $minimum) = array_values(unpack('N*', substr($message->data, $consumed, 20))); - $consumed += 20; - - $rdata = array( - 'mname' => implode('.', $primaryLabels), - 'rname' => implode('.', $mailLabels), - 'serial' => $serial, - 'refresh' => $refresh, - 'retry' => $retry, - 'expire' => $expire, - 'minimum' => $minimum - ); + list($mname, $consumed) = $this->readDomain($message->data, $consumed); + list($rname, $consumed) = $this->readDomain($message->data, $consumed); + + if ($mname !== null && $rname !== null && isset($message->data[$consumed + 20 - 1])) { + list($serial, $refresh, $retry, $expire, $minimum) = array_values(unpack('N*', substr($message->data, $consumed, 20))); + $consumed += 20; + + $rdata = array( + 'mname' => $mname, + 'rname' => $rname, + 'serial' => $serial, + 'refresh' => $refresh, + 'retry' => $retry, + 'expire' => $expire, + 'minimum' => $minimum + ); + } } else { // unknown types simply parse rdata as an opaque binary string $rdata = substr($message->data, $consumed, $rdLength); $consumed += $rdLength; } + // ensure parsing record data consumes expact number of bytes indicated in record length + if ($consumed !== $expected || $rdata === null) { + return; + } + $message->consumed = $consumed; - $name = implode('.', $labels); - $ttl = $this->signedLongToUnsignedLong($ttl); $record = new Record($name, $type, $class, $ttl, $rdata); $message->answers[] = $record; @@ -231,42 +237,78 @@ return $message; } + private function readDomain($data, $consumed) + { + list ($labels, $consumed) = $this->readLabels($data, $consumed); + + if ($labels === null) { + return array(null, null); + } + + return array(implode('.', $labels), $consumed); + } + private function readLabels($data, $consumed) { $labels = array(); while (true) { - if ($this->isEndOfLabels($data, $consumed)) { + if (!isset($data[$consumed])) { + return array(null, null); + } + + $length = \ord($data[$consumed]); + + // end of labels reached + if ($length === 0) { $consumed += 1; break; } - if ($this->isCompressedLabel($data, $consumed)) { - list($newLabels, $consumed) = $this->getCompressedLabel($data, $consumed); + // first two bits set? this is a compressed label (14 bit pointer offset) + if (($length & 0xc0) === 0xc0 && isset($data[$consumed + 1])) { + $offset = ($length & ~0xc0) << 8 | \ord($data[$consumed + 1]); + if ($offset >= $consumed) { + return array(null, null); + } + + $consumed += 2; + list($newLabels) = $this->readLabels($data, $offset); + + if ($newLabels === null) { + return array(null, null); + } + $labels = array_merge($labels, $newLabels); break; } - $length = ord(substr($data, $consumed, 1)); - $consumed += 1; - - if (strlen($data) - $consumed < $length) { + // length MUST be 0-63 (6 bits only) and data has to be large enough + if ($length & 0xc0 || !isset($data[$consumed + $length - 1])) { return array(null, null); } - $labels[] = substr($data, $consumed, $length); - $consumed += $length; + $labels[] = substr($data, $consumed + 1, $length); + $consumed += $length + 1; } 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); @@ -275,6 +317,10 @@ return array($labels, $consumed); } + /** + * @deprecated unused, exists for BC only + * @codeCoverageIgnore + */ public function isCompressedLabel($data, $consumed) { $mask = 0xc000; // 1100000000000000 @@ -283,6 +329,10 @@ return (bool) ($peek & $mask); } + /** + * @deprecated unused, exists for BC only + * @codeCoverageIgnore + */ public function getCompressedLabelOffset($data, $consumed) { $mask = 0x3fff; // 0011111111111111 @@ -291,6 +341,10 @@ 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/RecordBag.php b/src/Query/RecordBag.php index 358cf5d..26007c3 100644 --- a/src/Query/RecordBag.php +++ b/src/Query/RecordBag.php @@ -2,7 +2,6 @@ namespace React\Dns\Query; -use React\Dns\Model\Message; use React\Dns\Model\Record; class RecordBag @@ -11,7 +10,7 @@ public function set($currentTime, Record $record) { - $this->records[$record->data] = array($currentTime + $record->ttl, $record); + $this->records[] = array($currentTime + $record->ttl, $record); } public function all() diff --git a/src/Query/RetryExecutor.php b/src/Query/RetryExecutor.php index 90353e5..46e2ef9 100644 --- a/src/Query/RetryExecutor.php +++ b/src/Query/RetryExecutor.php @@ -2,6 +2,7 @@ namespace React\Dns\Query; +use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; class RetryExecutor implements ExecutorInterface @@ -22,23 +23,57 @@ public function tryQuery($nameserver, Query $query, $retries) { - $that = $this; - $errorback = function ($error) use ($nameserver, $query, $retries, $that) { - if (!$error instanceof TimeoutException) { - throw $error; + $deferred = new Deferred(function () use (&$promise) { + if ($promise instanceof CancellablePromiseInterface) { + $promise->cancel(); } - if (0 >= $retries) { - throw new \RuntimeException( - sprintf("DNS query for %s failed: too many retries", $query->name), + }); + + $success = function ($value) use ($deferred, &$errorback) { + $errorback = null; + $deferred->resolve($value); + }; + + $executor = $this->executor; + $errorback = function ($e) use ($deferred, &$promise, $nameserver, $query, $success, &$errorback, &$retries, $executor) { + if (!$e instanceof TimeoutException) { + $errorback = null; + $deferred->reject($e); + } elseif ($retries <= 0) { + $errorback = null; + $deferred->reject($e = new \RuntimeException( + 'DNS query for ' . $query->name . ' failed: too many retries', 0, - $error + $e + )); + + // avoid garbage references by replacing all closures in call stack. + // what a lovely piece of code! + $r = new \ReflectionProperty('Exception', 'trace'); + $r->setAccessible(true); + $trace = $r->getValue($e); + foreach ($trace as &$one) { + foreach ($one['args'] as &$arg) { + if ($arg instanceof \Closure) { + $arg = 'Object(' . \get_class($arg) . ')'; + } + } + } + $r->setValue($e, $trace); + } else { + --$retries; + $promise = $executor->query($nameserver, $query)->then( + $success, + $errorback ); } - return $that->tryQuery($nameserver, $query, $retries-1); }; - return $this->executor - ->query($nameserver, $query) - ->then(null, $errorback); + $promise = $this->executor->query($nameserver, $query)->then( + $success, + $errorback + ); + + return $deferred->promise(); } } diff --git a/tests/FunctionalResolverTest.php b/tests/FunctionalResolverTest.php index 899e120..7b6a37b 100644 --- a/tests/FunctionalResolverTest.php +++ b/tests/FunctionalResolverTest.php @@ -47,6 +47,20 @@ /** * @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 testResolveInvalidRejects() { $ex = $this->callback(function ($param) { diff --git a/tests/Protocol/ParserTest.php b/tests/Protocol/ParserTest.php index 2bba482..aed4e45 100644 --- a/tests/Protocol/ParserTest.php +++ b/tests/Protocol/ParserTest.php @@ -157,6 +157,81 @@ $this->assertSame('178.79.169.131', $response->answers[0]->data); } + public function testParseAnswerWithExcessiveTtlReturnsZeroTtl() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 01 00 01"; // answer: type A, class IN + $data .= "ff ff ff ff"; // answer: ttl 2^32 - 1 + $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); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_A, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(0, $response->answers[0]->ttl); + $this->assertSame('178.79.169.131', $response->answers[0]->data); + } + + public function testParseAnswerWithTtlExactlyBoundaryReturnsZeroTtl() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 01 00 01"; // answer: type A, class IN + $data .= "80 00 00 00"; // answer: ttl 2^31 + $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); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_A, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(0, $response->answers[0]->ttl); + $this->assertSame('178.79.169.131', $response->answers[0]->data); + } + + public function testParseAnswerWithMaximumTtlReturnsExactTtl() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 01 00 01"; // answer: type A, class IN + $data .= "7f ff ff ff"; // answer: ttl 2^31 - 1 + $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); + + $this->assertCount(1, $response->answers); + $this->assertSame('igor.io', $response->answers[0]->name); + $this->assertSame(Message::TYPE_A, $response->answers[0]->type); + $this->assertSame(Message::CLASS_IN, $response->answers[0]->class); + $this->assertSame(0x7fffffff, $response->answers[0]->ttl); + $this->assertSame('178.79.169.131', $response->answers[0]->data); + } + public function testParseAnswerWithUnknownType() { $data = ""; @@ -437,7 +512,7 @@ $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 07"; // answer: rdlength 7 + $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 @@ -519,7 +594,7 @@ /** * @expectedException InvalidArgumentException */ - public function testParseIncomplete() + public function testParseIncompleteQuestionThrows() { $data = ""; $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header @@ -531,6 +606,303 @@ $this->parser->parseMessage($data); } + /** + * @expectedException InvalidArgumentException + */ + public function testParseIncompleteQuestionLabelThrows() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "04 69 67"; // question: ig …? + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseIncompleteQuestionNameThrows() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "04 69 67 6f 72"; // question: igor. …? + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseIncompleteOffsetPointerInQuestionNameThrows() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "ff"; // question: incomplete offset pointer + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidOffsetPointerInQuestionNameThrows() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "ff ff"; // question: offset pointer to invalid address + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidOffsetPointerToSameLabelInQuestionNameThrows() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "c0 0c"; // question: offset pointer to invalid address + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseInvalidOffsetPointerToStartOfMessageInQuestionNameThrows() + { + $data = ""; + $data .= "72 62 01 00 00 01 00 00 00 00 00 00"; // header + $data .= "c0 00"; // question: offset pointer to start of message + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseIncompleteAnswerFieldsThrows() + { + $data = ""; + $data .= "72 62 81 80 00 01 00 01 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 .= "c0 0c"; // answer: offset pointer to igor.io + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testParseIncompleteAnswerRecordDataThrows() + { + $data = ""; + $data .= "72 62 81 80 00 01 00 01 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 .= "c0 0c"; // answer: offset pointer to igor.io + $data .= "00 01 00 01"; // answer: type A, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $data .= "00 04"; // answer: rdlength 4 + + $data = $this->convertTcpDumpToBinary($data); + + $this->parser->parseMessage($data); + } + + public function testParseInvalidNSResponseWhereDomainNameIsMissing() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 02 00 01"; // answer: type NS, class IN + $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); + } + + public function testParseInvalidAResponseWhereIPIsMissing() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 01 00 01"; // answer: type A, class IN + $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); + } + + public function testParseInvalidAAAAResponseWhereIPIsMissing() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 1c 00 01"; // answer: type AAAA, class IN + $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); + } + + public function testParseInvalidTXTResponseWhereTxtChunkExceedsLimit() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 10 00 01"; // answer: type TXT, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $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); + } + + public function testParseInvalidMXResponseWhereDomainNameIsIncomplete() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 0f 00 01"; // answer: type MX, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $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); + } + + public function testParseInvalidMXResponseWhereDomainNameIsMissing() + { + $data = ""; + $data .= "04 69 67 6f 72 02 69 6f 00"; // answer: igor.io + $data .= "00 0f 00 01"; // answer: type MX, class IN + $data .= "00 01 51 80"; // answer: ttl 86400 + $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); + } + + public function testParseInvalidSRVResponseWhereDomainNameIsIncomplete() + { + $data = ""; + $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 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); + } + + public function testParseInvalidSRVResponseWhereDomainNameIsMissing() + { + $data = ""; + $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 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); + } + + public function testParseInvalidSOAResponseWhereFlagsAreMissing() + { + $data = ""; + $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 13"; // answer: rdlength 19 + $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); + } + private function convertTcpDumpToBinary($input) { // sudo ngrep -d en1 -x port 53 diff --git a/tests/Query/RecordBagTest.php b/tests/Query/RecordBagTest.php index 83b8934..c0615be 100644 --- a/tests/Query/RecordBagTest.php +++ b/tests/Query/RecordBagTest.php @@ -39,6 +39,25 @@ } /** + * @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 */ diff --git a/tests/Query/RetryExecutorTest.php b/tests/Query/RetryExecutorTest.php index 8950f84..7e44a08 100644 --- a/tests/Query/RetryExecutorTest.php +++ b/tests/Query/RetryExecutorTest.php @@ -162,6 +162,159 @@ $this->assertEquals(1, $cancelled); } + /** + * @covers React\Dns\Query\RetryExecutor + * @test + */ + public function queryShouldCancelSecondQueryOnCancel() + { + $deferred = new Deferred(); + $cancelled = 0; + + $executor = $this->createExecutorMock(); + $executor + ->expects($this->exactly(2)) + ->method('query') + ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->onConsecutiveCalls( + $this->returnValue($deferred->promise()), + $this->returnCallback(function ($domain, $query) use (&$cancelled) { + $deferred = new Deferred(function ($resolve, $reject) use (&$cancelled) { + ++$cancelled; + $reject(new CancellationException('Cancelled')); + }); + + return $deferred->promise(); + }) + )); + + $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); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + + // first query will time out after a while and this sends the next query + $deferred->reject(new TimeoutException()); + + $this->assertEquals(0, $cancelled); + $promise->cancel(); + $this->assertEquals(1, $cancelled); + } + + /** + * @covers React\Dns\Query\RetryExecutor + * @test + */ + public function queryShouldNotCauseGarbageReferencesOnSuccess() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with('8.8.8.8', $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); + + $this->assertEquals(0, gc_collect_cycles()); + } + + /** + * @covers React\Dns\Query\RetryExecutor + * @test + */ + public function queryShouldNotCauseGarbageReferencesOnTimeoutErrors() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $executor = $this->createExecutorMock(); + $executor + ->expects($this->any()) + ->method('query') + ->with('8.8.8.8', $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); + + $this->assertEquals(0, gc_collect_cycles()); + } + + /** + * @covers React\Dns\Query\RetryExecutor + * @test + */ + public function queryShouldNotCauseGarbageReferencesOnCancellation() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $deferred = new Deferred(function () { + throw new \RuntimeException(); + }); + + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with('8.8.8.8', $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); + $promise->cancel(); + $promise = null; + + $this->assertEquals(0, gc_collect_cycles()); + } + + /** + * @covers React\Dns\Query\RetryExecutor + * @test + */ + public function queryShouldNotCauseGarbageReferencesOnNonTimeoutErrors() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + $executor = $this->createExecutorMock(); + $executor + ->expects($this->once()) + ->method('query') + ->with('8.8.8.8', $this->isInstanceOf('React\Dns\Query\Query')) + ->will($this->returnCallback(function ($domain, $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); + + $this->assertEquals(0, gc_collect_cycles()); + } + protected function expectPromiseOnce($return = null) { $mock = $this->createPromiseMock();