using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Core.Utility; using StellaOps.Scanner.Emit.Pedigree; namespace StellaOps.Scanner.Emit.Composition; public sealed record ImageArtifactDescriptor { public string ImageDigest { get; init; } = string.Empty; public string? ImageReference { get; init; } = null; public string? Repository { get; init; } = null; public string? Tag { get; init; } = null; public string? Architecture { get; init; } = null; } public sealed record SbomCompositionRequest { public required ImageArtifactDescriptor Image { get; init; } public required ImmutableArray LayerFragments { get; init; } public DateTimeOffset GeneratedAt { get; init; } = ScannerTimestamps.UtcNow(); public string? GeneratorName { get; init; } = null; public string? GeneratorVersion { get; init; } = null; public IReadOnlyDictionary? AdditionalProperties { get; init; } = null; public ImmutableArray PolicyFindings { get; init; } = ImmutableArray.Empty; /// /// Gets the pre-fetched pedigree data keyed by component PURL. /// This enables synchronous composition while allowing async pedigree lookups /// to happen before calling . /// Sprint: SPRINT_20260107_005_002 Task PD-009 /// public IReadOnlyDictionary? PedigreeDataByPurl { get; init; } = null; /// /// Gets whether pedigree data should be included in the SBOM. /// Defaults to true if pedigree data is provided. /// public bool IncludePedigree { get; init; } = true; public static SbomCompositionRequest Create( ImageArtifactDescriptor image, IEnumerable fragments, DateTimeOffset generatedAt, string? generatorName = null, string? generatorVersion = null, IReadOnlyDictionary? properties = null, IEnumerable? policyFindings = null, IReadOnlyDictionary? pedigreeData = null, bool includePedigree = true) { ArgumentNullException.ThrowIfNull(image); ArgumentNullException.ThrowIfNull(fragments); var normalizedImage = new ImageArtifactDescriptor { ImageDigest = ScannerIdentifiers.NormalizeDigest(image.ImageDigest) ?? throw new ArgumentException("Image digest is required.", nameof(image)), ImageReference = Normalize(image.ImageReference), Repository = Normalize(image.Repository), Tag = Normalize(image.Tag), Architecture = Normalize(image.Architecture), }; return new SbomCompositionRequest { Image = normalizedImage, LayerFragments = fragments.ToImmutableArray(), GeneratedAt = ScannerTimestamps.Normalize(generatedAt), GeneratorName = Normalize(generatorName), GeneratorVersion = Normalize(generatorVersion), AdditionalProperties = properties, PolicyFindings = NormalizePolicyFindings(policyFindings), PedigreeDataByPurl = pedigreeData, IncludePedigree = includePedigree, }; } private static string? Normalize(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return value.Trim(); } private static ImmutableArray NormalizePolicyFindings(IEnumerable? policyFindings) { if (policyFindings is null) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); foreach (var finding in policyFindings) { if (finding is null) { continue; } SbomPolicyFinding normalized; try { normalized = finding.Normalize(); } catch (ArgumentException) { continue; } if (string.IsNullOrWhiteSpace(normalized.FindingId) || string.IsNullOrWhiteSpace(normalized.ComponentKey)) { continue; } builder.Add(normalized); } if (builder.Count == 0) { return ImmutableArray.Empty; } return builder .ToImmutable() .OrderBy(static finding => finding.FindingId, StringComparer.Ordinal) .ThenBy(static finding => finding.ComponentKey, StringComparer.Ordinal) .ThenBy(static finding => finding.VulnerabilityId ?? string.Empty, StringComparer.Ordinal) .ToImmutableArray(); } }