Restructure solution layout by module
This commit is contained in:
20
src/Scanner/__Libraries/StellaOps.Scanner.Diff/AGENTS.md
Normal file
20
src/Scanner/__Libraries/StellaOps.Scanner.Diff/AGENTS.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# StellaOps.Scanner.Diff — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver deterministic image-to-image component diffs grouped by layer with provenance signals that power policy previews, UI surfacing, and downstream scheduling.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain diff computation pipelines for inventory and usage SBOM views.
|
||||
- Ensure ordering, hashing, and serialization are stable across runs and hosts.
|
||||
- Capture layer provenance, usage flags, and supporting evidence for every change.
|
||||
- Provide JSON artifacts and helper APIs consumed by the Scanner WebService, Worker, CLI, and UI.
|
||||
|
||||
## Interfaces & Dependencies
|
||||
- Consumes normalized component fragments emitted by analyzers and usage signals from EntryTrace.
|
||||
- Emits diff models used by `StellaOps.Scanner.WebService` and persisted by `StellaOps.Scanner.Storage`.
|
||||
- Shares deterministic primitives from `StellaOps.Scanner.Core` once extended with component contracts.
|
||||
|
||||
## Testing Expectations
|
||||
- Golden diff fixtures for add/remove/version-change flows.
|
||||
- Determinism checks comparing shuffled inputs.
|
||||
- Layer attribution regression tests to guard provenance correctness.
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Diff;
|
||||
|
||||
public enum ComponentChangeKind
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
VersionChanged,
|
||||
MetadataChanged,
|
||||
}
|
||||
|
||||
public sealed record ComponentDiffRequest
|
||||
{
|
||||
public required ComponentGraph OldGraph { get; init; }
|
||||
|
||||
public required ComponentGraph NewGraph { get; init; }
|
||||
|
||||
public SbomView View { get; init; } = SbomView.Inventory;
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string? OldImageDigest { get; init; }
|
||||
= null;
|
||||
|
||||
public string? NewImageDigest { get; init; }
|
||||
= null;
|
||||
}
|
||||
|
||||
public sealed record ComponentChange
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public ComponentChangeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("componentKey")]
|
||||
public string ComponentKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("introducingLayer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? IntroducingLayer { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("removingLayer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? RemovingLayer { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("oldComponent")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public AggregatedComponent? OldComponent { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("newComponent")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public AggregatedComponent? NewComponent { get; init; }
|
||||
= null;
|
||||
}
|
||||
|
||||
public sealed record LayerDiff
|
||||
{
|
||||
[JsonPropertyName("layerDigest")]
|
||||
public string LayerDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("changes")]
|
||||
public ImmutableArray<ComponentChange> Changes { get; init; } = ImmutableArray<ComponentChange>.Empty;
|
||||
}
|
||||
|
||||
public sealed record DiffSummary
|
||||
{
|
||||
[JsonPropertyName("added")]
|
||||
public int Added { get; init; }
|
||||
|
||||
[JsonPropertyName("removed")]
|
||||
public int Removed { get; init; }
|
||||
|
||||
[JsonPropertyName("versionChanged")]
|
||||
public int VersionChanged { get; init; }
|
||||
|
||||
[JsonPropertyName("metadataChanged")]
|
||||
public int MetadataChanged { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ComponentDiffDocument
|
||||
{
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("view")]
|
||||
public SbomView View { get; init; }
|
||||
|
||||
[JsonPropertyName("oldImageDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? OldImageDigest { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("newImageDigest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? NewImageDigest { get; init; }
|
||||
= null;
|
||||
|
||||
[JsonPropertyName("summary")]
|
||||
public DiffSummary Summary { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("layers")]
|
||||
public ImmutableArray<LayerDiff> Layers { get; init; } = ImmutableArray<LayerDiff>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Utility;
|
||||
|
||||
namespace StellaOps.Scanner.Diff;
|
||||
|
||||
public sealed class ComponentDiffer
|
||||
{
|
||||
private static readonly StringComparer Ordinal = StringComparer.Ordinal;
|
||||
private const string UnknownLayerKey = "(unknown)";
|
||||
|
||||
public ComponentDiffDocument Compute(ComponentDiffRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var generatedAt = ScannerTimestamps.Normalize(request.GeneratedAt);
|
||||
var oldComponents = ToDictionary(FilterComponents(request.OldGraph, request.View));
|
||||
var newComponents = ToDictionary(FilterComponents(request.NewGraph, request.View));
|
||||
var layerOrder = BuildLayerOrder(request.OldGraph, request.NewGraph);
|
||||
|
||||
var changes = new List<ComponentChange>();
|
||||
var counters = new DiffCounters();
|
||||
|
||||
foreach (var (key, newComponent) in newComponents)
|
||||
{
|
||||
if (!oldComponents.TryGetValue(key, out var oldComponent))
|
||||
{
|
||||
changes.Add(new ComponentChange
|
||||
{
|
||||
Kind = ComponentChangeKind.Added,
|
||||
ComponentKey = key,
|
||||
IntroducingLayer = GetIntroducingLayer(newComponent),
|
||||
NewComponent = newComponent,
|
||||
});
|
||||
counters.Added++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var change = CompareComponents(oldComponent, newComponent, key);
|
||||
if (change is not null)
|
||||
{
|
||||
changes.Add(change);
|
||||
counters.Register(change.Kind);
|
||||
}
|
||||
|
||||
oldComponents.Remove(key);
|
||||
}
|
||||
|
||||
foreach (var (key, oldComponent) in oldComponents)
|
||||
{
|
||||
changes.Add(new ComponentChange
|
||||
{
|
||||
Kind = ComponentChangeKind.Removed,
|
||||
ComponentKey = key,
|
||||
RemovingLayer = GetRemovingLayer(oldComponent),
|
||||
OldComponent = oldComponent,
|
||||
});
|
||||
counters.Removed++;
|
||||
}
|
||||
|
||||
var layerGroups = changes
|
||||
.GroupBy(ResolveLayerKey, Ordinal)
|
||||
.OrderBy(group => layerOrder.TryGetValue(group.Key, out var position) ? position : int.MaxValue)
|
||||
.ThenBy(static group => group.Key, Ordinal)
|
||||
.Select(group => new LayerDiff
|
||||
{
|
||||
LayerDigest = group.Key,
|
||||
Changes = group
|
||||
.OrderBy(change => change.ComponentKey, Ordinal)
|
||||
.ThenBy(change => change.Kind)
|
||||
.ThenBy(change => change.NewComponent?.Identity.Version ?? change.OldComponent?.Identity.Version ?? string.Empty, Ordinal)
|
||||
.ToImmutableArray(),
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
var document = new ComponentDiffDocument
|
||||
{
|
||||
GeneratedAt = generatedAt,
|
||||
View = request.View,
|
||||
OldImageDigest = request.OldImageDigest,
|
||||
NewImageDigest = request.NewImageDigest,
|
||||
Summary = counters.ToSummary(),
|
||||
Layers = layerGroups,
|
||||
};
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static ComponentChange? CompareComponents(AggregatedComponent oldComponent, AggregatedComponent newComponent, string key)
|
||||
{
|
||||
var versionChanged = !string.Equals(oldComponent.Identity.Version, newComponent.Identity.Version, StringComparison.Ordinal);
|
||||
if (versionChanged)
|
||||
{
|
||||
return new ComponentChange
|
||||
{
|
||||
Kind = ComponentChangeKind.VersionChanged,
|
||||
ComponentKey = key,
|
||||
IntroducingLayer = GetIntroducingLayer(newComponent),
|
||||
RemovingLayer = GetRemovingLayer(oldComponent),
|
||||
OldComponent = oldComponent,
|
||||
NewComponent = newComponent,
|
||||
};
|
||||
}
|
||||
|
||||
var metadataChanged = HasMetadataChanged(oldComponent, newComponent);
|
||||
if (!metadataChanged)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ComponentChange
|
||||
{
|
||||
Kind = ComponentChangeKind.MetadataChanged,
|
||||
ComponentKey = key,
|
||||
IntroducingLayer = GetIntroducingLayer(newComponent),
|
||||
RemovingLayer = GetRemovingLayer(oldComponent),
|
||||
OldComponent = oldComponent,
|
||||
NewComponent = newComponent,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasMetadataChanged(AggregatedComponent oldComponent, AggregatedComponent newComponent)
|
||||
{
|
||||
if (!string.Equals(oldComponent.Identity.Name, newComponent.Identity.Name, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(oldComponent.Identity.ComponentType, newComponent.Identity.ComponentType, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(oldComponent.Identity.Group, newComponent.Identity.Group, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(oldComponent.Identity.Purl, newComponent.Identity.Purl, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!oldComponent.Dependencies.SequenceEqual(newComponent.Dependencies, Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!oldComponent.LayerDigests.SequenceEqual(newComponent.LayerDigests, Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!oldComponent.Evidence.SequenceEqual(newComponent.Evidence))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (UsageChanged(oldComponent.Usage, newComponent.Usage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!MetadataEquals(oldComponent.Metadata, newComponent.Metadata))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool UsageChanged(ComponentUsage oldUsage, ComponentUsage newUsage)
|
||||
{
|
||||
if (oldUsage.UsedByEntrypoint != newUsage.UsedByEntrypoint)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !oldUsage.Entrypoints.SequenceEqual(newUsage.Entrypoints, Ordinal);
|
||||
}
|
||||
|
||||
private static bool MetadataEquals(ComponentMetadata? left, ComponentMetadata? right)
|
||||
{
|
||||
if (left is null && right is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(left.Scope, right.Scope, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SequenceEqual(left.Licenses, right.Licenses))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(NormalizeBuildId(left.BuildId), NormalizeBuildId(right.BuildId), StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DictionaryEqual(left.Properties, right.Properties))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SequenceEqual(IReadOnlyList<string>? left, IReadOnlyList<string>? right)
|
||||
{
|
||||
if (left is null && right is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.Count; i++)
|
||||
{
|
||||
if (!string.Equals(left[i], right[i], StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool DictionaryEqual(IReadOnlyDictionary<string, string>? left, IReadOnlyDictionary<string, string>? right)
|
||||
{
|
||||
if (left is null && right is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.Count != right.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in left)
|
||||
{
|
||||
if (!right.TryGetValue(key, out var rightValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(value, rightValue, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeBuildId(string? buildId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(buildId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = buildId.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Dictionary<string, AggregatedComponent> ToDictionary(ImmutableArray<AggregatedComponent> components)
|
||||
{
|
||||
var dictionary = new Dictionary<string, AggregatedComponent>(components.Length, Ordinal);
|
||||
foreach (var component in components)
|
||||
{
|
||||
dictionary[component.Identity.Key] = component;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static ImmutableArray<AggregatedComponent> FilterComponents(ComponentGraph graph, SbomView view)
|
||||
{
|
||||
if (view == SbomView.Usage)
|
||||
{
|
||||
return graph.Components.Where(static component => component.Usage.UsedByEntrypoint).ToImmutableArray();
|
||||
}
|
||||
|
||||
return graph.Components;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BuildLayerOrder(ComponentGraph oldGraph, ComponentGraph newGraph)
|
||||
{
|
||||
var order = new Dictionary<string, int>(Ordinal);
|
||||
var index = 0;
|
||||
|
||||
foreach (var layer in newGraph.Layers)
|
||||
{
|
||||
AddLayer(order, layer.LayerDigest, ref index);
|
||||
}
|
||||
|
||||
foreach (var layer in oldGraph.Layers)
|
||||
{
|
||||
AddLayer(order, layer.LayerDigest, ref index);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
private static void AddLayer(IDictionary<string, int> order, string? layerDigest, ref int index)
|
||||
{
|
||||
var normalized = NormalizeLayer(layerDigest);
|
||||
if (normalized is null || order.ContainsKey(normalized))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
order[normalized] = index++;
|
||||
}
|
||||
|
||||
private static string ResolveLayerKey(ComponentChange change)
|
||||
=> NormalizeLayer(change.IntroducingLayer) ?? NormalizeLayer(change.RemovingLayer) ?? UnknownLayerKey;
|
||||
|
||||
private static string? GetIntroducingLayer(AggregatedComponent component)
|
||||
=> NormalizeLayer(component.FirstLayerDigest);
|
||||
|
||||
private static string? GetRemovingLayer(AggregatedComponent component)
|
||||
{
|
||||
var layer = component.LastLayerDigest ?? component.FirstLayerDigest;
|
||||
return NormalizeLayer(layer);
|
||||
}
|
||||
|
||||
private static string? NormalizeLayer(string? layer)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(layer))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
private sealed class DiffCounters
|
||||
{
|
||||
public int Added;
|
||||
public int Removed;
|
||||
public int VersionChanged;
|
||||
public int MetadataChanged;
|
||||
|
||||
public void Register(ComponentChangeKind kind)
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case ComponentChangeKind.VersionChanged:
|
||||
VersionChanged++;
|
||||
break;
|
||||
case ComponentChangeKind.MetadataChanged:
|
||||
MetadataChanged++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public DiffSummary ToSummary()
|
||||
=> new()
|
||||
{
|
||||
Added = Added,
|
||||
Removed = Removed,
|
||||
VersionChanged = VersionChanged,
|
||||
MetadataChanged = MetadataChanged,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Diff;
|
||||
|
||||
public static class DiffJsonSerializer
|
||||
{
|
||||
public static string Serialize(ComponentDiffDocument document)
|
||||
=> JsonSerializer.Serialize(document, ScannerJsonOptions.Default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
7
src/Scanner/__Libraries/StellaOps.Scanner.Diff/TASKS.md
Normal file
7
src/Scanner/__Libraries/StellaOps.Scanner.Diff/TASKS.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Scanner Diff Task Board (Sprint 10)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SCANNER-DIFF-10-501 | DONE (2025-10-19) | Diff Guild | SCANNER-CORE-09-501 | Build component differ tracking add/remove/version changes with deterministic ordering. | Diff engine produces deterministic results across runs; unit tests cover add/remove/version scenarios. |
|
||||
| SCANNER-DIFF-10-502 | DONE (2025-10-19) | Diff Guild | SCANNER-DIFF-10-501 | Attribute diffs to introducing/removing layers including provenance evidence. | Layer attribution stored on every change; tests validate provenance with synthetic layer stacks. |
|
||||
| SCANNER-DIFF-10-503 | DONE (2025-10-19) | Diff Guild | SCANNER-DIFF-10-502 | Produce JSON diff output for inventory vs usage views aligned with API contract. | JSON serializer emits stable ordering; golden fixture captured; API contract documented. |
|
||||
Reference in New Issue
Block a user