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.2
    License
    Apache-2.0
    Last updated
    9 days ago
    Downloads
    24

    #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 返回 (Truncated / UnknownOpcode / MissingTerminator / FieldTooLong / Unsupported,各自指名道姓),block 号 u16 回绕。
    • 客户端:get / put,超时重传、重复包去重(Sorcerer's Apprentice 问题修复)、 blksize / timeout / tsize 选项协商。
    • 服务端:69 端口监听、每请求独立 TID 会话、并发会话管理、 根目录约束(路径穿越防护)与写入策略(仅新建 / 允许覆盖)、错误码语义。
    • CLI:cmd/main 的 get / put / serve 三个子命令。

    #当前状态

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

    #快速开始

    首次在干净环境(新容器 / 新机器)里跑之前,先同步一次 MoonBit registry 索引:

    moon update # 首次必跑:拉取 registry 索引,否则依赖解析失败

    moon update 把 MoonBit registry 的索引克隆到本地(~/.moon/registry), moon 靠它才能把 moon.mod 里的模块名解析成具体版本。这一步只在首次 或用久了之后需要,日常改代码不必重复执行。

    跳过它的典型报错是 Failed to calculate build plan + module was not found in the registry(例如 mizchi/x、moonbitlang/async、 moonbitlang/regexp),同时伴随提示 you may need to run 'moon update'。 CI(scripts/ci.sh)与 Copilot 环境(.github/workflows/copilot-setup-steps.yml) 都已把 moon update 作为第一步。

    索引就位后,日常循环是:

    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并发会话上限,超出的请求回 ERROR 4 且不建立会话32

    无法解析的请求同样回 ERROR 4(RFC 1350 §5 里 4 是「此刻不接受这个请求」的 唯一通路),不会静默丢弃:静默丢弃只会让对端一直重传到预算耗尽。

    #下载(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单包超时(秒,1~255)5
    --retries N放弃前的重传次数6
    --netascii使用 netascii 模式(默认 octet)关闭

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

    超出范围的取值按用法错误拒绝(退出码 2),不发给对端:服务端有权忽略 它不认的选项(RFC 2347 §2),而客户端如果照原值继续跑,两边对「一个块有 多大」的理解会不一致,最后是干等到超时,而不是一条能看懂的报错。

    #进度与容量校验

    put / get 在协商到 tsize 时会按整百分比打印进度行,块与块之间不刷屏:

    boot.img: 12% (30720/262144 bytes) boot.img: 24% (61440/262144 bytes)

    put 还会做容量校验:对端在 OACK 里确认的 tsize 必须等于本地实际发送的 字节数,不一致就以非 0 退出并报 capacity check failed——停等协议没有别的 方式报告对端只收下了一部分。对端不协商 tsize(选项可选)时不校验。

    #本机对拍

    一条命令就能在本机验证 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"

    文本文件用 --netascii 时同理,只是行尾在线上变成 CRLF、两端各自转回来, 所以比对对象是本地那份原文,而不是服务端磁盘上的字节:

    # 终端 B printf 'line one\nline two\n' > ./config.txt moon run cmd/main -- put 127.0.0.1:6969 ./config.txt --netascii moon run cmd/main -- get 127.0.0.1:6969 config.txt ./got.txt --netascii cmp ./config.txt ./got.txt && echo "byte-for-byte identical"

    只跑单向会漏掉服务端那半边的转换:客户端编码 + 服务端透传在「源文件本来就是 CRLF」时会假装全绿,换成 LF 源文件才暴露。完整往返与假阳性的来龙去脉见 docs/interop.md。

    #作为库使用

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

    ///|
    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,需要宿主提供真实网络栈,wasm / wasm-gc 后端无法提供, 故声明为 native。仓库在 moon.mod 里写明了这一点:

    // TFTP 需要真实网络栈(UDP),wasm/wasm-gc 后端无法提供,故声明为 native。 preferred_target = "native"

    换句话说:

    • native 是唯一支持的目标,scripts/ci.sh 的 check / test / build 全跑在它上面。
    • js / wasm / wasm-gc 不在支持范围内,仓库不承诺它们能编译、能跑。
    • 协议层(报文编解码、选项协商、netascii)与状态机是纯函数,不碰 socket, 理论上可在任意后端离线使用;但一碰到 moonbitlang/async 的 socket / fs (server_fs.mbt、transport_socket.mbt)就回到 native 这条路上。

    #文档

    #许可证

    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
    async fn is_directory(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.

    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?
    mode : Mode
    } 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::mode

    The transfer mode the request asked for, Octet unless it said otherwise.

    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
    FieldTooLong
    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(tid~ : String, error~ : ServerError)
    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
    active : 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::active

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

    How many sessions are running right now.

    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::release

    fn[A] Dispatcher::release(self : Dispatcher[A]) -> Unit

    Reports a session as over, releasing its slot.

    The ceiling is on concurrent sessions, so it only means anything if a finished session gives its slot back; without this the dispatcher would refuse every request after the first max_sessions transfers.

    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.

    A datagram that does not decode is answered with ERROR 4 rather than dropped: RFC 1350 section 5 gives code 4 to "this request is not one I can take right now", and a body that cannot be parsed is exactly that. Silence would leave the peer retransmitting until its budget is spent, which is the failure mode the ERROR packet exists to prevent.

    A request that arrives once max_sessions are already running is refused rather than started: the ceiling is the whole point of the field, and a session that is silently dropped leaves the client retransmitting into the void. The refusal is an ERROR 4, the RFC's one code for "not right now".

    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::is_directory

    async fn HostFileSystem::is_directory(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]
    directories : Map[String, Unit]
    read_log : Array[String]
    write_log : Array[String]
    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::declare_directory

    fn MemoryFileSystem::declare_directory(self : MemoryFileSystem, path : String) -> Unit

    Makes path behave as a directory.

    Nothing is stored there: the only thing a directory changes is the answer is_directory gives, which is exactly the input the planner's rule needs. The real backend reports directories because a host has them; this one reports the ones a test declares.

    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::is_directory

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

    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::reads

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

    Every path a read was attempted on, in order.

    MemoryFileSystem::write

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

    MemoryFileSystem::writes

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

    Every path a write was attempted on, in order.

    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

    Progress

    pub(all) struct Progress {
    done : Int
    total : Int
    } derive(Eq,
    Debug
    )

    One progress report, handed to a caller's callback after every block.

    done is the bytes delivered so far and total the size the peer announced; total is 0 when nothing is known yet (a get before the OACK, or a peer that did not negotiate tsize), so a caller must read it as "unknown", not as "empty".

    The two are on the same scale for an octet transfer. A netascii transfer decodes as it goes, so done counts local bytes while total counts wire bytes; the pair is a good estimate, not an exact ratio.

    Progress::done

    fn Progress::done(self : Progress) -> Int

    Bytes transferred so far.

    Progress::equal

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

    Progress::is_known

    fn Progress::is_known(self : Progress) -> Bool

    Whether the size is known, so total is a real ceiling.

    Progress::not_equal

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

    Progress::to_repr

    Progress::total

    fn Progress::total(self : Progress) -> Int

    The transfer's total size, or 0 when the peer has not said.

    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)
    UnknownTransferId(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. The ceiling is the same 32 the CLI and README document, so there is one number to remember rather than three that drift.

    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.

    get_with_progress

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

    get, with a callback after every block so a caller can show progress.

    The callback is a plain function rather than an interface: it is called synchronously between blocks, and the state machine does not care what it does with the numbers. Progress::total carries the tsize the peer announced, so a get can report a percentage once the OACK has arrived.

    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_field_length

    let max_field_length : Int

    Longest \0-terminated field decode accepts, in bytes.

    This is the single cap shared by every \0-terminated field — filename, mode, option name, option value and ERROR message — so a malformed packet is rejected on the same rule no matter which field is oversized. It is set to the largest packet a legitimate RRQ/WRQ can produce: two bytes of opcode below the 65535-byte UDP datagram ceiling, so nothing a real peer sends can trip it. The RFC cap on blksize (65464, RFC 2348) leaves room to spare.

    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.

    Superseded by max_field_length, which caps every field at the size of a legal UDP packet; kept so existing callers of the public name keep working.

    max_timeout

    let max_timeout : Int

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

    max_tsize

    let max_tsize : Int

    Largest tsize a peer may announce, in bytes.

    RFC 2349 puts no ceiling on tsize: it is a whole file size, not a packet payload, so a value far past max_block_size is perfectly ordinary. This bound is only the Int32 arithmetic of this implementation, and it exists so every accepted size has a definite maximum.

    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 on the last byte of the block cannot be resolved yet: its partner may be the first byte of the next block, so the decoder holds it back and returns LocalCr, exactly as the encoder does. The caller then folds the next block in with that state, and the held CR is emitted (or joined to its \n) once its partner is known. A stream that ends on a held CR gets it from netascii_decode_finish.

    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. A \r still held back at the end of the stream had no partner on the wire, so it is a real lone CR and is emitted here; nothing else is pending.

    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.

    tsize is a whole file size, so unlike blksize it is not bounded by the block-size ceiling the other options share: a 100 MB firmware image carries a tsize far past any single packet, and refusing it would break exactly the transfers the option exists for. max_tsize is the only ceiling, and it is Int32 arithmetic rather than anything the RFC asks for.

    A decimal wider than max_tsize is rejected at parse time rather than wrapped: parse_decimal is given the ceiling, so 2147483648 cannot come back as a negative tsize that looks legal to everyone downstream.

    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. The three questions it asks the backend — exists for the file-exists rule, is_directory for the directory rule, and nothing else — are all answered before any file is opened, which is what keeps a refused request down to one ERROR packet.

    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.

    put_with_progress

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

    put, with a callback after every acknowledged block.

    The total is the encoded size the WRQ announced as tsize, so a callback sees a real denominator from the first block even before the OACK arrives.

    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 twice over: the server's value is the file size, and a file is not bounded by a packet payload, so neither max_block_size nor the client's request applies.

    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) -> Result[(String, Int), DecodeError]

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

    Failure says which way the field is malformed: Truncated when the packet ends before the field starts, MissingTerminator when the field runs to the last byte without a \0, FieldTooLong when a terminated field exceeds max_field_length. They are separate variants so a caller (or a test) can tell a short packet from an oversized field instead of guessing.

    take_field

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

    Alias of take_cstring, kept because it reads better at the call sites in decode that pull one field off the wire.

    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) -> Result[Array[TftpOption], DecodeError]

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

    Every field goes through the same rule as a filename, so an oversized option name or value is reported as FieldTooLong rather than passed on to the option layer.

    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.