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 Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
<RepositoryUrl>https://github.com/managedcode/Storage</RepositoryUrl>
<PackageProjectUrl>https://github.com/managedcode/Storage</PackageProjectUrl>
<Product>Managed Code - Storage</Product>
<Version>10.0.14</Version>
<PackageVersion>10.0.14</PackageVersion>
<Version>10.0.15</Version>
<PackageVersion>10.0.15</PackageVersion>

</PropertyGroup>

Expand Down
24 changes: 11 additions & 13 deletions ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,22 +275,20 @@ protected Dictionary<string, string> ExtractCustomMetadata(IDictionary<string, s

foreach (var kvp in storageMetadata)
{
if (kvp.Key.StartsWith(MetadataPrefix, StringComparison.Ordinal))
if (!kvp.Key.StartsWith(MetadataPrefix, StringComparison.Ordinal))
{
// Skip VFS system metadata
if (kvp.Key.EndsWith(VfsVersionKey, StringComparison.Ordinal) ||
kvp.Key.EndsWith(VfsCreatedKey, StringComparison.Ordinal) ||
kvp.Key.EndsWith(VfsModifiedKey, StringComparison.Ordinal) ||
kvp.Key.EndsWith(VfsAttributesKey, StringComparison.Ordinal) ||
kvp.Key.Contains(VfsCustomPrefix, StringComparison.Ordinal))
{
continue;
}
result[kvp.Key] = kvp.Value;
continue;
}

// Include other custom metadata
var key = kvp.Key[MetadataPrefix.Length..];
result[key] = kvp.Value;
var key = kvp.Key[MetadataPrefix.Length..];
if (key is VfsVersionKey or VfsCreatedKey or VfsModifiedKey or VfsAttributesKey ||
key.StartsWith(VfsCustomPrefix, StringComparison.Ordinal))
{
continue;
}

result[key] = kvp.Value;
}

return result;
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Cloud storage vendors expose distinct SDKs, option models, and authentication pa
- Azure metadata preserves Unicode filenames and other logical string values through a provider-owned ASCII transport envelope; ordinary native ASCII metadata remains interoperable. See [metadata transport](https://github.com/managed-code-hub/Storage/blob/main/docs/Features/provider-azure-blob.md#metadata-transport).
- Virtual File System package provides a file/directory API (`IVirtualFileSystem`) on top of the configured `IStorage` and can cache metadata for faster repeated operations, including browser storage verified through real Playwright flows in both Blazor WebAssembly and Interactive Server hosts.
- For decisions requiring current storage state, `IVirtualFileSystem.StorageFileExistsAsync` bypasses that cache and propagates provider errors. `WriteBytesIfAbsentOrSameAsync` uses an atomic provider capability and accepts only an exact immutable retry. `ManagedCode.Storage.Core.Primitives.VerifiedContentSnapshot` verifies bounded reads by length and SHA-256.
- Metadata supplied to a VFS immutable write is returned by `IVirtualFile.GetMetadataAsync`, including Unicode values and legacy VFS-prefixed user fields. Reserved VFS bookkeeping fields remain private to the overlay.
- Comprehensive automated test suite with cross-provider sync fixtures, multi-gigabyte streaming simulations (4 MB units per "GB"), ASP.NET controller harnesses, SFTP/local filesystem coverage, and Playwright browser verification for browser storage small-file overwrites, concurrent tabs, VFS flows, a fast `128 MiB` browser large-file lane, and a separate `256 MiB` browser stress lane in both Interactive Server and Blazor WebAssembly hosts.
- ManagedCode.Storage.TestFakes package plus Testcontainers-based fixtures make it easy to run offline or CI tests without touching real cloud accounts.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using ManagedCode.Storage.Azure.Extensions;
using ManagedCode.Storage.Core;
using ManagedCode.Storage.Core.Primitives;
using ManagedCode.Storage.Tests.Common;
using ManagedCode.Storage.VirtualFileSystem.Core;
using ManagedCode.Storage.VirtualFileSystem.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Testcontainers.Azurite;
using Xunit;

namespace ManagedCode.Storage.Tests.VirtualFileSystem;

public sealed class VfsImmutableMetadataTests : IAsyncLifetime
{
private readonly AzuriteContainer _container = new AzuriteBuilder(ContainerImages.Azurite)
.WithCommand("--skipApiVersionCheck").Build();

public Task InitializeAsync() => _container.StartAsync();
public Task DisposeAsync() => _container.DisposeAsync().AsTask();

[Theory]
[InlineData(false, "material.txt")]
[InlineData(true, "material.txt")]
[InlineData(false, "матеріал.txt")]
[InlineData(true, "матеріал.txt")]
public async Task ImmutableWrite_PreservesMetadataThroughFreshAndPreviouslyEmptyHandles(bool cache, string name)
{
await using var provider = CreateProvider(cache);
await using var scope = provider.CreateAsyncScope();
var vfs = scope.ServiceProvider.GetRequiredService<IVirtualFileSystem>();
var path = new VfsPath("/learning/material/content");
var previous = await vfs.GetFileAsync(path);
(await previous.GetMetadataAsync()).ShouldBeEmpty();
(await vfs.FileExistsAsync(path)).ShouldBeFalse();
var bytes = Encoding.UTF8.GetBytes("exact immutable author bytes");
var metadata = new Dictionary<string, string>
{
["fileName"] = name,
["ownerUserId"] = "exact-owner",
["contentType"] = "text/plain"
};
var options = new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata };
var written = await vfs.WriteBytesIfAbsentOrSameAsync(path, bytes, options);
written.ReusedExisting.ShouldBeFalse();
AssertMetadata(await previous.GetMetadataAsync(), metadata);
(await vfs.FileExistsAsync(path)).ShouldBeTrue();
var fresh = await vfs.GetFileAsync(path);
await fresh.RefreshAsync();
fresh.Size.ShouldBe(bytes.LongLength);
AssertMetadata(await fresh.GetMetadataAsync(), metadata);
await using var stream = await fresh.OpenReadAsync();
using var content = new MemoryStream();
await stream.CopyToAsync(content);
content.ToArray().ShouldBe(bytes);
var replay = await vfs.WriteBytesIfAbsentOrSameAsync(path, bytes, options);
replay.ReusedExisting.ShouldBeTrue();
replay.Info.ETag.ShouldBe(written.Info.ETag);
AssertMetadata(await fresh.GetMetadataAsync(), metadata);
}

[Fact]
public async Task MetadataRead_PreservesLegacyCustomFieldsAndFiltersOnlyReservedVfsFields()
{
await using var provider = CreateProvider(false);
await using var scope = provider.CreateAsyncScope();
var storage = scope.ServiceProvider.GetRequiredService<IStorage>();
var vfs = scope.ServiceProvider.GetRequiredService<IVirtualFileSystem>();
var metadata = new Dictionary<string, string>
{
["ownerUserId"] = "exact-owner",
["x-vfs-reviewer"] = "legacy-custom",
["x-vfs-review-vfs-created"] = "user-field",
["x-vfs-vfs-version"] = "1",
["x-vfs-vfs-created"] = DateTimeOffset.UtcNow.ToString("O"),
["x-vfs-vfs-modified"] = DateTimeOffset.UtcNow.ToString("O"),
["x-vfs-vfs-attributes"] = "0",
["x-vfs-vfs-internal"] = "private-vfs-state"
};
await storage.RequireObjectStorage().CreatePrivateContainerAsync();
using var bytes = new MemoryStream(Encoding.UTF8.GetBytes("legacy bytes"));
await storage.RequireObjectStorage().WriteObjectAsync("legacy/content", bytes,
new StorageWriteOptions { Metadata = metadata });
var file = await vfs.GetFileAsync("/legacy/content");
AssertMetadata(await file.GetMetadataAsync(), new Dictionary<string, string>
{
["ownerUserId"] = "exact-owner",
["reviewer"] = "legacy-custom",
["review-vfs-created"] = "user-field"
});
}

private ServiceProvider CreateProvider(bool cache)
{
var services = new ServiceCollection();
services.AddLogging();
var containerName = $"vfs-metadata-{Guid.NewGuid():N}";
services.AddAzureStorageAsDefault(options =>
{
options.ConnectionString = _container.GetConnectionString();
options.Container = containerName;
options.CreateContainerIfNotExists = true;
});
services.AddVirtualFileSystem(options =>
{
options.DefaultContainer = containerName;
options.EnableCache = cache;
});
return services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
}

private static void AssertMetadata(IReadOnlyDictionary<string, string> actual, Dictionary<string, string> expected)
{
actual.Count.ShouldBe(expected.Count);
foreach (var item in expected)
{
actual[item.Key].ShouldBe(item.Value);
}
}
}
7 changes: 7 additions & 0 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ flowchart LR

### 2.2 Interfaces / contracts map

VFS immutable writes carry the caller's portable object metadata without rewriting
its keys. A fresh `IVirtualFile.GetMetadataAsync` must return those fields as well
as legacy VFS-prefixed custom fields. VFS version, timestamps, attributes and
internal custom-state entries remain reserved and are excluded from this public
custom-metadata view. The same contract applies with caching enabled or disabled;
an immutable write invalidates previous absence and metadata cache entries.

```mermaid
flowchart LR
Providers["Provider Packages"]
Expand Down
11 changes: 11 additions & 0 deletions docs/Features/virtual-file-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ flowchart TD

- Directory listing is implemented by prefix-listing blob metadata via `IStorage.GetBlobMetadataListAsync(...)`.

### Immutable content and metadata

`IVirtualFileSystem.WriteBytesIfAbsentOrSameAsync` stores the provided portable
object metadata alongside the exact immutable bytes. `IVirtualFile.GetMetadataAsync`
returns those user fields from both fresh handles and handles which previously
cached a missing file. This includes ASCII and Unicode values. Legacy user fields
stored under the metadata manager's VFS prefix are exposed with that prefix
removed. Only the overlay's reserved version, timestamp, attribute and internal
custom-state fields are filtered; a user field merely containing those names is
preserved. A matching retry retains the existing object and ETag.

## Components

Key files:
Expand Down
Loading