<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\library\mail\AsyncDnsResolver;
use seekquarry\yioop\library\UnitTest;
/**
* Tests the pieces of the async DNS resolver that turn names and
* records into wire bytes and back. These are the parts that must be
* exactly right for a lookup to work, and they are pure, so every case
* here builds its own bytes by hand and never touches the network.
* Sending, waiting, and picking a name server are left to a running
* mail server rather than a unit test.
*/
class AsyncDnsResolverTest extends UnitTest
{
/**
* Sets up the test; there is no shared state to prepare.
* @return void
*/
public function setUp()
{
}
/**
* Tears down the test; there is no shared state to clean up.
* @return void
*/
public function tearDown()
{
}
/**
* Builds a reply message for one text record, with the answer's
* name written as a compression pointer back to the question, so
* parsing exercises pointer following as a real reply would.
*
* @param int $id the query id the reply answers
* @param string $name the name asked about
* @param array $runs the character-string runs of the text record
* @param int $ttl the record's time to live in seconds
* @param int $flags the reply flag word
* @return string the reply bytes
*/
private function textReply($id, $name, $runs, $ttl, $flags)
{
$question = AsyncDnsResolver::encodeName($name) .
pack('n2', AsyncDnsResolver::TYPE_TXT,
AsyncDnsResolver::CLASS_INTERNET);
$rdata = '';
foreach ($runs as $run) {
$rdata .= chr(strlen($run)) . $run;
}
$answer = pack('n', AsyncDnsResolver::COMPRESSION_MASK << 8 |
AsyncDnsResolver::HEADER_LENGTH) .
pack('n2', AsyncDnsResolver::TYPE_TXT,
AsyncDnsResolver::CLASS_INTERNET) . pack('N', $ttl) .
pack('n', strlen($rdata)) . $rdata;
$header = pack('n6', $id, $flags, 1, 1, 0, 0);
return $header . $question . $answer;
}
/**
* Checks that a name encodes to a length byte then bytes for each
* label ended by a zero byte, that the root is a single zero byte,
* and that a label longer than the limit is rejected.
* @return void
*/
public function encodeNameTestCase()
{
$this->assertEqual(AsyncDnsResolver::encodeName('a.bc'),
"\x01a\x02bc\x00", "Two labels encode with length bytes.");
$this->assertEqual(AsyncDnsResolver::encodeName(''), "\x00",
"The empty name encodes as the root.");
$long = str_repeat('x', AsyncDnsResolver::MAX_LABEL_LENGTH + 1);
$this->assertEqual(AsyncDnsResolver::encodeName($long), '',
"An over-long label is rejected.");
}
/**
* Checks that a name reads back from a message and that the offset
* lands just past the name, including when the name is reached
* through a compression pointer.
* @return void
*/
public function readNameCompressionTestCase()
{
$head = str_repeat("\x00", AsyncDnsResolver::HEADER_LENGTH);
$data = $head . AsyncDnsResolver::encodeName('mail.example.com');
$pointer_at = strlen($data);
$data .= pack('n', AsyncDnsResolver::COMPRESSION_MASK << 8 |
AsyncDnsResolver::HEADER_LENGTH);
$offset = AsyncDnsResolver::HEADER_LENGTH;
$first = AsyncDnsResolver::readName($data, $offset);
$this->assertEqual($first, 'mail.example.com',
"A plain name reads back whole.");
$this->assertEqual($offset, $pointer_at,
"The offset lands just past the plain name.");
$pointer_offset = $pointer_at;
$second = AsyncDnsResolver::readName($data, $pointer_offset);
$this->assertEqual($second, 'mail.example.com',
"A pointer resolves to the same name.");
$this->assertEqual($pointer_offset, $pointer_at + 2,
"The offset lands just past the two-byte pointer.");
}
/**
* Checks that a text reply's several character-string runs are
* joined into one string and its time to live is read, matching
* how a DKIM key split across runs must be reassembled.
* @return void
*/
public function parseTextReplyTestCase()
{
$reply = $this->textReply(0x1234, 'sel._domainkey.example.com',
['v=DKIM1; k=rsa; ', 'p=ABCDEF'], 3600, 0x8180);
$records = AsyncDnsResolver::parseResponse($reply, 0x1234,
AsyncDnsResolver::TYPE_TXT);
$this->assertEqual(count($records), 1,
"One text record is returned.");
$this->assertEqual($records[0]['txt'],
'v=DKIM1; k=rsa; p=ABCDEF',
"The record's runs are joined into one string.");
$this->assertEqual($records[0]['ttl'], 3600,
"The record's time to live is read.");
}
/**
* Checks that an address reply yields the dotted-quad address in
* the ip field the callers read.
* @return void
*/
public function parseAddressReplyTestCase()
{
$question = AsyncDnsResolver::encodeName('example.com') .
pack('n2', AsyncDnsResolver::TYPE_A,
AsyncDnsResolver::CLASS_INTERNET);
$rdata = inet_pton('93.184.216.34');
$answer = pack('n', AsyncDnsResolver::COMPRESSION_MASK << 8 |
AsyncDnsResolver::HEADER_LENGTH) .
pack('n2', AsyncDnsResolver::TYPE_A,
AsyncDnsResolver::CLASS_INTERNET) . pack('N', 300) .
pack('n', strlen($rdata)) . $rdata;
$reply = pack('n6', 0x2020, 0x8180, 1, 1, 0, 0) . $question .
$answer;
$records = AsyncDnsResolver::parseResponse($reply, 0x2020,
AsyncDnsResolver::TYPE_A);
$this->assertEqual(count($records), 1,
"One address record is returned.");
$this->assertEqual($records[0]['ip'], '93.184.216.34',
"The address reads back as a dotted quad.");
}
/**
* Checks that a mail-exchanger reply yields the preference and the
* target host name, with the target reached through a compression
* pointer as real replies commonly write it.
* @return void
*/
public function parseMxReplyTestCase()
{
$question = AsyncDnsResolver::encodeName('example.com') .
pack('n2', AsyncDnsResolver::TYPE_MX,
AsyncDnsResolver::CLASS_INTERNET);
$rdata = pack('n', 10) . pack('n',
AsyncDnsResolver::COMPRESSION_MASK << 8 |
AsyncDnsResolver::HEADER_LENGTH);
$answer = pack('n', AsyncDnsResolver::COMPRESSION_MASK << 8 |
AsyncDnsResolver::HEADER_LENGTH) .
pack('n2', AsyncDnsResolver::TYPE_MX,
AsyncDnsResolver::CLASS_INTERNET) . pack('N', 900) .
pack('n', strlen($rdata)) . $rdata;
$reply = pack('n6', 0x3030, 0x8180, 1, 1, 0, 0) . $question .
$answer;
$records = AsyncDnsResolver::parseResponse($reply, 0x3030,
AsyncDnsResolver::TYPE_MX);
$this->assertEqual(count($records), 1,
"One mail-exchanger record is returned.");
$this->assertEqual($records[0]['pri'], 10,
"The preference is read.");
$this->assertEqual($records[0]['target'], 'example.com',
"The target host is read through its pointer.");
}
/**
* Checks that a query carries the right question and an EDNS0
* option record, so a large answer is not truncated.
* @return void
*/
public function buildQueryHasEdnsTestCase()
{
list($id, $packet) = AsyncDnsResolver::buildQuery(
'example.com', AsyncDnsResolver::TYPE_TXT);
$header = unpack('nid/nflags/nqd/nan/nns/nar',
substr($packet, 0, AsyncDnsResolver::HEADER_LENGTH));
$this->assertEqual($header['id'], $id,
"The header carries the returned query id.");
$this->assertEqual($header['qd'], 1, "There is one question.");
$this->assertEqual($header['ar'], 1,
"There is one additional record, the EDNS0 option.");
$offset = AsyncDnsResolver::HEADER_LENGTH;
$this->assertEqual(AsyncDnsResolver::readName($packet, $offset),
'example.com', "The question encodes the asked name.");
$qtype = unpack('n', substr($packet, $offset, 2))[1];
$this->assertEqual($qtype, AsyncDnsResolver::TYPE_TXT,
"The question asks for the text record type.");
}
/**
* Checks that a reply is thrown out when its id does not match the
* query, when it reports an error code, or when it was truncated,
* so a stray or partial answer is read as no records.
* @return void
*/
public function rejectsBadReplyTestCase()
{
$good = $this->textReply(0x4040, 'example.com', ['v=x'], 60,
0x8180);
$this->assertEqual(count(AsyncDnsResolver::parseResponse($good,
0x9999, AsyncDnsResolver::TYPE_TXT)), 0,
"A reply with the wrong id is discarded.");
$error = $this->textReply(0x4040, 'example.com', ['v=x'], 60,
0x8183);
$this->assertEqual(count(AsyncDnsResolver::parseResponse($error,
0x4040, AsyncDnsResolver::TYPE_TXT)), 0,
"A reply with an error code yields no records.");
$cut = $this->textReply(0x4040, 'example.com', ['v=x'], 60,
0x8180 | AsyncDnsResolver::FLAG_TRUNCATED);
$this->assertEqual(count(AsyncDnsResolver::parseResponse($cut,
0x4040, AsyncDnsResolver::TYPE_TXT)), 0,
"A truncated reply yields no records.");
}
}