using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Net; using System.Net.Http; using System.Linq; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Services.Models; using StellaOps.Cli.Services.Models.Transport; namespace StellaOps.Cli.Services; internal sealed class BackendOperationsClient : IBackendOperationsClient { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30); private static readonly IReadOnlyDictionary EmptyMetadata = new ReadOnlyDictionary(new Dictionary(0, StringComparer.OrdinalIgnoreCase)); private readonly HttpClient _httpClient; private readonly StellaOpsCliOptions _options; private readonly ILogger _logger; private readonly IStellaOpsTokenClient? _tokenClient; private readonly object _tokenSync = new(); private string? _cachedAccessToken; private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; public BackendOperationsClient(HttpClient httpClient, StellaOpsCliOptions options, ILogger logger, IStellaOpsTokenClient? tokenClient = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _tokenClient = tokenClient; if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && httpClient.BaseAddress is null) { if (Uri.TryCreate(_options.BackendUrl, UriKind.Absolute, out var baseUri)) { httpClient.BaseAddress = baseUri; } } } public async Task DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken) { EnsureBackendConfigured(); channel = string.IsNullOrWhiteSpace(channel) ? "stable" : channel.Trim(); outputPath = ResolveArtifactPath(outputPath, channel); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); if (!overwrite && File.Exists(outputPath)) { var existing = new FileInfo(outputPath); _logger.LogInformation("Scanner artifact already cached at {Path} ({Size} bytes).", outputPath, existing.Length); return new ScannerArtifactResult(outputPath, existing.Length, true); } var attempt = 0; var maxAttempts = Math.Max(1, _options.ScannerDownloadAttempts); while (true) { attempt++; try { using var request = CreateRequest(HttpMethod.Get, $"api/scanner/artifacts/{channel}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); 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); } return await ProcessScannerResponseAsync(response, outputPath, channel, verbose, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (attempt < maxAttempts) { var backoffSeconds = Math.Pow(2, attempt); _logger.LogWarning(ex, "Scanner download attempt {Attempt}/{MaxAttempts} failed. Retrying in {Delay:F0}s...", attempt, maxAttempts, backoffSeconds); await Task.Delay(TimeSpan.FromSeconds(backoffSeconds), cancellationToken).ConfigureAwait(false); } } } private async Task ProcessScannerResponseAsync(HttpResponseMessage response, string outputPath, string channel, bool verbose, CancellationToken cancellationToken) { var tempFile = outputPath + ".tmp"; await using (var payloadStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) await using (var fileStream = File.Create(tempFile)) { await payloadStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } var expectedDigest = ExtractHeaderValue(response.Headers, "X-StellaOps-Digest"); var signatureHeader = ExtractHeaderValue(response.Headers, "X-StellaOps-Signature"); var digestHex = await ValidateDigestAsync(tempFile, expectedDigest, cancellationToken).ConfigureAwait(false); await ValidateSignatureAsync(signatureHeader, digestHex, verbose, cancellationToken).ConfigureAwait(false); if (verbose) { var signatureNote = string.IsNullOrWhiteSpace(signatureHeader) ? "no signature" : "signature validated"; _logger.LogDebug("Scanner digest sha256:{Digest} ({SignatureNote}).", digestHex, signatureNote); } if (File.Exists(outputPath)) { File.Delete(outputPath); } File.Move(tempFile, outputPath); PersistMetadata(outputPath, channel, digestHex, signatureHeader, response); var downloaded = new FileInfo(outputPath); _logger.LogInformation("Scanner downloaded to {Path} ({Size} bytes).", outputPath, downloaded.Length); return new ScannerArtifactResult(outputPath, downloaded.Length, false); } public async Task UploadScanResultsAsync(string filePath, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (!File.Exists(filePath)) { throw new FileNotFoundException("Scan result file not found.", filePath); } var maxAttempts = Math.Max(1, _options.ScanUploadAttempts); var attempt = 0; while (true) { attempt++; try { using var content = new MultipartFormDataContent(); await using var fileStream = File.OpenRead(filePath); var streamContent = new StreamContent(fileStream); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); content.Add(streamContent, "file", Path.GetFileName(filePath)); using var request = CreateRequest(HttpMethod.Post, "api/scanner/results"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = content; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { _logger.LogInformation("Scan results uploaded from {Path}.", filePath); return; } var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); if (attempt >= maxAttempts) { throw new InvalidOperationException(failure); } var delay = GetRetryDelay(response, attempt); _logger.LogWarning( "Scan upload attempt {Attempt}/{MaxAttempts} failed ({Reason}). Retrying in {Delay:F1}s...", attempt, maxAttempts, failure, delay.TotalSeconds); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (attempt < maxAttempts) { var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); _logger.LogWarning( ex, "Scan upload attempt {Attempt}/{MaxAttempts} threw an exception. Retrying in {Delay:F1}s...", attempt, maxAttempts, delay.TotalSeconds); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } } } public async Task TriggerJobAsync(string jobKind, IDictionary parameters, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(jobKind)) { throw new ArgumentException("Job kind must be provided.", nameof(jobKind)); } var requestBody = new JobTriggerRequest { Trigger = "cli", Parameters = parameters is null ? new Dictionary(StringComparer.Ordinal) : new Dictionary(parameters, StringComparer.Ordinal) }; var request = CreateRequest(HttpMethod.Post, $"jobs/{jobKind}"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); request.Content = JsonContent.Create(requestBody, options: SerializerOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Accepted) { JobRunResponse? run = null; if (response.Content.Headers.ContentLength is > 0) { try { run = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to deserialize job run response for job kind {Kind}.", jobKind); } } var location = response.Headers.Location?.ToString(); return new JobTriggerResult(true, "Accepted", location, run); } var failureMessage = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); return new JobTriggerResult(false, failureMessage, null, null); } public async Task ExecuteExcititorOperationAsync(string route, HttpMethod method, object? payload, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(route)) { throw new ArgumentException("Route must be provided.", nameof(route)); } var relative = route.TrimStart('/'); using var request = CreateRequest(method, $"excititor/{relative}"); if (payload is not null && method != HttpMethod.Get && method != HttpMethod.Delete) { request.Content = JsonContent.Create(payload, options: SerializerOptions); } await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { var (message, payloadElement) = await ExtractExcititorResponseAsync(response, cancellationToken).ConfigureAwait(false); var location = response.Headers.Location?.ToString(); return new ExcititorOperationResult(true, message, location, payloadElement); } var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); return new ExcititorOperationResult(false, failure, null, null); } public async Task DownloadExcititorExportAsync(string exportId, string destinationPath, string? expectedDigestAlgorithm, string? expectedDigest, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (string.IsNullOrWhiteSpace(exportId)) { throw new ArgumentException("Export id must be provided.", nameof(exportId)); } if (string.IsNullOrWhiteSpace(destinationPath)) { throw new ArgumentException("Destination path must be provided.", nameof(destinationPath)); } var fullPath = Path.GetFullPath(destinationPath); var directory = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } var normalizedAlgorithm = string.IsNullOrWhiteSpace(expectedDigestAlgorithm) ? null : expectedDigestAlgorithm.Trim(); var normalizedDigest = NormalizeExpectedDigest(expectedDigest); if (File.Exists(fullPath) && string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(normalizedDigest)) { var existingDigest = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false); if (string.Equals(existingDigest, normalizedDigest, StringComparison.OrdinalIgnoreCase)) { var info = new FileInfo(fullPath); _logger.LogDebug("Export {ExportId} already present at {Path}; digest matches.", exportId, fullPath); return new ExcititorExportDownloadResult(fullPath, info.Length, true); } } var encodedId = Uri.EscapeDataString(exportId); using var request = CreateRequest(HttpMethod.Get, $"excititor/export/{encodedId}/download"); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); var tempPath = fullPath + ".tmp"; if (File.Exists(tempPath)) { File.Delete(tempPath); } 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 fileStream = File.Create(tempPath)) { await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } } if (!string.IsNullOrWhiteSpace(normalizedAlgorithm) && !string.IsNullOrWhiteSpace(normalizedDigest)) { if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase)) { var computed = await ComputeSha256Async(tempPath, cancellationToken).ConfigureAwait(false); if (!string.Equals(computed, normalizedDigest, StringComparison.OrdinalIgnoreCase)) { File.Delete(tempPath); throw new InvalidOperationException($"Export digest mismatch. Expected sha256:{normalizedDigest}, computed sha256:{computed}."); } } else { _logger.LogWarning("Export digest verification skipped. Unsupported algorithm {Algorithm}.", normalizedAlgorithm); } } if (File.Exists(fullPath)) { File.Delete(fullPath); } File.Move(tempPath, fullPath); var downloaded = new FileInfo(fullPath); return new ExcititorExportDownloadResult(fullPath, downloaded.Length, false); } public async Task EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken) { EnsureBackendConfigured(); if (request is null) { throw new ArgumentNullException(nameof(request)); } var images = NormalizeImages(request.Images); if (images.Count == 0) { throw new ArgumentException("At least one image digest must be provided.", nameof(request)); } var payload = new RuntimePolicyEvaluationRequestDocument { Namespace = string.IsNullOrWhiteSpace(request.Namespace) ? null : request.Namespace.Trim(), Images = images }; if (request.Labels.Count > 0) { payload.Labels = new Dictionary(StringComparer.Ordinal); foreach (var label in request.Labels) { if (!string.IsNullOrWhiteSpace(label.Key)) { payload.Labels[label.Key] = label.Value ?? string.Empty; } } } using var message = CreateRequest(HttpMethod.Post, "api/scanner/policy/runtime"); await AuthorizeRequestAsync(message, cancellationToken).ConfigureAwait(false); message.Content = JsonContent.Create(payload, options: SerializerOptions); 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); } RuntimePolicyEvaluationResponseDocument? document; try { document = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); } catch (JsonException ex) { var raw = response.Content is null ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); throw new InvalidOperationException($"Failed to parse runtime policy response. {ex.Message}", ex) { Data = { ["payload"] = raw } }; } if (document is null) { throw new InvalidOperationException("Runtime policy response was empty."); } var decisions = new Dictionary(StringComparer.Ordinal); if (document.Results is not null) { foreach (var kvp in document.Results) { var image = kvp.Key; var decision = kvp.Value; if (string.IsNullOrWhiteSpace(image) || decision is null) { continue; } var verdict = string.IsNullOrWhiteSpace(decision.PolicyVerdict) ? "unknown" : decision.PolicyVerdict!.Trim(); var reasons = ExtractReasons(decision.Reasons); var metadata = ExtractExtensionMetadata(decision.ExtensionData); var hasSbom = decision.HasSbomReferrers ?? decision.HasSbomLegacy; RuntimePolicyRekorReference? rekor = null; if (decision.Rekor is not null && (!string.IsNullOrWhiteSpace(decision.Rekor.Uuid) || !string.IsNullOrWhiteSpace(decision.Rekor.Url) || decision.Rekor.Verified.HasValue)) { rekor = new RuntimePolicyRekorReference( NormalizeOptionalString(decision.Rekor.Uuid), NormalizeOptionalString(decision.Rekor.Url), decision.Rekor.Verified); } decisions[image] = new RuntimePolicyImageDecision( verdict, decision.Signed, hasSbom, reasons, rekor, metadata); } } var decisionsView = new ReadOnlyDictionary(decisions); return new RuntimePolicyEvaluationResult( document.TtlSeconds ?? 0, document.ExpiresAtUtc?.ToUniversalTime(), string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision, decisionsView); } public async Task> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken) { EnsureBackendConfigured(); var query = includeDisabled ? "?includeDisabled=true" : string.Empty; using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}"); 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 Array.Empty(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); if (stream is null || stream.Length == 0) { return Array.Empty(); } using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = document.RootElement; if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("providers", out var providersProperty)) { root = providersProperty; } if (root.ValueKind != JsonValueKind.Array) { return Array.Empty(); } var list = new List(); foreach (var item in root.EnumerateArray()) { var id = GetStringProperty(item, "id") ?? string.Empty; if (string.IsNullOrWhiteSpace(id)) { continue; } var kind = GetStringProperty(item, "kind") ?? "unknown"; var displayName = GetStringProperty(item, "displayName") ?? id; var trustTier = GetStringProperty(item, "trustTier") ?? string.Empty; var enabled = GetBooleanProperty(item, "enabled", defaultValue: true); var lastIngested = GetDateTimeOffsetProperty(item, "lastIngestedAt"); list.Add(new ExcititorProviderSummary(id, kind, displayName, trustTier, enabled, lastIngested)); } return list; } public async Task 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 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(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 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()); } OfflineKitStatusTransport? document; try { document = await response.Content.ReadFromJsonAsync(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 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(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 MapOfflineComponents(List? transports) { if (transports is null || transports.Count == 0) { return Array.Empty(); } var list = new List(); 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() : 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 NormalizeImages(IReadOnlyList images) { var normalized = new List(); if (images is null) { return normalized; } var seen = new HashSet(StringComparer.Ordinal); foreach (var entry in images) { if (string.IsNullOrWhiteSpace(entry)) { continue; } var trimmed = entry.Trim(); if (seen.Add(trimmed)) { normalized.Add(trimmed); } } return normalized; } private static IReadOnlyList ExtractReasons(List? reasons) { if (reasons is null || reasons.Count == 0) { return Array.Empty(); } var list = new List(); foreach (var reason in reasons) { if (!string.IsNullOrWhiteSpace(reason)) { list.Add(reason.Trim()); } } return list.Count == 0 ? Array.Empty() : list; } private static IReadOnlyDictionary ExtractExtensionMetadata(Dictionary? extensionData) { if (extensionData is null || extensionData.Count == 0) { return EmptyMetadata; } var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kvp in extensionData) { var value = ConvertJsonElementToObject(kvp.Value); if (value is not null) { metadata[kvp.Key] = value; } } if (metadata.Count == 0) { return EmptyMetadata; } return new ReadOnlyDictionary(metadata); } private static object? ConvertJsonElementToObject(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Number when element.TryGetInt64(out var integer) => integer, JsonValueKind.Number when element.TryGetDouble(out var @double) => @double, JsonValueKind.Null or JsonValueKind.Undefined => null, _ => element.GetRawText() }; } private static string? NormalizeOptionalString(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri) { if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri)) { throw new InvalidOperationException($"Invalid request URI '{relativeUri}'."); } if (requestUri.IsAbsoluteUri) { // Nothing to normalize. } else { requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative); } return new HttpRequestMessage(method, requestUri); } private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } private async Task ResolveAccessTokenAsync(CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(_options.ApiKey)) { return _options.ApiKey; } if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url)) { return null; } var now = DateTimeOffset.UtcNow; lock (_tokenSync) { if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew) { return _cachedAccessToken; } } var cacheKey = AuthorityTokenUtilities.BuildCacheKey(_options); var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew) { lock (_tokenSync) { _cachedAccessToken = cachedEntry.AccessToken; _cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc; return _cachedAccessToken; } } var scope = AuthorityTokenUtilities.ResolveScope(_options); StellaOpsTokenResult token; if (!string.IsNullOrWhiteSpace(_options.Authority.Username)) { if (string.IsNullOrWhiteSpace(_options.Authority.Password)) { throw new InvalidOperationException("Authority password must be configured when username is provided."); } token = await _tokenClient.RequestPasswordTokenAsync( _options.Authority.Username, _options.Authority.Password!, scope, cancellationToken).ConfigureAwait(false); } else { token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false); } await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false); lock (_tokenSync) { _cachedAccessToken = token.AccessToken; _cachedAccessTokenExpiresAt = token.ExpiresAtUtc; return _cachedAccessToken; } } private async Task<(string Message, JsonElement? Payload)> ExtractExcititorResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response.Content is null || response.Content.Headers.ContentLength is 0) { return ($"HTTP {(int)response.StatusCode}", null); } try { await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); if (stream is null || stream.Length == 0) { return ($"HTTP {(int)response.StatusCode}", null); } using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = document.RootElement.Clone(); string? message = null; if (root.ValueKind == JsonValueKind.Object) { message = GetStringProperty(root, "message") ?? GetStringProperty(root, "status"); } if (string.IsNullOrWhiteSpace(message)) { message = root.ValueKind == JsonValueKind.Object || root.ValueKind == JsonValueKind.Array ? root.ToString() : root.GetRawText(); } return (message ?? $"HTTP {(int)response.StatusCode}", root); } catch (JsonException) { var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return (string.IsNullOrWhiteSpace(text) ? $"HTTP {(int)response.StatusCode}" : text.Trim(), null); } } private static bool TryGetPropertyCaseInsensitive(JsonElement element, string propertyName, out JsonElement property) { if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property)) { return true; } if (element.ValueKind == JsonValueKind.Object) { foreach (var candidate in element.EnumerateObject()) { if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase)) { property = candidate.Value; return true; } } } property = default; return false; } private static string? GetStringProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { if (property.ValueKind == JsonValueKind.String) { return property.GetString(); } } return null; } private static bool GetBooleanProperty(JsonElement element, string propertyName, bool defaultValue) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)) { return property.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.String when bool.TryParse(property.GetString(), out var parsed) => parsed, _ => defaultValue }; } return defaultValue; } private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName) { if (TryGetPropertyCaseInsensitive(element, propertyName, out var property) && property.ValueKind == JsonValueKind.String) { if (DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) { return parsed.ToUniversalTime(); } } return null; } private void EnsureBackendConfigured() { if (_httpClient.BaseAddress is null) { throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings."); } } private string ResolveArtifactPath(string outputPath, string channel) { if (!string.IsNullOrWhiteSpace(outputPath)) { return Path.GetFullPath(outputPath); } var directory = string.IsNullOrWhiteSpace(_options.ScannerCacheDirectory) ? Directory.GetCurrentDirectory() : Path.GetFullPath(_options.ScannerCacheDirectory); Directory.CreateDirectory(directory); var fileName = $"stellaops-scanner-{channel}.tar.gz"; return Path.Combine(directory, fileName); } private async Task CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var statusCode = (int)response.StatusCode; var builder = new StringBuilder(); builder.Append("Backend request failed with status "); builder.Append(statusCode); builder.Append(' '); builder.Append(response.ReasonPhrase ?? "Unknown"); if (response.Content.Headers.ContentLength is > 0) { try { var problem = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); if (problem is not null) { if (!string.IsNullOrWhiteSpace(problem.Title)) { builder.AppendLine().Append(problem.Title); } if (!string.IsNullOrWhiteSpace(problem.Detail)) { builder.AppendLine().Append(problem.Detail); } } } catch (JsonException) { var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(raw)) { builder.AppendLine().Append(raw); } } } return builder.ToString(); } private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name) { if (headers.TryGetValues(name, out var values)) { return values.FirstOrDefault(); } return null; } private static string? NormalizeExpectedDigest(string? digest) { if (string.IsNullOrWhiteSpace(digest)) { return null; } var trimmed = digest.Trim(); return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? trimmed[7..] : trimmed; } private async Task ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken) { string digestHex; await using (var stream = File.OpenRead(filePath)) { var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); digestHex = Convert.ToHexString(hash).ToLowerInvariant(); } if (!string.IsNullOrWhiteSpace(expectedDigest)) { var normalized = NormalizeDigest(expectedDigest); if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase)) { File.Delete(filePath); throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}."); } } else { _logger.LogWarning("Scanner download missing X-StellaOps-Digest header; relying on computed digest only."); } return digestHex; } private static string NormalizeDigest(string digest) { if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)) { return digest[7..]; } return digest; } private static async Task ComputeSha256Async(string filePath, CancellationToken cancellationToken) { await using var stream = File.OpenRead(filePath); var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); return Convert.ToHexString(hash).ToLowerInvariant(); } private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_options.ScannerSignaturePublicKeyPath)) { if (!string.IsNullOrWhiteSpace(signatureHeader)) { _logger.LogDebug("Signature header present but no public key configured; skipping validation."); } return; } if (string.IsNullOrWhiteSpace(signatureHeader)) { throw new InvalidOperationException("Scanner signature missing while a public key is configured."); } var publicKeyPath = Path.GetFullPath(_options.ScannerSignaturePublicKeyPath); if (!File.Exists(publicKeyPath)) { throw new FileNotFoundException("Scanner signature public key not found.", publicKeyPath); } var signatureBytes = Convert.FromBase64String(signatureHeader); var digestBytes = Convert.FromHexString(digestHex); var pem = await File.ReadAllTextAsync(publicKeyPath, cancellationToken).ConfigureAwait(false); using var rsa = RSA.Create(); rsa.ImportFromPem(pem); var valid = rsa.VerifyHash(digestBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); if (!valid) { throw new InvalidOperationException("Scanner signature validation failed."); } if (verbose) { _logger.LogDebug("Scanner signature validated using key {KeyPath}.", publicKeyPath); } } private void PersistMetadata(string outputPath, string channel, string digestHex, string? signatureHeader, HttpResponseMessage response) { var metadata = new { channel, digest = $"sha256:{digestHex}", signature = signatureHeader, downloadedAt = DateTimeOffset.UtcNow, source = response.RequestMessage?.RequestUri?.ToString(), sizeBytes = new FileInfo(outputPath).Length, headers = new { etag = response.Headers.ETag?.Tag, lastModified = response.Content.Headers.LastModified, contentType = response.Content.Headers.ContentType?.ToString() } }; var metadataPath = outputPath + ".metadata.json"; var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(metadataPath, json); } private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) { if (response.Headers.TryGetValues("Retry-After", out var retryValues)) { var value = retryValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(value)) { if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0) { return TimeSpan.FromSeconds(Math.Min(seconds, 300)); } if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var when)) { var delta = when - DateTimeOffset.UtcNow; if (delta > TimeSpan.Zero) { return delta < TimeSpan.FromMinutes(5) ? delta : TimeSpan.FromMinutes(5); } } } } var fallbackSeconds = Math.Min(60, Math.Pow(2, attempt)); return TimeSpan.FromSeconds(fallbackSeconds); } }