// Copyright (C) 2026, 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 Amazon.S3; using Amazon.S3.Model; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; using System.Runtime.CompilerServices; namespace Duplicati.Library.Backend { /// /// Helper class that fixes long list support and injects location headers, includes using directives etc. /// public class S3AwsClient : IS3Client { /// /// The maximum number of items to list in a single request /// private const int ITEM_LIST_LIMIT = 1000; /// /// The prefix for extended options /// private const string EXT_OPTION_PREFIX = "s3-ext-"; /// /// The location constraint for the bucket /// private readonly string? m_locationConstraint; /// /// The storage class for the bucket /// private readonly string? m_storageClass; /// /// The S3 client /// private readonly AmazonS3Client m_client; /// /// The option to specify if chunk encoding should be used /// private readonly bool m_useChunkEncoding; /// /// The option to specify if payload signing should be disabled /// private readonly bool m_disablePayloadSigning; /// /// The DNS host of the S3 server /// private readonly string? m_dnsHost; /// /// The option to specify if the V2 list API should be used /// private readonly bool m_useV2ListApi; /// /// The timeouts to use /// private readonly TimeoutOptionsHelper.Timeouts m_timeouts; /// /// The archive classes that are considered archive classes /// private readonly IReadOnlySet m_archiveClasses; /// /// The lock mode to use for object locking /// private readonly string m_lockMode; /// /// The option to specify the archive classes /// public const string S3_ARCHIVE_CLASSES_OPTION = "s3-archive-classes"; /// /// The default storage classes that are considered archive classes /// public static readonly IReadOnlySet DEFAULT_ARCHIVE_CLASSES = new HashSet([ S3StorageClass.DeepArchive, S3StorageClass.Glacier, S3StorageClass.GlacierInstantRetrieval, S3StorageClass.Snow ]); public S3AwsClient(string awsID, string awsKey, string? locationConstraint, string servername, string? storageClass, bool useSSL, bool disableChunkEncoding, bool disablePayloadSigning, TimeoutOptionsHelper.Timeouts timeouts, Dictionary options, string lockMode, string? authenticationRegion) { var cfg = GetDefaultAmazonS3Config(); cfg.UseHttp = !useSSL; cfg.ServiceURL = (useSSL ? "https://" : "http://") + servername; var authRegion = !string.IsNullOrWhiteSpace(authenticationRegion) ? authenticationRegion : locationConstraint; if (!string.IsNullOrWhiteSpace(authRegion)) cfg.AuthenticationRegion = authRegion; CommandLineArgumentMapper.ApplyArguments(cfg, options, EXT_OPTION_PREFIX); m_client = new AmazonS3Client(awsID, awsKey, cfg); m_timeouts = timeouts; m_useV2ListApi = string.Equals(options.GetValueOrDefault("list-api-version", "v1"), "v2", StringComparison.OrdinalIgnoreCase); m_locationConstraint = locationConstraint; m_storageClass = storageClass; m_dnsHost = string.IsNullOrWhiteSpace(cfg.ServiceURL) ? null : new System.Uri(cfg.ServiceURL).Host; m_useChunkEncoding = !disableChunkEncoding; m_disablePayloadSigning = disablePayloadSigning; m_archiveClasses = ParseStorageClasses(options.GetValueOrDefault(S3_ARCHIVE_CLASSES_OPTION)); m_lockMode = lockMode; } /// /// Parses the storage classes from the string /// /// The storage class string /// The storage classes private static IReadOnlySet ParseStorageClasses(string? storageClass) { if (string.IsNullOrWhiteSpace(storageClass)) return DEFAULT_ARCHIVE_CLASSES; return new HashSet(storageClass.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(x => new S3StorageClass(x))); } /// public async Task AddBucketAsync(string bucketName, CancellationToken cancelToken) { var request = new PutBucketRequest { BucketName = bucketName, }; if (!string.IsNullOrEmpty(m_locationConstraint)) request.BucketRegionName = m_locationConstraint; try { await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => m_client.PutBucketAsync(request, ct)).ConfigureAwait(false); } catch (AmazonS3Exception s3ex) { TranslateException(s3ex, bucketName); throw; } } /// /// Gets the default Amazon S3 configuration /// /// >The default Amazon S3 configuration public static AmazonS3Config GetDefaultAmazonS3Config() { return new AmazonS3Config() { BufferSize = (int)Utility.Utility.DEFAULT_BUFFER_SIZE, // If this is not set, accessing the property will trigger an expensive operation (~30 seconds) // to get the region endpoint. The use of ARNs (Amazon Resource Names) doesn't appear to be // critical for our usages. // See: https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html UseArnRegion = false, }; } /// /// Extended options that are not included as reported options /// private static readonly HashSet EXCLUDED_EXTENDED_OPTIONS = new HashSet([ nameof(AmazonS3Config.USEast1RegionalEndpointValue) ]); /// /// List of properties that are slow to read the default value from /// /// Changes in this list will likely need to be reflected in AWSSecretProvider.cs private static readonly HashSet SLOW_LOADING_PROPERTIES = new[] { nameof(AmazonS3Config.RegionEndpoint), nameof(AmazonS3Config.ServiceURL), nameof(AmazonS3Config.MaxErrorRetry), nameof(AmazonS3Config.DefaultConfigurationMode), nameof(AmazonS3Config.Timeout), nameof(AmazonS3Config.RetryMode), }.ToHashSet(); public static IEnumerable GetAwsExtendedOptions() => CommandLineArgumentMapper.MapArguments(GetDefaultAmazonS3Config(), prefix: EXT_OPTION_PREFIX, exclude: EXCLUDED_EXTENDED_OPTIONS, excludeDefaultValue: SLOW_LOADING_PROPERTIES) .Cast() .Select(x => { x.LongDescription = $"Extended option {x.LongDescription}"; return x; }); public virtual async Task GetFileStreamAsync(string bucketName, string keyName, Stream target, CancellationToken cancelToken) { try { var objectGetRequest = new GetObjectRequest { BucketName = bucketName, Key = keyName }; using (var objectGetResponse = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => m_client.GetObjectAsync(objectGetRequest, ct)).ConfigureAwait(false)) using (var s = objectGetResponse.ResponseStream) using (var t = s.ObserveReadTimeout(m_timeouts.ReadWriteTimeout)) await Utility.Utility.CopyStreamAsync(t, target, cancelToken).ConfigureAwait(false); } catch (AmazonS3Exception s3Ex) { TranslateException(s3Ex, keyName); throw; } } public string? GetDnsHost() { return m_dnsHost; } public virtual async Task AddFileStreamAsync(string bucketName, string keyName, Stream source, CancellationToken cancelToken) { (source, var hashes, var tmp) = await Utility.Utility.CalculateThrottledStreamHash(source, ["MD5", "SHA256"], cancelToken).ConfigureAwait(false); using var _ = tmp; // Precalculate the hashes to avoid calculating them in the PutObjectAsync call where the stream could be throttled var md5 = Convert.ToBase64String(Utility.Utility.HexStringAsByteArray(hashes[0])); var sha256 = Convert.ToBase64String(Utility.Utility.HexStringAsByteArray(hashes[1])); await AddFileStreamAsync(bucketName, keyName, source, md5, sha256, source.Length, cancelToken).ConfigureAwait(false); } /// /// Adds a file stream to the bucket with the specified hashes and content length /// /// The name of the bucket /// The name of the object to create /// The source stream to upload /// The MD5 hash of the content as a hex string /// The SHA256 hash of the content as a hex string /// The content length of the stream /// The cancellation token /// >A task representing the asynchronous operation public virtual async Task AddFileStreamAsync(string bucketName, string keyName, Stream source, string md5, string sha256, long contentLength, CancellationToken cancelToken) { using var ts = source.ObserveReadTimeout(m_timeouts.ReadWriteTimeout, false); var objectAddRequest = new PutObjectRequest { BucketName = bucketName, Key = keyName, InputStream = ts, UseChunkEncoding = m_useChunkEncoding, MD5Digest = md5, ChecksumAlgorithm = ChecksumAlgorithm.SHA256, ChecksumSHA256 = sha256, DisablePayloadSigning = m_disablePayloadSigning }; objectAddRequest.Headers["Content-Length"] = contentLength.ToString(); if (!string.IsNullOrWhiteSpace(m_storageClass)) objectAddRequest.StorageClass = new S3StorageClass(m_storageClass); // Provide SigV4 payload hash explicitly (lowercase hex) iff applicable. // - If chunked streaming is ON, the SDK uses the streaming literal. // - If payload signing is disabled, the SDK uses the UNSIGNED-PAYLOAD literal. if (!m_useChunkEncoding && !m_disablePayloadSigning) objectAddRequest.Headers["x-amz-content-sha256"] = sha256.ToLowerInvariant(); try { await m_client.PutObjectAsync(objectAddRequest, cancelToken); } catch (AmazonS3Exception s3Ex) { TranslateException(s3Ex, keyName); throw; } } public async Task DeleteObjectAsync(string bucketName, string keyName, CancellationToken cancellationToken) { var objectDeleteRequest = new DeleteObjectRequest { BucketName = bucketName, Key = keyName }; try { await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, ct => m_client.DeleteObjectAsync(objectDeleteRequest, ct)).ConfigureAwait(false); } catch (AmazonS3Exception s3Ex) { TranslateException(s3Ex, keyName); throw; } } public async Task GetObjectLockUntilAsync(string bucketName, string keyName, CancellationToken cancelToken) { var request = new GetObjectRetentionRequest { BucketName = bucketName, Key = keyName }; try { var response = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => m_client.GetObjectRetentionAsync(request, ct)).ConfigureAwait(false); return response.Retention?.RetainUntilDate?.ToUniversalTime(); } catch (AmazonS3Exception s3Ex) { if ("NoSuchObjectLockConfiguration".Equals(s3Ex.ErrorCode, StringComparison.OrdinalIgnoreCase)) return null; TranslateException(s3Ex, keyName); throw; } } public async Task SetObjectLockUntilAsync(string bucketName, string keyName, DateTime lockUntilUtc, CancellationToken cancelToken) { var lockMode = typeof(ObjectLockRetentionMode) .GetProperties(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public) .FirstOrDefault(x => x.Name.Equals(m_lockMode, StringComparison.OrdinalIgnoreCase))? .GetValue(null) as ObjectLockRetentionMode ?? new ObjectLockRetentionMode(m_lockMode); var request = new PutObjectRetentionRequest { BucketName = bucketName, Key = keyName, Retention = new ObjectLockRetention { Mode = lockMode, RetainUntilDate = lockUntilUtc.ToUniversalTime(), } }; try { await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancelToken, ct => m_client.PutObjectRetentionAsync(request, ct)).ConfigureAwait(false); } catch (AmazonS3Exception s3Ex) { TranslateException(s3Ex, keyName); throw; } } /// /// Lists the contents of a bucket, using a plugable API call /// /// The bucket to list /// The prefix to list /// If true, the list is recursive /// The cancellation token /// The list of files public async IAsyncEnumerable ListBucketAsync(string bucketName, string prefix, bool recursive, [EnumeratorCancellation] CancellationToken cancellationToken) { var isTruncated = true; string? filename = null; var delimiter = recursive ? "" : "/"; if (!string.IsNullOrWhiteSpace(prefix)) prefix = Util.AppendDirSeparator(prefix, "/"); //TODO: Figure out if this is the case with AWSSDK too //Unfortunately S3 sometimes reports duplicate values when requesting more than one page of results //So, track the files that have already been returned and skip any duplicates. var alreadyReturned = new HashSet(); //We truncate after ITEM_LIST_LIMIT elements, and then repeat while (isTruncated) { ListObjectsV2Response listResponse; try { if (m_useV2ListApi) { var listRequest = new ListObjectsV2Request { BucketName = bucketName, Prefix = prefix, ContinuationToken = filename, MaxKeys = ITEM_LIST_LIMIT, Delimiter = delimiter }; listResponse = await Utility.Utility.WithTimeout(m_timeouts.ListTimeout, cancellationToken, ct => m_client.ListObjectsV2Async(listRequest, ct)).ConfigureAwait(false); } else { // Use the V1 API var listRequest = new ListObjectsRequest { BucketName = bucketName, Prefix = prefix, Marker = filename, MaxKeys = ITEM_LIST_LIMIT, Delimiter = delimiter }; var listResponsev1 = await Utility.Utility.WithTimeout(m_timeouts.ListTimeout, cancellationToken, ct => m_client.ListObjectsAsync(listRequest, ct)).ConfigureAwait(false); // Map the V1 response to the V2 response listResponse = new ListObjectsV2Response { CommonPrefixes = listResponsev1.CommonPrefixes, IsTruncated = listResponsev1.IsTruncated, NextContinuationToken = listResponsev1.NextMarker, S3Objects = listResponsev1.S3Objects }; } } catch (AmazonS3Exception s3ex) { TranslateException(s3ex, prefix ?? ""); throw; } isTruncated = listResponse.IsTruncated ?? false; filename = listResponse.NextContinuationToken; foreach (var obj in listResponse.CommonPrefixes ?? []) { if (obj == prefix || !obj.StartsWith(prefix)) continue; // Because the prefixes are returned, and not the folder objects // we do not get the folder modification date yield return new FileEntry( obj.Substring(prefix.Length), -1, new DateTime(0), new DateTime(0) ) { IsFolder = true }; } foreach (var obj in (listResponse.S3Objects ?? []).Where(obj => alreadyReturned.Add(obj.Key))) { // Skip self-prefix, this discards the folder modification date :/ if (obj.Key == prefix || !obj.Key.StartsWith(prefix)) continue; yield return new FileEntry( obj.Key.Substring(prefix.Length), obj.Size ?? -1, obj.LastModified ?? default, obj.LastModified ?? default ) { IsFolder = obj.Key.EndsWith("/"), IsArchived = m_archiveClasses.Contains(obj.StorageClass) }; } } } public async Task RenameFileAsync(string bucketName, string source, string target, CancellationToken cancelToken) { var copyObjectRequest = new CopyObjectRequest { SourceBucket = bucketName, SourceKey = source, DestinationBucket = bucketName, DestinationKey = target }; try { await Utility.Utility.WithTimeout(m_timeouts.ListTimeout, cancelToken, ct => m_client.CopyObjectAsync(copyObjectRequest, ct)).ConfigureAwait(false); await DeleteObjectAsync(bucketName, source, cancelToken).ConfigureAwait(false); } catch (AmazonS3Exception s3Ex) { TranslateException(s3Ex, source); throw; } } private static void TranslateException(AmazonS3Exception s3Ex, string keyName) { if ("NoSuchKey".Equals(s3Ex.ErrorCode, StringComparison.OrdinalIgnoreCase)) throw new FileMissingException(string.Format("File {0} not found", keyName), s3Ex); if ("NoSuchBucket".Equals(s3Ex.ErrorCode, StringComparison.OrdinalIgnoreCase)) throw new FolderMissingException(s3Ex); // Fallback for non-AWS servers if (s3Ex.StatusCode == System.Net.HttpStatusCode.NotFound) throw new FileMissingException(string.Format("File {0} not found", keyName), s3Ex); } public async Task GetFileEntryAsync(string bucketName, string keyName, CancellationToken cancellationToken) { try { var request = new GetObjectMetadataRequest { BucketName = bucketName, Key = keyName }; var response = await Utility.Utility.WithTimeout(m_timeouts.ShortTimeout, cancellationToken, ct => m_client.GetObjectMetadataAsync(request, ct)).ConfigureAwait(false); return new FileEntry(keyName, response.ContentLength, response.LastModified ?? default, response.LastModified ?? default) { IsFolder = keyName.EndsWith("/") }; } catch (AmazonS3Exception ex) { if (ex.StatusCode == System.Net.HttpStatusCode.NotFound || "NoSuchKey".Equals(ex.ErrorCode, StringComparison.OrdinalIgnoreCase)) return null; throw; } } #region IDisposable Members public void Dispose() { if (m_client != null) m_client.Dispose(); } #endregion } }