# SPDX-License-Identifier: GPL-2.0-only
# This file is part of Scapy
# See https://scapy.net/ for more information
# Copyright (C) Philippe Biondi <phil@secdev.org>

"""
TFTP (Trivial File Transfer Protocol).

This provides TFTP implementation and 4 small automata:
    - TFTP_read: read a remote file
    - TFTP_RRQ_server: server that answers to read requests
    - TFTP_write: write a remote file
    - TFTP_WRQ_server: server than accepts write requests
"""

import os
import random

from scapy.packet import Packet, bind_layers, split_bottom_up, bind_bottom_up
from scapy.fields import (
    PacketListField,
    ShortEnumField,
    ShortField,
    StrNullField,
)
from scapy.automaton import ATMT, Automaton
from scapy.base_classes import Net
from scapy.config import conf
from scapy.sessions import IPSession
from scapy.volatile import RandShort

from scapy.layers.inet import UDP, IP

TFTP_operations = {1: "RRQ", 2: "WRQ", 3: "DATA", 4: "ACK", 5: "ERROR", 6: "OACK"}  # noqa: E501


class TFTP(Packet):
    name = "TFTP opcode"
    fields_desc = [ShortEnumField("op", 1, TFTP_operations), ]


class TFTP_RRQ(Packet):
    name = "TFTP Read Request"
    fields_desc = [StrNullField("filename", ""),
                   StrNullField("mode", "octet")]

    def answers(self, other):
        return 0

    def mysummary(self):
        return self.sprintf("RRQ %filename%"), [UDP]


class TFTP_WRQ(Packet):
    name = "TFTP Write Request"
    fields_desc = [StrNullField("filename", ""),
                   StrNullField("mode", "octet")]

    def answers(self, other):
        return 0

    def mysummary(self):
        return self.sprintf("WRQ %filename%"), [UDP]


class TFTP_DATA(Packet):
    name = "TFTP Data"
    fields_desc = [ShortField("block", 0)]

    def answers(self, other):
        return self.block == 1 and isinstance(other, TFTP_RRQ)

    def mysummary(self):
        return self.sprintf("DATA %block%"), [UDP]


class TFTP_Option(Packet):
    fields_desc = [StrNullField("oname", ""),
                   StrNullField("value", "")]

    def extract_padding(self, pkt):
        return "", pkt


class TFTP_Options(Packet):
    fields_desc = [PacketListField("options", [], TFTP_Option, length_from=lambda x:None)]  # noqa: E501


class TFTP_ACK(Packet):
    name = "TFTP Ack"
    fields_desc = [ShortField("block", 0)]

    def answers(self, other):
        if isinstance(other, TFTP_DATA):
            return self.block == other.block
        elif isinstance(other, (TFTP_RRQ, TFTP_WRQ, TFTP_OACK)):  # noqa: E501
            return self.block == 0
        return 0

    def mysummary(self):
        return self.sprintf("ACK %block%"), [UDP]


TFTP_Error_Codes = {0: "Not defined",
                    1: "File not found",
                    2: "Access violation",
                    3: "Disk full or allocation exceeded",
                    4: "Illegal TFTP operation",
                    5: "Unknown transfer ID",
                    6: "File already exists",
                    7: "No such user",
                    8: "Terminate transfer due to option negotiation",
                    }


class TFTP_ERROR(Packet):
    name = "TFTP Error"
    fields_desc = [ShortEnumField("errorcode", 0, TFTP_Error_Codes),
                   StrNullField("errormsg", "")]

    def answers(self, other):
        return isinstance(other, (TFTP_DATA, TFTP_RRQ, TFTP_WRQ, TFTP_ACK))

    def mysummary(self):
        return self.sprintf("ERROR %errorcode%: %errormsg%"), [UDP]


class TFTP_OACK(Packet):
    name = "TFTP Option Ack"
    fields_desc = []

    def answers(self, other):
        return isinstance(other, (TFTP_WRQ, TFTP_RRQ))


bind_layers(UDP, TFTP, dport=69)
bind_layers(TFTP, TFTP_RRQ, op=1)
bind_layers(TFTP, TFTP_WRQ, op=2)
bind_layers(TFTP, TFTP_DATA, op=3)
bind_layers(TFTP, TFTP_ACK, op=4)
bind_layers(TFTP, TFTP_ERROR, op=5)
bind_layers(TFTP, TFTP_OACK, op=6)
bind_layers(TFTP_RRQ, TFTP_Options)
bind_layers(TFTP_WRQ, TFTP_Options)
bind_layers(TFTP_OACK, TFTP_Options)


# Automatons

class TFTP_read(Automaton):
    """
    TFTP automaton to read a remote file on a TFTP server.

    :param filename: the name of the remote file to read.
    :param server: the host on which to read (IP or name).
    :param sport: (optional) the source port to use. (default: random)
    :param port: (optional) the TFTP port (default: 69)
    """

    def parse_args(self, filename, server, sport=None, port=69, **kargs):
        if "iface" not in kargs:
            server = str(Net(server))
            kargs["iface"] = conf.route.route(server)[0]
        Automaton.parse_args(self, **kargs)
        self.filename = filename
        self.server = server
        self.port = port
        self.sport = sport

    def master_filter(self, pkt):
        return (IP in pkt and pkt[IP].src == self.server and UDP in pkt and
                pkt[UDP].dport == self.my_tid and
                (self.server_tid is None or pkt[UDP].sport == self.server_tid))

    # BEGIN
    @ATMT.state(initial=1)
    def BEGIN(self):
        self.blocksize = 512
        self.my_tid = self.sport or RandShort()._fix()
        bind_bottom_up(UDP, TFTP, dport=self.my_tid)
        self.server_tid = None
        self.res = b""

        self.l3 = IP(dst=self.server) / UDP(sport=self.my_tid, dport=self.port) / TFTP()  # noqa: E501
        self.last_packet = self.l3 / TFTP_RRQ(filename=self.filename, mode="octet")  # noqa: E501
        self.send(self.last_packet)
        self.awaiting = 1

        raise self.WAITING()

    # WAITING
    @ATMT.state()
    def WAITING(self):
        pass

    @ATMT.receive_condition(WAITING)
    def receive_data(self, pkt):
        if TFTP_DATA in pkt and pkt[TFTP_DATA].block == self.awaiting:
            if self.server_tid is None:
                self.server_tid = pkt[UDP].sport
                self.l3[UDP].dport = self.server_tid
            raise self.RECEIVING(pkt)

    @ATMT.receive_condition(WAITING, prio=1)
    def receive_error(self, pkt):
        if TFTP_ERROR in pkt:
            raise self.ERROR(pkt)

    @ATMT.timeout(WAITING, 3)
    def timeout_waiting(self):
        raise self.WAITING()

    @ATMT.action(timeout_waiting)
    def retransmit_last_packet(self):
        self.send(self.last_packet)

    @ATMT.action(receive_data)
#    @ATMT.action(receive_error)
    def send_ack(self):
        self.last_packet = self.l3 / TFTP_ACK(block=self.awaiting)
        self.send(self.last_packet)

    # RECEIVED
    @ATMT.state()
    def RECEIVING(self, pkt):
        if conf.raw_layer in pkt:
            recvd = pkt[conf.raw_layer].load
        else:
            recvd = b""
        self.res += recvd
        self.awaiting += 1
        if len(recvd) == self.blocksize:
            raise self.WAITING()
        raise self.END()

    # ERROR
    @ATMT.state(error=1)
    def ERROR(self, pkt):
        split_bottom_up(UDP, TFTP, dport=self.my_tid)
        return pkt[TFTP_ERROR].summary()

    # END
    @ATMT.state(final=1)
    def END(self):
        split_bottom_up(UDP, TFTP, dport=self.my_tid)
        return self.res


class TFTP_write(Automaton):
    """
    TFTP automaton to write a local file onto a TFTP server.

    :param filename: the name of the remote file to write.
    :param data: the bytes data to write.
    :param server: the host on which to read (IP or name).
    :param sport: (optional) the source port to use. (default: random)
    :param port: (optional) the TFTP port (default: 69)
    """

    def parse_args(self, filename, data, server, sport=None, port=69, **kargs):
        if "iface" not in kargs:
            server = str(Net(server))
            kargs["iface"] = conf.route.route(server)[0]
        Automaton.parse_args(self, **kargs)
        self.filename = filename
        self.server = server
        self.port = port
        self.sport = sport
        self.blocksize = 512
        self.origdata = data

    def master_filter(self, pkt):
        return (IP in pkt and pkt[IP].src == self.server and UDP in pkt and
                pkt[UDP].dport == self.my_tid and
                (self.server_tid is None or pkt[UDP].sport == self.server_tid))

    # BEGIN
    @ATMT.state(initial=1)
    def BEGIN(self):
        self.data = [self.origdata[i * self.blocksize:(i + 1) * self.blocksize]
                     for i in range(len(self.origdata) // self.blocksize + 1)]
        self.my_tid = self.sport or RandShort()._fix()
        bind_bottom_up(UDP, TFTP, dport=self.my_tid)
        self.server_tid = None

        self.l3 = IP(dst=self.server) / UDP(sport=self.my_tid, dport=self.port) / TFTP()  # noqa: E501
        self.last_packet = self.l3 / TFTP_WRQ(filename=self.filename, mode="octet")  # noqa: E501
        self.send(self.last_packet)
        self.res = ""
        self.awaiting = 0

        raise self.WAITING_ACK()

    # WAITING_ACK
    @ATMT.state()
    def WAITING_ACK(self):
        pass

    @ATMT.receive_condition(WAITING_ACK)
    def received_ack(self, pkt):
        if TFTP_ACK in pkt and pkt[TFTP_ACK].block == self.awaiting:
            if self.server_tid is None:
                self.server_tid = pkt[UDP].sport
                self.l3[UDP].dport = self.server_tid
            raise self.SEND_DATA()

    @ATMT.receive_condition(WAITING_ACK)
    def received_error(self, pkt):
        if TFTP_ERROR in pkt:
            raise self.ERROR(pkt)

    @ATMT.timeout(WAITING_ACK, 3)
    def timeout_waiting(self):
        raise self.WAITING_ACK()

    @ATMT.action(timeout_waiting)
    def retransmit_last_packet(self):
        self.send(self.last_packet)

    # SEND_DATA
    @ATMT.state()
    def SEND_DATA(self):
        self.awaiting += 1
        self.last_packet = self.l3 / TFTP_DATA(block=self.awaiting) / self.data.pop(0)  # noqa: E501
        self.send(self.last_packet)
        if self.data:
            raise self.WAITING_ACK()
        raise self.END()

    # ERROR
    @ATMT.state(error=1)
    def ERROR(self, pkt):
        split_bottom_up(UDP, TFTP, dport=self.my_tid)
        return pkt[TFTP_ERROR].summary()

    # END
    @ATMT.state(final=1)
    def END(self):
        split_bottom_up(UDP, TFTP, dport=self.my_tid)


class TFTP_WRQ_server(Automaton):
    """
    TFTP automaton to wait for incoming files

    :param ip: (optional) the local IP to listen on.
    :param sport: (optional) the local port (by default: random)
    """

    def parse_args(self, ip=None, sport=None, *args, **kargs):
        if "iface" not in kargs and ip:
            ip = str(Net(ip))
            kargs["iface"] = conf.route.route(ip)[0]
        kargs.setdefault("session", IPSession())
        Automaton.parse_args(self, *args, **kargs)
        self.ip = ip
        self.sport = sport

    def master_filter(self, pkt):
        return TFTP in pkt and (not self.ip or pkt[IP].dst == self.ip)

    @ATMT.state(initial=1)
    def BEGIN(self):
        self.blksize = 512
        self.blk = 1
        self.filedata = b""
        self.my_tid = self.sport or random.randint(10000, 65500)
        bind_bottom_up(UDP, TFTP, dport=self.my_tid)

    @ATMT.receive_condition(BEGIN)
    def receive_WRQ(self, pkt):
        if TFTP_WRQ in pkt:
            raise self.WAIT_DATA().action_parameters(pkt)

    @ATMT.action(receive_WRQ)
    def ack_WRQ(self, pkt):
        ip = pkt[IP]
        self.ip = ip.dst
        self.dst = ip.src
        self.filename = pkt[TFTP_WRQ].filename
        options = pkt.getlayer(TFTP_Options)
        self.l3 = IP(src=ip.dst, dst=ip.src) / UDP(sport=self.my_tid, dport=pkt.sport) / TFTP()  # noqa: E501
        if options is None:
            self.last_packet = self.l3 / TFTP_ACK(block=0)
            self.send(self.last_packet)
        else:
            opt = [x for x in options.options if x.oname.upper() == b"BLKSIZE"]
            if opt:
                self.blksize = int(opt[0].value)
                self.debug(2, "Negotiated new blksize at %i" % self.blksize)
            self.last_packet = self.l3 / TFTP_OACK() / TFTP_Options(options=opt)  # noqa: E501
            self.send(self.last_packet)

    @ATMT.state()
    def WAIT_DATA(self):
        pass

    @ATMT.timeout(WAIT_DATA, 1)
    def resend_ack(self):
        self.send(self.last_packet)
        raise self.WAIT_DATA()

    @ATMT.receive_condition(WAIT_DATA)
    def receive_data(self, pkt):
        if TFTP_DATA in pkt:
            data = pkt[TFTP_DATA]
            if data.block == self.blk:
                raise self.DATA(data)

    @ATMT.action(receive_data)
    def ack_data(self):
        self.last_packet = self.l3 / TFTP_ACK(block=self.blk)
        self.send(self.last_packet)

    @ATMT.state()
    def DATA(self, data):
        self.filedata += data.load
        if len(data.load) < self.blksize:
            raise self.END()
        self.blk += 1
        raise self.WAIT_DATA()

    @ATMT.state(final=1)
    def END(self):
        split_bottom_up(UDP, TFTP, dport=self.my_tid)
        return self.filename, self.filedata


class TFTP_RRQ_server(Automaton):
    """
    TFTP automaton to serve local files

    You can't use 'store' and 'dir' at the same time.

    :param store: (optional) a dictionary that contains the file data, like
                  {"thefile": b"data"}.
    :param dir: (optional) a folder that contains the data file data.
    :param joker: (optional) data to return when no file/data is found.
    :param ip: (optional) the local IP to listen on.
    :param sport: (optional) the local port (by default: random)
    :param serve_one: (optional) close after serving one client (default: False)
    """

    def parse_args(self, store=None, joker=None, dir=None, ip=None, sport=None, serve_one=False, **kargs):  # noqa: E501
        if "iface" not in kargs and ip:
            ip = str(Net(ip))
            kargs["iface"] = conf.route.route(ip)[0]
        kargs.setdefault("session", IPSession())
        Automaton.parse_args(self, **kargs)
        if store is None:
            store = {}
        if dir is not None:
            self.dir = os.path.join(os.path.abspath(dir), "")
        else:
            self.dir = None
        self.store = store
        self.joker = joker
        self.ip = ip
        self.sport = sport
        self.serve_one = serve_one
        self.my_tid = self.sport or random.randint(10000, 65500)
        bind_bottom_up(UDP, TFTP, dport=self.my_tid)

    def master_filter(self, pkt):
        return TFTP in pkt and (not self.ip or pkt[IP].dst == self.ip)

    @ATMT.state(initial=1)
    def WAIT_RRQ(self):
        self.blksize = 512
        self.blk = 0

    @ATMT.receive_condition(WAIT_RRQ)
    def receive_rrq(self, pkt):
        if TFTP_RRQ in pkt:
            raise self.RECEIVED_RRQ(pkt)

    @ATMT.state()
    def RECEIVED_RRQ(self, pkt):
        ip = pkt[IP]
        options = pkt.getlayer(TFTP_Options)
        self.l3 = IP(src=ip.dst, dst=ip.src) / UDP(sport=self.my_tid, dport=ip.sport) / TFTP()  # noqa: E501
        self.filename = pkt[TFTP_RRQ].filename.decode("utf-8", "ignore")
        self.blk = 1
        self.data = None
        if self.filename in self.store:
            self.data = self.store[self.filename]
        elif self.dir is not None:
            fn = os.path.abspath(os.path.join(self.dir, self.filename))
            if fn.startswith(self.dir):  # Check we're still in the server's directory  # noqa: E501
                try:
                    with open(fn) as fd:
                        self.data = fd.read()
                except IOError:
                    pass
        if self.data is None:
            self.data = self.joker

        if options:
            opt = [x for x in options.options if x.oname.upper() == b"BLKSIZE"]
            if opt:
                self.blksize = int(opt[0].value)
                self.debug(2, "Negotiated new blksize at %i" % self.blksize)
            self.last_packet = self.l3 / TFTP_OACK() / TFTP_Options(options=opt)  # noqa: E501
            self.send(self.last_packet)

    @ATMT.condition(RECEIVED_RRQ)
    def file_in_store(self):
        if self.data is not None:
            self.blknb = len(self.data) / self.blksize + 1
            raise self.SEND_FILE()

    @ATMT.condition(RECEIVED_RRQ)
    def file_not_found(self):
        if self.data is None:
            raise self.WAIT_RRQ()

    @ATMT.action(file_not_found)
    def send_error(self):
        self.send(self.l3 / TFTP_ERROR(errorcode=1, errormsg=TFTP_Error_Codes[1]))  # noqa: E501

    @ATMT.state()
    def SEND_FILE(self):
        self.send(self.l3 / TFTP_DATA(block=self.blk) / self.data[(self.blk - 1) * self.blksize:self.blk * self.blksize])  # noqa: E501

    @ATMT.timeout(SEND_FILE, 3)
    def timeout_waiting_ack(self):
        raise self.SEND_FILE()

    @ATMT.receive_condition(SEND_FILE)
    def received_ack(self, pkt):
        if TFTP_ACK in pkt and pkt[TFTP_ACK].block == self.blk:
            raise self.RECEIVED_ACK()

    @ATMT.state()
    def RECEIVED_ACK(self):
        self.blk += 1

    @ATMT.condition(RECEIVED_ACK)
    def no_more_data(self):
        if self.blk > self.blknb:
            if self.serve_one:
                raise self.END()
            raise self.WAIT_RRQ()

    @ATMT.condition(RECEIVED_ACK, prio=2)
    def data_remaining(self):
        raise self.SEND_FILE()

    @ATMT.state(final=1)
    def END(self):
        split_bottom_up(UDP, TFTP, dport=self.my_tid)
