File: TransientJson.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.Buffers;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
 
namespace AnalyzeCiFailure;
 
/// <summary>Strict decoding for versioned tool-owned transfers; never used to rewrite durable history.</summary>
internal static class TransientJson
{
    private static readonly UTF8Encoding s_utf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
    private static readonly JsonSerializerOptions s_options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
        RespectNullableAnnotations = true,
        AllowOutOfOrderMetadataProperties = true,
        WriteIndented = true,
        Converters =
        {
            new CanonicalIdConverter(),
            new PositiveIntegerConverter(),
            new StrictEnumConverter<RunScope>(),
            new StrictEnumConverter<EvidenceState>(),
            new StrictEnumConverter<CandidateHistoryState>(),
            new StrictEnumConverter<JobClassification>(),
            new StrictEnumConverter<TestClassification>(),
            new StrictEnumConverter<CauseType>(),
            new StrictEnumConverter<TestArtifactFormat>(),
            new StrictEnumConverter<AnalysisVerdict>(),
        },
    };
 
    internal static T Read<T>(string text)
    {
        try
        {
            using var document = JsonDocument.Parse(text);
            CheckPropertiesAndStrings(document.RootElement);
            var value = document.RootElement.Deserialize<T>(s_options) ?? throw new JsonException("The document must be an object, not null.");
            if (value is EvidenceBundle { SchemaVersion: not 1 } or AnalysisProposal { SchemaVersion: not 1 } or
                AnalysisOutcome { SchemaVersion: not 1 })
            {
                throw new JsonException("Unsupported contract schema version.");
            }
            if (value is AnalysisOutcome outcome)
            {
                CheckOutcome(outcome);
            }
            return value;
        }
        catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)
        {
            throw new ValidationException("Cannot decode the versioned analysis contract: " +
                CiFailureJson.Display(DiagnosticTextSanitizer.Sanitize(ex.Message, limit: 1000)));
        }
    }
 
    internal static string Write<T>(T value) => JsonSerializer.Serialize(value, s_options) + "\n";
    internal static JsonElement ToElement<T>(T value) => JsonSerializer.SerializeToElement(value, s_options);
    internal static T ReadUtf8<T>(ReadOnlySpan<byte> bytes) => Read<T>(DecodeUtf8(bytes));
 
    internal static string DecodeUtf8(ReadOnlySpan<byte> bytes)
    {
        try
        {
            var text = s_utf8.GetString(bytes);
            return text.StartsWith('\uFEFF') ? text[1..] : text;
        }
        catch (DecoderFallbackException)
        {
            throw new ValidationException("Cannot decode JSON: files must contain valid UTF-8 text.");
        }
    }
 
    private static void CheckOutcome(AnalysisOutcome outcome)
    {
        if (outcome.Status is not ("ready" or "no-work" or "degraded" or "failed") ||
            outcome.Problems.IsDefault || outcome.Problems.Any(problem => problem is null || problem.Stage is null || problem.Message is null) ||
            outcome.Status is "ready" or "degraded" && (outcome.HasRelevantFailures != true || outcome.RunId is null || outcome.RunAttempt is null) ||
            outcome.Status == "no-work" && (outcome.HasRelevantFailures != false || outcome.RunId is null) ||
            outcome.Status == "failed" && outcome.Problems.Length == 0)
        {
            throw new JsonException("The collection outcome has an invalid status, facts, or problem array.");
        }
    }
 
    internal static long ParseId(string text)
    {
        if (text.Length == 0 || text[0] is < '1' or > '9' || text.Any(character => character is < '0' or > '9') ||
            !long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var value))
        {
            throw new ValidationException("An identifier must be canonical positive 64-bit decimal text without signs, spaces, or leading zeros.");
        }
        return value;
    }
 
    private static void CheckPropertiesAndStrings(JsonElement element)
    {
        if (element.ValueKind == JsonValueKind.Object)
        {
            var names = new HashSet<string>(StringComparer.Ordinal);
            foreach (var property in element.EnumerateObject())
            {
                CheckString(property.Name);
                if (!names.Add(property.Name))
                {
                    throw new JsonException("A JSON object contains a duplicate decoded property name.");
                }
                CheckPropertiesAndStrings(property.Value);
            }
        }
        else if (element.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in element.EnumerateArray())
            {
                CheckPropertiesAndStrings(item);
            }
        }
        else if (element.ValueKind == JsonValueKind.String)
        {
            CheckString(element.GetString()!);
        }
    }
 
    private static void CheckString(string value)
    {
        var remaining = value.AsSpan();
        while (!remaining.IsEmpty)
        {
            if (Rune.DecodeFromUtf16(remaining, out _, out var consumed) != OperationStatus.Done)
            {
                throw new JsonException("JSON strings must contain well-formed Unicode.");
            }
            remaining = remaining[consumed..];
        }
    }
 
    /// <summary>Transfers positive 64-bit identifiers as canonical decimal strings without numeric coercion.</summary>
    private sealed class CanonicalIdConverter : JsonConverter<long>
    {
        public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType != JsonTokenType.String)
            {
                throw new JsonException("Identifiers must be JSON strings containing canonical decimal digits.");
            }
 
            return ParseId(reader.GetString()!);
        }
 
        public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
        {
            if (value <= 0)
            {
                throw new JsonException("Identifiers must be positive.");
            }
            writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
        }
    }
 
    /// <summary>Requires a positive 32-bit JSON integer for versioned attempt and step-number fields.</summary>
    private sealed class PositiveIntegerConverter : JsonConverter<int>
    {
        public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            => reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var value) && value > 0
                ? value : throw new JsonException("Versions, attempts, and step numbers must be positive 32-bit JSON integers.");
 
        public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
        {
            if (value <= 0)
            {
                throw new JsonException("Versions, attempts, and step numbers must be positive.");
            }
            writer.WriteNumberValue(value);
        }
    }
 
    /// <summary>Decodes only the exact string values defined by a versioned contract&apos;s enum vocabulary.</summary>
    private sealed class StrictEnumConverter<T> : JsonConverter<T> where T : struct, Enum
    {
        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if (reader.TokenType == JsonTokenType.String &&
                CiFailureJson.TryReadEnum<T>(reader.GetString(), out var value))
            {
                return value;
            }
            throw new JsonException($"Unrecognized {typeof(T).Name}; use its documented string value.");
        }
 
        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
        {
            if (!Enum.IsDefined(value))
            {
                throw new JsonException($"Unrecognized {typeof(T).Name}.");
            }
            writer.WriteStringValue(CiFailureJson.EnumText(value));
        }
    }
}