chromium/components/cronet/android/test/src/org/chromium/net/Http2TestHandler.java

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.net;

import static io.netty.buffer.Unpooled.copiedBuffer;
import static io.netty.buffer.Unpooled.unreleasableBuffer;
import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static io.netty.handler.logging.LogLevel.INFO;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http2.AbstractHttp2ConnectionHandlerBuilder;
import io.netty.handler.codec.http2.DefaultHttp2Headers;
import io.netty.handler.codec.http2.Http2ConnectionDecoder;
import io.netty.handler.codec.http2.Http2ConnectionEncoder;
import io.netty.handler.codec.http2.Http2ConnectionHandler;
import io.netty.handler.codec.http2.Http2Exception;
import io.netty.handler.codec.http2.Http2Flags;
import io.netty.handler.codec.http2.Http2FrameListener;
import io.netty.handler.codec.http2.Http2FrameLogger;
import io.netty.handler.codec.http2.Http2Headers;
import io.netty.handler.codec.http2.Http2Settings;
import io.netty.util.CharsetUtil;

import org.chromium.base.Log;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CountDownLatch;

/** HTTP/2 test handler for Cronet BidirectionalStream tests. */
public final class Http2TestHandler extends Http2ConnectionHandler implements Http2FrameListener {
    // Some Url Paths that have special meaning.
    public static final String ECHO_ALL_HEADERS_PATH = "/echoallheaders";
    public static final String ECHO_HEADER_PATH = "/echoheader";
    public static final String ECHO_METHOD_PATH = "/echomethod";
    public static final String ECHO_STREAM_PATH = "/echostream";
    public static final String ECHO_TRAILERS_PATH = "/echotrailers";
    public static final String SERVE_SIMPLE_BROTLI_RESPONSE = "/simplebrotli";
    public static final String SERVE_SHARED_BROTLI_RESPONSE = "/sharedbrotli";
    public static final String REPORTING_COLLECTOR_PATH = "/reporting-collector";
    public static final String SUCCESS_WITH_NEL_HEADERS_PATH = "/success-with-nel";
    public static final String COMBINED_HEADERS_PATH = "/combinedheaders";
    public static final String HANGING_REQUEST_PATH = "/hanging-request";

    private static final String TAG = Http2TestHandler.class.getSimpleName();
    private static final Http2FrameLogger sLogger =
            new Http2FrameLogger(INFO, Http2TestHandler.class);
    private static final ByteBuf RESPONSE_BYTES =
            unreleasableBuffer(copiedBuffer("HTTP/2 Test Server", CharsetUtil.UTF_8));

    private HashMap<Integer, RequestResponder> mResponderMap = new HashMap<>();

    private ReportingCollector mReportingCollector;
    private String mServerUrl;
    private CountDownLatch mHangingUrlLatch;

    /** Builder for HTTP/2 test handler. */
    public static final class Builder
            extends AbstractHttp2ConnectionHandlerBuilder<Http2TestHandler, Builder> {
        public Builder() {
            frameLogger(sLogger);
        }

        public Builder setReportingCollector(ReportingCollector reportingCollector) {
            mReportingCollector = reportingCollector;
            return this;
        }

        public Builder setServerUrl(String serverUrl) {
            mServerUrl = serverUrl;
            return this;
        }

        public Builder setHangingUrlLatch(CountDownLatch hangingUrlLatch) {
            mHangingUrlLatch = hangingUrlLatch;
            return this;
        }

        @Override
        public Http2TestHandler build() {
            return super.build();
        }

        @Override
        protected Http2TestHandler build(
                Http2ConnectionDecoder decoder,
                Http2ConnectionEncoder encoder,
                Http2Settings initialSettings) {
            Http2TestHandler handler =
                    new Http2TestHandler(
                            decoder,
                            encoder,
                            initialSettings,
                            mReportingCollector,
                            mServerUrl,
                            mHangingUrlLatch);
            frameListener(handler);
            return handler;
        }

        private ReportingCollector mReportingCollector;
        private String mServerUrl;
        private CountDownLatch mHangingUrlLatch;
    }

    private class RequestResponder {
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            encoder()
                    .writeHeaders(
                            ctx,
                            streamId,
                            createResponseHeadersFromRequestHeaders(headers),
                            0,
                            endOfStream,
                            ctx.newPromise());
            ctx.flush();
        }

        int onDataRead(
                ChannelHandlerContext ctx,
                int streamId,
                ByteBuf data,
                int padding,
                boolean endOfStream) {
            int processed = data.readableBytes() + padding;
            encoder().writeData(ctx, streamId, data.retain(), 0, true, ctx.newPromise());
            ctx.flush();
            return processed;
        }

        void sendResponseString(ChannelHandlerContext ctx, int streamId, String responseString) {
            ByteBuf content = ctx.alloc().buffer();
            ByteBufUtil.writeAscii(content, responseString);
            encoder()
                    .writeHeaders(
                            ctx,
                            streamId,
                            createDefaultResponseHeaders(),
                            0,
                            false,
                            ctx.newPromise());
            encoder().writeData(ctx, streamId, content, 0, true, ctx.newPromise());
            ctx.flush();
        }
    }

    private class EchoStreamResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            // Send a frame for the response headers.
            encoder()
                    .writeHeaders(
                            ctx,
                            streamId,
                            createResponseHeadersFromRequestHeaders(headers),
                            0,
                            endOfStream,
                            ctx.newPromise());
            ctx.flush();
        }

        @Override
        int onDataRead(
                ChannelHandlerContext ctx,
                int streamId,
                ByteBuf data,
                int padding,
                boolean endOfStream) {
            int processed = data.readableBytes() + padding;
            encoder().writeData(ctx, streamId, data.retain(), 0, endOfStream, ctx.newPromise());
            ctx.flush();
            return processed;
        }
    }

    private class CombinedHeadersResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            ByteBuf content = ctx.alloc().buffer();
            ByteBufUtil.writeAscii(content, "GET");
            Http2Headers responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
            // Upon receiving, the following two headers will be jointed by '\0'.
            responseHeaders.add("foo", "bar");
            responseHeaders.add("foo", "bar2");
            encoder().writeHeaders(ctx, streamId, responseHeaders, 0, false, ctx.newPromise());
            encoder().writeData(ctx, streamId, content, 0, true, ctx.newPromise());
            ctx.flush();
        }
    }

    private class HangingRequestResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            try {
                mHangingUrlLatch.await();
            } catch (InterruptedException e) {
            }
        }
    }

    private class EchoHeaderResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            String[] splitPath = headers.path().toString().split("\\?");
            if (splitPath.length <= 1) {
                sendResponseString(ctx, streamId, "Header name not found.");
                return;
            }

            String headerName = splitPath[1].toLowerCase(Locale.US);
            if (headers.get(headerName) == null) {
                sendResponseString(ctx, streamId, "Header not found:" + headerName);
                return;
            }

            sendResponseString(ctx, streamId, headers.get(headerName).toString());
        }
    }

    private class EchoAllHeadersResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            StringBuilder response = new StringBuilder();
            for (Map.Entry<CharSequence, CharSequence> header : headers) {
                response.append(header.getKey() + ": " + header.getValue() + "\r\n");
            }
            sendResponseString(ctx, streamId, response.toString());
        }
    }

    private class EchoMethodResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            sendResponseString(ctx, streamId, headers.method().toString());
        }
    }

    private class EchoTrailersResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            encoder()
                    .writeHeaders(
                            ctx,
                            streamId,
                            createDefaultResponseHeaders(),
                            0,
                            false,
                            ctx.newPromise());
            encoder()
                    .writeData(
                            ctx, streamId, RESPONSE_BYTES.duplicate(), 0, false, ctx.newPromise());
            Http2Headers responseTrailers =
                    createResponseHeadersFromRequestHeaders(headers)
                            .add("trailer", "value1", "Value2");
            encoder().writeHeaders(ctx, streamId, responseTrailers, 0, true, ctx.newPromise());
            ctx.flush();
        }
    }

    // A RequestResponder that serves a simple Brotli-encoded response.
    private class ServeSimpleBrotliResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            Http2Headers responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
            byte[] quickfoxCompressed = {
                0x0b, 0x15, -0x80, 0x54, 0x68, 0x65, 0x20, 0x71, 0x75, 0x69, 0x63, 0x6b, 0x20, 0x62,
                0x72, 0x6f, 0x77, 0x6e, 0x20, 0x66, 0x6f, 0x78, 0x20, 0x6a, 0x75, 0x6d, 0x70, 0x73,
                0x20, 0x6f, 0x76, 0x65, 0x72, 0x20, 0x74, 0x68, 0x65, 0x20, 0x6c, 0x61, 0x7a, 0x79,
                0x20, 0x64, 0x6f, 0x67, 0x03
            };
            ByteBuf content = copiedBuffer(quickfoxCompressed);
            responseHeaders.add("content-encoding", "br");
            encoder().writeHeaders(ctx, streamId, responseHeaders, 0, false, ctx.newPromise());
            encoder().writeData(ctx, streamId, content, 0, true, ctx.newPromise());
            ctx.flush();
        }
    }

    // A RequestResponder that serves a shared Brotli-encoded response.
    private class ServeSharedBrotliResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            Http2Headers responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
            // Contents of a "Dictionary-Compressed Brotli" stream when:
            // * Dictionary = "A dictionary"
            // * Payload = "This is compressed test data using a test dictionary"
            // Accordingly to draft-ietf-httpbis-compression-dictionary-08 the stream is composed
            // by: a header (magic number "ff:44:43:42" & SHA-256 of Dictionary) and the compressed
            // payload.
            // The compressed payload can be obtained via //third-party/brotli:brotli by passing:
            // --dictionary=Dictionary --input=Payload (make sure to be consistent with the presence
            // of EOL in the Java strings and what //third-party/brotli:brotli receives).
            byte[] sharedBrotliCompressed = {
                (byte) 0xff,
                (byte) 0x44,
                (byte) 0x43,
                (byte) 0x42,
                (byte) 0x0a,
                (byte) 0xa3,
                (byte) 0x69,
                (byte) 0x01,
                (byte) 0x4f,
                (byte) 0x7f,
                (byte) 0xab,
                (byte) 0x37,
                (byte) 0x0b,
                (byte) 0xe9,
                (byte) 0x40,
                (byte) 0x74,
                (byte) 0x69,
                (byte) 0x85,
                (byte) 0x45,
                (byte) 0xc7,
                (byte) 0xbb,
                (byte) 0x93,
                (byte) 0x2e,
                (byte) 0xc4,
                (byte) 0x61,
                (byte) 0x25,
                (byte) 0x27,
                (byte) 0x8f,
                (byte) 0x37,
                (byte) 0xbf,
                (byte) 0x34,
                (byte) 0xab,
                (byte) 0x02,
                (byte) 0xa3,
                (byte) 0x5a,
                (byte) 0xec,
                (byte) 0xa1,
                (byte) 0x98,
                (byte) 0x01,
                (byte) 0x80,
                (byte) 0x22,
                (byte) 0xe0,
                (byte) 0x26,
                (byte) 0x4b,
                (byte) 0x95,
                (byte) 0x5c,
                (byte) 0x19,
                (byte) 0x18,
                (byte) 0x9d,
                (byte) 0xc1,
                (byte) 0xc3,
                (byte) 0x44,
                (byte) 0x0e,
                (byte) 0x5c,
                (byte) 0x6a,
                (byte) 0x09,
                (byte) 0x9d,
                (byte) 0xf0,
                (byte) 0xb0,
                (byte) 0x01,
                (byte) 0x47,
                (byte) 0x14,
                (byte) 0x87,
                (byte) 0x14,
                (byte) 0x6d,
                (byte) 0xfb,
                (byte) 0x60,
                (byte) 0x96,
                (byte) 0xdb,
                (byte) 0xae,
                (byte) 0x9e,
                (byte) 0x79,
                (byte) 0x54,
                (byte) 0xe3,
                (byte) 0x69,
                (byte) 0x03,
                (byte) 0x29
            };
            ByteBuf content = copiedBuffer(sharedBrotliCompressed);
            responseHeaders.add("content-encoding", "dcb");
            encoder().writeHeaders(ctx, streamId, responseHeaders, 0, false, ctx.newPromise());
            encoder().writeData(ctx, streamId, content, 0, true, ctx.newPromise());
            ctx.flush();
        }
    }

    // A RequestResponder that implements a Reporting collector.
    private class ReportingCollectorResponder extends RequestResponder {
        private ByteArrayOutputStream mPartialPayload = new ByteArrayOutputStream();

        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {}

        @Override
        int onDataRead(
                ChannelHandlerContext ctx,
                int streamId,
                ByteBuf data,
                int padding,
                boolean endOfStream) {
            int processed = data.readableBytes() + padding;
            try {
                data.readBytes(mPartialPayload, data.readableBytes());
            } catch (IOException e) {
            }
            if (endOfStream) {
                processPayload(ctx, streamId);
            }
            return processed;
        }

        private void processPayload(ChannelHandlerContext ctx, int streamId) {
            boolean succeeded = false;
            try {
                String payload = mPartialPayload.toString(CharsetUtil.UTF_8.name());
                succeeded = mReportingCollector.addReports(payload);
            } catch (UnsupportedEncodingException e) {
            }
            Http2Headers responseHeaders;
            if (succeeded) {
                responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
            } else {
                responseHeaders = new DefaultHttp2Headers().status(BAD_REQUEST.codeAsText());
            }
            encoder().writeHeaders(ctx, streamId, responseHeaders, 0, true, ctx.newPromise());
            ctx.flush();
        }
    }

    // A RequestResponder that serves a successful response with Reporting and NEL headers
    private class SuccessWithNELHeadersResponder extends RequestResponder {
        @Override
        void onHeadersRead(
                ChannelHandlerContext ctx,
                int streamId,
                boolean endOfStream,
                Http2Headers headers) {
            Http2Headers responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
            responseHeaders.add("report-to", getReportToHeader());
            responseHeaders.add("nel", getNELHeader());
            encoder().writeHeaders(ctx, streamId, responseHeaders, 0, true, ctx.newPromise());
            ctx.flush();
        }

        @Override
        int onDataRead(
                ChannelHandlerContext ctx,
                int streamId,
                ByteBuf data,
                int padding,
                boolean endOfStream) {
            int processed = data.readableBytes() + padding;
            return processed;
        }

        private String getReportToHeader() {
            return String.format(
                    "{\"group\": \"nel\", \"max_age\": 86400, "
                            + "\"endpoints\": [{\"url\": \"%s%s\"}]}",
                    mServerUrl, REPORTING_COLLECTOR_PATH);
        }

        private String getNELHeader() {
            return "{\"report_to\": \"nel\", \"max_age\": 86400, \"success_fraction\": 1.0}";
        }
    }

    private static Http2Headers createDefaultResponseHeaders() {
        return new DefaultHttp2Headers().status(OK.codeAsText());
    }

    private static Http2Headers createResponseHeadersFromRequestHeaders(
            Http2Headers requestHeaders) {
        // Create response headers by echoing request headers.
        Http2Headers responseHeaders = new DefaultHttp2Headers().status(OK.codeAsText());
        for (Map.Entry<CharSequence, CharSequence> header : requestHeaders) {
            if (!header.getKey().toString().startsWith(":")) {
                responseHeaders.add("echo-" + header.getKey(), header.getValue());
            }
        }

        responseHeaders.add("echo-method", requestHeaders.get(":method").toString());
        return responseHeaders;
    }

    private Http2TestHandler(
            Http2ConnectionDecoder decoder,
            Http2ConnectionEncoder encoder,
            Http2Settings initialSettings,
            ReportingCollector reportingCollector,
            String serverUrl,
            CountDownLatch hangingUrlLatch) {
        super(decoder, encoder, initialSettings);
        mReportingCollector = reportingCollector;
        mServerUrl = serverUrl;
        mHangingUrlLatch = hangingUrlLatch;
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        super.exceptionCaught(ctx, cause);
        Log.e(TAG, "An exception was caught", cause);
        ctx.close();
        throw new Exception("Exception Caught", cause);
    }

    @Override
    public int onDataRead(
            ChannelHandlerContext ctx, int streamId, ByteBuf data, int padding, boolean endOfStream)
            throws Http2Exception {
        RequestResponder responder = mResponderMap.get(streamId);
        if (endOfStream) {
            mResponderMap.remove(streamId);
        }
        return responder.onDataRead(ctx, streamId, data, padding, endOfStream);
    }

    @Override
    public void onHeadersRead(
            ChannelHandlerContext ctx,
            int streamId,
            Http2Headers headers,
            int padding,
            boolean endOfStream)
            throws Http2Exception {
        String path = headers.path().toString();
        RequestResponder responder;
        if (path.startsWith(ECHO_STREAM_PATH)) {
            responder = new EchoStreamResponder();
        } else if (path.startsWith(ECHO_TRAILERS_PATH)) {
            responder = new EchoTrailersResponder();
        } else if (path.startsWith(ECHO_ALL_HEADERS_PATH)) {
            responder = new EchoAllHeadersResponder();
        } else if (path.startsWith(ECHO_HEADER_PATH)) {
            responder = new EchoHeaderResponder();
        } else if (path.startsWith(ECHO_METHOD_PATH)) {
            responder = new EchoMethodResponder();
        } else if (path.startsWith(SERVE_SIMPLE_BROTLI_RESPONSE)) {
            responder = new ServeSimpleBrotliResponder();
        } else if (path.startsWith(SERVE_SHARED_BROTLI_RESPONSE)) {
            responder = new ServeSharedBrotliResponder();
        } else if (path.startsWith(REPORTING_COLLECTOR_PATH)) {
            responder = new ReportingCollectorResponder();
        } else if (path.startsWith(SUCCESS_WITH_NEL_HEADERS_PATH)) {
            responder = new SuccessWithNELHeadersResponder();
        } else if (path.startsWith(COMBINED_HEADERS_PATH)) {
            responder = new CombinedHeadersResponder();
        } else if (path.startsWith(HANGING_REQUEST_PATH)) {
            responder = new HangingRequestResponder();
        } else {
            responder = new RequestResponder();
        }

        responder.onHeadersRead(ctx, streamId, endOfStream, headers);

        if (!endOfStream) {
            mResponderMap.put(streamId, responder);
        }
    }

    @Override
    public void onHeadersRead(
            ChannelHandlerContext ctx,
            int streamId,
            Http2Headers headers,
            int streamDependency,
            short weight,
            boolean exclusive,
            int padding,
            boolean endOfStream)
            throws Http2Exception {
        onHeadersRead(ctx, streamId, headers, padding, endOfStream);
    }

    @Override
    public void onPriorityRead(
            ChannelHandlerContext ctx,
            int streamId,
            int streamDependency,
            short weight,
            boolean exclusive)
            throws Http2Exception {}

    @Override
    public void onRstStreamRead(ChannelHandlerContext ctx, int streamId, long errorCode)
            throws Http2Exception {}

    @Override
    public void onSettingsAckRead(ChannelHandlerContext ctx) throws Http2Exception {}

    @Override
    public void onSettingsRead(ChannelHandlerContext ctx, Http2Settings settings)
            throws Http2Exception {}

    @Override
    public void onPingRead(ChannelHandlerContext ctx, ByteBuf data) throws Http2Exception {}

    @Override
    public void onPingAckRead(ChannelHandlerContext ctx, ByteBuf data) throws Http2Exception {}

    @Override
    public void onPushPromiseRead(
            ChannelHandlerContext ctx,
            int streamId,
            int promisedStreamId,
            Http2Headers headers,
            int padding)
            throws Http2Exception {}

    @Override
    public void onGoAwayRead(
            ChannelHandlerContext ctx, int lastStreamId, long errorCode, ByteBuf debugData)
            throws Http2Exception {}

    @Override
    public void onWindowUpdateRead(ChannelHandlerContext ctx, int streamId, int windowSizeIncrement)
            throws Http2Exception {}

    @Override
    public void onUnknownFrame(
            ChannelHandlerContext ctx,
            byte frameType,
            int streamId,
            Http2Flags flags,
            ByteBuf payload)
            throws Http2Exception {}
}