Add tests for SBOM generation determinism across multiple formats

- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -23,6 +23,8 @@ using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Bun;
using StellaOps.Cli.Services.Models.Ruby;
using StellaOps.Cli.Services.Models.Transport;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Digests;
namespace StellaOps.Cli.Services;
@@ -44,16 +46,23 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
private readonly ILogger<BackendOperationsClient> _logger;
private readonly ICryptoHash _cryptoHash;
private readonly IStellaOpsTokenClient? _tokenClient;
private readonly object _tokenSync = new();
private string? _cachedAccessToken;
private DateTimeOffset _cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public BackendOperationsClient(HttpClient httpClient, StellaOpsCliOptions options, ILogger<BackendOperationsClient> logger, IStellaOpsTokenClient? tokenClient = null)
public BackendOperationsClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<BackendOperationsClient> logger,
ICryptoHash cryptoHash,
IStellaOpsTokenClient? tokenClient = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(_options.BackendUrl) && httpClient.BaseAddress is null)
@@ -305,14 +314,19 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
var normalizedAlgorithm = string.IsNullOrWhiteSpace(expectedDigestAlgorithm)
? null
: expectedDigestAlgorithm.Trim();
var normalizedDigest = NormalizeExpectedDigest(expectedDigest);
var expectedDigestRaw = string.IsNullOrWhiteSpace(expectedDigest) ? null : expectedDigest.Trim();
string? expectedSha256Hex = null;
if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase) && expectedDigestRaw is not null)
{
expectedSha256Hex = Sha256Digest.ExtractHex(expectedDigestRaw, requirePrefix: false, parameterName: nameof(expectedDigest));
}
if (File.Exists(fullPath)
&& string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(normalizedDigest))
&& expectedSha256Hex is not null)
{
var existingDigest = await ComputeSha256Async(fullPath, cancellationToken).ConfigureAwait(false);
if (string.Equals(existingDigest, normalizedDigest, StringComparison.OrdinalIgnoreCase))
if (string.Equals(existingDigest, expectedSha256Hex, StringComparison.OrdinalIgnoreCase))
{
var info = new FileInfo(fullPath);
_logger.LogDebug("Export {ExportId} already present at {Path}; digest matches.", exportId, fullPath);
@@ -345,15 +359,15 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
}
if (!string.IsNullOrWhiteSpace(normalizedAlgorithm) && !string.IsNullOrWhiteSpace(normalizedDigest))
if (!string.IsNullOrWhiteSpace(normalizedAlgorithm) && expectedDigestRaw is not null)
{
if (string.Equals(normalizedAlgorithm, "sha256", StringComparison.OrdinalIgnoreCase))
{
var computed = await ComputeSha256Async(tempPath, cancellationToken).ConfigureAwait(false);
if (!string.Equals(computed, normalizedDigest, StringComparison.OrdinalIgnoreCase))
if (expectedSha256Hex is null || !string.Equals(computed, expectedSha256Hex, StringComparison.OrdinalIgnoreCase))
{
File.Delete(tempPath);
throw new InvalidOperationException($"Export digest mismatch. Expected sha256:{normalizedDigest}, computed sha256:{computed}.");
throw new InvalidOperationException($"Export digest mismatch. Expected sha256:{expectedSha256Hex}, computed sha256:{computed}.");
}
}
else
@@ -3020,35 +3034,31 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return null;
}
private static string? NormalizeExpectedDigest(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var trimmed = digest.Trim();
return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? trimmed[7..]
: trimmed;
}
private async Task<string> ValidateDigestAsync(string filePath, string? expectedDigest, CancellationToken cancellationToken)
{
string digestHex;
await using (var stream = File.OpenRead(filePath))
{
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
digestHex = Convert.ToHexString(hash).ToLowerInvariant();
digestHex = await _cryptoHash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(expectedDigest))
{
var normalized = NormalizeDigest(expectedDigest);
if (!normalized.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
string expectedHex;
try
{
expectedHex = Sha256Digest.ExtractHex(expectedDigest, requirePrefix: false, parameterName: "X-StellaOps-Digest");
}
catch (Exception ex) when (ex is ArgumentException or FormatException)
{
File.Delete(filePath);
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{normalized}, calculated sha256:{digestHex}.");
throw new InvalidOperationException($"Scanner digest header is invalid: {ex.Message}", ex);
}
if (!expectedHex.Equals(digestHex, StringComparison.OrdinalIgnoreCase))
{
File.Delete(filePath);
throw new InvalidOperationException($"Scanner digest mismatch. Expected sha256:{expectedHex}, calculated sha256:{digestHex}.");
}
}
else
@@ -3059,21 +3069,10 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return digestHex;
}
private static string NormalizeDigest(string digest)
{
if (digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
return digest[7..];
}
return digest;
}
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
private async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
return await _cryptoHash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken).ConfigureAwait(false);
}
private async Task ValidateSignatureAsync(string? signatureHeader, string digestHex, bool verbose, CancellationToken cancellationToken)