sprints work
This commit is contained in:
@@ -124,6 +124,9 @@ public enum DeltaGateLevel
|
||||
/// </summary>
|
||||
public sealed class DeltaVerdictBuilder
|
||||
{
|
||||
private static readonly IVerdictIdGenerator DefaultIdGenerator = new VerdictIdGenerator();
|
||||
|
||||
private readonly IVerdictIdGenerator _idGenerator;
|
||||
private DeltaVerdictStatus _status = DeltaVerdictStatus.Pass;
|
||||
private DeltaGateLevel _gate = DeltaGateLevel.G1;
|
||||
private int _riskPoints;
|
||||
@@ -133,6 +136,22 @@ public sealed class DeltaVerdictBuilder
|
||||
private readonly List<string> _recommendations = [];
|
||||
private string? _explanation;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="DeltaVerdictBuilder"/> with the default ID generator.
|
||||
/// </summary>
|
||||
public DeltaVerdictBuilder() : this(DefaultIdGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="DeltaVerdictBuilder"/> with a custom ID generator.
|
||||
/// </summary>
|
||||
/// <param name="idGenerator">Custom verdict ID generator for testing or specialized scenarios.</param>
|
||||
public DeltaVerdictBuilder(IVerdictIdGenerator idGenerator)
|
||||
{
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public DeltaVerdictBuilder WithStatus(DeltaVerdictStatus status)
|
||||
{
|
||||
_status = status;
|
||||
@@ -206,17 +225,29 @@ public sealed class DeltaVerdictBuilder
|
||||
_status = DeltaVerdictStatus.PassWithExceptions;
|
||||
}
|
||||
|
||||
var blockingDrivers = _blockingDrivers.ToList();
|
||||
var warningDrivers = _warningDrivers.ToList();
|
||||
var appliedExceptions = _exceptions.ToList();
|
||||
|
||||
// Compute content-addressed VerdictId from inputs
|
||||
var verdictId = _idGenerator.ComputeVerdictId(
|
||||
deltaId,
|
||||
blockingDrivers,
|
||||
warningDrivers,
|
||||
appliedExceptions,
|
||||
_gate);
|
||||
|
||||
return new DeltaVerdict
|
||||
{
|
||||
VerdictId = $"dv:{Guid.NewGuid():N}",
|
||||
VerdictId = verdictId,
|
||||
DeltaId = deltaId,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
Status = _status,
|
||||
RecommendedGate = _gate,
|
||||
RiskPoints = _riskPoints,
|
||||
BlockingDrivers = _blockingDrivers.ToList(),
|
||||
WarningDrivers = _warningDrivers.ToList(),
|
||||
AppliedExceptions = _exceptions.ToList(),
|
||||
BlockingDrivers = blockingDrivers,
|
||||
WarningDrivers = warningDrivers,
|
||||
AppliedExceptions = appliedExceptions,
|
||||
Explanation = _explanation ?? GenerateExplanation(),
|
||||
Recommendations = _recommendations.ToList()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating content-addressed IDs for delta verdicts.
|
||||
/// </summary>
|
||||
public interface IVerdictIdGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a content-addressed verdict ID from individual components.
|
||||
/// </summary>
|
||||
/// <param name="deltaId">The delta ID being evaluated.</param>
|
||||
/// <param name="blockingDrivers">Drivers that caused blocking status.</param>
|
||||
/// <param name="warningDrivers">Drivers that raised warnings.</param>
|
||||
/// <param name="appliedExceptions">Exception IDs that were applied.</param>
|
||||
/// <param name="gateLevel">The recommended gate level.</param>
|
||||
/// <returns>A content-addressed verdict ID in format "verdict:sha256:<hex>".</returns>
|
||||
string ComputeVerdictId(
|
||||
string deltaId,
|
||||
IReadOnlyList<DeltaDriver> blockingDrivers,
|
||||
IReadOnlyList<DeltaDriver> warningDrivers,
|
||||
IReadOnlyList<string> appliedExceptions,
|
||||
DeltaGateLevel gateLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a content-addressed verdict ID from an existing verdict.
|
||||
/// </summary>
|
||||
/// <param name="verdict">The verdict to compute an ID for.</param>
|
||||
/// <returns>A content-addressed verdict ID in format "verdict:sha256:<hex>".</returns>
|
||||
/// <remarks>
|
||||
/// This method is useful for recomputing the expected ID of a verdict
|
||||
/// during verification. The computed ID should match the verdict's
|
||||
/// <see cref="DeltaVerdict.VerdictId"/> if it was generated correctly.
|
||||
/// </remarks>
|
||||
string ComputeVerdictId(DeltaVerdict verdict);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Policy.Deltas;
|
||||
|
||||
/// <summary>
|
||||
/// Generates content-addressed IDs for delta verdicts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// VerdictId Formula:
|
||||
/// <code>
|
||||
/// verdict:sha256:<hex> = SHA256(CanonicalJson(
|
||||
/// DeltaId,
|
||||
/// Sort(BlockingDrivers by Type, CveId, Purl, Severity),
|
||||
/// Sort(WarningDrivers by Type, CveId, Purl, Severity),
|
||||
/// Sort(AppliedExceptions),
|
||||
/// GateLevel
|
||||
/// ))
|
||||
/// </code>
|
||||
///
|
||||
/// The canonical JSON uses RFC 8785 (JCS) format to ensure deterministic output
|
||||
/// regardless of property order or whitespace.
|
||||
/// </remarks>
|
||||
public sealed class VerdictIdGenerator : IVerdictIdGenerator
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="VerdictIdGenerator"/>.
|
||||
/// </summary>
|
||||
public VerdictIdGenerator()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeVerdictId(
|
||||
string deltaId,
|
||||
IReadOnlyList<DeltaDriver> blockingDrivers,
|
||||
IReadOnlyList<DeltaDriver> warningDrivers,
|
||||
IReadOnlyList<string> appliedExceptions,
|
||||
DeltaGateLevel gateLevel)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deltaId);
|
||||
ArgumentNullException.ThrowIfNull(blockingDrivers);
|
||||
ArgumentNullException.ThrowIfNull(warningDrivers);
|
||||
ArgumentNullException.ThrowIfNull(appliedExceptions);
|
||||
|
||||
var payload = new VerdictIdPayload
|
||||
{
|
||||
CanonVersion = CanonVersion.Current,
|
||||
DeltaId = deltaId,
|
||||
BlockingDrivers = SortDrivers(blockingDrivers),
|
||||
WarningDrivers = SortDrivers(warningDrivers),
|
||||
AppliedExceptions = SortExceptions(appliedExceptions),
|
||||
GateLevel = gateLevel.ToString()
|
||||
};
|
||||
|
||||
// Canonicalize the payload with deterministic key ordering
|
||||
var canonical = CanonJson.Canonicalize(payload, SerializerOptions);
|
||||
var hash = SHA256.HashData(canonical);
|
||||
|
||||
return $"verdict:sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ComputeVerdictId(DeltaVerdict verdict)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(verdict);
|
||||
|
||||
return ComputeVerdictId(
|
||||
verdict.DeltaId,
|
||||
verdict.BlockingDrivers,
|
||||
verdict.WarningDrivers,
|
||||
verdict.AppliedExceptions,
|
||||
verdict.RecommendedGate);
|
||||
}
|
||||
|
||||
private static List<DriverPayload> SortDrivers(IReadOnlyList<DeltaDriver> drivers)
|
||||
{
|
||||
return drivers
|
||||
.OrderBy(d => d.Type, StringComparer.Ordinal)
|
||||
.ThenBy(d => d.CveId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(d => d.Purl ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(d => d.Severity.ToString(), StringComparer.Ordinal)
|
||||
.Select(d => new DriverPayload
|
||||
{
|
||||
Type = d.Type,
|
||||
Severity = d.Severity.ToString(),
|
||||
Description = d.Description,
|
||||
CveId = d.CveId,
|
||||
Purl = d.Purl
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<string> SortExceptions(IReadOnlyList<string> exceptions)
|
||||
{
|
||||
return exceptions
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload structure for verdict ID computation.
|
||||
/// </summary>
|
||||
private sealed record VerdictIdPayload
|
||||
{
|
||||
[JsonPropertyName("_canonVersion")]
|
||||
public required string CanonVersion { get; init; }
|
||||
public required string DeltaId { get; init; }
|
||||
public required List<DriverPayload> BlockingDrivers { get; init; }
|
||||
public required List<DriverPayload> WarningDrivers { get; init; }
|
||||
public required List<string> AppliedExceptions { get; init; }
|
||||
public required string GateLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializable driver payload for deterministic ordering.
|
||||
/// </summary>
|
||||
private sealed record DriverPayload
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -141,12 +141,105 @@ public sealed class DeltaVerdictTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GeneratesUniqueVerdictId()
|
||||
public void Build_GeneratesDeterministicVerdictId_ForIdenticalInputs()
|
||||
{
|
||||
var verdict1 = new DeltaVerdictBuilder().Build("delta:sha256:test");
|
||||
var verdict2 = new DeltaVerdictBuilder().Build("delta:sha256:test");
|
||||
|
||||
verdict1.VerdictId.Should().StartWith("dv:");
|
||||
verdict1.VerdictId.Should().NotBe(verdict2.VerdictId);
|
||||
// Content-addressed IDs are deterministic
|
||||
verdict1.VerdictId.Should().StartWith("verdict:sha256:");
|
||||
verdict1.VerdictId.Should().Be(verdict2.VerdictId, "identical inputs must produce identical VerdictId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_GeneratesDifferentVerdictId_ForDifferentInputs()
|
||||
{
|
||||
var verdict1 = new DeltaVerdictBuilder().Build("delta:sha256:test1");
|
||||
var verdict2 = new DeltaVerdictBuilder().Build("delta:sha256:test2");
|
||||
|
||||
verdict1.VerdictId.Should().StartWith("verdict:sha256:");
|
||||
verdict2.VerdictId.Should().StartWith("verdict:sha256:");
|
||||
verdict1.VerdictId.Should().NotBe(verdict2.VerdictId, "different inputs must produce different VerdictId");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(10)]
|
||||
public void Build_IsIdempotent_AcrossMultipleIterations(int iterations)
|
||||
{
|
||||
var driver = new DeltaDriver
|
||||
{
|
||||
Type = "new-reachable-cve",
|
||||
Severity = DeltaDriverSeverity.High,
|
||||
Description = "High severity CVE",
|
||||
CveId = "CVE-2024-999"
|
||||
};
|
||||
|
||||
var expected = new DeltaVerdictBuilder()
|
||||
.AddBlockingDriver(driver)
|
||||
.Build("delta:sha256:determinism-test")
|
||||
.VerdictId;
|
||||
|
||||
for (int i = 0; i < iterations; i++)
|
||||
{
|
||||
var verdict = new DeltaVerdictBuilder()
|
||||
.AddBlockingDriver(driver)
|
||||
.Build("delta:sha256:determinism-test");
|
||||
|
||||
verdict.VerdictId.Should().Be(expected, $"iteration {i}: VerdictId must be stable");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_VerdictIdIsDeterministic_RegardlessOfDriverAddOrder()
|
||||
{
|
||||
var driver1 = new DeltaDriver
|
||||
{
|
||||
Type = "aaa-first",
|
||||
Severity = DeltaDriverSeverity.Medium,
|
||||
Description = "First driver"
|
||||
};
|
||||
|
||||
var driver2 = new DeltaDriver
|
||||
{
|
||||
Type = "zzz-last",
|
||||
Severity = DeltaDriverSeverity.Low,
|
||||
Description = "Second driver"
|
||||
};
|
||||
|
||||
// Add in one order
|
||||
var verdict1 = new DeltaVerdictBuilder()
|
||||
.AddWarningDriver(driver1)
|
||||
.AddWarningDriver(driver2)
|
||||
.Build("delta:sha256:order-test");
|
||||
|
||||
// Add in reverse order
|
||||
var verdict2 = new DeltaVerdictBuilder()
|
||||
.AddWarningDriver(driver2)
|
||||
.AddWarningDriver(driver1)
|
||||
.Build("delta:sha256:order-test");
|
||||
|
||||
// Content-addressed IDs should be same because drivers are sorted by Type
|
||||
verdict1.VerdictId.Should().Be(verdict2.VerdictId, "drivers are sorted by Type before hashing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerdictIdGenerator_ComputeFromVerdict_MatchesOriginal()
|
||||
{
|
||||
var driver = new DeltaDriver
|
||||
{
|
||||
Type = "recompute-test",
|
||||
Severity = DeltaDriverSeverity.Critical,
|
||||
Description = "Test driver"
|
||||
};
|
||||
|
||||
var verdict = new DeltaVerdictBuilder()
|
||||
.AddBlockingDriver(driver)
|
||||
.AddException("EXCEPTION-001")
|
||||
.Build("delta:sha256:recompute-test");
|
||||
|
||||
var generator = new VerdictIdGenerator();
|
||||
var recomputed = generator.ComputeVerdictId(verdict);
|
||||
|
||||
recomputed.Should().Be(verdict.VerdictId, "recomputed VerdictId must match original");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user