Rename Vexer to Excititor
This commit is contained in:
		@@ -0,0 +1,332 @@
 | 
			
		||||
using System.Collections.Immutable;
 | 
			
		||||
using System.Net;
 | 
			
		||||
using System.Net.Http.Headers;
 | 
			
		||||
using System.Text.Json;
 | 
			
		||||
using Microsoft.Extensions.Caching.Memory;
 | 
			
		||||
using Microsoft.Extensions.Logging;
 | 
			
		||||
using Microsoft.Extensions.Options;
 | 
			
		||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
 | 
			
		||||
using StellaOps.Excititor.Core;
 | 
			
		||||
using System.IO.Abstractions;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
 | 
			
		||||
 | 
			
		||||
public sealed class CiscoProviderMetadataLoader
 | 
			
		||||
{
 | 
			
		||||
    public const string CacheKey = "StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata";
 | 
			
		||||
 | 
			
		||||
    private readonly IHttpClientFactory _httpClientFactory;
 | 
			
		||||
    private readonly IMemoryCache _memoryCache;
 | 
			
		||||
    private readonly ILogger<CiscoProviderMetadataLoader> _logger;
 | 
			
		||||
    private readonly CiscoConnectorOptions _options;
 | 
			
		||||
    private readonly IFileSystem _fileSystem;
 | 
			
		||||
    private readonly JsonSerializerOptions _serializerOptions;
 | 
			
		||||
    private readonly SemaphoreSlim _semaphore = new(1, 1);
 | 
			
		||||
 | 
			
		||||
    public CiscoProviderMetadataLoader(
 | 
			
		||||
        IHttpClientFactory httpClientFactory,
 | 
			
		||||
        IMemoryCache memoryCache,
 | 
			
		||||
        IOptions<CiscoConnectorOptions> options,
 | 
			
		||||
        ILogger<CiscoProviderMetadataLoader> logger,
 | 
			
		||||
        IFileSystem? fileSystem = null)
 | 
			
		||||
    {
 | 
			
		||||
        _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
 | 
			
		||||
        _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
 | 
			
		||||
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 | 
			
		||||
        ArgumentNullException.ThrowIfNull(options);
 | 
			
		||||
        _options = options.Value ?? throw new ArgumentNullException(nameof(options));
 | 
			
		||||
        _fileSystem = fileSystem ?? new FileSystem();
 | 
			
		||||
        _serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
 | 
			
		||||
        {
 | 
			
		||||
            PropertyNameCaseInsensitive = true,
 | 
			
		||||
            ReadCommentHandling = JsonCommentHandling.Skip,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async Task<CiscoProviderMetadataResult> LoadAsync(CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out var cached) && cached is not null && !cached.IsExpired())
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogDebug("Returning cached Cisco provider metadata (expires {Expires}).", cached.ExpiresAt);
 | 
			
		||||
            return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            if (_memoryCache.TryGetValue<CacheEntry>(CacheKey, out cached) && cached is not null && !cached.IsExpired())
 | 
			
		||||
            {
 | 
			
		||||
                return new CiscoProviderMetadataResult(cached.Provider, cached.FetchedAt, cached.FromOffline, true);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            CacheEntry? previous = cached;
 | 
			
		||||
 | 
			
		||||
            if (!_options.PreferOfflineSnapshot)
 | 
			
		||||
            {
 | 
			
		||||
                var network = await TryFetchFromNetworkAsync(previous, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
                if (network is not null)
 | 
			
		||||
                {
 | 
			
		||||
                    StoreCache(network);
 | 
			
		||||
                    return new CiscoProviderMetadataResult(network.Provider, network.FetchedAt, false, false);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var offline = TryLoadFromOffline();
 | 
			
		||||
            if (offline is not null)
 | 
			
		||||
            {
 | 
			
		||||
                var entry = offline with
 | 
			
		||||
                {
 | 
			
		||||
                    FetchedAt = DateTimeOffset.UtcNow,
 | 
			
		||||
                    ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
 | 
			
		||||
                    FromOffline = true,
 | 
			
		||||
                };
 | 
			
		||||
                StoreCache(entry);
 | 
			
		||||
                return new CiscoProviderMetadataResult(entry.Provider, entry.FetchedAt, true, false);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            throw new InvalidOperationException("Unable to load Cisco CSAF provider metadata from network or offline snapshot.");
 | 
			
		||||
        }
 | 
			
		||||
        finally
 | 
			
		||||
        {
 | 
			
		||||
            _semaphore.Release();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async Task<CacheEntry?> TryFetchFromNetworkAsync(CacheEntry? previous, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var client = _httpClientFactory.CreateClient(CiscoConnectorOptions.HttpClientName);
 | 
			
		||||
            using var request = new HttpRequestMessage(HttpMethod.Get, _options.MetadataUri);
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(_options.ApiToken))
 | 
			
		||||
            {
 | 
			
		||||
                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiToken);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!string.IsNullOrWhiteSpace(previous?.ETag) && EntityTagHeaderValue.TryParse(previous.ETag, out var etag))
 | 
			
		||||
            {
 | 
			
		||||
                request.Headers.IfNoneMatch.Add(etag);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (response.StatusCode == HttpStatusCode.NotModified && previous is not null)
 | 
			
		||||
            {
 | 
			
		||||
                _logger.LogDebug("Cisco provider metadata not modified (etag {ETag}).", previous.ETag);
 | 
			
		||||
                return previous with
 | 
			
		||||
                {
 | 
			
		||||
                    FetchedAt = DateTimeOffset.UtcNow,
 | 
			
		||||
                    ExpiresAt = DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            response.EnsureSuccessStatusCode();
 | 
			
		||||
            var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 | 
			
		||||
            var provider = ParseProvider(payload);
 | 
			
		||||
            var etagHeader = response.Headers.ETag?.ToString();
 | 
			
		||||
 | 
			
		||||
            if (_options.PersistOfflineSnapshot && !string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
 | 
			
		||||
            {
 | 
			
		||||
                try
 | 
			
		||||
                {
 | 
			
		||||
                    _fileSystem.File.WriteAllText(_options.OfflineSnapshotPath, payload);
 | 
			
		||||
                    _logger.LogDebug("Persisted Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
 | 
			
		||||
                }
 | 
			
		||||
                catch (Exception ex)
 | 
			
		||||
                {
 | 
			
		||||
                    _logger.LogWarning(ex, "Failed to persist Cisco metadata snapshot to {Path}.", _options.OfflineSnapshotPath);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return new CacheEntry(
 | 
			
		||||
                provider,
 | 
			
		||||
                DateTimeOffset.UtcNow,
 | 
			
		||||
                DateTimeOffset.UtcNow + _options.MetadataCacheDuration,
 | 
			
		||||
                etagHeader,
 | 
			
		||||
                FromOffline: false);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex) when (ex is not OperationCanceledException && !_options.PreferOfflineSnapshot)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning(ex, "Failed to fetch Cisco provider metadata from {Uri}; falling back to offline snapshot when available.", _options.MetadataUri);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private CacheEntry? TryLoadFromOffline()
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(_options.OfflineSnapshotPath))
 | 
			
		||||
        {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!_fileSystem.File.Exists(_options.OfflineSnapshotPath))
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogWarning("Cisco offline snapshot path {Path} does not exist.", _options.OfflineSnapshotPath);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var payload = _fileSystem.File.ReadAllText(_options.OfflineSnapshotPath);
 | 
			
		||||
            var provider = ParseProvider(payload);
 | 
			
		||||
            return new CacheEntry(provider, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow + _options.MetadataCacheDuration, null, true);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.LogError(ex, "Failed to load Cisco provider metadata from offline snapshot {Path}.", _options.OfflineSnapshotPath);
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private VexProvider ParseProvider(string payload)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(payload))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Cisco provider metadata payload was empty.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ProviderMetadataDocument? document;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            document = JsonSerializer.Deserialize<ProviderMetadataDocument>(payload, _serializerOptions);
 | 
			
		||||
        }
 | 
			
		||||
        catch (JsonException ex)
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Failed to parse Cisco provider metadata.", ex);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (document?.Metadata?.Publisher?.ContactDetails is null || string.IsNullOrWhiteSpace(document.Metadata.Publisher.ContactDetails.Id))
 | 
			
		||||
        {
 | 
			
		||||
            throw new InvalidOperationException("Cisco provider metadata did not include a publisher identifier.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var discovery = new VexProviderDiscovery(document.Discovery?.WellKnown, document.Discovery?.RolIe);
 | 
			
		||||
        var trust = document.Trust is null
 | 
			
		||||
            ? VexProviderTrust.Default
 | 
			
		||||
            : new VexProviderTrust(
 | 
			
		||||
                document.Trust.Weight ?? 1.0,
 | 
			
		||||
                document.Trust.Cosign is null ? null : new VexCosignTrust(document.Trust.Cosign.Issuer ?? string.Empty, document.Trust.Cosign.IdentityPattern ?? string.Empty),
 | 
			
		||||
                document.Trust.PgpFingerprints ?? Enumerable.Empty<string>());
 | 
			
		||||
 | 
			
		||||
        var directories = document.Distributions?.Directories is null
 | 
			
		||||
            ? Enumerable.Empty<Uri>()
 | 
			
		||||
            : document.Distributions.Directories
 | 
			
		||||
                .Where(static s => !string.IsNullOrWhiteSpace(s))
 | 
			
		||||
                .Select(static s => Uri.TryCreate(s, UriKind.Absolute, out var uri) ? uri : null)
 | 
			
		||||
                .Where(static uri => uri is not null)!
 | 
			
		||||
                .Select(static uri => uri!);
 | 
			
		||||
 | 
			
		||||
        return new VexProvider(
 | 
			
		||||
            id: document.Metadata.Publisher.ContactDetails.Id,
 | 
			
		||||
            displayName: document.Metadata.Publisher.Name ?? document.Metadata.Publisher.ContactDetails.Id,
 | 
			
		||||
            kind: document.Metadata.Publisher.Category?.Equals("vendor", StringComparison.OrdinalIgnoreCase) == true ? VexProviderKind.Vendor : VexProviderKind.Hub,
 | 
			
		||||
            baseUris: directories,
 | 
			
		||||
            discovery: discovery,
 | 
			
		||||
            trust: trust,
 | 
			
		||||
            enabled: true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void StoreCache(CacheEntry entry)
 | 
			
		||||
    {
 | 
			
		||||
        var options = new MemoryCacheEntryOptions
 | 
			
		||||
        {
 | 
			
		||||
            AbsoluteExpiration = entry.ExpiresAt,
 | 
			
		||||
        };
 | 
			
		||||
        _memoryCache.Set(CacheKey, entry, options);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private sealed record CacheEntry(
 | 
			
		||||
        VexProvider Provider,
 | 
			
		||||
        DateTimeOffset FetchedAt,
 | 
			
		||||
        DateTimeOffset ExpiresAt,
 | 
			
		||||
        string? ETag,
 | 
			
		||||
        bool FromOffline)
 | 
			
		||||
    {
 | 
			
		||||
        public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
public sealed record CiscoProviderMetadataResult(
 | 
			
		||||
    VexProvider Provider,
 | 
			
		||||
    DateTimeOffset FetchedAt,
 | 
			
		||||
    bool FromOfflineSnapshot,
 | 
			
		||||
    bool ServedFromCache);
 | 
			
		||||
 | 
			
		||||
#region document models
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataDocument
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("metadata")]
 | 
			
		||||
    public ProviderMetadataMetadata Metadata { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("discovery")]
 | 
			
		||||
    public ProviderMetadataDiscovery? Discovery { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("trust")]
 | 
			
		||||
    public ProviderMetadataTrust? Trust { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("distributions")]
 | 
			
		||||
    public ProviderMetadataDistributions? Distributions { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataMetadata
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("publisher")]
 | 
			
		||||
    public ProviderMetadataPublisher Publisher { get; set; } = new();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataPublisher
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("name")]
 | 
			
		||||
    public string? Name { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("category")]
 | 
			
		||||
    public string? Category { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("contact_details")]
 | 
			
		||||
    public ProviderMetadataPublisherContact ContactDetails { get; set; } = new();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataPublisherContact
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("id")]
 | 
			
		||||
    public string? Id { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataDiscovery
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("well_known")]
 | 
			
		||||
    public Uri? WellKnown { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("rolie")]
 | 
			
		||||
    public Uri? RolIe { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataTrust
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("weight")]
 | 
			
		||||
    public double? Weight { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("cosign")]
 | 
			
		||||
    public ProviderMetadataTrustCosign? Cosign { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("pgp_fingerprints")]
 | 
			
		||||
    public string[]? PgpFingerprints { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataTrustCosign
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("issuer")]
 | 
			
		||||
    public string? Issuer { get; set; }
 | 
			
		||||
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("identity_pattern")]
 | 
			
		||||
    public string? IdentityPattern { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class ProviderMetadataDistributions
 | 
			
		||||
{
 | 
			
		||||
    [System.Text.Json.Serialization.JsonPropertyName("directories")]
 | 
			
		||||
    public string[]? Directories { get; set; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#endregion
 | 
			
		||||
		Reference in New Issue
	
	Block a user