FTP

Crate hopf-ftp: FTP / FTPS server and callback-driven client, a port of Gumdrop org.bluezoo.gumdrop.ftp. The server runs on the Hopf thread-per-core reactor; data connections use dynamic PASV listeners on a shared Arc<Runtime>. TLS is provided by hopf-core::tls (in-tree, on AWS-LC). Authentication uses hopf-auth TrustPolicy.

Standards

Reference Role in Hopf
RFC 959 Base FTP: control commands, TYPE/STRU/MODE, NVFS concepts
RFC 2428 EPRT / EPSV (IPv6-capable active/passive)
RFC 2389 FEAT / OPTS
RFC 2640 OPTS UTF8 ON/OFF pathname charset
RFC 3659 SIZE, MDTM, MLST/MLSD (TVFS facts)
RFC 4217 AUTH TLS, PBSZ, PROT; implicit FTPS

Architecture

Client ──TCP──► FtpControlHandler (reactor)
                    │ USER/PASS → TrustPolicy
                    │ RETR/STOR/LIST → FtpFileSystem (via StorageExecutor for FS)
                    │
                    ├── PASV/EPSV → Runtime::add_tcp_listener (ephemeral data port)
                    └── PORT/EPRT → outbound data connect (bounce guard)

Supported commands

Access control

USER, PASS, ACCT (always 202), CWD / XCWD, CDUP / XCUP, QUIT, REIN

Transfer parameters

PORT, PASV, EPRT, EPSV, TYPE (A / I), STRU (F only), MODE (S only)

Service commands

RETR, STOR, STOU (server-generated unique name, actually unique per call — see FtpFileSystem::generate_unique_name), APPE, ALLO (dispatched to FtpFileSystem::allocate_space; 202 by default, matching Gumdrop's own no-op default), REST, ABOR, DELE, RMD / XRMD, MKD / XMKD, PWD / XPWD, LIST, NLST, STAT, HELP, NOOP, SYST, RNFR / RNTO, SITE (dispatched to FtpConnectionHandler::handle_site_command; 502 by default)

Extensions

Command Notes
FEAT Feature negotiation
SIZE File size (RFC 3659 §4)
MDTM Modification time YYYYMMDDhhmmss UTC
MLST / MLSD Machine listings (size, modify, type, perm)
OPTS UTF8 ON/OFF Pathname encoding (RFC 2640)
AUTH TLS / AUTH SSL Upgrade control to TLS
PBSZ Protection buffer size (must be 0 before PROT)
PROT C / P Clear / protected data

Rejected / not implemented: CCC, SMNT (appropriate error replies).

Data connection modes

FEAT advertisement

When FEAT is issued, the server advertises (among others):

UTF8
SIZE
MDTM
MLST Type*;Size*;Modify*;Perm*;
MLSD
REST STREAM
EPSV
EPRT
AUTH TLS
PBSZ
PROT

Security (FTPS)

Explicit AUTH TLS

C: AUTH TLS
S: 234 AUTH TLS successful
[TLS handshake]
C: USER alice
…

Configure with FtpConfig::with_tls(acceptor) (explicit) — the control listener uses with_starttls_acceptor so the first bytes remain cleartext until AUTH TLS.

Implicit FTPS

FtpConfig::implicit_ftps() (or implicit_tls = true) expects TLS from connection establishment. Wire the listener with with_tls (TLS-from-accept). Conventionally listen on port 990 when not overridden.

Data protection

C: PBSZ 0
S: 200 PBSZ=0
C: PROT P
S: 200 Protection level set to P

Same-IP data check

Incoming PASV/EPSV data connections are checked against the control connection's remote address as they connect — a peer other than the control connection's own IP is refused outright, regardless of transfer mode or TLS. Active PORT/EPRT bounce to a non-peer address is rejected at the PORT/EPRT command itself unless allow_active_bounce is enabled.

Authentication

Stock FilesystemFtpHandler evaluates TrustPolicy with IdentityMaterial::UsernamePassword after USER/PASS.

use std::sync::Arc;
use hopf_auth::PasswordTrustPolicy;
use hopf_ftp::{FtpConfig, FtpService};
use hopf_core::{Runtime, RuntimeConfig};

let policy = PasswordTrustPolicy::default()
    .with_user("ftp", "ftp")
    .shared();
let config = FtpConfig::new("127.0.0.1:2121".parse()?, "/tmp/ftp-root".into(), policy);
let service = FtpService::new(config);
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let bound = service.start(Arc::clone(&rt))?;

Custom handlers return FtpAuthResult::{Success, NeedPassword, NeedAccount, Failed, Unavailable}. See Auth.

File system handler

Type Purpose
FtpFileSystem NVFS trait: cwd, list, retrieve, store, mkdir, …
BasicFtpFileSystem Local chroot under root
FilesystemFtpHandler TrustPolicy + BasicFtpFileSystem
FilesystemFtpHandlerFactory Stock factory for FtpService::new
TransferType Ascii / Image
FtpFileInfo / FtpFileOpResult / DirectoryChange Metadata / results

FilesystemFtpHandler::new(root, policy) and ::read_only(root, policy).

Configuration

FtpConfig

Property Type Default Notes
listen SocketAddr caller Typical cleartext :21, demo 127.0.0.1:2121
root PathBuf caller Chroot for stock FS handler
policy Arc<dyn TrustPolicy> caller USER/PASS evaluation
tls_acceptor Option<SharedTlsAcceptor> None SharedTlsAcceptor from hopf-core::tls PEM builders (acceptor_from_pem, …)
data_tls_connector Option<SharedTlsConnector> None Protects active-mode (PORT/EPRT) PROT P data connections — the server dials out, so it needs a connector, not an acceptor
data_tls_server_name Option<String> None (falls back to "ftp-client") Fixed SNI/verification name for every active-mode TLS dial
implicit_tls bool false Implicit FTPS when true
require_tls_for_data bool false Reject (522) a data connection attempted without PROT P already in effect
allow_active_bounce bool false Allow PORT ≠ control peer
pasv_advertised Option<IpAddr> None NAT public IP in PASV reply
pasv_port_min Option<u16> None PASV port range lower
pasv_port_max Option<u16> None PASV port range upper
read_only bool false Stock FS write denied

Builders: FtpConfig::new(listen, root, policy), .with_tls(acceptor), .with_data_tls_connector(connector, server_name), .implicit_ftps(), .require_data_tls().

FtpService

Method Notes
FtpService::new(config) Stock FilesystemFtpHandlerFactory
with_handler_factory(config, factory) Custom SPI
control_listener(runtime) Build TcpListenerConfig without starting
start(runtime) Bind + register; returns bound SocketAddr
metrics() FtpServerMetrics snapshot

Handler SPI

pub trait FtpConnectionHandler: Send {
    fn welcome_message(&self, meta: &FtpConnectionMetadata) -> Option<String>;
    fn authenticate(
        &mut self,
        username: &str,
        password: Option<&str>,
        account: Option<&str>,
        meta: &FtpConnectionMetadata,
    ) -> FtpAuthResult;
    fn file_system(&mut self, meta: &FtpConnectionMetadata) -> &mut dyn FtpFileSystem;
    fn is_authorized(
        &self,
        op: FtpOperation, // Read | Write | Delete | CreateDir | DeleteDir | Rename | Navigate | SiteCommand | Admin
        path: &str,
        meta: &FtpConnectionMetadata,
    ) -> bool;
    fn handle_site_command(
        &mut self,
        command: &str,
        meta: &FtpConnectionMetadata,
    ) -> FtpFileOpResult; // default: NotSupported (502)
    fn disconnected(&mut self, meta: &FtpConnectionMetadata); // default: no-op
}

pub trait FtpConnectionHandlerFactory: Send + Sync {
    fn create(&self) -> Box<dyn FtpConnectionHandler>;
}

FtpConnectionMetadata: peer, local, user, tls. is_authorized now gates CWD/CDUP (Navigate), MKD (CreateDir), RMD (DeleteDir), and RNFR/RNTO (Rename) in addition to RETR/LIST/STAT (Read), STOR/APPE/STOU (Write), and DELE (Delete); SiteCommand/Admin are available for handlers to use from within handle_site_command itself (the framework doesn't auto-gate SITE, matching Gumdrop).

Transfer progress (Gumdrop transferStarting/transferProgress/transferCompleted): transfer_starting(path, upload, size, meta) fires synchronously on the control connection before RETR/STOR/APPE/STOU open a data connection. Per-chunk and completion notifications go through a separate transfer_observer(meta) -> Option<Arc<dyn TransferObserver>> instead of more FtpConnectionHandler methods — RETR runs on a StorageExecutor thread and STOR/APPE/STOU on the data connection's own reactor thread, neither of which is the control connection's thread where the rest of the handler lives, so TransferObserver is Send + Sync and obtained once per transfer rather than called back through &mut self.

Quota (Gumdrop getQuotaManager/canStore/getQuota/recordBytesAdded/recordBytesRemoved): quota_manager() -> Option<Arc<dyn hopf_core::QuotaManager>> plus default-implemented can_store/quota/record_bytes_added/record_bytes_removed delegating to it — see Quota. STOR/APPE/STOU are rejected (552) up front if the user is already over quota; usage is recorded once the upload completes, via the quota handle carried alongside the transfer (same cross-thread reasoning as TransferObserver). The stock FilesystemFtpHandler/FilesystemFtpHandlerFactory take one via with_quota(Arc<dyn QuotaManager>).

Client

Callback-driven FtpClient on an Arc<Runtime>. DNS (when needed) and the control/data session run on worker reactors; connect returns immediately. Drive the session with an FtpPipeline — stock pipelines FtpGet (RETR) and FtpPut (STOR).

FtpClientTimeouts

Property Type Default Notes
dns duration 5s Hostname resolve budget (ignored for literal IPs)
connect duration 30s TCP connect handshake
stage duration 60s Control-channel reply per command
data duration 600s Data-transfer budget (RETR / STOR / LIST)

FtpClient

Builder Notes
FtpClient::new(host) Hostname or IP string; default port 21
.port(u16) Override control port
.credentials(user, pass) USER / PASS
.timeouts(FtpClientTimeouts) Override defaults
.prefer_epsv(bool) Prefer EPSV over PASV (default true)
.resolver(Arc<DnsResolver>) Reuse a resolver
.connect(&Arc<Runtime>, pipeline) Schedule DNS/dial; returns immediately
use std::sync::Arc;
use std::time::Duration;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_ftp::{FtpClient, FtpClientTimeouts, FtpGet};

let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let pipeline = FtpGet::new("/readme.txt", |r| {
    match r {
        Ok(bytes) => eprintln!("got {} bytes", bytes.len()),
        Err(e) => eprintln!("RETR failed: {e}"),
    }
});
FtpClient::new("127.0.0.1")
    .port(2121)
    .credentials("ftp", "ftp")
    .timeouts(FtpClientTimeouts {
        dns: Duration::from_secs(5),
        connect: Duration::from_secs(10),
        stage: Duration::from_secs(30),
        data: Duration::from_secs(120),
    })
    .connect(&rt, Box::new(pipeline))?;

FtpGet / FtpPut sequence: auth (from credentials) → TYPE I → PASV/EPSV → RETR or STOR → QUIT. Custom workflows implement FtpPipeline (start / done / failed) and issue ops via FtpSessionWrite.

Metrics

Process-local FtpServerMetrics (in hopf-ftp): connections, auth_ok / auth_fail, commands, bytes_in / bytes_out, and pasv_binds via FtpService::metrics().

OTLP/JSONL export (when FtpService::with_telemetry(&pipeline) is used): hopf_otel::FtpServerMetrics emits ftp.server.connections, ftp.server.active_connections, ftp.server.auth, ftp.server.commands, ftp.server.pasv_binds, ftp.server.transfers (direction/outcome), ftp.server.transfer.duration (ms), and ftp.server.transfer.size.

With traces enabled on the pipeline, FtpConnectionMetadata::traceparent carries the active W3C traceparent for the connection or current data transfer (RETR/STOR/LIST/MLSD). Handler SPI implementers can pass it to outbound HTTP with hopf_otel::with_traceparent. Connection duration is not duplicated on metadata — use telemetry Accept→Close timestamps.

Examples

See Cookbook: FTP.

# Cleartext stock server (user/pass ftp/ftp)
cargo run -p ftp-server -- 127.0.0.1:2121 /tmp/ftp-root

# Async client RETR
FTP_USER=ftp FTP_PASS=ftp cargo run -p ftp-get -- 127.0.0.1:2121 /hello.txt
Package Demonstrates Knobs
examples/ftp (ftp-server) Stock FS server addr (def 127.0.0.1:2121), root
examples/ftp-get Async FtpClient + FtpGet host[:port], path; FTP_USER / FTP_PASS

Limitations