// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using System.Runtime.CompilerServices; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Uri = System.Uri; using Duplicati.Library.Utility.Options; using Duplicati.Library.Utility; using Azure.Core.Pipeline; namespace Duplicati.Library.Backend.AzureBlob { /// /// Azure blob storage facade. /// public class AzureBlobWrapper { private readonly BlobContainerClient _container; private readonly TimeoutOptionsHelper.Timeouts _timeouts; private readonly IReadOnlySet _archiveClasses; private readonly AccessTier? _accessTier; /// /// Gets an array of DNS names associated with the blob container. /// /// An array of DNS hostnames for the primary and secondary URIs of the container. public string[] DnsNames { get { var lst = new List(); if (_container != null && _container.Uri != null) lst.Add(_container.Uri.Host); return lst.ToArray(); } } /// /// Initializes a new instance of the AzureBlobWrapper class. /// /// The Azure storage account name. /// The access key for the storage account. /// The Shared Access Signature (SAS) token for authentication. /// The name of the blob container. /// The access tier assigned to blobs on upload. /// The storage classes that are considered archive classes. /// The timeout options. /// The maximum number of retries for Azure operations. public AzureBlobWrapper(string accountName, string? accessKey, string? sasToken, string containerName, AccessTier? accessTier, IReadOnlySet archiveClasses, TimeoutOptionsHelper.Timeouts timeouts, int maxRetries) { BlobServiceClient blobServiceClient; var maxTicks = timeouts.ReadWriteTimeout.Ticks - TimeSpan.FromSeconds(1).Ticks; // If not infinite, then share the timeout across retries var delay = maxTicks <= 0 ? TimeSpan.FromSeconds(1) : TimeSpan.FromTicks(maxTicks / Math.Max(1, maxRetries)); var maxDelay = maxTicks <= 0 ? TimeSpan.FromSeconds(10) : TimeSpan.FromTicks(maxTicks); var blobServiceOptions = new BlobClientOptions() { Retry = { MaxRetries = maxRetries, Mode = Azure.Core.RetryMode.Exponential, Delay = delay, MaxDelay = maxDelay }, Transport = new HttpClientTransport(new HttpClient { Timeout = Timeout.InfiniteTimeSpan }) }; if (sasToken != null) { var sasUri = new Uri($"https://{accountName}.blob.core.windows.net/?{sasToken}"); blobServiceClient = new BlobServiceClient(sasUri, blobServiceOptions); } else { var connectionString = $"DefaultEndpointsProtocol=https;AccountName={accountName};AccountKey={accessKey};EndpointSuffix=core.windows.net"; blobServiceClient = new BlobServiceClient(connectionString, blobServiceOptions); } _accessTier = accessTier; _archiveClasses = archiveClasses; _container = blobServiceClient.GetBlobContainerClient(containerName); _timeouts = timeouts; } /// /// Creates a new blob container asynchronously and sets its access permissions to private. /// /// A token to monitor for cancellation requests. /// A task that represents the asynchronous operation. public async Task AddContainerAsync(CancellationToken cancellationToken) { await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => // Even though PublicAccessType.None is by default, we set it explicitly to highlight it. await _container.CreateAsync(PublicAccessType.None, cancellationToken: ct).ConfigureAwait(false) ).ConfigureAwait(false); } /// /// Downloads a blob to a stream asynchronously. /// /// The name of the blob to download. /// The stream to download the blob to. /// A token to monitor for cancellation requests. /// A task that represents the asynchronous download operation. public async Task GetFileStreamAsync(string keyName, Stream target, CancellationToken cancellationToken) { var blobClient = _container.GetBlobClient(keyName); using var timeoutStream = target.ObserveWriteTimeout(_timeouts.ReadWriteTimeout, false); await blobClient.DownloadToAsync(target, cancellationToken).ConfigureAwait(false); } /// /// Uploads a stream to a blob asynchronously. /// /// The name to give the uploaded blob. /// The stream containing the data to upload. /// A token to monitor for cancellation requests. /// A task that represents the asynchronous upload operation. public async Task AddFileStream(string keyName, Stream source, CancellationToken cancelToken) { var blobClient = _container.GetBlobClient(keyName); using var timeoutStream = source.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false); var options = new BlobUploadOptions() { Conditions = null, // Overwrite any existing blob AccessTier = _accessTier }; await blobClient.UploadAsync(timeoutStream, options, cancelToken).ConfigureAwait(false); } /// /// Deletes a blob if it exists asynchronously. /// /// The name of the blob to delete. /// A token to monitor for cancellation requests. public async Task DeleteObjectAsync(string keyName, CancellationToken cancelToken) { var blobClient = _container.GetBlobClient(keyName); await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, async ct => await blobClient.DeleteIfExistsAsync(cancellationToken: ct).ConfigureAwait(false) ).ConfigureAwait(false); } /// /// Copies a blob to a new name asynchronously. /// /// The name of the source blob. /// The name of the destination blob. /// A token to monitor for cancellation requests. /// A task that represents the asynchronous copy operation. public async Task CopyFileAsync(string sourceKeyName, string destinationKeyName, CancellationToken cancelToken) { var sourceBlobClient = _container.GetBlobClient(sourceKeyName); var destinationBlobClient = _container.GetBlobClient(destinationKeyName); // Start the copy operation var operation = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, async ct => await destinationBlobClient.StartCopyFromUriAsync(sourceBlobClient.Uri, cancellationToken: ct).ConfigureAwait(false) ).ConfigureAwait(false); // Wait for the copy to complete await operation.WaitForCompletionAsync(cancelToken).ConfigureAwait(false); } /// /// List container files. /// /// A token to monitor for cancellation requests. /// /// Thrown when the container is not found public virtual async IAsyncEnumerable ListContainerEntriesAsync([EnumeratorCancellation] CancellationToken cancelToken) { await using var blobEnumerator = _container.GetBlobsAsync().GetAsyncEnumerator(cancelToken); while (true) { bool hasNext; try { hasNext = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, cancelToken, async ct => await blobEnumerator.MoveNextAsync().ConfigureAwait(false) ).ConfigureAwait(false); } catch (Azure.RequestFailedException ex) when (ex.Status == 404) { throw new FolderMissingException(ex); } if (!hasNext) break; cancelToken.ThrowIfCancellationRequested(); if (blobEnumerator.Current is { } blob) { var blobName = Uri.UnescapeDataString(blob.Name.Replace("+", "%2B")); var lastModified = blob.Properties.LastModified?.UtcDateTime ?? DateTime.UtcNow; var isArchive = blob.Properties.AccessTier.HasValue && _archiveClasses.Contains(blob.Properties.AccessTier.Value); yield return new FileEntry( blobName, blob.Properties.ContentLength ?? 0, lastModified, lastModified ) { IsArchived = isArchive, IsFolder = false, }; } } } public async Task GetObjectLockUntilAsync(string keyName, CancellationToken cancellationToken) { var blobClient = _container.GetBlobClient(keyName); try { var properties = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, ct => blobClient.GetPropertiesAsync(cancellationToken: ct) ).ConfigureAwait(false); return properties.Value.ImmutabilityPolicy?.ExpiresOn?.UtcDateTime; } catch (RequestFailedException e) when (e.Status == 404 || e.ErrorCode == BlobErrorCode.BlobNotFound || e.ErrorCode == BlobErrorCode.ResourceNotFound) { throw new FileMissingException(e.Message, e); } catch (RequestFailedException e) when (e.ErrorCode == BlobErrorCode.ContainerNotFound || e.ErrorCode == BlobErrorCode.ContainerBeingDeleted || e.ErrorCode == BlobErrorCode.ContainerDisabled) { throw new FolderMissingException(e.Message, e); } } public async Task SetObjectLockUntilAsync(string keyName, DateTime lockUntilUtc, BlobImmutabilityPolicyMode policyMode, CancellationToken cancellationToken) { var blobClient = _container.GetBlobClient(keyName); var policy = new BlobImmutabilityPolicy { ExpiresOn = new DateTimeOffset(lockUntilUtc.ToUniversalTime(), TimeSpan.Zero), PolicyMode = policyMode }; try { var result = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct => await blobClient.SetImmutabilityPolicyAsync(policy, cancellationToken: ct).ConfigureAwait(false) ).ConfigureAwait(false); if (result.Value.ExpiresOn == null) throw new Exception("Failed to set object lock, call succeeded but no immutability policy returned"); } catch (RequestFailedException e) when (e.Status == 404 || e.ErrorCode == BlobErrorCode.BlobNotFound || e.ErrorCode == BlobErrorCode.ResourceNotFound) { throw new FileMissingException(e.Message, e); } catch (RequestFailedException e) when (e.ErrorCode == BlobErrorCode.ContainerNotFound || e.ErrorCode == BlobErrorCode.ContainerBeingDeleted || e.ErrorCode == BlobErrorCode.ContainerDisabled) { throw new FolderMissingException(e.Message, e); } } } }