Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
<PackageVersion Include="Npgsql" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.3.0" />
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.10.0" />
Expand Down
31 changes: 31 additions & 0 deletions src/Sa.HybridFileStorage.FileSystem/FileRetryHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace Sa.HybridFileStorage.FileSystem;

internal static class FileRetryHelper
{
public static async Task RetryAsync(
Action action,
int maxRetries = 3,
int baseDelayMs = 100,
CancellationToken cancellationToken = default)
{
Exception? lastException = null;

for (int attempt = 0; attempt < maxRetries; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();

try
{
action();
return;
}
catch (IOException ex) when (attempt < maxRetries - 1)
{
lastException = ex;
await Task.Delay(baseDelayMs * (int)Math.Pow(2, attempt), cancellationToken);
}
}

throw lastException ?? new InvalidOperationException("Retry failed");
}
}
150 changes: 128 additions & 22 deletions src/Sa.HybridFileStorage.FileSystem/FileSystemStorage.cs
Original file line number Diff line number Diff line change
@@ -1,68 +1,150 @@
using Sa.HybridFileStorage.Domain;
using System.Security;

namespace Sa.HybridFileStorage.FileSystem;

internal sealed class FileSystemStorage(FileSystemStorageOptions options, TimeProvider? timeProvider = null) : IFileStorage
internal sealed class FileSystemStorage(
FileSystemStorageOptions options,
TimeProvider? timeProvider = null) : IFileStorage
{
private readonly string _basePath = Path.TrimEndingDirectorySeparator(options.BasePath);

private readonly string _basePath = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(options.BasePath ?? throw new ArgumentNullException(nameof(options.BasePath))));

private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;

public string? ScopeName => options.ScopeName;

public string StorageType { get; } = options.StorageType ?? "file";

public bool IsReadOnly { get; } = options.IsReadOnly ?? false;
public bool IsReadOnly { get; } = options.IsReadOnly;

private void EnsureWritable()
{
if (IsReadOnly)
{
throw new InvalidOperationException("Cannot perform this operation. The storage is read-only.");
throw new HybridFileStorageWritableException();
}
}

public async Task<StorageResult> UploadAsync(UploadFileInput metadata, Stream fileStream, CancellationToken cancellationToken)
public async Task<StorageResult> UploadAsync(
UploadFileInput metadata,
Stream fileStream,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(metadata);
ArgumentException.ThrowIfNullOrWhiteSpace(metadata.FileName);
ArgumentNullException.ThrowIfNull(fileStream);
EnsureWritable();

string filePath = $"{_basePath}/{metadata.TenantId}/{metadata.FileName.Replace('\\', '/')}";
string relativePath = PathSanitizer.SanitizeRelativePath(metadata.FileName);

string? dir = Path.GetDirectoryName(filePath);
EnsureDirectory(dir);
string filePath = Path.Combine(_basePath, metadata.TenantId.ToString(), relativePath);

EnsurePathWithinBase(filePath);

string? directory = Path.GetDirectoryName(filePath);
EnsureDirectory(directory);

await using var fileStreamOutput = new FileStream(filePath, new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Share = FileShare.None,
BufferSize = 4096,
Options = FileOptions.Asynchronous | FileOptions.SequentialScan,
PreallocationSize = 10 * 1024 * 1024
});

using var fileStreamOutput = new FileStream(filePath, FileMode.Create, FileAccess.Write);
await fileStream.CopyToAsync(fileStreamOutput, cancellationToken);

var fileId = FilePathToId(filePath);
var fileAbsolute = Path.GetFullPath(filePath);
var absolutePath = Path.GetFullPath(filePath);

return new StorageResult(fileId, fileAbsolute, StorageType, timeProvider?.GetUtcNow() ?? TimeProvider.System.GetUtcNow());
return new StorageResult(
FilePathToId(filePath),
absolutePath,
StorageType,
_timeProvider.GetUtcNow());
}

public async Task<bool> DownloadAsync(string fileId, Func<Stream, CancellationToken, Task> loadStream, CancellationToken cancellationToken)

public async Task<bool> DownloadAsync(
string fileId,
Func<Stream, CancellationToken, Task> loadStream,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fileId);
ArgumentNullException.ThrowIfNull(loadStream);


var filePath = FileIdToPath(fileId);
if (File.Exists(filePath))

EnsurePathWithinBase(filePath);


try
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
await using var fs = new FileStream(filePath, new FileStreamOptions
{
Mode = FileMode.Open,
Access = FileAccess.Read,
Share = FileShare.Read,
BufferSize = 81_920,
Options = FileOptions.Asynchronous | FileOptions.SequentialScan,
});

await loadStream(fs, cancellationToken);

return true;

}
catch (FileNotFoundException)
{
return false;
}
catch (DirectoryNotFoundException)
{
return false;
}
return false;
}

public Task<bool> DeleteAsync(string fileId, CancellationToken cancellationToken)
public async Task<bool> DeleteAsync(string fileId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fileId);
EnsureWritable();

var filePath = FileIdToPath(fileId);
if (File.Exists(filePath))
EnsurePathWithinBase(filePath);

if (!File.Exists(filePath))
{
File.Delete(filePath);
return Task.FromResult(true);
return false;
}

try
{
await FileRetryHelper.RetryAsync(
() => File.Delete(filePath),
cancellationToken: cancellationToken);

return true;
}
catch (IOException)
{
return false;
}
return Task.FromResult(false);
}

public bool CanProcess(string fileId)
=> fileId.StartsWith(FilePathToId(_basePath));
{
if (string.IsNullOrWhiteSpace(fileId))
{
return false;
}

var expectedPrefix = $"{StorageType}://";
return fileId.StartsWith(expectedPrefix, StringComparison.Ordinal);
}

private static void EnsureDirectory(string? dir)
{
Expand Down Expand Up @@ -93,4 +175,28 @@ private static string FileIdToPath(string fileId)

return filePath.ToString();
}

private void EnsurePathWithinBase(string path)
{
var fullPath = Path.GetFullPath(path);

if (!fullPath.StartsWith(_basePath, StringComparison.Ordinal))
{
throw new SecurityException($"""
Access denied. Path '{fullPath}' is outside the allowed base directory '{_basePath}'.
""");
}

// Edge-case защита: basePath="C:/Data", path="C:/DataOther"
if (fullPath.Length > _basePath.Length)
{
var nextChar = fullPath[_basePath.Length];
if (nextChar != Path.DirectorySeparatorChar && nextChar != Path.AltDirectorySeparatorChar)
{
throw new SecurityException($"""
Access denied. Path '{fullPath}' is outside the allowed base directory.
""");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
namespace Sa.HybridFileStorage.FileSystem;

public sealed class FileSystemStorageOptions
public sealed record FileSystemStorageOptions
{
public string StorageType { get; set; } = "file";
public required string BasePath { get; set; }
public bool? IsReadOnly { get; set; }
public string StorageType { get; init; } = "file";
public required string BasePath { get; init; }
public bool IsReadOnly { get; init; } = false;
public string? ScopeName { get; init; } = null;
}
Loading