< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.JsonSafety
Assembly: AsyncResponse.Core
File(s): /_/src/AsyncResponse.Core/JsonSafety.cs
Line coverage
96%
Covered lines: 60
Uncovered lines: 2
Coverable lines: 62
Total lines: 248
Line coverage: 96.7%
Branch coverage
72%
Covered branches: 13
Total branches: 18
Branch coverage: 72.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
SafeDeserialize(...)100%11100%
SafeDeserialize(...)100%11100%
SafeDeserialize(...)100%11100%
SafeDeserialize(...)100%11100%
SafeDeserialize(...)100%11100%
UnsupportedPayload(...)100%11100%
WireContractFailure(...)100%11100%
IsBodyFree(...)100%11100%
ParseFailure(...)100%11100%
ParseFailure(...)100%11100%
Describe(...)50%44100%
WithResolver(...)83.33%66100%
PopulateReflectionResolver()100%11100%
ThrowIfClearlyNotJson(...)100%44100%
ThrowIfClearlyNotJson(...)50%5466.66%

File(s)

/_/src/AsyncResponse.Core/JsonSafety.cs

#LineLine coverage
 1using System.Diagnostics.CodeAnalysis;
 2using System.Text.Json;
 3using System.Text.Json.Serialization.Metadata;
 4
 5namespace AsyncResponse;
 6
 7/// <summary>
 8/// Defensive JSON helpers for broker ingress payloads. All overloads resolve contract metadata
 9/// through <see cref="AsyncResponseJson"/> (trim/AOT-safe) instead of the reflection-based
 10/// serializer entry points; property matching stays case-insensitive as it always was here.
 11/// <para>
 12/// <b>No body in any message these throw.</b> These run at the ingress, where every inbound
 13/// response and worker envelope passes through, and the exceptions they raise are logged
 14/// (<c>AsyncResponseIngress</c>) and, on the response path, republished through
 15/// <c>SetException</c> to the waiter. A payload prefix in the message therefore reached both the
 16/// application log and a remote consumer, which is exactly what "the library never logs a message
 17/// body" (docs/security.md) rules out — arguments, tenant/auth baggage, PII and proxy HTML all
 18/// travel in that first 200 characters. What is left is size and JSON position, which locate the
 19/// fault without disclosing it.
 20/// </para>
 21/// </summary>
 22internal static class JsonSafety
 23{
 24    /// <summary>
 25    /// Deserializes with guards for the classic broker-ingress garbage: empty bodies and HTML
 26    /// error pages. Throws <see cref="InvalidDataException"/> describing the failure's size and
 27    /// position — never its content — so the failure is diagnosable from logs.
 28    /// </summary>
 29    public static T? SafeDeserialize<T>(string json, JsonSerializerOptions? options = null)
 658930        => SafeDeserialize(json, AsyncResponseJson.GetTypeInfo<T>(WithResolver(options)));
 31
 32    /// <summary>Deserializes with the ingress guards using pre-resolved contract metadata.</summary>
 33    public static T? SafeDeserialize<T>(string json, JsonTypeInfo<T> typeInfo)
 34    {
 2796635        ThrowIfClearlyNotJson(json);
 36
 37        try
 38        {
 2795639            return JsonSerializer.Deserialize(json, typeInfo);
 40        }
 15141        catch (JsonException jsonException) when (!IsBodyFree(jsonException))
 42        {
 12543            throw ParseFailure(json, jsonException);
 44        }
 445        catch (NotSupportedException)
 46        {
 447            throw UnsupportedPayload(json.Length, "UTF-16 code units");
 48        }
 2780149    }
 50
 51    /// <summary>
 52    /// UTF-8 counterpart of <see cref="SafeDeserialize{T}(string, JsonTypeInfo{T})"/> for readers
 53    /// that receive bytes off the wire (the Redis channel), with the same body-free failure
 54    /// contract: size in bytes plus the reader's position, never its message or path.
 55    /// </summary>
 56    public static T? SafeDeserialize<T>(ReadOnlySpan<byte> utf8Json, JsonTypeInfo<T> typeInfo)
 57    {
 431258        ThrowIfClearlyNotJson(utf8Json);
 59
 60        try
 61        {
 431262            return JsonSerializer.Deserialize(utf8Json, typeInfo);
 63        }
 1064        catch (JsonException jsonException) when (!IsBodyFree(jsonException))
 65        {
 1066            throw ParseFailure(utf8Json.Length, "UTF-8 bytes", jsonException);
 67        }
 268        catch (NotSupportedException)
 69        {
 270            throw UnsupportedPayload(utf8Json.Length, "UTF-8 bytes");
 71        }
 430072    }
 73
 74    /// <summary>
 75    /// Non-generic counterpart for callers that only know the target type at runtime (e.g.
 76    /// materializing a persisted flow input).
 77    /// </summary>
 78    public static object? SafeDeserialize(string json, Type returnType, JsonSerializerOptions? options = null)
 79    {
 596880        ThrowIfClearlyNotJson(json);
 596881        var typeInfo = AsyncResponseJson.GetTypeInfo(returnType, WithResolver(options));
 82
 83        try
 84        {
 596885            return JsonSerializer.Deserialize(json, typeInfo);
 86        }
 1287        catch (JsonException jsonException) when (!IsBodyFree(jsonException))
 88        {
 1289            throw ParseFailure(json, jsonException);
 90        }
 291        catch (NotSupportedException)
 92        {
 293            throw UnsupportedPayload(json.Length, "UTF-16 code units");
 94        }
 595495    }
 96
 97    /// <summary>
 98    /// Converts an already-parsed <see cref="JsonElement"/> (a worker-job argument, a recovery
 99    /// payload) to <paramref name="returnType"/> with the same body-free failure contract as the
 100    /// string overloads. The outer envelope parse is guarded elsewhere; this is the second reader
 101    /// pass — the one that reads dictionary keys and property values off the wire into the
 102    /// callback's parameter types — and its <see cref="JsonException"/> carries the same
 103    /// <c>Path: $.&lt;key&gt;</c> the envelope's would, so it needs the same scrubbing.
 104    /// </summary>
 105    public static object? SafeDeserialize(JsonElement element, Type returnType, JsonSerializerOptions? options = null)
 106    {
 6164107        var typeInfo = AsyncResponseJson.GetTypeInfo(returnType, WithResolver(options));
 108        try
 109        {
 6164110            return JsonSerializer.Deserialize(element, typeInfo);
 111        }
 4112        catch (JsonException jsonException) when (!IsBodyFree(jsonException))
 113        {
 114            // GetRawText only on the failure path, and only for its length.
 4115            throw ParseFailure(element.GetRawText(), jsonException);
 116        }
 4117        catch (NotSupportedException)
 118        {
 4119            throw UnsupportedPayload(element.GetRawText().Length, "UTF-16 code units");
 120        }
 6156121    }
 122
 123    // STJ appends body-derived paths to NotSupportedException too (for example a missing
 124    // polymorphic discriminator). Never chain it. Resolve metadata before the reader's try
 125    // block so safe AOT registration guidance remains actionable.
 126    private static InvalidDataException UnsupportedPayload(int length, string unit)
 12127        => new($"Cannot deserialize JSON payload ({length} {unit}): an unsupported value or missing type discriminator w
 128
 129    /// <summary>
 130    /// Key under which a <see cref="JsonException"/> the LIBRARY authored marks itself as
 131    /// body-free, so <c>SafeDeserialize</c> preserves its message. Visible on
 132    /// <see cref="Exception.Data"/>, harmlessly — the channels already carry
 133    /// <c>RemoteStackTrace</c> there.
 134    /// </summary>
 135    private const string BodyFreeMessageKey = "AsyncResponse.BodyFreeMessage";
 136
 137    /// <summary>
 138    /// A malformed-message failure whose text the library wrote itself: it names only the wire
 139    /// contract's own property names — <c>SchemaVersion</c>, <c>Success</c>, <c>Payload</c> — and
 140    /// never a byte of the inbound body, so <c>SafeDeserialize</c> lets it through untouched
 141    /// instead of replacing it with the position-only failure it builds for the reader's own.
 142    /// <para>
 143    /// The distinction is the whole point. <c>System.Text.Json</c>'s own messages quote the body
 144    /// (<c>Path: $.Payload.Values['…']</c> is built from inbound dictionary keys), so they must be
 145    /// dropped; ours are the primary operator diagnosis for the commonest malformed-envelope cause
 146    /// in production — a foreign or mismatched producer writing to the response channel — and
 147    /// scrubbing them to "failed at line 0, byte position 2" costs the diagnosis while protecting
 148    /// nothing. Marked rather than subtyped so the exception REMAINS a plain
 149    /// <see cref="JsonException"/>: every classification (the ingress treats it as permanent, no
 150    /// retry burn), every <c>catch</c>, and every exact-type assertion keeps working unchanged.
 151    /// </para>
 152    /// </summary>
 153    public static JsonException WireContractFailure(string message)
 154    {
 66155        var failure = new JsonException(message);
 66156        failure.Data[BodyFreeMessageKey] = true;
 66157        return failure;
 158    }
 159
 160    /// <summary>Whether <paramref name="jsonException"/> carries a message the library authored (see <see cref="WireCon
 177161    private static bool IsBodyFree(JsonException jsonException) => jsonException.Data.Contains(BodyFreeMessageKey);
 162
 163    /// <summary>
 164    /// Builds the body-free parse failure: size plus the JSON coordinates the reader stopped at.
 165    /// <para>
 166    /// The inner exception is deliberately NOT the raw <see cref="JsonException"/>. Its message is
 167    /// not the bounded metadata it looks like: System.Text.Json appends <c>Path: $.&lt;name&gt;</c>
 168    /// built from the INBOUND property names — including dictionary keys read straight off the
 169    /// wire, such as a worker envelope's propagated Context — and for a malformed literal it quotes
 170    /// several raw body characters ("'tru}' is an invalid JSON literal"). Both reach the
 171    /// application log through the ingress's <c>LogError(ex, …)</c> and, on the response path, the
 172    /// waiter through SetException, which is exactly what "the library never logs a message body"
 173    /// forbids. Only the position is carried across; the original is dropped, not chained.
 174    /// </para>
 175    /// </summary>
 176    private static InvalidDataException ParseFailure(string json, JsonException jsonException)
 141177        => ParseFailure(json.Length, "UTF-16 code units", jsonException);
 178
 179    private static InvalidDataException ParseFailure(int length, string unit, JsonException jsonException)
 151180        => new(
 151181            $"Failed to parse JSON payload ({length} {unit}) at line {Describe(jsonException.LineNumber)}, " +
 151182            $"byte position {Describe(jsonException.BytePositionInLine)}.",
 151183            new JsonException(
 151184                $"The JSON payload is malformed at line {Describe(jsonException.LineNumber)}, " +
 151185                $"byte position {Describe(jsonException.BytePositionInLine)}. " +
 151186                "The reader's own message and path are omitted because they quote the inbound body."));
 187
 188    private static string Describe(long? position)
 604189        => position?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown";
 190
 191    /// <summary>
 192    /// Defaults to the library's case-insensitive chain options. Caller-supplied options are
 193    /// honored exactly as the reflection-based overloads honored them: an instance with no
 194    /// resolver gets the runtime's default reflection resolver bound (that is what
 195    /// <c>JsonSerializer.Deserialize(json, options)</c> used to do on first use), which throws at
 196    /// runtime when the app disabled reflection-based serialization — same as before, but without
 197    /// carrying IL2026/IL3050.
 198    /// </summary>
 199    private static JsonSerializerOptions WithResolver(JsonSerializerOptions? options)
 200    {
 18721201        if (options is null)
 12386202            return AsyncResponseJson.CaseInsensitive;
 203
 204        // When reflection is unavailable (trimmed/AOT) the resolver stays null and GetTypeInfo
 205        // surfaces the actionable register-a-context error instead.
 6335206        if (options.TypeInfoResolver is null && JsonSerializer.IsReflectionEnabledByDefault)
 2207            PopulateReflectionResolver(options);
 208
 6335209        return options;
 210
 211        [UnconditionalSuppressMessage("Trimming", "IL2026",
 212            Justification = "Reachable only when JsonSerializer.IsReflectionEnabledByDefault is true; trimmed and AOT bu
 213        [UnconditionalSuppressMessage("AOT", "IL3050",
 214            Justification = "Same guard: never reached under Native AOT.")]
 215        static void PopulateReflectionResolver(JsonSerializerOptions options)
 2216            => options.MakeReadOnly(populateMissingResolver: true);
 217    }
 218
 219    /// <summary>Runs the ThrowIfClearlyNotJson operation.</summary>
 220    public static void ThrowIfClearlyNotJson(string json)
 221    {
 34371222        if (string.IsNullOrWhiteSpace(json))
 6223            throw new InvalidDataException("Empty message body when JSON was expected.");
 224
 34365225        var trimmed = json.AsSpan().TrimStart();
 226
 227        // Guard against HTML error pages. Size only, never the markup: the classic source of one
 228        // is a reverse proxy or auth gateway answering in place of the service, and its body is
 229        // the last thing to copy into a log — it carries session banners, internal hostnames and
 230        // whatever the gateway decided to echo back. The '<' that triggered this is already the
 231        // whole diagnosis.
 34365232        if (trimmed[0] == '<')
 6233            throw new InvalidDataException($"Received HTML when JSON was expected ({json.Length} UTF-16 code units).");
 34359234    }
 235
 236    /// <summary>UTF-8 counterpart of <see cref="ThrowIfClearlyNotJson(string)"/>: the same two guards over raw bytes.</
 237    public static void ThrowIfClearlyNotJson(ReadOnlySpan<byte> utf8Json)
 238    {
 239        // JSON whitespace is exactly these four ASCII bytes (RFC 8259 §2), so a byte-level trim
 240        // matches what the reader itself would skip.
 4312241        var trimmed = utf8Json.TrimStart("\t\n\r "u8);
 4312242        if (trimmed.IsEmpty)
 0243            throw new InvalidDataException("Empty message body when JSON was expected.");
 244
 4312245        if (trimmed[0] == (byte)'<')
 0246            throw new InvalidDataException($"Received HTML when JSON was expected ({utf8Json.Length} UTF-8 bytes).");
 4312247    }
 248}