using System.Collections.Generic; using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; namespace StellaOps.Feedser.Models; /// /// Deterministic JSON serializer tuned for canonical advisory output. /// public static class CanonicalJsonSerializer { private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false); private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true); private static readonly IReadOnlyDictionary PropertyOrderOverrides = new Dictionary { { typeof(AdvisoryProvenance), new[] { "source", "kind", "value", "decisionReason", "recordedAt", "fieldMask", } }, { typeof(AffectedPackage), new[] { "type", "identifier", "platform", "versionRanges", "normalizedVersions", "statuses", "provenance", } }, { typeof(AdvisoryCredit), new[] { "displayName", "role", "contacts", "provenance", } }, { typeof(NormalizedVersionRule), new[] { "scheme", "type", "min", "minInclusive", "max", "maxInclusive", "value", "notes", } }, { typeof(AdvisoryWeakness), new[] { "taxonomy", "identifier", "name", "uri", "provenance", } }, }; public static string Serialize(T value) => JsonSerializer.Serialize(value, CompactOptions); public static string SerializeIndented(T value) => JsonSerializer.Serialize(value, PrettyOptions); public static Advisory Normalize(Advisory advisory) => new( advisory.AdvisoryKey, advisory.Title, advisory.Summary, advisory.Language, advisory.Published, advisory.Modified, advisory.Severity, advisory.ExploitKnown, advisory.Aliases, advisory.Credits, advisory.References, advisory.AffectedPackages, advisory.CvssMetrics, advisory.Provenance, advisory.Description, advisory.Cwes, advisory.CanonicalMetricId); public static T Deserialize(string json) => JsonSerializer.Deserialize(json, PrettyOptions)! ?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}."); private static JsonSerializerOptions CreateOptions(bool writeIndented) { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.Never, WriteIndented = writeIndented, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver(); options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver); options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false)); return options; } private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver { private readonly IJsonTypeInfoResolver _inner; public DeterministicTypeInfoResolver(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 is { Count: > 1 }) { var ordered = info.Properties .OrderBy(property => GetPropertyOrder(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 GetPropertyOrder(Type type, string propertyName) { if (PropertyOrderOverrides.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is var index && index >= 0) { return index; } return int.MaxValue; } } }