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:
@@ -0,0 +1,93 @@
|
||||
namespace StellaOps.Cryptography.Digests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for working with SHA-256 digests in the canonical <c>sha256:<hex></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:<lower-hex></c> form.
|
||||
/// </summary>
|
||||
/// <param name="digest">Digest in either <c>sha256:<hex></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:<hex></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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user