Files
git.stella-ops.org/src/StellaOps.Feedser.Models/CanonicalJsonSerializer.cs

176 lines
5.4 KiB
C#

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;
/// <summary>
/// Deterministic JSON serializer tuned for canonical advisory output.
/// </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[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
{
{
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>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(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<T>(string json)
=> JsonSerializer.Deserialize<T>(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;
}
}
}