ftp

    A pure MoonBit FTP client library ported from jlaffaye/ftp, supporting passive mode, MLSD/LIST parsing, resume and FTPS.

    ftp
    network
    client
    protocol
    async
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    19 hours ago
    Downloads
    2

    Dependencies

    #PaiGack/ftp

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

    支持被动模式、MLSD / LIST 四种列表格式解析、断点续传与 FTPS(显式 / 隐式 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

    #开发

    所有 moon 命令都显式带上 --target nativepreferred_target 已是 native wasm / wasm-gc 后端提供不了真实网络栈。

    moon fmt --check # 格式化检查 moon check --target native --deny-warn # 类型检查,0 warning 0 error moon test --target native # 运行测试(真机用例默认跳过) moon build --target native # 构建 moon run cmd/ftp # 运行 CLI(打印 usage) moon info # 更新生成接口(.mbti)

    #真实 FTP 服务器测试

    ftp_server_test.mbt 的端到端用例跑在真实的 vsftpd 上,需要显式设置环境变量才会 执行(不设时用例直接 return,本机 moon test 保持全绿)。服务器由公共脚本 scripts/start-ftp.sh 以 Docker 起,四个「能力画像」各一个容器:

    scripts/start-ftp.sh # 起 full / no-mlst / no-time / no-epsv 四个容器 export FTP_TEST_HOST=127.0.0.1 FTP_TEST_PORT=2121 FTP_TEST_USER=test FTP_TEST_PASS=test FTP_TEST_FIXTURE=/fixture FTP_TEST_DIR=/upload FTP_TEST_PORT_NO_MLST=2122 FTP_TEST_PORT_NO_TIME=2123 FTP_TEST_PORT_NO_EPSV=2124 moon test --target native scripts/stop-ftp.sh # 打日志并清理容器

    GitHub Actions、CNB 流水线与 CNB 云原生开发环境调用的都是同一份脚本 scripts/start-ftp.sh / scripts/stop-ftp.sh),镜像为 jmoyer/vsftpd(vsftpd 3.0.5),fixture 在 testdata/ftp/fixture/ 详见 docs/porting/05-testing.md 第 4 节。

    #公共脚本

    跨 CI 复用、需要跟平台无关的脚本统一放在 scripts/

    脚本作用
    scripts/start-ftp.sh起四个真实 FTP 服务器容器并轮询端口,起不来直接 exit 1
    scripts/stop-ftp.sh打印每个容器的日志并删除,从不失败

    #目录结构

    所有源码直接平铺在仓库根目录,没有包目录层级。原先的 types/ client/ parse/ 这些目录全部展开成了同级的 .mbt 文件,引用也从 @types.Entry 变成直接的 Entry (都在同一个包 PaiGack/ftp 里)。

    . ├── entry.mbt Entry / EntryType / TransferType 纯逻辑 ├── status.mbt RFC 959 状态码与常量 + status_text() 纯逻辑 ├── error.mbt FtpError / FtpErrors 纯逻辑 ├── parse.mbt RFC3659 / 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 │ + SIZE / MDTM / MFMT ├── dial.mbt dial / 登录 / FEAT 能力协商 IO ├── commands.mbt CWD / PWD / MKD / RMD / DELE / RNFR+RNTO IO │ / NOOP / REIN / QUIT ├── list.mbt / transfer.mbt / walker.mbt │ 列表、传输、目录树遍历 IO ├── cmd/ftp/ CLI 示例:ls / get / put / walk / mkdir / rm ├── scripts/ 跨 CI 复用的公共脚本 │ ├── start-ftp.sh 起四个真实 FTP 服务器容器(CNB / GitHub 共用) │ └── stop-ftp.sh 打容器日志并清理 ├── testdata/ftp/ 测试数据:fixture 与 vsftpd 配置模板 ├── .cnb.yml / .github/workflows/ CNB 与 GitHub 两条流水线(调同一份 scripts/) ├── moon.pkg 根包清单(唯一的源码包) └── moon.mod 模块根

    根包只有一个 moon.pkg,它的普通 import 块里带着 moonbitlang/async:纯逻辑与 IO 源码同属一个包,MoonBit 目前也没有「按文件限定 import」的语法。分层约束因此落在每个 源文件头部的 // Layer: pure logic / // Layer: IO 标记上,目录树按层分组列出文件名。

    依赖方向单向、禁止反向,详见 docs/porting/01-architecture.md

    #文档

    • docs/porting.md — 移植总体方案
    • docs/porting/ — 实施文档集(架构、上游映射、工作包、API 映射、测试、兼容清单、风险、验收)

    #致谢与来源

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

    #许可证

    Apache-2.0,见 LICENSE

    FtpError

    pub(all) suberror FtpError {
    ServerError(code~ : Int, msg~ : String)
    InvalidCommand(arg~ : String)
    UnsupportedListLine(line~ : String)
    UnsupportedListDate(field~ : String)
    ParseError(msg~ : String)
    } 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).

    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.

    Control

    pub struct Control {
    reader : &
    Reader

    writer : &
    Writer

    host : String
    }

    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

    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.

    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

    pub struct DataConn {
    reader : &
    Reader

    writer : &
    Writer

    closer : () -> Unit
    }

    The data connection used for one transfer.

    DataConn::close

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

    Close the data connection without touching the control channel.

    DataConn::reader

    The readable end of the data connection.

    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.

    EntryType

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

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

    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.

    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/04-api-mapping.md.

    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.

    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::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::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 five steps are exactly upstream's:
    1. initialise cur to a synthetic folder entry for the root,
    2. list cur when descend is set, 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.

    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,
    3. REST <offset> when a resume offset was given, expecting 350,
    4. send the transfer command, expecting 125 or 150,
    5. on a non 2xx answer, close the data connection before raising.

    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 require one of the expected codes. Convenience wrapper mirroring upstream cmd(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/04-api-mapping.md.

    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.

    With TLS the socket is connected but the handshake is deferred to the first read/write: ProFTPD and PureFTPD both refuse a data connection that starts its handshake before the transfer command was answered.

    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/02-upstream-map.md.

    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.

    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.