<?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.");
    }

    /**
     * ednsRecordIsWhereItBelongsTestCase reads past the question into
     * the option record itself, which says what size of answer this
     * resolver can take. Its name is the root, written as one zero byte;
     * writing that byte as the four letters of its escape put three
     * spare bytes into every query, so every name server threw the
     * packet away and every domain looked as though it had no mail
     * exchanger at all. The case above reads only the header and the
     * question, so those three bytes sat where nothing looked.
     * @return void
     */
    public function ednsRecordIsWhereItBelongsTestCase()
    {
        list($id, $packet) = AsyncDnsResolver::buildQuery(
            'example.com', AsyncDnsResolver::TYPE_MX);
        /* header, then the encoded name, then its type and class */
        $offset = AsyncDnsResolver::HEADER_LENGTH;
        AsyncDnsResolver::readName($packet, $offset);
        $offset += 4;
        $this->assertEqual(ord(substr($packet, $offset, 1)), 0,
            "The option record's name is the root, one zero byte.");
        $option = unpack('ntype/nsize',
            substr($packet, $offset + 1, 4));
        $this->assertEqual($option['type'], AsyncDnsResolver::TYPE_OPT,
            "The record after that name says it is an option record.");
        $this->assertEqual($option['size'], AsyncDnsResolver::UDP_BUFFER,
            "and carries the answer size this resolver can take.");
        $this->assertEqual(strlen($packet), $offset + 11,
            "The packet ends with that record and holds nothing else.");
    }
}
