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.