Skip to content

Migrating to 2.0

Gustavo Viana edited this page Aug 7, 2026 · 4 revisions

Migrating to 2.0

2.0 tightens the file/directory contract so it mirrors System.IO semantics: destructive operations no longer happen by default, and error conditions now surface as specific, catchable exceptions instead of silent success or a generic failure.

This page lists every breaking change and how to update. Exception changes are breaking too — code that relied on an operation not throwing, or on a different exception type, must be updated even if no signature changed.


Behavioral changes

overwrite now defaults to false

CopyTo and MoveTo — on both files and directories — no longer clobber the destination by default. An existing destination throws FileAlreadyExistsException; the source is left untouched, no bytes written.

// 1.x: silently replaced an existing "report.pdf"
file.CopyTo(dir, "report.pdf");

// 2.0: throws FileAlreadyExistsException if "report.pdf" exists.
// Opt back into the old behavior explicitly:
file.CopyTo(dir, "report.pdf", overwrite: true);

For directories, overwrite: true merges into the existing destination (replacing colliding leaves); overwrite: false (the default) throws before anything is copied.

A file never replaces a directory

MoveTo mirrors File.Move: it refuses to drop a file where a directory exists, regardless of overwrite. In 1.x the Local driver would delete the whole directory tree first.

CreateFile(name) refuses an existing target

The single-argument overload no longer truncates/replaces an existing entry — it throws FileAlreadyExistsException when a file or directory already occupies the name.

// 1.x: truncated an existing "data.bin" to zero bytes
var f = dir.CreateFile("data.bin");

// 2.0: throws if "data.bin" exists. To clobber on purpose:
var f = dir.CreateFile("data.bin", overwrite: true);
// Or, to get a handle to whatever is there, open it:
var f = dir.OpenFile("data.bin");

Directory Delete is non-recursive by default

Delete() and Delete(name) gained a recursive parameter that defaults to false. Deleting a non-empty directory now throws DirectoryNotEmptyException instead of erasing the subtree.

// 1.x: wiped the whole subtree
dir.Delete();

// 2.0: throws DirectoryNotEmptyException when non-empty.
// To remove the subtree, ask for it:
dir.Delete(recursive: true);

File / named delete is idempotent-silent everywhere

Deleting a file (or a named child) that no longer exists is a silent no-op on every backend. Previously the OCI and FTP drivers threw FileNotFoundException on a missing target — a cleanup loop written against the other drivers could abort mid-way. Retries and double-deletes now succeed uniformly.

Rename is leaf-only

Rename(newName) changes the leaf name in place. A newName containing a / or \ separator now throws ArgumentException. In 1.x a separator silently turned the rename into a move plus directory creation.

// 1.x: silently moved the file into a new "archive" subdirectory
file.Rename("archive/report.pdf");

// 2.0: throws ArgumentException. Use MoveTo to relocate:
file.MoveTo(dir.CreateDirectory("archive"), "report.pdf");

OCI signed upload rejects header-binding options

ISignedUploadable.GetSignedUploadUrl(name, expiresIn, options) on Oracle Object Storage now throws NotSupportedException when options carry header-binding fields (ContentType, CacheControl, Metadata). OCI pre-authenticated requests cannot bind request headers, so 1.x returned a URL the caller believed was header-constrained but was not. Amazon S3 still binds these options into the signature. Passing no options (or an empty FileWriteOptions) works on both.

Empty / whitespace names are rejected

Entry names are validated the way System.IO validates a path segment: a null name throws ArgumentNullException, and an empty or all-whitespace name ("", " ") throws ArgumentException. In 1.x only null/empty was caught, so a whitespace-only name created a real but unusable entry. This applies to every name-taking API (CreateFile, CreateDirectory, OpenFile, Rename, MoveTo/CopyTo destination names, …) on every backend.

Read streams allow seeking past the end

Seeking a read stream past its length now mirrors FileStream: Position may exceed Length, and a read that starts at or after the end simply returns 0. The S3 and OCI drivers previously threw IOException("Seek past end of stream"). Code that relied on that throw must change; a legal seek-then-read that expected 0 bytes now works uniformly across all drivers.

Stream contract fixes on write / FTP streams

The returned streams now honor the BCL Stream contract more closely:

  • Flush after Dispose throws ObjectDisposedException. The S3/OCI write and multipart streams previously ignored a flush on a disposed stream.
  • FTP Position getter throws. FTP data channels are not seekable (CanSeek == false), so both the getter and setter of Position throw NotSupportedException; the getter previously leaked the inner position.
  • FTP write-stream Length returns bytes written instead of throwing.
  • FTP CanRead/CanWrite are fixed at open from the stream direction and no longer flip mid-lifetime as the data channel drains.

Exception changes

Even where a call signature is unchanged, the exception it throws may have changed. Update your catch blocks accordingly.

Situation 1.x 2.0
CopyTo / MoveTo onto an existing entry (default) silently overwrote FileAlreadyExistsException
CreateFile(name) onto an existing entry truncated / replaced FileAlreadyExistsException
Deleting a non-empty directory without recursive: true erased subtree DirectoryNotEmptyException
Deleting a missing file/child (OCI, FTP) FileNotFoundException silent no-op
Rename with a / or \ in the name silent move ArgumentException
Access denied on the Local driver FileHubException UnauthorizedAccessException
OCI signed upload with header-binding options silent, unconstrained URL NotSupportedException
null name to any name-taking API ArgumentException ArgumentNullException
Empty or all-whitespace name created / partial catch ArgumentException
Seeking a read stream past the end (S3/OCI) IOException allowed (mirrors FileStream)
Flush after Dispose on an S3/OCI write stream silent no-op ObjectDisposedException
FTP stream Position getter returned inner position NotSupportedException

Notes:

  • FileAlreadyExistsException and DirectoryNotEmptyException both derive from FileHubException : IOException, so a broad catch (IOException) keeps working. Catch the specific type to react to the exact condition and read DestinationPath on a collision.
  • UnauthorizedAccessException is now surfaced unwrapped on every backend (S3/OCI already mapped 401/403 to it). A single catch (UnauthorizedAccessException) now covers Local too.
  • FTP driver-level failures now throw the new FtpDriverException (exposing CompletionCode / Path) instead of a bare FileHubException, completing the per-provider exception family alongside AmazonS3DriverException / OracleObjectStorageDriverException. It derives from FileHubException, so existing catch (FileHubException) / catch (IOException) blocks are unaffected — this is additive.

Signature & API changes

FileDirectory renamed to DirectoryEntry

The directory type is now DirectoryEntry, pairing with FileEntry under the shared FileSystemEntry base. The old name broke the *Entry suffix and carried a misleading File prefix on a type that models a directory.

// 1.x
FileDirectory root = hub.GetRoot();

// 2.0
DirectoryEntry root = hub.GetRoot();

Update every FileDirectory reference — type declarations, method signatures, and custom driver base classes — to DirectoryEntry. A global find-and-replace of the whole word is safe; no other type contains it as a substring.

Cloud options and driver-exception types use the full provider prefix

Each provider now uses one prefix across its public types, so a single call no longer mixes two spellings of the same backend:

1.x 2.0
S3HubOptions AmazonS3HubOptions
S3DriverException AmazonS3DriverException
OciHubOptions OracleObjectStorageHubOptions
OciDriverException OracleObjectStorageDriverException
// 1.x
var hub = AmazonS3FileHub.Create(S3HubOptions.FromProfile("reports"));

// 2.0 — hub and options share the AmazonS3 prefix
var hub = AmazonS3FileHub.Create(AmazonS3HubOptions.FromProfile("reports"));

The factory methods (From*), properties, and members are otherwise unchanged — only the type name differs. Internal types (clients, sessions, streams) keep their short names; they never appear in consumer code.

New / changed parameters

// Files
FileEntry MoveTo(DirectoryEntry dir, string name, IProgress<TransferStatus> progress = null, bool overwrite = false);
FileEntry CopyTo(DirectoryEntry dir, string name, IProgress<TransferStatus> progress = null, bool overwrite = false);

// Directories — overwrite and recursive params added
DirectoryEntry MoveTo(DirectoryEntry dir, string name, bool overwrite = false);
DirectoryEntry CopyTo(DirectoryEntry dir, string name, bool overwrite = false);
void Delete(bool recursive = false);
void Delete(string name, bool recursive = false);

The defaults keep most call sites compiling, but the runtime behavior changed (see above) — review calls that relied on the old clobber/recursive defaults.

Directory MoveTo/CopyTo intentionally omit the IProgress<TransferStatus> parameter that the file overloads carry: a directory transfer is recursive, so a single aggregate progress figure would be misleading. Track progress per file if you need it.

Write stream preference moved into FileWriteOptions

Stream-commit preference is configured through FileWriteOptions.StreamPreference across all drivers and write APIs, rather than a separate parameter.

Multipart interfaces removed

IMultipartUploadable and IMultipartUploadableDirectory are gone. Large writes spill to multipart automatically; thresholds and part size are configured globally or per write through the multipart options.

DirectoryPathMode dropped

The OpenIntermediates / Direct switch was removed. Nested paths ("a/b/c") always resolve in the fewest backend operations (one recursive mkdir/MKD, one PUT/LIST on object storage). Missing intermediates are created implicitly by CreateDirectory.

Cloud hub From* factories removed

The From* static factories on AmazonS3FileHub / OracleObjectStorageFileHub were removed. Build the options object instead:

// 1.x
var hub = AmazonS3FileHub.FromAccessKey(...);

// 2.0
var hub = AmazonS3FileHub.Create(AmazonS3HubOptions.FromAccessKey(...));
var hub = OracleObjectStorageFileHub.Create(OracleObjectStorageHubOptions.FromConfigFile(...));

FTP hub uses a single Create(FtpHubOptions) entry point

FtpFileHub.Connect / ConnectAsync, FromCredentials / FromCredentialsAsync, and FromClient / FromClientAsync were removed in favour of one factory pair, Create(FtpHubOptions) / CreateAsync(...). Connection, root, and the new FTPS (TLS) settings all live on FtpHubOptions.

// 1.x
var hub = FtpFileHub.Connect("ftp.example.com", user: "svc", password: "s3cret");
var hub = await FtpFileHub.FromClientAsync(client, ownsClient: true);

// 2.0
var hub = FtpFileHub.Create(
    FtpHubOptions.FromCredentials("ftp.example.com", user: "svc", password: "s3cret"));
var hub = await FtpFileHub.CreateAsync(FtpHubOptions.FromClient(client, ownsClient: true));

// New: FTPS
var hub = FtpFileHub.Create(FtpHubOptions.FromCredentials(
    "ftp.example.com", user: "svc", password: "s3cret",
    encryption: FluentFTP.FtpEncryptionMode.Explicit));

Custom driver authors

The abstract surface of DirectoryEntry flipped to async-first. Implement the async primitives; the base class supplies every sync sibling by bridging to them:

  • Implement: CreateFileAsync, TryOpenFileAsync, CreateDirectoryAsync, TryOpenDirectoryAsync, FileExistsAsync, DirectoryExistsAsync, DeleteAsync(bool recursive), DeleteAsync(string name, bool recursive).
  • Do not implement both rails independently — override a sync method only for a genuine protocol constraint.
  • CreateFileAsync(name) must refuse an existing target (throw FileAlreadyExistsException); the overwrite overload is what clobbers.
  • Rename must reject path separators — call NestedPath.EnsureLeaf(newName).
  • Validate names through PathUtil.ValidateName (or ValidateLocalName): it now rejects null (ArgumentNullException) and empty/whitespace (ArgumentException). Don't hand-roll the check.
  • Returned streams must follow the BCL Stream contract: a read stream may be positioned past Length (a read there returns 0, don't throw); flush/read/ write after Dispose must throw ObjectDisposedException; if CanSeek is false, both Position accessors throw.
  • Enumeration stays sync-abstract (IAsyncEnumerable is absent on netstandard2.0); the async enumeration defaults wrap it.

See Custom drivers for the full contract.


Quick checklist

  • Add overwrite: true to any CopyTo/MoveTo that intentionally replaces.
  • Add recursive: true to any Delete that intentionally removes a subtree.
  • Replace CreateFile(name) used to truncate with CreateFile(name, overwrite: true) or OpenFile(name).
  • Replace Rename("a/b")-style relocation with MoveTo.
  • Catch FileAlreadyExistsException / DirectoryNotEmptyException where you previously relied on silent overwrite/erase.
  • Catch UnauthorizedAccessException for Local permission errors (was FileHubException).
  • Stop passing header-binding options to OCI signed uploads (or catch NotSupportedException).
  • Move StreamPreference into FileWriteOptions; drop any IMultipartUploadable / DirectoryPathMode usage.
  • Switch cloud hubs to Create(AmazonS3HubOptions.From*) / Create(OracleObjectStorageHubOptions.From*).
  • Rename every FileDirectory reference to DirectoryEntry.
  • Rename S3HubOptions/OciHubOptions and S3DriverException/OciDriverException to their full-prefix (AmazonS3* / OracleObjectStorage*) forms.
  • Trim names before use; expect ArgumentException on empty/whitespace and ArgumentNullException on null.
  • Drop any catch (IOException) that relied on S3/OCI throwing when seeking past end of stream.
  • Don't flush an S3/OCI write stream after disposing it (throws ObjectDisposedException); don't read Position on an FTP stream.

Clone this wiki locally