Sign in

    tftp

    TFTP (RFC 1350/2347/2348/2349) client and server in pure MoonBit.

    tftp
    udp
    protocol
    netascii
    file-transfer
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    10 days ago
    Downloads
    22

    #PaiGack/tftp

    TFTP(Trivial File Transfer Protocol,简单文件传输协议)的纯 MoonBit 实现, 覆盖 RFC 1350 基础协议与 RFC 2347/2348/2349 的选项扩展。

    #目标

    做成一个「协议库 + 客户端 + 服务端 + 命令行」的完整实现,各层共享报文编解码:

    • 报文层:RRQ / WRQ / DATA / ACK / ERROR / OACK 的编解码(encode / decode), 通道模式(netascii / octet),畸形报文以 DecodeError 返回,block 号 u16 回绕。
    • 客户端:get / put,超时重传、重复包去重(Sorcerer's Apprentice 问题修复)、 blksize / timeout / tsize 选项协商。
    • 服务端:69 端口监听、每请求独立 TID 会话、并发会话管理、 读写权限与路径穿越防护、错误码语义。
    • CLI:cmd/main 的 get / put / serve 三个子命令。

    #当前状态

    M0~M5 已落地:报文层、选项协商、客户端、服务端与命令行全部可用, moon test 覆盖到内存传输上的完整会话,CLI 已在真实 UDP 套接字上跑通 (见下方示例)。

    #快速开始

    moon check # 类型检查 moon test # 运行测试 moon fmt # 格式化 moon info # 更新 .mbti 接口

    #命令行

    构建后 cmd/main 是一个可执行程序,三个子命令覆盖日常用法。

    #服务端

    # 在 6969 端口把 ./tftpboot 目录(含子目录)发布出去,允许覆盖已有文件 moon run cmd/main -- serve --root ./tftpboot --port 6969 --allow-overwrite

    参数说明默认
    --root DIR对外服务的根目录,请求路径被限制在其中.
    --port N监听的 UDP 端口69
    --allow-overwrite允许 WRQ 覆盖已存在的文件关闭(仅新建)
    --max-sessions N并发会话上限32

    #下载(get)

    # 把服务器上的 boot.img 下载到本地同名文件 moon run cmd/main -- get 192.168.1.10:69 boot.img # 指定本地落盘路径,并协商 1428 字节块 moon run cmd/main -- get 192.168.1.10:69 boot.img ./downloads/boot.img --blksize 1428 # 文本文件用 netascii 模式 moon run cmd/main -- get 192.168.1.10:69 config.txt --netascii

    #上传(put)

    # 第三个位置参数省略时,远端沿用本地文件名 moon run cmd/main -- put 192.168.1.10:69 ./boot.img # 显式指定远端文件名 moon run cmd/main -- put 192.168.1.10:69 ./boot.img remote/boot.img

    get / put 共用以下参数:

    参数说明默认
    --blksize N协商的块大小(8~65464)512
    --timeout N单包超时(秒)5
    --retries N放弃前的重传次数6
    --netascii使用 netascii 模式(默认 octet)关闭

    退出码:0 成功;1 传输失败(对端报错 / 超时 / 本地读写失败); 2 命令行用法错误。

    #本机对拍

    一条命令就能在本机验证 put 与 get 的双向正确性:

    # 终端 A moon run cmd/main -- serve --root /tmp/tftproot --port 6969 --allow-overwrite # 终端 B moon run cmd/main -- put 127.0.0.1:6969 ./big.bin # 上传 moon run cmd/main -- get 127.0.0.1:6969 big.bin ./got.bin # 下载回来 cmp ./big.bin ./got.bin && echo "byte-for-byte identical"

    #作为库使用

    传输由调用方注入,既可以是真实套接字,也可以是内存实现:

    ///|
    async fn download() -> Unit {
    let transport = @tftp.SocketTransport::new("192.168.1.10:69")
    defer transport.close()
    match
    @tftp.get(
    transport,
    "192.168.1.10:69",
    "boot.img",
    @tftp.ClientOptions::default(),
    ) {
    Ok(outcome) =>
    match outcome.received() {
    Some(bytes) => println("got \{bytes.length()} bytes")
    None => ()
    }
    Err(_) => println("transfer failed")
    }
    }

    #平台支持

    TFTP 基于 UDP,需要宿主提供 UDP socket。当前以 native 为唯一验证目标:

    后端状态说明
    native✅ 支持CI 全流程(check / test / build)在 native 上跑
    js⚠️ 未验证依赖宿主提供 UDP;协议层与状态机可编译,socket 后端未验证
    wasm / wasm-gc⚠️ 未验证同上,视运行时 socket 能力而定

    协议层(报文编解码、选项协商、netascii)与两个状态机都是纯函数或纯状态转移, 不依赖 socket,因此在任意后端都可测试;native 之外缺的只是 socket 后端与 CLI 的端到端验证。

    #文档

    #许可证

    Apache-2.0,见 LICENSE。

    FileSystem

    pub(open) trait FileSystem {
    async fn read(Self, String) -> Result[Bytes, ServerError]
    async fn write(Self, String, Bytes) -> Result[Unit, ServerError]
    async fn exists(Self, String) -> Bool
    }

    One file a session may act on, and the errors that go with it.

    The server only ever sees this interface, so the same sessions run against the real filesystem and against an in-memory file that reports DiskFull on purpose. That is what makes the disk-full branch — the hardest one to reach on a test machine — an ordinary unit test.

    ServerTransport

    pub(open) trait ServerTransport {
    async fn send(Self, Bytes, peer : String) -> Unit
    async fn recv(Self, timeout_ms : Int) -> Recv
    fn local_tid(Self) -> String
    fn close(Self) -> Unit
    }

    The transport a session runs on: a UDP socket bound to the session's TID.

    TidAllocator

    pub trait TidAllocator {
    fn allocate(Self, peer : String) -> String
    }

    The TID a request is answered from (RFC 1350, section 4).

    The RFC only says "a new TID", not how to pick one, so this derives it from the peer's address and a counter: deterministic, so a test can assert it, and distinct per request, which is the property that matters. A real socket backend overrides this with whatever port its ephemeral bind picked; the interface is the same either way.

    Transport

    pub(open) trait Transport {
    async fn send(Self, Bytes, peer : String) -> Unit
    async fn recv(Self, peer : String, timeout_ms : Int) -> Recv
    fn local_peer(Self) -> String
    }

    The minimal UDP interface the endpoints depend on.

    Implementations are responsible for the TID rule: a send goes to peer, and a recv filtered on peer never returns a datagram from anyone else. The state machines above assume that, so a stray packet cannot be mistaken for the peer's answer.

    AcceptedOptions

    pub struct AcceptedOptions {
    options : Options?
    first_packet : Packet?
    } derive(Eq,
    Debug
    )

    The options a server agreed to, plus the first packet that says so.

    Both are None for a plain RFC 1350 transfer, which is exactly what a server has to answer with then: the first DATA / ACK, never an empty OACK (RFC 2347, section 4).

    AcceptedOptions::equal

    AcceptedOptions::first_packet

    fn AcceptedOptions::first_packet(self : AcceptedOptions) -> Packet?

    The OACK, or None when the transfer runs as plain RFC 1350.

    AcceptedOptions::not_equal

    fn AcceptedOptions::not_equal(x : AcceptedOptions, y : AcceptedOptions) -> Bool

    AcceptedOptions::options

    fn AcceptedOptions::options(self : AcceptedOptions) -> Options?

    The negotiated values, or None when nothing was negotiated.

    ClientError

    pub(all) enum ClientError {
    PeerError(ErrorCode, String)
    Timeout
    Protocol(String)
    BadRequest(String)
    } derive(Eq,
    Debug
    )

    Why a transfer ended badly.

    ClientError::equal

    fn ClientError::equal(ClientError, ClientError) -> Bool

    ClientError::not_equal

    fn ClientError::not_equal(x : ClientError, y : ClientError) -> Bool

    ClientOptions

    pub struct ClientOptions {
    mode : Mode
    block_size : Int
    timeout_seconds : Int
    max_retries : Int
    options : Array[TftpOption]
    } derive(Eq,
    Debug
    )

    The knobs a caller sets, and the defaults RFC 1350 gives them.

    ClientOptions::block_size

    fn ClientOptions::block_size(self : ClientOptions) -> Int

    ClientOptions::default

    fn ClientOptions::default() -> ClientOptions

    The RFC 1350 defaults: octet, 512-byte blocks, a 5-second timeout, and a retry budget that survives a handful of lost packets without hanging.

    ClientOptions::equal

    ClientOptions::max_retries

    fn ClientOptions::max_retries(self : ClientOptions) -> Int

    How many times a packet is retransmitted before the transfer gives up.

    ClientOptions::mode

    fn ClientOptions::mode(self : ClientOptions) -> Mode

    ClientOptions::not_equal

    fn ClientOptions::not_equal(x : ClientOptions, y : ClientOptions) -> Bool

    ClientOptions::options

    The RFC 2347 options to put in the request.

    ClientOptions::timeout_seconds

    fn ClientOptions::timeout_seconds(self : ClientOptions) -> Int

    ClientOptions::with_block_size

    fn ClientOptions::with_block_size(self : ClientOptions, block_size : Int) -> ClientOptions

    A client that asks for a specific block size (RFC 2348).

    ClientOptions::with_max_retries

    fn ClientOptions::with_max_retries(self : ClientOptions, retries : Int) -> ClientOptions

    A client with a different retry budget.

    ClientOptions::with_mode

    fn ClientOptions::with_mode(self : ClientOptions, mode : Mode) -> ClientOptions

    A client with a plain transfer mode, e.g. with_mode(Netascii).

    ClientOptions::with_options

    fn ClientOptions::with_options(self : ClientOptions, options : Array[TftpOption]) -> ClientOptions

    A client that asks for additional RFC 2347 options.

    ClientOptions::with_timeout

    fn ClientOptions::with_timeout(self : ClientOptions, seconds : Int) -> ClientOptions

    A client that asks for a specific retransmission timeout (RFC 2349).

    CounterTid

    pub struct CounterTid {
    next_port : Int
    } derive(
    Debug
    )

    The default allocator: the peer's address with a monotonically increasing port, so scripts and tests see a stable answer.

    CounterTid::allocate

    fn CounterTid::allocate(self : CounterTid, peer : String) -> String

    CounterTid::new

    fn CounterTid::new(base_port : Int) -> CounterTid

    An allocator that starts handing out base_port, base_port + 1, ...

    Datagram

    pub struct Datagram {
    payload : Bytes
    peer : String
    } derive(Eq,
    Debug
    )

    A datagram: the bytes and the peer that sent them.

    Datagram::equal

    fn Datagram::equal(Datagram, Datagram) -> Bool

    Datagram::new

    fn Datagram::new(payload : Bytes, peer : String) -> Datagram

    Datagram::not_equal

    fn Datagram::not_equal(x : Datagram, y : Datagram) -> Bool

    Datagram::payload

    fn Datagram::payload(self : Datagram) -> Bytes

    Datagram::peer

    fn Datagram::peer(self : Datagram) -> String

    Datagram::to_repr

    DecodeError

    pub(all) enum DecodeError {
    Truncated
    UnknownOpcode(Int)
    MissingTerminator
    Unsupported(String)
    } derive(Eq,
    Debug
    )

    Why a packet could not be decoded. Returned as data, never raised, so callers on any backend (including wasm) can inspect it without an error type.

    DecodeError::equal

    fn DecodeError::equal(DecodeError, DecodeError) -> Bool

    DecodeError::not_equal

    fn DecodeError::not_equal(x : DecodeError, y : DecodeError) -> Bool

    Dispatch

    pub(all) enum Dispatch {
    Started(String)
    Rejected(String)
    Ignored
    } derive(Eq,
    Debug
    )

    What a dispatcher decided to do with a datagram that arrived on the well-known port.

    Dispatch::equal

    fn Dispatch::equal(Dispatch, Dispatch) -> Bool

    Dispatch::not_equal

    fn Dispatch::not_equal(x : Dispatch, y : Dispatch) -> Bool

    Dispatch::to_repr

    Dispatcher

    pub struct Dispatcher[A] {
    allocator : A
    started : Int
    max_sessions : Int
    } derive(
    Debug
    )

    The rule a dispatcher applies to the datagrams arriving on port 69.

    Kept separate from any socket so the TID rules can be tested directly: a session is started once per request, and anything that is not a RRQ/WRQ from the initial TID is either answered with ERROR 5 or ignored.

    Dispatcher::new

    fn[A] Dispatcher::new(allocator : A, max_sessions : Int) -> Dispatcher[A]

    A dispatcher that hands out TIDs through allocator and starts at most max_sessions sessions.

    Dispatcher::route

    fn[A : TidAllocator] Dispatcher::route(self : Dispatcher[A], datagram : Datagram) -> Dispatch

    Decides what one datagram arriving on the request port means.

    A request starts a session; a DATA/ACK/ERROR that got here instead of to a session is a client using the wrong TID, which RFC 1350 section 4 answers with ERROR 5.

    Dispatcher::started

    fn[A] Dispatcher::started(self : Dispatcher[A]) -> Int

    How many sessions this dispatcher has started.

    ErrorCode

    pub(all) enum ErrorCode {
    Undefined
    FileNotFound
    AccessViolation
    DiskFull
    IllegalOperation
    UnknownTransferId
    FileAlreadyExists
    NoSuchUser
    OptionNegotiationFailed
    } derive(Eq,
    Debug
    )

    Error codes carried by ERROR packets (RFC 1350, section 5).

    The variant name mirrors the code, so Undefined is code 0 ("not defined").

    ErrorCode::equal

    fn ErrorCode::equal(ErrorCode, ErrorCode) -> Bool

    ErrorCode::not_equal

    fn ErrorCode::not_equal(x : ErrorCode, y : ErrorCode) -> Bool

    HostFileSystem

    pub struct HostFileSystem {
    root : String
    }

    A FileSystem backed by the host's filesystem.

    Every failure is translated into the TFTP error code that describes it (RFC 1350, section 5) here, at the one boundary that knows what a filesystem error is, so the session above never sees an OS error.

    HostFileSystem::exists

    async fn HostFileSystem::exists(self : HostFileSystem, path : String) -> Bool

    HostFileSystem::new

    A filesystem view of the host.

    HostFileSystem::read

    async fn HostFileSystem::read(self : HostFileSystem, path : String) -> Result[Bytes, ServerError]

    HostFileSystem::write

    async fn HostFileSystem::write(self : HostFileSystem, path : String, payload : Bytes) -> Result[Unit, ServerError]

    Mailbox

    pub struct Mailbox {
    queue : Array[Datagram]
    drop_next : Bool
    } derive(
    Debug
    )

    The shared mailbox two in-memory endpoints talk through.

    A Mailbox is a mutable class so both endpoints see the same queues after the pair is split.

    Mailbox::drop_next_packet

    fn Mailbox::drop_next_packet(self : Mailbox) -> Unit

    Drops the next datagram sent through this mailbox, so a sender runs into its retransmission timeout. Tests use it instead of sleeping.

    Mailbox::inject

    fn Mailbox::inject(self : Mailbox, datagram : Datagram) -> Unit

    Delivers a datagram as if it came from peer, bypassing the pair.

    Mailbox::new

    fn Mailbox::new() -> Mailbox

    Mailbox::to_repr

    MemoryFileSystem

    pub struct MemoryFileSystem {
    files : Map[String, Bytes]
    disk_full : Bool
    read_fails : Bool
    }

    A filesystem whose files live in memory.

    MemoryFileSystem::break_reads

    fn MemoryFileSystem::break_reads(self : MemoryFileSystem) -> Unit

    Makes every subsequent read report a missing file.

    MemoryFileSystem::exists

    async fn MemoryFileSystem::exists(self : MemoryFileSystem, path : String) -> Bool

    MemoryFileSystem::get

    fn MemoryFileSystem::get(self : MemoryFileSystem, path : String) -> Bytes?

    The bytes stored at path, for a test that wants to look at them.

    MemoryFileSystem::has

    fn MemoryFileSystem::has(self : MemoryFileSystem, path : String) -> Bool

    Whether path is present.

    MemoryFileSystem::make_full

    fn MemoryFileSystem::make_full(self : MemoryFileSystem) -> Unit

    Makes every subsequent write report a full disk.

    MemoryFileSystem::new

    An empty in-memory filesystem.

    MemoryFileSystem::paths

    fn MemoryFileSystem::paths(self : MemoryFileSystem) -> Array[String]

    Every path currently stored.

    MemoryFileSystem::put

    fn MemoryFileSystem::put(self : MemoryFileSystem, path : String, payload : Bytes) -> Unit

    Puts payload at path as if it had been written there before.

    MemoryFileSystem::read

    async fn MemoryFileSystem::read(self : MemoryFileSystem, path : String) -> Result[Bytes, ServerError]

    MemoryFileSystem::write

    async fn MemoryFileSystem::write(self : MemoryFileSystem, path : String, payload : Bytes) -> Result[Unit, ServerError]

    MemoryTransport

    pub struct MemoryTransport {
    address : String
    outbox : Mailbox
    inbox : Mailbox
    }

    One end of an in-memory link.

    MemoryTransport::inbox

    The mailbox this endpoint reads from, for tests that inject by hand.

    MemoryTransport::local_peer

    fn MemoryTransport::local_peer(self : MemoryTransport) -> String

    MemoryTransport::outbox

    The mailbox the other endpoint reads from; a test drops packets here to force a retransmission.

    MemoryTransport::pair

    fn MemoryTransport::pair(left_address : String, right_address : String) -> (MemoryTransport, MemoryTransport)

    Creates two endpoints wired to each other.

    left sends into right's inbox and receives from it, and vice versa; the addresses are the TIDs each side expects the other to use.

    MemoryTransport::recv

    async fn MemoryTransport::recv(self : MemoryTransport, peer : String, timeout_ms : Int) -> Recv

    MemoryTransport::send

    async fn MemoryTransport::send(self : MemoryTransport, payload : Bytes, _peer : String) -> Unit

    Mode

    pub(all) enum Mode {
    Netascii
    Octet
    Mail
    } derive(Eq,
    Debug
    )

    Transfer modes of a RRQ/WRQ (RFC 1350, section 2).

    Mail exists in the RFC but the conversion is out of scope: mode_of rejects it, so it can only ever appear as a round-trip value, never on the wire from this package.

    Mode::equal

    fn Mode::equal(Mode, Mode) -> Bool

    Mode::not_equal

    fn Mode::not_equal(x : Mode, y : Mode) -> Bool

    Mode::to_repr

    NegotiationError

    pub(all) enum NegotiationError {
    BadOption(String, String)
    BadTsize(String)
    } derive(Eq,
    Debug
    )

    Why an option set could not be accepted.

    Every variant maps to ERROR code 8 on the wire (RFC 2347, section 3), but they stay distinct so logs and tests can tell the cases apart.

    NegotiationError::equal

    NegotiationError::not_equal

    fn NegotiationError::not_equal(x : NegotiationError, y : NegotiationError) -> Bool

    NetasciiState

    pub(all) enum NetasciiState {
    Clean
    LocalCr
    } derive(Eq,
    Debug
    )

    The byte a pending escape belongs to, carried across a block boundary.

    NetasciiState::equal

    NetasciiState::not_equal

    fn NetasciiState::not_equal(x : NetasciiState, y : NetasciiState) -> Bool

    Opcode

    pub(all) enum Opcode {
    Rrq
    Wrq
    Data
    Ack
    Error
    Oack
    } derive(Eq,
    Debug
    )

    TFTP opcodes as they appear on the wire (RFC 1350, section 5).

    Opcode::code

    fn Opcode::code(self : Opcode) -> Int

    The numeric opcode of a packet, as written on the wire.

    Opcode::equal

    fn Opcode::equal(Opcode, Opcode) -> Bool

    Opcode::not_equal

    fn Opcode::not_equal(x : Opcode, y : Opcode) -> Bool

    Opcode::to_repr

    OptionKind

    pub(all) enum OptionKind {
    Blksize
    Timeout
    Tsize
    } derive(Eq,
    Debug
    )

    The option names RFC 1350's extensions define.

    An unknown name is not a failure: a peer may ask for options this module does not implement, and RFC 2347 section 2 says to ignore those.

    OptionKind::equal

    fn OptionKind::equal(OptionKind, OptionKind) -> Bool

    OptionKind::not_equal

    fn OptionKind::not_equal(x : OptionKind, y : OptionKind) -> Bool

    Options

    pub struct Options {
    blksize : Int
    timeout_seconds : Int
    tsize : Int?
    oack_values : Array[(String, String)]
    } derive(Eq,
    Debug
    )

    A validated option set, ready to be echoed in an OACK.

    Options nobody negotiated keep their defaults, so a transfer just reads blksize() and timeout_seconds() without branching on "did the peer ask".

    Options::blksize

    fn Options::blksize(self : Options) -> Int

    The block size both peers agreed on, in bytes.

    Options::default

    fn Options::default() -> Options

    The RFC 1350 defaults: a plain 512-byte, no-options transfer.

    Options::equal

    fn Options::equal(Options, Options) -> Bool

    Options::is_empty

    fn Options::is_empty(self : Options) -> Bool

    Whether anything was negotiated at all: false means plain RFC 1350.

    Options::not_equal

    fn Options::not_equal(x : Options, y : Options) -> Bool

    Options::oack_packet

    fn Options::oack_packet(self : Options) -> Packet?

    The OACK (opcode 6) that accepts these options (RFC 2347, section 4).

    None when nothing was negotiated: a server that accepts no option must answer with the first DATA / ACK, never with an empty OACK.

    Options::oack_values

    fn Options::oack_values(self : Options) -> Array[(String, String)]

    The name/value pairs to echo in an OACK, in request order.

    Options::timeout_seconds

    fn Options::timeout_seconds(self : Options) -> Int

    The retransmission timeout both peers agreed on, in seconds.

    Options::to_repr

    Options::tsize

    fn Options::tsize(self : Options) -> Int?

    The transfer size the server announced, if one was negotiated. A read request answers with the real file size, a write request with what the client claimed (RFC 2349, section 4).

    Packet

    pub(all) enum Packet {
    Rrq(String, Mode, Array[TftpOption])
    Wrq(String, Mode, Array[TftpOption])
    Data(UInt16, Bytes)
    Ack(UInt16)
    Error(ErrorCode, String)
    Oack(Array[TftpOption])
    } derive(Eq,
    Debug
    )

    Every TFTP packet, in decoded form.

    Packet::block_number

    fn Packet::block_number(self : Packet) -> UInt16?

    The block number of a DATA or ACK packet, None for every other packet.

    Packet::data_payload

    fn Packet::data_payload(self : Packet) -> Bytes?

    The payload of a DATA packet, None for every other packet.

    The packet layer stays encoding-only; the client and server state machines read packets through these accessors so Packet keeps its payload opaque.

    Packet::equal

    fn Packet::equal(Packet, Packet) -> Bool

    Packet::error_info

    fn Packet::error_info(self : Packet) -> (ErrorCode, String)?

    The error code and message of an ERROR packet, None for every other packet.

    Packet::mode

    fn Packet::mode(self : Packet) -> Mode?

    The mode of a RRQ/WRQ, None for every other packet.

    Packet::not_equal

    fn Packet::not_equal(x : Packet, y : Packet) -> Bool

    Packet::options

    fn Packet::options(self : Packet) -> Array[TftpOption]?

    The options carried by a RRQ/WRQ/OACK, None for every other packet.

    Packet::to_repr

    Recv

    pub(all) enum Recv {
    Packet(Datagram)
    Timeout
    } derive(Eq,
    Debug
    )

    How long a recv should wait before giving up.

    Recv::equal

    fn Recv::equal(Recv, Recv) -> Bool

    Recv::not_equal

    fn Recv::not_equal(x : Recv, y : Recv) -> Bool

    Recv::to_repr

    RequestAction

    pub(all) enum RequestAction {
    ServeFile(String)
    ReceiveFile(String)
    } derive(Eq,
    Debug
    )

    What a server does with the first datagram of a session.

    There is no "answer with this packet" variant on purpose. Every session's first packet — including the OACK — is chosen and sent by the session itself, so there is exactly one place that decides what a client is told first, and a refused request keeps its ERROR on the request TID instead of racing a session for it.

    RequestAction::equal

    RequestAction::not_equal

    fn RequestAction::not_equal(x : RequestAction, y : RequestAction) -> Bool

    RequestedOption

    pub struct RequestedOption {
    kind : OptionKind
    request_value : Int
    } derive(Eq,
    Debug
    )

    A syntactically valid Option\0Value\0 pair of a RRQ/WRQ.

    RequestedOption::equal

    RequestedOption::kind

    Which option the peer asked for.

    RequestedOption::new

    fn RequestedOption::new(kind : OptionKind, request_value : Int) -> RequestedOption

    RequestedOption::not_equal

    fn RequestedOption::not_equal(x : RequestedOption, y : RequestedOption) -> Bool

    RequestedOption::request_value

    fn RequestedOption::request_value(self : RequestedOption) -> Int

    The numeric value the peer sent for it.

    RequestedOptions

    pub struct RequestedOptions {
    options : Array[RequestedOption]
    tsize_hint : Int?
    } derive(Eq,
    Debug
    )

    The options a request asked for, in request order.

    tsize is the odd one out: a read request states the placeholder 0 because only the server knows the file size, while a write request already carries the size it will send. tsize_hint is therefore the number a server should echo, or None when it first has to look at its file.

    RequestedOptions::equal

    RequestedOptions::is_empty

    fn RequestedOptions::is_empty(self : RequestedOptions) -> Bool

    Whether the request asked for anything at all. false means the transfer runs exactly as plain RFC 1350: no OACK, no tsize.

    RequestedOptions::not_equal

    fn RequestedOptions::not_equal(x : RequestedOptions, y : RequestedOptions) -> Bool

    RequestedOptions::options

    The accepted options, in request order.

    RequestedOptions::tsize_hint

    fn RequestedOptions::tsize_hint(self : RequestedOptions) -> Int?

    The tsize the client already told us, if it told us anything useful.

    ServerError

    pub(all) enum ServerError {
    IllegalOperation(String)
    AccessViolation(String)
    FileNotFound(String)
    DiskFull(String)
    FileAlreadyExists(String)
    OptionNegotiationFailed(String)
    Timeout
    } derive(Eq,
    Debug
    )

    Why a request could not be served. Each variant is a promise about what goes on the wire (RFC 1350, section 5).

    ServerError::equal

    fn ServerError::equal(ServerError, ServerError) -> Bool

    ServerError::error_code

    fn ServerError::error_code(self : ServerError) -> ErrorCode

    The wire error code this failure is reported with.

    ServerError::message

    fn ServerError::message(self : ServerError) -> String

    The message text of the ERROR packet.

    ServerError::not_equal

    fn ServerError::not_equal(x : ServerError, y : ServerError) -> Bool

    ServerError::to_packet

    fn ServerError::to_packet(self : ServerError) -> Packet

    The ERROR packet this failure is answered with.

    ServerOptions

    pub struct ServerOptions {
    root : String
    write_policy : WritePolicy
    max_sessions : Int
    max_retries : Int
    } derive(Eq,
    Debug
    )

    The knobs a server owner sets, and the safe defaults.

    ServerOptions::default

    fn ServerOptions::default() -> ServerOptions

    The safe defaults: a root of ., writes only create, and a session ceiling that a plain server never reaches but a flood cannot pass.

    ServerOptions::equal

    ServerOptions::max_retries

    fn ServerOptions::max_retries(self : ServerOptions) -> Int

    How many times a session retransmits before it gives up.

    ServerOptions::max_sessions

    fn ServerOptions::max_sessions(self : ServerOptions) -> Int

    ServerOptions::not_equal

    fn ServerOptions::not_equal(x : ServerOptions, y : ServerOptions) -> Bool

    ServerOptions::root

    fn ServerOptions::root(self : ServerOptions) -> String

    ServerOptions::with_max_retries

    fn ServerOptions::with_max_retries(self : ServerOptions, retries : Int) -> ServerOptions

    A server with a different session retry budget.

    ServerOptions::with_max_sessions

    fn ServerOptions::with_max_sessions(self : ServerOptions, max_sessions : Int) -> ServerOptions

    A server with a different ceiling on concurrent sessions.

    ServerOptions::with_root

    fn ServerOptions::with_root(self : ServerOptions, root : String) -> ServerOptions

    A server rooted at root, the usual way to configure one.

    ServerOptions::with_write_policy

    fn ServerOptions::with_write_policy(self : ServerOptions, policy : WritePolicy) -> ServerOptions

    A server that lets a client replace an existing file.

    ServerOptions::write_policy

    fn ServerOptions::write_policy(self : ServerOptions) -> WritePolicy

    SessionOutcome

    pub(all) enum SessionOutcome {
    Read(Int)
    Written(Int)
    Failed(ServerError)
    } derive(Eq,
    Debug
    )

    How a session ended, for logs and for tests that want to assert it.

    SessionOutcome::bytes

    fn SessionOutcome::bytes(self : SessionOutcome) -> Int

    The Int32 an outcome stands for, or -1 for a failure.

    SessionOutcome::equal

    SessionOutcome::error

    The failure an outcome carries, or None when the session succeeded.

    SessionOutcome::not_equal

    fn SessionOutcome::not_equal(x : SessionOutcome, y : SessionOutcome) -> Bool

    SocketSession

    pub struct SocketSession {
    socket :
    UdpClient

    tid : String
    }

    A session transport over a real UDP socket.

    The socket is already bound to the session's TID, so send/recv only ever take the peer's address: the TID rule (RFC 1350, section 4) is enforced by the kernel's connect, which is exactly where a packet from a stranger should be dropped.

    SocketSession::close

    fn SocketSession::close(self : SocketSession) -> Unit

    Releases the TID's port.

    SocketSession::local_peer

    fn SocketSession::local_peer(self : SocketSession) -> String

    SocketSession::new

    async fn SocketSession::new(bind_to : String, peer : String) -> SocketSession

    Binds a session socket to local and locks it onto peer.

    SocketSession::recv

    async fn SocketSession::recv(self : SocketSession, timeout_ms : Int) -> Recv

    Waits up to timeout_ms for a datagram, reporting Recv::Timeout if none arrived.

    This is where the retransmission timer lives: a real client that goes quiet has to make the session time out, and the state machine above treats that exactly like a lost packet.

    SocketSession::send

    async fn SocketSession::send(self : SocketSession, payload : Bytes) -> Unit

    Sends one datagram to the peer this socket was connected to.

    SocketSession::tid

    fn SocketSession::tid(self : SocketSession) -> String

    The TID this session is bound to.

    SocketTransport

    pub struct SocketTransport {
    socket :
    UdpServer

    request_peer : String
    peer : String
    address : String
    }

    A client Transport bound to a local ephemeral port.

    SocketTransport::close

    fn SocketTransport::close(self : SocketTransport) -> Unit

    Releases the local port.

    SocketTransport::local_peer

    fn SocketTransport::local_peer(self : SocketTransport) -> String

    SocketTransport::new

    async fn SocketTransport::new(remote : String) -> SocketTransport

    Binds an ephemeral local port for the transfer's own TID.

    Port 0 lets the OS pick: the server answers the request back to whatever port it came from, and that port is this transfer's TID.

    SocketTransport::recv

    async fn SocketTransport::recv(self : SocketTransport, peer : String, timeout_ms : Int) -> Recv

    SocketTransport::send

    async fn SocketTransport::send(self : SocketTransport, payload : Bytes, peer : String) -> Unit

    TftpOption

    pub struct TftpOption {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    An Option\0Value\0 pair of a RRQ/WRQ (RFC 2347, section 2).

    Values stay String here: M2 owns validation, so this layer only splits.

    TftpOption::equal

    fn TftpOption::equal(TftpOption, TftpOption) -> Bool

    TftpOption::name

    fn TftpOption::name(self : TftpOption) -> String

    TftpOption::new

    fn TftpOption::new(name : String, value : String) -> TftpOption

    TftpOption::not_equal

    fn TftpOption::not_equal(x : TftpOption, y : TftpOption) -> Bool

    TftpOption::value

    fn TftpOption::value(self : TftpOption) -> String

    TransferOutcome

    pub struct TransferOutcome {
    result : TransferResult
    tsize : Int?
    } derive(Eq,
    Debug
    )

    A finished transfer plus the negotiated details worth reporting.

    tsize is the size the peer announced in its OACK (RFC 2349) when it announced one; None means no tsize was negotiated.

    TransferOutcome::equal

    TransferOutcome::not_equal

    fn TransferOutcome::not_equal(x : TransferOutcome, y : TransferOutcome) -> Bool

    TransferOutcome::received

    fn TransferOutcome::received(self : TransferOutcome) -> Bytes?

    The file contents of a get, or None for a put.

    TransferOutcome::result

    The bytes or byte count the transfer produced.

    TransferOutcome::sent

    fn TransferOutcome::sent(self : TransferOutcome) -> Int?

    The number of bytes a put got acknowledged, or None for a get.

    TransferOutcome::tsize

    fn TransferOutcome::tsize(self : TransferOutcome) -> Int?

    The size the peer announced in its OACK, if it announced one.

    TransferResult

    pub(all) enum TransferResult {
    Received(Bytes)
    Sent(Int)
    } derive(Eq,
    Debug
    )

    What a get / put produced.

    TransferResult::equal

    TransferResult::not_equal

    fn TransferResult::not_equal(x : TransferResult, y : TransferResult) -> Bool

    TransferSession

    pub struct TransferSession[U, T] {
    transport : U
    files : T
    peer : String
    options : ServerOptions
    }

    One TFTP session on its own TID, driven by the transport it was handed.

    A session owns exactly one transfer and stops talking the moment it is done. peer is the client's address, and the transport is already bound to the session's TID, which is why nothing below has to think about ports.

    TransferSession::new

    fn[U, T] TransferSession::new(transport : U, files : T, peer : String, options : ServerOptions) -> TransferSession[U, T]

    A session that reads one file, writes one file, or gives up with an ERROR.

    TransferSession::serve

    async fn[U : Transport, T : FileSystem] TransferSession::serve(self : TransferSession[U, T], request : Packet) -> SessionOutcome

    Serves one request on this session and returns how it went.

    Everything that can be decided without I/O — the path, the options, the file-exists rule — is decided first, so a refused request costs exactly one ERROR packet and never opens a file.

    TransferSession::step_write

    async fn[U : Transport, T] TransferSession::step_write(self : TransferSession[U, T], acknowledged : UInt16, collected : Bytes, max_retries : Int) -> (Bool, SessionOutcome)

    Takes one step of a write session, from the caller's side of the wire.

    A write session cannot be driven like a read one from the session's own task: the first packet (ACK 0, or the OACK) has to be on its way before the peer sends DATA, so whoever owns the socket sends that packet itself and then steps this function once per expected datagram. serve does exactly that internally; this is the same loop, exposed.

    This is the shape a socket server wants, and the shape tests want too: the first packet of a write session (ACK 0 / the OACK) cannot be sent inside this call, because the task that owns the socket has to send it before the peer's DATA is read. Nothing here sends anything unprompted, so the caller stays in control of when the wire is touched.

    acknowledged is the block the caller last acknowledged, and collected is the bytes gathered so far; both come back in the outcome, and the caller threads them into the next call. The block size is the RFC default here: this is the plain RFC 1350 loop, where a socket server would have negotiated one before the first packet.

    Returns (finished, outcome); outcome is only meaningful once finished is true.

    WritePolicy

    pub(all) enum WritePolicy {
    CreateOnly
    AllowOverwrite
    } derive(Eq,
    Debug
    )

    Where a write request is allowed to put its file.

    TFTP has no authentication at all, so opening a server to the whole disk is never the right default: either the path is a plain name below the root, or the request is refused.

    WritePolicy::equal

    fn WritePolicy::equal(WritePolicy, WritePolicy) -> Bool

    WritePolicy::not_equal

    fn WritePolicy::not_equal(x : WritePolicy, y : WritePolicy) -> Bool

    block_after

    fn block_after(value : UInt16, reference : UInt16) -> Bool

    Whether value is strictly newer than reference, on the u16 circle.

    block_before

    fn block_before(value : UInt16, reference : UInt16) -> Bool

    Whether value is strictly older than reference, on the u16 circle.

    value is older when stepping forward from value reaches reference within half the circle. A duplicate of the current block is not older, it is equal; that case is value == reference and is reported as false here.

    block_follows

    fn block_follows(value : UInt16, reference : UInt16) -> Bool

    Whether value is the block number that directly follows reference.

    A naive value == reference + 1 is wrong at the wrap: after 65535 comes 0.

    decode

    fn decode(bytes : Bytes) -> Result[Packet, DecodeError]

    Decodes a packet from its wire form.

    Malformed input is reported as DecodeError instead of raising, so the same path works on every backend.

    default_block_size

    let default_block_size : Int

    Default TFTP data block size, in bytes (RFC 1350).

    default_port

    let default_port : Int

    The UDP port a TFTP server listens on for the initial request (RFC 1350).

    default_timeout

    let default_timeout : Int

    The retransmission timeout a transfer runs with when nobody negotiates one. The RFC names no default; this is the value RFC 2349 was written against and what most implementations pick.

    encode

    fn encode(packet : Packet) -> Bytes

    Serializes a packet to its wire form (RFC 1350, section 5).

    get

    async fn[T : Transport] get(transport : T, remote : String, filename : String, request : ClientOptions) -> Result[TransferOutcome, ClientError]

    The get half: request a file, receive DATA, acknowledge, finish on a short block.

    Returns the file contents with the transfer mode already undone, so a Netascii download comes back with local line endings.

    join_blocks

    fn join_blocks(blocks : Array[Bytes]) -> Bytes

    The inverse of split_blocks, for callers that want the file back.

    max_block_size

    let max_block_size : Int

    Largest blksize a peer may ask for.

    A DATA payload has to fit a u16 block number plus a 4-byte header, so the ceiling is 65535 - 4 = 65464, not 65535 (RFC 2348, section 3).

    max_filename_length

    let max_filename_length : Int

    Longest filename accepted in a RRQ/WRQ, in bytes. The RFC imposes no limit; this one only exists so a malformed packet cannot make us allocate forever.

    max_timeout

    let max_timeout : Int

    Largest timeout a peer may ask for, in seconds (RFC 2349, section 3).

    min_block_size

    let min_block_size : Int

    Smallest blksize a peer may ask for (RFC 2348, section 3).

    min_timeout

    let min_timeout : Int

    Smallest timeout a peer may ask for, in seconds (RFC 2349, section 3).

    negotiation_error_packet

    fn negotiation_error_packet(error : NegotiationError) -> Packet

    The ERROR packet that refuses an option set (RFC 2347, section 3).

    netascii_decode

    fn netascii_decode(state : NetasciiState, chunk : Bytes) -> (NetasciiState, Bytes)

    Decodes one block of netascii received from the wire.

    \r\n becomes \n and \r\0 becomes \r. A \r with no partner inside this chunk is emitted as a bare \r: it may be the first half of an escape in the next chunk, but a decoder cannot un-emit a byte, and a following \n or \0 in the next chunk is swallowed by the caller-side merge below. The common case — escapes contained in one block — is exact.

    netascii_decode_all

    fn netascii_decode_all(bytes : Bytes) -> Bytes

    Decodes a whole buffer in one go.

    netascii_decode_finish

    fn netascii_decode_finish(state : NetasciiState, tail : Bytes) -> Bytes

    Closes the stream on the decoding side. The decoder holds no state, so this is the identity; it stays for symmetry with netascii_encode_finish and so a future stateful decoder does not change the call sites.

    netascii_encode

    fn netascii_encode(state : NetasciiState, chunk : Bytes) -> (NetasciiState, Bytes)

    Encodes one block of local bytes for the netascii wire.

    Every \n becomes \r\n and every \r becomes \r\0. A \r that lands on the last byte of a block is held back rather than written: the next block's first byte then decides whether the held CR becomes \r\n (a line ending split across the boundary) or \r\0 (a real lone CR). A stream that ends with a held CR gets its NUL from netascii_encode_finish.

    netascii_encode_all

    fn netascii_encode_all(bytes : Bytes) -> Bytes

    Encodes a whole buffer in one go, for the cases with no block boundary to straddle (a file read into memory, a tsize computation).

    netascii_encode_finish

    fn netascii_encode_finish(state : NetasciiState, tail : Bytes) -> Bytes

    Closes the stream on the encoding side: whatever is still pending is a real lone CR and gets its \0 partner.

    next_block

    fn next_block(block : UInt16) -> UInt16

    The block number after block, with the u16 wrap-around.

    opcode_of

    fn opcode_of(code : Int) -> Opcode?

    The opcode encoded in the first two bytes of a packet.

    parse_options

    fn parse_options(request : Packet) -> Result[RequestedOptions, NegotiationError]

    Parses the Option\0Value\0 sequence of a RRQ/WRQ.

    RFC 2347 section 2 makes a server ignore options it cannot honor, so an unknown name or an out-of-range value is dropped while the rest of the request still counts: no failure, just fewer options in the result. The one fatal case is tsize (RFC 2349, section 4), whose request side is a shape rather than a bound: a read request states the placeholder 0, and anything else means the peer expects us to accept a size that is not a request. That is the Err a server turns into ERROR 8.

    peer_error

    fn peer_error(code : ErrorCode, message : String) -> ServerError

    The error a peer's own ERROR packet becomes, so a session that is told "I cannot" reports the same code back instead of inventing one. fn is private, so this is the exported spelling of the same mapping.

    plan_request

    async fn[T : FileSystem] plan_request(options : ServerOptions, files : T, request : Packet) -> Result[RequestAction, ServerError]

    Decides what to do with a request, before any file is touched.

    This is the server's outer state machine: parse the request, check the path against the sandbox, agree on options, and only then hand back a decision. exists lets the file-exists rule be checked without a second file backend interface, which keeps the filesystem trait to "read, size, write".

    put

    async fn[T : Transport] put(transport : T, remote : String, filename : String, data : Bytes, request : ClientOptions) -> Result[TransferOutcome, ClientError]

    The put half: request to write a file, send DATA one block at a time, wait for each ACK.

    Returns the number of bytes acknowledged. The data is already in memory, so the caller hands the whole file in and the state machine slices it.

    resolve_options

    fn resolve_options(requested : RequestedOptions, server_value : (RequestedOption) -> Result[String?, NegotiationError]) -> Result[Options, NegotiationError]

    Turns a parsed request into the option set both peers will use, after the server has had a say (RFC 2347, section 2).

    For blksize and timeout the server may answer with at most what the client asked for: a larger value would overrun the client's buffers or its patience, a smaller one is always safe. tsize is exempt: the server's value is the file size.

    server_value supplies the server-side number for an option as decimal text, or Ok(None) to refuse it. A refusal is fatal on purpose: the client explicitly asked and could not be answered, so the server rejects the negotiation with ERROR 8 instead of silently ignoring the option.

    resolve_path

    fn resolve_path(root : String, name : String) -> Result[String, ServerError]

    Resolves a client-supplied filename against root.

    This is the whole traversal defence, so it is deliberately paranoid and deliberately a pure function: the RFC puts no limit on a filename, and a server that accepted ../../etc/passwd would be a file disclosure with extra steps.

    The rule is structural rather than a substring search. . names the root, . is a no-op segment, and every other segment — including .., which is rejected outright rather than popped — must be a plain name. So docs/../secret is refused just like .., not silently simplified to secret, and no amount of %2e%2e helps because nothing here decodes: the client's bytes are the bytes we look at.

    Backslashes are refused too: on the platforms this server runs on they are ordinary name characters, so a path that carries one is more likely an attempt to look like a Windows escape than a real filename.

    serve_read

    async fn[T : Transport] serve_read(transport : T, peer : String, payload : Bytes, accepted : AcceptedOptions, options : ServerOptions) -> Result[Int, ServerError]

    The server half of a read: send DATA, wait for ACK, finish on the last one.

    first_packet is the OACK a peer with options gets instead of DATA 1, and it is retransmitted like any other packet, because until the client acknowledges the options nothing about the transfer is settled.

    The state machine is the client's mirror image. The two asymmetries worth naming: the server hears only ACKs, so a duplicate ACK for an already acknowledged block is answered by repeating the current DATA (the peer missed it), and the transfer ends when our DATA was shorter than the block size, not when the ACK arrives.

    serve_write

    async fn[T : Transport] serve_write(transport : T, peer : String, accepted : AcceptedOptions, options : ServerOptions) -> Result[Bytes, ServerError]

    The server half of a write: acknowledge DATA, collect it, answer the short block that ends the file.

    The duplicate rule is the one that makes or breaks this loop. A DATA that repeats the block we already hold is a retransmission of an ACK we lost, so it must be re-acknowledged and not appended again. block.mbt's half-circle comparison is what tells "duplicate" from "the peer moved two blocks ahead", so the check survives the u16 wrap.

    split_blocks

    fn split_blocks(data : Bytes, block_size : Int) -> Array[Bytes]

    Splits data into DATA payloads of at most block_size bytes.

    Public because a caller that wants to show progress, or to feed the blocks to a server of its own, needs the same slicing the client uses.

    A file whose length is an exact multiple of the block size needs one extra, empty block for the peer to see the end (RFC 1350, section 1).

    take_cstring

    fn take_cstring(bytes : Bytes, offset : Int) -> (String, Int)?

    Reads a \0-terminated field starting at offset, returning the field and the offset just past its terminator.

    take_field

    fn take_field(bytes : Bytes, offset : Int) -> Result[(String, Int), DecodeError]

    Reads a \0-terminated field, reporting a DecodeError instead of None so the caller can tell "packet too short" from "no terminator".

    take_opcode

    fn take_opcode(bytes : Bytes, offset : Int) -> (Opcode, Int)?

    Reads the 2-byte opcode at offset and advances past it.

    take_options

    fn take_options(bytes : Bytes, offset : Int) -> Array[TftpOption]?

    Reads Option\0Value\0 pairs until the packet ends.

    take_u16

    fn take_u16(bytes : Bytes, offset : Int) -> (UInt16, Int)?

    Reads a 2-byte unsigned integer at offset and advances past it.

    timed_out_packet

    fn timed_out_packet() -> Packet

    The packet a session sends when the peer stopped answering.

    A thin alias for to_error_packet(peer_timeout), kept because the call site reads better saying what happened than naming the variable.

    timeout_to_ms

    fn timeout_to_ms(seconds : Int) -> Int

    Turns a duration in seconds into the milliseconds Transport::recv takes.

    to_error_packet

    fn to_error_packet(error : ServerError) -> Packet

    Turns a ServerError into the packet the peer is owed.