up
This commit is contained in:
266
src/StellaOps.Policy/PolicyScoringConfigBinder.cs
Normal file
266
src/StellaOps.Policy/PolicyScoringConfigBinder.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyScoringBindingResult(
|
||||
bool Success,
|
||||
PolicyScoringConfig? Config,
|
||||
ImmutableArray<PolicyIssue> Issues);
|
||||
|
||||
public static class PolicyScoringConfigBinder
|
||||
{
|
||||
private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public static PolicyScoringConfig LoadDefault()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(DefaultResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{DefaultResourceName}' not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var json = reader.ReadToEnd();
|
||||
var binding = Bind(json, PolicyDocumentFormat.Json);
|
||||
if (!binding.Success || binding.Config is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to load default policy scoring configuration.");
|
||||
}
|
||||
|
||||
return binding.Config;
|
||||
}
|
||||
|
||||
public static PolicyScoringBindingResult Bind(string content, PolicyDocumentFormat format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.empty", "Scoring configuration content is empty.", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var root = Parse(content, format);
|
||||
if (root is not JsonObject obj)
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.invalid", "Scoring configuration must be a JSON object.", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
var config = BuildConfig(obj, issues);
|
||||
var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error);
|
||||
return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.parse.json", $"Failed to parse scoring JSON: {ex.Message}", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
catch (YamlDotNet.Core.YamlException ex)
|
||||
{
|
||||
var issue = PolicyIssue.Error("scoring.parse.yaml", $"Failed to parse scoring YAML: {ex.Message}", "$");
|
||||
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? Parse(string content, PolicyDocumentFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
PolicyDocumentFormat.Json => JsonNode.Parse(content, new JsonNodeOptions { PropertyNameCaseInsensitive = true }),
|
||||
PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported scoring configuration format."),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertYamlToJsonNode(string content)
|
||||
{
|
||||
var yamlObject = YamlDeserializer.Deserialize<object?>(content);
|
||||
return PolicyBinderUtilities.ConvertYamlObject(yamlObject);
|
||||
}
|
||||
|
||||
private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion;
|
||||
|
||||
var severityWeights = ReadSeverityWeights(obj, issues);
|
||||
var quietPenalty = ReadDouble(obj, "quietPenalty", issues, defaultValue: 45);
|
||||
var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15);
|
||||
var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35);
|
||||
var trustOverrides = ReadTrustOverrides(obj, issues);
|
||||
|
||||
return new PolicyScoringConfig(
|
||||
version,
|
||||
severityWeights,
|
||||
quietPenalty,
|
||||
warnPenalty,
|
||||
ignorePenalty,
|
||||
trustOverrides);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<PolicySeverity, double> ReadSeverityWeights(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("severityWeights", out var node) || node is not JsonObject severityObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.severityWeights.missing", "severityWeights section is required.", "$.severityWeights"));
|
||||
return ImmutableDictionary<PolicySeverity, double>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<PolicySeverity, double>();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
if (!severityObj.TryGetPropertyValue(key, out var valueNode))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.severityWeights.default", $"Severity '{key}' not specified; defaulting to 0.", $"$.severityWeights.{key}"));
|
||||
builder[severity] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractDouble(valueNode, issues, $"$.severityWeights.{key}");
|
||||
builder[severity] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static double ReadDouble(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, double defaultValue)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(property, out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.numeric.default", $"{property} not specified; defaulting to {defaultValue:0.##}.", $"$.{property}"));
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return ExtractDouble(node, issues, $"$.{property}");
|
||||
}
|
||||
|
||||
private static double ExtractDouble(JsonNode? node, ImmutableArray<PolicyIssue>.Builder issues, string path)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.numeric.null", $"Value at {path} missing; defaulting to 0.", path));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (node is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out double number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error("scoring.numeric.invalid", $"Value at {path} is not numeric.", path));
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> ReadTrustOverrides(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("trustOverrides", out var node) || node is not JsonObject trustObj)
|
||||
{
|
||||
return ImmutableDictionary<string, double>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in trustObj)
|
||||
{
|
||||
var value = ExtractDouble(pair.Value, issues, $"$.trustOverrides.{pair.Key}");
|
||||
builder[pair.Key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, bool required)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue(property, out var node) || node is null)
|
||||
{
|
||||
if (required)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.string.missing", $"{property} is required.", $"$.{property}"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node is JsonValue value && value.TryGetValue(out string? text))
|
||||
{
|
||||
return text?.Trim();
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error("scoring.string.invalid", $"{property} must be a string.", $"$.{property}"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class PolicyBinderUtilities
|
||||
{
|
||||
public static JsonNode? ConvertYamlObject(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case string s:
|
||||
return JsonValue.Create(s);
|
||||
case bool b:
|
||||
return JsonValue.Create(b);
|
||||
case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal:
|
||||
return JsonValue.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture));
|
||||
case IDictionary dictionary:
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
if (entry.Key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[entry.Key.ToString()!] = ConvertYamlObject(entry.Value);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
case IEnumerable enumerable:
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
array.Add(ConvertYamlObject(item));
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user