using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; namespace StellaOps.Scheduler.Models; /// /// Deterministic serializer for scheduler DTOs. /// public static class CanonicalJsonSerializer { private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false); private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true); private static readonly IReadOnlyDictionary PropertyOrder = new Dictionary { [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 value) => JsonSerializer.Serialize(value, CompactOptions); public static string SerializeIndented(T value) => JsonSerializer.Serialize(value, PrettyOptions); public static T Deserialize(string json) => JsonSerializer.Deserialize(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; } } }