# **************************************************************************
#                                  _   _ ____  _
#  Project                     ___| | | |  _ \| |
#                             / __| | | | |_) | |
#                            | (__| |_| |  _ <| |___
#                             \___|\___/|_| \_\_____|
#
# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at https://curl.se/docs/copyright.html.
#
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
# copies of the Software, and permit persons to whom the Software is
# furnished to do so, under the terms of the COPYING file.
#
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
# KIND, either express or implied.
#
# SPDX-License-Identifier: curl
#
# **************************************************************************

# This is a server designed for the curl test suite.
#
# In December 2009 we started remaking the server to support more protocols
# that are similar in spirit. Like POP3, IMAP and SMTP in addition to the FTP
# it already supported since a long time. Note that it still only supports one
# protocol per invoke. You need to start multiple servers to support multiple
# protocols simultaneously.
#
# It is meant to exercise curl, it is not meant to be a fully working
# or even very standard compliant server.
#
# You may optionally specify port on the command line, otherwise it'll
# default to port 8921.
#
# All socket/network/TCP related stuff is done by the 'sockfilt' program.

from typing import List

import serverhelp
from serverhelp import log_msg
from pathhelp import exe_ext
from directories import ftp_contentlist, wildcard_filesize, wildcard_getfile
from processhelp import clear_advisor_read_lock

BEGIN {
push(@INC, $ENV{'srcdir'}) if(defined $ENV{'srcdir'});
push(@INC, ".");
}

use IPC::Open2;
use Digest::MD5;
use File::Basename;

use globalconfig qw(
    $SERVERCMD
    );

class FtpServer:

#**********************************************************************
# global vars...
#
my $verbose = 0;    # set to 1 for debugging
my $idstr = ""     # server instance string
my $idnum = 1;      # server instance number
my $ipvnum = 4;     # server IPv number (4 or 6)
    self.proto: str = "ftp"  # default server protocol
my $srcdir;         # directory where ftpserver.pl is located
my $srvrname;       # server name for presentation purposes
my $cwd_testno;     # test case numbers extracted from CWD command
my $testno = 0;     # test case number (read from server.cmd)
my $path   = '.';
my $logdir = $path .'/log';
$piddir: Path = None

#**********************************************************************
# global vars used for server address and primary listener port
#
my $port = 8921;              # default primary listener port
listen_addr: str = "127.0.0.1"  # default address for listener port

#**********************************************************************
# global vars used for file names
#
my $PORTFILE="ftpserver.port"; # server port file name
$pidfile:  Path = None  # server pid file name
$portfile: Path = None  # server port file path
my $mainsockf_pidfile;  # pid file for primary connection sockfilt process
my $mainsockf_logfile;  # log file for primary connection sockfilt process
my $datasockf_pidfile;  # pid file for secondary connection sockfilt process
my $datasockf_logfile;  # log file for secondary connection sockfilt process

#**********************************************************************
# global vars used for server logs advisor read lock handling
#
my $serverlogs_lockfile;
serverlogs_locked: bool = False

#**********************************************************************
# global vars used for child processes PID tracking
#
my $sfpid;        # PID for primary connection sockfilt process
my $slavepid;     # PID for secondary connection sockfilt process

#**********************************************************************
# global typeglob filehandle vars to read/write from/to sockfilters
#
local *SFREAD;    # used to read from primary connection
local *SFWRITE;   # used to write to primary connection
local *DREAD;     # used to read from secondary connection
local *DWRITE;    # used to write to secondary connection

my $sockfilt_timeout = 5;  # default timeout for sockfilter eXsysreads

#**********************************************************************
# global vars which depend on server protocol selection
#
    self.command_func: Dict  # protocol command specific function callbacks
    self.display_text: Dict  # text returned to client before callback runs

#**********************************************************************
# global vars customized for each test from the server commands file
#
    self.ctrl_delay:  float   # set if server should throttle ctrl stream
    self.data_delay:  float   # set if server should throttle data stream
$retrweirdo: bool    # set if ftp server should use RETRWEIRDO
$retrnosize: bool    # set if ftp server should use RETRNOSIZE
my $retrsize;      # set if ftp server should use RETRSIZE
my $pasvbadip;     # set if ftp server should use PASVBADIP
    self.no_save:     bool        # set if ftp server should not save uploaded data
    self.ftp_nodataconn:    bool  # set if ftp srvr doesn't establish or accepts data channel
    self.ftp_nodataconn425: bool  # set if ftp srvr doesn't establish data ch and replies 425
    self.ftp_nodataconn421: bool  # set if ftp srvr doesn't establish data ch and replies 421
    self.ftp_nodataconn150: bool  # set if ftp srvr doesn't establish data ch and replies 150
my $storeresp;
my $postfetch;
    self.capabilities: List = [] # set if server supports capability commands
    self.auth_mechs:   List = []  # set if server supports authentication commands
my %fulltextreply; #
my %commandreply;  #
my %customcount;   #
my %delayreply;    #

#**********************************************************************
# global variables for to test ftp wildcardmatching or other test that
# need flexible LIST responses.. and corresponding files.
# ftp_target_dir is keeping the fake "name" of LIST directory.
#
    self.ftp_listparserstate: str = None
    self.ftp_target_dir = ""

#**********************************************************************
# global variables used when running a ftp server to keep state info
# relative to the secondary or data sockfilt process. Values of these
# variables should only be modified using datasockf_set_state() sub, given
# that they are closely related and relationship is a bit awkward.
#
    self.datasockf_state: str = "STOPPED"  # see datasockf_set_state() sub
    self.datasockf_mode:  str = "none"     # ["none", "active", "passive"]
    self.datasockf_runs:  str = "no"       # ["no", "yes"]
    self.datasockf_conn:  str = "no"       # ["no", "yes"]

#**********************************************************************
# global vars used for signal handling
#
got_exit_signal: bool = False  # set if program should finish execution ASAP
my $exit_signal;          # first signal handled in exit_signal_handler

#**********************************************************************
# Mail related definitions
#
my $TEXT_PASSWORD  = "secret"
my $POP3_TIMESTAMP = f"<1972.987654321\@curl>"

def exit_signal_handler($signame):
    """
    exit_signal_handler will be triggered to indicate that the program
    should finish its execution in a controlled way as soon as possible.
    For now, program will also terminate from within this handler.
    """
    my  = shift;
    # For now, simply mimic old behavior.
    killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose)
    $pidfile.unlink()
    $portfile.unlink()
    if serverlogs_locked:
        serverlogs_locked = False
        clear_advisor_read_lock($serverlogs_lockfile)
    exit;

sub ftpmsg {
  # append to the server.input file
  open(my $input, ">>", f"{$logdir}/server$idstr.input") ||
    log_msg(f"failed to open $logdir/server$idstr.input")

  print $input @_;
  close($input);

  # use this, open->print->close system only to make the file
  # open as little as possible, to make the test suite run
  # better on Windows/Cygwin
}

sub eXsysread {
    # eXsysread is a wrapper around perl's sysread() function. This will
    # repeat the call to sysread() until it has actually read the complete
    # number of requested bytes or an unrecoverable condition occurs.
    # On success returns a positive value, the number of bytes requested.
    # On failure or timeout returns zero.
    #
    my $FH      = shift;
    my $scalar  = shift;
    my $nbytes  = shift;
    my $timeout = shift; # A zero timeout disables eXsysread() time limit
    #
    my $time_limited = 0;
    my $timeout_rest = 0;
    my $start_time = 0;
    my $nread  = 0;
    my $rc;

    $$scalar = ""

    if((not defined $nbytes) || ($nbytes < 1)) {
        log_msg("Error: eXsysread() failure: "
                "length argument must be positive")
        return 0
    }
    if((not defined $timeout) || ($timeout < 0)) {
        log_msg("Error: eXsysread() failure: "
                "timeout argument must be zero or positive")
        return 0
    }
    if($timeout > 0) {
        # caller sets eXsysread() time limit
        $time_limited = 1;
        $timeout_rest = $timeout;
        $start_time = int(time());
    }

    while $nread < $nbytes:
    {
        if $time_limited:
            eval {
                local $SIG{ALRM} = sub { die "alarm\n"; };
                alarm $timeout_rest;
                $rc = sysread($FH, $$scalar, $nbytes - $nread, $nread);
                alarm 0;
            };
            $timeout_rest = $timeout - (int(time()) - $start_time);
            if $timeout_rest < 1:
                log_msg("Error: eXsysread() failure: timed out")
                return 0
        else:
            $rc = sysread($FH, $$scalar, $nbytes - $nread, $nread);

        if got_exit_signal:
            log_msg("Error: eXsysread() failure: signalled to die")
            return 0

        if(not defined $rc) {
            if($!{EINTR}) {
                log_msg("Warning: retrying sysread() interrupted system call")
                next;
            }
            if($!{EAGAIN}) {
                log_msg("Warning: retrying sysread() due to EAGAIN")
                next;
            }
            if($!{EWOULDBLOCK}) {
                log_msg("Warning: retrying sysread() due to EWOULDBLOCK")
                next;
            }
            log_msg(f"Error: sysread() failure: $!")
            return 0
        }
        if($rc < 0) {
            log_msg(f"Error: sysread() failure: returned negative value $rc")
            return 0
        }
        if($rc == 0) {
            log_msg("Error: sysread() failure: read zero bytes")
            return 0
        }
        $nread += $rc;
    }
    return $nread;
}

sub read_mainsockf {
    # read_mainsockf attempts to read the given amount of output from the
    # sockfilter which is in use for the main or primary connection. This
    # reads untranslated sockfilt lingo which may hold data read from the
    # main or primary socket. On success returns 1, otherwise zero.

    my $scalar  = shift;
    my $nbytes  = shift;
    my $timeout = shift;  # Optional argument, if zero blocks indefinitely

    my $FH = \*SFREAD;

    if(not defined $timeout) {
        $timeout = $sockfilt_timeout + ($nbytes >> 12);
    }
    if(eXsysread($FH, $scalar, $nbytes, $timeout) != $nbytes) {
        fcaller, lcaller = (caller)[1,2];
        log_msg(f"Error: read_mainsockf() failure at {fcaller} "
                f"line {lcaller}. Due to eXsysread() failure")
        return 0
    }

    return 1


sub read_datasockf {
    # read_datasockf attempts to read the given amount of output from the
    # sockfilter which is in use for the data or secondary connection. This
    # reads untranslated sockfilt lingo which may hold data read from the
    # data or secondary socket. On success returns 1, otherwise zero.

    my $scalar  = shift;
    my $nbytes  = shift;
    my $timeout = shift; # Optional argument, if zero blocks indefinitely

    my $FH = \*DREAD;

    if(not defined $timeout) {
        $timeout = $sockfilt_timeout + ($nbytes >> 12);
    }
    if(eXsysread($FH, $scalar, $nbytes, $timeout) != $nbytes) {
        fcaller, lcaller = (caller)[1,2];
        log_msg(f"Error: read_datasockf() failure at {fcaller} "
                f"line {lcaller}. Due to eXsysread() failure")
        return 0
    }

    return 1


def sysread_or_die($FH, $scalar, $length):

    my $result;
    $result = sysread($$FH, $$scalar, $length)

    if not defined $result:
        fcaller, lcaller = (caller)[1,2];
        log_msg("Failed to read input")
        log_msg(f"Error: {$srvrname} server, sysread error: $!")
        log_msg(f"Exited from sysread_or_die() at {fcaller} "
                f"line {lcaller}. {$srvrname} server, sysread error: $!")
        killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose)
        $pidfile.unlink()
        $portfile.unlink()
        if serverlogs_locked:
            serverlogs_locked = False
            clear_advisor_read_lock($serverlogs_lockfile)
        exit;
    elif $result == 0:
        fcaller, lcaller = (caller)[1,2];
        log_msg("Failed to read input")
        log_msg(f"Error: {$srvrname} server, read zero")
        log_msg(f"Exited from sysread_or_die() at {fcaller} "
                f"line {lcaller}. {$srvrname} server, read zero")
        killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose)
        $pidfile.unlink()
        $portfile.unlink()
        if serverlogs_locked:
            serverlogs_locked = False
            clear_advisor_read_lock($serverlogs_lockfile)
        exit;

    return $result


def startsf():

    my @mainsockfcmd = [
        f"./server/sockfilt{exe_ext('SRV')}",
        f"--ipv{$ipvnum}",
        "--port", $port,
        "--pidfile", $mainsockf_pidfile,
        "--portfile", str($portfile),
        "--logfile", $mainsockf_logfile,
    ]
    $sfpid = open2(*SFREAD, *SFWRITE, @mainsockfcmd);

    if $verbose:
        print(STDERR, f"@mainsockfcmd")

    print SFWRITE "PING\n";
    my $pong;
    sysread_or_die(\*SFREAD, \$pong, 5);

    if($pong !~ /^PONG/) {
        log_msg(f"Failed sockfilt command: @mainsockfcmd")
        killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose)
        $pidfile.unlink()
        $portfile.unlink()
        if serverlogs_locked:
            serverlogs_locked = False
            clear_advisor_read_lock($serverlogs_lockfile)
        die "Failed to start sockfilt!";
    }


def getreplydata($num) -> List:
    # Returns the given test's reply data

    test_part = ""
    $num =~ s/^([^0-9]*)//
    if $num > 10000:
        test_part = $num % 10000

    data = getpart.get_part("reply", f"data{test_part}")
    if ! data and test_part != "":
        data = getpart.get_part("reply", "data")

    return data


sub sockfilt {
    for l in @_:
        printf SFWRITE "DATA\n%04x\n", length(l)
        print SFWRITE l;
}

sub sockfiltsecondary {
    for l in @_:
        printf DWRITE "DATA\n%04x\n", length(l)
        print DWRITE l;
}

    def send_control(self, *args):
        # Send data to the client on the control stream, which happens to be
        # plain stdout.

        if not self.ctrl_delay:
            # spit it all out at once
            sockfilt args;
        else:
            my $a = "".join(args)
            my @a = split("", $a);

            for $_ in @a:
                sockfilt $_;
                portable_sleep(self.ctrl_delay)

        for log in args:
            l = log
            l =~ s/\r/[CR]/g;
            l =~ s/\n/[LF]/g;
            log_msg(f'> \"{l}\"')

    def send_data(self, *args):
        # Send data to the FTP client on the data stream when data connection
        # is actually established. Given that this sub should only be called
        # when a data connection is supposed to be established, calling this
        # without a data connection is an indication of weak logic somewhere.

        if self.datasockf_conn == "no":
            log_msg("WARNING: Detected data sending attempt without DATA channel")
            for l in args:
                log_msg(f"WARNING: Data swallowed: {l}")
            return

        for l in args:
            if not self.data_delay:
                # spit it all out at once
                sockfiltsecondary(l)
            else:
                # pause between each byte
                for $_ in l.split(//):
                    sockfiltsecondary $_;
                    portable_sleep(self.ctrl_delay)

    def protocol_setup(self, proto: str):
        # protocol_setup initializes the 'display_text' and 'command_func' hashes
        # for the given protocol. References to protocol command callbacks are
        # stored in 'command_func' hash, and text which will be returned to the
        # client before the command callback runs is stored in 'display_text'.

        if proto == "ftp":
            self.command_func = {
                "PORT": PORT_ftp,
                "EPRT": PORT_ftp,
                "LIST": LIST_ftp,
                "NLST": NLST_ftp,
                "PASV": PASV_ftp,
                "CWD":  CWD_ftp,
                "PWD":  PWD_ftp,
                "EPSV": PASV_ftp,
                "RETR": RETR_ftp,
                "SIZE": SIZE_ftp,
                "REST": REST_ftp,
                "STOR": STOR_ftp,
                "APPE": STOR_ftp,  # append looks like upload
                "MDTM": MDTM_ftp,
            }
            self.display_text = {
                "USER": "331 We are happy you popped in!",
                "PASS": "230 Welcome you silly person",
                "PORT": "200 You said PORT - I say FINE",
                "TYPE": "200 I modify TYPE as you wanted",
                "LIST": "150 here comes a directory",
                "NLST": "150 here comes a directory",
                "CWD":  "250 CWD command successful.",
                "SYST": "215 UNIX Type: L8",  # just fake something
                "QUIT": "221 bye bye baby",   # just reply something
                "MKD":  "257 Created your requested directory",
                "REST": "350 Yeah yeah we set it there for you",
                "DELE": "200 OK OK OK whatever you say",
                "RNFR": "350 Received your order. Please provide more",
                "RNTO": "250 Ok, thanks. File renaming completed.",
                "NOOP": "200 Yes, I'm very good at doing nothing.",
                "PBSZ": "500 PBSZ not implemented",
                "PROT": "500 PROT not implemented",
                "welcome": "".join([
                "220-        _   _ ____  _     \r\n",
                "220-    ___| | | |  _ \| |    \r\n",
                "220-   / __| | | | |_) | |    \r\n",
                "220-  | (__| |_| |  _ {| |___ \r\n",
                "220    \___|\___/|_| \_\_____|\r\n"])
            }
        elif proto == "pop3":
            self.command_func = {
                "APOP": APOP_pop3,
                "AUTH": AUTH_pop3,
                "CAPA": CAPA_pop3,
                "DELE": DELE_pop3,
                "LIST": LIST_pop3,
                "NOOP": NOOP_pop3,
                "PASS": PASS_pop3,
                "QUIT": QUIT_pop3,
                "RETR": RETR_pop3,
                "RSET": RSET_pop3,
                "STAT": STAT_pop3,
                "TOP":  TOP_pop3,
                "UIDL": UIDL_pop3,
                "USER": USER_pop3,
            }
            self.display_text = {
                "welcome": "".join([
                "        _   _ ____  _     \r\n",
                "    ___| | | |  _ \| |    \r\n",
                "   / __| | | | |_) | |    \r\n",
                "  | (__| |_| |  _ {| |___ \r\n",
                "   \___|\___/|_| \_\_____|\r\n",
                "+OK curl POP3 server ready to serve \r\n"])
            }
        elif proto == "imap":
            self.command_func = {
                "APPEND":     APPEND_imap,
                "CAPABILITY": CAPABILITY_imap,
                "CHECK":      CHECK_imap,
                "CLOSE":      CLOSE_imap,
                "COPY":       COPY_imap,
                "CREATE":     CREATE_imap,
                "DELETE":     DELETE_imap,
                "EXAMINE":    EXAMINE_imap,
                "EXPUNGE":    EXPUNGE_imap,
                "FETCH":      FETCH_imap,
                "LIST":       LIST_imap,
                "LSUB":       LSUB_imap,
                "LOGIN":      LOGIN_imap,
                "LOGOUT":     LOGOUT_imap,
                "NOOP":       NOOP_imap,
                "RENAME":     RENAME_imap,
                "SEARCH":     SEARCH_imap,
                "SELECT":     SELECT_imap,
                "STATUS":     STATUS_imap,
                "STORE":      STORE_imap,
                "UID":        UID_imap,
                "IDLE":       IDLE_imap,
            }
            self.display_text = {
                "welcome": "".join([
                "        _   _ ____  _     \r\n",
                "    ___| | | |  _ \| |    \r\n",
                "   / __| | | | |_) | |    \r\n",
                "  | (__| |_| |  _ {| |___ \r\n",
                "   \___|\___/|_| \_\_____|\r\n",
                "* OK curl IMAP server ready to serve\r\n"])
            }
        elif proto == "smtp":
            self.command_func = {
                "DATA": DATA_smtp,
                "EHLO": EHLO_smtp,
                "EXPN": EXPN_smtp,
                "HELO": HELO_smtp,
                "HELP": HELP_smtp,
                "MAIL": MAIL_smtp,
                "NOOP": NOOP_smtp,
                "RSET": RSET_smtp,
                "RCPT": RCPT_smtp,
                "VRFY": VRFY_smtp,
                "QUIT": QUIT_smtp,
            }
            self.display_text = {
                "welcome": "".join([
                "220-        _   _ ____  _     \r\n",
                "220-    ___| | | |  _ \| |    \r\n",
                "220-   / __| | | | |_) | |    \r\n",
                "220-  | (__| |_| |  _ {| |___ \r\n",
                "220    \___|\___/|_| \_\_____|\r\n"])
            }

    def close_dataconn(self, closed: bool = False):

        data_pid = processexists($datasockf_pidfile)

        log_msg(f"=====> Closing {self.datasockf_mode} DATA connection...")

        if not closed:
            if data_pid > 0:
                log_msg(f"Server disconnects {self.datasockf_mode} DATA connection")
                disconnect_handshake()
                log_msg(f"Server disconnected {self.datasockf_mode} DATA connection")
            else:
                log_msg(f"Server finds {self.datasockf_mode} DATA connection already "
                        f"disconnected")
        else:
            log_msg(f"Server knows {self.datasockf_mode} DATA connection is already "
                    f"disconnected")

        if data_pid > 0:
            log_msg(f"DATA sockfilt for {self.datasockf_mode} data channel quits "
                    f"(pid {data_pid})")
            print DWRITE "QUIT\n";
            pidwait(data_pid, 0)
            if (-f $datasockf_pidfile): unlink($datasockf_pidfile)
            log_msg(f"DATA sockfilt for {self.datasockf_mode} data channel quit "
                    f"(pid {data_pid})")
        else:
            log_msg(f"DATA sockfilt for {self.datasockf_mode} data channel already "
                    f"dead")

        log_msg(f"=====> Closed {self.datasockf_mode} DATA connection")

        ftp_server.datasockf_set_state("STOPPED")


def disconnect_handshake():
    # Perform the disconnect handshake with sockfilt on the secondary connection
    # (the only connection we actively disconnect).
    # This involves waiting for the disconnect acknowledgment after the DISC
    # command, while throwing away anything else that might come in before
    # that.

    print DWRITE "DISC\n";

    my $line;
    my $nr;
    while ($nr := sysread(DREAD, $line, 5)) == 5:

        if $line == "DATA\n":
            # Must read the data bytes to stay in sync
            my $i;
            sysread(DREAD, $i, 5)

            size = 0
            if $i =~ /^([0-9a-fA-F]{4})\n/:
                size = hex($1)

            log_msg(f"> Throwing away {size} bytes on closed connection")
            read_datasockf(\$line, size)

        elif $line == "DISC\n":

            log_msg("Fancy that; client wants to DISC, too")
            printf DWRITE "ACKD\n";

        elif $line == "ACKD\n":

            # Got the ack we were waiting for
            last;

        else:
            log_msg(f"Ignoring: $line", end="")
            # sockfilt should not be sending us any other commands

    if not defined($nr):
        log_msg(f"Error: pipe read error ($!) while waiting for ACKD", end="")
    elif $nr <= 0:
        log_msg("Error: pipe EOF while waiting for ACKD", end="")

    ################
    ################ SMTP commands
    ################

    # The type of server (SMTP or ESMTP)
    self.smtp_type: str = None
    # The client (which normally contains the test number)
    self.smtp_client = None

    def EHLO_smtp(self, $client):

        # TODO: Get the IP address of the client connection to use in the
        # EHLO response when the client doesn't specify one but for now use
        # 127.0.0.1
        if not $client:
            $client = "[127.0.0.1]"

        data = []

        # Set the server type to ESMTP
        self.smtp_type = "ESMTP"

        # Calculate the EHLO response
        data.append(f"{self.smtp_type} pingpong test server Hello {$client}")

        if self.capabilities or self.auth_mechs:

            for c in self.capabilities:
                data.append(c)

            mechs = None
            for am in self.auth_mechs:
                if mechs is None:
                    mechs = f"{am}"
                else:
                    mechs += f" {am}"

            if mechs:
                data.append(f"AUTH {mechs}")

        # Send the EHLO response
        for (my $i = 0; $i < @data; $i++):
            d = $data[$i]
            if $i < @data - 1:
                self.send_control(f"250-{d}\r\n")
            else:
                self.send_control(f"250 {d}\r\n")

        # Store the client (as it may contain the test number)
        self.smtp_client = $client

        return 0

    def HELO_smtp(self, $client):

        # TODO: Get the IP address of the client connection to use in the HELO
        # response when the client doesn't specify one but for now use 127.0.0.1
        if not $client:
            $client = "[127.0.0.1]";

        # Set the server type to SMTP
        self.smtp_type = "SMTP"

        # Send the HELO response
        self.send_control(f"250 {self.smtp_type} pingpong test server Hello $client\r\n")

        # Store the client (as it may contain the test number)
        self.smtp_client = $client

        return 0

    def MAIL_smtp(self, args: str):

        log_msg(f"MAIL_smtp got {args}")

        if not args:
            self.send_control("501 Unrecognized parameter\r\n")
        else:
            my $from;
            my $size;
            my $smtputf8 = grep /^SMTPUTF8$/, self.capabilities

            elements = args.split(" ")
            # Get the FROM and SIZE parameters
            for e in elements:
                if e =~ /^FROM:(.*)$/:
                    $from = $1
                elif e =~ /^SIZE=(\d+)$/:
                    $size = $1

            # this server doesn't "validate" MAIL FROM addresses
            if length($from):

                valid = True
                # Check the capabilities for SIZE and if the specified size is
                # greater than the message size then reject it
                if (found := grep /^SIZE (\d+)$/, self.capabilities):
                    if found[0] =~ /^SIZE (\d+)$/):
                        if $size > $1:
                            valid = False

                if not valid:
                    self.send_control("552 Message size too large\r\n")
                else:
                    self.send_control("250 Sender OK\r\n")
            else:
                self.send_control("501 Invalid address\r\n")

        return 0

    def RCPT_smtp(self, args: str):

        log_msg(f"RCPT_smtp got {args}")

        # Get the TO parameter
        if args !~ /^TO:(.*)/:
            self.send_control("501 Unrecognized parameter\r\n")
        else:
            my $smtputf8 = grep /^SMTPUTF8$/, self.capabilities
            my $to = $1

            # Validate the to address (only a valid email address inside <> is
            # allowed, such as <user@example.com>)
            if ((!$smtputf8 and $to =~ /^<([a-zA-Z0-9._%+-]+)\@(([a-zA-Z0-9-]+)\.)+([a-zA-Z]{2,4})>$/) or
                ($smtputf8  and $to =~ /^<([a-zA-Z0-9\x{80}-\x{ff}._%+-]+)\@(([a-zA-Z0-9\x{80}-\x{ff}-]+)\.)+([a-zA-Z]{2,4})>$/)):
                self.send_control("250 Recipient OK\r\n")
            else:
                self.send_control("501 Invalid address\r\n")

        return 0

    def DATA_smtp(self, args: str):

        if args:
            self.send_control("501 Unrecognized parameter\r\n")
        elif self.smtp_client !~ /^(\d*)$/:
            self.send_control("501 Invalid arguments\r\n")
        else:
            self.send_control("354 Show me the mail\r\n")

            my $testno   = self.smtp_client
            my $filename = f"{$logdir}/upload.{$testno}"

            log_msg(f"Store test number $testno in $filename")

            my $file = open("$filename", ">") ||
                return 0 # failed to open output

            my $line;
            my $ulsize=0;
            my $disc=0;
            my $raw;
            while sysread(\*SFREAD, $line, 5) == 5:

                if $line == "DATA\n":

                    my $i;
                    my $eob;
                    sysread(\*SFREAD, $i, 5)

                    size = 0
                    if $i =~ /^([0-9a-fA-F]{4})\n/:
                        size = hex($1)

                    read_mainsockf(\$line, size)

                    $ulsize += size
                    if not self.no_save: print $file $line

                    $raw += $line
                    if ($raw =~ /(?:^|\x0d\x0a)\x2e\x0d\x0a/):
                        # end of data marker!
                        $eob = 1

                    log_msg(f"> Appending {size} bytes to file")

                    if $eob:
                        log_msg("Found SMTP EOB marker")
                        last;

                elif $line == "DISC\n":

                    # disconnect!
                    $disc = 1
                    printf SFWRITE "ACKD\n";
                    last;

                else:
                    log_msg(f"No support for: $line", end="")
                    last;

            if self.no_save:
                print $file "$ulsize bytes would've been stored here\n";

            close($file);

            log_msg(f"received $ulsize bytes upload")

            self.send_control("250 OK, data received!\r\n")

        return 0

    def NOOP_smtp(self, args: str):

        if args:
            self.send_control("501 Unrecognized parameter\r\n")
        else:
            self.send_control("250 OK\r\n")

        return 0

    def RSET_smtp(self, args: str):

        if args:
            self.send_control("501 Unrecognized parameter\r\n")
        else:
            self.send_control("250 Resetting\r\n")

        return 0

    def HELP_smtp(self, args: str):

        # One argument is optional
        if args:
            log_msg(f"HELP_smtp got {args}")

        if self.smtp_client == "verifiedserver":
            # This is the secret command that verifies that this actually is
            # the curl test server
            self.send_control("214 WE ROOLZ: $$\r\n")

            if $verbose:
                print("FTPD: We returned proof we are the test server", file=sys.stderr)

            log_msg("return proof we are we")
        else:
            self.send_control("214-This server supports the following commands:\r\n")

            if self.auth_mechs:
                self.send_control("214 HELO EHLO RCPT DATA RSET MAIL VRFY EXPN QUIT HELP AUTH\r\n")
            else:
                self.send_control("214 HELO EHLO RCPT DATA RSET MAIL VRFY EXPN QUIT HELP\r\n")

        return 0

    def VRFY_smtp(self, args: str):

        username, address = args.split(" ", 2)

        log_msg(f"VRFY_smtp got {args}")

        if username == "":
            self.send_control("501 Unrecognized parameter\r\n")
        else:
            my $smtputf8 = grep /^SMTPUTF8$/, self.capabilities

            # Validate the username (only a valid local or external username is
            # allowed, such as user or user@example.com)
            if ((!$smtputf8 and username =~ /^([a-zA-Z0-9._%+-]+)(\@(([a-zA-Z0-9-]+)\.)+([a-zA-Z]{2,4}))?$/) or
                ($smtputf8  and username =~ /^([a-zA-Z0-9\x{80}-\x{ff}._%+-]+)(\@(([a-zA-Z0-9\x{80}-\x{ff}-]+)\.)+([a-zA-Z]{2,4}))?$/)):

                data = getreplydata(self.smtp_client)

                if not data:
                    if username !~ /^([a-zA-Z0-9._%+-]+)\@(([a-zA-Z0-9-]+)\.)+([a-zA-Z]{2,4})$/:
                        data.append(f"250 <{username}\@example.com>\r\n")
                    else:
                        data.append(f"250 <{username}>\r\n")

                for d in data:
                    self.send_control(d)
            else:
                self.send_control("501 Invalid address\r\n")

        return 0

    def EXPN_smtp(self, $list_name):

        log_msg(f"EXPN_smtp got $list_name")

        if not $list_name:
            self.send_control("501 Unrecognized parameter\r\n")
        else:
            data = getreplydata(self.smtp_client)

            for d in data:
                self.send_control(d)

        return 0

    def QUIT_smtp(self):

        self.send_control(f"221 curl {self.smtp_type} server signing off\r\n")

        return 0

    # What was deleted by IMAP STORE / POP3 DELE commands
    self.deleted: List = []

    ################
    ################ IMAP commands
    ################

    # global to allow the command functions to read it
    self.imap_cmdid: str
    # what was picked by SELECT
    self.imap_selected: str

    sub fix_imap_params {
        # Any IMAP parameter can come in escaped and in double quotes.
        # This function is dumb (so far) and just removes the quotes if present.
        foreach (@_):
            $_ = $1 if /^"(.*)"$/;
    }

    def CAPABILITY_imap(self):

        if not self.capabilities and not self.auth_mechs:
            self.send_control(f"{self.imap_cmdid} BAD Command\r\n")
        else:
            # Calculate the CAPABILITY response
            data = "* CAPABILITY IMAP4"

            for c in self.capabilities:
                data += f" {c}"

            for am in self.auth_mechs:
                data += f" AUTH={am}"

            data += " pingpong test server\r\n"

            # Send the CAPABILITY response
            self.send_control(data)
            self.send_control(f"{self.imap_cmdid} OK CAPABILITY completed\r\n")

        return 0

    def LOGIN_imap(self, args: str):

        user, password = args.split(" ", 2)

        fix_imap_params(user, password)

        log_msg(f"LOGIN_imap got {args}")

        if user == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK LOGIN completed\r\n")

        return 0

    def SELECT_imap(self, mailbox: str):

        fix_imap_params(mailbox)

        log_msg(f"SELECT_imap got test {mailbox}")

        if mailbox == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            # Example from RFC 3501, 6.3.1. SELECT Command
            self.send_control("* 172 EXISTS\r\n")
            self.send_control("* 1 RECENT\r\n")
            self.send_control("* OK [UNSEEN 12] Message 12 is first unseen\r\n")
            self.send_control("* OK [UIDVALIDITY 3857529045] UIDs valid\r\n")
            self.send_control("* OK [UIDNEXT 4392] Predicted next UID\r\n")
            self.send_control("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n")
            self.send_control("* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n")
            self.send_control(f"{self.imap_cmdid} OK [READ-WRITE] SELECT completed\r\n")

            self.imap_selected = mailbox

        return 0

    def FETCH_imap(self, args: str):

        uid, how = args.split(" ", 2)

        fix_imap_params(uid, how)

        log_msg(f"FETCH_imap got {args}")

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        else:
            my $size;

            if self.imap_selected == "verifiedserver":
                # this is the secret command that verifies that this actually is
                # the curl test server
                response = "WE ROOLZ: $$\r\n"

                if $verbose:
                    print("FTPD: We returned proof we are the test server", file=sys.stderr)

                data = [response]
                log_msg("return proof we are we")
            else:
                # send mail content
                log_msg("retrieve a mail")

                data = getreplydata(self.imap_selected)

            for $_ in data:
                $size += length($_)

            self.send_control(f"* {uid} FETCH ({how} {$size}\r\n")

            for d in data:
                self.send_control(d)

            # Set the custom extra header content with POSTFETCH
            self.send_control(f"{$postfetch})\r\n")
            self.send_control(f"{self.imap_cmdid} OK FETCH completed\r\n")

        return 0

    def APPEND_imap(self, args: str):

        log_msg(f"APPEND_imap got args")  # <AK>: was \r\n

        args =~ /^([^ ]+) [^{]*\{(\d+)\}$/
        mailbox, size = ($1, $2)

        fix_imap_params(mailbox)

        if mailbox == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control("+ Ready for literal data\r\n")

            my $testno   = mailbox
            my $filename = f"{$logdir}/upload.{$testno}"

            log_msg(f"Store test number $testno in $filename")

            open(my $file, ">", "$filename") ||
                return 0 # failed to open output

            my $received = 0;
            my $line;
            while sysread(\*SFREAD, $line, 5) == 5:

                if $line == "DATA\n":

                    sysread(\*SFREAD, $line, 5)

                    my $chunksize = 0;
                    if($line =~ /^([0-9a-fA-F]{4})\n/) {
                        $chunksize = hex($1);
                    }

                    read_mainsockf(\$line, $chunksize);

                    my $left = size - $received
                    my $datasize = ($left > $chunksize) ? $chunksize : $left;

                    if($datasize > 0) {
                        log_msg(f"> Appending $datasize bytes to file")
                        if not self.no_save: print $file substr($line, 0, $datasize)
                        $line = substr($line, $datasize);

                        $received += $datasize;
                        if $received == size:
                            log_msg("Received all data, waiting for final CRLF.")
                    }

                    if $received == size and $line == "\r\n":
                        last;

                elif $line == "DISC\n":

                    log_msg("Unexpected disconnect!")
                    printf SFWRITE "ACKD\n";
                    last;

                else:
                    log_msg(f"No support for: $line", end="")
                    last;

            if self.no_save:
                print $file f"{size} bytes would've been stored here\n";

            close($file);

            log_msg(f"received {size} bytes upload")

            self.send_control(f"{self.imap_cmdid} OK APPEND completed\r\n")

        return 0

    def STORE_imap(self, args: str):

        uid, what, value = args.split(" ", 3)

        fix_imap_params(uid)

        log_msg(f"STORE_imap got {args}")

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        elif uid == "" or what != "+Flags" or value == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            if value == "\\Deleted":
                self.deleted.append(uid)
            self.send_control(f"* {uid} FETCH (FLAGS (\\Seen {value}))\r\n")
            self.send_control(f"{self.imap_cmdid} OK STORE completed\r\n")

        return 0

    def LIST_imap(self, args: str):

        reference, mailbox = args.split(" ", 2)

        fix_imap_params(reference, mailbox)

        log_msg(f"LIST_imap got {args}")

        if reference == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        elif reference == "verifiedserver":
            # this is the secret command that verifies that this actually is
            # the curl test server
            self.send_control("* LIST () \"/\" \"WE ROOLZ: $$\"\r\n")
            self.send_control(f"{self.imap_cmdid} OK LIST Completed\r\n")

            if $verbose:
                print("FTPD: We returned proof we are the test server", file=sys.stderr)

            log_msg("return proof we are we")
        else:
            data = getreplydata(reference)

            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK LIST Completed\r\n")

        return 0

    def LSUB_imap(self, args: str):

        reference, mailbox = args.split(" ", 2)

        fix_imap_params(reference, mailbox)

        log_msg(f"LSUB_imap got {args}")

        if reference == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            data = getreplydata(reference)

            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK LSUB Completed\r\n")

        return 0

    def EXAMINE_imap(self, mailbox: str):

        fix_imap_params(mailbox)

        log_msg(f"EXAMINE_imap got {mailbox}")

        if mailbox == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            data = getreplydata(mailbox)

            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK [READ-ONLY] EXAMINE completed\r\n")

        return 0

    def STATUS_imap(self, args: str):

        mailbox, what = args.split(" ", 2)

        fix_imap_params(mailbox)

        log_msg(f"STATUS_imap got {args}")

        if mailbox == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            data = getreplydata(mailbox)

            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK STATUS completed\r\n")

        return 0

    def SEARCH_imap(self, what: str):

        fix_imap_params(what);

        log_msg(f"SEARCH_imap got {what}")

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        elif what == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            data = getreplydata(self.imap_selected)

            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK SEARCH completed\r\n")

        return 0

    def CREATE_imap(self, args: str):

        fix_imap_params(args)

        log_msg(f"CREATE_imap got {args}")

        if args == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK CREATE completed\r\n")

        return 0

    def DELETE_imap(self, args: str):

        fix_imap_params(args)

        log_msg(f"DELETE_imap got {args}")

        if args == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK DELETE completed\r\n")

        return 0

    def RENAME_imap(self, args: str):

        from_mailbox, to_mailbox = args.split(" ", 2)

        fix_imap_params(from_mailbox, to_mailbox)

        log_msg(f"RENAME_imap got {args}")

        if from_mailbox == "" or to_mailbox == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK RENAME completed\r\n")

        return 0

    def CHECK_imap(self):

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK CHECK completed\r\n")

        return 0

    def CLOSE_imap(self):

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        elif not self.deleted:
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK CLOSE completed\r\n")
            self.deleted = []

        return 0

    def EXPUNGE_imap(self):

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        else:
            if not self.deleted:
                # Report the number of existing messages as per the SELECT
                # command
                self.send_control("* 172 EXISTS\r\n")
            else:
                # Report the message UIDs being deleted
                for d in self.deleted:
                    self.send_control(f"* {d} EXPUNGE\r\n")
                self.deleted = []

            self.send_control(f"{self.imap_cmdid} OK EXPUNGE completed\r\n")

        return 0

    def COPY_imap(self, args: str):

        uid, mailbox = args.split(" ", 2)

        fix_imap_params(uid, mailbox)

        log_msg(f"COPY_imap got {args}")

        if uid == "" or mailbox == "":
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            self.send_control(f"{self.imap_cmdid} OK COPY completed\r\n")

        return 0

    def IDLE_imap(self):

        log_msg("IDLE received")

        self.send_control("+ entering idle mode\r\n")

        return 0

    def UID_imap(self, args: str):

        command, = args.split(" ", 1)

        fix_imap_params(command)

        log_msg(f"UID_imap got {args}")

        if self.imap_selected == "":
            self.send_control(f"{self.imap_cmdid} BAD Command received in Invalid state\r\n")
        elif command.startswith("FETCH"):
            func = self.command_func.get("FETCH")
            if func:
                func(args, command)
        elif command not in ["COPY", "STORE", "SEARCH"]:
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            data = getreplydata(self.imap_selected)

            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK {command} completed\r\n")

        return 0

    def NOOP_imap(self, args: str):

        data = [
            "* 22 EXPUNGE\r\n",
            "* 23 EXISTS\r\n",
            "* 3 RECENT\r\n",
            "* 14 FETCH (FLAGS (\\Seen \\Deleted))\r\n",
        ]

        if args:
            self.send_control(f"{self.imap_cmdid} BAD Command Argument\r\n")
        else:
            for d in data:
                self.send_control(d)

            self.send_control(f"{self.imap_cmdid} OK NOOP completed\r\n")

        return 0

    def LOGOUT_imap(self):

        self.send_control("* BYE curl IMAP server signing off\r\n")
        self.send_control(f"{self.imap_cmdid} OK LOGOUT completed\r\n")

        return 0

    ################
    ################ POP3 commands
    ################

    # Who is attempting to log in
    self.pop3_user: str

    sub CAPA_pop3(self,  {

        # Calculate the capability list based on the specified capabilities
        # (except APOP) and any authentication mechanisms
        capa_list = []
        for c in self.capabilities:
            if c != "APOP":
                capa_list.append(f"{c}\r\n")

        mechs = None
        for am in self.auth_mechs:
            if mechs is None:
                mechs = f"{am}"
            else:
                mechs += f" {am}"

        if mechs:
            capa_list.append(f"SASL {mechs}\r\n")

        if not capa_list:
            self.send_control("-ERR Unrecognized command\r\n")
        else:
            data = []
            # Calculate the CAPA response
            data.append("+OK List of capabilities follows\r\n")
            for l in capa_list:
                data.append(f"{l}\r\n")
            data.append("IMPLEMENTATION POP3 pingpong test server\r\n")

            # Send the CAPA response
            for d in data:
                self.send_control(d)

            # End with the magic 3-byte end of listing marker
            self.send_control(".\r\n")

        return 0

    def APOP_pop3(self, args: str):

        user, secret = args.split(" ", 2)

        if ! grep /^APOP$/, self.capabilities:
            self.send_control("-ERR Unrecognized command\r\n")
        elif user == "" or secret == "":
            self.send_control("-ERR Protocol error\r\n")
        else:
            digest = Digest::MD5::md5_hex($POP3_TIMESTAMP, $TEXT_PASSWORD)

            if secret != digest:
                self.send_control("-ERR Login failure\r\n")
            else:
                self.send_control("+OK Login successful\r\n")

        return 0

    def AUTH_pop3(self):

        if not self.auth_mechs:
            self.send_control("-ERR Unrecognized command\r\n")
        else:
            data = []

            # Calculate the AUTH response
            data.append("+OK List of supported mechanisms follows\r\n")

            for am in self.auth_mechs:
                data.append(f"{am}\r\n")

            # Send the AUTH response
            for d in data:
                self.send_control(d)

            # End with the magic 3-byte end of listing marker
            self.send_control(".\r\n")

        return 0

    def USER_pop3(self, user):

        log_msg(f"USER_pop3 got {user}")

        if not user:
            self.send_control("-ERR Protocol error\r\n")
        else:
            self.pop3_user = user

            self.send_control("+OK\r\n")

        return 0

    def PASS_pop3(self, password):

        log_msg(f"PASS_pop3 got {password}")

        self.send_control("+OK Login successful\r\n")

        return 0

    def RETR_pop3(self, $msgid):

        if $msgid =~ /^verifiedserver$/:
            # this is the secret command that verifies that this actually is
            # the curl test server
            response = "WE ROOLZ: $$\r\n"

            if $verbose:
                print("FTPD: We returned proof we are the test server", file=sys.stderr)

            data = [response]
            log_msg("return proof we are we")
        else:
            # send mail content
            log_msg("retrieve a mail")

            data = getreplydata($msgid)

        self.send_control("+OK Mail transfer starts\r\n")

        for d in data:
            self.send_control(d)

        # end with the magic 3-byte end of mail marker, assumes that the
        # mail body ends with a CRLF!
        self.send_control(".\r\n")

        return 0

    def LIST_pop3(self):
        # This is a built-in fake-message list

        data = getpart.get_part("reply", "data")

        log_msg("retrieve a message list")

        self.send_control("+OK Listing starts\r\n")

        for d in data:
            self.send_control(d)

        # End with the magic 3-byte end of listing marker
        self.send_control(".\r\n")

        return 0

    def DELE_pop3(self, msgid: str):

        log_msg(f"DELE_pop3 got {msgid}")

        if not msgid:
            self.send_control("-ERR Protocol error\r\n")
        else:
            self.deleted.append(msgid)
            self.send_control("+OK\r\n")

        return 0

    def STAT_pop3(self, args: str):

        if args:
            self.send_control("-ERR Protocol error\r\n")
        else:
            # Send statistics for the built-in fake message list as
            # detailed in the LIST_pop3 function above
            self.send_control("+OK 3 4294967800\r\n")

        return 0

    def NOOP_pop3(self, args: str):

        if args:
            self.send_control("-ERR Protocol error\r\n")
        else:
            self.send_control("+OK\r\n")

        return 0

    def UIDL_pop3(self):
        # This is a built-in fake-message UID list

        data = [
            "1 1\r\n",
            "2 2\r\n",
            "3 4\r\n",  # Note that UID 3 is a simulated "deleted" message
        ]

        if ! grep /^UIDL$/, self.capabilities:
            self.send_control("-ERR Unrecognized command\r\n")
        else:
            log_msg("retrieve a message UID list")

            self.send_control("+OK Listing starts\r\n")

            for d in data:
                self.send_control(d)

            # End with the magic 3-byte end of listing marker
            self.send_control(".\r\n")

        return 0

    def TOP_pop3(self, args: str):

        msgid, lines = args.split(" ", 2)

        log_msg(f"TOP_pop3 got {args}")

        if ! grep /^TOP$/, self.capabilities:
            self.send_control("-ERR Unrecognized command\r\n")
        elif msgid == "" or lines == "":
            self.send_control("-ERR Protocol error\r\n")
        else:
            if lines == "0":
                log_msg("retrieve header of mail")
            else:
                log_msg(f"retrieve top {lines} lines of mail")

            data = getreplydata(msgid)

            self.send_control("+OK Mail transfer starts\r\n")

            # Send mail content
            for d in data:
                self.send_control(d)

            # End with the magic 3-byte end of mail marker, assumes that the
            # mail body ends with a CRLF!
            self.send_control(".\r\n")

        return 0

    def RSET_pop3(self, args: str):

        if args:
            self.send_control("-ERR Protocol error\r\n")
        else:
            if self.deleted:
                log_msg(f"resetting {self.deleted} message(s)")
                self.deleted = []

            self.send_control("+OK\r\n")

        return 0

    def QUIT_pop3(self):

        if self.deleted:
            log_msg(f"deleting {self.deleted} message(s)")
            self.deleted = []

        self.send_control("+OK curl POP3 server signing off\r\n")

        return 0

    ################
    ################ FTP commands
    ################

    self.ftp_rest_pos: int = 0

    def REST_ftp(self, rest_pos: int):
        self.ftp_rest_pos = rest_pos
        log_msg(f"Set REST position to {self.ftp_rest_pos}")

    sub switch_directory_goto(self, {

        my $target_dir = $_;

        if not self.ftp_target_dir:
            self.ftp_target_dir = "/"

        if $target_dir == "":
            self.ftp_target_dir = "/"
        elif $target_dir == "..":
            if self.ftp_target_dir == "/":
                self.ftp_target_dir = "/"
            else:
                self.ftp_target_dir =~ s/[[:alnum:]]+\/$//
        else:
            self.ftp_target_dir += $target_dir + "/"

    sub switch_directory(self, {

        my $target_dir = $_[0];

        if $target_dir =~ /^test-(\d+)/:
            $cwd_testno = $1
        elif $target_dir == "/":
            self.ftp_target_dir = "/"
        else:
            dirs = $target_dir.split("/")
            for $_ in dirs:
                switch_directory_goto($_)

    sub CWD_ftp(self, {

        folder, $fullcommand = $_[0]

        switch_directory(folder)
        if self.ftp_target_dir =~ /^\/fully_simulated/:
            self.ftp_listparserstate = "enabled"
            log_msg("enabled FTP list parser mode")
        else:
            self.ftp_listparserstate = None

    def PWD_ftp(self):

        mydir = self.ftp_target_dir or "/"
        if mydir != "/":
            mydir =~ s/\/$//

        self.send_control(f"257 \"{mydir}\" is current directory\r\n")

    sub LIST_ftp(self,
        #  print "150 ASCII data connection for /bin/ls (193.15.23.1,59196) (0 bytes)\r\n";

        if self.datasockf_conn == "no":
            if self.ftp_nodataconn425:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("425 Can't open data connection\r\n")
            elif self.ftp_nodataconn421:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("421 Connection timed out\r\n")
            elif self.ftp_nodataconn150:
                self.send_control("150 Opening data connection\r\n")
                # client shall timeout
            else:
                # client shall timeout
                pass
            return 0

        log_msg("pass LIST data on data connection")

        if self.ftp_listparserstate:
            # provide a synthetic response
            ftpdir = ftp_contentlist(self.ftp_target_dir)
            # old hard-coded style
            for row in ftpdir:
                self.send_data(row)
        else:
            data = getpart.get_part("reply", "data")
            for send in data:
                # convert all \n to \r\n for ASCII transfer
                send =~ s/\r\n/\n/g;
                send =~ s/\n/\r\n/g;
                log_msg(f"send {send} as data")
                self.send_data(send)

        self.close_dataconn()
        self.send_control("226 ASCII transfer complete\r\n")

        return 0

    def NLST_ftp(self):

        ftpdir = ["file", "with space", "fake", "..", " ..", "funny", "README"]

        if self.datasockf_conn == "no":
            if self.ftp_nodataconn425:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("425 Can't open data connection\r\n")
            elif self.ftp_nodataconn421:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("421 Connection timed out\r\n")
            elif self.ftp_nodataconn150:
                self.send_control("150 Opening data connection\r\n")
                # client shall timeout
            else:
                # client shall timeout
                pass
            return 0

        log_msg("pass NLST data on data connection")
        for row in ftpdir:
            self.send_data(f"{row}\r\n")

        self.close_dataconn()
        self.send_control("226 ASCII transfer complete\r\n")

        return 0

    def MDTM_ftp(self, $testno: int):{

        test_part = ""
        if $testno > 10000:
            test_part = $testno % 10000
            $testno = int($testno / 10000)

        getpart.loadtest(f"{$logdir}/test{$testno}")

        data = getpart.get_part("reply", "mdtm")

        my $reply = $data[0]
        if $reply: chomp $reply

        if $reply and ($reply =~ /^[+-]?\d+$/) and $reply < 0:
            self.send_control(f"550 $testno: no such file.\r\n")
        elif $reply:
            self.send_control(f"{$reply}\r\n")
        else:
            self.send_control("500 MDTM: no such command.\r\n")

        return 0

    def SIZE_ftp(self, $testno): {

        if self.ftp_listparserstate is not None:
            size = wildcard_filesize(self.ftp_target_dir, $testno)
            if size == -1:
                self.send_control(f"550 {$testno}: No such file or directory.\r\n")
            else:
                self.send_control(f"213 {size}\r\n")
            return 0

        if $testno =~ /^verifiedserver$/:
            response = "WE ROOLZ: $$\r\n"
            size = length(response)
            self.send_control(f"213 {size}\r\n")
            return 0

        if $testno =~ /(\d+)\/?$/:
            $testno = int($1)
        else:
            print(f"SIZE_ftp: invalid test number: {$testno}", file=sys.stderr)
            return 1

        test_part = ""
        if $testno > 10000:
            test_part = $testno % 10000
            $testno = int($testno / 10000)

        getpart.loadtest(f"{$logdir}/test{$testno}")
        data = getpart.get_part("reply", "size")

        size = $data[0]
        if size:
            size = int(size)  # make it a number
            if size > -1:
                self.send_control(f"213 {size}\r\n")
            else:
                self.send_control(f"550 {$testno}: No such file or directory.\r\n")
        else:
            data = getpart.get_part("reply", f"data{test_part}")
            size = 0
            for $_ in data:
                size += length($_)
            if size:
                self.send_control(f"213 {size}\r\n")
            else:
                self.send_control(f"550 {$testno}: No such file or directory.\r\n")

        return 0

    def RETR_ftp(self, $testno):

        if self.datasockf_conn == "no":
            if self.ftp_nodataconn425:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("425 Can't open data connection\r\n")
            elif self.ftp_nodataconn421:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("421 Connection timed out\r\n")
            elif self.ftp_nodataconn150:
                self.send_control("150 Opening data connection\r\n")
                # client shall timeout
            else:
                # client shall timeout
                pass
            return 0

        if self.ftp_listparserstate is not None:
            my @content = wildcard_getfile(self.ftp_target_dir, $testno)
            if($content[0] == -1) {
                # file not found
                pass
            }
            else:
                size = length $content[1];
                sendcontrol f"150 Binary data connection for {$testno} ({size} bytes).\r\n",
                self.send_data($content[1])
                self.close_dataconn()
                self.send_control("226 File transfer complete\r\n")
            return 0

        if $testno =~ /^verifiedserver$/:
            # this is the secret command that verifies that this actually is
            # the curl test server
            response = "WE ROOLZ: $$\r\n"
            my $len = length(response)
            self.send_control(f"150 Binary junk ({$len} bytes).\r\n")
            self.send_data("WE ROOLZ: $$\r\n")
            self.close_dataconn()
            self.send_control("226 File transfer complete\r\n")

            if $verbose:
                print("FTPD: We returned proof we are the test server", file=sys.stderr)

            return 0

        $testno =~ s/^([^0-9]*)//;
        test_part = ""
        if $testno > 10000:
            test_part = $testno % 10000
            $testno = int($testno / 10000)

        getpart.loadtest(f"{$logdir}/test{$testno}")

        data = getpart.get_part("reply", f"data{test_part}")

        size = 0
        for $_ in data:
            size += length($_)

        my %hash = getpart.get_partattr("reply", f"data{test_part}")

        if size or $hash{'sendzero'}:

            if self.ftp_rest_pos:
                # move read pointer forward
                size -= self.ftp_rest_pos
                log_msg(f"REST {self.ftp_rest_pos} was removed from size, makes {size} left")
                self.ftp_rest_pos = 0  # reset REST offset again

            if $retrweirdo:
                self.send_control(f"150 Binary data connection for {$testno} () ({size} bytes).\r\n",
                                  "226 File transfer complete\r\n")
                for send in data:
                    self.send_data(send)
                self.close_dataconn()
                $retrweirdo = False  # switch off the weirdo again!
            else:
                my $sz = f"({size} bytes)"
                if $retrnosize:
                    $sz = "size?"
                elif $retrsize > 0:
                    $sz = f"({$retrsize} bytes)"

                self.send_control(f"150 Binary data connection for {$testno} ({test_part}) {$sz}.\r\n")
                for send in data:
                    self.send_data(send)
                self.close_dataconn()
                self.send_control("226 File transfer complete\r\n")
        else:
            self.send_control(f"550 {$testno}: No such file or directory.\r\n")

        return 0

    def STOR_ftp(self, $testno: int):

        my $filename = f"{$logdir}/upload.{$testno}"

        if self.datasockf_conn == "no":
            if self.ftp_nodataconn425:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("425 Can't open data connection\r\n")
            elif self.ftp_nodataconn421:
                self.send_control("150 Opening data connection\r\n")
                self.send_control("421 Connection timed out\r\n")
            elif self.ftp_nodataconn150:
                self.send_control("150 Opening data connection\r\n")
                # client shall timeout
            else:
                # client shall timeout
                pass
            return 0

        log_msg(f"STOR test number $testno in {$filename}")

        self.send_control("125 Gimme gimme gimme!\r\n")

        open(my $file, ">", "$filename") ||
            return 0 # failed to open output

        my $line;
        my $ulsize = 0;
        my $disc   = 0;
        while sysread(DREAD, $line, 5) == 5:

            if $line == "DATA\n":
                my $i;
                sysread(DREAD, $i, 5)

                size = 0
                if $i =~ /^([0-9a-fA-F]{4})\n/:
                    size = hex($1)

                read_datasockf(\$line, size)

                # print(f"  GOT: {{size}} bytes", file=sys.stderr)

                $ulsize += size
                if not self.no_save: print $file $line
                log_msg(f"> Appending {size} bytes to file")

            elif $line == "DISC\n":

                # disconnect!
                $disc = 1
                printf DWRITE "ACKD\n";
                last;

            else:
                log_msg(f"No support for: $line", end="")
                last;

            if $storeresp:
                # abort early
                last;

        if self.no_save:
            print $file "$ulsize bytes would've been stored here\n";

        close($file);
        self.close_dataconn($disc)
        log_msg(f"received $ulsize bytes upload")
        if $storeresp:
            self.send_control(f"$storeresp\r\n")
        else:
            self.send_control("226 File transfer complete\r\n")

        return 0

    def PASV_ftp(self, $arg, $cmd):

        my $pasvport;

        # kill previous data connection sockfilt when alive
        if self.datasockf_runs == "yes":
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg(f"DATA sockfilt for {self.datasockf_mode} data channel killed")

        self.datasockf_set_state("STOPPED")

        log_msg("====> Passive DATA channel requested by client")

        log_msg("DATA sockfilt for passive data channel starting...")

        # We fire up a new sockfilt to do the data transfer for us.
        my @datasockfcmd = [
            f"./server/sockfilt{exe_ext('SRV')}",
            "--ipv$ipvnum",
            "--port", 0,
            "--pidfile", $datasockf_pidfile,
            "--logfile", $datasockf_logfile,
        ]
        if self.ftp_nodataconn:
            push(@datasockfcmd, '--bindonly');

        $slavepid = open2(\*DREAD, \*DWRITE, @datasockfcmd);

        if self.ftp_nodataconn:
            self.datasockf_set_state("PASSIVE_NODATACONN")
        else:
            self.datasockf_set_state("PASSIVE")

        if $verbose:
            print(f"{@datasockfcmd}", file=sys.stderr)

        print DWRITE "PING\n";
        my $pong;
        sysread_or_die(\*DREAD, \$pong, 5);

        if($pong =~ /^FAIL/) {
            log_msg("DATA sockfilt said: FAIL")
            log_msg("DATA sockfilt for passive data channel failed")
            log_msg("DATA sockfilt not running")
            self.datasockf_set_state("STOPPED")
            self.send_control("500 no free ports!\r\n")
            return
        }
        elsif($pong !~ /^PONG/) {
            log_msg(f"DATA sockfilt unexpected response: $pong")
            log_msg("DATA sockfilt for passive data channel failed")
            log_msg("DATA sockfilt killed now")
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg("DATA sockfilt not running")
            self.datasockf_set_state("STOPPED")
            self.send_control("500 no free ports!\r\n")
            return
        }

        log_msg(f"DATA sockfilt for passive data channel started (pid $slavepid)")

        # Find out on what port we listen on or have bound
        my $i;
        print DWRITE "PORT\n";

        # READ the response code
        sysread_or_die(\*DREAD, \$i, 5)

        # READ the response size
        sysread_or_die(\*DREAD, \$i, 5)

        size = 0
        if $i =~ /^([0-9a-fA-F]{4})\n/:
            size = hex($1)

        # READ the response data
        read_datasockf(\$i, size)

        # The data is in the format
        # IPvX/NNN

        if $i =~ /IPv(\d)\/(\d+)/:
            # FIX: deal with IP protocol version
            $pasvport = $2

        if not $pasvport:
            log_msg("DATA sockfilt unknown listener port")
            log_msg("DATA sockfilt for passive data channel failed")
            log_msg("DATA sockfilt killed now")
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg("DATA sockfilt not running")
            self.datasockf_set_state("STOPPED")
            self.send_control("500 no free ports!\r\n")
            return

        if self.ftp_nodataconn:
            my $str = nodataconn_str()
            log_msg(f"DATA sockfilt for passive data channel ($str) bound on port "
                    f"$pasvport")
        else:
            log_msg(f"DATA sockfilt for passive data channel listens on port "
                    f"$pasvport")

        if($cmd != "EPSV") {
            # PASV reply
            my $p = listen_addr
            $p =~ s/\./,/g;
            if($pasvbadip) {
                $p="1,2,3,4";
            }
            self.send_control(f"227 Entering Passive Mode ($p,%d,%d)\n" %
                              (int($pasvport / 256), int($pasvport % 256)))
        }
        else:
            # EPSV reply
            self.send_control("229 Entering Passive Mode (|||%d|)\n" % $pasvport)

        log_msg(f"Client has been notified that DATA conn "
                f"will be accepted on port $pasvport")

        if self.ftp_nodataconn:
            my $str = nodataconn_str();
            log_msg(f"====> Client fooled ($str)")
            return

        eval {
            local $SIG{ALRM} = sub { die "alarm\n" };

            # assume swift operations unless explicitly slow
            alarm(20 if self.data_delay else 2)

            # Wait for 'CNCT'
            my $input;

            # FIX: Monitor ctrl conn for disconnect

            while(sysread(DREAD, $input, 5)) {

                if($input !~ /^CNCT/) {
                    # we wait for a connected client
                    log_msg(f"Odd, we got $input from client")
                    next;
                }
                log_msg(f"Client connects to port $pasvport")
                last;
            }
            alarm 0;
        };
        if ($@) {
            # timed out
            log_msg(f"{$srvrname} server timed out awaiting data connection "
                    f"on port $pasvport")
            log_msg("accept failed or connection not even attempted")
            log_msg("DATA sockfilt killed now")
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg("DATA sockfilt not running")
            self.datasockf_set_state("STOPPED")
            return
        }
        else:
            log_msg(f"====> Client established passive DATA connection "
                    f"on port $pasvport")

    def PORT_ftp(self, $arg, $cmd):
        """Support both PORT and EPRT here."""

        my $port;
        my $addr;

        # kill previous data connection sockfilt when alive
        if self.datasockf_runs == "yes":
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg(f"DATA sockfilt for {self.datasockf_mode} data channel killed")

        self.datasockf_set_state("STOPPED")

        log_msg("====> Active DATA channel requested by client")

        # We always ignore the given IP and use localhost.

        if($cmd == "PORT") {
            if($arg !~ /(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)/) {
                log_msg(f"DATA sockfilt for active data channel not started "
                        f"(bad PORT-line: $arg)")
                self.send_control("500 silly you, go away\r\n")
                return
            }
            $port = ($5<<8)+$6;
            $addr = "$1.$2.$3.$4";
        }
        # EPRT |2|::1|49706|
        elsif($cmd == "EPRT") {
            if($arg !~ /(\d+)\|([^\|]+)\|(\d+)/) {
                log_msg(f"DATA sockfilt for active data channel not started "
                        f"(bad EPRT-line: $arg)")
                self.send_control("500 silly you, go away\r\n")
                return
            }
            self.send_control("200 Thanks for dropping by. We contact you later\r\n")
            $port = $3
            $addr = $2
        }
        else:
            log_msg(f"DATA sockfilt for active data channel not started "
                    f"(invalid command: $cmd)")
            self.send_control(f"500 we don't like $cmd now\r\n")
            return

        if(!$port || $port > 65535) {
            log_msg(f"DATA sockfilt for active data channel not started "
                    f"(illegal PORT number: $port)")
            return
        }

        if self.ftp_nodataconn:
            my $str = nodataconn_str()
            log_msg(f"DATA sockfilt for active data channel not started ($str)")
            self.datasockf_set_state("ACTIVE_NODATACONN")
            log_msg("====> Active DATA channel not established")
            return

        log_msg("DATA sockfilt for active data channel starting...")

        # We fire up a new sockfilt to do the data transfer for us.
        my @datasockfcmd = [
            f"./server/sockfilt{exe_ext('SRV')}",
            "--ipv$ipvnum",
            "--connect", $port,
            "--addr", $addr,
            "--pidfile", $datasockf_pidfile,
            "--logfile", $datasockf_logfile,
        ]
        $slavepid = open2(\*DREAD, \*DWRITE, @datasockfcmd);

        self.datasockf_set_state("ACTIVE")

        if $verbose:
            print(f"{@datasockfcmd}", file=sys.stderr)

        print DWRITE "PING\n";
        my $pong;
        sysread_or_die(\*DREAD, \$pong, 5);

        if($pong =~ /^FAIL/) {
            log_msg("DATA sockfilt said: FAIL")
            log_msg("DATA sockfilt for active data channel failed")
            log_msg("DATA sockfilt not running")
            self.datasockf_set_state("STOPPED")
            # client shall timeout awaiting connection from server
            return
        }
        elsif($pong !~ /^PONG/) {
            log_msg(f"DATA sockfilt unexpected response: $pong")
            log_msg("DATA sockfilt for active data channel failed")
            log_msg("DATA sockfilt killed now")
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg("DATA sockfilt not running")
            self.datasockf_set_state("STOPPED")
            # client shall timeout awaiting connection from server
            return
        }

        log_msg(f"DATA sockfilt for active data channel started (pid $slavepid)")

        log_msg(f"====> Active DATA channel connected to client port $port")

    # OK
    def datasockf_set_state(self, state):
        """
        datasockf_set_state() is used to change variables that keep state
        info relative to the FTP secondary or data sockfilt process as soon
        as one of the five possible stable states is reached. Variables that
        are modified by this sub may be checked independently but should
        not be changed except by calling this sub.
        """
        if state == "STOPPED":
            # Data sockfilter initial state, not running,
            # not connected and not used.
            self.datasockf_state = state
            self.datasockf_mode  = "none"
            self.datasockf_runs  = "no"
            self.datasockf_conn  = "no"
        elif state == "PASSIVE":
            # Data sockfilter accepted connection from client.
            self.datasockf_state = state
            self.datasockf_mode  = "passive"
            self.datasockf_runs  = "yes"
            self.datasockf_conn  = "yes"
        elif state == "ACTIVE":
            # Data sockfilter has connected to client.
            self.datasockf_state = state
            self.datasockf_mode  = "active"
            self.datasockf_runs  = "yes"
            self.datasockf_conn  = "yes"
        elif state == "PASSIVE_NODATACONN":
            # Data sockfilter bound port without listening,
            # client won't be able to establish data connection.
            self.datasockf_state = state
            self.datasockf_mode  = "passive"
            self.datasockf_runs  = "yes"
            self.datasockf_conn  = "no"
        elif state == "ACTIVE_NODATACONN":
            # Data sockfilter does not even run,
            # client awaits data connection from server in vain.
            self.datasockf_state = state
            self.datasockf_mode  = "active"
            self.datasockf_runs  = "no"
            self.datasockf_conn  = "no"
        else:
            die(f"Internal error. Unknown datasockf state: {state}!")

    def nodataconn_str(self):
        """
        nodataconn_str returns string of effective nodataconn command. Notice
        that ftp_nodataconn may be set alone or in addition to a ftp_nodataconnXXX.
        """
        my str;
        # order matters
        if self.ftp_nodataconn:    str = "NODATACONN"
        if self.ftp_nodataconn425: str = "NODATACONN425"
        if self.ftp_nodataconn421: str = "NODATACONN421"
        if self.ftp_nodataconn150: str = "NODATACONN150"
        return f"{str}"

    def customize(self):
        # customize configures test server operation for each curl test, reading
        # configuration commands/parameters from server commands file each time
        # a new client control connection is established with the test server.
        # On success returns 1, otherwise zero.

        # my($cmdfile) = @_;

        self.ctrl_delay  = 0.0          # default is no throttling of the ctrl stream
        self.data_delay  = 0.0          # default is no throttling of the data stream
        $retrweirdo = False    # default is no use of RETRWEIRDO
        $retrnosize = 0;    # default is no use of RETRNOSIZE
        $retrsize = 0;      # default is no use of RETRSIZE
        $pasvbadip = 0;     # default is no use of PASVBADIP
        self.no_save     = False        # default is to actually save uploaded data to file
        self.ftp_nodataconn    = False  # default is to establish or accept data channel
        self.ftp_nodataconn425 = False  # default is to not send 425 without data channel
        self.ftp_nodataconn421 = False  # default is to not send 421 without data channel
        self.ftp_nodataconn150 = False  # default is to not send 150 without data channel
        $storeresp = ""    # send as ultimate STOR response
        $postfetch = ""    # send as header after a FETCH response
        self.capabilities = []  # default is to not support capability commands
        self.auth_mechs   = []  # default is to not support authentication commands
        %fulltextreply = ()  #
        %commandreply  = ()  #
        %customcount   = ()  #
        %delayreply    = ()  #

        my $custom = ("$logdir/$SERVERCMD").open("<", ) ||
            return 1

        log_msg(f"FTPD: Getting commands from $logdir/$SERVERCMD")

        while (<$custom>):

            if $_ =~ /REPLY \"([A-Z]+ [A-Za-z0-9+-\/=\*. ]+)\" (.*)/:
                $fulltextreply{$1}=eval "qq{$2}";
                log_msg(f"FTPD: set custom reply for $1")
            elif $_ =~ /REPLY(LF|) ([A-Za-z0-9+\/=\*]*) (.*)/:
                $commandreply{$2}=eval "qq{$3}";
                if $1 != "LF":
                    $commandreply{$2} .= "\r\n";
                else:
                    $commandreply{$2} .= "\n";
                if $2 == "":
                    log_msg("FTPD: set custom reply for empty command")
                else:
                    log_msg(f"FTPD: set custom reply for $2 command")
            elif $_ =~ /COUNT ([A-Z]+) (.*)/:
                # we blank the custom reply for this command when having
                # been used this number of times
                $customcount{$1} = $2
                log_msg(f"FTPD: blank custom reply for $1 command after $2 uses")
            elif $_ =~ /DELAY ([A-Z]+) (\d*)/:
                $delayreply{$1} = $2
                log_msg(f"FTPD: delay reply for $1 with $2 seconds")
            elif $_ =~ /POSTFETCH (.*)/:
                log_msg("FTPD: read POSTFETCH header data")
                $postfetch = $1
            elif $_ =~ /SLOWDOWNDATA/:
                self.ctrl_delay = 0.0
                self.data_delay = 0.005
                log_msg("FTPD: send response data with 5ms delay per byte")
            elif $_ =~ /SLOWDOWN/:
                self.ctrl_delay = 0.005
                self.data_delay = 0.005
                log_msg("FTPD: send response with 5ms delay between each byte")
            elif $_ =~ /RETRWEIRDO/:
                log_msg("FTPD: instructed to use RETRWEIRDO")
                $retrweirdo = True
            elif $_ =~ /RETRNOSIZE/:
                log_msg("FTPD: instructed to use RETRNOSIZE")
                $retrnosize=1;
            elif $_ =~ /RETRSIZE (\d+)/:
                $retrsize = $1
                log_msg(f"FTPD: instructed to use RETRSIZE = $1")
            elif $_ =~ /PASVBADIP/:
                log_msg("FTPD: instructed to use PASVBADIP")
                $pasvbadip=1;
            elif $_ =~ /NODATACONN425/:
                # applies to both active and passive FTP modes
                log_msg("FTPD: instructed to use NODATACONN425")
                self.ftp_nodataconn425 = True
                self.ftp_nodataconn    = True
            elif $_ =~ /NODATACONN421/:
                # applies to both active and passive FTP modes
                log_msg("FTPD: instructed to use NODATACONN421")
                self.ftp_nodataconn421 = True
                self.ftp_nodataconn    = True
            elif $_ =~ /NODATACONN150/:
                # applies to both active and passive FTP modes
                log_msg("FTPD: instructed to use NODATACONN150")
                self.ftp_nodataconn150 = True
                self.ftp_nodataconn    = True
            elif $_ =~ /NODATACONN/:
                # applies to both active and passive FTP modes
                log_msg("FTPD: instructed to use NODATACONN")
                self.ftp_nodataconn = True
            elif $_ =~ /^STOR (.*)/:
                $storeresp = $1
                log_msg(f"FTPD: instructed to use respond to STOR with '$storeresp'")
            elif $_ =~ /CAPA (.*)/:
                log_msg("FTPD: instructed to support CAPABILITY command")
                self.capabilities = $1.split(/ (?!(?:[^" ]|[^"] [^"])+")/)
                foreach self.capabilities:
                    $_ = $1 if /^"(.*)"$/;
            elif $_ =~ /AUTH (.*)/:
                log_msg("FTPD: instructed to support AUTHENTICATION command")
                self.auth_mechs = $1.split(" ")
            elif $_ =~ /NOSAVE/:
                # don't actually store the file we upload - to be used when
                # uploading insanely huge amounts
                self.no_save = True
                log_msg("FTPD: NOSAVE prevents saving of uploaded data")
            elif $_ =~ /^Testnum (\d+)/:
                $testno = $1
                log_msg(f"FTPD: run test case number: {$testno}")

        close($custom)


def get_options(argv=sys.argv[1:]):
    #"""Process command line options"""
    """
    Parse command line options

    Options:

    --verbose   # verbose
    --srcdir    # source directory
    --id        # server instance number
    --proto     # server protocol
    --pidfile   # server pid file
    --portfile  # server port file
    --logfile   # server log file
    --logdir    # server log directory
    --ipv4      # server IP version 4
    --ipv6      # server IP version 6
    --port      # server listener port
    --addr      # server address for listener port binding
    """
    app_name = sys.argv[0].rpartition("/")[2].rpartition("\\")[2]

    parser = argparse.ArgumentParser(prog=f"python {app_name}")

    # ...

    options = parser.parse_args(argv)
    options._app_name   = app_name
    options._arg_parser = parser

    return options

    while (@ARGV):

        if($ARGV[0] == '--verbose') {
            $verbose = 1;
        }
        elsif($ARGV[0] == '--srcdir') {
            if($ARGV[1]) {
                $srcdir = $ARGV[1];
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--id') {
            if($ARGV[1] and ($ARGV[1] =~ /^(\d+)$/)) {
                $idnum = $1 if($1 > 0);
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--proto') {
            if($ARGV[1] and ($ARGV[1] =~ /^(ftp|imap|pop3|smtp)$/)) {
                $proto = $1
                shift @ARGV;
            }
            else:
                die "unsupported protocol $ARGV[1]";
        }
        elsif($ARGV[0] == '--pidfile') {
            if($ARGV[1]) {
                $pidfile = Path($ARGV[1])
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--portfile') {
            if($ARGV[1]) {
                $portfile = Path($ARGV[1])
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--logfile') {
            if($ARGV[1]) {
                serverhelp.log_file = $ARGV[1];
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--logdir') {
            if($ARGV[1]) {
                $logdir = $ARGV[1];
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--ipv4') {
            $ipvnum = 4;
            if listen_addr == "::1": listen_addr = "127.0.0.1"
        }
        elsif($ARGV[0] == '--ipv6') {
            $ipvnum = 6;
            if listen_addr == "127.0.0.1": listen_addr = "::1"
        }
        elsif($ARGV[0] == '--port') {
            if($ARGV[1] =~ /^(\d+)$/) {
                $port = $1
                shift @ARGV;
            }
        }
        elsif($ARGV[0] == '--addr') {
            if($ARGV[1]) {
                my $tmpstr = $ARGV[1];
                if($tmpstr =~ /^(\d\d?\d?)\.(\d\d?\d?)\.(\d\d?\d?)\.(\d\d?\d?)$/) {
                    if $ipvnum == 4: listen_addr = f"$1.$2.$3.$4"
                }
                elsif($ipvnum == 6) {
                    listen_addr = $tmpstr
                    listen_addr =~ s/^\[(.*)\]$/$1/
                }
                shift @ARGV;
            }
        }
        else:
            print(STDERR f"\nWarning: ftpserver.pl unknown parameter: $ARGV[0]")

        shift @ARGV;

#----------------------------------------------------------------------
#----------------------------------------------------------------------
#---------------------------  END OF SUBS  ----------------------------
#----------------------------------------------------------------------
#----------------------------------------------------------------------

def main(argv=sys.argv[1:]) -> int:

    # Get the options from the user.
    options = get_options(argv)

    ftp_server = FtpServer($proto)

    #***************************************************************************
    # Initialize command line option dependent variables
    #

    if $pidfile:
        # Use our pidfile directory to store the other pidfiles
        $piddir = $pidfile.parent
    else:
        # Use the current directory to store all the pidfiles
        $piddir = Path($path)
        $pidfile = serverhelp.server_pid_file($piddir, $proto, $ipvnum, $idnum)
    if ! $portfile:
        $portfile = $piddir/$PORTFILE
    if ! $srcdir:
        $srcdir = $ENV{'srcdir'} || '.'
    if not serverhelp.log_file:
        serverhelp.log_file = serverhelp.server_logfilename($logdir, $proto, $ipvnum, $idnum)

    $mainsockf_pidfile = serverhelp.mainsockf_pid_filepath($piddir, $proto, $ipvnum, $idnum)
    $mainsockf_logfile = serverhelp.mainsockf_log_filepath($logdir, $proto, $ipvnum, $idnum)

    if $proto == 'ftp':
        $datasockf_pidfile = serverhelp.datasockf_pid_filepath($piddir, $proto, $ipvnum, $idnum)
        $datasockf_logfile = serverhelp.datasockf_log_filepath($logdir, $proto, $ipvnum, $idnum)

    $srvrname = serverhelp.server_name_str($proto, $ipvnum, $idnum)
    $serverlogs_lockfile = $logdir/config.LOCKDIR/f"${srvrname}.lock"

    if $idnum > 1: $idstr = f"$idnum"

    ftp_server.protocol_setup($proto)

    $SIG{INT}  = exit_signal_handler
    $SIG{TERM} = exit_signal_handler

    startsf()

    # actual port
    if $portfile and ! $port:
        with $portfile.open("<") as $p:
            $aport = <$p>
        $port = int($aport)

    log_msg(f"%s server listens on port IPv${ipvnum}/${port}" % $proto.upper())

    with $pidfile.open(">") as pid:
        print($$, file=pid)

    log_msg(f"logged pid $$ in {$pidfile}")

    # Server loop
    while True:

        # kill previous data connection sockfilt when alive
        if ftp_server.datasockf_runs == "yes":
            killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose, 'data')
            log_msg(f"DATA sockfilt for {ftp_server.datasockf_mode} data channel "
                    "killed now")

        ftp_server.datasockf_set_state("STOPPED")

        #
        # We read 'sockfilt' commands.
        #
        my $input;

        log_msg("Awaiting input")
        sysread_or_die(\*SFREAD, \$input, 5);

        if ($input !~ /^CNCT/):
            # we wait for a connected client
            log_msg(f"MAIN sockfilt said: $input", end="")
            next;

        log_msg("====> Client connect")

        set_advisor_read_lock($serverlogs_lockfile)
        serverlogs_locked = True

        # flush data:
        sys.stdout.reconfigure(line_buffering=False)
        sys.stderr.reconfigure(line_buffering=False)

        &customize();  # read test control instructions
        getpart.loadtest(f"{$logdir}/test{$testno}")

        welcome = $commandreply{"welcome"}
        if not welcome:
            welcome = ftp_server.display_text.get("welcome")
        else:
            # clear it after use
            $commandreply{"welcome"} = ""
            if welcome !~ /\r\n\z/:
                welcome += "\r\n"

        ftp_server.send_control(welcome)

        # remove global variables from last connection
        ftp_server.ftp_listparserstate = None
        ftp_server.ftp_target_dir      = ""

        if $verbose:
            print(f"OUT: {welcome}";, file=sys.stderr)

        my $full = ""

        while True:

            # Now we expect to read DATA\n[hex size]\n[prot], where the [prot]
            # part only is FTP lingo.

            # COMMAND
            my $i;
            sysread_or_die(\*SFREAD, \$i, 5);

            if $i !~ /^DATA/:
                log_msg(f"MAIN sockfilt said $i", end="")
                if $i =~ /^DISC/:
                    # disconnect
                    printf SFWRITE "ACKD\n";
                    last;
                next;

            # SIZE of data
            sysread_or_die(\*SFREAD, \$i, 5)

            size = 0
            if $i =~ /^([0-9a-fA-F]{4})\n/:
                size = hex($1)

            # data
            read_mainsockf(\$input, size)

            ftpmsg $input;

            $full += $input

            # Loop until command completion
            if not ($full =~ /\r\n$/): next

            # Remove trailing CRLF.
            $full =~ s/[\n\r]+$//;

            my $FTPCMD;
            my $FTPARG;
            if $proto == "imap":
                # IMAP is different with its identifier first on the command line
                if (($full =~ /^([^ ]+) ([^ ]+) (.*)/) ||
                    ($full =~ /^([^ ]+) ([^ ]+)/)):
                    ftp_server.imap_cmdid = $1  # set the global variable
                    $FTPCMD = $2
                    $FTPARG = $3
                # IMAP authentication cancellation
                elif $full =~ /^\*$/:
                    # Command id has already been set
                    $FTPCMD = "*"
                    $FTPARG = ""
                # IMAP long "commands" are base64 authentication data
                elif $full =~ /^[A-Z0-9+\/]*={0,2}$/i:
                    # Command id has already been set
                    $FTPCMD = $full;
                    $FTPARG = ""
                else:
                    ftp_server.send_control(f"$full BAD Command\r\n")
                    last;
            elif $full =~ /^([A-Z]{3,4})(\s(.*))?$/i:
                $FTPCMD = $1
                $FTPARG = $3
            elif $proto == "pop3":
                # POP3 authentication cancellation
                if $full =~ /^\*$/:
                    $FTPCMD = "*"
                    $FTPARG = ""
                # POP3 long "commands" are base64 authentication data
                elif $full =~ /^[A-Z0-9+\/]*={0,2}$/i:
                    $FTPCMD = $full;
                    $FTPARG = ""
                else:
                    ftp_server.send_control("-ERR Unrecognized command\r\n")
                    last;
            elif $proto == "smtp":
                # SMTP authentication cancellation
                if $full =~ /^\*$/:
                    $FTPCMD = "*"
                    $FTPARG = ""
                # SMTP long "commands" are base64 authentication data
                elif $full =~ /^[A-Z0-9+\/]{0,512}={0,2}$/i:
                    $FTPCMD = $full;
                    $FTPARG = ""
                else:
                    ftp_server.send_control("500 Unrecognized command\r\n")
                    last;
            else:
                ftp_server.send_control("500 Unrecognized command\r\n")
                last;

            log_msg(f'< "{$full}"')

            if $verbose:
                print(f"IN: {$full}", file=sys.stderr)

            $full = ""

            delay = $delayreply{$FTPCMD}
            if delay:
                # just go sleep this many seconds!
                log_msg(f"Sleep for {delay} seconds")
                twentieths = delay * 20
                while twentieths:
                    twentieths -= 1
                    if not got_exit_signal:
                        portable_sleep(0.05)

            check: bool = True  # no response yet

            # See if there is a custom reply for the full text
            fulltext = ($FTPCMD + " " + $FTPARG) if $FTPARG else $FTPCMD
            my $text = $fulltextreply{fulltext}
            if $text and $text != "":
                ftp_server.send_control(f"{$text}\r\n")
                check = False
            else:
                # See if there is a custom reply for the command
                $text = $commandreply{$FTPCMD};
                if $text and $text != "":
                    if $customcount{$FTPCMD} and (! --$customcount{$FTPCMD}):
                        # used enough times so blank the custom command reply
                        $commandreply{$FTPCMD} = ""

                    ftp_server.send_control($text)
                    check = False
                else:
                    # See if there is any display text for the command
                    $text = ftp_server.display_text.get($FTPCMD)
                    if $text and $text != "":
                        if $proto == "imap":
                            ftp_server.send_control(f"{cmdid} {$text}\r\n")
                        else:
                            ftp_server.send_control(f"{$text}\r\n")
                        check = False

                    # only perform this if we're not faking a reply
                    func = ftp_server.command_func.get($FTPCMD.upper())
                    if func:
                        func($FTPARG, $FTPCMD)
                        check = False

            if check:
                log_msg(f"{$FTPCMD} wasn't handled!")
                if $proto == "pop3":
                    ftp_server.send_control(f"-ERR {$FTPCMD} is not dealt with!\r\n")
                elif $proto == "imap":
                    ftp_server.send_control(f"{cmdid} BAD {$FTPCMD} is not dealt with!\r\n")
                else:
                    ftp_server.send_control(f"500 {$FTPCMD} is not dealt with!\r\n")

        log_msg("====> Client disconnected")

        if serverlogs_locked:
            serverlogs_locked = False
            clear_advisor_read_lock($serverlogs_lockfile)

    killsockfilters($piddir, $proto, $ipvnum, $idnum, $verbose)
    $pidfile.unlink()
    if serverlogs_locked:
        serverlogs_locked = False
        clear_advisor_read_lock($serverlogs_lockfile)


if __name__ == "__main__":
    sys.exit(main())
