Resolve Concelier/Excititor merge conflicts

This commit is contained in:
root
2025-10-20 14:19:25 +03:00
2687 changed files with 212646 additions and 85913 deletions

View File

@@ -0,0 +1,915 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Policy;
public enum PolicyDocumentFormat
{
Json,
Yaml,
}
public sealed record PolicyBindingResult(
bool Success,
PolicyDocument Document,
ImmutableArray<PolicyIssue> Issues,
PolicyDocumentFormat Format);
public static class PolicyBinder
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString,
Converters =
{
new JsonStringEnumConverter()
},
};
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
public static PolicyBindingResult Bind(string content, PolicyDocumentFormat format)
{
if (string.IsNullOrWhiteSpace(content))
{
var issues = ImmutableArray.Create(
PolicyIssue.Error("policy.empty", "Policy document is empty.", "$"));
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
}
try
{
var node = ParseToNode(content, format);
if (node is not JsonObject obj)
{
var issues = ImmutableArray.Create(
PolicyIssue.Error("policy.document.invalid", "Policy document must be an object.", "$"));
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
}
var model = obj.Deserialize<PolicyDocumentModel>(SerializerOptions) ?? new PolicyDocumentModel();
var normalization = PolicyNormalizer.Normalize(model);
var success = normalization.Issues.All(static issue => issue.Severity != PolicyIssueSeverity.Error);
return new PolicyBindingResult(success, normalization.Document, normalization.Issues, format);
}
catch (JsonException ex)
{
var issues = ImmutableArray.Create(
PolicyIssue.Error("policy.parse.json", $"Failed to parse policy JSON: {ex.Message}", "$"));
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
}
catch (YamlDotNet.Core.YamlException ex)
{
var issues = ImmutableArray.Create(
PolicyIssue.Error("policy.parse.yaml", $"Failed to parse policy YAML: {ex.Message}", "$"));
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
}
}
public static PolicyBindingResult Bind(Stream stream, PolicyDocumentFormat format, Encoding? encoding = null)
{
if (stream is null)
{
throw new ArgumentNullException(nameof(stream));
}
encoding ??= Encoding.UTF8;
using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
var content = reader.ReadToEnd();
return Bind(content, format);
}
private static JsonNode? ParseToNode(string content, PolicyDocumentFormat format)
{
return format switch
{
PolicyDocumentFormat.Json => JsonNode.Parse(content, documentOptions: new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
}),
PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content),
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported policy document format."),
};
}
private static JsonNode? ConvertYamlToJsonNode(string content)
{
var yamlObject = YamlDeserializer.Deserialize<object?>(content);
return ConvertYamlObject(yamlObject);
}
private static JsonNode? ConvertYamlObject(object? value)
{
switch (value)
{
case null:
return null;
case string s when bool.TryParse(s, out var boolValue):
return JsonValue.Create(boolValue);
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.ToDecimal(value, CultureInfo.InvariantCulture));
case DateTime dt:
return JsonValue.Create(dt.ToString("O", CultureInfo.InvariantCulture));
case DateTimeOffset dto:
return JsonValue.Create(dto.ToString("O", CultureInfo.InvariantCulture));
case Enum e:
return JsonValue.Create(e.ToString());
case IDictionary dictionary:
{
var obj = new JsonObject();
foreach (DictionaryEntry entry in dictionary)
{
if (entry.Key is null)
{
continue;
}
var key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
obj[key!] = 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());
}
}
private sealed record PolicyDocumentModel
{
[JsonPropertyName("version")]
public JsonNode? Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonPropertyName("rules")]
public List<PolicyRuleModel>? Rules { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyRuleModel
{
[JsonPropertyName("id")]
public string? Identifier { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("severity")]
public List<string>? Severity { get; init; }
[JsonPropertyName("sources")]
public List<string>? Sources { get; init; }
[JsonPropertyName("vendors")]
public List<string>? Vendors { get; init; }
[JsonPropertyName("licenses")]
public List<string>? Licenses { get; init; }
[JsonPropertyName("tags")]
public List<string>? Tags { get; init; }
[JsonPropertyName("environments")]
public List<string>? Environments { get; init; }
[JsonPropertyName("images")]
public List<string>? Images { get; init; }
[JsonPropertyName("repositories")]
public List<string>? Repositories { get; init; }
[JsonPropertyName("packages")]
public List<string>? Packages { get; init; }
[JsonPropertyName("purls")]
public List<string>? Purls { get; init; }
[JsonPropertyName("cves")]
public List<string>? Cves { get; init; }
[JsonPropertyName("paths")]
public List<string>? Paths { get; init; }
[JsonPropertyName("layerDigests")]
public List<string>? LayerDigests { get; init; }
[JsonPropertyName("usedByEntrypoint")]
public List<string>? UsedByEntrypoint { get; init; }
[JsonPropertyName("action")]
public JsonNode? Action { get; init; }
[JsonPropertyName("expires")]
public JsonNode? Expires { get; init; }
[JsonPropertyName("until")]
public JsonNode? Until { get; init; }
[JsonPropertyName("justification")]
public string? Justification { get; init; }
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed class PolicyNormalizer
{
private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = PolicySeverity.Critical,
["high"] = PolicySeverity.High,
["medium"] = PolicySeverity.Medium,
["moderate"] = PolicySeverity.Medium,
["low"] = PolicySeverity.Low,
["informational"] = PolicySeverity.Informational,
["info"] = PolicySeverity.Informational,
["none"] = PolicySeverity.None,
["unknown"] = PolicySeverity.Unknown,
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
public static (PolicyDocument Document, ImmutableArray<PolicyIssue> Issues) Normalize(PolicyDocumentModel model)
{
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var version = NormalizeVersion(model.Version, issues);
var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
var rules = NormalizeRules(model.Rules, issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.document.extension",
$"Unrecognized document property '{pair.Key}' has been ignored.",
$"$.{pair.Key}"));
}
}
var document = new PolicyDocument(
version ?? PolicySchema.CurrentVersion,
rules,
metadata);
var orderedIssues = SortIssues(issues);
return (document, orderedIssues);
}
private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.Builder issues)
{
if (versionNode is null)
{
issues.Add(PolicyIssue.Warning("policy.version.missing", "Policy version not specified; defaulting to 1.0.", "$.version"));
return PolicySchema.CurrentVersion;
}
if (versionNode is JsonValue value)
{
if (value.TryGetValue(out string? versionText))
{
versionText = versionText?.Trim();
if (string.IsNullOrEmpty(versionText))
{
issues.Add(PolicyIssue.Error("policy.version.empty", "Policy version is empty.", "$.version"));
return null;
}
if (IsSupportedVersion(versionText))
{
return CanonicalizeVersion(versionText);
}
issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{versionText}'. Expected '{PolicySchema.CurrentVersion}'.", "$.version"));
return null;
}
if (value.TryGetValue(out double numericVersion))
{
var numericText = numericVersion.ToString("0.0###", CultureInfo.InvariantCulture);
if (IsSupportedVersion(numericText))
{
return CanonicalizeVersion(numericText);
}
issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{numericText}'.", "$.version"));
return null;
}
}
var raw = versionNode.ToJsonString();
issues.Add(PolicyIssue.Error("policy.version.invalid", $"Policy version must be a string. Received: {raw}", "$.version"));
return null;
}
private static bool IsSupportedVersion(string versionText)
=> string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(versionText, "1.0", StringComparison.OrdinalIgnoreCase)
|| string.Equals(versionText, PolicySchema.CurrentVersion, StringComparison.OrdinalIgnoreCase);
private static string CanonicalizeVersion(string versionText)
=> string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase)
? "1.0"
: versionText;
private static ImmutableDictionary<string, string> NormalizeMetadata(
Dictionary<string, JsonNode?>? metadata,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (metadata is null || metadata.Count == 0)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
var key = pair.Key?.Trim();
if (string.IsNullOrEmpty(key))
{
issues.Add(PolicyIssue.Warning("policy.metadata.key.empty", "Metadata keys must be non-empty strings.", path));
continue;
}
var value = ConvertNodeToString(pair.Value);
builder[key] = value;
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyRule> NormalizeRules(
List<PolicyRuleModel>? rules,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (rules is null || rules.Count == 0)
{
issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules"));
return ImmutableArray<PolicyRule>.Empty;
}
var normalized = new List<(PolicyRule Rule, int Index)>(rules.Count);
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < rules.Count; index++)
{
var model = rules[index];
var normalizedRule = NormalizeRule(model, index, issues);
if (normalizedRule is null)
{
continue;
}
if (!seenNames.Add(normalizedRule.Name))
{
issues.Add(PolicyIssue.Warning(
"policy.rules.duplicateName",
$"Duplicate rule name '{normalizedRule.Name}' detected; evaluation order may be ambiguous.",
$"$.rules[{index}].name"));
}
normalized.Add((normalizedRule, index));
}
return normalized
.OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Index)
.Select(static tuple => tuple.Rule)
.ToImmutableArray();
}
private static PolicyRule? NormalizeRule(
PolicyRuleModel model,
int index,
ImmutableArray<PolicyIssue>.Builder issues)
{
var basePath = $"$.rules[{index}]";
var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues);
if (name is null)
{
return null;
}
var identifier = NormalizeOptionalString(model.Identifier);
var description = NormalizeOptionalString(model.Description);
var metadata = NormalizeMetadata(model.Metadata, $"{basePath}.metadata", issues);
var severities = NormalizeSeverityList(model.Severity, $"{basePath}.severity", issues);
var environments = NormalizeStringList(model.Environments, $"{basePath}.environments", issues);
var sources = NormalizeStringList(model.Sources, $"{basePath}.sources", issues);
var vendors = NormalizeStringList(model.Vendors, $"{basePath}.vendors", issues);
var licenses = NormalizeStringList(model.Licenses, $"{basePath}.licenses", issues);
var tags = NormalizeStringList(model.Tags, $"{basePath}.tags", issues);
var match = new PolicyRuleMatchCriteria(
NormalizeStringList(model.Images, $"{basePath}.images", issues),
NormalizeStringList(model.Repositories, $"{basePath}.repositories", issues),
NormalizeStringList(model.Packages, $"{basePath}.packages", issues),
NormalizeStringList(model.Purls, $"{basePath}.purls", issues),
NormalizeStringList(model.Cves, $"{basePath}.cves", issues),
NormalizeStringList(model.Paths, $"{basePath}.paths", issues),
NormalizeStringList(model.LayerDigests, $"{basePath}.layerDigests", issues),
NormalizeStringList(model.UsedByEntrypoint, $"{basePath}.usedByEntrypoint", issues));
var action = NormalizeAction(model, basePath, issues);
var justification = NormalizeOptionalString(model.Justification);
var expires = NormalizeTemporal(model.Expires ?? model.Until, $"{basePath}.expires", issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.rule.extension",
$"Unrecognized rule property '{pair.Key}' has been ignored.",
$"{basePath}.{pair.Key}"));
}
}
return PolicyRule.Create(
name,
action,
severities,
environments,
sources,
vendors,
licenses,
tags,
match,
expires,
justification,
identifier,
description,
metadata);
}
private static PolicyAction NormalizeAction(
PolicyRuleModel model,
string basePath,
ImmutableArray<PolicyIssue>.Builder issues)
{
var actionNode = model.Action;
var quiet = model.Quiet ?? false;
if (!quiet && model.Extensions is not null && model.Extensions.TryGetValue("quiet", out var quietExtension) && quietExtension.ValueKind == JsonValueKind.True)
{
quiet = true;
}
string? justification = NormalizeOptionalString(model.Justification);
DateTimeOffset? until = NormalizeTemporal(model.Until, $"{basePath}.until", issues);
DateTimeOffset? expires = NormalizeTemporal(model.Expires, $"{basePath}.expires", issues);
var effectiveUntil = until ?? expires;
if (actionNode is null)
{
issues.Add(PolicyIssue.Error("policy.action.missing", "Rule action is required.", $"{basePath}.action"));
return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: false);
}
string? actionType = null;
JsonObject? actionObject = null;
switch (actionNode)
{
case JsonValue value when value.TryGetValue(out string? text):
actionType = text;
break;
case JsonValue value when value.TryGetValue(out bool booleanValue):
actionType = booleanValue ? "block" : "ignore";
break;
case JsonObject obj:
actionObject = obj;
if (obj.TryGetPropertyValue("type", out var typeNode) && typeNode is JsonValue typeValue && typeValue.TryGetValue(out string? typeText))
{
actionType = typeText;
}
else
{
issues.Add(PolicyIssue.Error("policy.action.type", "Action object must contain a 'type' property.", $"{basePath}.action.type"));
}
if (obj.TryGetPropertyValue("quiet", out var quietNode) && quietNode is JsonValue quietValue && quietValue.TryGetValue(out bool quietFlag))
{
quiet = quietFlag;
}
if (obj.TryGetPropertyValue("until", out var untilNode))
{
effectiveUntil ??= NormalizeTemporal(untilNode, $"{basePath}.action.until", issues);
}
if (obj.TryGetPropertyValue("justification", out var justificationNode) && justificationNode is JsonValue justificationValue && justificationValue.TryGetValue(out string? justificationText))
{
justification = NormalizeOptionalString(justificationText);
}
break;
default:
actionType = actionNode.ToString();
break;
}
if (string.IsNullOrWhiteSpace(actionType))
{
issues.Add(PolicyIssue.Error("policy.action.type", "Action type is required.", $"{basePath}.action"));
return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: quiet);
}
actionType = actionType.Trim();
var (type, typeIssues) = MapActionType(actionType, $"{basePath}.action");
foreach (var issue in typeIssues)
{
issues.Add(issue);
}
PolicyIgnoreOptions? ignoreOptions = null;
PolicyEscalateOptions? escalateOptions = null;
PolicyRequireVexOptions? requireVexOptions = null;
if (type == PolicyActionType.Ignore)
{
ignoreOptions = new PolicyIgnoreOptions(effectiveUntil, justification);
}
else if (type == PolicyActionType.Escalate)
{
escalateOptions = NormalizeEscalateOptions(actionObject, $"{basePath}.action", issues);
}
else if (type == PolicyActionType.RequireVex)
{
requireVexOptions = NormalizeRequireVexOptions(actionObject, $"{basePath}.action", issues);
}
return new PolicyAction(type, ignoreOptions, escalateOptions, requireVexOptions, quiet);
}
private static (PolicyActionType Type, ImmutableArray<PolicyIssue> Issues) MapActionType(string value, string path)
{
var issues = ImmutableArray<PolicyIssue>.Empty;
var lower = value.ToLowerInvariant();
return lower switch
{
"block" or "fail" or "deny" => (PolicyActionType.Block, issues),
"ignore" or "mute" => (PolicyActionType.Ignore, issues),
"warn" or "warning" => (PolicyActionType.Warn, issues),
"defer" => (PolicyActionType.Defer, issues),
"escalate" => (PolicyActionType.Escalate, issues),
"requirevex" or "require_vex" or "require-vex" => (PolicyActionType.RequireVex, issues),
_ => (PolicyActionType.Block, ImmutableArray.Create(PolicyIssue.Warning(
"policy.action.unknown",
$"Unknown action '{value}' encountered. Defaulting to 'block'.",
path))),
};
}
private static PolicyEscalateOptions? NormalizeEscalateOptions(
JsonObject? actionObject,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (actionObject is null)
{
return null;
}
PolicySeverity? minSeverity = null;
bool requireKev = false;
double? minEpss = null;
if (actionObject.TryGetPropertyValue("severity", out var severityNode) && severityNode is JsonValue severityValue && severityValue.TryGetValue(out string? severityText))
{
if (SeverityMap.TryGetValue(severityText ?? string.Empty, out var mapped))
{
minSeverity = mapped;
}
else
{
issues.Add(PolicyIssue.Warning("policy.action.escalate.severity", $"Unknown escalate severity '{severityText}'.", $"{path}.severity"));
}
}
if (actionObject.TryGetPropertyValue("kev", out var kevNode) && kevNode is JsonValue kevValue && kevValue.TryGetValue(out bool kevFlag))
{
requireKev = kevFlag;
}
if (actionObject.TryGetPropertyValue("epss", out var epssNode))
{
var parsed = ParseDouble(epssNode, $"{path}.epss", issues);
if (parsed is { } epssValue)
{
if (epssValue < 0 || epssValue > 1)
{
issues.Add(PolicyIssue.Warning("policy.action.escalate.epssRange", "EPS score must be between 0 and 1.", $"{path}.epss"));
}
else
{
minEpss = epssValue;
}
}
}
return new PolicyEscalateOptions(minSeverity, requireKev, minEpss);
}
private static PolicyRequireVexOptions? NormalizeRequireVexOptions(
JsonObject? actionObject,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (actionObject is null)
{
return null;
}
var vendors = ImmutableArray<string>.Empty;
var justifications = ImmutableArray<string>.Empty;
if (actionObject.TryGetPropertyValue("vendors", out var vendorsNode))
{
vendors = NormalizeJsonStringArray(vendorsNode, $"{path}.vendors", issues);
}
if (actionObject.TryGetPropertyValue("justifications", out var justificationsNode))
{
justifications = NormalizeJsonStringArray(justificationsNode, $"{path}.justifications", issues);
}
return new PolicyRequireVexOptions(vendors, justifications);
}
private static ImmutableArray<string> NormalizeStringList(
List<string>? values,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (values is null || values.Count == 0)
{
return ImmutableArray<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
var normalized = NormalizeOptionalString(value);
if (string.IsNullOrEmpty(normalized))
{
issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path));
continue;
}
builder.Add(normalized);
}
return builder.ToImmutable()
.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static ImmutableArray<PolicySeverity> NormalizeSeverityList(
List<string>? values,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (values is null || values.Count == 0)
{
return ImmutableArray<PolicySeverity>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicySeverity>();
foreach (var value in values)
{
var normalized = NormalizeOptionalString(value);
if (string.IsNullOrEmpty(normalized))
{
issues.Add(PolicyIssue.Warning("policy.severity.blank", "Blank severity was ignored.", path));
continue;
}
if (SeverityMap.TryGetValue(normalized, out var severity))
{
builder.Add(severity);
}
else
{
issues.Add(PolicyIssue.Error("policy.severity.invalid", $"Unknown severity '{value}'.", path));
}
}
return builder.Distinct().OrderBy(static sev => sev).ToImmutableArray();
}
private static ImmutableArray<string> NormalizeJsonStringArray(
JsonNode? node,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (node is null)
{
return ImmutableArray<string>.Empty;
}
if (node is JsonArray array)
{
var values = new List<string>(array.Count);
foreach (var element in array)
{
var text = ConvertNodeToString(element);
if (string.IsNullOrWhiteSpace(text))
{
issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path));
}
else
{
values.Add(text);
}
}
return values
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
var single = ConvertNodeToString(node);
return ImmutableArray.Create(single);
}
private static double? ParseDouble(JsonNode? node, string path, ImmutableArray<PolicyIssue>.Builder issues)
{
if (node is null)
{
return null;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out double numeric))
{
return numeric;
}
if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out numeric))
{
return numeric;
}
}
issues.Add(PolicyIssue.Warning("policy.number.invalid", $"Value '{node.ToJsonString()}' is not a valid number.", path));
return null;
}
private static DateTimeOffset? NormalizeTemporal(JsonNode? node, string path, ImmutableArray<PolicyIssue>.Builder issues)
{
if (node is null)
{
return null;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out DateTimeOffset dto))
{
return dto;
}
if (value.TryGetValue(out DateTime dt))
{
return new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc));
}
if (value.TryGetValue(out string? text))
{
if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
{
return parsed;
}
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedDate))
{
return new DateTimeOffset(parsedDate);
}
}
}
issues.Add(PolicyIssue.Warning("policy.date.invalid", $"Value '{node.ToJsonString()}' is not a valid ISO-8601 timestamp.", path));
return null;
}
private static string? NormalizeRequiredString(
string? value,
string path,
string fieldDescription,
ImmutableArray<PolicyIssue>.Builder issues)
{
var normalized = NormalizeOptionalString(value);
if (!string.IsNullOrEmpty(normalized))
{
return normalized;
}
issues.Add(PolicyIssue.Error(
"policy.required",
$"{fieldDescription} is required.",
path));
return null;
}
private static string? NormalizeOptionalString(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
private static string ConvertNodeToString(JsonNode? node)
{
if (node is null)
{
return string.Empty;
}
return node switch
{
JsonValue value when value.TryGetValue(out string? text) => text ?? string.Empty,
JsonValue value when value.TryGetValue(out bool boolean) => boolean ? "true" : "false",
JsonValue value when value.TryGetValue(out double numeric) => numeric.ToString(CultureInfo.InvariantCulture),
JsonObject obj => obj.ToJsonString(),
JsonArray array => array.ToJsonString(),
_ => node.ToJsonString(),
};
}
private static ImmutableArray<PolicyIssue> SortIssues(ImmutableArray<PolicyIssue>.Builder issues)
{
return issues.ToImmutable()
.OrderBy(static issue => issue.Severity switch
{
PolicyIssueSeverity.Error => 0,
PolicyIssueSeverity.Warning => 1,
_ => 2,
})
.ThenBy(static issue => issue.Path, StringComparer.Ordinal)
.ThenBy(static issue => issue.Code, StringComparer.Ordinal)
.ToImmutableArray();
}
}
}