Skip to content
Gustavo Viana edited this page Aug 7, 2026 · 15 revisions

API reference

All types live in the FileHub namespace. Each driver ships a marker interface (e.g. ILocalFileHub) that inherits from IFileHub.

IFileHub

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.

DirectoryEntry

public abstract class DirectoryEntry : FileSystemEntry
{
    public abstract DirectoryEntry Parent { get; }
}

Files

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.

Directories

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)

Common

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).

Nested paths and trailing separators

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.

TryOpen*Async tuple shape

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.

Tiny example

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.

FileEntry

public abstract class FileEntry : FileSystemEntry
{
    public          string Extension { get; }
    public abstract long   Length    { get; }
    public abstract DirectoryEntry Parent { get; }
}

Content

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)

Write with options

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)

WriteStreamPreference

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);

Metadata

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"

Lifecycle

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.

Tiny example

var file = hub.Root.OpenFile("report.pdf");
await file.CopyToAsync(otherHub.Root, "report.pdf", ct);  // works across any two drivers

FileSystemEntry

Shared 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 / LastWriteTimeUtc are 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 require Refresh() for a round-trip. Writes through cloud drivers update the cached length locally so file.Length after SetText is correct without a refresh.
  • Exists() may return false after Delete(); the object itself stays alive.
  • IsReadOnly = true causes every write to throw FileHubException — see Security → Read-only.

IUrlAccessible

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

Tiny example

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.

IRefreshable

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

Tiny example

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 now

Writes 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.

ILazyLoad

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 with Length = -1, default timestamps and an empty metadata snapshot. No HEAD/PUT is fired until you write or call Exists().
  • GetFiles(...) — LIST doesn'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.

Tiny example

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".

FileWriteOptions

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).

Tiny example

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.

FileMetadata

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).

TransferStatus

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.

FileListOffset

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.

FileHubException

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

Tiny example

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.

PartialMoveException

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 — generic IOException handlers catch it.
  • FileNotFoundException on the delete step is not wrapped — the source is already gone, so the move is effectively complete.
  • Rename does not wrap transient/backend errors — those propagate raw. A name collision is the exception: Rename always throws FileAlreadyExistsException rather than overwriting.
  • For directory moves on S3/OCI, InnerException is an AggregateException carrying 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.

FileAlreadyExistsException

Thrown when an operation refuses to overwrite an entry that already exists at the destination:

  • CopyTo / MoveTo (sync and async) onto an existing entry — overwrite defaults to false; pass overwrite: true to clobber/merge instead.
  • Rename onto 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 Exists probe 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);
}

Clone this wiki locally