feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
		@@ -1,6 +1,7 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Buffers;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Collections.ObjectModel;
 | 
			
		||||
using System.Diagnostics;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.IO;
 | 
			
		||||
@@ -8,6 +9,7 @@ using System.Linq;
 | 
			
		||||
using System.Net.Http;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using System.Text.Json.Serialization;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
@@ -512,49 +514,213 @@ internal static class CommandHandlers
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Task HandleExcititorExportAsync(
 | 
			
		||||
    public static async Task HandleExcititorExportAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string format,
 | 
			
		||||
        bool delta,
 | 
			
		||||
        string? scope,
 | 
			
		||||
        DateTimeOffset? since,
 | 
			
		||||
        string? provider,
 | 
			
		||||
        string? outputPath,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
        {
 | 
			
		||||
            ["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
 | 
			
		||||
            ["delta"] = delta
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        await using var scopeHandle = services.CreateAsyncScope();
 | 
			
		||||
        var client = scopeHandle.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scopeHandle.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("excititor-export");
 | 
			
		||||
        var options = scopeHandle.ServiceProvider.GetRequiredService<StellaOpsCliOptions>();
 | 
			
		||||
        var verbosity = scopeHandle.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.excititor.export", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "excititor export");
 | 
			
		||||
        activity?.SetTag("stellaops.cli.format", format);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.delta", delta);
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(scope))
 | 
			
		||||
        {
 | 
			
		||||
            payload["scope"] = scope.Trim();
 | 
			
		||||
            activity?.SetTag("stellaops.cli.scope", scope);
 | 
			
		||||
        }
 | 
			
		||||
        if (since.HasValue)
 | 
			
		||||
        {
 | 
			
		||||
            payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
 | 
			
		||||
            activity?.SetTag("stellaops.cli.since", since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
 | 
			
		||||
        }
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(provider))
 | 
			
		||||
        {
 | 
			
		||||
            payload["provider"] = provider.Trim();
 | 
			
		||||
            activity?.SetTag("stellaops.cli.provider", provider);
 | 
			
		||||
        }
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(outputPath))
 | 
			
		||||
        {
 | 
			
		||||
            activity?.SetTag("stellaops.cli.output", outputPath);
 | 
			
		||||
        }
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("excititor export");
 | 
			
		||||
 | 
			
		||||
        return ExecuteExcititorCommandAsync(
 | 
			
		||||
            services,
 | 
			
		||||
            commandName: "excititor export",
 | 
			
		||||
            verbose,
 | 
			
		||||
            new Dictionary<string, object?>
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
            {
 | 
			
		||||
                ["format"] = payload["format"],
 | 
			
		||||
                ["delta"] = delta,
 | 
			
		||||
                ["scope"] = scope,
 | 
			
		||||
                ["since"] = since?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
 | 
			
		||||
                ["provider"] = provider
 | 
			
		||||
            },
 | 
			
		||||
            client => client.ExecuteExcititorOperationAsync("export", HttpMethod.Post, RemoveNullValues(payload), cancellationToken),
 | 
			
		||||
            cancellationToken);
 | 
			
		||||
                ["format"] = string.IsNullOrWhiteSpace(format) ? "openvex" : format.Trim(),
 | 
			
		||||
                ["delta"] = delta
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(scope))
 | 
			
		||||
            {
 | 
			
		||||
                payload["scope"] = scope.Trim();
 | 
			
		||||
            }
 | 
			
		||||
            if (since.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                payload["since"] = since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
 | 
			
		||||
            }
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(provider))
 | 
			
		||||
            {
 | 
			
		||||
                payload["provider"] = provider.Trim();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var result = await client.ExecuteExcititorOperationAsync(
 | 
			
		||||
                "export",
 | 
			
		||||
                HttpMethod.Post,
 | 
			
		||||
                RemoveNullValues(payload),
 | 
			
		||||
                cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (!result.Success)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(string.IsNullOrWhiteSpace(result.Message) ? "Excititor export failed." : result.Message);
 | 
			
		||||
                Environment.ExitCode = 1;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
 | 
			
		||||
            var manifest = TryParseExportManifest(result.Payload);
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(result.Message)
 | 
			
		||||
                && (manifest is null || !string.Equals(result.Message, "ok", StringComparison.OrdinalIgnoreCase)))
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation(result.Message);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (manifest is not null)
 | 
			
		||||
            {
 | 
			
		||||
                activity?.SetTag("stellaops.cli.export_id", manifest.ExportId);
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(manifest.Format))
 | 
			
		||||
                {
 | 
			
		||||
                    activity?.SetTag("stellaops.cli.export_format", manifest.Format);
 | 
			
		||||
                }
 | 
			
		||||
                if (manifest.FromCache.HasValue)
 | 
			
		||||
                {
 | 
			
		||||
                    activity?.SetTag("stellaops.cli.export_cached", manifest.FromCache.Value);
 | 
			
		||||
                }
 | 
			
		||||
                if (manifest.SizeBytes.HasValue)
 | 
			
		||||
                {
 | 
			
		||||
                    activity?.SetTag("stellaops.cli.export_size", manifest.SizeBytes.Value);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (manifest.FromCache == true)
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogInformation("Reusing cached export {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown");
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogInformation("Export ready: {ExportId} ({Format}).", manifest.ExportId, manifest.Format ?? "unknown");
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (manifest.CreatedAt.HasValue)
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogInformation("Created at {CreatedAt}.", manifest.CreatedAt.Value.ToString("u", CultureInfo.InvariantCulture));
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(manifest.Digest))
 | 
			
		||||
                {
 | 
			
		||||
                    var digestDisplay = BuildDigestDisplay(manifest.Algorithm, manifest.Digest);
 | 
			
		||||
                    if (manifest.SizeBytes.HasValue)
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Digest {Digest} ({Size}).", digestDisplay, FormatSize(manifest.SizeBytes.Value));
 | 
			
		||||
                    }
 | 
			
		||||
                    else
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Digest {Digest}.", digestDisplay);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(manifest.RekorLocation))
 | 
			
		||||
                {
 | 
			
		||||
                    if (!string.IsNullOrWhiteSpace(manifest.RekorIndex))
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Rekor entry: {Location} (index {Index}).", manifest.RekorLocation, manifest.RekorIndex);
 | 
			
		||||
                    }
 | 
			
		||||
                    else
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Rekor entry: {Location}.", manifest.RekorLocation);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(manifest.RekorInclusionUrl)
 | 
			
		||||
                    && !string.Equals(manifest.RekorInclusionUrl, manifest.RekorLocation, StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogInformation("Rekor inclusion proof: {Url}.", manifest.RekorInclusionUrl);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(outputPath))
 | 
			
		||||
                {
 | 
			
		||||
                    var resolvedPath = ResolveExportOutputPath(outputPath!, manifest);
 | 
			
		||||
                    var download = await client.DownloadExcititorExportAsync(
 | 
			
		||||
                        manifest.ExportId,
 | 
			
		||||
                        resolvedPath,
 | 
			
		||||
                        manifest.Algorithm,
 | 
			
		||||
                        manifest.Digest,
 | 
			
		||||
                        cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
                    activity?.SetTag("stellaops.cli.export_path", download.Path);
 | 
			
		||||
 | 
			
		||||
                    if (download.FromCache)
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Export already cached at {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes));
 | 
			
		||||
                    }
 | 
			
		||||
                    else
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Export saved to {Path} ({Size}).", download.Path, FormatSize(download.SizeBytes));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                else if (!string.IsNullOrWhiteSpace(result.Location))
 | 
			
		||||
                {
 | 
			
		||||
                    var downloadUrl = ResolveLocationUrl(options, result.Location);
 | 
			
		||||
                    if (!string.IsNullOrWhiteSpace(downloadUrl))
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Download URL: {Url}", downloadUrl);
 | 
			
		||||
                    }
 | 
			
		||||
                    else
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Download location: {Location}", result.Location);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(result.Location))
 | 
			
		||||
                {
 | 
			
		||||
                    var downloadUrl = ResolveLocationUrl(options, result.Location);
 | 
			
		||||
                    if (!string.IsNullOrWhiteSpace(downloadUrl))
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Download URL: {Url}", downloadUrl);
 | 
			
		||||
                    }
 | 
			
		||||
                    else
 | 
			
		||||
                    {
 | 
			
		||||
                        logger.LogInformation("Location: {Location}", result.Location);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                else if (string.IsNullOrWhiteSpace(result.Message))
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogInformation("Export request accepted.");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Excititor export failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Task HandleExcititorVerifyAsync(
 | 
			
		||||
@@ -646,6 +812,106 @@ internal static class CommandHandlers
 | 
			
		||||
            cancellationToken);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleRuntimePolicyTestAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        string? namespaceValue,
 | 
			
		||||
        IReadOnlyList<string> imageArguments,
 | 
			
		||||
        string? filePath,
 | 
			
		||||
        IReadOnlyList<string> labelArguments,
 | 
			
		||||
        bool outputJson,
 | 
			
		||||
        bool verbose,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        await using var scope = services.CreateAsyncScope();
 | 
			
		||||
        var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
 | 
			
		||||
        var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("runtime-policy-test");
 | 
			
		||||
        var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
 | 
			
		||||
        var previousLevel = verbosity.MinimumLevel;
 | 
			
		||||
        verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
 | 
			
		||||
        using var activity = CliActivitySource.Instance.StartActivity("cli.runtime.policy.test", ActivityKind.Client);
 | 
			
		||||
        activity?.SetTag("stellaops.cli.command", "runtime policy test");
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(namespaceValue))
 | 
			
		||||
        {
 | 
			
		||||
            activity?.SetTag("stellaops.cli.namespace", namespaceValue);
 | 
			
		||||
        }
 | 
			
		||||
        using var duration = CliMetrics.MeasureCommandDuration("runtime policy test");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            IReadOnlyList<string> images;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                images = await GatherImageDigestsAsync(imageArguments, filePath, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or FileNotFoundException)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(ex, "Failed to gather image digests: {Message}", ex.Message);
 | 
			
		||||
                Environment.ExitCode = 9;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (images.Count == 0)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError("No image digests provided. Use --image, --file, or pipe digests via stdin.");
 | 
			
		||||
                Environment.ExitCode = 9;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            IReadOnlyDictionary<string, string> labels;
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                labels = ParseLabelSelectors(labelArguments);
 | 
			
		||||
            }
 | 
			
		||||
            catch (ArgumentException ex)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogError(ex.Message);
 | 
			
		||||
                Environment.ExitCode = 9;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            activity?.SetTag("stellaops.cli.images", images.Count);
 | 
			
		||||
            activity?.SetTag("stellaops.cli.labels", labels.Count);
 | 
			
		||||
 | 
			
		||||
            var request = new RuntimePolicyEvaluationRequest(namespaceValue, labels, images);
 | 
			
		||||
            var result = await client.EvaluateRuntimePolicyAsync(request, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            activity?.SetTag("stellaops.cli.ttl_seconds", result.TtlSeconds);
 | 
			
		||||
            Environment.ExitCode = 0;
 | 
			
		||||
 | 
			
		||||
            if (outputJson)
 | 
			
		||||
            {
 | 
			
		||||
                var json = BuildRuntimePolicyJson(result, images);
 | 
			
		||||
                Console.WriteLine(json);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (result.ExpiresAtUtc.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Decision TTL: {TtlSeconds}s (expires {ExpiresAt})", result.TtlSeconds, result.ExpiresAtUtc.Value.ToString("u", CultureInfo.InvariantCulture));
 | 
			
		||||
            }
 | 
			
		||||
            else
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Decision TTL: {TtlSeconds}s", result.TtlSeconds);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(result.PolicyRevision))
 | 
			
		||||
            {
 | 
			
		||||
                logger.LogInformation("Policy revision: {Revision}", result.PolicyRevision);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            DisplayRuntimePolicyResults(logger, result, images);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogError(ex, "Runtime policy evaluation failed.");
 | 
			
		||||
            Environment.ExitCode = 1;
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            verbosity.MinimumLevel = previousLevel;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static async Task HandleAuthLoginAsync(
 | 
			
		||||
        IServiceProvider services,
 | 
			
		||||
        StellaOpsCliOptions options,
 | 
			
		||||
@@ -1485,6 +1751,617 @@ internal static class CommandHandlers
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static async Task<IReadOnlyList<string>> GatherImageDigestsAsync(
 | 
			
		||||
        IReadOnlyList<string> inline,
 | 
			
		||||
        string? filePath,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var results = new List<string>();
 | 
			
		||||
        var seen = new HashSet<string>(StringComparer.Ordinal);
 | 
			
		||||
 | 
			
		||||
        void AddCandidates(string? candidate)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var image in SplitImageCandidates(candidate))
 | 
			
		||||
            {
 | 
			
		||||
                if (seen.Add(image))
 | 
			
		||||
                {
 | 
			
		||||
                    results.Add(image);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (inline is not null)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var entry in inline)
 | 
			
		||||
            {
 | 
			
		||||
                AddCandidates(entry);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(filePath))
 | 
			
		||||
        {
 | 
			
		||||
            var path = Path.GetFullPath(filePath);
 | 
			
		||||
            if (!File.Exists(path))
 | 
			
		||||
            {
 | 
			
		||||
                throw new FileNotFoundException("Input file not found.", path);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            foreach (var line in File.ReadLines(path))
 | 
			
		||||
            {
 | 
			
		||||
                cancellationToken.ThrowIfCancellationRequested();
 | 
			
		||||
                AddCandidates(line);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (Console.IsInputRedirected)
 | 
			
		||||
        {
 | 
			
		||||
            while (!cancellationToken.IsCancellationRequested)
 | 
			
		||||
            {
 | 
			
		||||
                var line = await Console.In.ReadLineAsync().ConfigureAwait(false);
 | 
			
		||||
                if (line is null)
 | 
			
		||||
                {
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                AddCandidates(line);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new ReadOnlyCollection<string>(results);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IEnumerable<string> SplitImageCandidates(string? raw)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(raw))
 | 
			
		||||
        {
 | 
			
		||||
            yield break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var candidate = raw.Trim();
 | 
			
		||||
        var commentIndex = candidate.IndexOf('#');
 | 
			
		||||
        if (commentIndex >= 0)
 | 
			
		||||
        {
 | 
			
		||||
            candidate = candidate[..commentIndex].Trim();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (candidate.Length == 0)
 | 
			
		||||
        {
 | 
			
		||||
            yield break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var tokens = candidate.Split(new[] { ',', ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
 | 
			
		||||
        foreach (var token in tokens)
 | 
			
		||||
        {
 | 
			
		||||
            var trimmed = token.Trim();
 | 
			
		||||
            if (trimmed.Length > 0)
 | 
			
		||||
            {
 | 
			
		||||
                yield return trimmed;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyDictionary<string, string> ParseLabelSelectors(IReadOnlyList<string> labelArguments)
 | 
			
		||||
    {
 | 
			
		||||
        if (labelArguments is null || labelArguments.Count == 0)
 | 
			
		||||
        {
 | 
			
		||||
            return EmptyLabelSelectors;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
        foreach (var raw in labelArguments)
 | 
			
		||||
        {
 | 
			
		||||
            if (string.IsNullOrWhiteSpace(raw))
 | 
			
		||||
            {
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var trimmed = raw.Trim();
 | 
			
		||||
            var delimiter = trimmed.IndexOf('=');
 | 
			
		||||
            if (delimiter <= 0 || delimiter == trimmed.Length - 1)
 | 
			
		||||
            {
 | 
			
		||||
                throw new ArgumentException($"Invalid label '{raw}'. Expected key=value format.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var key = trimmed[..delimiter].Trim();
 | 
			
		||||
            var value = trimmed[(delimiter + 1)..].Trim();
 | 
			
		||||
            if (key.Length == 0)
 | 
			
		||||
            {
 | 
			
		||||
                throw new ArgumentException($"Invalid label '{raw}'. Label key cannot be empty.");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            labels[key] = value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return labels.Count == 0 ? EmptyLabelSelectors : new ReadOnlyDictionary<string, string>(labels);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record ExcititorExportManifestSummary(
 | 
			
		||||
        string ExportId,
 | 
			
		||||
        string? Format,
 | 
			
		||||
        string? Algorithm,
 | 
			
		||||
        string? Digest,
 | 
			
		||||
        long? SizeBytes,
 | 
			
		||||
        bool? FromCache,
 | 
			
		||||
        DateTimeOffset? CreatedAt,
 | 
			
		||||
        string? RekorLocation,
 | 
			
		||||
        string? RekorIndex,
 | 
			
		||||
        string? RekorInclusionUrl);
 | 
			
		||||
 | 
			
		||||
    private static ExcititorExportManifestSummary? TryParseExportManifest(JsonElement? payload)
 | 
			
		||||
    {
 | 
			
		||||
        if (payload is null || payload.Value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var element = payload.Value;
 | 
			
		||||
        var exportId = GetStringProperty(element, "exportId");
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(exportId))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var format = GetStringProperty(element, "format");
 | 
			
		||||
        var algorithm = default(string?);
 | 
			
		||||
        var digest = default(string?);
 | 
			
		||||
 | 
			
		||||
        if (TryGetPropertyCaseInsensitive(element, "artifact", out var artifact) && artifact.ValueKind == JsonValueKind.Object)
 | 
			
		||||
        {
 | 
			
		||||
            algorithm = GetStringProperty(artifact, "algorithm");
 | 
			
		||||
            digest = GetStringProperty(artifact, "digest");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var sizeBytes = GetInt64Property(element, "sizeBytes");
 | 
			
		||||
        var fromCache = GetBooleanProperty(element, "fromCache");
 | 
			
		||||
        var createdAt = GetDateTimeOffsetProperty(element, "createdAt");
 | 
			
		||||
 | 
			
		||||
        string? rekorLocation = null;
 | 
			
		||||
        string? rekorIndex = null;
 | 
			
		||||
        string? rekorInclusion = null;
 | 
			
		||||
 | 
			
		||||
        if (TryGetPropertyCaseInsensitive(element, "attestation", out var attestation) && attestation.ValueKind == JsonValueKind.Object)
 | 
			
		||||
        {
 | 
			
		||||
            if (TryGetPropertyCaseInsensitive(attestation, "rekor", out var rekor) && rekor.ValueKind == JsonValueKind.Object)
 | 
			
		||||
            {
 | 
			
		||||
                rekorLocation = GetStringProperty(rekor, "location");
 | 
			
		||||
                rekorIndex = GetStringProperty(rekor, "logIndex");
 | 
			
		||||
                var inclusion = GetStringProperty(rekor, "inclusionProofUri");
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(inclusion))
 | 
			
		||||
                {
 | 
			
		||||
                    rekorInclusion = inclusion;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new ExcititorExportManifestSummary(
 | 
			
		||||
            exportId.Trim(),
 | 
			
		||||
            format,
 | 
			
		||||
            algorithm,
 | 
			
		||||
            digest,
 | 
			
		||||
            sizeBytes,
 | 
			
		||||
            fromCache,
 | 
			
		||||
            createdAt,
 | 
			
		||||
            rekorLocation,
 | 
			
		||||
            rekorIndex,
 | 
			
		||||
            rekorInclusion);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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))
 | 
			
		||||
        {
 | 
			
		||||
            return property.ValueKind switch
 | 
			
		||||
            {
 | 
			
		||||
                JsonValueKind.String => property.GetString(),
 | 
			
		||||
                JsonValueKind.Number => property.ToString(),
 | 
			
		||||
                _ => null
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool? GetBooleanProperty(JsonElement element, string propertyName)
 | 
			
		||||
    {
 | 
			
		||||
        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,
 | 
			
		||||
                _ => null
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static long? GetInt64Property(JsonElement element, string propertyName)
 | 
			
		||||
    {
 | 
			
		||||
        if (TryGetPropertyCaseInsensitive(element, propertyName, out var property))
 | 
			
		||||
        {
 | 
			
		||||
            if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
 | 
			
		||||
            {
 | 
			
		||||
                return value;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (property.ValueKind == JsonValueKind.String
 | 
			
		||||
                && long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
 | 
			
		||||
            {
 | 
			
		||||
                return parsed;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static DateTimeOffset? GetDateTimeOffsetProperty(JsonElement element, string propertyName)
 | 
			
		||||
    {
 | 
			
		||||
        if (TryGetPropertyCaseInsensitive(element, propertyName, out var property)
 | 
			
		||||
            && property.ValueKind == JsonValueKind.String
 | 
			
		||||
            && DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value))
 | 
			
		||||
        {
 | 
			
		||||
            return value.ToUniversalTime();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string BuildDigestDisplay(string? algorithm, string digest)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(digest))
 | 
			
		||||
        {
 | 
			
		||||
            return string.Empty;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (digest.Contains(':', StringComparison.Ordinal))
 | 
			
		||||
        {
 | 
			
		||||
            return digest;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(algorithm) || algorithm.Equals("sha256", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return $"sha256:{digest}";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $"{algorithm}:{digest}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string FormatSize(long sizeBytes)
 | 
			
		||||
    {
 | 
			
		||||
        if (sizeBytes < 0)
 | 
			
		||||
        {
 | 
			
		||||
            return $"{sizeBytes} bytes";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        string[] units = { "bytes", "KB", "MB", "GB", "TB" };
 | 
			
		||||
        double size = sizeBytes;
 | 
			
		||||
        var unit = 0;
 | 
			
		||||
 | 
			
		||||
        while (size >= 1024 && unit < units.Length - 1)
 | 
			
		||||
        {
 | 
			
		||||
            size /= 1024;
 | 
			
		||||
            unit++;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return unit == 0 ? $"{sizeBytes} bytes" : $"{size:0.##} {units[unit]}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string ResolveExportOutputPath(string outputPath, ExcititorExportManifestSummary manifest)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(outputPath))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Output path must be provided.", nameof(outputPath));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var fullPath = Path.GetFullPath(outputPath);
 | 
			
		||||
        if (Directory.Exists(fullPath)
 | 
			
		||||
            || outputPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
 | 
			
		||||
            || outputPath.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal))
 | 
			
		||||
        {
 | 
			
		||||
            return Path.Combine(fullPath, BuildExportFileName(manifest));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var directory = Path.GetDirectoryName(fullPath);
 | 
			
		||||
        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
 | 
			
		||||
        {
 | 
			
		||||
            Directory.CreateDirectory(directory);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return fullPath;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string BuildExportFileName(ExcititorExportManifestSummary manifest)
 | 
			
		||||
    {
 | 
			
		||||
        var token = !string.IsNullOrWhiteSpace(manifest.Digest)
 | 
			
		||||
            ? manifest.Digest!
 | 
			
		||||
            : manifest.ExportId;
 | 
			
		||||
 | 
			
		||||
        token = SanitizeToken(token);
 | 
			
		||||
        if (token.Length > 40)
 | 
			
		||||
        {
 | 
			
		||||
            token = token[..40];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var extension = DetermineExportExtension(manifest.Format);
 | 
			
		||||
        return $"stellaops-excititor-{token}{extension}";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string DetermineExportExtension(string? format)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(format))
 | 
			
		||||
        {
 | 
			
		||||
            return ".bin";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return format switch
 | 
			
		||||
        {
 | 
			
		||||
            not null when format.Equals("jsonl", StringComparison.OrdinalIgnoreCase) => ".jsonl",
 | 
			
		||||
            not null when format.Equals("json", StringComparison.OrdinalIgnoreCase) => ".json",
 | 
			
		||||
            not null when format.Equals("openvex", StringComparison.OrdinalIgnoreCase) => ".json",
 | 
			
		||||
            not null when format.Equals("csaf", StringComparison.OrdinalIgnoreCase) => ".json",
 | 
			
		||||
            _ => ".bin"
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string SanitizeToken(string token)
 | 
			
		||||
    {
 | 
			
		||||
        var builder = new StringBuilder(token.Length);
 | 
			
		||||
        foreach (var ch in token)
 | 
			
		||||
        {
 | 
			
		||||
            if (char.IsLetterOrDigit(ch))
 | 
			
		||||
            {
 | 
			
		||||
                builder.Append(char.ToLowerInvariant(ch));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (builder.Length == 0)
 | 
			
		||||
        {
 | 
			
		||||
            builder.Append("export");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return builder.ToString();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string? ResolveLocationUrl(StellaOpsCliOptions options, string location)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(location))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (Uri.TryCreate(location, UriKind.Absolute, out var absolute))
 | 
			
		||||
        {
 | 
			
		||||
            return absolute.ToString();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!string.IsNullOrWhiteSpace(options?.BackendUrl) && Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
 | 
			
		||||
        {
 | 
			
		||||
            if (!location.StartsWith("/", StringComparison.Ordinal))
 | 
			
		||||
            {
 | 
			
		||||
                location = "/" + location;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new Uri(baseUri, location).ToString();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return location;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string BuildRuntimePolicyJson(RuntimePolicyEvaluationResult result, IReadOnlyList<string> requestedImages)
 | 
			
		||||
    {
 | 
			
		||||
        var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
 | 
			
		||||
        var results = new Dictionary<string, object?>(StringComparer.Ordinal);
 | 
			
		||||
 | 
			
		||||
        foreach (var image in orderedImages)
 | 
			
		||||
        {
 | 
			
		||||
            if (result.Decisions.TryGetValue(image, out var decision))
 | 
			
		||||
            {
 | 
			
		||||
                results[image] = BuildDecisionMap(decision);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
 | 
			
		||||
        {
 | 
			
		||||
            WriteIndented = true,
 | 
			
		||||
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
        {
 | 
			
		||||
            ["ttlSeconds"] = result.TtlSeconds,
 | 
			
		||||
            ["expiresAtUtc"] = result.ExpiresAtUtc?.ToString("O", CultureInfo.InvariantCulture),
 | 
			
		||||
            ["policyRevision"] = result.PolicyRevision,
 | 
			
		||||
            ["results"] = results
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return JsonSerializer.Serialize(payload, options);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IDictionary<string, object?> BuildDecisionMap(RuntimePolicyImageDecision decision)
 | 
			
		||||
    {
 | 
			
		||||
        var map = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
        {
 | 
			
		||||
            ["policyVerdict"] = decision.PolicyVerdict,
 | 
			
		||||
            ["signed"] = decision.Signed,
 | 
			
		||||
            ["hasSbom"] = decision.HasSbom
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (decision.Reasons.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            map["reasons"] = decision.Reasons;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (decision.Rekor is not null)
 | 
			
		||||
        {
 | 
			
		||||
            map["rekor"] = new Dictionary<string, object?>(StringComparer.Ordinal)
 | 
			
		||||
            {
 | 
			
		||||
                ["uuid"] = decision.Rekor.Uuid,
 | 
			
		||||
                ["url"] = decision.Rekor.Url
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var kvp in decision.AdditionalProperties)
 | 
			
		||||
        {
 | 
			
		||||
            map[kvp.Key] = kvp.Value;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return map;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void DisplayRuntimePolicyResults(ILogger logger, RuntimePolicyEvaluationResult result, IReadOnlyList<string> requestedImages)
 | 
			
		||||
    {
 | 
			
		||||
        var orderedImages = BuildImageOrder(requestedImages, result.Decisions.Keys);
 | 
			
		||||
        var summary = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
 | 
			
		||||
 | 
			
		||||
        if (AnsiConsole.Profile.Capabilities.Interactive)
 | 
			
		||||
        {
 | 
			
		||||
            var table = new Table().Border(TableBorder.Rounded).AddColumns("Image", "Verdict", "Signed", "SBOM", "Reasons", "Attestation");
 | 
			
		||||
 | 
			
		||||
            foreach (var image in orderedImages)
 | 
			
		||||
            {
 | 
			
		||||
                if (result.Decisions.TryGetValue(image, out var decision))
 | 
			
		||||
                {
 | 
			
		||||
                    table.AddRow(
 | 
			
		||||
                        image,
 | 
			
		||||
                        decision.PolicyVerdict,
 | 
			
		||||
                        FormatBoolean(decision.Signed),
 | 
			
		||||
                        FormatBoolean(decision.HasSbom),
 | 
			
		||||
                        decision.Reasons.Count > 0 ? string.Join(Environment.NewLine, decision.Reasons) : "-",
 | 
			
		||||
                        string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!);
 | 
			
		||||
 | 
			
		||||
                    summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
 | 
			
		||||
 | 
			
		||||
                    if (decision.AdditionalProperties.Count > 0)
 | 
			
		||||
                    {
 | 
			
		||||
                        var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}"));
 | 
			
		||||
                        logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    table.AddRow(image, "<missing>", "-", "-", "-", "-");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            AnsiConsole.Write(table);
 | 
			
		||||
        }
 | 
			
		||||
        else
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var image in orderedImages)
 | 
			
		||||
            {
 | 
			
		||||
                if (result.Decisions.TryGetValue(image, out var decision))
 | 
			
		||||
                {
 | 
			
		||||
                    var reasons = decision.Reasons.Count > 0 ? string.Join(", ", decision.Reasons) : "none";
 | 
			
		||||
                    logger.LogInformation(
 | 
			
		||||
                        "{Image} -> verdict={Verdict} signed={Signed} sbom={Sbom} attestation={Attestation} reasons={Reasons}",
 | 
			
		||||
                        image,
 | 
			
		||||
                        decision.PolicyVerdict,
 | 
			
		||||
                        FormatBoolean(decision.Signed),
 | 
			
		||||
                        FormatBoolean(decision.HasSbom),
 | 
			
		||||
                        string.IsNullOrWhiteSpace(decision.Rekor?.Uuid) ? "-" : decision.Rekor!.Uuid!,
 | 
			
		||||
                        reasons);
 | 
			
		||||
 | 
			
		||||
                    summary[decision.PolicyVerdict] = summary.TryGetValue(decision.PolicyVerdict, out var count) ? count + 1 : 1;
 | 
			
		||||
 | 
			
		||||
                    if (decision.AdditionalProperties.Count > 0)
 | 
			
		||||
                    {
 | 
			
		||||
                        var metadata = string.Join(", ", decision.AdditionalProperties.Select(kvp => $"{kvp.Key}={FormatAdditionalValue(kvp.Value)}"));
 | 
			
		||||
                        logger.LogDebug("Metadata for {Image}: {Metadata}", image, metadata);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    logger.LogWarning("{Image} -> no decision returned by backend.", image);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (summary.Count > 0)
 | 
			
		||||
        {
 | 
			
		||||
            var summaryText = string.Join(", ", summary.Select(kvp => $"{kvp.Key}:{kvp.Value}"));
 | 
			
		||||
            logger.LogInformation("Verdict summary: {Summary}", summaryText);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<string> BuildImageOrder(IReadOnlyList<string> requestedImages, IEnumerable<string> actual)
 | 
			
		||||
    {
 | 
			
		||||
        var order = new List<string>();
 | 
			
		||||
        var seen = new HashSet<string>(StringComparer.Ordinal);
 | 
			
		||||
 | 
			
		||||
        if (requestedImages is not null)
 | 
			
		||||
        {
 | 
			
		||||
            foreach (var image in requestedImages)
 | 
			
		||||
            {
 | 
			
		||||
                if (!string.IsNullOrWhiteSpace(image))
 | 
			
		||||
                {
 | 
			
		||||
                    var trimmed = image.Trim();
 | 
			
		||||
                    if (seen.Add(trimmed))
 | 
			
		||||
                    {
 | 
			
		||||
                        order.Add(trimmed);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach (var image in actual)
 | 
			
		||||
        {
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(image))
 | 
			
		||||
            {
 | 
			
		||||
                var trimmed = image.Trim();
 | 
			
		||||
                if (seen.Add(trimmed))
 | 
			
		||||
                {
 | 
			
		||||
                    order.Add(trimmed);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return new ReadOnlyCollection<string>(order);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static string FormatBoolean(bool? value)
 | 
			
		||||
        => value is null ? "unknown" : value.Value ? "yes" : "no";
 | 
			
		||||
 | 
			
		||||
    private static string FormatAdditionalValue(object? value)
 | 
			
		||||
    {
 | 
			
		||||
        return value switch
 | 
			
		||||
        {
 | 
			
		||||
            null => "null",
 | 
			
		||||
            bool b => b ? "true" : "false",
 | 
			
		||||
            double d => d.ToString("G17", CultureInfo.InvariantCulture),
 | 
			
		||||
            float f => f.ToString("G9", CultureInfo.InvariantCulture),
 | 
			
		||||
            IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
 | 
			
		||||
            _ => value.ToString() ?? string.Empty
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static readonly IReadOnlyDictionary<string, string> EmptyLabelSelectors =
 | 
			
		||||
        new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(0, StringComparer.OrdinalIgnoreCase));
 | 
			
		||||
 | 
			
		||||
    private static IReadOnlyList<string> NormalizeProviders(IReadOnlyList<string> providers)
 | 
			
		||||
    {
 | 
			
		||||
        if (providers is null || providers.Count == 0)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user