<?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\atto\MailSite;
use seekquarry\atto\MailStorage;
use seekquarry\atto\FileMailStorage;
use seekquarry\atto\RamMailStorage;
use seekquarry\yioop\library\UnitTest;

/**
 * Drives the daemon's command-fiber frame with a stub command that
 * yields once, standing in for a store-lock wait.
 */
class CommandFiberProbeMailSite extends MailSite
{
    /** @var int how many times the stub command ran */
    public $process_calls = 0;
    /**
     * Stub command: yields on its first run (as a contended store lock
     * would), then reports no more input.
     * @param int $key connection key
     * @return bool true on the first run so the caller loops
     */
    protected function processOne($key)
    {
        $this->process_calls++;
        if ($this->process_calls === 1) {
            \Fiber::suspend();
            return true;
        }
        return false;
    }
    /**
     * Registers a minimal connection context so the frame treats the
     * key as a live connection.
     * @param int $key connection key
     * @return void
     */
    public function registerContext($key)
    {
        $this->in_streams[self::CONTEXT][$key] = ['PROTOCOL' => 'IMAP'];
    }
    /**
     * @param int $key connection key
     * @return void
     */
    public function drive($key)
    {
        $this->driveConnectionFiber($key);
    }
    /**
     * @return void
     */
    public function resumeAll()
    {
        $this->resumePendingFibers();
    }
    /**
     * @return int number of parked connection fibers
     */
    public function pendingCount()
    {
        return count($this->pending_fibers);
    }
}

/**
 * Exposes FileMailStorage's protected cooperative lock helper so a test
 * can drive it directly, without standing up a whole mail store.
 */
class CooperativeFlockProbe extends FileMailStorage
{
    /**
     * Public wrapper around the protected cooperativeFlock().
     *
     * @param resource $handle open file handle to lock
     * @param int $operation the lock to take, LOCK_SH or LOCK_EX
     * @return bool true once the lock is held
     */
    public static function probe($handle, $operation)
    {
        return self::cooperativeFlock($handle, $operation);
    }
}

/**
 * Exposes FileMailStorage's bounded folder-index cache so a test can
 * drive its store-and-evict and most-recently-used promotion entirely
 * in memory, without standing up a real mail store on disk.
 */
class FolderIndexCacheProbe extends FileMailStorage
{
    /**
     * Caches an empty index under a key, exercising the bounded
     * insert that drops the least recently used folder when full.
     *
     * @param string $cache_key the cache key to store under
     */
    public function store($cache_key)
    {
        $this->storeFolderIndex($cache_key, [], 0);
    }
    /**
     * Reads back what a cached folder holds as records, so what went into
     * the cache can be checked against what comes out of it.
     *
     * @param string $cache_key the cache key to read
     * @return array|null the records, or null on a miss
     */
    public function fetchRecords($cache_key)
    {
        $cached = $this->cachedFolderIndex($cache_key);
        return ($cached === null) ? null :
            $this->recordsFromCompact($cached);
    }
    /**
     * Reads a cached index back, which promotes its folder to most
     * recently used.
     *
     * @param string $cache_key the cache key to read
     * @return array|null the cached entry, or null on a miss
     */
    public function fetch($cache_key)
    {
        return $this->cachedFolderIndex($cache_key);
    }
    /**
     * Caches an index carrying records under a key, so what the cache
     * reports holding can be checked against what was put in it.
     *
     * @param string $cache_key the cache key to store under
     * @param array $records the records to hold under that key
     */
    public function storeRecords($cache_key, $records)
    {
        $this->storeFolderIndex($cache_key, $records, count($records));
    }
}

/**
 * MailSite subclass that captures queueWrite output into a string
 * (or discards it when measuring memory) so the protected FETCH
 * emitter can be exercised without a real client socket.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForFetch extends MailSite
{
    /**
     * Concatenated bytes passed to queueWrite during a call.
     * @var string
     */
    public $taken = "";
    /**
     * When true, queued bytes are counted but not retained, so a
     * memory measurement reflects only what the emitter itself
     * holds rather than the captured transcript.
     * @var bool
     */
    public $drop_taken = false;
    /**
     * Runs the protected FETCH emitter.
     * @param int $sequence_number message sequence number
     * @param array $meta message metadata (uid, flags, size,
     *      internal_date)
     * @param string $body raw message bytes
     * @param string $items the FETCH items list
     * @param bool $is_uid_variant whether this is UID FETCH
     * @param bool $mark_seen set true when a non-peek body is read
     */
    public function emit($sequence_number, $meta, $body, $items,
        $is_uid_variant, &$mark_seen)
    {
        $this->taken = "";
        $this->imapEmitFetch(0, $sequence_number, $meta, $body,
            $items, $is_uid_variant, $mark_seen);
    }
    /**
     * Captures queued bytes instead of writing to a socket.
     * @param int $key connection key (ignored)
     * @param string $bytes bytes that would be sent to the client
     * @return void
     */
    protected function queueWrite($key, $bytes)
    {
        if (!$this->drop_taken) {
            $this->taken .= $bytes;
        }
    }
}

/**
 * MailSite subclass that exposes the protected handshake state
 * machinery and supplies test sockets and a throwaway certificate.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForHandshake extends MailSite
{
    /**
     * Initializes the stream tables and server globals the
     * handshake methods read, the way listen() would.
     */
    public function __construct()
    {
        $this->default_server_globals = [
            'TLS_HANDSHAKE_TIMEOUT' => 5,
            'CONNECTION_TIMEOUT' => 1800,
        ];
        $this->in_streams = [self::CONNECTION => [], self::DATA => [],
            self::CONTEXT => [], self::MODIFIED_TIME => []];
        $this->out_streams = [self::CONNECTION => [], self::DATA => [],
            self::CONTEXT => [], self::MODIFIED_TIME => []];
    }
    /**
     * Returns a connected pair of non-blocking sockets for tests.
     * @return array two stream resources
     */
    public function makePair()
    {
        $pair = stream_socket_pair(STREAM_PF_UNIX,
            STREAM_SOCK_STREAM, 0);
        stream_set_blocking($pair[0], 0);
        stream_set_blocking($pair[1], 0);
        return $pair;
    }
    /**
     * Starts a handshake through the protected beginHandshake.
     * @param resource $connection socket resource
     * @param int $key stream key
     * @param string $mode handshake mode
     * @param string $protocol protocol name
     * @param string $addr remote address
     * @param int $port remote port
     */
    public function begin($connection, $key, $mode, $protocol,
        $addr, $port)
    {
        $this->beginHandshake($connection, $key, $mode, $protocol,
            $addr, $port, null);
    }
    /**
     * Seeds a pending handshake entry directly, as if a step had
     * returned "needs more I/O", so the cull logic can be tested
     * without a live TLS peer (a socketpair cannot complete a real
     * handshake, and a raw TCP socket's "needs I/O" state is not
     * deterministic in a unit test).
     * @param resource $connection socket resource
     * @param int $key stream key
     * @param float $deadline absolute microtime deadline
     */
    public function seedPending($connection, $key, $deadline)
    {
        $this->in_streams[self::CONNECTION][$key] = $connection;
        $this->in_streams[self::MODIFIED_TIME][$key] = time();
        $this->handshakes[$key] = [
            'mode' => 'implicit',
            'protocol' => 'SMTP',
            'remote_addr' => '198.51.100.7',
            'remote_port' => 12345,
            'listener' => null,
            'deadline' => $deadline,
        ];
    }
    /**
     * Returns the pending-handshakes table for assertions.
     * @return array the handshakes table
     */
    public function pendingHandshakes()
    {
        return $this->handshakes;
    }
    /**
     * Runs the cull pass that evicts stalled handshakes.
     */
    public function cull()
    {
        $this->cullDeadStreams();
    }
}

/**
 * Minimal MailSite subclass that exposes the protected MIME entity
 * parser so the bounds can be tested directly.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForMime extends MailSite
{
    /**
     * Parses raw entity bytes through the protected parser.
     * @param string $bytes the entity bytes to parse
     * @return array the parsed MIME entity
     */
    public function parse($bytes)
    {
        return $this->imapParseEntity($bytes);
    }
}

/**
 * MailSite subclass that drives the protected SMTP DATA-phase consumer
 * with a caller-owned buffer, and records whether the connection was
 * dropped and what was written back, so the message-size cap can be
 * checked without sockets.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForSmtpData extends MailSite
{
    /** @var bool whether the consumer dropped the connection */
    public $was_shut_down = false;
    /** @var string bytes the consumer queued back to the client */
    public $written = "";
    /**
     * Sets just the message-size limit the DATA consumer reads.
     * @param int $max_message_len byte ceiling for one message
     */
    public function __construct($max_message_len)
    {
        $this->default_server_globals = [
            'MAX_MESSAGE_LEN' => $max_message_len,
        ];
    }
    /**
     * Records a drop rather than closing a real socket.
     * @param int $key connection key
     * @return void
     */
    protected function shutdownStream($key)
    {
        $this->was_shut_down = true;
    }
    /**
     * Captures bytes bound for the client rather than queueing them
     * on an out stream.
     * @param int $key connection key
     * @param string $bytes bytes to send
     * @return void
     */
    protected function queueWrite($key, $bytes)
    {
        $this->written .= $bytes;
    }
    /**
     * Runs the protected DATA consumer once and returns its result and
     * the buffer left after it ran.
     * @param int $key connection key
     * @param string $buffer bytes received so far in the DATA phase
     * @param array $context per-session context
     * @return array [bool consumed, string leftover buffer]
     */
    public function feedData($key, $buffer, $context)
    {
        $result = $this->consumeSmtpDataPhase($key, $buffer, $context);
        return [$result, $buffer];
    }
}

/**
 * Gathers the tests for the atto MailSite server in one place: its
 * command fibers, cooperative file locking, fetch memory use, the TLS
 * handshake reaper, MIME parsing, standard-folder provisioning, and
 * UID-restricted expunge. Each group sets up only what it needs through
 * its own helper, called at the start of its cases, since the server is
 * self-contained and the cases run against in-memory or temporary
 * storage with no network.
 *
 * @author Chris Pollett
 */
/**
 * A stand-in mail store that hands back a message of a set size, so a
 * search can be asked to read many large messages without any of them
 * being on disk.
 *
 * @author Chris Pollett
 */
class SizedMessageStore
{
    /**
     * How many bytes each message handed back holds.
     * @var int
     */
    public $size = 0;
    /**
     * How many times a message has been asked for.
     * @var int
     */
    public $asked = 0;
    /**
     * How many times only the front of a message has been asked for.
     * @var int
     */
    public $asked_headers = 0;
    /**
     * How many messages the folder holds.
     * @var int
     */
    public $message_count = 1;
    /**
     * Builds a store whose every message is the given size.
     *
     * @param int $size how many bytes each message holds
     */
    public function __construct($size)
    {
        $this->size = $size;
    }
    /**
     * Hands back a message of the set size, whichever one is asked for.
     *
     * @param string $user whose mail is being read
     * @param string $folder which folder it sits in
     * @param int $uid which message
     * @return string the message
     */
    public function fetchMessage($user, $folder, $uid)
    {
        $this->asked++;
        return str_repeat("x", $this->size);
    }
    /**
     * Hands back a short header block, the way a store that reads the
     * front of a message and stops does.
     *
     * @param string $user whose mail is being read
     * @param string $folder which folder it sits in
     * @param int $uid which message
     * @return string the header block
     */
    public function messageHeaderBytes($user, $folder, $uid)
    {
        $this->asked_headers++;
        return "Subject: message $uid\r\nFrom: mary@example.com";
    }
    /**
     * Hands back what the folder holds, one record per message.
     *
     * @param string $user whose mail is being read
     * @param string $folder which folder it sits in
     * @return array one metadata record per message
     */
    public function listMessages($user, $folder)
    {
        $records = [];
        for ($number = 1; $number <= $this->message_count; $number++) {
            $records[] = ['uid' => $number, 'size' => $this->size,
                'flags' => [], 'internal_date' => 0];
        }
        return $records;
    }
}
/**
 * Mail server that runs a search without a socket under it, so a case
 * can see whether a long search hands the event loop a turn.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForSearchYield extends MailSite
{
    /**
     * Runs the search the server would run for a client.
     *
     * @param string $arguments the words following the SEARCH verb
     * @return void
     */
    public function runSearch($arguments)
    {
        $context = ['AUTH_USER' => 'mary', 'SELECTED' => 'INBOX',
            'STATE' => 'SELECTED'];
        $this->imapCmdSearch(0, 'a1', $arguments, $context, true);
    }
    /**
     * Throws away what would go to the client.
     *
     * @param int $key connection key (ignored)
     * @param string $bytes bytes that would be sent to the client
     * @return void
     */
    protected function queueWrite($key, $bytes)
    {
    }
}
/**
 * Checks the mail server.
 */
class MailSiteTest extends UnitTest
{
    /**
     * How many bytes each message the search case reads holds.
     */
    const SEARCH_MESSAGE_SIZE = 2097152;
    /**
     * How many messages the search case asks the search to read.
     */
    const SEARCH_MESSAGES_READ = 40;
    /**
     * How many messages the folder the yielding case searches holds.
     */
    const SEARCH_LONG_FOLDER = 120;
    /**
     * How many bytes each message in that folder holds.
     */
    const SEARCH_SMALL_MESSAGE = 64;
    /**
     * How many turns the yielding case gives a search before calling
     * it stuck, well above the few it should need.
     */
    const SEARCH_MOST_TURNS = 20;
    /**
     * How many bytes the search may hold when it is done. Well under
     * what one message alone comes to, and far under what holding every
     * message would.
     */
    const SEARCH_MOST_LEFT_HELD = 1048576;
    /**
     * The mail server the search case works against.
     * @var object
     */
    public $search_site;
    /**
     * The stand-in store the search case reads messages from.
     * @var object
     */
    public $search_store;
    /**
     * The method that reads one message against a search's terms,
     * reached through reflection because a server keeps it to itself.
     * @var \ReflectionMethod
     */
    public $eval_search;
    /**
     * The probe mail server a group's cases drive by hand.
     * @var object
     */
    public $probe;
    /**
     * Path of the temporary lock file a cooperative-flock case makes,
     * empty when no such file is outstanding.
     * @var string
     */
    public $path = "";
    /**
     * In-memory mail storage the folder and expunge cases work against.
     * @var object
     */
    public $storage;
    /**
     * The mail server the folder and expunge cases work against.
     * @var object
     */
    public $mail_site;
    /**
     * Name of the mailbox account the folder and expunge cases use.
     * @var string
     */
    public $account;
    /**
     * Uid of the first seeded message in the expunge cases.
     * @var int
     */
    public $first_uid;
    /**
     * Uid of the second seeded message in the expunge cases.
     * @var int
     */
    public $second_uid;
    /**
     * Uid of the third seeded message in the expunge cases.
     * @var int
     */
    public $third_uid;
    /**
     * Required by the test runner. Each group sets up its own
     * state through its helper at the start of its cases, so
     * there is nothing to do here.
     */
    public function setUp()
    {
    }
    /**
     * Builds the state the CommandFiber cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the state the Flock cases need, called
     * at the start of each of those cases.
     */
    /**
     * Makes a throwaway file for the lock cases.
     */
    public function setUpFlock()
    {
        $this->path = tempnam(sys_get_temp_dir(), "msf");
    }
    /**
     * Builds the state the Fetch cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe instance used by every case.
     */
    public function setUpFetch()
    {
        $this->probe = new ProbeMailSiteForFetch();
    }
    /**
     * Builds the state the Handshake cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe used by every case.
     */
    public function setUpHandshake()
    {
        $this->probe = new ProbeMailSiteForHandshake();
    }
    /**
     * Builds the state the Mime cases need, called
     * at the start of each of those cases.
     */
    /**
     * Builds the probe instance used by every case.
     */
    public function setUpMime()
    {
        $this->probe = new ProbeMailSiteForMime();
    }
    /**
     * Builds the state the Folders cases need, called
     * at the start of each of those cases.
     */
    /**
     * Provisions a fresh account with the standard folders.
     */
    public function setUpFolders()
    {
        $this->account = "bob";
        $this->mail_site = new MailSite();
        $this->storage = new RamMailStorage();
        $this->storage->ensureUser($this->account);
        $this->storage->ensureStandardFolders($this->account);
    }
    /**
     * Builds the state the Expunge cases need, called
     * at the start of each of those cases.
     */
    /**
     * Seeds three INBOX messages, two of them flagged deleted.
     */
    public function setUpExpunge()
    {
        $this->account = "alice";
        $this->mail_site = new MailSite();
        $this->storage = new RamMailStorage();
        $this->mail_site->storage($this->storage);
        for ($number = 1; $number <= 3; $number++) {
            $this->storage->appendMessage($this->account, 'INBOX',
                "Subject: message $number\r\n\r\nbody $number\r\n");
        }
        $messages = $this->storage->listMessages($this->account,
            'INBOX');
        $this->first_uid = $messages[0]['uid'];
        $this->second_uid = $messages[1]['uid'];
        $this->third_uid = $messages[2]['uid'];
        $this->storage->setFlags($this->account, 'INBOX',
            $this->first_uid, [MailSite::FLAG_DELETED]);
        $this->storage->setFlags($this->account, 'INBOX',
            $this->second_uid, [MailSite::FLAG_DELETED]);
    }
    /**
     * Removes any temporary lock file a cooperative-flock case
     * created; the other groups leave nothing on disk to clean.
     */
    public function tearDown()
    {
        if ($this->path !== "" && is_file($this->path)) {
            unlink($this->path);
        }
    }
    /**
     * A command that yields parks its fiber; resuming runs it to the end
     * and unparks it.
     */
    public function commandFiberParksAndDrainsTestCase()
    {
        $this->probe = new CommandFiberProbeMailSite();
        $this->probe->registerContext(7);
        $this->probe->drive(7);
        $this->assertTrue($this->probe->pendingCount() === 1,
            "a command that yields parks its fiber");
        $this->assertTrue($this->probe->process_calls === 1,
            "the fiber suspended mid-command");
        $this->probe->resumeAll();
        $this->assertTrue($this->probe->pendingCount() === 0,
            "resuming finishes the command and unparks the fiber");
        $this->assertTrue($this->probe->process_calls === 2,
            "the command ran to completion once resumed");
    }
    /**
     * With another handle holding the file exclusively, a shared-lock
     * read started inside a fiber suspends instead of blocking; once the
     * exclusive lock is released and the fiber is resumed, the read
     * acquires its lock and finishes. This is what keeps one local-mailbox
     * read from freezing every other web connection while the mail daemon
     * is mid-write.
     */
    public function sharedReadYieldsWhileExclusiveHeldTestCase()
    {
        $this->setUpFlock();
        $blocker = fopen($this->path, "rb+");
        flock($blocker, LOCK_EX);
        $reader = fopen($this->path, "rb");
        $acquired = false;
        $fiber = new \Fiber(function () use ($reader, &$acquired) {
            CooperativeFlockProbe::probe($reader, LOCK_SH);
            $acquired = true;
        });
        $fiber->start();
        $this->assertTrue($fiber->isSuspended(),
            "the read suspends while the file is held exclusively");
        $this->assertTrue($acquired === false,
            "the lock has not been taken yet while suspended");
        flock($blocker, LOCK_UN);
        while ($fiber->isSuspended()) {
            $fiber->resume();
        }
        $this->assertTrue($acquired,
            "the read takes its lock once the exclusive lock frees");
        fclose($reader);
        fclose($blocker);
    }
    /**
     * Outside a fiber the helper is a plain blocking lock, so the mail
     * daemon and the command-line tools behave exactly as before; an
     * uncontended shared lock is taken at once.
     */
    public function uncontendedLockTakenDirectlyTestCase()
    {
        $this->setUpFlock();
        $reader = fopen($this->path, "rb");
        CooperativeFlockProbe::probe($reader, LOCK_SH);
        $this->assertTrue(flock($reader, LOCK_UN),
            "an uncontended shared lock is held and can be released");
        fclose($reader);
    }
    /**
     * A delivery's exclusive lock behaves the same way inside a fiber:
     * while another handle holds the file, it suspends; once that frees
     * and the fiber resumes, it takes the lock and reports success.
     */
    public function exclusiveWriteYieldsWhileHeldTestCase()
    {
        $this->setUpFlock();
        $blocker = fopen($this->path, "rb+");
        flock($blocker, LOCK_EX);
        $writer = fopen($this->path, "rb+");
        $result = null;
        $fiber = new \Fiber(function () use ($writer, &$result) {
            $result = CooperativeFlockProbe::probe($writer, LOCK_EX);
        });
        $fiber->start();
        $this->assertTrue($fiber->isSuspended(),
            "the write lock suspends while the file is held");
        flock($blocker, LOCK_UN);
        while ($fiber->isSuspended()) {
            $fiber->resume();
        }
        $this->assertTrue($result === true,
            "the write lock is taken and reports success once free");
        fclose($writer);
        fclose($blocker);
    }
    /**
     * A small FETCH of FLAGS and RFC822.SIZE produces the exact
     * untagged response line with both items inside one set of
     * parentheses.
     */
    public function smallFetchWireFormatTestCase()
    {
        $this->setUpFetch();
        $meta = ['uid' => 7, 'flags' => ['\\Seen'],
            'internal_date' => 0, 'size' => 5];
        $mark_seen = false;
        $this->probe->emit(3, $meta, "hello", "FLAGS RFC822.SIZE",
            false, $mark_seen);
        $output = $this->probe->taken;
        $this->assertEqual(
            "* 3 FETCH (FLAGS (\\Seen) RFC822.SIZE 5)\r\n", $output,
            "a small fetch sends one untagged FETCH line naming the message");
    }
    /**
     * A BODY[] fetch returns the whole message as an IMAP literal
     * with the right byte count and trailing bytes.
     */
    public function bodyFetchEmitsLiteralTestCase()
    {
        $this->setUpFetch();
        $body = str_repeat("A", 1000);
        $meta = ['uid' => 1, 'flags' => [],
            'internal_date' => 0, 'size' => 1000];
        $mark_seen = false;
        $this->probe->emit(1, $meta, $body, "BODY[]", false,
            $mark_seen);
        $output = $this->probe->taken;
        $this->assertTrue(
            str_contains($output, "BODY[] {1000}\r\n"),
            "body literal announces the correct byte count");
        $this->assertTrue(str_ends_with($output, ")\r\n"),
            "response closes the FETCH parenthesis");
        $this->assertTrue($mark_seen,
            "non-peek BODY[] marks the message seen");
    }
    /**
     * Emitting a large body must not retain several copies of it.
     * The emitter queues pieces directly instead of building a
     * parts array and imploding, so peak memory growth across the
     * call should stay well under the multiple-copy cost of the
     * old path (which held three-plus copies). We allow up to two
     * body-sizes of headroom and assert we stay below three.
     */
    public function largeBodyDoesNotMultiplyCopiesTestCase()
    {
        $this->setUpFetch();
        $size = 8388608;
        $body = str_repeat("B", $size);
        $meta = ['uid' => 2, 'flags' => [],
            'internal_date' => 0, 'size' => $size];
        $mark_seen = false;
        $this->probe->drop_taken = true;
        $before = memory_get_usage();
        $this->probe->emit(2, $meta, $body, "BODY[]", false,
            $mark_seen);
        $after = memory_get_usage();
        $growth = $after - $before;
        $this->assertTrue($growth < 3 * $size,
            "emitting a large body holds fewer than three copies");
    }
    /**
     * Starting a handshake without a configured certificate drops
     * the socket: prepareCrypto fails, so nothing is left pending.
     */
    public function beginWithoutCertDropsTestCase()
    {
        $this->setUpHandshake();
        list($near, $far) = $this->probe->makePair();
        $key = (int) $near;
        $this->probe->begin($near, $key, 'implicit', 'SMTP',
            '198.51.100.7', 12345);
        $pending = $this->probe->pendingHandshakes();
        $this->assertTrue(!isset($pending[$key]),
            "no certificate means no pending handshake");
        is_resource($far) && fclose($far);
    }
    /**
     * A pending handshake whose deadline has passed is removed by
     * the cull pass, so a client that opens a socket and never
     * finishes the handshake cannot hold the slot or block.
     */
    public function stalledHandshakeIsEvictedTestCase()
    {
        $this->setUpHandshake();
        list($near, $far) = $this->probe->makePair();
        $key = (int) $near;
        $this->probe->seedPending($near, $key, microtime(true) - 1);
        $before = $this->probe->pendingHandshakes();
        $this->assertTrue(isset($before[$key]),
            "handshake is pending before the cull");
        $this->probe->cull();
        $after = $this->probe->pendingHandshakes();
        $this->assertTrue(!isset($after[$key]),
            "a handshake past its deadline is evicted");
        is_resource($far) && fclose($far);
    }
    /**
     * A pending handshake still within its deadline survives a cull
     * pass, so a handshake that is merely mid-negotiation is not
     * torn down prematurely.
     */
    public function freshHandshakeSurvivesCullTestCase()
    {
        $this->setUpHandshake();
        list($near, $far) = $this->probe->makePair();
        $key = (int) $near;
        $this->probe->seedPending($near, $key, microtime(true) + 30);
        $this->probe->cull();
        $after = $this->probe->pendingHandshakes();
        $this->assertTrue(isset($after[$key]),
            "a handshake within its deadline is kept");
        is_resource($near) && fclose($near);
        is_resource($far) && fclose($far);
    }
    /**
     * A multipart entity that declares an empty boundary must not
     * recurse: an empty boundary degenerates to the delimiter "--",
     * which would otherwise match throughout the body and split it
     * into a runaway number of parts.
     */
    public function emptyBoundaryProducesNoPartsTestCase()
    {
        $this->setUpMime();
        $bytes = "Content-Type: multipart/mixed; boundary=\"\"\r\n" .
            "\r\n" . str_repeat("X", 2000) .
            "\r\n--\r\nfiller\r\n--\r\n";
        $entity = $this->probe->parse($bytes);
        $this->assertEqual('multipart', $entity['type'],
            "empty boundary still parses as multipart at top");
        $this->assertEqual(0, count($entity['parts']),
            "empty boundary yields no child parts, no runaway");
    }
    /**
     * A message whose parts keep declaring themselves multipart
     * with the same boundary must stop at the depth bound rather
     * than recursing until memory is exhausted.
     */
    public function deeplyNestedMultipartIsDepthBoundedTestCase()
    {
        $this->setUpMime();
        $bytes = "Content-Type: multipart/mixed; boundary=b\r\n\r\n";
        $nested = "";
        for ($i = 0; $i < 200; $i++) {
            $nested .= "--b\r\nContent-Type: multipart/mixed; " .
                "boundary=b\r\n\r\n";
        }
        $bytes .= $nested . "--b--\r\n";
        $before = memory_get_usage();
        $entity = $this->probe->parse($bytes);
        $delta = memory_get_usage() - $before;
        $this->assertTrue(is_array($entity['parts']),
            "deeply nested message parses without exhausting memory");
        $this->assertTrue($delta < 16 * 1024 * 1024,
            "depth-bounded parse stays well under the memory limit");
    }
    /**
     * A normal two-part multipart/alternative message still parses
     * into its two child entities, so the bounds do not disturb
     * ordinary mail.
     */
    public function normalMultipartStillParsesTestCase()
    {
        $this->setUpMime();
        $bytes = "Content-Type: multipart/alternative; " .
            "boundary=xyz\r\n\r\n" .
            "--xyz\r\nContent-Type: text/plain\r\n\r\nHello\r\n" .
            "--xyz\r\nContent-Type: text/html\r\n\r\n" .
            "<p>Hi</p>\r\n--xyz--\r\n";
        $entity = $this->probe->parse($bytes);
        $this->assertEqual('multipart', $entity['type'],
            "normal message parses as multipart");
        $this->assertEqual('alternative', $entity['subtype'],
            "subtype preserved");
        $this->assertEqual(2, count($entity['parts']),
            "both child parts are parsed");
    }
    /**
     * A multipart body larger than the structure-parse cap is not
     * split into parts and its body string is not materialized, so
     * a message dominated by a large attachment cannot blow up
     * memory during a BODYSTRUCTURE fetch. The reported size still
     * reflects the real body length.
     */
    public function oversizedBodyIsNotSplitTestCase()
    {
        $this->setUpMime();
        $filler_length = MailSite::MAX_STRUCTURE_PARSE_BYTES + 1024;
        $bytes = "Content-Type: multipart/mixed; boundary=b\r\n\r\n" .
            "--b\r\nContent-Type: application/octet-stream\r\n\r\n" .
            str_repeat("A", $filler_length) . "\r\n--b--\r\n";
        $entity = $this->probe->parse($bytes);
        $this->assertEqual('multipart', $entity['type'],
            "oversized message still parses as multipart at top");
        $this->assertEqual(0, count($entity['parts']),
            "oversized body is left unsplit");
        $this->assertEqual('', $entity['body'],
            "oversized body string is not materialized");
        $this->assertTrue($entity['size'] >= $filler_length,
            "reported size still reflects the real body length");
    }
    /**
     * The four special-use folders plus INBOX exist after
     * provisioning.
     */
    public function standardFoldersProvisionedTestCase()
    {
        $this->setUpFolders();
        foreach ([MailSite::FOLDER_INBOX, MailSite::FOLDER_SENT,
            MailSite::FOLDER_DRAFTS, MailSite::FOLDER_JUNK,
            MailSite::FOLDER_TRASH] as $folder) {
            $this->assertTrue($this->storage->folderExists(
                $this->account, $folder),
                "$folder exists after provisioning");
        }
    }
    /**
     * Provisioning twice does not error or duplicate folders;
     * createFolder is idempotent, which matters because the
     * provisioning runs on every login.
     */
    public function provisioningIsIdempotentTestCase()
    {
        $this->setUpFolders();
        $before = $this->storage->listFolders($this->account);
        $this->storage->ensureStandardFolders($this->account);
        $after = $this->storage->listFolders($this->account);
        sort($before);
        sort($after);
        $this->assertEqual($before, $after,
            "re-provisioning leaves the folder set unchanged");
    }
    /**
     * An Apple-style delete (copy to Trash, flag the source
     * \Deleted, expunge) removes the message from INBOX and
     * leaves a copy in Trash.
     */
    public function copyToTrashThenExpungeDeletesTestCase()
    {
        $this->setUpFolders();
        $uid = $this->storage->appendMessage($this->account,
            'INBOX', "Subject: remove me\r\n\r\nbody\r\n");
        $body = $this->storage->fetchMessage($this->account,
            'INBOX', $uid);
        $this->storage->appendMessage($this->account,
            MailSite::FOLDER_TRASH, $body, [], 0);
        $this->storage->setFlags($this->account, 'INBOX', $uid,
            [MailSite::FLAG_DELETED]);
        $this->storage->expunge($this->account, 'INBOX');
        $this->assertEqual(0, $this->storage->messageCount(
            $this->account, 'INBOX'),
            "the deleted message left INBOX");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_TRASH),
            "a copy of the deleted message is in Trash");
    }
    /**
     * Deleting an existing non-INBOX folder succeeds, and a
     * folder that was never created reports as absent. These are
     * the storage primitives behind the protocol layer answering
     * OK to a DELETE of an already-gone folder (so a client can
     * clear a phantom folder from its cache).
     */
    public function deleteFolderAndPhantomTestCase()
    {
        $this->setUpFolders();
        $this->storage->createFolder($this->account, 'Archive');
        $this->assertTrue($this->storage->folderExists(
            $this->account, 'Archive'),
            "Archive exists before deletion");
        $this->assertTrue($this->storage->deleteFolder(
            $this->account, 'Archive'),
            "deleting an existing folder succeeds");
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Archive'),
            "Archive is gone after deletion");
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Deleted Messages'),
            "a folder that was never created reports as absent");
    }
    /**
     * A pre-existing non-canonical role folder (e.g. "Deleted
     * Messages" for Trash, "Sent Messages" for Sent) is
     * consolidated into the canonical folder: its messages move
     * over and the non-canonical folder is removed. Running the
     * consolidation again is a no-op.
     */
    public function consolidatesNonCanonicalFoldersTestCase()
    {
        $this->setUpFolders();
        $this->storage->createFolder($this->account,
            'Deleted Messages');
        $this->storage->appendMessage($this->account,
            'Deleted Messages', "Subject: old\r\n\r\nx\r\n");
        $this->storage->appendMessage($this->account,
            'Deleted Messages', "Subject: old two\r\n\r\ny\r\n");
        $this->storage->createFolder($this->account,
            'Sent Messages');
        $this->storage->appendMessage($this->account,
            'Sent Messages', "Subject: out\r\n\r\nz\r\n");
        $this->storage->ensureStandardFolders($this->account);
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Deleted Messages'),
            "non-canonical Trash folder was removed");
        $this->assertFalse($this->storage->folderExists(
            $this->account, 'Sent Messages'),
            "non-canonical Sent folder was removed");
        $this->assertEqual(2, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_TRASH),
            "Trash received both consolidated messages");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_SENT),
            "Sent received the consolidated message");
        $this->storage->ensureStandardFolders($this->account);
        $this->assertEqual(2, $this->storage->messageCount(
            $this->account, MailSite::FOLDER_TRASH),
            "a second consolidation run does not duplicate mail");
    }
    /**
     * ensureStandardFolders returns a summary describing each
     * consolidation it performed, and returns an empty list when
     * an account is already clean.
     */
    public function consolidationSummaryReturnedTestCase()
    {
        $this->setUpFolders();
        $clean = $this->storage->ensureStandardFolders(
            $this->account);
        $this->assertEqual([], $clean,
            "a clean account reports no consolidations");
        $this->storage->createFolder($this->account,
            'Deleted Messages');
        $this->storage->appendMessage($this->account,
            'Deleted Messages', "Subject: a\r\n\r\nb\r\n");
        $summary = $this->storage->ensureStandardFolders(
            $this->account);
        $this->assertEqual(1, count($summary),
            "one consolidation is reported");
        $this->assertEqual('Deleted Messages',
            $summary[0]['from'],
            "summary names the removed folder");
        $this->assertEqual(MailSite::FOLDER_TRASH,
            $summary[0]['into'],
            "summary names the canonical target");
        $this->assertEqual(1, $summary[0]['moved'],
            "summary reports the moved message count");
    }
    /**
     * A stored message no larger than a message may be is read whole, and
     * one larger than that is refused rather than pulled into memory.
     *
     * A file on disk can end up bigger than the path that wrote it meant,
     * and reading such a file whole is how one command can take the memory
     * the whole server shares, so its size is checked first. The check
     * lives on the storage every backend shares, since more than one of
     * them reads a stored message this way.
     */
    public function oversizeStoredMessageRefusedTestCase()
    {
        $this->setUpFolders();
        $read = new \ReflectionMethod($this->storage,
            'readWholeMessageFile');
        $path = tempnam(sys_get_temp_dir(), "stored_eml");
        file_put_contents($path, "short message");
        $this->assertEqual($read->invoke($this->storage, $path),
            "short message",
            "a message within the size a message may be is read whole");
        $handle = fopen($path, "wb");
        ftruncate($handle, MailStorage::MAX_MESSAGE_LEN_DEFAULT + 1);
        fclose($handle);
        clearstatcache(true, $path);
        /* The refusal says so somewhere. Catching it here keeps a test run
           to what the tests themselves report, and lets the saying of it
           be checked rather than merely heard. */
        $said = [];
        $this->storage->onStorageLog(function ($message) use (&$said) {
            $said[] = $message;
        });
        $this->assertTrue($read->invoke($this->storage, $path) === false,
            "a message larger than allowed is refused");
        $this->assertEqual(count($said), 1,
            "the refusal is reported once");
        $this->assertTrue(strpos($said[0], "refusing to read it whole") !==
            false, "and says why");
        $this->storage->onStorageLog(null);
        unlink($path);
    }
    /**
     * Multiple registered onLog callbacks each receive MailSite
     * log events in registration order, and emitLog is a no-op
     * when none are registered.
     */
    public function onLogHandlerReceivesEventsTestCase()
    {
        $this->setUpFolders();
        $reflection = new \ReflectionMethod($this->mail_site,
            'emitLog');
        $reflection->invoke($this->mail_site, "before any handler");
        $first = [];
        $second = [];
        $this->mail_site->onLog(function ($message) use (&$first) {
            $first[] = $message;
        });
        $this->mail_site->onLog(function ($message) use (&$second) {
            $second[] = $message;
        });
        $reflection->invoke($this->mail_site, "hello log");
        $this->assertEqual(["hello log"], $first,
            "the first registered handler received the message");
        $this->assertEqual(["hello log"], $second,
            "the second registered handler received the message");
    }
    /**
     * Folders with spaces and hierarchy are stored as literal,
     * nested directories: a space stays a space (no percent
     * encoding) and "Archive/2026" nests under "Archive". Listing
     * returns the slash-joined paths, messages are addressable at
     * each level, and a parent that still has children cannot be
     * deleted.
     */
    public function nestedAndSpacedFoldersTestCase()
    {
        $this->setUpFolders();
        $this->storage->createFolder($this->account,
            'Seekquarry Leads');
        $this->storage->createFolder($this->account, 'Archive/2026');
        $this->storage->appendMessage($this->account,
            'Seekquarry Leads', "Subject: lead\r\n\r\nx\r\n");
        $this->storage->appendMessage($this->account,
            'Archive/2026', "Subject: arc\r\n\r\ny\r\n");
        $folders = $this->storage->listFolders($this->account);
        $this->assertTrue(in_array('Seekquarry Leads', $folders,
            true), "the spaced folder is listed by its literal name");
        $this->assertTrue(in_array('Archive', $folders, true),
            "the intermediate parent folder is listed");
        $this->assertTrue(in_array('Archive/2026', $folders, true),
            "the nested child folder is listed by its path");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, 'Seekquarry Leads'),
            "messages are addressable in the spaced folder");
        $this->assertEqual(1, $this->storage->messageCount(
            $this->account, 'Archive/2026'),
            "messages are addressable in the nested folder");
        $this->assertFalse($this->storage->deleteFolder(
            $this->account, 'Archive'),
            "a parent with a child folder cannot be deleted");
        $this->assertTrue($this->storage->deleteFolder(
            $this->account, 'Archive/2026'),
            "the leaf child folder can be deleted");
        $this->assertTrue($this->storage->deleteFolder(
            $this->account, 'Archive'),
            "the parent can be deleted once it has no children");
    }
    /**
     * When a message in a non-canonical folder cannot be copied
     * into its canonical folder, consolidation keeps the source
     * folder holding exactly the uncopied message (no data loss),
     * moves the rest, and a later retry that can read the message
     * finishes the move without duplicating the mail that already
     * landed in the canonical folder.
     */
    public function partialFailureKeepsSourceTestCase()
    {
        $this->setUpFolders();
        $storage = new class extends RamMailStorage {
            /**
             * uid whose fetchMessage returns false.
             * @var int
             */
            public $fail_uid = 0;
            /**
             * Refuses the designated uid; otherwise defers to the
             * in-memory parent.
             * @param string $user username (no @domain)
             * @param string $folder folder name with hierarchy
             * @param int $uid persistent IMAP unique identifier
             * @return string|false bytes, or false when designated
             */
            public function fetchMessage($user, $folder, $uid)
            {
                if ((int) $uid === $this->fail_uid) {
                    return false;
                }
                return parent::fetchMessage($user, $folder, $uid);
            }
        };
        $storage->ensureUser($this->account);
        $storage->ensureStandardFolders($this->account);
        $storage->createFolder($this->account, 'Sent Messages');
        $good = $storage->appendMessage($this->account,
            'Sent Messages', "Subject: copies\r\n\r\nx\r\n");
        $bad = $storage->appendMessage($this->account,
            'Sent Messages', "Subject: stuck\r\n\r\ny\r\n");
        $storage->fail_uid = $bad;
        $summary = $storage->ensureStandardFolders($this->account);
        $this->assertTrue($storage->folderExists($this->account,
            'Sent Messages'),
            "source folder is kept when a copy fails");
        $this->assertEqual(1, $storage->messageCount($this->account,
            'Sent Messages'),
            "only the uncopied message remains in the source");
        $this->assertEqual(1, $storage->messageCount($this->account,
            MailSite::FOLDER_SENT),
            "the copyable message reached the canonical folder");
        $entry = null;
        foreach ($summary as $consolidation) {
            if ($consolidation['from'] === 'Sent Messages') {
                $entry = $consolidation;
            }
        }
        $this->assertTrue($entry !== null &&
            $entry['failed'] === 1 && $entry['deleted'] === false,
            "summary reports the failure and that the source stayed");
        $storage->fail_uid = 0;
        $storage->ensureStandardFolders($this->account);
        $this->assertFalse($storage->folderExists($this->account,
            'Sent Messages'),
            "a retry that can read the message removes the source");
        $this->assertEqual(2, $storage->messageCount($this->account,
            MailSite::FOLDER_SENT),
            "the retry moves the last message without duplicating");
    }
    /**
     * A UID-restricted expunge removes only the deleted message
     * whose UID is in the restriction, leaving the other deleted
     * message in place.
     */
    public function uidRestrictedExpungeRemovesOnlyNamedTestCase()
    {
        $this->setUpExpunge();
        $removed = $this->storage->expunge($this->account, 'INBOX',
            [$this->first_uid]);
        $this->assertEqual([$this->first_uid], $removed,
            "only the restricted deleted UID is expunged");
        $remaining = [];
        foreach ($this->storage->listMessages($this->account,
            'INBOX') as $meta) {
            $remaining[] = $meta['uid'];
        }
        sort($remaining);
        $expected = [$this->second_uid, $this->third_uid];
        sort($expected);
        $this->assertEqual($expected, $remaining,
            "the other deleted message and the live message stay");
    }
    /**
     * A UID restriction naming a non-deleted message expunges
     * nothing, since only deleted messages are ever removed.
     */
    public function uidRestrictionOnLiveMessageRemovesNothingTestCase()
    {
        $this->setUpExpunge();
        $removed = $this->storage->expunge($this->account, 'INBOX',
            [$this->third_uid]);
        $this->assertEqual([], $removed,
            "a live message named in the set is not expunged");
        $this->assertEqual(3, count($this->storage->listMessages(
            $this->account, 'INBOX')),
            "no messages were removed");
    }
    /**
     * An unrestricted expunge still removes every deleted
     * message, preserving the plain EXPUNGE behavior desktop
     * clients rely on.
     */
    public function unrestrictedExpungeRemovesAllDeletedTestCase()
    {
        $this->setUpExpunge();
        $removed = $this->storage->expunge($this->account, 'INBOX');
        sort($removed);
        $expected = [$this->first_uid, $this->second_uid];
        sort($expected);
        $this->assertEqual($expected, $removed,
            "plain expunge removes all deleted messages");
        $this->assertEqual(1, count($this->storage->listMessages(
            $this->account, 'INBOX')),
            "only the one live message remains");
    }
    /**
     * Caching more folders than the limit allows drops the oldest and
     * holds the count at the limit, so a mail server that visits many
     * mailboxes over its life does not grow without bound.
     */
    public function folderIndexCacheEvictsOldestTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $limit = FileMailStorage::MAX_CACHED_FOLDER_INDEXES;
        for ($i = 0; $i < $limit; $i++) {
            $probe->store("folder" . $i);
        }
        $probe->store("overflow");
        $this->assertTrue($probe->fetch("folder0") === null,
            "the oldest folder is evicted once the limit is passed");
        $this->assertTrue($probe->fetch("overflow") !== null,
            "the newest folder is resident");
    }
    /**
     * The cache reports what it is holding, both the number of folders
     * and the number of message records across them, so a memory line
     * says whether a climb is the cache or something else.
     */
    public function indexCacheSizeIsReportedTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        list($folders, $records) = $probe->indexCacheSize();
        $this->assertEqual($folders, 0, "an empty cache holds no folders");
        $this->assertEqual($records, 0, "an empty cache holds no records");
        $probe->storeRecords("one", [1 => ['uid' => 1], 2 => ['uid' => 2]]);
        $probe->storeRecords("two", [3 => ['uid' => 3]]);
        list($folders, $records) = $probe->indexCacheSize();
        $this->assertEqual($folders, 2, "both folders are counted");
        $this->assertEqual($records, 3,
            "the records across them are counted");
    }
    /**
     * indexCacheSizeReadsThroughToStorageTestCase checks that a
     * MailSite reports its storage backend's index-cache size, since
     * the heartbeat asks the server for that number rather than the
     * storage directly. A file-backed store hands back what it holds; a
     * store that keeps no such cache, such as the in-memory one, reads
     * as a zero pair rather than raising an undefined-method error, the
     * fault that had stopped the heartbeat from logging at all.
     */
    public function indexCacheSizeReadsThroughToStorageTestCase()
    {
        $file_site = new MailSite();
        $file_store = new FolderIndexCacheProbe(sys_get_temp_dir());
        $file_store->storeRecords("one", [1 => ['uid' => 1]]);
        $file_store->storeRecords("two", [2 => ['uid' => 2],
            3 => ['uid' => 3]]);
        $file_site->storage($file_store);
        list($folders, $records) = $file_site->indexCacheSize();
        $this->assertEqual($folders, 2,
            "the server reports the store's folder count");
        $this->assertEqual($records, 3,
            "the server reports the store's record count");
        $ram_site = new MailSite();
        $ram_site->storage(new RamMailStorage());
        list($ram_folders, $ram_records) = $ram_site->indexCacheSize();
        $this->assertEqual($ram_folders, 0,
            "a store with no such cache reports no folders");
        $this->assertEqual($ram_records, 0,
            "a store with no such cache reports no records");
    }
    /**
     * A folder held in the cache comes back as the records that went into
     * it, though it is kept as plain values beside each other rather than
     * a small array for every message.
     */
    public function cachedIndexKeepsWhatWentInTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $probe->storeRecords("kept", [
            7 => ['uid' => 7, 'size' => 4096, 'internal_date' => 1750,
                'flags' => ['\\Seen', '\\Flagged']],
            9 => ['uid' => 9, 'size' => 512, 'internal_date' => 1751,
                'flags' => []]]);
        $back = $probe->fetchRecords("kept");
        $this->assertEqual($back[7]['size'], 4096, "the size comes back");
        $this->assertEqual($back[7]['internal_date'], 1750,
            "the date comes back");
        $this->assertEqual($back[7]['flags'], ['\\Seen', '\\Flagged'],
            "both flags come back");
        $this->assertEqual($back[9]['flags'], [],
            "a message with no flags comes back with none");
        $this->assertEqual($back[9]['uid'], 9, "the uid comes back");
    }
    /**
     * Caching folders whose records together pass the ceiling drops the
     * least recently used until they fit, so one large mailbox cannot sit
     * resident merely because few folders are held.
     */
    public function indexCacheEvictsOnRecordsTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $half = intdiv(FileMailStorage::MAX_CACHED_INDEX_RECORDS, 2);
        $probe->storeRecords("first", array_fill(0, $half, ['uid' => 1]));
        $probe->storeRecords("second", array_fill(0, $half, ['uid' => 2]));
        list($folders, ) = $probe->indexCacheSize();
        $this->assertEqual($folders, 2, "two half-sized folders both fit");
        $probe->storeRecords("third", array_fill(0, $half, ['uid' => 3]));
        $this->assertTrue($probe->fetch("first") === null,
            "the oldest is dropped to make room");
        list(, $records) = $probe->indexCacheSize();
        $this->assertTrue($records <=
            FileMailStorage::MAX_CACHED_INDEX_RECORDS,
            "what is held stays inside the ceiling");
    }
    /**
     * A folder holding more records than the ceiling allows is not cached
     * at all, rather than emptying the cache for something that still
     * would not fit.
     */
    public function oversizeFolderIsNotCachedTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $probe->storeRecords("small", [['uid' => 1]]);
        $probe->storeRecords("huge", array_fill(0,
            FileMailStorage::MAX_CACHED_INDEX_RECORDS + 1, ['uid' => 2]));
        $this->assertTrue($probe->fetch("huge") === null,
            "the folder too large to fit is not held");
    }
    /**
     * Reading a folder marks it most recently used, so a later
     * overflow evicts a different, untouched folder rather than the
     * one still in active use.
     */
    public function folderIndexCachePromotesOnReadTestCase()
    {
        $probe = new FolderIndexCacheProbe(sys_get_temp_dir());
        $limit = FileMailStorage::MAX_CACHED_FOLDER_INDEXES;
        for ($i = 0; $i < $limit; $i++) {
            $probe->store("folder" . $i);
        }
        $probe->fetch("folder0");
        $probe->store("overflow");
        $this->assertTrue($probe->fetch("folder0") !== null,
            "the folder read most recently is the one kept");
        $this->assertTrue($probe->fetch("folder1") === null,
            "the next-oldest untouched folder is evicted instead");
    }
    /**
     * A DATA body that runs past the size limit without ever sending its
     * end-of-message marker drops the connection instead of buffering
     * without bound, the crash this cap prevents.
     */
    public function unterminatedOversizeDataDropsTestCase()
    {
        $probe = new ProbeMailSiteForSmtpData(1024);
        $buffer = str_repeat("x", 4096);
        [$result, $left] = $probe->feedData(7,
            $buffer, ['STATE' => 'DATA']);
        $this->assertFalse($result,
            "an unterminated over-limit body reports no command consumed");
        $this->assertTrue($probe->was_shut_down,
            "the connection is dropped rather than buffered without bound");
    }
    /**
     * A partial body still under the limit waits for more input and keeps
     * the connection, so the cap does not fire on ordinary streaming.
     */
    public function underLimitPartialDataWaitsTestCase()
    {
        $probe = new ProbeMailSiteForSmtpData(1024);
        $buffer = str_repeat("y", 200);
        [$result, $left] = $probe->feedData(7,
            $buffer, ['STATE' => 'DATA']);
        $this->assertFalse($result,
            "a short partial body reports no command consumed yet");
        $this->assertFalse($probe->was_shut_down,
            "an under-limit partial body keeps the connection open");
    }
    /**
     * An over-limit body that does arrive with its terminator still gets
     * the 552 reply and keeps the connection, the courteous path the cap
     * leaves intact.
     */
    public function terminatedOversizeDataGets552TestCase()
    {
        $probe = new ProbeMailSiteForSmtpData(1024);
        $buffer = str_repeat("z", 4096) . "\r\n.\r\n";
        [$result, $left] = $probe->feedData(7,
            $buffer, ['STATE' => 'DATA', 'MAILFROM' => 'a@b.c',
            'RCPTTO' => []]);
        $this->assertTrue($result,
            "a terminated over-limit body is consumed and answered");
        $this->assertTrue(strpos($probe->written, "552") !== false,
            "the client is told the message exceeds the size limit");
        $this->assertFalse($probe->was_shut_down,
            "the terminated over-limit path replies instead of dropping");
    }
    /**
     * A connection whose output buffer grows past the warning line is
     * reported once, with the connection named, and is reported again only
     * after the buffer has fallen back and grown past the line a second
     * time. This is the signal that would have named the runaway before it
     * reached the memory ceiling.
     */
    public function runawayOutputBufferIsWarnedOnceTestCase()
    {
        $probe = new ProbeMailSiteForOutputWarning();
        $probe->setWarnBytes(100);
        $messages = [];
        $probe->onLog(function ($message) use (&$messages) {
            $messages[] = $message;
        });
        $probe->openConnection(42, 'IMAP', 'SELECTED');
        $probe->queue(42, str_repeat("a", 60));
        $this->assertEqual(0, count($messages),
            "a buffer under the warning line says nothing");
        $probe->queue(42, str_repeat("a", 60));
        $this->assertEqual(1, count($messages),
            "crossing the warning line logs exactly once");
        $this->assertTrue(strpos($messages[0], "connection 42") !== false,
            "the warning names the connection that is misbehaving");
        $probe->queue(42, "a");
        $this->assertEqual(1, count($messages),
            "staying over the line does not log again");
    }
    /**
     * The heartbeat's largest-output-buffer figure reflects the biggest
     * queued buffer across connections and returns to zero when nothing is
     * queued, so a rising figure on the heartbeat is the early sign of the
     * same runaway.
     */
    public function largestOutputBufferReflectsTheBiggestQueueTestCase()
    {
        $probe = new ProbeMailSiteForOutputWarning();
        $this->assertEqual(0, $probe->largestOutputBuffer(),
            "with nothing queued the largest buffer is zero");
        $probe->openConnection(1, 'IMAP', 'SELECTED');
        $probe->openConnection(2, 'IMAP', 'SELECTED');
        $probe->queue(1, str_repeat("x", 100));
        $probe->queue(2, str_repeat("x", 250));
        $this->assertEqual(250, $probe->largestOutputBuffer(),
            "the largest of several queued buffers is reported");
    }
    /**
     * A connection whose unconsumed input grows past the warning line is
     * reported once, with the connection named, and again only after the
     * buffer falls back and grows past the line a second time.
     */
    public function runawayInputBufferIsWarnedOnceTestCase()
    {
        $probe = new ProbeMailSiteForOutputWarning();
        $probe->setInputWarnBytes(100);
        $messages = [];
        $probe->onLog(function ($message) use (&$messages) {
            $messages[] = $message;
        });
        $probe->openConnection(7, 'IMAP', 'NONAUTH');
        $probe->feedInput(7, str_repeat("a", 60));
        $this->assertEqual(0, count($messages),
            "input under the warning line says nothing");
        $probe->feedInput(7, str_repeat("a", 60));
        $this->assertEqual(1, count($messages),
            "crossing the input warning line logs exactly once");
        $this->assertTrue(strpos($messages[0], "connection 7") !== false,
            "the input warning names the connection");
        $probe->feedInput(7, "a");
        $this->assertEqual(1, count($messages),
            "staying over the line does not log again");
    }
    /**
     * The heartbeat's largest-input-buffer figure reflects the biggest
     * unconsumed buffer across connections and is zero when nothing is
     * buffered.
     */
    public function largestInputBufferReflectsTheBiggestBufferTestCase()
    {
        $probe = new ProbeMailSiteForOutputWarning();
        $this->assertEqual(0, $probe->largestInputBuffer(),
            "with nothing buffered the largest input is zero");
        $probe->openConnection(1, 'IMAP', 'NONAUTH');
        $probe->openConnection(2, 'IMAP', 'NONAUTH');
        $probe->setInputWarnBytes(1000);
        $probe->feedInput(1, str_repeat("y", 100));
        $probe->feedInput(2, str_repeat("y", 300));
        $this->assertEqual(300, $probe->largestInputBuffer(),
            "the largest of several input buffers is reported");
    }
    /**
     * A LOGIN that announces a literal username larger than a credential
     * ever is gets refused outright, with no "+ Ready" inviting the body,
     * so the server never buffers the announced bytes. This is the read
     * path that took the mail server out of memory.
     */
    public function oversizedLoginLiteralIsRefusedTestCase()
    {
        $probe = new ProbeMailSiteForOutputWarning();
        $probe->openConnection(9, 'IMAP', 'NONAUTH');
        $big = MailSite::MAX_CREDENTIAL_LITERAL_BYTES + 1;
        $response = $probe->runLogin(9, "{" . $big . "}");
        $this->assertTrue(strpos($response, "TOOBIG") !== false,
            "an oversized LOGIN literal is told it is too big");
        $this->assertTrue(strpos($response, "+ Ready") === false,
            "the server does not invite the oversized literal body");
    }
    /**
     * A LOGIN literal within the credential limit is still accepted into
     * the continuation flow, so the guard rejects only the abusive case.
     */
    public function smallLoginLiteralIsAcceptedTestCase()
    {
        $probe = new ProbeMailSiteForOutputWarning();
        $probe->openConnection(10, 'IMAP', 'NONAUTH');
        $response = $probe->runLogin(10, "{5}");
        $this->assertTrue(strpos($response, "+ Ready") !== false,
            "a small LOGIN literal is invited as before");
        $this->assertTrue(strpos($response, "TOOBIG") === false,
            "a small LOGIN literal is not refused");
    }
    /**
     * Builds the state the search case needs: a server without its
     * constructor, which would want to listen on a port a search has
     * nothing to do with, and a stand-in store in place of the one that
     * reads from disk.
     */
    public function setUpSearch()
    {
        $reflection = new \ReflectionClass(MailSite::class);
        $this->search_site = $reflection->newInstanceWithoutConstructor();
        $this->search_store = new SizedMessageStore(
            self::SEARCH_MESSAGE_SIZE);
        /* what a class keeps to itself has been reachable through
           reflection without asking since PHP 8.1, and asking is an
           error as of 8.5, so nothing is asked for here. */
        $storage = new \ReflectionProperty(MailSite::class,
            "mail_storage");
        $storage->setValue($this->search_site, $this->search_store);
        $this->eval_search = new \ReflectionMethod(MailSite::class,
            "imapEvalSearch");
    }
    /**
     * Reading many large messages against a search leaves almost
     * nothing held. The reader that walks a search's terms names
     * itself, so it can call itself for a group of them, and a thing
     * that names itself is not let go when the count of what points at
     * it drops. It held the message body through the reader of it, so
     * a search over a large folder once kept every message it had read
     * and ran the server out of memory.
     */
    public function searchLetsGoOfEachMessageTestCase()
    {
        $this->setUpSearch();
        /* a token is a pair whose second part carries the word, so
           these two say: the message text holds "needle" */
        $terms = [['ATOM', 'TEXT'], ['STRING', 'needle']];
        $started = memory_get_usage();
        for ($i = 0; $i < self::SEARCH_MESSAGES_READ; $i++) {
            $tokens = $terms;
            $this->eval_search->invokeArgs($this->search_site,
                [&$tokens, ['uid' => $i + 1,
                'size' => self::SEARCH_MESSAGE_SIZE, 'flags' => []],
                $i + 1, self::SEARCH_MESSAGES_READ,
                self::SEARCH_MESSAGES_READ, "mary", "INBOX"]);
        }
        $held = memory_get_usage() - $started;
        $this->assertEqual($this->search_store->asked,
            self::SEARCH_MESSAGES_READ, "every message was read");
        $this->assertTrue($held < self::SEARCH_MOST_LEFT_HELD,
            "and almost nothing is still held, rather than all of them");
    }
    /**
     * A search for words in a header reads only the front of each
     * message. Reading the whole of every message in a folder for a
     * subject line held the mail server's one event loop for minutes
     * at a time, which its watchdog reported as being stuck.
     */
    public function headerSearchReadsOnlyHeadersTestCase()
    {
        $this->setUpSearch();
        /* a token is a pair whose second part carries the word, so
           these two say: the subject line holds "message" */
        $tokens = [['ATOM', 'SUBJECT'], ['STRING', 'message']];
        $matched = $this->eval_search->invokeArgs($this->search_site,
            [&$tokens, ['uid' => 1, 'size' => self::SEARCH_MESSAGE_SIZE,
            'flags' => []], 1, 1, 1, "mary", "INBOX"]);
        $this->assertTrue($matched,
            "a subject that holds the word is a hit");
        $this->assertEqual(0, $this->search_store->asked,
            "and no message was read whole to find it");
        $this->assertEqual(1, $this->search_store->asked_headers,
            "only the front of the message was read");
    }
    /**
     * A search for words anywhere in a message still reads the whole
     * message, since the words may be anywhere in it.
     */
    public function textSearchStillReadsTheMessageTestCase()
    {
        $this->setUpSearch();
        $tokens = [['ATOM', 'TEXT'], ['STRING', 'xxx']];
        $matched = $this->eval_search->invokeArgs($this->search_site,
            [&$tokens, ['uid' => 1, 'size' => self::SEARCH_MESSAGE_SIZE,
            'flags' => []], 1, 1, 1, "mary", "INBOX"]);
        $this->assertTrue($matched, "text held in the message is a hit");
        $this->assertEqual(1, $this->search_store->asked,
            "the message was read whole, as such a search needs");
    }
    /**
     * A search over many messages hands the event loop a turn as it
     * goes, so other connections are answered while it runs. Without
     * this the server's one process stayed inside a single search for
     * as long as it took, and its watchdog reported the loop stuck.
     */
    public function longSearchHandsTheLoopATurnTestCase()
    {
        $reflection = new \ReflectionClass(
            ProbeMailSiteForSearchYield::class);
        $probe = $reflection->newInstanceWithoutConstructor();
        $store = new SizedMessageStore(self::SEARCH_SMALL_MESSAGE);
        $store->message_count = self::SEARCH_LONG_FOLDER;
        $storage = new \ReflectionProperty(MailSite::class,
            "mail_storage");
        $storage->setValue($probe, $store);
        $fiber = new \Fiber(function () use ($probe) {
            $probe->runSearch("ALL");
        });
        $fiber->start();
        $this->assertTrue(!$fiber->isTerminated(),
            "the search stops part way rather than running to the end");
        $turns = 0;
        while (!$fiber->isTerminated() &&
            $turns < self::SEARCH_MOST_TURNS) {
            $fiber->resume();
            $turns++;
        }
        $this->assertTrue($fiber->isTerminated(),
            "and it finishes once the loop hands it back its turn");
        $this->assertEqual(intdiv(self::SEARCH_LONG_FOLDER,
            MailSite::SEARCH_YIELD_MESSAGES), $turns,
            "handing back once for every batch it looks at");
    }
    /**
     * The headers of a message end at the first blank line whether or
     * not its lines end in a carriage return. Looking only for the
     * pair of carriage-return newlines left every line of the body
     * standing as a header line to be read for a colon, and let a line
     * of the body pass itself off as a header.
     */
    public function headersEndAtABlankLineEitherWayTestCase()
    {
        $site = new MailSite();
        $parse = new \ReflectionMethod(MailSite::class,
            "imapParseHeaders");
        $crlf = $parse->invokeArgs($site, ["Subject: real\r\n" .
            "\r\nSubject: written in the body\r\n"]);
        $this->assertEqual("real", $crlf['subject'],
            "the subject is the one above the blank line");
        $newlines = $parse->invokeArgs($site, ["Subject: real\n" .
            "\nSubject: written in the body\n"]);
        $this->assertEqual("real", $newlines['subject'],
            "and the same where lines end in a bare newline");
    }
    /**
     * Checks that text written in some other character set is turned
     * into the one the mail server works in, and that a name this build
     * of PHP has never heard of leaves the text as it came rather than
     * stopping the server. A message naming windows-1250 in a subject
     * line brought the server down in the middle of a search.
     */
    public function textInOtherCharsetsTestCase()
    {
        $site = new MailSite();
        $this->assertEqual($site->toUtf8FromCharset("plain", "UTF-8"),
            "plain", "text already in the working set is left alone");
        $this->assertEqual($site->toUtf8FromCharset("plain",
            "NOT-A-CHARSET"), "plain",
            "a name nothing knows leaves the text as it came");
        $dashed = $site->toUtf8FromCharset("Bags \x96 Their",
            "WINDOWS-1250");
        $this->assertTrue(strpos($dashed, "\xe2\x80\x93") !== false,
            "a windows-1250 dash becomes the dash it stands for");
        $accented = $site->toUtf8FromCharset("caf\xe9", "WINDOWS-1252");
        $this->assertEqual($accented, "caf\xc3\xa9",
            "a windows-1252 byte is read as the letter it means");
        $reader = new \ReflectionMethod($site, "imapDecodeMimeHeader");
        $header = $reader->invoke($site,
            "=?windows-1250?Q?Bags_=96_Their_story?=");
        $this->assertTrue(strpos($header, "Bags") === 0,
            "a subject naming an unknown set still decodes");
    }
}

/**
 * MailSite subclass that lets a test open a bare connection and queue
 * output through the real queueWrite, so the runaway-output warning and
 * the largest-buffer reporter can be exercised without a socket.
 *
 * @author Chris Pollett chris@pollett.org
 */
class ProbeMailSiteForOutputWarning extends MailSite
{
    /**
     * Registers a connection with just the context the warning reads, and
     * a placeholder connection handle so queueWrite treats it as live.
     * @param int $key connection key
     * @param string $protocol protocol label for the context
     * @param string $state state label for the context
     * @return void
     */
    public function openConnection($key, $protocol, $state)
    {
        $this->in_streams[self::CONNECTION][$key] = $key;
        $this->in_streams[self::CONTEXT][$key] = [
            'PROTOCOL' => $protocol,
            'STATE' => $state,
        ];
    }
    /**
     * Queues bytes through the real queueWrite so the warning path runs.
     * @param int $key connection key
     * @param string $bytes bytes to queue
     * @return void
     */
    public function queue($key, $bytes)
    {
        $this->queueWrite($key, $bytes);
    }
    /**
     * Lowers the warning line so a test need not queue the full default.
     * @param int $bytes byte count at which to warn
     * @return void
     */
    public function setWarnBytes($bytes)
    {
        $this->output_buffer_warn_bytes = $bytes;
    }
    /**
     * Appends bytes to a connection's input buffer and runs the runaway-
     * input check, so the read-side warning is exercised without a socket.
     * @param int $key connection key
     * @param string $bytes bytes to buffer as input
     * @return void
     */
    public function feedInput($key, $bytes)
    {
        if (!isset($this->in_streams[self::DATA][$key])) {
            $this->in_streams[self::DATA][$key] = "";
        }
        $this->in_streams[self::DATA][$key] .= $bytes;
        $this->warnOnRunawayInput($key);
    }
    /**
     * Lowers the input warning line so a test need not send the default.
     * @param int $bytes byte count at which to warn
     * @return void
     */
    public function setInputWarnBytes($bytes)
    {
        $this->input_buffer_warn_bytes = $bytes;
    }
    /**
     * Runs LOGIN with the given raw argument on an open connection and
     * returns whatever the command queued back, so a test can check how an
     * oversized literal announcement is answered.
     * @param int $key connection key
     * @param string $arguments the text after LOGIN on the command line
     * @return string the bytes queued to the client in response
     */
    public function runLogin($key, $arguments)
    {
        $this->in_streams[self::CONTEXT][$key]['TLS_ACTIVE'] = true;
        $this->out_streams[self::DATA][$key] = "";
        $context = &$this->in_streams[self::CONTEXT][$key];
        $this->imapCmdLogin($key, "a1", $arguments, $context);
        return isset($this->out_streams[self::DATA][$key]) ?
            $this->out_streams[self::DATA][$key] : "";
    }
}
