diff --git a/src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs b/src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs
new file mode 100644
index 0000000..302b51c
--- /dev/null
+++ b/src/Umbraco.StorageProviders/Abstractions/IStorageProvider.cs
@@ -0,0 +1,91 @@
+using Microsoft.Extensions.FileProviders;
+
+namespace Umbraco.StorageProviders;
+
+///
+/// A read/write storage provider abstraction.
+///
+///
+public interface IStorageProvider : IFileProvider
+{
+ ///
+ /// Create a writable stream to store file contents. Caller should dispose the stream when complete.
+ ///
+ /// The path/name of the file.
+ /// true if the file can be overwritten; otherwise, false.
+ ///
+ /// The file stream.
+ ///
+ Stream? CreateWriteStream(string subpath, bool overwrite = false);
+
+ ///
+ /// Moves the specified file to a new location, providing the option to specify a new file name.
+ ///
+ /// The path/name of the source file.
+ /// The path/name of the destination file. This cannot be a directory.
+ /// true if the destination file can be overwritten; otherwise, false.
+ /// The cancellation token.
+ ///
+ /// Returns a value indicating whether the operation succeeded.
+ ///
+ Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Moves the specified directory to a new location.
+ ///
+ /// The path/name of the source directory.
+ /// The path/name of the destination directory.
+ /// true if files in the destination directory can be overwritten; otherwise, false.
+ /// true to recursively move subdirectories and files; otherwise, false.
+ /// The cancellation token.
+ ///
+ /// Returns a value indicating whether the operation succeeded.
+ ///
+ Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Copies an existing file to a new file, providing the option to specify a new file name.
+ ///
+ /// The path/name of the source directory.
+ /// The path/name of the destination file. This cannot be a directory.
+ /// true if the destination file can be overwritten; otherwise, false.
+ /// The cancellation token.
+ ///
+ /// Returns a value indicating whether the operation succeeded.
+ ///
+ Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Copies an existing directory to a new directory.
+ ///
+ /// The path/name of the source directory.
+ /// The path/name of the destination directory.
+ /// true if files in the destination directory can be overwritten; otherwise, false.
+ /// true to recursively copy subdirectories and files; otherwise, false.
+ /// The cancellation token.
+ ///
+ /// Returns a value indicating whether the operation succeeded.
+ ///
+ Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes the specified file.
+ ///
+ /// The path/name of the file.
+ /// The cancellation token.
+ ///
+ /// Returns a value indicating whether the operation succeeded.
+ ///
+ Task DeleteAsync(string subpath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes the specified directory.
+ ///
+ /// The path/name of the directory.
+ /// true to recursively remove subdirectories and files; otherwise, false.
+ /// The cancellation token.
+ ///
+ /// Returns a value indicating whether the operation succeeded.
+ ///
+ Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default);
+}
diff --git a/src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs b/src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs
new file mode 100644
index 0000000..363b27c
--- /dev/null
+++ b/src/Umbraco.StorageProviders/Abstractions/StorageProviderBase.cs
@@ -0,0 +1,146 @@
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
+
+namespace Umbraco.StorageProviders;
+
+///
+/// Provides a generic implementation for most storage provider actions.
+///
+///
+/// The default implementation where moving is based on copy/delete and copying requires reading/writing the file contents.
+///
+///
+public abstract class StorageProviderBase : IStorageProvider
+{
+ ///
+ public abstract IDirectoryContents GetDirectoryContents(string subpath);
+
+ ///
+ public abstract IFileInfo GetFileInfo(string subpath);
+
+ ///
+ public virtual IChangeToken Watch(string filter) => NullChangeToken.Singleton;
+
+ ///
+ public abstract Stream CreateWriteStream(string subpath, bool overwrite = false);
+
+ ///
+ public virtual async Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default)
+ => await CopyAsync(sourceSubpath, destinationSubpath, overwrite, cancellationToken).ConfigureAwait(false) &&
+ await DeleteAsync(sourceSubpath, cancellationToken).ConfigureAwait(false);
+
+ ///
+ public virtual async Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ var sourceDirectoryContents = GetDirectoryContents(sourceSubpath);
+ if (sourceDirectoryContents is null || sourceDirectoryContents.Exists is false)
+ {
+ return false;
+ }
+
+ foreach (var sourceFileInfo in sourceDirectoryContents)
+ {
+ var sourcePath = CombinePath(sourceSubpath, sourceFileInfo.Name);
+ var destinationPath = CombinePath(destinationSubpath, sourceFileInfo.Name);
+
+ if (sourceFileInfo.IsDirectory is false)
+ {
+ await MoveAsync(sourcePath, destinationPath, overwrite, cancellationToken).ConfigureAwait(false);
+ }
+ else if (recursive)
+ {
+ await MoveDirectoryAsync(sourcePath, destinationPath, overwrite, recursive, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ public virtual async Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default)
+ => await CopyAsync(GetFileInfo(sourceSubpath), destinationSubpath, overwrite, cancellationToken).ConfigureAwait(false);
+
+ ///
+ protected virtual async Task CopyAsync(IFileInfo sourceFileInfo, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default)
+ {
+ if (sourceFileInfo is null || sourceFileInfo.Exists is false || sourceFileInfo.IsDirectory)
+ {
+ return false;
+ }
+
+ using (var source = sourceFileInfo.CreateReadStream())
+ using (var destination = CreateWriteStream(destinationSubpath, overwrite))
+ {
+ await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
+ }
+
+ return true;
+ }
+
+ ///
+ public virtual async Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ var sourceDirectoryContents = GetDirectoryContents(sourceSubpath);
+ if (sourceDirectoryContents is null || sourceDirectoryContents.Exists is false)
+ {
+ return false;
+ }
+
+ foreach (var sourceFileInfo in sourceDirectoryContents)
+ {
+ var destinationPath = CombinePath(destinationSubpath, sourceFileInfo.Name);
+
+ if (sourceFileInfo.IsDirectory is false)
+ {
+ await CopyAsync(sourceFileInfo, destinationPath, overwrite, cancellationToken).ConfigureAwait(false);
+ }
+ else if (recursive)
+ {
+ var sourcePath = CombinePath(sourceSubpath, sourceFileInfo.Name);
+
+ await CopyDirectoryAsync(sourcePath, destinationPath, overwrite, recursive, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ public abstract Task DeleteAsync(string subpath, CancellationToken cancellationToken = default);
+
+ ///
+ public virtual async Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ var directoryContents = GetDirectoryContents(subpath);
+ if (directoryContents is null || directoryContents.Exists is false)
+ {
+ return false;
+ }
+
+ foreach (var fileInfo in directoryContents)
+ {
+ var path = CombinePath(subpath, fileInfo.Name);
+
+ if (fileInfo.IsDirectory is false)
+ {
+ await DeleteAsync(path, cancellationToken).ConfigureAwait(false);
+ }
+ else if (recursive)
+ {
+ await DeleteDirectoryAsync(path, recursive, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Combines the path and file name.
+ ///
+ /// The path.
+ /// The file name.
+ ///
+ /// The combined path and file name.
+ ///
+ protected virtual string CombinePath(string path, string fileName) => Path.Combine(path, fileName);
+}
diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs
new file mode 100644
index 0000000..3997f6e
--- /dev/null
+++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryContents.cs
@@ -0,0 +1,56 @@
+using System.Collections;
+using Microsoft.Extensions.FileProviders;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.StorageProviders.FileSystem;
+
+///
+/// Represents the directory contents in an .
+///
+///
+public class FileSystemDirectoryContents : IDirectoryContents
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly string _subpath;
+ private IEnumerable _entries = null!;
+
+ ///
+ public bool Exists => true;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file system.
+ /// The subpath.
+ ///
+ /// fileSystem
+ /// or
+ /// subpath
+ ///
+ public FileSystemDirectoryContents(IFileSystem fileSystem, string subpath)
+ {
+ _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+ _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath));
+ }
+
+ ///
+ public IEnumerator GetEnumerator()
+ {
+ EnsureInitialized();
+ return _entries.GetEnumerator();
+ }
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ EnsureInitialized();
+ return _entries.GetEnumerator();
+ }
+
+ private void EnsureInitialized()
+ {
+ _entries = _fileSystem.GetDirectories(_subpath).Select(d => new FileSystemDirectoryInfo(_fileSystem, d))
+ .Union(_fileSystem.GetFiles(_subpath).Select(f => new FileSystemFileInfo(_fileSystem, f)))
+ .ToList();
+ }
+}
diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs
new file mode 100644
index 0000000..289cbee
--- /dev/null
+++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemDirectoryInfo.cs
@@ -0,0 +1,46 @@
+using Microsoft.Extensions.FileProviders;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.StorageProviders.FileSystem;
+
+///
+/// Represents a directory in an .
+///
+///
+public class FileSystemDirectoryInfo : IFileInfo
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly string _subpath;
+
+ ///
+ public bool Exists => true;
+
+ ///
+ public bool IsDirectory => true;
+
+ ///
+ public DateTimeOffset LastModified => _fileSystem.GetLastModified(_subpath);
+
+ ///
+ public long Length => -1;
+
+ ///
+ public string Name => _fileSystem.GetRelativePath(_subpath);
+
+ ///
+ public string PhysicalPath => null!;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file system.
+ /// The subpath.
+ public FileSystemDirectoryInfo(IFileSystem fileSystem, string subpath)
+ {
+ _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+ _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath));
+ }
+
+ ///
+ public Stream CreateReadStream() => throw new InvalidOperationException("Cannot create a stream for a directory.");
+}
diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs
new file mode 100644
index 0000000..cbd5dfb
--- /dev/null
+++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileInfo.cs
@@ -0,0 +1,46 @@
+using Microsoft.Extensions.FileProviders;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.StorageProviders.FileSystem;
+
+///
+/// Represents a file in an .
+///
+///
+public class FileSystemFileInfo : IFileInfo
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly string _subpath;
+
+ ///
+ public bool Exists => true;
+
+ ///
+ public bool IsDirectory => false;
+
+ ///
+ public DateTimeOffset LastModified => _fileSystem.GetLastModified(_subpath);
+
+ ///
+ public long Length => _fileSystem.GetSize(_subpath);
+
+ ///
+ public string Name => _fileSystem.GetRelativePath(_subpath);
+
+ ///
+ public string PhysicalPath => null!;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file system.
+ /// The subpath.
+ public FileSystemFileInfo(IFileSystem fileSystem, string subpath)
+ {
+ _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+ _subpath = subpath ?? throw new ArgumentNullException(nameof(subpath));
+ }
+
+ ///
+ public Stream CreateReadStream() => _fileSystem.OpenFile(_subpath);
+}
diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs
new file mode 100644
index 0000000..9809db6
--- /dev/null
+++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemFileProvider.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
+using Umbraco.Cms.Core.IO;
+using Umbraco.StorageProviders.FileSystem;
+
+namespace Umbraco.StorageProviders;
+
+///
+/// Exposes an as an .
+///
+///
+public class FileSystemFileProvider : IFileProvider
+{
+ ///
+ /// The file system.
+ ///
+ protected IFileSystem FileSystem { get; }
+
+ ///
+ /// The path prefix.
+ ///
+ protected string? PathPrefix { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file system.
+ /// The path prefix.
+ /// fileSystem
+ public FileSystemFileProvider(IFileSystem fileSystem, string? pathPrefix = null)
+ {
+ FileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+ PathPrefix = pathPrefix;
+ }
+
+ ///
+ public IDirectoryContents GetDirectoryContents(string subpath)
+ {
+ var path = PathPrefix + subpath;
+ if (path == null || !FileSystem.DirectoryExists(path))
+ {
+ return NotFoundDirectoryContents.Singleton;
+ }
+
+ return new FileSystemDirectoryContents(FileSystem, path);
+ }
+
+ ///
+ public IFileInfo GetFileInfo(string subpath)
+ {
+ var path = PathPrefix + subpath;
+ if (!FileSystem.FileExists(path))
+ {
+ return new NotFoundFileInfo(path);
+ }
+
+ return new FileSystemFileInfo(FileSystem, path);
+ }
+
+ ///
+ public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
+}
diff --git a/src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs b/src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs
new file mode 100644
index 0000000..5a52974
--- /dev/null
+++ b/src/Umbraco.StorageProviders/FileSystem/FileSystemStorageProvider.cs
@@ -0,0 +1,90 @@
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.StorageProviders;
+
+///
+/// Exposes an as an .
+///
+///
+///
+public class FileSystemStorageProvider : FileSystemFileProvider, IStorageProvider
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file system.
+ /// The path prefix.
+ public FileSystemStorageProvider(IFileSystem fileSystem, string? pathPrefix = null)
+ : base(fileSystem, pathPrefix)
+ { }
+
+ /////
+ //public void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false)
+ //{
+ // var sourcePath = PathPrefix + sourceSubpath;
+ // var destinationPath = PathPrefix + destinationSubpath;
+ // if (sourcePath != null && destinationPath != null)
+ // {
+ // using var stream = FileSystem.OpenFile(sourcePath);
+ // FileSystem.AddFile(destinationPath, stream, overwrite);
+ // }
+ //}
+
+ /////
+ //public void DeleteDirectory(string subpath, bool recursive = false)
+ //{
+ // var path = PathPrefix + subpath;
+ // if (path != null)
+ // {
+ // FileSystem.DeleteDirectory(path, recursive);
+ // }
+ //}
+
+ /////
+ //public void DeleteFile(string subpath)
+ //{
+ // var path = PathPrefix + subpath;
+ // if (path != null)
+ // {
+ // FileSystem.DeleteFile(path);
+ // }
+ //}
+
+ /////
+ //public void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false)
+ //{
+ // CopyFile(sourceSubpath, destinationSubpath, overwrite);
+ // DeleteFile(sourceSubpath);
+ //}
+
+ /////
+ //public void StoreFile(string subpath, Stream stream, bool overwrite = false)
+ //{
+ // var path = PathPrefix + subpath;
+ // if (path != null)
+ // {
+ // FileSystem.AddFile(path, stream, overwrite);
+ // }
+ //}
+
+ ///
+ public Stream? CreateWriteStream(string subpath, bool overwrite = false) => throw new NotImplementedException();
+
+ ///
+ public Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ ///
+ public Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ ///
+ public Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ ///
+ public Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ ///
+ public Task DeleteAsync(string subpath, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+
+ ///
+ public Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default) => throw new NotImplementedException();
+}
diff --git a/src/Umbraco.StorageProviders/Internal/PathUtils.cs b/src/Umbraco.StorageProviders/Internal/PathUtils.cs
new file mode 100644
index 0000000..07d8e43
--- /dev/null
+++ b/src/Umbraco.StorageProviders/Internal/PathUtils.cs
@@ -0,0 +1,49 @@
+using Microsoft.Extensions.Primitives;
+
+namespace Umbraco.StorageProviders.Internal;
+
+internal static class PathUtils
+{
+ private static readonly char[] _invalidFileNameChars = Path.GetInvalidFileNameChars()
+ .Where(c => c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar).ToArray();
+
+ private static readonly char[] _pathSeparators = new[]
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ };
+
+ internal static bool HasInvalidPathChars(string path)
+ {
+ return path.IndexOfAny(_invalidFileNameChars) != -1;
+ }
+
+ internal static bool PathNavigatesAboveRoot(string path)
+ {
+ var tokenizer = new StringTokenizer(path, _pathSeparators);
+ int depth = 0;
+
+ foreach (StringSegment segment in tokenizer)
+ {
+ if (segment.Equals(".") || segment.Equals(""))
+ {
+ continue;
+ }
+ else if (segment.Equals(".."))
+ {
+ depth--;
+
+ if (depth == -1)
+ {
+ return true;
+ }
+ }
+ else
+ {
+ depth++;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs b/src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs
new file mode 100644
index 0000000..38e10ba
--- /dev/null
+++ b/src/Umbraco.StorageProviders/Physical/PhysicalStorageProvider.cs
@@ -0,0 +1,440 @@
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.FileProviders.Physical;
+using Umbraco.StorageProviders.Internal;
+
+namespace Umbraco.StorageProviders;
+
+///
+/// Storage provider for the on-disk file system.
+///
+///
+///
+public class PhysicalStorageProvider : PhysicalFileProvider, IStorageProvider
+{
+ private static readonly char[] _pathSeparators = new[]
+ {
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ };
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The root directory. This should be an absolute path.
+ public PhysicalStorageProvider(string root)
+ : base(root)
+ { }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The root directory. This should be an absolute path.
+ /// Specifies which files or directories are excluded.
+ public PhysicalStorageProvider(string root, ExclusionFilters filters)
+ : base(root, filters)
+ { }
+
+ ///
+ public Stream? CreateWriteStream(string subpath, bool overwrite = false)
+ {
+ if (TryGetFullPath(subpath, out string fullPath))
+ {
+ EnsureDirectory(fullPath);
+
+ return File.Open(fullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.None);
+ }
+
+ return null;
+ }
+
+ ///
+ public Task MoveAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default)
+ {
+ var fileInfo = GetFileInfo(sourceSubpath);
+ if (fileInfo.Exists &&
+ fileInfo.PhysicalPath is string sourceFullPath &&
+ TryGetFullPath(destinationSubpath, out string destinationFullPath))
+ {
+ EnsureDirectory(destinationFullPath);
+
+ File.Move(sourceFullPath, destinationFullPath, overwrite);
+
+ return Task.FromResult(true);
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task MoveDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ var directoryContents = GetDirectoryContents(sourceSubpath);
+ if (directoryContents.Exists &&
+ TryGetFullPath(sourceSubpath, out string sourceFullPath) &&
+ TryGetFullPath(destinationSubpath, out string destinationFullPath))
+ {
+ EnsureDirectory(destinationFullPath);
+
+ bool deleteSourceDirectory = true;
+ foreach (var fileInfo in directoryContents)
+ {
+ if (fileInfo.PhysicalPath is string sourceFilePath)
+ {
+ var relativeSourcePath = Path.GetRelativePath(sourceFullPath, sourceFilePath);
+ var destinationFilePath = Path.Combine(destinationFullPath, relativeSourcePath);
+
+ if (!fileInfo.IsDirectory)
+ {
+ File.Move(sourceFilePath, destinationFilePath, overwrite);
+ }
+ else if (recursive)
+ {
+ MoveDirectoryAsync(sourceFilePath, destinationFilePath);
+ }
+ else
+ {
+ // We have subdirectories, but aren't recursively moving them, so can't delete source directory
+ deleteSourceDirectory = false;
+ }
+ }
+
+
+ }
+
+ var directoryInfo = new DirectoryInfo(sourceFullPath);
+ if (directoryInfo.Exists)
+ {
+ EnsureDirectory(destinationSubpath);
+
+ // Move files
+ foreach (var fileInfo in directoryInfo.EnumerateFiles())
+ {
+ var destinationFilePath = Path.Combine(destinationFullPath, Path.GetRelativePath(sourceFullPath, fileInfo.FullName));
+
+ fileInfo.MoveTo(destinationFilePath, overwrite);
+ }
+
+ if (recursive)
+ {
+ foreach (var subDirectoryInfo in directoryInfo.EnumerateDirectories())
+ {
+
+ }
+ }
+
+ return Task.FromResult(true);
+ }
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task CopyAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, CancellationToken cancellationToken = default)
+ {
+ if (TryGetFullPath(sourceSubpath, out string sourceFullPath) &&
+ TryGetFullPath(destinationSubpath, out string destinationFullPath))
+ {
+ var fileInfo = new FileInfo(sourceFullPath);
+ if (fileInfo.Exists)
+ {
+ EnsureDirectory(destinationSubpath);
+
+ fileInfo.CopyTo(destinationFullPath, overwrite);
+
+ return Task.FromResult(true);
+ }
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task CopyDirectoryAsync(string sourceSubpath, string destinationSubpath, bool overwrite = false, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ if (TryGetFullPath(sourceSubpath, out string sourceFullPath) &&
+ TryGetFullPath(destinationSubpath, out string destinationFullPath))
+ {
+ var directoryInfo = new DirectoryInfo(sourceFullPath);
+ if (directoryInfo.Exists)
+ {
+
+
+ return Task.FromResult(true);
+ }
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task DeleteAsync(string subpath, CancellationToken cancellationToken = default)
+ {
+ if (TryGetFullPath(subpath, out string fullPath))
+ {
+ var fileInfo = new FileInfo(subpath);
+ if (fileInfo.Exists)
+ {
+ fileInfo.Delete();
+
+ return Task.FromResult(true);
+ }
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public Task DeleteDirectoryAsync(string subpath, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ if (TryGetFullPath(subpath, out string fullPath))
+ {
+ var directoryInfo = new DirectoryInfo(subpath);
+ if (directoryInfo.Exists)
+ {
+ directoryInfo.Delete(recursive);
+
+ return Task.FromResult(true);
+ }
+ }
+
+ return Task.FromResult(false);
+ }
+
+ private bool TryGetFullPath(string subpath, out string fullPath)
+ {
+ if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath))
+ {
+ fullPath = null!;
+ return false;
+ }
+
+ // Relative paths starting with leading slashes are okay
+ subpath = subpath.TrimStart(_pathSeparators);
+
+ // Absolute paths not permitted
+ if (Path.IsPathRooted(subpath))
+ {
+ fullPath = null!;
+ return false;
+ }
+
+ if (PathUtils.PathNavigatesAboveRoot(subpath))
+ {
+ fullPath = null!;
+ return false;
+ }
+
+ try
+ {
+ fullPath = Path.GetFullPath(Path.Combine(Root, subpath));
+ }
+ catch
+ {
+ fullPath = null!;
+ return false;
+ }
+
+ if (!fullPath.StartsWith(Root, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void EnsureDirectory(string fullPath)
+ {
+ var directoryName = Path.GetDirectoryName(fullPath);
+ if (directoryName is not null && !Directory.Exists(directoryName))
+ {
+ Directory.CreateDirectory(directoryName);
+ }
+ }
+}
+
+//private class Temp
+//{
+// private static readonly char[] _pathSeparators = new[]
+// {
+// Path.DirectorySeparatorChar,
+// Path.AltDirectorySeparatorChar
+// };
+
+// ///
+// public void CopyFile(string sourceSubpath, string destinationSubpath, bool overwrite = false)
+// {
+// if (string.IsNullOrEmpty(sourceSubpath) || PathUtils.HasInvalidPathChars(sourceSubpath) ||
+// string.IsNullOrEmpty(destinationSubpath) || PathUtils.HasInvalidPathChars(destinationSubpath))
+// {
+// return;
+// }
+
+// // Relative paths starting with leading slashes are okay
+// sourceSubpath = sourceSubpath.TrimStart(_pathSeparators);
+// destinationSubpath = destinationSubpath.TrimStart(_pathSeparators);
+
+// // Absolute paths not permitted.
+// if (Path.IsPathRooted(sourceSubpath) || Path.IsPathRooted(destinationSubpath))
+// {
+// return;
+// }
+
+// string? sourceFullPath = GetFullPath(sourceSubpath);
+// if (sourceFullPath == null)
+// {
+// return;
+// }
+
+// string? destinationFullPath = GetFullPath(destinationSubpath);
+// if (destinationFullPath == null)
+// {
+// return;
+// }
+
+// File.Copy(sourceFullPath, destinationFullPath, overwrite);
+// }
+
+// ///
+// public void DeleteDirectory(string subpath, bool recursive = false)
+// {
+// if (subpath == null || PathUtils.HasInvalidPathChars(subpath))
+// {
+// return;
+// }
+
+// // Relative paths starting with leading slashes are okay
+// subpath = subpath.TrimStart(_pathSeparators);
+
+// // Absolute paths not permitted.
+// if (Path.IsPathRooted(subpath))
+// {
+// return;
+// }
+
+// string? fullPath = GetFullPath(subpath);
+// if (fullPath == null || !Directory.Exists(fullPath))
+// {
+// return;
+// }
+
+// Directory.Delete(fullPath, recursive);
+// }
+
+// ///
+// public void DeleteFile(string subpath)
+// {
+// if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath))
+// {
+// return;
+// }
+
+// // Relative paths starting with leading slashes are okay
+// subpath = subpath.TrimStart(_pathSeparators);
+
+// // Absolute paths not permitted.
+// if (Path.IsPathRooted(subpath))
+// {
+// return;
+// }
+
+// string? fullPath = GetFullPath(subpath);
+// if (fullPath == null || !Directory.Exists(fullPath))
+// {
+// return;
+// }
+
+// File.Delete(fullPath);
+// }
+
+// ///
+// public void MoveFile(string sourceSubpath, string destinationSubpath, bool overwrite = false)
+// {
+// if (string.IsNullOrEmpty(sourceSubpath) || PathUtils.HasInvalidPathChars(sourceSubpath) ||
+// string.IsNullOrEmpty(destinationSubpath) || PathUtils.HasInvalidPathChars(destinationSubpath))
+// {
+// return;
+// }
+
+// // Relative paths starting with leading slashes are okay
+// sourceSubpath = sourceSubpath.TrimStart(_pathSeparators);
+// destinationSubpath = destinationSubpath.TrimStart(_pathSeparators);
+
+// // Absolute paths not permitted.
+// if (Path.IsPathRooted(sourceSubpath) || Path.IsPathRooted(destinationSubpath))
+// {
+// return;
+// }
+
+// string? sourceFullPath = GetFullPath(sourceSubpath);
+// if (sourceFullPath == null)
+// {
+// return;
+// }
+
+// string? destinationFullPath = GetFullPath(destinationSubpath);
+// if (destinationFullPath == null)
+// {
+// return;
+// }
+
+// File.Move(sourceFullPath, destinationFullPath, overwrite);
+// }
+
+// ///
+// public void StoreFile(string subpath, Stream stream, bool overwrite = false)
+// {
+// if (string.IsNullOrEmpty(subpath) || PathUtils.HasInvalidPathChars(subpath) || stream is null)
+// {
+// return;
+// }
+
+// // Relative paths starting with leading slashes are okay
+// subpath = subpath.TrimStart(_pathSeparators);
+
+// // Absolute paths not permitted.
+// if (Path.IsPathRooted(subpath))
+// {
+// return;
+// }
+
+// string? fullPath = GetFullPath(subpath);
+// if (fullPath == null || !Directory.Exists(fullPath))
+// {
+// return;
+// }
+
+// using var fileStream = new FileStream(fullPath, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write);
+// stream.CopyTo(fileStream);
+// }
+
+// private string? GetFullPath(string path)
+// {
+// if (PathUtils.PathNavigatesAboveRoot(path))
+// {
+// return null;
+// }
+
+// string fullPath;
+// try
+// {
+// fullPath = Path.GetFullPath(Path.Combine(Root, path));
+// }
+// catch
+// {
+// return null;
+// }
+
+// if (!IsUnderneathRoot(fullPath))
+// {
+// return null;
+// }
+
+// return fullPath;
+// }
+
+// private bool IsUnderneathRoot(string fullPath)
+// {
+// return fullPath.StartsWith(Root, StringComparison.OrdinalIgnoreCase);
+// }
+//}