// // Copyright (c) StellaOps. Licensed under BUSL-1.1. // using DotNet.Globbing; using System.Collections.Immutable; namespace StellaOps.Facet; /// /// Default implementation of . /// public sealed class FacetDriftDetector : IFacetDriftDetector { private readonly TimeProvider _timeProvider; /// /// Initializes a new instance of the class. /// /// Time provider for timestamps. public FacetDriftDetector(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } /// public Task DetectDriftAsync( FacetSeal baseline, FacetExtractionResult current, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(baseline); ArgumentNullException.ThrowIfNull(current); var drifts = new List(); // Build lookup for current facets var currentFacetLookup = current.Facets.ToDictionary(f => f.FacetId); // Process each baseline facet foreach (var baselineFacet in baseline.Facets) { ct.ThrowIfCancellationRequested(); if (currentFacetLookup.TryGetValue(baselineFacet.FacetId, out var currentFacet)) { // Both have this facet - compute drift var drift = ComputeFacetDrift( baselineFacet, currentFacet, baseline.GetQuota(baselineFacet.FacetId)); drifts.Add(drift); currentFacetLookup.Remove(baselineFacet.FacetId); } else { // Facet was removed entirely - all files are "removed" var drift = CreateRemovedFacetDrift(baselineFacet, baseline.GetQuota(baselineFacet.FacetId)); drifts.Add(drift); } } // Remaining current facets are new foreach (var newFacet in currentFacetLookup.Values) { var drift = CreateNewFacetDrift(newFacet); drifts.Add(drift); } var overallVerdict = ComputeOverallVerdict(drifts); var report = new FacetDriftReport { ImageDigest = baseline.ImageDigest, BaselineSealId = baseline.CombinedMerkleRoot, AnalyzedAt = _timeProvider.GetUtcNow(), FacetDrifts = [.. drifts], OverallVerdict = overallVerdict }; return Task.FromResult(report); } /// public Task DetectDriftAsync( FacetSeal baseline, FacetSeal current, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(baseline); ArgumentNullException.ThrowIfNull(current); var drifts = new List(); // Build lookup for current facets var currentFacetLookup = current.Facets.ToDictionary(f => f.FacetId); // Process each baseline facet foreach (var baselineFacet in baseline.Facets) { ct.ThrowIfCancellationRequested(); if (currentFacetLookup.TryGetValue(baselineFacet.FacetId, out var currentFacet)) { // Both have this facet - compute drift var drift = ComputeFacetDrift( baselineFacet, currentFacet, baseline.GetQuota(baselineFacet.FacetId)); drifts.Add(drift); currentFacetLookup.Remove(baselineFacet.FacetId); } else { // Facet was removed entirely var drift = CreateRemovedFacetDrift(baselineFacet, baseline.GetQuota(baselineFacet.FacetId)); drifts.Add(drift); } } // Remaining current facets are new foreach (var newFacet in currentFacetLookup.Values) { var drift = CreateNewFacetDrift(newFacet); drifts.Add(drift); } var overallVerdict = ComputeOverallVerdict(drifts); var report = new FacetDriftReport { ImageDigest = current.ImageDigest, BaselineSealId = baseline.CombinedMerkleRoot, AnalyzedAt = _timeProvider.GetUtcNow(), FacetDrifts = [.. drifts], OverallVerdict = overallVerdict }; return Task.FromResult(report); } private static FacetDrift ComputeFacetDrift( FacetEntry baseline, FacetEntry current, FacetQuota quota) { // Quick check: if Merkle roots match, no drift if (baseline.MerkleRoot == current.MerkleRoot) { return FacetDrift.NoDrift(baseline.FacetId, baseline.FileCount); } // Need file-level comparison if (baseline.Files is null || current.Files is null) { // Can't compute detailed drift without file entries // Fall back to root-level drift indication return new FacetDrift { FacetId = baseline.FacetId, Added = [], Removed = [], Modified = [], DriftScore = 100m, // Max drift since we can't compute details QuotaVerdict = quota.Action switch { QuotaExceededAction.Block => QuotaVerdict.Blocked, QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex, _ => QuotaVerdict.Warning }, BaselineFileCount = baseline.FileCount }; } // Build allowlist globs var allowlistGlobs = quota.AllowlistGlobs .Select(p => Glob.Parse(p)) .ToList(); bool IsAllowlisted(string path) => allowlistGlobs.Any(g => g.IsMatch(path)); // Build file dictionaries var baselineFiles = baseline.Files.Value.ToDictionary(f => f.Path); var currentFiles = current.Files.Value.ToDictionary(f => f.Path); var added = new List(); var removed = new List(); var modified = new List(); // Find additions and modifications foreach (var (path, currentFile) in currentFiles) { if (IsAllowlisted(path)) { continue; } if (baselineFiles.TryGetValue(path, out var baselineFile)) { // File exists in both - check for modification if (baselineFile.Digest != currentFile.Digest) { modified.Add(new FacetFileModification( path, baselineFile.Digest, currentFile.Digest, baselineFile.SizeBytes, currentFile.SizeBytes)); } } else { // File is new added.Add(currentFile); } } // Find removals foreach (var (path, baselineFile) in baselineFiles) { if (IsAllowlisted(path)) { continue; } if (!currentFiles.ContainsKey(path)) { removed.Add(baselineFile); } } var totalChanges = added.Count + removed.Count + modified.Count; var driftScore = ComputeDriftScore( added.Count, removed.Count, modified.Count, baseline.FileCount); var churnPercent = baseline.FileCount > 0 ? totalChanges / (decimal)baseline.FileCount * 100 : added.Count > 0 ? 100m : 0m; var verdict = EvaluateQuota(quota, churnPercent, totalChanges); return new FacetDrift { FacetId = baseline.FacetId, Added = [.. added], Removed = [.. removed], Modified = [.. modified], DriftScore = driftScore, QuotaVerdict = verdict, BaselineFileCount = baseline.FileCount }; } private static FacetDrift CreateRemovedFacetDrift(FacetEntry baseline, FacetQuota quota) { var removedFiles = baseline.Files?.ToImmutableArray() ?? []; var verdict = quota.Action switch { QuotaExceededAction.Block => QuotaVerdict.Blocked, QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex, _ => QuotaVerdict.Warning }; return new FacetDrift { FacetId = baseline.FacetId, Added = [], Removed = removedFiles, Modified = [], DriftScore = 100m, QuotaVerdict = verdict, BaselineFileCount = baseline.FileCount }; } private static FacetDrift CreateNewFacetDrift(FacetEntry newFacet) { var addedFiles = newFacet.Files?.ToImmutableArray() ?? []; return new FacetDrift { FacetId = newFacet.FacetId, Added = addedFiles, Removed = [], Modified = [], DriftScore = 100m, // All new = max drift from baseline perspective QuotaVerdict = QuotaVerdict.Warning, // New facets get warning by default BaselineFileCount = 0 }; } private static decimal ComputeDriftScore( int added, int removed, int modified, int baselineCount) { if (baselineCount == 0) { return added > 0 ? 100m : 0m; } // Weighted score: additions=1.0, removals=1.0, modifications=0.5 var weightedChanges = added + removed + (modified * 0.5m); var score = weightedChanges / baselineCount * 100; return Math.Min(100m, score); } private static QuotaVerdict EvaluateQuota(FacetQuota quota, decimal churnPercent, int totalChanges) { var exceeds = churnPercent > quota.MaxChurnPercent || totalChanges > quota.MaxChangedFiles; if (!exceeds) { return QuotaVerdict.Ok; } return quota.Action switch { QuotaExceededAction.Block => QuotaVerdict.Blocked, QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex, _ => QuotaVerdict.Warning }; } private static QuotaVerdict ComputeOverallVerdict(List drifts) { // Return worst verdict if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.Blocked)) { return QuotaVerdict.Blocked; } if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.RequiresVex)) { return QuotaVerdict.RequiresVex; } if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.Warning)) { return QuotaVerdict.Warning; } return QuotaVerdict.Ok; } }