// Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. namespace DotNetty.Codecs.Http.Tests { using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using DotNetty.Buffers; using DotNetty.Codecs.Compression; using DotNetty.Common.Utilities; using DotNetty.Transport.Channels; using DotNetty.Transport.Channels.Embedded; using Xunit; public sealed class HttpContentDecoderTest { const string HelloWorld = "hello, world"; static readonly byte[] GzHelloWorld = { 31, (256 -117), 8, 8, 12, 3, (256 -74), 84, 0, 3, 50, 0, (256 -53), 72, (256 -51), (256 -55), (256 -55), (256 -41), 81, 40, (256 -49), 47, (256 -54), 73, 1, 0, 58, 114, (256 -85), (256 -1), 12, 0, 0, 0 }; [Fact] public void BinaryDecompression() { // baseline test: zlib library and test helpers work correctly. byte[] helloWorld = GzDecompress(GzHelloWorld); byte[] expected = Encoding.ASCII.GetBytes(HelloWorld); Assert.True(expected.SequenceEqual(helloWorld)); const string FullCycleTest = "full cycle test"; byte[] compressed = GzCompress(Encoding.ASCII.GetBytes(FullCycleTest)); byte[] decompressed = GzDecompress(compressed); string result = Encoding.ASCII.GetString(decompressed); Assert.Equal(FullCycleTest, result); } [Fact] public void RequestDecompression() { // baseline test: request decoder, content decompressor && request aggregator work as expected var decoder = new HttpRequestDecoder(); var decompressor = new HttpContentDecompressor(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, decompressor, aggregator); string headers = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; IByteBuffer buf = Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld); Assert.True(channel.WriteInbound(buf)); var req = channel.ReadInbound(); Assert.NotNull(req); Assert.True(req.Headers.TryGetInt(HttpHeaderNames.ContentLength, out int length)); Assert.Equal(HelloWorld.Length, length); Assert.Equal(HelloWorld, req.Content.ToString(Encoding.ASCII)); req.Release(); AssertHasInboundMessages(channel, false); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); // assert that no messages are left in channel } [Fact] public void ResponseDecompression() { // baseline test: response decoder, content decompressor && request aggregator work as expected var decoder = new HttpResponseDecoder(); var decompressor = new HttpContentDecompressor(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, decompressor, aggregator); string headers = "HTTP/1.1 200 OK\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; IByteBuffer buf = Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld); Assert.True(channel.WriteInbound(buf)); var resp = channel.ReadInbound(); Assert.NotNull(resp); Assert.True(resp.Headers.TryGetInt(HttpHeaderNames.ContentLength, out int length)); Assert.Equal(HelloWorld.Length, length); Assert.Equal(HelloWorld, resp.Content.ToString(Encoding.ASCII)); resp.Release(); AssertHasInboundMessages(channel, false); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); // assert that no messages are left in channel } [Fact] public void ExpectContinueResponse1() { // request with header "Expect: 100-continue" must be replied with one "100 Continue" response // case 1: no ContentDecoder in chain at all (baseline test) var decoder = new HttpRequestDecoder(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, aggregator); string req = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Expect: 100-continue\r\n" + "\r\n"; // note: the following writeInbound() returns false as there is no message is inbound buffer // until HttpObjectAggregator caches composes a complete message. // however, http response "100 continue" must be sent as soon as headers are received Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(Encoding.ASCII.GetBytes(req)))); var resp = channel.ReadOutbound(); Assert.NotNull(resp); Assert.Equal(100, resp.Status.Code); Assert.True(channel.WriteInbound(Unpooled.WrappedBuffer(GzHelloWorld))); resp.Release(); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void ExpectContinueResponse2() { // request with header "Expect: 100-continue" must be replied with one "100 Continue" response // case 2: contentDecoder is in chain, but the content is not encoded, should be no-op var decoder = new HttpRequestDecoder(); var decompressor = new HttpContentDecompressor(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, decompressor, aggregator); string req = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Expect: 100-continue\r\n" + "\r\n"; Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(Encoding.ASCII.GetBytes(req)))); var resp = channel.ReadOutbound(); Assert.NotNull(resp); Assert.Equal(100, resp.Status.Code); resp.Release(); Assert.True(channel.WriteInbound(Unpooled.WrappedBuffer(GzHelloWorld))); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void ExpectContinueResponse3() { // request with header "Expect: 100-continue" must be replied with one "100 Continue" response // case 3: ContentDecoder is in chain and content is encoded var decoder = new HttpRequestDecoder(); var decompressor = new HttpContentDecompressor(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, decompressor, aggregator); string req = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Expect: 100-continue\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(Encoding.ASCII.GetBytes(req)))); var resp = channel.ReadOutbound(); Assert.Equal(100, resp.Status.Code); resp.Release(); Assert.True(channel.WriteInbound(Unpooled.WrappedBuffer(GzHelloWorld))); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void ExpectContinueResponse4() { // request with header "Expect: 100-continue" must be replied with one "100 Continue" response // case 4: ObjectAggregator is up in chain var decoder = new HttpRequestDecoder(); var aggregator = new HttpObjectAggregator(1024); var decompressor = new HttpContentDecompressor(); var channel = new EmbeddedChannel(decoder, aggregator, decompressor); string req = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Expect: 100-continue\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(Encoding.ASCII.GetBytes(req)))); var resp = channel.ReadOutbound(); Assert.NotNull(resp); Assert.Equal(100, resp.Status.Code); resp.Release(); Assert.True(channel.WriteInbound(Unpooled.WrappedBuffer(GzHelloWorld))); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } sealed class TestHandler : ChannelHandlerAdapter { IFullHttpRequest request; public IFullHttpRequest Request => this.request; public override void ChannelRead(IChannelHandlerContext context, object message) { if (message is IFullHttpRequest value) { if (Interlocked.CompareExchange(ref this.request, value, null) != null) { value.Release(); } } else { ReferenceCountUtil.Release(message); } } } [Fact] public void ExpectContinueResetHttpObjectDecoder() { // request with header "Expect: 100-continue" must be replied with one "100 Continue" response // case 5: Test that HttpObjectDecoder correctly resets its internal state after a failed expectation. var decoder = new HttpRequestDecoder(); const int MaxBytes = 10; var aggregator = new HttpObjectAggregator(MaxBytes); var testHandler = new TestHandler(); var channel = new EmbeddedChannel(decoder, aggregator, testHandler); string req1 = "POST /1 HTTP/1.1\r\n" + "Content-Length: " + (MaxBytes + 1) + "\r\n" + "Expect: 100-continue\r\n" + "\r\n"; Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(Encoding.ASCII.GetBytes(req1)))); var resp = channel.ReadOutbound(); Assert.Equal(HttpStatusClass.ClientError, resp.Status.CodeClass); resp.Release(); string req2 = "POST /2 HTTP/1.1\r\n" + "Content-Length: " + MaxBytes + "\r\n" + "Expect: 100-continue\r\n" + "\r\n"; Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(Encoding.ASCII.GetBytes(req2)))); resp = channel.ReadOutbound(); Assert.Equal(100, resp.Status.Code); resp.Release(); var content = new byte[MaxBytes]; Assert.False(channel.WriteInbound(Unpooled.WrappedBuffer(content))); IFullHttpRequest req = testHandler.Request; Assert.NotNull(req); Assert.Equal("/2", req.Uri); Assert.Equal(10, req.Content.ReadableBytes); req.Release(); AssertHasInboundMessages(channel, false); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void RequestContentLength1() { // case 1: test that ContentDecompressor either sets the correct Content-Length header // or removes it completely (handlers down the chain must rely on LastHttpContent object) // force content to be in more than one chunk (5 bytes/chunk) var decoder = new HttpRequestDecoder(4096, 4096, 5); var decompressor = new HttpContentDecompressor(); var channel = new EmbeddedChannel(decoder, decompressor); string headers = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; IByteBuffer buf = Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld); Assert.True(channel.WriteInbound(buf)); Queue req = channel.InboundMessages; Assert.True(req.Count >= 1); object o = req.Peek(); Assert.IsAssignableFrom(o); var request = (IHttpRequest)o; if (request.Headers.TryGet(HttpHeaderNames.ContentLength, out ICharSequence v)) { Assert.Equal(HelloWorld.Length, long.Parse(v.ToString())); } AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void RequestContentLength2() { // case 2: if HttpObjectAggregator is down the chain, then correct Content-Length header must be set // force content to be in more than one chunk (5 bytes/chunk) var decoder = new HttpRequestDecoder(4096, 4096, 5); var decompressor = new HttpContentDecompressor(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, decompressor, aggregator); string headers = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; IByteBuffer buf = Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld); Assert.True(channel.WriteInbound(buf)); var req = channel.ReadInbound(); Assert.NotNull(req); Assert.True(req.Headers.TryGet(HttpHeaderNames.ContentLength, out ICharSequence value)); Assert.Equal(HelloWorld.Length, long.Parse(value.ToString())); req.Release(); AssertHasInboundMessages(channel, false); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void ResponseContentLength1() { // case 1: test that ContentDecompressor either sets the correct Content-Length header // or removes it completely (handlers down the chain must rely on LastHttpContent object) // force content to be in more than one chunk (5 bytes/chunk) var decoder = new HttpResponseDecoder(4096, 4096, 5); var decompressor = new HttpContentDecompressor(); var channel = new EmbeddedChannel(decoder, decompressor); string headers = "HTTP/1.1 200 OK\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; IByteBuffer buf = Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld); Assert.True(channel.WriteInbound(buf)); Queue resp = channel.InboundMessages; Assert.True(resp.Count >= 1); object o = resp.Peek(); Assert.IsAssignableFrom(o); var r = (IHttpResponse)o; Assert.False(r.Headers.Contains(HttpHeaderNames.ContentLength)); Assert.True(r.Headers.TryGet(HttpHeaderNames.TransferEncoding, out ICharSequence transferEncoding)); Assert.NotNull(transferEncoding); Assert.Equal(HttpHeaderValues.Chunked, transferEncoding); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void ResponseContentLength2() { // case 2: if HttpObjectAggregator is down the chain, then correct Content - Length header must be set // force content to be in more than one chunk (5 bytes/chunk) var decoder = new HttpResponseDecoder(4096, 4096, 5); var decompressor = new HttpContentDecompressor(); var aggregator = new HttpObjectAggregator(1024); var channel = new EmbeddedChannel(decoder, decompressor, aggregator); string headers = "HTTP/1.1 200 OK\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; IByteBuffer buf = Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld); Assert.True(channel.WriteInbound(buf)); var res = channel.ReadInbound(); Assert.NotNull(res); Assert.True(res.Headers.TryGet(HttpHeaderNames.ContentLength, out ICharSequence value)); Assert.Equal(HelloWorld.Length, long.Parse(value.ToString())); res.Release(); AssertHasInboundMessages(channel, false); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void FullHttpRequest() { // test that ContentDecoder can be used after the ObjectAggregator var decoder = new HttpRequestDecoder(4096, 4096, 5); var aggregator = new HttpObjectAggregator(1024); var decompressor = new HttpContentDecompressor(); var channel = new EmbeddedChannel(decoder, aggregator, decompressor); string headers = "POST / HTTP/1.1\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; Assert.True(channel.WriteInbound(Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld))); Queue req = channel.InboundMessages; Assert.True(req.Count > 1); int contentLength = 0; contentLength = CalculateContentLength(req, contentLength); byte[] receivedContent = ReadContent(req, contentLength, true); Assert.Equal(HelloWorld, Encoding.ASCII.GetString(receivedContent)); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } [Fact] public void FullHttpResponse() { // test that ContentDecoder can be used after the ObjectAggregator var decoder = new HttpResponseDecoder(4096, 4096, 5); var aggregator = new HttpObjectAggregator(1024); var decompressor = new HttpContentDecompressor(); var channel = new EmbeddedChannel(decoder, aggregator, decompressor); string headers = "HTTP/1.1 200 OK\r\n" + "Content-Length: " + GzHelloWorld.Length + "\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; Assert.True(channel.WriteInbound(Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld))); Queue resp = channel.InboundMessages; Assert.True(resp.Count > 1); int contentLength = 0; contentLength = CalculateContentLength(resp, contentLength); byte[] receivedContent = ReadContent(resp, contentLength, true); Assert.Equal(HelloWorld, Encoding.ASCII.GetString(receivedContent)); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } // See https://github.com/netty/netty/issues/5892 [Fact] public void FullHttpResponseEOF() { // test that ContentDecoder can be used after the ObjectAggregator var decoder = new HttpResponseDecoder(4096, 4096, 5); var decompressor = new HttpContentDecompressor(); var channel = new EmbeddedChannel(decoder, decompressor); string headers = "HTTP/1.1 200 OK\r\n" + "Content-Encoding: gzip\r\n" + "\r\n"; Assert.True(channel.WriteInbound(Unpooled.CopiedBuffer(Encoding.ASCII.GetBytes(headers), GzHelloWorld))); // This should terminate it. Assert.True(channel.Finish()); Queue resp = channel.InboundMessages; Assert.True(resp.Count > 1); int contentLength = 0; contentLength = CalculateContentLength(resp, contentLength); byte[] receivedContent = ReadContent(resp, contentLength, false); Assert.Equal(HelloWorld, Encoding.ASCII.GetString(receivedContent)); AssertHasInboundMessages(channel, true); AssertHasOutboundMessages(channel, false); Assert.False(channel.Finish()); } // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local static byte[] ReadContent(IEnumerable req, int contentLength, bool hasTransferEncoding) { var receivedContent = new byte[contentLength]; int readCount = 0; foreach (object o in req) { if (o is IHttpContent content) { int readableBytes = content.Content.ReadableBytes; content.Content.ReadBytes(receivedContent, readCount, readableBytes); readCount += readableBytes; } if (o is IHttpMessage message) { Assert.Equal(hasTransferEncoding, message.Headers.Contains(HttpHeaderNames.TransferEncoding)); } } return receivedContent; } [Fact] public void CleanupThrows() { var decoder = new CleanupDecoder(); var inboundHandler = new InboundHandler(); var channel = new EmbeddedChannel(decoder, inboundHandler); Assert.True(channel.WriteInbound(new DefaultHttpRequest(HttpVersion.Http11, HttpMethod.Get, "/"))); var content = new DefaultHttpContent(Unpooled.Buffer().WriteZero(10)); Assert.True(channel.WriteInbound(content)); Assert.Equal(1, content.ReferenceCount); Assert.Throws(() => channel.FinishAndReleaseAll()); Assert.Equal(1, inboundHandler.ChannelInactiveCalled); Assert.Equal(0, content.ReferenceCount); } sealed class CleanupDecoder : HttpContentDecoder { protected override EmbeddedChannel NewContentDecoder(ICharSequence contentEncoding) => new EmbeddedChannel(new Handler()); sealed class Handler : ChannelHandlerAdapter { public override void ChannelInactive(IChannelHandlerContext context) { context.FireExceptionCaught(new DecoderException("CleanupThrows")); context.FireChannelInactive(); } } } sealed class InboundHandler : ChannelHandlerAdapter { public int ChannelInactiveCalled; public override void ChannelInactive(IChannelHandlerContext context) { Interlocked.CompareExchange(ref this.ChannelInactiveCalled, 1, 0); base.ChannelInactive(context); } } static int CalculateContentLength(IEnumerable req, int contentLength) { foreach (object o in req) { if (o is IHttpContent content) { Assert.True(content.ReferenceCount > 0); contentLength += content.Content.ReadableBytes; } } return contentLength; } static byte[] GzCompress(byte[] input) { ZlibEncoder encoder = ZlibCodecFactory.NewZlibEncoder(ZlibWrapper.Gzip); var channel = new EmbeddedChannel(encoder); Assert.True(channel.WriteOutbound(Unpooled.WrappedBuffer(input))); Assert.True(channel.Finish()); // close the channel to indicate end-of-data int outputSize = 0; IByteBuffer o; var outbound = new List(); while ((o = channel.ReadOutbound()) != null) { outbound.Add(o); outputSize += o.ReadableBytes; } var output = new byte[outputSize]; int readCount = 0; foreach (IByteBuffer b in outbound) { int readableBytes = b.ReadableBytes; b.ReadBytes(output, readCount, readableBytes); b.Release(); readCount += readableBytes; } Assert.True(channel.InboundMessages.Count == 0 && channel.OutboundMessages.Count == 0); return output; } static byte[] GzDecompress(byte[] input) { ZlibDecoder decoder = ZlibCodecFactory.NewZlibDecoder(ZlibWrapper.Gzip); var channel = new EmbeddedChannel(decoder); Assert.True(channel.WriteInbound(Unpooled.WrappedBuffer(input))); Assert.True(channel.Finish()); // close the channel to indicate end-of-data int outputSize = 0; IByteBuffer o; var inbound = new List(); while ((o = channel.ReadInbound()) != null) { inbound.Add(o); outputSize += o.ReadableBytes; } var output = new byte[outputSize]; int readCount = 0; foreach (IByteBuffer b in inbound) { int readableBytes = b.ReadableBytes; b.ReadBytes(output, readCount, readableBytes); b.Release(); readCount += readableBytes; } Assert.True(channel.InboundMessages.Count == 0 && channel.OutboundMessages.Count == 0); return output; } static void AssertHasInboundMessages(EmbeddedChannel channel, bool hasMessages) { object o; if (hasMessages) { while (true) { o = channel.ReadInbound(); Assert.NotNull(o); ReferenceCountUtil.Release(o); if (o is ILastHttpContent) { break; } } } else { o = channel.ReadInbound(); Assert.Null(o); } } static void AssertHasOutboundMessages(EmbeddedChannel channel, bool hasMessages) { object o; if (hasMessages) { while (true) { o = channel.ReadOutbound(); Assert.NotNull(o); ReferenceCountUtil.Release(o); if (o is ILastHttpContent) { break; } } } else { o = channel.ReadOutbound(); Assert.Null(o); } } } }