Repository navigation
Usage
Three things every caller hits eventually: sync/async pairing, streams, and pagination.
Async is the designed API. Use the sync overloads only when you have no choice. The library is built around Task-returning methods with cancellation tokens — that is what every driver is optimised and tested for. The sync shapes exist for legacy call sites (event handlers, constructors, old interfaces you can't change) where await isn't available. Reach for them as an escape hatch, not a default.
// Preferred
var file = await hub.Root.CreateFileAsync("a.txt", ct);
await file.SetTextAsync("hi", cancellationToken: ct);
var text = await file.ReadAllTextAsync(ct);
// Escape hatch — only when the caller can't be async
var file = hub.Root.CreateFile("a.txt");
file.SetText("hi");
var text = file.ReadAllText();Every async method accepts a CancellationToken. Cloud drivers honour it on every network call. Sync overloads do not accept one.
| Driver | Overrides async? |
|---|---|
| Memory | No — sync is instant, async wraps it. |
| Local | No — System.IO is already fast; async wraps sync. |
| S3 | Yes — async talks the AWSSDK async API; sync bridges to it. |
| OCI | Yes — async is truly async (SDK returns Task); sync bridges to it. |
| FTP | Yes — async talks FluentFTP's async client; sync bridges to it. |
Calling .GetAwaiter().GetResult() on a Task under a captured SynchronizationContext (WinForms, WPF, ASP.NET "classic" on the full .NET Framework) is a classic deadlock recipe: the continuation tries to resume on the blocked UI/request thread.
The cloud drivers (S3, OCI) defend against this by routing every sync overload through an internal SyncBridge that queues the async work to the thread pool via Task.Run. The calling thread still blocks on the result, but the async continuation runs on a pool worker that has no captured context, so it can always make progress. Modern hosts (ASP.NET Core, console, worker services, .NET 6+) have no ambient SynchronizationContext, so the Task.Run hop is a single enqueue with no behavioural difference.
A regression test (SyncBridgeDeadlockTests) exercises the sync API from inside a single-thread SynchronizationContext with a 10-second watchdog: if anything in the chain starts capturing the context, the test fails with a timeout. Removing the Task.Run hop from SyncBridge flips three of four cases to failure, which confirms the test detects the deadlock rather than passing by accident.
This is not a universal guarantee. It addresses the most common deadlock scenario we expect callers to hit. Other patterns can still stall — for instance, mixing sync .Result / .Wait() on foreign tasks inside an async method, a custom TaskScheduler that serialises work without a SynchronizationContext, thread-pool starvation under heavy concurrent load, or a user-supplied IAmazonS3 / ObjectStorageClient whose own code captures a context without ConfigureAwait(false). If your application runs under a legacy UI framework, make async the default and treat the sync overloads as emergency exits.
The sync TryOpenFile / TryOpenDirectory use an out parameter; the async siblings can't (no out across await) and return a tuple instead:
var (file, exists) = await dir.TryOpenFileAsync("report.pdf", ct);
if (exists) await file.CopyToStreamAsync(output, ct);
var (sub, found) = await dir.TryOpenDirectoryAsync("2026/01", ct);The base implementation wraps the sync call; FTP / S3 / OCI override with native async.
Four ways in and out of a file:
| What | Sync | Async |
|---|---|---|
| Text (UTF-8 by default) |
ReadAllText() / SetText(s)
|
ReadAllTextAsync(ct) / SetTextAsync(s, ct)
|
| Bytes |
ReadAllBytes() / SetBytes(b)
|
ReadAllBytesAsync(ct) / SetBytesAsync(b, ct)
|
| Read stream | GetReadStream() |
GetReadStreamAsync(ct) |
| Write stream | GetWriteStream() |
GetWriteStreamAsync(ct) |
| Pipe out (file → stream) | CopyToStream(dest) |
CopyToStreamAsync(dest, ct) |
| Pipe in (stream → file) | CopyFromStream(src) |
CopyFromStreamAsync(src, ct) |
Each CopyToStream / CopyFromStream overload accepts an optional IProgress<TransferStatus>:
var progress = new Progress<TransferStatus>(s =>
Console.WriteLine($"{s.BytesTransferred}/{s.TotalBytes}"));
await file.CopyToStreamAsync(output, progress, ct);
await file.CopyFromStreamAsync(input, progress, ct);CopyFromStream reads from source.Length for the total — pass a seekable stream when you want a populated TotalBytes. CopyTo uses FileEntry.Length.
using var read = await hub.Root.OpenFile("big.bin").GetReadStreamAsync(ct);
using var write = await otherHub.Root.CreateFile("big.bin").GetWriteStreamAsync(ct);
await read.CopyToAsync(write, ct);GetWriteStream() truncates (FileMode.Create semantics on Local, replace on Memory/OCI). Append is not supported — read it all, stream through, write back.
FileEntry.CopyToAsync(dir, name, progress, overwrite, ct) detects same-credential OCI endpoints and uses server-side CopyObject. Anything else falls back to streaming bytes end-to-end. Works for every pair of drivers. overwrite defaults to false: an existing destination throws FileAlreadyExistsException. Pass overwrite: true to replace it.
SetText defaults to UTF-8 without BOM. Override:
file.SetText("olá", Encoding.Latin1);
var text = file.ReadAllText(Encoding.Latin1);IEnumerable<FileEntry> GetFiles(
string searchPattern = "*",
FileListOffset offset = default,
int? limit = null);
IAsyncEnumerable<FileEntry> GetFilesAsync(
string searchPattern = "*",
FileListOffset offset = default,
int? limit = null,
CancellationToken ct = default);Two forms, implicit or explicit:
| Form | Semantics |
|---|---|
default / 0
|
Start from the beginning. |
int n / FileListOffset.FromIndex(n)
|
Skip the first n entries. |
FileListOffset.FromName("abc.txt") |
Start from the first entry with Name >= "abc.txt" (inclusive cursor). |
var page1 = hub.Root.GetFiles(limit: 50).ToList();
var page2 = hub.Root.GetFiles(offset: 50, limit: 50).ToList();string cursor = null;
while (true)
{
var page = hub.Root
.GetFiles(
offset: cursor is null ? default : FileListOffset.FromName(cursor),
limit: 100)
.ToList();
if (page.Count == 0) break;
foreach (var f in page) Process(f);
// Advance past the last name (offset is inclusive).
cursor = page[^1].Name + "\0";
}OCI pages by a string cursor, not a numeric offset. An int offset of N forces the driver to walk N objects from the start — billed, slow, bandwidth-heavy. Use FromName for anything beyond the first ~1000 entries.
GetDirectories / GetDirectoriesAsync don't accept offset/limit — directory counts are typically small. Pattern and async support are symmetric with files.
Cloud drivers (OCI, FTP) cache Length, CreationTimeUtc, and LastWriteTimeUtc locally and never do hidden I/O inside property getters. The cached values come from:
- the stat call that opened the file (
TryOpenFile,OpenFile) or listed it (GetFiles), - the create call (length 0 at creation),
- the write pipeline (length is updated as bytes stream).
That means file.Length immediately after SetText("hi") is 2 — no refresh needed for the common write-then-read flow.
When you need to re-sync with the server (someone mutated the object out-of-band, or you want the server-authoritative timestamp instead of a client-side estimate), cast to IRefreshable:
if (file is IRefreshable refresh)
await refresh.RefreshAsync(ct);A sync Refresh() is also on the interface — it just does RefreshAsync().GetAwaiter().GetResult() at the top-level boundary. Use the async variant under a SynchronizationContext (UI, ASP.NET classic) to avoid blocking.
Local and Memory drivers don't implement
IRefreshable— OS / in-process state is already authoritative. Cast-check is the portable pattern.
FileHub — unified file & directory API for .NET. Core is dependency-free; drivers are opt-in packages.