using System.Reflection; using System.Security.Cryptography; using System.Text.RegularExpressions; using AlibabaCloud.OSS.V2.Credentials; using AlibabaCloud.OSS.V2.Models; using Microsoft.Extensions.Options; using Minio; using Minio.DataModel.Args; using Minio.Exceptions; using Tiku.Application.Storage; using Oss = AlibabaCloud.OSS.V2; namespace Tiku.Infrastructure.Storage; public sealed partial class AliyunOssObjectStorageService( IOptions storageOptions, IOptions aliyunOptions, IOptions s3Options) : IObjectStorageService, IDisposable { private static readonly HashSet SupportedProviders = [ ObjectStorageProviders.ExternalUrl, ObjectStorageProviders.AliyunOss, ObjectStorageProviders.TencentCos, ObjectStorageProviders.QiniuKodo, ObjectStorageProviders.LocalDev ]; private static readonly HashSet UploadableProviders = [ ObjectStorageProviders.LocalDev, ObjectStorageProviders.AliyunOss ]; private readonly object clientLock = new(); private Oss.Client? client; private IMinioClient? s3Client; private bool disposed; public void Dispose() { if (disposed) return; lock (clientLock) { client?.Dispose(); client = null; s3Client?.Dispose(); s3Client = null; disposed = true; } } public string ConfiguredDefaultProvider() { return NormalizeProvider(storageOptions.Value.DefaultProvider, ObjectStorageProviders.LocalDev); } public string ConfiguredDefaultBucket() { return string.IsNullOrWhiteSpace(storageOptions.Value.DefaultBucket) ? "tenant-assets" : storageOptions.Value.DefaultBucket.Trim(); } public string NormalizeProvider(string? value, string? fallback = null) { var provider = (string.IsNullOrWhiteSpace(value) ? fallback : value)?.Trim() ?? ConfiguredDefaultProvider(); if (!SupportedProviders.Contains(provider)) throw new ObjectStorageException($"Unsupported storage provider: {provider}", "UNSUPPORTED_STORAGE_PROVIDER"); return provider; } public string ValidateObjectKey(Guid tenantId, string objectKey) { ArgumentException.ThrowIfNullOrWhiteSpace(objectKey); var clean = CanonicalObjectKey(objectKey); if (string.IsNullOrWhiteSpace(clean) || clean.Contains("..", StringComparison.Ordinal) || clean.Contains('\\', StringComparison.Ordinal) || clean.Contains("%2f", StringComparison.OrdinalIgnoreCase) || !SafeObjectKeyRegex().IsMatch(clean)) throw new ObjectStorageException("Invalid objectKey.", "INVALID_OBJECT_KEY"); if (storageOptions.Value.RequireTenantPrefix && !clean.StartsWith($"{tenantId:N}/", StringComparison.Ordinal)) throw new ObjectStorageException( "objectKey must be scoped by tenantId prefix.", "OBJECT_KEY_TENANT_PREFIX_REQUIRED"); return clean; } public string ValidateMimeType(string mimeType) { ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); var normalized = mimeType.Trim().ToLowerInvariant(); if (normalized.Length > 160 || normalized.Contains('\r', StringComparison.Ordinal) || normalized.Contains('\n', StringComparison.Ordinal)) throw new ObjectStorageException("Invalid mimeType.", "INVALID_MIME_TYPE"); var exact = storageOptions.Value.AllowedMimeTypes .Select(item => item.Trim().ToLowerInvariant()) .Where(item => item.Length > 0) .ToHashSet(StringComparer.Ordinal); var prefixes = storageOptions.Value.AllowedMimePrefixes .Select(item => item.Trim().ToLowerInvariant()) .Where(item => item.Length > 0) .ToArray(); if (!exact.Contains(normalized) && !prefixes.Any(prefix => normalized.StartsWith(prefix, StringComparison.Ordinal))) throw new ObjectStorageException($"mimeType is not allowed: {normalized}", "MIME_TYPE_NOT_ALLOWED"); return normalized; } public long? ValidateFileSize(long? fileSizeBytes) { if (fileSizeBytes is null) return null; if (fileSizeBytes < 0) throw new ObjectStorageException("fileSizeBytes must be non-negative.", "INVALID_FILE_SIZE"); if (fileSizeBytes > storageOptions.Value.MaxUploadBytes) throw new ObjectStorageException("file exceeds configured storage max upload bytes.", "FILE_TOO_LARGE"); return fileSizeBytes; } public void AssertUploadProvider(string provider) { var normalized = NormalizeProvider(provider); if (!UploadableProviders.Contains(normalized)) throw new ObjectStorageException( $"{normalized} does not support managed upload signing.", "UPLOAD_PROVIDER_NOT_SUPPORTED"); } public void AssertWritableLocation(StorageAssetLocation location) { var provider = NormalizeProvider(location.Provider); if (provider is ObjectStorageProviders.AliyunOss or ObjectStorageProviders.LocalDev && (string.IsNullOrWhiteSpace(location.Bucket) || string.IsNullOrWhiteSpace(location.ObjectKey))) throw new ObjectStorageException( $"{provider} asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED"); } public async Task SignUploadAsync( ObjectStorageUploadSignRequest request, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var provider = NormalizeProvider(request.Provider); AssertUploadProvider(provider); var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); var mimeType = ValidateMimeType(request.MimeType); ValidateFileSize(request.FileSizeBytes); if (provider == ObjectStorageProviders.LocalDev) { await EnsureS3BucketAsync(request.Bucket, cancellationToken); var url = await GetS3Client().PresignedPutObjectAsync(new PresignedPutObjectArgs() .WithBucket(request.Bucket) .WithObject(objectKey) .WithExpiry(ExpirySeconds(request.ExpiresIn))); return new ObjectStorageSignedUrl( provider, request.Bucket, objectKey, "PUT", new Uri(url, UriKind.Absolute), new Dictionary(StringComparer.OrdinalIgnoreCase) { ["content-type"] = mimeType }, DateTimeOffset.UtcNow.Add(request.ExpiresIn), request.ExpiresIn, "s3-compatible-presigned-put"); } return SignAliyunOss( request.Bucket, objectKey, "PUT", request.ExpiresIn, mimeType, null, null); } public async Task SignDownloadAsync( ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var provider = NormalizeProvider(request.Provider); if (!string.IsNullOrWhiteSpace(request.CdnUrl)) return new ObjectStorageSignedUrl( provider, request.Bucket, request.ObjectKey, "GET", new Uri(request.CdnUrl, UriKind.Absolute), new Dictionary(), DateTimeOffset.UtcNow.Add(request.ExpiresIn), request.ExpiresIn, "public-or-provider-managed"); if (string.IsNullOrWhiteSpace(request.ObjectKey)) throw new ObjectStorageException("Asset requires objectKey or cdnUrl.", "ASSET_LOCATION_REQUIRED"); var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); if (provider == ObjectStorageProviders.LocalDev) { if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("S3-compatible asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); var url = await GetS3Client().PresignedGetObjectAsync(new PresignedGetObjectArgs() .WithBucket(request.Bucket) .WithObject(objectKey) .WithExpiry(ExpirySeconds(request.ExpiresIn))); return new ObjectStorageSignedUrl( provider, request.Bucket, objectKey, "GET", new Uri(url, UriKind.Absolute), new Dictionary(), DateTimeOffset.UtcNow.Add(request.ExpiresIn), request.ExpiresIn, "s3-compatible-presigned-get"); } if (provider == ObjectStorageProviders.QiniuKodo) return LocalSignedUrl(provider, request.Bucket, objectKey, "GET", request.ExpiresIn, request.Disposition); if (provider != ObjectStorageProviders.AliyunOss) throw new ObjectStorageException($"{provider} asset requires cdnUrl.", "ASSET_LOCATION_REQUIRED"); if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); return SignAliyunOss( request.Bucket, objectKey, "GET", request.ExpiresIn, null, request.FileName, request.Disposition); } public async Task HeadObjectAsync( ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var provider = NormalizeProvider(request.Provider); if (string.IsNullOrWhiteSpace(request.ObjectKey)) throw new ObjectStorageException("Asset requires objectKey.", "ASSET_LOCATION_REQUIRED"); var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); var fileSizeBytes = ValidateFileSize(request.DeclaredFileSizeBytes); var mimeType = request.DeclaredMimeType is null ? null : ValidateMimeType(request.DeclaredMimeType); var checksumSha256 = request.DeclaredChecksumSha256?.Trim().ToLowerInvariant(); if (provider == ObjectStorageProviders.LocalDev) { if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("S3-compatible asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); try { var result = await GetS3Client().StatObjectAsync(new StatObjectArgs() .WithBucket(request.Bucket) .WithObject(objectKey), cancellationToken); return new ObjectStorageMetadata( provider, request.Bucket, objectKey, true, result.Size, result.ContentType, checksumSha256, result.ETag, result.LastModified.ToString("O"), new Dictionary(), "s3-compatible-stat-object"); } catch (ObjectNotFoundException) { throw new ObjectStorageException("Uploaded object was not found in S3-compatible storage.", "STORAGE_OBJECT_NOT_FOUND"); } } if (provider != ObjectStorageProviders.AliyunOss) throw new ObjectStorageException( $"{provider} does not support upload confirmation.", "UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED"); if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("Aliyun OSS asset requires bucket and objectKey.", "ASSET_OBJECT_LOCATION_REQUIRED"); try { var result = await GetClient().HeadObjectAsync(new HeadObjectRequest { Bucket = request.Bucket, Key = objectKey }, cancellationToken: cancellationToken); return MetadataFromHeadResult(request.Bucket, objectKey, result); } catch (Exception exception) when (TryGetStatusCode(exception) == 404) { throw new ObjectStorageException( "Uploaded object was not found in Aliyun OSS.", "STORAGE_OBJECT_NOT_FOUND"); } } public async Task WriteObjectAsync( ObjectStorageWriteRequest request, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var provider = NormalizeProvider(request.Provider); AssertUploadProvider(provider); var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); var mimeType = ValidateMimeType(request.MimeType); var fileSizeBytes = ValidateFileSize(request.FileSizeBytes); var checksumSha256 = NormalizeChecksum(request.ChecksumSha256); if (request.Content.CanSeek) request.Content.Position = 0; if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("Writable asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); if (provider == ObjectStorageProviders.LocalDev) { await EnsureS3BucketAsync(request.Bucket, cancellationToken); MemoryStream? bufferedContent = null; var uploadContent = request.Content; if (checksumSha256 is null && !request.Content.CanSeek) { bufferedContent = new MemoryStream(); await request.Content.CopyToAsync(bufferedContent, cancellationToken); bufferedContent.Position = 0; uploadContent = bufferedContent; } var resolvedChecksum = checksumSha256 ?? await ComputeSha256Async(uploadContent, cancellationToken); var objectSize = fileSizeBytes ?? (uploadContent.CanSeek ? uploadContent.Length - uploadContent.Position : -1); try { await GetS3Client().PutObjectAsync(new PutObjectArgs() .WithBucket(request.Bucket) .WithObject(objectKey) .WithStreamData(uploadContent) .WithObjectSize(objectSize) .WithContentType(mimeType), cancellationToken); return new ObjectStorageWriteResult( provider, request.Bucket, objectKey, BuildPublicUrl(request.Bucket, objectKey), fileSizeBytes ?? objectSize, mimeType, resolvedChecksum, null, new Dictionary(), "s3-compatible-put-object"); } finally { if (bufferedContent is not null) await bufferedContent.DisposeAsync(); } } if (provider != ObjectStorageProviders.AliyunOss) throw new ObjectStorageException( $"{provider} does not support managed server-side writes.", "WRITE_PROVIDER_NOT_SUPPORTED"); var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); if (request.Metadata is not null) foreach (var (key, value) in request.Metadata) if (!string.IsNullOrWhiteSpace(key) && value is not null) metadata[key.Trim()] = value; if (!string.IsNullOrWhiteSpace(checksumSha256)) metadata["sha256"] = checksumSha256; var result = await GetClient().PutObjectAsync(new PutObjectRequest { Bucket = request.Bucket, Key = objectKey, Body = request.Content, ContentType = mimeType, ContentLength = fileSizeBytes, Metadata = metadata.Count == 0 ? null : metadata, ForbidOverwrite = !request.Upsert }, cancellationToken: cancellationToken); return new ObjectStorageWriteResult( provider, request.Bucket, objectKey, BuildPublicUrl(request.Bucket, objectKey), fileSizeBytes, mimeType, checksumSha256, CleanEtag(result.ETag), NormalizeHeaders(result.Headers), "aliyun-oss-put-object"); } public async Task OpenReadAsync( ObjectStorageReadRequest request, CancellationToken cancellationToken = default) { var provider = NormalizeProvider(request.Provider); var objectKey = ValidateObjectKey(request.TenantId, request.ObjectKey); if (provider == ObjectStorageProviders.LocalDev) { if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("S3-compatible asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); var content = new MemoryStream(); try { await GetS3Client().GetObjectAsync(new GetObjectArgs() .WithBucket(request.Bucket) .WithObject(objectKey) .WithCallbackStream(async (stream, token) => await stream.CopyToAsync(content, token)), cancellationToken); content.Position = 0; return content; } catch (ObjectNotFoundException) { await content.DisposeAsync(); throw new ObjectStorageException("Object was not found in S3-compatible storage.", "STORAGE_OBJECT_NOT_FOUND"); } } if (provider != ObjectStorageProviders.AliyunOss) throw new ObjectStorageException( $"{provider} does not support managed server-side reads.", "READ_PROVIDER_NOT_SUPPORTED"); if (string.IsNullOrWhiteSpace(request.Bucket)) throw new ObjectStorageException("Aliyun OSS asset requires bucket.", "ASSET_OBJECT_LOCATION_REQUIRED"); var result = await GetClient().GetObjectAsync( new GetObjectRequest { Bucket = request.Bucket, Key = objectKey }, HttpCompletionOption.ResponseHeadersRead, null, cancellationToken); return result.Body ?? throw new ObjectStorageException( "Object storage returned an empty response stream.", "STORAGE_OBJECT_EMPTY_RESPONSE"); } private ObjectStorageSignedUrl SignAliyunOss( string bucket, string objectKey, string method, TimeSpan expiresIn, string? mimeType, string? fileName, string? disposition) { ArgumentException.ThrowIfNullOrWhiteSpace(bucket); var expiresAt = ResolveExpiration(expiresIn); PresignResult result; if (method == "PUT") result = GetClient().Presign(new PutObjectRequest { Bucket = bucket, Key = objectKey, ContentType = mimeType }, expiresAt.UtcDateTime); else result = GetClient().Presign(new GetObjectRequest { Bucket = bucket, Key = objectKey, ResponseContentDisposition = BuildContentDisposition(fileName, disposition) }, expiresAt.UtcDateTime); return ToSignedUrl(result, ObjectStorageProviders.AliyunOss, bucket, objectKey, method, expiresIn, "aliyun-oss-signature-url-v4"); } private ObjectStorageSignedUrl LocalSignedUrl( string provider, string? bucket, string? objectKey, string method, TimeSpan expiresIn, string? disposition, IReadOnlyDictionary? headers = null) { var expiresAt = ResolveExpiration(expiresIn); var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/'); var path = $"{Uri.EscapeDataString(bucket ?? "default")}/{Uri.EscapeDataString(objectKey ?? "missing")}"; var url = string.IsNullOrWhiteSpace(baseUrl) ? $"https://local-storage.invalid/{provider}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}" : $"{baseUrl}/{path}?expiresAt={Uri.EscapeDataString(expiresAt.ToString("O"))}&disposition={disposition ?? "attachment"}"; return new ObjectStorageSignedUrl( provider, bucket, objectKey, method, new Uri(url), headers ?? new Dictionary(), expiresAt, expiresIn, "local-placeholder"); } private Uri? BuildPublicUrl(string bucket, string objectKey) { var baseUrl = storageOptions.Value.PublicBaseUrl?.Trim().TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) return null; var path = string.Join( '/', bucket.Trim('/'), objectKey .Split('/', StringSplitOptions.RemoveEmptyEntries) .Select(Uri.EscapeDataString)); return new Uri($"{baseUrl}/{path}", UriKind.Absolute); } private Oss.Client GetClient() { ObjectDisposedException.ThrowIf(disposed, this); if (client is not null) return client; lock (clientLock) { client ??= CreateClient(aliyunOptions.Value); return client; } } private IMinioClient GetS3Client() { ObjectDisposedException.ThrowIf(disposed, this); if (s3Client is not null) return s3Client; lock (clientLock) { if (s3Client is not null) return s3Client; var options = s3Options.Value; if (!options.IsConfigured) throw new ObjectStorageNotConfiguredException( "S3-compatible storage is not configured: S3_ENDPOINT, S3_ACCESS_KEY and S3_SECRET_KEY are required."); var builder = new MinioClient() .WithEndpoint(options.Endpoint!.Trim()) .WithCredentials(options.AccessKey!.Trim(), options.SecretKey!.Trim()) .WithSSL(options.Secure); if (!string.IsNullOrWhiteSpace(options.Region)) builder = builder.WithRegion(options.Region.Trim()); s3Client = builder.Build(); return s3Client; } } private async Task EnsureS3BucketAsync(string bucket, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(bucket); var client = GetS3Client(); var exists = await client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), cancellationToken); if (!exists) await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), cancellationToken); } private static int ExpirySeconds(TimeSpan expiresIn) { return Math.Clamp((int)Math.Ceiling(expiresIn.TotalSeconds), 1, 7 * 24 * 60 * 60); } private static Oss.Client CreateClient(AliyunOssOptions currentOptions) { if (!currentOptions.IsConfigured) throw new ObjectStorageNotConfiguredException( "aliyun_oss is not configured: ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET and ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT are required."); var configuration = Oss.Configuration.LoadDefault(); configuration.Region = currentOptions.Region?.Trim(); configuration.Endpoint = NullIfWhiteSpace(currentOptions.Endpoint); configuration.UseInternalEndpoint = currentOptions.UseInternalEndpoint; configuration.UsePathStyle = currentOptions.UsePathStyle; configuration.UseCName = currentOptions.UseCName; configuration.DisableSsl = false; configuration.CredentialsProvider = string.IsNullOrWhiteSpace(currentOptions.SecurityToken) ? new StaticCredentialsProvider( currentOptions.AccessKeyId!.Trim(), currentOptions.AccessKeySecret!.Trim()) : new StaticCredentialsProvider( currentOptions.AccessKeyId!.Trim(), currentOptions.AccessKeySecret!.Trim(), currentOptions.SecurityToken!.Trim()); return new Oss.Client(configuration); } private static ObjectStorageSignedUrl ToSignedUrl( PresignResult result, string provider, string bucket, string objectKey, string method, TimeSpan expiresIn, string signatureMode) { if (!Uri.TryCreate(result.Url, UriKind.Absolute, out var url)) throw new InvalidOperationException("Aliyun OSS SDK returned an invalid presigned URL."); return new ObjectStorageSignedUrl( provider, bucket, objectKey, string.IsNullOrWhiteSpace(result.Method) ? method : result.Method, url, result.SignedHeaders is null ? new Dictionary() : new Dictionary(result.SignedHeaders, StringComparer.OrdinalIgnoreCase), result.Expiration.HasValue ? new DateTimeOffset(DateTime.SpecifyKind(result.Expiration.Value, DateTimeKind.Utc)) : DateTimeOffset.UtcNow.Add(expiresIn), expiresIn, signatureMode); } private static ObjectStorageMetadata MetadataFromHeadResult( string bucket, string objectKey, HeadObjectResult result) { var headers = NormalizeHeaders(result.Headers); foreach (var metadata in result.Metadata ?? new Dictionary()) headers[$"x-oss-meta-{metadata.Key}".ToLowerInvariant()] = metadata.Value; return new ObjectStorageMetadata( ObjectStorageProviders.AliyunOss, bucket, objectKey, true, result.ContentLength, NullIfWhiteSpace(result.ContentType), FirstHeader(headers, "x-oss-meta-sha256", "x-oss-meta-checksum-sha256")?.Trim().ToLowerInvariant(), CleanEtag(result.ETag ?? FirstHeader(headers, "etag")), NullIfWhiteSpace(result.LastModified) ?? FirstHeader(headers, "last-modified"), headers, "aliyun-oss-head-object"); } private static Dictionary NormalizeHeaders(IDictionary? headers) { var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); if (headers is null) return normalized; foreach (var (key, value) in headers) if (!string.IsNullOrWhiteSpace(key) && value is not null) normalized[key.ToLowerInvariant()] = value; return normalized; } private static int? TryGetStatusCode(Exception exception) { var type = exception.GetType(); var property = type.GetProperty("StatusCode", BindingFlags.Public | BindingFlags.Instance) ?? type.GetProperty("Status", BindingFlags.Public | BindingFlags.Instance); return property?.GetValue(exception) switch { int statusCode => statusCode, long statusCode => checked((int)statusCode), string value when int.TryParse(value, out var statusCode) => statusCode, _ => null }; } private static DateTimeOffset ResolveExpiration(TimeSpan expiresIn) { if (expiresIn <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(expiresIn), expiresIn, "Signed URL expiration must be positive."); return DateTimeOffset.UtcNow.Add(expiresIn); } private static string CanonicalObjectKey(string objectKey) { return ConsecutiveSlashRegex().Replace(objectKey.Trim().TrimStart('/'), "/"); } private static string? BuildContentDisposition(string? fileName, string? disposition) { if (string.IsNullOrWhiteSpace(fileName)) return null; var normalizedDisposition = string.Equals(disposition, "inline", StringComparison.OrdinalIgnoreCase) ? "inline" : "attachment"; return $"{normalizedDisposition}; filename=\"{Uri.EscapeDataString(fileName.Trim())}\""; } private static string? FirstHeader(IReadOnlyDictionary headers, params string[] keys) { foreach (var key in keys) if (headers.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)) return value; return null; } private static string? CleanEtag(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim().Trim('"'); } private static string? NullIfWhiteSpace(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static string? NormalizeChecksum(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var normalized = value.Trim().ToLowerInvariant(); return normalized.Length == 64 ? normalized : null; } private static async Task ComputeSha256Async(Stream stream, CancellationToken cancellationToken) { if (stream.CanSeek) stream.Position = 0; var hash = await SHA256.HashDataAsync(stream, cancellationToken); if (stream.CanSeek) stream.Position = 0; return Convert.ToHexString(hash).ToLowerInvariant(); } [GeneratedRegex("""^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$""")] private static partial Regex SafeObjectKeyRegex(); [GeneratedRegex("/{2,}")] private static partial Regex ConsecutiveSlashRegex(); }