up
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Canonicalization;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic canonicalization, digesting, and merge semantics for RiskProfile documents.
|
||||
/// </summary>
|
||||
public static class RiskProfileCanonicalizer
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions SerializeOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
};
|
||||
|
||||
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(utf8Json, DocOptions);
|
||||
var canonical = CanonicalizeElement(doc.RootElement);
|
||||
return Encoding.UTF8.GetBytes(canonical);
|
||||
}
|
||||
|
||||
public static string CanonicalizeToString(string json)
|
||||
{
|
||||
var utf8 = Encoding.UTF8.GetBytes(json);
|
||||
return Encoding.UTF8.GetString(CanonicalizeToUtf8(utf8));
|
||||
}
|
||||
|
||||
public static string ComputeDigest(string json)
|
||||
{
|
||||
var canonical = CanonicalizeToUtf8(Encoding.UTF8.GetBytes(json));
|
||||
var hash = SHA256.HashData(canonical);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Merge(string baseProfileJson, string overlayProfileJson)
|
||||
{
|
||||
using var baseDoc = JsonDocument.Parse(baseProfileJson, DocOptions);
|
||||
using var overlayDoc = JsonDocument.Parse(overlayProfileJson, DocOptions);
|
||||
|
||||
var merged = MergeObjects(baseDoc.RootElement, overlayDoc.RootElement);
|
||||
var raw = merged.ToJsonString(SerializeOptions);
|
||||
return CanonicalizeToString(raw);
|
||||
}
|
||||
|
||||
private static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var node = JsonNode.Parse(element.GetRawText())!;
|
||||
CanonicalizeNode(node);
|
||||
return node.ToJsonString(SerializeOptions);
|
||||
}
|
||||
|
||||
private static void CanonicalizeNode(JsonNode node, IReadOnlyList<string>? path = null)
|
||||
{
|
||||
path ??= Array.Empty<string>();
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach (var kvp in obj.ToList())
|
||||
{
|
||||
if (kvp.Value is { } child)
|
||||
{
|
||||
CanonicalizeNode(child, Append(path, kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = obj.OrderBy(k => k.Key, StringComparer.Ordinal).ToList();
|
||||
obj.Clear();
|
||||
foreach (var kvp in ordered)
|
||||
{
|
||||
obj[kvp.Key] = kvp.Value;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
var items = array.ToList();
|
||||
foreach (var child in items)
|
||||
{
|
||||
CanonicalizeNode(child!, path);
|
||||
}
|
||||
|
||||
if (IsSignals(path))
|
||||
{
|
||||
items = items.OrderBy(i => i?["name"]?.GetValue<string>(), StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsWeights(path))
|
||||
{
|
||||
// weights are objects, not arrays; no-op
|
||||
}
|
||||
else if (IsSeverityOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsDecisionOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
array.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
array.Add(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject MergeObjects(JsonElement baseObj, JsonElement overlayObj)
|
||||
{
|
||||
var result = new JsonObject();
|
||||
|
||||
void Copy(JsonElement source)
|
||||
{
|
||||
foreach (var prop in source.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
Copy(baseObj);
|
||||
Copy(overlayObj);
|
||||
|
||||
// Signals
|
||||
var signals = MergeArrayByKey(baseObj, overlayObj, "signals", "name");
|
||||
if (signals is not null)
|
||||
{
|
||||
result["signals"] = signals;
|
||||
}
|
||||
|
||||
// Weights
|
||||
var weights = MergeObjectProperties(baseObj, overlayObj, "weights");
|
||||
if (weights is not null)
|
||||
{
|
||||
result["weights"] = weights;
|
||||
}
|
||||
|
||||
// Overrides.severity
|
||||
var overrides = MergeOverrides(baseObj, overlayObj);
|
||||
if (overrides is not null)
|
||||
{
|
||||
result["overrides"] = overrides;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
var metadata = MergeObjectProperties(baseObj, overlayObj, "metadata");
|
||||
if (metadata is not null)
|
||||
{
|
||||
result["metadata"] = metadata;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeOverrides(JsonElement baseObj, JsonElement overlayObj)
|
||||
{
|
||||
JsonElement? BaseOverrides() => baseObj.TryGetProperty("overrides", out var o) ? o : (JsonElement?)null;
|
||||
JsonElement? OverlayOverrides() => overlayObj.TryGetProperty("overrides", out var o) ? o : (JsonElement?)null;
|
||||
|
||||
var baseOverrides = BaseOverrides();
|
||||
var overlayOverrides = OverlayOverrides();
|
||||
if (baseOverrides is null && overlayOverrides is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
var severity = MergeArrayByPredicate(baseOverrides, overlayOverrides, "severity");
|
||||
if (severity is not null)
|
||||
{
|
||||
result["severity"] = severity;
|
||||
}
|
||||
|
||||
var decisions = MergeArrayByPredicate(baseOverrides, overlayOverrides, "decisions");
|
||||
if (decisions is not null)
|
||||
{
|
||||
result["decisions"] = decisions;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeArrayByPredicate(JsonElement? baseObj, JsonElement? overlayObj, string propertyName)
|
||||
{
|
||||
var baseArray = baseObj is { } b && b.TryGetProperty(propertyName, out var ba) && ba.ValueKind == JsonValueKind.Array ? ba : (JsonElement?)null;
|
||||
var overlayArray = overlayObj is { } o && o.TryGetProperty(propertyName, out var oa) && oa.ValueKind == JsonValueKind.Array ? oa : (JsonElement?)null;
|
||||
|
||||
if (baseArray is null && overlayArray is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var item in src.Value.EnumerateArray())
|
||||
{
|
||||
var key = GetWhenThenKey(item);
|
||||
dict[key] = JsonNode.Parse(item.GetRawText())!;
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseArray);
|
||||
Add(overlayArray);
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var kvp in dict.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
arr.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeArrayByKey(JsonElement baseObj, JsonElement overlayObj, string propertyName, string keyName)
|
||||
{
|
||||
JsonElement? Base() => baseObj.TryGetProperty(propertyName, out var s) && s.ValueKind == JsonValueKind.Array ? s : (JsonElement?)null;
|
||||
JsonElement? Overlay() => overlayObj.TryGetProperty(propertyName, out var s) && s.ValueKind == JsonValueKind.Array ? s : (JsonElement?)null;
|
||||
|
||||
var baseArray = Base();
|
||||
var overlayArray = Overlay();
|
||||
if (baseArray is null && overlayArray is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var item in src.Value.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty(keyName, out var keyProp) || keyProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = keyProp.GetString() ?? string.Empty;
|
||||
dict[key] = JsonNode.Parse(item.GetRawText())!;
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseArray);
|
||||
Add(overlayArray);
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var kvp in dict.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
arr.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeObjectProperties(JsonElement baseObj, JsonElement overlayObj, string propertyName)
|
||||
{
|
||||
var baseProp = baseObj.TryGetProperty(propertyName, out var bp) && bp.ValueKind == JsonValueKind.Object ? bp : (JsonElement?)null;
|
||||
var overlayProp = overlayObj.TryGetProperty(propertyName, out var op) && op.ValueKind == JsonValueKind.Object ? op : (JsonElement?)null;
|
||||
|
||||
if (baseProp is null && overlayProp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var prop in src.Value.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseProp);
|
||||
Add(overlayProp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetWhenThenKey(JsonElement element)
|
||||
{
|
||||
var when = element.TryGetProperty("when", out var whenProp) ? whenProp.GetRawText() : string.Empty;
|
||||
var then = element.TryGetProperty("set", out var setProp) ? setProp.GetRawText() : element.TryGetProperty("action", out var actionProp) ? actionProp.GetRawText() : string.Empty;
|
||||
return when + "|" + then;
|
||||
}
|
||||
|
||||
private static bool IsSignals(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "signals";
|
||||
|
||||
private static bool IsWeights(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "weights";
|
||||
|
||||
private static bool IsSeverityOverrides(IReadOnlyList<string> path)
|
||||
=> path.Count >= 2 && path[^2] == "overrides" && path[^1] == "severity";
|
||||
|
||||
private static bool IsDecisionOverrides(IReadOnlyList<string> path)
|
||||
=> path.Count >= 2 && path[^2] == "overrides" && path[^1] == "decisions";
|
||||
|
||||
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
return new[] { segment };
|
||||
}
|
||||
|
||||
var next = new string[path.Count + 1];
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
next[i] = path[i];
|
||||
}
|
||||
next[^1] = segment;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user