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; } } }