Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Canonicalization.Culture;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all string operations use invariant culture.
|
||||
/// </summary>
|
||||
public static class InvariantCulture
|
||||
{
|
||||
public static IDisposable Scope()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
|
||||
return new CultureScope(original);
|
||||
}
|
||||
|
||||
public static int Compare(string? a, string? b) => string.Compare(a, b, StringComparison.Ordinal);
|
||||
|
||||
public static string FormatDecimal(decimal value) => value.ToString("G", CultureInfo.InvariantCulture);
|
||||
|
||||
public static decimal ParseDecimal(string value) => decimal.Parse(value, CultureInfo.InvariantCulture);
|
||||
|
||||
private sealed class CultureScope : IDisposable
|
||||
{
|
||||
private readonly CultureInfo _original;
|
||||
public CultureScope(CultureInfo original) => _original = original;
|
||||
public void Dispose()
|
||||
{
|
||||
CultureInfo.CurrentCulture = _original;
|
||||
CultureInfo.CurrentUICulture = _original;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UTF-8 encoding utilities.
|
||||
/// </summary>
|
||||
public static class Utf8Encoding
|
||||
{
|
||||
public static string Normalize(string input)
|
||||
{
|
||||
return input.Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
public static byte[] GetBytes(string input) => Encoding.UTF8.GetBytes(Normalize(input));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Canonicalization.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Produces canonical JSON output with deterministic ordering.
|
||||
/// Implements RFC 8785 principles for stable output.
|
||||
/// </summary>
|
||||
public static class CanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
NumberHandling = JsonNumberHandling.Strict,
|
||||
Converters =
|
||||
{
|
||||
new StableDictionaryConverterFactory(),
|
||||
new Iso8601DateTimeConverter()
|
||||
}
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, Options);
|
||||
|
||||
public static (string Json, string Digest) SerializeWithDigest<T>(T value)
|
||||
{
|
||||
var json = Serialize(value);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return (json, digest);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, Options)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter factory that orders dictionary keys alphabetically.
|
||||
/// </summary>
|
||||
public sealed class StableDictionaryConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
if (!typeToConvert.IsGenericType) return false;
|
||||
var generic = typeToConvert.GetGenericTypeDefinition();
|
||||
return generic == typeof(Dictionary<,>) || generic == typeof(IDictionary<,>) || generic == typeof(IReadOnlyDictionary<,>);
|
||||
}
|
||||
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var args = typeToConvert.GetGenericArguments();
|
||||
var converterType = typeof(StableDictionaryConverter<,>).MakeGenericType(args[0], args[1]);
|
||||
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StableDictionaryConverter<TKey, TValue> : JsonConverter<IDictionary<TKey, TValue>>
|
||||
where TKey : notnull
|
||||
{
|
||||
public override IDictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, IDictionary<TKey, TValue> value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in value.OrderBy(x => x.Key?.ToString(), StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key?.ToString() ?? string.Empty);
|
||||
JsonSerializer.Serialize(writer, kvp.Value, options);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter for ISO 8601 date/time with UTC normalization.
|
||||
/// </summary>
|
||||
public sealed class Iso8601DateTimeConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> DateTimeOffset.Parse(reader.GetString()!, CultureInfo.InvariantCulture);
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace StellaOps.Canonicalization.Ordering;
|
||||
|
||||
/// <summary>
|
||||
/// Provides stable ordering for SBOM packages.
|
||||
/// Order: purl -> name -> version -> type.
|
||||
/// </summary>
|
||||
public static class PackageOrderer
|
||||
{
|
||||
public static IOrderedEnumerable<T> StableOrder<T>(
|
||||
this IEnumerable<T> packages,
|
||||
Func<T, string?> getPurl,
|
||||
Func<T, string?> getName,
|
||||
Func<T, string?> getVersion,
|
||||
Func<T, string?> getType)
|
||||
{
|
||||
return packages
|
||||
.OrderBy(p => getPurl(p) ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(p => getName(p) ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(p => getVersion(p) ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(p => getType(p) ?? string.Empty, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides stable ordering for vulnerabilities.
|
||||
/// Order: id -> source -> severity.
|
||||
/// </summary>
|
||||
public static class VulnerabilityOrderer
|
||||
{
|
||||
public static IOrderedEnumerable<T> StableOrder<T>(
|
||||
this IEnumerable<T> vulnerabilities,
|
||||
Func<T, string> getId,
|
||||
Func<T, string?> getSource,
|
||||
Func<T, decimal?> getSeverity)
|
||||
{
|
||||
return vulnerabilities
|
||||
.OrderBy(v => getId(v), StringComparer.Ordinal)
|
||||
.ThenBy(v => getSource(v) ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenByDescending(v => getSeverity(v) ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides stable ordering for graph edges.
|
||||
/// Order: source -> target -> type.
|
||||
/// </summary>
|
||||
public static class EdgeOrderer
|
||||
{
|
||||
public static IOrderedEnumerable<T> StableOrder<T>(
|
||||
this IEnumerable<T> edges,
|
||||
Func<T, string> getSource,
|
||||
Func<T, string> getTarget,
|
||||
Func<T, string?> getType)
|
||||
{
|
||||
return edges
|
||||
.OrderBy(e => getSource(e), StringComparer.Ordinal)
|
||||
.ThenBy(e => getTarget(e), StringComparer.Ordinal)
|
||||
.ThenBy(e => getType(e) ?? string.Empty, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides stable ordering for evidence lists.
|
||||
/// Order: type -> id -> digest.
|
||||
/// </summary>
|
||||
public static class EvidenceOrderer
|
||||
{
|
||||
public static IOrderedEnumerable<T> StableOrder<T>(
|
||||
this IEnumerable<T> evidence,
|
||||
Func<T, string> getType,
|
||||
Func<T, string> getId,
|
||||
Func<T, string?> getDigest)
|
||||
{
|
||||
return evidence
|
||||
.OrderBy(e => getType(e), StringComparer.Ordinal)
|
||||
.ThenBy(e => getId(e), StringComparer.Ordinal)
|
||||
.ThenBy(e => getDigest(e) ?? string.Empty, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
|
||||
namespace StellaOps.Canonicalization.Verification;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that serialization produces identical output across runs.
|
||||
/// </summary>
|
||||
public sealed class DeterminismVerifier
|
||||
{
|
||||
public DeterminismResult Verify<T>(T value, int iterations = 10)
|
||||
{
|
||||
var outputs = new HashSet<string>(StringComparer.Ordinal);
|
||||
var digests = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var (json, digest) = CanonicalJsonSerializer.SerializeWithDigest(value);
|
||||
outputs.Add(json);
|
||||
digests.Add(digest);
|
||||
}
|
||||
|
||||
return new DeterminismResult(
|
||||
IsDeterministic: outputs.Count == 1 && digests.Count == 1,
|
||||
UniqueOutputs: outputs.Count,
|
||||
UniqueDigests: digests.Count,
|
||||
SampleOutput: outputs.FirstOrDefault() ?? string.Empty,
|
||||
SampleDigest: digests.FirstOrDefault() ?? string.Empty);
|
||||
}
|
||||
|
||||
public ComparisonResult Compare(string jsonA, string jsonB)
|
||||
{
|
||||
if (string.Equals(jsonA, jsonB, StringComparison.Ordinal))
|
||||
{
|
||||
return new ComparisonResult(true, []);
|
||||
}
|
||||
|
||||
var differences = FindDifferences(jsonA, jsonB);
|
||||
return new ComparisonResult(false, differences);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FindDifferences(string a, string b)
|
||||
{
|
||||
var differences = new List<string>();
|
||||
using var docA = JsonDocument.Parse(a);
|
||||
using var docB = JsonDocument.Parse(b);
|
||||
CompareElements(docA.RootElement, docB.RootElement, "$", differences);
|
||||
return differences;
|
||||
}
|
||||
|
||||
private static void CompareElements(JsonElement a, JsonElement b, string path, List<string> differences)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind)
|
||||
{
|
||||
differences.Add($"{path}: type mismatch ({a.ValueKind} vs {b.ValueKind})");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (a.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var propsA = a.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
var propsB = b.EnumerateObject().ToDictionary(p => p.Name, StringComparer.Ordinal);
|
||||
foreach (var key in propsA.Keys.Union(propsB.Keys).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var hasA = propsA.TryGetValue(key, out var propA);
|
||||
var hasB = propsB.TryGetValue(key, out var propB);
|
||||
if (!hasA) differences.Add($"{path}.{key}: missing in first");
|
||||
else if (!hasB) differences.Add($"{path}.{key}: missing in second");
|
||||
else CompareElements(propA.Value, propB.Value, $"{path}.{key}", differences);
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
var arrA = a.EnumerateArray().ToList();
|
||||
var arrB = b.EnumerateArray().ToList();
|
||||
if (arrA.Count != arrB.Count)
|
||||
differences.Add($"{path}: array length mismatch ({arrA.Count} vs {arrB.Count})");
|
||||
for (var i = 0; i < Math.Min(arrA.Count, arrB.Count); i++)
|
||||
CompareElements(arrA[i], arrB[i], $"{path}[{i}]", differences);
|
||||
break;
|
||||
default:
|
||||
if (a.GetRawText() != b.GetRawText())
|
||||
differences.Add($"{path}: value mismatch");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DeterminismResult(
|
||||
bool IsDeterministic,
|
||||
int UniqueOutputs,
|
||||
int UniqueDigests,
|
||||
string SampleOutput,
|
||||
string SampleDigest);
|
||||
|
||||
public sealed record ComparisonResult(
|
||||
bool IsIdentical,
|
||||
IReadOnlyList<string> Differences);
|
||||
Reference in New Issue
Block a user