Add tests and implement StubBearer authentication for Signer endpoints

- Created SignerEndpointsTests to validate the SignDsse and VerifyReferrers endpoints.
- Implemented StubBearerAuthenticationDefaults and StubBearerAuthenticationHandler for token-based authentication.
- Developed ConcelierExporterClient for managing Trivy DB settings and export operations.
- Added TrivyDbSettingsPageComponent for UI interactions with Trivy DB settings, including form handling and export triggering.
- Implemented styles and HTML structure for Trivy DB settings page.
- Created NotifySmokeCheck tool for validating Redis event streams and Notify deliveries.
This commit is contained in:
master
2025-10-21 09:37:07 +03:00
parent d6cb41dd51
commit 48f3071e2a
298 changed files with 20490 additions and 5751 deletions

View File

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