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

@@ -53,7 +53,8 @@ public interface ICryptoProvider
/// <param name="algorithmId">Signing algorithm identifier (e.g., RS256, ES256).</param>
/// <param name="publicKeyBytes">Public key in SubjectPublicKeyInfo format (DER-encoded).</param>
/// <returns>Ephemeral signer instance (supports VerifyAsync only).</returns>
ICryptoSigner CreateEphemeralVerifier(string algorithmId, ReadOnlySpan<byte> publicKeyBytes);
ICryptoSigner CreateEphemeralVerifier(string algorithmId, ReadOnlySpan<byte> publicKeyBytes)
=> throw new NotSupportedException($"Provider '{Name}' does not support ephemeral verification.");
/// <summary>
/// Adds or replaces signing key material managed by this provider.

View File

@@ -67,7 +67,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
=> Convert.ToHexStringLower(ComputeHash(data, algorithmId));
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
@@ -99,7 +99,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
return Convert.ToHexStringLower(bytes);
}
private static byte[] ComputeSha256(ReadOnlySpan<byte> data)
@@ -190,7 +190,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
}
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> Convert.ToHexString(ComputeHashForPurpose(data, purpose)).ToLowerInvariant();
=> Convert.ToHexStringLower(ComputeHashForPurpose(data, purpose));
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> Convert.ToBase64String(ComputeHashForPurpose(data, purpose));
@@ -207,7 +207,7 @@ public sealed class DefaultCryptoHash : ICryptoHash
public async ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashForPurposeAsync(stream, purpose, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
return Convert.ToHexStringLower(bytes);
}
public string GetAlgorithmForPurpose(string purpose)

View File

@@ -61,7 +61,7 @@ public sealed class DefaultCryptoHmac : ICryptoHmac
}
public string ComputeHmacHexForPurpose(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, string purpose)
=> Convert.ToHexString(ComputeHmacForPurpose(key, data, purpose)).ToLowerInvariant();
=> Convert.ToHexStringLower(ComputeHmacForPurpose(key, data, purpose));
public string ComputeHmacBase64ForPurpose(ReadOnlySpan<byte> key, ReadOnlySpan<byte> data, string purpose)
=> Convert.ToBase64String(ComputeHmacForPurpose(key, data, purpose));
@@ -78,7 +78,7 @@ public sealed class DefaultCryptoHmac : ICryptoHmac
public async ValueTask<string> ComputeHmacHexForPurposeAsync(ReadOnlyMemory<byte> key, Stream stream, string purpose, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHmacForPurposeAsync(key, stream, purpose, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
return Convert.ToHexStringLower(bytes);
}
#endregion

View File

@@ -0,0 +1,93 @@
namespace StellaOps.Cryptography.Digests;
/// <summary>
/// Shared helpers for working with SHA-256 digests in the canonical <c>sha256:&lt;hex&gt;</c> form.
/// </summary>
public static class Sha256Digest
{
public const string Prefix = "sha256:";
public const int HexLength = 64;
/// <summary>
/// Normalizes an input digest to the canonical <c>sha256:&lt;lower-hex&gt;</c> form.
/// </summary>
/// <param name="digest">Digest in either <c>sha256:&lt;hex&gt;</c> or bare-hex form.</param>
/// <param name="requirePrefix">If true, requires the <c>sha256:</c> prefix to be present.</param>
/// <param name="parameterName">Optional parameter name used in exception messages.</param>
/// <exception cref="ArgumentException">Thrown when the input is null/empty/whitespace.</exception>
/// <exception cref="FormatException">Thrown when the input is not a valid SHA-256 hex digest.</exception>
public static string Normalize(string digest, bool requirePrefix = false, string? parameterName = null)
{
if (string.IsNullOrWhiteSpace(digest))
{
throw new ArgumentException("Digest is required.", parameterName ?? nameof(digest));
}
var trimmed = digest.Trim();
string hex;
if (trimmed.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
{
hex = trimmed[Prefix.Length..];
}
else if (requirePrefix)
{
var name = string.IsNullOrWhiteSpace(parameterName) ? "Digest" : parameterName;
throw new FormatException($"{name} must start with '{Prefix}'.");
}
else if (trimmed.Contains(':', StringComparison.Ordinal))
{
throw new FormatException($"Unsupported digest algorithm in '{digest}'. Only sha256 is supported.");
}
else
{
hex = trimmed;
}
hex = hex.Trim();
if (hex.Length != HexLength || !IsHex(hex.AsSpan()))
{
var name = string.IsNullOrWhiteSpace(parameterName) ? "Digest" : parameterName;
throw new FormatException($"{name} must contain {HexLength} hexadecimal characters.");
}
return Prefix + hex.ToLowerInvariant();
}
/// <summary>
/// Normalizes a digest to the canonical form, returning null when the input is null/empty.
/// </summary>
public static string? NormalizeOrNull(string? digest, bool requirePrefix = false, string? parameterName = null)
=> string.IsNullOrWhiteSpace(digest) ? null : Normalize(digest, requirePrefix, parameterName);
/// <summary>
/// Extracts the lowercase hex value from a digest (with optional <c>sha256:</c> prefix).
/// </summary>
public static string ExtractHex(string digest, bool requirePrefix = false, string? parameterName = null)
=> Normalize(digest, requirePrefix, parameterName)[Prefix.Length..];
/// <summary>
/// Computes a canonical <c>sha256:&lt;hex&gt;</c> digest for the provided content using the StellaOps crypto stack.
/// </summary>
public static string Compute(ICryptoHash hash, ReadOnlySpan<byte> content)
{
ArgumentNullException.ThrowIfNull(hash);
return Prefix + hash.ComputeHashHex(content, HashAlgorithms.Sha256);
}
private static bool IsHex(ReadOnlySpan<char> value)
{
foreach (var c in value)
{
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))
{
continue;
}
return false;
}
return true;
}
}