// Copyright 2026 PaiGack
// Licensed under the Apache License, Version 2.0.
// Ported from jlaffaye/ftp (ISC License), see LICENSE-THIRD-PARTY.
// Layer: IO — uses the `moonbitlang/async` stack from the root moon.pkg.
///|
/// 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.
pub struct DataResponse {
reader : &@io.Reader
/// Set once `close()` ran, so a second call is a no-op.
mut closed : Bool
session : Session
/// Pushes the control connection deadline before the `226` read when the
/// caller configured `shut_timeout_ms`.
shut_timeout_ms : Int
}
///|
/// Read up to `max_len` bytes of the transfer.
pub async fn DataResponse::read(
self : DataResponse,
dst : FixedArray[Byte],
offset? : Int = 0,
max_len? : Int = dst.length() - offset,
) -> Int {
self.reader.read(dst, offset~, max_len~)
}
///|
/// 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.
pub async fn DataResponse::close(self : DataResponse) -> Unit {
guard !self.closed else { return }
self.closed = true
if self.shut_timeout_ms > 0 {
ignore(
@async.with_timeout(self.shut_timeout_ms, async fn() {
check_data_shut(self.session) catch {
_ => ()
}
}),
)
} else {
check_data_shut(self.session)
}
}
///|
/// Read the `226` (or `250`) that terminates a transfer. Must always run
/// before the next command is sent.
pub async fn check_data_shut(session : Session) -> Unit {
// The wrap-up reply is *read*, never requested: sending an empty command
// here (`cmd_expect(control, "", ...)`) puts a stray CRLF on the wire and
// real servers answer `500 Command "" not understood.`, leaving the session
// one reply behind.
let response = read_response(session.control())
guard response.code() == status_closing_data_connection ||
response.code() == status_file_action_ok else {
raise FtpError::ServerError(code=response.code(), msg=response.message())
}
}
///|
/// `RETR `, returning a readable `DataResponse`.
pub async fn retr(client : FTPClient, path : String) -> DataResponse {
retr_from(client, path, offset=0)
}
///|
/// `RETR ` after `REST `, i.e. a resumed download.
pub async fn retr_from(
client : FTPClient,
path : String,
offset? : Int64 = 0,
) -> DataResponse {
client.lock()
defer client.unlock()
let conn = cmd_data_conn_from(client.session, offset, "RETR {}", [path])
{
reader: conn.reader(),
closed: false,
session: client.session,
shut_timeout_ms: client.options.shut_timeout_ms,
}
}
///|
/// `STOR `, uploading from `source`.
pub async fn stor(
client : FTPClient,
path : String,
source : &@io.Reader,
) -> Unit {
stor_from(client, path, source, offset=0)
}
///|
/// `STOR ` after `REST `, 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.
pub async fn stor_from(
client : FTPClient,
path : String,
source : &@io.Reader,
offset? : Int64 = 0,
) -> Unit {
client.lock()
defer client.unlock()
let conn = cmd_data_conn_from(client.session, offset, "STOR {}", [path])
let errors : Array[Error] = []
let written = stream(source, conn.writer()) catch {
err => {
errors.push(err)
0
}
}
if written == 0 && client.options.tls {
ignore(written)
}
conn.close()
check_data_shut(client.session) catch {
err => errors.push(err)
}
match join_errors(errors) {
Some(err) => raise err
None => ()
}
}
///|
/// `APPE `, appending a file.
pub async fn append(
client : FTPClient,
path : String,
source : &@io.Reader,
) -> Unit {
client.lock()
defer client.unlock()
let conn = cmd_data_conn_from(client.session, 0, "APPE {}", [path])
let errors : Array[Error] = []
ignore(
stream(source, conn.writer()) catch {
err => {
errors.push(err)
0
}
},
)
conn.close()
check_data_shut(client.session) catch {
err => errors.push(err)
}
match join_errors(errors) {
Some(err) => raise err
None => ()
}
}
///|
/// Copy everything from `source` to `sink`, returning the number of bytes
/// written.
async fn stream(source : &@io.Reader, sink : &@io.Writer) -> Int {
let mut total = 0
while source.read_some() is Some(chunk) {
sink.write(chunk)
total += chunk.length()
}
total
}