XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEEDToolBox

【WinUI】XFE精英危险工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEExtension.NetCore.XUnit.Attributes;

namespace XFEEDToolBox.Test;

/// <summary>Exercises the native wire protocol using isolated local pipes, without loading a game or DLL.</summary>
[TestFixture]
public class NativeInterfaceContractTests
{
    private static int NextProcessId() => Random.Shared.Next(200_000_000, int.MaxValue);

    private static NamedPipeServerStream CreateServer(int processId, string service = "Navigation") =>
        new($"XFEEDToolBox.{service}.{processId}", PipeDirection.InOut, 1,
            PipeTransmissionMode.Byte, PipeOptions.Asynchronous);

    private static async Task<JsonElement> ReadRequestAsync(NamedPipeServerStream server, CancellationToken token)
    {
        using var request = new MemoryStream();
        var buffer = new byte[4096];
        while (request.Length < 65536)
        {
            var count = await server.ReadAsync(buffer, token);
            if (count == 0) throw new InvalidDataException("The client disconnected before completing its request.");
            request.Write(buffer, 0, count);
            try
            {
                using var parsed = JsonDocument.Parse(request.GetBuffer().AsMemory(0, (int)request.Length));
                return parsed.RootElement.Clone();
            }
            catch (JsonException) { }
        }
        throw new InvalidDataException("The client sent an oversized request.");
    }

    private static async Task ServeAsync(NamedPipeServerStream server, byte[] response, CancellationToken token,
        Action<JsonElement>? inspect = null, int fragmentSize = int.MaxValue)
    {
        await server.WaitForConnectionAsync(token);
        var request = await ReadRequestAsync(server, token);
        inspect?.Invoke(request);
        for (var offset = 0; offset < response.Length; offset += Math.Min(fragmentSize, response.Length - offset))
        {
            var count = Math.Min(fragmentSize, response.Length - offset);
            await server.WriteAsync(response.AsMemory(offset, count), token);
            await server.FlushAsync(token);
        }
        if (response.Length == 0) return;
        // Keep the server alive until the client consumes the reply and closes its end.
        Check.Equal(0, await server.ReadAsync(new byte[1], token));
    }

    private static async Task<T> RejectsAsync<T>(Func<Task> action) where T : Exception
    {
        try { await action(); }
        catch (T error) { return error; }
        throw new InvalidOperationException("Expected " + typeof(T).Name);
    }

    [Test]
    public void InvalidProcessAndUnknownServiceCannotCreatePipeClients()
    {
        foreach (var id in new[] { 0, -1, int.MinValue })
            Check.Throws<ArgumentOutOfRangeException>(() => new NavigationPipeClient(id));
        foreach (var service in new[] { "", "Plugin", "navigation", "Navigation.1", "../Navigation" })
            Check.Throws<ArgumentException>(() => new NavigationPipeClient(1, service));
    }

    [Test]
    public async Task FragmentedUtf8ResponsesPreserveExactIntegerValuesOnBothServices()
    {
        foreach (var service in new[] { "Navigation", "Interaction" })
        {
            var id = NextProcessId();
            using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            await using var server = CreateServer(id, service);
            const long address = 9007199254740995;
            const string title = "阿尔法星系 🚀";
            var wire = Encoding.UTF8.GetBytes("{\"ok\":true,\"result\":{\"title\":\"" + title +
                "\",\"address\":9007199254740995,\"entityId\":\"18446744073709551615\"}}\n");
            var serving = ServeAsync(server, wire, timeout.Token, request =>
            {
                Check.Equal("describe", request.GetProperty("op").GetString());
                Check.Equal(address, request.GetProperty("targetSystemAddress").GetInt64());
            }, fragmentSize: 1);
            var result = await new NavigationPipeClient(id, service)
                .CallAsync(new { op = "describe", targetSystemAddress = address }, timeout.Token);
            await serving;
            // Returned JSON must remain usable after the client's JsonDocument is disposed.
            Check.Equal(title, result.GetProperty("title").GetString());
            Check.Equal(address, result.GetProperty("address").GetInt64());
            Check.Equal(ulong.MaxValue, ulong.Parse(result.GetProperty("entityId").GetString()!));
        }
    }

    [Test]
    public async Task RejectedRepliesPreserveNativeErrorAndAllowFollowingRequests()
    {
        var id = NextProcessId();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var client = new NavigationPipeClient(id);
        foreach (var envelope in new[]
        {
            "{\"ok\":false,\"error\":\"Game route planning requires a fresh idle FSD\"}\n",
            "{\"error\":\"Missing protocol acknowledgement\"}\n",
            "{\"ok\":\"true\",\"error\":\"Invalid acknowledgement type\"}\n"
        })
        {
            await using var server = CreateServer(id);
            var serving = ServeAsync(server, Encoding.UTF8.GetBytes(envelope), timeout.Token);
            var error = await RejectsAsync<InvalidOperationException>(async () =>
                await client.CallAsync(new { op = "describe" }, timeout.Token));
            using var expected = JsonDocument.Parse(envelope);
            Check.Equal(expected.RootElement.GetProperty("error").GetString(), error.Message);
            await serving;
        }
        await using var recovery = CreateServer(id);
        var recovered = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"recovered\":true}}\n"u8.ToArray(), timeout.Token);
        Check.That((await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("recovered").GetBoolean());
        await recovered;
    }

    [Test]
    public async Task MaximumNavigationRequestRetainsItsByteBoundary()
    {
        var id = NextProcessId();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var client = new NavigationPipeClient(id);
        var overhead = JsonSerializer.SerializeToUtf8Bytes(new { op = "describe", padding = "" }, ToolboxSettings.JsonOptions).Length;
        var largest = new { op = "describe", padding = new string('x', 65534 - overhead) };
        Check.Equal(65534, JsonSerializer.SerializeToUtf8Bytes(largest, ToolboxSettings.JsonOptions).Length);
        await using var server = CreateServer(id);
        var serving = ServeAsync(server, "{\"ok\":true,\"result\":{}}\n"u8.ToArray(), timeout.Token,
            request => Check.Equal(largest.padding.Length, request.GetProperty("padding").GetString()!.Length));
        await client.CallAsync(largest, timeout.Token);
        await serving;
        // An oversized request is rejected locally, before any connection is required.
        foreach (var length in new[] { 65535, 65536 })
            await RejectsAsync<InvalidDataException>(async () => await client.CallAsync(
                new { op = "describe", padding = new string('x', length - overhead) }, timeout.Token));
    }

    [Test]
    public async Task MalformedJsonReplyDoesNotPoisonTheNextConnection()
    {
        var id = NextProcessId();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var client = new NavigationPipeClient(id);
        await using (var malformed = CreateServer(id))
        {
            var serving = ServeAsync(malformed, "{\"ok\":true,\"result\":\n"u8.ToArray(), timeout.Token);
            await RejectsAsync<JsonException>(async () => await client.CallAsync(new { op = "describe" }, timeout.Token));
            await serving;
        }
        await using var recovery = CreateServer(id);
        var recovered = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"recovered\":true}}\n"u8.ToArray(), timeout.Token);
        Check.That((await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("recovered").GetBoolean());
        await recovered;
    }

    [Test]
    public async Task ResponseByteLimitRejectsFullBufferAndAcceptsThePreviousByte()
    {
        foreach (var size in new[] { 65535, 65536 })
        {
            var id = NextProcessId();
            using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            await using var server = CreateServer(id);
            const string prefix = "{\"ok\":true,\"result\":{\"padding\":\"";
            const string suffix = "\"}}\n";
            var wire = Encoding.UTF8.GetBytes(prefix + new string('x', size - prefix.Length - suffix.Length) + suffix);
            Check.Equal(size, wire.Length);
            var serving = ServeAsync(server, wire, timeout.Token, fragmentSize: 1024);
            var client = new NavigationPipeClient(id);
            if (size == 65535)
                Check.Equal(size - prefix.Length - suffix.Length,
                    (await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("padding").GetString()!.Length);
            else
                await RejectsAsync<InvalidDataException>(async () => await client.CallAsync(new { op = "describe" }, timeout.Token));
            await serving;
        }
    }

    [Test]
    public async Task EmptyReplyIsRejectedWithoutPoisoningTheNextConnection()
    {
        var id = NextProcessId();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        var client = new NavigationPipeClient(id);
        await using (var empty = CreateServer(id))
        {
            var serving = ServeAsync(empty, [], timeout.Token);
            var call = client.CallAsync(new { op = "describe" }, timeout.Token);
            await serving;
            await empty.DisposeAsync();
            await RejectsAsync<InvalidDataException>(async () => await call);
        }
        await using var recovery = CreateServer(id);
        var recovered = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"sequence\":1}}\n"u8.ToArray(), timeout.Token);
        Check.Equal(1, (await client.CallAsync(new { op = "read" }, timeout.Token)).GetProperty("sequence").GetInt32());
        await recovered;
    }

    [Test]
    public async Task CancelledQueuedRequestDoesNotCancelTheActiveRequestOrConsumeItsReply()
    {
        var id = NextProcessId();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        using var queuedCancellation = new CancellationTokenSource();
        var client = new NavigationPipeClient(id);
        var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        await using (var first = CreateServer(id))
        {
            async Task ServeFirstAsync()
            {
                await first.WaitForConnectionAsync(timeout.Token);
                Check.Equal("read", (await ReadRequestAsync(first, timeout.Token)).GetProperty("op").GetString());
                received.SetResult();
                await release.Task.WaitAsync(timeout.Token);
                await first.WriteAsync("{\"ok\":true,\"result\":{\"sequence\":1}}\n"u8.ToArray(), timeout.Token);
                Check.Equal(0, await first.ReadAsync(new byte[1], timeout.Token));
            }
            var serving = ServeFirstAsync();
            var active = client.CallAsync(new { op = "read" }, timeout.Token);
            await received.Task.WaitAsync(timeout.Token);
            var queued = client.CallAsync(new { op = "describe" }, queuedCancellation.Token);
            queuedCancellation.Cancel();
            await RejectsAsync<OperationCanceledException>(async () => await queued);
            Check.That(!active.IsCompleted, "Cancelling a waiting caller must not terminate the current pipe exchange.");
            release.SetResult();
            Check.Equal(1, (await active).GetProperty("sequence").GetInt32());
            await serving;
        }
        await using var next = CreateServer(id);
        var nextServing = ServeAsync(next, "{\"ok\":true,\"result\":{\"sequence\":2}}\n"u8.ToArray(), timeout.Token);
        Check.Equal(2, (await client.CallAsync(new { op = "read" }, timeout.Token)).GetProperty("sequence").GetInt32());
        await nextServing;
    }

    [Test]
    public async Task CallerCancellationDuringReplyReleasesPipeAndSerializationGate()
    {
        var id = NextProcessId();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
        using var cancellation = new CancellationTokenSource();
        var client = new NavigationPipeClient(id);
        await using (var blocked = CreateServer(id))
        {
            var connected = blocked.WaitForConnectionAsync(timeout.Token);
            var call = client.CallAsync(new { op = "describe" }, cancellation.Token);
            await connected;
            await ReadRequestAsync(blocked, timeout.Token);
            cancellation.Cancel();
            await RejectsAsync<OperationCanceledException>(async () => await call);
            Check.Equal(0, await blocked.ReadAsync(new byte[1], timeout.Token));
        }
        await using var recovery = CreateServer(id);
        var serving = ServeAsync(recovery, "{\"ok\":true,\"result\":{\"recovered\":true}}\n"u8.ToArray(), timeout.Token);
        Check.That((await client.CallAsync(new { op = "describe" }, timeout.Token)).GetProperty("recovered").GetBoolean());
        await serving;
    }
}