diff --git a/Directory.Build.props b/Directory.Build.props
index 2eb1685..29ed770 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -29,8 +29,8 @@
https://github.com/managedcode/Storage
https://github.com/managedcode/Storage
Managed Code - Storage
- 10.1.11
- 10.1.11
+ 10.1.12
+ 10.1.12
diff --git a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadFiles.cs b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadFiles.cs
index 33f71eb..620c478 100644
--- a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadFiles.cs
+++ b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadFiles.cs
@@ -13,7 +13,8 @@ internal static class ChunkUploadFiles
{
private const int CopyBufferSize = 64 * 1024;
- internal static async Task StageChunkAsync(ChunkUploadSession session, FileUploadPayload payload, CancellationToken cancellationToken)
+ internal static async Task StageChunkAsync(ChunkUploadSession session, FileUploadPayload payload, long length,
+ CancellationToken cancellationToken)
{
var chunkIndex = payload.Payload.ChunkIndex;
var chunkFilePath = Path.Combine(session.WorkingDirectory, $"{chunkIndex:D6}.part");
@@ -24,11 +25,13 @@ internal static async Task StageChunkAsync(ChunkUploadSession session, FileUploa
await using (var source = payload.File.OpenReadStream())
{
await source.CopyToAsync(target, CopyBufferSize, cancellationToken).ConfigureAwait(false);
+ if (target.Position != length)
+ throw new InvalidDataException("The staged chunk differs from its form file length.");
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(stagingPath, chunkFilePath, overwrite: true);
- session.RegisterChunk(chunkIndex, chunkFilePath);
+ session.RegisterChunk(chunkIndex, chunkFilePath, length);
}
finally
{
diff --git a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadOptions.cs b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadOptions.cs
index b29c3f2..f0de9dc 100644
--- a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadOptions.cs
+++ b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadOptions.cs
@@ -14,7 +14,8 @@ public class ChunkUploadOptions
public string TempPath { get; set; } = Path.Combine(Path.GetTempPath(), "managedcode-storage", "chunks");
///
- /// How long chunks are kept on disk after the last write. Expired sessions are cleaned up on completion or abort.
+ /// Idle time after which an upload session expires. Append and completion requests clean up expired sessions.
+ /// A nonpositive value disables expiration.
///
public TimeSpan SessionTtl { get; set; } = TimeSpan.FromHours(1);
diff --git a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadService.cs b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadService.cs
index d4a895a..fe0b6b8 100644
--- a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadService.cs
+++ b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadService.cs
@@ -1,10 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.IO;
-using System.Net;
-using System.Security.Cryptography;
-using System.Text;
using System.Linq;
+using System.Net;
using System.Threading;
using System.Threading.Tasks;
using ManagedCode.Communication;
@@ -48,10 +46,13 @@ public async Task AppendChunkAsync(FileUploadPayload payload, Cancellati
var descriptor = payload.Payload;
var uploadId = ValidateUploadId(ChunkUploadDescriptor.ResolveUploadId(descriptor));
+ var chunkLength = payload.File.Length;
if (descriptor.ChunkIndex <= 0 || descriptor.TotalChunks < 0 ||
(descriptor.TotalChunks > 0 && descriptor.ChunkIndex > descriptor.TotalChunks) ||
- descriptor.ChunkSize <= 0 || descriptor.FileSize < 0 || payload.File.Length > descriptor.ChunkSize)
+ descriptor.ChunkSize <= 0 || descriptor.FileSize < 0 || chunkLength < 0 || chunkLength > descriptor.ChunkSize)
return Result.Fail(HttpStatusCode.BadRequest, "Invalid chunk index or declared size");
+ if (chunkLength > ChunkUploadSession.ResolveExpectedLength(descriptor.TotalChunks, descriptor.FileSize))
+ return Result.Fail(HttpStatusCode.BadRequest, "Upload exceeds its declared length");
RemoveExpiredSessions();
var session = AdmitSession(uploadId, descriptor);
@@ -63,10 +64,14 @@ public async Task AppendChunkAsync(FileUploadPayload payload, Cancellati
{
if (session.Closed)
return Result.Fail("Upload session is closed");
+ if (TryExpireSession(session, DateTimeOffset.UtcNow))
+ return Result.Fail("Upload session has expired");
if (session.TotalChunks != descriptor.TotalChunks || session.FileSize != descriptor.FileSize)
return Result.Fail(HttpStatusCode.BadRequest, "Chunk does not match the upload session");
+ if (!session.CanAcceptChunk(descriptor.ChunkIndex, chunkLength))
+ return Result.Fail(HttpStatusCode.BadRequest, "Upload exceeds its declared length");
- await ChunkUploadFiles.StageChunkAsync(session, payload, cancellationToken).ConfigureAwait(false);
+ await ChunkUploadFiles.StageChunkAsync(session, payload, chunkLength, cancellationToken).ConfigureAwait(false);
return Result.Succeed();
}
finally
@@ -84,8 +89,7 @@ public async Task AppendChunkAsync(FileUploadPayload payload, Cancellati
if (_options.MaxActiveSessions > 0 && _sessions.Count >= _options.MaxActiveSessions)
return null;
- var directoryKey = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(uploadId)));
- var directory = Path.Combine(_options.TempPath, directoryKey);
+ var directory = Path.Combine(_options.TempPath, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var session = new ChunkUploadSession(uploadId, descriptor.FileName ?? descriptor.UploadId,
descriptor.ContentType, descriptor.TotalChunks, descriptor.ChunkSize, descriptor.FileSize, directory);
@@ -118,17 +122,18 @@ public async Task> CompleteAsync(ChunkUpload
acquired = true;
if (session.Closed)
return Result.Fail("Upload session is closed");
+ if (TryExpireSession(session, DateTimeOffset.UtcNow))
+ return Result.Fail("Upload session has expired");
session.Touch();
session.EnsureAllChunksPresent();
var orderedChunks = session.ChunkFiles
.OrderBy(x => x.Key)
- .Select(x => x.Value)
+ .Select(x => x.Value.Path)
.ToArray();
var mergedFilePath = Path.Combine(session.WorkingDirectory, "merged.bin");
- var expectedLength = session.TotalChunks == 0 && session.FileSize == 0 ? null : session.FileSize;
- var crc = await ChunkUploadFiles.MergeChunksAsync(mergedFilePath, orderedChunks, expectedLength, cancellationToken).ConfigureAwait(false);
+ var crc = await ChunkUploadFiles.MergeChunksAsync(mergedFilePath, orderedChunks, session.ExpectedLength, cancellationToken).ConfigureAwait(false);
BlobMetadata? metadata = null;
if (request.CommitToStorage)
@@ -211,7 +216,7 @@ private void RemoveExpiredSessions()
return;
}
- var expirationThreshold = DateTimeOffset.UtcNow - _options.SessionTtl;
+ var now = DateTimeOffset.UtcNow;
foreach (var (_, session) in _sessions)
{
if (!session.Gate.Wait(0))
@@ -219,8 +224,8 @@ private void RemoveExpiredSessions()
try
{
- if (!session.Closed && session.LastTouchedUtc < expirationThreshold)
- CloseSession(session);
+ if (!session.Closed)
+ TryExpireSession(session, now);
}
finally
{
@@ -228,4 +233,14 @@ private void RemoveExpiredSessions()
}
}
}
+
+ private bool TryExpireSession(ChunkUploadSession session, DateTimeOffset now)
+ {
+ var ttl = _options.SessionTtl;
+ if (ttl <= TimeSpan.Zero || now - session.LastTouchedUtc < ttl)
+ return false;
+
+ CloseSession(session);
+ return true;
+ }
}
diff --git a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadSession.cs b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadSession.cs
index 021db9f..1e09a58 100644
--- a/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadSession.cs
+++ b/Integraions/ManagedCode.Storage.Server/ChunkUpload/ChunkUploadSession.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
@@ -8,7 +7,8 @@ namespace ManagedCode.Storage.Server.ChunkUpload;
internal sealed class ChunkUploadSession
{
- private readonly ConcurrentDictionary _chunkFiles = new();
+ private readonly Dictionary _chunkFiles = new();
+ private long _acceptedBytes;
public ChunkUploadSession(string uploadId, string fileName, string? contentType, int totalChunks, int chunkSize, long? fileSize, string workingDirectory)
{
@@ -34,6 +34,8 @@ public ChunkUploadSession(string uploadId, string fileName, string? contentType,
public long? FileSize { get; }
+ public long? ExpectedLength => ResolveExpectedLength(TotalChunks, FileSize);
+
public string WorkingDirectory { get; }
public SemaphoreSlim Gate { get; } = new(1, 1);
@@ -42,18 +44,24 @@ public ChunkUploadSession(string uploadId, string fileName, string? contentType,
public DateTimeOffset LastTouchedUtc { get; private set; }
- public IReadOnlyDictionary ChunkFiles => _chunkFiles;
+ public IReadOnlyDictionary ChunkFiles => _chunkFiles;
+
+ public static long? ResolveExpectedLength(int totalChunks, long? fileSize) =>
+ totalChunks == 0 && fileSize == 0 ? null : fileSize;
public void Touch()
{
LastTouchedUtc = DateTimeOffset.UtcNow;
}
- public string RegisterChunk(int index, string path)
+ public bool CanAcceptChunk(int index, long length) =>
+ ExpectedLength is not long expected || length <= expected - _acceptedBytes + _chunkFiles.GetValueOrDefault(index).Length;
+
+ public void RegisterChunk(int index, string path, long length)
{
- _chunkFiles[index] = path;
+ _acceptedBytes += length - _chunkFiles.GetValueOrDefault(index).Length;
+ _chunkFiles[index] = (path, length);
Touch();
- return path;
}
public void EnsureAllChunksPresent()
@@ -71,7 +79,7 @@ public void EnsureAllChunksPresent()
public void Cleanup(bool keepMergedFile = false)
{
- foreach (var (_, path) in _chunkFiles)
+ foreach (var (path, _) in _chunkFiles.Values)
{
if (File.Exists(path))
{
diff --git a/Integraions/ManagedCode.Storage.Server/Hubs/StorageHubBase.cs b/Integraions/ManagedCode.Storage.Server/Hubs/StorageHubBase.cs
index d6e9dfc..0f7839c 100644
--- a/Integraions/ManagedCode.Storage.Server/Hubs/StorageHubBase.cs
+++ b/Integraions/ManagedCode.Storage.Server/Hubs/StorageHubBase.cs
@@ -252,15 +252,34 @@ public virtual async IAsyncEnumerable DownloadStreamAsync(string blobNam
blobName, null, cancellationToken);
byte[]? buffer = null;
Exception? failure = null;
- var wasCanceled = false;
var token = registration.Cancellation.Token;
try
{
- await using var sourceStream = await OpenDownloadStreamAsync(blobName, token).ConfigureAwait(false);
- registration.Status.TotalBytes = sourceStream.CanSeek ? sourceStream.Length : null;
- buffer = ArrayPool.Shared.Rent(_options.StreamBufferSize);
+ Stream openedStream;
+ try
+ {
+ openedStream = await OpenDownloadStreamAsync(blobName, token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "DownloadStreamAsync could not open {TransferId}", transferId);
+ failure = ex;
+ throw;
+ }
+
+ await using var sourceStream = openedStream;
+ try
+ {
+ registration.Status.TotalBytes = sourceStream.CanSeek ? sourceStream.Length : null;
+ buffer = ArrayPool.Shared.Rent(_options.StreamBufferSize);
+ }
+ catch (Exception ex)
+ {
+ failure = ex;
+ throw;
+ }
while (true)
{
@@ -272,7 +291,6 @@ public virtual async IAsyncEnumerable DownloadStreamAsync(string blobNam
catch (OperationCanceledException oce)
{
failure = oce;
- wasCanceled = true;
throw;
}
catch (Exception ex)
@@ -293,35 +311,14 @@ public virtual async IAsyncEnumerable DownloadStreamAsync(string blobNam
registration.Touch();
var progressSnapshot = CreateStatusSnapshot(registration.Status);
- try
- {
- await NotifyClientAsync(StorageHubEvents.TransferProgress, progressSnapshot, token).ConfigureAwait(false);
- }
- catch (OperationCanceledException oce)
- {
- failure = oce;
- wasCanceled = true;
- throw;
- }
- catch (HubException)
- {
- throw;
- }
+ await NotifyClientAsync(StorageHubEvents.TransferProgress, progressSnapshot, token).ConfigureAwait(false);
yield return chunk;
}
+ token.ThrowIfCancellationRequested();
registration.Status.IsCompleted = true;
var completionSnapshot = CreateStatusSnapshot(registration.Status);
- try
- {
- await NotifyClientAsync(StorageHubEvents.TransferCompleted, completionSnapshot, token).ConfigureAwait(false);
- }
- catch (OperationCanceledException oce)
- {
- failure = oce;
- wasCanceled = true;
- throw;
- }
+ await NotifyClientAsync(StorageHubEvents.TransferCompleted, completionSnapshot, token).ConfigureAwait(false);
}
finally
{
@@ -329,19 +326,13 @@ public virtual async IAsyncEnumerable DownloadStreamAsync(string blobNam
ArrayPool.Shared.Return(buffer);
Transfers.Remove(registration);
- if (failure is not null)
+ if (!registration.Status.IsCompleted && (failure is not null || token.IsCancellationRequested))
{
- if (wasCanceled)
- {
- registration.Status.IsCanceled = true;
- registration.Status.Error ??= "Transfer canceled";
- _ = NotifyClientAsync(StorageHubEvents.TransferCanceled, CreateStatusSnapshot(registration.Status), CancellationToken.None);
- }
- else
- {
- registration.Status.Error = failure.Message;
- _ = NotifyClientAsync(StorageHubEvents.TransferFaulted, CreateStatusSnapshot(registration.Status), CancellationToken.None);
- }
+ var canceled = failure is OperationCanceledException || token.IsCancellationRequested;
+ registration.Status.IsCanceled = canceled;
+ registration.Status.Error = canceled ? "Transfer canceled" : "Download failed";
+ var eventName = canceled ? StorageHubEvents.TransferCanceled : StorageHubEvents.TransferFaulted;
+ await NotifyClientAsync(eventName, CreateStatusSnapshot(registration.Status), CancellationToken.None).ConfigureAwait(false);
}
}
}
diff --git a/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs b/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs
index 417257a..a9b2216 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Extensions/ServiceCollectionExtensions.cs
@@ -146,10 +146,12 @@ public override async Task SetVfsMetadataAsync(
string blobName,
CancellationToken cancellationToken = default)
{
- var metadata = await GetBlobInfoAsync(blobName, cancellationToken);
- return metadata?.Metadata is { } values ? ParseVfsMetadata(values) : null;
+ return DecodeVfsMetadata(await GetBlobInfoAsync(blobName, cancellationToken));
}
+ internal VfsMetadata? DecodeVfsMetadata(BlobMetadata? metadata) =>
+ metadata?.Metadata is { } values ? ParseVfsMetadata(values) : null;
+
public override async Task> GetCustomMetadataAsync(
string blobName,
CancellationToken cancellationToken = default)
diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs
index 72effeb..55b63b5 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.RangeRead.cs
@@ -8,21 +8,37 @@ namespace ManagedCode.Storage.VirtualFileSystem.Implementations;
public partial class VirtualFile
{
+ private const int RangeBufferSize = 64 * 1024;
+
///
public async ValueTask ReadRangeAsync(long offset, int count, CancellationToken cancellationToken = default)
{
ArgumentOutOfRangeException.ThrowIfNegative(offset);
ArgumentOutOfRangeException.ThrowIfNegative(count);
await using var stream = await OpenReadAsync(cancellationToken: cancellationToken);
+ if (count == 0)
+ return Array.Empty();
+
if (stream.CanSeek)
{
- if (offset >= stream.Length)
+ var available = stream.Length - offset;
+ if (available <= 0)
return Array.Empty();
+
stream.Seek(offset, SeekOrigin.Begin);
+ return await ReadAvailableAsync(stream, (int)Math.Min(count, available), cancellationToken);
}
- else if (!await SkipForwardAsync(stream, offset, cancellationToken))
+
+ if (!await SkipForwardAsync(stream, offset, cancellationToken))
return Array.Empty();
+ return count <= RangeBufferSize
+ ? await ReadAvailableAsync(stream, count, cancellationToken)
+ : await ReadBufferedRangeAsync(stream, count, cancellationToken);
+ }
+
+ private static async ValueTask ReadAvailableAsync(Stream stream, int count, CancellationToken cancellationToken)
+ {
var buffer = new byte[count];
var bytesRead = await stream.ReadAtLeastAsync(buffer, count, throwOnEndOfStream: false, cancellationToken);
if (bytesRead < count)
@@ -30,16 +46,41 @@ public async ValueTask ReadRangeAsync(long offset, int count, Cancellati
return buffer;
}
+ private static async ValueTask ReadBufferedRangeAsync(Stream stream, int count, CancellationToken cancellationToken)
+ {
+ var buffer = ArrayPool.Shared.Rent(RangeBufferSize);
+ try
+ {
+ using var result = new MemoryStream();
+ while (count > 0)
+ {
+ var read = await stream.ReadAsync(buffer.AsMemory(0, Math.Min(count, RangeBufferSize)), cancellationToken);
+ if (read == 0)
+ break;
+
+ result.Write(buffer, 0, read);
+ count -= read;
+ }
+
+ return result.ToArray();
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
private static async Task SkipForwardAsync(Stream stream, long count, CancellationToken cancellationToken)
{
if (count == 0)
return true;
- var buffer = ArrayPool.Shared.Rent(81920);
+ var bufferSize = (int)Math.Min(count, RangeBufferSize);
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
try
{
while (count > 0)
{
- var read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(count, buffer.Length)), cancellationToken);
+ var read = await stream.ReadAsync(buffer.AsMemory(0, (int)Math.Min(count, bufferSize)), cancellationToken);
if (read == 0)
return false;
count -= read;
diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs
index 1145adb..619c971 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFile.cs
@@ -10,6 +10,7 @@
using ManagedCode.Storage.Core.Models;
using ManagedCode.Storage.VirtualFileSystem.Core;
using ManagedCode.Storage.VirtualFileSystem.Exceptions;
+using ManagedCode.Storage.VirtualFileSystem.Extensions;
using ManagedCode.Storage.VirtualFileSystem.Metadata;
using ManagedCode.Storage.VirtualFileSystem.Options;
using ManagedCode.Storage.VirtualFileSystem.Streaming;
@@ -29,7 +30,6 @@ public partial class VirtualFile : IVirtualFile
private BlobMetadata? _blobMetadata;
private VfsMetadata? _vfsMetadata;
- private bool _metadataLoaded;
///
/// Initializes a new instance of VirtualFile
@@ -86,30 +86,21 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
_logger.LogDebug("Refreshing file metadata: {Path}", _path);
- _blobMetadata = await _metadataManager.GetBlobInfoAsync(_path.ToBlobKey(), cancellationToken);
- _vfsMetadata = await _metadataManager.GetVfsMetadataAsync(_path.ToBlobKey(), cancellationToken);
- _metadataLoaded = true;
+ var key = _path.ToBlobKey();
+ var blobMetadata = await _metadataManager.GetBlobInfoAsync(key, cancellationToken);
+ var vfsMetadata = _metadataManager is DefaultMetadataManager defaultManager
+ ? defaultManager.DecodeVfsMetadata(blobMetadata)
+ : await _metadataManager.GetVfsMetadataAsync(key, cancellationToken);
- if (_blobMetadata != null)
- {
- ETag = _blobMetadata.Uri?.Query.Contains("sv=") == true ?
- ExtractETagFromUri(_blobMetadata.Uri) : null;
- }
+ _blobMetadata = blobMetadata;
+ _vfsMetadata = vfsMetadata;
+ ETag = blobMetadata?.Uri?.Query.Contains("sv=") == true ? ExtractETagFromUri(blobMetadata.Uri) : null;
if (_vfs.Options.EnableCache)
{
_cache.Remove(VfsCacheKey.For(_vfs, VfsCacheKind.FileExists, _path));
var metadataKey = VfsCacheKey.For(_vfs, VfsCacheKind.Metadata, _path);
- var entry = new MetadataCacheEntry
- {
- Metadata = _vfsMetadata ?? new VfsMetadata(),
- CustomMetadata = new Dictionary(),
- CachedAt = DateTimeOffset.UtcNow,
- ETag = ETag,
- Size = (long)(_blobMetadata?.Length ?? 0),
- ContentType = _blobMetadata?.MimeType,
- BlobMetadata = _blobMetadata
- };
+ var entry = new MetadataCacheEntry(blobMetadata, vfsMetadata, ETag);
_cache.Set(metadataKey, entry, _vfs.Options.CacheTTL);
var customKey = VfsCacheKey.For(_vfs, VfsCacheKind.CustomMetadata, _path);
@@ -275,12 +266,6 @@ public async ValueTask> GetMetadataAsync(
if (_vfs.Options.EnableCache)
{
_cache.Set(cacheKey, metadata, _vfs.Options.CacheTTL);
- var metadataKey = VfsCacheKey.For(_vfs, VfsCacheKind.Metadata, _path);
- if (_cache.TryGetValue(metadataKey, out MetadataCacheEntry? entry) && entry is not null)
- {
- entry.CustomMetadata = metadata;
- _cache.Set(metadataKey, entry, _vfs.Options.CacheTTL);
- }
}
_logger.LogDebug("File metadata: {Path}, count: {Count}", _path, metadata.Count);
@@ -345,11 +330,6 @@ public async Task DeleteAsync(CancellationToken cancellationToken = defaul
private async Task EnsureMetadataLoadedAsync(CancellationToken cancellationToken)
{
- if (_metadataLoaded)
- {
- return;
- }
-
if (_vfs.Options.EnableCache)
{
var metadataKey = VfsCacheKey.For(_vfs, VfsCacheKind.Metadata, _path);
@@ -358,7 +338,6 @@ private async Task EnsureMetadataLoadedAsync(CancellationToken cancellationToken
_vfsMetadata = entry.Metadata;
_blobMetadata = entry.BlobMetadata;
ETag = entry.ETag;
- _metadataLoaded = true;
return;
}
}
diff --git a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs
index 20139e7..ff51485 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Implementations/VirtualFileSystem.cs
@@ -11,6 +11,7 @@
using ManagedCode.Storage.VirtualFileSystem.Exceptions;
using ManagedCode.Storage.VirtualFileSystem.Metadata;
using ManagedCode.Storage.VirtualFileSystem.Options;
+using ManagedCode.Storage.VirtualFileSystem.Streaming;
namespace ManagedCode.Storage.VirtualFileSystem.Implementations;
@@ -280,30 +281,60 @@ public async Task MoveAsync(
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
+ cancellationToken.ThrowIfCancellationRequested();
+ if (source == destination)
+ throw new VfsOperationException($"Cannot move a path onto itself: {source}");
+
options ??= new MoveOptions();
_logger.LogDebug("Moving: {Source} -> {Destination}", source, destination);
- // For now, implement as copy + delete
- await CopyAsync(source, destination, new CopyOptions
+ var copyOptions = new CopyOptions
{
Overwrite = options.Overwrite,
PreserveMetadata = options.PreserveMetadata
- }, null, cancellationToken);
+ };
- // Delete source
- if (await FileExistsAsync(source, cancellationToken))
+ if (await StorageFileExistsAsync(source, cancellationToken))
{
- await DeleteFileAsync(source, cancellationToken);
+ await CopyFileAsync(source, destination, copyOptions, null, cancellationToken);
+ await DeleteMovedSourceAsync(source, cancellationToken);
}
else if (await DirectoryExistsAsync(source, cancellationToken))
{
- await DeleteDirectoryAsync(source, true, cancellationToken);
+ await MoveDirectoryAsync(source, destination, copyOptions, cancellationToken);
+ }
+ else
+ {
+ throw new VfsNotFoundException(source);
}
_logger.LogDebug("Move completed: {Source} -> {Destination}", source, destination);
}
+ private async Task MoveDirectoryAsync(VfsPath source, VfsPath destination, CopyOptions options,
+ CancellationToken cancellationToken)
+ {
+ if (source.IsRoot || destination.IsRoot ||
+ destination.Value.StartsWith(source.Value + "/", StringComparison.Ordinal) ||
+ source.Value.StartsWith(destination.Value + "/", StringComparison.Ordinal))
+ {
+ throw new VfsOperationException($"Cannot move overlapping directories: {source} and {destination}");
+ }
+
+ var copiedSources = new List();
+ await CopyDirectoryAsync(source, destination, options, null, cancellationToken, copiedSources);
+ foreach (var path in copiedSources)
+ await DeleteMovedSourceAsync(path, cancellationToken);
+ }
+
+ private async Task DeleteMovedSourceAsync(VfsPath source, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!await DeleteFileAsync(source, cancellationToken))
+ throw new VfsOperationException($"Copied the file but failed to remove its source: {source}");
+ }
+
///
public async Task CopyAsync(
VfsPath source,
@@ -317,8 +348,10 @@ public async Task CopyAsync(
_logger.LogDebug("Copying: {Source} -> {Destination}", source, destination);
- // Check if source is a file
- if (await FileExistsAsync(source, cancellationToken))
+ if (source == destination)
+ throw new VfsOperationException($"Cannot copy a path onto itself: {source}");
+
+ if (await StorageFileExistsAsync(source, cancellationToken))
{
await CopyFileAsync(source, destination, options, progress, cancellationToken);
}
@@ -351,36 +384,19 @@ private async Task CopyFileAsync(
var sourceFile = await GetFileAsync(source, cancellationToken);
var destinationFile = await GetFileAsync(destination, cancellationToken);
- if (await destinationFile.ExistsAsync(cancellationToken) && !options.Overwrite)
+ if (!options.Overwrite && await StorageFileExistsAsync(destination, cancellationToken))
{
throw new VfsAlreadyExistsException(destination);
}
- progress?.Report(new CopyProgress
+ if (progress is not null)
{
- TotalFiles = 1,
- TotalBytes = sourceFile.Size,
- CurrentFile = source
- });
-
- // Copy content
- await using var sourceStream = await sourceFile.OpenReadAsync(cancellationToken: cancellationToken);
- await using var destinationStream = await destinationFile.OpenWriteAsync(
- new WriteOptions { Overwrite = options.Overwrite }, cancellationToken);
-
- await sourceStream.CopyToAsync(destinationStream, cancellationToken);
-
- // Copy metadata if requested
- if (options.PreserveMetadata)
- {
- var metadata = await sourceFile.GetMetadataAsync(cancellationToken);
- if (metadata.Count > 0)
- {
- var metadataDict = new Dictionary(metadata);
- await destinationFile.SetMetadataAsync(metadataDict, cancellationToken: cancellationToken);
- }
+ await sourceFile.RefreshAsync(cancellationToken);
+ progress.Report(new CopyProgress { TotalFiles = 1, TotalBytes = sourceFile.Size, CurrentFile = source });
}
+ await CopyContentAsync(sourceFile, destinationFile, options, cancellationToken);
+
progress?.Report(new CopyProgress
{
TotalFiles = 1,
@@ -396,69 +412,45 @@ private async Task CopyDirectoryAsync(
VfsPath destination,
CopyOptions options,
IProgress? progress,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ List? copiedSources = null)
{
var sourceDir = await GetDirectoryAsync(source, cancellationToken);
- // Calculate total work for progress reporting
- var totalFiles = 0;
- var totalBytes = 0L;
-
- await foreach (var entry in sourceDir.GetEntriesAsync(recursive: true, cancellationToken: cancellationToken))
- {
- if (entry.Type == VfsEntryType.File && entry is IVirtualFile file)
- {
- totalFiles++;
- totalBytes += file.Size;
- }
- }
+ if (source.IsRoot || destination.Value.StartsWith(source.Value + "/", StringComparison.Ordinal))
+ throw new VfsOperationException($"Cannot copy a directory into itself: {source} and {destination}");
+ var (totalFiles, totalBytes) = progress is null ? (0, 0L) : await GetCopyTotalsAsync(source, cancellationToken);
var copiedFiles = 0;
var copiedBytes = 0L;
- await foreach (var entry in sourceDir.GetEntriesAsync(recursive: true, cancellationToken: cancellationToken))
+ await foreach (var sourceFile in sourceDir.GetFilesAsync(recursive: true, cancellationToken: cancellationToken))
{
- if (entry.Type == VfsEntryType.File && entry is IVirtualFile sourceFile)
- {
- var relativePath = entry.Path.Value[source.Value.Length..].TrimStart('/');
- var destPath = destination.Combine(relativePath);
- var destFile = await GetFileAsync(destPath, cancellationToken);
+ var relativePath = sourceFile.Path.Value[source.Value.Length..].TrimStart('/');
+ var destPath = destination.Combine(relativePath);
- if (await destFile.ExistsAsync(cancellationToken) && !options.Overwrite)
- {
- continue; // Skip existing files
- }
-
- progress?.Report(new CopyProgress
- {
- TotalFiles = totalFiles,
- CopiedFiles = copiedFiles,
- TotalBytes = totalBytes,
- CopiedBytes = copiedBytes,
- CurrentFile = entry.Path
- });
-
- // Copy file content
- await using var sourceStream = await sourceFile.OpenReadAsync(cancellationToken: cancellationToken);
- await using var destStream = await destFile.OpenWriteAsync(
- new WriteOptions { Overwrite = options.Overwrite }, cancellationToken);
-
- await sourceStream.CopyToAsync(destStream, cancellationToken);
-
- // Copy metadata if requested
- if (options.PreserveMetadata)
- {
- var metadata = await sourceFile.GetMetadataAsync(cancellationToken);
- if (metadata.Count > 0)
- {
- var metadataDict = new Dictionary(metadata);
- await destFile.SetMetadataAsync(metadataDict, cancellationToken: cancellationToken);
- }
- }
+ if (!options.Overwrite && await StorageFileExistsAsync(destPath, cancellationToken))
+ {
+ if (copiedSources is not null)
+ throw new VfsAlreadyExistsException(destPath);
- copiedFiles++;
- copiedBytes += sourceFile.Size;
+ continue;
}
+
+ progress?.Report(new CopyProgress
+ {
+ TotalFiles = totalFiles,
+ CopiedFiles = copiedFiles,
+ TotalBytes = totalBytes,
+ CopiedBytes = copiedBytes,
+ CurrentFile = sourceFile.Path
+ });
+
+ var destFile = await GetFileAsync(destPath, cancellationToken);
+ await CopyContentAsync(sourceFile, destFile, options, cancellationToken);
+ copiedFiles++;
+ copiedBytes += sourceFile.Size;
+ copiedSources?.Add(sourceFile.Path);
}
progress?.Report(new CopyProgress
@@ -470,6 +462,48 @@ private async Task CopyDirectoryAsync(
});
}
+ private async Task<(int Files, long Bytes)> GetCopyTotalsAsync(VfsPath source, CancellationToken cancellationToken)
+ {
+ var totalFiles = 0;
+ var totalBytes = 0L;
+ await foreach (var blob in _storage.GetBlobMetadataListAsync(source.ToBlobKey() + "/", cancellationToken))
+ {
+ if (!string.IsNullOrEmpty(blob?.FullName))
+ {
+ totalFiles++;
+ totalBytes += (long)blob.Length;
+ }
+ }
+
+ return (totalFiles, totalBytes);
+ }
+
+ private static async Task CopyContentAsync(IVirtualFile source, IVirtualFile destination, CopyOptions options,
+ CancellationToken cancellationToken)
+ {
+ await using var sourceStream = await source.OpenReadAsync(cancellationToken: cancellationToken);
+ var writeOptions = new WriteOptions { Overwrite = options.Overwrite };
+ if (options.PreserveMetadata)
+ {
+ writeOptions.ContentType = source.ContentType;
+ var metadata = await source.GetMetadataAsync(cancellationToken);
+ if (metadata.Count > 0)
+ writeOptions.Metadata = new Dictionary(metadata);
+ }
+
+ await using var destinationStream = await destination.OpenWriteAsync(writeOptions, cancellationToken);
+ try
+ {
+ await sourceStream.CopyToAsync(destinationStream, cancellationToken);
+ }
+ catch
+ {
+ if (destinationStream is VfsWriteStream bufferedStream)
+ bufferedStream.Abort();
+ throw;
+ }
+ }
+
///
public async ValueTask GetEntryAsync(VfsPath path, CancellationToken cancellationToken = default)
{
diff --git a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs
index ad156eb..4ffbc19 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs
@@ -134,16 +134,7 @@ public enum VfsAttributes
///
/// Cache entry for metadata
///
-internal class MetadataCacheEntry
-{
- public VfsMetadata Metadata { get; set; } = null!;
- public IReadOnlyDictionary CustomMetadata { get; set; } = new Dictionary();
- public DateTimeOffset CachedAt { get; set; } = DateTimeOffset.UtcNow;
- public string? ETag { get; set; }
- public long Size { get; set; }
- public string? ContentType { get; set; }
- public BlobMetadata? BlobMetadata { get; set; }
-}
+internal sealed record MetadataCacheEntry(BlobMetadata? BlobMetadata, VfsMetadata? Metadata, string? ETag);
///
/// Base implementation for metadata managers
diff --git a/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs b/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs
index ae806c8..27ab000 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Options/VfsOptions.cs
@@ -249,7 +249,7 @@ public bool IsMatch(string name)
return IsWildcardMatch(Pattern, name, comparison);
}
- private static bool IsWildcardMatch(string pattern, string input, StringComparison comparison)
+ private static bool IsWildcardMatch(ReadOnlySpan pattern, ReadOnlySpan input, StringComparison comparison)
{
int patternIndex = 0;
int inputIndex = 0;
@@ -258,17 +258,17 @@ private static bool IsWildcardMatch(string pattern, string input, StringComparis
while (inputIndex < input.Length)
{
- if (patternIndex < pattern.Length && (pattern[patternIndex] == '?' ||
- string.Equals(pattern[patternIndex].ToString(), input[inputIndex].ToString(), comparison)))
+ if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
{
+ starIndex = patternIndex;
+ match = inputIndex;
patternIndex++;
- inputIndex++;
}
- else if (patternIndex < pattern.Length && pattern[patternIndex] == '*')
+ else if (patternIndex < pattern.Length && (pattern[patternIndex] == '?' ||
+ pattern.Slice(patternIndex, 1).Equals(input.Slice(inputIndex, 1), comparison)))
{
- starIndex = patternIndex;
- match = inputIndex;
patternIndex++;
+ inputIndex++;
}
else if (starIndex != -1)
{
diff --git a/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs b/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs
index 84a33e5..114b65c 100644
--- a/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs
+++ b/ManagedCode.Storage.VirtualFileSystem/Streaming/VfsWriteStream.cs
@@ -26,6 +26,8 @@ internal class VfsWriteStream : Stream
private bool _abortUpload;
private bool _disposed;
+ internal void Abort() => _abortUpload = true;
+
public VfsWriteStream(
IVirtualFileSystem vfs,
VfsPath path,
diff --git a/Storages/ManagedCode.Storage.Aws/BlobStream.cs b/Storages/ManagedCode.Storage.Aws/BlobStream.cs
index 08016bf..6f64d89 100644
--- a/Storages/ManagedCode.Storage.Aws/BlobStream.cs
+++ b/Storages/ManagedCode.Storage.Aws/BlobStream.cs
@@ -102,6 +102,8 @@ public override long Seek(long offset, SeekOrigin origin)
public override void SetLength(long value)
{
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentOutOfRangeException.ThrowIfNegative(value);
_metadata.Length = Math.Max(_metadata.Length, value);
_metadata.PartLength = Math.Max(MIN_PART_LENGTH, Math.Min(MAX_PART_LENGTH, _metadata.Length / MAX_PART_COUNT));
}
@@ -132,7 +134,7 @@ public override void Flush()
private void FlushPart(bool disposing)
{
var part = _metadata.CurrentStream;
- if (part is null || (part.Length < MIN_PART_LENGTH && !disposing))
+ if (part is null || (part.Position < MIN_PART_LENGTH && !disposing))
return;
if (_metadata.Tasks.Count == MaxPendingParts)
@@ -241,11 +243,21 @@ public override void Write(byte[] buffer, int offset, int count)
ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, buffer.Length);
if (count > buffer.Length - offset)
throw new ArgumentException("The requested range exceeds the buffer length.", nameof(count));
- if (count == 0) return;
+ WriteBuffered(buffer.AsSpan(offset, count));
+ }
+
+ public override void Write(ReadOnlySpan buffer)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ WriteBuffered(buffer);
+ }
+
+ private void WriteBuffered(ReadOnlySpan buffer)
+ {
try
{
- WriteParts(buffer, offset, count);
+ WriteParts(buffer);
}
catch (Exception failure)
{
@@ -254,23 +266,20 @@ public override void Write(byte[] buffer, int offset, int count)
}
}
- private void WriteParts(byte[] buffer, int offset, int count)
+ private void WriteParts(ReadOnlySpan buffer)
{
- // write as much of the buffer as will fit to the current part, and if needed
- // allocate a new part and continue writing to it (and so on).
- while (count > 0)
+ while (!buffer.IsEmpty)
{
- if (_metadata.CurrentStream == null || _metadata.CurrentStream.Length >= _metadata.PartLength)
+ if (_metadata.CurrentStream == null || _metadata.CurrentStream.Position >= _metadata.PartLength)
StartNewPart();
var currentStream = _metadata.CurrentStream ?? throw new InvalidOperationException("Multipart upload stream was not initialized.");
- var remaining = _metadata.PartLength - currentStream.Length;
- var written = (int)Math.Min(count, remaining);
- currentStream.Write(buffer, offset, written);
+ var remaining = _metadata.PartLength - currentStream.Position;
+ var written = (int)Math.Min(buffer.Length, remaining);
+ currentStream.Write(buffer[..written]);
_metadata.Position += written;
- count -= written;
- offset += written;
+ buffer = buffer[written..];
}
}
diff --git a/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs b/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs
index f0fd83f..92155fa 100644
--- a/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs
+++ b/Storages/ManagedCode.Storage.FileSystem/FileSystemStorage.cs
@@ -15,7 +15,10 @@ namespace ManagedCode.Storage.FileSystem;
public class FileSystemStorage(FileSystemStorageOptions options) : BaseStorage(options), IFileSystemStorage
{
- private readonly Dictionary _lockedFiles = new();
+ private static readonly StringComparison PathComparison =
+ OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ private readonly Dictionary _lockedFiles = new(StringComparer.FromComparison(PathComparison));
+ private readonly Lock _legalHoldGate = new();
public override async Task RemoveContainerAsync(CancellationToken cancellationToken = default)
{
@@ -24,8 +27,7 @@ public override async Task RemoveContainerAsync(CancellationToken cancel
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
- if (Directory.Exists(StorageClient))
- Directory.Delete(StorageClient, true);
+ DeleteUnheldDirectory(Path.GetFullPath(StorageClient));
return Result.Succeed();
}
@@ -153,8 +155,7 @@ protected override async Task DeleteDirectoryInternalAsync(string direct
cancellationToken.ThrowIfCancellationRequested();
var path = GetDirectoryPath(directory, allowRoot: false);
- if (Directory.Exists(path))
- Directory.Delete(path, true);
+ DeleteUnheldDirectory(path);
return Result.Succeed();
}
@@ -175,21 +176,15 @@ protected override async Task> UploadInternalAsync(Stream s
var filePath = GetPathFromOptions(options);
cancellationToken.ThrowIfCancellationRequested();
- const int bufferSize = 4096 * 1024; // 4MB buffer
- await using var fileStream = new FileStream(
- filePath,
- FileMode.Create,
- FileAccess.Write,
- FileShare.None,
- bufferSize,
- FileOptions.Asynchronous | FileOptions.SequentialScan);
+ const int copyBufferSize = 256 * 1024;
+ await using var fileStream = OpenUnheldFileForUpload(filePath);
if (stream.CanSeek)
{
stream.Position = 0;
}
- await stream.CopyToAsync(fileStream, bufferSize, cancellationToken);
+ await stream.CopyToAsync(fileStream, copyBufferSize, cancellationToken);
await fileStream.FlushAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
@@ -255,10 +250,14 @@ protected override async Task> DeleteInternalAsync(DeleteOptions op
var filePath = GetPathFromOptions(options, createDirectories: false);
cancellationToken.ThrowIfCancellationRequested();
- if (!File.Exists(filePath))
- return Result.Succeed(false);
+ lock (_legalHoldGate)
+ {
+ ThrowIfHeld(filePath);
+ if (!File.Exists(filePath))
+ return Result.Succeed(false);
- File.Delete(filePath);
+ File.Delete(filePath);
+ }
return Result.Succeed(true);
}
catch (Exception ex)
@@ -331,22 +330,7 @@ protected override async Task SetLegalHoldInternalAsync(bool hasLegalHol
await EnsureContainerExist(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
- if (hasLegalHold && !_lockedFiles.ContainsKey(filePath))
- {
- var fileStream = File.OpenRead(filePath);
- if (!OperatingSystem.IsMacOS())
- fileStream.Lock(0, fileStream.Length);
-
- _lockedFiles.Add(filePath, fileStream);
- }
- else if (!hasLegalHold && _lockedFiles.TryGetValue(filePath, out var lockedFile))
- {
- if (!OperatingSystem.IsMacOS())
- lockedFile.Unlock(0, lockedFile.Length);
-
- lockedFile.Dispose();
- _lockedFiles.Remove(filePath);
- }
+ SetLegalHold(filePath, hasLegalHold);
return Result.Succeed();
}
@@ -364,7 +348,8 @@ protected override async Task> HasLegalHoldInternalAsync(LegalHoldO
await EnsureContainerExist(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
- return Result.Succeed(_lockedFiles.ContainsKey(filePath));
+ lock (_legalHoldGate)
+ return Result.Succeed(_lockedFiles.ContainsKey(filePath));
}
catch (Exception ex)
{
@@ -372,6 +357,66 @@ protected override async Task> HasLegalHoldInternalAsync(LegalHoldO
}
}
+ private void SetLegalHold(string filePath, bool hasLegalHold)
+ {
+ lock (_legalHoldGate)
+ {
+ if (hasLegalHold && !_lockedFiles.ContainsKey(filePath))
+ {
+ var fileStream = File.OpenRead(filePath);
+ try
+ {
+ if (!OperatingSystem.IsMacOS())
+ fileStream.Lock(0, fileStream.Length);
+
+ _lockedFiles.Add(filePath, fileStream);
+ }
+ catch
+ {
+ fileStream.Dispose();
+ throw;
+ }
+ }
+ else if (!hasLegalHold && _lockedFiles.Remove(filePath, out var fileStream))
+ {
+ fileStream.Dispose();
+ }
+ }
+ }
+
+ private FileStream OpenUnheldFileForUpload(string filePath)
+ {
+ lock (_legalHoldGate)
+ {
+ ThrowIfHeld(filePath);
+ // CopyToAsync supplies the transfer buffer.
+ return new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None,
+ bufferSize: 1, options: FileOptions.Asynchronous | FileOptions.SequentialScan);
+ }
+ }
+
+ private void DeleteUnheldDirectory(string path)
+ {
+ var prefix = Path.EndsInDirectorySeparator(path) ? path : path + Path.DirectorySeparatorChar;
+ lock (_legalHoldGate)
+ {
+ foreach (var heldPath in _lockedFiles.Keys)
+ {
+ if (heldPath.StartsWith(prefix, PathComparison))
+ throw new UnauthorizedAccessException($"Directory contains a file on legal hold: {heldPath}");
+ }
+
+ if (Directory.Exists(path))
+ Directory.Delete(path, true);
+ }
+ }
+
+ private void ThrowIfHeld(string filePath)
+ {
+ if (_lockedFiles.ContainsKey(filePath))
+ throw new UnauthorizedAccessException($"File is on legal hold: {filePath}");
+ }
+
private string GetPathFromOptions(BaseOptions options, bool createDirectories = true)
{
if (string.IsNullOrWhiteSpace(options.FileName))
diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs
index 9e0928f..8a11b6c 100644
--- a/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Azure/SignalRRuntimeContractsTests.cs
@@ -75,9 +75,21 @@ public async Task MissingDownload_PropagatesTheRealHubFailure()
await using var client = CreateClient();
var token = TestContext.Current.CancellationToken;
await client.ConnectAsync(token);
+ var name = "missing-" + Guid.NewGuid().ToString("N");
+ var terminal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.TransferFaulted += (_, status) => terminal.TrySetResult(status);
await using var output = new MemoryStream();
- await Should.ThrowAsync(() => client.DownloadAsync("missing-" + Guid.NewGuid().ToString("N"), output, new StatusRecorder(), token));
+
+ await Should.ThrowAsync(() => client.DownloadAsync(name, output, new StatusRecorder(), token));
+
output.Length.ShouldBe(0);
+ var fault = await terminal.Task.WaitAsync(TimeSpan.FromSeconds(5), token);
+ fault.ResourceName.ShouldBe(name);
+ fault.Operation.ShouldBe("download");
+ fault.BytesTransferred.ShouldBe(0);
+ fault.IsCompleted.ShouldBeFalse();
+ fault.Error.ShouldBe("Download failed");
+ (await client.GetStatusAsync(fault.TransferId, token)).ShouldBeNull();
}
[Theory]
diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/SignalRDownloadFallbackTests.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/SignalRDownloadFallbackTests.cs
new file mode 100644
index 0000000..1ccf07f
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/SignalRDownloadFallbackTests.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using ManagedCode.Storage.Client.SignalR;
+using ManagedCode.Storage.Core;
+using ManagedCode.Storage.Core.Models;
+using ManagedCode.Storage.Server.Hubs;
+using Microsoft.AspNetCore.Http.Connections;
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.AspNetTests.Runtime;
+
+public sealed class SignalRDownloadFallbackTests(StorageHttpRuntimeHost host) : IClassFixture
+{
+ [Fact]
+ public async Task DownloadThroughAlias_CompletesFromBytesWhenEventsUseTheBackingName()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var alias = $"document-{Guid.NewGuid():N}.bin";
+ var backingName = AliasedDownloadHub.BackingPrefix + alias;
+ byte[] bytes = [1, 5, 9, 14];
+ (await host.Storage.UploadAsync(bytes, new UploadOptions(backingName), token)).IsSuccess.ShouldBeTrue();
+ await using var client = new StorageSignalRClient(new StorageSignalRClientOptions
+ {
+ HubUrl = new Uri(host.Client.BaseAddress!, AliasedDownloadHub.Route),
+ TransportType = HttpTransportType.LongPolling
+ });
+ var completedName = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.TransferCompleted += (_, status) => completedName.TrySetResult(status.ResourceName);
+ await client.ConnectAsync(token);
+ using var output = new MemoryStream();
+
+ var downloaded = await client.DownloadAsync(alias, output, cancellationToken: token);
+
+ (await completedName.Task.WaitAsync(TimeSpan.FromSeconds(5), token)).ShouldBe(backingName);
+ downloaded.IsCompleted.ShouldBeTrue();
+ downloaded.ResourceName.ShouldBe(alias);
+ downloaded.BytesTransferred.ShouldBe(bytes.LongLength);
+ downloaded.TotalBytes.ShouldBe(bytes.LongLength);
+ output.ToArray().ShouldBe(bytes);
+ }
+}
+
+public sealed class AliasedDownloadHub(IStorage storage, StorageHubOptions options, ILogger logger)
+ : StorageHubBase(storage, options, logger)
+{
+ internal const string Route = "/hubs/aliased-storage";
+ internal const string BackingPrefix = "backing/";
+
+ public override IAsyncEnumerable DownloadStreamAsync(string blobName, CancellationToken cancellationToken = default) =>
+ base.DownloadStreamAsync(BackingPrefix + blobName, cancellationToken);
+}
diff --git a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs
index b498256..6a32f8e 100644
--- a/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs
+++ b/Tests/ManagedCode.Storage.Tests/AspNetTests/Runtime/StorageHttpRuntimeHost.cs
@@ -8,6 +8,7 @@
using ManagedCode.Storage.FileSystem.Options;
using ManagedCode.Storage.Server.Controllers;
using ManagedCode.Storage.Server.Extensions.DependencyInjection;
+using ManagedCode.Storage.Server.Hubs;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
@@ -34,6 +35,8 @@ public async ValueTask InitializeAsync()
builder.Services.AddControllers().AddApplicationPart(typeof(RuntimeStorageController).Assembly);
builder.Services.AddFileSystemStorageAsDefault(new FileSystemStorageOptions { BaseFolder = _root });
builder.Services.AddStorageServer();
+ builder.Services.AddSignalR();
+ builder.Services.AddSingleton(new StorageHubOptions { TempPath = Path.Combine(_root, "signalr-temp") });
builder.Services.AddSingleton(new StorageServerOptions
{
MaxFileSize = 16 * 1024,
@@ -42,6 +45,7 @@ public async ValueTask InitializeAsync()
});
_app = builder.Build();
_app.MapControllers();
+ _app.MapHub(AliasedDownloadHub.Route);
await _app.StartAsync(TestContext.Current.CancellationToken);
(await Storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue();
var endpoint = _app.Services.GetRequiredService().Features.Get()!.Addresses.Single();
diff --git a/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadLengthBudgetTests.cs b/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadLengthBudgetTests.cs
new file mode 100644
index 0000000..a6466ae
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadLengthBudgetTests.cs
@@ -0,0 +1,113 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using ManagedCode.Communication;
+using ManagedCode.Storage.FileSystem;
+using ManagedCode.Storage.FileSystem.Options;
+using ManagedCode.Storage.Server.ChunkUpload;
+using ManagedCode.Storage.Server.Models;
+using Microsoft.AspNetCore.Http;
+using Shouldly;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.Server;
+
+public sealed class ChunkUploadLengthBudgetTests : IDisposable
+{
+ private const string UploadId = "bounded-upload";
+ private const string FileName = "object.bin";
+ private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("storage-chunk-budget-");
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ public async Task FirstChunk_ExceedingDeclaredSize_DoesNotReserveAWorkingDirectory(long size)
+ {
+ var options = new ChunkUploadOptions { TempPath = Path.Combine(_root.FullName, "chunks"), MaxActiveSessions = 1 };
+ var service = new ChunkUploadService(options);
+
+ var rejected = await AppendAsync(service, [1, 2, 3], size: size);
+
+ rejected.IsFailed.ShouldBeTrue();
+ rejected.Problem.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
+ Directory.GetDirectories(options.TempPath).ShouldBeEmpty();
+ (await AppendAsync(service, [1, 2, 3])).IsSuccess.ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task OversizedAppendOrReplacement_PreservesPreviouslyAcceptedBytes(bool replacement)
+ {
+ var service = CreateService();
+ (await AppendAsync(service, [1, 2, 3])).IsSuccess.ShouldBeTrue();
+ if (replacement)
+ (await AppendAsync(service, [4, 5], index: 2)).IsSuccess.ShouldBeTrue();
+
+ var rejected = replacement
+ ? await AppendAsync(service, [9, 8, 7, 6])
+ : await AppendAsync(service, [6, 7, 8], index: 2);
+
+ rejected.IsFailed.ShouldBeTrue();
+ rejected.Problem.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
+ if (!replacement)
+ (await AppendAsync(service, [4, 5], index: 2)).IsSuccess.ShouldBeTrue();
+ (await CompleteAndReadAsync(service)).ShouldBe(new byte[] { 1, 2, 3, 4, 5 });
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(0L)]
+ public async Task UnknownLength_RemainsCompatibleWithBothDescriptorForms(long? size)
+ {
+ var service = CreateService();
+ (await AppendAsync(service, [4, 7, 9], total: 0, size: size)).IsSuccess.ShouldBeTrue();
+ (await CompleteAndReadAsync(service)).ShouldBe(new byte[] { 4, 7, 9 });
+ }
+
+ [Fact]
+ public async Task ShortFormStream_PreservesAcceptedChunk()
+ {
+ var service = CreateService();
+ (await AppendAsync(service, [1, 2, 3])).IsSuccess.ShouldBeTrue();
+ (await AppendAsync(service, [4, 5], index: 2)).IsSuccess.ShouldBeTrue();
+
+ await Should.ThrowAsync(() => AppendAsync(service, [9, 8], formLength: 3));
+
+ (await CompleteAndReadAsync(service)).ShouldBe(new byte[] { 1, 2, 3, 4, 5 });
+ }
+
+ private ChunkUploadService CreateService() =>
+ new(new ChunkUploadOptions { TempPath = Path.Combine(_root.FullName, "chunks") });
+
+ private async Task CompleteAndReadAsync(ChunkUploadService service)
+ {
+ var token = TestContext.Current.CancellationToken;
+ var destination = Path.Combine(_root.FullName, "objects");
+ using var storage = new FileSystemStorage(new FileSystemStorageOptions { BaseFolder = destination });
+ var completed = await service.CompleteAsync(new ChunkUploadCompleteRequest { UploadId = UploadId }, storage, token);
+ completed.IsSuccess.ShouldBeTrue();
+ return await File.ReadAllBytesAsync(Path.Combine(destination, FileName), token);
+ }
+
+ private static async Task AppendAsync(ChunkUploadService service, byte[] bytes, int index = 1,
+ int total = 2, long? size = 5, int? formLength = null)
+ {
+ using var stream = new MemoryStream(bytes, writable: false);
+ return await service.AppendChunkAsync(new FileUploadPayload
+ {
+ File = new FormFile(stream, 0, formLength ?? bytes.Length, "file", FileName),
+ Payload = new FilePayload
+ {
+ UploadId = UploadId,
+ FileName = FileName,
+ ChunkIndex = index,
+ TotalChunks = total,
+ FileSize = size,
+ ChunkSize = formLength ?? bytes.Length
+ }
+ }, TestContext.Current.CancellationToken);
+ }
+
+ public void Dispose() => _root.Delete(recursive: true);
+}
diff --git a/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadPathIntegrityTests.cs b/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadPathIntegrityTests.cs
index a283f44..90ac593 100644
--- a/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadPathIntegrityTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/Server/ChunkUploadPathIntegrityTests.cs
@@ -112,9 +112,58 @@ public async Task CompletionRetainsMergedContentOnlyWhenRequested()
CreateContainerIfNotExists = true
});
- private static async Task AppendAsync(ChunkUploadService service, string id, string name)
+ [Fact]
+ public async Task ExpiredSession_CannotCommitAndReleasesItsSlot()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var options = new ChunkUploadOptions { TempPath = Path.Combine(_root, "chunks"), MaxActiveSessions = 1 };
+ var service = new ChunkUploadService(options);
+ using var storage = CreateStorage();
+ await AppendAsync(service, "expired", "expired.bin");
+ options.SessionTtl = TimeSpan.FromTicks(1);
+
+ var result = await service.CompleteAsync(new ChunkUploadCompleteRequest { UploadId = "expired" }, storage, token);
+
+ result.IsFailed.ShouldBeTrue();
+ (await storage.ExistsAsync("expired.bin", token)).Value.ShouldBeFalse();
+ Directory.GetDirectories(options.TempPath).ShouldBeEmpty();
+ options.SessionTtl = TimeSpan.FromHours(1);
+ await AppendAsync(service, "fresh", "fresh.bin");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ReusingId_LeavesPreviouslyRetainedBytesIntact(bool keepSecond)
+ {
+ var token = TestContext.Current.CancellationToken;
+ var chunks = Path.Combine(_root, "chunks");
+ var service = new ChunkUploadService(new ChunkUploadOptions { TempPath = chunks });
+ using var storage = CreateStorage();
+ await AppendAsync(service, "reused", "first.bin");
+ (await service.CompleteAsync(new ChunkUploadCompleteRequest
+ {
+ UploadId = "reused",
+ CommitToStorage = false,
+ KeepMergedFile = true
+ }, storage, token)).IsSuccess.ShouldBeTrue();
+ var retained = Directory.GetFiles(chunks, "*.bin", SearchOption.AllDirectories).ShouldHaveSingleItem();
+
+ await AppendAsync(service, "reused", "second.bin", [8, 9, 10]);
+ (await service.CompleteAsync(new ChunkUploadCompleteRequest
+ {
+ UploadId = "reused",
+ CommitToStorage = false,
+ KeepMergedFile = keepSecond
+ }, storage, token)).IsSuccess.ShouldBeTrue();
+
+ (await File.ReadAllBytesAsync(retained, token)).ShouldBe(new byte[] { 1, 2, 3 });
+ Directory.GetFiles(chunks, "*.bin", SearchOption.AllDirectories).Length.ShouldBe(keepSecond ? 2 : 1);
+ }
+
+ private static async Task AppendAsync(ChunkUploadService service, string id, string name, byte[]? bytes = null)
{
- using var content = new MemoryStream(new byte[] { 1, 2, 3 }, writable: false);
+ using var content = new MemoryStream(bytes ?? [1, 2, 3], writable: false);
var result = await service.AppendChunkAsync(new FileUploadPayload
{
File = new FormFile(content, 0, content.Length, "file", name),
diff --git a/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs
index e9a2e3b..4cd8776 100644
--- a/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/Storages/AWS/AwsMultipartStreamContractsTests.cs
@@ -47,7 +47,7 @@ public async Task MultipartWrites_RoundTripBytesAcrossPartBoundaries(int constru
stream.CanSeek.ShouldBeFalse();
stream.CanWrite.ShouldBeTrue();
stream.SetLength(content.Length);
- stream.Write(content, 0, 5 * 1024 * 1024);
+ stream.Write(content.AsSpan(0, 5 * 1024 * 1024));
stream.Flush();
stream.Write(content, 5 * 1024 * 1024, 257);
stream.Write(content, 0, 0);
@@ -63,6 +63,26 @@ public async Task MultipartWrites_RoundTripBytesAcrossPartBoundaries(int constru
(pending.MultipartUploads?.Count ?? 0).ShouldBe(0);
}
+ [Fact]
+ public async Task LengthHint_RejectsNegativeAndDisposedChanges()
+ {
+ using var client = CreateClient();
+ var token = TestContext.Current.CancellationToken;
+ var bucket = "length-contract-" + Guid.NewGuid().ToString("N");
+ await client.PutBucketAsync(bucket, token);
+ using var stream = new BlobStream(client, bucket, "file");
+
+ var negativeError = Record.Exception(() => stream.SetLength(-1));
+ stream.Write(new byte[] { 7, 8 }.AsSpan());
+ stream.Dispose();
+ var disposedError = Record.Exception(() => stream.SetLength(3));
+
+ negativeError.ShouldBeOfType();
+ disposedError.ShouldBeOfType();
+ using var downloaded = await client.GetObjectAsync(bucket, "file", token);
+ (await SHA256.HashDataAsync(downloaded.ResponseStream, token)).ShouldBe(SHA256.HashData(new byte[] { 7, 8 }));
+ }
+
[Fact]
public async Task EmptyStream_CreatesEmptyObjectWithoutLeavingMultipartUpload()
{
diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs
index 72f522e..00ba23c 100644
--- a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemLegalHoldContractsTests.cs
@@ -1,3 +1,5 @@
+using System.IO;
+using System.Threading;
using System.Threading.Tasks;
using ManagedCode.Storage.Core.Models;
using ManagedCode.Storage.Tests.VirtualFileSystem;
@@ -24,4 +26,30 @@ public async Task HoldTransitionsAndMissingFiles_ReturnObservableResults()
(await storage.SetLegalHoldAsync(true, "missing.txt", token)).IsFailed.ShouldBeTrue();
(await storage.DeleteAsync("held.txt", token)).IsSuccess.ShouldBeTrue();
}
+
+ [Fact]
+ public async Task HeldFile_RejectsOverwriteAndDeletionThroughAllProviderRoutes()
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var storage = session.Storage;
+ var token = TestContext.Current.CancellationToken;
+ const string name = "folder/held.txt";
+ (await storage.UploadAsync("original", new UploadOptions(name), token)).IsSuccess.ShouldBeTrue();
+ (await storage.SetLegalHoldAsync(true, name, token)).IsSuccess.ShouldBeTrue();
+ try
+ {
+ (await storage.UploadAsync("replacement", new UploadOptions(name), token)).IsFailed.ShouldBeTrue();
+ (await storage.DeleteAsync(name, token)).IsFailed.ShouldBeTrue();
+ (await storage.DeleteDirectoryAsync("folder", token)).IsFailed.ShouldBeTrue();
+ (await storage.RemoveContainerAsync(token)).IsFailed.ShouldBeTrue();
+ (await File.ReadAllTextAsync(Path.Combine(session.RootDirectory, name), token)).ShouldBe("original");
+ }
+ finally
+ {
+ (await storage.SetLegalHoldAsync(false, name, CancellationToken.None)).IsSuccess.ShouldBeTrue();
+ }
+
+ (await storage.DeleteDirectoryAsync("folder", token)).IsSuccess.ShouldBeTrue();
+ File.Exists(Path.Combine(session.RootDirectory, name)).ShouldBeFalse();
+ }
}
diff --git a/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadBufferTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadBufferTests.cs
new file mode 100644
index 0000000..b32c33b
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/Storages/FileSystem/FileSystemUploadBufferTests.cs
@@ -0,0 +1,69 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using ManagedCode.Storage.Core.Models;
+using ManagedCode.Storage.Tests.VirtualFileSystem;
+using Shouldly;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.Storages.FileSystem;
+
+public sealed class FileSystemUploadBufferTests
+{
+ [Fact]
+ public async Task NonSeekableUpload_BoundsReadBuffersAndPreservesTheCallerStream()
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var sourcePath = Path.Combine(session.RootDirectory, "source.bin");
+ var destinationPath = Path.Combine(session.RootDirectory, "destination.bin");
+ var bytes = new byte[512 * 1024 + 17];
+ new Random(19).NextBytes(bytes);
+ await File.WriteAllBytesAsync(sourcePath, bytes, token);
+ using var observed = new ObservedReadStream(File.OpenRead(sourcePath));
+
+ var result = await session.Storage.UploadAsync(observed, new UploadOptions("destination.bin"), token);
+
+ result.IsSuccess.ShouldBeTrue();
+ observed.MaxRequestedBytes.ShouldBeGreaterThan(0);
+ observed.MaxRequestedBytes.ShouldBeLessThanOrEqualTo(256 * 1024);
+ observed.CanRead.ShouldBeTrue();
+ (await File.ReadAllBytesAsync(destinationPath, token)).ShouldBe(bytes);
+ }
+
+ private sealed class ObservedReadStream(FileStream source) : Stream
+ {
+ public int MaxRequestedBytes { get; private set; }
+ public override bool CanRead => source.CanRead;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+ public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ MaxRequestedBytes = Math.Max(MaxRequestedBytes, count);
+ return source.Read(buffer, offset, count);
+ }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ MaxRequestedBytes = Math.Max(MaxRequestedBytes, buffer.Length);
+ return source.ReadAsync(buffer, cancellationToken);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ source.Dispose();
+
+ base.Dispose(disposing);
+ }
+
+ public override void Flush() => throw new NotSupportedException();
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+}
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs
index 6519f5c..61d91d3 100644
--- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/LocalVfsSession.cs
@@ -30,14 +30,16 @@ private LocalVfsSession(string root, ServiceProvider provider, FileSystemStorage
public IVirtualFileSystem FileSystem { get; }
public static async Task CreateAsync(bool cache = true,
- DirectoryStrategy strategy = DirectoryStrategy.Virtual, int pageSize = 100, bool initialize = true)
+ DirectoryStrategy strategy = DirectoryStrategy.Virtual, int pageSize = 100, bool initialize = true,
+ Func? createStorage = null)
{
var root = Path.Combine(Path.GetTempPath(), "managedcode-vfs-contracts", Guid.NewGuid().ToString("N"));
- var storage = new FileSystemStorage(new FileSystemStorageOptions
+ var storageOptions = new FileSystemStorageOptions
{
BaseFolder = root,
CreateContainerIfNotExists = true
- });
+ };
+ var storage = createStorage?.Invoke(storageOptions) ?? new FileSystemStorage(storageOptions);
if (initialize)
(await storage.CreateContainerAsync(TestContext.Current.CancellationToken)).IsSuccess.ShouldBeTrue();
var services = new ServiceCollection();
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs
index 9e9cecf..f9cecc4 100644
--- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/SearchPatternContractTests.cs
@@ -16,6 +16,11 @@ public sealed class SearchPatternContractTests
[InlineData("a*b*c", "axxbxxbc", true)]
[InlineData("a*b*c", "axxbxxbd", false)]
[InlineData("a**", "abc", true)]
+ [InlineData("*", "*literal", true)]
+ [InlineData("prefix-*", "prefix-*tail", true)]
+ [InlineData("*report.txt", "*draft-report.txt", true)]
+ [InlineData("*.txt", "*draft.csv", false)]
+ [InlineData("*-é.txt", "café-É.txt", true)]
[InlineData("a?c", "abc", true)]
[InlineData("abc", "abcd", false)]
[InlineData("abc", "abc", true)]
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsCopySafetyTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsCopySafetyTests.cs
new file mode 100644
index 0000000..e380653
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsCopySafetyTests.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using ManagedCode.Communication;
+using ManagedCode.Storage.Core.Models;
+using ManagedCode.Storage.FileSystem;
+using ManagedCode.Storage.FileSystem.Options;
+using ManagedCode.Storage.VirtualFileSystem.Core;
+using ManagedCode.Storage.VirtualFileSystem.Exceptions;
+using ManagedCode.Storage.VirtualFileSystem.Options;
+using Shouldly;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.VirtualFileSystem;
+
+public sealed class VfsCopySafetyTests
+{
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task Copy_WithInterruptedSourceRead_PreservesDestination(bool directory, bool exists)
+ {
+ await using var session = await LocalVfsSession.CreateAsync(createStorage: options => new ObservedStorage(options));
+ var token = TestContext.Current.CancellationToken;
+ var source = await session.FileSystem.GetFileAsync("/source/file.txt", token);
+ await source.WriteAllTextAsync("original source", cancellationToken: token);
+ var destination = await session.FileSystem.GetFileAsync("/destination/file.txt", token);
+ if (exists)
+ await destination.WriteAllTextAsync("existing destination", cancellationToken: token);
+
+ ((ObservedStorage)session.Storage).InterruptSourceRead = true;
+ await Should.ThrowAsync(() => session.FileSystem.CopyAsync(
+ directory ? "/source" : "/source/file.txt",
+ directory ? "/destination" : "/destination/file.txt",
+ new CopyOptions { Overwrite = true }, cancellationToken: token));
+ ((ObservedStorage)session.Storage).InterruptSourceRead = false;
+
+ (await source.ReadAllTextAsync(cancellationToken: token)).ShouldBe("original source");
+ (await destination.ExistsAsync(token)).ShouldBe(exists);
+ if (exists)
+ (await destination.ReadAllTextAsync(cancellationToken: token)).ShouldBe("existing destination");
+ }
+
+ [Fact]
+ public async Task DirectoryCopy_WithoutProgress_ListsOnceAfterFindingSource()
+ {
+ await using var session = await LocalVfsSession.CreateAsync(createStorage: options => new ObservedStorage(options));
+ var token = TestContext.Current.CancellationToken;
+ var source = await session.FileSystem.GetFileAsync("/source/file.txt", token);
+ await source.WriteAllTextAsync("copy bytes", cancellationToken: token);
+ (await session.FileSystem.DirectoryExistsAsync("/source", token)).ShouldBeTrue();
+ var storage = (ObservedStorage)session.Storage;
+ storage.ListRequests = 0;
+
+ await session.FileSystem.CopyAsync("/source", "/destination", cancellationToken: token);
+
+ storage.ListRequests.ShouldBe(1);
+ var destination = await session.FileSystem.GetFileAsync("/destination/file.txt", token);
+ (await destination.ReadAllTextAsync(cancellationToken: token)).ShouldBe("copy bytes");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Copy_WithProgress_ReportsActualSize(bool directory)
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var source = await session.FileSystem.GetFileAsync("/source/file.bin", token);
+ var content = new byte[] { 7, 11, 19, 23 };
+ await source.WriteAllBytesAsync(content, cancellationToken: token);
+ var progress = new RecordedProgress();
+
+ await session.FileSystem.CopyAsync(directory ? "/source" : "/source/file.bin",
+ directory ? "/destination" : "/destination/file.bin", progress: progress, cancellationToken: token);
+
+ progress.Reports[0].TotalBytes.ShouldBe(content.LongLength);
+ progress.Reports[^1].CopiedBytes.ShouldBe(content.LongLength);
+ progress.Reports[^1].CopiedFiles.ShouldBe(1);
+ }
+
+ [Theory]
+ [InlineData("/source/file.txt", "/source/./file.txt")]
+ [InlineData("/source", "/source/child")]
+ public async Task Copy_IntoItself_RejectsBeforeWriting(string source, string destination)
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var original = await session.FileSystem.GetFileAsync("/source/file.txt", token);
+ await original.WriteAllTextAsync("original", cancellationToken: token);
+
+ await Should.ThrowAsync(() => session.FileSystem.CopyAsync(source, destination,
+ new CopyOptions { Overwrite = true }, cancellationToken: token));
+
+ (await original.ReadAllTextAsync(cancellationToken: token)).ShouldBe("original");
+ Directory.GetFiles(session.RootDirectory, "*", SearchOption.AllDirectories).Length.ShouldBe(1);
+ }
+
+ private sealed class RecordedProgress : IProgress
+ {
+ public List Reports { get; } = [];
+ public void Report(CopyProgress value) => Reports.Add(value);
+ }
+
+ private sealed class ObservedStorage(FileSystemStorageOptions options) : FileSystemStorage(options)
+ {
+ public bool InterruptSourceRead { get; set; }
+ public int ListRequests { get; set; }
+
+ public override IAsyncEnumerable GetBlobMetadataListAsync(string? directory = null,
+ CancellationToken cancellationToken = default)
+ {
+ ListRequests++;
+ return base.GetBlobMetadataListAsync(directory, cancellationToken);
+ }
+
+ public override async Task> GetStreamAsync(string fileName, CancellationToken cancellationToken = default)
+ {
+ var result = await base.GetStreamAsync(fileName, cancellationToken);
+ if (InterruptSourceRead && fileName == "source/file.txt" && result.IsSuccess && result.Value is FileStream file)
+ {
+ await file.DisposeAsync();
+ return Result.Succeed(new InterruptedFileReadStream(file.Name));
+ }
+
+ return result;
+ }
+ }
+
+ private sealed class InterruptedFileReadStream(string path) : FileStream(path, FileMode.Open, FileAccess.Read)
+ {
+ public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
+ {
+ var prefix = new byte[4];
+ await ReadExactlyAsync(prefix, cancellationToken);
+ await destination.WriteAsync(prefix, cancellationToken);
+ await DisposeAsync();
+ await ReadExactlyAsync(prefix, cancellationToken);
+ }
+ }
+}
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs
index e1e6ccd..5fc56ff 100644
--- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs
@@ -24,6 +24,27 @@ public sealed class VfsImmutableMetadataTests : IAsyncLifetime
public ValueTask InitializeAsync() => new(_container.StartAsync());
public ValueTask DisposeAsync() => _container.DisposeAsync();
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Copy_WithMetadata_CommitsExactBytesAndCustomFields(bool directory)
+ {
+ await using var provider = CreateProvider(true);
+ await using var scope = provider.CreateAsyncScope();
+ var token = TestContext.Current.CancellationToken;
+ var vfs = scope.ServiceProvider.GetRequiredService();
+ var metadata = new Dictionary { ["title"] = "Копія", ["owner"] = "source" };
+ var source = await vfs.GetFileAsync("/source/file.txt", token);
+ await source.WriteAllTextAsync("source content", options: new() { Metadata = metadata }, cancellationToken: token);
+
+ await vfs.CopyAsync(directory ? "/source" : "/source/file.txt",
+ directory ? "/destination" : "/destination/file.txt", cancellationToken: token);
+
+ var destination = await vfs.GetFileAsync("/destination/file.txt", token);
+ (await destination.ReadAllTextAsync(cancellationToken: token)).ShouldBe("source content");
+ AssertMetadata(await destination.GetMetadataAsync(token), metadata);
+ }
+
[Theory]
[InlineData(false, "material.txt")]
[InlineData(true, "material.txt")]
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMetadataFailureTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMetadataFailureTests.cs
index 62eadcd..b65d1b4 100644
--- a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMetadataFailureTests.cs
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMetadataFailureTests.cs
@@ -3,6 +3,7 @@
using System.Threading;
using System.Threading.Tasks;
using ManagedCode.Communication;
+using ManagedCode.Storage.Core.Models;
using ManagedCode.Storage.FileSystem;
using ManagedCode.Storage.FileSystem.Options;
using ManagedCode.Storage.VirtualFileSystem.Core;
@@ -80,6 +81,60 @@ public async Task ActualMissingFile_StillReportsNotFoundAndCanBeCreatedAndRead()
(await file.ReadAllTextAsync(cancellationToken: TestContext.Current.CancellationToken)).ShouldBe("now-present");
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ExistingHandle_ObservesWritesAndDeletesAfterLoadingMissingMetadata(bool cache)
+ {
+ await using var session = await LocalVfsSession.CreateAsync(cache);
+ var token = TestContext.Current.CancellationToken;
+ var observer = await session.FileSystem.GetFileAsync("/observed.bin", token);
+ var writer = await session.FileSystem.GetFileAsync("/observed.bin", token);
+ await Should.ThrowAsync(() => observer.OpenReadAsync(cancellationToken: token));
+
+ await writer.WriteAllBytesAsync([2, 5, 9], cancellationToken: token);
+ (await observer.ReadAllBytesAsync(token)).ShouldBe(new byte[] { 2, 5, 9 });
+ observer.Size.ShouldBe(3);
+
+ (await writer.DeleteAsync(token)).ShouldBeTrue();
+ await Should.ThrowAsync(() => observer.OpenReadAsync(cancellationToken: token));
+ }
+
+ [Theory]
+ [InlineData(true, 1)]
+ [InlineData(false, 2)]
+ public async Task Refresh_UsesOneProviderMetadataReadAndHonorsSharedCaching(bool cache, int expectedReads)
+ {
+ await using var session = await LocalVfsSession.CreateAsync(cache, createStorage: options => new MetadataCountingStorage(options));
+ var token = TestContext.Current.CancellationToken;
+ var file = await session.FileSystem.GetFileAsync("/counted.bin", token);
+ await file.WriteAllBytesAsync([2, 5, 9], cancellationToken: token);
+ var storage = (MetadataCountingStorage)session.Storage;
+ var before = storage.MetadataReads;
+
+ await file.RefreshAsync(token);
+
+ file.Size.ShouldBe(3);
+ (storage.MetadataReads - before).ShouldBe(1);
+ var secondHandle = await session.FileSystem.GetFileAsync("/counted.bin", token);
+ await using var opened = await secondHandle.OpenReadAsync(cancellationToken: token);
+ opened.ReadByte().ShouldBe(2);
+ secondHandle.CreatedOn.ShouldBe(file.CreatedOn);
+ (storage.MetadataReads - before).ShouldBe(expectedReads);
+ }
+
+ private sealed class MetadataCountingStorage(FileSystemStorageOptions options) : FileSystemStorage(options)
+ {
+ public int MetadataReads { get; private set; }
+
+ protected override Task> GetBlobMetadataInternalAsync(MetadataOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ MetadataReads++;
+ return base.GetBlobMetadataInternalAsync(options, cancellationToken);
+ }
+ }
+
private sealed class Session(string root, ServiceProvider services, FileSystemStorage storage) : IAsyncDisposable
{
public FileSystemStorage Storage { get; } = storage;
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMoveSafetyTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMoveSafetyTests.cs
new file mode 100644
index 0000000..b327994
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsMoveSafetyTests.cs
@@ -0,0 +1,102 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using ManagedCode.Storage.VirtualFileSystem.Exceptions;
+using ManagedCode.Storage.VirtualFileSystem.Options;
+using Shouldly;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.VirtualFileSystem;
+
+public sealed class VfsMoveSafetyTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task DirectoryMove_WithDestinationConflict_PreservesBothVersions(bool cachedMiss)
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var source = await session.FileSystem.GetFileAsync("/source/file.txt", token);
+ var destination = await session.FileSystem.GetFileAsync("/destination/file.txt", token);
+ await source.WriteAllTextAsync("source bytes", cancellationToken: token);
+ if (cachedMiss)
+ {
+ (await destination.ExistsAsync(token)).ShouldBeFalse();
+ var destinationDirectory = Directory.CreateDirectory(Path.Combine(session.RootDirectory, "destination"));
+ await File.WriteAllTextAsync(Path.Combine(destinationDirectory.FullName, "file.txt"), "destination bytes", token);
+ }
+ else
+ {
+ await destination.WriteAllTextAsync("destination bytes", cancellationToken: token);
+ }
+
+ await Should.ThrowAsync(() =>
+ session.FileSystem.MoveAsync("/source", "/destination", cancellationToken: token));
+
+ (await source.ReadAllTextAsync(cancellationToken: token)).ShouldBe("source bytes");
+ (await File.ReadAllTextAsync(Path.Combine(session.RootDirectory, "destination", "file.txt"), token)).ShouldBe("destination bytes");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Move_WhenLegalHoldPreventsSourceDeletion_ReportsFailure(bool directory)
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var source = await session.FileSystem.GetFileAsync("/source/file.txt", token);
+ await source.WriteAllTextAsync("held bytes", cancellationToken: token);
+ (await session.Storage.SetLegalHoldAsync(true, "source/file.txt", token)).IsSuccess.ShouldBeTrue();
+
+ try
+ {
+ await Should.ThrowAsync(() => session.FileSystem.MoveAsync(
+ directory ? "/source" : "/source/file.txt", directory ? "/destination" : "/destination/file.txt",
+ cancellationToken: token));
+
+ (await source.ReadAllTextAsync(cancellationToken: token)).ShouldBe("held bytes");
+ var copy = await session.FileSystem.GetFileAsync("/destination/file.txt", token);
+ (await copy.ReadAllTextAsync(cancellationToken: token)).ShouldBe("held bytes");
+ }
+ finally
+ {
+ (await session.Storage.SetLegalHoldAsync(false, "source/file.txt", CancellationToken.None)).IsSuccess.ShouldBeTrue();
+ }
+ }
+
+ [Theory]
+ [InlineData("/tree/file.txt", "/tree/./file.txt")]
+ [InlineData("/tree", "/tree/")]
+ [InlineData("/tree", "/tree/nested")]
+ [InlineData("/tree/nested", "/tree")]
+ public async Task Move_WithOverlappingPaths_LeavesOriginalBytes(string source, string destination)
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var originalPath = source.EndsWith(".txt", System.StringComparison.Ordinal) ? source : source + "/file.txt";
+ var original = await session.FileSystem.GetFileAsync(originalPath, token);
+ await original.WriteAllTextAsync("original bytes", cancellationToken: token);
+
+ await Should.ThrowAsync(() =>
+ session.FileSystem.MoveAsync(source, destination, new MoveOptions { Overwrite = true }, token));
+
+ (await original.ReadAllTextAsync(cancellationToken: token)).ShouldBe("original bytes");
+ Directory.GetFiles(session.RootDirectory, "*", SearchOption.AllDirectories).Length.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task DirectoryMove_WithSimilarSiblingPrefix_RelocatesFiles()
+ {
+ await using var session = await LocalVfsSession.CreateAsync();
+ var token = TestContext.Current.CancellationToken;
+ var original = await session.FileSystem.GetFileAsync("/tree/nested/file.txt", token);
+ await original.WriteAllTextAsync("original bytes", cancellationToken: token);
+
+ await session.FileSystem.MoveAsync("/tree", "/tree-backup", cancellationToken: token);
+
+ (await original.ExistsAsync(token)).ShouldBeFalse();
+ var moved = await session.FileSystem.GetFileAsync("/tree-backup/nested/file.txt", token);
+ (await moved.ReadAllTextAsync(cancellationToken: token)).ShouldBe("original bytes");
+ }
+}
diff --git a/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsRangeAllocationTests.cs b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsRangeAllocationTests.cs
new file mode 100644
index 0000000..2f2a2f4
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsRangeAllocationTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using ManagedCode.Communication;
+using ManagedCode.Storage.FileSystem;
+using ManagedCode.Storage.FileSystem.Options;
+using Shouldly;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.VirtualFileSystem;
+
+public sealed class VfsRangeAllocationTests
+{
+ [Theory]
+ [InlineData(true, 8)]
+ [InlineData(false, 8)]
+ [InlineData(true, 10)]
+ [InlineData(false, 10)]
+ public async Task HugeRange_ReturnsOnlyBytesStillAvailable(bool seekable, int offset)
+ {
+ await using var session = await LocalVfsSession.CreateAsync(createStorage: seekable ? null : options => new ForwardOnlyStorage(options));
+ var token = TestContext.Current.CancellationToken;
+ var content = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
+ var file = await session.FileSystem.GetFileAsync("/range.bin", token);
+ await file.WriteAllBytesAsync(content, cancellationToken: token);
+
+ (await file.ReadRangeAsync(offset, int.MaxValue, token)).ShouldBe(content[offset..]);
+ }
+
+ [Fact]
+ public async Task ForwardOnlyRange_StopsAtRequestedCountAcrossBufferBoundaries()
+ {
+ await using var session = await LocalVfsSession.CreateAsync(createStorage: options => new ForwardOnlyStorage(options));
+ var token = TestContext.Current.CancellationToken;
+ var content = new byte[192 * 1024];
+ new Random(17).NextBytes(content);
+ var file = await session.FileSystem.GetFileAsync("/range.bin", token);
+ await file.WriteAllBytesAsync(content, cancellationToken: token);
+
+ const int offset = 13;
+ const int count = 128 * 1024 + 11;
+ (await file.ReadRangeAsync(offset, count, token)).ShouldBe(content[offset..(offset + count)]);
+ }
+
+ private sealed class ForwardOnlyStorage(FileSystemStorageOptions options) : FileSystemStorage(options)
+ {
+ public override async Task> GetStreamAsync(string fileName, CancellationToken cancellationToken = default)
+ {
+ var result = await base.GetStreamAsync(fileName, cancellationToken);
+ if (result.IsFailed || result.Value is not FileStream file)
+ return result;
+
+ var path = file.Name;
+ await file.DisposeAsync();
+ return Result.Succeed(new ForwardOnlyFileStream(path));
+ }
+ }
+
+ private sealed class ForwardOnlyFileStream(string path) : FileStream(path, FileMode.Open, FileAccess.Read)
+ {
+ public override bool CanSeek => false;
+ }
+}
diff --git a/docs/Features/chunked-uploads.md b/docs/Features/chunked-uploads.md
index c328f62..755df8d 100644
--- a/docs/Features/chunked-uploads.md
+++ b/docs/Features/chunked-uploads.md
@@ -76,10 +76,13 @@ Client-side:
## Temporary file ownership
-Session directories are derived from the exact validated upload identity. IDs
-accept ASCII letters, digits, hyphens and underscores, up to 128 characters;
-invalid IDs are rejected rather than normalized into another session. The
+Each admitted session gets a new server-generated working directory, including
+when a previously completed upload ID is reused. IDs accept ASCII letters,
+digits, hyphens and underscores, up to 128 characters. The
service generates the merged staging filename independently of client file
names. Client names cannot overwrite files outside a session or collide with
numbered part files. `KeepMergedFile` retains only the merged content after the
part files are cleaned; callers requesting retention own its eventual cleanup.
+Expired sessions are cleaned up before completion can write to storage.
+For a known upload length, append requests check the combined size before staging.
+A replacement counts only its new size, and a rejected replacement preserves the accepted chunk.
diff --git a/docs/Features/integration-aspnet-server.md b/docs/Features/integration-aspnet-server.md
index 1e708b7..93809de 100644
--- a/docs/Features/integration-aspnet-server.md
+++ b/docs/Features/integration-aspnet-server.md
@@ -37,6 +37,7 @@ sequenceDiagram
### SignalR streaming
- `StorageHub` streams bytes over SignalR and bridges to `IStorage` stream operations.
+- Failed download opens and reads emit a terminal transfer status to the caller. Canceled downloads emit the cancellation status.
## Quickstart
diff --git a/docs/Features/provider-aws-s3.md b/docs/Features/provider-aws-s3.md
index 2fe0a2c..7d4aa90 100644
--- a/docs/Features/provider-aws-s3.md
+++ b/docs/Features/provider-aws-s3.md
@@ -51,6 +51,7 @@ builder.Services.AddAWSStorageAsDefault(options =>
- Downloads read the body and metadata from one S3 response. Metadata returns the object key relative to its bucket. Listings follow the service's continuation tokens.
- The multipart write stream buffers parts in temporary files and keeps at most two part uploads in flight. If a write or upload fails, it closes the buffers and aborts the pending multipart upload.
+- Multipart writes accept byte arrays and spans directly. `SetLength` is a nonnegative sizing hint for an open writer.
- Supports multiple auth modes (access keys / role / instance profile) via `AWSStorageOptions`.
- Can create the bucket when `CreateContainerIfNotExists = true`.
diff --git a/docs/Features/provider-filesystem.md b/docs/Features/provider-filesystem.md
index 713abab..f482716 100644
--- a/docs/Features/provider-filesystem.md
+++ b/docs/Features/provider-filesystem.md
@@ -50,8 +50,10 @@ builder.Services.AddFileSystemStorageAsDefault(options =>
- `BaseFolder` acts as the container root.
- Supports directory creation when `CreateContainerIfNotExists = true`.
+- Uploads use a 256 KiB copy buffer and leave the source stream under caller ownership.
- Directory listing and deletion accept paths relative to `BaseFolder`. Use `RemoveContainerAsync` to delete the container root.
- File and directory operations reject paths that traverse symbolic links or reparse points below the configured root. Recursive listings skip those entries.
+- A legal hold in the provider instance blocks overwrite and deletion of the file. Deleting a directory or the container also fails while it contains a held file.
## Tests
diff --git a/docs/Features/virtual-file-system.md b/docs/Features/virtual-file-system.md
index 89e385a..a7b36f8 100644
--- a/docs/Features/virtual-file-system.md
+++ b/docs/Features/virtual-file-system.md
@@ -22,6 +22,10 @@ Metadata reads return null or empty metadata only for a real provider 404 or abs
After a write or deletion through the backing provider, call `IVirtualFile.RefreshAsync` on an existing file handle. It refreshes metadata and invalidates cached existence, including a cached miss before a multipart commit. The next existence check and read observe the committed object.
+Writes and deletes through the VFS invalidate shared metadata for existing handles. Reads resolve metadata through that cache or the provider. The default metadata manager builds each refreshed snapshot from one provider lookup.
+
+`ReadRangeAsync` returns the available bytes when a range reaches the end of a file. Seekable reads cap allocation at the remaining length. Large ranges on forward-only streams use a pooled transfer buffer and grow the result as bytes arrive.
+
```mermaid
flowchart TD
A[Caller] --> VFS[IVirtualFileSystem]
@@ -33,6 +37,9 @@ flowchart TD
### Directory operations
- Directory listing is implemented by prefix-listing blob metadata via `IStorage.GetBlobMetadataListAsync(...)`.
+- Search patterns match names with `*` for zero or more characters and `?` for one character. Matching uses ordinal comparison and ignores case by default.
+- A directory move rejects overlapping source and destination paths. With overwrite disabled, a destination file conflict leaves the source files in place. The move removes only the source files it copied, and reports an error if a source cannot be removed. Copies completed before a failure can remain at the destination.
+- A copy stages each file and sends its bytes and selected metadata in the same upload. If reading that file fails, an existing destination retains its content. A directory copy scans for progress totals only when a progress reporter is supplied.
### Immutable content and metadata