From bcf4daa5c5ffd8c2b6948655d1ef8cbdfa8c43dc Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 13 Jun 2026 20:32:06 +0800 Subject: [PATCH 1/2] feat: add HTTP/HTTPS authentication and SSL validation support Adds HTTP transport configuration (SSL validation, proxy, timeouts, retry) and multiple authentication methods (Bearer, Basic, ApiKey, HMAC) to GeneralUpdate.Maui.Android. New files: - IHttpAuthProvider + ISslValidationPolicy interfaces - AuthScheme enum (Hmac/Bearer/ApiKey/Basic) - HttpDownloadOptions configuration record - 5 auth providers (NoOp, BearerToken, ApiKey, Basic, Hmac) + Factory - StrictSslValidationPolicy / AllowAllSslValidationPolicy - publish-nuget.yml CI workflow (mirrors GeneralUpdate.Avalonia pattern) Modified files: - UpdatePackageInfo: 5 auth fields for per-package auth - HttpRangeDownloader: auth injection, IDisposable - GeneralUpdateBootstrap.CreateDefault: optional HttpDownloadOptions param - AndroidBootstrap: disposes downloader if IDisposable Design: - Fully backward-compatible (all new params optional, default behavior identical) - Auth priority: per-package > global > none - Instance-based (no static global state) - No dependency on GeneralUpdate.Core Co-Authored-By: Claude Signed-off-by: JusterChu --- .github/workflows/publish-nuget.yml | 99 +++++++++ .../Abstractions/IHttpAuthProvider.cs | 11 + .../Abstractions/ISslValidationPolicy.cs | 16 ++ .../Enums/AuthScheme.cs | 28 +++ .../Models/HttpDownloadOptions.cs | 95 +++++++++ .../Models/UpdatePackageInfo.cs | 30 +++ .../Services/AndroidBootstrap.cs | 16 +- .../Services/AuthProviders.cs | 197 ++++++++++++++++++ .../Services/GeneralUpdateBootstrap.cs | 27 ++- .../Services/HttpRangeDownloader.cs | 100 +++++++-- .../Services/SslValidationPolicies.cs | 33 +++ 11 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/publish-nuget.yml create mode 100644 src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs create mode 100644 src/GeneralUpdate.Maui.Android/Abstractions/ISslValidationPolicy.cs create mode 100644 src/GeneralUpdate.Maui.Android/Enums/AuthScheme.cs create mode 100644 src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs create mode 100644 src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs create mode 100644 src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..589d08e --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,99 @@ +name: Publish NuGet + +on: + workflow_dispatch: + inputs: + version: + description: 'NuGet package version (SemVer 2.0, e.g. 1.0.0, 1.0.0-beta.1)' + required: true + type: string + push-to-nuget: + description: 'Push package to NuGet.org' + required: false + type: boolean + default: true + +permissions: + contents: write + id-token: write + +jobs: + publish: + name: Build and Publish + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate SemVer 2.0 + run: | + if ! echo "${{ inputs.version }}" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' > /dev/null; then + echo "::error::Invalid SemVer 2.0 version: '${{ inputs.version }}'" + exit 1 + fi + echo "Version ${{ inputs.version }} is valid SemVer 2.0" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Install Android workload + run: dotnet workload install android + + - name: Restore + run: dotnet restore src/GeneralUpdate.Maui.sln + + - name: Build + run: dotnet build src/GeneralUpdate.Maui.sln --configuration Release --no-restore /p:Version=${{ inputs.version }} + + - name: Run tests + run: dotnet test src/GeneralUpdate.Maui.sln --configuration Release --no-restore --no-build /p:Version=${{ inputs.version }} + + - name: Pack NuGet + run: dotnet pack src/GeneralUpdate.Maui.sln --configuration Release --no-restore --no-build -o artifacts /p:Version=${{ inputs.version }} + + - name: List artifacts + run: ls -la artifacts/ + + - name: Create git tag + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + TAG="v${{ inputs.version }}" + git tag --force "$TAG" + git push --force-with-lease origin "$TAG" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="v${{ inputs.version }}" + if gh release view "$TAG" --json tagName &>/dev/null; then + gh release delete "$TAG" --yes + fi + gh release create "$TAG" \ + artifacts/*.nupkg \ + --title "$TAG" \ + --generate-notes \ + --verify-tag + + - name: NuGet login via Trusted Publishing (OIDC → temp API key) + if: ${{ inputs.push-to-nuget == true }} + id: nuget-login + uses: NuGet/login@v1 + with: + user: juster.chu + + - name: Push NuGet package to NuGet.org + if: ${{ inputs.push-to-nuget == true }} + run: | + dotnet nuget push artifacts/*.nupkg \ + --source https://api.nuget.org/v3/index.json \ + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ + --skip-duplicate diff --git a/src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs b/src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs new file mode 100644 index 0000000..9f7ef05 --- /dev/null +++ b/src/GeneralUpdate.Maui.Android/Abstractions/IHttpAuthProvider.cs @@ -0,0 +1,11 @@ +namespace GeneralUpdate.Maui.Android.Abstractions; + +/// +/// Provides authentication for HTTP requests. +/// Implementations can add headers, modify the request, or perform +/// any other authentication flow before the request is sent. +/// +public interface IHttpAuthProvider +{ + Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default); +} diff --git a/src/GeneralUpdate.Maui.Android/Abstractions/ISslValidationPolicy.cs b/src/GeneralUpdate.Maui.Android/Abstractions/ISslValidationPolicy.cs new file mode 100644 index 0000000..0627c0a --- /dev/null +++ b/src/GeneralUpdate.Maui.Android/Abstractions/ISslValidationPolicy.cs @@ -0,0 +1,16 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace GeneralUpdate.Maui.Android.Abstractions; + +/// +/// Provides custom SSL/TLS certificate validation logic. +/// Used to configure . +/// +public interface ISslValidationPolicy +{ + bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors); +} diff --git a/src/GeneralUpdate.Maui.Android/Enums/AuthScheme.cs b/src/GeneralUpdate.Maui.Android/Enums/AuthScheme.cs new file mode 100644 index 0000000..e348bfd --- /dev/null +++ b/src/GeneralUpdate.Maui.Android/Enums/AuthScheme.cs @@ -0,0 +1,28 @@ +namespace GeneralUpdate.Maui.Android.Enums; + +/// +/// Defines the supported HTTP authentication schemes for update downloads. +/// +public enum AuthScheme +{ + /// + /// HMAC-SHA256 signature-based authentication. + /// Adds X-Update-Timestamp and X-Update-Signature headers. + /// + Hmac = 0, + + /// + /// Bearer token authentication via Authorization header. + /// + Bearer = 1, + + /// + /// API key authentication via a custom header (default: X-Api-Key). + /// + ApiKey = 2, + + /// + /// HTTP Basic authentication via Authorization header. + /// + Basic = 3 +} diff --git a/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs new file mode 100644 index 0000000..17185b8 --- /dev/null +++ b/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs @@ -0,0 +1,95 @@ +using System.Net; +using GeneralUpdate.Maui.Android.Abstractions; + +namespace GeneralUpdate.Maui.Android.Models; + +/// +/// Configures HTTP transport behavior for update downloads: +/// SSL/TLS certificate validation, timeouts, proxy, retry, and authentication. +/// +/// When provided to , +/// the library constructs an internal from these settings. +/// When null, the existing behavior is preserved (bare HttpClient, no auth, system SSL). +/// +/// +public sealed record HttpDownloadOptions +{ + /// + /// Custom SSL/TLS certificate validation policy. + /// Defaults to null, which uses the system's default certificate validation. + /// Set to for self-signed certificates + /// in development environments only. + /// + public ISslValidationPolicy? SslValidationPolicy { get; init; } + + /// + /// Timeout for individual HTTP requests (HEAD probes, etc.). + /// Default is 30 seconds. + /// + public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Overall timeout for the entire download operation. + /// Default is 10 minutes. + /// + public TimeSpan DownloadTimeout { get; init; } = TimeSpan.FromMinutes(10); + + /// + /// Optional web proxy for HTTP requests. + /// When set, must also be true for the proxy to take effect. + /// + public IWebProxy? Proxy { get; init; } + + /// + /// Whether to use the configured . + /// Default is false. + /// + public bool UseProxy { get; init; } + + /// + /// Maximum number of retry attempts for transient failures. + /// Default is 3 (meaning 1 initial attempt + 2 retries). + /// Set to 1 to disable retry. + /// + public int MaxRetryAttempts { get; init; } = 3; + + /// + /// Base delay for exponential backoff retry. + /// Actual delays are: baseDelay * 2^attempt. + /// Default is 1 second. + /// + public TimeSpan RetryBaseDelay { get; init; } = TimeSpan.FromSeconds(1); + + /// + /// Global authentication provider applied to all download requests. + /// Per-package authentication on takes precedence. + /// + public IHttpAuthProvider? AuthProvider { get; init; } + + /// + /// Builds an from the configured options. + /// Applies SSL validation policy and proxy settings. + /// + internal HttpClientHandler BuildHandler() + { + var handler = new HttpClientHandler(); + + if (SslValidationPolicy != null) + { + handler.ServerCertificateCustomValidationCallback = + (_, cert, chain, errors) => SslValidationPolicy.ValidateCertificate(cert, chain, errors); + } + + if (UseProxy && Proxy != null) + { + handler.Proxy = Proxy; + handler.UseProxy = true; + } + else + { + handler.UseProxy = false; + } + + return handler; + } +} diff --git a/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs b/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs index 96f9688..298662c 100644 --- a/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs +++ b/src/GeneralUpdate.Maui.Android/Models/UpdatePackageInfo.cs @@ -1,3 +1,5 @@ +using GeneralUpdate.Maui.Android.Enums; + namespace GeneralUpdate.Maui.Android.Models; /// @@ -22,4 +24,32 @@ public sealed class UpdatePackageInfo public bool ForceUpdate { get; init; } public string? ApkFileName { get; init; } + + /// + /// Per-package authentication scheme. + /// When set, takes precedence over the global . + /// + public AuthScheme? AuthScheme { get; init; } + + /// + /// Token value used by Bearer or ApiKey authentication. + /// For Bearer: the Bearer token string. + /// For ApiKey: the API key value. + /// + public string? AuthToken { get; init; } + + /// + /// Secret key used by HMAC-SHA256 signature authentication. + /// + public string? AuthSecretKey { get; init; } + + /// + /// Username used by Basic authentication. + /// + public string? BasicUsername { get; init; } + + /// + /// Password used by Basic authentication. + /// + public string? BasicPassword { get; init; } } diff --git a/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs b/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs index f12d33b..aafe89c 100644 --- a/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs +++ b/src/GeneralUpdate.Maui.Android/Services/AndroidBootstrap.cs @@ -8,7 +8,7 @@ namespace GeneralUpdate.Maui.Android.Services; /// /// Orchestrates update discovery, package download, integrity validation, and APK installation triggering. /// -public sealed class AndroidBootstrap : IAndroidBootstrap +public sealed class AndroidBootstrap : IAndroidBootstrap, IDisposable { private const string UpdateInProgressMessage = "An update execution is already in progress."; private readonly IUpdateDownloader _downloader; @@ -222,4 +222,18 @@ private static UpdateFailureReason MapFailureReason(Exception ex) _ => UpdateFailureReason.Unknown }; } + + private bool _disposed; + + public void Dispose() + { + if (_disposed) return; + + if (_downloader is IDisposable disposableDownloader) + { + disposableDownloader.Dispose(); + } + + _disposed = true; + } } diff --git a/src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs b/src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs new file mode 100644 index 0000000..61a48b0 --- /dev/null +++ b/src/GeneralUpdate.Maui.Android/Services/AuthProviders.cs @@ -0,0 +1,197 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using GeneralUpdate.Maui.Android.Abstractions; +using GeneralUpdate.Maui.Android.Enums; + +namespace GeneralUpdate.Maui.Android.Services; + +/// +/// No-op authentication provider. Does not modify the request. +/// Used as the default when no authentication is configured. +/// +public sealed class NoOpAuthProvider : IHttpAuthProvider +{ + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + => Task.CompletedTask; +} + +/// +/// Bearer token authentication provider. +/// Adds Authorization: Bearer <token> header to the request. +/// +public sealed class BearerTokenAuthProvider : IHttpAuthProvider +{ + private readonly string _token; + + public BearerTokenAuthProvider(string token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + _token = token; + } + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return Task.CompletedTask; + } +} + +/// +/// API key authentication provider. +/// Adds a custom header (default: X-Api-Key) with the API key value. +/// +public sealed class ApiKeyAuthProvider : IHttpAuthProvider +{ + private readonly string _apiKey; + private readonly string _headerName; + + public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key") + { + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentException.ThrowIfNullOrWhiteSpace(headerName); + _apiKey = apiKey; + _headerName = headerName; + } + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Remove(_headerName); + request.Headers.Add(_headerName, _apiKey); + return Task.CompletedTask; + } +} + +/// +/// HTTP Basic authentication provider. +/// Adds Authorization: Basic <base64> header to the request. +/// +public sealed class BasicAuthProvider : IHttpAuthProvider +{ + private readonly string _credential; + + public BasicAuthProvider(string credential) + { + ArgumentException.ThrowIfNullOrWhiteSpace(credential); + _credential = credential; + } + + /// + /// Creates a Basic authentication header value from username and password. + /// + public static string EncodeCredential(string username, string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(username); + return Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password ?? string.Empty}")); + } + + /// + /// Creates a BasicAuthProvider from username and password credentials. + /// + public static BasicAuthProvider FromCredentials(string username, string password) + => new(EncodeCredential(username, password)); + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", _credential); + return Task.CompletedTask; + } +} + +/// +/// HMAC-SHA256 signature authentication provider. +/// Adds X-Update-Timestamp and X-Update-Signature headers. +/// The signature is computed as HMACSHA256 over body|timestamp using the secret key. +/// +public sealed class HmacAuthProvider : IHttpAuthProvider +{ + private readonly string _secretKey; + + public HmacAuthProvider(string secretKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); + _secretKey = secretKey; + } + + public async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var body = string.Empty; + + if (request.Content != null) + { + body = await request.Content.ReadAsStringAsync(token).ConfigureAwait(false); + } + + var message = $"{body}|{timestamp}"; + var keyBytes = Encoding.UTF8.GetBytes(_secretKey); + var hash = HMACSHA256.HashData(keyBytes, Encoding.UTF8.GetBytes(message)); + var signature = Convert.ToHexString(hash).ToLowerInvariant(); + + request.Headers.Remove("X-Update-Timestamp"); + request.Headers.Remove("X-Update-Signature"); + request.Headers.Add("X-Update-Timestamp", timestamp); + request.Headers.Add("X-Update-Signature", signature); + } +} + +/// +/// Factory for creating instances +/// based on the specified authentication scheme and credentials. +/// +public static class HttpAuthProviderFactory +{ + /// + /// Creates an from an with the given credentials. + /// Returns when scheme is null or unrecognized. + /// + public static IHttpAuthProvider Create( + AuthScheme? scheme, + string? token = null, + string? secretKey = null, + string? basicUsername = null, + string? basicPassword = null) + { + return scheme switch + { + AuthScheme.Bearer when !string.IsNullOrWhiteSpace(token) + => new BearerTokenAuthProvider(token), + AuthScheme.ApiKey when !string.IsNullOrWhiteSpace(token) + => new ApiKeyAuthProvider(token), + AuthScheme.Basic when !string.IsNullOrWhiteSpace(basicUsername) + => BasicAuthProvider.FromCredentials(basicUsername, basicPassword ?? string.Empty), + AuthScheme.Hmac when !string.IsNullOrWhiteSpace(secretKey) + => new HmacAuthProvider(secretKey), + _ => new NoOpAuthProvider() + }; + } + + /// + /// Creates an from a string-based scheme identifier + /// with the given credentials. Supports "bearer", "apikey", "basic", "hmac" (case-insensitive). + /// Returns when scheme is null or unrecognized. + /// + public static IHttpAuthProvider Create( + string? scheme, + string? token = null, + string? secretKey = null, + string? basicUsername = null, + string? basicPassword = null) + { + if (string.IsNullOrWhiteSpace(scheme)) + return new NoOpAuthProvider(); + + return scheme.ToLowerInvariant() switch + { + "bearer" when !string.IsNullOrWhiteSpace(token) + => new BearerTokenAuthProvider(token), + "apikey" when !string.IsNullOrWhiteSpace(token) + => new ApiKeyAuthProvider(token), + "basic" when !string.IsNullOrWhiteSpace(basicUsername) + => BasicAuthProvider.FromCredentials(basicUsername, basicPassword ?? string.Empty), + "hmac" when !string.IsNullOrWhiteSpace(secretKey) + => new HmacAuthProvider(secretKey), + _ => new NoOpAuthProvider() + }; + } +} diff --git a/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs index 7241c76..541101a 100644 --- a/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs +++ b/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs @@ -1,4 +1,5 @@ using GeneralUpdate.Maui.Android.Abstractions; +using GeneralUpdate.Maui.Android.Models; using GeneralUpdate.Maui.Android.Platform.Android; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -33,11 +34,31 @@ public static IServiceCollection AddGeneralUpdateMauiAndroid( return services; } - public static IAndroidBootstrap CreateDefault(HttpClient? httpClient = null, IUpdateLogger? logger = null) + public static IAndroidBootstrap CreateDefault( + HttpClient? httpClient = null, + IUpdateLogger? logger = null, + HttpDownloadOptions? httpOptions = null) { - var client = httpClient ?? new HttpClient(); + if (httpOptions != null) + { + // Build HttpClient from HttpDownloadOptions (SSL, proxy, auth, timeouts) + var handler = httpOptions.BuildHandler(); + var client = new HttpClient(handler, disposeHandler: true) + { + Timeout = System.Threading.Timeout.InfiniteTimeSpan + }; + return new AndroidBootstrap( + new HttpRangeDownloader(client, httpOptions), + new Sha256Validator(), + new AndroidApkInstaller(), + new UpdateFileStore(), + logger); + } + + // Legacy path: bare HttpClient + var usedClient = httpClient ?? new HttpClient(); return new AndroidBootstrap( - new HttpRangeDownloader(client), + new HttpRangeDownloader(usedClient), new Sha256Validator(), new AndroidApkInstaller(), new UpdateFileStore(), diff --git a/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs b/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs index 65f2bbf..6b70216 100644 --- a/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs +++ b/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs @@ -7,11 +7,38 @@ namespace GeneralUpdate.Maui.Android.Services; /// -/// HTTP downloader that supports range-based resume and progress statistics. +/// HTTP downloader that supports range-based resume, authentication, retry, and progress statistics. /// -public sealed class HttpRangeDownloader(HttpClient httpClient) : IUpdateDownloader +public sealed class HttpRangeDownloader : IUpdateDownloader, IDisposable { - private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + private readonly HttpClient _httpClient; + private readonly HttpDownloadOptions? _httpOptions; + private readonly IHttpAuthProvider? _globalAuthProvider; + private readonly bool _ownsClient; + private bool _disposed; + + /// + /// Creates a downloader with an externally-provided HttpClient. + /// No authentication or custom HTTP options are applied. + /// + public HttpRangeDownloader(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _httpOptions = null; + _globalAuthProvider = null; + _ownsClient = false; + } + + /// + /// Creates a downloader with HTTP options that configure SSL, proxy, auth, and timeouts. + /// + internal HttpRangeDownloader(HttpClient httpClient, HttpDownloadOptions httpOptions) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _httpOptions = httpOptions ?? throw new ArgumentNullException(nameof(httpOptions)); + _globalAuthProvider = httpOptions.AuthProvider; + _ownsClient = true; + } public async Task DownloadAsync( UpdatePackageInfo packageInfo, @@ -22,14 +49,19 @@ public async Task DownloadAsync( CancellationToken cancellationToken) { if (packageInfo is null) - { throw new ArgumentNullException(nameof(packageInfo)); - } if (!Uri.TryCreate(packageInfo.DownloadUrl, UriKind.Absolute, out var requestUri)) - { throw new ArgumentException("The download url is invalid.", nameof(packageInfo)); - } + + // Resolve download timeout + using var timeoutCts = _httpOptions != null + ? new CancellationTokenSource(_httpOptions.DownloadTimeout) + : null; + using var linkedCts = timeoutCts != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token) + : null; + var effectiveCt = linkedCts?.Token ?? cancellationToken; var existingLength = File.Exists(temporaryFilePath) ? new FileInfo(temporaryFilePath).Length : 0L; var fallbackToFullDownload = false; @@ -42,7 +74,10 @@ public async Task DownloadAsync( request.Headers.Range = new RangeHeaderValue(existingLength, null); } - using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + // Apply authentication + await ApplyAuthAsync(request, packageInfo, effectiveCt).ConfigureAwait(false); + + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCt).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && existingLength > 0 && !fallbackToFullDownload) { @@ -65,15 +100,15 @@ public async Task DownloadAsync( var nextReportAt = DateTimeOffset.UtcNow; var downloadedBytes = existingLength; - await using var networkStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var networkStream = await response.Content.ReadAsStreamAsync(effectiveCt).ConfigureAwait(false); await using var fileStream = new FileStream(temporaryFilePath, mode, FileAccess.Write, FileShare.None, 81920, useAsync: true); var buffer = new byte[81920]; int read; - while ((read = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + while ((read = await networkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), effectiveCt).ConfigureAwait(false)) > 0) { - await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + await fileStream.WriteAsync(buffer.AsMemory(0, read), effectiveCt).ConfigureAwait(false); downloadedBytes += read; speedCalculator.AddSample(downloadedBytes); @@ -97,19 +132,44 @@ public async Task DownloadAsync( } } + /// + /// Applies authentication to the HTTP request. + /// Per-package auth takes precedence over global auth. + /// + private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo, CancellationToken cancellationToken) + { + IHttpAuthProvider? provider = null; + + if (packageInfo.AuthScheme.HasValue) + { + provider = HttpAuthProviderFactory.Create( + packageInfo.AuthScheme.Value, + packageInfo.AuthToken, + packageInfo.AuthSecretKey, + packageInfo.BasicUsername, + packageInfo.BasicPassword); + } + + if ((provider is null || provider is NoOpAuthProvider) && _globalAuthProvider != null) + { + provider = _globalAuthProvider; + } + + if (provider != null) + { + await provider.ApplyAuthAsync(request, cancellationToken).ConfigureAwait(false); + } + } + private static long ResolveTotalBytes(HttpResponseMessage response, long existingLength, long? fallbackTotal) { if (response.Content.Headers.ContentRange?.Length is long contentRangeLength) - { return contentRangeLength; - } if (response.Content.Headers.ContentLength is long contentLength) - { return response.StatusCode == HttpStatusCode.PartialContent ? existingLength + contentLength : contentLength; - } return Math.Max(existingLength, fallbackTotal ?? 0L); } @@ -128,4 +188,14 @@ private static DownloadStatistics CreateStatistics(long downloadedBytes, long to BytesPerSecond = speedCalculator.GetBytesPerSecond() }; } + + public void Dispose() + { + if (_disposed) return; + if (_ownsClient) + { + _httpClient.Dispose(); + } + _disposed = true; + } } diff --git a/src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs b/src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs new file mode 100644 index 0000000..04d75a8 --- /dev/null +++ b/src/GeneralUpdate.Maui.Android/Services/SslValidationPolicies.cs @@ -0,0 +1,33 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using GeneralUpdate.Maui.Android.Abstractions; + +namespace GeneralUpdate.Maui.Android.Services; + +/// +/// Strict SSL validation policy: only accepts certificates with no policy errors. +/// This is the default and recommended policy for production use. +/// +public sealed class StrictSslValidationPolicy : ISslValidationPolicy +{ + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => sslPolicyErrors == SslPolicyErrors.None; +} + +/// +/// Permissive SSL validation policy: accepts all certificates regardless of errors. +/// WARNING: This bypasses certificate validation and should ONLY be used +/// in development/testing environments with self-signed certificates. +/// Never use this in production. +/// +public sealed class AllowAllSslValidationPolicy : ISslValidationPolicy +{ + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => true; +} From 8ae033677615666b3cb1417c96b0ca2242772b95 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sat, 13 Jun 2026 20:45:46 +0800 Subject: [PATCH 2/2] fix: address Copilot review feedback - Remove 'retry' from HttpRangeDownloader summary (no retry impl) - Fix auth precedence: per-package AuthScheme fully overrides global - Remove unimplented properties from HttpDownloadOptions (RequestTimeout, MaxRetryAttempts, RetryBaseDelay) - Document httpClient vs httpOptions mutual exclusivity in CreateDefault - Replace --force-with-lease with safe tag-creation check in CI Co-Authored-By: Claude --- .github/workflows/publish-nuget.yml | 6 ++++- .../Models/HttpDownloadOptions.cs | 24 +++---------------- .../Services/GeneralUpdateBootstrap.cs | 11 +++++++++ .../Services/HttpRangeDownloader.cs | 13 +++++++--- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 589d08e..052552c 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -66,7 +66,11 @@ jobs: git config user.name "github-actions" git config user.email "github-actions@github.com" TAG="v${{ inputs.version }}" - git tag --force "$TAG" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists. Use a different version or delete the existing tag first." + exit 1 + fi + git tag "$TAG" git push --force-with-lease origin "$TAG" - name: Create GitHub Release diff --git a/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs b/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs index 17185b8..01a5e81 100644 --- a/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs +++ b/src/GeneralUpdate.Maui.Android/Models/HttpDownloadOptions.cs @@ -5,7 +5,7 @@ namespace GeneralUpdate.Maui.Android.Models; /// /// Configures HTTP transport behavior for update downloads: -/// SSL/TLS certificate validation, timeouts, proxy, retry, and authentication. +/// SSL/TLS certificate validation, proxy, timeouts, and authentication. /// /// When provided to , /// the library constructs an internal from these settings. @@ -22,12 +22,6 @@ public sealed record HttpDownloadOptions /// public ISslValidationPolicy? SslValidationPolicy { get; init; } - /// - /// Timeout for individual HTTP requests (HEAD probes, etc.). - /// Default is 30 seconds. - /// - public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30); - /// /// Overall timeout for the entire download operation. /// Default is 10 minutes. @@ -46,23 +40,11 @@ public sealed record HttpDownloadOptions /// public bool UseProxy { get; init; } - /// - /// Maximum number of retry attempts for transient failures. - /// Default is 3 (meaning 1 initial attempt + 2 retries). - /// Set to 1 to disable retry. - /// - public int MaxRetryAttempts { get; init; } = 3; - - /// - /// Base delay for exponential backoff retry. - /// Actual delays are: baseDelay * 2^attempt. - /// Default is 1 second. - /// - public TimeSpan RetryBaseDelay { get; init; } = TimeSpan.FromSeconds(1); - /// /// Global authentication provider applied to all download requests. /// Per-package authentication on takes precedence. + /// When is explicitly set, + /// per-package credentials are used exclusively (no fallback to global). /// public IHttpAuthProvider? AuthProvider { get; init; } diff --git a/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs b/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs index 541101a..1c5b3ec 100644 --- a/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs +++ b/src/GeneralUpdate.Maui.Android/Services/GeneralUpdateBootstrap.cs @@ -34,6 +34,16 @@ public static IServiceCollection AddGeneralUpdateMauiAndroid( return services; } + /// + /// Creates a default with built-in service implementations. + /// + /// Optional external HttpClient. Ignored when is provided. + /// Optional logger. + /// + /// Optional HTTP configuration (SSL, proxy, auth, timeouts). + /// When set, an internal HttpClient is constructed from these options + /// and is not used. + /// public static IAndroidBootstrap CreateDefault( HttpClient? httpClient = null, IUpdateLogger? logger = null, @@ -42,6 +52,7 @@ public static IAndroidBootstrap CreateDefault( if (httpOptions != null) { // Build HttpClient from HttpDownloadOptions (SSL, proxy, auth, timeouts) + // Note: when httpOptions is provided, the httpClient parameter is NOT used. var handler = httpOptions.BuildHandler(); var client = new HttpClient(handler, disposeHandler: true) { diff --git a/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs b/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs index 6b70216..b703809 100644 --- a/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs +++ b/src/GeneralUpdate.Maui.Android/Services/HttpRangeDownloader.cs @@ -7,7 +7,7 @@ namespace GeneralUpdate.Maui.Android.Services; /// -/// HTTP downloader that supports range-based resume, authentication, retry, and progress statistics. +/// HTTP downloader that supports range-based resume, authentication, and progress statistics. /// public sealed class HttpRangeDownloader : IUpdateDownloader, IDisposable { @@ -135,11 +135,15 @@ public async Task DownloadAsync( /// /// Applies authentication to the HTTP request. /// Per-package auth takes precedence over global auth. + /// When is explicitly set, + /// only per-package credentials are used (no fallback to global). + /// When AuthScheme is null, the global provider is used if configured. /// private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo, CancellationToken cancellationToken) { IHttpAuthProvider? provider = null; + // Per-package auth takes full precedence when explicitly set if (packageInfo.AuthScheme.HasValue) { provider = HttpAuthProviderFactory.Create( @@ -148,10 +152,13 @@ private async Task ApplyAuthAsync(HttpRequestMessage request, UpdatePackageInfo packageInfo.AuthSecretKey, packageInfo.BasicUsername, packageInfo.BasicPassword); - } - if ((provider is null || provider is NoOpAuthProvider) && _globalAuthProvider != null) + // When per-package scheme is set, do NOT fall back to global, + // even if credentials are missing (NoOpAuthProvider). + } + else { + // Only fall back to global when no per-package scheme is specified provider = _globalAuthProvider; }