chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,123 @@
using Fleet.Core;
namespace Fleet.Tests;
public sealed class CoreTests
{
[Theory]
[InlineData(0UL)]
[InlineData(1UL)]
[InlineData(127UL)]
[InlineData(128UL)]
[InlineData(300UL)]
[InlineData(ulong.MaxValue)]
public void VarInt_RoundTrips(ulong value)
{
Span<byte> buffer = stackalloc byte[VarInt.MaxBytes];
int written = VarInt.Write(buffer, value);
Assert.True(VarInt.TryRead(buffer[..written], out ulong read, out int consumed));
Assert.Equal(value, read);
Assert.Equal(written, consumed);
}
[Fact]
public void VarInt_RejectsTruncatedAndOverlongInput()
{
Assert.False(VarInt.TryRead([0x80], out _, out _));
Assert.False(VarInt.TryRead([], out _, out _));
byte[] overlong = [.. Enumerable.Repeat((byte)0x80, 11), 0x01];
Assert.False(VarInt.TryRead(overlong, out _, out _));
}
[Theory]
[InlineData(0L)]
[InlineData(-1L)]
[InlineData(1L)]
[InlineData(long.MinValue)]
[InlineData(long.MaxValue)]
public void ZigZag_RoundTrips_AndKeepsSmallMagnitudesSmall(long value)
{
Assert.Equal(value, VarInt.UnZigZag(VarInt.ZigZag(value)));
if (value is >= -63 and <= 63)
{
Assert.True(VarInt.ZigZag(value) < 128);
}
}
[Fact]
public void Ulid_IsTwentySixCharacters_SortsByTime_AndDoesNotRepeat()
{
var time = new FakeClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
string a = Ulid.New(time);
time.Advance(TimeSpan.FromMilliseconds(5));
string b = Ulid.New(time);
Assert.Equal(26, a.Length);
Assert.True(string.CompareOrdinal(a, b) < 0);
Assert.Equal(1000, Enumerable.Range(0, 1000).Select(_ => Ulid.New(time)).Distinct().Count());
Assert.StartsWith("agt_", Ulid.NewPrefixed("agt", time));
}
[Fact]
public void Tokens_AreHighEntropy_AndOnlyTheHashIsComparable()
{
string a = TokenHash.NewToken("jt");
string b = TokenHash.NewToken("jt");
Assert.NotEqual(a, b);
Assert.StartsWith("jt_", a);
Assert.Equal(3 + 43, a.Length);
Assert.True(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(a)));
Assert.False(TokenHash.FixedTimeEquals(TokenHash.Sha256(a), TokenHash.Sha256(b)));
}
[Fact]
public void Base64Url_RoundTrips_AndRejectsGarbage()
{
byte[] data = [0xFB, 0xFF, 0xFE, 0x00, 0x01];
string text = Base64Url.Encode(data);
Assert.DoesNotContain('+', text);
Assert.DoesNotContain('/', text);
Assert.DoesNotContain('=', text);
Assert.True(Base64Url.TryDecode(text, out byte[] back));
Assert.Equal(data, back);
Assert.False(Base64Url.TryDecode("***", out _));
}
[Fact]
public void Result_CarriesEitherAValueOrAnError_AndRefusesTheWrongAccess()
{
Result<int> ok = Result<int>.Ok(5);
Result<int> failed = Result<int>.Fail(FleetErrors.NotFound, "missing");
Assert.True(ok.IsSuccess);
Assert.Equal(10, ok.Map(v => v * 2).Value);
Assert.False(failed.IsSuccess);
Assert.Equal(FleetErrors.NotFound, failed.Error.Code);
Assert.Throws<InvalidOperationException>(() => failed.Value);
Assert.Throws<InvalidOperationException>(() => ok.Error);
Assert.Equal("fleet_not_found", failed.Map(v => v).Error.Code.Value);
}
[Fact]
public void FrameworkErrorCodes_ArePrefixedAndSnakeCase()
{
foreach (System.Reflection.FieldInfo field in typeof(FleetErrors).GetFields())
{
string value = ((ErrorCode)field.GetValue(null)!).Value;
Assert.StartsWith("fleet_", value);
Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
}
}
private sealed class FakeClock : TimeProvider
{
private DateTimeOffset _now;
public FakeClock(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<ProjectReference Include="..\..\src\Fleet.Core\Fleet.Core.csproj" />
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,162 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Tests;
public sealed class IdentityAndRpcTests
{
[Fact]
public void Signatures_VerifyOnlyForTheSameKeyPurposeAndFields()
{
using NodeIdentity node = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] signature = node.Sign(SignaturePurpose.Auth, "node-1", "nonce-1");
Assert.True(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"));
Assert.False(NodeIdentity.Verify(other.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-1"), "another key");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Peer, "node-1", "nonce-1"), "another purpose: a signature must not be reusable across scenarios");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-2", "nonce-1"), "another node id");
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, signature, SignaturePurpose.Auth, "node-1", "nonce-2"), "another nonce");
}
[Fact]
public void Verify_NeverThrowsOnGarbage()
{
using NodeIdentity node = NodeIdentity.Generate();
Assert.False(NodeIdentity.Verify([1, 2, 3], [4, 5, 6], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, [], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.Verify(node.PublicKeySpki, new byte[64], SignaturePurpose.Auth, "x"));
Assert.False(NodeIdentity.IsValidPublicKey([]));
Assert.False(NodeIdentity.IsValidPublicKey(new byte[91]));
Assert.True(NodeIdentity.IsValidPublicKey(node.PublicKeySpki));
}
[Fact]
public void Identity_SurvivesAnExportImportRoundTrip()
{
using NodeIdentity original = NodeIdentity.Generate();
using NodeIdentity restored = NodeIdentity.FromPkcs8(original.ExportPkcs8());
Assert.Equal(original.PublicKeySpki, restored.PublicKeySpki);
Assert.True(NodeIdentity.Verify(original.PublicKeySpki, restored.Sign("t", "a"), "t", "a"));
Assert.Equal(32, NodeIdentity.Fingerprint(original.PublicKeySpki).Length);
}
[Fact]
public void SignatureString_IsPrefixedWithPurpose()
{
Assert.Equal("fleet|v1|auth|a|b", SignatureString.Build("auth", "a", "b"));
}
[Fact]
public void OutboxFrame_RoundTrips_AndRejectsShortFrames()
{
var id = Guid.CreateVersion7();
byte[] frame = OutboxFrame.Build(16, id, [1, 2, 3]);
Assert.True(OutboxFrame.TryParse(frame, out byte channel, out Guid parsed, out ReadOnlyMemory<byte> payload));
Assert.Equal((byte)16, channel);
Assert.Equal(id, parsed);
Assert.Equal(new byte[] { 1, 2, 3 }, payload.ToArray());
Assert.False(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes - 1], out _, out _, out _));
Assert.True(OutboxFrame.TryParse(new byte[OutboxFrame.HeaderBytes], out _, out _, out ReadOnlyMemory<byte> empty));
Assert.Equal(0, empty.Length);
}
[Fact]
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
{
var router = new RpcRouter<string>()
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage ok = (await router.DispatchAsync("alice", Request(1, "test.add", """{"a":2,"b":3}"""), CancellationToken.None))!;
Assert.Equal(5, ok.Result!.Value.Deserialize(EchoContext.Default.AddResult)!.Sum);
Assert.Equal("alice", ok.Result.Value.Deserialize(EchoContext.Default.AddResult)!.Caller);
RpcMessage denied = (await router.DispatchAsync("alice", Request(2, "test.add", """{"a":-1,"b":3}"""), CancellationToken.None))!;
Assert.Equal("fleet_forbidden", denied.Error!.Data!.Code);
Assert.Equal(RpcError.ApplicationError, denied.Error.Code);
RpcMessage invalid = (await router.DispatchAsync("alice", Request(3, "test.add", """{"a":"x"}"""), CancellationToken.None))!;
Assert.Equal(RpcError.InvalidParams, invalid.Error!.Code);
RpcMessage unknown = (await router.DispatchAsync("alice", Request(4, "test.nope", "{}"), CancellationToken.None))!;
Assert.Equal(RpcError.MethodNotFound, unknown.Error!.Code);
Assert.Equal("fleet_unsupported", unknown.Error.Data!.Code);
}
[Fact]
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
{
string? logged = null;
var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
Assert.Equal("fleet_internal", reply.Error!.Data!.Code);
Assert.DoesNotContain("secret", reply.Error.Message);
Assert.Equal("test.boom:secret detail", logged);
}
[Fact]
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
{
int calls = 0;
var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
});
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":1,"b":2}"""), CancellationToken.None));
Assert.Null(await router.DispatchAsync("a", Notification("test.note", """{"a":"bad"}"""), CancellationToken.None));
Assert.Null(await router.DispatchAsync("a", Notification("test.unknown", "{}"), CancellationToken.None));
Assert.Equal(1, calls);
}
[Fact]
public void Envelope_OmitsAbsentFields_AndRoundTrips()
{
string request = JsonSerializer.Serialize(Request(7, "x.y", """{"k":1}"""), FleetRpcContext.Default.RpcMessage);
Assert.Equal("""{"jsonrpc":"2.0","id":7,"method":"x.y","params":{"k":1}}""", request);
RpcMessage back = JsonSerializer.Deserialize(request, FleetRpcContext.Default.RpcMessage)!;
Assert.True(back.IsRequest);
Assert.False(back.IsResponse);
Assert.True(Notification("a.b", "{}").IsNotification);
string failure = JsonSerializer.Serialize(RpcMessage.Failure(3, new FleetError(FleetErrors.Timeout, "slow")), FleetRpcContext.Default.RpcMessage);
Assert.Contains("\"code\":\"fleet_timeout\"", failure);
Assert.Equal(FleetErrors.Timeout, JsonSerializer.Deserialize(failure, FleetRpcContext.Default.RpcMessage)!.Error!.ToFleetError().Code);
}
private static RpcMessage Request(long id, string method, string json) =>
new() { Id = id, Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
private static RpcMessage Notification(string method, string json) =>
new() { Method = method, Params = JsonDocument.Parse(json).RootElement.Clone() };
}
public sealed class AddParams
{
[JsonPropertyName("a")]
public int A { get; set; }
[JsonPropertyName("b")]
public int B { get; set; }
}
public sealed class AddResult
{
[JsonPropertyName("sum")]
public int Sum { get; set; }
[JsonPropertyName("caller")]
public string Caller { get; set; } = "";
}
[JsonSerializable(typeof(AddParams))]
[JsonSerializable(typeof(AddResult))]
internal sealed partial class EchoContext : JsonSerializerContext;
@@ -0,0 +1,431 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
namespace Fleet.Tests;
public sealed class LinkTests
{
// ───────────────────────── 基础件 ─────────────────────────
[Fact]
public void ReplayWindow_AcceptsEachSequenceOnce_AndRejectsStaleOnes()
{
var window = new ReplayWindow();
Assert.True(window.TryAccept(100));
Assert.False(window.TryAccept(100), "duplicate");
Assert.True(window.TryAccept(99), "late but inside the window");
Assert.False(window.TryAccept(99));
Assert.True(window.TryAccept(105));
Assert.True(window.TryAccept(101));
Assert.True(window.TryAccept(100 + 1024), "the window slides");
Assert.False(window.TryAccept(100), "now older than the window");
Assert.True(window.TryAccept(100 + 5000), "a big jump clears everything");
Assert.False(window.TryAccept(100 + 5000));
Assert.True(window.TryAccept(100 + 4999));
}
[Fact]
public void Backoff_GrowsExponentially_IsCappedAndJittered_AndResets()
{
var backoff = new Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
TimeSpan previousCeiling = TimeSpan.Zero;
for (int i = 0; i < 12; i++)
{
TimeSpan delay = backoff.Next();
double ceiling = Math.Min(60, Math.Pow(2, i));
Assert.InRange(delay.TotalSeconds, ceiling * 0.5 - 1e-9, ceiling + 1e-9);
Assert.True(TimeSpan.FromSeconds(ceiling) >= previousCeiling);
previousCeiling = TimeSpan.FromSeconds(ceiling);
}
backoff.Reset();
Assert.InRange(backoff.Next().TotalSeconds, 0.5, 1.0);
}
[Fact]
public async Task Outbox_DeliversInOrder_UntilAcknowledged_AndResendsAfterAReconnect()
{
var outbox = new Outbox(1 << 20);
Guid a = Guid.CreateVersion7();
Guid b = Guid.CreateVersion7();
outbox.Enqueue(16, a, [1]);
outbox.Enqueue(16, b, [2]);
outbox.Enqueue(16, a, [9]); // 同一个 msg_id 只保留一份
Assert.Equal(2, outbox.PendingCount);
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid first, out _));
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid second, out _));
Assert.Equal((a, b), (first, second));
// 连接断了:还没确认的全部重发,顺序不变
outbox.ResetSent();
Assert.True(OutboxFrame.TryParse(await outbox.TakeNextAsync(1 << 20, default), out _, out Guid again, out _));
Assert.Equal(a, again);
outbox.Acknowledge([a, b, Guid.NewGuid()]);
Assert.Equal(0, outbox.PendingCount);
Assert.Equal(0, outbox.PendingBytes);
}
[Fact]
public async Task Outbox_IsBounded_DropsTheOldest_AndReportsTheGap()
{
var outbox = new Outbox(maxBytes: 100);
int gap = 0;
outbox.OnGap = n => gap += n;
for (int i = 0; i < 10; i++)
{
outbox.Enqueue(16, Guid.CreateVersion7(), new byte[40]); // 每条 57 字节
}
Assert.Equal(1, outbox.PendingCount);
Assert.Equal(9, outbox.Dropped);
Assert.Equal(9, gap);
Assert.True(outbox.PendingBytes <= 100);
var counted = new Outbox(1 << 20, maxCount: 3);
for (int i = 0; i < 5; i++)
{
counted.Enqueue(16, Guid.CreateVersion7(), [1]);
}
Assert.Equal(3, counted.PendingCount);
Assert.Equal(2, counted.Dropped);
_ = await counted.TakeNextAsync(1 << 20, default);
}
[Fact]
public async Task Outbox_LimitsBytesInFlight_UntilTheServerAcknowledges()
{
var outbox = new Outbox(1 << 20);
Guid first = Guid.CreateVersion7();
outbox.Enqueue(16, first, new byte[1000]);
outbox.Enqueue(16, Guid.CreateVersion7(), new byte[1000]);
await outbox.TakeNextAsync(1500, default); // 第一条放行
using var cts = new CancellationTokenSource(200);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await outbox.TakeNextAsync(1500, cts.Token)); // 第二条超过在途上限,等 ACK
outbox.Acknowledge([first]);
Assert.NotNull(await outbox.TakeNextAsync(1500, default));
// 单条消息比在途上限还大时也要放行,否则永远卡住
var big = new Outbox(1 << 20);
big.Enqueue(16, Guid.CreateVersion7(), new byte[5000]);
Assert.NotNull(await big.TakeNextAsync(100, default));
}
[Fact]
public void SessionKeys_AgreeOnBothSides_AndBindTheHandshake()
{
using var client = new EphemeralKey();
using var server = new EphemeralKey();
byte[] nonce = RandomNumberGenerator.GetBytes(32);
SessionCredentials c = client.Derive(server.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki);
SessionCredentials s = server.Derive(client.PublicKeySpki, nonce, client.PublicKeySpki, server.PublicKeySpki);
Assert.Equal(c.Key, s.Key);
Assert.Equal(c.SessionId, s.SessionId);
Assert.Equal(8, c.SessionId.Length);
Assert.Equal(32, c.Key.Length);
SessionCredentials otherNonce = client.Derive(server.PublicKeySpki, RandomNumberGenerator.GetBytes(32), client.PublicKeySpki, server.PublicKeySpki);
Assert.NotEqual(c.Key, otherNonce.Key);
using var third = new EphemeralKey();
Assert.NotEqual(c.Key, third.Derive(server.PublicKeySpki, nonce, third.PublicKeySpki, server.PublicKeySpki).Key);
}
[Fact]
public void DataPlaneMac_DetectsAnyTampering()
{
byte[] key = RandomNumberGenerator.GetBytes(32);
byte[] data = [1, 2, 3, 4, 5];
byte[] mac = new byte[DataPlaneMac.Bytes];
DataPlaneMac.Compute(key, data, mac);
Assert.True(DataPlaneMac.Verify(key, data, mac));
data[2] ^= 1;
Assert.False(DataPlaneMac.Verify(key, data, mac));
data[2] ^= 1;
mac[0] ^= 1;
Assert.False(DataPlaneMac.Verify(key, data, mac));
mac[0] ^= 1;
Assert.False(DataPlaneMac.Verify(RandomNumberGenerator.GetBytes(32), data, mac));
}
// ───────────────────────── 传输与会话 ─────────────────────────
private static async Task<(TlsFrameTransport Left, TlsFrameTransport Right)> PairAsync(int maxFrame = 1 << 20)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var client = new TcpClient();
Task<TcpClient> accept = listener.AcceptTcpClientAsync();
await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
TcpClient server = await accept;
listener.Stop();
return (new TlsFrameTransport(client.GetStream(), maxFrame), new TlsFrameTransport(server.GetStream(), maxFrame));
}
[Fact]
public async Task TlsFrames_RoundTripTextAndBinary_AndCloseCleanly()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using (left)
await using (right)
{
await left.SendAsync(new RpcFrame(FrameKind.Text, "hello"u8.ToArray()), default);
await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[] { 1, 2, 3 }), default);
await left.SendAsync(new RpcFrame(FrameKind.Binary, ReadOnlyMemory<byte>.Empty), default);
RpcFrame a = (await right.ReceiveAsync(default))!.Value;
RpcFrame b = (await right.ReceiveAsync(default))!.Value;
RpcFrame c = (await right.ReceiveAsync(default))!.Value;
Assert.Equal((FrameKind.Text, "hello"), (a.Kind, System.Text.Encoding.UTF8.GetString(a.Data.Span)));
Assert.Equal(new byte[] { 1, 2, 3 }, b.Data.ToArray());
Assert.Equal(0, c.Data.Length);
await left.CloseAsync("done", default);
Assert.Null(await right.ReceiveAsync(default));
}
}
[Fact]
public async Task TlsFrames_RefuseOversizedFrames_OnBothSides()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync(maxFrame: 100);
await using (left)
await using (right)
{
await Assert.ThrowsAsync<InvalidOperationException>(async () => await left.SendAsync(new RpcFrame(FrameKind.Binary, new byte[101]), default));
// 对端绕过长度检查发来超长帧:接收方直接断开
(TlsFrameTransport raw, TlsFrameTransport receiver) = await PairAsync(maxFrame: 100);
await using (raw)
await using (receiver)
{
var permissive = new TlsFrameTransport(await TakeStreamAsync(raw), 1 << 20);
await permissive.SendAsync(new RpcFrame(FrameKind.Binary, new byte[500]), default);
Assert.Null(await receiver.ReceiveAsync(default));
}
}
}
private static Task<Stream> TakeStreamAsync(TlsFrameTransport transport) =>
Task.FromResult((Stream)typeof(TlsFrameTransport).GetField("_stream", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(transport)!);
[Fact]
public async Task Session_CorrelatesRequestsAndResponses_BothWays_WithoutDeadlockingInHandlers()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
RpcSession<string>? rightSession = null;
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var rightRouter = new RpcRouter<string>().On(
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
async (_, p, ct) =>
{
// 处理函数里等待向请求方发起的请求:接收循环和处理函数在不同的任务里,所以不会死锁
Result<Number> inner = await rightSession!.RequestAsync("left.double", p, LinkJson.Default.Number, LinkJson.Default.Number, ct);
return Result<Number>.Ok(new Number { Value = inner.Value.Value + 1 });
});
await using var leftSession = new RpcSession<string>(left, leftRouter, "left", TimeProvider.System);
rightSession = new RpcSession<string>(right, rightRouter, "right", TimeProvider.System);
await using (rightSession)
{
Task leftRun = leftSession.RunAsync(default);
Task rightRun = rightSession.RunAsync(default);
// left 请求 right.relay;right 的处理函数回头请求 left.double:21 → 42 → +1
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(43, result.Value.Value);
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
await leftSession.CloseAsync("bye", default);
await rightRun.WaitAsync(TimeSpan.FromSeconds(5));
await leftRun.WaitAsync(TimeSpan.FromSeconds(5));
}
}
[Fact]
public async Task Session_RequestsFailWhenThePeerNeverAnswers_OrTheSessionCloses()
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using var silent = right; // 对端不读也不回
await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
Task run = session.RunAsync(default);
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
await silent.CloseAsync("gone", default);
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
// ───────────────────────── 点对点握手 ─────────────────────────
private sealed class PeerFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
Listener = new PeerListener(
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
Task = Listener.RunAsync(_stop.Token);
}
public X509Certificate2 Certificate { get; }
public PeerListener Listener { get; }
public Task Task { get; }
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
await Task;
Certificate.Dispose();
_stop.Dispose();
}
private static async Task KeepOpenAsync(IRpcTransport transport, CancellationToken ct)
{
while (await transport.ReceiveAsync(ct) is not null)
{
}
}
}
[Fact]
public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials()
{
using NodeIdentity probe = NodeIdentity.Generate();
var seen = new TaskCompletionSource<PeerSessionInfo>();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
{
seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
});
await agent.Listener.Started.Task;
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using (transport)
{
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("prb_1", server.PeerId);
Assert.Equal(client.Credentials.Key, server.Credentials.Key);
Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId);
}
}
[Fact]
public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity impostor = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
await agent.Listener.Started.Task;
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
await agent.Listener.Started.Task;
byte[] wrong = SHA256.HashData("another certificate"u8);
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
}
[Fact]
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{
using NodeIdentity probe = NodeIdentity.Generate();
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture(
_ => probe.PublicKeySpki,
async (info, transport, ct) =>
{
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct);
});
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value);
await client.CloseAsync("bye", default);
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
[Fact]
public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 不说 TLS 的连接:握手失败,监听继续
using (var garbage = new TcpClient())
{
await garbage.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
}
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public void SelfSignedCertificate_IsEcdsaP256_AndHasAStableFingerprint()
{
byte[] pfx = NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System);
using X509Certificate2 certificate = NodeCertificate.LoadPfx(pfx);
Assert.True(certificate.HasPrivateKey);
Assert.Equal("1.2.840.10045.2.1", certificate.PublicKey.Oid.Value); // id-ecPublicKey
Assert.Equal(NodeCertificate.SpkiFingerprint(certificate), NodeCertificate.SpkiFingerprint(NodeCertificate.LoadPfx(pfx)));
Assert.Equal(32, NodeCertificate.SpkiFingerprint(certificate).Length);
}
}
public sealed class Number
{
[JsonPropertyName("value")]
public int Value { get; set; }
}
[JsonSerializable(typeof(Number))]
internal sealed partial class LinkJson : JsonSerializerContext;