chore: initial project import
This commit is contained in:
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;
|
||||
Reference in new issue
Block a user