From 454e4466bd1637ba0ddaf92082231fb854bf76eb Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Fri, 2 Oct 2026 19:49:06 +0200 Subject: [PATCH] Fix VFS immutable metadata reads and release 10.0.15 --- Directory.Build.props | 4 +- .../Metadata/IMetadataManager.cs | 24 ++-- README.md | 1 + .../VfsImmutableMetadataTests.cs | 125 ++++++++++++++++++ docs/Architecture.md | 7 + docs/Features/virtual-file-system.md | 11 ++ 6 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 Tests/ManagedCode.Storage.Tests/VirtualFileSystem/VfsImmutableMetadataTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 8cc50cd..0b1c10f 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.0.14 - 10.0.14 + 10.0.15 + 10.0.15 diff --git a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs index f49b594..87b273a 100644 --- a/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs +++ b/ManagedCode.Storage.VirtualFileSystem/Metadata/IMetadataManager.cs @@ -275,22 +275,20 @@ protected Dictionary ExtractCustomMetadata(IDictionary _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(); + 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 + { + ["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(); + var vfs = scope.ServiceProvider.GetRequiredService(); + var metadata = new Dictionary + { + ["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 + { + ["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 actual, Dictionary expected) + { + actual.Count.ShouldBe(expected.Count); + foreach (var item in expected) + { + actual[item.Key].ShouldBe(item.Value); + } + } +} diff --git a/docs/Architecture.md b/docs/Architecture.md index 4f05e6f..a4be95a 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -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"] diff --git a/docs/Features/virtual-file-system.md b/docs/Features/virtual-file-system.md index 1963a45..7160c51 100644 --- a/docs/Features/virtual-file-system.md +++ b/docs/Features/virtual-file-system.md @@ -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: