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