Permissions, RateControl and Auditing

This commit is contained in:
2026-07-16 14:01:08 +03:00
parent 3a1b95dbc5
commit ba64a0c95a
62 changed files with 6095 additions and 2366 deletions
@@ -15,14 +15,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="MessagePack" Version="3.1.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.28" />
<PackageReference Include="MQTTnet" Version="5.0.1.1416" />
<PackageReference Include="protobuf-net.Core" Version="3.2.56" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
<PackageReference Include="System.Diagnostics.Tracing" Version="4.3.0" />
<PackageReference Include="System.Text.Json" Version="9.0.10" />
</ItemGroup>
<ItemGroup>
@@ -7,8 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<!-- ASP.NET Core 10 permits Microsoft.OpenApi 2.0.0; enforce a patched 2.x floor. -->
<PackageReference Include="Microsoft.OpenApi" Version="2.10.0" />
</ItemGroup>
</Project>
@@ -9,7 +9,6 @@
<ItemGroup>
<PackageReference Include="ApacheThrift" Version="0.22.0" />
<PackageReference Include="Thrift" Version="0.9.1.3" />
</ItemGroup>
</Project>
@@ -12,7 +12,8 @@
<PackageReference Include="AvroConvert" Version="3.4.16" />
<PackageReference Include="FlatSharp" Version="6.3.5" />
<PackageReference Include="Google.Protobuf" Version="3.34.0" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="MessagePack" Version="3.1.8" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="MongoDB.Bson" Version="3.7.1" />
<PackageReference Include="PeterO.Cbor" Version="4.5.5" />
<PackageReference Include="protobuf-net" Version="3.2.56" />
@@ -12,7 +12,8 @@
<PackageReference Include="AvroConvert" Version="3.4.16" />
<PackageReference Include="FlatSharp" Version="6.3.5" />
<PackageReference Include="Google.Protobuf" Version="3.34.0" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="MessagePack" Version="3.1.8" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="MongoDB.Bson" Version="3.7.1" />
<PackageReference Include="PeterO.Cbor" Version="4.5.5" />
<PackageReference Include="protobuf-net" Version="3.2.56" />
+37
View File
@@ -0,0 +1,37 @@
using Esiur.Core;
namespace Esiur.Tests.Unit;
public class AsyncBagTests
{
[Fact]
public void Seal_CollectsConcurrentReplyCompletionsExactlyOnce()
{
const int count = 2_000;
var replies = Enumerable.Range(0, count)
.Select(_ => new AsyncReply<int>())
.ToArray();
var bag = new AsyncBag<int>();
foreach (var reply in replies)
bag.Add(reply);
bag.Seal();
Parallel.For(0, replies.Length, index => replies[index].Trigger(index));
Assert.Equal(Enumerable.Range(0, count), bag.Wait(5_000));
}
[Fact]
public void AddBag_UsesAStableSnapshot()
{
var source = new AsyncBag<int>();
source.Add(1);
source.Add(new AsyncReply<int>(2));
var destination = new AsyncBag<int>();
destination.AddBag(source);
destination.Seal();
Assert.Equal(new[] { 1, 2 }, destination.Wait(1_000));
}
}
+23
View File
@@ -94,4 +94,27 @@ public class AsyncQueueTests
Assert.Equal(Enumerable.Range(1, itemCount), sequences.OrderBy(x => x));
}
[Fact]
public void LargeReadyBatch_IsDeliveredInOrder()
{
const int itemCount = 5_000;
var queue = new AsyncQueue<int>();
var replies = Enumerable.Range(0, itemCount)
.Select(_ => new AsyncReply<int>())
.ToArray();
var delivered = new List<int>(itemCount);
queue.Then(delivered.Add);
foreach (var reply in replies)
queue.Add(reply);
// Keep the head pending while every later item becomes ready, then release
// the whole batch at once. This exercises the bulk-removal path.
for (var i = 1; i < replies.Length; i++)
replies[i].Trigger(i);
replies[0].Trigger(0);
Assert.Equal(Enumerable.Range(0, itemCount), delivered);
}
}
+280
View File
@@ -0,0 +1,280 @@
using Esiur.Core;
using Esiur.Data.Types;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace Esiur.Tests.Unit;
public class AsyncReplyConcurrencyTests
{
[Fact]
public void CompletedRepliesKeepCallbackAndWaiterStorageLazy()
{
var reply = new AsyncReply<int>(42);
var awaiter = reply.GetAwaiter();
Assert.True(reply.Ready);
Assert.True(awaiter.IsCompleted);
Assert.Equal(42, awaiter.GetResult());
Assert.Null(GetBaseField(reply, "callbacks"));
Assert.Null(GetBaseField(reply, "errorCallbacks"));
Assert.Null(GetBaseField(reply, "completionEvent"));
}
[Fact]
public void CompletedReplyAllocationStaysWithinObjectAndLockBudget()
{
const int count = 4096;
const int maximumBytesPerReply = 224;
var replies = new AsyncReply<int>[count];
// Warm up constructors and property access before measuring this thread.
for (var i = 0; i < 32; i++)
_ = new AsyncReply<int>(i).Ready;
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < replies.Length; i++)
replies[i] = new AsyncReply<int>(i);
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
GC.KeepAlive(replies);
Assert.True(
allocated < count * (long)maximumBytesPerReply,
$"Completed replies allocated {allocated / (double)count:N1} bytes each.");
}
[Fact]
public void TriggerErrorWithoutAHandlerIsRetainedAndObservedByWaiters()
{
var reply = new AsyncReply<int>();
var source = new InvalidOperationException("synchronous failure");
var triggerException = Record.Exception(() => reply.TriggerError(source));
Assert.Null(triggerException);
Assert.False(reply.Ready);
Assert.True(reply.Failed);
var stored = Assert.IsType<AsyncException>(reply.Exception);
Assert.Same(source, stored.InnerException);
Assert.Same(stored, Assert.Throws<AsyncException>(() => reply.Wait()));
Assert.Same(stored, Assert.Throws<AsyncException>(() => reply.Wait(10)));
AsyncException? observed = null;
reply.Error(error => observed = error);
Assert.Same(stored, observed);
}
[Fact]
public async Task CustomAsyncBuilderRetainsAnErrorThrownBeforeTheFirstAwait()
{
AsyncReply<int>? reply = null;
var creationException = Record.Exception(() => reply = FailBeforeFirstAwait(fail: true));
Assert.Null(creationException);
Assert.NotNull(reply);
Assert.True(reply!.Failed);
await Assert.ThrowsAsync<InvalidOperationException>(async () => _ = await reply);
}
[Fact]
public async Task CustomAsyncBuilderPreservesAsyncExceptionAfterSuspension()
{
var gate = new AsyncReply();
var reply = FailAfterFirstAwait(gate);
var observation = AwaitAsTask(reply);
gate.Trigger(null!);
var exception = await Assert.ThrowsAsync<AsyncException>(() => observation);
Assert.IsType<InvalidOperationException>(exception.InnerException);
}
[Fact]
public async Task CustomAsyncBuilderPublishesSuspensionBeforeInlineContinuation()
{
AsyncReply<int>? reply = null;
var creationException = Record.Exception(() => reply = FailAfterInlineCompletion());
Assert.Null(creationException);
var exception = await Assert.ThrowsAsync<AsyncException>(async () => _ = await reply!);
Assert.IsType<InvalidOperationException>(exception.InnerException);
}
[Fact]
public void StreamErrorsReachTheBaseTerminalStateWithoutAnErrorHandler()
{
static AsyncReply CompletedControl() => new AsyncReply((object)null!);
var stream = new AsyncStreamReply<int>(
StreamMode.Push,
CompletedControl,
CompletedControl,
CompletedControl,
CompletedControl);
var expected = new AsyncException(ErrorType.Exception, 0, "stream failed");
stream.TriggerError(expected);
Assert.True(stream.Failed);
Assert.Same(expected, stream.Exception);
Assert.Same(expected, Assert.Throws<AsyncException>(() => stream.Wait()));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void TerminalStateWakesEverySynchronousWaiter(bool fail)
{
const int waiterCount = 8;
var reply = new AsyncReply<int>();
var results = new int[waiterCount];
var errors = new Exception?[waiterCount];
var threads = new Thread[waiterCount];
for (var i = 0; i < threads.Length; i++)
{
var index = i;
threads[i] = new Thread(() =>
{
try
{
results[index] = reply.Wait();
}
catch (Exception exception)
{
errors[index] = exception;
}
})
{
IsBackground = true
};
threads[i].Start();
}
var allWaiting = SpinWait.SpinUntil(
() => threads.All(thread =>
(thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0),
millisecondsTimeout: 5000);
if (fail)
reply.TriggerError(new InvalidOperationException("failed"));
else
reply.Trigger(73);
var allJoined = threads.Select(thread => thread.Join(2000)).ToArray();
Assert.True(allWaiting, "Not all test threads reached the blocking wait.");
Assert.All(allJoined, joined => Assert.True(joined, "A synchronous waiter was not released."));
if (fail)
Assert.All(errors, error => Assert.IsType<AsyncException>(error));
else
{
Assert.All(results, value => Assert.Equal(73, value));
Assert.All(errors, Assert.Null);
}
}
[Fact]
public void CompletionCallbacksRunOutsideTheReplyLock()
{
var reply = new AsyncReply<int>();
reply.Then(_ =>
{
var concurrentRegistration = Task.Run(() => reply.Then(__ => { }));
if (!concurrentRegistration.Wait(2000))
throw new TimeoutException("Concurrent callback registration was blocked by the completion callback.");
});
Assert.Null(Record.Exception(() => reply.Trigger(1)));
}
[Fact]
public void ErrorRegistrationRacingWithFailureInvokesExactlyOnce()
{
for (var iteration = 0; iteration < 500; iteration++)
{
var reply = new AsyncReply<int>();
var expected = new AsyncException(ErrorType.Exception, 0, "failed");
AsyncException? observed = null;
var calls = 0;
Parallel.Invoke(
() => reply.Error(error =>
{
observed = error;
Interlocked.Increment(ref calls);
}),
() => reply.TriggerError(expected));
Assert.Equal(1, Volatile.Read(ref calls));
Assert.Same(expected, observed);
}
}
[Fact]
public void AwaiterContinuationIsNotLostDuringCompletionRace()
{
for (var iteration = 0; iteration < 500; iteration++)
{
var reply = new AsyncReply<int>();
var awaiter = reply.GetAwaiter();
using var continued = new ManualResetEventSlim(false);
Parallel.Invoke(
() => awaiter.OnCompleted(continued.Set),
() => reply.Trigger(iteration));
Assert.True(continued.Wait(2000), "The awaiter continuation was lost.");
Assert.True(awaiter.IsCompleted);
Assert.Equal(iteration, awaiter.GetResult());
}
}
private static object? GetBaseField(AsyncReply reply, string name)
=> typeof(AsyncReply)
.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)
?.GetValue(reply);
private static async AsyncReply<int> FailBeforeFirstAwait(bool fail)
{
if (fail)
throw new InvalidOperationException("failed before await");
await Task.Yield();
return 1;
}
private static async AsyncReply<int> FailAfterFirstAwait(AsyncReply gate)
{
await gate;
throw new InvalidOperationException("failed after await");
}
private static async Task<int> AwaitAsTask(AsyncReply<int> reply) => await reply;
private static async AsyncReply<int> FailAfterInlineCompletion()
{
await new InlineCompletion();
throw new InvalidOperationException("failed after inline continuation");
}
private readonly struct InlineCompletion
{
public Awaiter GetAwaiter() => new Awaiter();
public readonly struct Awaiter : INotifyCompletion
{
public bool IsCompleted => false;
public void GetResult()
{
}
public void OnCompleted(Action continuation) => continuation();
}
}
}
+260
View File
@@ -0,0 +1,260 @@
using System.Collections;
using System.Reflection;
using System.Text;
using Esiur.Data;
using Esiur.Net.Http;
using Esiur.Net.Packets.Http;
namespace Esiur.Tests.Unit;
public class HttpHardeningTests
{
[Theory]
[InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")]
[InlineData("Content-Length: 1\r\nContent-Length: 2\r\n")]
public void Request_RejectsDuplicateContentLength(string framingHeaders)
{
var data = RequestBytes("POST", framingHeaders, "x");
Assert.Throws<InvalidDataException>(() =>
new HttpRequestPacket().Parse(data, 0, (uint)data.Length));
}
[Theory]
[InlineData("Transfer-Encoding: chunked\r\n")]
[InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")]
public void Request_RejectsUnsupportedTransferEncoding(string framingHeaders)
{
var data = RequestBytes("POST", framingHeaders, "x");
Assert.Throws<InvalidDataException>(() =>
new HttpRequestPacket().Parse(data, 0, (uint)data.Length));
}
[Theory]
[InlineData("PUT")]
[InlineData("GET")]
[InlineData("DELETE")]
public void Request_ConsumesDeclaredBodiesForEveryMethod(string method)
{
var data = RequestBytes(
method,
"Content-Type: application/octet-stream\r\nContent-Length: 3\r\n",
"abc");
var packet = new HttpRequestPacket();
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
Assert.Equal("abc", Encoding.ASCII.GetString(packet.Message));
}
[Fact]
public void Request_AllowsPostWithoutABodyOrContentLength()
{
var data = RequestBytes("POST", string.Empty, string.Empty);
var packet = new HttpRequestPacket();
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
Assert.Empty(packet.PostForms);
}
[Fact]
public void Request_EnforcesHeaderCountBeforeSplittingHeaders()
{
var data = RequestBytes("GET", "X-One: 1\r\nX-Two: 2\r\n", string.Empty);
var packet = new HttpRequestPacket { MaximumHeaderCount = 1 };
Assert.Throws<ParserLimitException>(() =>
packet.Parse(data, 0, (uint)data.Length));
}
[Fact]
public void UrlEncodedForm_EnforcesFieldKeyAndValueLimits()
{
AssertFormLimit("a=1&b=2", packet => packet.MaximumFormFields = 1);
AssertFormLimit("long=1", packet => packet.MaximumFormKeyLength = 3);
AssertFormLimit("a=long", packet => packet.MaximumFormValueLength = 3);
}
[Fact]
public void UrlEncodedForm_CombinesUnkeyedFieldsWithinTheConfiguredBound()
{
const string body = "first&second&third";
var data = RequestBytes(
"POST",
$"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n",
body);
var packet = new HttpRequestPacket { MaximumFormValueLength = body.Length };
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
Assert.Equal(body, packet.PostForms["unknown"]);
packet = new HttpRequestPacket { MaximumFormValueLength = body.Length - 1 };
Assert.Throws<ParserLimitException>(() =>
packet.Parse(data, 0, (uint)data.Length));
}
[Fact]
public void MultipartForm_EnforcesPartLength()
{
const string boundary = "test-boundary";
const string body =
"--test-boundary\r\n" +
"Content-Disposition: form-data; name=\"value\"\r\n\r\n" +
"payload\r\n" +
"--test-boundary--\r\n";
var data = RequestBytes(
"POST",
$"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" +
$"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n",
body);
var packet = new HttpRequestPacket { MaximumMultipartPartLength = 8 };
Assert.Throws<ParserLimitException>(() =>
packet.Parse(data, 0, (uint)data.Length));
}
[Fact]
public void MultipartForm_ParsesQuotedBoundaryIncrementally()
{
const string boundary = "test-boundary";
const string body =
"--test-boundary\r\n" +
"Content-Disposition: form-data; name=\"first\"\r\n\r\n" +
"one\r\n" +
"--test-boundary\r\n" +
"Content-Disposition: form-data; name=\"second\"\r\n\r\n" +
"two\r\n" +
"--test-boundary--\r\n";
var data = RequestBytes(
"POST",
$"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" +
$"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n",
body);
var packet = new HttpRequestPacket();
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
Assert.Equal("one", packet.PostForms["first"]);
Assert.Equal("two", packet.PostForms["second"]);
}
[Theory]
[InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")]
[InlineData("Transfer-Encoding: chunked\r\n")]
[InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")]
public void Response_RejectsAmbiguousOrUnsupportedFraming(string framingHeaders)
{
var data = Encoding.ASCII.GetBytes(
"HTTP/1.1 200 OK\r\n" + framingHeaders + "\r\nx");
Assert.Throws<InvalidDataException>(() =>
new HttpResponsePacket().Parse(data, 0, (uint)data.Length));
}
[Fact]
public void Cookie_RoundTripsSecureAndSameSiteAttributes()
{
var cookie = new HttpCookie("sid", "value")
{
Path = "/",
HttpOnly = true,
Secure = true,
SameSite = HttpCookieSameSite.Strict,
};
var serialized = cookie.ToString();
Assert.Contains("; Secure", serialized, StringComparison.Ordinal);
Assert.Contains("; SameSite=Strict", serialized, StringComparison.Ordinal);
var data = Encoding.ASCII.GetBytes(
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nSet-Cookie: " + serialized + "\r\n\r\n");
var response = new HttpResponsePacket();
Assert.Equal(data.Length, response.Parse(data, 0, (uint)data.Length));
Assert.True(response.Cookies[0].Secure);
Assert.Equal(HttpCookieSameSite.Strict, response.Cookies[0].SameSite);
}
[Fact]
public void Response_ComposeUsesAnExplicitOutboundLimitOnly()
{
var response = new HttpResponsePacket
{
MaximumContentLength = 1,
Message = new byte[] { 1, 2 },
};
Assert.True(response.Compose(HttpComposeOption.DataOnly));
Assert.Equal(response.Message, response.Data);
response.MaximumComposedContentLength = 1;
Assert.Throws<ParserLimitException>(() =>
response.Compose(HttpComposeOption.DataOnly));
}
[Fact]
public async Task Session_TimerStartsAndServerRemovesExpiredSession()
{
var server = new HttpServer();
var ended = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var session = server.CreateSession("expiring", 1);
session.OnEnd += _ => ended.TrySetResult(true);
await ended.Task.WaitAsync(TimeSpan.FromSeconds(3));
var sessions = (IDictionary)typeof(HttpServer)
.GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(server)!;
Assert.Empty(sessions);
// Destruction remains safe after the expiry callback has already disposed the timer.
session.Destroy();
}
[Fact]
public void Session_WithoutATimerCanBeDestroyedIdempotently()
{
var session = new HttpSession();
session.Destroy();
session.Destroy();
}
[Fact]
public void ExplicitlyDestroyedSession_IsRemovedFromServer()
{
var server = new HttpServer();
var session = server.CreateSession("destroyed", 0);
session.Destroy();
var sessions = (IDictionary)typeof(HttpServer)
.GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(server)!;
Assert.Empty(sessions);
}
[Fact]
public void InternalServerErrorPage_HtmlEncodesExceptionText()
{
var page = HttpConnection.FormatError500Page("<script>alert('x')</script>&");
Assert.DoesNotContain("<script>", page, StringComparison.OrdinalIgnoreCase);
Assert.Contains("&lt;script&gt;", page, StringComparison.Ordinal);
Assert.Contains("&amp;", page, StringComparison.Ordinal);
}
private static void AssertFormLimit(string body, Action<HttpRequestPacket> configure)
{
var data = RequestBytes(
"POST",
$"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n",
body);
var packet = new HttpRequestPacket();
configure(packet);
Assert.Throws<ParserLimitException>(() =>
packet.Parse(data, 0, (uint)data.Length));
}
private static byte[] RequestBytes(string method, string headers, string body)
=> Encoding.ASCII.GetBytes($"{method} / HTTP/1.1\r\n{headers}\r\n{body}");
}
+541
View File
@@ -0,0 +1,541 @@
using System.Net;
using System.Reflection;
using System.Text;
using Esiur.Core;
using Esiur.Data;
using Esiur.Net;
using Esiur.Net.Http;
using Esiur.Net.Packets.Http;
using Esiur.Net.Packets.WebSocket;
using Esiur.Net.Sockets;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
public class HttpWebSocketConnectionTests
{
[Fact]
public void Handshake_RequiresGetHttp11TokensVersionAndCanonical16ByteKey()
{
Assert.True(HttpConnection.IsWebsocketRequest(ValidHandshake()));
var request = ValidHandshake();
request.Method = Esiur.Net.Packets.Http.HttpMethod.POST;
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.Version = "HTTP/1.0";
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.RawMethod = "get";
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.Headers["Connection"] = "keep-alive, not-an-upgrade";
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.Headers["Upgrade"] = "notwebsocket";
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.Headers["Sec-WebSocket-Version"] = "12";
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.Headers["Sec-WebSocket-Key"] = "not-base64";
Assert.False(HttpConnection.IsWebsocketRequest(request));
request = ValidHandshake();
request.Headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[15]);
Assert.False(HttpConnection.IsWebsocketRequest(request));
}
[Fact]
public void Upgrade_SelectsOnlyAnExplicitlySupportedSubprotocol()
{
var request = ValidHandshake();
request.Headers["Sec-WebSocket-Protocol"] = "chat, superchat";
var response = new HttpResponsePacket();
response.Headers["Sec-WebSocket-Protocol"] = "untrusted";
Assert.True(HttpConnection.Upgrade(request, response));
Assert.Null(response.Headers["Sec-WebSocket-Protocol"]);
Assert.Equal("websocket", response.Headers["Upgrade"]);
Assert.Equal("Upgrade", response.Headers["Connection"]);
response = new HttpResponsePacket();
Assert.True(HttpConnection.Upgrade(
request,
response,
new[] { "superchat", "chat" },
out var selected));
Assert.Equal("superchat", selected);
Assert.Equal("superchat", response.Headers["Sec-WebSocket-Protocol"]);
}
[Fact]
public void MalformedAdvertisedUpgrade_IsRejectedBeforeFiltersOrRoutesRun()
{
var server = new HttpServer();
var (connection, socket, filter) = CreateHttpConnection(server);
var request = Encoding.ASCII.GetBytes(
"GET / HTTP/1.1\r\n" +
"Connection: Upgrade\r\n" +
"Upgrade: websocket\r\n" +
"Sec-WebSocket-Version: 13\r\n" +
"Sec-WebSocket-Key: invalid\r\n\r\n");
Receive(connection, socket, request);
Assert.Equal(SocketState.Closed, socket.State);
Assert.False(connection.WSMode);
Assert.Equal(0, filter.ExecutionCount);
Assert.StartsWith("HTTP/1.1 400 Bad Request", Encoding.ASCII.GetString(Assert.Single(socket.Sent)));
}
[Fact]
public void ValidUpgrade_UsesServerSubprotocolConfiguration()
{
var server = new HttpServer
{
WebSocketSubprotocols = new[] { "superchat" }
};
var (connection, socket, filter) = CreateHttpConnection(server);
var request = Encoding.ASCII.GetBytes(
"GET / HTTP/1.1\r\n" +
"Connection: keep-alive, Upgrade\r\n" +
"Upgrade: websocket\r\n" +
"Sec-WebSocket-Version: 13\r\n" +
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n" +
"Sec-WebSocket-Protocol: chat, superchat\r\n\r\n");
Receive(connection, socket, request);
Assert.Equal(SocketState.Established, socket.State);
Assert.True(connection.WSMode);
Assert.Equal("superchat", connection.WebSocketSubprotocol);
Assert.Equal(1, filter.ExecutionCount);
Assert.Contains(
"sec-websocket-protocol: superchat\r\n",
Encoding.ASCII.GetString(Assert.Single(socket.Sent)),
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ValidUpgrade_DrainsFramesCoalescedWithTheHttpHandshake()
{
var (connection, socket, filter) = CreateHttpConnection();
var handshake = Encoding.ASCII.GetBytes(
"GET / HTTP/1.1\r\n" +
"Connection: Upgrade\r\n" +
"Upgrade: websocket\r\n" +
"Sec-WebSocket-Version: 13\r\n" +
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n\r\n");
var input = Concat(
handshake,
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1 }),
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 2 }));
Receive(connection, socket, input);
Assert.True(connection.WSMode);
Assert.Equal(3, filter.ExecutionCount);
Assert.Collection(
filter.Messages,
message => Assert.Equal(new byte[] { 1 }, message.Payload),
message => Assert.Equal(new byte[] { 2 }, message.Payload));
}
[Fact]
public void BuiltInWebSocket_SendAlwaysRecomposesServerFramesAsUnmasked()
{
var (connection, socket, _) = CreateWebSocketConnection();
var packet = new WebsocketPacket
{
FIN = true,
Opcode = WebsocketPacket.WSOpcode.BinaryFrame,
Mask = true,
MaskKey = new byte[] { 1, 2, 3, 4 },
Message = new byte[] { 7, 8, 9 }
};
packet.Compose();
connection.Send(packet);
var sent = ParseServerFrame(Assert.Single(socket.Sent));
Assert.False(sent.Mask);
Assert.Equal(new byte[] { 7, 8, 9 }, sent.Message);
}
[Fact]
public void BuiltInWebSocket_ReassemblesMessagesHandlesPingAndDrainsCoalescedFrames()
{
var (connection, socket, filter) = CreateWebSocketConnection();
var frames = Concat(
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, Encoding.UTF8.GetBytes("hel")),
Frame(WebsocketPacket.WSOpcode.Ping, true, true, Encoding.ASCII.GetBytes("p")),
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, Encoding.UTF8.GetBytes("lo")),
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1, 2 }));
Receive(connection, socket, frames);
Assert.Equal(SocketState.Established, socket.State);
Assert.Collection(
filter.Messages,
message =>
{
Assert.Equal(WebsocketPacket.WSOpcode.TextFrame, message.Opcode);
Assert.Equal("hello", Encoding.UTF8.GetString(message.Payload));
},
message =>
{
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, message.Opcode);
Assert.Equal(new byte[] { 1, 2 }, message.Payload);
});
var pong = Assert.Single(socket.Sent);
var parsedPong = ParseServerFrame(pong);
Assert.Equal(WebsocketPacket.WSOpcode.Pong, parsedPong.Opcode);
Assert.Equal("p", Encoding.ASCII.GetString(parsedPong.Message));
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, connection.WSRequest.Opcode);
}
[Fact]
public void BuiltInWebSocket_RetainsAnIncompleteFrameWithoutLosingTheNextFrame()
{
var (connection, socket, filter) = CreateWebSocketConnection();
var first = Frame(
WebsocketPacket.WSOpcode.BinaryFrame,
true,
true,
new byte[] { 1, 2, 3, 4 });
var second = Frame(
WebsocketPacket.WSOpcode.BinaryFrame,
true,
true,
new byte[] { 5, 6 });
var buffer = new NetworkBuffer();
var split = first.Length - 2;
buffer.Write(first, 0, (uint)split);
connection.NetworkReceive(socket, buffer);
Assert.True(buffer.Protected);
Assert.Empty(filter.Messages);
buffer.Write(Concat(first.Skip(split).ToArray(), second));
connection.NetworkReceive(socket, buffer);
Assert.False(buffer.Protected);
Assert.Collection(
filter.Messages,
message => Assert.Equal(new byte[] { 1, 2, 3, 4 }, message.Payload),
message => Assert.Equal(new byte[] { 5, 6 }, message.Payload));
}
[Fact]
public void BuiltInWebSocket_RejectsUnmaskedClientFramesWithProtocolClose()
{
var (connection, socket, filter) = CreateWebSocketConnection();
Receive(connection, socket, Frame(
WebsocketPacket.WSOpcode.BinaryFrame,
true,
false,
new byte[] { 1 }));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Empty(filter.Messages);
AssertCloseCode(socket, 1002);
}
[Fact]
public void BuiltInWebSocket_RejectsInvalidUtf8AcrossFragments()
{
var (connection, socket, filter) = CreateWebSocketConnection();
Receive(connection, socket, Concat(
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, new byte[] { 0xC3 }),
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 0x28 })));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Empty(filter.Messages);
AssertCloseCode(socket, 1007);
}
[Fact]
public void BuiltInWebSocket_EnforcesAggregateFragmentLimit()
{
var server = new HttpServer { MaximumWebSocketMessageLength = 4 };
var (connection, socket, filter) = CreateWebSocketConnection(server);
Receive(connection, socket, Concat(
Frame(WebsocketPacket.WSOpcode.BinaryFrame, false, true, new byte[] { 1, 2, 3 }),
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 4, 5 })));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Empty(filter.Messages);
AssertCloseCode(socket, 1009);
}
[Fact]
public void BuiltInWebSocket_EchoesAValidClosePayloadThenCloses()
{
var (connection, socket, _) = CreateWebSocketConnection();
var payload = new byte[] { 0x03, 0xE8 };
Receive(connection, socket, Frame(
WebsocketPacket.WSOpcode.ConnectionClose,
true,
true,
payload));
Assert.Equal(SocketState.Closed, socket.State);
var close = ParseServerFrame(Assert.Single(socket.Sent));
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
Assert.Equal(payload, close.Message);
}
[Fact]
public void BuiltInWebSocket_WaitsForCloseFrameSendBeforeClosingTransport()
{
var pendingSend = new AsyncReply<bool>();
var server = new HttpServer();
var filter = new CaptureFilter();
SetFilters(server, filter);
var socket = new TestSocket(pendingSend);
var connection = new HttpConnection { Server = server, WSMode = true };
connection.Assign(socket);
Receive(connection, socket, Frame(
WebsocketPacket.WSOpcode.ConnectionClose,
true,
true,
new byte[] { 0x03, 0xE8 }));
Assert.Equal(SocketState.Established, socket.State);
Assert.Single(socket.Sent);
pendingSend.Trigger(true);
Assert.Equal(SocketState.Closed, socket.State);
}
[Fact]
public void ExceptionDetails_AreGenericByDefaultAndEncodedWhenOptedIn()
{
var server = new HttpServer();
var connection = new HttpConnection { Server = server };
var exception = new InvalidOperationException("<script>secret</script>");
var genericPage = connection.FormatError500Page(exception);
Assert.Contains("An internal server error occurred.", genericPage);
Assert.DoesNotContain("secret", genericPage, StringComparison.Ordinal);
server.ExposeExceptionDetails = true;
var detailedPage = connection.FormatError500Page(exception);
Assert.Contains("&lt;script&gt;secret&lt;/script&gt;", detailedPage);
Assert.DoesNotContain("<script>", detailedPage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SessionLookup_RestoresRefreshesAndStopsReturningDestroyedSessions()
{
var server = new HttpServer();
var session = server.CreateSession("known-session", 0);
var initialAction = session.LastAction;
var request = new HttpRequestPacket { Cookies = new StringKeyList() };
request.Cookies["SID"] = session.Id;
var connection = new HttpConnection
{
Server = server,
Request = request
};
Assert.True(server.TryGetSession(session.Id, out var found));
Assert.Same(session, found);
connection.RestoreSessionFromRequest();
Assert.Same(session, connection.Session);
Assert.True(session.LastAction >= initialAction);
session.Destroy();
Assert.False(server.TryGetSession(session.Id, out _));
connection.RestoreSessionFromRequest();
Assert.Null(connection.Session);
}
private static HttpRequestPacket ValidHandshake()
{
var headers = new StringKeyList();
headers["Connection"] = "keep-alive, Upgrade";
headers["Upgrade"] = "websocket";
headers["Sec-WebSocket-Version"] = "13";
headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[16]);
return new HttpRequestPacket
{
Method = Esiur.Net.Packets.Http.HttpMethod.GET,
Version = "HTTP/1.1",
Headers = headers,
URL = "/"
};
}
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
CreateWebSocketConnection(HttpServer? server = null)
{
var result = CreateHttpConnection(server);
result.Connection.WSMode = true;
return result;
}
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
CreateHttpConnection(HttpServer? server = null)
{
server ??= new HttpServer();
var filter = new CaptureFilter();
SetFilters(server, filter);
var socket = new TestSocket();
var connection = new HttpConnection
{
Server = server
};
connection.Assign(socket);
return (connection, socket, filter);
}
private static void SetFilters(HttpServer server, params HttpFilter[] filters)
=> typeof(HttpServer)
.GetField("filters", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(server, filters);
private static void Receive(
HttpConnection connection,
TestSocket socket,
byte[] message)
{
var buffer = new NetworkBuffer();
buffer.Write(message);
connection.NetworkReceive(socket, buffer);
}
private static byte[] Frame(
WebsocketPacket.WSOpcode opcode,
bool final,
bool masked,
byte[] payload)
{
var packet = new WebsocketPacket
{
FIN = final,
Opcode = opcode,
Mask = masked,
MaskKey = new byte[] { 1, 2, 3, 4 },
Message = payload
};
packet.Compose();
return packet.Data;
}
private static byte[] Concat(params byte[][] messages)
{
var length = messages.Sum(message => message.Length);
var result = new byte[length];
var offset = 0;
foreach (var message in messages)
{
Buffer.BlockCopy(message, 0, result, offset, message.Length);
offset += message.Length;
}
return result;
}
private static WebsocketPacket ParseServerFrame(byte[] data)
{
var packet = new WebsocketPacket { ExpectedMask = false };
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
return packet;
}
private static void AssertCloseCode(TestSocket socket, int expected)
{
var close = ParseServerFrame(Assert.Single(socket.Sent));
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
Assert.Equal(expected, close.Message[0] << 8 | close.Message[1]);
}
private sealed class CaptureFilter : HttpFilter
{
public List<(WebsocketPacket.WSOpcode Opcode, byte[] Payload)> Messages { get; } = new();
public int ExecutionCount { get; private set; }
public override AsyncReply<bool> Execute(HttpConnection sender)
{
ExecutionCount++;
var packet = sender.WSRequest;
if (packet != null)
Messages.Add((packet.Opcode, packet.Message.ToArray()));
return new AsyncReply<bool>(true);
}
public override AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null) => new(true);
}
private sealed class TestSocket : ISocket
{
private readonly AsyncReply<bool>? sendReply;
public TestSocket(AsyncReply<bool>? sendReply = null)
=> this.sendReply = sendReply;
public event DestroyedEvent? OnDestroy;
public SocketState State { get; private set; } = SocketState.Established;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
public List<byte[]> Sent { get; } = new();
public void Send(byte[] message) => Send(message, 0, message.Length);
public void Send(byte[] message, int offset, int length)
{
var copy = new byte[length];
Buffer.BlockCopy(message, offset, copy, 0, length);
Sent.Add(copy);
}
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
{
Send(message, offset, length);
return sendReply ?? new AsyncReply<bool>(true);
}
public void Close()
{
if (State == SocketState.Closed)
return;
State = SocketState.Closed;
Receiver?.NetworkClose(this);
}
public void Destroy()
{
Close();
OnDestroy?.Invoke(this);
OnDestroy = null;
}
public AsyncReply<bool> Connect(string hostname, ushort port) => new(false);
public bool Begin() => true;
public AsyncReply<bool> BeginAsync() => new(true);
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
public ISocket Accept() => null!;
public void Hold() { }
public void Unhold() { }
}
}
+10 -8
View File
@@ -114,14 +114,17 @@ internal sealed class IntegrationCluster : IAsyncDisposable
bool allowEncryption = true,
bool oneStepAuthentication = false,
bool useWebSocket = false,
bool mismatchedSessionKeys = false)
bool mismatchedSessionKeys = false,
bool allowAuthentication = true,
bool registerServerAuthenticationProvider = true)
{
var port = Interlocked.Increment(ref _portCounter);
var serverWh = new Warehouse();
serverWh.RegisterAuthenticationProvider(oneStepAuthentication
? new OneStepAuthenticationProvider()
: new TestServerAuthProvider());
if (registerServerAuthenticationProvider)
serverWh.RegisterAuthenticationProvider(oneStepAuthentication
? new OneStepAuthenticationProvider()
: new TestServerAuthProvider());
if (encrypted || requireEncryption)
serverWh.RegisterEncryptionProvider(new AesEncryptionProvider());
@@ -129,10 +132,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
var server = await serverWh.Put("sys/server", new EpServer
{
Port = (ushort)port,
AllowedAuthenticationProviders = new[]
{
oneStepAuthentication ? "one-step" : "hash",
},
AllowedAuthenticationProviders = allowAuthentication
? new[] { oneStepAuthentication ? "one-step" : "hash" }
: Array.Empty<string>(),
AllowedEncryptionProviders = (encrypted || requireEncryption) && allowEncryption
? new[] { AesEncryptionProvider.Name }
: Array.Empty<string>(),
@@ -70,6 +70,32 @@ public class SessionHeadersIntegrationTests
.WaitAsync(TimeSpan.FromSeconds(10)));
}
[Fact]
public async Task DisallowedAuthenticationProvider_FailsClosedPromptly()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await IntegrationCluster
.StartAsync(
_ => Task.CompletedTask,
allowAuthentication: false)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
}
[Fact]
public async Task AllowlistedButUnregisteredAuthenticationProvider_FailsClosedPromptly()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await IntegrationCluster
.StartAsync(
_ => Task.CompletedTask,
registerServerAuthenticationProvider: false)
.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.IsNotType<TimeoutException>(exception);
}
[Fact]
public async Task AddressBoundEncryption_DerivesMatchingCiphersAcrossPeers()
{
+122
View File
@@ -0,0 +1,122 @@
using Esiur.Net;
namespace Esiur.Tests.Unit;
public class NetworkBufferTests
{
[Fact]
public void FragmentedWritesAreConcatenatedAndReadResetsState()
{
var buffer = new NetworkBuffer();
buffer.Write(new byte[] { 1, 2 });
buffer.Write(new byte[] { 99, 3, 4, 100 }, 1, 2);
Assert.Equal(4u, buffer.Available);
Assert.True(buffer.CanRead);
Assert.False(buffer.Protected);
Assert.Equal(new byte[] { 1, 2, 3, 4 }, buffer.Read());
Assert.Equal(0u, buffer.Available);
Assert.False(buffer.CanRead);
Assert.False(buffer.Protected);
Assert.Null(buffer.Read());
}
[Fact]
public void HoldForRetainsPartialPacketUntilThreshold()
{
var buffer = new NetworkBuffer();
buffer.HoldFor(new byte[] { 1, 2 }, 5);
Assert.True(buffer.Protected);
Assert.False(buffer.CanRead);
Assert.Null(buffer.Read());
buffer.Write(new byte[] { 3, 4 });
Assert.True(buffer.Protected);
Assert.Null(buffer.Read());
buffer.Write(new byte[] { 5 });
Assert.False(buffer.Protected);
Assert.True(buffer.CanRead);
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, buffer.Read());
}
[Fact]
public void HoldForPrependsPartialPacketToBufferedData()
{
var buffer = new NetworkBuffer();
buffer.Write(new byte[] { 3, 4 });
buffer.HoldFor(new byte[] { 0, 1, 2, 9 }, 1, 2, 5);
buffer.Write(new byte[] { 5 });
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, buffer.Read());
}
[Fact]
public void ProtectRetainsOnlyTheRemainingSlice()
{
var buffer = new NetworkBuffer();
var source = new byte[] { 99, 1, 2 };
Assert.True(buffer.Protect(source, 1, 4));
Assert.Equal(2u, buffer.Available);
Assert.True(buffer.Protected);
buffer.Write(new byte[] { 3, 4 });
Assert.Equal(new byte[] { 1, 2, 3, 4 }, buffer.Read());
}
[Fact]
public void ProtectDoesNotRetainACompleteSlice()
{
var buffer = new NetworkBuffer();
Assert.False(buffer.Protect(new byte[] { 0, 1, 2, 3 }, 1, 3));
Assert.Equal(0u, buffer.Available);
Assert.Null(buffer.Read());
}
[Fact]
public void InvalidRangesAndImpossibleThresholdsAreRejected()
{
var buffer = new NetworkBuffer();
var source = new byte[4];
Assert.Throws<ArgumentNullException>(() => buffer.Write(null!));
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Write(source, 5, 0));
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Write(source, 3, 2));
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Protect(source, 5, 6));
Assert.Throws<ArgumentOutOfRangeException>(() => buffer.Protect(source, 0, uint.MaxValue));
Assert.Throws<Exception>(() => buffer.HoldFor(source, 0, 2, 2));
}
[Fact]
public void FragmentedGrowthHasLinearAllocationBound()
{
const int payloadLength = 512 * 1024;
const int chunkLength = 1024;
var chunk = new byte[chunkLength];
// Warm up the methods used below so JIT allocation is outside the measurement.
var warmup = new NetworkBuffer();
warmup.Write(chunk);
warmup.Write(chunk);
_ = warmup.Read();
var before = GC.GetAllocatedBytesForCurrentThread();
var buffer = new NetworkBuffer();
for (var offset = 0; offset < payloadLength; offset += chunkLength)
buffer.Write(chunk);
var result = buffer.Read();
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.NotNull(result);
Assert.Equal(payloadLength, result.Length);
Assert.True(
allocated < payloadLength * 8L,
$"Fragmented buffering allocated {allocated:N0} bytes for a {payloadLength:N0}-byte payload.");
}
}
@@ -0,0 +1,311 @@
using System.Collections.Concurrent;
using System.Net;
using Esiur.Core;
using Esiur.Data;
using Esiur.Net;
using Esiur.Net.Sockets;
namespace Esiur.Tests.Unit;
public class NetworkLifecycleConcurrencyTests
{
[Fact]
public async Task NetworkConnection_DrainsDistinctBuffersQueuedWhileParserIsBusy()
{
using var parserEntered = new ManualResetEventSlim();
using var releaseParser = new ManualResetEventSlim();
var connection = new BlockingConnection(parserEntered, releaseParser);
var socket = new TestSocket();
connection.Assign(socket);
var firstBuffer = BufferWith(1);
var secondBuffer = BufferWith(2);
var firstReceive = Task.Run(() => connection.NetworkReceive(socket, firstBuffer));
try
{
Assert.True(parserEntered.Wait(TimeSpan.FromSeconds(2)));
var secondReceive = Task.Run(() => connection.NetworkReceive(socket, secondBuffer));
Assert.Same(secondReceive, await Task.WhenAny(
secondReceive,
Task.Delay(TimeSpan.FromSeconds(2))));
releaseParser.Set();
var bothReceives = Task.WhenAll(firstReceive, secondReceive);
Assert.Same(bothReceives, await Task.WhenAny(
bothReceives,
Task.Delay(TimeSpan.FromSeconds(2))));
await bothReceives;
Assert.Equal(new byte[] { 1, 2 }, connection.Received.ToArray());
Assert.Equal(0u, secondBuffer.Available);
}
finally
{
releaseParser.Set();
await firstReceive;
}
}
[Fact]
public async Task NetworkConnection_DrainsNewSocketBufferAfterReplacementDuringReceive()
{
using var parserEntered = new ManualResetEventSlim();
using var releaseParser = new ManualResetEventSlim();
var connection = new BlockingConnection(parserEntered, releaseParser);
var oldSocket = new TestSocket();
var newSocket = new TestSocket();
connection.Assign(oldSocket);
var firstReceive = Task.Run(() =>
connection.NetworkReceive(oldSocket, BufferWith(1)));
try
{
Assert.True(parserEntered.Wait(TimeSpan.FromSeconds(2)));
var replacementBuffer = BufferWith(2);
var replacementReceive = Task.Run(() =>
{
Assert.Same(oldSocket, connection.Unassign());
connection.Assign(newSocket);
connection.NetworkReceive(newSocket, replacementBuffer);
});
Assert.Same(replacementReceive, await Task.WhenAny(
replacementReceive,
Task.Delay(TimeSpan.FromSeconds(2))));
releaseParser.Set();
var bothReceives = Task.WhenAll(firstReceive, replacementReceive);
Assert.Same(bothReceives, await Task.WhenAny(
bothReceives,
Task.Delay(TimeSpan.FromSeconds(2))));
await bothReceives;
Assert.Equal(new byte[] { 1, 2 }, connection.Received.ToArray());
Assert.Equal(0u, replacementBuffer.Available);
}
finally
{
releaseParser.Set();
await firstReceive;
}
}
[Fact]
public void NetworkConnection_IgnoresCallbacksFromUnassignedSocket()
{
var connection = new RecordingConnection();
var staleSocket = new TestSocket();
var currentSocket = new TestSocket();
var connectEvents = 0;
var closeEvents = 0;
connection.OnConnect += _ => connectEvents++;
connection.OnClose += _ => closeEvents++;
connection.Assign(staleSocket);
Assert.Same(staleSocket, connection.Unassign());
connection.Assign(currentSocket);
var staleBuffer = BufferWith(9);
connection.NetworkReceive(staleSocket, staleBuffer);
connection.NetworkConnect(staleSocket);
connection.NetworkClose(staleSocket);
Assert.Equal(1u, staleBuffer.Available);
Assert.Empty(connection.Received);
Assert.Equal(0, connection.ConnectedCalls);
Assert.Equal(0, connection.DisconnectedCalls);
Assert.Equal(0, connectEvents);
Assert.Equal(0, closeEvents);
connection.NetworkConnect(currentSocket);
connection.NetworkReceive(currentSocket, BufferWith(1));
connection.NetworkClose(currentSocket);
Assert.Equal(new byte[] { 1 }, connection.Received.ToArray());
Assert.Equal(1, connection.ConnectedCalls);
Assert.Equal(1, connection.DisconnectedCalls);
Assert.Equal(1, connectEvents);
Assert.Equal(1, closeEvents);
}
[Fact]
public void NetworkServer_ClosesSocketAcceptedAfterStopSnapshot()
{
var acceptedSocket = new TestSocket();
using var listener = new BlockingListener(acceptedSocket);
var server = new TestServer();
try
{
server.Start(listener);
Assert.True(listener.AcceptEntered.Wait(TimeSpan.FromSeconds(2)));
server.Stop();
listener.ReleaseAccept.Set();
Assert.True(SpinWait.SpinUntil(
() => acceptedSocket.State == SocketState.Closed,
TimeSpan.FromSeconds(2)));
Assert.Empty(server.Connections);
Assert.Equal(0, Volatile.Read(ref acceptedSocket.BeginCalls));
Assert.Equal(0, Volatile.Read(ref server.ClientConnectedCalls));
}
finally
{
listener.ReleaseAccept.Set();
server.Destroy();
}
}
private static NetworkBuffer BufferWith(byte value)
{
var buffer = new NetworkBuffer();
buffer.Write(new[] { value });
return buffer;
}
private class RecordingConnection : NetworkConnection
{
public ConcurrentQueue<byte> Received { get; } = new();
public int ConnectedCalls;
public int DisconnectedCalls;
protected override void DataReceived(NetworkBuffer buffer)
{
var data = buffer.Read();
if (data == null)
return;
foreach (var value in data)
Received.Enqueue(value);
}
protected override void Connected()
=> Interlocked.Increment(ref ConnectedCalls);
protected override void Disconnected()
=> Interlocked.Increment(ref DisconnectedCalls);
}
private sealed class BlockingConnection : RecordingConnection
{
private readonly ManualResetEventSlim parserEntered;
private readonly ManualResetEventSlim releaseParser;
private int blocked;
public BlockingConnection(
ManualResetEventSlim parserEntered,
ManualResetEventSlim releaseParser)
{
this.parserEntered = parserEntered;
this.releaseParser = releaseParser;
}
protected override void DataReceived(NetworkBuffer buffer)
{
base.DataReceived(buffer);
if (Interlocked.CompareExchange(ref blocked, 1, 0) == 0)
{
parserEntered.Set();
releaseParser.Wait(TimeSpan.FromSeconds(5));
}
}
}
private sealed class ServerConnection : NetworkConnection
{
protected override void DataReceived(NetworkBuffer buffer) { }
protected override void Connected() { }
protected override void Disconnected() { }
}
private sealed class TestServer : NetworkServer<ServerConnection>
{
public int ClientConnectedCalls;
protected override void ClientConnected(ServerConnection connection)
=> Interlocked.Increment(ref ClientConnectedCalls);
protected override void ClientDisconnected(ServerConnection connection) { }
}
private class TestSocket : ISocket
{
public event DestroyedEvent? OnDestroy;
public SocketState State { get; protected set; } = SocketState.Established;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
public int BeginCalls;
public void Close()
{
if (State == SocketState.Closed)
return;
State = SocketState.Closed;
Receiver?.NetworkClose(this);
}
public void Destroy()
{
Close();
OnDestroy?.Invoke(this);
OnDestroy = null;
}
public bool Begin()
{
Interlocked.Increment(ref BeginCalls);
return State == SocketState.Established;
}
public AsyncReply<bool> BeginAsync() => new(Begin());
public AsyncReply<bool> Connect(string hostname, ushort port) => new(true);
public virtual ISocket Accept() => null!;
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
public void Send(byte[] message) { }
public void Send(byte[] message, int offset, int length) { }
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) => new(true);
public void Hold() { }
public void Unhold() { }
}
private sealed class BlockingListener : TestSocket, IDisposable
{
private readonly ISocket acceptedSocket;
private int acceptCalls;
public BlockingListener(ISocket acceptedSocket)
{
this.acceptedSocket = acceptedSocket;
State = SocketState.Listening;
}
public ManualResetEventSlim AcceptEntered { get; } = new();
public ManualResetEventSlim ReleaseAccept { get; } = new();
public override ISocket Accept()
{
if (Interlocked.Increment(ref acceptCalls) != 1)
return null!;
AcceptEntered.Set();
ReleaseAccept.Wait(TimeSpan.FromSeconds(5));
return acceptedSocket;
}
public void Dispose()
{
ReleaseAccept.Set();
AcceptEntered.Dispose();
ReleaseAccept.Dispose();
}
}
}
+155
View File
@@ -0,0 +1,155 @@
using Esiur.Data;
using Esiur.Net.Packets;
using Esiur.Net.Packets.Http;
using Esiur.Net.Packets.WebSocket;
using Esiur.Resource;
using System.Text;
namespace Esiur.Tests.Unit;
public class PacketCodecTests
{
[Fact]
public void EpParser_ValidatesSliceBounds()
{
var packet = new EpAuthPacket(new Warehouse());
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Parse(Array.Empty<byte>(), 1, 0));
Assert.Throws<ArgumentOutOfRangeException>(() => packet.Parse(Array.Empty<byte>(), 0, 1));
}
[Fact]
public void WebSocket_ComposesAndParsesUnmaskedFrame()
{
var packet = new WebsocketPacket
{
FIN = true,
Opcode = WebsocketPacket.WSOpcode.BinaryFrame,
Message = new byte[] { 1, 2, 3 }
};
Assert.True(packet.Compose());
Assert.Equal(new byte[] { 0x82, 0x03, 1, 2, 3 }, packet.Data);
var parsed = new WebsocketPacket();
Assert.Equal(packet.Data.Length, parsed.Parse(packet.Data, 0, (uint)packet.Data.Length));
Assert.Equal(packet.Message, parsed.Message);
}
[Fact]
public void WebSocket_MasksOutboundPayloadAndRestoresItWhenParsed()
{
var message = Encoding.UTF8.GetBytes("hello");
var packet = new WebsocketPacket
{
FIN = true,
Mask = true,
MaskKey = new byte[] { 1, 2, 3, 4 },
Opcode = WebsocketPacket.WSOpcode.TextFrame,
Message = message
};
packet.Compose();
Assert.Equal(0x81, packet.Data[0]);
Assert.Equal(0x85, packet.Data[1]);
Assert.Equal(packet.MaskKey, packet.Data.Skip(2).Take(4));
Assert.NotEqual(message, packet.Data.Skip(6).ToArray());
var parsed = new WebsocketPacket();
Assert.Equal(packet.Data.Length, parsed.Parse(packet.Data, 0, (uint)packet.Data.Length));
Assert.Equal(message, parsed.Message);
}
[Fact]
public void WebSocket_RejectsOversizedDeclarationBeforePayloadArrives()
{
var packet = new WebsocketPacket { MaximumPayloadLength = 1_024 };
var header = new byte[] { 0x82, 126, 0x04, 0x01 };
Assert.Throws<ParserLimitException>(() => packet.Parse(header, 0, (uint)header.Length));
}
[Fact]
public void HttpRequest_ParsesOffsetBodyQueryCookiesAndForm()
{
const string request =
"POST /submit?a=1 HTTP/1.1\r\n" +
"Host: example.test\r\n" +
"Cookie: sid=abc; flag\r\n" +
"Content-Type: application/x-www-form-urlencoded\r\n" +
"Content-Length: 7\r\n\r\n" +
"x=hello";
var packetBytes = Encoding.ASCII.GetBytes(request);
var data = new byte[packetBytes.Length + 2];
Buffer.BlockCopy(packetBytes, 0, data, 2, packetBytes.Length);
var packet = new HttpRequestPacket();
var consumed = packet.Parse(data, 2, (uint)data.Length);
Assert.Equal(packetBytes.Length, consumed);
Assert.Equal(Esiur.Net.Packets.Http.HttpMethod.POST, packet.Method);
Assert.Equal("/submit", packet.Filename);
Assert.Equal("1", packet.Query["A"]);
Assert.Equal("abc", packet.Cookies["SID"]);
Assert.Equal(string.Empty, packet.Cookies["flag"]);
Assert.Equal("hello", packet.PostForms["x"]);
}
[Fact]
public void HttpRequest_RejectsOversizedContentBeforeBuffering()
{
var data = Encoding.ASCII.GetBytes(
"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n");
var packet = new HttpRequestPacket { MaximumContentLength = 4 };
Assert.Throws<ParserLimitException>(() => packet.Parse(data, 0, (uint)data.Length));
}
[Fact]
public void HttpResponse_ParsesBodyFromHeaderBoundaryAndUsesNegativeMissingCount()
{
var complete = Encoding.ASCII.GetBytes(
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nSet-Cookie: sid=abc; Path=/; HttpOnly\r\n\r\nhello");
var prefixed = new byte[complete.Length + 2];
Buffer.BlockCopy(complete, 0, prefixed, 2, complete.Length);
var packet = new HttpResponsePacket();
Assert.Equal(complete.Length, packet.Parse(prefixed, 2, (uint)prefixed.Length));
Assert.Equal("hello", Encoding.ASCII.GetString(packet.Message));
Assert.Single(packet.Cookies);
Assert.True(packet.Cookies[0].HttpOnly);
var incomplete = Encoding.ASCII.GetBytes(
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhe");
Assert.Equal(-3, new HttpResponsePacket().Parse(incomplete, 0, (uint)incomplete.Length));
}
[Fact]
public void HttpResponse_ComposeCalculatesLengthAndAppendsBody()
{
var packet = new HttpResponsePacket
{
Text = "OK",
Message = Encoding.ASCII.GetBytes("hello")
};
packet.Compose(HttpComposeOption.AllCalculateLength);
var response = Encoding.ASCII.GetString(packet.Data);
Assert.Contains("Content-Length: 5\r\n", response, StringComparison.OrdinalIgnoreCase);
Assert.EndsWith("\r\n\r\nhello", response, StringComparison.Ordinal);
}
[Fact]
public void StringKeyList_LookupsRemainCaseInsensitiveWithoutDuplicates()
{
var values = new StringKeyList();
values.Add("Content-Type", "text/plain");
values["CONTENT-TYPE"] = "application/json";
Assert.Equal(1, values.Count);
Assert.Equal("application/json", values["content-type"]);
Assert.True(values.ContainsKey("Content-Type"));
}
}
+71
View File
@@ -1,5 +1,6 @@
using Esiur.Data;
using Esiur.Net.Packets;
using Esiur.Protocol;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
@@ -52,4 +53,74 @@ public class ParserSecurityTests
Assert.Throws<ParserLimitException>(() => Codec.ParseSync(data, 0, warehouse));
}
[Fact]
public void TruMetadataParser_EnforcesConfiguredDepthInSyncParsing()
{
var warehouse = new Warehouse();
warehouse.Configuration.Parser.MaximumTypeMetadataDepth = 3;
var accepted = ParsedTdu.ParseSync(
ComposeTypedTdu(ComposeNestedTypeMetadata(3)),
0,
5,
warehouse);
Assert.Equal(TduClass.Typed, accepted.Class);
Assert.Throws<ParserLimitException>(() => ParsedTdu.ParseSync(
ComposeTypedTdu(ComposeNestedTypeMetadata(4)),
0,
6,
warehouse));
}
[Fact]
public async Task TruMetadataParser_EnforcesConnectionWarehouseDepthInAsyncParsing()
{
var warehouse = new Warehouse();
warehouse.Configuration.Parser.MaximumTypeMetadataDepth = 2;
var connection = new EpConnection();
await connection.Handle(
ResourceOperation.Configure,
new EpServerConnectionContext
{
Server = new EpServer(),
Warehouse = warehouse,
});
try
{
var data = ComposeTypedTdu(ComposeNestedTypeMetadata(3));
await Assert.ThrowsAsync<ParserLimitException>(async () =>
await ParsedTdu.ParseAsync(data, connection));
}
finally
{
connection.Destroy();
}
}
private static byte[] ComposeNestedTypeMetadata(int depth)
{
if (depth < 1)
throw new ArgumentOutOfRangeException(nameof(depth));
var metadata = new byte[depth];
Array.Fill(metadata, (byte)TruIdentifier.TypedList, 0, depth - 1);
metadata[^1] = (byte)TruIdentifier.Bool;
return metadata;
}
private static byte[] ComposeTypedTdu(byte[] metadata)
{
if (metadata.Length > byte.MaxValue)
throw new ArgumentOutOfRangeException(nameof(metadata));
var data = new byte[metadata.Length + 2];
data[0] = 0x88; // Typed TDU with a one-byte payload-length prefix.
data[1] = (byte)metadata.Length;
Buffer.BlockCopy(metadata, 0, data, 2, metadata.Length);
return data;
}
}
+423
View File
@@ -0,0 +1,423 @@
using System.Net;
using System.Net.Sockets;
using Esiur.Core;
using Esiur.Data;
using Esiur.Net;
using Esiur.Net.Packets.WebSocket;
using Esiur.Net.Sockets;
namespace Esiur.Tests.Unit;
public class SocketProtocolSecurityTests
{
[Fact]
public void NetworkServer_DoesNotBlockAcceptLoopOnSocketInitialization()
{
var neverCompletes = new AsyncReply<bool>();
var stalled = new TestSocket(neverCompletes);
var ready = new TestSocket(new AsyncReply<bool>(true));
var listener = new TestListener(stalled, ready);
var server = new TestServer
{
ConnectionInitializationTimeout = TimeSpan.FromMilliseconds(100)
};
try
{
server.Start(listener);
Assert.True(SpinWait.SpinUntil(
() => Volatile.Read(ref ready.BeginCalls) == 1,
TimeSpan.FromSeconds(2)));
Assert.True(SpinWait.SpinUntil(
() => stalled.State == SocketState.Closed,
TimeSpan.FromSeconds(2)));
Assert.Equal(SocketState.Established, ready.State);
}
finally
{
server.Destroy();
}
}
[Fact]
public void WebSocketServer_RejectsUnmaskedClientFrames()
{
var transport = new TestSocket();
var socket = new WSocket(transport, isServer: true)
{
Receiver = new TestReceiver()
};
var frame = ComposeFrame(
WebsocketPacket.WSOpcode.BinaryFrame,
final: true,
masked: false,
new byte[] { 1, 2, 3 });
var input = new NetworkBuffer();
input.Write(frame);
socket.NetworkReceive(transport, input);
Assert.Equal(SocketState.Closed, transport.State);
}
[Fact]
public void WebSocket_ReassemblesFragmentsBeforeDeliveringThem()
{
var transport = new TestSocket();
var receiver = new TestReceiver();
var socket = new WSocket(transport, isServer: true)
{
Receiver = receiver
};
var first = ComposeFrame(
WebsocketPacket.WSOpcode.BinaryFrame,
final: false,
masked: true,
new byte[] { 1, 2 });
var second = ComposeFrame(
WebsocketPacket.WSOpcode.ContinuationFrame,
final: true,
masked: true,
new byte[] { 3, 4 });
var input = new NetworkBuffer();
input.Write(first.Concat(second).ToArray());
socket.NetworkReceive(transport, input);
Assert.Equal(SocketState.Established, transport.State);
Assert.Single(receiver.Messages);
Assert.Equal(new byte[] { 1, 2, 3, 4 }, receiver.Messages[0]);
}
[Fact]
public void WebSocket_RejectsInvalidUtf8AcrossFragments()
{
var transport = new TestSocket();
var receiver = new TestReceiver();
var socket = new WSocket(transport, isServer: true)
{
Receiver = receiver
};
var first = ComposeFrame(
WebsocketPacket.WSOpcode.TextFrame,
final: false,
masked: true,
new byte[] { 0xC3 });
var second = ComposeFrame(
WebsocketPacket.WSOpcode.ContinuationFrame,
final: true,
masked: true,
new byte[] { 0x28 });
var input = new NetworkBuffer();
input.Write(first.Concat(second).ToArray());
socket.NetworkReceive(transport, input);
Assert.Equal(SocketState.Closed, transport.State);
Assert.Empty(receiver.Messages);
}
[Fact]
public void WebSocketClient_MasksEveryOutgoingFrameWithAFreshKey()
{
var transport = new TestSocket();
var socket = new WSocket(transport, isServer: false);
socket.Send(new byte[] { 5, 6, 7 });
socket.Send(new byte[] { 8, 9 });
Assert.Equal(2, transport.Sent.Count);
var frame = transport.Sent[0];
var secondFrame = transport.Sent[1];
Assert.False(
frame.Skip(2).Take(4)
.SequenceEqual(secondFrame.Skip(2).Take(4)),
"Each client frame must use a fresh masking key.");
var parsed = new WebsocketPacket { ExpectedMask = true };
Assert.Equal(frame.Length, parsed.Parse(frame, 0, (uint)frame.Length));
Assert.Equal(new byte[] { 5, 6, 7 }, parsed.Message);
parsed = new WebsocketPacket { ExpectedMask = true };
Assert.Equal(
secondFrame.Length,
parsed.Parse(secondFrame, 0, (uint)secondFrame.Length));
Assert.Equal(new byte[] { 8, 9 }, parsed.Message);
}
[Fact]
public void WebSocket_MessageLimitAlsoAppliesToUnfragmentedFrames()
{
var transport = new TestSocket();
var socket = new WSocket(transport, isServer: true)
{
Receiver = new TestReceiver(),
MaximumMessageLength = 2
};
var frame = ComposeFrame(
WebsocketPacket.WSOpcode.BinaryFrame,
final: true,
masked: true,
new byte[] { 1, 2, 3 });
var input = new NetworkBuffer();
input.Write(frame);
socket.NetworkReceive(transport, input);
Assert.Equal(SocketState.Closed, transport.State);
}
[Fact]
public void WebSocket_DestroyIsReentrantAndForwardsTheWrapperOnClose()
{
var transport = new TestSocket();
var receiver = new ReentrantCloseReceiver();
var socket = new WSocket(transport, isServer: true)
{
Receiver = receiver
};
receiver.OnClose = socket.Destroy;
socket.Destroy();
socket.Destroy();
Assert.Equal(SocketState.Closed, transport.State);
Assert.Same(socket, receiver.ClosedSender);
Assert.Null(transport.Receiver);
}
[Fact]
public async Task TcpSocket_BoundsCopiedPendingSendData()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
using var client = new Socket(
AddressFamily.InterNetwork,
SocketType.Stream,
ProtocolType.Tcp);
var endpoint = (IPEndPoint)listener.LocalEndpoint;
var connectTask = client.ConnectAsync(endpoint);
var accepted = await listener.AcceptSocketAsync();
await connectTask;
var socket = new TcpSocket(accepted)
{
MaximumPendingSendBytes = 4
};
try
{
socket.Hold();
socket.Send(new byte[] { 1, 2, 3, 4 });
Assert.Equal(4, socket.PendingSendBytes);
Assert.Throws<InvalidOperationException>(() => socket.Send(new byte[] { 5 }));
var rejected = socket.SendAsync(new byte[] { 6 }, 0, 1);
var exception = await Assert.ThrowsAsync<AsyncException>(async () => await rejected);
Assert.IsType<InvalidOperationException>(exception.InnerException);
Assert.Equal(4, socket.PendingSendBytes);
}
finally
{
socket.Destroy();
}
}
[Fact]
public async Task TcpSocket_SendCallbackFailureDoesNotCloseTheTransport()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
using var client = new Socket(
AddressFamily.InterNetwork,
SocketType.Stream,
ProtocolType.Tcp);
var endpoint = (IPEndPoint)listener.LocalEndpoint;
var connectTask = client.ConnectAsync(endpoint);
using var accepted = await listener.AcceptSocketAsync();
await connectTask;
var socket = new TcpSocket(accepted);
try
{
socket.Hold();
var reply = socket.SendAsync(new byte[] { 42 }, 0, 1);
_ = reply.Then(_ => throw new InvalidOperationException("Consumer callback failure."));
socket.Unhold();
Assert.True(await reply);
Assert.Equal(SocketState.Established, socket.State);
Assert.True(SpinWait.SpinUntil(
() => socket.PendingSendBytes == 0,
TimeSpan.FromSeconds(2)));
}
finally
{
socket.Destroy();
}
}
private static byte[] ComposeFrame(
WebsocketPacket.WSOpcode opcode,
bool final,
bool masked,
byte[] payload)
{
var packet = new WebsocketPacket
{
FIN = final,
Opcode = opcode,
Mask = masked,
MaskKey = new byte[] { 1, 2, 3, 4 },
Message = payload
};
packet.Compose();
return packet.Data;
}
private sealed class TestReceiver : INetworkReceiver<ISocket>
{
public List<byte[]> Messages { get; } = new();
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
{
var message = buffer.Read();
if (message != null)
Messages.Add(message);
}
public void NetworkConnect(ISocket sender) { }
public void NetworkClose(ISocket sender) { }
}
private sealed class ReentrantCloseReceiver : INetworkReceiver<ISocket>
{
public Action? OnClose { get; set; }
public ISocket? ClosedSender { get; private set; }
public void NetworkReceive(ISocket sender, NetworkBuffer buffer) { }
public void NetworkConnect(ISocket sender) { }
public void NetworkClose(ISocket sender)
{
ClosedSender = sender;
OnClose?.Invoke();
}
}
private sealed class TestSocket : ISocket
{
private readonly AsyncReply<bool>? beginReply;
public event DestroyedEvent? OnDestroy;
public SocketState State { get; private set; } = SocketState.Established;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
public List<byte[]> Sent { get; } = new();
public int BeginCalls;
public TestSocket(AsyncReply<bool>? beginReply = null)
=> this.beginReply = beginReply;
public void Send(byte[] message) => Send(message, 0, message.Length);
public void Send(byte[] message, int offset, int length)
{
var copy = new byte[length];
Buffer.BlockCopy(message, offset, copy, 0, length);
Sent.Add(copy);
}
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
{
Send(message, offset, length);
return new AsyncReply<bool>(true);
}
public void Close()
{
if (State == SocketState.Closed)
return;
State = SocketState.Closed;
Receiver?.NetworkClose(this);
}
public AsyncReply<bool> Connect(string hostname, ushort port) => new(true);
public bool Begin() => true;
public AsyncReply<bool> BeginAsync()
{
Interlocked.Increment(ref BeginCalls);
return beginReply ?? new AsyncReply<bool>(true);
}
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
public ISocket Accept() => null!;
public void Hold() { }
public void Unhold() { }
public void Destroy()
{
Close();
OnDestroy?.Invoke(this);
OnDestroy = null;
}
}
private sealed class TestListener : ISocket
{
private readonly Queue<ISocket> accepted;
public TestListener(params ISocket[] accepted)
=> this.accepted = new Queue<ISocket>(accepted);
public event DestroyedEvent? OnDestroy;
public SocketState State { get; private set; } = SocketState.Listening;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint => null!;
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 10518);
public ISocket Accept()
{
lock (accepted)
return accepted.Count == 0 ? null! : accepted.Dequeue();
}
public void Close() => State = SocketState.Closed;
public void Destroy()
{
Close();
OnDestroy?.Invoke(this);
}
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) => new(false);
public void Send(byte[] message) { }
public void Send(byte[] message, int offset, int length) { }
public AsyncReply<bool> Connect(string hostname, ushort port) => new(false);
public bool Begin() => false;
public AsyncReply<bool> BeginAsync() => new(false);
public AsyncReply<ISocket> AcceptAsync() => new(Accept());
public void Hold() { }
public void Unhold() { }
}
private sealed class TestConnection : NetworkConnection
{
protected override void DataReceived(NetworkBuffer buffer) { }
protected override void Connected() { }
protected override void Disconnected() { }
}
private sealed class TestServer : NetworkServer<TestConnection>
{
protected override void ClientDisconnected(TestConnection connection) { }
protected override void ClientConnected(TestConnection connection) { }
}
}
+55
View File
@@ -0,0 +1,55 @@
using Esiur.Proxy;
namespace Esiur.Tests.Unit;
public class TypeDefGeneratorSafetyTests
{
[Fact]
public void GeneratedFileName_RejectsPathsAndAcceptsQualifiedTypeNames()
{
Assert.Equal(
"Example.Contracts.Device.g.cs",
TypeDefGenerator.GetGeneratedFileName("Example.Contracts.Device"));
Assert.Throws<InvalidDataException>(
() => TypeDefGenerator.GetGeneratedFileName("../outside"));
Assert.Throws<InvalidDataException>(
() => TypeDefGenerator.GetGeneratedFileName("folder/type"));
Assert.Throws<InvalidDataException>(
() => TypeDefGenerator.GetGeneratedFileName(" "));
}
[Fact]
public void Cleanup_DeletesOnlyManifestedGeneratedFiles()
{
var root = Path.Combine(Path.GetTempPath(), $"esiur-generator-{Guid.NewGuid():N}");
var output = Path.Combine(root, "output");
Directory.CreateDirectory(output);
try
{
var generated = Path.Combine(output, "Old.g.cs");
var unlistedGenerated = Path.Combine(output, "Keep.g.cs");
var userFile = Path.Combine(output, "notes.txt");
var outsideFile = Path.Combine(root, "outside.g.cs");
File.WriteAllText(generated, "generated");
File.WriteAllText(unlistedGenerated, "unlisted");
File.WriteAllText(userFile, "user");
File.WriteAllText(outsideFile, "outside");
File.WriteAllLines(
Path.Combine(output, ".esiur-generated-files"),
new[] { "Old.g.cs", "../outside.g.cs", "notes.txt" });
TypeDefGenerator.DeletePreviouslyGeneratedFiles(new DirectoryInfo(output));
Assert.False(File.Exists(generated));
Assert.True(File.Exists(unlistedGenerated));
Assert.True(File.Exists(userFile));
Assert.True(File.Exists(outsideFile));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
}
+109
View File
@@ -0,0 +1,109 @@
using Esiur.Data;
using Esiur.Data.Types;
using Esiur.Security.Authority;
using Esiur.Security.Permissions;
namespace Esiur.Tests.Unit;
public class UserPermissionsManagerTests
{
[Fact]
public void ExplicitMemberGrant_AllowsReadAndWriteActions()
{
var memberPermissions = new Map<string, object>
{
[ActionType.GetProperty.ToString()] = "yes",
[ActionType.SetProperty.ToString()] = "yes"
};
var manager = CreateManager(new Map<string, object>
{
["Value"] = memberPermissions
});
var session = new Session { RemoteIdentity = "alice" };
var member = new MemberDef { Name = "Value" };
Assert.Equal(
Ruling.Allowed,
manager.Applicable(null!, session, ActionType.GetProperty, member, null!));
Assert.Equal(
Ruling.Allowed,
manager.Applicable(null!, session, ActionType.SetProperty, member, null!));
}
[Fact]
public void MissingMemberOrAction_DeniesInsteadOfFallingThrough()
{
var manager = CreateManager(new Map<string, object>
{
["Value"] = new Map<string, object>
{
[ActionType.GetProperty.ToString()] = "yes"
}
});
var session = new Session { RemoteIdentity = "alice" };
Assert.Equal(
Ruling.Denied,
manager.Applicable(
null!,
session,
ActionType.Execute,
new MemberDef { Name = "Missing" },
null!));
Assert.Equal(
Ruling.Denied,
manager.Applicable(
null!,
session,
ActionType.SetProperty,
new MemberDef { Name = "Value" },
null!));
}
[Fact]
public void ExplicitResourceGrant_AllowsAttributeInquiry()
{
var manager = CreateManager(new Map<string, object>
{
["_get_attributes"] = "yes"
});
Assert.Equal(
Ruling.Allowed,
manager.Applicable(
null!,
new Session { RemoteIdentity = "alice" },
ActionType.InquireAttributes,
null!,
null!));
}
[Fact]
public void MissingIdentityAndMalformedPermissionMaps_FailClosed()
{
var manager = new UserPermissionsManager(new Map<string, object>
{
["alice"] = "not a permission map"
});
Assert.Equal(
Ruling.Denied,
manager.Applicable(
null!,
new Session { RemoteIdentity = "bob" },
ActionType.Attach,
null!,
null!));
Assert.Equal(
Ruling.Denied,
manager.Applicable(
null!,
new Session { RemoteIdentity = "alice" },
ActionType.Attach,
null!,
null!));
}
private static UserPermissionsManager CreateManager(Map<string, object> permissions)
=> new(new Map<string, object> { ["alice"] = permissions });
}