Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalizationBoundaryAnalyzerTests.cs
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-18
|
||||
// Description: Unit tests for STELLA0100 canonicalization analyzer.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp.Testing;
|
||||
using Microsoft.CodeAnalysis.Testing;
|
||||
using StellaOps.Determinism.Analyzers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Determinism.Analyzers.Tests;
|
||||
|
||||
public class CanonicalizationBoundaryAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task JsonSerialize_InDigestMethod_ReportsDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public string ComputeDigest(object data)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(data);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.DiagnosticId, DiagnosticSeverity.Warning)
|
||||
.WithSpan(9, 24, 9, 54)
|
||||
.WithArguments("ComputeDigest");
|
||||
|
||||
await VerifyAsync(testCode, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JsonSerialize_InRegularMethod_NoDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Text.Json;
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public string GetJson(object data)
|
||||
{
|
||||
return JsonSerializer.Serialize(data);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyAsync(testCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JsonSerialize_WithCanonicalizerField_NoDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public class Rfc8785JsonCanonicalizer
|
||||
{
|
||||
public string Canonicalize(object data) => "";
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
private readonly Rfc8785JsonCanonicalizer _canonicalizer = new();
|
||||
|
||||
public string ComputeDigest(object data)
|
||||
{
|
||||
var json = _canonicalizer.Canonicalize(data);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyAsync(testCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DictionaryForeach_InDigestMethod_ReportsDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public string ComputeHash(Dictionary<string, string> items)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in items)
|
||||
{
|
||||
sb.Append(item.Key);
|
||||
sb.Append(item.Value);
|
||||
}
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.CollectionDiagnosticId, DiagnosticSeverity.Warning)
|
||||
.WithSpan(10, 30, 10, 35)
|
||||
.WithArguments("ComputeHash");
|
||||
|
||||
await VerifyAsync(testCode, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DictionaryForeach_WithOrderBy_NoDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public string ComputeHash(Dictionary<string, string> items)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in items.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(item.Key);
|
||||
sb.Append(item.Value);
|
||||
}
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyAsync(testCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FrozenDictionaryForeach_NoDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public string ComputeHash(FrozenDictionary<string, string> items)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in items)
|
||||
{
|
||||
sb.Append(item.Key);
|
||||
sb.Append(item.Value);
|
||||
}
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await VerifyAsync(testCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JsonSerialize_InResolverClass_ReportsDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Text.Json;
|
||||
|
||||
public class VerdictResolver
|
||||
{
|
||||
public string Resolve(object data)
|
||||
{
|
||||
return JsonSerializer.Serialize(data);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.DiagnosticId, DiagnosticSeverity.Warning)
|
||||
.WithSpan(7, 16, 7, 46)
|
||||
.WithArguments("Resolve");
|
||||
|
||||
await VerifyAsync(testCode, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JsonSerialize_InAttestorClass_ReportsDiagnostic()
|
||||
{
|
||||
var testCode = """
|
||||
using System.Text.Json;
|
||||
|
||||
public class SigningAttestor
|
||||
{
|
||||
public string CreatePayload(object data)
|
||||
{
|
||||
return JsonSerializer.Serialize(data);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = new DiagnosticResult(CanonicalizationBoundaryAnalyzer.DiagnosticId, DiagnosticSeverity.Warning)
|
||||
.WithSpan(7, 16, 7, 46)
|
||||
.WithArguments("CreatePayload");
|
||||
|
||||
await VerifyAsync(testCode, expected);
|
||||
}
|
||||
|
||||
private static Task VerifyAsync(string source, params DiagnosticResult[] expected)
|
||||
{
|
||||
var test = new CSharpAnalyzerTest<CanonicalizationBoundaryAnalyzer, DefaultVerifier>
|
||||
{
|
||||
TestCode = source,
|
||||
ReferenceAssemblies = ReferenceAssemblies.Net.Net80
|
||||
};
|
||||
|
||||
test.ExpectedDiagnostics.AddRange(expected);
|
||||
return test.RunAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Determinism.Analyzers.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Determinism.Analyzers\StellaOps.Determinism.Analyzers.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
; No releases shipped yet
|
||||
@@ -0,0 +1,9 @@
|
||||
## Release 1.0
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
--------|----------|----------|-------
|
||||
STELLA0100 | Determinism | Warning | Non-canonical JSON serialization at resolver boundary
|
||||
STELLA0101 | Determinism | Info | Unicode string not NFC normalized before hashing
|
||||
STELLA0102 | Determinism | Warning | Non-deterministic collection iteration in digest context
|
||||
@@ -0,0 +1,317 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalizationBoundaryAnalyzer.cs
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-18
|
||||
// Description: Roslyn analyzer enforcing canonicalization at resolver boundaries.
|
||||
// Diagnostic ID: STELLA0100
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
|
||||
namespace StellaOps.Determinism.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Roslyn analyzer that detects non-canonical JSON serialization at resolver boundaries.
|
||||
/// Reports STELLA0100 when JsonSerializer.Serialize is used without RFC 8785 canonicalization
|
||||
/// in methods that participate in digest computation or attestation signing.
|
||||
/// </summary>
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public sealed class CanonicalizationBoundaryAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic ID for canonicalization boundary violations.
|
||||
/// </summary>
|
||||
public const string DiagnosticId = "STELLA0100";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for missing NFC normalization.
|
||||
/// </summary>
|
||||
public const string NfcDiagnosticId = "STELLA0101";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for non-deterministic collection iteration.
|
||||
/// </summary>
|
||||
public const string CollectionDiagnosticId = "STELLA0102";
|
||||
|
||||
// Type names to detect
|
||||
private const string JsonSerializerTypeName = "System.Text.Json.JsonSerializer";
|
||||
private const string Sha256TypeName = "System.Security.Cryptography.SHA256";
|
||||
private const string Sha384TypeName = "System.Security.Cryptography.SHA384";
|
||||
private const string Sha512TypeName = "System.Security.Cryptography.SHA512";
|
||||
private const string CanonicalizerTypeName = "StellaOps.Attestor.ProofChain.Json.Rfc8785JsonCanonicalizer";
|
||||
private const string CanonicalJsonTypeName = "StellaOps.Canonical.Json.CanonicalJsonSerializer";
|
||||
|
||||
// Method names indicating digest computation
|
||||
private static readonly string[] DigestMethodPatterns =
|
||||
[
|
||||
"ComputeDigest",
|
||||
"ComputeHash",
|
||||
"HashData",
|
||||
"CreateDigest",
|
||||
"CalculateHash",
|
||||
"Sign",
|
||||
"CreateAttestation",
|
||||
"CreateProof",
|
||||
"SerializeForSigning"
|
||||
];
|
||||
|
||||
// Attribute names marking resolver boundaries
|
||||
private static readonly string[] ResolverBoundaryAttributes =
|
||||
[
|
||||
"ResolverBoundary",
|
||||
"RequiresCanonicalization",
|
||||
"DeterministicOutput"
|
||||
];
|
||||
|
||||
private static readonly DiagnosticDescriptor CanonicalizationRule = new(
|
||||
DiagnosticId,
|
||||
title: "Non-canonical JSON serialization at resolver boundary",
|
||||
messageFormat: "Use Rfc8785JsonCanonicalizer instead of JsonSerializer.Serialize for deterministic digest computation in '{0}'",
|
||||
category: "Determinism",
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: "JSON serialization at resolver boundaries must use RFC 8785 JCS canonicalization to ensure deterministic digests across platforms. Use Rfc8785JsonCanonicalizer.Canonicalize() instead of JsonSerializer.Serialize().",
|
||||
helpLinkUri: "https://stella-ops.org/docs/contributing/canonicalization-determinism");
|
||||
|
||||
private static readonly DiagnosticDescriptor NfcRule = new(
|
||||
NfcDiagnosticId,
|
||||
title: "Unicode string not NFC normalized before hashing",
|
||||
messageFormat: "String '{0}' should be NFC normalized before digest computation to ensure cross-platform consistency",
|
||||
category: "Determinism",
|
||||
defaultSeverity: DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: true,
|
||||
description: "Strings from external sources should be NFC normalized before participating in digest computation to avoid platform-specific Unicode representation differences.",
|
||||
helpLinkUri: "https://stella-ops.org/docs/contributing/canonicalization-determinism#unicode-nfc-normalization");
|
||||
|
||||
private static readonly DiagnosticDescriptor CollectionRule = new(
|
||||
CollectionDiagnosticId,
|
||||
title: "Non-deterministic collection iteration in digest context",
|
||||
messageFormat: "Collection iteration in '{0}' should use OrderBy or FrozenDictionary for deterministic ordering",
|
||||
category: "Determinism",
|
||||
defaultSeverity: DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: "Collections participating in digest computation must be iterated in a deterministic order. Use OrderBy() with StringComparer.Ordinal or FrozenDictionary for stable iteration.",
|
||||
helpLinkUri: "https://stella-ops.org/docs/contributing/canonicalization-determinism#collection-ordering");
|
||||
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
|
||||
ImmutableArray.Create(CanonicalizationRule, NfcRule, CollectionRule);
|
||||
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
|
||||
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
||||
context.RegisterSyntaxNodeAction(AnalyzeForEach, SyntaxKind.ForEachStatement);
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IInvocationOperation invocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var method = invocation.TargetMethod;
|
||||
var containingMethod = context.ContainingSymbol as IMethodSymbol;
|
||||
|
||||
// Check if we're in a resolver boundary context
|
||||
if (!IsInResolverBoundaryContext(containingMethod, context.Compilation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for JsonSerializer.Serialize usage
|
||||
if (IsJsonSerializerSerialize(method))
|
||||
{
|
||||
// Check if there's a canonicalizer call nearby (simple heuristic)
|
||||
if (!HasCanonicalizerInScope(containingMethod, context.Compilation))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
CanonicalizationRule,
|
||||
invocation.Syntax.GetLocation(),
|
||||
containingMethod?.Name ?? "unknown");
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Dictionary/HashSet iteration in digest methods
|
||||
if (IsHashComputationMethod(method) && HasDictionaryArgumentWithoutOrdering(invocation))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
CollectionRule,
|
||||
invocation.Syntax.GetLocation(),
|
||||
containingMethod?.Name ?? "unknown");
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeForEach(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var forEachStatement = (ForEachStatementSyntax)context.Node;
|
||||
var containingMethod = context.ContainingSymbol as IMethodSymbol;
|
||||
|
||||
// Check if we're in a resolver boundary context
|
||||
if (!IsInResolverBoundaryContext(containingMethod, context.Compilation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the type of the collection being iterated
|
||||
var typeInfo = context.SemanticModel.GetTypeInfo(forEachStatement.Expression, context.CancellationToken);
|
||||
var collectionType = typeInfo.Type;
|
||||
|
||||
if (collectionType is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a Dictionary or HashSet (non-deterministic iteration order)
|
||||
if (IsNonDeterministicCollection(collectionType) && !HasOrderByInExpression(forEachStatement.Expression))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
CollectionRule,
|
||||
forEachStatement.Expression.GetLocation(),
|
||||
containingMethod?.Name ?? "unknown");
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInResolverBoundaryContext(IMethodSymbol? method, Compilation compilation)
|
||||
{
|
||||
if (method is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for resolver boundary attributes
|
||||
foreach (var attr in method.GetAttributes())
|
||||
{
|
||||
var attrName = attr.AttributeClass?.Name;
|
||||
if (attrName is not null && ResolverBoundaryAttributes.Any(a => attrName.Contains(a)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check method name patterns
|
||||
foreach (var pattern in DigestMethodPatterns)
|
||||
{
|
||||
if (method.Name.Contains(pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check containing type for resolver patterns
|
||||
var containingType = method.ContainingType;
|
||||
if (containingType?.Name.Contains("Resolver") == true ||
|
||||
containingType?.Name.Contains("Attestor") == true ||
|
||||
containingType?.Name.Contains("Proof") == true ||
|
||||
containingType?.Name.Contains("Canonicalizer") == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsJsonSerializerSerialize(IMethodSymbol method)
|
||||
{
|
||||
if (method.Name != "Serialize")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var containingType = method.ContainingType;
|
||||
return containingType?.ToDisplayString() == JsonSerializerTypeName;
|
||||
}
|
||||
|
||||
private static bool IsHashComputationMethod(IMethodSymbol method)
|
||||
{
|
||||
var containingType = method.ContainingType?.ToDisplayString();
|
||||
return containingType == Sha256TypeName ||
|
||||
containingType == Sha384TypeName ||
|
||||
containingType == Sha512TypeName;
|
||||
}
|
||||
|
||||
private static bool HasCanonicalizerInScope(IMethodSymbol? method, Compilation compilation)
|
||||
{
|
||||
if (method is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the containing type has a field or local of type Rfc8785JsonCanonicalizer
|
||||
var containingType = method.ContainingType;
|
||||
if (containingType is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var member in containingType.GetMembers())
|
||||
{
|
||||
if (member is IFieldSymbol field)
|
||||
{
|
||||
var fieldTypeName = field.Type.ToDisplayString();
|
||||
if (fieldTypeName.Contains("Canonicalizer") || fieldTypeName.Contains("CanonicalJson"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasDictionaryArgumentWithoutOrdering(IInvocationOperation invocation)
|
||||
{
|
||||
foreach (var arg in invocation.Arguments)
|
||||
{
|
||||
var argType = arg.Value.Type;
|
||||
if (argType is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var typeName = argType.ToDisplayString();
|
||||
if ((typeName.Contains("Dictionary") || typeName.Contains("HashSet")) &&
|
||||
!typeName.Contains("Frozen") &&
|
||||
!typeName.Contains("Sorted") &&
|
||||
!typeName.Contains("Immutable"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNonDeterministicCollection(ITypeSymbol type)
|
||||
{
|
||||
var typeName = type.ToDisplayString();
|
||||
|
||||
// FrozenDictionary, SortedDictionary, ImmutableSortedDictionary are deterministic
|
||||
if (typeName.Contains("Frozen") ||
|
||||
typeName.Contains("Sorted") ||
|
||||
typeName.Contains("ImmutableSorted"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Regular Dictionary and HashSet are non-deterministic
|
||||
return typeName.Contains("Dictionary") || typeName.Contains("HashSet");
|
||||
}
|
||||
|
||||
private static bool HasOrderByInExpression(ExpressionSyntax expression)
|
||||
{
|
||||
// Simple check: look for OrderBy in the expression text
|
||||
var text = expression.ToString();
|
||||
return text.Contains("OrderBy") || text.Contains("Order(");
|
||||
}
|
||||
}
|
||||
93
src/__Analyzers/StellaOps.Determinism.Analyzers/README.md
Normal file
93
src/__Analyzers/StellaOps.Determinism.Analyzers/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# StellaOps.Determinism.Analyzers
|
||||
|
||||
Roslyn analyzers enforcing determinism patterns in StellaOps codebase.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
| ID | Severity | Description |
|
||||
|----|----------|-------------|
|
||||
| STELLA0100 | Warning | Non-canonical JSON serialization at resolver boundary |
|
||||
| STELLA0101 | Info | Unicode string not NFC normalized before hashing |
|
||||
| STELLA0102 | Warning | Non-deterministic collection iteration in digest context |
|
||||
|
||||
## STELLA0100: Canonicalization Boundary Violation
|
||||
|
||||
**Triggers when:** `JsonSerializer.Serialize()` is used in methods that compute digests, create attestations, or are marked with resolver boundary attributes.
|
||||
|
||||
**Why it matters:** Non-canonical JSON produces different byte representations on different platforms, breaking signature verification and replay guarantees.
|
||||
|
||||
**Fix:** Use `Rfc8785JsonCanonicalizer.Canonicalize()` instead of `JsonSerializer.Serialize()`.
|
||||
|
||||
### Example
|
||||
|
||||
```csharp
|
||||
// ❌ STELLA0100: Non-canonical JSON serialization
|
||||
public string ComputeDigest(object data)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(data); // Warning here
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(json)).ToHexString();
|
||||
}
|
||||
|
||||
// ✅ Correct: Use canonicalizer
|
||||
public string ComputeDigest(object data)
|
||||
{
|
||||
var canonicalizer = new Rfc8785JsonCanonicalizer();
|
||||
var canonical = canonicalizer.Canonicalize(data);
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(canonical)).ToHexString();
|
||||
}
|
||||
```
|
||||
|
||||
## STELLA0102: Non-Deterministic Collection Iteration
|
||||
|
||||
**Triggers when:** `foreach` iterates over `Dictionary` or `HashSet` in resolver boundary methods without explicit ordering.
|
||||
|
||||
**Why it matters:** Dictionary/HashSet iteration order is not guaranteed across runs or platforms.
|
||||
|
||||
**Fix:** Use `OrderBy()` before iteration, or use `FrozenDictionary`/`SortedDictionary`.
|
||||
|
||||
### Example
|
||||
|
||||
```csharp
|
||||
// ❌ STELLA0102: Non-deterministic iteration
|
||||
public void ProcessItems(Dictionary<string, Item> items)
|
||||
{
|
||||
foreach (var item in items) // Warning here
|
||||
{
|
||||
AppendToDigest(item.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: Order before iteration
|
||||
public void ProcessItems(Dictionary<string, Item> items)
|
||||
{
|
||||
foreach (var item in items.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
{
|
||||
AppendToDigest(item.Key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the analyzer to your project:
|
||||
|
||||
```xml
|
||||
<ProjectReference Include="path/to/StellaOps.Determinism.Analyzers.csproj"
|
||||
OutputItemType="Analyzer"
|
||||
ReferenceOutputAssembly="false" />
|
||||
```
|
||||
|
||||
## Suppression
|
||||
|
||||
When intentionally using non-canonical serialization (e.g., for human-readable output):
|
||||
|
||||
```csharp
|
||||
#pragma warning disable STELLA0100 // Intentional: human-readable log output
|
||||
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
#pragma warning restore STELLA0100
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Canonicalization & Determinism Patterns](../../docs/contributing/canonicalization-determinism.md)
|
||||
- [RFC 8785 - JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785)
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<RootNamespace>StellaOps.Determinism.Analyzers</RootNamespace>
|
||||
<Description>Roslyn analyzer enforcing canonicalization at resolver boundaries (STELLA0100).</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Visible="false" />
|
||||
<None Include="AnalyzerReleases.Shipped.md" Visible="false" />
|
||||
<None Include="AnalyzerReleases.Unshipped.md" Visible="false" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user