using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace StellaOps.Scanner.Emit.Lineage; /// /// Engine for computing semantic diffs between SBOM versions. /// public sealed class SbomDiffEngine { /// /// Computes the semantic diff between two SBOMs. /// public SbomDiff ComputeDiff( SbomId fromId, IReadOnlyList fromComponents, SbomId toId, IReadOnlyList toComponents) { var fromByPurl = fromComponents.ToDictionary(c => c.Purl, c => c); var toByPurl = toComponents.ToDictionary(c => c.Purl, c => c); var deltas = new List(); var added = 0; var removed = 0; var versionChanged = 0; var otherModified = 0; var unchanged = 0; var isBreaking = false; // Find added and modified components foreach (var (purl, toComp) in toByPurl) { if (!fromByPurl.TryGetValue(purl, out var fromComp)) { // Added deltas.Add(new ComponentDelta { Type = ComponentDeltaType.Added, After = toComp }); added++; } else { // Possibly modified var changedFields = CompareComponents(fromComp, toComp); if (changedFields.Length > 0) { var deltaType = changedFields.Contains("Version") ? ComponentDeltaType.VersionChanged : changedFields.Contains("License") ? ComponentDeltaType.LicenseChanged : ComponentDeltaType.MetadataChanged; deltas.Add(new ComponentDelta { Type = deltaType, Before = fromComp, After = toComp, ChangedFields = changedFields }); if (deltaType == ComponentDeltaType.VersionChanged) versionChanged++; else otherModified++; // Check for version downgrade (breaking) if (changedFields.Contains("Version") && IsVersionDowngrade(fromComp.Version, toComp.Version)) isBreaking = true; } else { unchanged++; } } } // Find removed components foreach (var (purl, fromComp) in fromByPurl) { if (!toByPurl.ContainsKey(purl)) { deltas.Add(new ComponentDelta { Type = ComponentDeltaType.Removed, Before = fromComp }); removed++; isBreaking = true; } } // Sort deltas for determinism var sortedDeltas = deltas .OrderBy(d => d.Type) .ThenBy(d => d.Before?.Purl ?? d.After?.Purl) .ToImmutableArray(); return new SbomDiff { FromId = fromId, ToId = toId, Deltas = sortedDeltas, Summary = new DiffSummary { Added = added, Removed = removed, VersionChanged = versionChanged, OtherModified = otherModified, Unchanged = unchanged, IsBreaking = isBreaking }, ComputedAt = DateTimeOffset.UtcNow }; } /// /// Creates a diff pointer from a diff. /// public SbomDiffPointer CreatePointer(SbomDiff diff) { var hash = ComputeDiffHash(diff); return new SbomDiffPointer { ComponentsAdded = diff.Summary.Added, ComponentsRemoved = diff.Summary.Removed, ComponentsModified = diff.Summary.VersionChanged + diff.Summary.OtherModified, DiffHash = hash }; } private static ImmutableArray CompareComponents(ComponentRef from, ComponentRef to) { var changes = new List(); if (from.Version != to.Version) changes.Add("Version"); if (from.License != to.License) changes.Add("License"); if (from.Type != to.Type) changes.Add("Type"); return [.. changes]; } private static bool IsVersionDowngrade(string fromVersion, string toVersion) { // Simple semver-like comparison // In production, use proper version comparison per ecosystem try { var fromParts = fromVersion.Split('.').Select(int.Parse).ToArray(); var toParts = toVersion.Split('.').Select(int.Parse).ToArray(); for (var i = 0; i < Math.Min(fromParts.Length, toParts.Length); i++) { if (toParts[i] < fromParts[i]) return true; if (toParts[i] > fromParts[i]) return false; } return toParts.Length < fromParts.Length; } catch { // Fall back to string comparison return string.Compare(toVersion, fromVersion, StringComparison.Ordinal) < 0; } } private static string ComputeDiffHash(SbomDiff diff) { var json = JsonSerializer.Serialize(new { diff.FromId, diff.ToId, Deltas = diff.Deltas.Select(d => new { d.Type, BeforePurl = d.Before?.Purl, AfterPurl = d.After?.Purl, d.ChangedFields }) }, new JsonSerializerOptions { WriteIndented = false }); var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json)); return Convert.ToHexStringLower(hashBytes); } }