Repository navigation
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.
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.
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.
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");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);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(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");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.
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.
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.
The returned streams now honor the BCL Stream contract more closely:
-
Flush after
DisposethrowsObjectDisposedException. The S3/OCI write and multipart streams previously ignored a flush on a disposed stream. -
FTP
Positiongetter throws. FTP data channels are not seekable (CanSeek == false), so both the getter and setter ofPositionthrowNotSupportedException; the getter previously leaked the inner position. -
FTP write-stream
Lengthreturns bytes written instead of throwing. -
FTP
CanRead/CanWriteare fixed at open from the stream direction and no longer flip mid-lifetime as the data channel drains.
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:
-
FileAlreadyExistsExceptionandDirectoryNotEmptyExceptionboth derive fromFileHubException : IOException, so a broadcatch (IOException)keeps working. Catch the specific type to react to the exact condition and readDestinationPathon a collision. -
UnauthorizedAccessExceptionis now surfaced unwrapped on every backend (S3/OCI already mapped 401/403 to it). A singlecatch (UnauthorizedAccessException)now covers Local too. - FTP driver-level failures now throw the new
FtpDriverException(exposingCompletionCode/Path) instead of a bareFileHubException, completing the per-provider exception family alongsideAmazonS3DriverException/OracleObjectStorageDriverException. It derives fromFileHubException, so existingcatch (FileHubException)/catch (IOException)blocks are unaffected — this is additive.
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.
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.
// 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.
Stream-commit preference is configured through FileWriteOptions.StreamPreference
across all drivers and write APIs, rather than a separate parameter.
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.
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.
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(...));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));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 (throwFileAlreadyExistsException); theoverwriteoverload is what clobbers. -
Renamemust reject path separators — callNestedPath.EnsureLeaf(newName). - Validate names through
PathUtil.ValidateName(orValidateLocalName): it now rejectsnull(ArgumentNullException) and empty/whitespace (ArgumentException). Don't hand-roll the check. - Returned streams must follow the BCL
Streamcontract: a read stream may be positioned pastLength(a read there returns0, don't throw); flush/read/ write afterDisposemust throwObjectDisposedException; ifCanSeekisfalse, bothPositionaccessors throw. - Enumeration stays sync-abstract (
IAsyncEnumerableis absent on netstandard2.0); the async enumeration defaults wrap it.
See Custom drivers for the full contract.
- Add
overwrite: trueto anyCopyTo/MoveTothat intentionally replaces. - Add
recursive: trueto anyDeletethat intentionally removes a subtree. - Replace
CreateFile(name)used to truncate withCreateFile(name, overwrite: true)orOpenFile(name). - Replace
Rename("a/b")-style relocation withMoveTo. - Catch
FileAlreadyExistsException/DirectoryNotEmptyExceptionwhere you previously relied on silent overwrite/erase. - Catch
UnauthorizedAccessExceptionfor Local permission errors (wasFileHubException). - Stop passing header-binding options to OCI signed uploads (or catch
NotSupportedException). - Move
StreamPreferenceintoFileWriteOptions; drop anyIMultipartUploadable/DirectoryPathModeusage. - Switch cloud hubs to
Create(AmazonS3HubOptions.From*)/Create(OracleObjectStorageHubOptions.From*). - Rename every
FileDirectoryreference toDirectoryEntry. - Rename
S3HubOptions/OciHubOptionsandS3DriverException/OciDriverExceptionto their full-prefix (AmazonS3*/OracleObjectStorage*) forms. - Trim names before use; expect
ArgumentExceptionon empty/whitespace andArgumentNullExceptiononnull. - 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 readPositionon an FTP stream.
FileHub — unified file & directory API for .NET. Core is dependency-free; drivers are opt-in packages.