实现 .NET 10 托盘版网关保活

包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
chuzhongzai
2026-10-23 23:35:20 +08:00
parent 59a2982b2c
commit 2d85527656
57 changed files with 8623 additions and 0 deletions
@@ -0,0 +1,646 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
using GatewayKeepalive.Core;
namespace GatewayKeepalive.ProbeTests;
internal static class Program
{
private static async Task<int> Main()
{
var tests = new (string Name, Func<Task> Run)[]
{
("SOCKS5 分片 IPv4 成功回复", SocksFragmentedIpv4Async),
("SOCKS5 域名与 IPv6 回复", SocksDomainAndIpv6Async),
("SOCKS5 认证与 REP 失败", SocksFailuresAsync),
("SOCKS5 截断回复", SocksTruncatedAsync),
("SOCKS5 慢读取消", SocksSlowReadAsync),
("DNS 压缩 A 记录", DnsCompressedAsync),
("DNS TC 后 TCP 回退", DnsTcpFallbackAsync),
("DNS 畸形响应拒绝", DnsMalformedAsync),
("DNS 事务与来源校验", DnsTransactionAndSourceAsync),
("TLS 客户端配置可进入握手", TlsConfigurationAsync),
("旁路由健康不依赖当前出口", ProxyRecoveryIndependentAsync),
("多目标阈值独立汇总", MultipleTargetThresholdAsync),
("旁路由与出口 DNS 来源分离", DnsServerScopeAsync),
("出口目标按当前网关选择", EgressTargetSelectionAsync),
("非 SOCKS 模式不探测 1070", UseSocksFalseAsync),
("调用方取消向上传播", CallerCancellationAsync),
};
var failed = 0;
foreach (var test in tests)
{
try
{
await test.Run().WaitAsync(TimeSpan.FromSeconds(10));
Console.WriteLine($"PASS {test.Name}");
}
catch (Exception ex)
{
failed++;
Console.WriteLine($"FAIL {test.Name}: {ex.GetType().Name}: {ex.Message}");
}
}
Console.WriteLine($"{tests.Length - failed}/{tests.Length} tests passed");
return failed == 0 ? 0 : 1;
}
private static async Task SocksFragmentedIpv4Async()
{
var reply = new byte[]
{
5, 0,
5, 0, 0, 1, 10, 20, 30, 40, 0x1f, 0x90,
0xaa
};
using var stream = new ScriptedStream(reply, fragment: true);
await Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None);
Assert.Equal(12, stream.Consumed, "回复必须精确消费到 BND.PORT,不能过读。");
var one = new byte[1];
var read = await stream.ReadAsync(one.AsMemory(), CancellationToken.None);
Assert.Equal(1, read, "尾随字节应仍可由调用方读取。");
Assert.Equal((byte)0xaa, one[0], "尾随字节内容被改变。");
var request = stream.Written.Skip(3).ToArray();
Assert.SequenceEqual(new byte[] { 5, 1, 0, 1, 1, 2, 3, 4, 0x01, 0xbb }, request,
"IPv4 CONNECT 请求编码错误。");
}
private static async Task SocksDomainAndIpv6Async()
{
byte[] domainReply =
[
5, 0,
5, 0, 0, 3, 11,
.. Encoding.ASCII.GetBytes("example.com"),
0, 80
];
using (var domainStream = new ScriptedStream(domainReply, fragment: true))
{
await Socks5Probe.ConnectAsync(domainStream, "example.com", 443, CancellationToken.None);
var request = domainStream.Written.Skip(3).ToArray();
Assert.Equal(3, request[3], "域名 CONNECT 必须使用 ATYP=3。");
Assert.Equal(11, request[4], "域名长度编码错误。");
Assert.Equal("example.com", Encoding.ASCII.GetString(request, 5, 11), "域名编码错误。");
}
var ipv6 = IPAddress.Parse("2001:db8::1").GetAddressBytes();
byte[] ipv6Reply =
[
5, 0,
5, 0, 0, 4,
.. ipv6,
0x01, 0xbb
];
using var ipv6Stream = new ScriptedStream(ipv6Reply, fragment: true);
await Socks5Probe.ConnectAsync(ipv6Stream, "2001:db8::1", 443, CancellationToken.None);
Assert.Equal(4, ipv6Stream.Written[6], "IPv6 CONNECT 必须使用 ATYP=4。");
Assert.Equal(ipv6Stream.Consumed, ipv6Reply.Length, "IPv6 回复未完整消费。");
}
private static async Task SocksFailuresAsync()
{
using (var auth = new ScriptedStream([5, 2], fragment: true))
{
var ex = await Assert.ThrowsAsync<Socks5Exception>(() =>
Socks5Probe.ConnectAsync(auth, "example.com", 443, CancellationToken.None));
Assert.Equal((byte)2, ex.ReplyCode, "认证方法错误码未被保留。");
}
var rejectedReply = new byte[]
{
5, 0,
5, 5, 0, 3, 4, (byte)'h', (byte)'o', (byte)'s', (byte)'t', 0, 80
};
using var rejected = new ScriptedStream(rejectedReply, fragment: true);
var rejectedEx = await Assert.ThrowsAsync<Socks5Exception>(() =>
Socks5Probe.ConnectAsync(rejected, "example.com", 443, CancellationToken.None));
Assert.Equal((byte)5, rejectedEx.ReplyCode, "REP 错误码未被保留。");
Assert.Equal(rejectedReply.Length, rejected.Consumed, "REP 失败时也必须消费完整变长回复。");
}
private static async Task SocksTruncatedAsync()
{
using var stream = new ScriptedStream([5, 0, 5, 0, 0, 1, 127, 0], fragment: true);
await Assert.ThrowsAsync<EndOfStreamException>(() =>
Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, CancellationToken.None));
}
private static async Task SocksSlowReadAsync()
{
using var stream = new ScriptedStream([5, 0], fragment: true, delayMs: 1000);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
await Assert.ThrowsAsync<OperationCanceledException>(() =>
Socks5Probe.ConnectAsync(stream, "1.2.3.4", 443, cts.Token));
}
private static async Task DnsCompressedAsync()
{
using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var server = ServeOnceAsync(udp, cts.Token, request =>
{
var (id, host) = ReadQuery(request);
return BuildDnsResponse(id, host, IPAddress.Parse("203.0.113.9"), compressed: true);
});
var addresses = await DnsProbe.QueryAAsync(
IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token);
await server;
Assert.Equal(1, addresses.Length, "A 记录数量错误。");
Assert.Equal("203.0.113.9", addresses[0].ToString(), "压缩名称后的 A 地址错误。");
}
private static async Task DnsTcpFallbackAsync()
{
using var tcp = new TcpListener(IPAddress.Loopback, 0);
tcp.Start();
tcp.Server.ReceiveTimeout = 3000;
var port = ((IPEndPoint)tcp.LocalEndpoint).Port;
using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var udpServer = ServeOnceAsync(udp, cts.Token, request =>
{
var (id, host) = ReadQuery(request);
return BuildDnsResponse(id, host, IPAddress.Any, compressed: false, truncated: true);
});
var tcpServer = ServeDnsTcpOnceAsync(tcp, cts.Token);
var addresses = await DnsProbe.QueryAAsync(
IPAddress.Loopback, port, "fallback.test", IPAddress.Loopback, cts.Token);
await Task.WhenAll(udpServer, tcpServer);
Assert.Equal("198.51.100.7", addresses.Single().ToString(), "TCP 回退地址错误。");
}
private static async Task DnsMalformedAsync()
{
using var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var server = ServeOnceAsync(udp, cts.Token, request =>
{
var (id, _) = ReadQuery(request);
var malformed = BuildDnsResponse(id, "other.example", IPAddress.Parse("203.0.113.8"), compressed: true);
BinaryPrimitives.WriteUInt16BigEndian(malformed.AsSpan(6, 2), 2);
return malformed;
});
await Assert.ThrowsAsync<InvalidDataException>(() => DnsProbe.QueryAAsync(
IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token));
await server;
var loopResponse = new byte[18];
BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(0, 2), 42);
BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(2, 2), 0x8180);
BinaryPrimitives.WriteUInt16BigEndian(loopResponse.AsSpan(4, 2), 1);
loopResponse[12] = 0xc0;
loopResponse[13] = 12;
Assert.Throws<InvalidDataException>(() =>
DnsProbe.ParseResponse(loopResponse, 42, "example.com"));
}
private static async Task DnsTransactionAndSourceAsync()
{
using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)))
{
var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var server = ServeOnceAsync(udp, cts.Token, request =>
{
var (id, host) = ReadQuery(request);
return BuildDnsResponse(
(ushort)(id + 1), host, IPAddress.Parse("203.0.113.10"), compressed: true);
});
await Assert.ThrowsAsync<InvalidDataException>(() => DnsProbe.QueryAAsync(
IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token));
await server;
}
using (var udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)))
{
var port = ((IPEndPoint)udp.Client.LocalEndPoint!).Port;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var server = ServeFromWrongSourceAsync(udp, cts.Token);
await Assert.ThrowsAsync<InvalidDataException>(() => DnsProbe.QueryAAsync(
IPAddress.Loopback, port, "example.com", IPAddress.Loopback, cts.Token));
await server;
}
}
private static async Task UseSocksFalseAsync()
{
var settings = new AppSettings
{
ProxyDns = "192.0.2.1",
UseSocks = false,
TestHosts = ["127.0.0.1"],
RequiredHealthyTargets = 1,
DirectTestHosts = ["127.0.0.9"],
RequiredDirectHealthyTargets = 1,
ProbeTimeoutMs = 150,
RoundTimeoutMs = 600,
};
var snapshot = LoopbackSnapshot(source: null);
var probe = new NetworkHealthProbe((_, _, _) => false);
var report = await probe.CheckAsync(settings, snapshot, CancellationToken.None);
Assert.Equal(3, report.Checks.Length, "非 SOCKS 模式应只有连接、DNS、出口三项。");
Assert.True(report.Checks.All(x => x.Name != "SOCKS5"), "非 SOCKS 模式不应要求 1070。");
Assert.True(report.Checks.First(x => x.Name == "旁路由 DNS").Success,
"IPv4 字面量目标不应因缺少系统 DNS 而失败。");
Assert.True(report.ProxyHealthy,
"未启用 SOCKS 时可选 TCP53 诊断失败不能否定旁路由 DNS 候选。");
Assert.False(report.InternetHealthy, "无本地 TLS 服务时出口不应被判为健康。");
Assert.True(
report.Checks.First(x => x.Name == "当前出口").Detail.Contains("127.0.0.9"),
"直连出口必须使用 DirectTestHosts,而不是代理 TestHosts。");
}
private static async Task TlsConfigurationAsync()
{
using var stream = new ScriptedStream([], fragment: true);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try
{
await NetworkHealthProbe.AuthenticateTlsAsync(stream, "localhost", cts.Token);
throw new InvalidOperationException("空流不应完成 TLS 握手。");
}
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException)
{
Assert.False(
ex is ArgumentException or InvalidOperationException,
$"TLS 配置失败,而不是正常的握手读取失败:{ex.Message}");
}
}
private static Task ProxyRecoveryIndependentAsync()
{
var checks = new[]
{
new ProbeResult("旁路由连接", true, 1, "可达"),
new ProbeResult("旁路由 DNS", true, 1, "解析成功"),
new ProbeResult("SOCKS5", true, 1, "CONNECT 成功"),
new ProbeResult("当前出口", false, 1, "当前直连出口失败")
};
var probeOnly = NetworkHealthProbe.AssembleHealthReport(
checks, proxyTcpSuccess: true, proxyDnsSuccess: true, socksSuccess: true,
useSocks: true, healthyTargets: 0, requiredHealthyTargets: 1, DateTimeOffset.UtcNow);
Assert.True(probeOnly.ProxyHealthy, "旁路由候选层通过时必须可判定恢复。");
Assert.False(probeOnly.InternetHealthy, "当前出口失败必须单独反映。");
var noSocks = NetworkHealthProbe.AssembleHealthReport(
checks, proxyTcpSuccess: false, proxyDnsSuccess: true, socksSuccess: null,
useSocks: false, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow);
Assert.True(noSocks.ProxyHealthy, "未启用 SOCKS 时 TCP53 失败不应否定成功的旁路由 DNS。");
Assert.True(noSocks.InternetHealthy, "当前出口成功应独立成立。");
var internetOnly = NetworkHealthProbe.AssembleHealthReport(
checks, proxyTcpSuccess: false, proxyDnsSuccess: false, socksSuccess: false,
useSocks: true, healthyTargets: 1, requiredHealthyTargets: 1, DateTimeOffset.UtcNow);
Assert.False(internetOnly.ProxyHealthy, "旁路由候选失败时不能误报恢复。");
Assert.True(internetOnly.InternetHealthy, "当前出口成功不应被候选失败覆盖。");
return Task.CompletedTask;
}
private static Task MultipleTargetThresholdAsync()
{
Assert.True(NetworkHealthProbe.MeetsHealthyTargets(1, 1), "1/3 目标成功且要求 1 时应通过。");
Assert.True(NetworkHealthProbe.MeetsHealthyTargets(2, 2), "2/3 目标成功且要求 2 时应通过。");
Assert.False(NetworkHealthProbe.MeetsHealthyTargets(1, 2), "仅 1/3 成功不应满足要求 2。");
Assert.False(NetworkHealthProbe.MeetsHealthyTargets(0, 1), "全部失败不应满足要求 1。");
return Task.CompletedTask;
}
private static Task DnsServerScopeAsync()
{
var settings = new AppSettings { ProxyDns = "192.168.6.2" };
var snapshot = new NetworkSnapshot(
new AdapterInfo(Guid.NewGuid(), 1, "Ethernet", true, [], 1, "scope-test"),
[],
DnsConfiguration.Auto,
["192.168.6.1", "192.168.6.1", "2001:db8::53", "not-an-ip"],
DateTimeOffset.UtcNow);
var proxy = NetworkHealthProbe.ProxyDnsServers(settings);
var effective = NetworkHealthProbe.EffectiveDnsServers(snapshot);
Assert.Equal(1, proxy.Length, "旁路由 DNS 只应取 ProxyDns。");
Assert.Equal("192.168.6.2", proxy[0].ToString(), "旁路由 DNS 服务器错误。");
Assert.Equal(1, effective.Length, "出口 DNS 应去重并忽略非 IPv4。");
Assert.Equal("192.168.6.1", effective[0].ToString(), "出口 DNS 必须取快照 EffectiveDns。");
Assert.True(proxy.All(x => !effective.Contains(x)), "两套 DNS 列表不得混用。");
return Task.CompletedTask;
}
private static Task EgressTargetSelectionAsync()
{
var settings = new AppSettings
{
TestHosts = ["www.google.com"],
RequiredHealthyTargets = 1,
DirectTestHosts = ["www.baidu.com", "www.microsoft.com"],
RequiredDirectHealthyTargets = 1,
};
var proxy = NetworkHealthProbe.SelectEgressTargets(
settings, LoopbackSnapshot(source: null, settings.ProxyGateway));
Assert.True(proxy.IsProxy, "旁路由网关必须选择代理目标。");
Assert.Equal("www.google.com", string.Join(',', proxy.Hosts), "旁路由出口目标错误。");
Assert.Equal(1, proxy.RequiredHealthyTargets, "旁路由出口阈值错误。");
var direct = NetworkHealthProbe.SelectEgressTargets(
settings, LoopbackSnapshot(source: null, settings.DirectGateway));
Assert.False(direct.IsProxy, "主路由网关必须选择直连目标。");
Assert.Equal(
"www.baidu.com,www.microsoft.com",
string.Join(',', direct.Hosts),
"直连出口不能继续使用 www.google.com。");
Assert.Equal(1, direct.RequiredHealthyTargets, "直连出口阈值错误。");
var external = NetworkHealthProbe.SelectEgressTargets(
settings, LoopbackSnapshot(source: null, "203.0.113.1"));
Assert.False(external.IsProxy, "非旁路由网关应按当前直连出口验证。");
Assert.Equal(
"www.baidu.com,www.microsoft.com",
string.Join(',', external.Hosts),
"外部网关不应回退到代理目标。");
return Task.CompletedTask;
}
private static async Task CallerCancellationAsync()
{
var settings = new AppSettings
{
ProxyDns = "192.0.2.1",
UseSocks = false,
TestHosts = ["127.0.0.1"],
ProbeTimeoutMs = 500,
RoundTimeoutMs = 1000,
};
using var cts = new CancellationTokenSource();
cts.Cancel();
var probe = new NetworkHealthProbe();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
probe.CheckAsync(settings, LoopbackSnapshot(source: null), cts.Token));
}
private static NetworkSnapshot LoopbackSnapshot(
string? source,
string gateway = "127.0.0.1") => new(
new AdapterInfo(
Guid.NewGuid(),
1,
"Loopback",
true,
source is null ? [] : [new Ipv4Address(source, 8)],
1,
"loopback"),
[new RouteEntry(gateway, 1, 0, 1)],
DnsConfiguration.Auto,
[],
DateTimeOffset.UtcNow);
private static async Task ServeOnceAsync(
UdpClient server,
CancellationToken ct,
Func<byte[], byte[]> responseFactory)
{
var request = await server.ReceiveAsync(ct);
var response = responseFactory(request.Buffer);
await server.SendAsync(response, request.RemoteEndPoint, ct);
}
private static async Task ServeFromWrongSourceAsync(UdpClient server, CancellationToken ct)
{
var request = await server.ReceiveAsync(ct);
var (id, host) = ReadQuery(request.Buffer);
var response = BuildDnsResponse(
id, host, IPAddress.Parse("203.0.113.11"), compressed: true);
using var sender = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
await sender.SendAsync(response, request.RemoteEndPoint, ct);
}
private static async Task ServeDnsTcpOnceAsync(TcpListener listener, CancellationToken ct)
{
using var client = await listener.AcceptTcpClientAsync(ct);
await using var stream = client.GetStream();
var prefix = new byte[2];
await ReadExactlyAsync(stream, prefix, ct);
var length = BinaryPrimitives.ReadUInt16BigEndian(prefix);
var request = new byte[length];
await ReadExactlyAsync(stream, request, ct);
var (id, host) = ReadQuery(request);
var response = BuildDnsResponse(
id, host, IPAddress.Parse("198.51.100.7"), compressed: true);
BinaryPrimitives.WriteUInt16BigEndian(prefix, (ushort)response.Length);
await stream.WriteAsync(prefix, ct);
var split = Math.Max(1, response.Length / 3);
await stream.WriteAsync(response.AsMemory(0, split), ct);
await Task.Delay(10, ct);
await stream.WriteAsync(response.AsMemory(split, response.Length - split), ct);
await stream.FlushAsync(ct);
}
private static (ushort Id, string Host) ReadQuery(byte[] query)
{
var id = BinaryPrimitives.ReadUInt16BigEndian(query.AsSpan(0, 2));
var offset = 12;
var labels = new List<string>();
while (query[offset] != 0)
{
var length = query[offset++];
labels.Add(Encoding.ASCII.GetString(query, offset, length));
offset += length;
}
return (id, string.Join('.', labels));
}
private static byte[] BuildDnsResponse(
ushort id,
string host,
IPAddress answer,
bool compressed,
bool truncated = false)
{
var question = EncodeName(host);
var answerCount = truncated ? 0 : 1;
var answerLength = truncated ? 0 : (compressed ? 16 : question.Length + 14);
var response = new byte[12 + question.Length + 4 + answerLength];
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(0, 2), id);
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(2, 2), truncated ? (ushort)0x8380 : (ushort)0x8180);
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(4, 2), 1);
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(6, 2), (ushort)answerCount);
question.CopyTo(response, 12);
var offset = 12 + question.Length;
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1);
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1);
offset += 4;
if (truncated) return response;
if (compressed)
{
response[offset++] = 0xc0;
response[offset++] = 12;
}
else
{
question.CopyTo(response, offset);
offset += question.Length;
}
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset, 2), 1);
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 2, 2), 1);
BinaryPrimitives.WriteUInt32BigEndian(response.AsSpan(offset + 4, 4), 60);
BinaryPrimitives.WriteUInt16BigEndian(response.AsSpan(offset + 8, 2), 4);
answer.GetAddressBytes().CopyTo(response, offset + 10);
return response;
}
private static byte[] EncodeName(string host)
{
var labels = host.Split('.');
var result = new List<byte>();
foreach (var label in labels)
{
var bytes = Encoding.ASCII.GetBytes(label);
result.Add((byte)bytes.Length);
result.AddRange(bytes);
}
result.Add(0);
return result.ToArray();
}
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
{
var read = 0;
while (read < buffer.Length)
{
var count = await stream.ReadAsync(buffer[read..], ct);
if (count == 0) throw new EndOfStreamException();
read += count;
}
}
private sealed class ScriptedStream : Stream
{
private readonly byte[] _incoming;
private readonly bool _fragment;
private readonly int _delayMs;
private int _position;
public ScriptedStream(byte[] incoming, bool fragment, int delayMs = 0)
{
_incoming = incoming;
_fragment = fragment;
_delayMs = delayMs;
}
public int Consumed => _position;
public List<byte> Written { get; } = [];
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override async ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default)
{
if (_delayMs > 0) await Task.Delay(_delayMs, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
if (_position >= _incoming.Length || buffer.Length == 0) return 0;
var count = _fragment ? 1 : Math.Min(buffer.Length, _incoming.Length - _position);
_incoming.AsMemory(_position, count).CopyTo(buffer);
_position += count;
return count;
}
public override ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Written.AddRange(buffer.ToArray());
return ValueTask.CompletedTask;
}
public override int Read(byte[] buffer, int offset, int count)
{
if (_position >= _incoming.Length) return 0;
var length = Math.Min(count, _incoming.Length - _position);
Array.Copy(_incoming, _position, buffer, offset, length);
_position += length;
return length;
}
public override void Write(byte[] buffer, int offset, int count) =>
Written.AddRange(buffer.AsSpan(offset, count).ToArray());
public override void Flush() { }
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
private static class Assert
{
public static void True(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
public static void False(bool condition, string message) => True(!condition, message);
public static void Equal<T>(T expected, T actual, string message) where T : IEquatable<T>
{
if (!expected.Equals(actual))
throw new InvalidOperationException($"{message} 期望 {expected},实际 {actual}。");
}
public static void SequenceEqual(byte[] expected, byte[] actual, string message)
{
if (!expected.AsSpan().SequenceEqual(actual))
throw new InvalidOperationException(
$"{message} 期望 {Convert.ToHexString(expected)},实际 {Convert.ToHexString(actual)}。");
}
public static async Task<T> ThrowsAsync<T>(Func<Task> action) where T : Exception
{
try
{
await action();
}
catch (T ex)
{
return ex;
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex);
}
throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。");
}
public static void Throws<T>(Action action) where T : Exception
{
try
{
action();
}
catch (T)
{
return;
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"期望 {typeof(T).Name},实际 {ex.GetType().Name}: {ex.Message}", ex);
}
throw new InvalidOperationException($"期望 {typeof(T).Name},但操作成功。");
}
}
}