diff --git a/zero.Core/FileStorage/FileSystemException.cs b/zero.Core/FileStorage/FileSystemException.cs
new file mode 100644
index 00000000..fc9d39b0
--- /dev/null
+++ b/zero.Core/FileStorage/FileSystemException.cs
@@ -0,0 +1,10 @@
+namespace zero.FileStorage;
+
+public class FileSystemException : Exception
+{
+ public FileSystemException() { }
+
+ public FileSystemException(string message) : base(message) { }
+
+ public FileSystemException(string message, Exception innerException) : base(message, innerException) { }
+}
\ No newline at end of file
diff --git a/zero.Core/FileStorage/IFileMeta.cs b/zero.Core/FileStorage/IFileMeta.cs
new file mode 100644
index 00000000..48edc66c
--- /dev/null
+++ b/zero.Core/FileStorage/IFileMeta.cs
@@ -0,0 +1,39 @@
+namespace zero.FileStorage;
+
+public interface IFileInfo
+{
+ ///
+ /// Full path of the item within the file system
+ ///
+ string Path { get; }
+
+ ///
+ /// Name of the item
+ ///
+ string Name { get; }
+
+ ///
+ /// Full path to the directory containing the item
+ ///
+ string DirectoryPath { get; }
+
+ ///
+ /// Length of the file in bytes
+ ///
+ long Length { get; }
+
+ ///
+ /// Date when the item was last modified
+ ///
+ DateTimeOffset LastModifiedDate { get; }
+
+ ///
+ /// Whether this item is directory or a file
+ ///
+ bool IsDirectory { get; }
+
+ ///
+ /// Additional properties
+ ///
+ Dictionary Properties { get; }
+}
diff --git a/zero.Core/FileStorage/IFileSystem.cs b/zero.Core/FileStorage/IFileSystem.cs
new file mode 100644
index 00000000..6d34bcc5
--- /dev/null
+++ b/zero.Core/FileStorage/IFileSystem.cs
@@ -0,0 +1,56 @@
+using System.IO;
+
+namespace zero.FileStorage;
+
+public interface IFileSystem
+{
+ ///
+ /// Get information of a file
+ ///
+ Task GetFileInfo(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns a readable stream for a file
+ ///
+ Task StreamFile(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a file at the given path
+ ///
+ Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a file at the given path. This method returns if the file does not exist.
+ ///
+ Task DeleteFile(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Moves a file to another path (can also be used to rename resources)
+ ///
+ Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Copies a file to another path
+ ///
+ Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get information of a directory
+ ///
+ Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all items within a directory
+ ///
+ IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Tries to create a directory at the given path. This method returns if the directory already exists.
+ ///
+ Task CreateDirectory(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a directory at the given path. This method returns if the directory does not exist.
+ ///
+ Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/zero.Core/FileStorage/PhysicalFileMeta.cs b/zero.Core/FileStorage/PhysicalFileMeta.cs
new file mode 100644
index 00000000..13aa767f
--- /dev/null
+++ b/zero.Core/FileStorage/PhysicalFileMeta.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.FileProviders;
+
+namespace zero.FileStorage;
+
+public class PhysicalFileMeta : IFileMeta
+{
+ ///
+ public string Path { get; }
+
+ ///
+ public string Name { get; }
+
+ ///
+ public string DirectoryPath { get; }
+
+ ///
+ public long Length { get; }
+
+ ///
+ public DateTimeOffset LastModifiedDate { get; }
+
+ ///
+ public bool IsDirectory { get; }
+
+ ///
+ public Dictionary Properties { get; }
+
+
+ public PhysicalFileMeta(IFileInfo fileInfo, string path)
+ {
+ Path = path;
+ Name = fileInfo.Name;
+ DirectoryPath = path.Substring(0, path.Length - fileInfo.Name.Length).TrimEnd('/');
+ LastModifiedDate = fileInfo.LastModified;
+ Length = fileInfo.Length;
+ IsDirectory = fileInfo.IsDirectory;
+ Properties = new();
+ }
+}
\ No newline at end of file
diff --git a/zero.Core/FileStorage/PhysicalFileSystem.cs b/zero.Core/FileStorage/PhysicalFileSystem.cs
new file mode 100644
index 00000000..8ef5d01a
--- /dev/null
+++ b/zero.Core/FileStorage/PhysicalFileSystem.cs
@@ -0,0 +1,275 @@
+using Microsoft.Extensions.FileProviders.Physical;
+using System.IO;
+
+namespace zero.FileStorage;
+
+public class PhysicalFileSystem : IFileSystem
+{
+ readonly string basePath;
+
+
+ public PhysicalFileSystem(string basePath)
+ {
+ this.basePath = basePath;
+ }
+
+
+ ///
+ public Task GetFileInfo(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ PhysicalFileInfo fileInfo = new(new FileInfo(resolvedPath));
+
+ if (!fileInfo.Exists)
+ {
+ return Task.FromResult(default);
+ }
+
+ return Task.FromResult(new PhysicalFileMeta(fileInfo, path));
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file info for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public Task StreamFile(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ if (resolvedPath.IsNullOrEmpty() || !File.Exists(resolvedPath))
+ {
+ throw new FileSystemException($"The path '{path}' does not exist in the file system");
+ }
+
+ return Task.FromResult(File.OpenRead(resolvedPath));
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file stream for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public async Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ if ((!overwrite && File.Exists(resolvedPath)) || Directory.Exists(resolvedPath))
+ {
+ throw new FileSystemException($"A file/directory at the path '{path}' already existsin the file system");
+ }
+
+ string directoryPath = Path.GetDirectoryName(resolvedPath);
+ Directory.CreateDirectory(directoryPath);
+
+ FileInfo fileInfo = new(resolvedPath);
+ using var outputStream = fileInfo.Create();
+ await fileStream.CopyToAsync(outputStream, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file stream for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public Task DeleteFile(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ File.Delete(resolvedPath);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not delete file for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedOldPath = ResolvePath(oldPath);
+ string resolvedNewPath = ResolvePath(newPath);
+
+ if (!File.Exists(resolvedOldPath))
+ {
+ throw new FileSystemException($"The path '{oldPath}' does not exist in the file system");
+ }
+
+ if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath))
+ {
+ throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system");
+ }
+
+ File.Move(resolvedOldPath, resolvedNewPath);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not move file from path '{oldPath}' to '{newPath}'", ex);
+ }
+ }
+
+
+ ///
+ public Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedOldPath = ResolvePath(oldPath);
+ string resolvedNewPath = ResolvePath(newPath);
+
+ if (!File.Exists(resolvedOldPath))
+ {
+ throw new FileSystemException($"The path '{oldPath}' does not exist in the file system");
+ }
+
+ if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath))
+ {
+ throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system");
+ }
+
+ File.Copy(resolvedOldPath, resolvedNewPath);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not copy file from path '{oldPath}' to '{newPath}'", ex);
+ }
+ }
+
+
+ ///
+ public Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default) => GetFileInfo(path, cancellationToken);
+
+
+ ///
+ public IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ List results = new();
+ SearchOption searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+
+ if (!Directory.Exists(resolvedPath))
+ {
+ return results.ToAsyncEnumerable();
+ }
+
+ results.AddRange(Directory.GetDirectories(resolvedPath, "*", searchOption).Select(f =>
+ {
+ PhysicalDirectoryInfo fileSystemInfo = new(new DirectoryInfo(f));
+ return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(basePath.Length)));
+ }));
+
+ results.AddRange(Directory.GetFiles(resolvedPath, "*", searchOption).Select(f =>
+ {
+ PhysicalFileInfo fileSystemInfo = new(new FileInfo(f));
+ return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(basePath.Length)));
+ }));
+
+ return results.ToAsyncEnumerable();
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get directory content for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public Task CreateDirectory(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ if (File.Exists(resolvedPath))
+ {
+ throw new FileSystemException($"A file at the path '{path}' already exists in the file system");
+ }
+
+ if (!Directory.Exists(resolvedPath))
+ {
+ Directory.CreateDirectory(resolvedPath);
+ }
+
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not create directory at path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ Directory.Delete(resolvedPath, recursive);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not delete directory at path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ /// Creates a fully-usable absolte path from the given path
+ ///
+ protected string ResolvePath(string path)
+ {
+ try
+ {
+ if (path.IsNullOrWhiteSpace())
+ {
+ return basePath;
+ }
+
+ // normalize path
+ path = path.Replace('\\', '/').FullTrim().Trim('/');
+
+ string physicalPath = Path.Combine(basePath, path);
+
+ // do not allow paths which are outside this file system
+ // the boundaries are set be the basePath which is assigned in the file system constructor
+ if (!Path.GetFullPath(physicalPath).StartsWith(basePath, StringComparison.InvariantCultureIgnoreCase))
+ {
+ throw new FileSystemException($"The path '{path}' is outside the file system");
+ }
+
+ return physicalPath;
+ }
+ catch (FileSystemException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new FileSystemException($"Could not resolve path '{path}'", ex);
+ }
+ }
+}