Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View 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.

View File

@@ -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;
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}

View File

@@ -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>

View 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. |