using System.Text; using XFESystem.Browser; using XFESystem.Kernel; namespace XFESystem.HttpProtocol.Tests; public sealed class NativeHttpProtocolTests { [Fact] public void ContentLengthIsAByteLengthAndUtf8ChineseDecodesCorrectly() { const string html = "

中文 42

"; var response = Parse("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " + Encoding.UTF8.GetByteCount(html) + "\r\n\r\n" + html); Assert.Equal(200, response.StatusCode); Assert.Equal(html, NativeHttpProtocol.DecodeBody(response)); } [Fact] public void ArbitraryPacketBoundariesDoNotChangeHeadersOrBody() { const string wire = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Security-Policy: script-src 'none'\r\n\r\nHello"; for (var packetSize = 1; packetSize <= Encoding.UTF8.GetByteCount(wire); packetSize++) { var response = Parse(wire, packetSize: packetSize); Assert.Equal("Hello", NativeHttpProtocol.DecodeBody(response)); Assert.Equal("script-src 'none'", response.ContentSecurityPolicy); } } [Fact] public void ChunkedBodySupportsExtensionsAndBoundedTrailers() { var response = Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: Chunked\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n4;Name=Value\r\nWiki\r\n5\r\npedia\r\n0\r\nChecksum: 42\r\n\r\n", packetSize: 1); Assert.Equal("Wikipedia", NativeHttpProtocol.DecodeBody(response)); } [Fact] public void CloseDelimitedBodyReadsUntilRealEof() { var response = Parse("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nClose-delimited", packetSize: 2); Assert.Equal("Close-delimited", NativeHttpProtocol.DecodeBody(response)); } [Fact] public void InterimResponsesAreBoundedAndDoNotLoseFollowingBytes() { var response = Parse("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"); Assert.Equal("OK", NativeHttpProtocol.DecodeBody(response)); Assert.Throws(() => Parse(string.Concat(Enumerable.Repeat("HTTP/1.1 100 Continue\r\n\r\n", 5)))); } [Theory] [InlineData("Content-Length: 5\r\nTransfer-Encoding: chunked\r\n")] [InlineData("Content-Length: 5\r\nContent-Length: 5\r\n")] [InlineData("Transfer-Encoding: chunked\r\nTransfer-Encoding: chunked\r\n")] [InlineData(" Content-Length: 5\r\n")] [InlineData("Bad Name: Value\r\n")] public void AmbiguousDuplicateAndFoldedFramingIsRejected(string headers) { Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\n" + headers + "\r\nHello")); } [Theory] [InlineData("Content-Length: -1\r\n")] [InlineData("Content-Length: +1\r\n")] [InlineData("Content-Length: 1x\r\n")] [InlineData("Content-Length: 0x10\r\n")] public void InvalidLengthsAreRejected(string headers) { Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\n" + headers + "\r\n")); } [Fact] public void OversizedBodiesFailBeforeAnUnboundedAllocation() { Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nContent-Length: 101\r\n\r\n", maximum: 100)); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n65\r\n", maximum: 100)); Assert.Throws(() => Parse("HTTP/1.0 200 OK\r\n\r\n" + new string('X', 101), maximum: 100)); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\n1234\r\n4\r\n5678\r\n0\r\n\r\n", maximum: 6)); } [Fact] public void PrematureEofAndMissingChunkCrLfAreNotReportedAsSuccess() { Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHi")); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWikiX\n0\r\n\r\n")); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWi")); } [Fact] public void ForbiddenTrailerFieldsAndUnsupportedCodingsAreRejected() { Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nContent-Length: 0\r\n\r\n")); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n")); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n\r\n")); Assert.Throws(() => Parse("HTTP/1.1 101 Upgrade\r\n\r\n")); } [Fact] public void HeaderLineTotalAndCountLimitsAreEnforced() { Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\nX: " + new string('A', 4_096) + "\r\n\r\n")); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\n" + string.Concat(Enumerable.Range(0, 101).Select(index => $"X{index}: A\r\n")) + "\r\n")); Assert.Throws(() => Parse("HTTP/1.1 200 OK\r\n" + string.Concat(Enumerable.Range(0, 10).Select(index => $"X{index}: {new string('A', 2_000)}\r\n")) + "\r\n")); } [Fact] public void RedirectAndNoContentResponsesReturnWithoutReadingAnUnneededBody() { var redirected = Parse("HTTP/1.1 302 Found\r\nLocation: ../Next\r\nContent-Length: 100\r\n\r\n"); Assert.Equal(302, redirected.StatusCode); Assert.Equal("../Next", redirected.Location); Assert.Empty(redirected.Body); Assert.Empty(Parse("HTTP/1.1 204 No Content\r\n\r\n").Body); } [Theory] [InlineData("HTTP/2 200 OK\r\n\r\n")] [InlineData("HTTP/1.1 20A OK\r\n\r\n")] [InlineData("HTTP/1.1 600 Wrong\r\n\r\n")] [InlineData("HTTP/1.1 200 OK\n\n")] [InlineData("HTTP/1.1 200 OK\r\nX: \u001b[31m\r\n\r\n")] public void InvalidStatusAndHeaderControlBytesAreRejected(string wire) { Assert.Throws(() => Parse(wire)); } [Fact] public void CharsetSelectionIsExplicitAndUnsupportedCharsetsFail() { var response = Parse("HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=ascii\r\nContent-Length: 2\r\n\r\nOK"); Assert.Equal("OK", NativeHttpProtocol.DecodeBody(response)); Assert.Throws(() => NativeHttpProtocol.DecodeBody(response with { ContentType = "text/plain; charset=gbk" })); } private static NativeHttpResponse Parse(string wire, int maximum = 10_000, int packetSize = 4_096) { var bytes = Encoding.UTF8.GetBytes(wire); var position = 0; return NativeHttpProtocol.ReadResponse((target, offset, count) => { var read = Math.Min(Math.Min(count, packetSize), bytes.Length - position); Array.Copy(bytes, position, target, offset, read); position += read; return read; }, maximum); } }