CodeLogic.Storage 4.8.93

This package has a SemVer 2.0.0 package version: 4.8.93+d843c0e.
dotnet add package CodeLogic.Storage --version 4.8.93
                    
NuGet\Install-Package CodeLogic.Storage -Version 4.8.93
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="CodeLogic.Storage" Version="4.8.93" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CodeLogic.Storage" Version="4.8.93" />
                    
Directory.Packages.props
<PackageReference Include="CodeLogic.Storage" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add CodeLogic.Storage --version 4.8.93
                    
#r "nuget: CodeLogic.Storage, 4.8.93"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package CodeLogic.Storage@4.8.93
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=CodeLogic.Storage&version=4.8.93
                    
Install as a Cake Addin
#tool nuget:?package=CodeLogic.Storage&version=4.8.93
                    
Install as a Cake Tool

CodeLogic.Storage

NuGet License: MIT

Provider-neutral, root-scoped storage for CodeLogic 4 and .NET 10. One API mounts local/UNC, S3-compatible, FTP/FTPS, SFTP, WebDAV, Azure Blob, Google Cloud Storage, and OpenStack Swift connections.

Install and load

dotnet add package CodeLogic.Storage
using CL.Storage;

await Libraries.LoadAsync<StorageLibrary>();
await CodeLogic.ConfigureAsync();
await CodeLogic.StartAsync();

var storage = Libraries.Get<StorageLibrary>();
IStorageService media = storage.GetStorage("media");

Every connection mounts exactly one local root, bucket/container prefix, or remote directory. Paths passed to IStorageService are relative slash-separated paths below that mount; rooted paths and .. escapes are rejected.

Providers and configuration

Provider connections live in typed, case-insensitive Connections dictionaries. Connection IDs must be unique across all sections.

Configuration section Connection model Mounted resource
storage.local LocalConnectionConfig local directory or UNC share
storage.s3 S3ConnectionConfig bucket plus optional prefix
storage.ftp FtpConnectionConfig FTP/FTPS directory
storage.sftp SftpConnectionConfig SFTP directory
storage.webdav WebDavConnectionConfig WebDAV endpoint plus root
storage.azure AzureBlobConnectionConfig Blob container plus prefix
storage.gcs GoogleCloudConnectionConfig GCS bucket plus prefix
storage.swift SwiftConnectionConfig Swift container plus prefix

The storage section selects DefaultConnection, controls the byte-buffering limit, and enables bounded health probes. Example config.storage.s3.json:

{
  "Connections": {
    "media": {
      "Enabled": true,
      "Bucket": "company-media",
      "Prefix": "production",
      "Region": "eu-north-1",
      "AuthenticationMode": "DefaultCredentialChain"
    },
    "minio": {
      "Enabled": true,
      "Bucket": "documents",
      "ServiceUrl": "https://minio.example.com",
      "ForcePathStyle": true,
      "AuthenticationMode": "StaticCredentials",
      "AccessKey": "...",
      "SecretKey": "..."
    }
  }
}

Clear-text custom S3 or WebDAV endpoints require AllowInsecureHttp = true. SFTP requires at least one SHA-256 host-key fingerprint unless AutoAcceptHostKey = true is explicitly enabled. That option trusts any SSH host key and is best limited to trusted development environments. FTPS and WebDAV use normal certificate validation by default and optionally accept configured SHA-256 certificate pins; there is no accept-any switch.

SFTP authentication, host keys, and jump hosts

{
  "Host": "sftp.internal",
  "Username": "deploy",
  "AuthenticationMode": "Auto",
  "Password": "...",
  "PrivateKeyPath": "/secrets/id_ed25519",
  "PrivateKeyContent": null,
  "AdditionalPrivateKeyPaths": [],
  "PrivateKeyPassphrase": "...",
  "KnownHostsPath": "/home/app/.ssh/known_hosts",
  "HostKeyFingerprints": [],
  "Ciphers": ["aes256-gcm@openssh.com", "aes256-ctr"],
  "Encoding": "utf-8",
  "BufferSize": 262144,
  "JumpHost": {
    "Host": "bastion.example.com",
    "Username": "jump",
    "PrivateKeyPath": "/secrets/bastion_ed25519",
    "KnownHostsPath": "/home/app/.ssh/known_hosts"
  }
}
  • AuthenticationMode: Password, PrivateKey, KeyboardInteractive (answers the password prompt), or Auto, which offers keys, then password, then keyboard-interactive, and also satisfies servers that demand several methods. Keys can be files or inline text (PrivateKeyContent) from a secret store. SSH agents are not supported by the underlying SSH library.
  • Host keys are trusted through HostKeyFingerprints, an OpenSSH KnownHostsPath (plain, hashed, wildcard, and [host]:port entries), or AutoAcceptHostKey for development. A key marked @revoked in known_hosts is refused even with auto-accept. A rejected key reports storage.host_key_rejected with the presented fingerprint in Details.
  • KeyExchangeAlgorithms, Ciphers, MacAlgorithms, and HostKeyAlgorithms restrict and order the offered algorithms, for hardening or for old servers. Unknown names fail validation and list what is supported.
  • JumpHost tunnels through an SSH bastion. The target's key is still verified against the target's settings, and a configured Proxy applies to the bastion connection.

FTP and FTPS options

{
  "Host": "ftp.partner.example",
  "EncryptionMode": "Explicit",
  "TrustedPublicKeySha256": ["SHA256:..."],
  "TlsProtocols": ["Tls12", "Tls13"],
  "EncryptDataChannel": true,
  "DataConnectionMode": "AutoPassive",
  "ActivePortMin": 50000,
  "ActivePortMax": 50100,
  "ActiveExternalIp": "203.0.113.7",
  "Encoding": "windows-1252",
  "TransferType": "Binary",
  "ListingParser": "Auto",
  "ServerTimeZone": "Europe/Copenhagen",
  "ReadTimeoutSeconds": 60,
  "SocketKeepAlive": true,
  "LoginCommands": ["SITE UMASK 022"]
}
  • TrustedCertificateSha256 pins the whole certificate; TrustedPublicKeySha256 pins only its public key, so it keeps working across renewals that keep the key. Pinned self-signed certificates are accepted unless RequireValidCertificateChain is set. Without pins, normal validation applies. TLS problems report storage.tls_failure.
  • Legacy encodings such as windows-1252, iso-8859-1, ibm437, and shift_jis are supported for file names on older servers.
  • ServerTimeZone converts listing times from servers that report local time.
  • LoginCommands run after every login; a command the server rejects fails the connection so misconfiguration surfaces immediately.

WebDAV options

AuthenticationMode accepts None, Basic (sent up front, saving a challenge round trip), BearerToken, Digest, Ntlm, Negotiate, and Windows (current user). HTTPS endpoints support TrustedCertificateSha256 and TrustedPublicKeySha256 pins, RequireValidCertificateChain, and a PFX ClientCertificatePath for mutual TLS. MaxConnectionsPerServer caps concurrent connections.

Proxies

Every remote provider can tunnel through an HTTP (CONNECT), SOCKS5, or SOCKS4 proxy:

"Proxy": { "Type": "Socks5", "Host": "proxy.corp.local", "Port": 1080, "Username": "me", "Password": "..." }

SOCKS5 and HTTP proxies resolve the destination host name on the proxy side. SOCKS4 cannot, so the host must resolve from the client, and SOCKS4 carries no password. FTP data connections are tunnelled as well, so use passive mode, and the server's passive address must be reachable from the proxy.

Sessions, retries, and keep-alive

FTP and SFTP keep authenticated sessions in a per-connection pool, and FTP, SFTP, and WebDAV retry transient failures automatically. Both are tuned per connection:

{
  "Connections": {
    "partner": {
      "Host": "sftp.partner.example",
      "Username": "upload",
      "Password": "...",
      "HostKeyFingerprints": ["SHA256:..."],
      "Session": {
        "MaxSessions": 4,
        "MaxIdleSessions": 2,
        "IdleLifetimeSeconds": 120,
        "AcquireTimeoutSeconds": 30,
        "ValidateAfterIdleSeconds": 15,
        "KeepAliveSeconds": 60
      },
      "Retry": {
        "RetryCount": 3,
        "BaseDelayMs": 100,
        "MaxDelayMs": 30000,
        "RetryNonIdempotent": false
      }
    }
  }
}
  • MaxSessions caps open sessions, busy or idle, so the library stays under a server's per-user connection limit. Callers beyond it wait up to AcquireTimeoutSeconds, then receive storage.server_busy.
  • A pooled session idle longer than ValidateAfterIdleSeconds is probed (FTP NOOP, SFTP stat) before reuse. Sessions that time out, drop, or fail TLS mid-operation are closed instead of reused.
  • KeepAliveSeconds sends FTP NOOP or SSH keep-alive packets while a session is open.
  • Reads, listings, info, and directory creation retry on timeouts, refused or dropped connections, and busy servers, with exponential backoff and jitter; a server Retry-After is honored. Uploads retry only from a seekable stream, which is replayed from its starting position; staged uploads never leave a partial file. Deletes and moves retry only with RetryNonIdempotent, because the first attempt may already have succeeded.
  • StorageConnectionOpenedEvent, StorageConnectionLostEvent, and StorageConnectionRetryEvent are published for monitoring, and each retry is logged as a warning.

Common API

await using var source = File.OpenRead("photo.jpg");
Result<StorageItem> uploaded = await media.UploadAsync(
    "photos/photo.jpg",
    source,
    new StorageUploadOptions
    {
        Overwrite = false,
        ContentType = "image/jpeg",
        Metadata = new Dictionary<string, string> { ["owner"] = "42" }
    });

Result<StoragePage> page = await media.ListAsync("photos", new StorageListOptions
{
    Recursive = true,
    PageSize = 250
});

Result<byte[]> range = await media.DownloadBytesAsync(
    "photos/photo.jpg",
    new StorageDownloadOptions { Offset = 1024, Length = 4096 });

Result deleted = await media.DeleteAsync(
    "photos",
    new StorageDeleteOptions { Recursive = true });

The common contract includes info/exists, paged recursive listing, physical or virtual directory creation, streaming and bounded byte uploads/downloads, ranges, delete, copy, move, and cancellation. Caller upload streams remain open. Returned download streams own their provider response and registry lease and must be disposed.

Use EnumeratePagesAsync or EnumerateItemsAsync to walk continuation tokens without buffering a complete remote tree. Bounded, order-preserving helpers are available for batch info, delete, copy, and move operations.

Safe transfers

The library can copy or move files and complete directory trees between any two mounted connections:

Result copied = await storage.CopyAsync(
    "primary", "exports/2026",
    "archive", "yearly/2026",
    new StorageTransferOptions
    {
        Overwrite = true,
        MetadataPreservation = StorageMetadataPreservation.BestEffort
    });

Result moved = await storage.MoveAsync(
    "incoming", "ready/item.bin",
    "processed", "item.bin");

Cross-provider data uses a System.IO.Pipelines relay capped at 1 MiB. Each destination file is uploaded to a unique staging name and committed only after the complete source stream succeeds. Existing destination files are backed up and restored if a later directory item fails. A move deletes its source only after the entire destination commits. Equal paths and a directory destination below its source are rejected.

The normal IStorageService.CopyAsync and MoveAsync methods use the same coordinator for recursive work. Safe same-provider file copies remain server-side when the provider can guarantee them.

Local directory trees can be transferred without manually registering a temporary local connection:

Result<StorageDirectoryTransferReport> upload = await storage.UploadDirectoryAsync(
    @"C:\exports\2026", "archive", "yearly/2026");

Result<StorageDirectoryTransferReport> download = await storage.DownloadDirectoryAsync(
    "archive", "yearly/2026", @"C:\restore\2026");

Links/reparse points in a local upload are rejected rather than followed. Reports contain file, directory, and byte counts.

File, text, JSON, progress, and integrity helpers

StorageServiceExtensions adds:

  • UploadFileAsync and atomic DownloadToFileAsync;
  • bounded ReadTextAsync / WriteTextAsync with explicit encodings;
  • bounded ReadJsonAsync<T> / WriteJsonAsync<T>;
  • UploadWithProgressAsync / DownloadWithProgressAsync;
  • streaming ComputeChecksumAsync / VerifyChecksumAsync using MD5, SHA-256, SHA-384, or SHA-512.
var progress = new Progress<StorageTransferProgress>(value =>
    Console.WriteLine($"{value.BytesTransferred} bytes"));

await media.UploadWithProgressAsync("large.bin", input, progress);
Result<StorageChecksumVerification> verified = await media.VerifyChecksumAsync(
    "large.bin", expectedSha256Hex);

MD5 is supplied only for interoperability; prefer SHA-256 or stronger for security-sensitive checks.

Capabilities and advanced contracts

Capabilities are granular flags plus provider limits. Check them at runtime rather than inferring behavior from a provider name:

if (media.Capabilities.Supports(StorageFeature.MetadataWrite))
    await media.SetMetadataAsync("photo.jpg", new Dictionary<string, string> { ["reviewed"] = "yes" });
Provider Directories Metadata Tags Conditional create/update/delete Versions Signed URLs
Local / UNC physical no no create no no
S3-compatible virtual read/write read/write yes/yes/yes read/list/delete read/write
FTP / FTPS physical no no no no no
SFTP physical no no no no no
WebDAV physical discovered properties are read-only no create no no
Azure Blob virtual read/write read/write yes/yes/yes read/list/delete SAS when credentials permit
Google Cloud Storage virtual read/write no portable contract yes/yes/yes read/list/delete when signing credentials permit
OpenStack Swift virtual read/write no portable contract yes/yes/yes endpoint-specific/native no portable TempURL contract

Advanced functionality stays out of the basic interface and is exposed through capability-gated optional contracts:

  • IStorageMetadataService: merge or replace user metadata, optionally matching ETag/version;
  • IStorageTagService: read, merge, or replace up to ten portable object tags;
  • IStorageSignedUrlService: temporary read or write URLs with bounded expiry;
  • IStorageVersionService: exact-object version pages and exact-version deletion.

Convenience extension methods (GetMetadataAsync, SetMetadataAsync, GetTagsAsync, SetTagsAsync, CreateSignedUrlAsync, ListVersionsAsync, EnumerateVersionPagesAsync, and DeleteVersionAsync) return storage.unsupported when the active backend does not implement the operation.

Atomic upload/delete identity checks use StorageMutationCondition:

await media.UploadAsync("settings.json", replacement, new StorageUploadOptions
{
    Condition = new StorageMutationCondition
    {
        ExpectedETag = current.Value!.ETag,
        ExpectedVersionId = current.Value.VersionId
    }
});

Providers that cannot enforce the condition atomically reject it instead of performing a racy check-then-write.

StorageItem now carries UnixMode (with Permissions as rwxr-xr-x text), Owner/Group (FTP listings), OwnerId/GroupId (SFTP), LinkTarget, Created, LastAccessed, and IsHidden wherever the provider reports them. Changing them goes through IStorageAttributeService, exposed as extension methods on every IStorageService:

await media.SetPermissionsAsync("reports/q3.csv", "640");
await media.SetPermissionsRecursiveAsync("public", fileMode: 0x1A4, directoryMode: 0x1ED); // 0644 / 0755
await media.SetOwnerAsync("reports/q3.csv", ownerId: 1001, groupId: 1001);
await media.SetTimestampsAsync("reports/q3.csv", lastModified: sourceTime);
await media.CreateLinkAsync("current", "releases/v42");
var link = await media.ReadLinkAsync("current");
Local FTP SFTP
Permissions Unix only SITE CHMOD yes, incl. setuid/setgid/sticky
Owner/group no no numeric IDs
Timestamps modified + accessed modified (MFMT/MDTM) modified + accessed
Create link yes (relative) no yes
Read link yes from listings no (SSH.NET lacks readlink)

Check Capabilities for Permissions, Ownership, SetTimestamps, CreateLinks, and ReadLinks; unsupported calls return storage.unsupported. Link targets must stay inside the mounted root. On Windows, creating local links needs Developer Mode or the symbolic-link privilege.

When the destination already exists

ConflictPolicy on StorageUploadOptions and StorageTransferOptions mirrors FileZilla's "target file already exists" choices: Fail, Overwrite, Skip, OverwriteIfNewer, OverwriteIfSizeDiffers, OverwriteIfNewerOrSizeDiffers, and Rename (writes name (1).ext). When it is not set, the Overwrite flag decides as before.

await media.UploadFileAsync("backup/db.bak", @"C:\dumps\db.bak",
    new StorageUploadOptions { ConflictPolicy = StorageConflictPolicy.OverwriteIfNewer });
var report = await storage.UploadDirectoryAsync(@"C:\site", "web", "public",
    new StorageTransferOptions { ConflictPolicy = StorageConflictPolicy.OverwriteIfNewerOrSizeDiffers });
Console.WriteLine($"{report.Value!.Files} uploaded, {report.Value.SkippedFiles} unchanged");
  • Directories are decided file by file; StorageDirectoryTransferReport.SkippedFiles counts the rest.
  • A skipped upload succeeds and returns the existing item, without a write event.
  • Moving a directory deletes only the source files that were transferred; skipped files stay.
  • "Newer" allows two seconds of clock slack. UploadFileAsync supplies the local file's time; for stream uploads set SourceLastModified. Unknown times or sizes count as newer or different.
  • DownloadToFileAsync takes a conflictPolicy for the local file.
  • Conditional policies on copy and move are applied by StorageLibrary and its connections, not by a backend's own CopyAsync/MoveAsync.

Resume and append

ConflictPolicy = Resume continues an interrupted upload by appending only what the destination is missing (FTP APPE, SFTP append mode, local files); a complete destination is left alone. The source must be seekable, and resumed bytes are written in place rather than staged, so check a checksum afterwards when integrity matters. DownloadToFileAsync(..., conflictPolicy: Resume) continues a partial local file with a ranged download. AppendAsync appends to a file directly, for example a log, and CleanupStaleStagingAsync removes staging leftovers of crashed transfers.

Progress and speed limits

Upload, download, and transfer options take a Progress sink. Reports arrive at most every 250 ms and carry BytesTransferred, TotalBytes, BytesPerSecond, EstimatedRemaining, and, for directory transfers, the ItemPath of the current file; directory transfers accumulate bytes across files.

var progress = new Progress<StorageTransferProgress>(p =>
    Console.WriteLine($"{p.ItemPath}: {p.BytesTransferred:N0} B at {p.BytesPerSecond / 1024:N0} KiB/s"));
await storage.CopyAsync("sftp", "exports", "s3", "archive", new StorageTransferOptions { Progress = progress });

Speed limits are set per connection and shared by all of its concurrent transfers:

"TransferLimits": { "MaxUploadBytesPerSecond": 1048576, "MaxDownloadBytesPerSecond": 5242880 }

StorageConfig.MaxTotalUploadBytesPerSecond and MaxTotalDownloadBytesPerSecond cap all connections together. Limits also apply to relayed transfers between connections.

Transfer queue

CreateTransferQueue runs transfers in the background, like FileZilla's queue:

await using var queue = storage.CreateTransferQueue(new StorageTransferQueueOptions
{
    MaxConcurrentTransfers = 4,
    MaxTransfersPerConnection = 2,
    AutomaticRetries = 2
});
queue.ProgressChanged += job => Console.WriteLine($"{job.Destination}: {job.Progress?.BytesTransferred:N0} B");
queue.EnqueueUploadDirectory(@"C:\exports", "sftp", "incoming");
var urgent = queue.EnqueueCopy("s3", "reports/q3.pdf", "sftp", "outbox/q3.pdf", priority: StorageTransferPriority.High);
await queue.WaitForIdleAsync();
foreach (var failed in queue.FailedJobs) Console.WriteLine($"{failed.Source}: {failed.Error?.Code}");
queue.RetryFailed();

Jobs cover copies, moves, and file and directory uploads and downloads. The queue respects a global and a per-connection limit, starts High priority jobs first, supports Pause/Resume/Cancel, and re-queues transient failures automatically before moving a job to FailedJobs. JobChanged and ProgressChanged suit a UI; StorageTransferStartedEvent, StorageTransferCompletedEvent, and StorageTransferFailedEvent go to the event bus. Jobs live in memory only.

Compare and sync

var diff = await storage.CompareAsync("sftp", "site", "s3", "backup/site");
foreach (var entry in diff.Value!.Entries.Where(e => e.Kind != StorageDiffKind.Same))
    Console.WriteLine($"{entry.Kind,-18} {entry.Reasons,-12} {entry.RelativePath}");

var report = await storage.SyncAsync("sftp", "site", "s3", "backup/site", new StorageSyncOptions
{
    Direction = StorageSyncDirection.Mirror,
    DeleteExtraneous = true,
    DryRun = true
});

CompareAsync and SyncAsync also work between any two IStorageService instances, such as a LocalStorageBackend over a local folder. Comparison uses size and modification time by default (two-second tolerance) and can add checksums. Sync directions:

  • Update copies new and changed files and never deletes; it will not replace a newer destination that has the same size.
  • Mirror makes the destination match the source, deleting extra items with DeleteExtraneous.
  • TwoWay copies each file toward the side where it is missing or older, without deletes.

Copied files keep the source's modification time where the destination supports it. On services that cannot (S3, Azure, GCS, Swift) a copy is newer than its source, and "changed" means "source newer", so repeated syncs stay no-ops. Per-file failures are collected in Failed. DryRun returns the plan without changing anything.

Raw commands and free space

With AllowRawCommands: true on an FTP or SFTP connection, ExecuteCommandAsync sends a raw FTP command (SITE ..., SYST) or runs an SSH shell command as the connection's account. It is off by default because commands are not confined to the connection's Root. A rejected command or non-zero exit is returned as a result with Succeeded = false, not as an error. Servers that allow only SFTP (ForceCommand internal-sftp) refuse shell commands.

GetSpaceAsync reports free and used space: SFTP through statvfs@openssh.com, FTP through AVBL where the server implements it, and local connections from the volume. WebDAV and object stores return storage.unsupported.

Watching for changes

await foreach (var change in media.WatchAsync("incoming", cancellationToken: stopping))
    Console.WriteLine($"{change.Kind}: {change.Path}");

Local connections use native file-system notifications (including renames). Every other provider is polled: the directory is listed every PollInterval (30 s by default) and compared by type, size, time, and ETag, so a rename appears as a delete plus a create. A failed poll is retried on the next interval rather than reported as deletions. The library's own staging items never appear.

Relayed copies and moves (across connections, or directory copies) meet links as provider-specific items. StorageTransferOptions.LinkHandling decides what happens:

Mode Behavior
Reject (default) fail with storage.unsupported and roll back
Skip leave links out
Follow copy the target file's content; links to directories are refused, so loops cannot occur
Recreate create an equivalent link; targets inside the copied tree point into the copy

Recreate needs ReadLinks on the source and CreateLinks on the destination; SFTP cannot be a Recreate source because SSH.NET cannot read link targets.

Server-side checksums

ComputeChecksumAsync and VerifyChecksumAsync ask the server for a stored digest first and only download the content when there is none; StorageChecksum.Source says which happened. GetServerChecksumAsync returns only the server's value, and StorageChecksumMode.ComputeOnly forces a download.

Provider Server digest
S3 MD5 from single-part, non-KMS ETags; SHA-256 when stored with the object
Azure Blob MD5 (Content-MD5)
Google Cloud Storage MD5 of non-composite objects
Swift MD5 ETag, except segmented large objects
FTP HASH/XMD5/XSHA256/XSHA512 when the server offers them
SFTP, WebDAV, Local none (always computed)

Runtime connections, health, and native clients

await storage.AddOrUpdateConnectionAsync("backup", new SftpConnectionConfig
{
    Host = "sftp.example.com",
    Username = "backup",
    AuthenticationMode = SftpAuthenticationMode.PrivateKey,
    PrivateKeyPath = @"C:\keys\backup_ed25519",
    HostKeyFingerprints = ["SHA256:..."]
});

Result health = await storage.CheckConnectionHealthAsync("backup");

Runtime changes can be persisted to the typed provider JSON section or installed for the process only with persist: false. Stable service proxies and active download/native leases keep an old backend alive until in-flight operations drain during replacement or shutdown.

Reusable native SDK clients and scoped session clients remain available as an escape hatch:

IAmazonS3 s3 = storage.GetNativeClient<IAmazonS3>("media");

var opened = await storage.OpenNativeConnectionAsync<AsyncFtpClient>("legacy-ftp");
if (opened.IsSuccess)
{
    await using var lease = opened.Value!;
    AsyncFtpClient ftp = lease.Client;
}

Do not dispose reusable clients returned by GetNativeClient; dispose session leases.

Testing settings and diagnosing connections

TestConnectionAsync tries settings without saving them, even before the library is initialized. It reports each step (validate, connect, list, details). If the server's certificate or host key is rejected, ServerIdentity still says what was presented, ready to pin:

var report = await storage.TestConnectionAsync(settings);
if (!report.Succeeded && report.ServerIdentity is { Kind: "ssh-host-key" } key)
{
    // Ask the user: "The server presented {key.Fingerprint} ({key.Algorithm}). Trust it?"
    settings.HostKeyFingerprints = [key.Fingerprint];
}
// For TLS, pin key.PublicKeyFingerprint in TrustedPublicKeySha256 (survives certificate renewal).

GetConnectionDiagnosticsAsync(id) describes a registered connection: host, port, transport security, the presented certificate or host key, what was negotiated (tls/cipher for FTPS; kex, hostKey, cipher, mac for SSH), server system and software (FTP SYST, SSH version string, HTTP Server), advertised features (FTP FEAT, WebDAV DAV and Allow), session pool counters, and the last health check. GetConnections() includes the host, port, security, and last health for every connection. Diagnostics never contain credentials.

Health checks publish StorageConnectionHealthChangedEvent when a connection's state changes (and on its first check). Every failed service operation publishes StorageOperationFailedEvent with the operation, path, and error code, including expected failures such as storage.not_found.

Failures and compatibility

Expected failures use stable storage.* error codes such as storage.not_found, storage.conflict, storage.authentication_failed, storage.permission_denied, storage.connection_lost, storage.server_busy, storage.quota_exceeded, storage.too_large, and storage.unsupported. StorageErrorInfo.IsTransient tells whether a failure is worth retrying, and StorageErrorInfo.TryGetDetail exposes the provider's own code (ftpReply, sftpStatus, httpStatus). Incomplete cleanup/source deletion is reported as storage.partial_failure with sanitized state and error codes. Provider bodies, credentials, and signed query strings are not exposed. Caller cancellation propagates as OperationCanceledException.

Migrating from the legacy S3-only package? See MIGRATION.md.

Requirements

  • CodeLogic 4
  • .NET 10

MIT license.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.8.93 0 9/23/2026
4.8.91 36 9/19/2026
4.8.87 72 9/13/2026
4.8.85 58 9/12/2026
4.6.84 45 9/4/2026
4.6.83 151 8/8/2026
4.6.79 45 8/2/2026
4.6.78 41 8/2/2026
4.6.77-preview 39 8/2/2026
4.6.76-preview 49 8/2/2026
4.6.75-preview 47 8/1/2026

# Changelog

## 2026-09-22

### Fixed

- Swift items never carried an `ETag`, because Swift sends it unquoted and the typed header parser
 rejects that, so every conditional delete against Swift failed as a conflict.
- FTP listing times were shifted by the client machine's UTC offset: the FTP client now converts to
 UTC, and unspecified times are no longer reinterpreted as local time.
- WebDAV uploads, moves, and copies each stranded a pooled HTTP connection until garbage collection,
 because the WebDAV client library never disposes those responses. With a connection limit the next
 request hung; without one, sockets piled up under load. The backend now issues MOVE and COPY itself.
- Legacy code-page encodings (windows-1252, iso-8859-x, ibm437, shift_jis) were unavailable because
 .NET does not register them by default.
- A Google Cloud object whose timestamp had fewer than three fractional digits failed the whole
 operation with `storage.provider_error`; timestamps are now parsed leniently.
- Unclassified provider failures now carry the exception type (never its message) in `Details`.
- Ranged Google Cloud Storage downloads failed hash validation, because the stored CRC32C covers the
 whole object; validation is now skipped for byte ranges only.
- Google Cloud clients whose credentials cannot sign URLs (anonymous or emulator clients) threw
 from the backend constructor instead of just disabling signed URLs.
- WebDAV failures were all reported as `storage.provider_error`, and missing-directory detection never
 matched: the HTTP status is read from `WebDAVException.GetHttpCode()` (with a message fallback)
 instead of `ErrorCode`, which the client leaves at zero.
- FTP server replies wrapped in FluentFTP's generic `FtpException` are now unwrapped and classified.
- On Linux and macOS, WebDAV items whose names need URL escaping (spaces, for example) were reported
 as missing right after being written: server-relative hrefs were parsed as file paths.
- Public-key (SPKI) pin checks disposed the server certificate they were given, so anything reading
 it later in the TLS callback saw a disposed certificate.

### Changed (breaking)

- `UploadWithProgressAsync` no longer hides seeking, so uploads with progress can be retried.

- `DownloadToFileAsync` gained a `conflictPolicy` parameter before `cancellationToken`.

- Listings no longer show the library's own staging and backup items (`.cl-storage-*`,
 `.clstorage-*`); set `IncludeInternal` to see them, for example to clean up after a crash.

- `ComputeChecksumAsync` and `VerifyChecksumAsync` gained a `mode` parameter before
 `cancellationToken`; positional callers passing a token must name it.

- FTP, SFTP, and WebDAV now declare `AtomicMove`, so renaming a folder through the library uses a
 single server-side rename (RNFR/RNTO, SFTP rename, WebDAV MOVE) instead of copying the whole tree
 through the client and deleting the original.

- SFTP no longer requires `HostKeyFingerprints` when `KnownHostsPath` is set.

- `StorageConnectionInfo` gained `Host`, `Port`, `Security`, and `LastHealth`. Positional
 deconstruction is unchanged; code comparing whole records for equality now also compares these.

- WebDAV always installs a certificate validation callback (to record the presented certificate).
 Without pins it still accepts only certificates with no policy errors.

- `FtpStorageBackend`, `SftpStorageBackend`, and `WebDavStorageBackend` constructors take optional
 session and retry settings. Retries are on by default (3 attempts); pass
 `new StorageRetryConfig { RetryCount = 0 }` to restore single-attempt behavior.

- Split coarse failures into precise error codes: `storage.authentication_failed`,
 `storage.permission_denied`, `storage.tls_failure`, `storage.host_key_rejected`,
 `storage.connection_failed`, `storage.connection_lost`, `storage.server_busy`, and
 `storage.quota_exceeded`. Code that compared against `storage.unauthorized` or
 `storage.unavailable` for provider failures must also accept the new codes; see `MIGRATION.md`.
- FTP errors are now classified from the server reply code (421, 425/426, 450/550, 452/552,
 530, 553, ...) instead of collapsing to `storage.provider_error`. TLS failures are no longer
 reported as credential failures.
- SFTP reports an untrusted host key as `storage.host_key_rejected` with the presented fingerprint
 in `Details`, and distinguishes refused connections, dropped sessions, and too-many-sessions.
- WebDAV, S3, Azure Blob, Google Cloud Storage, and Swift share one HTTP status mapping: 401 vs 403
 are distinguished, 429/503 become `storage.server_busy` (carrying `retryAfterMs` when the server
 sent `Retry-After`), and 507 becomes `storage.quota_exceeded`.
- A full local disk now returns `storage.quota_exceeded`, and local access denial returns
 `storage.permission_denied`.

### Added

- `TestConnectionAsync` checks connection settings that have not been saved: it validates them,
 connects, lists the root, and reads server details, reporting each step. When a certificate or host key
 is rejected, the report still carries what the server presented so a setup screen can offer to pin it.
- `GetConnectionDiagnosticsAsync` describes a live connection: address, transport security, the
 presented certificate or host key, negotiated TLS/SSH algorithms, server system and software, FTP
 `FEAT` or WebDAV `DAV`/`Allow` features, session pool counters, and the last health check.
- `StorageConnectionHealthChangedEvent` when a health check finds a connection in a new state, and
 `StorageOperationFailedEvent` for every failed service operation (with operation, path, and code).
- `WatchAsync` change streams: native notifications for local connections (`ChangeNotifications`),
 polling with snapshot diffs for every other provider.
- `IStorageCommandService.ExecuteCommandAsync` for raw FTP and SSH commands (opt-in with
 `AllowRawCommands`) and `IStorageSpaceService.GetSpaceAsync` for SFTP, FTP (`AVBL`), and local.
- Directory `CompareAsync` and `SyncAsync` (`Update`, `Mirror` with optional deletes, `TwoWay`) across
 any two connections, with dry runs, timestamp preservation, checksum comparison, and parallel copies.
- `StorageLibrary.CreateTransferQueue`: a background queue with global and per-connection concurrency,
 priorities, pause/resume, cancellation, automatic re-queueing of transient failures, a retryable
 failed list, progress and state events, and started/completed/failed bus events.
- `Progress` on upload, download, and transfer options, with speed, remaining time, and the current
 file; relayed directory transfers report one running total.
- Per-connection `TransferLimits` and library-wide `MaxTotalUploadBytesPerSecond` /
 `MaxTotalDownloadBytesPerSecond` speed limits, shared by concurrent transfers.
- `IStorageAppendService.AppendAsync` (Local, FTP, SFTP), `StorageConflictPolicy.Resume` for
 interrupted uploads and `DownloadToFileAsync`, and `CleanupStaleStagingAsync`.
- `StorageConflictPolicy` (`Fail`, `Overwrite`, `Skip`, `OverwriteIfNewer`, `OverwriteIfSizeDiffers`,
 `OverwriteIfNewerOrSizeDiffers`, `Rename`) on uploads, transfers, directory uploads/downloads, and
 `DownloadToFileAsync`, with `SkippedFiles` in directory reports and partial moves that keep skipped
 sources.
- `StorageListOptions.IncludeInternal`, `IncludeHidden`, and `NamePattern` (`*`/`?` wildcards).
- `StorageTransferOptions.LinkHandling` (`Reject`, `Skip`, `Follow`, `Recreate`) for relayed
 transfers that meet symbolic links.
- Deleting a local link removes the link itself, even when `FollowLinks` is off, and never its target.
- `IStorageChecksumService.GetServerChecksumAsync` for S3, Azure Blob, Google Cloud Storage, Swift, and
 FTP. `ComputeChecksumAsync`/`VerifyChecksumAsync` use the server digest when available (new
 `StorageChecksumMode` parameter) and report it in `StorageChecksum.Source`.
- `IStorageAttributeService` with permissions (including recursive file/directory modes), numeric
 ownership, timestamps, and symbolic links, plus `StorageItem.UnixMode`, `Permissions`, `Owner`,
 `Group`, `OwnerId`, `GroupId`, `LinkTarget`, `Created`, `LastAccessed`, and `IsHidden`, and
 `UnixPermissions` for octal and `rwx` conversion.
- WebDAV: Digest, NTLM, and Negotiate authentication, public-key (SPKI) pins,
 `RequireValidCertificateChain`, client certificates for mutual TLS, and `MaxConnectionsPerServer`.
 Basic credentials are sent up front instead of after a 401 challenge.
- FTP/FTPS: public-key (SPKI) pins, `RequireValidCertificateChain`, revocation checks, TLS version
 selection, `EncryptDataChannel`, active-mode port range and external IP, file-name `Encoding`
 (including legacy code pages), ASCII `TransferType`, `ListingParser`, `ServerTimeZone`, separate
 connect/read/data timeouts, `SocketKeepAlive`, and `LoginCommands` run after each login.
- SFTP: `KeyboardInteractive` and `Auto` authentication (keys, password, keyboard-interactive, and
 multi-method servers), inline private keys (`PrivateKeyContent`), several keys, OpenSSH
 `known_hosts` verification (hashed, wildcard, `[host]:port`, and `@revoked` entries), algorithm
 allow-lists, file-name `Encoding`, `BufferSize`, and `JumpHost` tunnelling through an SSH bastion.
- HTTP, SOCKS5, and SOCKS4 proxy support (`Proxy`) for FTP (including data connections), SFTP,
 WebDAV, S3, Azure Blob, Google Cloud Storage, and Swift.
- Google Cloud Storage `ServiceUrl` and `AllowInsecureHttp` for private endpoints and emulators, plus
 an `Anonymous` authentication mode for public buckets and fake-gcs-server.
- Swift `TempAuthV1` authentication (`X-Auth-User` / `X-Auth-Key`) and `AllowInsecureHttp`.
- FTP, SFTP, and WebDAV retry transient failures (timeouts, refused or dropped connections, busy
 servers) with exponential backoff, jitter, and `Retry-After` support, configured per connection
 through `Retry`. Deletes and moves retry only with `RetryNonIdempotent`; uploads retry only from
 seekable streams.
- FTP and SFTP session pools are configurable per connection through `Session`: `MaxSessions` caps
 open sessions and makes excess callers wait (`storage.server_busy` on timeout), idle sessions are
 probed before reuse, and sessions that fail mid-operation are retired instead of reused.
- `Session.KeepAliveSeconds` enables FTP NOOP and SSH keep-alive packets.
- `StorageConnectionOpenedEvent`, `StorageConnectionLostEvent`, and `StorageConnectionRetryEvent`,
 plus a warning log line per retry.
- `StorageErrorInfo` with `IsTransient`, `IsConnectionFault`, `TryGetRetryAfter`, and `TryGetDetail`
 for retry decisions and provider diagnostics (`ftpReply`, `sftpStatus`, `httpStatus`).

### Added

- Added mounted local/UNC, S3-compatible, FTP/FTPS, SFTP, WebDAV, Azure Blob, Google Cloud
 Storage, and OpenStack Swift backends behind one `IStorageService` contract.
- Added typed provider configuration, runtime add/update/remove persistence, stable service proxies,
 per-connection health, custom-backend registration, native clients, scoped native sessions, and
 asynchronous library disposal.
- Added granular `StorageFeature` flags and provider limits for page, object, metadata, batch, and
 multipart boundaries.
- Added bounded cross-connection file and recursive-directory copy/move, plus rollback-safe local
 directory upload/download reports.
- Added page/item async enumeration and bounded order-preserving batch info/delete/copy/move helpers.
- Added file, text, JSON, progress, and streaming checksum convenience APIs.
- Added optional metadata, object-tag, signed-URL, and object-version contracts. S3 and Azure Blob
 support bounded tag reads plus merge/replace updates. S3, Azure Blob, and GCS support exact version
 listing/deletion; S3, Azure, and signing-capable GCS credentials support temporary URLs.
- Added atomic ETag/version upload and delete conditions where providers can enforce them.
- Added typed write/delete/copy/move and cross-connection completion events.
- Added a migration guide from `CodeLogic.StorageS3`.

### Safety

- Added an explicit, default-off `AutoAcceptHostKey` SFTP connection option for trusted
 environments where a server host-key fingerprint cannot be configured.
- Accept SSH.NET's canonical unpadded Base64 SHA-256 host-key fingerprints while continuing to
 reject malformed, noncanonical, or non-SHA-256 values before SFTP trust decisions.
- Centralized path normalization and source/destination relationship checks; equal transfers and
 directory moves/copies below their source are rejected by the library and direct backends.
- Staged local, FTP, SFTP, and WebDAV overwrites so a failed upload cannot truncate existing data.
- Made recursive transfers use unique staging objects, preserve caller upload streams, hold registry
 leases, and cap relay read-ahead at 1 MiB.
- Back up pre-existing destination files and restore them when a later directory item fails. Incomplete
 cleanup or source deletion returns sanitized `storage.partial_failure` state.
- Propagate caller cancellation and keep provider response/session ownership attached to returned
 download streams.
- Enforce byte-buffering, metadata, multipart, serialization, and batch limits.
- Removed certificate and host-key bypass behavior. Clear-text custom endpoints require explicit opt-in;
 SFTP host-key trust is mandatory; FTPS/WebDAV certificate pins use SHA-256.
- Reject header injection, transport-managed custom headers, unsafe endpoint URL components, malformed
 metadata, and unsupported version/metadata options instead of silently ignoring them.
- Sanitized public provider errors so credentials, signed query strings, raw response bodies, and
 provider exception messages are not exposed.

### Changed

- FTP, SFTP, WebDAV, and local listings now page from a single cached, ordinally sorted snapshot per
 listing pass instead of re-materialising and re-sorting the whole listing on every page. Paging a
 recursive listing of N entries at page size P cost `ceil(N/P)` complete directory walks and is now
 one walk; ordering and item content are unchanged. Continuation tokens remain opaque and are still
 rejected when malformed, but their internal format changed, so a token minted by an earlier version
 is not accepted by this one. A token is only meaningful within the listing that minted it;
 presenting one to a different listing resumes that listing from the path the token carries rather
 than failing, because an evicted snapshot cannot be told apart from a foreign one.
- FTP and SFTP reuse pooled, already-authenticated sessions across operations rather than opening and
 tearing down a connection per call. Idle sessions are health-checked before reuse, bounded in count
 and idle lifetime, and closed when the backend is disposed. Sessions handed out through
 `OpenNativeConnectionAsync` are retired rather than pooled, since caller code may leave them in an
 unexpected state.
- Recursive service copy/move now always uses the safe coordinator; same-provider file staging remains
 server-side when the backend advertises a safe native copy.
- Object-provider listing distinguishes an exact file path from a virtual directory and treats root
 directory creation as an idempotent no-op.
- S3 uploads use explicit bounded multipart handling for seekable and non-seekable streams.
- Google Cloud downloads stream through a bounded pipe and listings use real provider paging.
- WebDAV metadata-read capability is now callable through `IStorageMetadataService`; property writes
 remain explicitly unsupported by the portable adapter.

## 2026-09-12

### Changed

- Unified the version line with the CodeLogic framework on **4.8.x**. Every official
 library and the framework now share one `major.minor`, so a given `4.8.<patch>`
 means the same generation across all packages.
- `version.txt` moved from `4.6` to `4.8`. The patch component remains the CI run
 number, composed at pack time; `AssemblyVersion` stays pinned at `Major.Minor.0.0`
 (now `4.8.0.0`) so every patch in the line loads interchangeably.