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,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;