Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
<RepositoryUrl>https://github.com/managedcode/Storage</RepositoryUrl>
<PackageProjectUrl>https://github.com/managedcode/Storage</PackageProjectUrl>
<Product>Managed Code - Storage</Product>
<Version>10.0.16</Version>
<PackageVersion>10.0.16</PackageVersion>
<Version>10.0.17</Version>
<PackageVersion>10.0.17</PackageVersion>

</PropertyGroup>

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal sealed class AzureObjectOperations(BlobContainerClient container) : IMu
public Task<StorageContainerInfo> 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));
});

Expand Down Expand Up @@ -50,7 +50,7 @@ public Task<Stream> 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)
Expand Down Expand Up @@ -98,7 +98,7 @@ public Task<StorageObjectPage> 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);
}
Expand Down Expand Up @@ -126,7 +126,7 @@ public Task<StorageObjectInfo> CommitPartsAsync(string path, IReadOnlyList<strin
private async Task<StorageObjectInfo> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["hash"] = "value" }, info.ETag);
(await objects.GetObjectInfoAsync(info.Path)).Metadata["hash"].ShouldBe("value");
(await Should.ThrowAsync<StorageOperationException>(() => objects.SetObjectMetadataAsync(info.Path, new Dictionary<string, string>(), info.ETag))).IsConflict.ShouldBeTrue();
Expand Down
2 changes: 2 additions & 0 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/Features/provider-azure-blob.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading