diff --git a/Directory.Build.props b/Directory.Build.props
index c183e47..8cc50cd 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.13
- 10.0.13
+ 10.0.14
+ 10.0.14
diff --git a/README.md b/README.md
index 787a738..70fc168 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,7 @@ Cloud storage vendors expose distinct SDKs, option models, and authentication pa
- ASP.NET storage controllers, chunk orchestration services, and a SignalR hub/client pair that deliver resumable uploads, ranged downloads, CRC32 validation, and real-time progress.
- `ManagedCode.Storage.Client` brings streaming uploads/downloads, CRC32 helpers, and MIME discovery via `MimeHelper` to any .NET app.
- Strongly typed option objects (`UploadOptions`, `DownloadOptions`, `DeleteOptions`, `MetadataOptions`, `LegalHoldOptions`, etc.) let you configure directories, metadata, and legal holds in one place.
+- 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.
- 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.
diff --git a/Storages/ManagedCode.Storage.Azure/AzureMetadataTransport.cs b/Storages/ManagedCode.Storage.Azure/AzureMetadataTransport.cs
new file mode 100644
index 0000000..9028ebd
--- /dev/null
+++ b/Storages/ManagedCode.Storage.Azure/AzureMetadataTransport.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+
+namespace ManagedCode.Storage.Azure;
+
+internal static class AzureMetadataTransport
+{
+ private const string EnvelopeKey = "managedcode_storage_metadata_v1";
+ private const string EnvelopePrefix = "utf8-json-base64:";
+ private static readonly UTF8Encoding StrictUtf8 = new(false, true);
+
+ internal static Dictionary? Encode(IEnumerable>? metadata)
+ {
+ if (metadata is null)
+ return null;
+
+ var values = metadata.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
+ if (values.All(pair => IsNativeKey(pair.Key) && IsNativeValue(pair.Value)) && !values.ContainsKey(EnvelopeKey))
+ return values;
+
+ foreach (var pair in values)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(pair.Key);
+ ArgumentNullException.ThrowIfNull(pair.Value);
+ _ = StrictUtf8.GetByteCount(pair.Key);
+ _ = StrictUtf8.GetByteCount(pair.Value);
+ }
+ return new Dictionary
+ {
+ [EnvelopeKey] = EnvelopePrefix + Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(values))
+ };
+ }
+
+ internal static Dictionary Decode(IEnumerable> metadata)
+ {
+ var values = metadata.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
+ if (!values.TryGetValue(EnvelopeKey, out var envelope) ||
+ !envelope.StartsWith(EnvelopePrefix, StringComparison.Ordinal))
+ return values;
+
+ if (values.Count != 1)
+ throw new InvalidDataException("Invalid Azure metadata envelope.");
+
+ try
+ {
+ using var document = JsonDocument.Parse(Convert.FromBase64String(envelope[EnvelopePrefix.Length..]));
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ throw new InvalidDataException("Invalid Azure metadata envelope.");
+
+ var decoded = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ if (string.IsNullOrEmpty(property.Name) || property.Value.ValueKind != JsonValueKind.String ||
+ !decoded.TryAdd(property.Name, property.Value.GetString()!))
+ throw new InvalidDataException("Invalid Azure metadata envelope.");
+ }
+ return decoded;
+ }
+ catch (Exception exception) when (exception is FormatException or JsonException)
+ {
+ throw new InvalidDataException("Invalid Azure metadata envelope.", exception);
+ }
+ }
+
+ private static bool IsNativeKey(string key) => key.Length > 0 && IsInitialKeyCharacter(key[0]) &&
+ key.Skip(1).All(character => IsInitialKeyCharacter(character) || character is >= '0' and <= '9');
+
+ private static bool IsInitialKeyCharacter(char character) => character is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or '_';
+
+ private static bool IsNativeValue(string value) => value is not null && value.All(character => character is >= ' ' and <= '~');
+}
diff --git a/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs b/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs
index 8467b10..467b6cb 100644
--- a/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs
+++ b/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs
@@ -20,18 +20,18 @@ public Task GetContainerInfoAsync(CancellationToken cancel
{
var value = (await container.GetPropertiesAsync(cancellationToken: cancellationToken)).Value;
return new StorageContainerInfo(value.ETag.ToString(), value.PublicAccess == PublicAccessType.None,
- new Dictionary(value.Metadata));
+ AzureMetadataTransport.Decode(value.Metadata));
});
public Task CreatePrivateContainerAsync(IReadOnlyDictionary? metadata = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () =>
{
- await container.CreateIfNotExistsAsync(PublicAccessType.None, Copy(metadata), cancellationToken: cancellationToken);
+ await container.CreateIfNotExistsAsync(PublicAccessType.None, AzureMetadataTransport.Encode(metadata), cancellationToken: cancellationToken);
return true;
});
public Task SetContainerMetadataAsync(IReadOnlyDictionary metadata, CancellationToken cancellationToken = default) => ExecuteAsync(async () =>
{
- await container.SetMetadataAsync(Copy(metadata), cancellationToken: cancellationToken);
+ await container.SetMetadataAsync(AzureMetadataTransport.Encode(metadata), cancellationToken: cancellationToken);
return true;
});
@@ -74,7 +74,7 @@ public Task WriteObjectAsync(string path, Stream content, Sto
{
Conditions = Conditions(options),
HttpHeaders = Headers(options),
- Metadata = Copy(options.Metadata),
+ Metadata = AzureMetadataTransport.Encode(options.Metadata),
TransferOptions = new global::Azure.Storage.StorageTransferOptions { MaximumConcurrency = 1, InitialTransferSize = 4 * 1024 * 1024, MaximumTransferSize = 4 * 1024 * 1024 }
}, cancellationToken);
return await ReadWrittenInfoAsync(path, response.Value.ETag, cancellationToken);
@@ -82,7 +82,7 @@ public Task WriteObjectAsync(string path, Stream content, Sto
public Task SetObjectMetadataAsync(string path, IReadOnlyDictionary metadata, string? ifMatch = null, CancellationToken cancellationToken = default) => ExecuteAsync(async () =>
{
- await container.GetBlobClient(path).SetMetadataAsync(Copy(metadata), new BlobRequestConditions { IfMatch = ETagOrNull(ifMatch) }, cancellationToken);
+ await container.GetBlobClient(path).SetMetadataAsync(AzureMetadataTransport.Encode(metadata), new BlobRequestConditions { IfMatch = ETagOrNull(ifMatch) }, cancellationToken);
return true;
});
@@ -100,7 +100,7 @@ public Task ListObjectsAsync(string? prefix = null, string? c
return new StorageObjectPage(page.Values.Select(item => new StorageObjectInfo(item.Name,
item.Properties.ETag?.ToString() ?? throw new InvalidDataException("Object listing returned no ETag."),
item.Properties.ContentLength ?? throw new InvalidDataException("Object listing returned no length."),
- item.Properties.ContentType, item.Properties.ContentEncoding, new Dictionary(item.Metadata), item.Properties.LastModified)).ToArray(), page.ContinuationToken);
+ item.Properties.ContentType, item.Properties.ContentEncoding, AzureMetadataTransport.Decode(item.Metadata), item.Properties.LastModified)).ToArray(), page.ContinuationToken);
}
return new StorageObjectPage([], null);
});
@@ -118,7 +118,7 @@ public Task CommitPartsAsync(string path, IReadOnlyList ReadWrittenInfoAsync(string path, ETag eta
Info(path, (await container.GetBlobClient(path).GetPropertiesAsync(new BlobRequestConditions { IfMatch = etag }, cancellationToken)).Value);
private static StorageObjectInfo Info(string path, BlobProperties value) => new(path, value.ETag.ToString(), value.ContentLength,
- value.ContentType, value.ContentEncoding, new Dictionary(value.Metadata), value.LastModified);
+ value.ContentType, value.ContentEncoding, AzureMetadataTransport.Decode(value.Metadata), value.LastModified);
private static BlobRequestConditions Conditions(StorageWriteOptions options)
{
@@ -137,7 +137,6 @@ private static BlobRequestConditions Conditions(StorageWriteOptions options)
private static BlobHttpHeaders Headers(StorageWriteOptions options) => new() { ContentType = options.ContentType, ContentEncoding = options.ContentEncoding };
private static ETag? ETagOrNull(string? value) => value is null ? null : new ETag(value);
- private static Dictionary? Copy(IReadOnlyDictionary? value) => value is null ? null : new(value);
private static async Task ExecuteAsync(Func> operation)
{
diff --git a/Storages/ManagedCode.Storage.Azure/AzureStorage.cs b/Storages/ManagedCode.Storage.Azure/AzureStorage.cs
index 4ed66ec..171a1fd 100644
--- a/Storages/ManagedCode.Storage.Azure/AzureStorage.cs
+++ b/Storages/ManagedCode.Storage.Azure/AzureStorage.cs
@@ -74,7 +74,7 @@ public override async IAsyncEnumerable GetBlobMetadataListAsync(st
Uri = new Uri(StorageClient.Uri, $"{StorageOptions.Container}/{blobItem.Name}"),
Container = StorageOptions.Container,
Length = (ulong)blobItem.Properties.ContentLength!.Value,
- Metadata = blobItem.Metadata.ToDictionary(k => k.Key, v => v.Value),
+ Metadata = AzureMetadataTransport.Decode(blobItem.Metadata),
LastModified = blobItem.Properties.LastModified!.Value,
CreatedOn = blobItem.Properties.CreatedOn!.Value,
MimeType = blobItem.Properties.ContentType
@@ -247,7 +247,7 @@ protected override async Task> UploadInternalAsync(Stream s
var uploadOptions = new BlobUploadOptions
{
- Metadata = options.Metadata,
+ Metadata = AzureMetadataTransport.Encode(options.Metadata),
HttpHeaders = new BlobHttpHeaders
{
ContentType = options.MimeType
@@ -315,7 +315,7 @@ protected override async Task> DownloadInternalAsync(LocalFile
Length = (ulong)response.Value.ContentLength,
CreatedOn = response.Value.Details.LastModified,
LastModified = response.Value.Details.LastModified,
- Metadata = response.Value.Details.Metadata.ToDictionary(k => k.Key, v => v.Value),
+ Metadata = AzureMetadataTransport.Decode(response.Value.Details.Metadata),
MimeType = response.Value.ContentType
};
@@ -390,7 +390,7 @@ protected override async Task> GetBlobMetadataInternalAsync
Length = (ulong)properties.Value.ContentLength,
CreatedOn = properties.Value.CreatedOn,
LastModified = properties.Value.LastModified,
- Metadata = properties.Value.Metadata.ToDictionary(k => k.Key, v => v.Value),
+ Metadata = AzureMetadataTransport.Decode(properties.Value.Metadata),
MimeType = properties.Value.ContentType
});
}
diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs
new file mode 100644
index 0000000..1062e03
--- /dev/null
+++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Azure.Storage.Blobs;
+using ManagedCode.Storage.Azure;
+using ManagedCode.Storage.Azure.Options;
+using ManagedCode.Storage.Core.Models;
+using ManagedCode.Storage.Core.Primitives;
+using ManagedCode.Storage.Tests.Common;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Testcontainers.Azurite;
+using Xunit;
+
+namespace ManagedCode.Storage.Tests.Storages.Azure;
+
+public sealed class AzureMetadataEncodingTests : IAsyncLifetime
+{
+ private readonly AzuriteContainer _container = new AzuriteBuilder(ContainerImages.Azurite)
+ .WithCommand("--skipApiVersionCheck").Build();
+
+ public Task InitializeAsync() => _container.StartAsync();
+ public Task DisposeAsync() => _container.DisposeAsync().AsTask();
+
+ [Fact]
+ public async Task PortableUpload_PreservesUnicodeMetadataAcrossReadsAndDownload()
+ {
+ using var storage = CreateStorage();
+ var metadata = UnicodeMetadata();
+ using var content = Content("unchanged material");
+ var upload = await storage.UploadAsync(content, new UploadOptions
+ {
+ FileName = "material.txt",
+ MimeType = "text/plain",
+ Metadata = metadata
+ });
+ upload.IsSuccess.ShouldBeTrue();
+ AssertMetadata(upload.Value!.Metadata!, metadata);
+ var stored = await storage.GetBlobMetadataAsync("material.txt");
+ stored.IsSuccess.ShouldBeTrue();
+ AssertMetadata(stored.Value!.Metadata!, metadata);
+ var listed = await storage.GetBlobMetadataListAsync().ToListAsync();
+ AssertMetadata(listed.Single().Metadata!, metadata);
+ var downloaded = await storage.DownloadAsync("material.txt");
+ downloaded.IsSuccess.ShouldBeTrue();
+ using var file = downloaded.Value!;
+ AssertMetadata(file.BlobMetadata!.Metadata!, metadata);
+ (await File.ReadAllTextAsync(file.FileInfo.FullName)).ShouldBe("unchanged material");
+ var native = await NativeContainer(storage).GetBlobClient("material.txt").GetPropertiesAsync();
+ native.Value.Metadata.Values.All(value => value.All(c => c is >= ' ' and <= '~')).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ObjectAndContainerMetadata_PreserveValuesAndConditionalRevision()
+ {
+ using var storage = CreateStorage();
+ var objects = storage.RequireObjectStorage();
+ var metadata = UnicodeMetadata();
+ await objects.CreatePrivateContainerAsync(metadata);
+ AssertMetadata((await objects.GetContainerInfoAsync()).Metadata, metadata);
+ var replacement = new Dictionary { ["owner"] = "Компанія" };
+ await objects.SetContainerMetadataAsync(replacement);
+ AssertMetadata((await objects.GetContainerInfoAsync()).Metadata, replacement);
+ using var content = Content("unchanged evidence");
+ var first = await objects.WriteIfAbsentOrSameAsync("evidence", content, content.Length,
+ new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata });
+ AssertMetadata(first.Info.Metadata, metadata);
+ using var retry = Content("unchanged evidence");
+ var reused = await objects.WriteIfAbsentOrSameAsync("evidence", retry, retry.Length,
+ new StorageWriteOptions { ContentType = "text/plain", Metadata = metadata });
+ reused.ReusedExisting.ShouldBeTrue();
+ reused.Info.ETag.ShouldBe(first.Info.ETag);
+ AssertMetadata((await objects.ListObjectsAsync()).Items.Single().Metadata, metadata);
+ await objects.SetObjectMetadataAsync("evidence", replacement, first.Info.ETag);
+ var second = await objects.GetObjectInfoAsync("evidence");
+ AssertMetadata(second.Metadata, replacement);
+ second.ETag.ShouldNotBe(first.Info.ETag);
+ var stale = await Should.ThrowAsync(() =>
+ objects.SetObjectMetadataAsync("evidence", metadata, first.Info.ETag));
+ stale.IsConflict.ShouldBeTrue();
+ await using var stream = await objects.OpenObjectReadAsync("evidence", new StorageReadOptions { IfMatch = second.ETag });
+ using var reader = new StreamReader(stream);
+ (await reader.ReadToEndAsync()).ShouldBe("unchanged evidence");
+ }
+
+ [Fact]
+ public async Task MultipartAndMarkerCollision_PreserveCompleteLogicalMetadata()
+ {
+ using var storage = CreateStorage();
+ var objects = storage.RequireMultipartStorage();
+ await objects.CreatePrivateContainerAsync();
+ var metadata = UnicodeMetadata();
+ var partId = Convert.ToBase64String(Encoding.UTF8.GetBytes("0001"));
+ using var content = Content("multipart material");
+ await objects.StagePartAsync("multipart", partId, content);
+ var committed = await objects.CommitPartsAsync("multipart", [partId], new StorageWriteOptions { Metadata = metadata });
+ AssertMetadata(committed.Metadata, metadata);
+ var native = await NativeContainer(storage).GetBlobClient("multipart").GetPropertiesAsync();
+ var marker = native.Value.Metadata.Single();
+ var collision = new Dictionary { [marker.Key] = marker.Value };
+ using var collidingContent = Content("caller marker");
+ await objects.WriteObjectAsync("collision", collidingContent, new StorageWriteOptions { Metadata = collision });
+ AssertMetadata((await objects.GetObjectInfoAsync("collision")).Metadata, collision);
+ await using var stream = await objects.OpenObjectReadAsync("multipart");
+ using var reader = new StreamReader(stream);
+ (await reader.ReadToEndAsync()).ShouldBe("multipart material");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MalformedEnvelope_FailsWithoutReturningTransportMetadata(bool mixedMetadata)
+ {
+ using var storage = CreateStorage();
+ var objects = storage.RequireObjectStorage();
+ await objects.CreatePrivateContainerAsync();
+ using var content = Content("material");
+ await objects.WriteObjectAsync("invalid", content, new StorageWriteOptions { Metadata = UnicodeMetadata() });
+ var blob = NativeContainer(storage).GetBlobClient("invalid");
+ var encoded = (await blob.GetPropertiesAsync()).Value.Metadata.Single();
+ var prefix = encoded.Value[..(encoded.Value.IndexOf(':') + 1)];
+ var invalid = new Dictionary
+ {
+ [encoded.Key] = mixedMetadata ? encoded.Value : prefix + "invalid-base64!"
+ };
+ if (mixedMetadata)
+ invalid["unexpected"] = "sibling";
+ await blob.SetMetadataAsync(invalid);
+ await Should.ThrowAsync(() => objects.GetObjectInfoAsync("invalid"));
+ (await storage.GetBlobMetadataAsync("invalid")).IsFailed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task NativeAsciiMetadata_RemainsUnencodedAndUninterpreted()
+ {
+ using var storage = CreateStorage();
+ var objects = storage.RequireObjectStorage();
+ await objects.CreatePrivateContainerAsync();
+ var metadata = new Dictionary
+ {
+ ["name"] = "%D0%BC.txt",
+ ["literal"] = "utf8-json-base64:eyJmb28iOiJiYXIifQ=="
+ };
+ using var nativeContent = Content("native bytes");
+ await NativeContainer(storage).GetBlobClient("native").UploadAsync(nativeContent,
+ new global::Azure.Storage.Blobs.Models.BlobUploadOptions { Metadata = metadata });
+ AssertMetadata((await objects.GetObjectInfoAsync("native")).Metadata, metadata);
+ using var content = Content("provider bytes");
+ await objects.WriteObjectAsync("provider", content, new StorageWriteOptions { Metadata = metadata });
+ var native = await NativeContainer(storage).GetBlobClient("provider").GetPropertiesAsync();
+ AssertMetadata(native.Value.Metadata, metadata);
+ }
+
+ private AzureStorage CreateStorage() => new(new AzureStorageOptions
+ {
+ ConnectionString = _container.GetConnectionString(),
+ Container = $"metadata-{Guid.NewGuid():N}"
+ }, NullLogger.Instance);
+
+ private BlobContainerClient NativeContainer(AzureStorage storage) =>
+ new(_container.GetConnectionString(), storage.ContainerUri.Segments[^1]);
+
+ private static MemoryStream Content(string value) => new(Encoding.UTF8.GetBytes(value));
+
+ private static Dictionary UnicodeMetadata() => new()
+ {
+ ["fileName"] = "матеріал 日本語 😀.txt",
+ ["notes"] = "line one\nline two\t"
+ };
+
+ private static void AssertMetadata(IEnumerable> actual,
+ IReadOnlyDictionary expected)
+ {
+ var values = actual.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
+ values.Count.ShouldBe(expected.Count);
+ foreach (var pair in expected)
+ values[pair.Key].ShouldBe(pair.Value);
+ }
+}
diff --git a/docs/Architecture.md b/docs/Architecture.md
index 90d90cd..4f05e6f 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -42,6 +42,11 @@ conditions because Azure does not offer that condition for this operation.
Provider failures become `StorageOperationException` with a status code; task
cancellation propagates normally. These optional streaming capabilities follow
the VFS exception model; existing `IStorage` result contracts are unchanged.
+Azure metadata transport belongs exclusively to the provider: printable ASCII
+metadata stays native, while Unicode/control-character dictionaries use one
+versioned ASCII envelope decoded across all metadata reads. See the
+[Azure provider](Features/provider-azure-blob.md#metadata-transport) for its
+physical representation, collision handling and metadata-size constraints.
VFS writes persist zero-byte files and truncate existing files on empty overwrite.
`FileExistsAsync` remains a cached, best-effort convenience query. Consumers that
make conflict, authorization, or recovery decisions use
diff --git a/docs/Features/provider-azure-blob.md b/docs/Features/provider-azure-blob.md
index a01d843..879a4ea 100644
--- a/docs/Features/provider-azure-blob.md
+++ b/docs/Features/provider-azure-blob.md
@@ -52,6 +52,34 @@ builder.Services.AddAzureStorageAsDefault(options =>
- Uses Azure SDK transfer options when configured (`UploadTransferOptions`).
- Builds the upload result from the successful Azure upload response and the caller's options, without issuing a second blob-properties request that can race with deletion or lifecycle processing.
- Returns a failed metadata result for an absent blob without logging the expected Azure `404 BlobNotFound` response as an unhandled exception; other metadata failures retain error logging.
+- Preserves Unicode and control characters in logical metadata through uploads, downloads, metadata/listing reads, conditional object writes, multipart commits and container operations. The Azure provider owns the HTTP-header representation; callers pass their original strings.
+
+## Metadata transport
+
+Ordinary metadata with native Azure identifier keys and printable ASCII values
+is stored directly. A dictionary requiring encoding is stored as one reserved
+`managedcode_storage_metadata_v1` value: `utf8-json-base64:` followed by the
+base64 of the complete UTF-8 JSON string dictionary. Reads restore the logical
+dictionary once. A caller-supplied reserved key is itself wrapped, so its value
+cannot be confused with provider metadata. Invalid recognized envelopes fail
+instead of returning opaque transport values. Invalid UTF-16 input is rejected
+before writing; the provider never substitutes characters.
+
+```mermaid
+flowchart LR
+ Caller["Logical string dictionary"] --> Transport["AzureMetadataTransport"]
+ Transport --> Native["Native printable ASCII metadata"]
+ Transport --> Envelope["Versioned ASCII envelope"]
+ Native --> Azure["Azure metadata headers"]
+ Envelope --> Azure
+ Azure --> Decode["Decode once on metadata reads"]
+ Decode --> Result["Original logical metadata"]
+```
+
+Azure's physical metadata-size limits still apply, including envelope overhead.
+Content bytes, content type, conditional ETags and exact immutable retry matching
+are unchanged. Objects written directly with ordinary native ASCII metadata
+remain readable without reinterpretation of percent escapes or value prefixes.
## Tests
@@ -61,6 +89,7 @@ builder.Services.AddAzureStorageAsDefault(options =>
- `Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureBlobStreamTests.cs`
- `Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureContainerTests.cs`
- `Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureConfigTests.cs`
+- [Azure metadata regressions](https://github.com/managed-code-hub/Storage/blob/main/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureMetadataEncodingTests.cs): real Azurite round trips, native metadata interoperability, marker collisions, malformed envelopes, exact retry and stale revision rejection.
## References