Sign in

    ftp

    FTP/FTPS client library for MoonBit with passive EPSV/PASV data connections, MLSD/LIST parsing, resume, directory tree walking, and explicit/implicit TLS.

    ftp
    network
    client
    protocol
    async
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    25 days ago
    Downloads
    85

    #PaiGack/ftp

    纯 MoonBit 实现的 FTP / FTPS 客户端库,移植自 jlaffaye/ftp。

    支持被动模式(EPSV 优先、PASV 回退)、MLSD / LIST 四种列表格式解析、断点续传、 目录树遍历,以及显式与隐式 TLS。

    #环境要求

    • MoonBit 工具链(moon)
    • C 编译器:gcc + libc6-dev。FTP 需要真实 TCP + TLS,模块声明了 preferred_target = "native",native 后端的编译与链接依赖 C 工具链。

    安装:

    curl -fsSL https://cli.moonbitlang.cn/install/unix.sh | bash export PATH="$HOME/.moon/bin:$PATH" # native 后端所需的 C 工具链(Debian / Ubuntu) sudo apt-get install -y gcc libc6-dev

    #快速开始

    所有公开函数都是 async 函数,调用方需处在 @async 运行时中(CLI / 测试里写 async fn main 即可,无需手写 await)。最小可运行例子:

    ///|
    async fn main {
    // 1. 建连并登录
    let client = @ftp.dial("127.0.0.1:21", timeout_ms=15000)
    @ftp.login(client, "user", "pass")

    // 2. 列目录:优先 MLSD,否则自动回退到 LIST 四种格式解析
    for entry in @ftp.list(client, "/") {
    println("\{entry.type_.to_string()} size=\{entry.size} \{entry.name}")
    }

    // 3. 下载文件
    let resp = @ftp.retr(client, "/hello.txt")
    let bytes = resp.reader.read_all() catch { _ => b"" }
    resp.close()

    // 4. 上传文件(任意 &@io.Reader 均可)
    // `MemoryReader` 的后台生产者由调用方负责关:绑到变量再 defer close,
    // 否则传输失败时它残留的任务会让事件循环以死锁收场。
    let source = @io.MemoryReader(w => w.write("demo upload"))
    defer source.close()
    let upload = "/upload.txt"
    @ftp.stor(client, upload, source)

    // 5. 重命名,顺手做一次建目录 / 删目录的往返
    let renamed = "/upload_renamed.txt"
    @ftp.rename(client, upload, renamed)
    @ftp.make_dir(client, "/newdir")
    @ftp.remove_dir(client, "/newdir")

    // 6. 递归遍历目录树
    let w = @ftp.walk(client, "/")
    while w.next() {
    println(w.path())
    }

    // 7. 遍历结束再删:上传的文件活到走完目录树才收拾,重复运行才安全(幂等)
    @ftp.delete(client, renamed)

    // 8. 退出并关闭连接
    @ftp.quit(client)
    }

    #常用函数

    函数作用
    dial(addr, ..)建连,可选 explicit_tls / tls / timeout_ms / trust_pasv_ip 等参数
    login(client, user, pass)USER/PASS 登录并协商 FEAT / TYPE 能力
    list(client, path)列目录,返回 Array[Entry](MLSD / LIST 自动回退)
    get_entry(client, path)取单个条目的 facts
    retr / retr_from(client, path, offset)下载 / 断点续传下载
    stor / append(client, path, reader)上传 / 追加
    make_dir / remove_dir / delete / rename / change_dir / current_dir目录与文件管理
    walk(client, root)深度优先遍历目录树,next() 失败时不抛错而是返回 false
    quit(client)发 QUIT 并关闭连接

    错误以 FtpError 抛出,可用 catch / try! 统一处理;list / walk 解析不出的行会被跳过而非中断。

    #开发

    #常用命令

    所有 moon 命令都带上 --target native:模块声明了 preferred_target = "native", wasm / wasm-gc 后端没有真实网络栈。

    moon fmt --check # 格式检查 moon check --target native --deny-warn # 类型检查,要求 0 warning moon test --target native # 单元测试 moon build --target native # 构建 moon info # 更新生成接口(.mbti)

    提交前请确认 moon fmt --check、moon check --target native --deny-warn、 moon test --target native 三条命令均通过。

    #测试分层

    层命令覆盖
    纯逻辑单测moon test --target native四种列表解析、状态码、路径 join、控制通道帧解析
    明文端到端scripts/start-ftp.sh + cmd/example/run.sh真实 vsftpd 上的 dial / login / list / retr / stor / rename / mkdir / walk
    FTPS 端到端scripts/ftps/start-ftps.sh + cmd/ftps/run.sh真实 vsftpd 上的显式 AUTH TLS:dial / login / LIST / RETR / STOR / DELE
    明文回归cmd/ftps/run.sh plain同一个 cmd/ftps 二进制走明文链路,确保数据通道时序改动没有破坏无 TLS 路径

    #本地端到端(需要 docker)

    scripts/start-ftp.sh # 明文 vsftpd,127.0.0.1:21 scripts/ftps/start-ftps.sh # FTPS vsftpd,127.0.0.1:2121 cmd/ftps/run.sh # 显式 AUTH TLS cmd/ftps/run.sh plain # 明文回归 scripts/ftps/stop-ftps.sh scripts/stop-ftp.sh

    FTPS 用自签名 CA,客户端始终校验证书;两种传输方式共用同一个 cmd/ftps 二进制。

    #目录结构

    所有源码直接平铺在仓库根目录,没有包目录层级,全部属于同一个包 PaiGack/ftp, 包内互相引用使用裸名字(如 Entry、parse_list_line)。

    . ├── entry.mbt Entry / EntryType / TransferType 纯逻辑 ├── status.mbt RFC 959 状态码与常量 + status_text() 纯逻辑 ├── error.mbt FtpError / FtpErrors 纯逻辑 ├── parse.mbt RFC 3659 / UNIX ls / DOS DIR / hostedftp 解析器 纯逻辑 │ 回退链 + LIST 时间字段解析 + 字段扫描器 ├── pathutil.mbt 远端路径 join(对齐 Go path.Join 语义) 纯逻辑 ├── control.mbt 控制通道:命令编码、多行响应、状态码校验 + 流量日志 IO ├── transport.mbt EPSV / PASV / PRET / REST / 数据连接 / TLS 建立 IO ├── client.mbt FTPClient + Session / Options + DialOptions IO ├── dial.mbt dial / 登录 / FEAT 能力协商 IO ├── commands.mbt CWD / PWD / MKD / RMD / DELE / RNFR+RNTO / QUIT IO ├── list.mbt 目录列表 ├── transfer.mbt 文件传输(RETR / STOR / APPE / REST) ├── walker.mbt 目录树遍历 ├── cmd/ftp/ CLI 示例:ls / get / put / walk / mkdir / rm │ ├── main.mbt │ └── run.sh 按 .env 跑 cmd/ftp,可带参数覆盖默认命令 ├── cmd/example/ 真实 vsftpd 端到端演示(明文) │ ├── main.mbt │ └── run.sh 按 .env 跑 cmd/example ├── cmd/ftps/ FTPS / 明文端到端冒烟:参数选择传输方式 │ ├── main.mbt │ └── run.sh 读 .ftp-tls.env 跑 cmd/ftps(可传 plain) ├── scripts/ │ ├── ci.sh CI 入口脚本 │ ├── start-ftp.sh 启动明文 vsftpd 容器(127.0.0.1:21) │ ├── probe-ftp.py 明文容器就绪探测(登录 + 一次被动 LIST) │ ├── stop-ftp.sh 导出明文容器日志并清理 │ └── ftps/ FTPS(显式 AUTH TLS)容器 │ ├── gen-cert.sh 自签名 CA + 叶证书(每次重新签发) │ ├── probe-ftps.sh 就绪探测:AUTH TLS 握手 + 证书校验 │ ├── probe-ftps-selftest.py 用 mock 服务器自测上面的探测(无需 Docker) │ ├── ftp_mock.py 明文 FTP mock,供 cmd-ftps-selftest.py 驱动真实二进制 │ ├── cmd-ftps-selftest.py 无 Docker 跑 cmd/ftps plain(含 STOR 被拒的回归用例) │ ├── fixture-isolation-selftest.py 断言两个容器不共用可写 fixture │ ├── start-ftps.sh 启动 FTPS 容器(127.0.0.1:2121)并写 .ftp-tls.env │ └── stop-ftps.sh 导出容器日志并清理 ├── testdata/ftp/ 测试与演示使用的 fixture ├── docs/ 移植方案与设计文档 ├── moon.pkg 根包清单 └── moon.mod 模块定义

    纯逻辑与 IO 源码同属一个包,分层约束通过每个源文件头部的 // Layer: 注释标记。

    #致谢与来源

    本项目为 jlaffaye/ftp(ISC License)的 MoonBit 移植, 参考其协议实现与测试用例。原项目版权归 Julien Laffaye 所有。

    上游 ISC 许可证原文、版权署名与来源说明见 LICENSE-THIRD-PARTY。 包含移植代码的源文件头部均标注 Ported from jlaffaye/ftp (ISC License), see LICENSE-THIRD-PARTY.。

    #许可证

    本项目自身代码采用 Apache-2.0,见 LICENSE。 移植自 jlaffaye/ftp 的部分同时受其 ISC 许可约束, 原文见 LICENSE-THIRD-PARTY。

    FtpError

    pub(all) suberror FtpError {
    ServerError(code~ : Int, msg~ : String)
    InvalidCommand(arg~ : String)
    UnsupportedListLine(line~ : String)
    UnsupportedListDate(field~ : String)
    ParseError(msg~ : String)
    RequestTimeout
    } derive(
    Debug
    )

    Error type for every failure that is produced by the FTP protocol layer itself (as opposed to failures raised by the underlying IO stack, which bubble up untouched so callers can still inspect the OS error).

    FtpError::to_repr

    FtpError promotes its Debug methods as regular methods; declare the promotion explicitly so the deprecation warning stays away.

    FtpErrors

    pub(all) suberror FtpErrors {
    MultipleErrors(errors~ : Array[Error])
    } derive(
    Debug
    )

    Aggregation of several failures that happened during one logical operation. This mirrors Go's errors.Join: the upstream implementation deliberately keeps every error (transfer + close + status read) instead of returning as soon as the first one shows up.

    FtpErrors::to_repr

    FtpErrors promotes its Debug methods as regular methods; declare the promotion explicitly so the deprecation warning stays away.

    Control

    pub struct Control {
    reader : &
    Reader

    writer : &
    Writer

    host : String
    timeout_ms : Int
    }

    An FTP control connection: a line buffered reader/writer pair over the underlying socket.

    The reader and the writer are kept as trait objects so the same code drives a plain Tcp socket and a Tls connection after AUTH TLS.

    Control::host

    fn Control::host(self : Control) -> String

    The peer address of the control connection.

    Control::new

    fn Control::new(reader : &
    Reader
    , writer : &
    Writer
    , host : String, timeout_ms? : Int) -> Control

    Wrap an already connected reader/writer pair.

    Control::read_line

    async fn Control::read_line(self : Control) -> String

    Read one line, without its trailing CRLF. Raises ReaderClosed when the connection was closed before a complete line arrived, and RequestTimeout when the server stayed silent for timeout_ms.

    The deadline is on the reply, not on the transfer: only the idle part of a wait is bounded, so a long listing still completes as long as the server keeps the lines coming.

    Control::send_line

    async fn Control::send_line(self : Control, line : String) -> Unit

    Write line followed by CRLF.

    Control::upgrade

    Replace the underlying reader/writer, used when the connection is upgraded to TLS after AUTH TLS.

    DataConn

    The data connection used for one transfer.

    The socket is always a raw Tcp at first, even for FTPS: the TLS handshake is deferred until start_tls runs, which happens only after the server answered the transfer command with 150. reader / writer are the trait objects the transfer code actually uses; before start_tls they point at the socket, after it they point at the Tls session.

    DataConn::close

    async fn DataConn::close(self : DataConn) -> Unit

    Close the data connection without touching the control channel.

    For a TLS data connection the close_notify is sent first. This is not politeness: a server that reads the upload until EOF waits for the record layer to say "no more data", and a bare socket close (which is all Tls::close does -- it frees the OpenSSL handle, the graceful half is the async Tls::shutdown) leaves the server without its EOF, so it never writes the 226 and the very next control read blocks forever. In the clear the socket close is the EOF, so nothing extra is needed there.

    DataConn::reader

    The readable end of the data connection.

    DataConn::start_tls

    async fn DataConn::start_tls(self : DataConn, session : Session) -> Unit

    Start the TLS handshake on an already connected data socket.

    The timing is the whole point of this function, and it is why the handshake is not done in open_data_conn:

    RFC 4217 starts the data-channel TLS session after the server accepted the transfer command. The server does not read the data socket until it has answered 150 on the control channel, so a client that connects and handshakes earlier writes a ClientHello into a socket nobody is reading yet -- and the server, when it later accepts the connection, sees the bytes of a handshake it never invited. vsftpd and proftpd answer 425 Unable to build data connection (or simply drop it) and the transfer dies; a lenient server may let it slide, which is exactly the "green but wrong" state a stub server used to hide.

    Doing it here also keeps the plain path untouched: start_tls is a no-op when Options::tls is off, so a plaintext transfer connects no differently than before.

    DataConn::writer

    The writable end of the data connection.

    DataResponse

    pub struct DataResponse {
    reader : &
    Reader

    closed : Bool
    session : Session
    shut_timeout_ms : Int
    }

    A data transfer in progress, returned by retr / retr_from.

    It implements @io.Reader; close() is idempotent and performs the mandatory 226 wrap-up read.

    DataResponse::close

    async fn DataResponse::close(self : DataResponse) -> Unit

    Release the data connection and read the closing 226.

    This is upstream's checkDataShut plus Close: dropping it would leave the trailing 226 in the control channel and desynchronise the next command. Errors from the two phases are aggregated.

    DataResponse::read

    async fn DataResponse::read(self : DataResponse, dst : FixedArray[Byte], offset? : Int, max_len? : Int) -> Int

    Read up to max_len bytes of the transfer.

    DialOptions

    pub struct DialOptions {
    timeout_ms : Int
    shut_timeout_ms : Int
    tls : Bool
    explicit_tls : Bool
    trust :
    TrustedRoot

    disable_epsv : Bool
    trust_pasv_ip : Bool
    disable_utf8 : Bool
    disable_mlsd : Bool
    writing_mdtm : Bool
    force_list_hidden : Bool
    location :
    Zone

    }

    The connection options, the MoonBit counterpart of upstream's 16 DialWith* functions. Every field has a documented default so that dial(addr) alone is a valid call.

    DialOptions::default

    fn DialOptions::default() -> DialOptions

    The documented defaults: 30s connect timeout, no TLS, EPSV enabled, PASV IP untrusted, UTF8 and MLSD enabled, UTC timestamps.

    DialOptions::set_disable_epsv

    fn DialOptions::set_disable_epsv(self : DialOptions, value : Bool) -> Unit

    Turn EPSV off, forcing PASV.

    DialOptions::set_disable_mlsd

    fn DialOptions::set_disable_mlsd(self : DialOptions, value : Bool) -> Unit

    Turn MLSD off, forcing LIST.

    DialOptions::set_disable_utf8

    fn DialOptions::set_disable_utf8(self : DialOptions, value : Bool) -> Unit

    Turn OPTS UTF8 ON off.

    DialOptions::set_explicit_tls

    fn DialOptions::set_explicit_tls(self : DialOptions, value : Bool) -> Unit

    Turn explicit AUTH TLS on or off.

    DialOptions::set_force_list_hidden

    fn DialOptions::set_force_list_hidden(self : DialOptions, value : Bool) -> Unit

    Force LIST -a.

    DialOptions::set_location

    fn DialOptions::set_location(self : DialOptions, value :
    Zone
    ) -> Unit

    Replace the timezone used to interpret LIST timestamps.

    DialOptions::set_shut_timeout_ms

    fn DialOptions::set_shut_timeout_ms(self : DialOptions, value : Int) -> Unit

    Replace the 226 wrap-up timeout in milliseconds.

    DialOptions::set_timeout_ms

    fn DialOptions::set_timeout_ms(self : DialOptions, value : Int) -> Unit

    Replace the connect timeout in milliseconds.

    DialOptions::set_tls

    fn DialOptions::set_tls(self : DialOptions, value : Bool) -> Unit

    Turn implicit TLS on or off.

    DialOptions::set_trust

    Replace the certificate trust policy.

    DialOptions::set_trust_pasv_ip

    fn DialOptions::set_trust_pasv_ip(self : DialOptions, value : Bool) -> Unit

    Trust or distrust the IP returned by PASV.

    DialOptions::set_writing_mdtm

    fn DialOptions::set_writing_mdtm(self : DialOptions, value : Bool) -> Unit

    Use MDTM <time> <path> to write timestamps (VsFtpd quirk).

    DialOptions::to_state_options

    fn DialOptions::to_state_options(self : DialOptions) -> Options

    Project a DialOptions onto the subset of parameters the data channel layer needs.

    Entry

    pub struct Entry {
    name : String
    target : String
    type_ : EntryType
    size : UInt64
    time :
    ZonedDateTime

    } derive(
    Debug
    )

    A single remote directory entry.

    Mirroring upstream ftp.Entry:
    • target is empty for non-symlinks (Go zero value semantics),
    • time carries an explicit timezone because LIST timestamps are wall-clock time in the server's timezone.

    Entry::set_name

    fn Entry::set_name(self : Entry, name : String) -> Unit

    Replace the name of an entry in place.

    Entry::set_size

    fn Entry::set_size(self : Entry, size : UInt64) -> Unit

    Replace the size of an entry in place.

    Entry::set_target

    fn Entry::set_target(self : Entry, target : String) -> Unit

    Replace the symlink target of an entry in place.

    Entry::set_time

    fn Entry::set_time(self : Entry, time :
    ZonedDateTime
    ) -> Unit

    Replace the modification time of an entry in place.

    Entry::set_type

    fn Entry::set_type(self : Entry, type_ : EntryType) -> Unit

    Replace the kind of an entry in place.

    Entry::to_repr

    Entry promotes its Debug methods as regular methods; declare the promotion explicitly so the deprecation warning stays away.

    EntryType

    pub(all) enum EntryType {
    File
    Folder
    Link
    } derive(Eq,
    Debug
    )

    The kind of a remote directory entry, mirroring upstream ftp.EntryType.

    EntryType::equal

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

    EntryType promotes its Eq and Debug methods as regular methods; declare the promotions explicitly so the deprecation warning stays away.

    EntryType::not_equal

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

    EntryType promotes its Eq and Debug methods as regular methods; declare the promotions explicitly so the deprecation warning stays away.

    EntryType::to_string

    fn EntryType::to_string(self : EntryType) -> String

    Return the lowercase name of the entry type, matching the string form used by the Go implementation (file / folder / link).

    FTPClient

    pub struct FTPClient {
    options : DialOptions
    state_options : Options
    session : Session
    closed : Bool
    host : String
    mutex :
    Mutex

    mlst_supported : Bool
    mfmt_supported : Bool
    mdtm_supported : Bool
    mdtm_can_write : Bool
    location :
    Zone

    features : Map[String, Bool]
    }

    The FTP client, the equivalent of upstream ftp.ServerConn.

    Upstream documents this type as not safe for concurrent use. We keep the same constraint but enforce it with a Mutex so that a misuse shows up as serialized commands instead of a corrupted session — a deliberate improvement over the Go implementation, with unchanged semantics.

    FTPClient::has_feature

    fn FTPClient::has_feature(self : FTPClient, name : String) -> Bool

    Whether the server advertised name in its FEAT list.

    FTPClient::is_closed

    fn FTPClient::is_closed(self : FTPClient) -> Bool

    Whether the client already sent QUIT.

    FTPClient::is_get_time_supported

    fn FTPClient::is_get_time_supported(self : FTPClient) -> Bool

    Whether the server accepts MDTM to read modification times.

    FTPClient::is_mfmt_supported

    fn FTPClient::is_mfmt_supported(self : FTPClient) -> Bool

    Whether the server accepts MFMT to set modification times.

    FTPClient::is_mlst_supported

    fn FTPClient::is_mlst_supported(self : FTPClient) -> Bool

    Whether the server advertised MLST (and MLSD was not disabled explicitly).

    FTPClient::is_set_time_supported

    fn FTPClient::is_set_time_supported(self : FTPClient) -> Bool

    Whether the modification time can be written, either via MFMT or via the VsFtpd style MDTM <time> <path>.

    FTPClient::is_time_precise_in_list

    fn FTPClient::is_time_precise_in_list(self : FTPClient) -> Bool

    Whether LIST output carries second level precision. Servers without MDTM only give minute precision through LIST.

    FTPClient::location

    The timezone used to interpret LIST timestamps.

    FTPClient::lock

    async fn FTPClient::lock(self : FTPClient) -> Unit

    Acquire the internal lock, making every public operation serialized.

    Upstream documents ServerConn as not concurrency safe; enforcing it at runtime is a deliberate improvement that does not change the semantics.

    FTPClient::options

    fn FTPClient::options(self : FTPClient) -> DialOptions

    The connection options this client was dialed with.

    FTPClient::session

    fn FTPClient::session(self : FTPClient) -> Session

    The negotiated session state shared with the data channel layer.

    FTPClient::set_closed

    fn FTPClient::set_closed(self : FTPClient, value : Bool) -> Unit

    Mark the client as closed so that quit becomes idempotent.

    FTPClient::set_features

    fn FTPClient::set_features(self : FTPClient, features : Map[String, Bool]) -> Unit

    Replace the cached FEAT feature set.

    FTPClient::set_location

    fn FTPClient::set_location(self : FTPClient, location :
    Zone
    ) -> Unit

    Replace the timezone used to interpret LIST timestamps.

    FTPClient::set_mdtm_can_write

    fn FTPClient::set_mdtm_can_write(self : FTPClient, value : Bool) -> Unit

    Record whether MDTM may be used to write timestamps (VsFtpd quirk).

    FTPClient::set_mdtm_supported

    fn FTPClient::set_mdtm_supported(self : FTPClient, value : Bool) -> Unit

    Record whether the server supports MDTM.

    FTPClient::set_mfmt_supported

    fn FTPClient::set_mfmt_supported(self : FTPClient, value : Bool) -> Unit

    Record whether the server supports MFMT.

    FTPClient::set_mlst_supported

    fn FTPClient::set_mlst_supported(self : FTPClient, value : Bool) -> Unit

    Record whether the server supports MLST / MLSD.

    FTPClient::state_options

    fn FTPClient::state_options(self : FTPClient) -> Options

    The data channel view of the connection options.

    FTPClient::unlock

    fn FTPClient::unlock(self : FTPClient) -> Unit

    Release the internal lock.

    ListDateField

    type ListDateField

    The pieces of a LIST date field: MMM is consumed by the caller.

    ListDateField::day

    fn ListDateField::day(self : ListDateField) -> Int

    The day of month of a parsed date field.

    ListDateField::has_time

    fn ListDateField::has_time(self : ListDateField) -> Bool

    Whether the field used the yearless HH:MM shape.

    ListDateField::hour

    fn ListDateField::hour(self : ListDateField) -> Int

    The hour, 0 when the field carried none.

    ListDateField::minute

    fn ListDateField::minute(self : ListDateField) -> Int

    The minute, 0 when the field carried none.

    ListDateField::next

    fn ListDateField::next(self : ListDateField) -> Int

    The index right after the parsed date field.

    ListDateField::year

    fn ListDateField::year(self : ListDateField) -> Int

    The year of a parsed date field, 0 when the field carried none.

    ListFormat

    pub(all) enum ListFormat {
    Rfc3659
    UnixLs
    DosDir
    HostedFtp
    } derive(Eq,
    Debug
    )

    Which of the four parsers recognised a listing line. The order of the variants is the fallback order used by parse_list_line.

    ListFormat::equal

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

    ListFormat promotes its Eq and Debug methods as regular methods; declare the promotions explicitly so the deprecation warning stays away.

    ListFormat::not_equal

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

    ListFormat promotes its Eq and Debug methods as regular methods; declare the promotions explicitly so the deprecation warning stays away.

    Options

    pub struct Options {
    disable_epsv : Bool
    trust_pasv_ip : Bool
    tls : Bool
    timeout_ms : Int
    shut_timeout_ms : Int
    trust :
    TrustedRoot

    }

    The connection parameters that affect the data channel, mirroring the subset of upstream DialOptions that transport cares about.

    Options::default

    fn Options::default() -> Options

    Default options, matching the documented defaults in docs/porting.md section 2.4 (API mapping).

    Options::disable_epsv

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

    Whether EPSV is disabled by configuration.

    Options::new

    fn Options::new(disable_epsv : Bool, trust_pasv_ip : Bool, tls : Bool, timeout_ms : Int, shut_timeout_ms : Int, trust :
    TrustedRoot
    ) -> Options

    Build the data channel options explicitly.

    Options::shut_timeout_ms

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

    Timeout pushing the control connection before the 226 read, milliseconds.

    Options::timeout_ms

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

    Connect timeout for the data connection, milliseconds.

    Options::tls

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

    Whether the data connection is wrapped in TLS.

    Options::trust

    The certificate trust policy for TLS data connections.

    Options::trust_pasv_ip

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

    Whether the PASV reply IP may be trusted.

    Response

    pub struct Response {
    code : Int
    message : String
    } derive(
    Debug
    )

    A parsed FTP reply: the numeric status code and the (possibly multi line) message body.

    The body keeps the text of the framing status lines, matching the semantics of Go's textproto.ReadResponse; see read_response for why the callers depend on that.

    Response::code

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

    The status code of the reply.

    Response::message

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

    The message body of the reply.

    Response::new

    fn Response::new(code : Int, message : String) -> Response

    Build a reply value.

    Response::to_repr

    Response promotes its Debug methods as regular methods; declare the promotion explicitly so the deprecation warning stays away.

    Scanner

    pub struct Scanner {
    src : String
    offset : Int
    }

    A whitespace separated field scanner, a faithful port of upstream scanner.go.

    The position semantics are not intuitive and upstream has a dedicated test for them, so they are spelled out here:

    new Scanner("foo bar x y") .next() -> "foo", position stops *after* the run of spaces .remaining() -> " bar x y" // the leading space is kept .next() -> "bar" .remaining() -> "x y"

    In other words next() consumes the field and exactly one following space. Everything after that stays in remaining().

    Scanner::at_end

    fn Scanner::at_end(self : Scanner) -> Bool

    Whether all input has been consumed.

    Scanner::new

    fn Scanner::new(src : String) -> Scanner

    Create a scanner over src.

    Scanner::next

    fn Scanner::next(self : Scanner) -> String

    Consume and return the next whitespace separated field.

    Returns "" at end of input. As in upstream, an empty field is only produced at the end — runs of spaces never yield empty fields.

    Scanner::next_fields

    fn Scanner::next_fields(self : Scanner, count : Int) -> Array[String]

    Read up to count consecutive fields. Stops early (without raising) when the input is exhausted, exactly like upstream nextFields.

    Scanner::remaining

    fn Scanner::remaining(self : Scanner) -> String

    The not-yet-consumed tail of the input.

    Session

    pub struct Session {
    options : Options
    control : Control
    skip_epsv : Bool
    use_pret : Bool
    }

    Everything transport needs from a dialed FTP session.

    This type exists to break the otherwise circular dependency between the client package (which owns FTPClient) and the transport package (which needs the control connection and the capability flags). client embeds a Session and fills it in during dial / feat, transport only reads it.

    Session::control

    fn Session::control(self : Session) -> Control

    The control connection of this session.

    Session::new

    fn Session::new(control : Control, options? : Options) -> Session

    Build a session around an already established control connection.

    Session::options

    fn Session::options(self : Session) -> Options

    The connection parameters of this session.

    Session::set_skip_epsv

    fn Session::set_skip_epsv(self : Session, value : Bool) -> Unit

    Remember that EPSV failed; every later transfer goes through PASV.

    Session::set_use_pret

    fn Session::set_use_pret(self : Session, value : Bool) -> Unit

    Record whether the server supports PRET.

    Session::skip_epsv

    fn Session::skip_epsv(self : Session) -> Bool

    Whether EPSV has already failed and must not be retried.

    Session::use_pret

    fn Session::use_pret(self : Session) -> Bool

    Whether the server supports PRET.

    TransferType

    pub(all) enum TransferType {
    Binary
    ASCII
    } derive(Eq,
    Debug
    )

    The transfer type of the data connection, matching the FTP TYPE command arguments.

    TransferType::argument

    fn TransferType::argument(self : TransferType) -> String

    The FTP TYPE argument, an alias of to_string that reads better at the call site.

    TransferType::equal

    TransferType promotes its Eq and Debug methods as regular methods; declare the promotions explicitly so the deprecation warning stays away.

    TransferType::from_string

    fn TransferType::from_string(value : String) -> TransferType?

    Parse a TYPE command argument back into a TransferType, the inverse of TransferType::to_string.

    TransferType::not_equal

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

    TransferType promotes its Eq and Debug methods as regular methods; declare the promotions explicitly so the deprecation warning stays away.

    TransferType::to_string

    fn TransferType::to_string(self : TransferType) -> String

    The argument string sent with the TYPE command.

    Walker

    pub struct Walker {
    client : FTPClient
    cur : Entry?
    cur_path : String
    stack : Array[(Entry, String)]
    descend : Bool
    root : String
    walker_err : Error?
    }

    A depth-first directory tree walker, a faithful port of upstream ftp.Walker.

    The API contract matters here: next() returns false on a listing error instead of raising, and the error is readable through err().

    Walker::entries

    fn Walker::entries(self : Walker) -> Array[(Entry, String)]

    The entries currently waiting on the stack, oldest first.

    Walker::err

    fn Walker::err(self : Walker) -> Error?

    The error that stopped the walk, if any.

    Walker::next

    async fn Walker::next(self : Walker) -> Bool

    Advance to the next entry, returning false when the walk is finished or when a listing failed.

    The steps are exactly upstream's:
    1. initialise cur to a synthetic folder entry for the root,
    2. list cur only when descend is set and cur is a folder, recording (not raising) failures,
    3. push every child except . and ..,
    4. stop when the stack is empty,
    5. pop the stack top, reset descend, return true.

    The cur is a folder guard is upstream's (Next tests w.cur.entry.Type == EntryTypeFolder), and it is what makes a walk of a real tree terminate cleanly: without it the walker would try to LIST every file it pops, and a server that refuses to list a file would turn an ordinary tree into an error at the end.

    Walker::path

    fn Walker::path(self : Walker) -> String

    The path of the current entry.

    Walker::pending

    fn Walker::pending(self : Walker) -> Int

    The number of entries still to visit.

    Walker::pop

    fn Walker::pop(self : Walker) -> (Entry, String)?

    Drop and return the most recently pushed entry, if any.

    Walker::push

    fn Walker::push(self : Walker, entry : Entry, path : String) -> Unit

    Push a child entry onto the walk stack.

    Walker::set_stack

    fn Walker::set_stack(self : Walker, stack : Array[(Entry, String)]) -> Unit

    Replace the pending entry stack.

    Walker::skip_dir

    fn Walker::skip_dir(self : Walker) -> Unit

    Do not descend into the directory returned by the last next().

    Walker::stat

    fn Walker::stat(self : Walker) -> Entry?

    The entry currently being visited.

    append

    async fn append(client : FTPClient, path : String, source : &
    Reader
    ) -> Unit

    APPE <path>, appending a file.

    apply_list_time

    fn apply_list_time(entry : Entry, year : Int, month : Int, day : Int, has_time : Bool, hour : Int, minute : Int, now :
    ZonedDateTime
    ) -> Unit raise FtpError

    Interpret the MMM DD HH:MM / MMM DD YYYY time field of a LIST line.

    This is upstream's setTime including the six month rule from info ls 10.1.6:

    • when the field contains : there is no year, so the current year is assumed; if the resulting instant is not before now + 6 months, the year is decremented,
    • when the field has no : the year must be exactly four digits, a malformed year is an error (not a fallback),
    • a missing time (HH:MM) defaults to midnight.

    change_dir

    async fn change_dir(client : FTPClient, dir : String) -> Unit

    CWD <path>, expecting 250.

    change_dir_to_parent

    async fn change_dir_to_parent(client : FTPClient) -> Unit

    CDUP, expecting 250.

    check_data_shut

    async fn check_data_shut(session : Session) -> Unit

    Read the 226 (or 250) that terminates a transfer. Must always run before the next command is sent.

    check_for_command_injection

    fn check_for_command_injection(arg : String) -> Unit raise FtpError

    Refuse command arguments that contain CR or LF.

    Upstream calls this checkForCommandInjection; the check must happen before a single byte is written to the socket, otherwise a crafted path could smuggle extra FTP commands into the session.

    cmd

    async fn cmd(control : Control, command : String) -> Response

    Send cmd (already encoded) and return the reply.

    cmd_data_conn_from

    async fn cmd_data_conn_from(session : Session, offset : Int64, cmd : String, args : Array[String]) -> DataConn

    Open a data connection and start a transfer command on it.

    This is the heart of the data channel and follows upstream cmdDataConnFrom step by step:

    1. PRET <cmd> first when the server advertised PRET,
    2. open the data connection (a plain TCP connect, no TLS yet),
    3. REST <offset> when a resume offset was given, expecting 350,
    4. send the transfer command, expecting 125 or 150,
    5. only then start the data-channel TLS handshake for FTPS,
    6. on a non 2xx answer, close the data connection before raising.

    Step 5 is the fix for the FTPS ordering bug: the handshake used to run inside open_data_conn, i.e. before step 4, which no real ftpd tolerates (see DataConn::start_tls). It now happens exactly once the server has answered 150 and is ready to read the data socket.

    cmd_expect

    async fn cmd_expect(control : Control, command : String, expected : Array[Int]) -> Response

    Send cmd and require one of the expected status codes.

    An empty expected list accepts any code, which is how upstream spells cmd(..., -1) for the expected == -1 case. A mismatch raises FtpError::ServerError carrying the original message.

    cmd_format

    async fn cmd_format(control : Control, format : String, args : Array[String], expected : Array[Int]) -> Response

    Send \format` with args applied through {} substitution and requireone of the expected codes. Convenience wrapper mirroring upstreamcmd(format, args, expected...)`.

    current_dir

    async fn current_dir(client : FTPClient) -> String

    PWD, returning the path inside the quoted part of the 257 reply.

    default_dial_timeout_ms

    let default_dial_timeout_ms : Int

    Default timeout used when dialing a server, in milliseconds (upstream ftp.DefaultDialTimeout, 30s).

    delete

    async fn delete(client : FTPClient, path : String) -> Unit

    DELE <path>, expecting 250.

    dial

    async fn dial(addr : String, timeout_ms? : Int, shut_timeout_ms? : Int, tls? : Bool, explicit_tls? : Bool, trust? :
    TrustedRoot
    , disable_epsv? : Bool, trust_pasv_ip? : Bool, disable_utf8? : Bool, disable_mlsd? : Bool, writing_mdtm? : Bool, force_list_hidden? : Bool, location? :
    Zone
    ) -> FTPClient

    Connect to addr (host or host:port) and return a ready client.

    The options mirror the Go DialWith* family; they are labels rather than a function-options variadic, which is the MoonBit idiom recorded in docs/porting.md section 2.4 (API mapping).

    Once connected the client reads the greeting (expecting 220). When explicit_tls is set it then sends AUTH TLS, expects 234 and upgrades the control connection.

    epoch

    The UNIX epoch, a convenient zero value for Entry::time.

    @time.ZonedDateTime::from_unix_second raises, which is awkward in pure parsers, so the fallback lives here where the failure path can be handled exactly once.

    epsv

    async fn epsv(session : Session) -> Int

    Send EPSV (RFC 2428) and return the data port the server opened.

    229 Entering Extended Passive Mode (|||6446|)

    extract_quoted

    fn extract_quoted(message : String) -> String?

    Extract the substring between the first pair of double quotes, which is how 257 "/incoming" created. encodes the path.

    feat

    async fn feat(client : FTPClient) -> Unit

    Ask the server for its feature list and cache the capabilities the client cares about.

    A server that answers FEAT with anything other than 211 simply has no feature list; that is not an error, upstream treats it the same way.

    file_size

    async fn file_size(client : FTPClient, path : String) -> UInt64

    SIZE <path>, expecting 213.

    find_date_start_pub

    fn find_date_start_pub(line : String, start : Int) -> Int?

    Index of the first MMM DD date field at or after start.

    flatten_errors

    fn flatten_errors(err : Error) -> Array[Error]

    Return every error carried by an aggregate, or the error itself when it is not an aggregate. Useful for tests and for re-raising partial failures.

    format_mdtm

    fn format_mdtm(timestamp :
    ZonedDateTime
    ) -> String

    Render a timestamp as yyyyMMddHHmmss in UTC.

    get_data_port

    async fn get_data_port(session : Session) -> (String, Int)

    Get the port to connect to, preferring EPSV and permanently falling back to PASV after the first failure.

    The "EPSV failed once, never try again" behaviour is a deliberate upstream design choice: some servers answer EPSV with an error but also with a malformed PASV reply, and retrying EPSV on every transfer turns a fast failure into a timeout per file.

    get_entry

    async fn get_entry(client : FTPClient, path : String) -> Entry

    MLST <path>: the facts of a single entry.

    The body carries the framing lines too, so this follows upstream exactly: the message must split into at least three lines, the first and the last are dropped, and the remaining lines are merged into one entry (RFC 3659 allows the facts of a single file to be spread over several lines).

    get_time

    async fn get_time(client : FTPClient, path : String) ->
    ZonedDateTime

    MDTM <path>, expecting 213 and a yyyyMMddHHmmss UTC timestamp.

    is_all_digits

    fn is_all_digits(src : String) -> Bool

    Whether every byte of src is an ASCII digit.

    is_bogus_data_ip

    fn is_bogus_data_ip(cmd_ip : String, data_ip : String) -> Bool

    Decide whether the IP returned by PASV may be used as the data destination.

    Upstream's rule, kept verbatim: the address is bogus when it is multicast, or when its "privateness" differs from the control connection, or when its loopback-ness differs. This is what makes a server that answers PASV ... (10,0,0,1,...) from a public IP unusable unless the caller opts in with trust_pasv_ip.

    is_continuation

    fn is_continuation(line : String) -> Bool

    Whether the line is a multi line continuation (211-Features:), i.e. the fourth byte is -.

    is_loopback

    fn is_loopback(ip : String) -> Bool

    Whether ip is a loopback address (127.0.0.0/8 or ::1).

    is_multicast

    fn is_multicast(ip : String) -> Bool

    Whether ip is a multicast address (224.0.0.0/4 or ff00::/8).

    is_positive_completion

    fn is_positive_completion(code : Int) -> Bool

    Whether the code is a 2xx "positive completion" reply.

    is_positive_intermediate

    fn is_positive_intermediate(code : Int) -> Bool

    Whether the code is a 1xx "positive intermediate" reply.

    is_private

    fn is_private(ip : String) -> Bool

    Whether ip is a private (RFC 1918 / link local / ULA) address.

    join

    fn join(base : String, name : String) -> String

    Join two remote path fragments the way Go's path.Join does: the result is always cleaned (. dropped, .. resolved, no empty segment, no trailing slash), using / as separator. A leading / in the inputs is significant only for the first non-empty segment, matching path.Join.

    join("root/", "lo") == "root/lo" join("root", "a") == "root/a" join("root", "..") == "." join("", "a") == "a"

    join_all

    fn join_all(parts : Array[String]) -> String

    Variadic form of join, equivalent to path.Join(parts...).

    join_errors

    fn join_errors(errors : Array[Error]) -> Error?

    Build an aggregated error, collapsing the trivial cases so callers do not have to care about them:
    • no error at all -> None
    • exactly one error -> that error itself
    • otherwise -> FtpErrors::MultipleErrors

    list

    async fn list(client : FTPClient, path : String) -> Array[Entry]

    List a directory.

    MLSD is preferred when the server supports it; otherwise LIST (with -a when force_list_hidden is set) is parsed with the four parser fallback. Lines that no parser recognises are skipped, not fatal: real servers emit total 1 headers and other noise.

    login

    async fn login(client : FTPClient, user : String, password : String) -> Unit

    Authenticate with USER / PASS, then negotiate capabilities.

    The sequence matches upstream Login: USER -> 331 -> PASS -> 230, then FEAT, then TYPE I, then optionally OPTS UTF8 ON, and for implicit TLS PBSZ 0 / PROT P.

    The FEAT / OPTS / TYPE steps are taken outside the client lock: they are public API and take the lock themselves, and the lock is deliberately not re-entrant (see FTPClient::lock). Holding it across these calls would deadlock every login against a real server.

    logout

    async fn logout(client : FTPClient) -> Unit

    REIN, expecting 220: drops the authentication state but keeps the control connection.

    make_dir

    async fn make_dir(client : FTPClient, dir : String) -> Unit

    MKD <path>, expecting 257.

    make_entry

    fn make_entry(name : String, type_ : EntryType, time? :
    ZonedDateTime
    ) -> Entry

    Build an entry with the given name and type; the target defaults to the empty string and the time to the epoch in UTC, both of which the parsers overwrite when the server supplies the information.

    make_file

    fn make_file(name : String, time? :
    ZonedDateTime
    ) -> Entry

    Build a plain file entry, the most common shape.

    make_folder

    fn make_folder(name : String, time? :
    ZonedDateTime
    ) -> Entry

    Build a folder entry, the shape used by the walker root marker.
    fn make_link(name : String, target : String, time? :
    ZonedDateTime
    ) -> Entry

    Build a link entry with the given target.

    month_names

    let month_names : Array[String]

    The English month abbreviations accepted in LIST output, in calendar order (Jan == 1).

    name_list

    async fn name_list(client : FTPClient, path : String) -> Array[String]

    NLST <path>: a bare list of names, one per line.

    new_datetime

    fn new_datetime(year : Int, month : Int, day : Int, hour : Int, minute : Int, zone :
    Zone
    ) ->
    ZonedDateTime
    raise FtpError

    Build a ZonedDateTime in zone, raising UnsupportedListDate when the calendar fields are not a valid date (e.g. Feb 30).

    no_op

    async fn no_op(client : FTPClient) -> Unit

    NOOP, expecting 200.

    open_data_conn

    async fn open_data_conn(session : Session) -> DataConn

    Open the passive data connection: EPSV / PASV, then a TCP connect.

    No TLS handshake happens here on purpose -- see DataConn::start_tls for why it belongs after the transfer command was accepted. The socket is left raw so the handshake can be triggered at the right moment.

    parse_code

    fn parse_code(line : String) -> Int?

    Parse the leading three digits of a reply line.

    parse_decimal

    fn parse_decimal(text : String) -> Int?

    Parse a decimal string into an Int, returning None on any non-digit.

    parse_dos_dir_line

    fn parse_dos_dir_line(line : String, now :
    ZonedDateTime
    ) -> Entry? raise FtpError

    Parse a MS-DOS / Windows DIR style line:

    07-27-17 04:50PM 1264086 File.txt 11-06-16 09:31AM <DIR> Softlib

    Faithful port of upstream parseDirListLine:

    • the first field is the date and is tried against four layouts (01-02-06 03:04PM with one or two digit month/day/year, and the 01-02-06 15:04 24 hour variant),
    • <DIR> switches the type to folder and the size to 0,
    • anything else must be a plain decimal size.

    parse_epsv

    fn parse_epsv(line : String) -> Int raise FtpError

    Extract the port from an EPSV reply body.

    The body is expected to contain ||| followed by the port and a closing |. A malformed reply raises ParseError instead of silently using port 0, which would otherwise hang the connect.

    parse_features

    fn parse_features(body : String) -> Map[String, Bool]

    Parse a FEAT body into a set of command names.

    Feature lines have the shape COMMAND [description]; only the first token is kept, uppercased.

    The leading space test is not cosmetic: the body carries the text of the 211-Features: and 211 End framing lines too (see read_response), and once trimmed they would otherwise register as features named FEATURES: and END. Upstream filters with exactly this test.

    parse_hostedftp_line

    fn parse_hostedftp_line(line : String, now :
    ZonedDateTime
    ) -> Entry? raise FtpError

    Parse a hostedftp.com style line, which is ls -l without the link count:

    drwxr-xr-x folder 0 Aug 15 05:49 !!!-Tipp des Haus!

    Upstream parseHostedFTPLine rewrites the first field by appending a space and the literal link count 0, then delegates to the Unix ls parser. Keeping that trick means the two parsers stay in sync.

    parse_list_date_field

    fn parse_list_date_field(src : String, start : Int) -> ListDateField?

    Parse the two year-dependent shapes used by the LIST time field, given the MMM DD prefix has already been consumed.

    Returns (year, month, day, has_time, hour, minute, next_index). year is 0 when the field carried no year at all (the HH:MM shape), the caller substitutes the current year.

    parse_list_line

    fn parse_list_line(line : String, now :
    ZonedDateTime
    ) -> (Entry, ListFormat) raise FtpError

    Parse a single LIST / MLSD line, trying the four parsers in the fixed fallback order documented in docs/porting.md section 1 (upstream analysis).

    line must not contain the trailing CR/LF. now is the reference instant used for the "no year" heuristic and is an explicit parameter so that the upstream test cases (fixed at 2017-03-10 23:00 UTC) stay reproducible.

    parse_ls_line

    fn parse_ls_line(line : String, now :
    ZonedDateTime
    ) -> Entry? raise FtpError

    Parse a Unix ls -l style line:

    drwxr-xr-x 3 110 1002 3 Dec 02 2009 pub -rw-r--r-- 1 marketwired marketwired 12016 Mar 16 2016 newsml drwxr-xr-x folder 0 Aug 15 05:49 !!!-Tipp des Haus!

    Port of upstream parseLsListLine:

    • the permission field is 10 bytes, or 11 with an ACL + marker,
    • the type comes from the first character,
    • the size is the last numeric field before the date, which is what makes the ls -l and the "no link count" variants both parse,
    • the date is found by scanning for MMM DD , so extra columns do not shift the parse,
    • the name is the entire remainder, so spaces inside names survive,
    • a symlink's -> is split into name and target.

    parse_ls_time

    fn parse_ls_time(src : String, start : Int, now :
    ZonedDateTime
    ) -> (
    ZonedDateTime
    , Int)? raise FtpError

    Parse an ls -l time field: MMM DD HH:MM or MMM DD YYYY.

    Returns the resolved time and the index right after the field.

    parse_mdtm

    fn parse_mdtm(text : String) ->
    ZonedDateTime
    raise FtpError

    Parse a yyyyMMddHHmmss timestamp as UTC.

    parse_month

    fn parse_month(name : String) -> Int?

    Parse a three letter month name case-insensitively, returning 1..12.

    parse_next_rfc3659_line

    fn parse_next_rfc3659_line(entry : Entry, line : String) -> Entry raise FtpError

    Merge the facts of a continuation line into the previous RFC 3659 entry. Some servers answer MLST with the same entry spread over several lines; upstream parseNextRFC3659ListLine requires the names to match.

    parse_octet

    fn parse_octet(text : String) -> Int?

    Parse a decimal byte value (0..255), returning None otherwise.

    parse_pasv

    fn parse_pasv(line : String) -> (String, Int) raise FtpError

    Parse the six comma separated numbers of a PASV reply into an IP and a port (port = p1 * 256 + p2). Fewer than six numbers is an error.

    parse_port

    fn parse_port(digits : String) -> Int?

    Parse a non-empty decimal string into an Int, returning None on any non-digit byte or on overflow.

    parse_rfc3659_line

    fn parse_rfc3659_line(line : String) -> Entry? raise FtpError

    Parse a single RFC 3659 (MLSD / MLST) fact line:

    type=file;size=951;modify=20140101000000; welcome.msg

    Faithful port of upstream parseRFC3659ListLine:

    • the facts are separated by ;, the last one is followed by the name,
    • fact keys are matched case-insensitively (Type= works as well, which is what WFTPD/Serv-U emit),
    • sizd is accepted as a typo for size,
    • an unrecognised type= value is an error, not a fallback,
    • a line whose last fact is not terminated by ; is not an RFC 3659 line.

    parse_uint

    fn parse_uint(digits : String, radix : Int) -> UInt64 raise FtpError

    Parse an unsigned 64 bit integer in the given radix, rejecting empty input and overflowing values.

    pasv

    async fn pasv(session : Session) -> (String, Int)

    Send PASV and return the (ip, port) pair the server advertises.

    227 Entering Passive Mode (127,0,0,1,196,6)

    quit

    async fn quit(client : FTPClient) -> Unit

    QUIT and close the connection.

    The errors are aggregated instead of short-circuiting: a failure to send QUIT must not hide a failure to close the socket, and the other way round. This mirrors upstream's use of errors.Join.

    read_response

    async fn read_response(control : Control) -> Response

    Read a complete reply from the control connection, handling both the single line and the multi line (RFC 959 211-...211 End) shapes.

    The result reproduces Go's net/textproto.ReadResponse byte for byte, because the rest of the port (starting with FEAT and MLST) is written against that shape. Two consequences are load bearing:

    • the message includes the text of the first and of the terminating status line, joined by \n. Go's feat() filters those back out with a leading space test, and its GetEntry relies on lines[1:lc-1]; a body that dropped the boundary lines would break MLST outright.
    • a line that is exactly three digits, with no separator, is a short response error, not an empty message.

    The multi line terminator is the line that repeats the code without a dash. Getting that wrong makes FEAT swallow the next reply, so it has a dedicated test.

    remove_dir

    async fn remove_dir(client : FTPClient, dir : String) -> Unit

    RMD <path>, expecting 250.

    rename

    async fn rename(client : FTPClient, from : String, to : String) -> Unit

    Rename in two steps, RNFR -> 350 then RNTO -> 250.

    render_command

    fn render_command(format : String, args : Array[String]) -> String raise FtpError

    Apply {} substitution to format without sending anything, validating every argument first.

    Split out of cmd_format because the PRET handshake needs the rendered command before it is sent: it announces the line that follows, so the two must be built by the same code or they drift.

    retr

    async fn retr(client : FTPClient, path : String) -> DataResponse

    RETR <path>, returning a readable DataResponse.

    retr_from

    async fn retr_from(client : FTPClient, path : String, offset? : Int64) -> DataResponse

    RETR <path> after REST <offset>, i.e. a resumed download.

    set_file_time

    async fn set_file_time(client : FTPClient, path : String, timestamp :
    ZonedDateTime
    ) -> Unit

    Set the modification time of path.

    MFMT is tried first; when the server only advertises MDTM and writing_mdtm is set, the VsFtpd quirk MDTM <time> <path> is used instead. When neither is available the call raises ServerError with code 502, matching upstream's "not implemented" behaviour.

    set_size

    fn set_size(raw : String) -> UInt64 raise FtpError

    Parse a numeric size field the way Go's strconv.ParseUint(s, 0, 64) does: an explicit 0x / 0X prefix means hex, 0o / 0O means octal, a leading 0 means octal, and anything else is decimal.

    set_transfer_type

    async fn set_transfer_type(client : FTPClient, transfer_type : TransferType) -> Unit

    TYPE I / TYPE A.

    split_addr

    fn split_addr(addr : String, default_port : Int) -> (String, Int)

    Split host / host:port into its two components, applying default_port when the caller did not specify one.

    status_action_not_taken

    let status_action_not_taken : Int

    status_auth_ok

    let status_auth_ok : Int

    status_bad_filename

    let status_bad_filename : Int

    status_cannot_open_data_connection

    let status_cannot_open_data_connection : Int

    status_closing_control_connection

    let status_closing_control_connection : Int

    221 Service closing control connection. — the answer to QUIT.

    status_closing_data_connection

    let status_closing_data_connection : Int

    status_cmd_not_implemented_superfluous

    let status_cmd_not_implemented_superfluous : Int

    status_cmd_ok

    let status_cmd_ok : Int

    status_data_conn_already_in_use

    let status_data_conn_already_in_use : Int

    status_data_connection_already_open

    let status_data_connection_already_open : Int

    status_data_connection_open_no_transfer

    let status_data_connection_open_no_transfer : Int

    status_dir_create

    let status_dir_create : Int

    status_dir_status

    let status_dir_status : Int

    status_enter_extended_passive_mode

    let status_enter_extended_passive_mode : Int

    status_enter_extended_port

    let status_enter_extended_port : Int

    status_enter_passive_mode

    let status_enter_passive_mode : Int

    status_enter_port

    let status_enter_port : Int

    status_entering_passive_mode_from_ip

    let status_entering_passive_mode_from_ip : Int

    status_exceeded_storage_allocation

    let status_exceeded_storage_allocation : Int

    status_file_action_aborted

    let status_file_action_aborted : Int

    status_file_action_aborted_local_error

    let status_file_action_aborted_local_error : Int

    status_file_action_not_taken

    let status_file_action_not_taken : Int

    status_file_action_ok

    let status_file_action_ok : Int

    status_file_action_pending_further_info

    let status_file_action_pending_further_info : Int

    status_file_status

    let status_file_status : Int

    status_help_message

    let status_help_message : Int

    status_name_status

    let status_name_status : Int

    status_need_account

    let status_need_account : Int

    status_need_account_for_login

    let status_need_account_for_login : Int

    status_not_implemented

    let status_not_implemented : Int

    status_not_implemented_for_param

    let status_not_implemented_for_param : Int

    status_not_logged_in

    let status_not_logged_in : Int

    status_page_type_unknown

    let status_page_type_unknown : Int

    status_request_denied

    let status_request_denied : Int

    status_restart_marker_answer

    let status_restart_marker_answer : Int

    RFC 959 / RFC 2228 / RFC 2428 / RFC 3659 status codes.

    Values are kept byte-for-byte identical to upstream status.go. Several distinct constants intentionally share the same numeric value (the RFCs overload the code), which is fine because they are plain Int constants.

    status_restart_marker_not_understood

    let status_restart_marker_not_understood : Int

    status_restart_marker_reply

    let status_restart_marker_reply : Int

    status_service_ready_for_new_user

    let status_service_ready_for_new_user : Int

    status_service_ready_soon

    let status_service_ready_soon : Int

    status_service_unavailable

    let status_service_unavailable : Int

    status_session_opened_data_connection

    let status_session_opened_data_connection : Int

    status_syntax_error_unknown_cmd

    let status_syntax_error_unknown_cmd : Int

    status_syntax_error_unknown_params

    let status_syntax_error_unknown_params : Int

    status_system_status

    let status_system_status : Int

    status_text

    fn status_text(code : Int) -> String

    The human readable text for every known status code, copied from upstream status.go (no localization on purpose).

    status_transfer_aborted

    let status_transfer_aborted : Int

    status_user_logged_in_proceed

    let status_user_logged_in_proceed : Int

    status_username_ok

    let status_username_ok : Int

    status_username_ok_need_password

    let status_username_ok_need_password : Int

    stor

    async fn stor(client : FTPClient, path : String, source : &
    Reader
    ) -> Unit

    STOR <path>, uploading from source.

    stor_from

    async fn stor_from(client : FTPClient, path : String, source : &
    Reader
    , offset? : Int64) -> Unit

    STOR <path> after REST <offset>, i.e. a resumed upload.

    The zero-byte TLS case is handled explicitly: ProFTPD answers Unable to build data connection when the data connection was never written to, because the TLS handshake never happened. Uploading nothing while triggering the handshake explicitly keeps the server happy.

    walk

    fn walk(client : FTPClient, root : String) -> Walker

    Walk the directory tree rooted at root.