Files
git.stella-ops.org/src/Concelier/__Libraries/StellaOps.Concelier.Cache.Valkey/AdvisoryCacheKeys.cs
StellaOps Bot 83c37243e0 save progress
2026-01-03 11:02:24 +02:00

248 lines
8.4 KiB
C#

// -----------------------------------------------------------------------------
// AdvisoryCacheKeys.cs
// Sprint: SPRINT_8200_0013_0001_GW_valkey_advisory_cache
// Task: VCACHE-8200-004, VCACHE-8200-005, VCACHE-8200-006, VCACHE-8200-007, VCACHE-8200-008
// Description: Key schema for Concelier Valkey cache
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Concelier.Cache.Valkey;
/// <summary>
/// Static class for generating Valkey cache keys for canonical advisories.
/// </summary>
/// <remarks>
/// Key Schema:
/// <code>
/// advisory:{merge_hash} -> JSON(CanonicalAdvisory) - TTL based on interest_score
/// rank:hot -> ZSET { merge_hash: interest_score } - max 10,000 entries
/// by:purl:{normalized_purl} -> SET { merge_hash, ... } - TTL 24h
/// by:cve:{cve_id} -> STRING merge_hash - TTL 24h
/// cache:stats:hits -> INCR counter
/// cache:stats:misses -> INCR counter
/// cache:warmup:last -> STRING ISO8601 timestamp
/// </code>
/// </remarks>
public static class AdvisoryCacheKeys
{
/// <summary>
/// Default key prefix for all cache keys.
/// </summary>
public const string DefaultPrefix = "concelier:";
public const int MaxPurlKeyLength = 500;
/// <summary>
/// Key for advisory by merge hash.
/// Format: {prefix}advisory:{mergeHash}
/// </summary>
public static string Advisory(string mergeHash, string prefix = DefaultPrefix)
=> $"{prefix}advisory:{mergeHash}";
/// <summary>
/// Key for the hot advisory sorted set.
/// Format: {prefix}rank:hot
/// </summary>
public static string HotSet(string prefix = DefaultPrefix)
=> $"{prefix}rank:hot";
/// <summary>
/// Key for PURL index set.
/// Format: {prefix}by:purl:{normalizedPurl}
/// </summary>
/// <param name="purl">The PURL (will be normalized).</param>
/// <param name="prefix">Key prefix.</param>
public static string ByPurl(string purl, string prefix = DefaultPrefix)
=> $"{prefix}by:purl:{NormalizePurl(purl)}";
/// <summary>
/// Key for CVE mapping.
/// Format: {prefix}by:cve:{cveId}
/// </summary>
/// <param name="cve">The CVE identifier (case-insensitive).</param>
/// <param name="prefix">Key prefix.</param>
public static string ByCve(string cve, string prefix = DefaultPrefix)
=> $"{prefix}by:cve:{cve.ToUpperInvariant()}";
/// <summary>
/// Key for cache hit counter.
/// Format: {prefix}cache:stats:hits
/// </summary>
public static string StatsHits(string prefix = DefaultPrefix)
=> $"{prefix}cache:stats:hits";
/// <summary>
/// Key for cache miss counter.
/// Format: {prefix}cache:stats:misses
/// </summary>
public static string StatsMisses(string prefix = DefaultPrefix)
=> $"{prefix}cache:stats:misses";
/// <summary>
/// Key for last warmup timestamp.
/// Format: {prefix}cache:warmup:last
/// </summary>
public static string WarmupLast(string prefix = DefaultPrefix)
=> $"{prefix}cache:warmup:last";
/// <summary>
/// Key for warmup lock (for distributed coordination).
/// Format: {prefix}cache:warmup:lock
/// </summary>
public static string WarmupLock(string prefix = DefaultPrefix)
=> $"{prefix}cache:warmup:lock";
/// <summary>
/// Key for total cached advisories gauge.
/// Format: {prefix}cache:stats:count
/// </summary>
public static string StatsCount(string prefix = DefaultPrefix)
=> $"{prefix}cache:stats:count";
/// <summary>
/// Pattern to match all advisory keys (for scanning/cleanup).
/// Format: {prefix}advisory:*
/// </summary>
public static string AdvisoryPattern(string prefix = DefaultPrefix)
=> $"{prefix}advisory:*";
/// <summary>
/// Pattern to match all PURL index keys (for scanning/cleanup).
/// Format: {prefix}by:purl:*
/// </summary>
public static string PurlIndexPattern(string prefix = DefaultPrefix)
=> $"{prefix}by:purl:*";
/// <summary>
/// Pattern to match all CVE mapping keys (for scanning/cleanup).
/// Format: {prefix}by:cve:*
/// </summary>
public static string CveMappingPattern(string prefix = DefaultPrefix)
=> $"{prefix}by:cve:*";
/// <summary>
/// Normalizes a PURL for use as a cache key.
/// </summary>
/// <param name="purl">The PURL to normalize.</param>
/// <returns>Normalized PURL safe for use in cache keys.</returns>
/// <remarks>
/// Normalization:
/// 1. Lowercase the entire PURL
/// 2. Replace special characters that may cause issues in keys
/// 3. Truncate very long PURLs to prevent oversized keys
/// </remarks>
public static string NormalizePurl(string purl)
{
if (string.IsNullOrWhiteSpace(purl))
{
return string.Empty;
}
// Normalize to lowercase
var normalized = purl.ToLowerInvariant();
// Replace characters that could cause issues in Redis keys
// Redis keys should avoid spaces and some special chars for simplicity
var sb = new StringBuilder(normalized.Length);
foreach (var c in normalized)
{
// Allow alphanumeric, standard PURL chars: : / @ . - _ %
if (char.IsLetterOrDigit(c) ||
c is ':' or '/' or '@' or '.' or '-' or '_' or '%')
{
sb.Append(c);
}
else
{
// Replace other chars with underscore
sb.Append('_');
}
}
var normalizedKey = sb.ToString();
if (normalizedKey.Length <= MaxPurlKeyLength)
{
return normalizedKey;
}
var hash = ComputeHash(normalizedKey);
var prefixLength = Math.Max(0, MaxPurlKeyLength - hash.Length - 1);
if (prefixLength == 0)
{
return hash;
}
return normalizedKey[..prefixLength] + "-" + hash;
}
/// <summary>
/// Extracts the merge hash from an advisory key.
/// </summary>
/// <param name="key">The full advisory key.</param>
/// <param name="prefix">The key prefix used.</param>
/// <returns>The merge hash, or null if key doesn't match expected format.</returns>
public static string? ExtractMergeHash(string key, string prefix = DefaultPrefix)
{
var expectedStart = $"{prefix}advisory:";
if (key.StartsWith(expectedStart, StringComparison.Ordinal))
{
return key[expectedStart.Length..];
}
return null;
}
/// <summary>
/// Extracts the PURL from a PURL index key.
/// </summary>
/// <param name="key">The full PURL index key.</param>
/// <param name="prefix">The key prefix used.</param>
/// <returns>The normalized PURL, or null if key doesn't match expected format.</returns>
public static string? ExtractPurl(string key, string prefix = DefaultPrefix)
{
var expectedStart = $"{prefix}by:purl:";
if (key.StartsWith(expectedStart, StringComparison.Ordinal))
{
return key[expectedStart.Length..];
}
return null;
}
/// <summary>
/// Extracts the CVE from a CVE mapping key.
/// </summary>
/// <param name="key">The full CVE mapping key.</param>
/// <param name="prefix">The key prefix used.</param>
/// <returns>The CVE identifier, or null if key doesn't match expected format.</returns>
public static string? ExtractCve(string key, string prefix = DefaultPrefix)
{
var expectedStart = $"{prefix}by:cve:";
if (key.StartsWith(expectedStart, StringComparison.Ordinal))
{
return key[expectedStart.Length..];
}
return null;
}
private static string ComputeHash(string value)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(value);
var hash = sha.ComputeHash(bytes);
return ToLowerHex(hash);
}
private static string ToLowerHex(byte[] bytes)
{
const string hex = "0123456789abcdef";
var chars = new char[bytes.Length * 2];
for (var i = 0; i < bytes.Length; i++)
{
var b = bytes[i];
chars[i * 2] = hex[b >> 4];
chars[i * 2 + 1] = hex[b & 0xF];
}
return new string(chars);
}
}