├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── .simplecov ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── benchmarks └── client_server.rb ├── example ├── Gemfile ├── README.md ├── client.rb ├── helper.rb ├── keys │ ├── server.crt │ └── server.key ├── raw.rb ├── server.rb ├── upgrade_client.rb └── upgrade_server.rb ├── h2spec-releases ├── h2spec_darwin_amd64.tar.gz ├── h2spec_linux_amd64.tar.gz └── h2spec_windows_amd64.zip ├── http-2.gemspec ├── lib └── http │ ├── 2 │ ├── base64.rb │ ├── client.rb │ ├── connection.rb │ ├── emitter.rb │ ├── error.rb │ ├── extensions.rb │ ├── flow_buffer.rb │ ├── framer.rb │ ├── header.rb │ ├── header │ │ ├── compressor.rb │ │ ├── decompressor.rb │ │ ├── encoding_context.rb │ │ ├── huffman.rb │ │ └── huffman_statemachine.rb │ ├── server.rb │ ├── stream.rb │ └── version.rb │ └── 2.rb ├── sig ├── 2.rbs ├── client.rbs ├── connection.rbs ├── emitter.rbs ├── error.rbs ├── extensions.rbs ├── flow_buffer.rbs ├── frame_buffer.rbs ├── framer.rbs ├── header.rbs ├── header │ ├── compressor.rbs │ ├── decompressor.rbs │ ├── encoding_context.rbs │ └── huffman.rbs ├── server.rbs └── stream.rbs ├── spec ├── client_spec.rb ├── compressor_spec.rb ├── connection_spec.rb ├── emitter_spec.rb ├── framer_spec.rb ├── helper.rb ├── hpack_test_spec.rb ├── huffman_spec.rb ├── server_spec.rb ├── shared_examples │ └── connection.rb └── stream_spec.rb └── tasks └── generate_huffman_table.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - master 8 | 9 | env: 10 | ruby_version: 3.4 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: [2.7 ,'3.0', 3.1, 3.2, 3.3, 3.4, jruby, truffleruby] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Setup Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Tests 30 | env: 31 | CI: 1 32 | run: | 33 | RUBY_VERSION=`ruby -e 'puts RUBY_VERSION'` 34 | RUBY_PLATFORM=`ruby -e 'puts RUBY_PLATFORM'` 35 | RUBY_ENGINE=`ruby -e 'puts RUBY_ENGINE'` 36 | if [[ "$RUBY_ENGINE" = "ruby" ]] && [[ ${RUBY_VERSION:0:1} = "3" ]] && [[ ! $RUBYOPT =~ "jit" ]]; then 37 | echo "running runtime type checking..." 38 | export RUBYOPT="-rbundler/setup -rrbs/test/setup" 39 | export RBS_TEST_RAISE="true" 40 | export RBS_TEST_LOGLEVEL="error" 41 | export RBS_TEST_OPT="-Isig -rbase64" 42 | export RBS_TEST_TARGET="HTTP2*" 43 | fi 44 | bundle exec rake 45 | - name: Upload coverage 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: coverage-${{matrix.ruby}} 49 | path: coverage/ 50 | include-hidden-files: true 51 | 52 | coverage: 53 | needs: test 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - name: Setup Ruby 60 | uses: ruby/setup-ruby@v1 61 | with: 62 | ruby-version: 3.3 63 | bundler-cache: true 64 | 65 | - name: Download coverage results 66 | uses: actions/download-artifact@v4 67 | with: 68 | pattern: coverage-* 69 | path: coverage 70 | 71 | - name: coverage 72 | env: 73 | CI: 1 74 | run: | 75 | find coverage -name "*resultset.json" -exec sed -i 's?${{ github.workspace }}?'`pwd`'?' {} \; 76 | bundle exec rake coverage:report 77 | 78 | - uses: joshmfrankel/simplecov-check-action@main 79 | with: 80 | github_token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | branches: 8 | - main 9 | paths: 10 | - lib/http/2/version.rb 11 | 12 | permissions: 13 | contents: read 14 | id-token: write 15 | 16 | jobs: 17 | release: 18 | if: github.event.pull_request.merged == true 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | bundler-cache: true 27 | ruby-version: 3.4 28 | 29 | - name: Publish to RubyGems 30 | uses: rubygems/release-gem@v1 31 | 32 | - name: Create GitHub release 33 | run: | 34 | tag_name="$(git describe --tags --abbrev=0)" 35 | gh release create "${tag_name}" --verify-tag --draft --generate-notes pkg/*.gem 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | Gemfile.lock 3 | *.gem 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | plugins: 4 | - rubocop-performance 5 | 6 | AllCops: 7 | NewCops: enable 8 | TargetRubyVersion: 2.7 9 | DisplayCopNames: true 10 | Exclude: 11 | - 'bin/**' 12 | - 'vendor/**/*' 13 | - '**/huffman_statemachine.rb' 14 | 15 | Layout/HeredocIndentation: 16 | Exclude: 17 | - 'lib/tasks/generate_huffman_table.rb' 18 | - 'example/*' 19 | 20 | 21 | Metrics/BlockLength: 22 | Enabled: false 23 | 24 | Metrics/PerceivedComplexity: 25 | Enabled: false 26 | 27 | Lint/EmptyWhen: 28 | Enabled: false 29 | 30 | Style/StringLiterals: 31 | EnforcedStyle: double_quotes 32 | 33 | Style/NumericPredicate: 34 | Enabled: false 35 | 36 | Gemspec/RequiredRubyVersion: 37 | Enabled: false 38 | 39 | Bundler/DuplicatedGem: 40 | Enabled: false 41 | 42 | Style/OptionalBooleanParameter: 43 | Enabled: false 44 | 45 | Style/ArgumentsForwarding: 46 | Enabled: false 47 | 48 | Lint/MissingSuper: 49 | Exclude: 50 | - 'lib/httpx/io/unix.rb' 51 | 52 | Style/HashTransformValues: 53 | Exclude: 54 | - 'lib/httpx/plugins/digest_authentication.rb' 55 | 56 | Lint/ConstantDefinitionInBlock: 57 | Exclude: 58 | - 'spec/**/*' 59 | 60 | # TODO: remove this if min supported version of ruby is 2.3 61 | Style/HashSyntax: 62 | Enabled: false 63 | 64 | Style/AndOr: 65 | Enabled: false 66 | 67 | Style/SafeNavigation: 68 | Enabled: false 69 | 70 | Naming/MethodParameterName: 71 | Enabled: false 72 | 73 | Naming/VariableNumber: 74 | Exclude: 75 | - example/server.rb 76 | 77 | Layout/LineLength: 78 | Max: 128 79 | 80 | Style/HashEachMethods: 81 | Enabled: true 82 | 83 | Style/HashTransformKeys: 84 | Enabled: true 85 | 86 | Style/CommentAnnotation: 87 | Enabled: false 88 | 89 | Style/SlicingWithRange: 90 | Enabled: false 91 | 92 | Lint/SuppressedException: 93 | Exclude: 94 | - Rakefile 95 | 96 | Lint/EmptyBlock: 97 | Exclude: 98 | - spec/* 99 | 100 | Performance/CollectionLiteralInLoop: 101 | Exclude: 102 | - spec/* 103 | 104 | Performance/MethodObjectAsBlock: 105 | Enabled: false 106 | 107 | Metrics/CollectionLiteralLength: 108 | Exclude: 109 | - lib/http/2/header/huffman.rb -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2016-06-09 10:57:54 -0400 using RuboCop version 0.40.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 24 10 | Metrics/AbcSize: 11 | Max: 200 12 | 13 | # Offense count: 16 14 | Metrics/BlockNesting: 15 | Max: 5 16 | Exclude: 17 | - "lib/http/2/connection.rb" 18 | 19 | # Offense count: 5 20 | # Configuration parameters: CountComments. 21 | Metrics/ClassLength: 22 | Enabled: false 23 | 24 | Metrics/ModuleLength: 25 | Enabled: false 26 | 27 | # Offense count: 12 28 | Metrics/CyclomaticComplexity: 29 | Max: 60 30 | 31 | # Offense count: 29 32 | # Configuration parameters: CountComments. 33 | Metrics/MethodLength: 34 | Enabled: false 35 | 36 | # Offense count: 1 37 | # Configuration parameters: CountKeywordArgs. 38 | Metrics/ParameterLists: 39 | Max: 7 40 | 41 | # Offense count: 10 42 | Metrics/PerceivedComplexity: 43 | Max: 50 44 | 45 | # Offense count: 4 46 | Style/Documentation: 47 | Enabled: false 48 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SimpleCov.start do 4 | command_name "Spec" 5 | add_filter "/.bundle/" 6 | add_filter "/vendor/" 7 | add_filter "/spec/" 8 | add_filter "/lib/http/2/base64" 9 | coverage_dir "coverage" 10 | minimum_coverage(RUBY_ENGINE == "truffleruby" ? 85 : 90) 11 | end 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.1 2 | 3 | ### Bugfixes 4 | 5 | * frame buffer was accidentally changing encoding before header packing, which raise invalid compatible encoding errors, due to usage of "String#". this was fixed by using internal `append_str´, which does not touch encoding, and calling `String.force_encoding` in case the buffer is a mutable string passed by the user. 6 | * dup PING frame payload passed by the user; while not really resulting in invalid encoding, the change of the input string could surprise the caller, since this would be expected to be stored somewhere so the peer PING frame can be matched on receive. 7 | 8 | 9 | ### Improvements 10 | 11 | Simplified `String#transition`, making sure it only does state machine transitions (the rest is handled outside of it). 12 | 13 | ## 1.1.0 14 | 15 | Several changes which improved performance for the common cases. A few highlights: 16 | 17 | * Opting into ruby 3.4 features when possible (such as `String#append_as_bytes` instead of `String#<<`) 18 | * reducing string and array allocations on several places (connection management, frame generation, hpack header compression, etc) 19 | * "streams recently closed" not having to regenerate the list when not necessary 20 | 21 | ## 1.0.2 22 | 23 | ### Improvements 24 | 25 | * Freezing static tables (used for header huffman coding) correctly. This makes them shareable, which makes `http-2` usable across ractors. 26 | * Moved buffer helpers from String refinements into mixins. Refinements impose a relevant performance penalty, unfortunately, despite its cleaner API. 27 | 28 | ## 1.0.1 29 | 30 | ### Improvements 31 | 32 | * discard closed streams from the connection (reduces memory consumption). 33 | 34 | ### Bugfixes 35 | 36 | * allow RST_STREAM frames to be ignored on closed streams. 37 | * prevent already closed streams from being initialized again. 38 | 39 | ## 1.0.0 40 | 41 | ### Breaking changes 42 | 43 | Set ruby 2.7 as the lowest supported ruby version. 44 | 45 | There are no public API breaking changes. 46 | 47 | ### Improvements 48 | 49 | * it passes the h2spec compliance suite. 50 | * RBS signatures. 51 | * ruby 3.3: Backporting required `base64` lib support (`base64` will no longer be in standard lib) 52 | * Using the `:buffer` kwarg from `Array#pack` to reduce string allocations 53 | * Using `#drop_while` enumerable function to drop timed out recently closed streams, which reduced the complexity of it from O(n) to O(log n), making a difference in a few benchmarks. 54 | * optimization for header decompression of static headers. 55 | * it was identified that traversing the static headers table for each incoming header was one of the bottlenecks, and it was O(n) for all cases where there was not an exact match. In order to circumvent this, an additional table derived from the static headers table with the header field as lookup key was created, which eliminated the main bottleneck (at the cost of roughly 1.5Kb extra). 56 | * `HTTPX::Buffer` has been removed, and was replaced by `String` usage with an enhanced API via refinements. 57 | * Using `String#byteslice` in significant chunks of the parsing process. 58 | * Removed usage of `Time.now` and replaced it with monotonic time calculations. 59 | * avoid needless header downcase calls. 60 | * using class_eval instead of define_method for performant lookups. 61 | * support for the ORIGIN frame. 62 | 63 | ### Bugfixes 64 | 65 | * force-encode data payloads to ascii while creating DATA frames. 66 | * fixed "string too short" error when reading headers with no value. 67 | * fixed HTTP/2 trailers (particularly the case where an end-headers flag is sent before the data, and another after data, which also closes the stream, which is valid spec-wise). 68 | * fixed comparison on callbacks when the returned value overwrite `eql?`. 69 | * bugfix: fixed bookkeeping of recently-closed streams. 70 | * bugfix: wrong window update accounting check causing random flow control errors. 71 | * bugfix: allow stream to send empty end-stream DATA frame even if remote window is exhausted. 72 | * fix: the connection window was being updated when receiving WINDOW_UPDATEs for a stream. 73 | * bugfix: do not update connection remote window on SETTINGS frame (aka the Cloudfront issue). 74 | * do not close stream when receiving frames from streams we've refused locally 75 | 76 | ### Chore 77 | 78 | Removing `base64` library usage for ruby 3.3 or higher (as it's going to be removed from bundled gems). 79 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rake", require: false 8 | 9 | group :development do 10 | gem "pry" 11 | gem "pry-byebug", platform: :mri 12 | if RUBY_VERSION >= "3.0.0" 13 | gem "rubocop" 14 | gem "rubocop-performance" 15 | end 16 | end 17 | 18 | group :docs do 19 | gem "yard" 20 | end 21 | 22 | group :test do 23 | gem "rspec" 24 | gem "simplecov", require: false 25 | end 26 | 27 | group :types do 28 | platform :mri do 29 | if RUBY_VERSION >= "3.0.0" 30 | gem "rbs" 31 | gem "steep" 32 | gem "typeprof" 33 | end 34 | end 35 | end 36 | 37 | group :benchmark do 38 | platform :mri do 39 | gem "memory_profiler" 40 | gem "singed" 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013 Ilya Grigorik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP-2 2 | 3 | [![Gem Version](https://badge.fury.io/rb/http-2.svg)](http://rubygems.org/gems/http-2) 4 | [![Build status](https://github.com/igrigorik/http-2/actions/workflows/ci.yml/badge.svg)](https://github.com/igrigorik/http-2) 5 | 6 | Pure Ruby, framework and transport agnostic, implementation of HTTP/2 protocol and HPACK header compression with support for: 7 | 8 | 9 | * [Binary framing](https://hpbn.co/http2/#binary-framing-layer) parsing and encoding 10 | * [Stream multiplexing](https://hpbn.co/http2/#streams-messages-and-frames) and [prioritization](https://hpbn.co/http2/#stream-prioritization) 11 | * Connection and stream [flow control](https://hpbn.co/http2/#flow-control) 12 | * [Header compression](https://hpbn.co/http2/#header-compression) and [server push](https://hpbn.co/http2/#server-push) 13 | * Connection and stream management 14 | * And more... see [API docs](https://www.rubydoc.info/gems/http-2) 15 | 16 | Protocol specifications: 17 | 18 | * [Hypertext Transfer Protocol Version 2 (RFC 7540)](https://httpwg.github.io/specs/rfc7540.html) 19 | * [HPACK: Header Compression for HTTP/2 (RFC 7541)](https://httpwg.github.io/specs/rfc7541.html) 20 | 21 | 22 | ## Getting started 23 | 24 | ```bash 25 | $> gem install http-2 26 | ``` 27 | 28 | This implementation makes no assumptions as how the data is delivered: it could be a regular Ruby TCP socket, your custom eventloop, or whatever other transport you wish to use - e.g. ZeroMQ, [avian carriers](http://www.ietf.org/rfc/rfc1149.txt), etc. 29 | 30 | Your code is responsible for feeding data into the parser, which performs all of the necessary HTTP/2 decoding, state management and the rest, and vice versa, the parser will emit bytes (encoded HTTP/2 frames) that you can then route to the destination. Roughly, this works as follows: 31 | 32 | ```ruby 33 | require 'http/2' 34 | 35 | socket = YourTransport.new 36 | 37 | conn = HTTP2::Client.new 38 | conn.on(:frame) {|bytes| socket << bytes } 39 | 40 | while bytes = socket.read 41 | conn << bytes 42 | end 43 | ``` 44 | 45 | Checkout provided [client](example/client.rb) and [server](example/server.rb) implementations for basic examples. 46 | 47 | 48 | ### Connection lifecycle management 49 | 50 | Depending on the role of the endpoint you must initialize either a [Client](lib/http/2/client.rb) or a [Server](lib/http/2/server.rb) object. Doing so picks the appropriate header compression / decompression algorithms and stream management logic. From there, you can subscribe to connection level events, or invoke appropriate APIs to allocate new streams and manage the lifecycle. For example: 51 | 52 | ```ruby 53 | # - Server --------------- 54 | server = HTTP2::Server.new 55 | 56 | server.on(:stream) { |stream| ... } # process inbound stream 57 | server.on(:frame) { |bytes| ... } # encoded HTTP/2 frames 58 | 59 | server.ping { ... } # run liveness check, process pong response 60 | server.goaway # send goaway frame to the client 61 | 62 | # - Client --------------- 63 | client = HTTP2::Client.new 64 | client.on(:promise) { |stream| ... } # process push promise 65 | 66 | stream = client.new_stream # allocate new stream 67 | stream.headers({':method' => 'post', ...}, end_stream: false) 68 | stream.data(payload, end_stream: true) 69 | ``` 70 | 71 | Events emitted by the connection object: 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
:promiseclient role only, fires once for each new push promise
:streamserver role only, fires once for each new client stream
:framefires once for every encoded HTTP/2 frame that needs to be sent to the peer
87 | 88 | 89 | ### Stream lifecycle management 90 | 91 | A single HTTP/2 connection can [multiplex multiple streams](https://hpbn.co/http2/#request-and-response-multiplexing) in parallel: multiple requests and responses can be in flight simultaneously and stream data can be interleaved and prioritized. Further, the specification provides a well-defined lifecycle for each stream (see below). 92 | 93 | The good news is, all of the stream management, and state transitions, and error checking is handled by the library. All you have to do is subscribe to appropriate events (marked with ":" prefix in diagram below) and provide your application logic to handle request and response processing. 94 | 95 | ``` 96 | +--------+ 97 | PP | | PP 98 | ,--------| idle |--------. 99 | / | | \ 100 | v +--------+ v 101 | +----------+ | +----------+ 102 | | | | H | | 103 | ,---|:reserved | | |:reserved |---. 104 | | | (local) | v | (remote) | | 105 | | +----------+ +--------+ +----------+ | 106 | | | :active | | :active | | 107 | | | ,-------|:active |-------. | | 108 | | | H / ES | | ES \ H | | 109 | | v v +--------+ v v | 110 | | +-----------+ | +-----------+ | 111 | | |:half_close| | |:half_close| | 112 | | | (remote) | | | (local) | | 113 | | +-----------+ | +-----------+ | 114 | | | v | | 115 | | | ES/R +--------+ ES/R | | 116 | | `----------->| |<-----------' | 117 | | R | :close | R | 118 | `-------------------->| |<--------------------' 119 | +--------+ 120 | ``` 121 | 122 | For sake of example, let's take a look at a simple server implementation: 123 | 124 | ```ruby 125 | conn = HTTP2::Server.new 126 | 127 | # emits new streams opened by the client 128 | conn.on(:stream) do |stream| 129 | stream.on(:active) { } # fires when stream transitions to open state 130 | stream.on(:close) { } # stream is closed by client and server 131 | 132 | stream.on(:headers) { |head| ... } # header callback 133 | stream.on(:data) { |chunk| ... } # body payload callback 134 | 135 | # fires when client terminates its request (i.e. request finished) 136 | stream.on(:half_close) do 137 | 138 | # ... generate_response 139 | 140 | # send response 141 | stream.headers({ 142 | ":status" => 200, 143 | "content-type" => "text/plain" 144 | }) 145 | 146 | # split response between multiple DATA frames 147 | stream.data(response_chunk, end_stream: false) 148 | stream.data(last_chunk) 149 | end 150 | end 151 | ``` 152 | 153 | Events emitted by the [Stream object](lib/http/2/stream.rb): 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 |
:reservedfires exactly once when a push stream is initialized
:activefires exactly once when the stream become active and is counted towards the open stream limit
:headersfires once for each received header block (multi-frame blocks are reassembled before emitting this event)
:datafires once for every DATA frame (no buffering)
:half_closefires exactly once when the opposing peer closes its end of connection (e.g. client indicating that request is finished, or server indicating that response is finished)
:closefires exactly once when both peers close the stream, or if the stream is reset
:priorityfires once for each received priority update (server only)
185 | 186 | 187 | ### Prioritization 188 | 189 | Each HTTP/2 [stream has a priority value](https://hpbn.co/http2/#stream-prioritization) that can be sent when the new stream is initialized, and optionally reprioritized later: 190 | 191 | ```ruby 192 | client = HTTP2::Client.new 193 | 194 | default_priority_stream = client.new_stream 195 | custom_priority_stream = client.new_stream(priority: 42) 196 | 197 | # sometime later: change priority value 198 | custom_priority_stream.reprioritize(32000) # emits PRIORITY frame 199 | ``` 200 | 201 | On the opposite side, the server can optimize its stream processing order or resource allocation by accessing the stream priority value (`stream.priority`). 202 | 203 | 204 | ### Flow control 205 | 206 | Multiplexing multiple streams over the same TCP connection introduces contention for shared bandwidth resources. Stream priorities can help determine the relative order of delivery, but priorities alone are insufficient to control how the resource allocation is performed between multiple streams. To address this, HTTP/2 provides a simple mechanism for [stream and connection flow control](https://hpbn.co/http2/#flow-control). 207 | 208 | Connection and stream flow control is handled by the library: all streams are initialized with the default window size (64KB), and send/receive window updates are automatically processed - i.e. window is decremented on outgoing data transfers, and incremented on receipt of window frames. Similarly, if the window is exceeded, then data frames are automatically buffered until window is updated. 209 | 210 | The only thing left is for your application to specify the logic as to when to emit window updates: 211 | 212 | ```ruby 213 | conn.buffered_amount # check amount of buffered data 214 | conn.window # check current window size 215 | conn.window_update(1024) # increment connection window by 1024 bytes 216 | 217 | stream.buffered_amount # check amount of buffered data 218 | stream.window # check current window size 219 | stream.window_update(2048) # increment stream window by 2048 bytes 220 | ``` 221 | 222 | 223 | ### Server push 224 | 225 | An HTTP/2 server can [send multiple replies](https://hpbn.co/http2/#server-push) to a single client request. To do so, first it emits a "push promise" frame which contains the headers of the promised resource, followed by the response to the original request, as well as promised resource payloads (which may be interleaved). A simple example is in order: 226 | 227 | ```ruby 228 | conn = HTTP2::Server.new 229 | 230 | conn.on(:stream) do |stream| 231 | stream.on(:headers) { |head| ... } 232 | stream.on(:data) { |chunk| ... } 233 | 234 | # fires when client terminates its request (i.e. request finished) 235 | stream.on(:half_close) do 236 | promise_header = { ':method' => 'GET', 237 | ':authority' => 'localhost', 238 | ':scheme' => 'https', 239 | ':path' => "/other_resource" } 240 | 241 | # initiate server push stream 242 | push_stream = nil 243 | stream.promise(promise_header) do |push| 244 | push.headers({...}) 245 | push_stream = push 246 | end 247 | 248 | # send response 249 | stream.headers({ 250 | ":status" => 200, 251 | "content-type" => "text/plain" 252 | }) 253 | 254 | # split response between multiple DATA frames 255 | stream.data(response_chunk, end_stream: false) 256 | stream.data(last_chunk) 257 | 258 | # now send the previously promised data 259 | push_stream.data(push_data) 260 | end 261 | end 262 | ``` 263 | 264 | When a new push promise stream is sent by the server, the client is notified via the `:promise` event: 265 | 266 | ```ruby 267 | conn = HTTP2::Client.new 268 | conn.on(:promise) do |push| 269 | # process push stream 270 | end 271 | ``` 272 | 273 | The client can cancel any given push stream (via `.close`), or disable server push entirely by sending the appropriate settings frame: 274 | 275 | ```ruby 276 | client.settings(settings_enable_push: 0) 277 | ``` 278 | ### Specs 279 | 280 | To run specs: 281 | 282 | ```ruby 283 | rake 284 | ``` 285 | 286 | ### License 287 | 288 | (MIT License) - Copyright (c) 2013-2019 Ilya Grigorik ![GA](https://www.google-analytics.com/__utm.gif?utmac=UA-71196-9&utmhn=github.com&utmdt=HTTP2&utmp=/http-2/readme) 289 | (MIT License) - Copyright (c) 2019 Tiago Cardoso ![GA](https://www.google-analytics.com/__utm.gif?utmac=UA-71196-9&utmhn=github.com&utmdt=HTTP2&utmp=/http-2/readme) 290 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "English" 4 | require "bundler/gem_tasks" 5 | require "open3" 6 | 7 | require_relative "tasks/generate_huffman_table" 8 | 9 | RUBY_MAJOR_MINOR = RUBY_VERSION.split(".").first(2).join(".") 10 | 11 | begin 12 | require "rspec/core/rake_task" 13 | RSpec::Core::RakeTask.new(:spec) do |t| 14 | t.exclude_pattern = "./spec/hpack_test_spec.rb" 15 | end 16 | 17 | RSpec::Core::RakeTask.new(:hpack) do |t| 18 | t.pattern = "./spec/hpack_test_spec.rb" 19 | end 20 | rescue LoadError 21 | end 22 | 23 | begin 24 | require "rubocop/rake_task" 25 | desc "Run rubocop" 26 | RuboCop::RakeTask.new 27 | rescue LoadError 28 | end 29 | 30 | begin 31 | require "yard" 32 | YARD::Rake::YardocTask.new 33 | rescue LoadError 34 | end 35 | 36 | namespace :coverage do 37 | desc "Aggregates coverage reports" 38 | task :report do 39 | return unless ENV.key?("CI") 40 | 41 | require "simplecov" 42 | 43 | puts Dir["coverage/**/.resultset.json"].inspect 44 | SimpleCov.collate Dir["coverage/**/.resultset.json"] 45 | end 46 | end 47 | 48 | desc "install h2spec" 49 | task :h2spec_install do 50 | platform = case RUBY_PLATFORM 51 | when /darwin/ 52 | "h2spec_darwin_amd64.tar.gz" 53 | when /cygwin|mswin|mingw|bccwin|wince|emx/ 54 | "h2spec_windows_amd64.zip" 55 | else 56 | "h2spec_linux_amd64.tar.gz" 57 | end 58 | # uri = "https://github.com/summerwind/h2spec/releases/download/v2.3.0/#{platform}" 59 | 60 | tar_location = File.join(__dir__, "h2spec-releases", platform) 61 | # require "net/http" 62 | # File.open(tar_location, "wb") do |file| 63 | # response = nil 64 | # loop do 65 | # uri = URI(uri) 66 | # http = Net::HTTP.new(uri.host, uri.port) 67 | # http.use_ssl = true 68 | # # http.set_debug_output($stderr) 69 | # response = http.get(uri.request_uri) 70 | # break unless response.is_a?(Net::HTTPRedirection) 71 | 72 | # uri = response["location"] 73 | # end 74 | # file.write(response.body) 75 | # end 76 | 77 | case RUBY_PLATFORM 78 | when /cygwin|mswin|mingw|bccwin|wince|emx/ 79 | puts "Hi, you're on Windows, please unzip this file: #{tar_location}" 80 | when /darwin/ 81 | system("gunzip -c #{tar_location} | tar -xvzf -") 82 | else 83 | system("tar -xvzf #{tar_location} h2spec") 84 | end 85 | # FileUtils.rm(tar_location) 86 | end 87 | 88 | desc "run h2spec" 89 | task :h2spec do 90 | h2spec = File.join(__dir__, "h2spec") 91 | unless File.exist?(h2spec) 92 | abort 'Please install h2spec first.\n' \ 93 | 'Run "rake h2spec_install",\n' \ 94 | "Or Download the binary from https://github.com/summerwind/h2spec/releases" 95 | end 96 | 97 | server_pid = Process.spawn("ruby example/server.rb -p 9000", out: File::NULL) 98 | sleep RUBY_ENGINE == "ruby" ? 5 : 20 99 | system("#{h2spec} -p 9000 -o 2 --strict") 100 | Process.kill("TERM", server_pid) 101 | exit($CHILD_STATUS.exitstatus) 102 | end 103 | 104 | default_tasks = %i[spec] 105 | default_tasks << :rubocop if defined?(RuboCop) && RUBY_ENGINE == "ruby" 106 | default_tasks += %i[h2spec_install h2spec] if ENV.key?("CI") 107 | task default: default_tasks 108 | task all: %i[default hpack] 109 | -------------------------------------------------------------------------------- /benchmarks/client_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "uri" 4 | require "http/2" 5 | 6 | DEBUG = ENV.key?("DEBUG") 7 | BENCHMARK = ENV.fetch("BENCH", "profile") 8 | ITERATIONS = 5000 9 | 10 | METHOD = "GET" 11 | BODY = "bang" 12 | URL = URI.parse(ARGV[0] || "http://localhost:8080/") 13 | CLIENT = HTTP2::Client.new 14 | SERVER = HTTP2::Server.new 15 | 16 | CLIENT_BUFFER = "".b 17 | SERVER_BUFFER = "".b 18 | 19 | def log 20 | return unless DEBUG 21 | 22 | puts yield 23 | end 24 | 25 | log { "build client..." } 26 | CLIENT.on(:frame) do |bytes| 27 | log { "(client) sending bytes: #{bytes.size}" } 28 | CLIENT_BUFFER << bytes 29 | end 30 | CLIENT.on(:frame_sent) do |frame| 31 | log { "(client) Sent frame: #{frame.inspect}" } 32 | end 33 | CLIENT.on(:frame_received) do |frame| 34 | log { "(client) Received frame: #{frame.inspect}" } 35 | end 36 | 37 | CLIENT.on(:altsvc) do |f| 38 | log { "(client) received ALTSVC #{f}" } 39 | end 40 | 41 | log { "build server..." } 42 | SERVER.on(:frame) do |bytes| 43 | log { "(server) sending bytes: #{bytes.bytesize}" } 44 | SERVER_BUFFER << bytes 45 | end 46 | SERVER.on(:frame_sent) do |frame| 47 | log { "(server) Sent frame: #{frame.inspect}" } 48 | end 49 | SERVER.on(:frame_received) do |frame| 50 | log { "(server) Received frame: #{frame.inspect}" } 51 | end 52 | 53 | SERVER.on(:goaway) do 54 | log { "(server) goaway received" } 55 | end 56 | 57 | SERVER.on(:stream) do |stream| 58 | req = {} 59 | buffer = "".b 60 | 61 | stream.on(:active) { log { "(server stream:#{stream.id}) client opened new stream" } } 62 | stream.on(:close) { log { "(server stream:#{stream.id}) stream closed" } } 63 | 64 | stream.on(:headers) do |h| 65 | log { "(server stream:#{stream.id}) request headers: #{Hash[*h.flatten]}" } 66 | end 67 | 68 | stream.on(:data) do |d| 69 | log { "(server stream:#{stream.id}) payload chunk: <<#{d}>>" } 70 | buffer << d 71 | end 72 | 73 | stream.on(:half_close) do 74 | log { "(server stream:#{stream.id}) client closed its end of the stream" } 75 | 76 | response = nil 77 | if req[":method"] == "POST" 78 | log { "(server stream:#{stream.id}) Received POST request, payload: #{buffer}" } 79 | response = "(server stream:#{stream.id}) Hello HTTP 2.0! POST payload: #{buffer}" 80 | else 81 | log { "Received GET request" } 82 | response = "(server stream:#{stream.id}) Hello HTTP 2.0! GET request" 83 | end 84 | 85 | stream.headers( 86 | { 87 | ":status" => "200", 88 | "content-length" => response.bytesize.to_s, 89 | "content-type" => "text/plain", 90 | "x-stream-id" => "stream-#{stream.id}" 91 | }, end_stream: false 92 | ) 93 | 94 | # split response into multiple DATA frames 95 | stream.data(response[0, 5], end_stream: false) 96 | stream.data(response[5, -1] || "") 97 | end 98 | end 99 | 100 | def send_request 101 | stream = CLIENT.new_stream 102 | 103 | stream.on(:close) do 104 | log { "(client stream:#{stream.id}) stream closed" } 105 | end 106 | 107 | stream.on(:half_close) do 108 | log { "(client stream:#{stream.id}) closing client-end of the stream" } 109 | end 110 | 111 | stream.on(:headers) do |h| 112 | log { "(client stream:#{stream.id}) response headers: #{h}" } 113 | end 114 | 115 | stream.on(:data) do |d| 116 | log { "(client stream:#{stream.id}) response data chunk: <<#{d}>>" } 117 | end 118 | 119 | stream.on(:altsvc) do |f| 120 | log { "(client stream:#{stream.id}) received ALTSVC #{f}" } 121 | end 122 | 123 | head = { 124 | ":scheme" => URL.scheme, 125 | ":method" => METHOD, 126 | ":authority" => [URL.host, URL.port].join(":"), 127 | ":path" => URL.path, 128 | "accept" => "*/*" 129 | } 130 | 131 | log { "Sending HTTP 2.0 request" } 132 | 133 | if head[":method"] == "GET" 134 | stream.headers(head, end_stream: true) 135 | else 136 | stream.headers(head, end_stream: false) 137 | stream.data(BODY) 138 | end 139 | 140 | until CLIENT_BUFFER.empty? && SERVER_BUFFER.empty? 141 | unless CLIENT_BUFFER.empty? 142 | SERVER << CLIENT_BUFFER 143 | CLIENT_BUFFER.clear 144 | end 145 | 146 | unless SERVER_BUFFER.empty? 147 | CLIENT << SERVER_BUFFER 148 | SERVER_BUFFER.clear 149 | end 150 | end 151 | end 152 | 153 | def benchmark(bench_type, &block) 154 | return yield if DEBUG 155 | 156 | case bench_type 157 | when "profile" 158 | require "singed" 159 | Singed.output_directory = "tmp/" 160 | 161 | flamegraph(&block) 162 | when "memory" 163 | require "memory_profiler" 164 | MemoryProfiler.report(allow_files: ["lib/http/2"], &block).pretty_print 165 | 166 | when "benchmark" 167 | require "benchmark" 168 | puts Benchmark.measure(&block) 169 | end 170 | end 171 | 172 | GC.start 173 | GC.disable 174 | 175 | puts "warmup..." 176 | ITERATIONS.times do 177 | # start client stream 178 | send_request 179 | end 180 | 181 | puts "bench!" 182 | # Benchmark.bmbm do |x| 183 | benchmark(BENCHMARK) do 184 | ITERATIONS.times do 185 | # start client stream 186 | send_request 187 | end 188 | 189 | CLIENT.goaway 190 | end 191 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "http_parser.rb" 6 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## Interop 2 | 3 | First, a quick test to ensure that we can talk to ourselves: 4 | 5 | ```bash 6 | # Direct connection 7 | $> ruby server.rb 8 | $> ruby client.rb http://localhost:8080/ # GET 9 | $> ruby client.rb http://localhost:8080/ -d 'some data' # POST 10 | 11 | # Server push 12 | $> ruby server.rb --push 13 | $> ruby client.rb http://localhost:8080/ # GET 14 | 15 | # TLS + NPN negotiation 16 | $> ruby server.rb --secure 17 | $> ruby client.rb https://localhost:8080/ # GET 18 | $> ... 19 | ``` 20 | 21 | ### [nghttp2](https://github.com/tatsuhiro-t/nghttp2) (HTTP/2.0 C Library) 22 | 23 | Public test server: http://106.186.112.116 (Upgrade + Direct) 24 | 25 | ```bash 26 | # Direct request (http-2 > nghttp2) 27 | $> ruby client.rb http://106.186.112.116/ 28 | 29 | # TLS + NPN request (http-2 > nghttp2) 30 | $> ruby client.rb https://106.186.112.116/ 31 | 32 | # Direct request (nghttp2 > http-2) 33 | $> ruby server.rb 34 | $> nghttp -vnu http://localhost:8080 # Direct request to Ruby server 35 | ``` 36 | 37 | ### Twitter (Java server) 38 | 39 | ```bash 40 | # NPN + GET request (http-2 > twitter) 41 | $> ruby client.rb https://twitter.com/ 42 | ``` 43 | 44 | For a complete list of current implementations, see [http2 wiki](https://github.com/http2/http2-spec/wiki/Implementations). 45 | -------------------------------------------------------------------------------- /example/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | options = {} 6 | OptionParser.new do |opts| 7 | opts.banner = "Usage: client.rb [options]" 8 | 9 | opts.on("-d", "--data [String]", "HTTP payload") do |v| 10 | options[:payload] = v 11 | end 12 | end.parse! 13 | 14 | uri = URI.parse(ARGV[0] || "http://localhost:8080/") 15 | tcp = TCPSocket.new(uri.host, uri.port) 16 | sock = nil 17 | 18 | if uri.scheme == "https" 19 | ctx = OpenSSL::SSL::SSLContext.new 20 | ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE 21 | 22 | # For ALPN support, Ruby >= 2.3 and OpenSSL >= 1.0.2 are required 23 | 24 | ctx.alpn_protocols = [DRAFT] 25 | ctx.alpn_select_cb = lambda do |protocols| 26 | puts "ALPN protocols supported by server: #{protocols}" 27 | DRAFT if protocols.include? DRAFT 28 | end 29 | 30 | sock = OpenSSL::SSL::SSLSocket.new(tcp, ctx) 31 | sock.sync_close = true 32 | sock.hostname = uri.hostname 33 | sock.connect 34 | 35 | if sock.alpn_protocol != DRAFT 36 | puts "Failed to negotiate #{DRAFT} via ALPN" 37 | exit 38 | end 39 | else 40 | sock = tcp 41 | end 42 | 43 | conn = HTTP2::Client.new 44 | stream = conn.new_stream 45 | log = Logger.new(stream.id) 46 | 47 | conn.on(:frame) do |bytes| 48 | # puts "Sending bytes: #{bytes.unpack("H*").first}" 49 | sock.print bytes 50 | sock.flush 51 | end 52 | conn.on(:frame_sent) do |frame| 53 | puts "Sent frame: #{frame.inspect}" 54 | end 55 | conn.on(:frame_received) do |frame| 56 | puts "Received frame: #{frame.inspect}" 57 | end 58 | 59 | conn.on(:promise) do |promise| 60 | promise.on(:promise_headers) do |h| 61 | log.info "promise request headers: #{h}" 62 | end 63 | 64 | promise.on(:headers) do |h| 65 | log.info "promise headers: #{h}" 66 | end 67 | 68 | promise.on(:data) do |d| 69 | log.info "promise data chunk: <<#{d.size}>>" 70 | end 71 | end 72 | 73 | conn.on(:altsvc) do |f| 74 | log.info "received ALTSVC #{f}" 75 | end 76 | 77 | stream.on(:close) do 78 | log.info "stream closed" 79 | conn.goaway 80 | end 81 | 82 | stream.on(:half_close) do 83 | log.info "closing client-end of the stream" 84 | end 85 | 86 | stream.on(:headers) do |h| 87 | log.info "response headers: #{h}" 88 | end 89 | 90 | stream.on(:data) do |d| 91 | log.info "response data chunk: <<#{d}>>" 92 | end 93 | 94 | stream.on(:altsvc) do |f| 95 | log.info "received ALTSVC #{f}" 96 | end 97 | 98 | head = { 99 | ":scheme" => uri.scheme, 100 | ":method" => (options[:payload].nil? ? "GET" : "POST"), 101 | ":authority" => [uri.host, uri.port].join(":"), 102 | ":path" => uri.path, 103 | "accept" => "*/*" 104 | } 105 | 106 | puts "Sending HTTP 2.0 request" 107 | if head[":method"] == "GET" 108 | stream.headers(head, end_stream: true) 109 | else 110 | stream.headers(head, end_stream: false) 111 | stream.data(options[:payload]) 112 | end 113 | 114 | require "memory_profiler" 115 | report = MemoryProfiler.report(allow_files: "http-2") do 116 | while !sock.closed? && !sock.eof? 117 | data = sock.read_nonblock(1024) 118 | # puts "Received bytes: #{data.unpack("H*").first}" 119 | 120 | begin 121 | conn << data 122 | rescue StandardError => e 123 | puts "#{e.class} exception: #{e.message} - closing socket." 124 | e.backtrace.each { |l| puts "\t#{l}" } 125 | sock.close 126 | end 127 | end 128 | end 129 | 130 | report.pretty_print 131 | -------------------------------------------------------------------------------- /example/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << "lib" << "../lib" 4 | 5 | require "optparse" 6 | require "socket" 7 | require "openssl" 8 | require "uri" 9 | 10 | # This will enable coverage within the CI environment 11 | if ENV.key?("CI") 12 | require "simplecov" 13 | SimpleCov.command_name "#{RUBY_ENGINE}-#{RUBY_VERSION}-h2spec" 14 | SimpleCov.coverage_dir "coverage/#{RUBY_ENGINE}-#{RUBY_VERSION}-h2spec" 15 | end 16 | 17 | require "http/2" 18 | 19 | DRAFT = "h2" 20 | 21 | class Logger 22 | def initialize(id) 23 | @id = id 24 | end 25 | 26 | def info(msg) 27 | puts "[Stream #{@id}]: #{msg}" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /example/keys/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDLjCCAhYCCQDIZ/9hq/2pXjANBgkqhkiG9w0BAQUFADBZMQswCQYDVQQGEwJB 3 | VTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0 4 | cyBQdHkgTHRkMRIwEAYDVQQDEwlsb2NhbGhvc3QwHhcNMTYwNjA2MTk0MzI1WhcN 5 | MTcwNjA2MTk0MzI1WjBZMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0 6 | ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDEwls 7 | b2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCjFXrWqtRZ 8 | EMOO/o6AxGbgDYMgg/7uxCFQJM5Z6C4II6D8V94FDyCd+J0LOK2hB+QUdFqpA1S9 9 | 6RW2MvIwdvRt03RJMgbfcUF0+w4ZItv2xrW9waCfCmLSRDZgcSATacEF6u9p2Vs+ 10 | o4J/cHacirSwjy4+m94CgkxtUFGtGcJaFqAZ6Cdj5WvQdJSiAI3x3gNC/UGA+5dL 11 | sp8+vwWx+/TMc6nDBmoRW3GHeG/NApQSh01w3wDv0FmUaFQlA5WPya/Js+CyuYh1 12 | miXbQJEjDnGGaJjnoyRAQpPrk72Jj+bnfOu9kxpzkuLJOsbaofRFkM+/Ar5U+bQz 13 | uU0ErQ8Ih8MPAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBACL8HkKjW8kWLlW4TE5K 14 | EcfsBad2Ah5ugTocJ/pLnr/YEo8uD91gECDtsuFTXul9n2M7U5jJmzbHZ63cjyC3 15 | lb1BxJxUL7aIyaL61IMMcIJMWhC9VGnFUshMDNVBhuRkKs/QvaMD5KefKN1E9I2M 16 | mZ72Yww0VihYwNOu3MTn8gUuy9eU6k/gTYPY7PJVh18QmR+Fs2MaaPp+bDwxiqML 17 | 0o2I6+0ZsqM3vFtcUjxjRASV5s+JkM34pTWFwUOl7TZv1YsxCKSz4f0BXDImZEvU 18 | rwqFdlELp5WOG9LJsrszDRbf1wbFUsG1XXZpIBiWo3d6pOiIyRKrak1vKViNfYvI 19 | W30= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /example/keys/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAoxV61qrUWRDDjv6OgMRm4A2DIIP+7sQhUCTOWeguCCOg/Ffe 3 | BQ8gnfidCzitoQfkFHRaqQNUvekVtjLyMHb0bdN0STIG33FBdPsOGSLb9sa1vcGg 4 | nwpi0kQ2YHEgE2nBBervadlbPqOCf3B2nIq0sI8uPpveAoJMbVBRrRnCWhagGegn 5 | Y+Vr0HSUogCN8d4DQv1BgPuXS7KfPr8Fsfv0zHOpwwZqEVtxh3hvzQKUEodNcN8A 6 | 79BZlGhUJQOVj8mvybPgsrmIdZol20CRIw5xhmiY56MkQEKT65O9iY/m53zrvZMa 7 | c5LiyTrG2qH0RZDPvwK+VPm0M7lNBK0PCIfDDwIDAQABAoIBAQCZShphZsccRJ6c 8 | bPdDX9iW5vyG9qsMgPwTGdWAOrXx3pN2PZ0pwjNVaRcsMgU6JHGlLEz/Kmtf6pQG 9 | 41I0bcuI48Yc+tHs+sadD1IMHHEHP3Yau8KfWyLSI129Pvf4Z2IQjuik5LJYaVbD 10 | NNG4iMQYZS0Bmn6Oey0dXu62t0ywYa0qvbIDse/RmjTQSTipuvGg8/QEAeRGABv8 11 | Nd4Esya0zuxk6hGaNp3hkjyRkeoC7RsBVJbFSnp6gSubPdXwrJyHfySKe9jvrDG3 12 | Q/AzyHUh/6EODd5n66x0p6rq7oo9/PnLvZJY8jIGWG+aEp68RJyEgimrwll0rAWw 13 | /buqijGRAoGBANimL8407fFirmct7BceavaeJfXPK5yWiOhVX0XlJ0phAFuaAxK3 14 | 5HVT7DD+KKV66g1jtS9FUVZGDiYFHlsdsYuHVYcRmr0h5rZr941obrDwNrM9Nf9C 15 | 0uehN5+n/FaeGoQLR3V4THoP3rlkYTlLpQnI5mKA19JukXnIiJM9ARUZAoGBAMC0 16 | mcVsVuSKSFwURtQHHIufxL6SqC2kLTwIQ7exqejNYPCqCiif+ZWOmsTqbVGAGbMK 17 | Ohak4oLwN5IGCl4jNQG+vWagREkx6OXSk5NYcfoNBrOm+0UoFRzoEA85s7Dy6PuD 18 | tBucNZpt1sGauzkCSx7C8jj4ZlSwkv0XhBFfbTZnAoGBAK2wBjF+U6iq4YFM2rLq 19 | KvzOa0Z3MdKXCOmiz//cKDTEMaI+heoyzZCWmIvqpzGLqirT3gUowH23Kk6m2eBY 20 | nOdst0/S+Eha7nkfc9bFe8CUxHXMRAcCTs1ufYadCXtzw3RLCp4NtNpC8N+Wry9d 21 | CtIeYz1jaCOHi0+kSoIobT65AoGAc6hxWkJp7ITqZQlucTdLdKmRheeztKEC3TMA 22 | obGqDqWldww3SKarP431ahZhQjcmNYT/1DNmF7xhPe0OL+3llISMXJn4Ig4ogDdg 23 | h2DgF3nV+eFQkfM6qLzHVrwFE0DXgI1NffzFV0hxSoW5tL+honbStkqv8EiCEBEb 24 | HOovPCUCgYBpXuPARd2ycInAulVHijJmj2rmK7f41ZhVCWovYjcCWyeJyLIO7j+b 25 | MBJZbmwpStJhEjW64nE2zZGWg2HCBbvZz5/SXIr3fp7qVXwpn1TvB/TJDf43t0oF 26 | 3caLgyQYoQCsVHKT3cU4s3wuog/DyHKh9FtRkcJrEy7h9Rrc+ModbA== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /example/raw.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "socket" 4 | 5 | puts "Starting server on port 9000" 6 | server = TCPServer.new(9000) 7 | 8 | loop do 9 | sock = server.accept 10 | 11 | puts sock.readpartial(1024).inspect while !sock.closed? && !(begin 12 | sock.eof? 13 | rescue StandardError 14 | true 15 | end) 16 | end 17 | -------------------------------------------------------------------------------- /example/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | 5 | options = { port: 8080 } 6 | OptionParser.new do |opts| 7 | opts.banner = "Usage: server.rb [options]" 8 | 9 | opts.on("-s", "--secure", "HTTPS mode") do |v| 10 | options[:secure] = v 11 | end 12 | 13 | opts.on("-p", "--port [Integer]", "listen port") do |v| 14 | options[:port] = v 15 | end 16 | 17 | opts.on("-u", "--push", "Push message") do |_v| 18 | options[:push] = true 19 | end 20 | end.parse! 21 | 22 | puts "Starting server on port #{options[:port]}" 23 | server = TCPServer.new(options[:port]) 24 | 25 | if options[:secure] 26 | ctx = OpenSSL::SSL::SSLContext.new 27 | ctx.cert = OpenSSL::X509::Certificate.new(File.open("keys/server.crt")) 28 | ctx.key = OpenSSL::PKey::RSA.new(File.open("keys/server.key")) 29 | 30 | ctx.ssl_version = :TLSv1_2 31 | ctx.options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] 32 | ctx.ciphers = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:ciphers] 33 | 34 | ctx.alpn_protocols = ["h2"] 35 | 36 | ctx.alpn_select_cb = lambda do |protocols| 37 | raise "Protocol #{DRAFT} is required" if protocols.index(DRAFT).nil? 38 | 39 | DRAFT 40 | end 41 | 42 | ctx.ecdh_curves = "P-256" 43 | 44 | server = OpenSSL::SSL::SSLServer.new(server, ctx) 45 | end 46 | 47 | loop do 48 | sock = server.accept 49 | sock.setsockopt(:SOCKET, :RCVBUF, 2048) 50 | puts "New TCP connection!" 51 | 52 | conn = HTTP2::Server.new 53 | conn.on(:frame) do |bytes| 54 | # puts "Writing bytes: #{bytes.unpack("H*").first}" 55 | sock.write(bytes) unless sock.closed? 56 | end 57 | conn.on(:frame_sent) do |frame| 58 | puts "Sent frame: #{frame.inspect}" 59 | end 60 | conn.on(:frame_received) do |frame| 61 | puts "Received frame: #{frame.inspect}" 62 | end 63 | 64 | conn.on(:goaway) do 65 | Thread.start do 66 | sleep(1) 67 | sock.close 68 | end 69 | end 70 | 71 | conn.on(:stream) do |stream| 72 | log = Logger.new(stream.id) 73 | req = {} 74 | buffer = "".b 75 | 76 | stream.on(:active) { log.info "client opened new stream" } 77 | stream.on(:close) { log.info "stream closed" } 78 | 79 | stream.on(:headers) do |h| 80 | req = Hash[*h.flatten] 81 | log.info "request headers: #{h}" 82 | end 83 | 84 | stream.on(:data) do |d| 85 | log.info "payload chunk: <<#{d}>>" 86 | buffer << d 87 | end 88 | 89 | stream.on(:half_close) do 90 | log.info "client closed its end of the stream" 91 | 92 | response = nil 93 | if req[":method"] == "POST" 94 | log.info "Received POST request, payload: #{buffer}" 95 | response = "Hello HTTP 2.0! POST payload: #{buffer}" 96 | else 97 | log.info "Received GET request" 98 | response = "Hello HTTP 2.0! GET request" 99 | end 100 | 101 | stream.headers({ 102 | ":status" => "200", 103 | "content-length" => response.bytesize.to_s, 104 | "content-type" => "text/plain" 105 | }, end_stream: false) 106 | 107 | if options[:push] 108 | push_streams = [] 109 | 110 | # send 10 promises 111 | 10.times do |i| 112 | puts "sending push" 113 | 114 | head = { ":method" => "GET", 115 | ":authority" => "localhost", 116 | ":scheme" => "https", 117 | ":path" => "/other_resource/#{i}" } 118 | 119 | stream.promise(head) do |push| 120 | push.headers({ ":status" => "200", "content-type" => "text/plain", "content-length" => "11" }) 121 | push_streams << push 122 | end 123 | end 124 | end 125 | 126 | # split response into multiple DATA frames 127 | stream.data(response[0, 5], end_stream: false) 128 | stream.data(response[5, -1] || "") 129 | 130 | if options[:push] 131 | push_streams.each_with_index do |push, i| 132 | sleep 1 133 | push.data("push_data #{i}") 134 | end 135 | end 136 | end 137 | end 138 | 139 | while !sock.closed? && !(sock.eof? rescue true) # rubocop:disable Style/RescueModifier 140 | # puts "Received bytes: #{data.unpack("H*").first}" 141 | data = sock.readpartial(16_384) 142 | 143 | begin 144 | conn << data 145 | rescue StandardError => e 146 | puts "#{e.class} exception: #{e.message} - closing socket." 147 | puts e.backtrace 148 | sock.close 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /example/upgrade_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | require "http_parser" 5 | 6 | OptionParser.new do |opts| 7 | opts.banner = "Usage: upgrade_client.rb [options]" 8 | end.parse! 9 | 10 | uri = URI.parse(ARGV[0] || "http://localhost:8080/") 11 | sock = TCPSocket.new(uri.host, uri.port) 12 | 13 | conn = HTTP2::Client.new 14 | 15 | def request_header_hash 16 | Hash.new do |hash, key| 17 | k = key.to_s.downcase 18 | k.tr! "_", "-" 19 | _, value = hash.find { |header_key, _| header_key.downcase == k } 20 | hash[key] = value if value 21 | end 22 | end 23 | 24 | conn.on(:frame) do |bytes| 25 | sock.print bytes 26 | sock.flush 27 | end 28 | conn.on(:frame_sent) do |frame| 29 | puts "Sent frame: #{frame.inspect}" 30 | end 31 | conn.on(:frame_received) do |frame| 32 | puts "Received frame: #{frame.inspect}" 33 | end 34 | 35 | # upgrader module 36 | class UpgradeHandler 37 | UPGRADE_REQUEST = <>" 114 | end 115 | 116 | stream.on(:altsvc) do |f| 117 | log.info "received ALTSVC #{f}" 118 | end 119 | 120 | @conn.on(:promise) do |promise| 121 | promise.on(:headers) do |h| 122 | log.info "promise headers: #{h}" 123 | end 124 | 125 | promise.on(:data) do |d| 126 | log.info "promise data chunk: <<#{d.size}>>" 127 | end 128 | end 129 | 130 | @conn.on(:altsvc) do |f| 131 | log.info "received ALTSVC #{f}" 132 | end 133 | end 134 | end 135 | 136 | uh = UpgradeHandler.new(conn, sock) 137 | puts "Sending HTTP/1.1 upgrade request" 138 | uh.request(uri) 139 | 140 | while !sock.closed? && !sock.eof? 141 | data = sock.read_nonblock(1024) 142 | 143 | begin 144 | if (!uh.parsing && !uh.complete) || 145 | (uh.parsing && !uh.complete) 146 | uh << data 147 | elsif uh.complete 148 | conn << data 149 | end 150 | rescue StandardError => e 151 | puts "#{e.class} exception: #{e.message} - closing socket." 152 | e.backtrace.each { |l| puts "\t#{l}" } 153 | conn.close 154 | sock.close 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /example/upgrade_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "helper" 4 | require "http_parser" 5 | 6 | options = { port: 8080 } 7 | OptionParser.new do |opts| 8 | opts.banner = "Usage: server.rb [options]" 9 | 10 | opts.on("-s", "--secure", "HTTPS mode") do |v| 11 | options[:secure] = v 12 | end 13 | 14 | opts.on("-p", "--port [Integer]", "listen port") do |v| 15 | options[:port] = v 16 | end 17 | end.parse! 18 | 19 | puts "Starting server on port #{options[:port]}" 20 | server = TCPServer.new(options[:port]) 21 | 22 | if options[:secure] 23 | ctx = OpenSSL::SSL::SSLContext.new 24 | ctx.cert = OpenSSL::X509::Certificate.new(File.open("keys/server.crt")) 25 | ctx.key = OpenSSL::PKey::RSA.new(File.open("keys/server.key")) 26 | ctx.npn_protocols = [DRAFT] 27 | 28 | server = OpenSSL::SSL::SSLServer.new(server, ctx) 29 | end 30 | 31 | def request_header_hash 32 | Hash.new do |hash, key| 33 | k = key.to_s.downcase 34 | k.tr! "_", "-" 35 | _, value = hash.find { |header_key, _| header_key.downcase == k } 36 | hash[key] = value if value 37 | end 38 | end 39 | 40 | class UpgradeHandler 41 | VALID_UPGRADE_METHODS = %w[GET OPTIONS].freeze 42 | UPGRADE_RESPONSE = < "http", 71 | ":method" => @parser.http_method, 72 | ":authority" => headers["Host"], 73 | ":path" => @parser.request_url 74 | }.merge(headers) 75 | 76 | @conn.upgrade(settings, request, @body) 77 | end 78 | 79 | def complete! 80 | @complete = true 81 | end 82 | 83 | def on_headers_complete(headers) 84 | @headers.merge! headers 85 | end 86 | 87 | def on_body(chunk) 88 | @body << chunk 89 | end 90 | 91 | def on_message_complete 92 | raise unless VALID_UPGRADE_METHODS.include?(@parser.http_method) 93 | 94 | @parsing = false 95 | complete! 96 | end 97 | end 98 | 99 | loop do 100 | sock = server.accept 101 | puts "New TCP connection!" 102 | 103 | conn = HTTP2::Server.new 104 | conn.on(:frame) do |bytes| 105 | # puts "Writing bytes: #{bytes.unpack("H*").first}" 106 | sock.write bytes 107 | end 108 | conn.on(:frame_sent) do |frame| 109 | puts "Sent frame: #{frame.inspect}" 110 | end 111 | conn.on(:frame_received) do |frame| 112 | puts "Received frame: #{frame.inspect}" 113 | end 114 | 115 | conn.on(:stream) do |stream| 116 | log = Logger.new(stream.id) 117 | req = request_header_hash 118 | buffer = "" 119 | 120 | stream.on(:active) { log.info "client opened new stream" } 121 | stream.on(:close) do 122 | log.info "stream closed" 123 | end 124 | 125 | stream.on(:headers) do |h| 126 | req.merge! Hash[*h.flatten] 127 | log.info "request headers: #{h}" 128 | end 129 | 130 | stream.on(:data) do |d| 131 | log.info "payload chunk: <<#{d}>>" 132 | buffer << d 133 | end 134 | 135 | stream.on(:half_close) do 136 | log.info "client closed its end of the stream" 137 | 138 | if req["Upgrade"] 139 | log.info "Processing h2c Upgrade request: #{req}" 140 | if req[":method"] != "OPTIONS" # Don't respond to OPTIONS... 141 | response = "Hello h2c world!" 142 | stream.headers({ 143 | ":status" => "200", 144 | "content-length" => response.bytesize.to_s, 145 | "content-type" => "text/plain" 146 | }, end_stream: false) 147 | stream.data(response) 148 | end 149 | else 150 | 151 | response = nil 152 | if req[":method"] == "POST" 153 | log.info "Received POST request, payload: #{buffer}" 154 | response = "Hello HTTP 2.0! POST payload: #{buffer}" 155 | else 156 | log.info "Received GET request" 157 | response = "Hello HTTP 2.0! GET request" 158 | end 159 | 160 | stream.headers({ 161 | ":status" => "200", 162 | "content-length" => response.bytesize.to_s, 163 | "content-type" => "text/plain" 164 | }, end_stream: false) 165 | 166 | # split response into multiple DATA frames 167 | stream.data(response.slice!(0, 5), end_stream: false) 168 | stream.data(response) 169 | end 170 | end 171 | end 172 | 173 | uh = UpgradeHandler.new(conn, sock) 174 | 175 | while !sock.closed? && !(sock.eof? rescue true) # rubocop:disable Style/RescueModifier 176 | data = sock.readpartial(1024) 177 | # puts "Received bytes: #{data.unpack("H*").first}" 178 | 179 | begin 180 | if !uh.parsing && !uh.complete 181 | 182 | if data.start_with?(*UpgradeHandler::VALID_UPGRADE_METHODS) 183 | uh << data 184 | else 185 | uh.complete! 186 | conn << data 187 | end 188 | 189 | elsif uh.parsing && !uh.complete 190 | uh << data 191 | 192 | elsif uh.complete 193 | conn << data 194 | end 195 | rescue StandardError => e 196 | puts "Exception: #{e}, #{e.message} - closing socket." 197 | puts e.backtrace.last(10).join("\n") 198 | sock.close 199 | end 200 | end 201 | end 202 | 203 | # echo foo=bar | nghttp -d - -t 0 -vu http://127.0.0.1:8080/ 204 | # nghttp -vu http://127.0.0.1:8080/ 205 | -------------------------------------------------------------------------------- /h2spec-releases/h2spec_darwin_amd64.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igrigorik/http-2/99cdf0808c1411a9d5ef3497f9abe81f2b418b99/h2spec-releases/h2spec_darwin_amd64.tar.gz -------------------------------------------------------------------------------- /h2spec-releases/h2spec_linux_amd64.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igrigorik/http-2/99cdf0808c1411a9d5ef3497f9abe81f2b418b99/h2spec-releases/h2spec_linux_amd64.tar.gz -------------------------------------------------------------------------------- /h2spec-releases/h2spec_windows_amd64.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igrigorik/http-2/99cdf0808c1411a9d5ef3497f9abe81f2b418b99/h2spec-releases/h2spec_windows_amd64.zip -------------------------------------------------------------------------------- /http-2.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("./lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "http/2/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "http-2" 9 | spec.version = HTTP2::VERSION 10 | spec.authors = ["Tiago Cardoso", "Ilya Grigorik", "Kaoru Maeda"] 11 | spec.email = %w[cardoso_tiago@hotmail.com ilya@igvita.com] 12 | spec.description = "Pure-ruby HTTP 2.0 protocol implementation" 13 | spec.summary = spec.description 14 | spec.homepage = "https://github.com/igrigorik/http-2" 15 | spec.license = "MIT" 16 | spec.required_ruby_version = ">=2.7.0" 17 | 18 | spec.metadata = { 19 | "bug_tracker_uri" => "https://github.com/igrigorik/http-2/issues", 20 | "changelog_uri" => "https://github.com/igrigorik/http-2/blob/main/CHANGELOG.md", 21 | "source_code_uri" => "https://github.com/igrigorik/http-2", 22 | "homepage_uri" => "https://github.com/igrigorik/http-2", 23 | "rubygems_mfa_required" => "true" 24 | } 25 | 26 | spec.files = Dir["LICENSE.txt", "README.md", "lib/**/*.rb", "sig/**/*.rbs"] 27 | spec.require_paths = ["lib"] 28 | end 29 | -------------------------------------------------------------------------------- /lib/http/2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "http/2/version" 4 | 5 | module HTTP2 6 | EMPTY = [].freeze 7 | end 8 | 9 | require "http/2/extensions" 10 | require "http/2/base64" 11 | require "http/2/error" 12 | require "http/2/emitter" 13 | require "http/2/flow_buffer" 14 | require "http/2/header" 15 | require "http/2/framer" 16 | require "http/2/connection" 17 | require "http/2/client" 18 | require "http/2/server" 19 | require "http/2/stream" 20 | -------------------------------------------------------------------------------- /lib/http/2/base64.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if RUBY_VERSION < "3.3.0" 4 | require "base64" 5 | elsif !defined?(Base64) 6 | module HTTP2 7 | # require "base64" will not be a default gem after ruby 3.4.0 8 | module Base64 9 | module_function 10 | 11 | def encode64(bin) 12 | [bin].pack("m") 13 | end 14 | 15 | def decode64(str) 16 | str.unpack1("m") 17 | end 18 | 19 | def strict_encode64(bin) 20 | [bin].pack("m0") 21 | end 22 | 23 | def strict_decode64(str) 24 | str.unpack1("m0") 25 | end 26 | 27 | def urlsafe_encode64(bin, padding: true) 28 | str = strict_encode64(bin) 29 | str.chomp!("==") or str.chomp!("=") unless padding 30 | str.tr!("+/", "-_") 31 | str 32 | end 33 | end 34 | 35 | def urlsafe_decode64(str) 36 | if !str.end_with?("=") && str.length % 4 != 0 37 | str = str.ljust((str.length + 3) & ~3, "=") 38 | str.tr!("-_", "+/") 39 | else 40 | str = str.tr("-_", "+/") 41 | end 42 | strict_decode64(str) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/http/2/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # HTTP 2.0 client connection class that implements appropriate header 5 | # compression / decompression algorithms and stream management logic. 6 | # 7 | # Your code is responsible for driving the client object, which in turn 8 | # performs all of the necessary HTTP 2.0 encoding / decoding, state 9 | # management, and the rest. A simple example: 10 | # 11 | # @example 12 | # socket = YourTransport.new 13 | # 14 | # conn = HTTP2::Client.new 15 | # conn.on(:frame) {|bytes| socket << bytes } 16 | # 17 | # while bytes = socket.read 18 | # conn << bytes 19 | # end 20 | # 21 | class Client < Connection 22 | # Initialize new HTTP 2.0 client object. 23 | def initialize(settings = {}) 24 | @stream_id = 1 25 | @state = :waiting_connection_preface 26 | 27 | @local_role = :client 28 | @remote_role = :server 29 | @h2c_upgrade = nil 30 | 31 | super 32 | end 33 | 34 | # Send an outgoing frame. Connection and stream flow control is managed 35 | # by Connection class. 36 | # 37 | # @see Connection 38 | # @param frame [Hash] 39 | def send(frame) 40 | send_connection_preface 41 | super 42 | end 43 | 44 | def receive(frame) 45 | send_connection_preface 46 | super 47 | end 48 | 49 | # sends the preface and initializes the first stream in half-closed state 50 | def upgrade 51 | @h2c_upgrade = :start 52 | raise ProtocolError unless @stream_id == 1 53 | 54 | send_connection_preface 55 | stream = new_stream(state: :half_closed_local) 56 | @h2c_upgrade = :finished 57 | stream 58 | end 59 | 60 | # Emit the connection preface if not yet 61 | def send_connection_preface 62 | return unless @state == :waiting_connection_preface 63 | 64 | @state = :connected 65 | emit(:frame, CONNECTION_PREFACE_MAGIC) 66 | 67 | payload = @local_settings.reject { |k, v| v == SPEC_DEFAULT_CONNECTION_SETTINGS[k] } 68 | settings(payload) 69 | end 70 | 71 | def self.settings_header(settings) 72 | frame = Framer.new.generate(type: :settings, stream: 0, payload: settings) 73 | Base64.urlsafe_encode64(frame[9..-1]) 74 | end 75 | 76 | private 77 | 78 | def verify_pseudo_headers(frame) 79 | _verify_pseudo_headers(frame, RESPONSE_MANDATORY_HEADERS) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/http/2/emitter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # Basic event emitter implementation with support for persistent and 5 | # one-time event callbacks. 6 | # 7 | module Emitter 8 | # Subscribe to all future events for specified type. 9 | # 10 | # @param event [Symbol] 11 | # @param block [Proc] callback function 12 | def on(event, &block) 13 | raise ArgumentError, "must provide callback" unless block 14 | 15 | @listeners[event] << block 16 | end 17 | 18 | # Subscribe to next event (at most once) for specified type. 19 | # 20 | # @param event [Symbol] 21 | # @param block [Proc] callback function 22 | def once(event, &block) 23 | on(event) do |*args, &callback| 24 | block.call(*args, &callback) 25 | :delete 26 | end 27 | end 28 | 29 | # Emit event with provided arguments. 30 | # 31 | # @param event [Symbol] 32 | # @param args [Array] arguments to be passed to the callbacks 33 | # @param block [Proc] callback function 34 | def emit(event, *args, &block) 35 | @listeners[event].delete_if do |cb| 36 | :delete == cb.call(*args, &block) # rubocop:disable Style/YodaCondition 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/http/2/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # Stream, connection, and compressor exceptions. 5 | module Error 6 | @types = {} 7 | 8 | class << self 9 | attr_reader :types 10 | end 11 | 12 | class Error < StandardError 13 | def self.inherited(klass) 14 | super 15 | 16 | type = klass.name or return 17 | 18 | type = type.split("::").last or return 19 | 20 | type = type.gsub(/([^\^])([A-Z])/, '\1_\2').downcase.to_sym 21 | HTTP2::Error.types[type] = klass 22 | end 23 | end 24 | 25 | # Raised if connection header is missing or invalid indicating that 26 | # this is an invalid HTTP 2.0 request - no frames are emitted and the 27 | # connection must be aborted. 28 | class HandshakeError < Error; end 29 | 30 | # Raised by stream or connection handlers, results in GOAWAY frame 31 | # which signals termination of the current connection. You *cannot* 32 | # recover from this exception, or any exceptions subclassed from it. 33 | class ProtocolError < Error; end 34 | 35 | # Raised on any header encoding / decoding exception. 36 | # 37 | # @see ProtocolError 38 | class CompressionError < ProtocolError; end 39 | 40 | # Raised on invalid flow control frame or command. 41 | # 42 | # @see ProtocolError 43 | class FlowControlError < ProtocolError; end 44 | 45 | # Raised on invalid stream processing: invalid frame type received or 46 | # sent, or invalid command issued. 47 | class InternalError < ProtocolError; end 48 | 49 | # 50 | # -- Recoverable errors ------------------------------------------------- 51 | # 52 | 53 | # Raised if stream has been closed and new frames cannot be sent. 54 | class StreamClosed < Error; end 55 | 56 | # Raised if connection has been closed (or draining) and new stream 57 | # cannot be opened. 58 | class ConnectionClosed < Error; end 59 | 60 | # Raised if stream limit has been reached and new stream cannot be opened. 61 | class StreamLimitExceeded < Error; end 62 | 63 | class FrameSizeError < Error; end 64 | 65 | @types.freeze 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/http/2/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | module BufferUtils 5 | if RUBY_VERSION > "3.4.0" 6 | def append_str(str, data) 7 | str.append_as_bytes(data) 8 | end 9 | else 10 | def append_str(str, data) 11 | enc = data.encoding 12 | reset = false 13 | 14 | if enc != Encoding::BINARY 15 | reset = true 16 | data = data.dup if data.frozen? 17 | data.force_encoding(Encoding::BINARY) 18 | end 19 | str << data 20 | ensure 21 | data.force_encoding(enc) if reset 22 | end 23 | end 24 | 25 | def read_str(str, n) 26 | return "".b if n == 0 27 | 28 | chunk = str.byteslice(0..n - 1) 29 | remaining = str.byteslice(n..-1) 30 | remaining ? str.replace(remaining) : str.clear 31 | chunk 32 | end 33 | 34 | def read_uint32(str) 35 | read_str(str, 4).unpack1("N") 36 | end 37 | 38 | def shift_byte(str) 39 | read_str(str, 1).ord 40 | end 41 | end 42 | 43 | # this mixin handles backwards-compatibility for the new packing options 44 | # shipping with ruby 3.3 (see https://docs.ruby-lang.org/en/3.3/packed_data_rdoc.html) 45 | module PackingExtensions 46 | if RUBY_VERSION < "3.3.0" 47 | def pack(array_to_pack, template, buffer:, offset: -1) 48 | packed_str = array_to_pack.pack(template) 49 | case offset 50 | when -1 51 | append_str(buffer, packed_str) 52 | when 0 53 | buffer.prepend(packed_str) 54 | else 55 | buffer.insert(offset, packed_str) 56 | end 57 | end 58 | else 59 | def pack(array_to_pack, template, buffer:, offset: -1) 60 | case offset 61 | when -1 62 | array_to_pack.pack(template, buffer: buffer) 63 | when 0 64 | buffer.prepend(array_to_pack.pack(template)) 65 | else 66 | buffer.insert(offset, array_to_pack.pack(template)) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/http/2/flow_buffer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # Implementation of stream and connection DATA flow control: frames may 5 | # be split and / or may be buffered based on current flow control window. 6 | # 7 | module FlowBuffer 8 | include Error 9 | 10 | attr_reader :send_buffer 11 | 12 | MAX_WINDOW_SIZE = (2 << 30) - 1 13 | 14 | # Amount of buffered data. Only DATA payloads are subject to flow stream 15 | # and connection flow control. 16 | # 17 | # @return [Integer] 18 | def buffered_amount 19 | @send_buffer.bytesize 20 | end 21 | 22 | def flush 23 | send_data 24 | end 25 | 26 | private 27 | 28 | def update_local_window(frame) 29 | frame_size = frame[:payload].bytesize 30 | frame_size += frame.fetch(:padding, 0) 31 | @local_window -= frame_size 32 | end 33 | 34 | def calculate_window_update(window_max_size) 35 | # If DATA frame is received with length > 0 and 36 | # current received window size + delta length is strictly larger than 37 | # local window size, it throws a flow control error. 38 | # 39 | error(:flow_control_error) if @local_window < 0 40 | 41 | # Send WINDOW_UPDATE if the received window size goes over 42 | # the local window size / 2. 43 | # 44 | # The HTTP/2 spec mandates that every DATA frame received 45 | # generates a WINDOW_UPDATE to send. In some cases however, 46 | # (ex: DATA frames with short payloads), 47 | # the noise generated by flow control frames creates enough 48 | # congestion for this to be deemed very inefficient. 49 | # 50 | # This heuristic was inherited from nghttp, which delays the 51 | # WINDOW_UPDATE until at least half the window is exhausted. 52 | # This works because the sender doesn't need those increments 53 | # until the receiver window is exhausted, after which he'll be 54 | # waiting for the WINDOW_UPDATE frame. 55 | return unless @local_window <= (window_max_size / 2) 56 | 57 | window_update(window_max_size - @local_window) 58 | end 59 | 60 | # Buffers outgoing DATA frames and applies flow control logic to split 61 | # and emit DATA frames based on current flow control window. If the 62 | # window is large enough, the data is sent immediately. Otherwise, the 63 | # data is buffered until the flow control window is updated. 64 | # 65 | # Buffered DATA frames are emitted in FIFO order. 66 | # 67 | # @param frame [Hash] 68 | # @param encode [Boolean] set to true by connection 69 | def send_data(frame = nil, encode = false) 70 | if frame 71 | if @send_buffer.empty? 72 | frame_size = frame[:payload].bytesize 73 | end_stream = frame[:flags].include?(:end_stream) 74 | # if buffer is empty, and frame is either end 0 length OR 75 | # is within available window size, skip buffering and send immediately. 76 | if @remote_window.positive? 77 | return send_frame(frame, encode) if frame_size <= @remote_window 78 | elsif frame_size.zero? && end_stream 79 | return send_frame(frame, encode) 80 | end 81 | end 82 | 83 | @send_buffer << frame 84 | end 85 | 86 | while (frame = @send_buffer.retrieve(@remote_window)) 87 | send_frame(frame, encode) 88 | end 89 | end 90 | 91 | def send_frame(frame, encode) 92 | sent = frame[:payload].bytesize 93 | 94 | manage_state(frame) do 95 | if encode 96 | encode(frame) 97 | else 98 | emit(:frame, frame) 99 | end 100 | @remote_window -= sent 101 | end 102 | end 103 | 104 | def process_window_update(frame:, encode: false) 105 | return if frame[:ignore] 106 | 107 | if (increment = frame[:increment]) 108 | raise ProtocolError, "increment MUST be higher than zero" if increment.zero? 109 | 110 | @remote_window += increment 111 | error(:flow_control_error, msg: "window size too large") if @remote_window > MAX_WINDOW_SIZE 112 | end 113 | send_data(nil, encode) 114 | end 115 | end 116 | 117 | class FrameBuffer 118 | attr_reader :bytesize 119 | 120 | def initialize 121 | @buffer = [] 122 | @bytesize = 0 123 | end 124 | 125 | def <<(frame) 126 | @buffer << frame 127 | @bytesize += frame[:payload].bytesize 128 | end 129 | 130 | def empty? 131 | @buffer.empty? 132 | end 133 | 134 | def retrieve(window_size) 135 | frame = @buffer.first or return 136 | 137 | frame_size = frame[:payload].bytesize 138 | end_stream = frame[:flags].include?(:end_stream) 139 | 140 | # Frames with zero length with the END_STREAM flag set (that 141 | # is, an empty DATA frame) MAY be sent if there is no available space 142 | # in either flow control window. 143 | return if window_size <= 0 && !(frame_size.zero? && end_stream) 144 | 145 | if frame_size > window_size 146 | chunk = frame.dup 147 | payload = frame[:payload] 148 | 149 | # Split frame so that it fits in the window 150 | # TODO: consider padding! 151 | 152 | chunk[:payload] = payload.byteslice(0, window_size) 153 | chunk[:length] = window_size 154 | frame[:payload] = payload.byteslice(window_size..-1) 155 | frame[:length] = frame_size - window_size 156 | 157 | # if no longer last frame in sequence... 158 | chunk[:flags] -= [:end_stream] if end_stream 159 | 160 | @bytesize -= window_size 161 | chunk 162 | else 163 | @bytesize -= frame_size 164 | @buffer.shift 165 | end 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/http/2/framer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # Performs encoding, decoding, and validation of binary HTTP/2 frames. 5 | # 6 | class Framer 7 | include Error 8 | include PackingExtensions 9 | include BufferUtils 10 | 11 | # Default value of max frame size (16384 bytes) 12 | DEFAULT_MAX_FRAME_SIZE = 2 << 13 13 | 14 | # maximum frame size 15 | attr_accessor :local_max_frame_size, :remote_max_frame_size 16 | 17 | # Maximum stream ID (2^31) 18 | MAX_STREAM_ID = 0x7fffffff 19 | 20 | # Maximum window increment value (2^31) 21 | MAX_WINDOWINC = 0x7fffffff 22 | 23 | # HTTP/2 frame type mapping as defined by the spec 24 | FRAME_TYPES = { 25 | data: 0x0, 26 | headers: 0x1, 27 | priority: 0x2, 28 | rst_stream: 0x3, 29 | settings: 0x4, 30 | push_promise: 0x5, 31 | ping: 0x6, 32 | goaway: 0x7, 33 | window_update: 0x8, 34 | continuation: 0x9, 35 | altsvc: 0xa, 36 | origin: 0xc 37 | }.freeze 38 | 39 | FRAME_TYPES_BY_NAME = FRAME_TYPES.invert.freeze 40 | 41 | FRAME_TYPES_WITH_PADDING = %i[data headers push_promise].freeze 42 | 43 | # Per frame flags as defined by the spec 44 | FRAME_FLAGS = { 45 | data: { 46 | end_stream: 0, 47 | padded: 3, 48 | compressed: 5 49 | }, 50 | headers: { 51 | end_stream: 0, 52 | end_headers: 2, 53 | padded: 3, 54 | priority: 5 55 | }, 56 | priority: {}, 57 | rst_stream: {}, 58 | settings: { ack: 0 }, 59 | push_promise: { 60 | end_headers: 2, 61 | padded: 3 62 | }, 63 | ping: { ack: 0 }, 64 | goaway: {}, 65 | window_update: {}, 66 | continuation: { end_headers: 2 }, 67 | altsvc: {}, 68 | origin: { 69 | reserved: 1, 70 | reserved2: 2, 71 | reserved3: 4, 72 | reserved4: 8 73 | } 74 | }.each_value(&:freeze).freeze 75 | 76 | # Default settings as defined by the spec 77 | DEFINED_SETTINGS = { 78 | settings_header_table_size: 1, 79 | settings_enable_push: 2, 80 | settings_max_concurrent_streams: 3, 81 | settings_initial_window_size: 4, 82 | settings_max_frame_size: 5, 83 | settings_max_header_list_size: 6 84 | }.freeze 85 | 86 | # Default error types as defined by the spec 87 | DEFINED_ERRORS = { 88 | no_error: 0, 89 | protocol_error: 1, 90 | internal_error: 2, 91 | flow_control_error: 3, 92 | settings_timeout: 4, 93 | stream_closed: 5, 94 | frame_size_error: 6, 95 | refused_stream: 7, 96 | cancel: 8, 97 | compression_error: 9, 98 | connect_error: 10, 99 | enhance_your_calm: 11, 100 | inadequate_security: 12, 101 | http_1_1_required: 13 102 | }.freeze 103 | 104 | RBIT = 0x7fffffff 105 | RBYTE = 0x0fffffff 106 | EBIT = 0x80000000 107 | UINT32 = "N" 108 | UINT16 = "n" 109 | UINT8 = "C" 110 | HEADERPACK = (UINT8 + UINT16 + UINT8 + UINT8 + UINT32).freeze 111 | FRAME_LENGTH_HISHIFT = 16 112 | FRAME_LENGTH_LOMASK = 0xFFFF 113 | 114 | private_constant :RBIT, :RBYTE, :EBIT, :HEADERPACK, :UINT32, :UINT16, :UINT8 115 | 116 | # Initializes new framer object. 117 | # 118 | def initialize(local_max_frame_size = DEFAULT_MAX_FRAME_SIZE, 119 | remote_max_frame_size = DEFAULT_MAX_FRAME_SIZE) 120 | @local_max_frame_size = local_max_frame_size 121 | @remote_max_frame_size = remote_max_frame_size 122 | end 123 | 124 | # Generates common 9-byte frame header. 125 | # - http://tools.ietf.org/html/draft-ietf-httpbis-http2-16#section-4.1 126 | # 127 | # @param frame [Hash] 128 | # @param buffer [String] buffer to pack bytes into 129 | # @return [String] 130 | def common_header(frame, buffer:) 131 | type = frame[:type] 132 | 133 | raise CompressionError, "Invalid frame type (#{type})" unless FRAME_TYPES[type] 134 | 135 | length = frame[:length] 136 | 137 | raise CompressionError, "Frame size is too large: #{length}" if length > @remote_max_frame_size 138 | 139 | raise CompressionError, "Frame size is invalid: #{length}" if length < 0 140 | 141 | stream_id = frame.fetch(:stream, 0) 142 | 143 | raise CompressionError, "Stream ID (#{stream_id}) is too large" if stream_id > MAX_STREAM_ID 144 | 145 | if type == :window_update && frame[:increment] > MAX_WINDOWINC 146 | raise CompressionError, "Window increment (#{frame[:increment]}) is too large" 147 | end 148 | 149 | header = buffer 150 | 151 | # make sure the buffer is binary and unfrozen 152 | if buffer.frozen? 153 | header = String.new("", encoding: Encoding::BINARY, capacity: buffer.bytesize + 9) # header length 154 | append_str(header, buffer) 155 | else 156 | header.force_encoding(Encoding::BINARY) 157 | end 158 | 159 | pack([ 160 | (length >> FRAME_LENGTH_HISHIFT), 161 | (length & FRAME_LENGTH_LOMASK), 162 | FRAME_TYPES[type], 163 | frame[:flags].reduce(0) do |acc, f| 164 | position = FRAME_FLAGS[type][f] 165 | raise CompressionError, "Invalid frame flag (#{f}) for #{type}" unless position 166 | 167 | acc | (1 << position) 168 | end, 169 | stream_id 170 | ], HEADERPACK, buffer: header, offset: 0) # 8+16,8,8,32 171 | end 172 | 173 | # Decodes common 9-byte header. 174 | # 175 | # @param buf [Buffer] 176 | # @return [Hash] the corresponding frame 177 | def read_common_header(buf) 178 | len_hi, len_lo, type, flags, stream = buf.byteslice(0, 9).unpack(HEADERPACK) 179 | 180 | type = FRAME_TYPES_BY_NAME[type] 181 | length = (len_hi << FRAME_LENGTH_HISHIFT) | len_lo 182 | 183 | return { length: length } unless type 184 | 185 | { 186 | type: type, 187 | flags: FRAME_FLAGS[type].filter_map do |name, pos| 188 | name if flags.anybits?((1 << pos)) 189 | end, 190 | length: length, 191 | stream: stream & RBIT 192 | } 193 | end 194 | 195 | # Generates encoded HTTP/2 frame. 196 | # - http://tools.ietf.org/html/draft-ietf-httpbis-http2 197 | # 198 | # @param frame [Hash] 199 | def generate(frame) 200 | length = 0 201 | frame[:flags] ||= EMPTY 202 | 203 | case frame[:type] 204 | when :data, :continuation 205 | bytes = frame[:payload] 206 | length = bytes.bytesize 207 | 208 | when :headers 209 | headers = frame[:payload] 210 | 211 | if frame[:weight] || frame[:dependency] || !frame[:exclusive].nil? 212 | unless frame[:weight] && frame[:dependency] && !frame[:exclusive].nil? 213 | raise CompressionError, "Must specify all of priority parameters for #{frame[:type]}" 214 | end 215 | 216 | frame[:flags] += [:priority] unless frame[:flags].include?(:priority) 217 | end 218 | 219 | if frame[:flags].include?(:priority) 220 | length = 5 + headers.bytesize 221 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 222 | pack([(frame[:exclusive] ? EBIT : 0) | (frame[:dependency] & RBIT)], UINT32, buffer: bytes) 223 | pack([frame[:weight] - 1], UINT8, buffer: bytes) 224 | append_str(bytes, headers) 225 | else 226 | length = headers.bytesize 227 | bytes = headers 228 | end 229 | 230 | when :priority 231 | unless frame[:weight] && frame[:dependency] && !frame[:exclusive].nil? 232 | raise CompressionError, "Must specify all of priority parameters for #{frame[:type]}" 233 | end 234 | 235 | length = 5 236 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 237 | pack([(frame[:exclusive] ? EBIT : 0) | (frame[:dependency] & RBIT)], UINT32, buffer: bytes) 238 | pack([frame[:weight] - 1], UINT8, buffer: bytes) 239 | 240 | when :rst_stream 241 | length = 4 242 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 243 | pack_error(frame[:error], buffer: bytes) 244 | 245 | when :settings 246 | if (stream_id = frame[:stream]) && stream_id.nonzero? 247 | raise CompressionError, "Invalid stream ID (#{stream_id})" 248 | end 249 | 250 | settings = frame[:payload] 251 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 252 | 253 | settings.each do |(k, v)| 254 | if k.is_a? Integer # rubocop:disable Style/GuardClause 255 | DEFINED_SETTINGS.value?(k) || next 256 | else 257 | k = DEFINED_SETTINGS[k] 258 | 259 | raise CompressionError, "Unknown settings ID for #{k}" if k.nil? 260 | end 261 | 262 | pack([k], UINT16, buffer: bytes) 263 | pack([v], UINT32, buffer: bytes) 264 | length += 6 265 | end 266 | 267 | when :push_promise 268 | length = 4 + frame[:payload].bytesize 269 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 270 | pack([frame[:promise_stream] & RBIT], UINT32, buffer: bytes) 271 | append_str(bytes, frame[:payload]) 272 | 273 | when :ping 274 | bytes = frame[:payload].b 275 | raise CompressionError, "Invalid payload size (#{bytes.size} != 8 bytes)" if bytes.bytesize != 8 276 | 277 | length = 8 278 | 279 | when :goaway 280 | data = frame[:payload] 281 | length = 8 282 | length += data.bytesize if data 283 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 284 | 285 | pack([frame[:last_stream] & RBIT], UINT32, buffer: bytes) 286 | pack_error(frame[:error], buffer: bytes) 287 | 288 | append_str(bytes, data) if data 289 | 290 | when :window_update 291 | length = 4 292 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 293 | pack([frame[:increment] & RBIT], UINT32, buffer: bytes) 294 | 295 | when :altsvc 296 | length = 6 297 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 298 | pack([frame[:max_age], frame[:port]], UINT32 + UINT16, buffer: bytes) 299 | if frame[:proto] 300 | raise CompressionError, "Proto too long" if frame[:proto].bytesize > 255 301 | 302 | pack([frame[:proto].bytesize], UINT8, buffer: bytes) 303 | append_str(bytes, frame[:proto]) 304 | length += 1 + frame[:proto].bytesize 305 | else 306 | pack([0], UINT8, buffer: bytes) 307 | length += 1 308 | end 309 | if frame[:host] 310 | raise CompressionError, "Host too long" if frame[:host].bytesize > 255 311 | 312 | pack([frame[:host].bytesize], UINT8, buffer: bytes) 313 | append_str(bytes, frame[:host]) 314 | length += 1 + frame[:host].bytesize 315 | else 316 | pack([0], UINT8, buffer: bytes) 317 | length += 1 318 | end 319 | if frame[:origin] 320 | append_str(bytes, frame[:origin]) 321 | length += frame[:origin].bytesize 322 | end 323 | 324 | when :origin 325 | origins = frame[:payload] 326 | length = origins.sum(&:bytesize) + (2 * origins.size) 327 | bytes = String.new("", encoding: Encoding::BINARY, capacity: length) 328 | origins.each do |origin| 329 | pack([origin.bytesize], UINT16, buffer: bytes) 330 | append_str(bytes, origin) 331 | end 332 | end 333 | 334 | # Process padding. 335 | # frame[:padding] gives number of extra octets to be added. 336 | # - http://tools.ietf.org/html/draft-ietf-httpbis-http2-16#section-6.1 337 | if frame[:padding] 338 | unless FRAME_TYPES_WITH_PADDING.include?(frame[:type]) 339 | raise CompressionError, "Invalid padding flag for #{frame[:type]}" 340 | end 341 | 342 | padlen = frame[:padding] 343 | 344 | if padlen <= 0 || padlen > 256 || padlen + length > @remote_max_frame_size 345 | raise CompressionError, "Invalid padding #{padlen}" 346 | end 347 | 348 | # make sure the buffer is binary and unfrozen 349 | if bytes.frozen? 350 | bytes = bytes.b 351 | else 352 | bytes.force_encoding(Encoding::BINARY) 353 | end 354 | 355 | length += padlen 356 | pack([padlen -= 1], UINT8, buffer: bytes, offset: 0) 357 | frame[:flags] += [:padded] 358 | 359 | # Padding: Padding octets that contain no application semantic value. 360 | # Padding octets MUST be set to zero when sending and ignored when 361 | # receiving. 362 | append_str(bytes, ("\0" * padlen)) 363 | end 364 | 365 | frame[:length] = length 366 | common_header(frame, buffer: bytes) 367 | end 368 | 369 | # Decodes complete HTTP/2 frame from provided buffer. If the buffer 370 | # does not contain enough data, no further work is performed. 371 | # 372 | # @param buf [Buffer] 373 | def parse(buf) 374 | return if buf.size < 9 375 | 376 | frame = read_common_header(buf) 377 | 378 | type = frame[:type] 379 | length = frame[:length] 380 | flags = frame[:flags] 381 | 382 | return if buf.size < 9 + length 383 | 384 | raise ProtocolError, "payload too large" if length > @local_max_frame_size 385 | 386 | read_str(buf, 9) 387 | payload = read_str(buf, length) 388 | 389 | # Implementations MUST discard frames 390 | # that have unknown or unsupported types. 391 | # - http://tools.ietf.org/html/draft-ietf-httpbis-http2-16#section-5.5 392 | return frame unless type 393 | 394 | # Process padding 395 | padlen = 0 396 | if FRAME_TYPES_WITH_PADDING.include?(type) 397 | padded = flags.include?(:padded) 398 | if padded 399 | padlen = read_str(payload, 1).unpack1(UINT8) 400 | frame[:padding] = padlen + 1 401 | raise ProtocolError, "padding too long" if padlen > payload.bytesize 402 | 403 | payload = payload.byteslice(0, payload.bytesize - padlen) if padlen > 0 404 | frame[:length] -= frame[:padding] 405 | flags.delete(:padded) 406 | end 407 | end 408 | 409 | case type 410 | when :data, :ping, :continuation 411 | frame[:payload] = read_str(payload, length) 412 | when :headers 413 | if flags.include?(:priority) 414 | e_sd = read_uint32(payload) 415 | frame[:dependency] = e_sd & RBIT 416 | frame[:exclusive] = e_sd.anybits?(EBIT) 417 | weight = payload.byteslice(0, 1).ord + 1 418 | frame[:weight] = weight 419 | payload = payload.byteslice(1..-1) 420 | end 421 | frame[:payload] = read_str(payload, length) 422 | when :priority 423 | raise FrameSizeError, "Invalid length for PRIORITY_STREAM (#{length} != 5)" if length != 5 424 | 425 | e_sd = read_uint32(payload) 426 | frame[:dependency] = e_sd & RBIT 427 | frame[:exclusive] = e_sd.anybits?(EBIT) 428 | weight = payload.byteslice(0, 1).ord + 1 429 | frame[:weight] = weight 430 | payload = payload.byteslice(1..-1) 431 | when :rst_stream 432 | raise FrameSizeError, "Invalid length for RST_STREAM (#{length} != 4)" if length != 4 433 | 434 | frame[:error] = unpack_error read_uint32(payload) 435 | 436 | when :settings 437 | # NOTE: frame[:length] might not match the number of frame[:payload] 438 | # because unknown extensions are ignored. 439 | frame[:payload] = [] 440 | raise ProtocolError, "Invalid settings payload length" unless (length % 6).zero? 441 | 442 | raise ProtocolError, "Invalid stream ID (#{frame[:stream]})" if frame[:stream].nonzero? 443 | 444 | (frame[:length] / 6).times do 445 | id = read_str(payload, 2).unpack1(UINT16) 446 | val = read_uint32(payload) 447 | 448 | # Unsupported or unrecognized settings MUST be ignored. 449 | # Here we send it along. 450 | name, = DEFINED_SETTINGS.find { |_name, v| v == id } 451 | frame[:payload] << [name, val] if name 452 | end 453 | when :push_promise 454 | frame[:promise_stream] = read_uint32(payload) & RBIT 455 | frame[:payload] = read_str(payload, length) 456 | when :goaway 457 | frame[:last_stream] = read_uint32(payload) & RBIT 458 | frame[:error] = unpack_error read_uint32(payload) 459 | 460 | size = length - 8 # for last_stream and error 461 | frame[:payload] = read_str(payload, size) if size > 0 462 | when :window_update 463 | raise FrameSizeError, "Invalid length for WINDOW_UPDATE (#{length} not multiple of 4)" if length % 4 != 0 464 | 465 | frame[:increment] = read_uint32(payload) & RBIT 466 | when :altsvc 467 | frame[:max_age], frame[:port] = read_str(payload, 6).unpack(UINT32 + UINT16) 468 | 469 | len = payload.byteslice(0, 1).ord 470 | payload = payload.byteslice(1..-1) 471 | frame[:proto] = read_str(payload, len) if len > 0 472 | 473 | len = payload.byteslice(0, 1).ord 474 | payload = payload.byteslice(1..-1) 475 | frame[:host] = read_str(payload, len) if len > 0 476 | 477 | frame[:origin] = read_str(payload, payload.size) unless payload.empty? 478 | 479 | when :origin 480 | origins = [] 481 | 482 | until payload.empty? 483 | len = read_str(payload, 2).unpack1(UINT16) 484 | origins << read_str(payload, len) 485 | end 486 | 487 | frame[:payload] = origins 488 | # else # Unknown frame type is explicitly allowed 489 | end 490 | 491 | frame 492 | end 493 | 494 | private 495 | 496 | def pack_error(error, buffer:) 497 | unless error.is_a? Integer 498 | error = DEFINED_ERRORS[error] 499 | 500 | raise CompressionError, "Unknown error ID for #{error}" unless error 501 | end 502 | 503 | pack([error], UINT32, buffer: buffer) 504 | end 505 | 506 | def unpack_error(error) 507 | DEFINED_ERRORS.key(error) || error 508 | end 509 | end 510 | end 511 | -------------------------------------------------------------------------------- /lib/http/2/header.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # Implementation of header compression for HTTP 2.0 (HPACK) format adapted 5 | # to efficiently represent HTTP headers in the context of HTTP 2.0. 6 | # 7 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10 8 | module Header 9 | # Header representation as defined by the spec. 10 | HEADREP = { 11 | indexed: { prefix: 7, pattern: 0x80 }, 12 | incremental: { prefix: 6, pattern: 0x40 }, 13 | noindex: { prefix: 4, pattern: 0x00 }, 14 | neverindexed: { prefix: 4, pattern: 0x10 }, 15 | changetablesize: { prefix: 5, pattern: 0x20 } 16 | }.each_value(&:freeze).freeze 17 | 18 | # Predefined options set for Compressor 19 | # http://mew.org/~kazu/material/2014-hpack.pdf 20 | NAIVE = { index: :never, huffman: :never }.freeze 21 | LINEAR = { index: :all, huffman: :never }.freeze 22 | STATIC = { index: :static, huffman: :never }.freeze 23 | SHORTER = { index: :all, huffman: :never }.freeze 24 | NAIVEH = { index: :never, huffman: :always }.freeze 25 | LINEARH = { index: :all, huffman: :always }.freeze 26 | STATICH = { index: :static, huffman: :always }.freeze 27 | SHORTERH = { index: :all, huffman: :shorter }.freeze 28 | end 29 | end 30 | 31 | require "http/2/header/huffman" 32 | require "http/2/header/huffman_statemachine" 33 | require "http/2/header/encoding_context" 34 | require "http/2/header/compressor" 35 | require "http/2/header/decompressor" 36 | -------------------------------------------------------------------------------- /lib/http/2/header/compressor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | module Header 5 | # Responsible for encoding header key-value pairs using HPACK algorithm. 6 | class Compressor 7 | include PackingExtensions 8 | include BufferUtils 9 | 10 | # @param options [Hash] encoding options 11 | def initialize(options = {}) 12 | @cc = EncodingContext.new(options) 13 | end 14 | 15 | # Set dynamic table size in EncodingContext 16 | # @param size [Integer] new dynamic table size 17 | def table_size=(size) 18 | @cc.table_size = size 19 | end 20 | 21 | # Encodes provided value via integer representation. 22 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-5.1 23 | # 24 | # If I < 2^N - 1, encode I on N bits 25 | # Else 26 | # encode 2^N - 1 on N bits 27 | # I = I - (2^N - 1) 28 | # While I >= 128 29 | # Encode (I % 128 + 128) on 8 bits 30 | # I = I / 128 31 | # encode (I) on 8 bits 32 | # 33 | # @param i [Integer] value to encode 34 | # @param n [Integer] number of available bits 35 | # @param buffer [String] buffer to pack bytes into 36 | # @param offset [Integer] offset to insert packed bytes in buffer 37 | # @return [String] binary string 38 | def integer(i, n, buffer:, offset: buffer.size) 39 | limit = (1 << n) - 1 40 | return pack([i], "C", buffer: buffer, offset: offset) if i < limit 41 | 42 | bytes = [] 43 | bytes.push limit unless n.zero? 44 | 45 | i -= limit 46 | while i >= 128 47 | bytes.push((i % 128) + 128) 48 | i /= 128 49 | end 50 | 51 | bytes.push i 52 | pack(bytes, "C*", buffer: buffer, offset: offset) 53 | end 54 | 55 | # Encodes provided value via string literal representation. 56 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-5.2 57 | # 58 | # * The string length, defined as the number of bytes needed to store 59 | # its UTF-8 representation, is represented as an integer with a seven 60 | # bits prefix. If the string length is strictly less than 127, it is 61 | # represented as one byte. 62 | # * If the bit 7 of the first byte is 1, the string value is represented 63 | # as a list of Huffman encoded octets 64 | # (padded with bit 1's until next octet boundary). 65 | # * If the bit 7 of the first byte is 0, the string value is 66 | # represented as a list of UTF-8 encoded octets. 67 | # 68 | # +@options [:huffman]+ controls whether to use Huffman encoding: 69 | # :never Do not use Huffman encoding 70 | # :always Always use Huffman encoding 71 | # :shorter Use Huffman when the result is strictly shorter 72 | # 73 | # @param str [String] 74 | # @param buffer [String] 75 | # @return [String] binary string 76 | def string(str, buffer = "".b) 77 | case @cc.options[:huffman] 78 | when :always 79 | huffman_string(str, buffer) 80 | when :never 81 | plain_string(str, buffer) 82 | else 83 | huffman = Huffman.encode(str) 84 | if huffman.bytesize < str.bytesize 85 | huffman_offset = buffer.bytesize 86 | append_str(buffer, huffman) 87 | set_huffman_size(buffer, huffman_offset) 88 | else 89 | plain_string(str, buffer) 90 | end 91 | end 92 | end 93 | 94 | # Encodes header command with appropriate header representation. 95 | # 96 | # @param h [Hash] header command 97 | # @param buffer [String] 98 | # @return [Buffer] 99 | def header(h, buffer = "".b) 100 | type = h[:type] 101 | rep = HEADREP[type] 102 | offset = buffer.size 103 | 104 | case type 105 | when :indexed 106 | integer(h[:name] + 1, rep[:prefix], buffer: buffer) 107 | when :changetablesize 108 | integer(h[:value], rep[:prefix], buffer: buffer) 109 | else 110 | name = h[:name] 111 | if name.is_a? Integer 112 | integer(name + 1, rep[:prefix], buffer: buffer) 113 | else 114 | integer(0, rep[:prefix], buffer: buffer) 115 | string(name, buffer) 116 | end 117 | 118 | string(h[:value], buffer) 119 | end 120 | 121 | # set header representation pattern on first byte 122 | fb = buffer[offset].ord | rep[:pattern] 123 | buffer.setbyte(offset, fb) 124 | 125 | buffer 126 | end 127 | 128 | # Encodes provided list of HTTP headers. 129 | # 130 | # @param headers [Array] +[[name, value], ...]+ 131 | # @return [Buffer] 132 | def encode(headers) 133 | buffer = "".b 134 | headers.partition { |f, _| f.start_with? ":" }.each do |hs| 135 | @cc.encode(hs) do |cmd| 136 | header(cmd, buffer) 137 | end 138 | end 139 | 140 | buffer 141 | end 142 | 143 | private 144 | 145 | # @param str [String] 146 | # @param buffer [String] 147 | # @return [String] binary string 148 | def huffman_string(str, buffer = "".b) 149 | huffman_offset = buffer.bytesize 150 | Huffman.encode(str, buffer) 151 | set_huffman_size(buffer, huffman_offset) 152 | end 153 | 154 | # @param str [String] 155 | # @param buffer [String] 156 | # @return [String] binary string 157 | def plain_string(str, plain = "".b) 158 | integer(str.bytesize, 7, buffer: plain) 159 | append_str(plain, str) 160 | plain 161 | end 162 | 163 | # @param buffer [String] 164 | # @param huffman_offset [Integer] buffer offset where huffman string was introduced 165 | # @return [String] binary string 166 | def set_huffman_size(buffer, huffman_offset) 167 | integer(buffer.bytesize - huffman_offset, 7, buffer: buffer, offset: huffman_offset) 168 | buffer.setbyte(huffman_offset, buffer[huffman_offset].ord | 0x80) 169 | buffer 170 | end 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /lib/http/2/header/decompressor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | module Header 5 | # Responsible for decoding received headers and maintaining compression 6 | # context of the opposing peer. Decompressor must be initialized with 7 | # appropriate starting context based on local role: client or server. 8 | # 9 | # @example 10 | # server_role = Decompressor.new(:request) 11 | # client_role = Decompressor.new(:response) 12 | class Decompressor 13 | include Error 14 | include BufferUtils 15 | 16 | FORBIDDEN_HEADERS = %w[connection te].freeze 17 | 18 | # @param options [Hash] decoding options. Only :table_size is effective. 19 | def initialize(options = {}) 20 | @cc = EncodingContext.new(options) 21 | end 22 | 23 | # Set dynamic table size in EncodingContext 24 | # @param size [Integer] new dynamic table size 25 | def table_size=(size) 26 | @cc.table_size = size 27 | end 28 | 29 | # Decodes integer value from provided buffer. 30 | # 31 | # @param buf [String] 32 | # @param n [Integer] number of available bits 33 | # @return [Integer] 34 | def integer(buf, n) 35 | limit = (1 << n) - 1 36 | i = n.zero? ? 0 : (shift_byte(buf) & limit) 37 | 38 | m = 0 39 | if i == limit 40 | offset = 0 41 | 42 | buf.each_byte.with_index do |byte, idx| 43 | offset = idx 44 | # while (byte = shift_byte(buf)) 45 | i += ((byte & 127) << m) 46 | m += 7 47 | 48 | break if byte.nobits?(128) 49 | end 50 | 51 | read_str(buf, offset + 1) 52 | end 53 | 54 | i 55 | end 56 | 57 | # Decodes string value from provided buffer. 58 | # 59 | # @param buf [String] 60 | # @return [String] UTF-8 encoded string 61 | # @raise [CompressionError] when input is malformed 62 | def string(buf) 63 | raise CompressionError, "invalid header block fragment" if buf.empty? 64 | 65 | huffman = buf.getbyte(0).allbits?(0x80) 66 | len = integer(buf, 7) 67 | str = read_str(buf, len) 68 | raise CompressionError, "string too short" unless str.bytesize == len 69 | 70 | str = Huffman.decode(str) if huffman 71 | str.force_encoding(Encoding::UTF_8) 72 | end 73 | 74 | # Decodes header command from provided buffer. 75 | # 76 | # @param buf [Buffer] 77 | # @return [Hash] command 78 | def header(buf) 79 | peek = buf.getbyte(0) 80 | 81 | header_type, type = HEADREP.find do |_, desc| 82 | mask = (peek >> desc[:prefix]) << desc[:prefix] 83 | mask == desc[:pattern] 84 | end 85 | 86 | raise CompressionError unless header_type && type 87 | 88 | header_name = integer(buf, type[:prefix]) 89 | 90 | case header_type 91 | when :indexed 92 | raise CompressionError if header_name.zero? 93 | 94 | header_name -= 1 95 | 96 | { type: header_type, name: header_name } 97 | when :changetablesize 98 | { type: header_type, name: header_name, value: header_name } 99 | else 100 | if header_name.zero? 101 | header_name = string(buf) 102 | else 103 | header_name -= 1 104 | end 105 | header_value = string(buf) 106 | 107 | { type: header_type, name: header_name, value: header_value } 108 | end 109 | end 110 | 111 | # Decodes and processes header commands within provided buffer. 112 | # 113 | # @param buf [Buffer] 114 | # @param frame [HTTP2::Frame, nil] 115 | # @return [Array] +[[name, value], ...] 116 | def decode(buf, frame = nil) 117 | list = [] 118 | decoding_pseudo_headers = true 119 | @cc.listen_on_table do 120 | until buf.empty? 121 | field, value = @cc.process(header(buf)) 122 | next if field.nil? 123 | 124 | is_pseudo_header = field.start_with?(":") 125 | if !decoding_pseudo_headers && is_pseudo_header 126 | raise ProtocolError, "one or more pseudo headers encountered after regular headers" 127 | end 128 | 129 | decoding_pseudo_headers = is_pseudo_header 130 | raise ProtocolError, "invalid header received: #{field}" if FORBIDDEN_HEADERS.include?(field) 131 | 132 | if frame 133 | case field 134 | when ":status" 135 | frame[:status] = Integer(value) 136 | when ":method" 137 | frame[:method] = value 138 | when "content-length" 139 | frame[:content_length] = Integer(value) 140 | when "trailer" 141 | (frame[:trailer] ||= []) << value 142 | end 143 | end 144 | list << [field, value] 145 | end 146 | end 147 | list 148 | end 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/http/2/header/encoding_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # To decompress header blocks, a decoder only needs to maintain a 5 | # dynamic table as a decoding context. 6 | # No other state information is needed. 7 | module Header 8 | class EncodingContext 9 | include Error 10 | 11 | UPPER = /[[:upper:]]/.freeze 12 | 13 | # @private 14 | # Static table 15 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#appendix-A 16 | STATIC_TABLE = [ 17 | [":authority", ""], 18 | [":method", "GET"], 19 | [":method", "POST"], 20 | [":path", "/"], 21 | [":path", "/index.html"], 22 | [":scheme", "http"], 23 | [":scheme", "https"], 24 | [":status", "200"], 25 | [":status", "204"], 26 | [":status", "206"], 27 | [":status", "304"], 28 | [":status", "400"], 29 | [":status", "404"], 30 | [":status", "500"], 31 | ["accept-charset", ""], 32 | ["accept-encoding", "gzip, deflate"], 33 | ["accept-language", ""], 34 | ["accept-ranges", ""], 35 | ["accept", ""], 36 | ["access-control-allow-origin", ""], 37 | ["age", ""], 38 | ["allow", ""], 39 | ["authorization", ""], 40 | ["cache-control", ""], 41 | ["content-disposition", ""], 42 | ["content-encoding", ""], 43 | ["content-language", ""], 44 | ["content-length", ""], 45 | ["content-location", ""], 46 | ["content-range", ""], 47 | ["content-type", ""], 48 | ["cookie", ""], 49 | ["date", ""], 50 | ["etag", ""], 51 | ["expect", ""], 52 | ["expires", ""], 53 | ["from", ""], 54 | ["host", ""], 55 | ["if-match", ""], 56 | ["if-modified-since", ""], 57 | ["if-none-match", ""], 58 | ["if-range", ""], 59 | ["if-unmodified-since", ""], 60 | ["last-modified", ""], 61 | ["link", ""], 62 | ["location", ""], 63 | ["max-forwards", ""], 64 | ["proxy-authenticate", ""], 65 | ["proxy-authorization", ""], 66 | ["range", ""], 67 | ["referer", ""], 68 | ["refresh", ""], 69 | ["retry-after", ""], 70 | ["server", ""], 71 | ["set-cookie", ""], 72 | ["strict-transport-security", ""], 73 | ["transfer-encoding", ""], 74 | ["user-agent", ""], 75 | ["vary", ""], 76 | ["via", ""], 77 | ["www-authenticate", ""] 78 | ].each(&:freeze).freeze 79 | 80 | STATIC_TABLE_BY_FIELD = 81 | STATIC_TABLE 82 | .each_with_object({}) 83 | .with_index { |((field, value), hs), idx| (hs[field] ||= []) << [idx, value].freeze } 84 | .each_value(&:freeze) 85 | .freeze 86 | 87 | STATIC_TABLE_SIZE = STATIC_TABLE.size 88 | 89 | DEFAULT_OPTIONS = { 90 | huffman: :shorter, 91 | index: :all, 92 | table_size: 4096 93 | }.freeze 94 | 95 | STATIC_ALL = %i[all static].freeze 96 | 97 | STATIC_NEVER = %i[never static].freeze 98 | 99 | # Current table of header key-value pairs. 100 | attr_reader :table 101 | 102 | # Current encoding options 103 | # 104 | # :table_size Integer maximum dynamic table size in bytes 105 | # :huffman Symbol :always, :never, :shorter 106 | # :index Symbol :all, :static, :never 107 | attr_reader :options 108 | 109 | # Current table size in octets 110 | attr_reader :current_table_size 111 | 112 | # Initializes compression context with appropriate client/server 113 | # defaults and maximum size of the dynamic table. 114 | # 115 | # @param options [Hash] encoding options 116 | # :table_size Integer maximum dynamic table size in bytes 117 | # :huffman Symbol :always, :never, :shorter 118 | # :index Symbol :all, :static, :never 119 | def initialize(options = {}) 120 | @table = [] 121 | @options = DEFAULT_OPTIONS.merge(options) 122 | @limit = @options[:table_size] 123 | @_table_updated = false 124 | @current_table_size = 0 125 | end 126 | 127 | # Duplicates current compression context 128 | # @return [EncodingContext] 129 | def dup 130 | other = EncodingContext.new(@options) 131 | t = @table 132 | l = @limit 133 | other.instance_eval do 134 | @table = t.dup # shallow copy 135 | @limit = l 136 | end 137 | other 138 | end 139 | 140 | # Finds an entry in current dynamic table by index. 141 | # Note that index is zero-based in this module. 142 | # 143 | # If the index is greater than the last index in the static table, 144 | # an entry in the dynamic table is dereferenced. 145 | # 146 | # If the index is greater than the last header index, an error is raised. 147 | # 148 | # @param index [Integer] zero-based index in the dynamic table. 149 | # @return [Array] +[key, value]+ 150 | def dereference(index) 151 | # NOTE: index is zero-based in this module. 152 | return STATIC_TABLE[index] if index < STATIC_TABLE_SIZE 153 | 154 | idx = index - STATIC_TABLE_SIZE 155 | 156 | raise CompressionError, "Index too large" if idx >= @table.size 157 | 158 | @table[index - STATIC_TABLE_SIZE] 159 | end 160 | 161 | # Header Block Processing 162 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-4.1 163 | # 164 | # @param cmd [Hash] { type:, name:, value:, index: } 165 | # @return [Array, nil] +[name, value]+ header field that is added to the decoded header list, 166 | # or nil if +cmd[:type]+ is +:changetablesize+ 167 | def process(cmd) 168 | type = cmd[:type] 169 | name = cmd[:name] 170 | value = cmd[:value] 171 | 172 | case type 173 | when :changetablesize 174 | raise CompressionError, "tried to change table size after adding elements to table" if @_table_updated 175 | 176 | # we can receive multiple table size change commands inside a header frame. However, 177 | # we should blow up if we receive another frame where the new table size is bigger. 178 | table_size_updated = @limit != @options[:table_size] 179 | 180 | raise CompressionError, "dynamic table size update exceed limit" if !table_size_updated && value > @limit 181 | 182 | self.table_size = value 183 | 184 | nil 185 | when :indexed 186 | # Indexed Representation 187 | # An _indexed representation_ entails the following actions: 188 | # o The header field corresponding to the referenced entry in either 189 | # the static table or dynamic table is added to the decoded header 190 | # list. 191 | dereference(name) 192 | when :incremental, :noindex, :neverindexed 193 | # A _literal representation_ that is _not added_ to the dynamic table 194 | # entails the following action: 195 | # o The header field is added to the decoded header list. 196 | 197 | # A _literal representation_ that is _added_ to the dynamic table 198 | # entails the following actions: 199 | # o The header field is added to the decoded header list. 200 | # o The header field is inserted at the beginning of the dynamic table. 201 | 202 | case name 203 | when Integer 204 | name, v = dereference(name) 205 | 206 | value ||= v 207 | when UPPER 208 | raise ProtocolError, "Invalid uppercase key: #{name}" 209 | end 210 | 211 | emit = [name, value] 212 | 213 | # add to table 214 | if type == :incremental && size_check(name.bytesize + value.bytesize + 32) 215 | @table.unshift(emit) 216 | @current_table_size += name.bytesize + value.bytesize + 32 217 | @_table_updated = true 218 | end 219 | 220 | emit 221 | else 222 | raise CompressionError, "Invalid type: #{type}" 223 | end 224 | end 225 | 226 | # Plan header compression according to +@options [:index]+ 227 | # :never Do not use dynamic table or static table reference at all. 228 | # :static Use static table only. 229 | # :all Use all of them. 230 | # 231 | # @param headers [Array] +[[name, value], ...]+ 232 | # @return [Array] array of commands 233 | def encode(headers) 234 | # Literals commands are marked with :noindex when index is not used 235 | noindex = STATIC_NEVER.include?(@options[:index]) 236 | 237 | headers.each do |field, value| 238 | # Literal header names MUST be translated to lowercase before 239 | # encoding and transmission. 240 | field = field.downcase if UPPER.match?(field) 241 | value = "/" if field == ":path" && value.empty? 242 | cmd = addcmd(field, value) 243 | cmd[:type] = :noindex if noindex && cmd[:type] == :incremental 244 | process(cmd) 245 | yield cmd 246 | end 247 | end 248 | 249 | # Emits command for a header. 250 | # Prefer static table over dynamic table. 251 | # Prefer exact match over name-only match. 252 | # 253 | # +@options [:index]+ controls whether to use the dynamic table, 254 | # static table, or both. 255 | # :never Do not use dynamic table or static table reference at all. 256 | # :static Use static table only. 257 | # :all Use all of them. 258 | # 259 | # @param field [String] the header field 260 | # @param value [String] the header value 261 | # @return [Hash] command 262 | def addcmd(field, value) 263 | # @type var exact: Integer? 264 | exact = nil 265 | # @type var name_only: Integer? 266 | name_only = nil 267 | 268 | index_type = @options[:index] 269 | 270 | if STATIC_ALL.include?(index_type) && 271 | STATIC_TABLE_BY_FIELD.key?(field) 272 | STATIC_TABLE_BY_FIELD[field].each do |i, svalue| 273 | name_only ||= i 274 | if value == svalue 275 | exact = i 276 | break 277 | end 278 | end 279 | end 280 | 281 | if index_type == :all && !exact 282 | @table.each_with_index do |(hfield, hvalue), i| 283 | next unless field == hfield 284 | 285 | if value == hvalue 286 | exact = i + STATIC_TABLE_SIZE 287 | break 288 | else 289 | name_only ||= i + STATIC_TABLE_SIZE 290 | end 291 | end 292 | end 293 | 294 | if exact 295 | { name: exact, type: :indexed } 296 | else 297 | { name: name_only || field, value: value, type: :incremental } 298 | end 299 | end 300 | 301 | # Alter dynamic table size. 302 | # When the size is reduced, some headers might be evicted. 303 | def table_size=(size) 304 | @limit = size 305 | size_check(0) 306 | end 307 | 308 | def listen_on_table 309 | yield 310 | ensure 311 | @_table_updated = false 312 | end 313 | 314 | private 315 | 316 | # To keep the dynamic table size lower than or equal to @limit, 317 | # remove one or more entries at the end of the dynamic table. 318 | # 319 | # @param cmdsize [Integer] 320 | # @return [Boolean] whether +cmd+ fits in the dynamic table. 321 | def size_check(cmdsize) 322 | unless @table.empty? 323 | while @current_table_size + cmdsize > @limit 324 | 325 | name, value = @table.pop 326 | @current_table_size -= name.bytesize + value.bytesize + 32 327 | break if @table.empty? 328 | 329 | end 330 | end 331 | 332 | cmdsize <= @limit 333 | end 334 | end 335 | end 336 | end 337 | -------------------------------------------------------------------------------- /lib/http/2/header/huffman.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../error" 4 | require_relative "../extensions" 5 | 6 | module HTTP2 7 | # Implementation of huffman encoding for HPACK 8 | # 9 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10 10 | module Header 11 | # Huffman encoder/decoder 12 | module Huffman 13 | module_function 14 | 15 | include Error 16 | extend PackingExtensions 17 | extend BufferUtils 18 | 19 | BITS_AT_ONCE = 4 20 | EOS = 256 21 | private_constant :EOS 22 | 23 | # Encodes provided value via huffman encoding. 24 | # Length is not encoded in this method. 25 | # 26 | # @param str [String] 27 | # @param buffer [String] 28 | # @return [String] binary string 29 | def encode(str, buffer = "".b) 30 | bitstring = String.new("", encoding: Encoding::BINARY, capacity: (str.bytesize * 30) + ((8 - str.size) % 8)) 31 | str.each_byte { |chr| append_str(bitstring, ENCODE_TABLE[chr]) } 32 | append_str(bitstring, ("1" * ((8 - bitstring.size) % 8))) 33 | pack([bitstring], "B*", buffer: buffer) 34 | end 35 | 36 | # Decodes provided Huffman coded string. 37 | # 38 | # @param buf [Buffer] 39 | # @return [String] binary string 40 | # @raise [CompressionError] when Huffman coded string is malformed 41 | def decode(buf) 42 | emit = "".b 43 | state = 0 # start state 44 | 45 | mask = (1 << BITS_AT_ONCE) - 1 46 | buf.each_byte do |chr| 47 | ((8 / BITS_AT_ONCE) - 1).downto(0) do |shift| 48 | branch = (chr >> (shift * BITS_AT_ONCE)) & mask 49 | # MACHINE[state] = [final, [transitions]] 50 | # [final] unfinished bits so far are prefix of the EOS code. 51 | # Each transition is [emit, next] 52 | # [emit] character to be emitted on this transition, empty string, or EOS. 53 | # [next] next state number. 54 | first, state = MACHINE.dig(state, branch) 55 | raise CompressionError, "Huffman decode error (EOS found)" if first == EOS 56 | 57 | append_str(emit, first.chr) if first 58 | end 59 | end 60 | # Check whether partial input is correctly filled 61 | raise CompressionError, "Huffman decode error (EOS invalid)" unless state <= MAX_FINAL_STATE 62 | 63 | emit.force_encoding(Encoding::BINARY) 64 | end 65 | 66 | # Huffman table as specified in 67 | # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#appendix-B 68 | CODES = [ 69 | [0x1ff8, 13], 70 | [0x7fffd8, 23], 71 | [0xfffffe2, 28], 72 | [0xfffffe3, 28], 73 | [0xfffffe4, 28], 74 | [0xfffffe5, 28], 75 | [0xfffffe6, 28], 76 | [0xfffffe7, 28], 77 | [0xfffffe8, 28], 78 | [0xffffea, 24], 79 | [0x3ffffffc, 30], 80 | [0xfffffe9, 28], 81 | [0xfffffea, 28], 82 | [0x3ffffffd, 30], 83 | [0xfffffeb, 28], 84 | [0xfffffec, 28], 85 | [0xfffffed, 28], 86 | [0xfffffee, 28], 87 | [0xfffffef, 28], 88 | [0xffffff0, 28], 89 | [0xffffff1, 28], 90 | [0xffffff2, 28], 91 | [0x3ffffffe, 30], 92 | [0xffffff3, 28], 93 | [0xffffff4, 28], 94 | [0xffffff5, 28], 95 | [0xffffff6, 28], 96 | [0xffffff7, 28], 97 | [0xffffff8, 28], 98 | [0xffffff9, 28], 99 | [0xffffffa, 28], 100 | [0xffffffb, 28], 101 | [0x14, 6], 102 | [0x3f8, 10], 103 | [0x3f9, 10], 104 | [0xffa, 12], 105 | [0x1ff9, 13], 106 | [0x15, 6], 107 | [0xf8, 8], 108 | [0x7fa, 11], 109 | [0x3fa, 10], 110 | [0x3fb, 10], 111 | [0xf9, 8], 112 | [0x7fb, 11], 113 | [0xfa, 8], 114 | [0x16, 6], 115 | [0x17, 6], 116 | [0x18, 6], 117 | [0x0, 5], 118 | [0x1, 5], 119 | [0x2, 5], 120 | [0x19, 6], 121 | [0x1a, 6], 122 | [0x1b, 6], 123 | [0x1c, 6], 124 | [0x1d, 6], 125 | [0x1e, 6], 126 | [0x1f, 6], 127 | [0x5c, 7], 128 | [0xfb, 8], 129 | [0x7ffc, 15], 130 | [0x20, 6], 131 | [0xffb, 12], 132 | [0x3fc, 10], 133 | [0x1ffa, 13], 134 | [0x21, 6], 135 | [0x5d, 7], 136 | [0x5e, 7], 137 | [0x5f, 7], 138 | [0x60, 7], 139 | [0x61, 7], 140 | [0x62, 7], 141 | [0x63, 7], 142 | [0x64, 7], 143 | [0x65, 7], 144 | [0x66, 7], 145 | [0x67, 7], 146 | [0x68, 7], 147 | [0x69, 7], 148 | [0x6a, 7], 149 | [0x6b, 7], 150 | [0x6c, 7], 151 | [0x6d, 7], 152 | [0x6e, 7], 153 | [0x6f, 7], 154 | [0x70, 7], 155 | [0x71, 7], 156 | [0x72, 7], 157 | [0xfc, 8], 158 | [0x73, 7], 159 | [0xfd, 8], 160 | [0x1ffb, 13], 161 | [0x7fff0, 19], 162 | [0x1ffc, 13], 163 | [0x3ffc, 14], 164 | [0x22, 6], 165 | [0x7ffd, 15], 166 | [0x3, 5], 167 | [0x23, 6], 168 | [0x4, 5], 169 | [0x24, 6], 170 | [0x5, 5], 171 | [0x25, 6], 172 | [0x26, 6], 173 | [0x27, 6], 174 | [0x6, 5], 175 | [0x74, 7], 176 | [0x75, 7], 177 | [0x28, 6], 178 | [0x29, 6], 179 | [0x2a, 6], 180 | [0x7, 5], 181 | [0x2b, 6], 182 | [0x76, 7], 183 | [0x2c, 6], 184 | [0x8, 5], 185 | [0x9, 5], 186 | [0x2d, 6], 187 | [0x77, 7], 188 | [0x78, 7], 189 | [0x79, 7], 190 | [0x7a, 7], 191 | [0x7b, 7], 192 | [0x7ffe, 15], 193 | [0x7fc, 11], 194 | [0x3ffd, 14], 195 | [0x1ffd, 13], 196 | [0xffffffc, 28], 197 | [0xfffe6, 20], 198 | [0x3fffd2, 22], 199 | [0xfffe7, 20], 200 | [0xfffe8, 20], 201 | [0x3fffd3, 22], 202 | [0x3fffd4, 22], 203 | [0x3fffd5, 22], 204 | [0x7fffd9, 23], 205 | [0x3fffd6, 22], 206 | [0x7fffda, 23], 207 | [0x7fffdb, 23], 208 | [0x7fffdc, 23], 209 | [0x7fffdd, 23], 210 | [0x7fffde, 23], 211 | [0xffffeb, 24], 212 | [0x7fffdf, 23], 213 | [0xffffec, 24], 214 | [0xffffed, 24], 215 | [0x3fffd7, 22], 216 | [0x7fffe0, 23], 217 | [0xffffee, 24], 218 | [0x7fffe1, 23], 219 | [0x7fffe2, 23], 220 | [0x7fffe3, 23], 221 | [0x7fffe4, 23], 222 | [0x1fffdc, 21], 223 | [0x3fffd8, 22], 224 | [0x7fffe5, 23], 225 | [0x3fffd9, 22], 226 | [0x7fffe6, 23], 227 | [0x7fffe7, 23], 228 | [0xffffef, 24], 229 | [0x3fffda, 22], 230 | [0x1fffdd, 21], 231 | [0xfffe9, 20], 232 | [0x3fffdb, 22], 233 | [0x3fffdc, 22], 234 | [0x7fffe8, 23], 235 | [0x7fffe9, 23], 236 | [0x1fffde, 21], 237 | [0x7fffea, 23], 238 | [0x3fffdd, 22], 239 | [0x3fffde, 22], 240 | [0xfffff0, 24], 241 | [0x1fffdf, 21], 242 | [0x3fffdf, 22], 243 | [0x7fffeb, 23], 244 | [0x7fffec, 23], 245 | [0x1fffe0, 21], 246 | [0x1fffe1, 21], 247 | [0x3fffe0, 22], 248 | [0x1fffe2, 21], 249 | [0x7fffed, 23], 250 | [0x3fffe1, 22], 251 | [0x7fffee, 23], 252 | [0x7fffef, 23], 253 | [0xfffea, 20], 254 | [0x3fffe2, 22], 255 | [0x3fffe3, 22], 256 | [0x3fffe4, 22], 257 | [0x7ffff0, 23], 258 | [0x3fffe5, 22], 259 | [0x3fffe6, 22], 260 | [0x7ffff1, 23], 261 | [0x3ffffe0, 26], 262 | [0x3ffffe1, 26], 263 | [0xfffeb, 20], 264 | [0x7fff1, 19], 265 | [0x3fffe7, 22], 266 | [0x7ffff2, 23], 267 | [0x3fffe8, 22], 268 | [0x1ffffec, 25], 269 | [0x3ffffe2, 26], 270 | [0x3ffffe3, 26], 271 | [0x3ffffe4, 26], 272 | [0x7ffffde, 27], 273 | [0x7ffffdf, 27], 274 | [0x3ffffe5, 26], 275 | [0xfffff1, 24], 276 | [0x1ffffed, 25], 277 | [0x7fff2, 19], 278 | [0x1fffe3, 21], 279 | [0x3ffffe6, 26], 280 | [0x7ffffe0, 27], 281 | [0x7ffffe1, 27], 282 | [0x3ffffe7, 26], 283 | [0x7ffffe2, 27], 284 | [0xfffff2, 24], 285 | [0x1fffe4, 21], 286 | [0x1fffe5, 21], 287 | [0x3ffffe8, 26], 288 | [0x3ffffe9, 26], 289 | [0xffffffd, 28], 290 | [0x7ffffe3, 27], 291 | [0x7ffffe4, 27], 292 | [0x7ffffe5, 27], 293 | [0xfffec, 20], 294 | [0xfffff3, 24], 295 | [0xfffed, 20], 296 | [0x1fffe6, 21], 297 | [0x3fffe9, 22], 298 | [0x1fffe7, 21], 299 | [0x1fffe8, 21], 300 | [0x7ffff3, 23], 301 | [0x3fffea, 22], 302 | [0x3fffeb, 22], 303 | [0x1ffffee, 25], 304 | [0x1ffffef, 25], 305 | [0xfffff4, 24], 306 | [0xfffff5, 24], 307 | [0x3ffffea, 26], 308 | [0x7ffff4, 23], 309 | [0x3ffffeb, 26], 310 | [0x7ffffe6, 27], 311 | [0x3ffffec, 26], 312 | [0x3ffffed, 26], 313 | [0x7ffffe7, 27], 314 | [0x7ffffe8, 27], 315 | [0x7ffffe9, 27], 316 | [0x7ffffea, 27], 317 | [0x7ffffeb, 27], 318 | [0xffffffe, 28], 319 | [0x7ffffec, 27], 320 | [0x7ffffed, 27], 321 | [0x7ffffee, 27], 322 | [0x7ffffef, 27], 323 | [0x7fffff0, 27], 324 | [0x3ffffee, 26], 325 | [0x3fffffff, 30] 326 | ].each(&:freeze).freeze 327 | 328 | ENCODE_TABLE = CODES.map { |c, l| [c].pack("N").unpack1("B*")[-l..-1] }.each(&:freeze).freeze 329 | end 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /lib/http/2/server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | # HTTP 2.0 server connection class that implements appropriate header 5 | # compression / decompression algorithms and stream management logic. 6 | # 7 | # Your code is responsible for feeding request data to the server object, 8 | # which in turn performs all of the necessary HTTP 2.0 decoding / encoding, 9 | # state management, and the rest. A simple example: 10 | # 11 | # @example 12 | # socket = YourTransport.new 13 | # 14 | # conn = HTTP2::Server.new 15 | # conn.on(:stream) do |stream| 16 | # ... 17 | # end 18 | # 19 | # while bytes = socket.read 20 | # conn << bytes 21 | # end 22 | # 23 | class Server < Connection 24 | attr_reader :origin_set 25 | 26 | # Initialize new HTTP 2.0 server object. 27 | def initialize(settings = {}) 28 | @stream_id = 2 29 | @state = :waiting_magic 30 | 31 | @local_role = :server 32 | @remote_role = :client 33 | 34 | @origin_set = [] 35 | @origins_sent = true 36 | 37 | super 38 | end 39 | 40 | # GET / HTTP/1.1 41 | # Host: server.example.com 42 | # Connection: Upgrade, HTTP2-Settings 43 | # Upgrade: h2c 44 | # HTTP2-Settings: 45 | # 46 | # Requests that contain a payload body MUST be sent in their entirety 47 | # before the client can send HTTP/2 frames. This means that a large 48 | # request can block the use of the connection until it is completely sent. 49 | # 50 | # If concurrency of an initial request with subsequent requests is 51 | # important, an OPTIONS request can be used to perform the upgrade to 52 | # HTTP/2, at the cost of an additional round trip. 53 | # 54 | # HTTP/1.1 101 Switching Protocols 55 | # Connection: Upgrade 56 | # Upgrade: h2c 57 | # 58 | # [ HTTP/2 connection ... 59 | # 60 | # - The first HTTP/2 frame sent by the server MUST be a server 61 | # connection preface (Section 3.5) consisting of a SETTINGS frame. 62 | # - Upon receiving the 101 response, the client MUST send a connection 63 | # preface (Section 3.5), which includes a SETTINGS frame. 64 | # 65 | # The HTTP/1.1 request that is sent prior to upgrade is assigned a stream 66 | # identifier of 1 (see Section 5.1.1) with default priority values 67 | # (Section 5.3.5). Stream 1 is implicitly "half-closed" from the client 68 | # toward the server (see Section 5.1), since the request is completed as 69 | # an HTTP/1.1 request. After commencing the HTTP/2 connection, stream 1 70 | # is used for the response. 71 | # 72 | def upgrade(settings, headers, body) 73 | @h2c_upgrade = :start 74 | 75 | # Pretend that we've received the preface 76 | # - puts us into :waiting_connection_preface state 77 | # - emits a SETTINGS frame to the client 78 | receive(CONNECTION_PREFACE_MAGIC) 79 | 80 | # Process received HTTP2-Settings payload 81 | buf = "".b 82 | append_str(buf, Base64.urlsafe_decode64(settings.to_s)) 83 | @framer.common_header( 84 | { 85 | length: buf.bytesize, 86 | type: :settings, 87 | stream: 0, 88 | flags: [] 89 | }, 90 | buffer: buf 91 | ) 92 | receive(buf) 93 | 94 | # Activate stream (id: 1) with on HTTP/1.1 request parameters 95 | stream = activate_stream(id: 1) 96 | emit(:stream, stream) 97 | 98 | headers_frame = { 99 | type: :headers, 100 | flags: [:end_headers], 101 | stream: 1, 102 | weight: DEFAULT_WEIGHT, 103 | dependency: 0, 104 | exclusive: false, 105 | payload: headers 106 | } 107 | 108 | if body.empty? 109 | headers_frame[:flags] << [:end_stream] 110 | stream << headers_frame 111 | else 112 | stream << headers_frame 113 | stream << { type: :data, stream: 1, payload: body, flags: [:end_stream] } 114 | end 115 | 116 | # Mark h2c upgrade as finished 117 | @h2c_upgrade = :finished 118 | 119 | # Transition back to :waiting_magic and wait for client's preface 120 | @state = :waiting_magic 121 | end 122 | 123 | def activate_stream(**) 124 | super.tap do |stream| 125 | stream.on(:promise, &method(:promise)) 126 | end 127 | end 128 | 129 | def origin_set=(origins) 130 | @origin_set = Array(origins).map(&:to_s) 131 | @origins_sent = @origin_set.empty? 132 | end 133 | 134 | private 135 | 136 | def connection_settings(frame) 137 | super 138 | return unless frame[:flags].include?(:ack) && !@origins_sent 139 | 140 | send(type: :origin, stream: 0, payload: @origin_set) 141 | end 142 | 143 | def verify_pseudo_headers(frame) 144 | _verify_pseudo_headers(frame, REQUEST_MANDATORY_HEADERS) 145 | end 146 | 147 | # Handle locally initiated server-push event emitted by the stream. 148 | # 149 | # @param parent [Stream] 150 | # @param headers [Enumerable[String, String]] 151 | # @param flags [Array[Symbol]] 152 | # @param callback [Proc] 153 | def promise(parent, headers, flags) 154 | promise = new_stream(parent: parent) 155 | promise.send( 156 | type: :push_promise, 157 | flags: flags, 158 | stream: parent.id, 159 | promise_stream: promise.id, 160 | payload: headers.to_a 161 | ) 162 | 163 | yield(promise) 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/http/2/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTTP2 4 | VERSION = "1.1.1" 5 | end 6 | -------------------------------------------------------------------------------- /sig/2.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | VERSION: String 3 | 4 | DEFAULT_FLOW_WINDOW: Integer 5 | 6 | DEFAULT_HEADER_SIZE: Integer 7 | 8 | DEFAULT_MAX_CONCURRENT_STREAMS: Integer 9 | 10 | EMPTY: [] 11 | 12 | type connection_opts = Hash[Symbol, untyped] 13 | 14 | type settings_hash = { 15 | settings_header_table_size: Integer, 16 | settings_enable_push: Integer, 17 | settings_max_concurrent_streams: Integer, 18 | settings_initial_window_size: Integer, 19 | settings_max_frame_size: Integer, 20 | settings_max_header_list_size: Integer 21 | } 22 | 23 | type settings_ary = Array[settings_enum] 24 | 25 | type settings_enum = Enumerable[[Symbol, Integer]] 26 | 27 | SPEC_DEFAULT_CONNECTION_SETTINGS: settings_hash 28 | 29 | DEFAULT_CONNECTION_SETTINGS: settings_hash 30 | 31 | DEFAULT_WEIGHT: Integer 32 | 33 | CONNECTION_PREFACE_MAGIC: String 34 | 35 | REQUEST_MANDATORY_HEADERS: Array[String] 36 | 37 | RESPONSE_MANDATORY_HEADERS: Array[String] 38 | 39 | type header_pair = [string, string] 40 | 41 | # # FRAMES 42 | type frame_control_flags = Array[:end_headers | :end_stream] 43 | 44 | type common_frame = { stream: Integer } 45 | 46 | # # HEADERS 47 | type headers_frame = common_frame & { 48 | type: :headers, flags: frame_control_flags, payload: Enumerable[header_pair] | String, 49 | ?method: Symbol, ?trailer: Array[String], ?content_length: Integer, ?padding: Integer 50 | } 51 | 52 | # # DATA 53 | type data_frame = { type: :data, flags: frame_control_flags, ?length: Integer, payload: String, ?padding: Integer } 54 | 55 | # # PUSH_PROMISE 56 | type push_promise_frame = { type: :push_promise, promise_stream: Integer, flags: frame_control_flags, ?method: Symbol, ?trailer: Array[String], ?content_length: Integer, payload: Enumerable[header_pair], ?padding: Integer } 57 | 58 | # # SETTINGS 59 | type settings_frame = { type: :settings, payload: Array[[Symbol | Integer, Integer]] } 60 | 61 | # # WINDOW_UPDATE 62 | type window_update_frame = { type: :window_update, increment: Integer } 63 | 64 | # # PRIORITY 65 | type priority_frame = { dependency: Integer, exclusive: bool, weight: Integer } 66 | 67 | # # ALTSVC 68 | type altsvc_frame = { type: :altsvc, max_age: Integer, port: Integer, proto: "String", host: String } 69 | 70 | # # ORIGIN 71 | type origin_frame = { type: :origin, origin: Array[String] } 72 | 73 | # # PING 74 | type ping_frame = { type: :ping, payload: String, length: Integer } 75 | 76 | # # GOAWAY 77 | type goaway_frame = { type: :goaway, last_stream: Integer, error: Symbol? } 78 | 79 | # type frame = common_frame & (headers_frame | data_frame | push_promise_frame | 80 | # settings_frame | window_update_frame | priority_frame | altsvc_frame | 81 | # origin_frame | ping_frame | goaway_frame) 82 | 83 | type frame_key = :type | :flags | :stream | :padding | :ignore | 84 | # headers 85 | :method | :trailer | :content_length | :status | 86 | # data, settings, ping 87 | :payload | :length | 88 | # promise 89 | :promise_stream | 90 | # window_update 91 | :increment | 92 | # priority 93 | :dependency | :exclusive | :weight | 94 | # altsvc 95 | :max_age | :port | :proto | :host | 96 | # origin 97 | :origin | 98 | # goaway 99 | :last_stream | :error 100 | 101 | type frame_value = Integer | 102 | Symbol | # type (:data, :headers) 103 | Array[Symbol] | 104 | String | 105 | bool | 106 | Array[String] | 107 | Array[[Symbol | Integer, Integer]] | 108 | Enumerable[header_pair] | 109 | nil 110 | 111 | type frame = Hash[frame_key, frame_value] 112 | end 113 | -------------------------------------------------------------------------------- /sig/client.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | class Client < Connection 3 | @h2c_upgrade: Symbol? 4 | 5 | def upgrade: () -> Stream 6 | 7 | def send_connection_preface: () -> void 8 | 9 | def self.settings_header: (settings_enum) -> String 10 | end 11 | end -------------------------------------------------------------------------------- /sig/connection.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | class Connection 3 | include FlowBuffer 4 | include Emitter 5 | include BufferUtils 6 | 7 | REQUEST_MANDATORY_HEADERS: Array[String] 8 | 9 | RESPONSE_MANDATORY_HEADERS: Array[String] 10 | 11 | CONNECTION_FRAME_TYPES: Array[Symbol] 12 | 13 | HEADERS_FRAME_TYPES: Array[Symbol] 14 | 15 | STREAM_OPEN_STATES: Array[Symbol] 16 | 17 | attr_reader state: Symbol 18 | 19 | attr_reader local_window: Integer 20 | attr_reader remote_window: Integer 21 | 22 | alias window local_window 23 | 24 | attr_reader remote_settings: settings_hash 25 | attr_reader local_settings: settings_hash 26 | attr_reader pending_settings: settings_ary 27 | 28 | attr_accessor active_stream_count: Integer 29 | 30 | @stream_id: Integer 31 | @active_stream_count: Integer 32 | @last_stream_id: Integer 33 | 34 | @streams: Hash[Integer, Stream] 35 | @streams_recently_closed: Hash[Integer, Numeric] 36 | 37 | @framer: Framer 38 | 39 | type role_type = :client | :server 40 | 41 | @local_role: role_type 42 | @remote_role: role_type 43 | 44 | @local_window_limit: Integer 45 | @remote_window_limit: Integer 46 | 47 | @compressor: Header::Compressor 48 | @decompressor: Header::Decompressor 49 | @error: Symbol? 50 | 51 | @recv_buffer: String 52 | @continuation: Array[frame] 53 | 54 | @h2c_upgrade: Symbol? 55 | @closed_since: Float? 56 | @received_frame: bool 57 | 58 | def closed?: () -> bool 59 | 60 | def new_stream: (**untyped) -> Stream 61 | 62 | def ping: (String) -> void 63 | | (String) { () -> void } -> void 64 | 65 | def goaway: (?Symbol, ?String) -> void 66 | 67 | def window_update: (Integer increment) -> void 68 | 69 | def settings: (settings_enum payload) -> void 70 | 71 | def receive: (string data) -> void 72 | alias << receive 73 | 74 | def initialize: (?connection_opts) -> void 75 | 76 | private 77 | 78 | def send: (frame frame) -> void 79 | 80 | def encode: (frame frame) -> void 81 | 82 | def connection_frame?: (frame) -> bool 83 | 84 | def connection_management: (frame) -> void 85 | 86 | def ping_management: (frame) -> void 87 | 88 | def validate_settings: (role_type, settings_enum) -> void 89 | 90 | def connection_settings: (frame) -> void 91 | 92 | def decode_headers: (frame) -> void 93 | 94 | def encode_headers: (frame headers_frame) -> void 95 | 96 | def activate_stream: (id: Integer, **untyped) -> Stream 97 | 98 | def verify_stream_order: (Integer id) -> void 99 | 100 | def verify_pseudo_headers: (frame) -> void 101 | 102 | def _verify_pseudo_headers: (frame, Array[String]) -> void 103 | 104 | def connection_error: (?Symbol error, ?msg: String?, ?e: StandardError?) -> void 105 | end 106 | end -------------------------------------------------------------------------------- /sig/emitter.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Emitter 3 | @listeners: Hash[Symbol, Array[^(*untyped) -> void]] 4 | 5 | def on: (Symbol event) { (*untyped) -> void } -> void 6 | 7 | def once: (Symbol event) { (*untyped) -> void } -> void 8 | 9 | def emit: (Symbol event, *untyped args) ?{ (*untyped) -> void } -> void 10 | end 11 | end -------------------------------------------------------------------------------- /sig/error.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Error 3 | def self?.types: () -> Hash[Symbol, singleton(Error)] 4 | 5 | class Error < StandardError 6 | end 7 | 8 | class HandshakeError < Error 9 | end 10 | 11 | class ProtocolError < Error 12 | end 13 | 14 | class CompressionError < ProtocolError 15 | end 16 | 17 | class FlowControlError < ProtocolError 18 | end 19 | 20 | class InternalError < ProtocolError 21 | end 22 | 23 | class StreamClosed < Error 24 | end 25 | 26 | class ConnectionClosed < Error 27 | end 28 | 29 | class StreamLimitExceeded < Error 30 | end 31 | 32 | class FrameSizeError < Error 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /sig/extensions.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module BufferUtils 3 | def append_str: (String str, String data) -> void 4 | 5 | def read_str: (String str, Integer n) -> String 6 | 7 | def read_uint32: (String str) -> Integer 8 | 9 | def shift_byte: (String str) -> Integer 10 | end 11 | 12 | module PackingExtensions 13 | def pack: (Array[Integer | String] array_to_pack, String template, buffer: String, ?offset: Integer) -> String 14 | end 15 | end -------------------------------------------------------------------------------- /sig/flow_buffer.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module FlowBuffer 3 | MAX_WINDOW_SIZE: Integer 4 | 5 | attr_reader send_buffer: FrameBuffer 6 | 7 | def buffered_amount: () -> Integer 8 | 9 | def flush: () -> void 10 | 11 | private 12 | 13 | def update_local_window: (data_frame frame) -> void 14 | 15 | def calculate_window_update: (Integer) -> void 16 | 17 | def send_data: (?data_frame? frame, ?bool encode) -> void 18 | 19 | def send_frame: (data_frame frame, bool encode) -> void 20 | 21 | def process_window_update: (frame: window_update_frame, ?encode: bool) -> void 22 | end 23 | end -------------------------------------------------------------------------------- /sig/frame_buffer.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | class FrameBuffer 3 | attr_reader bytesize: Integer 4 | 5 | @buffer: Array[data_frame] 6 | 7 | def <<: (data_frame frame) -> void 8 | 9 | def empty?: () -> bool 10 | 11 | def retrieve: (Integer) -> data_frame? 12 | end 13 | end -------------------------------------------------------------------------------- /sig/framer.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | class Framer 3 | include Error 4 | include PackingExtensions 5 | include BufferUtils 6 | 7 | DEFAULT_MAX_FRAME_SIZE: Integer 8 | 9 | MAX_STREAM_ID: Integer 10 | 11 | MAX_WINDOWINC: Integer 12 | 13 | FRAME_TYPES: Hash[Symbol, Integer] 14 | 15 | FRAME_TYPES_BY_NAME: Array[Symbol] 16 | 17 | FRAME_TYPES_WITH_PADDING: Array[Symbol] 18 | 19 | FRAME_FLAGS: Hash[Symbol, Hash[Symbol, Integer]] 20 | 21 | DEFINED_SETTINGS: Hash[Symbol, Integer] 22 | 23 | DEFINED_ERRORS: Hash[Symbol, Integer] 24 | 25 | RBIT: Integer 26 | RBYTE: Integer 27 | EBIT: Integer 28 | UINT32: String 29 | UINT16: String 30 | UINT8: String 31 | HEADERPACK: String 32 | FRAME_LENGTH_HISHIFT: Integer 33 | FRAME_LENGTH_LOMASK: Integer 34 | 35 | @local_max_frame_size: Integer 36 | @remote_max_frame_size: Integer 37 | 38 | attr_accessor local_max_frame_size: Integer 39 | 40 | attr_accessor remote_max_frame_size: Integer 41 | 42 | def common_header: (frame, buffer: String) -> String 43 | 44 | def read_common_header: (String buf) -> frame 45 | 46 | def read_common_frame: (String) -> frame 47 | 48 | def generate: (frame) -> String 49 | 50 | def parse: (String) -> frame? 51 | 52 | private 53 | 54 | def initialize: (?Integer local_max_frame_size, ?Integer remote_max_frame_size) -> untyped 55 | 56 | def pack_error: (Integer | Symbol error, buffer: String) -> String 57 | 58 | def unpack_error: (Integer) -> (Symbol | Integer) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /sig/header.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Header 3 | type header_key = :type | :name | :value | :index 4 | type header_value = Integer | String | :indexed | :changetablesize | :incremental | :noindex | :neverindexed 5 | 6 | type context_hash = { 7 | huffman: (:always | :never | :shorter), 8 | index: (:all | :static | :never), 9 | table_size: Integer 10 | } 11 | 12 | type header_type = :indexed | :incremental | :noindex | :neverindexed | :changetablesize 13 | 14 | type header_command = { type: :indexed , name: Integer } | 15 | { type: (:incremental | :noindex | :neverindexed), name: Integer | String, value: String } | 16 | { type: :changetablesize, ?name: Integer, value: Integer } 17 | 18 | HEADREP: Hash[header_type, { prefix: Integer, pattern: Integer }] 19 | 20 | NAIVE: Hash[Symbol, Symbol] 21 | LINEAR: Hash[Symbol, Symbol] 22 | STATIC: Hash[Symbol, Symbol] 23 | SHORTER: Hash[Symbol, Symbol] 24 | NAIVEH: Hash[Symbol, Symbol] 25 | LINEARH: Hash[Symbol, Symbol] 26 | STATICH: Hash[Symbol, Symbol] 27 | SHORTERH: Hash[Symbol, Symbol] 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /sig/header/compressor.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Header 3 | class Compressor 4 | include PackingExtensions 5 | 6 | @cc: EncodingContext 7 | 8 | def table_size=: (Integer) -> void 9 | 10 | def integer: (Integer, Integer, buffer: String, ?offset: Integer) -> String 11 | 12 | def string: (String, ?String buffer) -> String 13 | 14 | def header: (header_command, ?String) -> String 15 | 16 | def encode: (Enumerable[header_pair]) -> String 17 | 18 | private 19 | 20 | def initialize: (?connection_opts options) -> void 21 | 22 | def huffman_string: (String str, ?String buffer) -> String 23 | 24 | def plain_string: (String str, ?String buffer) -> String 25 | 26 | def set_huffman_size: (String str, Integer huffman_offset) -> String 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /sig/header/decompressor.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Header 3 | class Decompressor 4 | include BufferUtils 5 | 6 | FORBIDDEN_HEADERS: Array[String] 7 | 8 | @cc: EncodingContext 9 | 10 | def table_size=: (Integer) -> void 11 | 12 | def integer: (String, Integer) -> Integer 13 | 14 | def string: (String) -> String 15 | 16 | def header: (String) -> header_command 17 | 18 | def decode: (String, frame?) -> Array[header_pair] 19 | | (String) -> Array[header_pair] 20 | private 21 | 22 | def initialize: (?connection_opts options) -> void 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /sig/header/encoding_context.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Header 3 | class EncodingContext 4 | STATIC_TABLE: Array[header_pair] 5 | 6 | STATIC_TABLE_BY_FIELD: Hash[String, Array[[Integer, String]]] 7 | 8 | STATIC_TABLE_SIZE: Integer 9 | 10 | STATIC_ALL: Array[Symbol] 11 | 12 | STATIC_NEVER: Array[Symbol] 13 | 14 | DEFAULT_OPTIONS: context_hash 15 | 16 | UPPER: Regexp 17 | 18 | 19 | attr_reader table: Array[header_pair] 20 | 21 | attr_reader options: context_hash 22 | 23 | attr_reader current_table_size: Integer 24 | 25 | @limit: Integer 26 | 27 | @_table_updated: bool 28 | 29 | def dup: () -> EncodingContext 30 | 31 | def dereference: (Integer) -> header_pair 32 | 33 | def process: (header_command cmd) -> header_pair? 34 | 35 | def encode: (_Each[header_pair]) { (header_command) -> void } -> void 36 | 37 | def addcmd: (String name, String value) -> header_command 38 | 39 | def table_size=: (Integer) -> void 40 | 41 | def listen_on_table: { () -> void } -> void 42 | 43 | private 44 | 45 | def initialize: (?connection_opts options) -> void 46 | 47 | def add_to_table: (string name, string value) -> void 48 | 49 | def size_check: (Integer cmdsize) -> bool 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /sig/header/huffman.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | module Header 3 | module Huffman 4 | include Error 5 | extend PackingExtensions 6 | extend BufferUtils 7 | 8 | BITS_AT_ONCE: Integer 9 | 10 | EOS: Integer 11 | 12 | CODES: Array[[Integer, Integer]] 13 | 14 | ENCODE_TABLE: Array[String] 15 | 16 | MAX_FINAL_STATE: Integer 17 | 18 | MACHINE: Array[Array[[Integer?, Integer]]] 19 | 20 | def self?.encode: (String str, ?String buffer) -> String 21 | 22 | def self?.decode: (String str) -> String 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /sig/server.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | class Server < Connection 3 | 4 | def upgrade: (String settings, Enumerable[header_pair] headers, String body) -> void 5 | 6 | def origin_set=: (Array[_ToS]) -> void 7 | 8 | private 9 | 10 | def promise: (Stream parent, Enumerable[header_pair] headers, Array[Symbol] flags) { (Stream) -> void } -> void 11 | end 12 | end -------------------------------------------------------------------------------- /sig/stream.rbs: -------------------------------------------------------------------------------- 1 | module HTTP2 2 | class Stream 3 | include FlowBuffer 4 | include Emitter 5 | 6 | STREAM_OPEN_STATES: Array[Symbol] 7 | 8 | attr_reader id: Integer 9 | attr_reader state: Symbol 10 | attr_reader parent: Stream? 11 | attr_reader weight: Integer 12 | attr_reader dependency: Integer 13 | attr_reader remote_window: Integer 14 | attr_reader local_window: Integer 15 | attr_reader closed: Symbol? 16 | 17 | @connection: Connection 18 | @local_window_max_size: Integer 19 | @error: bool 20 | @_method: String? 21 | @_content_length: Integer? 22 | @_status_code: Integer? 23 | @_waiting_on_trailers: bool 24 | @_trailers: Array[String]? 25 | @received_data: bool 26 | @activated: bool 27 | 28 | alias window local_window 29 | 30 | def closed?: () -> bool 31 | 32 | def receive: (frame frame) -> void 33 | 34 | alias << receive 35 | 36 | def verify_trailers: (headers_frame frame) -> void 37 | 38 | def calculate_content_length: (Integer?) -> void 39 | 40 | def send: (frame frame) -> void 41 | 42 | def headers: (Enumerable[header_pair] headers, ?end_headers: bool, ?end_stream: bool) -> void 43 | 44 | def promise: (Enumerable[header_pair] headers, ?end_headers: bool) { (Stream) -> void } -> void 45 | 46 | def reprioritize: (?weight: Integer, ?dependency: Integer, ?exclusive: bool) -> void 47 | 48 | def data: (String payload, ?end_stream: bool) -> void 49 | 50 | def chunk_data: (String payload, Integer max_size) { (String) -> void } -> String 51 | 52 | def close: (Symbol error) -> void 53 | | () -> void 54 | 55 | def cancel: () -> void 56 | 57 | def refuse: () -> void 58 | 59 | def window_update: (Integer increment) -> void 60 | 61 | private 62 | 63 | def initialize: ( 64 | connection: Connection, 65 | id: Integer, 66 | ?weight: Integer, 67 | ?dependency: Integer, 68 | ?exclusive: bool, 69 | ?parent: Stream?, 70 | ?state: Symbol 71 | ) -> untyped 72 | 73 | def transition: (frame, bool sending) -> void 74 | 75 | def event: (Symbol newstate) -> void 76 | 77 | def activate_stream_in_conn: () -> void 78 | 79 | def close_stream_in_conn: (*untyped) -> void 80 | 81 | def complete_transition: (frame) -> void 82 | 83 | def process_priority: (priority_frame frame) -> void 84 | 85 | def end_stream?: (frame frame) -> boolish 86 | 87 | def stream_error: (Symbol error, ?msg: String?) -> void 88 | | () -> void 89 | 90 | alias error stream_error 91 | 92 | def manage_state: (frame) { () -> void } -> void 93 | end 94 | end -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | require "shared_examples/connection" 5 | 6 | RSpec.describe HTTP2::Client do 7 | include FrameHelpers 8 | 9 | let(:f) { Framer.new } 10 | let(:client) do 11 | client = Client.new 12 | client << f.generate(settings_frame) 13 | client 14 | end 15 | 16 | it_behaves_like "a connection" do 17 | let(:connected_conn) { client } 18 | end 19 | 20 | context "initialization and settings" do 21 | let(:client) { Client.new } 22 | it "should raise error if first frame is not settings" do 23 | (frame_types - [settings_frame]).each do |frame| 24 | conn = Client.new 25 | expect { conn << f.generate(frame) }.to raise_error(ProtocolError) 26 | expect(conn).to be_closed 27 | end 28 | end 29 | 30 | it "should not raise error if first frame is SETTINGS" do 31 | expect { client << f.generate(settings_frame) }.to_not raise_error 32 | expect(client.state).to eq :connected 33 | expect(client).to_not be_closed 34 | end 35 | 36 | it "should raise error if SETTINGS stream != 0" do 37 | frame = set_stream_id(f.generate(settings_frame), 0x1) 38 | expect { client << frame }.to raise_error(ProtocolError) 39 | end 40 | 41 | it "should return odd stream IDs" do 42 | expect(client.new_stream.id).not_to be_even 43 | end 44 | 45 | it "should emit connection header and SETTINGS on new client connection" do 46 | frames = [] 47 | client.on(:frame) { |bytes| frames << bytes } 48 | client.ping("12345678") 49 | 50 | expect(frames[0]).to eq CONNECTION_PREFACE_MAGIC 51 | expect(f.parse(frames[1])[:type]).to eq :settings 52 | end 53 | 54 | it "should initialize client with custom connection settings" do 55 | frames = [] 56 | 57 | client = Client.new(settings_max_concurrent_streams: 200) 58 | client.on(:frame) { |bytes| frames << bytes } 59 | client.ping("12345678") 60 | 61 | frame = f.parse(frames[1]) 62 | expect(frame[:type]).to eq :settings 63 | expect(frame[:payload]).to include([:settings_max_concurrent_streams, 200]) 64 | end 65 | 66 | it "should initialize client when receiving server settings before sending ack" do 67 | frames = [] 68 | client.on(:frame) { |bytes| frames << bytes } 69 | client << f.generate(settings_frame) 70 | 71 | expect(frames[0]).to eq CONNECTION_PREFACE_MAGIC 72 | expect(f.parse(frames[1])[:type]).to eq :settings 73 | ack_frame = f.parse(frames[2]) 74 | expect(ack_frame[:type]).to eq :settings 75 | expect(ack_frame[:flags]).to include(:ack) 76 | end 77 | end 78 | 79 | context "settings synchronization" do 80 | let(:client) { Client.new } 81 | it "should reflect outgoing settings when ack is received" do 82 | expect(client.local_settings[:settings_header_table_size]).to eq 4096 83 | client.settings(settings_header_table_size: 256) 84 | expect(client.local_settings[:settings_header_table_size]).to eq 4096 85 | 86 | ack = { type: :settings, stream: 0, payload: [], flags: [:ack] } 87 | client << f.generate(ack) 88 | 89 | expect(client.local_settings[:settings_header_table_size]).to eq 256 90 | end 91 | end 92 | 93 | context "upgrade" do 94 | it "fails when client has already created streams" do 95 | client.new_stream 96 | expect { client.upgrade }.to raise_error(HTTP2::Error::ProtocolError) 97 | end 98 | 99 | it "sends the preface" do 100 | expect(client).to receive(:send_connection_preface) 101 | client.upgrade 102 | end 103 | 104 | it "initializes the first stream in the half-closed state" do 105 | stream = client.upgrade 106 | expect(stream.state).to be(:half_closed_local) 107 | end 108 | end 109 | 110 | context "push" do 111 | it "should disallow client initiated push" do 112 | expect do 113 | client.promise({}) {} 114 | end.to raise_error(NoMethodError) 115 | end 116 | 117 | it "should raise error on PUSH_PROMISE against stream 0" do 118 | expect do 119 | client << set_stream_id(f.generate(push_promise_frame), 0) 120 | end.to raise_error(ProtocolError) 121 | end 122 | 123 | it "should raise error on PUSH_PROMISE against bogus stream" do 124 | expect do 125 | client << set_stream_id(f.generate(push_promise_frame), 31_415) 126 | end.to raise_error(ProtocolError) 127 | end 128 | 129 | it "should raise error on PUSH_PROMISE against non-idle stream" do 130 | expect do 131 | s = client.new_stream 132 | s.send headers_frame 133 | 134 | client << set_stream_id(f.generate(push_promise_frame), s.id) 135 | client << set_stream_id(f.generate(push_promise_frame), s.id) 136 | end.to raise_error(ProtocolError) 137 | end 138 | 139 | it "should emit stream object for received PUSH_PROMISE" do 140 | s = client.new_stream 141 | s.send headers_frame 142 | 143 | promise = nil 144 | client.on(:promise) { |stream| promise = stream } 145 | client << set_stream_id(f.generate(push_promise_frame), s.id) 146 | 147 | expect(promise.id).to eq 2 148 | expect(promise.state).to eq :reserved_remote 149 | end 150 | 151 | it "should emit promise headers for received PUSH_PROMISE" do 152 | header = nil 153 | s = client.new_stream 154 | s.send headers_frame 155 | 156 | client.on(:promise) do |stream| 157 | stream.on(:promise_headers) do |h| 158 | header = h 159 | end 160 | end 161 | client << set_stream_id(f.generate(push_promise_frame), s.id) 162 | 163 | expect(header).to be_a(Array) 164 | # expect(header).to eq([%w(a b)]) 165 | end 166 | 167 | it "should auto RST_STREAM promises against locally-RST stream" do 168 | s = client.new_stream 169 | s.send headers_frame 170 | s.close 171 | 172 | allow(client).to receive(:send) 173 | expect(client).to receive(:send) do |frame| 174 | expect(frame[:type]).to eq :rst_stream 175 | expect(frame[:stream]).to eq 2 176 | end 177 | 178 | client << set_stream_id(f.generate(push_promise_frame), s.id) 179 | end 180 | end 181 | 182 | context "alt-svc" do 183 | context "received in the connection" do 184 | it "should emit :altsvc when receiving one" do 185 | client << f.generate(settings_frame) 186 | frame = nil 187 | client.on(:altsvc) do |f| 188 | frame = f 189 | end 190 | client << f.generate(altsvc_frame) 191 | expect(frame).to be_a(Hash) 192 | end 193 | it "should not emit :altsvc when the frame when contains no host" do 194 | client << f.generate(settings_frame) 195 | frame = nil 196 | client.on(:altsvc) do |f| 197 | frame = f 198 | end 199 | 200 | client << f.generate(altsvc_frame.merge(origin: nil)) 201 | expect(frame).to be_nil 202 | end 203 | end 204 | context "received in a stream" do 205 | it "should emit :altsvc" do 206 | s = client.new_stream 207 | s.send headers_frame 208 | 209 | frame = nil 210 | s.on(:altsvc) { |f| frame = f } 211 | 212 | client << set_stream_id(f.generate(altsvc_frame.merge(origin: nil)), s.id) 213 | 214 | expect(frame).to be_a(Hash) 215 | end 216 | it "should not emit :alt_svc when the frame when contains a origin" do 217 | s = client.new_stream 218 | s.send headers_frame 219 | 220 | frame = nil 221 | s.on(:altsvc) { |f| frame = f } 222 | 223 | client << set_stream_id(f.generate(altsvc_frame), s.id) 224 | 225 | expect(frame).to be_nil 226 | end 227 | end 228 | end 229 | 230 | context "origin" do 231 | let(:orig_frame) { origin_frame.merge(payload: %w[https://www.google.com https://www.youtube.com]) } 232 | context "received in the connection" do 233 | it "should emit :origin when receiving one" do 234 | client << f.generate(settings_frame) 235 | origins = [] 236 | client.on(:origin) do |origin| 237 | origins << origin 238 | end 239 | client << f.generate(orig_frame) 240 | expect(origins).to include("https://www.google.com") 241 | expect(origins).to include("https://www.youtube.com") 242 | end 243 | context "initialized as h2c" do 244 | it "should be ignored" do 245 | client.upgrade 246 | origins = [] 247 | client.on(:origin) do |origin| 248 | origins << origin 249 | end 250 | client << f.generate(orig_frame) 251 | expect(origins).to be_empty 252 | end 253 | end 254 | context "when receiving a reserved flag" do 255 | let(:orig_frame) { origin_frame.merge(flags: [:reserved]) } 256 | it "should be ignored" do 257 | client << f.generate(settings_frame) 258 | origins = [] 259 | client.on(:origin) do |origin| 260 | origins << origin 261 | end 262 | client << f.generate(orig_frame) 263 | expect(origins).to be_empty 264 | end 265 | end 266 | end 267 | context "received in a stream" do 268 | it "should be ignored" do 269 | s = client.new_stream 270 | s.send headers_frame 271 | 272 | expect do 273 | client << set_stream_id(f.generate(orig_frame), s.id) 274 | end.not_to raise_error 275 | end 276 | end 277 | end 278 | 279 | context "connection management" do 280 | let(:conn) { Client.new } 281 | it "should send GOAWAY frame on connection error" do 282 | stream = conn.new_stream 283 | 284 | expect(conn).to receive(:encode) do |frame| 285 | expect(frame[:type]).to eq :settings 286 | [frame] 287 | end 288 | expect(conn).to receive(:encode) do |frame| 289 | expect(frame[:type]).to eq :goaway 290 | expect(frame[:last_stream]).to eq stream.id 291 | expect(frame[:error]).to eq :protocol_error 292 | [frame] 293 | end 294 | 295 | expect { conn << f.generate(data_frame) }.to raise_error(ProtocolError) 296 | end 297 | end 298 | 299 | context "stream management" do 300 | it "should process connection management frames after GOAWAY" do 301 | stream = client.new_stream 302 | stream.send headers_frame 303 | client << f.generate(goaway_frame) 304 | client << f.generate(push_promise_frame) 305 | expect(client.active_stream_count).to eq 1 306 | end 307 | end 308 | 309 | context "framing" do 310 | it "should buffer incomplete frames" do 311 | frame = f.generate(window_update_frame.merge(stream: 0, increment: 1000)) 312 | client << frame 313 | expect(client.remote_window).to eq DEFAULT_FLOW_WINDOW + 1000 314 | 315 | client << frame.slice!(0, 1) 316 | client << frame 317 | expect(client.remote_window).to eq DEFAULT_FLOW_WINDOW + 2000 318 | end 319 | 320 | it "should decompress header blocks regardless of stream state" do 321 | req_headers = [ 322 | %w[:status 200], 323 | %w[x-my-header first] 324 | ] 325 | 326 | cc = Compressor.new 327 | headers = headers_frame.merge(stream: 2) 328 | headers[:payload] = cc.encode(req_headers) 329 | 330 | client.on(:stream) do |stream| 331 | expect(stream).to receive(:<<) do |frame| 332 | expect(frame[:payload]).to eq req_headers 333 | end 334 | end 335 | 336 | client << f.generate(headers) 337 | end 338 | 339 | it "should decode non-contiguous header blocks" do 340 | req_headers = [ 341 | %w[:status 200], 342 | %w[x-my-header first] 343 | ] 344 | 345 | cc = Compressor.new 346 | h1 = headers_frame 347 | h2 = continuation_frame 348 | 349 | # Header block fragment might not complete for decompression 350 | payload = cc.encode(req_headers) 351 | h1[:payload] = payload.slice!(0, payload.size / 2) # first half 352 | h1[:stream] = 2 353 | h1[:flags] = [] 354 | 355 | h2[:payload] = payload # the remaining 356 | h2[:stream] = 2 357 | 358 | client.on(:stream) do |stream| 359 | expect(stream).to receive(:<<) do |frame| 360 | expect(frame[:payload]).to eq req_headers 361 | end 362 | end 363 | 364 | client << f.generate(h1) 365 | client << f.generate(h2) 366 | end 367 | end 368 | 369 | context "API" do 370 | it ".goaway should generate GOAWAY frame with last processed stream ID" do 371 | stream = client.new_stream 372 | stream.send headers_frame 373 | 374 | expect(client).to receive(:send) do |frame| 375 | expect(frame[:type]).to eq :goaway 376 | expect(frame[:last_stream]).to eq 1 377 | expect(frame[:error]).to eq :internal_error 378 | expect(frame[:payload]).to eq "payload" 379 | end 380 | 381 | client.goaway(:internal_error, "payload") 382 | end 383 | end 384 | 385 | context ".settings_header" do 386 | it "encodes the settings frame in base64" do 387 | settings_header = described_class.settings_header(settings_frame[:payload]) 388 | expect(f.generate(settings_frame)).to end_with(Base64.urlsafe_decode64(settings_header)) 389 | end 390 | end 391 | end 392 | -------------------------------------------------------------------------------- /spec/connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | 5 | RSpec.describe HTTP2::Connection do 6 | include FrameHelpers 7 | let(:conn) { Client.new } 8 | let(:f) { Framer.new } 9 | 10 | context "Headers pre/post processing" do 11 | let(:conn) do 12 | client = Client.new 13 | client << f.generate(settings_frame) 14 | client 15 | end 16 | 17 | it "should not concatenate multiple occurences of a header field with the same name" do 18 | input = [ 19 | ["Content-Type", "text/html"], 20 | ["Cache-Control", "max-age=60, private"], 21 | %w[Cache-Control must-revalidate] 22 | ] 23 | expected = [ 24 | ["content-type", "text/html"], 25 | ["cache-control", "max-age=60, private"], 26 | %w[cache-control must-revalidate] 27 | ] 28 | headers = [] 29 | conn.on(:frame) do |bytes| 30 | headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord) 31 | end 32 | 33 | stream = conn.new_stream 34 | stream.headers(input) 35 | 36 | expect(headers.size).to eq 1 37 | emitted = Decompressor.new.decode(headers.first[:payload]) 38 | expect(emitted).to match_array(expected) 39 | end 40 | 41 | it "should not split zero-concatenated header field values" do 42 | input = [*RESPONSE_HEADERS, 43 | ["cache-control", "max-age=60, private\0must-revalidate"], 44 | ["content-type", "text/html"], 45 | ["cookie", "a=b\0c=d; e=f"]] 46 | expected = [*RESPONSE_HEADERS, 47 | ["cache-control", "max-age=60, private\0must-revalidate"], 48 | ["content-type", "text/html"], 49 | ["cookie", "a=b\0c=d; e=f"]] 50 | 51 | result = nil 52 | conn.on(:stream) do |stream| 53 | stream.on(:headers) { |h| result = h } 54 | end 55 | 56 | srv = Server.new 57 | srv.on(:frame) { |bytes| conn << bytes } 58 | stream = srv.new_stream 59 | stream.headers(input) 60 | 61 | expect(result).to eq expected 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/emitter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | 5 | RSpec.describe HTTP2::Emitter do 6 | class Worker 7 | include Emitter 8 | def initialize 9 | @listeners = Hash.new { |hash, key| hash[key] = [] } 10 | end 11 | end 12 | 13 | let(:w) { Worker.new } 14 | before(:each) do 15 | @cnt = 0 16 | end 17 | 18 | it "should raise error on missing callback" do 19 | expect { w.on(:a) {} }.to_not raise_error 20 | expect { w.on(:a) }.to raise_error 21 | end 22 | 23 | it "should allow multiple callbacks on single event" do 24 | cnt = 0 25 | w.on(:a) { cnt += 1 } 26 | w.on(:a) { cnt += 1 } 27 | w.emit(:a) 28 | 29 | expect(cnt).to eq 2 30 | end 31 | 32 | it "should execute callback with optional args" do 33 | args = nil 34 | w.on(:a) { |a| args = a } 35 | w.emit(:a, 123) 36 | 37 | expect(args).to eq 123 38 | end 39 | 40 | it "should pass emitted callbacks to listeners" do 41 | cnt = 0 42 | w.on(:a) { |&block| block.call } 43 | w.once(:a) { |&block| block.call } 44 | w.emit(:a) { cnt += 1 } 45 | 46 | expect(cnt).to eq 2 47 | end 48 | 49 | it "should allow events with no callbacks" do 50 | expect { w.emit(:missing) }.to_not raise_error 51 | end 52 | 53 | it "should execute callback exactly once" do 54 | cnt = 0 55 | w.on(:a) { cnt += 1 } 56 | w.once(:a) { cnt += 1 } 57 | w.emit(:a) 58 | w.emit(:a) 59 | 60 | expect(cnt).to eq 3 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/framer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | 5 | RSpec.describe HTTP2::Framer do 6 | let(:f) { Framer.new } 7 | 8 | context "common header" do 9 | let(:frame) do 10 | { 11 | length: 4, 12 | type: :headers, 13 | flags: %i[end_stream end_headers], 14 | stream: 15 15 | } 16 | end 17 | 18 | let(:bytes) { [0, 0x04, 0x01, 0x5, 0x0000000F].pack("CnCCN") } 19 | 20 | it "should generate common 9 byte header" do 21 | expect(f.common_header(frame, buffer: "".b)).to eq bytes 22 | end 23 | 24 | it "should parse common 9 byte header" do 25 | expect(f.read_common_header(bytes)).to eq frame 26 | end 27 | 28 | it "should generate a large frame" do 29 | f = Framer.new 30 | f.remote_max_frame_size = (2**24) - 1 31 | frame = { 32 | length: (2**18) + (2**16) + 17, 33 | type: :headers, 34 | flags: %i[end_stream end_headers], 35 | stream: 15 36 | } 37 | bytes = [5, 17, 0x01, 0x5, 0x0000000F].pack("CnCCN") 38 | expect(f.common_header(frame, buffer: "".b)).to eq bytes 39 | expect(f.read_common_header(bytes)).to eq frame 40 | end 41 | 42 | it "should raise exception on invalid frame type when sending" do 43 | expect do 44 | frame[:type] = :bogus 45 | f.common_header(frame, buffer: "".b) 46 | end.to raise_error(CompressionError, /invalid.*type/i) 47 | end 48 | 49 | it "should raise exception on invalid stream ID" do 50 | expect do 51 | frame[:stream] = Framer::MAX_STREAM_ID + 1 52 | f.common_header(frame, buffer: "".b) 53 | end.to raise_error(CompressionError, /stream/i) 54 | end 55 | 56 | it "should raise exception on invalid frame flag" do 57 | expect do 58 | frame[:flags] = [:bogus] 59 | f.common_header(frame, buffer: "".b) 60 | end.to raise_error(CompressionError, /frame flag/) 61 | end 62 | 63 | it "should raise exception on invalid frame size" do 64 | expect do 65 | frame[:length] = 2**24 66 | f.common_header(frame, buffer: "".b) 67 | end.to raise_error(CompressionError, /too large/) 68 | end 69 | end 70 | 71 | context "DATA" do 72 | it "should generate and parse bytes" do 73 | frame = { 74 | length: 4, 75 | type: :data, 76 | flags: [:end_stream], 77 | stream: 1, 78 | payload: "text" 79 | } 80 | 81 | bytes = f.generate(frame) 82 | expect(bytes).to eq [0, 0x4, 0x0, 0x1, 0x1, *"text".bytes].pack("CnCCNC*") 83 | 84 | expect(f.parse(bytes)).to eq frame 85 | end 86 | end 87 | 88 | context "HEADERS" do 89 | it "should generate and parse bytes" do 90 | frame = { 91 | length: 12, 92 | type: :headers, 93 | flags: %i[end_stream end_headers], 94 | stream: 1, 95 | payload: "header-block" 96 | } 97 | 98 | bytes = f.generate(frame) 99 | expect(bytes).to eq [0, 0xc, 0x1, 0x5, 0x1, *"header-block".bytes].pack("CnCCNC*") 100 | expect(f.parse(bytes)).to eq frame 101 | end 102 | 103 | it "should carry an optional stream priority" do 104 | frame = { 105 | length: 16, 106 | type: :headers, 107 | flags: [:end_headers], 108 | stream: 1, 109 | dependency: 15, 110 | weight: 12, 111 | exclusive: false, 112 | payload: "header-block" 113 | } 114 | 115 | bytes = f.generate(frame) 116 | expect(bytes).to eq [0, 0x11, 0x1, 0x24, 0x1, 0xf, 0xb, *"header-block".bytes].pack("CnCCNNCC*") 117 | expect(f.parse(bytes)).to eq frame 118 | end 119 | end 120 | 121 | context "PRIORITY" do 122 | it "should generate and parse bytes" do 123 | frame = { 124 | length: 5, 125 | type: :priority, 126 | stream: 1, 127 | dependency: 15, 128 | weight: 12, 129 | exclusive: true 130 | } 131 | 132 | bytes = f.generate(frame) 133 | expect(bytes).to eq [0, 0x5, 0x2, 0x0, 0x1, 0x8000000f, 0xb].pack("CnCCNNC") 134 | expect(f.parse(bytes)).to eq frame 135 | end 136 | end 137 | 138 | context "RST_STREAM" do 139 | it "should generate and parse bytes" do 140 | frame = { 141 | length: 4, 142 | type: :rst_stream, 143 | stream: 1, 144 | error: :stream_closed 145 | } 146 | 147 | bytes = f.generate(frame) 148 | expect(bytes).to eq [0, 0x4, 0x3, 0x0, 0x1, 0x5].pack("CnCCNN") 149 | expect(f.parse(bytes)).to eq frame 150 | end 151 | end 152 | 153 | context "SETTINGS" do 154 | let(:frame) do 155 | { 156 | type: :settings, 157 | flags: [], 158 | stream: 0, 159 | payload: [ 160 | [:settings_max_concurrent_streams, 10], 161 | [:settings_header_table_size, 2048] 162 | ] 163 | } 164 | end 165 | 166 | it "should generate and parse bytes" do 167 | bytes = f.generate(frame) 168 | expect(bytes).to eq [0, 12, 0x4, 0x0, 0x0, 3, 10, 1, 2048].pack("CnCCNnNnN") 169 | parsed = f.parse(bytes) 170 | parsed.delete(:length) 171 | frame.delete(:length) 172 | expect(parsed).to eq frame 173 | end 174 | 175 | it "should generate settings when id is given as an integer" do 176 | frame[:payload][1][0] = 1 177 | bytes = f.generate(frame) 178 | expect(bytes).to eq [0, 12, 0x4, 0x0, 0x0, 3, 10, 1, 2048].pack("CnCCNnNnN") 179 | end 180 | 181 | it "should ignore custom settings when sending" do 182 | frame[:payload] = [ 183 | [:settings_max_concurrent_streams, 10], 184 | [:settings_initial_window_size, 20], 185 | [55, 30] 186 | ] 187 | 188 | buf = f.generate(frame) 189 | frame[:payload].slice!(2) # cut off the extension 190 | frame[:length] = 12 # frame length should be computed WITHOUT extensions 191 | expect(f.parse(buf)).to eq frame 192 | end 193 | 194 | it "should ignore custom settings when receiving" do 195 | frame[:payload] = [ 196 | [:settings_max_concurrent_streams, 10], 197 | [:settings_initial_window_size, 20] 198 | ] 199 | 200 | buf = f.generate(frame) 201 | buf.setbyte(2, 18) # add 6 to the frame length 202 | buf << "\x00\x37\x00\x00\x00\x1e" 203 | parsed = f.parse(buf) 204 | parsed.delete(:length) 205 | frame.delete(:length) 206 | expect(parsed).to eq frame 207 | end 208 | 209 | it "should raise exception on sending invalid stream ID" do 210 | expect do 211 | frame[:stream] = 1 212 | f.generate(frame) 213 | end.to raise_error(CompressionError, /Invalid stream ID/) 214 | end 215 | 216 | it "should raise exception on receiving invalid stream ID" do 217 | expect do 218 | buf = f.generate(frame) 219 | buf.setbyte(8, 1) 220 | f.parse(buf) 221 | end.to raise_error(ProtocolError, /Invalid stream ID/) 222 | end 223 | 224 | it "should raise exception on sending invalid setting" do 225 | expect do 226 | frame[:payload] = [[:random, 23]] 227 | f.generate(frame) 228 | end.to raise_error(CompressionError, /Unknown settings ID/) 229 | end 230 | 231 | it "should raise exception on receiving invalid payload length" do 232 | expect do 233 | buf = f.generate(frame) 234 | buf.setbyte(2, 11) # change payload length 235 | f.parse(buf) 236 | end.to raise_error(ProtocolError, /Invalid settings payload length/) 237 | end 238 | end 239 | 240 | context "PUSH_PROMISE" do 241 | it "should generate and parse bytes" do 242 | frame = { 243 | length: 11, 244 | type: :push_promise, 245 | flags: [:end_headers], 246 | stream: 1, 247 | promise_stream: 2, 248 | payload: "headers" 249 | } 250 | 251 | bytes = f.generate(frame) 252 | expect(bytes).to eq [0, 0xb, 0x5, 0x4, 0x1, 0x2, *"headers".bytes].pack("CnCCNNC*") 253 | expect(f.parse(bytes)).to eq frame 254 | end 255 | end 256 | 257 | context "PING" do 258 | let(:frame) do 259 | { 260 | length: 8, 261 | stream: 1, 262 | type: :ping, 263 | flags: [:ack], 264 | payload: "12345678" 265 | } 266 | end 267 | 268 | it "should generate and parse bytes" do 269 | bytes = f.generate(frame) 270 | expect(bytes).to eq [0, 0x8, 0x6, 0x1, 0x1, *"12345678".bytes].pack("CnCCNC*") 271 | expect(f.parse(bytes)).to eq frame 272 | end 273 | 274 | it "should raise exception on invalid payload" do 275 | expect do 276 | frame[:payload] = "1234" 277 | f.generate(frame) 278 | end.to raise_error(CompressionError, /Invalid payload size/) 279 | end 280 | end 281 | 282 | context "GOAWAY" do 283 | let(:frame) do 284 | { 285 | length: 13, 286 | stream: 1, 287 | type: :goaway, 288 | last_stream: 2, 289 | error: :no_error, 290 | payload: "debug" 291 | } 292 | end 293 | 294 | it "should generate and parse bytes" do 295 | bytes = f.generate(frame) 296 | expect(bytes).to eq [0, 0xd, 0x7, 0x0, 0x1, 0x2, 0x0, *"debug".bytes].pack("CnCCNNNC*") 297 | expect(f.parse(bytes)).to eq frame 298 | end 299 | 300 | it "should treat debug payload as optional" do 301 | frame.delete :payload 302 | frame[:length] = 0x8 303 | 304 | bytes = f.generate(frame) 305 | expect(bytes).to eq [0, 0x8, 0x7, 0x0, 0x1, 0x2, 0x0].pack("CnCCNNN") 306 | expect(f.parse(bytes)).to eq frame 307 | end 308 | end 309 | 310 | context "WINDOW_UPDATE" do 311 | it "should generate and parse bytes" do 312 | frame = { 313 | length: 4, 314 | type: :window_update, 315 | increment: 10 316 | } 317 | 318 | bytes = f.generate(frame) 319 | expect(bytes).to eq [0, 0x4, 0x8, 0x0, 0x0, 0xa].pack("CnCCNN") 320 | parsed_frame = f.parse(bytes) 321 | frame.each do |k, v| 322 | expect(parsed_frame[k]).to eq(v) 323 | end 324 | end 325 | 326 | it "should break when the increment is too large" do 327 | frame = { 328 | length: 4, 329 | type: :window_update, 330 | increment: 0x7fffffff + 1 331 | } 332 | 333 | expect { f.generate(frame) }.to raise_error(CompressionError) 334 | end 335 | end 336 | 337 | context "CONTINUATION" do 338 | it "should generate and parse bytes" do 339 | frame = { 340 | length: 12, 341 | type: :continuation, 342 | stream: 1, 343 | flags: [:end_headers], 344 | payload: "header-block" 345 | } 346 | 347 | bytes = f.generate(frame) 348 | expect(bytes).to eq [0, 0xc, 0x9, 0x4, 0x1, *"header-block".bytes].pack("CnCCNC*") 349 | expect(f.parse(bytes)).to eq frame 350 | end 351 | end 352 | 353 | context "ALTSVC" do 354 | it "should generate and parse bytes" do 355 | frame = { 356 | length: 44, 357 | type: :altsvc, 358 | stream: 1, 359 | max_age: 1_402_290_402, # 4 360 | port: 8080, # 2 361 | proto: "h2-13", # 1 + 5 362 | host: "www.example.com", # 1 + 15 363 | origin: "www.example.com" # 15 364 | } 365 | bytes = f.generate(frame) 366 | expected = [0, 43, 0xa, 0, 1, 1_402_290_402, 8080].pack("CnCCNNn") 367 | expected << [5, *"h2-13".bytes].pack("CC*") 368 | expected << [15, *"www.example.com".bytes].pack("CC*") 369 | expected << [*"www.example.com".bytes].pack("C*") 370 | expect(bytes).to eq expected 371 | expect(f.parse(bytes)).to eq frame 372 | end 373 | end 374 | 375 | context "Padding" do 376 | let(:frame) do 377 | { 378 | length: 12, 379 | type: type, 380 | stream: 1, 381 | payload: "example data" 382 | } 383 | end 384 | %i[data headers push_promise].each do |type| 385 | let(:type) { type } 386 | if type == :push_promise 387 | let(:frame) do 388 | { 389 | length: 12, 390 | type: type, 391 | stream: 1, 392 | payload: "example data", 393 | promise_stream: 2 394 | } 395 | end 396 | end 397 | [1, 256].each do |padlen| 398 | context "generating #{type} frame padded #{padlen}" do 399 | let(:normal) { f.generate(frame) } 400 | let(:padded) { f.generate(frame.merge(padding: padlen)) } 401 | it "should generate a frame with padding" do 402 | expect(padded.bytesize).to eq normal.bytesize + padlen 403 | end 404 | it "should fill padded octets with zero" do 405 | trailer_len = padlen - 1 406 | expect(padded[-trailer_len, trailer_len]).to match(/\A\0*\z/) 407 | end 408 | it "should parse a frame with padding" do 409 | expect(f.parse(padded)).to eq \ 410 | f.parse(normal).merge(padding: padlen) 411 | end 412 | it "should preserve payload" do 413 | expect(f.parse(padded)[:payload]).to eq frame[:payload] 414 | end 415 | end 416 | end 417 | end 418 | context "generating with invalid padding length" do 419 | [0, 257, 1334].each do |padlen| 420 | it "should raise error on trying to generate data frame padded with invalid #{padlen}" do 421 | expect do 422 | f.generate(frame.merge(padding: padlen)) 423 | end.to raise_error(CompressionError, /padding/i) 424 | end 425 | end 426 | it "should raise error when adding a padding would make frame too large" do 427 | frame[:payload] = "q" * (f.remote_max_frame_size - 200) 428 | frame[:length] = frame[:payload].size 429 | frame[:padding] = 210 # would exceed 4096 430 | expect do 431 | f.generate(frame) 432 | end.to raise_error(CompressionError, /padding/i) 433 | end 434 | end 435 | context "parsing frames with invalid paddings" do 436 | let(:padded) { f.generate(frame.merge(padding: 123)) } 437 | it "should raise exception when the given padding is longer than the payload" do 438 | padded.setbyte(9, 240) 439 | expect { f.parse(padded) }.to raise_error(ProtocolError, /padding/) 440 | end 441 | end 442 | end 443 | 444 | it "should determine frame length" do 445 | frames = [ 446 | [{ type: :data, stream: 1, flags: [:end_stream], payload: "abc" }, 3], 447 | [{ type: :headers, stream: 1, payload: "abc" }, 3], 448 | [{ type: :priority, stream: 3, dependency: 30, exclusive: false, weight: 1 }, 5], 449 | [{ type: :rst_stream, stream: 3, error: 100 }, 4], 450 | [{ type: :settings, payload: [[:settings_max_concurrent_streams, 10]] }, 6], 451 | [{ type: :push_promise, promise_stream: 5, payload: "abc" }, 7], 452 | [{ type: :ping, payload: "blob" * 2 }, 8], 453 | [{ type: :goaway, last_stream: 5, error: 20, payload: "blob" }, 12], 454 | [{ type: :window_update, stream: 1, increment: 1024 }, 4], 455 | [{ type: :continuation, stream: 1, payload: "abc" }, 3] 456 | ] 457 | 458 | frames.each do |(frame, size)| 459 | bytes = f.generate(frame) 460 | expect(bytes.slice(1, 2).unpack1("n")).to eq size 461 | expect(bytes.getbyte(0)).to eq 0 462 | end 463 | end 464 | 465 | it "should parse single frame at a time" do 466 | frames = [ 467 | { type: :headers, stream: 1, payload: "headers" }, 468 | { type: :data, stream: 1, flags: [:end_stream], payload: "abc" } 469 | ] 470 | 471 | buf = f.generate(frames[0]) << f.generate(frames[1]) 472 | 473 | expect(f.parse(buf)).to eq frames[0] 474 | expect(f.parse(buf)).to eq frames[1] 475 | end 476 | 477 | it "should process full frames only" do 478 | frame = { type: :headers, stream: 1, payload: "headers" } 479 | bytes = f.generate(frame) 480 | 481 | expect(f.parse(bytes.slice(0...-1))).to be_nil 482 | expect(f.parse(bytes)).to eq frame 483 | expect(bytes).to be_empty 484 | end 485 | 486 | it "should ignore unknown extension frames" do 487 | frame = { type: :headers, stream: 1, payload: "headers" } 488 | bytes = f.generate(frame) 489 | bytes = "#{bytes}#{bytes}".b # Two HEADERS frames in bytes 490 | bytes.setbyte(3, 42) # Make the first unknown type 42 491 | 492 | expect(f.parse(bytes)[:type]).to be_nil 493 | expect(f.parse(bytes)).to eq frame # should generate only one HEADERS 494 | expect(bytes).to be_empty 495 | end 496 | end 497 | -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | GC.auto_compact = true if GC.respond_to?(:auto_compact=) 4 | 5 | if ENV.key?("CI") 6 | require "simplecov" 7 | SimpleCov.command_name "#{RUBY_ENGINE}-#{RUBY_VERSION}" 8 | SimpleCov.coverage_dir "coverage/#{RUBY_ENGINE}-#{RUBY_VERSION}" 9 | end 10 | 11 | RSpec.configure(&:disable_monkey_patching!) 12 | RSpec::Expectations.configuration.warn_about_potential_false_positives = false 13 | 14 | require "json" 15 | 16 | # rubocop: disable Style/MixinUsage 17 | require "http/2" 18 | include HTTP2 19 | include HTTP2::Header 20 | include HTTP2::Error 21 | # rubocop: enable Style/MixinUsage 22 | 23 | REQUEST_HEADERS = [%w[:scheme https], 24 | %w[:path /], 25 | %w[:authority example.com], 26 | %w[:method GET], 27 | %w[a b]].freeze 28 | RESPONSE_HEADERS = [%w[:status 200]].freeze 29 | 30 | HTTP2::Connection.__send__ :public, :send_buffer 31 | HTTP2::Stream.__send__ :public, :send_buffer 32 | 33 | module FrameHelpers 34 | def data_frame 35 | { 36 | type: :data, 37 | flags: [:end_stream], 38 | stream: 1, 39 | payload: "text" 40 | } 41 | end 42 | 43 | def headers_frame 44 | { 45 | type: :headers, 46 | flags: [:end_headers].freeze, 47 | stream: 1, 48 | payload: Compressor.new.encode(REQUEST_HEADERS) 49 | } 50 | end 51 | 52 | def priority_frame 53 | { 54 | type: :priority, 55 | stream: 1, 56 | exclusive: false, 57 | dependency: 0, 58 | weight: 20 59 | } 60 | end 61 | 62 | def rst_stream_frame 63 | { 64 | type: :rst_stream, 65 | stream: 1, 66 | error: :stream_closed 67 | } 68 | end 69 | 70 | def settings_frame 71 | { 72 | type: :settings, 73 | stream: 0, 74 | payload: [ 75 | [:settings_max_concurrent_streams, 10], 76 | [:settings_initial_window_size, 0x7fffffff] 77 | ] 78 | } 79 | end 80 | 81 | def push_promise_frame 82 | { 83 | type: :push_promise, 84 | flags: [:end_headers], 85 | stream: 1, 86 | promise_stream: 2, 87 | payload: Compressor.new.encode(REQUEST_HEADERS) 88 | } 89 | end 90 | 91 | def ping_frame 92 | { 93 | stream: 0, 94 | type: :ping, 95 | payload: "12345678" 96 | } 97 | end 98 | 99 | def pong_frame 100 | { 101 | stream: 0, 102 | type: :ping, 103 | flags: [:ack], 104 | payload: "12345678" 105 | } 106 | end 107 | 108 | def goaway_frame 109 | { 110 | type: :goaway, 111 | last_stream: 2, 112 | error: :no_error, 113 | payload: "debug" 114 | } 115 | end 116 | 117 | def window_update_frame 118 | { 119 | type: :window_update, 120 | increment: 10 121 | } 122 | end 123 | 124 | def continuation_frame 125 | { 126 | type: :continuation, 127 | stream: 1, 128 | flags: [:end_headers], 129 | payload: "-second-block" 130 | } 131 | end 132 | 133 | def altsvc_frame 134 | { 135 | type: :altsvc, 136 | max_age: 1_402_290_402, # 4 137 | port: 8080, # 2 reserved 1 138 | proto: "h2-12", # 1 + 5 139 | host: "www.example.com", # 1 + 15 140 | origin: "www.example.com" # 15 141 | } 142 | end 143 | 144 | def origin_frame 145 | { 146 | type: :origin, 147 | payload: %w[https://www.example.com https://www.example.org] 148 | } 149 | end 150 | 151 | DATA_FRAMES = %w[headers continuation push_promise data].freeze 152 | 153 | def control_frames 154 | methods.select { |meth| meth.to_s.end_with?("_frame") } 155 | .reject { |meth| DATA_FRAMES.include?(meth.to_s.gsub(/_frame$/, "")) } 156 | .map { |meth| __send__(meth) } 157 | end 158 | 159 | def frame_types 160 | methods.select { |meth| meth.to_s.end_with?("_frame") } 161 | .map { |meth| __send__(meth) } 162 | end 163 | end 164 | 165 | def set_stream_id(bytes, id) 166 | scheme = "CnCCN" 167 | head = bytes.slice!(0, 9).unpack(scheme) 168 | head[4] = id 169 | 170 | head.pack(scheme) + bytes 171 | end 172 | -------------------------------------------------------------------------------- /spec/hpack_test_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | require "json" 5 | 6 | RSpec.describe HTTP2::Header do 7 | folders = %w[ 8 | go-hpack 9 | haskell-http2-diff 10 | haskell-http2-diff-huffman 11 | haskell-http2-linear 12 | haskell-http2-linear-huffman 13 | haskell-http2-naive 14 | haskell-http2-naive-huffman 15 | haskell-http2-static 16 | haskell-http2-static-huffman 17 | #hyper-hpack 18 | nghttp2 19 | nghttp2-16384-4096 20 | nghttp2-change-table-size 21 | node-http2-hpack 22 | ] 23 | 24 | context "Decompressor" do 25 | folders.each do |folder| 26 | next if folder.include?("#") 27 | 28 | path = File.expand_path("hpack-test-case/#{folder}", File.dirname(__FILE__)) 29 | next unless Dir.exist?(path) 30 | 31 | context folder.to_s do 32 | Dir.foreach(path) do |file| 33 | next unless file.include?(".json") 34 | 35 | it "should decode #{file}" do 36 | story = JSON.parse(File.read("#{path}/#{file}")) 37 | cases = story["cases"] 38 | table_size = cases[0]["header_table_size"] || 4096 39 | @dc = Decompressor.new(table_size: table_size) 40 | cases.each do |c| 41 | wire = [c["wire"]].pack("H*").force_encoding(Encoding::BINARY) 42 | @emitted = @dc.decode(HTTP2::Buffer.new(wire)) 43 | headers = c["headers"].flat_map(&:to_a) 44 | expect(@emitted).to eq headers 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | 52 | context "Compressor" do 53 | %w[ 54 | LINEAR 55 | NAIVE 56 | SHORTER 57 | STATIC 58 | ].each do |mode| 59 | next if mode.include?("#") 60 | 61 | ["", "H"].each do |huffman| 62 | encoding_mode = :"#{mode}#{huffman}" 63 | encoding_options = HTTP2::Header::EncodingContext.const_get(encoding_mode) 64 | [4096, 512].each do |table_size| 65 | options = { table_size: table_size } 66 | options.update(encoding_options) 67 | 68 | context "with #{mode}#{huffman} mode and table_size #{table_size}" do 69 | path = File.expand_path("hpack-test-case/raw-data", File.dirname(__FILE__)) 70 | Dir.foreach(path) do |file| 71 | next unless file.include?(".json") 72 | 73 | it "should encode #{file}" do 74 | story = JSON.parse(File.read("#{path}/#{file}")) 75 | cases = story["cases"] 76 | @cc = Compressor.new(options) 77 | @dc = Decompressor.new(options) 78 | cases.each do |c| 79 | headers = c["headers"].flat_map(&:to_a) 80 | wire = @cc.encode(headers) 81 | decoded = @dc.decode(HTTP2::Buffer.new(wire)) 82 | expect(decoded).to eq headers 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/huffman_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | 5 | RSpec.describe HTTP2::Header::Huffman do 6 | huffman_examples = [ # plain, encoded 7 | ["www.example.com", "f1e3c2e5f23a6ba0ab90f4ff"], 8 | %w[no-cache a8eb10649cbf], 9 | ["Mon, 21 Oct 2013 20:13:21 GMT", "d07abe941054d444a8200595040b8166e082a62d1bff"] 10 | ] 11 | context "encode" do 12 | let(:encoder) { HTTP2::Header::Huffman } 13 | huffman_examples.each do |plain, encoded| 14 | it "should encode #{plain} into #{encoded}" do 15 | expect(encoder.encode(plain).unpack1("H*")).to eq encoded 16 | end 17 | end 18 | end 19 | context "decode" do 20 | let(:encoder) { HTTP2::Header::Huffman } 21 | huffman_examples.each do |plain, encoded| 22 | it "should decode #{encoded} into #{plain}" do 23 | expect(encoder.decode([encoded].pack("H*"))).to eq plain 24 | end 25 | end 26 | 27 | [ 28 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.8; rv:16.0) Gecko/20100101 Firefox/16.0", 29 | "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 30 | "http://www.craigslist.org/about/sites/", 31 | "cl_b=AB2BKbsl4hGM7M4nH5PYWghTM5A; cl_def_lang=en; cl_def_hp=shoals", 32 | "image/png,image/*;q=0.8,*/*;q=0.5", 33 | "BX=c99r6jp89a7no&b=3&s=q4; localization=en-us%3Bus%3Bus", 34 | "UTF-8でエンコードした日本語文字列" 35 | ].each do |string| 36 | it "should encode then decode '#{string}' into the same" do 37 | s = string.dup.force_encoding(Encoding::BINARY) 38 | encoded = encoder.encode(s) 39 | expect(encoder.decode(encoded)).to eq s 40 | end 41 | end 42 | 43 | it "should encode/decode all_possible 2-byte sequences" do 44 | (2**16).times do |n| 45 | str = [n].pack("V")[0, 2].force_encoding(Encoding::BINARY) 46 | expect(encoder.decode(encoder.encode(str))).to eq str 47 | end 48 | end 49 | 50 | it "should raise when input is shorter than expected" do 51 | encoded = huffman_examples.first.last 52 | encoded = [encoded].pack("H*") 53 | expect { encoder.decode(encoded[0...-1]) }.to raise_error(/EOS invalid/) 54 | end 55 | it "should raise when input is not padded by 1s" do 56 | encoded = "f1e3c2e5f23a6ba0ab90f4fe" # note the fe at end 57 | encoded = [encoded].pack("H*") 58 | expect { encoder.decode(encoded) }.to raise_error(/EOS invalid/) 59 | end 60 | it "should raise when exceedingly padded" do 61 | encoded = "e7cf9bebe89b6fb16fa9b6ffff" # note the extra ff 62 | encoded = [encoded].pack("H*") 63 | expect { encoder.decode(encoded) }.to raise_error(/EOS invalid/) 64 | end 65 | it "should raise when EOS is explicitly encoded" do 66 | encoded = ["1c7fffffffff"].pack("H*") # a b EOS 67 | expect { encoder.decode(encoded) }.to raise_error(/EOS found/) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "helper" 4 | require "shared_examples/connection" 5 | 6 | RSpec.describe HTTP2::Server do 7 | include FrameHelpers 8 | 9 | it_behaves_like "a connection" do 10 | let(:conn) do 11 | srv = Server.new 12 | srv << CONNECTION_PREFACE_MAGIC 13 | srv 14 | end 15 | let(:connected_conn) do 16 | srv = Server.new 17 | srv << CONNECTION_PREFACE_MAGIC 18 | srv << f.generate(settings_frame) 19 | srv 20 | end 21 | end 22 | 23 | let(:srv) { Server.new } 24 | let(:f) { Framer.new } 25 | 26 | context "initialization and settings" do 27 | it "should return even stream IDs" do 28 | expect(srv.new_stream.id).to be_even 29 | end 30 | 31 | it "should emit SETTINGS on new connection" do 32 | frames = [] 33 | srv.on(:frame) { |recv| frames << recv } 34 | srv << CONNECTION_PREFACE_MAGIC 35 | 36 | expect(f.parse(frames[0])[:type]).to eq :settings 37 | end 38 | 39 | it "should initialize client with custom connection settings" do 40 | frames = [] 41 | 42 | srv = Server.new(settings_max_concurrent_streams: 200, 43 | settings_initial_window_size: 2**10) 44 | srv.on(:frame) { |recv| frames << recv } 45 | srv << CONNECTION_PREFACE_MAGIC 46 | 47 | frame = f.parse(frames[0]) 48 | expect(frame[:type]).to eq :settings 49 | expect(frame[:payload]).to include([:settings_max_concurrent_streams, 200]) 50 | expect(frame[:payload]).to include([:settings_initial_window_size, 2**10]) 51 | end 52 | end 53 | 54 | it "should allow server push" do 55 | client = Client.new 56 | client.on(:frame) { |bytes| srv << bytes } 57 | 58 | srv.on(:stream) do |stream| 59 | expect do 60 | stream.promise({ ":method" => "GET" }) {} 61 | end.to_not raise_error 62 | end 63 | 64 | client.new_stream 65 | client.send headers_frame 66 | end 67 | 68 | context "should allow upgrade" do 69 | let(:settings) { Client.settings_header(settings_frame[:payload]) } 70 | 71 | it "for bodyless responses" do 72 | expect(srv.active_stream_count).to eq(0) 73 | 74 | srv.upgrade(settings, RESPONSE_HEADERS, "") 75 | 76 | expect(srv.active_stream_count).to eq(1) 77 | end 78 | 79 | it "for responses with body" do 80 | expect(srv.active_stream_count).to eq(0) 81 | srv.upgrade(settings, RESPONSE_HEADERS + [[:content_length, 4]], "bang") 82 | 83 | expect(srv.active_stream_count).to eq(1) 84 | end 85 | end 86 | 87 | it "should allow to send supported origins" do 88 | srv.origin_set = %w[https://www.youtube.com] 89 | origins = [] 90 | client = Client.new 91 | client.on(:frame) { |bytes| srv << bytes } 92 | client.on(:origin) { |origin| origins << origin } 93 | srv.on(:frame) { |bytes| client << bytes } 94 | 95 | client.new_stream 96 | client.send headers_frame 97 | expect(origins).to eq(%w[https://www.youtube.com]) 98 | end 99 | 100 | context "connection management" do 101 | it "should raise error on invalid connection header" do 102 | srv = Server.new 103 | expect { srv << f.generate(settings_frame) }.to raise_error(HandshakeError) 104 | 105 | srv = Server.new 106 | expect do 107 | srv << CONNECTION_PREFACE_MAGIC 108 | srv << f.generate(settings_frame) 109 | end.to_not raise_error 110 | end 111 | 112 | it "should not raise an error on frame for a closed stream ID" do 113 | srv = Server.new 114 | srv << CONNECTION_PREFACE_MAGIC 115 | 116 | stream = srv.new_stream 117 | stream.send headers_frame 118 | stream.send data_frame 119 | stream.close 120 | 121 | # WINDOW_UPDATE or RST_STREAM frames can be received in this state 122 | # for a short period 123 | expect do 124 | srv << f.generate(rst_stream_frame.merge(stream: stream.id)) 125 | end.to_not raise_error 126 | 127 | expect do 128 | srv << f.generate(window_update_frame.merge(stream: stream.id)) 129 | end.to_not raise_error 130 | 131 | # PRIORITY frames can be sent on closed streams to prioritize 132 | # streams that are dependent on the closed stream. 133 | expect do 134 | srv << f.generate(priority_frame.merge(stream: stream.id)) 135 | end.to_not raise_error 136 | end 137 | end 138 | 139 | context "stream management" do 140 | it "should initialize stream with HEADERS priority value" do 141 | srv << CONNECTION_PREFACE_MAGIC 142 | srv << f.generate(settings_frame) 143 | 144 | stream = nil 145 | headers = headers_frame 146 | headers[:weight] = 20 147 | headers[:dependency] = 0 148 | headers[:exclusive] = false 149 | 150 | srv.on(:stream) { |s| stream = s } 151 | srv << f.generate(headers) 152 | 153 | expect(stream.weight).to eq 20 154 | end 155 | 156 | it "should process connection management frames after GOAWAY" do 157 | srv << CONNECTION_PREFACE_MAGIC 158 | srv << f.generate(settings_frame) 159 | srv << f.generate(headers_frame) 160 | srv << f.generate(goaway_frame) 161 | srv << f.generate(headers_frame.merge(stream: 3)) 162 | expect(srv.active_stream_count).to eq 1 163 | end 164 | end 165 | 166 | context "API" do 167 | it ".goaway should generate GOAWAY frame with last processed stream ID" do 168 | srv << CONNECTION_PREFACE_MAGIC 169 | srv << f.generate(settings_frame) 170 | srv << f.generate(headers_frame) 171 | 172 | expect(srv).to receive(:send) do |frame| 173 | expect(frame[:type]).to eq :goaway 174 | expect(frame[:last_stream]).to eq 1 175 | expect(frame[:error]).to eq :internal_error 176 | expect(frame[:payload]).to eq "payload" 177 | end 178 | 179 | srv.goaway(:internal_error, "payload") 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /spec/shared_examples/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "a connection" do 4 | let(:conn) { described_class.new } 5 | let(:f) { Framer.new } 6 | 7 | context "settings synchronization" do 8 | it "should reflect incoming settings when SETTINGS is received" do 9 | expect(conn.remote_settings[:settings_header_table_size]).to eq 4096 10 | settings = settings_frame 11 | settings[:payload] = [[:settings_header_table_size, 256]] 12 | 13 | conn << f.generate(settings) 14 | 15 | expect(conn.remote_settings[:settings_header_table_size]).to eq 256 16 | end 17 | 18 | it "should send SETTINGS ACK when SETTINGS is received" do 19 | settings = settings_frame 20 | settings[:payload] = [[:settings_header_table_size, 256]] 21 | 22 | # We should expect two frames here (append .twice) - one for the connection setup, and one for the settings ack. 23 | frames = [] 24 | allow(conn).to receive(:send) do |frame| 25 | frames << frame 26 | end 27 | 28 | conn << f.generate(settings) 29 | 30 | frame = frames.last 31 | expect(frame[:type]).to eq :settings 32 | expect(frame[:flags]).to eq [:ack] 33 | expect(frame[:payload]).to eq [] 34 | end 35 | end 36 | 37 | context "flow control" do 38 | it "should initialize to default flow window" do 39 | expect(conn.remote_window).to eq DEFAULT_FLOW_WINDOW 40 | end 41 | 42 | it "should update connection and stream windows on SETTINGS" do 43 | settings = settings_frame 44 | data = data_frame 45 | settings[:payload] = [[:settings_initial_window_size, 1024]] 46 | data[:payload] = "x" * 2048 47 | 48 | stream = conn.new_stream 49 | 50 | stream.send headers_frame 51 | stream.send data 52 | expect(stream.remote_window).to eq(DEFAULT_FLOW_WINDOW - 2048) 53 | expect(conn.remote_window).to eq(DEFAULT_FLOW_WINDOW - 2048) 54 | 55 | conn << f.generate(settings) 56 | 57 | # connection window size can only be updated through WINDOW_UPDATE 58 | expect(conn.remote_window).to eq(DEFAULT_FLOW_WINDOW - 2048) 59 | expect(stream.remote_window).to eq(-1024) 60 | end 61 | 62 | it "should initialize streams with window specified by peer" do 63 | settings = settings_frame 64 | settings[:payload] = [[:settings_initial_window_size, 1024]] 65 | 66 | conn << f.generate(settings) 67 | expect(conn.new_stream.remote_window).to eq 1024 68 | end 69 | 70 | it "should observe connection flow control" do 71 | settings = settings_frame 72 | data = data_frame 73 | settings[:payload] = [[:settings_max_frame_size, 65_535]] 74 | 75 | conn << f.generate(settings) 76 | s1 = conn.new_stream 77 | s2 = conn.new_stream 78 | 79 | s1.send headers_frame 80 | s1.send data.merge(payload: "x" * 65_000) 81 | expect(conn.remote_window).to eq 535 82 | 83 | s2.send headers_frame 84 | s2.send data.merge(payload: "x" * 635) 85 | expect(conn.remote_window).to eq 0 86 | expect(conn.buffered_amount).to eq 100 87 | 88 | conn << f.generate(window_update_frame.merge(stream: 0, increment: 1000)) 89 | expect(conn.buffered_amount).to eq 0 90 | expect(conn.remote_window).to eq 900 91 | end 92 | 93 | it "should update window when data received is over half of the maximum local window size" do 94 | settings = settings_frame 95 | data = data_frame 96 | conn = Client.new(settings_initial_window_size: 500) 97 | 98 | conn.receive f.generate(settings) 99 | s1 = conn.new_stream 100 | s2 = conn.new_stream 101 | 102 | s1.send headers_frame 103 | s2.send headers_frame 104 | expect(conn).to receive(:send) do |frame| 105 | expect(frame[:type]).to eq :window_update 106 | expect(frame[:stream]).to eq 0 107 | expect(frame[:increment]).to eq 400 108 | end 109 | conn.receive f.generate(data.merge(payload: "x" * 200, stream: s1.id)) 110 | conn.receive f.generate(data.merge(payload: "x" * 200, stream: s2.id)) 111 | expect(s1.local_window).to eq 300 112 | expect(s2.local_window).to eq 300 113 | expect(conn.local_window).to eq 500 114 | end 115 | end 116 | 117 | context "connection management" do 118 | it "should respond to PING frames" do 119 | conn << f.generate(settings_frame) 120 | expect(conn).to receive(:send) do |frame| 121 | expect(frame[:type]).to eq :ping 122 | expect(frame[:flags]).to eq [:ack] 123 | expect(frame[:payload]).to eq "12345678" 124 | end 125 | 126 | conn << f.generate(ping_frame) 127 | end 128 | 129 | it "should fire callback on PONG" do 130 | conn << f.generate(settings_frame) 131 | 132 | pong = nil 133 | conn.ping("12345678") { |d| pong = d } 134 | conn << f.generate(pong_frame) 135 | expect(pong).to eq "12345678" 136 | end 137 | 138 | it "should fire callback on receipt of GOAWAY" do 139 | last_stream, payload, error = nil 140 | conn << f.generate(settings_frame) 141 | conn.on(:goaway) do |s, e, p| 142 | last_stream = s 143 | error = e 144 | payload = p 145 | end 146 | conn << f.generate(goaway_frame.merge(last_stream: 17, payload: "test")) 147 | 148 | expect(last_stream).to eq 17 149 | expect(error).to eq :no_error 150 | expect(payload).to eq "test" 151 | 152 | expect(conn).to be_closed 153 | end 154 | 155 | it "should raise error when opening new stream after sending GOAWAY" do 156 | conn.goaway 157 | expect(conn).to be_closed 158 | 159 | expect { conn.new_stream }.to raise_error(ConnectionClosed) 160 | end 161 | 162 | it "should raise error when opening new stream after receiving GOAWAY" do 163 | conn << f.generate(settings_frame) 164 | conn << f.generate(goaway_frame) 165 | expect { conn.new_stream }.to raise_error(ConnectionClosed) 166 | end 167 | 168 | it "should not raise error when receiving connection management frames immediately after emitting goaway" do 169 | conn.goaway 170 | expect(conn).to be_closed 171 | 172 | expect { conn << f.generate(settings_frame) }.not_to raise_error(ProtocolError) 173 | expect { conn << f.generate(ping_frame) }.not_to raise_error(ProtocolError) 174 | end 175 | 176 | it "should respond with protocol error when receiving goaway" do 177 | conn.goaway 178 | expect(conn).to be_closed 179 | 180 | expect { conn << f.generate(goaway_frame) }.to raise_error(ProtocolError) 181 | end 182 | 183 | it "should raise error on frame for invalid stream ID" do 184 | conn << f.generate(settings_frame) 185 | 186 | expect do 187 | conn << f.generate(data_frame.merge(stream: 31)) 188 | end.to raise_error(ProtocolError) 189 | end 190 | 191 | it "should allow to change the frame size" do 192 | buffer = [] 193 | conn.on(:frame) do |bytes| 194 | buffer << bytes 195 | end 196 | stream1 = conn.new_stream 197 | stream1.send headers_frame 198 | 199 | # splits big data 200 | expect { stream1.data("a" * 16_385) }.to change { buffer.size }.by(2) 201 | 202 | conn << f.generate(settings_frame.merge(payload: [[:settings_max_frame_size, 65_536]])) 203 | 204 | stream2 = conn.new_stream 205 | stream2.send headers_frame 206 | expect { stream2.data("a" * 16_385, end_stream: false) }.to change { buffer.size }.by(1) 207 | end 208 | end 209 | 210 | context "stream management" do 211 | it "should initialize to default stream limit (100)" do 212 | expect(conn.local_settings[:settings_max_concurrent_streams]).to eq 100 213 | end 214 | 215 | it "should change stream limit to received SETTINGS value" do 216 | conn << f.generate(settings_frame) 217 | expect(conn.remote_settings[:settings_max_concurrent_streams]).to eq 10 218 | end 219 | 220 | it "should count open streams against stream limit" do 221 | s = conn.new_stream 222 | expect(conn.active_stream_count).to eq 0 223 | s.receive headers_frame 224 | expect(conn.active_stream_count).to eq 1 225 | end 226 | 227 | it "should not count reserved streams against stream limit" do 228 | s1 = conn.new_stream 229 | s1.receive push_promise_frame 230 | expect(conn.active_stream_count).to eq 0 231 | 232 | s2 = conn.new_stream 233 | s2.send push_promise_frame 234 | expect(conn.active_stream_count).to eq 0 235 | 236 | s3 = conn.new_stream 237 | s3.send push_promise_frame 238 | expect(conn.active_stream_count).to eq 0 239 | 240 | # transition to half closed 241 | s1.receive headers_frame 242 | s2.send headers_frame 243 | s3.send rst_stream_frame 244 | expect(conn.active_stream_count).to eq 2 245 | 246 | # transition to closed 247 | s1.receive data_frame 248 | s2.send data_frame 249 | expect(conn.active_stream_count).to eq 0 250 | 251 | expect(s1).to be_closed 252 | expect(s2).to be_closed 253 | expect(s3).to be_closed 254 | end 255 | 256 | it "should not exceed stream limit set by peer" do 257 | conn << f.generate(settings_frame) 258 | 259 | expect do 260 | 10.times do 261 | s = conn.new_stream 262 | s.send headers_frame 263 | end 264 | end.to_not raise_error 265 | 266 | expect { conn.new_stream }.to raise_error(StreamLimitExceeded) 267 | end 268 | 269 | it "should initialize idle stream on PRIORITY frame" do 270 | conn << f.generate(settings_frame) 271 | 272 | stream = nil 273 | conn.on(:stream) { |s| stream = s } 274 | conn << f.generate(priority_frame) 275 | 276 | expect(stream.state).to eq :idle 277 | end 278 | end 279 | 280 | context "framing" do 281 | let(:conn) { connected_conn } 282 | 283 | it "should chain continuation frames" do 284 | headers = headers_frame 285 | headers[:flags] = [] 286 | continuation = continuation_frame 287 | continuation[:stream] = headers[:stream] 288 | continuation[:flags] = [] 289 | 290 | conn << f.generate(headers) 291 | conn << f.generate(continuation) 292 | expect(conn.active_stream_count).to be_zero # stream not open yet 293 | end 294 | 295 | it "should refuse continuation frames which overflow the max frame size" do 296 | max_frame_size = connected_conn.local_settings[:settings_max_frame_size] 297 | 298 | headers = headers_frame 299 | headers[:flags] = [] 300 | 301 | conn << f.generate(headers) 302 | expect do 303 | max_frame_size.times do 304 | continuation = continuation_frame 305 | continuation[:stream] = headers[:stream] 306 | continuation[:flags] = [] 307 | conn << f.generate(continuation) 308 | end 309 | end.to raise_error(ProtocolError) 310 | end 311 | 312 | it "should require that split header blocks are a contiguous sequence" do 313 | headers = headers_frame 314 | headers[:flags] = [] 315 | 316 | conn << f.generate(headers) 317 | (frame_types - [continuation_frame]).each do |frame| 318 | expect { conn << f.generate(frame) }.to raise_error(ProtocolError) 319 | end 320 | end 321 | 322 | it "should require that split promise blocks are a contiguous sequence" do 323 | headers = push_promise_frame 324 | headers[:flags] = [] 325 | 326 | conn << f.generate(headers) 327 | (frame_types - [continuation_frame]).each do |frame| 328 | expect { conn << f.generate(frame) }.to raise_error(ProtocolError) 329 | end 330 | end 331 | 332 | it "should raise connection error on decode of invalid frame" do 333 | frame = f.generate(data_frame) # Receiving DATA on unopened stream 1 is an error. 334 | # Connection errors emit protocol error frames 335 | expect { conn << frame }.to raise_error(ProtocolError) 336 | end 337 | 338 | it "should emit encoded frames via on(:frame)" do 339 | bytes = nil 340 | conn.on(:frame) { |d| bytes = d } 341 | conn.settings(settings_max_concurrent_streams: 10, 342 | settings_initial_window_size: 0x7fffffff) 343 | 344 | expect(bytes).to eq f.generate(settings_frame) 345 | end 346 | 347 | it "should compress stream headers" do 348 | conn.on(:frame) do |bytes| 349 | expect(bytes).not_to include("get") 350 | expect(bytes).not_to include("http") 351 | expect(bytes).not_to include("www.example.org") # should be huffman encoded 352 | end 353 | 354 | stream = conn.new_stream 355 | stream.headers({ 356 | ":method" => "get", 357 | ":scheme" => "http", 358 | ":authority" => "www.example.org", 359 | ":path" => "/resource" 360 | }) 361 | end 362 | 363 | it "should generate CONTINUATION if HEADERS is too long" do 364 | headers = [] 365 | conn.on(:frame) do |bytes| 366 | # bytes[3]: frame's type field 367 | headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord) 368 | end 369 | 370 | stream = conn.new_stream 371 | stream.headers({ 372 | ":method" => "get", 373 | ":scheme" => "http", 374 | ":authority" => "www.example.org", 375 | ":path" => "/resource", 376 | "custom" => "q" * 44_000 377 | }, end_stream: true) 378 | expect(headers.size).to eq 3 379 | expect(headers[0][:type]).to eq :headers 380 | expect(headers[1][:type]).to eq :continuation 381 | expect(headers[2][:type]).to eq :continuation 382 | expect(headers[0][:flags]).to eq [:end_stream] 383 | expect(headers[1][:flags]).to eq [] 384 | expect(headers[2][:flags]).to eq [:end_headers] 385 | end 386 | 387 | it "should not generate CONTINUATION if HEADERS fits exactly in a frame" do 388 | headers = [] 389 | conn.on(:frame) do |bytes| 390 | # bytes[3]: frame's type field 391 | headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord) 392 | end 393 | 394 | stream = conn.new_stream 395 | stream.headers({ 396 | ":method" => "get", 397 | ":scheme" => "http", 398 | ":authority" => "www.example.org", 399 | ":path" => "/resource", 400 | "custom" => "q" * 18_682 # this number should be updated when Huffman table is changed 401 | }, end_stream: true) 402 | expect(headers[0][:length]).to eq conn.remote_settings[:settings_max_frame_size] 403 | expect(headers.size).to eq 1 404 | expect(headers[0][:type]).to eq :headers 405 | expect(headers[0][:flags]).to include(:end_headers) 406 | expect(headers[0][:flags]).to include(:end_stream) 407 | end 408 | 409 | it "should not generate CONTINUATION if HEADERS fits exactly in a frame" do 410 | headers = [] 411 | conn.on(:frame) do |bytes| 412 | # bytes[3]: frame's type field 413 | headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord) 414 | end 415 | 416 | stream = conn.new_stream 417 | stream.headers({ 418 | ":method" => "get", 419 | ":scheme" => "http", 420 | ":authority" => "www.example.org", 421 | ":path" => "/resource", 422 | "custom" => "q" * 18_682 # this number should be updated when Huffman table is changed 423 | }, end_stream: true) 424 | expect(headers[0][:length]).to eq conn.remote_settings[:settings_max_frame_size] 425 | expect(headers.size).to eq 1 426 | expect(headers[0][:type]).to eq :headers 427 | expect(headers[0][:flags]).to include(:end_headers) 428 | expect(headers[0][:flags]).to include(:end_stream) 429 | end 430 | 431 | it "should generate CONTINUATION if HEADERS exceed the max payload by one byte" do 432 | headers = [] 433 | conn.on(:frame) do |bytes| 434 | headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord) 435 | end 436 | 437 | stream = conn.new_stream 438 | stream.headers({ 439 | ":method" => "get", 440 | ":scheme" => "http", 441 | ":authority" => "www.example.org", 442 | ":path" => "/resource", 443 | "custom" => "q" * 18_683 # this number should be updated when Huffman table is changed 444 | }, end_stream: true) 445 | expect(headers[0][:length]).to eq conn.remote_settings[:settings_max_frame_size] 446 | expect(headers[1][:length]).to eq 1 447 | expect(headers.size).to eq 2 448 | expect(headers[0][:type]).to eq :headers 449 | expect(headers[1][:type]).to eq :continuation 450 | expect(headers[0][:flags]).to eq [:end_stream] 451 | expect(headers[1][:flags]).to eq [:end_headers] 452 | end 453 | end 454 | context "API" do 455 | it ".settings should emit SETTINGS frames" do 456 | expect(conn).to receive(:send) do |frame| 457 | expect(frame[:type]).to eq :settings 458 | expect(frame[:payload].to_a).to eq([ 459 | [:settings_max_concurrent_streams, 10], 460 | [:settings_initial_window_size, 0x7fffffff] 461 | ]) 462 | expect(frame[:stream]).to eq 0 463 | end 464 | 465 | conn.settings(settings_max_concurrent_streams: 10, 466 | settings_initial_window_size: 0x7fffffff) 467 | end 468 | 469 | it ".ping should generate PING frames" do 470 | expect(conn).to receive(:send) do |frame| 471 | expect(frame[:type]).to eq :ping 472 | expect(frame[:payload]).to eq "somedata" 473 | end 474 | 475 | conn.ping("somedata") 476 | end 477 | 478 | it ".window_update should emit WINDOW_UPDATE frames" do 479 | expect(conn).to receive(:send) do |frame| 480 | expect(frame[:type]).to eq :window_update 481 | expect(frame[:increment]).to eq 20 482 | expect(frame[:stream]).to eq 0 483 | end 484 | conn.window_update(20) 485 | end 486 | end 487 | end 488 | -------------------------------------------------------------------------------- /tasks/generate_huffman_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Generate Huffman precompiled table in huffman_statemachine.rb" 4 | task :generate_huffman_table do 5 | HuffmanTable::Node.generate_state_table 6 | end 7 | 8 | require_relative "../lib/http/2/header/huffman" 9 | 10 | module HuffmanTable 11 | BITS_AT_ONCE = HTTP2::Header::Huffman::BITS_AT_ONCE 12 | EOS = 256 13 | 14 | class Node 15 | attr_accessor :next, :emit, :final, :depth, :transitions, :id 16 | 17 | @@id = 0 # rubocop:disable Style/ClassVars 18 | def initialize(depth) 19 | @next = [nil, nil] 20 | @id = @@id 21 | @@id += 1 # rubocop:disable Style/ClassVars 22 | @final = false 23 | @depth = depth 24 | end 25 | 26 | def add(code, len, chr) 27 | self.final = true if chr == EOS && @depth <= 7 28 | if len.zero? 29 | @emit = chr 30 | else 31 | bit = code.nobits?((1 << (len - 1))) ? 0 : 1 32 | node = @next[bit] ||= Node.new(@depth + 1) 33 | node.add(code, len - 1, chr) 34 | end 35 | end 36 | 37 | class Transition 38 | attr_accessor :emit, :node 39 | 40 | def initialize(emit, node) 41 | @emit = emit 42 | @node = node 43 | end 44 | end 45 | 46 | def self.generate_tree 47 | @root = new(0) 48 | HTTP2::Header::Huffman::CODES.each_with_index do |c, chr| 49 | code, len = c 50 | @root.add(code, len, chr) 51 | end 52 | puts "#{@@id} nodes" 53 | @root 54 | end 55 | 56 | def self.generate_machine 57 | generate_tree 58 | togo = Set[@root] 59 | @states = Set[@root] 60 | 61 | until togo.empty? 62 | node = togo.first 63 | togo.delete(node) 64 | 65 | next if node.transitions 66 | 67 | node.transitions = [1 << BITS_AT_ONCE] 68 | 69 | (1 << BITS_AT_ONCE).times do |input| 70 | n = node 71 | emit = "".b 72 | (BITS_AT_ONCE - 1).downto(0) do |i| 73 | bit = input.nobits?((1 << i)) ? 0 : 1 74 | n = n.next[bit] 75 | next unless n.emit 76 | 77 | if n.emit == EOS 78 | emit = EOS # cause error on decoding 79 | else 80 | emit << n.emit.chr(Encoding::BINARY) unless emit == EOS 81 | end 82 | n = @root 83 | end 84 | node.transitions[input] = Transition.new(emit, n) 85 | togo << n 86 | @states << n 87 | end 88 | end 89 | puts "#{@states.size} states" 90 | @root 91 | end 92 | 93 | def self.generate_state_table 94 | generate_machine 95 | state_id = {} 96 | id_state = {} 97 | state_id[@root] = 0 98 | id_state[0] = @root 99 | max_final = 0 100 | id = 1 101 | (@states - [@root]).sort_by { |s| s.final ? 0 : 1 }.each do |s| 102 | state_id[s] = id 103 | id_state[id] = s 104 | max_final = id if s.final 105 | id += 1 106 | end 107 | 108 | File.open(File.expand_path("../lib/http/2/header/huffman_statemachine.rb", File.dirname(__FILE__)), "w") do |f| 109 | f.print <<~HEADER 110 | # Machine generated Huffman decoder state machine. 111 | # DO NOT EDIT THIS FILE. 112 | 113 | # The following task generates this file. 114 | # rake generate_huffman_table 115 | 116 | module HTTP2 117 | module Header 118 | class Huffman 119 | # :nodoc: 120 | MAX_FINAL_STATE = #{max_final} 121 | MACHINE = [ 122 | HEADER 123 | id.times do |i| 124 | n = id_state[i] 125 | f.print " [" 126 | string = Array.new((1 << 4)) do |t| 127 | transition = n.transitions.fetch(t) 128 | emit = transition.emit 129 | unless emit == EOS 130 | bytes = emit.bytes 131 | raise ArgumentError if bytes.size > 1 132 | 133 | emit = bytes.first 134 | end 135 | "[#{emit.inspect}, #{state_id.fetch(transition.node)}]" 136 | end.join(", ") 137 | f.print(string) 138 | f.print "],\n" 139 | end 140 | f.print <<~TAILER 141 | ].each { |arr| arr.each { |subarr| subarr.freeze }.freeze }.freeze 142 | end 143 | end 144 | end 145 | TAILER 146 | end 147 | end 148 | 149 | class << self 150 | attr_reader :root 151 | end 152 | 153 | # Test decoder 154 | def self.decode(input) 155 | emit = "" 156 | n = root 157 | nibbles = input.unpack("C*").flat_map { |b| [((b & 0xf0) >> 4), b & 0xf] } 158 | until nibbles.empty? 159 | nb = nibbles.shift 160 | t = n.transitions[nb] 161 | emit << t.emit 162 | n = t.node 163 | end 164 | puts "len = #{emit.size} n.final = #{n.final} nibbles = #{nibbles}" unless n.final && nibbles.all?(0xf) 165 | emit 166 | end 167 | end 168 | end 169 | --------------------------------------------------------------------------------