140 lines
5.4 KiB
C#
140 lines
5.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
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.Services.Models;
|
|
|
|
namespace StellaOps.Signals.Services;
|
|
|
|
/// <summary>
|
|
/// Writes reachability union bundles (runtime + static) into the CAS layout: reachability_graphs/<analysisId>/
|
|
/// Validates meta.json hashes before persisting.
|
|
/// </summary>
|
|
public sealed class ReachabilityUnionIngestionService : IReachabilityUnionIngestionService
|
|
{
|
|
private static readonly string[] RequiredFiles = { "nodes.ndjson", "edges.ndjson", "meta.json" };
|
|
|
|
private readonly ILogger<ReachabilityUnionIngestionService> logger;
|
|
private readonly SignalsOptions options;
|
|
|
|
public ReachabilityUnionIngestionService(
|
|
ILogger<ReachabilityUnionIngestionService> logger,
|
|
IOptions<SignalsOptions> options)
|
|
{
|
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
|
}
|
|
|
|
public async Task<ReachabilityUnionIngestResponse> IngestAsync(string analysisId, Stream zipStream, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
|
|
ArgumentNullException.ThrowIfNull(zipStream);
|
|
|
|
var casRoot = Path.Combine(options.Storage.RootPath, "reachability_graphs", analysisId.Trim());
|
|
if (Directory.Exists(casRoot))
|
|
{
|
|
Directory.Delete(casRoot, recursive: true);
|
|
}
|
|
|
|
Directory.CreateDirectory(casRoot);
|
|
|
|
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
|
|
|
|
var entries = archive.Entries.ToDictionary(e => e.FullName, StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var required in RequiredFiles)
|
|
{
|
|
if (!entries.ContainsKey(required))
|
|
{
|
|
throw new InvalidOperationException($"Union bundle missing required file: {required}");
|
|
}
|
|
}
|
|
|
|
var metaEntry = entries["meta.json"];
|
|
await using var metaBuffer = new MemoryStream();
|
|
await using (var metaStream = metaEntry.Open())
|
|
{
|
|
await metaStream.CopyToAsync(metaBuffer, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
metaBuffer.Position = 0;
|
|
using var metaDoc = await JsonDocument.ParseAsync(metaBuffer, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
var metaRoot = metaDoc.RootElement;
|
|
|
|
var filesElement = metaRoot.TryGetProperty("files", out var f) && f.ValueKind == JsonValueKind.Array
|
|
? f
|
|
: throw new InvalidOperationException("meta.json is missing required 'files' array");
|
|
|
|
var recorded = filesElement.EnumerateArray()
|
|
.Select(el => new
|
|
{
|
|
Path = el.GetProperty("path").GetString() ?? string.Empty,
|
|
Sha = el.GetProperty("sha256").GetString() ?? string.Empty,
|
|
Records = el.TryGetProperty("records", out var r) && r.ValueKind == JsonValueKind.Number ? r.GetInt32() : (int?)null
|
|
})
|
|
.ToList();
|
|
|
|
metaBuffer.Position = 0;
|
|
var metaPath = Path.Combine(casRoot, "meta.json");
|
|
await using (var metaDest = File.Create(metaPath))
|
|
{
|
|
await metaBuffer.CopyToAsync(metaDest, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var filesForResponse = new List<ReachabilityUnionFile>();
|
|
|
|
foreach (var file in recorded)
|
|
{
|
|
if (!entries.TryGetValue(file.Path, out var zipEntry))
|
|
{
|
|
throw new InvalidOperationException($"meta.json references missing file '{file.Path}'.");
|
|
}
|
|
|
|
var destPath = Path.Combine(casRoot, file.Path.Replace('/', Path.DirectorySeparatorChar));
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
|
|
|
|
using (var entryStream = zipEntry.Open())
|
|
using (var dest = File.Create(destPath))
|
|
{
|
|
await entryStream.CopyToAsync(dest, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var actualSha = ComputeSha256Hex(destPath);
|
|
if (!string.Equals(actualSha, file.Sha, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidOperationException($"SHA mismatch for {file.Path}: expected {file.Sha}, actual {actualSha}.");
|
|
}
|
|
|
|
filesForResponse.Add(new ReachabilityUnionFile(file.Path, actualSha, file.Records));
|
|
}
|
|
|
|
logger.LogInformation("Ingested reachability union bundle {AnalysisId} with {FileCount} files.", analysisId, filesForResponse.Count);
|
|
|
|
return new ReachabilityUnionIngestResponse(analysisId, $"cas://reachability_graphs/{analysisId}", filesForResponse);
|
|
}
|
|
|
|
private static string ComputeSha256Hex(string path)
|
|
{
|
|
using var stream = File.OpenRead(path);
|
|
var buffer = new byte[8192];
|
|
using var sha = SHA256.Create();
|
|
int read;
|
|
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
|
{
|
|
sha.TransformBlock(buffer, 0, read, null, 0);
|
|
}
|
|
|
|
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
|
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
|
}
|
|
}
|