using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Options; using StellaOps.SbomService.Models; using StellaOps.SbomService.Repositories; namespace StellaOps.SbomService.Services; internal sealed class SbomLedgerService : ISbomLedgerService { private readonly ISbomLedgerRepository _repository; private readonly IClock _clock; private readonly SbomLedgerOptions _options; public SbomLedgerService(ISbomLedgerRepository repository, IClock clock, IOptions options) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _options = options?.Value ?? new SbomLedgerOptions(); } public async Task AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(submission); cancellationToken.ThrowIfCancellationRequested(); var artifact = submission.ArtifactRef.Trim(); var chainId = await _repository.GetChainIdAsync(artifact, cancellationToken).ConfigureAwait(false) ?? Guid.NewGuid(); var existing = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false); var sequence = existing.Count + 1; var versionId = Guid.NewGuid(); var createdAt = _clock.UtcNow; SbomLedgerVersion? parent = null; if (submission.ParentVersionId.HasValue) { parent = await _repository.GetVersionAsync(submission.ParentVersionId.Value, cancellationToken).ConfigureAwait(false); if (parent is null) { throw new InvalidOperationException($"Parent version '{submission.ParentVersionId}' was not found."); } } var version = new SbomLedgerVersion { VersionId = versionId, ChainId = chainId, ArtifactRef = artifact, SequenceNumber = sequence, Digest = submission.Digest, Format = submission.Format, FormatVersion = submission.FormatVersion, Source = submission.Source, CreatedAtUtc = createdAt, Provenance = submission.Provenance, ParentVersionId = parent?.VersionId, ParentDigest = parent?.Digest, Components = submission.Components }; await _repository.AddVersionAsync(version, cancellationToken).ConfigureAwait(false); await _repository.AddAuditAsync( new SbomLedgerAuditEntry(artifact, versionId, "created", createdAt, $"format={submission.Format}"), cancellationToken).ConfigureAwait(false); return version; } public async Task GetHistoryAsync(string artifactRef, int limit, int offset, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(artifactRef)) { return null; } var artifact = artifactRef.Trim(); var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false); if (versions.Count == 0) { return null; } var ordered = versions .OrderByDescending(v => v.SequenceNumber) .ThenByDescending(v => v.CreatedAtUtc) .ToList(); var page = ordered .Skip(offset) .Take(limit) .Select(v => new SbomVersionHistoryItem( v.VersionId, v.SequenceNumber, v.Digest, v.Format, v.FormatVersion, v.Source, v.CreatedAtUtc, v.ParentVersionId, v.ParentDigest, v.Components.Count)) .ToList(); var nextCursor = offset + limit < ordered.Count ? (offset + limit).ToString(CultureInfo.InvariantCulture) : null; var chainId = versions.First().ChainId; return new SbomVersionHistoryResult(artifact, chainId, page, nextCursor); } public async Task GetAtTimeAsync(string artifactRef, DateTimeOffset atUtc, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(artifactRef)) { return null; } var artifact = artifactRef.Trim(); var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false); if (versions.Count == 0) { return null; } var match = versions .Where(v => v.CreatedAtUtc <= atUtc) .OrderByDescending(v => v.CreatedAtUtc) .ThenByDescending(v => v.SequenceNumber) .FirstOrDefault(); if (match is null) { return new SbomTemporalQueryResult(artifact, null); } return new SbomTemporalQueryResult( artifact, new SbomVersionHistoryItem( match.VersionId, match.SequenceNumber, match.Digest, match.Format, match.FormatVersion, match.Source, match.CreatedAtUtc, match.ParentVersionId, match.ParentDigest, match.Components.Count)); } public async Task GetRangeAsync(string artifactRef, DateTimeOffset startUtc, DateTimeOffset endUtc, int limit, int offset, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(artifactRef)) { return null; } var artifact = artifactRef.Trim(); var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false); if (versions.Count == 0) { return null; } var filtered = versions .Where(v => v.CreatedAtUtc >= startUtc && v.CreatedAtUtc <= endUtc) .OrderByDescending(v => v.CreatedAtUtc) .ThenByDescending(v => v.SequenceNumber) .ToList(); var page = filtered .Skip(offset) .Take(limit) .Select(v => new SbomVersionHistoryItem( v.VersionId, v.SequenceNumber, v.Digest, v.Format, v.FormatVersion, v.Source, v.CreatedAtUtc, v.ParentVersionId, v.ParentDigest, v.Components.Count)) .ToList(); var nextCursor = offset + limit < filtered.Count ? (offset + limit).ToString(CultureInfo.InvariantCulture) : null; var chainId = versions.First().ChainId; return new SbomVersionHistoryResult(artifact, chainId, page, nextCursor); } public async Task DiffAsync(Guid beforeVersionId, Guid afterVersionId, CancellationToken cancellationToken) { var before = await _repository.GetVersionAsync(beforeVersionId, cancellationToken).ConfigureAwait(false); var after = await _repository.GetVersionAsync(afterVersionId, cancellationToken).ConfigureAwait(false); if (before is null || after is null) { return null; } var beforeMap = BuildComponentMap(before.Components); var afterMap = BuildComponentMap(after.Components); var added = new List(); var removed = new List(); var versionChanged = new List(); var licenseChanged = new List(); foreach (var (key, component) in afterMap) { if (!beforeMap.TryGetValue(key, out var beforeComponent)) { added.Add(ToDiffComponent(component)); continue; } if (!string.Equals(component.Version, beforeComponent.Version, StringComparison.OrdinalIgnoreCase)) { versionChanged.Add(new SbomVersionChange( key, component.Name, component.Purl, beforeComponent.Version, component.Version)); } if (!string.Equals(component.License, beforeComponent.License, StringComparison.OrdinalIgnoreCase)) { licenseChanged.Add(new SbomLicenseChange( key, component.Name, component.Purl, beforeComponent.License, component.License)); } } foreach (var (key, component) in beforeMap) { if (!afterMap.ContainsKey(key)) { removed.Add(ToDiffComponent(component)); } } added = added.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(); removed = removed.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(); versionChanged = versionChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(); licenseChanged = licenseChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList(); return new SbomDiffResult { BeforeVersionId = beforeVersionId, AfterVersionId = afterVersionId, Added = added, Removed = removed, VersionChanged = versionChanged, LicenseChanged = licenseChanged, Summary = new SbomDiffSummary(added.Count, removed.Count, versionChanged.Count, licenseChanged.Count) }; } public async Task GetLineageAsync(string artifactRef, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(artifactRef)) { return null; } var artifact = artifactRef.Trim(); var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false); if (versions.Count == 0) { return null; } var nodes = versions .OrderBy(v => v.SequenceNumber) .ThenBy(v => v.CreatedAtUtc) .Select(v => new SbomLineageNode(v.VersionId, v.SequenceNumber, v.Digest, v.Source, v.CreatedAtUtc)) .ToList(); var edges = new List(); edges.AddRange(versions .Where(v => v.ParentVersionId.HasValue) .Select(v => new SbomLineageEdge(v.ParentVersionId!.Value, v.VersionId, SbomLineageRelationships.Parent))); var buildEdges = versions .Where(v => !string.IsNullOrWhiteSpace(v.Provenance?.CiContext?.BuildId)) .GroupBy(v => v.Provenance!.CiContext!.BuildId!.Trim(), StringComparer.OrdinalIgnoreCase) .SelectMany(group => { var ordered = group .OrderBy(v => v.SequenceNumber) .ThenBy(v => v.CreatedAtUtc) .ToList(); var groupEdges = new List(); for (var i = 1; i < ordered.Count; i++) { groupEdges.Add(new SbomLineageEdge( ordered[i - 1].VersionId, ordered[i].VersionId, SbomLineageRelationships.Build)); } return groupEdges; }) .ToList(); edges.AddRange(buildEdges); var orderedEdges = edges .GroupBy(e => new { e.FromVersionId, e.ToVersionId, e.Relationship }) .Select(g => g.First()) .OrderBy(e => e.FromVersionId) .ThenBy(e => e.ToVersionId) .ThenBy(e => e.Relationship, StringComparer.Ordinal) .ToList(); return new SbomLineageResult(artifact, versions[0].ChainId, nodes, orderedEdges); } public async Task ApplyRetentionAsync(CancellationToken cancellationToken) { var artifacts = await _repository.ListArtifactsAsync(cancellationToken).ConfigureAwait(false); var messages = new List(); var totalPruned = 0; var chainsTouched = 0; foreach (var artifact in artifacts) { var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false); if (versions.Count == 0) { continue; } var prunable = ApplyRetentionPolicy(versions); if (prunable.Count == 0) { continue; } var pruned = await _repository.RemoveVersionsAsync(artifact, prunable.Select(v => v.VersionId).ToList(), cancellationToken).ConfigureAwait(false); totalPruned += pruned; chainsTouched++; foreach (var version in prunable) { await _repository.AddAuditAsync( new SbomLedgerAuditEntry(artifact, version.VersionId, "retention_prune", _clock.UtcNow, $"sequence={version.SequenceNumber}"), cancellationToken).ConfigureAwait(false); } messages.Add($"Pruned {pruned} versions for {artifact}."); } return new SbomRetentionResult(totalPruned, chainsTouched, messages); } public Task> GetAuditAsync(string artifactRef, CancellationToken cancellationToken) { return _repository.GetAuditAsync(artifactRef.Trim(), cancellationToken); } public Task> ListAnalysisJobsAsync(string artifactRef, CancellationToken cancellationToken) { return _repository.ListAnalysisJobsAsync(artifactRef.Trim(), cancellationToken); } private List ApplyRetentionPolicy(IReadOnlyList versions) { var ordered = versions.OrderByDescending(v => v.CreatedAtUtc).ThenByDescending(v => v.SequenceNumber).ToList(); var keep = new HashSet(); for (var i = 0; i < Math.Min(_options.MinVersionsToKeep, ordered.Count); i++) { keep.Add(ordered[i].VersionId); } var prunable = new List(); if (_options.MaxVersionsPerArtifact > 0 && ordered.Count > _options.MaxVersionsPerArtifact) { var toPrune = ordered .Skip(_options.MaxVersionsPerArtifact) .Where(v => !keep.Contains(v.VersionId)) .ToList(); prunable.AddRange(toPrune); } if (_options.MaxAgeDays > 0) { var threshold = _clock.UtcNow.AddDays(-_options.MaxAgeDays); foreach (var version in ordered.Where(v => v.CreatedAtUtc < threshold)) { if (!keep.Contains(version.VersionId)) { prunable.Add(version); } } } return prunable .GroupBy(v => v.VersionId) .Select(g => g.First()) .ToList(); } private static Dictionary BuildComponentMap(IReadOnlyList components) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var component in components.OrderBy(c => c.Key, StringComparer.Ordinal)) { if (!map.ContainsKey(component.Key)) { map[component.Key] = component; } } return map; } private static SbomDiffComponent ToDiffComponent(SbomNormalizedComponent component) => new(component.Key, component.Name, component.Purl, component.Version, component.License); }