Skip to content

Driver Ftp

Gustavo Viana edited this page Aug 7, 2026 · 9 revisions

Driver: FTP

Driver for FTP and FTPS servers. Ships in the FileHub.Ftp package, backed by FluentFTP. A hub instance is scoped to a single FTP connection; an optional root path narrows visibility to a subdirectory on the server.

using FileHub.Ftp;

using var hub = FtpFileHub.Create(FtpHubOptions.FromCredentials(
    host:     "ftp.example.com",
    user:     "svc",
    password: "s3cret",
    rootPath: "/uploads/2026"));

Always using or register as a singleton — the hub owns the FTP control connection and disposes it.

Construction

A single entry point — FtpFileHub.Create(FtpHubOptions) and its async sibling CreateAsync(FtpHubOptions, CancellationToken) — so there is no per-style sync/async factory sprawl. Sync delegates to async at the boundary; prefer CreateAsync under a SynchronizationContext (UI, ASP.NET classic) to avoid blocking. All connection, root, and FTPS settings live on FtpHubOptions:

FtpHubOptions factory Use
FromCredentials(host, port?, user?, password?, rootPath?, encryption?, certificateValidation?) Fresh connection with inline credentials. Default port 21, default user anonymous. A NetworkCredential overload exists for secrets from a store / DI.
FromClient(AsyncFtpClient, ownsClient?, rootPath?) Reuse an externally-configured FluentFTP client. ownsClient defaults to false — caller keeps ownership, hub disposal is a no-op on the client. Pass true to transfer ownership.
new FtpHubOptions { ... } Object initializer for advanced cases.

Nested directory paths ("a/b/c") resolve in a single round-trip: one recursive MKD on create, one existence probe on open.

var hub = await FtpFileHub.CreateAsync(FtpHubOptions.FromCredentials(
    host:     "ftp.example.com",
    port:     21,
    user:     credentials.UserName,
    password: credentials.Password,
    rootPath: "/uploads"));

If rootPath is not /, Create ensures the directory exists on the server (creating it recursively if missing) before returning.

Interface

public interface IFtpFileHub : IFileHub { }
public sealed class FtpFileHub : IFtpFileHub, IDisposable { ... }

Server → filesystem mapping

Unlike object storage, FTP is already hierarchical — the driver is a thin mapper over CWD / LIST / MKD / RNFR-RNTO / RETR / STOR / DELE.

Concept Mapping
File path Absolute path on the server, /-separated
Directory Real server-side directory
Listing LIST (via FluentFTP's GetListing) filtered to files or dirs
Sandbox Every path is verified against the configured rootPath; names outside it throw FileHubException

Nested directory creation

CreateDirectory("a/b/c") materialises every missing intermediate. FTP has no atomic MKD -p, so the driver issues one MKD per segment (a, a/b, a/b/c), skipping segments that already exist. TryOpenDirectory("a/b/c") probes the leaf. Path-traversal guards (.., ., absolute paths) always apply.

(The 1.x DirectoryPathMode — OpenIntermediates / Direct — was removed in 2.0; nested paths now always create intermediates in the fewest operations the backend allows. See Migrating to 2.0.)

Existence probes

FTP separates file and directory checks into two specific calls:

hub.Root.FileExists("report.pdf");        // 1 stat (FluentFTP FileExists)
hub.Root.DirectoryExists("uploads");      // 1 stat (FluentFTP DirectoryExists)

Each is a single round-trip.

Nested paths in CreateFile / OpenFile / TryOpenFile

CreateFile("a/b/c.txt") (and OpenFile / TryOpenFile) creates the file at the nested location, auto-creating intermediate directories with MKD per segment. Both / and \ separators work; .. is rejected with FileHubException.

hub.Root.CreateFile("uploads/2026/q1.pdf").SetText("...");

Metadata refresh

FtpFile and FtpDirectory implement IRefreshable. Property getters return cached values and never do hidden I/O — call Refresh() / RefreshAsync() explicitly when you need fresh data from the server.

var file = hub.Root.OpenFile("data.csv");

// Stale — whatever was known at construction / last write.
var known = file.Length;

// Round-trip the server to re-sync.
await ((IRefreshable)file).RefreshAsync(ct);

Write through this driver updates the cached length as bytes stream, so the common SetText → file.Length flow works without an explicit refresh.

MoveTo semantics

file.MoveTo(dir, name) / dir.MoveTo(parent, name) uses the FTP RNFR/RNTO command when source and destination share the same connection — fast, atomic on most servers. The native RNFR/RNTO rename fast path is only taken when the destination does not exist; against an existing destination it throws FileAlreadyExistsException unless overwrite: true (which merges via copy + delete for a directory). Across two hubs (different connections / servers) the driver falls back to stream copy + delete. Cross-driver moves (e.g. FTP → Local) always use stream copy.

FTP has no server-side copy. CopyTo between different connections streams bytes through directly. On the same connection — which supports only one data transfer at a time — the copy spills through a temporary file on local disk and runs sequentially (download fully, close the channel, then upload); both legs stream in chunks, so memory usage is constant regardless of file size.

Overwrite. CopyTo / MoveTo default to overwrite: false: the driver checks first and throws FileAlreadyExistsException when the destination exists, leaving the source intact (many servers reject RNTO onto an existing path anyway, but the explicit check makes the failure a clear library exception). A file never overwrites a directory. Pass overwrite: true to replace the destination. Best-effort — not atomic against a concurrent writer. Rename is leaf-only (a newName with / or \ throws ArgumentException) and never overwrites — it always throws FileAlreadyExistsException on a taken name.

Delete semantics

The FTP driver follows the same delete contract as every other backend — it is no longer the non-recursive, throw-on-missing outlier it once was:

  • Idempotent-silent. file.Delete() and dir.Delete(name) on a missing target succeed silently. FTP previously threw FileNotFoundException on a missing object; it no longer does.
  • Recursive defaults to false. Delete() / Delete(name) refuse to remove a non-empty directory — that throws DirectoryNotEmptyException. Pass recursive: true to delete the subtree.

Streams

  • GetReadStream* streams the RETR data channel directly — no full buffering.
  • GetWriteStream* streams into STOR; the byte count is tracked and cached on the file when the stream is disposed.
  • Seeking is not supported on either direction — FTP data channels are sequential. CanSeek is false, and both the getter and setter of Position throw NotSupportedException.
  • On a write stream Length reports the bytes written so far (it does not throw); on a read stream it reports the file's cached length.
  • Only one stream open per file at a time — a second concurrent open throws InvalidOperationException.

Exceptions

FluentFTP-specific exceptions never leak — they are translated at the driver boundary:

Condition Exception
Missing path (550, "not found") FileNotFoundException
Authentication failure UnauthorizedAccessException
Any other FTP command / transfer failure FtpDriverException
Upload stored fewer bytes than sent FtpTransferTruncatedException

FtpDriverException is the per-provider driver exception (mirroring AmazonS3DriverException / OracleObjectStorageDriverException). It exposes CompletionCode (the FTP reply code, e.g. "530") and Path, and derives from FileHubException : IOException, so a broad catch (IOException) or catch (FileHubException) still catches it. FtpTransferTruncatedException also derives from FileHubException.

FTPS (TLS)

FTPS is configured through FtpHubOptions and the Create factory:

using FluentFTP; // FtpEncryptionMode

using var hub = FtpFileHub.Create(FtpHubOptions.FromCredentials(
    "ftp.example.com", user: "svc", password: "s3cret",
    encryption: FtpEncryptionMode.Explicit));

encryption is FluentFTP's FtpEncryptionMode:

FtpEncryptionMode Meaning
None Plain FTP (default)
Explicit FTPES — connect plaintext then AUTH TLS (usually port 21)
Implicit TLS from the first byte (usually port 990)
Auto Try explicit, fall back to plaintext (convenient, not downgrade-safe)
  • Data channel is encrypted too by default (FtpHubOptions.DataConnectionEncryption).
  • Certificate validation is on by default — an untrusted or invalid server certificate fails the connection. Supply certificateValidation (a BCL RemoteCertificateValidationCallback, not a FluentFTP type) to accept a self-signed certificate in development.
  • The convenience factories (Connect / FromCredentials(host, ...)) remain plain FTP; use Create(FtpHubOptions) for TLS. Advanced TLS tuning is still possible by configuring an AsyncFtpClient yourself and passing it via FtpHubOptions.FromClient.

SFTP is a different protocol

SFTP (FTP over SSH, port 22) shares no wire-level compatibility with FTP. A separate driver (FileHub.Sftp, over SSH.NET) would be its own package.

DI

services.AddFileHub<IFtpFileHub>(sp =>
    FtpFileHub.Create(FtpHubOptions.FromCredentials(opts.Host, user: opts.User, password: opts.Password, rootPath: opts.Root)));

Register as a singleton so the FTP control connection is reused. For multiple servers use named hubs — see Dependency Injection.

Disposal

Factory Owns the FluentFTP client by default?
Connect, ConnectAsync, FromCredentials, FromCredentialsAsync Yes — always. Dispose() disconnects and disposes it.
FromClient(..., ownsClient: false) (default) No — caller owns it; hub disposal is a no-op on it.
FromClient(..., ownsClient: true) Yes — hub disposes the client on its own Dispose().

Clone this wiki locally