using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.IO;
using System.IO.Enumeration;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.ImpactIndex;
///
/// Fixture-backed implementation of used while the real index is under construction.
///
public sealed class FixtureImpactIndex : IImpactIndex
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
private readonly ImpactIndexStubOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
private readonly SemaphoreSlim _initializationLock = new(1, 1);
private FixtureIndexState? _state;
public FixtureImpactIndex(
ImpactIndexStubOptions options,
TimeProvider? timeProvider,
ILogger logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask ResolveByPurlsAsync(
IEnumerable purls,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(purls);
ArgumentNullException.ThrowIfNull(selector);
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
var normalizedPurls = NormalizeKeys(purls);
if (normalizedPurls.Length == 0)
{
return CreateImpactSet(state, selector, Enumerable.Empty(), usageOnly);
}
var matches = new List();
foreach (var purl in normalizedPurls)
{
cancellationToken.ThrowIfCancellationRequested();
if (!state.PurlIndex.TryGetValue(purl, out var componentMatches))
{
continue;
}
foreach (var component in componentMatches)
{
var usedByEntrypoint = component.Component.UsedByEntrypoint;
if (usageOnly && !usedByEntrypoint)
{
continue;
}
matches.Add(new FixtureMatch(component.Image, usedByEntrypoint));
}
}
return CreateImpactSet(state, selector, matches, usageOnly);
}
public async ValueTask ResolveByVulnerabilitiesAsync(
IEnumerable vulnerabilityIds,
bool usageOnly,
Selector selector,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(vulnerabilityIds);
ArgumentNullException.ThrowIfNull(selector);
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
// The stub does not maintain a vulnerability → purl projection, so we return an empty result.
if (_logger.IsEnabled(LogLevel.Debug))
{
var first = vulnerabilityIds.FirstOrDefault(static id => !string.IsNullOrWhiteSpace(id));
if (first is not null)
{
_logger.LogDebug(
"ImpactIndex stub received ResolveByVulnerabilitiesAsync for '{VulnerabilityId}' but mappings are not available.",
first);
}
}
return CreateImpactSet(state, selector, Enumerable.Empty(), usageOnly);
}
public async ValueTask ResolveAllAsync(
Selector selector,
bool usageOnly,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selector);
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
var matches = state.ImagesByDigest.Values
.Select(image => new FixtureMatch(image, image.UsedByEntrypoint))
.Where(match => !usageOnly || match.UsedByEntrypoint);
return CreateImpactSet(state, selector, matches, usageOnly);
}
public ValueTask RemoveAsync(string imageDigest, CancellationToken cancellationToken = default)
{
// Fixture-backed index is immutable; removals are ignored.
return ValueTask.CompletedTask;
}
public async ValueTask CreateSnapshotAsync(CancellationToken cancellationToken = default)
{
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
var images = state.ImagesByDigest.Values
.OrderBy(image => image.Digest, StringComparer.OrdinalIgnoreCase)
.Select((image, index) => new ImpactImageRecord(
index,
"fixture",
image.Digest,
image.Registry,
image.Repository,
image.Namespaces,
image.Tags,
image.Labels,
image.GeneratedAt,
image.Components.Select(c => c.Purl).ToImmutableArray(),
image.Components.Where(c => c.UsedByEntrypoint).Select(c => c.Purl).ToImmutableArray()))
.ToImmutableArray();
var contains = images
.SelectMany(img => img.Components.Select(purl => (purl, img.ImageId)))
.GroupBy(pair => pair.purl, StringComparer.OrdinalIgnoreCase)
.ToImmutableDictionary(
g => g.Key,
g => g.Select(p => p.ImageId).Distinct().OrderBy(id => id).ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
var usedBy = images
.SelectMany(img => img.EntrypointComponents.Select(purl => (purl, img.ImageId)))
.GroupBy(pair => pair.purl, StringComparer.OrdinalIgnoreCase)
.ToImmutableDictionary(
g => g.Key,
g => g.Select(p => p.ImageId).Distinct().OrderBy(id => id).ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
return new ImpactIndexSnapshot(
state.GeneratedAt,
state.SnapshotId,
images,
contains,
usedBy);
}
public ValueTask RestoreSnapshotAsync(ImpactIndexSnapshot snapshot, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
// Fixture index remains immutable; restoration is a no-op.
return ValueTask.CompletedTask;
}
private async Task EnsureInitializedAsync(CancellationToken cancellationToken)
{
if (_state is not null)
{
return _state;
}
await _initializationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_state is not null)
{
return _state;
}
var state = await LoadAsync(cancellationToken).ConfigureAwait(false);
_state = state;
_logger.LogInformation(
"ImpactIndex stub loaded {ImageCount} fixture images from {SourceDescription}.",
state.ImagesByDigest.Count,
state.SourceDescription);
return state;
}
finally
{
_initializationLock.Release();
}
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
var images = new List();
string? sourceDescription = null;
if (!string.IsNullOrWhiteSpace(_options.FixtureDirectory))
{
var directory = ResolveDirectoryPath(_options.FixtureDirectory!);
if (Directory.Exists(directory))
{
images.AddRange(await LoadFromDirectoryAsync(directory, cancellationToken).ConfigureAwait(false));
sourceDescription = directory;
}
else
{
_logger.LogWarning(
"ImpactIndex stub fixture directory '{Directory}' was not found. Falling back to embedded fixtures.",
directory);
}
}
if (images.Count == 0)
{
images.AddRange(await LoadFromResourcesAsync(cancellationToken).ConfigureAwait(false));
sourceDescription ??= "embedded:scheduler-impact-index-fixtures";
}
if (images.Count == 0)
{
throw new InvalidOperationException("No BOM-Index fixtures were found for the ImpactIndex stub.");
}
return BuildState(images, sourceDescription!, _options.SnapshotId);
}
private static string ResolveDirectoryPath(string path)
{
if (Path.IsPathRooted(path))
{
return path;
}
var basePath = AppContext.BaseDirectory;
return Path.GetFullPath(Path.Combine(basePath, path));
}
private static async Task> LoadFromDirectoryAsync(
string directory,
CancellationToken cancellationToken)
{
var results = new List();
foreach (var file in Directory.EnumerateFiles(directory, "bom-index.json", SearchOption.AllDirectories)
.OrderBy(static file => file, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = File.OpenRead(file);
var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
continue;
}
results.Add(CreateFixtureImage(document));
}
return results;
}
private static async Task> LoadFromResourcesAsync(CancellationToken cancellationToken)
{
var assembly = typeof(FixtureImpactIndex).Assembly;
var resourceNames = assembly
.GetManifestResourceNames()
.Where(static name => name.EndsWith(".bom-index.json", StringComparison.OrdinalIgnoreCase))
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
var results = new List(resourceNames.Length);
foreach (var resourceName in resourceNames)
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
continue;
}
var document = await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
continue;
}
results.Add(CreateFixtureImage(document));
}
return results;
}
private static FixtureIndexState BuildState(
IReadOnlyList images,
string sourceDescription,
string snapshotId)
{
var imagesByDigest = images
.GroupBy(static image => image.Digest, StringComparer.OrdinalIgnoreCase)
.ToImmutableDictionary(
static group => group.Key,
static group => group
.OrderBy(static image => image.Repository, StringComparer.Ordinal)
.ThenBy(static image => image.Registry, StringComparer.Ordinal)
.ThenBy(static image => image.Tags.Length, Comparer.Default)
.First(),
StringComparer.OrdinalIgnoreCase);
var purlIndexBuilder = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var image in images)
{
foreach (var component in image.Components)
{
if (!purlIndexBuilder.TryGetValue(component.Purl, out var list))
{
list = new List();
purlIndexBuilder[component.Purl] = list;
}
list.Add(new FixtureComponentMatch(image, component));
}
}
var purlIndex = purlIndexBuilder.ToImmutableDictionary(
static entry => entry.Key,
static entry => entry.Value
.OrderBy(static item => item.Image.Digest, StringComparer.Ordinal)
.Select(static item => new FixtureComponentMatch(item.Image, item.Component))
.ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
var generatedAt = images.Count == 0
? DateTimeOffset.UnixEpoch
: images.Max(static image => image.GeneratedAt);
return new FixtureIndexState(imagesByDigest, purlIndex, generatedAt, sourceDescription, snapshotId);
}
private ImpactSet CreateImpactSet(
FixtureIndexState state,
Selector selector,
IEnumerable matches,
bool usageOnly)
{
var aggregated = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var match in matches)
{
if (!ImageMatchesSelector(match.Image, selector))
{
continue;
}
if (!aggregated.TryGetValue(match.Image.Digest, out var builder))
{
builder = new ImpactImageBuilder(match.Image);
aggregated[match.Image.Digest] = builder;
}
builder.MarkUsedByEntrypoint(match.UsedByEntrypoint);
}
var images = aggregated.Values
.Select(static builder => builder.Build())
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
.ToImmutableArray();
return new ImpactSet(
selector,
images,
usageOnly,
state.GeneratedAt == DateTimeOffset.UnixEpoch
? _timeProvider.GetUtcNow()
: state.GeneratedAt,
images.Length,
state.SnapshotId,
SchedulerSchemaVersions.ImpactSet);
}
private static bool ImageMatchesSelector(FixtureImage image, Selector selector)
{
if (selector is null)
{
return true;
}
if (selector.Digests.Length > 0 &&
!selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (selector.Repositories.Length > 0)
{
var repositoryMatch = selector.Repositories.Any(repo =>
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase));
if (!repositoryMatch)
{
return false;
}
}
if (selector.Namespaces.Length > 0)
{
if (image.Namespaces.IsDefaultOrEmpty)
{
return false;
}
var namespaceMatch = selector.Namespaces.Any(namespaceId =>
image.Namespaces.Contains(namespaceId, StringComparer.OrdinalIgnoreCase));
if (!namespaceMatch)
{
return false;
}
}
if (selector.IncludeTags.Length > 0)
{
if (image.Tags.IsDefaultOrEmpty)
{
return false;
}
var tagMatch = selector.IncludeTags.Any(pattern =>
MatchesAnyTag(image.Tags, pattern));
if (!tagMatch)
{
return false;
}
}
if (selector.Labels.Length > 0)
{
if (image.Labels.Count == 0)
{
return false;
}
foreach (var labelSelector in selector.Labels)
{
if (!image.Labels.TryGetValue(labelSelector.Key, out var value))
{
return false;
}
if (labelSelector.Values.Length > 0 &&
!labelSelector.Values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
return false;
}
}
}
return selector.Scope switch
{
SelectorScope.ByDigest => selector.Digests.Length == 0
? true
: selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase),
SelectorScope.ByRepository => selector.Repositories.Length == 0
? true
: selector.Repositories.Any(repo =>
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase)),
SelectorScope.ByNamespace => selector.Namespaces.Length == 0
? true
: !image.Namespaces.IsDefaultOrEmpty &&
selector.Namespaces.Any(namespaceId =>
image.Namespaces.Contains(namespaceId, StringComparer.OrdinalIgnoreCase)),
SelectorScope.ByLabels => selector.Labels.Length == 0
? true
: selector.Labels.All(label =>
image.Labels.TryGetValue(label.Key, out var value) &&
(label.Values.Length == 0 || label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))),
_ => true,
};
}
private static bool MatchesAnyTag(ImmutableArray tags, string pattern)
{
foreach (var tag in tags)
{
if (FileSystemName.MatchesSimpleExpression(pattern, tag, ignoreCase: true))
{
return true;
}
}
return false;
}
private static FixtureImage CreateFixtureImage(BomIndexDocument document)
{
if (document.Image is null)
{
throw new InvalidOperationException("BOM-Index image metadata is required.");
}
var digest = Validation.EnsureDigestFormat(document.Image.Digest, "image.digest");
var (registry, repository) = SplitRepository(document.Image.Repository);
var tags = string.IsNullOrWhiteSpace(document.Image.Tag)
? ImmutableArray.Empty
: ImmutableArray.Create(document.Image.Tag.Trim());
var components = (document.Components ?? Array.Empty())
.Where(static component => !string.IsNullOrWhiteSpace(component.Purl))
.Select(component => new FixtureComponent(
component.Purl!.Trim(),
component.Usage?.Any(static usage =>
usage.Equals("runtime", StringComparison.OrdinalIgnoreCase) ||
usage.Equals("usedByEntrypoint", StringComparison.OrdinalIgnoreCase)) == true))
.OrderBy(static component => component.Purl, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new FixtureImage(
digest,
registry,
repository,
ImmutableArray.Empty,
tags,
ImmutableSortedDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
components,
document.GeneratedAt == default ? DateTimeOffset.UnixEpoch : document.GeneratedAt.ToUniversalTime(),
components.Any(static component => component.UsedByEntrypoint));
}
private static (string Registry, string Repository) SplitRepository(string repository)
{
var normalized = Validation.EnsureNotNullOrWhiteSpace(repository, nameof(repository));
var separatorIndex = normalized.IndexOf('/');
if (separatorIndex < 0)
{
return ("docker.io", normalized);
}
var registry = normalized[..separatorIndex];
var repo = normalized[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(repo))
{
throw new ArgumentException("Repository segment is required after registry.", nameof(repository));
}
return (registry.Trim(), repo.Trim());
}
private static string[] NormalizeKeys(IEnumerable values)
{
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private readonly record struct FixtureMatch(FixtureImage Image, bool UsedByEntrypoint);
private sealed record FixtureImage(
string Digest,
string Registry,
string Repository,
ImmutableArray Namespaces,
ImmutableArray Tags,
ImmutableSortedDictionary Labels,
ImmutableArray Components,
DateTimeOffset GeneratedAt,
bool UsedByEntrypoint);
private sealed record FixtureComponent(string Purl, bool UsedByEntrypoint);
private sealed record FixtureComponentMatch(FixtureImage Image, FixtureComponent Component);
private sealed record FixtureIndexState(
ImmutableDictionary ImagesByDigest,
ImmutableDictionary> PurlIndex,
DateTimeOffset GeneratedAt,
string SourceDescription,
string SnapshotId);
private sealed class ImpactImageBuilder
{
private readonly FixtureImage _image;
private bool _usedByEntrypoint;
public ImpactImageBuilder(FixtureImage image)
{
_image = image;
}
public void MarkUsedByEntrypoint(bool usedByEntrypoint)
{
_usedByEntrypoint |= usedByEntrypoint;
}
public ImpactImage Build()
{
return new ImpactImage(
_image.Digest,
_image.Registry,
_image.Repository,
_image.Namespaces,
_image.Tags,
_usedByEntrypoint,
_image.Labels);
}
}
private sealed record BomIndexDocument
{
[JsonPropertyName("schema")]
public string? Schema { get; init; }
[JsonPropertyName("image")]
public BomIndexImage? Image { get; init; }
[JsonPropertyName("generatedAt")]
public DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("components")]
public IReadOnlyList? Components { get; init; }
}
private sealed record BomIndexImage
{
[JsonPropertyName("repository")]
public string Repository { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("tag")]
public string? Tag { get; init; }
}
private sealed record BomIndexComponent
{
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("usage")]
public IReadOnlyList? Usage { get; init; }
}
}