Add Canonical JSON serialization library with tests and documentation

- Implemented CanonJson class for deterministic JSON serialization and hashing.
- Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters.
- Created project files for the Canonical JSON library and its tests, including necessary package references.
- Added README.md for library usage and API reference.
- Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -0,0 +1,263 @@
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.Canonical.Json.Tests;
public class CanonJsonTests
{
[Fact]
public void Canonicalize_SameInput_ProducesSameHash()
{
var obj = new { foo = "bar", baz = 42, nested = new { x = 1, y = 2 } };
var bytes1 = CanonJson.Canonicalize(obj);
var bytes2 = CanonJson.Canonicalize(obj);
Assert.Equal(bytes1, bytes2);
Assert.Equal(CanonJson.Sha256Hex(bytes1), CanonJson.Sha256Hex(bytes2));
}
[Fact]
public void Canonicalize_SortsKeysAlphabetically()
{
var obj = new { z = 3, a = 1, m = 2 };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Keys should be ordered: a, m, z
Assert.Matches(@"\{""a"":1,""m"":2,""z"":3\}", json);
}
[Fact]
public void Canonicalize_HandlesNestedObjects()
{
var obj = new { outer = new { z = 9, a = 1 }, inner = new { b = 2 } };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Nested keys also sorted
Assert.Contains(@"""inner"":{""b"":2}", json);
Assert.Contains(@"""outer"":{""a"":1,""z"":9}", json);
}
[Fact]
public void Canonicalize_HandlesArrays()
{
var obj = new { items = new[] { 3, 1, 2 } };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Array order preserved (not sorted)
Assert.Contains(@"""items"":[3,1,2]", json);
}
[Fact]
public void Canonicalize_HandlesNullValues()
{
var obj = new { name = "test", value = (string?)null };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Contains(@"""value"":null", json);
}
[Fact]
public void Canonicalize_HandlesBooleans()
{
var obj = new { enabled = true, disabled = false };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Contains(@"""disabled"":false", json);
Assert.Contains(@"""enabled"":true", json);
}
[Fact]
public void Canonicalize_HandlesDecimals()
{
var obj = new { value = 3.14159, integer = 42 };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Contains(@"""integer"":42", json);
Assert.Contains(@"""value"":3.14159", json);
}
[Fact]
public void Canonicalize_HandlesEmptyObject()
{
var obj = new { };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Equal("{}", json);
}
[Fact]
public void Canonicalize_HandlesEmptyArray()
{
var obj = new { items = Array.Empty<int>() };
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
Assert.Equal(@"{""items"":[]}", json);
}
[Fact]
public void Canonicalize_WithCustomOptions_UsesOptions()
{
var obj = new { MyProperty = "test" };
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj, options));
Assert.Contains(@"""my_property"":""test""", json);
}
[Fact]
public void Canonicalize_RawJsonBytes_SortsKeys()
{
var rawJson = Encoding.UTF8.GetBytes(@"{""z"":3,""a"":1}");
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
var json = Encoding.UTF8.GetString(canonical);
Assert.Equal(@"{""a"":1,""z"":3}", json);
}
[Fact]
public void Sha256Hex_ProducesLowercaseHex()
{
var bytes = Encoding.UTF8.GetBytes("test");
var hash = CanonJson.Sha256Hex(bytes);
Assert.Matches(@"^[0-9a-f]{64}$", hash);
}
[Fact]
public void Sha256Hex_ProducesConsistentHash()
{
var bytes = Encoding.UTF8.GetBytes("deterministic input");
var hash1 = CanonJson.Sha256Hex(bytes);
var hash2 = CanonJson.Sha256Hex(bytes);
Assert.Equal(hash1, hash2);
}
[Fact]
public void Sha256Prefixed_IncludesPrefix()
{
var bytes = Encoding.UTF8.GetBytes("test");
var hash = CanonJson.Sha256Prefixed(bytes);
Assert.StartsWith("sha256:", hash);
Assert.Equal(71, hash.Length); // "sha256:" (7) + 64 hex chars
}
[Fact]
public void Hash_CanonicalizesAndHashes()
{
var obj = new { z = 3, a = 1 };
var hash1 = CanonJson.Hash(obj);
var hash2 = CanonJson.Hash(obj);
Assert.Equal(hash1, hash2);
Assert.Matches(@"^[0-9a-f]{64}$", hash1);
}
[Fact]
public void HashPrefixed_CanonicalizesAndHashesWithPrefix()
{
var obj = new { name = "test" };
var hash = CanonJson.HashPrefixed(obj);
Assert.StartsWith("sha256:", hash);
}
[Fact]
public void DifferentObjects_ProduceDifferentHashes()
{
var obj1 = new { value = 1 };
var obj2 = new { value = 2 };
var hash1 = CanonJson.Hash(obj1);
var hash2 = CanonJson.Hash(obj2);
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void KeyOrderDoesNotAffectHash()
{
// These should produce the same hash because keys are sorted
var json1 = Encoding.UTF8.GetBytes(@"{""a"":1,""b"":2}");
var json2 = Encoding.UTF8.GetBytes(@"{""b"":2,""a"":1}");
var canonical1 = CanonJson.CanonicalizeParsedJson(json1);
var canonical2 = CanonJson.CanonicalizeParsedJson(json2);
Assert.Equal(
CanonJson.Sha256Hex(canonical1),
CanonJson.Sha256Hex(canonical2));
}
[Fact]
public void Canonicalize_DeeplyNestedStructure()
{
var obj = new
{
level1 = new
{
z = "last",
a = new
{
nested = new { b = 2, a = 1 }
}
}
};
var json = Encoding.UTF8.GetString(CanonJson.Canonicalize(obj));
// Verify deep nesting is sorted
Assert.Contains(@"""a"":{""nested"":{""a"":1,""b"":2}}", json);
}
[Fact]
public void Canonicalize_ArrayOfObjects_SortsObjectKeys()
{
// Use raw JSON to test mixed object shapes in array
var rawJson = Encoding.UTF8.GetBytes(@"{""items"":[{""z"":3,""a"":1},{""b"":2,""a"":1}]}");
var canonical = CanonJson.CanonicalizeParsedJson(rawJson);
var json = Encoding.UTF8.GetString(canonical);
// Objects in array have sorted keys
Assert.Contains(@"{""a"":1,""z"":3}", json);
Assert.Contains(@"{""a"":1,""b"":2}", json);
}
[Fact]
public void Canonicalize_UnicodeStrings()
{
var obj = new { greeting = "Привет мир", emoji = "🚀" };
var bytes = CanonJson.Canonicalize(obj);
// Verify deterministic hashing regardless of Unicode escaping
var hash1 = CanonJson.Sha256Hex(bytes);
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
Assert.Equal(hash1, hash2);
// Unicode may be escaped in JSON output - this is valid canonical JSON
var json = Encoding.UTF8.GetString(bytes);
Assert.Contains("greeting", json);
Assert.Contains("emoji", json);
}
[Fact]
public void Canonicalize_SpecialCharactersInStrings()
{
var obj = new { path = "C:\\Users\\test", quote = "He said \"hello\"" };
var bytes = CanonJson.Canonicalize(obj);
// Should not throw and should produce consistent output
var hash1 = CanonJson.Sha256Hex(bytes);
var hash2 = CanonJson.Sha256Hex(CanonJson.Canonicalize(obj));
Assert.Equal(hash1, hash2);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>