CodeLogic.Storage
4.8.93
dotnet add package CodeLogic.Storage --version 4.8.93
NuGet\Install-Package CodeLogic.Storage -Version 4.8.93
<PackageReference Include="CodeLogic.Storage" Version="4.8.93" />
<PackageVersion Include="CodeLogic.Storage" Version="4.8.93" />
<PackageReference Include="CodeLogic.Storage" />
paket add CodeLogic.Storage --version 4.8.93
#r "nuget: CodeLogic.Storage, 4.8.93"
#:package CodeLogic.Storage@4.8.93
#addin nuget:?package=CodeLogic.Storage&version=4.8.93
#tool nuget:?package=CodeLogic.Storage&version=4.8.93
CodeLogic.Storage
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), orAuto, 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 OpenSSHKnownHostsPath(plain, hashed, wildcard, and[host]:portentries), orAutoAcceptHostKeyfor development. A key marked@revokedinknown_hostsis refused even with auto-accept. A rejected key reportsstorage.host_key_rejectedwith the presented fingerprint inDetails. KeyExchangeAlgorithms,Ciphers,MacAlgorithms, andHostKeyAlgorithmsrestrict and order the offered algorithms, for hardening or for old servers. Unknown names fail validation and list what is supported.JumpHosttunnels through an SSH bastion. The target's key is still verified against the target's settings, and a configuredProxyapplies 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"]
}
TrustedCertificateSha256pins the whole certificate;TrustedPublicKeySha256pins only its public key, so it keeps working across renewals that keep the key. Pinned self-signed certificates are accepted unlessRequireValidCertificateChainis set. Without pins, normal validation applies. TLS problems reportstorage.tls_failure.- Legacy encodings such as
windows-1252,iso-8859-1,ibm437, andshift_jisare supported for file names on older servers. ServerTimeZoneconverts listing times from servers that report local time.LoginCommandsrun 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
}
}
}
}
MaxSessionscaps open sessions, busy or idle, so the library stays under a server's per-user connection limit. Callers beyond it wait up toAcquireTimeoutSeconds, then receivestorage.server_busy.- A pooled session idle longer than
ValidateAfterIdleSecondsis probed (FTPNOOP, SFTPstat) before reuse. Sessions that time out, drop, or fail TLS mid-operation are closed instead of reused. KeepAliveSecondssends FTPNOOPor 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-Afteris 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 withRetryNonIdempotent, because the first attempt may already have succeeded. StorageConnectionOpenedEvent,StorageConnectionLostEvent, andStorageConnectionRetryEventare 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:
UploadFileAsyncand atomicDownloadToFileAsync;- bounded
ReadTextAsync/WriteTextAsyncwith explicit encodings; - bounded
ReadJsonAsync<T>/WriteJsonAsync<T>; UploadWithProgressAsync/DownloadWithProgressAsync;- streaming
ComputeChecksumAsync/VerifyChecksumAsyncusing 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.
Permissions, ownership, timestamps, and links
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.SkippedFilescounts 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.
UploadFileAsyncsupplies the local file's time; for stream uploads setSourceLastModified. Unknown times or sizes count as newer or different. DownloadToFileAsynctakes aconflictPolicyfor the local file.- Conditional policies on copy and move are applied by
StorageLibraryand its connections, not by a backend's ownCopyAsync/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:
Updatecopies new and changed files and never deletes; it will not replace a newer destination that has the same size.Mirrormakes the destination match the source, deleting extra items withDeleteExtraneous.TwoWaycopies 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.
Links in transfers
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 | Versions 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. |
-
net10.0
- AWSSDK.S3 (>= 4.0.101.6)
- Azure.Identity (>= 1.21.0)
- Azure.Storage.Blobs (>= 12.29.1)
- CodeLogic (>= 4.0.0 && < 5.0.0)
- FluentFTP (>= 54.2.0)
- Google.Cloud.Storage.V1 (>= 4.15.0)
- SSH.NET (>= 2026.0.0)
- WebDAVClient (>= 2.7.0)
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.