376 lines
15 KiB
C#
376 lines
15 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Globalization;
|
|
using StellaOps.SbomService.Models;
|
|
using StellaOps.SbomService.Repositories;
|
|
|
|
namespace StellaOps.SbomService.Services;
|
|
|
|
internal sealed class InMemorySbomQueryService : ISbomQueryService
|
|
{
|
|
private readonly IReadOnlyList<PathRecord> _paths;
|
|
private readonly IReadOnlyList<TimelineRecord> _timelines;
|
|
private readonly IReadOnlyList<CatalogRecord> _catalog;
|
|
private readonly IComponentLookupRepository _componentLookupRepository;
|
|
private readonly IProjectionRepository _projectionRepository;
|
|
private readonly ConcurrentDictionary<string, object> _cache = new();
|
|
|
|
public InMemorySbomQueryService(IComponentLookupRepository componentLookupRepository, IProjectionRepository projectionRepository)
|
|
{
|
|
_componentLookupRepository = componentLookupRepository;
|
|
_projectionRepository = projectionRepository;
|
|
// Deterministic seed data for early contract testing; replace with Mongo-backed implementation later.
|
|
_paths = SeedPaths();
|
|
_timelines = SeedTimelines();
|
|
_catalog = SeedCatalog();
|
|
}
|
|
|
|
public Task<QueryResult<SbomPathResult>> GetPathsAsync(SbomPathQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = $"paths|{query.Purl}|{query.Artifact}|{query.Scope}|{query.Environment}|{query.Offset}|{query.Limit}";
|
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomPathResult cachedResult)
|
|
{
|
|
return Task.FromResult(new QueryResult<SbomPathResult>(cachedResult, true));
|
|
}
|
|
|
|
var filtered = _paths
|
|
.Where(p => p.Purl.Equals(query.Purl, StringComparison.OrdinalIgnoreCase))
|
|
.Where(p => query.Artifact is null || p.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
|
.Where(p => query.Scope is null || string.Equals(p.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
|
.Where(p => query.Environment is null || string.Equals(p.Environment, query.Environment, StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(p => p.Artifact)
|
|
.ThenBy(p => p.Environment)
|
|
.ThenBy(p => p.Scope)
|
|
.ThenBy(p => string.Join("->", p.Nodes.Select(n => n.Name)))
|
|
.ToList();
|
|
|
|
var page = filtered
|
|
.Skip(query.Offset)
|
|
.Take(query.Limit)
|
|
.Select(r => new SbomPath(r.Nodes, r.RuntimeFlag, r.BlastRadius, r.NearestSafeVersion))
|
|
.ToList();
|
|
|
|
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
|
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
|
: null;
|
|
|
|
var result = new SbomPathResult(
|
|
Purl: query.Purl,
|
|
Artifact: query.Artifact,
|
|
Scope: query.Scope,
|
|
Environment: query.Environment,
|
|
Paths: page,
|
|
NextCursor: nextCursor);
|
|
|
|
_cache[cacheKey] = result;
|
|
return Task.FromResult(new QueryResult<SbomPathResult>(result, false));
|
|
}
|
|
|
|
public Task<QueryResult<SbomTimelineResult>> GetTimelineAsync(SbomTimelineQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = $"timeline|{query.Artifact}|{query.Offset}|{query.Limit}";
|
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomTimelineResult cachedTimeline)
|
|
{
|
|
return Task.FromResult(new QueryResult<SbomTimelineResult>(cachedTimeline, true));
|
|
}
|
|
|
|
var filtered = _timelines
|
|
.Where(t => t.Artifact.Equals(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
|
.OrderByDescending(t => t.CreatedAt)
|
|
.ThenByDescending(t => t.Version)
|
|
.ToList();
|
|
|
|
var page = filtered
|
|
.Skip(query.Offset)
|
|
.Take(query.Limit)
|
|
.Select(t => new SbomVersion(t.Version, t.Digest, t.CreatedAt, t.SourceBundleHash, t.Provenance))
|
|
.ToList();
|
|
|
|
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
|
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
|
: null;
|
|
|
|
var result = new SbomTimelineResult(query.Artifact, page, nextCursor);
|
|
_cache[cacheKey] = result;
|
|
return Task.FromResult(new QueryResult<SbomTimelineResult>(result, false));
|
|
}
|
|
|
|
public Task<QueryResult<SbomCatalogResult>> GetConsoleCatalogAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = $"catalog|{query.Artifact}|{query.License}|{query.Scope}|{query.AssetTag}|{query.Offset}|{query.Limit}";
|
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomCatalogResult cachedCatalog)
|
|
{
|
|
return Task.FromResult(new QueryResult<SbomCatalogResult>(cachedCatalog, true));
|
|
}
|
|
|
|
var filtered = _catalog
|
|
.Where(c => query.Artifact is null || c.Artifact.Contains(query.Artifact, StringComparison.OrdinalIgnoreCase))
|
|
.Where(c => query.License is null || string.Equals(c.License, query.License, StringComparison.OrdinalIgnoreCase))
|
|
.Where(c => query.Scope is null || string.Equals(c.Scope, query.Scope, StringComparison.OrdinalIgnoreCase))
|
|
.Where(c => query.AssetTag is null || c.AssetTags.ContainsKey(query.AssetTag))
|
|
.OrderByDescending(c => c.CreatedAt)
|
|
.ThenBy(c => c.Artifact)
|
|
.ToList();
|
|
|
|
var page = filtered
|
|
.Skip(query.Offset)
|
|
.Take(query.Limit)
|
|
.Select(c => new SbomCatalogItem(
|
|
c.Artifact,
|
|
c.SbomVersion,
|
|
c.Digest,
|
|
c.License,
|
|
c.Scope,
|
|
c.AssetTags,
|
|
c.CreatedAt,
|
|
c.ProjectionHash,
|
|
c.EvaluationMetadata))
|
|
.ToList();
|
|
|
|
string? nextCursor = query.Offset + query.Limit < filtered.Count
|
|
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
|
: null;
|
|
|
|
var result = new SbomCatalogResult(page, nextCursor);
|
|
_cache[cacheKey] = result;
|
|
return Task.FromResult(new QueryResult<SbomCatalogResult>(result, false));
|
|
}
|
|
|
|
public async Task<QueryResult<ComponentLookupResult>> GetComponentLookupAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = $"component|{query.Purl}|{query.Artifact}|{query.Offset}|{query.Limit}";
|
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached is ComponentLookupResult cachedResult)
|
|
{
|
|
return new QueryResult<ComponentLookupResult>(cachedResult, true);
|
|
}
|
|
|
|
var page = await _componentLookupRepository.QueryAsync(query, cancellationToken);
|
|
|
|
string? nextCursor = query.Offset + query.Limit < page.Count
|
|
? (query.Offset + query.Limit).ToString(CultureInfo.InvariantCulture)
|
|
: null;
|
|
|
|
var neighbors = page
|
|
.Select(c => new ComponentNeighbor(c.NeighborPurl, c.Relationship, c.License, c.Scope, c.RuntimeFlag))
|
|
.ToList();
|
|
|
|
var result = new ComponentLookupResult(query.Purl, query.Artifact, neighbors, nextCursor, CacheHint: "seeded");
|
|
_cache[cacheKey] = result;
|
|
return new QueryResult<ComponentLookupResult>(result, false);
|
|
}
|
|
|
|
public async Task<SbomProjectionResult?> GetProjectionAsync(string snapshotId, string tenantId, CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = $"projection|{snapshotId}|{tenantId}";
|
|
if (_cache.TryGetValue(cacheKey, out var cached) && cached is SbomProjectionResult cachedProjection)
|
|
{
|
|
return cachedProjection;
|
|
}
|
|
|
|
var projection = await _projectionRepository.GetAsync(snapshotId, tenantId, cancellationToken);
|
|
if (projection is not null)
|
|
{
|
|
_cache[cacheKey] = projection;
|
|
}
|
|
|
|
return projection;
|
|
}
|
|
|
|
private static IReadOnlyList<PathRecord> SeedPaths()
|
|
{
|
|
return new List<PathRecord>
|
|
{
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
|
Purl: "pkg:npm/lodash@4.17.21",
|
|
Scope: "runtime",
|
|
Environment: "prod",
|
|
RuntimeFlag: true,
|
|
BlastRadius: "medium",
|
|
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
|
|
Nodes: new[]
|
|
{
|
|
new SbomPathNode("sample-api", "artifact"),
|
|
new SbomPathNode("express", "npm"),
|
|
new SbomPathNode("lodash", "npm")
|
|
}),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
|
Purl: "pkg:npm/lodash@4.17.21",
|
|
Scope: "build",
|
|
Environment: "prod",
|
|
RuntimeFlag: false,
|
|
BlastRadius: "low",
|
|
NearestSafeVersion: "pkg:npm/lodash@4.17.22",
|
|
Nodes: new[]
|
|
{
|
|
new SbomPathNode("sample-api", "artifact"),
|
|
new SbomPathNode("rollup", "npm"),
|
|
new SbomPathNode("lodash", "npm")
|
|
}),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api@sha256:222",
|
|
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
|
Scope: "runtime",
|
|
Environment: "staging",
|
|
RuntimeFlag: true,
|
|
BlastRadius: "high",
|
|
NearestSafeVersion: "pkg:nuget/Newtonsoft.Json@13.0.3",
|
|
Nodes: new[]
|
|
{
|
|
new SbomPathNode("sample-worker", "artifact"),
|
|
new SbomPathNode("StellaOps.Core", "nuget"),
|
|
new SbomPathNode("Newtonsoft.Json", "nuget")
|
|
})
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<TimelineRecord> SeedTimelines()
|
|
{
|
|
return new List<TimelineRecord>
|
|
{
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api",
|
|
Version: "2025.11.15.1",
|
|
Digest: "sha256:111",
|
|
SourceBundleHash: "sha256:bundle111",
|
|
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
|
|
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api",
|
|
Version: "2025.11.16.1",
|
|
Digest: "sha256:112",
|
|
SourceBundleHash: "sha256:bundle112",
|
|
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
|
|
Provenance: "scanner:surface_bundle_mock_v1.tgz"),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-worker",
|
|
Version: "2025.11.12.0",
|
|
Digest: "sha256:222",
|
|
SourceBundleHash: "sha256:bundle222",
|
|
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
|
|
Provenance: "upload:spdx:worker"),
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<CatalogRecord> SeedCatalog()
|
|
{
|
|
return new List<CatalogRecord>
|
|
{
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api",
|
|
SbomVersion: "2025.11.16.1",
|
|
Digest: "sha256:112",
|
|
License: "MIT",
|
|
Scope: "runtime",
|
|
AssetTags: new Dictionary<string, string>
|
|
{
|
|
["owner"] = "payments",
|
|
["criticality"] = "high",
|
|
["env"] = "prod"
|
|
},
|
|
CreatedAt: new DateTimeOffset(2025, 11, 16, 12, 0, 0, TimeSpan.Zero),
|
|
ProjectionHash: "sha256:proj112",
|
|
EvaluationMetadata: "eval:passed:v1"),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api",
|
|
SbomVersion: "2025.11.15.1",
|
|
Digest: "sha256:111",
|
|
License: "MIT",
|
|
Scope: "runtime",
|
|
AssetTags: new Dictionary<string, string>
|
|
{
|
|
["owner"] = "payments",
|
|
["criticality"] = "high",
|
|
["env"] = "prod"
|
|
},
|
|
CreatedAt: new DateTimeOffset(2025, 11, 15, 12, 0, 0, TimeSpan.Zero),
|
|
ProjectionHash: "sha256:proj111",
|
|
EvaluationMetadata: "eval:passed:v1"),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-worker",
|
|
SbomVersion: "2025.11.12.0",
|
|
Digest: "sha256:222",
|
|
License: "Apache-2.0",
|
|
Scope: "runtime",
|
|
AssetTags: new Dictionary<string, string>
|
|
{
|
|
["owner"] = "platform",
|
|
["criticality"] = "medium",
|
|
["env"] = "staging"
|
|
},
|
|
CreatedAt: new DateTimeOffset(2025, 11, 12, 8, 0, 0, TimeSpan.Zero),
|
|
ProjectionHash: "sha256:proj222",
|
|
EvaluationMetadata: "eval:pending:v1"),
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<ComponentLookupRecord> SeedComponents()
|
|
{
|
|
return new List<ComponentLookupRecord>
|
|
{
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api",
|
|
Purl: "pkg:npm/lodash@4.17.21",
|
|
NeighborPurl: "pkg:npm/express@4.18.2",
|
|
Relationship: "DEPENDS_ON",
|
|
License: "MIT",
|
|
Scope: "runtime",
|
|
RuntimeFlag: true),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-api",
|
|
Purl: "pkg:npm/lodash@4.17.21",
|
|
NeighborPurl: "pkg:npm/rollup@3.0.0",
|
|
Relationship: "DEPENDS_ON",
|
|
License: "MIT",
|
|
Scope: "build",
|
|
RuntimeFlag: false),
|
|
new(
|
|
Artifact: "ghcr.io/stellaops/sample-worker",
|
|
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
|
NeighborPurl: "pkg:nuget/StellaOps.Core@1.0.0",
|
|
Relationship: "DEPENDS_ON",
|
|
License: "Apache-2.0",
|
|
Scope: "runtime",
|
|
RuntimeFlag: true)
|
|
};
|
|
}
|
|
|
|
private sealed record PathRecord(
|
|
string Artifact,
|
|
string Purl,
|
|
string? Scope,
|
|
string? Environment,
|
|
bool RuntimeFlag,
|
|
string? BlastRadius,
|
|
string? NearestSafeVersion,
|
|
IReadOnlyList<SbomPathNode> Nodes);
|
|
|
|
private sealed record TimelineRecord(
|
|
string Artifact,
|
|
string Version,
|
|
string Digest,
|
|
string SourceBundleHash,
|
|
DateTimeOffset CreatedAt,
|
|
string? Provenance);
|
|
|
|
private sealed record CatalogRecord(
|
|
string Artifact,
|
|
string SbomVersion,
|
|
string Digest,
|
|
string? License,
|
|
string Scope,
|
|
IReadOnlyDictionary<string, string> AssetTags,
|
|
DateTimeOffset CreatedAt,
|
|
string ProjectionHash,
|
|
string EvaluationMetadata);
|
|
|
|
private sealed record ComponentLookupRecord(
|
|
string Artifact,
|
|
string Purl,
|
|
string NeighborPurl,
|
|
string Relationship,
|
|
string? License,
|
|
string Scope,
|
|
bool RuntimeFlag);
|
|
}
|