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: