// ----------------------------------------------------------------------------- // 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; /// /// Static class for generating Valkey cache keys for canonical advisories. /// /// /// Key Schema: /// /// 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 /// /// public static class AdvisoryCacheKeys { /// /// Default key prefix for all cache keys. /// public const string DefaultPrefix = "concelier:"; public const int MaxPurlKeyLength = 500; /// /// Key for advisory by merge hash. /// Format: {prefix}advisory:{mergeHash} /// public static string Advisory(string mergeHash, string prefix = DefaultPrefix) => $"{prefix}advisory:{mergeHash}"; /// /// Key for the hot advisory sorted set. /// Format: {prefix}rank:hot /// public static string HotSet(string prefix = DefaultPrefix) => $"{prefix}rank:hot"; /// /// Key for PURL index set. /// Format: {prefix}by:purl:{normalizedPurl} /// /// The PURL (will be normalized). /// Key prefix. public static string ByPurl(string purl, string prefix = DefaultPrefix) => $"{prefix}by:purl:{NormalizePurl(purl)}"; /// /// Key for CVE mapping. /// Format: {prefix}by:cve:{cveId} /// /// The CVE identifier (case-insensitive). /// Key prefix. public static string ByCve(string cve, string prefix = DefaultPrefix) => $"{prefix}by:cve:{cve.ToUpperInvariant()}"; /// /// Key for cache hit counter. /// Format: {prefix}cache:stats:hits /// public static string StatsHits(string prefix = DefaultPrefix) => $"{prefix}cache:stats:hits"; /// /// Key for cache miss counter. /// Format: {prefix}cache:stats:misses /// public static string StatsMisses(string prefix = DefaultPrefix) => $"{prefix}cache:stats:misses"; /// /// Key for last warmup timestamp. /// Format: {prefix}cache:warmup:last /// public static string WarmupLast(string prefix = DefaultPrefix) => $"{prefix}cache:warmup:last"; /// /// Key for warmup lock (for distributed coordination). /// Format: {prefix}cache:warmup:lock /// public static string WarmupLock(string prefix = DefaultPrefix) => $"{prefix}cache:warmup:lock"; /// /// Key for total cached advisories gauge. /// Format: {prefix}cache:stats:count /// public static string StatsCount(string prefix = DefaultPrefix) => $"{prefix}cache:stats:count"; /// /// Pattern to match all advisory keys (for scanning/cleanup). /// Format: {prefix}advisory:* /// public static string AdvisoryPattern(string prefix = DefaultPrefix) => $"{prefix}advisory:*"; /// /// Pattern to match all PURL index keys (for scanning/cleanup). /// Format: {prefix}by:purl:* /// public static string PurlIndexPattern(string prefix = DefaultPrefix) => $"{prefix}by:purl:*"; /// /// Pattern to match all CVE mapping keys (for scanning/cleanup). /// Format: {prefix}by:cve:* /// public static string CveMappingPattern(string prefix = DefaultPrefix) => $"{prefix}by:cve:*"; /// /// Normalizes a PURL for use as a cache key. /// /// The PURL to normalize. /// Normalized PURL safe for use in cache keys. /// /// 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 /// 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; } /// /// Extracts the merge hash from an advisory key. /// /// The full advisory key. /// The key prefix used. /// The merge hash, or null if key doesn't match expected format. 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; } /// /// Extracts the PURL from a PURL index key. /// /// The full PURL index key. /// The key prefix used. /// The normalized PURL, or null if key doesn't match expected format. 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; } /// /// Extracts the CVE from a CVE mapping key. /// /// The full CVE mapping key. /// The key prefix used. /// The CVE identifier, or null if key doesn't match expected format. 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); } }