-
Notifications
You must be signed in to change notification settings - Fork 0
API
All types live in the FileHub namespace. Each driver ships a marker interface (e.g. ILocalFileHub) that inherits from IFileHub.
- IFileHub
- DirectoryEntry
- FileEntry
- FileSystemEntry (common base)
- IUrlAccessible
- IRefreshable
- ILazyLoad
- FileWriteOptions
- FileMetadata
- TransferStatus
- FileListOffset
- FileHubException
- PartialMoveException
public interface IFileHub
{
DirectoryEntry Root { get; }
}Entry point. Root is the sandbox; everything else is reached from it. Per-driver capabilities (metadata, signed URLs, lazy stubs, …) are documented on each driver page.
| Marker | Assembly |
|---|---|
ILocalFileHub |
FileHub |
IMemoryFileHub |
FileHub |
IAmazonS3FileHub |
FileHub.AmazonS3 |
IOracleObjectStorageFileHub |
FileHub.OracleObjectStorage |
IFtpFileHub |
FileHub.Ftp |
Use a marker when a consumer must bind to a specific backend; use IFileHub when it's backend-agnostic.
public abstract class DirectoryEntry : FileSystemEntry
{
public abstract DirectoryEntry Parent { get; }
}| Sync | Async | Notes |
|---|---|---|
CreateFile(name) |
CreateFileAsync(name, ct) |
Throws FileAlreadyExistsException if a file or directory already exists at name. Accepts nested paths and a trailing / or \. |
CreateFile(name, overwrite) |
CreateFileAsync(name, overwrite, ct) |
overwrite: true ⇒ DeleteIfExists first. |
OpenFile(name) |
OpenFileAsync(name, ct) |
Throws FileNotFoundException if missing. Accepts nested paths. |
OpenFile(name, createIfNotExists) |
OpenFileAsync(name, createIfNotExists, ct) |
Open-or-create. createIfNotExists: true creates the leaf and any missing ancestor directories; false throws FileNotFoundException. Accepts nested paths. |
TryOpenFile(name, out FileEntry) |
TryOpenFileAsync(name, ct) → (FileEntry, bool)
|
Non-throwing. Accepts nested paths. Async returns a tuple — out is unavailable across await. |
GetFiles(pattern, offset, limit) |
GetFilesAsync(pattern, offset, limit, ct) |
See Usage → Pagination. |
| Sync | Async | Notes |
|---|---|---|
CreateDirectory(name) |
CreateDirectoryAsync(name, ct) |
Accepts nested paths and a trailing / or \. |
OpenDirectory(name) |
OpenDirectoryAsync(name, ct) |
Throws DirectoryNotFoundException if missing. Accepts nested paths and a trailing separator. |
OpenDirectory(name, createIfNotExists) |
OpenDirectoryAsync(name, createIfNotExists, ct) |
Open-or-create. createIfNotExists: true creates the leaf and any missing ancestor directories; false throws DirectoryNotFoundException. Accepts nested paths and a trailing separator. |
TryOpenDirectory(name, out DirectoryEntry) |
TryOpenDirectoryAsync(name, ct) → (DirectoryEntry, bool)
|
Non-throwing. Accepts nested paths and a trailing separator. Async returns a tuple. |
GetDirectories(pattern) |
GetDirectoriesAsync(pattern, ct) |
| Sync | Async | Notes |
|---|---|---|
FileExists(name) |
FileExistsAsync(name, ct) |
Accepts nested paths and a trailing separator. |
DirectoryExists(name) |
DirectoryExistsAsync(name, ct) |
Accepts nested paths and a trailing separator. |
Exists(name) |
ExistsAsync(name, ct) |
File or directory at name. S3/OCI answer with one LIST; the base does file-then-dir. |
Delete(recursive) / Delete(name, recursive) / DeleteIfExists(name)
|
DeleteAsync siblings |
recursive defaults to false — a non-empty directory throws DirectoryNotEmptyException unless recursive: true (mirrors System.IO.Directory.Delete). Delete(name) is idempotent-silent when the target is missing. The name form accepts nested paths and a trailing separator. |
Rename(newName) |
RenameAsync(newName, ct) |
Leaf name only — a newName containing / or \ throws ArgumentException (use MoveTo to relocate). Never overwrites — throws FileAlreadyExistsException if the name is taken. |
MoveTo(dir, name, overwrite) / CopyTo(dir, name, overwrite)
|
MoveToAsync / CopyToAsync
|
Leaf name only. overwrite defaults to false — an existing destination throws FileAlreadyExistsException; overwrite: true merges into it, replacing colliding leaves. |
FileExists and DirectoryExists are intentionally separate. In object-storage drivers (S3/OCI) a key foo and a prefix foo/ can coexist, so a single combined check would be ambiguous; in every backend the split also collapses to a single round-trip per call. Callers that want either should call both — or use Exists(name), which answers "is anything here?" (the object-storage drivers override it to a single LIST instead of two probes).
Every method that takes a relative name — CreateFile/OpenFile/TryOpenFile, CreateDirectory/OpenDirectory/TryOpenDirectory, FileExists/DirectoryExists, Delete(name)/DeleteIfExists(name) — accepts:
- A subpath with
/or\as separator ("reports/2026/q1.pdf","reports\\2026\\q1.pdf"). - A trailing
/or\("reports/","reports/2026/"— useful when callers concatenate an external path).
Absolute paths (leading separator) and .. / . segments are still rejected with FileHubException.
// Creates "reports/2026/q1.pdf", auto-creating intermediate directories.
hub.Root.CreateFile("reports/2026/q1.pdf").SetText("...");
// Same on the read side.
var file = hub.Root.OpenFile("reports/2026/q1.pdf");
if (hub.Root.TryOpenFile("reports/2026/q1.pdf", out var existing))
Console.WriteLine(existing.Length);
// Same on FileExists / DirectoryExists / Delete:
hub.Root.FileExists("reports/2026/q1.pdf");
hub.Root.DirectoryExists("reports/2026/"); // trailing slash tolerated
hub.Root.Delete("reports/2026/q1.pdf");On cost-optimised drivers (S3, OCI) intermediate directories are not materialised — the leaf write is a single PUT. See Driver: Amazon S3 → Cost minimization and Driver: Oracle Object Storage → Cost minimization.
Async can't return through an out parameter, so the async non-throwing variants return a tuple:
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);
if (found) foreach (var f in sub.GetFiles()) Process(f);The base implementation wraps the sync version; FTP / S3 / OCI override with native async. The same FileNotFoundException / DirectoryNotFoundException filtering rules apply — anything else still throws.
var logs = hub.Root.CreateDirectory("2026/01/logs"); // nested ⇒ creates intermediates
logs.CreateFile("app.log").SetText("ready");
foreach (var f in hub.Root.GetFiles("*.log"))
Console.WriteLine(f.Path);Nested paths are resolved as a whole in the fewest operations the backend supports: one recursive mkdir on disk, one PUT/LIST on object storage, one recursive MKD/probe on FTP, an in-process walk in memory. Missing intermediates are created implicitly by CreateDirectory.
public abstract class FileEntry : FileSystemEntry
{
public string Extension { get; }
public abstract long Length { get; }
public abstract DirectoryEntry Parent { get; }
}| Sync | Async |
|---|---|
ReadAllText() / ReadAllText(Encoding)
|
ReadAllTextAsync(ct) / ReadAllTextAsync(Encoding, ct)
|
ReadAllBytes() |
ReadAllBytesAsync(ct) |
SetText(content, Encoding?) |
SetTextAsync(content, Encoding?, ct) |
SetBytes(buffer) |
SetBytesAsync(buffer, ct) |
GetReadStream() |
GetReadStreamAsync(ct) |
GetWriteStream() |
GetWriteStreamAsync(ct) |
CopyToStream(Stream) / CopyToStream(Stream, IProgress<TransferStatus>)
|
CopyToStreamAsync(Stream, ct) / CopyToStreamAsync(Stream, IProgress<TransferStatus>, ct)
|
CopyFromStream(Stream) / CopyFromStream(Stream, IProgress<TransferStatus>)
|
CopyFromStreamAsync(Stream, ct) / CopyFromStreamAsync(Stream, IProgress<TransferStatus>, ct)
|
Every write entry point takes an optional FileWriteOptions parameter (the async rail keeps the CancellationToken last) — apply content type, cache-control, and per-object metadata at the moment the bytes are committed. Drivers without a native metadata surface ignore the options and write the bytes normally.
| Sync | Async |
|---|---|
SetText(content, Encoding, FileWriteOptions) |
SetTextAsync(content, Encoding, FileWriteOptions, ct) |
SetBytes(buffer, FileWriteOptions) |
SetBytesAsync(buffer, FileWriteOptions, ct) |
GetWriteStream(FileWriteOptions) |
GetWriteStreamAsync(FileWriteOptions, ct) |
CopyFromStream(Stream, FileWriteOptions) / CopyFromStream(Stream, FileWriteOptions, IProgress<TransferStatus>)
|
CopyFromStreamAsync(Stream, FileWriteOptions, ct) / CopyFromStreamAsync(Stream, FileWriteOptions, IProgress<TransferStatus>, ct)
|
Set FileWriteOptions.StreamPreference to hint how the write should commit. Because the preference lives in the common options object, it applies consistently to SetBytes, SetText, CopyFromStream and GetWriteStream. Drivers without a multipart surface (Local, Memory, FTP) ignore it silently — the same contract as unsupported FileWriteOptions fields.
| Value | Behaviour on S3 / OCI |
|---|---|
Auto (default) |
Buffer up to the configured threshold (32 MiB by default), then spill to multipart. |
Single |
Never spill: the whole payload buffers in memory and commits as one PutObject. Caller owns the memory cost — use only when the payload is known small. |
Multipart |
Multipart from the first written byte — skips the buffering phase. Use when the payload is known large. |
The policy is grouped in one immutable value:
new MultipartStreamOptions(
threshold: 32 * 1024 * 1024,
partSize: 64 * 1024 * 1024)Set it on AmazonS3HubOptions.Multipart / OracleObjectStorageHubOptions.Multipart for the whole hub, or on the provider-specific S3WriteOptions.Multipart / OciWriteOptions.Multipart for one write. The per-write value replaces the complete hub policy. Single ignores both values; Multipart ignores Threshold but uses PartSize.
// Known-large payload: skip the initial single-request buffer.
var options = new FileWriteOptions
{
StreamPreference = WriteStreamPreference.Multipart,
};
using var stream = await file.GetWriteStreamAsync(options, ct);
await source.CopyToAsync(stream, ct);| Sync | Async |
|---|---|
GetMetadata() |
GetMetadataAsync(ct) |
GetMetadata* returns an immutable FileMetadata snapshot. When the file was already loaded by an earlier op (a strict OpenFile / TryOpenFile that paid a HEAD), the cached snapshot is returned with no round-trip; otherwise the driver fires a single HEAD. Drivers without a per-object metadata surface return an empty snapshot. S3 returns an AmazonS3FileMetadata — downcast to read StorageClass / ServerSideEncryption.
var file = hub.Root.OpenFile("report.pdf");
// Write with metadata applied at commit time.
await file.SetBytesAsync(bytes, new FileWriteOptions
{
ContentType = "application/pdf",
CacheControl = "public,max-age=3600",
Metadata = new Dictionary<string, string> { ["owner"] = "team-x" },
}, ct);
// Read it back.
var meta = await file.GetMetadataAsync(ct);
Console.WriteLine(meta.ContentType); // "application/pdf"
Console.WriteLine(meta.Tags["owner"]); // "team-x"| Sync | Async |
|---|---|
Delete() |
DeleteAsync(ct) — idempotent-silent; deleting a missing file is a no-op on every backend |
Rename(newName) |
RenameAsync(newName, ct) — leaf name only (a / or \ throws ArgumentException); never overwrites, throws FileAlreadyExistsException on a taken name |
MoveTo(dir, name, progress, overwrite) |
MoveToAsync(dir, name, progress, overwrite, ct) |
CopyTo(newName, progress, overwrite) / CopyTo(dir, name, progress, overwrite)
|
CopyToAsync(...) — cross-driver safe (falls back to stream copy) |
overwrite defaults to false: an existing destination throws FileAlreadyExistsException and the source is left untouched, no bytes written. Pass overwrite: true to clobber the destination instead. A file never overwrites or deletes a directory. On same-backend fast paths (object-store CopyObject, FTP RNFR/RNTO) the guard is a best-effort Exists probe, not atomic against a concurrent writer.
var file = hub.Root.OpenFile("report.pdf");
await file.CopyToAsync(otherHub.Root, "report.pdf", ct); // works across any two driversShared base for FileEntry and DirectoryEntry.
public abstract class FileSystemEntry : IDisposable
{
public abstract string Path { get; }
public string Name { get; protected set; }
public bool IsReadOnly { get; protected set; }
public abstract DateTime CreationTimeUtc { get; }
public abstract DateTime LastWriteTimeUtc { get; }
public abstract bool Exists();
public virtual Task<bool> ExistsAsync(CancellationToken ct = default);
protected void ThrowIfReadOnly(); // used by driver writes
protected static void ValidateName(string); // portable rule (PathUtil): blocks empty, '.', '..', separators, control chars
}-
CreationTimeUtc/LastWriteTimeUtcare cached values. Local/Memory read them straight from the in-process / OS state on access; cloud drivers (OCI, FTP) return whatever was last fetched and requireRefresh()for a round-trip. Writes through cloud drivers update the cached length locally sofile.LengthafterSetTextis correct without a refresh. -
Exists()may returnfalseafterDelete(); the object itself stays alive. -
IsReadOnly = truecauses every write to throwFileHubException— see Security → Read-only.
Optional interface a FileEntry may implement when the file is reachable through a URL.
public interface IUrlAccessible
{
bool IsPublic { get; }
Uri GetPublicUrl();
Uri GetSignedUrl(TimeSpan expiresIn);
Task<Uri> GetSignedUrlAsync(TimeSpan expiresIn, CancellationToken ct = default);
}| Driver | Implements? |
|---|---|
LocalFile, MemoryFile
|
No |
AmazonS3File |
Yes |
OracleObjectStorageFile |
Yes |
FtpFile |
No |
if (file is IUrlAccessible url)
{
var link = url.IsPublic
? url.GetPublicUrl()
: await url.GetSignedUrlAsync(TimeSpan.FromMinutes(15), ct);
return Redirect(link.ToString());
}
return PhysicalFile(file.Path, "application/octet-stream");GetPublicUrl() throws InvalidOperationException if IsPublic == false. GetSignedUrl throws ArgumentOutOfRangeException when expiresIn <= TimeSpan.Zero.
Optional interface implemented by FileEntry / DirectoryEntry whose metadata is cached and must be re-fetched explicitly. Exists so property getters can stay cheap and non-blocking — hidden async-over-sync inside a getter risks deadlock under UI / ASP.NET (classic) SynchronizationContexts.
public interface IRefreshable
{
void Refresh();
Task RefreshAsync(CancellationToken ct = default);
}| Driver | File / Directory implement it? |
|---|---|
LocalFile, LocalDirectory
|
No (OS tracks it — no cache) |
MemoryFile, MemoryDirectory
|
No (state is authoritative) |
AmazonS3File, AmazonS3Directory
|
Yes |
OracleObjectStorageFile, OracleObjectStorageDirectory
|
Yes |
FtpFile, FtpDirectory
|
Yes |
var file = hub.Root.OpenFile("data.csv"); // stat'd at open, metadata populated
// ... time passes, bucket mutated externally ...
if (file is IRefreshable r) await r.RefreshAsync(ct);
Console.WriteLine(file.Length); // server-authoritative nowWrites through the driver update the cached length as bytes stream, so the common SetText → file.Length flow works without an explicit refresh. See Usage → Refreshing metadata.
Optional capability interface implemented by FileEntry types whose handles can be returned in an unloaded state — i.e. constructed without a server round-trip. Lets consumers detect whether the cached state (Length, LastWriteTimeUtc, the metadata snapshot, …) was actually populated from the store.
public interface ILazyLoad : IRefreshable
{
bool IsLoaded { get; }
}| Driver | File implements it? | Directory implements it? |
|---|---|---|
LocalFile, MemoryFile
|
No (state is always authoritative) | No |
AmazonS3File |
Yes | No |
OracleObjectStorageFile |
Yes | No |
FtpFile |
No | No |
Where unloaded handles come from on S3/OCI:
-
OpenFile(name, createIfNotExists: true)— returns a pending stub withLength = -1, default timestamps and an empty metadata snapshot. No HEAD/PUT is fired until you write or callExists(). -
GetFiles(...)—LISTdoesn't return per-object metadata, so each entry starts unloaded.
IsLoaded flips to true after any of: Refresh() / RefreshAsync(), an Exists() HEAD that hits, TryOpenFile resolving the entry, or a successful write commit. Exception: completing a signed multipart upload (CompleteSignedMultipartUpload) flips it back to false — the bytes went client-to-store, so the local Length/metadata snapshot is unknown until the next refresh.
var file = hub.Root.OpenFile("report.pdf", createIfNotExists: true);
if (file is ILazyLoad lazy && !lazy.IsLoaded)
{
// No HEAD has been fired yet — Length is -1, the metadata snapshot is empty.
if (file.Exists()) // 1 HEAD; on hit, IsLoaded flips to true.
Console.WriteLine(file.Length);
else
file.SetBytes(payload); // 1 PUT; commit also flips IsLoaded.
}Reading from a stub that doesn't exist on the server returns 0 bytes silently — Exists() is the contract for "is there really something here".
Options applied at write time via the optional FileWriteOptions parameter on FileEntry's write methods. Drivers that don't support a field ignore it silently — they never throw. Per-driver behaviour is documented on each driver page.
| Driver | Applies metadata? |
|---|---|
AmazonS3FileHub |
yes (ContentType, CacheControl, Metadata, plus StorageClass / ServerSideEncryption via S3WriteOptions) |
OracleObjectStorageFileHub |
yes (ContentType, CacheControl, Metadata) |
MemoryFileHub |
yes — ContentType, CacheControl, Metadata in-process storage (not StreamPreference) |
LocalFileHub |
no — disk has no per-object metadata API |
FtpFileHub |
no — protocol has no per-object metadata |
public class FileWriteOptions
{
public WriteStreamPreference StreamPreference { get; set; } = WriteStreamPreference.Auto;
public string ContentType { get; set; } // MIME type, e.g. "image/png"; null = driver default
public string CacheControl { get; set; } // HTTP Cache-Control header; null = omit
public IReadOnlyDictionary<string, string> Metadata { get; set; } // free-form key/value tags
}| Field | Type | Default | Maps to |
|---|---|---|---|
StreamPreference |
WriteStreamPreference |
Auto |
Write strategy (Auto, Single, or Multipart); ignored by drivers without multipart support |
ContentType |
string |
null (backend default) |
Content-Type header |
CacheControl |
string |
null (omitted) |
HTTP Cache-Control header |
Metadata |
IReadOnlyDictionary<string,string> |
null |
Backend user-metadata (x-amz-meta-* on S3, OCI opc-meta-*) |
Drivers backed by storage with extra per-object knobs expose a typed subclass — pass an instance of the subclass through the same parameter; the driver downcasts. S3 ships S3WriteOptions (adds StorageClass, ServerSideEncryption); OCI ships OciWriteOptions (no extra fields yet — reserved).
await file.SetBytesAsync(bytes, new FileWriteOptions
{
ContentType = "application/pdf",
CacheControl = "public,max-age=86400",
Metadata = new Dictionary<string, string> { ["owner"] = "team-x" },
}, ct);💡 Dica: there is no metadata-only update API. To change an object's metadata you write it again with
FileWriteOptions— that re-uploads the bytes.
Immutable read-only snapshot of a file's per-object metadata, returned by FileEntry.GetMetadataAsync. Each read returns a fresh snapshot — drivers replace it wholesale on refresh/write rather than mutating in place, so a snapshot a caller holds never changes underneath it.
public class FileMetadata
{
public FileMetadata(
string contentType = null,
string cacheControl = null,
IReadOnlyDictionary<string, string> tags = null);
public IReadOnlyDictionary<string, string> Tags { get; } // case-insensitive keys; read-only
public string ContentType { get; } // null when not tracked / not set
public string CacheControl { get; } // null when not tracked / not set
}There are no setters, no IsModified flag, and no SetTags — to change metadata, pass FileWriteOptions to the next write call. Driver subclasses add read-only typed properties — see AmazonS3FileMetadata (adds StorageClass, ServerSideEncryption).
public readonly struct TransferStatus
{
public long BytesTransferred { get; } // monotonic; ends at TotalBytes when known
public long TotalBytes { get; } // -1 when the driver doesn't know the length
public TransferStatus(long bytesTransferred, long totalBytes);
}Reported via IProgress<TransferStatus> from CopyToStreamAsync / CopyFromStreamAsync after each buffered chunk.
var progress = new Progress<TransferStatus>(s =>
Console.WriteLine($"{s.BytesTransferred}/{s.TotalBytes}"));
await file.CopyToStreamAsync(output, progress, ct);CopyTo uses FileEntry.Length for TotalBytes. CopyFrom uses the source stream's Length — pass a seekable stream when you want a populated total.
public readonly struct FileListOffset : IEquatable<FileListOffset>
{
public int Index { get; }
public string Name { get; }
public bool IsNamed { get; }
public static FileListOffset FromIndex(int index);
public static FileListOffset FromName (string name);
public static implicit operator FileListOffset(int index);
}Cursor passed to GetFiles(pattern, offset, limit) / GetFilesAsync. Three forms:
| Form | Semantics |
|---|---|
default / 0
|
Start from the beginning. |
int n / FromIndex(n)
|
Skip the first n entries. |
FromName("abc.txt") |
Start from the first entry with Name >= "abc.txt" (inclusive). |
Prefer FromName for deep paging on cloud drivers (S3, OCI page by string cursor — index offsets force a walk). See Usage → Pagination.
public class FileHubException : IOException
{
public FileHubException(string message);
public FileHubException(string message, Exception innerException);
}Extends IOException so callers that already catch I/O errors match it automatically.
| Thrown when… | Exception type |
|---|---|
Sandbox escape (.., absolute path, symlink out of root) |
FileHubException |
| Write on a read-only entry | FileHubException |
| Driver wraps an I/O-shaped backend failure | FileHubException |
| File missing and not creating | FileNotFoundException |
| Directory missing | DirectoryNotFoundException |
Delete of a non-empty directory without recursive: true
|
DirectoryNotEmptyException |
Copy/move with overwrite: false (the default) onto an existing entry, or Rename onto a taken name |
FileAlreadyExistsException |
null / empty / invalid name (.., ., path chars), or Rename with a / or \ separator |
ArgumentException |
| Access denied by the backend (all drivers, including Local) | UnauthorizedAccessException |
OCI signed-upload URL requested with header-binding options (ContentType / CacheControl / Metadata) |
NotSupportedException |
| Stream re-open on OCI while another is live | InvalidOperationException |
try
{
hub.Root.OpenDirectory("../escape");
}
catch (FileHubException ex)
{
logger.LogWarning(ex, "Blocked traversal attempt");
}Driver authors writing their own backend: wrap SDK-level failures that look like an I/O problem in FileHubException — see Custom Drivers.
Thrown by file.MoveTo / dir.MoveTo (on S3, OCI, FTP cross-credential paths) when the copy step succeeds and the source delete step then fails (revoked permissions, transient network, partial directory delete). Lets callers distinguish "move failed entirely" from "destination exists, source must be cleaned up manually".
public sealed class PartialMoveException : FileHubException
{
public string SourcePath { get; } // original still exists here
public string DestinationPath { get; } // copy succeeded here
}-
PartialMoveException : FileHubException : IOException— genericIOExceptionhandlers catch it. -
FileNotFoundExceptionon the delete step is not wrapped — the source is already gone, so the move is effectively complete. -
Renamedoes not wrap transient/backend errors — those propagate raw. A name collision is the exception:Renamealways throwsFileAlreadyExistsExceptionrather than overwriting. - For directory moves on S3/OCI,
InnerExceptionis anAggregateExceptioncarrying every per-object delete failure.
try
{
file.MoveTo(otherHub.Root, "name.txt");
}
catch (PartialMoveException ex)
{
logger.LogWarning(ex,
"Move partial: source still at {Src}, copy at {Dst}",
ex.SourcePath, ex.DestinationPath);
}Atomic rename paths (same-bucket OCI RenameObject, FTP RNFR/RNTO, Local Move) cannot leave partial state — they never throw this.
Thrown when an operation refuses to overwrite an entry that already exists at the destination:
-
CopyTo/MoveTo(sync and async) onto an existing entry —overwritedefaults tofalse; passoverwrite: trueto clobber/merge instead. -
Renameonto a name already taken — rename never overwrites, on any driver.
The source is left untouched; no bytes are written.
public sealed class FileAlreadyExistsException : FileHubException
{
public string DestinationPath { get; } // the entry that already exists
}-
FileAlreadyExistsException : FileHubException : IOException— generic I/O handlers catch it. - On object-storage (S3, OCI) and FTP same-backend fast paths the check is a best-effort
Existsprobe before the server op — not atomic against a concurrent writer.
try
{
src.CopyTo(hub.Root, "report.pdf", progress: null, overwrite: false);
}
catch (FileAlreadyExistsException ex)
{
logger.LogInformation("Kept existing {Path}", ex.DestinationPath);
}FileHub — unified file & directory API for .NET. Core is dependency-free; drivers are opt-in packages.