355 lines
11 KiB
C#
355 lines
11 KiB
C#
// <copyright file="FacetDriftDetector.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
|
// </copyright>
|
|
|
|
|
|
using DotNet.Globbing;
|
|
using System.Collections.Immutable;
|
|
|
|
namespace StellaOps.Facet;
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="IFacetDriftDetector"/>.
|
|
/// </summary>
|
|
public sealed class FacetDriftDetector : IFacetDriftDetector
|
|
{
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="FacetDriftDetector"/> class.
|
|
/// </summary>
|
|
/// <param name="timeProvider">Time provider for timestamps.</param>
|
|
public FacetDriftDetector(TimeProvider? timeProvider = null)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<FacetDriftReport> DetectDriftAsync(
|
|
FacetSeal baseline,
|
|
FacetExtractionResult current,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(baseline);
|
|
ArgumentNullException.ThrowIfNull(current);
|
|
|
|
var drifts = new List<FacetDrift>();
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<FacetDriftReport> DetectDriftAsync(
|
|
FacetSeal baseline,
|
|
FacetSeal current,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(baseline);
|
|
ArgumentNullException.ThrowIfNull(current);
|
|
|
|
var drifts = new List<FacetDrift>();
|
|
|
|
// 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<FacetFileEntry>();
|
|
var removed = new List<FacetFileEntry>();
|
|
var modified = new List<FacetFileModification>();
|
|
|
|
// 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<FacetDrift> 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;
|
|
}
|
|
}
|