-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
public interface IFtpFileHub : IFileHub { }
public sealed class FtpFileHub : IFtpFileHub, IDisposable { ... }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
|
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.)
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.
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("...");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.
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.
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()anddir.Delete(name)on a missing target succeed silently. FTP previously threwFileNotFoundExceptionon a missing object; it no longer does. -
Recursive defaults to
false.Delete()/Delete(name)refuse to remove a non-empty directory — that throwsDirectoryNotEmptyException. Passrecursive: trueto delete the subtree.
-
GetReadStream*streams theRETRdata channel directly — no full buffering. -
GetWriteStream*streams intoSTOR; 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.
CanSeekisfalse, and both the getter and setter ofPositionthrowNotSupportedException. - On a write stream
Lengthreports 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.
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 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 BCLRemoteCertificateValidationCallback, not a FluentFTP type) to accept a self-signed certificate in development. - The convenience factories (
Connect/FromCredentials(host, ...)) remain plain FTP; useCreate(FtpHubOptions)for TLS. Advanced TLS tuning is still possible by configuring anAsyncFtpClientyourself and passing it viaFtpHubOptions.FromClient.
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.
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.
| 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(). |
FileHub — unified file & directory API for .NET. Core is dependency-free; drivers are opt-in packages.