File: Rpc\NativeRpcProtocol.cs
Web Access
Project: src\src\Aspire.Hosting.Native.Server\Aspire.Hosting.Native.Server.csproj (Aspire.Hosting.Native.Server)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text;
using Aspire.Hosting.Native.Server;
 
namespace Aspire.Hosting.Native.Rpc;
 
internal sealed record NativeRpcRequest(JsonNode? Id, string Method, JsonNode? Parameters, bool IsNotification);
internal readonly record struct NativeRpcResponseEnvelope(string Id, JsonElement Result, JsonElement? Error);
 
/// <summary>Parses JSON-RPC envelopes and bounded trace context independently of dispatch.</summary>
internal static class NativeRpcProtocol
{
    public static NativeRpcResponseEnvelope ParseResponse(JsonElement response)
    {
        // A reply has exactly one of result/error, with a string outbound ID:
        // {"jsonrpc":"2.0","id":"native:1","result":null}.
        if (response.ValueKind != JsonValueKind.Object || !response.TryGetProperty("jsonrpc", out var version) ||
            version.ValueKind != JsonValueKind.String || !version.ValueEquals("2.0") ||
            !response.TryGetProperty("id", out var id) || id.ValueKind != JsonValueKind.String ||
            response.TryGetProperty("result", out var result) == response.TryGetProperty("error", out var error))
        {
            throw new InvalidDataException("Invalid RPC response envelope.");
        }
 
        return new(id.GetString()!, result, error.ValueKind == JsonValueKind.Undefined ? null : error);
    }
 
    public static void ValidateResponseError(JsonElement error)
    {
        if (error.ValueKind != JsonValueKind.Object || !error.TryGetProperty("code", out var code) ||
            code.ValueKind != JsonValueKind.Number || !code.TryGetInt32(out _) ||
            !error.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.String)
        {
            throw new InvalidDataException("Invalid RPC failure envelope.");
        }
    }
 
    public static void ValidateAuthenticationToken(string token)
    {
        ArgumentException.ThrowIfNullOrEmpty(token);
        if (Encoding.UTF8.GetByteCount(token) is < 32 or > 256)
        {
            throw new ArgumentException("RPC authentication tokens must contain 32 to 256 UTF-8 bytes.", nameof(token));
        }
    }
 
    public static NativeRpcRequest ParseRequest(ReadOnlyMemory<byte> payload)
    {
        if (payload.Length > NativeServerOptions.DefaultMaximumRequestBytes)
        {
            throw InvalidRequest();
        }
        using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 32 });
 
        return ParseRequest(document.RootElement);
    }
 
    public static NativeRpcRequest ParseRequest(JsonElement envelope)
    {
        // JSON-RPC uses {"jsonrpc":"2.0","id":1,"method":"invokeCapability",
        // "params":["assembly/capability",{"context":{"$handle":"...","$type":"..."}}]}.
        // Existing no-argument clients also send "params":null; retain that
        // representation of omitted arguments while rejecting scalar arguments.
        // Duplicate keys at any depth cannot be interpreted as last-write-wins.
        ValidateUniqueProperties(envelope);
        if (envelope.ValueKind != JsonValueKind.Object)
        {
            throw InvalidRequest();
        }
        foreach (var property in envelope.EnumerateObject())
        {
            if (property.Name is not ("jsonrpc" or "id" or "method" or "params" or "traceparent" or "tracestate"))
            {
                throw InvalidRequest();
            }
        }
        var notification = !envelope.TryGetProperty("id", out var id);
        envelope.TryGetProperty("params", out var parameters);
        envelope.TryGetProperty("traceparent", out var parent);
        envelope.TryGetProperty("tracestate", out var state);
        if (!envelope.TryGetProperty("jsonrpc", out var version) || version.ValueKind != JsonValueKind.String || !version.ValueEquals("2.0") ||
            !envelope.TryGetProperty("method", out var methodValue) || methodValue.ValueKind != JsonValueKind.String ||
            methodValue.GetString() is not { } method || string.IsNullOrWhiteSpace(method) || method.Length > 256 ||
            !IsValidId(id) ||
            parameters.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null or JsonValueKind.Array or JsonValueKind.Object) ||
            !IsValidTraceField(parent) || !IsValidTraceField(state))
        {
            throw InvalidRequest();
        }
 
        return new(notification ? null : ToNode(id), method,
            parameters.ValueKind == JsonValueKind.Undefined ? null : ToNode(parameters), notification);
    }
 
    public static ActivityContext? ReadTraceContext(ReadOnlyMemory<byte> payload, int maximumBytes)
    {
        if (payload.Length > maximumBytes)
        {
            return null;
        }
        try
        {
            using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 32 });
 
            return ReadTraceContext(document.RootElement);
        }
        catch (Exception exception) when (exception is JsonException or RpcFault)
        {
            // Trace propagation is preliminary. ParseRequest still rejects an
            // invalid envelope rather than turning absent context into success.
            return null;
        }
    }
 
    public static ActivityContext? ReadTraceContext(JsonElement envelope)
    {
        if (envelope.ValueKind != JsonValueKind.Object ||
            !envelope.TryGetProperty("traceparent", out var parent) || parent.ValueKind != JsonValueKind.String)
        {
            return null;
        }
        try
        {
            ValidateUniqueProperties(envelope);
        }
        catch (RpcFault)
        {
            return null;
        }
        string? state = null;
        if (envelope.TryGetProperty("tracestate", out var value))
        {
            if (value.ValueKind != JsonValueKind.String)
            {
                return null;
            }
            state = value.GetString();
        }
        // StreamJsonRpc sends "traceparent":"00-<trace-id>-<span-id>-01",
        // "tracestate":"vendor=value". Never attach oversized/malformed context.
        // https://github.com/microsoft/vs-streamjsonrpc/blob/main/doc/tracecontext.md
        if (parent.GetString() is not { Length: <= 512 } parentValue || state is { Length: > 512 } ||
            !ActivityContext.TryParse(parentValue, state, isRemote: true, out var context))
        {
            return null;
        }
 
        return context;
    }
 
    public static JsonNode? ToNode(JsonElement element) => element.ValueKind switch
    {
        // Nodes retain their element rather than owning its document. Clone only
        // the retained subtree so handles and replies survive envelope disposal.
        JsonValueKind.Null => null,
        JsonValueKind.Object => JsonObject.Create(element.Clone()),
        JsonValueKind.Array => JsonArray.Create(element.Clone()),
        _ => JsonValue.Create(element.Clone())
    };
 
    public static void ValidateUniqueProperties(JsonElement element)
    {
        // {"method":"safe","m\u0065thod":"other"} has two decoded "method" keys.
        // Reject ambiguity before different DOM/typed readers can disagree about
        // authority-bearing fields. RFC 8259 documents inconsistent duplicate handling:
        // https://www.rfc-editor.org/rfc/rfc8259#section-4
        if (element.ValueKind == JsonValueKind.Object)
        {
            var names = new HashSet<string>(StringComparer.Ordinal);
            foreach (var property in element.EnumerateObject())
            {
                if (!names.Add(property.Name))
                {
                    throw InvalidRequest();
                }
                ValidateUniqueProperties(property.Value);
            }
        }
        else if (element.ValueKind == JsonValueKind.Array)
        {
            foreach (var item in element.EnumerateArray())
            {
                ValidateUniqueProperties(item);
            }
        }
    }
 
    private static RpcFault InvalidRequest() => RpcFault.InvalidRequest("The request shape is invalid.");
    private static bool IsValidId(JsonElement id) => id.ValueKind switch
    {
        JsonValueKind.Undefined or JsonValueKind.Null => true,
        JsonValueKind.String => id.GetString()!.Length <= 512,
        JsonValueKind.Number => id.TryGetInt64(out _),
        _ => false
    };
    private static bool IsValidTraceField(JsonElement value) => value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null ||
        value.ValueKind == JsonValueKind.String && value.GetString()!.Length <= 512;
}