Add tests and implement StubBearer authentication for Signer endpoints
- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints. - Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication. - Developed ConcelierExporterClient for managing Trivy DB settings and export operations. - Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering. - Implemented styles and HTML structure for Trivy DB settings page. - Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
		@@ -535,7 +535,687 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
 | 
			
		||||
        return list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static List<string> NormalizeImages(IReadOnlyList<string> images)
 | 
			
		||||
    public async Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureBackendConfigured();
 | 
			
		||||
 | 
			
		||||
        var rootDirectory = ResolveOfflineDirectory(destinationDirectory);
 | 
			
		||||
        Directory.CreateDirectory(rootDirectory);
 | 
			
		||||
 | 
			
		||||
        var descriptor = await FetchOfflineKitDescriptorAsync(bundleId, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        var bundlePath = Path.Combine(rootDirectory, descriptor.BundleName);
 | 
			
		||||
        var metadataPath = bundlePath + ".metadata.json";
 | 
			
		||||
        var manifestPath = Path.Combine(rootDirectory, descriptor.ManifestName);
 | 
			
		||||
        var bundleSignaturePath = descriptor.BundleSignatureName is not null ? Path.Combine(rootDirectory, descriptor.BundleSignatureName) : null;
 | 
			
		||||
        var manifestSignaturePath = descriptor.ManifestSignatureName is not null ? Path.Combine(rootDirectory, descriptor.ManifestSignatureName) : null;
 | 
			
		||||
 | 
			
		||||
        var fromCache = false;
 | 
			
		||||
        if (!overwrite && File.Exists(bundlePath))
 | 
			
		||||
        {
 | 
			
		||||
            var digest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            if (string.Equals(digest, descriptor.BundleSha256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
            {
 | 
			
		||||
                fromCache = true;
 | 
			
		||||
            }
 | 
			
		||||
            else if (resume)
 | 
			
		||||
            {
 | 
			
		||||
                var partial = bundlePath + ".partial";
 | 
			
		||||
                File.Move(bundlePath, partial, overwrite: true);
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                File.Delete(bundlePath);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!fromCache)
 | 
			
		||||
        {
 | 
			
		||||
            await DownloadFileWithResumeAsync(descriptor.BundleDownloadUri, bundlePath, descriptor.BundleSha256, descriptor.BundleSize, resume, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await DownloadFileWithResumeAsync(descriptor.ManifestDownloadUri, manifestPath, descriptor.ManifestSha256, descriptor.ManifestSize ?? 0, resume: false, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (descriptor.BundleSignatureDownloadUri is not null && bundleSignaturePath is not null)
 | 
			
		||||
        {
 | 
			
		||||
            await DownloadAuxiliaryFileAsync(descriptor.BundleSignatureDownloadUri, bundleSignaturePath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (descriptor.ManifestSignatureDownloadUri is not null && manifestSignaturePath is not null)
 | 
			
		||||
        {
 | 
			
		||||
            await DownloadAuxiliaryFileAsync(descriptor.ManifestSignatureDownloadUri, manifestSignaturePath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await WriteOfflineKitMetadataAsync(metadataPath, descriptor, bundlePath, manifestPath, bundleSignaturePath, manifestSignaturePath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return new OfflineKitDownloadResult(
 | 
			
		||||
            descriptor,
 | 
			
		||||
            bundlePath,
 | 
			
		||||
            manifestPath,
 | 
			
		||||
            bundleSignaturePath,
 | 
			
		||||
            manifestSignaturePath,
 | 
			
		||||
            metadataPath,
 | 
			
		||||
            fromCache);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureBackendConfigured();
 | 
			
		||||
 | 
			
		||||
        if (request is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentNullException(nameof(request));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var bundlePath = Path.GetFullPath(request.BundlePath);
 | 
			
		||||
        if (!File.Exists(bundlePath))
 | 
			
		||||
        {
 | 
			
		||||
            throw new FileNotFoundException("Offline kit bundle not found.", bundlePath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        string? manifestPath = null;
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(request.ManifestPath))
 | 
			
		||||
        {
 | 
			
		||||
            manifestPath = Path.GetFullPath(request.ManifestPath);
 | 
			
		||||
            if (!File.Exists(manifestPath))
 | 
			
		||||
            {
 | 
			
		||||
                throw new FileNotFoundException("Offline kit manifest not found.", manifestPath);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        string? bundleSignaturePath = null;
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(request.BundleSignaturePath))
 | 
			
		||||
        {
 | 
			
		||||
            bundleSignaturePath = Path.GetFullPath(request.BundleSignaturePath);
 | 
			
		||||
            if (!File.Exists(bundleSignaturePath))
 | 
			
		||||
            {
 | 
			
		||||
                throw new FileNotFoundException("Offline kit bundle signature not found.", bundleSignaturePath);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        string? manifestSignaturePath = null;
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(request.ManifestSignaturePath))
 | 
			
		||||
        {
 | 
			
		||||
            manifestSignaturePath = Path.GetFullPath(request.ManifestSignaturePath);
 | 
			
		||||
            if (!File.Exists(manifestSignaturePath))
 | 
			
		||||
            {
 | 
			
		||||
                throw new FileNotFoundException("Offline kit manifest signature not found.", manifestSignaturePath);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var bundleSize = request.BundleSize ?? new FileInfo(bundlePath).Length;
 | 
			
		||||
        var bundleSha = string.IsNullOrWhiteSpace(request.BundleSha256)
 | 
			
		||||
            ? await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
            : NormalizeSha(request.BundleSha256) ?? throw new InvalidOperationException("Bundle digest must not be empty.");
 | 
			
		||||
 | 
			
		||||
        string? manifestSha = null;
 | 
			
		||||
        long? manifestSize = null;
 | 
			
		||||
        if (manifestPath is not null)
 | 
			
		||||
        {
 | 
			
		||||
            manifestSize = request.ManifestSize ?? new FileInfo(manifestPath).Length;
 | 
			
		||||
            manifestSha = string.IsNullOrWhiteSpace(request.ManifestSha256)
 | 
			
		||||
                ? await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
                : NormalizeSha(request.ManifestSha256);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var metadata = new OfflineKitImportMetadataPayload
 | 
			
		||||
        {
 | 
			
		||||
            BundleId = request.BundleId,
 | 
			
		||||
            BundleSha256 = bundleSha,
 | 
			
		||||
            BundleSize = bundleSize,
 | 
			
		||||
            CapturedAt = request.CapturedAt,
 | 
			
		||||
            Channel = request.Channel,
 | 
			
		||||
            Kind = request.Kind,
 | 
			
		||||
            IsDelta = request.IsDelta,
 | 
			
		||||
            BaseBundleId = request.BaseBundleId,
 | 
			
		||||
            ManifestSha256 = manifestSha,
 | 
			
		||||
            ManifestSize = manifestSize
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        using var message = CreateRequest(HttpMethod.Post, "api/offline-kit/import");
 | 
			
		||||
        await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        using var content = new MultipartFormDataContent();
 | 
			
		||||
 | 
			
		||||
        var metadataOptions = new JsonSerializerOptions(SerializerOptions)
 | 
			
		||||
        {
 | 
			
		||||
            WriteIndented = false
 | 
			
		||||
        };
 | 
			
		||||
        var metadataJson = JsonSerializer.Serialize(metadata, metadataOptions);
 | 
			
		||||
        var metadataContent = new StringContent(metadataJson, Encoding.UTF8, "application/json");
 | 
			
		||||
        content.Add(metadataContent, "metadata");
 | 
			
		||||
 | 
			
		||||
        var bundleStream = File.OpenRead(bundlePath);
 | 
			
		||||
        var bundleContent = new StreamContent(bundleStream);
 | 
			
		||||
        bundleContent.Headers.ContentType = new MediaTypeHeaderValue("application/gzip");
 | 
			
		||||
        content.Add(bundleContent, "bundle", Path.GetFileName(bundlePath));
 | 
			
		||||
 | 
			
		||||
        if (manifestPath is not null)
 | 
			
		||||
        {
 | 
			
		||||
            var manifestStream = File.OpenRead(manifestPath);
 | 
			
		||||
            var manifestContent = new StreamContent(manifestStream);
 | 
			
		||||
            manifestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
 | 
			
		||||
            content.Add(manifestContent, "manifest", Path.GetFileName(manifestPath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (bundleSignaturePath is not null)
 | 
			
		||||
        {
 | 
			
		||||
            var signatureStream = File.OpenRead(bundleSignaturePath);
 | 
			
		||||
            var signatureContent = new StreamContent(signatureStream);
 | 
			
		||||
            signatureContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
 | 
			
		||||
            content.Add(signatureContent, "bundleSignature", Path.GetFileName(bundleSignaturePath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (manifestSignaturePath is not null)
 | 
			
		||||
        {
 | 
			
		||||
            var manifestSignatureStream = File.OpenRead(manifestSignaturePath);
 | 
			
		||||
            var manifestSignatureContent = new StreamContent(manifestSignatureStream);
 | 
			
		||||
            manifestSignatureContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
 | 
			
		||||
            content.Add(manifestSignatureContent, "manifestSignature", Path.GetFileName(manifestSignaturePath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        message.Content = content;
 | 
			
		||||
 | 
			
		||||
        using var response = await _httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException(failure);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        OfflineKitImportResponseTransport? document;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            document = await response.Content.ReadFromJsonAsync<OfflineKitImportResponseTransport>(SerializerOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (JsonException ex)
 | 
			
		||||
        {
 | 
			
		||||
            var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException($"Failed to parse offline kit import response. {ex.Message}", ex)
 | 
			
		||||
            {
 | 
			
		||||
                Data = { ["payload"] = raw }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var submittedAt = document?.SubmittedAt ?? DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
        return new OfflineKitImportResult(
 | 
			
		||||
            document?.ImportId,
 | 
			
		||||
            document?.Status,
 | 
			
		||||
            submittedAt,
 | 
			
		||||
            document?.Message);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        EnsureBackendConfigured();
 | 
			
		||||
 | 
			
		||||
        using var request = CreateRequest(HttpMethod.Get, "api/offline-kit/status");
 | 
			
		||||
        await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException(failure);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (response.Content is null || response.Content.Headers.ContentLength is 0)
 | 
			
		||||
        {
 | 
			
		||||
            return new OfflineKitStatus(null, null, null, false, null, null, null, null, null, Array.Empty<OfflineKitComponentStatus>());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        OfflineKitStatusTransport? document;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            document = await response.Content.ReadFromJsonAsync<OfflineKitStatusTransport>(SerializerOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (JsonException ex)
 | 
			
		||||
        {
 | 
			
		||||
            var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException($"Failed to parse offline kit status response. {ex.Message}", ex)
 | 
			
		||||
            {
 | 
			
		||||
                Data = { ["payload"] = raw }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var current = document?.Current;
 | 
			
		||||
        var components = MapOfflineComponents(document?.Components);
 | 
			
		||||
 | 
			
		||||
        if (current is null)
 | 
			
		||||
        {
 | 
			
		||||
            return new OfflineKitStatus(null, null, null, false, null, null, null, null, null, components);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new OfflineKitStatus(
 | 
			
		||||
            NormalizeOptionalString(current.BundleId),
 | 
			
		||||
            NormalizeOptionalString(current.Channel),
 | 
			
		||||
            NormalizeOptionalString(current.Kind),
 | 
			
		||||
            current.IsDelta ?? false,
 | 
			
		||||
            NormalizeOptionalString(current.BaseBundleId),
 | 
			
		||||
            current.CapturedAt?.ToUniversalTime(),
 | 
			
		||||
            current.ImportedAt?.ToUniversalTime(),
 | 
			
		||||
            NormalizeSha(current.BundleSha256),
 | 
			
		||||
            current.BundleSize,
 | 
			
		||||
            components);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private string ResolveOfflineDirectory(string destinationDirectory)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(destinationDirectory))
 | 
			
		||||
        {
 | 
			
		||||
            return Path.GetFullPath(destinationDirectory);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var configured = _options.Offline?.KitsDirectory;
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(configured))
 | 
			
		||||
        {
 | 
			
		||||
            return Path.GetFullPath(configured);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "offline-kits"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<OfflineKitBundleDescriptor> FetchOfflineKitDescriptorAsync(string? bundleId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var route = string.IsNullOrWhiteSpace(bundleId)
 | 
			
		||||
            ? "api/offline-kit/bundles/latest"
 | 
			
		||||
            : $"api/offline-kit/bundles/{Uri.EscapeDataString(bundleId)}";
 | 
			
		||||
 | 
			
		||||
        using var request = CreateRequest(HttpMethod.Get, route);
 | 
			
		||||
        await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException(failure);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        OfflineKitBundleDescriptorTransport? payload;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            payload = await response.Content.ReadFromJsonAsync<OfflineKitBundleDescriptorTransport>(SerializerOptions, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (JsonException ex)
 | 
			
		||||
        {
 | 
			
		||||
            var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException($"Failed to parse offline kit metadata. {ex.Message}", ex)
 | 
			
		||||
            {
 | 
			
		||||
                Data = { ["payload"] = raw }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (payload is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Offline kit metadata response was empty.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return MapOfflineKitDescriptor(payload);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private OfflineKitBundleDescriptor MapOfflineKitDescriptor(OfflineKitBundleDescriptorTransport transport)
 | 
			
		||||
    {
 | 
			
		||||
        if (transport is null)
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentNullException(nameof(transport));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var bundleName = string.IsNullOrWhiteSpace(transport.BundleName)
 | 
			
		||||
            ? throw new InvalidOperationException("Offline kit metadata missing bundleName.")
 | 
			
		||||
            : transport.BundleName!.Trim();
 | 
			
		||||
 | 
			
		||||
        var bundleId = string.IsNullOrWhiteSpace(transport.BundleId) ? bundleName : transport.BundleId!.Trim();
 | 
			
		||||
        var bundleSha = NormalizeSha(transport.BundleSha256) ?? throw new InvalidOperationException("Offline kit metadata missing bundleSha256.");
 | 
			
		||||
 | 
			
		||||
        var bundleSize = transport.BundleSize;
 | 
			
		||||
        if (bundleSize <= 0)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Offline kit metadata missing bundle size.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var manifestName = string.IsNullOrWhiteSpace(transport.ManifestName) ? "offline-manifest.json" : transport.ManifestName!.Trim();
 | 
			
		||||
        var manifestSha = NormalizeSha(transport.ManifestSha256) ?? throw new InvalidOperationException("Offline kit metadata missing manifestSha256.");
 | 
			
		||||
        var capturedAt = transport.CapturedAt?.ToUniversalTime() ?? DateTimeOffset.UtcNow;
 | 
			
		||||
 | 
			
		||||
        var bundleDownloadUri = ResolveDownloadUri(transport.BundleUrl, transport.BundlePath, bundleName);
 | 
			
		||||
        var manifestDownloadUri = ResolveDownloadUri(transport.ManifestUrl, transport.ManifestPath, manifestName);
 | 
			
		||||
        var bundleSignatureUri = ResolveOptionalDownloadUri(transport.BundleSignatureUrl, transport.BundleSignaturePath, transport.BundleSignatureName);
 | 
			
		||||
        var manifestSignatureUri = ResolveOptionalDownloadUri(transport.ManifestSignatureUrl, transport.ManifestSignaturePath, transport.ManifestSignatureName);
 | 
			
		||||
        var bundleSignatureName = ResolveArtifactName(transport.BundleSignatureName, bundleSignatureUri);
 | 
			
		||||
        var manifestSignatureName = ResolveArtifactName(transport.ManifestSignatureName, manifestSignatureUri);
 | 
			
		||||
 | 
			
		||||
        return new OfflineKitBundleDescriptor(
 | 
			
		||||
            bundleId,
 | 
			
		||||
            bundleName,
 | 
			
		||||
            bundleSha,
 | 
			
		||||
            bundleSize,
 | 
			
		||||
            bundleDownloadUri,
 | 
			
		||||
            manifestName,
 | 
			
		||||
            manifestSha,
 | 
			
		||||
            manifestDownloadUri,
 | 
			
		||||
            capturedAt,
 | 
			
		||||
            NormalizeOptionalString(transport.Channel),
 | 
			
		||||
            NormalizeOptionalString(transport.Kind),
 | 
			
		||||
            transport.IsDelta ?? false,
 | 
			
		||||
            NormalizeOptionalString(transport.BaseBundleId),
 | 
			
		||||
            bundleSignatureName,
 | 
			
		||||
            bundleSignatureUri,
 | 
			
		||||
            manifestSignatureName,
 | 
			
		||||
            manifestSignatureUri,
 | 
			
		||||
            transport.ManifestSize);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? ResolveArtifactName(string? explicitName, Uri? uri)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(explicitName))
 | 
			
		||||
        {
 | 
			
		||||
            return explicitName.Trim();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (uri is not null)
 | 
			
		||||
        {
 | 
			
		||||
            var name = Path.GetFileName(uri.LocalPath);
 | 
			
		||||
            return string.IsNullOrWhiteSpace(name) ? null : name;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Uri ResolveDownloadUri(string? absoluteOrRelativeUrl, string? relativePath, string fallbackFileName)
 | 
			
		||||
    {
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(absoluteOrRelativeUrl))
 | 
			
		||||
        {
 | 
			
		||||
            var candidate = new Uri(absoluteOrRelativeUrl, UriKind.RelativeOrAbsolute);
 | 
			
		||||
            if (candidate.IsAbsoluteUri)
 | 
			
		||||
            {
 | 
			
		||||
                return candidate;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (_httpClient.BaseAddress is not null)
 | 
			
		||||
            {
 | 
			
		||||
                return new Uri(_httpClient.BaseAddress, candidate);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return BuildUriFromRelative(candidate.ToString());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(relativePath))
 | 
			
		||||
        {
 | 
			
		||||
            return BuildUriFromRelative(relativePath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(fallbackFileName))
 | 
			
		||||
        {
 | 
			
		||||
            return BuildUriFromRelative(fallbackFileName);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new InvalidOperationException("Offline kit metadata did not include a download URL.");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Uri BuildUriFromRelative(string relative)
 | 
			
		||||
    {
 | 
			
		||||
        var normalized = relative.TrimStart('/');
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(_options.Offline?.MirrorUrl) &&
 | 
			
		||||
            Uri.TryCreate(_options.Offline.MirrorUrl, UriKind.Absolute, out var mirrorBase))
 | 
			
		||||
        {
 | 
			
		||||
            if (!mirrorBase.AbsoluteUri.EndsWith("/"))
 | 
			
		||||
            {
 | 
			
		||||
                mirrorBase = new Uri(mirrorBase.AbsoluteUri + "/");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new Uri(mirrorBase, normalized);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (_httpClient.BaseAddress is not null)
 | 
			
		||||
        {
 | 
			
		||||
            return new Uri(_httpClient.BaseAddress, normalized);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        throw new InvalidOperationException($"Cannot resolve offline kit URI for '{relative}' because no mirror or backend base address is configured.");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Uri? ResolveOptionalDownloadUri(string? absoluteOrRelativeUrl, string? relativePath, string? fallbackName)
 | 
			
		||||
    {
 | 
			
		||||
        var hasData = !string.IsNullOrWhiteSpace(absoluteOrRelativeUrl) ||
 | 
			
		||||
                      !string.IsNullOrWhiteSpace(relativePath) ||
 | 
			
		||||
                      !string.IsNullOrWhiteSpace(fallbackName);
 | 
			
		||||
 | 
			
		||||
        if (!hasData)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            return ResolveDownloadUri(absoluteOrRelativeUrl, relativePath, fallbackName ?? string.Empty);
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task DownloadFileWithResumeAsync(Uri downloadUri, string targetPath, string expectedSha256, long expectedSize, bool resume, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var directory = Path.GetDirectoryName(targetPath);
 | 
			
		||||
        if (!string.IsNullOrEmpty(directory))
 | 
			
		||||
        {
 | 
			
		||||
            Directory.CreateDirectory(directory);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var partialPath = resume ? targetPath + ".partial" : targetPath + ".tmp";
 | 
			
		||||
 | 
			
		||||
        if (!resume && File.Exists(targetPath))
 | 
			
		||||
        {
 | 
			
		||||
            File.Delete(targetPath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (resume && File.Exists(targetPath))
 | 
			
		||||
        {
 | 
			
		||||
            File.Move(targetPath, partialPath, overwrite: true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        long existingLength = 0;
 | 
			
		||||
        if (resume && File.Exists(partialPath))
 | 
			
		||||
        {
 | 
			
		||||
            existingLength = new FileInfo(partialPath).Length;
 | 
			
		||||
            if (expectedSize > 0 && existingLength >= expectedSize)
 | 
			
		||||
            {
 | 
			
		||||
                existingLength = expectedSize;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        while (true)
 | 
			
		||||
        {
 | 
			
		||||
            using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri);
 | 
			
		||||
            if (resume && existingLength > 0 && expectedSize > 0 && existingLength < expectedSize)
 | 
			
		||||
            {
 | 
			
		||||
                request.Headers.Range = new RangeHeaderValue(existingLength, null);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (resume && existingLength > 0 && expectedSize > 0 && existingLength < expectedSize && response.StatusCode == HttpStatusCode.OK)
 | 
			
		||||
            {
 | 
			
		||||
                existingLength = 0;
 | 
			
		||||
                if (File.Exists(partialPath))
 | 
			
		||||
                {
 | 
			
		||||
                    File.Delete(partialPath);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!response.IsSuccessStatusCode &&
 | 
			
		||||
                !(resume && existingLength > 0 && response.StatusCode == HttpStatusCode.PartialContent))
 | 
			
		||||
            {
 | 
			
		||||
                var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                throw new InvalidOperationException(failure);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var destination = resume ? partialPath : targetPath;
 | 
			
		||||
            var mode = resume && existingLength > 0 ? FileMode.Append : FileMode.Create;
 | 
			
		||||
 | 
			
		||||
            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            await using (var file = new FileStream(destination, mode, FileAccess.Write, FileShare.None, 81920, useAsync: true))
 | 
			
		||||
            {
 | 
			
		||||
                await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (resume && File.Exists(partialPath))
 | 
			
		||||
        {
 | 
			
		||||
            File.Move(partialPath, targetPath, overwrite: true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var digest = await ComputeSha256Async(targetPath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        if (!string.Equals(digest, expectedSha256, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            File.Delete(targetPath);
 | 
			
		||||
            throw new InvalidOperationException($"Digest mismatch for {Path.GetFileName(targetPath)}. Expected {expectedSha256} but computed {digest}.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (expectedSize > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var actualSize = new FileInfo(targetPath).Length;
 | 
			
		||||
            if (actualSize != expectedSize)
 | 
			
		||||
            {
 | 
			
		||||
                File.Delete(targetPath);
 | 
			
		||||
                throw new InvalidOperationException($"Size mismatch for {Path.GetFileName(targetPath)}. Expected {expectedSize:N0} bytes but downloaded {actualSize:N0} bytes.");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task DownloadAuxiliaryFileAsync(Uri downloadUri, string targetPath, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var directory = Path.GetDirectoryName(targetPath);
 | 
			
		||||
        if (!string.IsNullOrEmpty(directory))
 | 
			
		||||
        {
 | 
			
		||||
            Directory.CreateDirectory(directory);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri);
 | 
			
		||||
        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        if (!response.IsSuccessStatusCode)
 | 
			
		||||
        {
 | 
			
		||||
            var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            throw new InvalidOperationException(failure);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        await using var file = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
 | 
			
		||||
        await stream.CopyToAsync(file, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task WriteOfflineKitMetadataAsync(
 | 
			
		||||
        string metadataPath,
 | 
			
		||||
        OfflineKitBundleDescriptor descriptor,
 | 
			
		||||
        string bundlePath,
 | 
			
		||||
        string manifestPath,
 | 
			
		||||
        string? bundleSignaturePath,
 | 
			
		||||
        string? manifestSignaturePath,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var document = new OfflineKitMetadataDocument
 | 
			
		||||
        {
 | 
			
		||||
            BundleId = descriptor.BundleId,
 | 
			
		||||
            BundleName = descriptor.BundleName,
 | 
			
		||||
            BundleSha256 = descriptor.BundleSha256,
 | 
			
		||||
            BundleSize = descriptor.BundleSize,
 | 
			
		||||
            BundlePath = Path.GetFullPath(bundlePath),
 | 
			
		||||
            CapturedAt = descriptor.CapturedAt,
 | 
			
		||||
            DownloadedAt = DateTimeOffset.UtcNow,
 | 
			
		||||
            Channel = descriptor.Channel,
 | 
			
		||||
            Kind = descriptor.Kind,
 | 
			
		||||
            IsDelta = descriptor.IsDelta,
 | 
			
		||||
            BaseBundleId = descriptor.BaseBundleId,
 | 
			
		||||
            ManifestName = descriptor.ManifestName,
 | 
			
		||||
            ManifestSha256 = descriptor.ManifestSha256,
 | 
			
		||||
            ManifestSize = descriptor.ManifestSize,
 | 
			
		||||
            ManifestPath = Path.GetFullPath(manifestPath),
 | 
			
		||||
            BundleSignaturePath = bundleSignaturePath is null ? null : Path.GetFullPath(bundleSignaturePath),
 | 
			
		||||
            ManifestSignaturePath = manifestSignaturePath is null ? null : Path.GetFullPath(manifestSignaturePath)
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var options = new JsonSerializerOptions(SerializerOptions)
 | 
			
		||||
        {
 | 
			
		||||
            WriteIndented = true
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var payload = JsonSerializer.Serialize(document, options);
 | 
			
		||||
        await File.WriteAllTextAsync(metadataPath, payload, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<OfflineKitComponentStatus> MapOfflineComponents(List<OfflineKitComponentStatusTransport>? transports)
 | 
			
		||||
    {
 | 
			
		||||
        if (transports is null || transports.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return Array.Empty<OfflineKitComponentStatus>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var list = new List<OfflineKitComponentStatus>();
 | 
			
		||||
        foreach (var transport in transports)
 | 
			
		||||
        {
 | 
			
		||||
            if (transport is null || string.IsNullOrWhiteSpace(transport.Name))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            list.Add(new OfflineKitComponentStatus(
 | 
			
		||||
                transport.Name.Trim(),
 | 
			
		||||
                NormalizeOptionalString(transport.Version),
 | 
			
		||||
                NormalizeSha(transport.Digest),
 | 
			
		||||
                transport.CapturedAt?.ToUniversalTime(),
 | 
			
		||||
                transport.SizeBytes));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return list.Count == 0 ? Array.Empty<OfflineKitComponentStatus>() : list;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? NormalizeSha(string? digest)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(digest))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var value = digest.Trim();
 | 
			
		||||
        if (value.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            value = value.Substring("sha256:".Length);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return value.ToLowerInvariant();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed class OfflineKitImportMetadataPayload
 | 
			
		||||
    {
 | 
			
		||||
        public string? BundleId { get; set; }
 | 
			
		||||
 | 
			
		||||
        public string BundleSha256 { get; set; } = string.Empty;
 | 
			
		||||
 | 
			
		||||
        public long BundleSize { get; set; }
 | 
			
		||||
 | 
			
		||||
        public DateTimeOffset? CapturedAt { get; set; }
 | 
			
		||||
 | 
			
		||||
        public string? Channel { get; set; }
 | 
			
		||||
 | 
			
		||||
        public string? Kind { get; set; }
 | 
			
		||||
 | 
			
		||||
        public bool? IsDelta { get; set; }
 | 
			
		||||
 | 
			
		||||
        public string? BaseBundleId { get; set; }
 | 
			
		||||
 | 
			
		||||
        public string? ManifestSha256 { get; set; }
 | 
			
		||||
 | 
			
		||||
        public long? ManifestSize { get; set; }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static List<string> NormalizeImages(IReadOnlyList<string> images)
 | 
			
		||||
    {
 | 
			
		||||
        var normalized = new List<string>();
 | 
			
		||||
        if (images is null)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user