using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Signals.Models; using StellaOps.Signals.Persistence; namespace StellaOps.Signals.Services; internal sealed class UnknownsIngestionService : IUnknownsIngestionService { private readonly IUnknownsRepository repository; private readonly TimeProvider timeProvider; private readonly ILogger logger; public UnknownsIngestionService(IUnknownsRepository repository, TimeProvider timeProvider, ILogger logger) { this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task IngestAsync(UnknownsIngestRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); if (request.Subject is null) { throw new UnknownsValidationException("Subject is required."); } if (string.IsNullOrWhiteSpace(request.CallgraphId)) { throw new UnknownsValidationException("callgraphId is required."); } if (request.Unknowns is null || request.Unknowns.Count == 0) { throw new UnknownsValidationException("Unknowns list must not be empty."); } var subjectKey = request.Subject.ToSubjectKey(); if (string.IsNullOrWhiteSpace(subjectKey)) { throw new UnknownsValidationException("Subject must include scanId, imageDigest, or component/version."); } var now = timeProvider.GetUtcNow(); var normalized = new List(); foreach (var entry in request.Unknowns) { if (entry is null) { continue; } var hasContent = !(string.IsNullOrWhiteSpace(entry.SymbolId) && string.IsNullOrWhiteSpace(entry.CodeId) && string.IsNullOrWhiteSpace(entry.Purl) && string.IsNullOrWhiteSpace(entry.EdgeFrom) && string.IsNullOrWhiteSpace(entry.EdgeTo)); if (!hasContent) { continue; } normalized.Add(new UnknownSymbolDocument { SubjectKey = subjectKey, CallgraphId = request.CallgraphId, SymbolId = entry.SymbolId?.Trim(), CodeId = entry.CodeId?.Trim(), Purl = entry.Purl?.Trim(), EdgeFrom = entry.EdgeFrom?.Trim(), EdgeTo = entry.EdgeTo?.Trim(), Reason = entry.Reason?.Trim(), CreatedAt = now, UpdatedAt = now, LastAnalyzedAt = now }); } if (normalized.Count == 0) { throw new UnknownsValidationException("Unknown entries must include at least one symbolId, codeId, purl, or edge."); } await repository.UpsertAsync(subjectKey, normalized, cancellationToken).ConfigureAwait(false); logger.LogInformation("Stored {Count} unknown symbols for subject {SubjectKey}", normalized.Count, subjectKey); return new UnknownsIngestResponse { SubjectKey = subjectKey, UnknownsCount = normalized.Count }; } }