Files
git.stella-ops.org/src/Scheduler/__Libraries/StellaOps.Scheduler.Models/CanonicalJsonSerializer.cs
2025-10-28 15:10:40 +02:00

471 lines
13 KiB
C#

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace StellaOps.Scheduler.Models;
/// <summary>
/// Deterministic serializer for scheduler DTOs.
/// </summary>
public static class CanonicalJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrder = new Dictionary<Type, string[]>
{
[typeof(Schedule)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"name",
"enabled",
"cronExpression",
"timezone",
"mode",
"selection",
"onlyIf",
"notify",
"limits",
"subscribers",
"createdAt",
"createdBy",
"updatedAt",
"updatedBy",
},
[typeof(Selector)] = new[]
{
"scope",
"tenantId",
"namespaces",
"repositories",
"digests",
"includeTags",
"labels",
"resolvesTags",
},
[typeof(LabelSelector)] = new[]
{
"key",
"values",
},
[typeof(ScheduleOnlyIf)] = new[]
{
"lastReportOlderThanDays",
"policyRevision",
},
[typeof(ScheduleNotify)] = new[]
{
"onNewFindings",
"minSeverity",
"includeKev",
"includeQuietFindings",
},
[typeof(ScheduleLimits)] = new[]
{
"maxJobs",
"ratePerSecond",
"parallelism",
"burst",
},
[typeof(Run)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"scheduleId",
"trigger",
"state",
"stats",
"reason",
"createdAt",
"startedAt",
"finishedAt",
"error",
"deltas",
},
[typeof(RunStats)] = new[]
{
"candidates",
"deduped",
"queued",
"completed",
"deltas",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
},
[typeof(RunReason)] = new[]
{
"manualReason",
"feedserExportId",
"vexerExportId",
"cursor",
"impactWindowFrom",
"impactWindowTo",
},
[typeof(DeltaSummary)] = new[]
{
"imageDigest",
"newFindings",
"newCriticals",
"newHigh",
"newMedium",
"newLow",
"kevHits",
"topFindings",
"reportUrl",
"attestation",
"detectedAt",
},
[typeof(DeltaFinding)] = new[]
{
"purl",
"vulnerabilityId",
"severity",
"link",
},
[typeof(ImpactSet)] = new[]
{
"schemaVersion",
"selector",
"images",
"usageOnly",
"generatedAt",
"total",
"snapshotId",
},
[typeof(ImpactImage)] = new[]
{
"imageDigest",
"registry",
"repository",
"namespaces",
"tags",
"usedByEntrypoint",
"labels",
},
[typeof(AuditRecord)] = new[]
{
"id",
"tenantId",
"category",
"action",
"occurredAt",
"actor",
"entityId",
"scheduleId",
"runId",
"correlationId",
"metadata",
"message",
},
[typeof(AuditActor)] = new[]
{
"actorId",
"displayName",
"kind",
},
[typeof(GraphBuildJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"sbomId",
"sbomVersionId",
"sbomDigest",
"graphSnapshotId",
"status",
"trigger",
"attempts",
"cartographerJobId",
"correlationId",
"createdAt",
"startedAt",
"completedAt",
"error",
"metadata",
},
[typeof(GraphOverlayJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"graphSnapshotId",
"buildJobId",
"overlayKind",
"overlayKey",
"subjects",
"status",
"trigger",
"attempts",
"correlationId",
"createdAt",
"startedAt",
"completedAt",
"error",
"metadata",
},
[typeof(PolicyRunRequest)] = new[]
{
"schemaVersion",
"tenantId",
"policyId",
"policyVersion",
"mode",
"priority",
"runId",
"queuedAt",
"requestedBy",
"correlationId",
"metadata",
"inputs",
},
[typeof(PolicyRunInputs)] = new[]
{
"sbomSet",
"advisoryCursor",
"vexCursor",
"environment",
"captureExplain",
},
[typeof(PolicyRunStatus)] = new[]
{
"schemaVersion",
"runId",
"tenantId",
"policyId",
"policyVersion",
"mode",
"status",
"priority",
"queuedAt",
"startedAt",
"finishedAt",
"determinismHash",
"errorCode",
"error",
"attempts",
"traceId",
"explainUri",
"metadata",
"stats",
"inputs",
},
[typeof(PolicyRunJob)] = new[]
{
"schemaVersion",
"id",
"tenantId",
"policyId",
"policyVersion",
"mode",
"priority",
"priorityRank",
"runId",
"queuedAt",
"requestedBy",
"correlationId",
"metadata",
"inputs",
"status",
"attemptCount",
"lastAttemptAt",
"lastError",
"createdAt",
"updatedAt",
"availableAt",
"submittedAt",
"completedAt",
"leaseOwner",
"leaseExpiresAt",
"cancellationRequested",
"cancellationRequestedAt",
"cancellationReason",
"cancelledAt",
},
[typeof(PolicyRunStats)] = new[]
{
"components",
"rulesFired",
"findingsWritten",
"vexOverrides",
"quieted",
"suppressed",
"durationSeconds",
},
[typeof(PolicyDiffSummary)] = new[]
{
"schemaVersion",
"added",
"removed",
"unchanged",
"bySeverity",
"ruleHits",
},
[typeof(PolicyDiffSeverityDelta)] = new[]
{
"up",
"down",
},
[typeof(PolicyDiffRuleDelta)] = new[]
{
"ruleId",
"ruleName",
"up",
"down",
},
[typeof(PolicyExplainTrace)] = new[]
{
"schemaVersion",
"findingId",
"policyId",
"policyVersion",
"tenantId",
"runId",
"evaluatedAt",
"verdict",
"ruleChain",
"evidence",
"vexImpacts",
"history",
"metadata",
},
[typeof(PolicyExplainVerdict)] = new[]
{
"status",
"severity",
"quiet",
"score",
"rationale",
},
[typeof(PolicyExplainRule)] = new[]
{
"ruleId",
"ruleName",
"action",
"decision",
"score",
"condition",
},
[typeof(PolicyExplainEvidence)] = new[]
{
"type",
"reference",
"source",
"status",
"weight",
"justification",
"metadata",
},
[typeof(PolicyExplainVexImpact)] = new[]
{
"statementId",
"provider",
"status",
"accepted",
"justification",
"confidence",
},
[typeof(PolicyExplainHistoryEvent)] = new[]
{
"status",
"occurredAt",
"actor",
"note",
},
};
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static T Deserialize<T>(string json)
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
?? throw new InvalidOperationException($"Unable to deserialize {typeof(T).Name}.");
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = writeIndented,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
var resolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicResolver(resolver);
options.Converters.Add(new ScheduleModeConverter());
options.Converters.Add(new SelectorScopeConverter());
options.Converters.Add(new RunTriggerConverter());
options.Converters.Add(new RunStateConverter());
options.Converters.Add(new SeverityRankConverter());
options.Converters.Add(new GraphJobStatusConverter());
options.Converters.Add(new GraphBuildJobTriggerConverter());
options.Converters.Add(new GraphOverlayJobTriggerConverter());
options.Converters.Add(new GraphOverlayKindConverter());
options.Converters.Add(new PolicyRunModeConverter());
options.Converters.Add(new PolicyRunPriorityConverter());
options.Converters.Add(new PolicyRunExecutionStatusConverter());
options.Converters.Add(new PolicyVerdictStatusConverter());
options.Converters.Add(new PolicyRunJobStatusConverter());
return options;
}
private sealed class DeterministicResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _inner;
public DeterministicResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options);
if (info is null)
{
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
}
if (info.Kind is JsonTypeInfoKind.Object && info.Properties.Count > 1)
{
var ordered = info.Properties
.OrderBy(property => ResolveOrder(type, property.Name))
.ThenBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
private static int ResolveOrder(Type type, string propertyName)
{
if (PropertyOrder.TryGetValue(type, out var order))
{
var index = Array.IndexOf(order, propertyName);
if (index >= 0)
{
return index;
}
}
return int.MaxValue;
}
}
}