using System.Text;
using XFESystem.Browser;
using XFESystem.Kernel;
namespace XFESystem.HttpProtocol.Tests;
public sealed class NativeHttpProtocolTests
{
[Fact]
public void ContentLengthIsAByteLengthAndUtf8ChineseDecodesCorrectly()
{
const string html = "<p>中文 42</p>";
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<IOException>(() => 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<IOException>(() => 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<IOException>(() => Parse("HTTP/1.1 200 OK\r\n" + headers + "\r\n"));
}
[Fact]
public void OversizedBodiesFailBeforeAnUnboundedAllocation()
{
Assert.Throws<BrowserLimitException>(() => Parse("HTTP/1.1 200 OK\r\nContent-Length: 101\r\n\r\n", maximum: 100));
Assert.Throws<BrowserLimitException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n65\r\n", maximum: 100));
Assert.Throws<BrowserLimitException>(() => Parse("HTTP/1.0 200 OK\r\n\r\n" + new string('X', 101), maximum: 100));
Assert.Throws<BrowserLimitException>(() => 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<IOException>(() => Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHi"));
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWikiX\n0\r\n\r\n"));
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWi"));
}
[Fact]
public void ForbiddenTrailerFieldsAndUnsupportedCodingsAreRejected()
{
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nContent-Length: 0\r\n\r\n"));
Assert.Throws<NotSupportedException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"));
Assert.Throws<NotSupportedException>(() => Parse("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n\r\n"));
Assert.Throws<NotSupportedException>(() => Parse("HTTP/1.1 101 Upgrade\r\n\r\n"));
}
[Fact]
public void HeaderLineTotalAndCountLimitsAreEnforced()
{
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nX: " + new string('A', 4_096) + "\r\n\r\n"));
Assert.Throws<IOException>(() => 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<IOException>(() => 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<IOException>(() => 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<NotSupportedException>(() => 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);
}
}
using System.Text;
using XFESystem.Browser;
using XFESystem.Kernel;
namespace XFESystem.HttpProtocol.Tests;
public sealed class NativeHttpProtocolTests
{
[Fact]
public void ContentLengthIsAByteLengthAndUtf8ChineseDecodesCorrectly()
{
const string html = "<p>中文 42</p>";
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<IOException>(() => 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<IOException>(() => 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<IOException>(() => Parse("HTTP/1.1 200 OK\r\n" + headers + "\r\n"));
}
[Fact]
public void OversizedBodiesFailBeforeAnUnboundedAllocation()
{
Assert.Throws<BrowserLimitException>(() => Parse("HTTP/1.1 200 OK\r\nContent-Length: 101\r\n\r\n", maximum: 100));
Assert.Throws<BrowserLimitException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n65\r\n", maximum: 100));
Assert.Throws<BrowserLimitException>(() => Parse("HTTP/1.0 200 OK\r\n\r\n" + new string('X', 101), maximum: 100));
Assert.Throws<BrowserLimitException>(() => 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<IOException>(() => Parse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHi"));
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWikiX\n0\r\n\r\n"));
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWi"));
}
[Fact]
public void ForbiddenTrailerFieldsAndUnsupportedCodingsAreRejected()
{
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nContent-Length: 0\r\n\r\n"));
Assert.Throws<NotSupportedException>(() => Parse("HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"));
Assert.Throws<NotSupportedException>(() => Parse("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\n\r\n"));
Assert.Throws<NotSupportedException>(() => Parse("HTTP/1.1 101 Upgrade\r\n\r\n"));
}
[Fact]
public void HeaderLineTotalAndCountLimitsAreEnforced()
{
Assert.Throws<IOException>(() => Parse("HTTP/1.1 200 OK\r\nX: " + new string('A', 4_096) + "\r\n\r\n"));
Assert.Throws<IOException>(() => 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<IOException>(() => 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<IOException>(() => 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<NotSupportedException>(() => 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);
}
}