using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.AdvisoryAI.Abstractions; using StellaOps.AdvisoryAI.Context; using StellaOps.AdvisoryAI.Providers; namespace StellaOps.AdvisoryAI.Retrievers; internal sealed class SbomContextRetriever : ISbomContextRetriever { private readonly ISbomContextClient _client; private readonly ILogger? _logger; public SbomContextRetriever(ISbomContextClient client, ILogger? logger = null) { _client = client ?? throw new ArgumentNullException(nameof(client)); _logger = logger; } public async Task RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var query = new SbomContextQuery( request.ArtifactId, request.Purl, request.MaxTimelineEntries, request.MaxDependencyPaths, request.IncludeEnvironmentFlags, request.IncludeBlastRadius); SbomContextDocument? document; try { document = await _client.GetContextAsync(query, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger?.LogError(ex, "Failed to retrieve SBOM context for artifact {ArtifactId}", request.ArtifactId); document = null; } if (document is null) { _logger?.LogWarning("No SBOM context returned for artifact {ArtifactId}", request.ArtifactId); return SbomContextResult.Empty(request.ArtifactId, request.Purl); } var timeline = ShapeTimeline(document.Versions, request.MaxTimelineEntries); var paths = ShapeDependencyPaths(document.DependencyPaths, request.MaxDependencyPaths); var environmentFlags = request.IncludeEnvironmentFlags ? NormalizeEnvironmentFlags(document.EnvironmentFlags) : ImmutableDictionary.Empty; var blastRadius = request.IncludeBlastRadius ? ShapeBlastRadius(document.BlastRadius) : null; var metadata = BuildMetadata(document, timeline.Count, paths.Count, environmentFlags.Count, blastRadius is not null); return SbomContextResult.Create( document.ArtifactId, document.Purl, timeline, paths, environmentFlags, blastRadius, metadata); } private static IReadOnlyList ShapeTimeline(ImmutableArray versions, int max) { if (versions.IsDefaultOrEmpty || max == 0) { return Array.Empty(); } return versions .OrderBy(static v => v.FirstObserved) .ThenBy(static v => v.Version, StringComparer.Ordinal) .Take(max > 0 ? max : int.MaxValue) .Select(static v => new SbomVersionTimelineEntry( v.Version, v.FirstObserved, v.LastObserved, string.IsNullOrWhiteSpace(v.Status) ? (v.IsFixAvailable ? "fixed" : "unknown") : v.Status.Trim(), string.IsNullOrWhiteSpace(v.Source) ? "sbom" : v.Source.Trim())) .ToImmutableArray(); } private static IReadOnlyList ShapeDependencyPaths(ImmutableArray paths, int max) { if (paths.IsDefaultOrEmpty || max == 0) { return Array.Empty(); } var distinct = new SortedDictionary(StringComparer.Ordinal); foreach (var path in paths) { if (path.Nodes.IsDefaultOrEmpty) { continue; } var nodeList = path.Nodes .Select(static node => new SbomDependencyNode(node.Identifier, node.Version)) .ToImmutableArray(); if (nodeList.IsDefaultOrEmpty) { continue; } var key = string.Join( "|", nodeList.Select(static n => string.Concat(n.Identifier, "@", n.Version ?? string.Empty))); if (distinct.ContainsKey(key)) { continue; } var dependencyPath = new SbomDependencyPath( nodeList, path.IsRuntime, string.IsNullOrWhiteSpace(path.Source) ? null : path.Source.Trim()); distinct[key] = dependencyPath; } return distinct.Values .OrderBy(p => p.IsRuntime ? 0 : 1) .ThenBy(p => p.Nodes.Length) .ThenBy(p => string.Join("|", p.Nodes.Select(n => n.Identifier)), StringComparer.Ordinal) .Take(max > 0 ? max : int.MaxValue) .ToImmutableArray(); } private static IReadOnlyDictionary NormalizeEnvironmentFlags(ImmutableDictionary flags) { if (flags == default || flags.IsEmpty) { return ImmutableDictionary.Empty; } var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var pair in flags) { if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null) { continue; } builder[pair.Key.Trim()] = pair.Value.Trim(); } return builder.ToImmutable(); } private static SbomBlastRadiusSummary? ShapeBlastRadius(SbomBlastRadiusRecord? record) { if (record is null) { return null; } var metadata = record.Metadata == default ? ImmutableDictionary.Empty : record.Metadata.ToImmutableDictionary(StringComparer.Ordinal); return new SbomBlastRadiusSummary( record.ImpactedAssets, record.ImpactedWorkloads, record.ImpactedNamespaces, record.ImpactedPercentage, metadata); } private static IReadOnlyDictionary BuildMetadata( SbomContextDocument document, int timelineCount, int pathCount, int environmentFlagCount, bool hasBlastRadius) { var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var pair in document.Metadata) { if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null) { continue; } builder[pair.Key.Trim()] = pair.Value.Trim(); } builder["version_count"] = timelineCount.ToString(); builder["dependency_path_count"] = pathCount.ToString(); builder["environment_flag_count"] = environmentFlagCount.ToString(); builder["blast_radius_present"] = hasBlastRadius.ToString(); return builder.ToImmutable(); } }