From 08d9ada339c12dc805bd36015fd364159646586b Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Mon, 5 Oct 2026 03:10:54 +0200 Subject: [PATCH] fix Azure object ETag consistency across reads --- Directory.Build.props | 4 ++-- README.md | 2 +- .../ManagedCode.Storage.Azure/AzureObjectOperations.cs | 8 ++++---- .../Storages/Azure/AzureObjectStorageTests.cs | 1 + docs/Architecture.md | 2 ++ docs/Features/provider-azure-blob.md | 1 + 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 035fbcb..9de4f4a 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.16 - 10.0.16 + 10.0.17 + 10.0.17 diff --git a/README.md b/README.md index 281960d..bae6715 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,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 object listings return bounded pages with opaque continuation tokens and `null` after the last page, including empty listings. +- Azure object listings return bounded pages with opaque continuation tokens and `null` after the last page, including empty listings. Listing and properties reads expose the same opaque ETag for the same revision. - 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. diff --git a/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs b/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs index 2c29dc4..ab0749c 100644 --- a/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs +++ b/Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs @@ -19,7 +19,7 @@ internal sealed class AzureObjectOperations(BlobContainerClient container) : IMu public Task GetContainerInfoAsync(CancellationToken cancellationToken = default) => ExecuteAsync(async () => { var value = (await container.GetPropertiesAsync(cancellationToken: cancellationToken)).Value; - return new StorageContainerInfo(value.ETag.ToString(), value.PublicAccess == PublicAccessType.None, + return new StorageContainerInfo(value.ETag.ToString("H"), value.PublicAccess == PublicAccessType.None, AzureMetadataTransport.Decode(value.Metadata)); }); @@ -50,7 +50,7 @@ public Task OpenObjectReadAsync(string path, StorageReadOptions? options ArgumentOutOfRangeException.ThrowIfNegative(options.Offset); if (options.Length is <= 0) throw new ArgumentOutOfRangeException(nameof(options)); var blob = container.GetBlobClient(path); - var etag = options.IfMatch ?? (await blob.GetPropertiesAsync(cancellationToken: cancellationToken)).Value.ETag.ToString(); + var etag = options.IfMatch ?? (await blob.GetPropertiesAsync(cancellationToken: cancellationToken)).Value.ETag.ToString("H"); if (options.Length is null) { return await blob.OpenReadAsync(new BlobOpenReadOptions(false) @@ -98,7 +98,7 @@ public Task ListObjectsAsync(string? prefix = null, string? c .AsPages(continuationToken, pageSize)) { 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.ETag?.ToString("H") ?? 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, AzureMetadataTransport.Decode(item.Metadata), item.Properties.LastModified)).ToArray(), string.IsNullOrEmpty(page.ContinuationToken) ? null : page.ContinuationToken); } @@ -126,7 +126,7 @@ public Task CommitPartsAsync(string path, IReadOnlyList ReadWrittenInfoAsync(string path, ETag etag, CancellationToken cancellationToken) => 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, + private static StorageObjectInfo Info(string path, BlobProperties value) => new(path, value.ETag.ToString("H"), value.ContentLength, value.ContentType, value.ContentEncoding, AzureMetadataTransport.Decode(value.Metadata), value.LastModified); private static BlobRequestConditions Conditions(StorageWriteOptions options) diff --git a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs index a679b5a..06138bc 100644 --- a/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs +++ b/Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs @@ -227,6 +227,7 @@ public async Task MetadataPaginationAndDeletion_PreserveContainerBoundary() (await objects.ListObjectsAsync("files/", pageSize: 1000)).ContinuationToken.ShouldBeNull(); (await objects.ListObjectsAsync("absent/")).ContinuationToken.ShouldBeNull(); var info = next.Items[0]; + (await objects.GetObjectInfoAsync(info.Path)).ETag.ShouldBe(info.ETag); await objects.SetObjectMetadataAsync(info.Path, new Dictionary { ["hash"] = "value" }, info.ETag); (await objects.GetObjectInfoAsync(info.Path)).Metadata["hash"].ShouldBe("value"); (await Should.ThrowAsync(() => objects.SetObjectMetadataAsync(info.Path, new Dictionary(), info.ETag))).IsConflict.ShouldBeTrue(); diff --git a/docs/Architecture.md b/docs/Architecture.md index 1049bd3..c18dd9a 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -32,6 +32,8 @@ flowchart LR For immutable uploads, `WriteIfAbsentOrSameAsync` streams an expected-length request through SHA-256, retries a conflicting write by reading the current ETag-pinned object, and accepts only byte-identical content with matching content type, encoding, and metadata. It returns the stored ETag, digest, and whether the object was reused; a mismatch keeps the provider conflict. Neither input nor stored content is buffered as a whole. +Azure object ETags use the SDK header format across listing and properties reads, so the same revision has the same opaque token and can be compared before a conditional operation. + Object conditions (`IfAbsent`, `IfMatch`) are enforced by the service for writes, metadata updates and reads. Read ranges use a fixed ETag and stream data without loading the object into memory. Multipart part IDs are opaque base64 identifiers diff --git a/docs/Features/provider-azure-blob.md b/docs/Features/provider-azure-blob.md index 97f44ed..d8e5bd7 100644 --- a/docs/Features/provider-azure-blob.md +++ b/docs/Features/provider-azure-blob.md @@ -50,6 +50,7 @@ builder.Services.AddAzureStorageAsDefault(options => - Supports container creation when `CreateContainerIfNotExists = true`. - `IObjectStorage.ListObjectsAsync` returns one bounded page and preserves opaque nonempty continuation tokens. The final page returns `null`, including empty listings; Azure's empty terminal marker never becomes another continuation request. +- Returned object/container ETags use the SDK header format, including quotes, across listing and properties reads. An unchanged object retains the same revision token; stale conditional mutations remain rejected. - 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.