- 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.
1729 lines
68 KiB
C#
1729 lines
68 KiB
C#
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<string, object?> EmptyMetadata =
|
|
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly StellaOpsCliOptions _options;
|
|
private readonly ILogger<BackendOperationsClient> _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<BackendOperationsClient> 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<ScannerArtifactResult> 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<ScannerArtifactResult> 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<JobTriggerResult> TriggerJobAsync(string jobKind, IDictionary<string, object?> 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<string, object?>(StringComparer.Ordinal) : new Dictionary<string, object?>(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<JobRunResponse>(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<ExcititorOperationResult> 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<ExcititorExportDownloadResult> 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<RuntimePolicyEvaluationResult> 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<string, string>(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<RuntimePolicyEvaluationResponseDocument>(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<string, RuntimePolicyImageDecision>(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<string, RuntimePolicyImageDecision>(decisions);
|
|
|
|
return new RuntimePolicyEvaluationResult(
|
|
document.TtlSeconds ?? 0,
|
|
document.ExpiresAtUtc?.ToUniversalTime(),
|
|
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
|
|
decisionsView);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ExcititorProviderSummary>> 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<ExcititorProviderSummary>();
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
if (stream is null || stream.Length == 0)
|
|
{
|
|
return Array.Empty<ExcititorProviderSummary>();
|
|
}
|
|
|
|
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<ExcititorProviderSummary>();
|
|
}
|
|
|
|
var list = new List<ExcititorProviderSummary>();
|
|
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<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)
|
|
{
|
|
return normalized;
|
|
}
|
|
|
|
var seen = new HashSet<string>(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<string> ExtractReasons(List<string>? reasons)
|
|
{
|
|
if (reasons is null || reasons.Count == 0)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
var list = new List<string>();
|
|
foreach (var reason in reasons)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(reason))
|
|
{
|
|
list.Add(reason.Trim());
|
|
}
|
|
}
|
|
|
|
return list.Count == 0 ? Array.Empty<string>() : list;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, object?> ExtractExtensionMetadata(Dictionary<string, JsonElement>? extensionData)
|
|
{
|
|
if (extensionData is null || extensionData.Count == 0)
|
|
{
|
|
return EmptyMetadata;
|
|
}
|
|
|
|
var metadata = new Dictionary<string, object?>(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<string, object?>(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<string?> 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<string> 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<ProblemDocument>(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<string> 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<string> 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);
|
|
}
|
|
}
|