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 { private readonly TimeProvider _timeProvider; public SbomDiffEngine(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } /// /// Computes the semantic diff between two SBOMs. /// public SbomDiff ComputeDiff( SbomId fromId, IReadOnlyList fromComponents, SbomId toId, IReadOnlyList toComponents) { // Match by package identity (PURL without version) to detect version changes var fromByIdentity = fromComponents.ToDictionary(c => GetPackageIdentity(c), c => c); var toByIdentity = toComponents.ToDictionary(c => GetPackageIdentity(c), 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 (identity, toComp) in toByIdentity) { if (!fromByIdentity.TryGetValue(identity, 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 (identity, fromComp) in fromByIdentity) { if (!toByIdentity.ContainsKey(identity)) { 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 = _timeProvider.GetUtcNow() }; } /// /// 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); } /// /// Gets the package identity (PURL without version) for matching. /// private static string GetPackageIdentity(ComponentRef component) { // Strip version from PURL to match by package identity // PURL format: pkg:type/namespace/name@version?qualifiers#subpath var purl = component.Purl; var atIndex = purl.IndexOf('@'); if (atIndex > 0) { var beforeAt = purl[..atIndex]; // Also preserve qualifiers/subpath after version if present var queryIndex = purl.IndexOf('?', atIndex); var hashIndex = purl.IndexOf('#', atIndex); var suffixIndex = queryIndex >= 0 ? queryIndex : hashIndex; return suffixIndex > 0 ? beforeAt + purl[suffixIndex..] : beforeAt; } return purl; } }