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.3
    License
    Apache-2.0
    Last updated
    8 days ago
    Downloads
    74

    Dependencies

    #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

    完整依赖表(openssl / python3 / docker 分别服务哪些步骤)见 开发 → 依赖安装。

    #快速开始

    所有公开函数都是 async 函数,调用方需处在 @async 运行时中(CLI / 测试里写 async fn main 即可,无需手写 await)。下面这段最小可运行例子同时是文档自身的 回归测试:它用 moonbit check 标注,会被 moon check 真的类型检查。

    ///|
    test {
    ///|
    async fn demo() -> Unit {
    // 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` 不消费会触发 unused warning
    let bytes = resp.reader.read_all() catch { _ => b"" }
    println(bytes.text() catch { _ => "<not utf8>" })
    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. 递归删除非空目录:`remove_dir` 只删空目录,`remove_dir_recur` 先删光
    // 内容再删目录本身(对应上游的 RemoveDirRecur)
    @ftp.make_dir(client, "/tree")
    @ftp.make_dir(client, "/tree/sub")
    let nested = @io.MemoryReader(w => w.write("nested"))
    defer nested.close()
    @ftp.stor(client, "/tree/sub/f.txt", nested)
    @ftp.remove_dir_recur(client, "/tree")

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

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

    // 9. 退出:发 QUIT 并标记为已关闭(底层 socket 的处理见下方说明)
    @ftp.quit(client)
    }
    ignore(demo)
    }

    这个代码块用 moonbit check 标注,因此它真的会被 moon check 类型检查, 不是只做语法高亮:把它单独跑一遍 moon check --target native --deny-warn 也是绿的。 写法上按 --deny-warn 的标准收紧了尾巴:

    • 例子里是 async fn demo() -> Unit 而不是 async fn main:main 只允许出现在 可执行包里(pkgtype(kind: "executable")),写进 README 这种普通包会报 Unexpected main function in the non-main package,所以用 test { ... } 包一层、 换个名字,语义不变。
    • main / demo 的返回类型必须是 Unit,标注成别的反而报错。
    • 读到的内容要被 println 接住,let bytes 不消费会触发 unused warning。
    • 上传的 @io.MemoryReader 绑到变量再 defer close():它的后台生产者只能这样停, 传输失败时没人关它,事件循环会以死锁 panic 收场。

    要真的跑起来,把同样的代码放进 cmd/example/main.mbt 那种可执行包里 (moon.pkg 声明 pkgtype(kind: "executable") + supported_targets = "+native"), 用 async fn main 去掉外层 test { } 即可 moon run --target native。

    #常用函数

    下面这些函数都是 async,调用方需处在 @async 运行时中(async fn main 或 test { } 块内);walk 本身是普通函数,但它的 Walker::next 是 async。

    函数作用
    dial(addr, ..)建连,可选 explicit_tls / tls / timeout_ms / trust_pasv_ip 等参数
    login(client, user, pass)USER/PASS 登录,随后按上游顺序协商:FEAT → TYPE I → OPTS UTF8 ON(能力与开关都满足时)→ PBSZ 0/PROT P(TLS 时)
    list(client, path)列目录,返回 Array[Entry](MLSD / LIST 自动回退)
    name_list(client, path)NLST,只拿名字,返回 Array[String]
    get_entry(client, path)MLST 取单个条目的 facts
    retr / retr_from(client, path, offset)下载 / 断点续传下载,返回 DataResponse(read 后必须 close)
    stor / append(client, path, reader)上传 / 追加,reader 由调用方负责 close
    file_size(client, path)SIZE
    get_time(client, path) / set_file_time(client, path, time)MDTM 读 / 写时间戳
    make_dir / remove_dir / remove_dir_recur / delete / rename / change_dir / change_dir_to_parent / current_dir目录与文件管理(remove_dir_recur 递归删除非空目录)
    walk(client, root)普通函数,拿到 Walker;while w.next() { ... } 深度优先遍历,next() 是 async 且失败时不抛错而是返回 false(原因读 w.err())
    no_op / logout / quit(client)NOOP / REIN / QUIT(quit 发 QUIT 并标记为已关闭;socket 不做显式 close,见下方"连接与关闭语义")

    错误以 FtpError 抛出,可用 catch / try! 统一处理;多次失败会被聚合成 FtpErrors::MultipleErrors 一起报,而不是遇到第一个就吞掉。list / walk 解析不出的行会被跳过而非中断。

    #连接与关闭语义

    quit 发 QUIT,然后把客户端标记为已关闭(之后再调 quit 是空操作,is_closed() 返回 true)。 它不关闭底层 socket:Control 没有 close 方法,也没有任何路径调用 @socket.Tcp::close。 关闭由两端各自完成——服务端收到 QUIT 后回包并关闭它那一侧,进程退出时本地的句柄随之释放。

    这与上游 Go 版不同:上游 Quit 会显式 c.conn.Close(),再把这条关闭错误与命令错误一起 errors.Join。本仓库删掉了那一步,所以 quit 的错误列表里最多只有 QUIT 命令本身的一个错误, "聚合"是上游 errors.Join 的形状,不是当下的必要性。因此也不要指望 quit 之后 socket 立刻 读到一个 EOF——那要靠服务端关闭它那一侧。

    与之互补的是 logout(REIN):它丢弃登录态但保留控制连接,可以继续在这条连接上 USER/PASS。

    #开发

    #依赖安装

    除 MoonBit 工具链外,本仓库的 moon 命令与自测脚本还有这些外部依赖,装不齐会在 完全无关的步骤上报错,所以先装好:

    依赖谁需要它安装(Debian / Ubuntu)
    gcc + libc6-dev所有 --target native 的编译与链接。native 后端没有 C 工具链连不出可执行文件,moon.pkg 因此声明 preferred_target = "native"sudo apt-get install -y gcc libc6-dev
    opensslFTPS:scripts/ftps/gen-cert.sh 每次签发自签名 CA + 叶证书,scripts/ftps/probe-ftps.sh 用 s_client 做就绪握手sudo apt-get install -y openssl
    python3scripts/ 下所有 *-selftest.py 无 Docker 自测sudo apt-get install -y python3
    docker仅"本地端到端"那一层需要(拉 jmoyer/vsftpd / bfren/ftps 镜像)。不需要跑真实服务器测试时可以完全不装见 Docker 官方安装

    MoonBit 工具链本身:

    curl -fsSL https://cli.moonbitlang.cn/install/unix.sh | bash export PATH="$HOME/.moon/bin:$PATH"

    moon test --target native 还有一个仓库内的前置条件:testdata/ftp/tls 里的 证书必须先由 scripts/ftps/gen-cert.sh testdata/ftp/tls 生成。有一条零字节 TLS 上传的 白测要讲服务端 TLS,只能从磁盘上的证书构造。没生成的话失败信息是 transfer_wbtest.mbt:386 FAILED: stor_from: Cancelled——一个任务组的取消,读起来像库 的 bug,而真正原因只是没人签发过证书。scripts/ci.sh 因此在任何测试之前先跑 gen-cert.sh,并用 tls-fixture-selftest.py 断言这一步真的发生过。

    #常用命令

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

    moon update # 首次 / 依赖变动后:拉取 registry 索引 moon fmt --check # 格式检查 moon check --target native --deny-warn # 类型检查,要求 0 warning moon test --target native --deny-warn # 单元测试(含 --enable-coverage 与覆盖率报告) moon build --target native # 构建 moon build --target native --release # release 构建 moon info # 更新生成接口(.mbti)

    提交前请确认下面三条均通过(与 scripts/ci.sh 用的是同一条命令,不是近似版):

    moon fmt --check moon check --target native --deny-warn moon test --target native --deny-warn

    moon test 前面记得先 scripts/ftps/gen-cert.sh testdata/ftp/tls(见上文"依赖安装")。 想省事可以直接 bash scripts/ci.sh,它按同样顺序跑完 check / test / build / 真实服务器 演示,并在失败时导出容器日志。

    #测试分层

    层命令覆盖
    纯逻辑单测moon test --target native --deny-warn四种列表解析、状态码、路径 join、控制通道帧解析
    明文端到端scripts/start-ftp.sh + cmd/example/run.sh真实 vsftpd 上的 dial / login / list / retr / stor / rename / mkdir / walk
    能力画像端到端cmd/profiles/run.sh <画像>同一个 vsftpd 上四个客户端画像(full / no-mlst / no-time / no-epsv):FEAT、MLSD/LIST、EPSV/PASV、STOR/RETR/NLST、MDTM 读+写、MKD/RMD/RemoveDirRecur。CI 逐个画像各调一次(每次前面插一次 scripts/ensure-ftp.sh 复检容器)
    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/ 下的 *-selftest.py拉镜像之前就跑:TLS fixture 已签发、FTPS 就绪探测、cmd/ftps(含 STOR 被拒回归)、cmd/profiles 的协议选择断言、两个容器不共用 fixture、cmd/ftp/run.sh 不拼 flag、start-ftp.sh 传给镜像的 10 个环境变量非空、ensure-ftp.sh 的复检三分支、明文探测 probe-ftp.py(含每次会话以 QUIT 收尾),以及 ensure-ftp.sh 在 ci.sh 中的位置

    #本地端到端(需要 docker)

    scripts/start-ftp.sh # 明文 vsftpd,127.0.0.1:21 cmd/example/run.sh # 明文演示:dial / login / list / retr / stor / rename / mkdir / walk cmd/profiles/run.sh # 四个能力画像,都打这个容器(也可 cmd/profiles/run.sh no-epsv 只跑一个) 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

    这一层还有一条硬性约定:scripts/ftps/gen-cert.sh 每次重新签发证书并写进 testdata/ftp/tls(gitignored),客户端用 trust=@tls.TrustedRoot::CustomPemFile(...) 注入它,证书校验始终开启——不要为了跑通把它关掉。

    不需要 Docker 也能跑其中一层:scripts/profiles/ftp_profile_mock.py 是一个带真实文件系统的 FTP mock,scripts/profiles/cmd-profiles-selftest.py 用它驱动真的 cmd/profiles,并逐条核对 每个画像实际发出的命令(LIST 还是 MLSD、PASV 还是 EPSV、有没有写时间戳)—— 这些在真实服务器上看不出来,因为它两种被动命令都支持、两种列表都出结果。

    mock 还必须按容器的形状回答 NLST:vsftpd 对 NLST <dir> 返回完整路径而不是裸文件名 (RFC 3659 §4.1 两者都允许),而这个差别在只跑 mock 时是隐形的——所以自测会直接向 mock 探测它的 NLST 形状,两边一旦不一致就立刻报错。

    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 ├── debug.mbt 流量日志(DebugLog / MirrorWriter / TeeLogReader) 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 用 ${VAR:-literal} 默认值补出连接 flag,再原样转发其余 argv ├── cmd/example/ 真实 vsftpd 端到端演示(明文) │ ├── main.mbt │ └── run.sh 跑 cmd/example(无参数) ├── cmd/ftps/ FTPS / 明文端到端冒烟:参数选择传输方式 │ ├── main.mbt │ └── run.sh 读 .ftp-tls.env 跑 cmd/ftps(可传 plain) ├── cmd/profiles/ 四个客户端能力画像的端到端测试(同一个容器) │ ├── main.mbt full / no-mlst / no-time / no-epsv,各自的开关与断言 │ └── run.sh 设置 FTP_TEST_* 环境默认值后跑 cmd/profiles ├── scripts/ │ ├── ci.sh CI 入口脚本(check / test / build / 演示的唯一入口) │ ├── start-ftp.sh 启动明文 vsftpd 容器(127.0.0.1:21) │ ├── probe-ftp.py 明文容器就绪探测(登录 + 一次被动 LIST) │ ├── probe-ftp-selftest.py 用 mock vsftpd 自测上面的探测(含每次会话都以 QUIT 收尾,无需 Docker) │ ├── ensure-ftp.sh 客户端之间复检明文容器,挂了就用 start-ftp.sh 重启 │ ├── ensure-ftp-selftest.py 用桩 docker 自测上面的复检(无需 Docker) │ ├── ftp-wrapper-selftest.py 断言 cmd/ftp/run.sh 只传环境默认值、不拼 flag(无需 Docker) │ ├── vsftpd-env-selftest.py 断言 start-ftp.sh 传齐镜像入口要的 10 个变量(无需 Docker) │ ├── stop-ftp.sh 导出明文容器日志并清理 │ ├── ftps/ FTPS(显式 AUTH TLS)容器 │ │ ├── gen-cert.sh 自签名 CA + 叶证书(每次重新签发,写进 testdata/ftp/tls) │ │ ├── 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 │ │ ├── tls-fixture-selftest.py 断言 moon test 要的证书在跑之前已被签发(无需 Docker) │ │ ├── start-ftps.sh 启动 FTPS 容器(127.0.0.1:2121)并写 .ftp-tls.env │ │ └── stop-ftps.sh 导出容器日志并清理 │ └── profiles/ cmd/profiles 的无 Docker 自测 │ ├── ftp_profile_mock.py 带真实文件系统的 FTP mock │ └── cmd-profiles-selftest.py 驱动真程序并核对每个画像发出的命令 ├── testdata/ftp/ 测试与演示使用的 fixture │ └── tls/ gen-cert.sh 每次现签的 CA + 叶证书(gitignored, │ `moon test` 的前置条件,见"开发 → 依赖安装") ├── docs/ 移植方案(porting.md)与设计文档(proposal.md) ├── moon.pkg 根包清单 └── moon.mod 模块定义

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

    probe-ftps-selftest.py 的用法:python3 scripts/ftps/probe-ftps-selftest.py PROBE [CA_FILE]。 PROBE 是被测的探测脚本(scripts/ftps/probe-ftps.sh),CA_FILE 可选,默认用自测现场 现签的那张 CA(正是 mock 出示的证书的签发者)。 第一个参数是脚本路径,不是仓库根目录——把 $PWD 之类的路径传进去,它会被当成 PROBE, 于是报出 bash /gen-cert.sh: exit 127 这种与真实原因无关的错。scripts/ci.sh 只传 PROBE。

    #致谢与来源

    本项目为 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
    debug : DebugLog?
    }

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

    fn Control::debug_enabled(self : Control) -> Bool

    Whether the traffic log is on.

    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, debug? : DebugLog?) -> 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::set_debug

    fn Control::set_debug(self : Control, debug : DebugLog?) -> Unit

    Install (or clear) the traffic log on an already built connection.

    Separate from Control::new so dial can attach the sink the caller asked for without every other call site growing the label.

    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.

    The call is idempotent (self.tls is None is the guard), because stor_from calls it again for a zero-byte upload -- the one case where nothing else would have driven the handshake. See that call site.

    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.

    DebugDirection

    pub(all) enum DebugDirection {
    Command
    Reply
    }

    One direction of the traffic log.

    DebugLog

    pub(all) struct DebugLog {
    sink : &
    Writer

    prefix : Bool
    }

    The traffic log: a direction tagged, CRLF terminated line sink.

    This is the MoonBit equivalent of upstream debug.go's TeeReader / MultiWriter pair, assembled into one object because a single transcript is the point -- two untagged writers produce exactly the log you cannot debug with. Upstream derives the direction implicitly, by teeing the socket's read side and fanning out its write side; here the direction is an explicit tag, so the record stays self describing even if a caller routes the two directions into different sinks.

    The two decorators are MirrorWriter (write side, the MultiWriter half) and TeeLogReader (read side); Control routes both of its directions through them, so the transcript and the protocol see the same bytes.

    DebugLog::new

    fn DebugLog::new(sink : &
    Writer
    , prefix? : Bool) -> DebugLog

    Build a traffic log over sink, tagging each line with its direction.

    DebugLog::render

    fn DebugLog::render(self : DebugLog, direction : DebugDirection, line : String) -> String

    Render one log record as it goes onto the wire.

    DebugLog::write_line

    async fn DebugLog::write_line(self : DebugLog, direction : DebugDirection, line : String) -> Unit

    Append one captured line, tagged with its direction.

    Writer::write already loops over short writes and a BytesView is one of its @io.Data payloads, so the record goes out whole or the sink raises.

    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

    debug_sink : &
    Writer
    ?
    now_ms : () -> UInt64
    }

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

    The traffic log sink, if one was installed.

    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_debug_sink

    fn DialOptions::set_debug_sink(self : DialOptions, sink : &
    Writer
    ?) -> Unit

    Route a log of every control channel line -- commands out, replies back -- into sink, the MoonBit counterpart of upstream DialWithDebugOutput.

    Off by default: with no sink installed dial installs no logging layer at all, so the hot path is a None check and the bytes on the wire are unchanged. sink = None turns it back off.

    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_now_ms

    fn DialOptions::set_now_ms(self : DialOptions, value : () -> UInt64) -> Unit

    Replace the clock backing FTPClient::now.

    The default reads the system time on every list(); a test injects a fixed clock to pin the half-year rule's reference instant. Go's epoch fallback is deliberately not an option here — see FTPClient::now.

    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>.

    writing_mdtm is part of the answer, not a detail on top of it: a server that only advertises MDTM reads timestamps through it and writes them only when the caller turned the VsFtpd quirk on, so without this flag the query would report a capability set_file_time then refuses with 502. Upstream computes the same expression (MFMT || (MDTM && writing_mdtm)).

    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.

    MirrorWriter

    pub struct MirrorWriter {
    inner : &
    Writer

    log : DebugLog
    }

    The MultiWriter half of upstream debug.go: every byte written through this writer goes to inner first and is then mirrored into log.

    Control::send_line builds one per call instead of decorating its writer once, for a reason debug.go's shape hides: Control::upgrade replaces the reader/writer pair wholesale after AUTH TLS, so a decorator installed at dial time would keep logging the plaintext socket and stop covering the TLS session every later command travels over. Decorating per call keeps the two in step, and costs one Option match when logging is off.

    A sink that raises does not fail the write: the decorator swallows the error. Losing a log line is survivable, losing the session is not.

    MirrorWriter::new

    Wrap inner so every write is also recorded as a command line.

    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.

    TeeLogReader

    pub struct TeeLogReader {
    inner : &
    Reader

    log : DebugLog
    }

    The TeeReader half of upstream debug.go: everything read through this reader is mirrored into log before being handed to the caller.

    The producer half of @io.Reader cannot be delegated by hand -- only moonbitlang/async/io can mint a ReaderBuffer -- so Control keeps its own reader and logs each line it parsed. This type is for the callers that hand a reader somewhere Control is not: a data connection, or a PRET preview. _direct_read is the one method that can be delegated, and it is where the bytes are.

    TeeLogReader::new

    Wrap inner so everything read through it is also recorded as a reply line.

    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
    , debug_sink? : &
    Writer
    ?, now_ms? : () -> UInt64) -> 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 command for command: USER -> 331 -> PASS -> 230, then FEAT, then TYPE I, then optionally OPTS UTF8 ON, then PBSZ 0 / PROT P for either TLS flavour.

    The FEAT / TYPE / OPTS 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 mark the connection as closed.

    The socket is not closed here: Control has no close, and no path in this module calls @socket.Tcp::close (see the "connection and shutdown semantics" note in README.mbt.md). Upstream's Quit closes c.conn explicitly and joins that error with the command's, which is why the shape below -- collect into errors, then finish_quit -- survives even though only the command can fail. Sending QUIT therefore does not make the next read observe EOF; the server closing its side and this process releasing the handle do.

    A second call is a no-op, visible through is_closed.

    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.

    remove_dir_recur

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

    Delete a non-empty directory recursively, the port of upstream RemoveDirRecur.

    The sequence is upstream's, and it is a cd-in / cd ..-out dance rather than a walk of the tree, because a plain FTP server has no way to name a child relative to a directory the client is not currently in: every LIST / RMD argument is resolved against the session's working directory.

    1. CWD path, then PWD to learn the absolute name the server assigned — not the caller's string, which may be relative or a symlink.
    2. LIST that absolute path and recurse into every folder child, DELE every file child. . and .. are skipped.
    3. CDUP out, then RMD the directory itself — bottom up, so a directory is only removed once it is empty.

    The client lock is deliberately not held across the recursion: list / delete / remove_dir each take it themselves and the lock is not reentrant, so wrapping the body would deadlock on the first child.

    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

    The second spelling of 450. See the note in the file header: upstream calls this one StatusFileActionIgnored.

    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.

    The numeric values are kept 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. The names are this file's own spelling, not upstream's: upstream has one StatusFileActionIgnored for 450, where this file carries both status_file_action_not_taken and status_action_not_taken, because the two are read in different places and renaming either call site for a constant the client only compares by value would only hide which is which. Nothing here is matched by name across the module boundary, so a spelling difference cannot become a behaviour difference.

    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.

    The wording follows the RFC rather than upstream status.go: where upstream trimmed a sentence down (226 as "Closing data connection." versus its "Closing data connection. Requested file action successful."), the RFC wording is kept, so the text a user reads is the one the code means. 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.

    See DataConn::start_tls for why the trigger is a real call here rather than a poke at a session that already exists: Go's tls.Conn handshakes lazily, @tls.Tls::client handshakes while it is being built.

    walk

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

    Walk the directory tree rooted at root.