using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Signals.Models; using StellaOps.Signals.Options; using StellaOps.Signals.Parsing; using StellaOps.Signals.Persistence; using StellaOps.Signals.Storage; using StellaOps.Signals.Storage.Models; namespace StellaOps.Signals.Services; internal sealed class CallgraphIngestionService : ICallgraphIngestionService { private static readonly HashSet AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) { "application/json", "application/vnd.stellaops.callgraph+json" }; private readonly ICallgraphParserResolver parserResolver; private readonly ICallgraphArtifactStore artifactStore; private readonly ICallgraphRepository repository; private readonly ILogger logger; private readonly SignalsOptions options; private readonly TimeProvider timeProvider; public CallgraphIngestionService( ICallgraphParserResolver parserResolver, ICallgraphArtifactStore artifactStore, ICallgraphRepository repository, IOptions options, TimeProvider timeProvider, ILogger logger) { this.parserResolver = parserResolver ?? throw new ArgumentNullException(nameof(parserResolver)); this.artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore)); this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } public async Task IngestAsync(CallgraphIngestRequest request, CancellationToken cancellationToken) { ValidateRequest(request); var parser = parserResolver.Resolve(request.Language); var artifactBytes = Convert.FromBase64String(request.ArtifactContentBase64); await using var parseStream = new MemoryStream(artifactBytes, writable: false); var parseResult = await parser.ParseAsync(parseStream, cancellationToken).ConfigureAwait(false); parseStream.Position = 0; var hash = ComputeSha256(artifactBytes); var artifactMetadata = await artifactStore.SaveAsync( new CallgraphArtifactSaveRequest( request.Language, request.Component, request.Version, request.ArtifactFileName, request.ArtifactContentType, hash), parseStream, cancellationToken).ConfigureAwait(false); var document = new CallgraphDocument { Language = parser.Language, Component = request.Component, Version = request.Version, Nodes = new List(parseResult.Nodes), Edges = new List(parseResult.Edges), Metadata = request.Metadata is null ? null : new Dictionary(request.Metadata, StringComparer.OrdinalIgnoreCase), Artifact = new CallgraphArtifactMetadata { Path = artifactMetadata.Path, Hash = artifactMetadata.Hash, CasUri = artifactMetadata.CasUri, ContentType = artifactMetadata.ContentType, Length = artifactMetadata.Length }, IngestedAt = timeProvider.GetUtcNow() }; document.Metadata ??= new Dictionary(StringComparer.OrdinalIgnoreCase); document.Metadata["formatVersion"] = parseResult.FormatVersion; document = await repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false); logger.LogInformation( "Ingested callgraph {Language}:{Component}:{Version} (id={Id}) with {NodeCount} nodes and {EdgeCount} edges.", document.Language, document.Component, document.Version, document.Id, document.Nodes.Count, document.Edges.Count); return new CallgraphIngestResponse(document.Id, document.Artifact.Path, document.Artifact.Hash, document.Artifact.CasUri); } private static void ValidateRequest(CallgraphIngestRequest request) { ArgumentNullException.ThrowIfNull(request); if (string.IsNullOrWhiteSpace(request.Language)) { throw new CallgraphIngestionValidationException("Language is required."); } if (string.IsNullOrWhiteSpace(request.Component)) { throw new CallgraphIngestionValidationException("Component is required."); } if (string.IsNullOrWhiteSpace(request.Version)) { throw new CallgraphIngestionValidationException("Version is required."); } if (string.IsNullOrWhiteSpace(request.ArtifactContentBase64)) { throw new CallgraphIngestionValidationException("Artifact content is required."); } if (string.IsNullOrWhiteSpace(request.ArtifactFileName)) { throw new CallgraphIngestionValidationException("Artifact file name is required."); } if (string.IsNullOrWhiteSpace(request.ArtifactContentType) || !AllowedContentTypes.Contains(request.ArtifactContentType)) { throw new CallgraphIngestionValidationException($"Unsupported artifact content type '{request.ArtifactContentType}'."); } } private static string ComputeSha256(ReadOnlySpan buffer) { Span hash = stackalloc byte[SHA256.HashSizeInBytes]; SHA256.HashData(buffer, hash); return Convert.ToHexString(hash); } } /// /// Exception thrown when the ingestion request is invalid. /// public sealed class CallgraphIngestionValidationException : Exception { public CallgraphIngestionValidationException(string message) : base(message) { } }