using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Runtime.CompilerServices;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Merge.Identity;
///
/// Builds an alias-driven identity graph that groups advisories referring to the same vulnerability.
///
public sealed class AdvisoryIdentityResolver
{
private static readonly string[] CanonicalAliasPriority =
{
AliasSchemes.Cve,
AliasSchemes.Rhsa,
AliasSchemes.Usn,
AliasSchemes.Dsa,
AliasSchemes.SuseSu,
AliasSchemes.Msrc,
AliasSchemes.CiscoSa,
AliasSchemes.OracleCpu,
AliasSchemes.Vmsa,
AliasSchemes.Apsb,
AliasSchemes.Apa,
AliasSchemes.AppleHt,
AliasSchemes.ChromiumPost,
AliasSchemes.Icsa,
AliasSchemes.Jvndb,
AliasSchemes.Jvn,
AliasSchemes.Bdu,
AliasSchemes.Vu,
AliasSchemes.Ghsa,
AliasSchemes.OsV,
};
///
/// Groups the provided advisories into identity clusters using normalized aliases.
///
public IReadOnlyList Resolve(IEnumerable advisories)
{
ArgumentNullException.ThrowIfNull(advisories);
var materialized = advisories
.Where(static advisory => advisory is not null)
.Distinct()
.ToArray();
if (materialized.Length == 0)
{
return Array.Empty();
}
var aliasIndex = BuildAliasIndex(materialized);
var visited = new HashSet();
var clusters = new List();
foreach (var advisory in materialized)
{
if (!visited.Add(advisory))
{
continue;
}
var component = TraverseComponent(advisory, visited, aliasIndex);
var key = DetermineCanonicalKey(component);
var aliases = component
.SelectMany(static entry => entry.Aliases)
.Select(static alias => new AliasIdentity(alias.Normalized, alias.Scheme));
clusters.Add(new AdvisoryIdentityCluster(key, component.Select(static entry => entry.Advisory), aliases));
}
return clusters
.OrderBy(static cluster => cluster.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static Dictionary> BuildAliasIndex(IEnumerable advisories)
{
var index = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var advisory in advisories)
{
foreach (var alias in ExtractAliases(advisory))
{
if (!index.TryGetValue(alias.Normalized, out var list))
{
list = new List();
index[alias.Normalized] = list;
}
list.Add(new AdvisoryAliasEntry(advisory, alias.Normalized, alias.Scheme));
}
}
return index;
}
private static IReadOnlyList TraverseComponent(
Advisory root,
HashSet visited,
Dictionary> aliasIndex)
{
var stack = new Stack();
stack.Push(root);
var bindings = new Dictionary(ReferenceEqualityComparer.Instance);
while (stack.Count > 0)
{
var advisory = stack.Pop();
if (!bindings.TryGetValue(advisory, out var binding))
{
binding = new AliasBinding(advisory);
bindings[advisory] = binding;
}
foreach (var alias in ExtractAliases(advisory))
{
binding.AddAlias(alias.Normalized, alias.Scheme);
if (!aliasIndex.TryGetValue(alias.Normalized, out var neighbors))
{
continue;
}
foreach (var neighbor in neighbors.Select(static entry => entry.Advisory))
{
if (visited.Add(neighbor))
{
stack.Push(neighbor);
}
if (!bindings.TryGetValue(neighbor, out var neighborBinding))
{
neighborBinding = new AliasBinding(neighbor);
bindings[neighbor] = neighborBinding;
}
neighborBinding.AddAlias(alias.Normalized, alias.Scheme);
}
}
}
return bindings.Values
.OrderBy(static binding => binding.Advisory.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static string DetermineCanonicalKey(IReadOnlyList component)
{
var aliases = component
.SelectMany(static binding => binding.Aliases)
.Where(static alias => !string.IsNullOrWhiteSpace(alias.Normalized))
.ToArray();
foreach (var scheme in CanonicalAliasPriority)
{
var candidate = aliases
.Where(alias => string.Equals(alias.Scheme, scheme, StringComparison.OrdinalIgnoreCase))
.Select(static alias => alias.Normalized)
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
if (candidate is not null)
{
return candidate;
}
}
var fallbackAlias = aliases
.Select(static alias => alias.Normalized)
.OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(fallbackAlias))
{
return fallbackAlias;
}
var advisoryKey = component
.Select(static binding => binding.Advisory.AdvisoryKey)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(advisoryKey))
{
return advisoryKey.Trim();
}
throw new InvalidOperationException("Unable to determine canonical advisory key for cluster.");
}
private static IEnumerable ExtractAliases(Advisory advisory)
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in EnumerateAliasCandidates(advisory))
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
var trimmed = candidate.Trim();
if (!seen.Add(trimmed))
{
continue;
}
if (AliasSchemeRegistry.TryNormalize(trimmed, out var normalized, out var scheme) &&
!string.IsNullOrWhiteSpace(normalized))
{
yield return new AliasProjection(normalized.Trim(), string.IsNullOrWhiteSpace(scheme) ? null : scheme);
}
else if (!string.IsNullOrWhiteSpace(normalized))
{
yield return new AliasProjection(normalized.Trim(), null);
}
}
}
private static IEnumerable EnumerateAliasCandidates(Advisory advisory)
{
if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey))
{
yield return advisory.AdvisoryKey;
}
if (!advisory.Aliases.IsDefaultOrEmpty)
{
foreach (var alias in advisory.Aliases)
{
if (!string.IsNullOrWhiteSpace(alias))
{
yield return alias;
}
}
}
}
private readonly record struct AdvisoryAliasEntry(Advisory Advisory, string Normalized, string? Scheme);
private readonly record struct AliasProjection(string Normalized, string? Scheme);
private sealed class AliasBinding
{
private readonly HashSet _aliases = new(HashSetAliasComparer.Instance);
public AliasBinding(Advisory advisory)
{
Advisory = advisory ?? throw new ArgumentNullException(nameof(advisory));
}
public Advisory Advisory { get; }
public IReadOnlyCollection Aliases => _aliases;
public void AddAlias(string normalized, string? scheme)
{
if (string.IsNullOrWhiteSpace(normalized))
{
return;
}
_aliases.Add(new AliasProjection(normalized.Trim(), scheme is null ? null : scheme.Trim()));
}
}
private sealed class HashSetAliasComparer : IEqualityComparer
{
public static readonly HashSetAliasComparer Instance = new();
public bool Equals(AliasProjection x, AliasProjection y)
=> string.Equals(x.Normalized, y.Normalized, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Scheme, y.Scheme, StringComparison.OrdinalIgnoreCase);
public int GetHashCode(AliasProjection obj)
{
var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Normalized);
if (!string.IsNullOrWhiteSpace(obj.Scheme))
{
hash = HashCode.Combine(hash, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Scheme));
}
return hash;
}
}
private sealed class ReferenceEqualityComparer : IEqualityComparer
where T : class
{
public static readonly ReferenceEqualityComparer Instance = new();
public bool Equals(T? x, T? y) => ReferenceEquals(x, y);
public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj);
}
}