├── .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 | [](http://rubygems.org/gems/http-2)
4 | [](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 | :promise |
76 | client role only, fires once for each new push promise |
77 |
78 |
79 | :stream |
80 | server role only, fires once for each new client stream |
81 |
82 |
83 | :frame |
84 | fires once for every encoded HTTP/2 frame that needs to be sent to the peer |
85 |
86 |
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 | :reserved |
158 | fires exactly once when a push stream is initialized |
159 |
160 |
161 | :active |
162 | fires exactly once when the stream become active and is counted towards the open stream limit |
163 |
164 |
165 | :headers |
166 | fires once for each received header block (multi-frame blocks are reassembled before emitting this event) |
167 |
168 |
169 | :data |
170 | fires once for every DATA frame (no buffering) |
171 |
172 |
173 | :half_close |
174 | fires 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) |
175 |
176 |
177 | :close |
178 | fires exactly once when both peers close the stream, or if the stream is reset |
179 |
180 |
181 | :priority |
182 | fires once for each received priority update (server only) |
183 |
184 |
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 
289 | (MIT License) - Copyright (c) 2019 Tiago Cardoso 
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 |
--------------------------------------------------------------------------------