File: CiFailureJson.cs
Web Access
Project: src\tools\AnalyzeCiFailure\AnalyzeCiFailure.csproj (AnalyzeCiFailure)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Numerics;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
 
namespace AnalyzeCiFailure;
 
// Tolerant external-payload and durable-record readers. Versioned transient
// transfers use TransientJson instead; these helpers never grant proposal authority.
/// <summary>Provides tolerant historical and upstream JSON access, exact numeric comparison and compatible serialization.</summary>
internal static class CiFailureJson
{
    private static readonly JsonSerializerOptions s_contractOptions = new()
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        Converters =
        {
            new ExactEnumConverter<RunScope>(),
            new ExactEnumConverter<EvidenceState>(),
            new ExactEnumConverter<CandidateHistoryState>(),
            new ExactEnumConverter<AnalysisVerdict>(),
            new ExactEnumConverter<JobClassification>(),
            new ExactEnumConverter<TestClassification>(),
            new ExactEnumConverter<CauseType>(),
            new ExactEnumConverter<TestArtifactFormat>(),
        },
    };
 
    private static readonly JsonSerializerOptions s_compactOptions = new(s_contractOptions) { WriteIndented = false };
 
    public static JsonNode? Read(string file) => Parse(File.ReadAllText(file));
 
    internal static T? ReadContract<T>(string file) => ToContract<T>(Read(file));
 
    internal static T? ToContract<T>(JsonNode? node) => node.Deserialize<T>(s_contractOptions);
 
    internal static T? ToContract<T>(JsonElement element) => element.Deserialize<T>(s_contractOptions);
 
    internal static JsonNode? ToNode<T>(T value) => JsonSerializer.SerializeToNode(value, s_contractOptions);
 
    internal static string Serialize<T>(T value) => JsonSerializer.Serialize(value, s_compactOptions);
 
    internal static string EnumText<T>(T value) where T : struct, Enum
        => Enum.IsDefined(value) ? JsonSerializer.SerializeToElement(value, s_contractOptions).GetString()! : value.ToString();
 
    internal static bool TryReadEnum<T>(string? text, out T value) where T : struct, Enum
    {
        if (text is null)
        {
            value = default;
            return false;
        }
        try
        {
            value = ToContract<T>(JsonValue.Create(text));
            return true;
        }
        catch (JsonException)
        {
            value = default;
            return false;
        }
    }
 
    internal static T ReadEnum<T>(string? text, string message) where T : struct, Enum
    {
        if (!TryReadEnum<T>(text, out var value))
        {
            throw new ValidationException(message);
        }
 
        return value;
    }
 
    internal static void WriteContract<T>(string file, T value)
        => File.WriteAllText(file, JsonSerializer.Serialize(value, s_contractOptions) + "\n");
 
    internal static NormalizedJsonNumber? AsNormalizedNumber(JsonElement? value)
        => value is { ValueKind: JsonValueKind.Number } number ? NormalizedJsonNumber.Parse(number.GetRawText()) : null;
 
    internal static string? AsString(JsonElement? value)
        => value is { ValueKind: JsonValueKind.String } text ? text.GetString() : null;
 
    public static JsonNode? Parse(string text)
    {
        try
        {
            using var document = JsonDocument.Parse(text);
            return Project(document.RootElement);
        }
        catch (InvalidOperationException ex)
        {
            // System.Text.Json decodes property names and string values lazily.
            // A key such as "bad\ud800" must fail at the input boundary, not escape as a crash.
            throw new JsonException("JSON contains malformed Unicode text", ex);
        }
    }
 
    private static JsonNode? Project(JsonElement node)
    {
        switch (node.ValueKind)
        {
            case JsonValueKind.Object:
                var obj = new JsonObject();
                foreach (var property in node.EnumerateObject())
                {
                    // Duplicate property names use the last value, including escaped spellings
                    // of the same name. Assigning eagerly avoids lazy duplicate-key exceptions.
                    obj[property.Name] = Project(property.Value);
                }
                return obj;
            case JsonValueKind.Array:
                var array = new JsonArray();
                foreach (var item in node.EnumerateArray())
                {
                    array.Add(Project(item));
                }
                return array;
            case JsonValueKind.String:
                return JsonValue.Create(node.GetString());
            default:
                // Preserve lexical numeric forms in existing durable records.
                return JsonNode.Parse(node.GetRawText());
        }
    }
 
    public static JsonNode? Field(JsonNode? node, string key) => node is JsonObject obj ? obj[key] : null;
    public static string? Text(JsonNode? node)
    {
        try
        {
            return node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
        }
        catch (InvalidOperationException ex)
        {
            // System.Text.Json can parse "\ud800" but throws only when its string is read.
            // Report malformed Unicode as input rejection, not an unhandled runtime failure.
            throw new JsonException("JSON contains malformed Unicode text", ex);
        }
    }
    public static NormalizedJsonNumber? ReadNormalizedNumber(JsonNode? node)
        => node is JsonValue value && value.GetValueKind() == JsonValueKind.Number ? NormalizedJsonNumber.Parse(value.ToJsonString()) : null;
 
    public static bool IsPositiveInteger(JsonNode? node)
    {
        // Retain the existing tolerant count-metadata check. GitHub identities
        // must use GitHubIdentifiers, never floating-point integrality.
        return ReadNormalizedNumber(node) is { Negative: false, Digits: not "0" } &&
            double.TryParse(node!.ToJsonString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var number) &&
            number == Math.Floor(number);
    }
 
    public static bool IsFalse(JsonNode? node) => node is JsonValue value && value.TryGetValue<bool>(out var boolean) && !boolean;
 
    // Strings contribute decoded content; other values retain their JSON representation,
    // including exact numeric tokens. A null node retains the explicit "null" marker.
    public static string GetValueText(JsonNode? node)
    {
        return node switch
        {
            null => "null",
            JsonValue value when Text(value) is { } text => text,
            JsonNode value => value.ToJsonString(),
        };
    }
 
    public static void Write(string file, JsonNode? node) => File.WriteAllText(
        file,
        (node?.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) ?? "null") + "\n");
 
    // Workflow-command values must remain on one physical line, even in rejected input.
    public static string Display(string text)
    {
        var result = new StringBuilder();
        foreach (var rune in text.EnumerateRunes())
        {
            if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.Control or UnicodeCategory.Format
                or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
            {
                result.Append(CultureInfo.InvariantCulture, $"\\u{rune.Value:x4}");
            }
            else
            {
                result.Append(rune);
            }
        }
 
        return result.ToString();
    }
 
    public static bool Safe(JsonNode? node, int max, bool multiline = false)
    {
        if (Text(node) is not { } text)
        {
            return false;
        }
 
        var count = 0;
        foreach (var rune in text.EnumerateRunes())
        {
            count++;
            if (multiline && rune.Value is '\t' or '\n')
            {
                continue;
            }
 
            if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.Control or UnicodeCategory.Format
                or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator ||
                rune.Value is >= 65024 and <= 65039 or >= 917760 and <= 917999)
            {
                return false;
            }
        }
 
        return count <= max;
    }
 
    // Optional upstream objects such as {"user":false} remain unavailable rather
    // than making unrelated run evidence fail. Only annotated properties opt in.
    /// <summary>Reads optional upstream objects while treating other JSON shapes as unavailable.</summary>
    internal sealed class OptionalObjectConverter<T> : JsonConverter<T> where T : class
    {
        public OptionalObjectConverter()
        {
        }
 
        public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType == JsonTokenType.StartObject)
            {
                return JsonSerializer.Deserialize<T>(ref reader, options);
            }
            reader.Skip();
            return null;
        }
 
        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
            => JsonSerializer.Serialize(writer, value, options);
    }
 
    // The framework converter also accepts case-insensitive names and numeric
    // strings. Our closed wire vocabularies accept only the explicit JSON names.
    /// <summary>Accepts only explicitly named enum wire values, rejecting numeric and case-insensitive alternatives.</summary>
    private sealed class ExactEnumConverter<T> : JsonConverter<T> where T : struct, Enum
    {
        private readonly JsonConverter<T> _converter = (JsonConverter<T>)
            new JsonStringEnumConverter<T>(allowIntegerValues: false).CreateConverter(typeof(T), new JsonSerializerOptions());
 
        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.String)
            {
                throw new JsonException($"{typeof(T).Name} must be a string");
            }
            var text = reader.GetString();
            var value = _converter.Read(ref reader, typeToConvert, options);
            if (text != EnumText(value))
            {
                throw new JsonException($"Unrecognized {typeof(T).Name}: {text}");
            }
 
            return value;
        }
 
        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
            => _converter.Write(writer, value, options);
    }
 
    // Partial upstream contracts treat non-string metadata (for example,
    // {"title":false}) as unavailable, just as Text does at the raw JSON boundary.
    /// <summary>Treats non-string optional metadata as unavailable without coercing it into text.</summary>
    internal sealed class OptionalTextConverter : JsonConverter<string>
    {
        public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType == JsonTokenType.String)
            {
                return reader.GetString();
            }
            reader.Skip();
            return null;
        }
 
        public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
            => writer.WriteStringValue(value);
    }
}
 
// Keep mathematical JSON number equality without rounding distinct large IDs through double,
// or narrowing the accepted numeric syntax to Int64/decimal. For example 123, 123.0 and 1.23e2
// normalize to the same digits and exponent; 9007199254740992 and 9007199254740993 stay distinct.
/// <summary>Compares mathematical JSON-number values exactly without rounding large identifiers.</summary>
internal readonly record struct NormalizedJsonNumber(string Digits, BigInteger Exponent, bool Negative)
{
    public static NormalizedJsonNumber Parse(string raw)
    {
        var negative = raw.StartsWith('-');
        var unsigned = negative ? raw[1..] : raw;
        var exponentIndex = unsigned.IndexOfAny(['e', 'E']);
        var exponent = exponentIndex < 0 ? BigInteger.Zero : BigInteger.Parse(unsigned[(exponentIndex + 1)..], CultureInfo.InvariantCulture);
        var significand = exponentIndex < 0 ? unsigned : unsigned[..exponentIndex];
        var decimalIndex = significand.IndexOf('.');
        if (decimalIndex >= 0)
        {
            exponent -= significand.Length - decimalIndex - 1;
        }
 
        var digits = significand.Replace(".", "", StringComparison.Ordinal).TrimStart('0');
        if (digits.Length == 0)
        {
            return new NormalizedJsonNumber("0", BigInteger.Zero, Negative: false);
        }
 
        var significantDigits = digits.TrimEnd('0');
        exponent += digits.Length - significantDigits.Length;
 
        return new NormalizedJsonNumber(significantDigits, exponent, negative);
    }
}
 
/// <summary>Reports invalid analyzer inputs, evidence or operations while preserving an available underlying cause.</summary>
internal sealed class ValidationException : Exception
{
    internal ValidationException(string message) : base(message)
    {
    }
 
    internal ValidationException(string message, Exception innerException) : base(message, innerException)
    {
    }
}