diff --git a/.rubocop.yml b/.rubocop.yml index 8a517b4d..48ef2b41 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -108,4 +108,7 @@ Performance/MethodObjectAsBlock: Metrics/CollectionLiteralLength: Exclude: - - lib/http/2/header/huffman.rb \ No newline at end of file + - lib/http/2/header/huffman.rb + +Metrics/ParameterLists: + Enabled: false \ No newline at end of file diff --git a/Gemfile b/Gemfile index 832c3c08..8bc2d789 100644 --- a/Gemfile +++ b/Gemfile @@ -16,10 +16,6 @@ group :development do gem "irb" if RUBY_VERSION >= "4.0.0" end -group :docs do - gem "yard" -end - group :test do gem "rspec" gem "simplecov", require: false diff --git a/Rakefile b/Rakefile index 79ff5add..a53e41b5 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require "English" require "bundler/gem_tasks" require "open3" +require "rdoc/task" require_relative "tasks/generate_huffman_table" @@ -27,10 +28,10 @@ begin rescue LoadError end -begin - require "yard" - YARD::Rake::YardocTask.new -rescue LoadError +RDoc::Task.new do |rdoc| + rdoc.rdoc_dir = "rdoc" + rdoc.options.push("--main", "README.md") + rdoc.rdoc_files.add %w[README.md CHANGELOG.md lib/**/*.rb] end namespace :coverage do diff --git a/lib/http/2/client.rb b/lib/http/2/client.rb index 04e51941..f6d7ef4e 100644 --- a/lib/http/2/client.rb +++ b/lib/http/2/client.rb @@ -64,7 +64,7 @@ def send_connection_preface @state = :connected emit(:frame, CONNECTION_PREFACE_MAGIC) - payload = @local_settings.each_pair.reject { |k, v| v == SPEC_DEFAULT_CONNECTION_SETTINGS[k] } + payload = @local_settings.connection_settings.reject { |k, v| v == SPEC_DEFAULT_CONNECTION_SETTINGS[k] } settings(payload) end diff --git a/lib/http/2/connection.rb b/lib/http/2/connection.rb index 79a96472..420b98f6 100644 --- a/lib/http/2/connection.rb +++ b/lib/http/2/connection.rb @@ -59,13 +59,12 @@ class Connection attr_accessor :active_stream_count # Initializes new connection object. - # def initialize(settings = {}) @local_settings = Settings.new(**settings) @remote_settings = Settings.new(settings_max_concurrent_streams: Framer::MAX_STREAM_ID) - @compressor = Header::Compressor.new(settings) - @decompressor = Header::Decompressor.new(settings) + @compressor = Header::Compressor.new(@local_settings) + @decompressor = Header::Decompressor.new(@local_settings) @active_stream_count = 0 @last_stream_id = 0 @@ -194,7 +193,7 @@ def receive(data) elsif read_str(@recv_buffer, 24) == CONNECTION_PREFACE_MAGIC # MAGIC is OK. Send our settings @state = :waiting_connection_preface - payload = @local_settings.each_pair.reject { |k, v| v == SPEC_DEFAULT_CONNECTION_SETTINGS[k] } + payload = @local_settings.connection_settings.reject { |k, v| v == SPEC_DEFAULT_CONNECTION_SETTINGS[k] } settings(payload) else raise HandshakeError diff --git a/lib/http/2/header/compressor.rb b/lib/http/2/header/compressor.rb index 8a6c280d..566a219b 100644 --- a/lib/http/2/header/compressor.rb +++ b/lib/http/2/header/compressor.rb @@ -7,18 +7,16 @@ class Compressor include PackingExtensions include BufferUtils - # @param options [Hash] encoding options - def initialize(options = {}) - @cc = EncodingContext.new(options) + def initialize(settings = Settings.new) + @cc = EncodingContext.new(settings) end - # Set dynamic table size in EncodingContext - # @param size [Integer] new dynamic table size + # Set dynamic table +size+ in EncodingContext def table_size=(size) @cc.table_size = size end - # Encodes provided value via integer representation. + # Encodes +i+ via integer representation into +buffer+ at the offset set by +offset+. # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-5.1 # # If I < 2^N - 1, encode I on N bits @@ -30,11 +28,6 @@ def table_size=(size) # I = I / 128 # encode (I) on 8 bits # - # @param i [Integer] value to encode - # @param n [Integer] number of available bits - # @param buffer [String] buffer to pack bytes into - # @param offset [Integer] offset to insert packed bytes in buffer - # @return [String] binary string def integer(i, n, buffer:, offset: buffer.size) limit = (1 << n) - 1 return pack([i], "C", buffer: buffer, offset: offset) if i < limit @@ -65,16 +58,8 @@ def integer(i, n, buffer:, offset: buffer.size) # * If the bit 7 of the first byte is 0, the string value is # represented as a list of UTF-8 encoded octets. # - # +@options [:huffman]+ controls whether to use Huffman encoding: - # :never Do not use Huffman encoding - # :always Always use Huffman encoding - # :shorter Use Huffman when the result is strictly shorter - # - # @param str [String] - # @param buffer [String] - # @return [String] binary string def string(str, buffer = "".b) - case @cc.options[:huffman] + case @cc.settings.huffman when :always huffman_string(str, buffer) when :never @@ -93,11 +78,7 @@ def string(str, buffer = "".b) end end - # Encodes header command with appropriate header representation. - # - # @param h [Hash] header command - # @param buffer [String] - # @return [Buffer] + # Encodes +h+ header command with appropriate header representation into +buffer+. def header(h, buffer = "".b) type = h[:type] rep = HEADREP[type] @@ -128,9 +109,6 @@ def header(h, buffer = "".b) end # Encodes provided list of HTTP headers. - # - # @param headers [Array] +[[name, value], ...]+ - # @return [Buffer] def encode(headers) buffer = "".b headers.partition { |f, _| f.start_with? ":" }.each do |hs| @@ -144,9 +122,7 @@ def encode(headers) private - # @param str [String] - # @param buffer [String] - # @return [String] binary string + # encodes +str+ into +buffer+ using Huffman encoding. def huffman_string(str, buffer = "".b) huffman_offset = buffer.bytesize buffer << "\x00".b @@ -162,18 +138,14 @@ def huffman_string(str, buffer = "".b) buffer end - # @param str [String] - # @param buffer [String] - # @return [String] binary string + # encodes +str+ into +buffer+. def plain_string(str, plain = "".b) integer(str.bytesize, 7, buffer: plain) append_str(plain, str) plain end - # @param buffer [String] - # @param huffman_offset [Integer] buffer offset where huffman string was introduced - # @return [String] binary string + # encodes the huffman string size from +buffer+ into the string at the offset indicated by +huffman_offset+ def set_huffman_size(buffer, huffman_offset) integer(buffer.bytesize - huffman_offset, 7, buffer: buffer, offset: huffman_offset) buffer.setbyte(huffman_offset, buffer.getbyte(huffman_offset) | 0x80) diff --git a/lib/http/2/header/decompressor.rb b/lib/http/2/header/decompressor.rb index 4a642860..b58ce41f 100644 --- a/lib/http/2/header/decompressor.rb +++ b/lib/http/2/header/decompressor.rb @@ -5,32 +5,22 @@ module Header # Responsible for decoding received headers and maintaining compression # context of the opposing peer. Decompressor must be initialized with # appropriate starting context based on local role: client or server. - # - # @example - # server_role = Decompressor.new(:request) - # client_role = Decompressor.new(:response) class Decompressor include Error include BufferUtils FORBIDDEN_HEADERS = %w[connection te].freeze - # @param options [Hash] decoding options. Only :table_size is effective. - def initialize(options = {}) - @cc = EncodingContext.new(options) + def initialize(settings = Settings.new) + @cc = EncodingContext.new(settings) end - # Set dynamic table size in EncodingContext - # @param size [Integer] new dynamic table size + # Set dynamic table +size+ in EncodingContext def table_size=(size) @cc.table_size = size end - # Decodes integer value from provided buffer. - # - # @param buf [String] - # @param n [Integer] number of available bits - # @return [Integer] + # Decodes integer value from provided +buffer+. def integer(buf, n) limit = (1 << n) - 1 if n.zero? @@ -55,11 +45,9 @@ def integer(buf, n) i end - # Decodes string value from provided buffer. + # Decodes string value from provided +buf+. # - # @param buf [String] - # @return [String] UTF-8 encoded string - # @raise [CompressionError] when input is malformed + # raises CompressionError when input is malformed def string(buf) raise CompressionError, "invalid header block fragment" if buf.empty? @@ -72,10 +60,7 @@ def string(buf) str.force_encoding(Encoding::UTF_8) end - # Decodes header command from provided buffer. - # - # @param buf [Buffer] - # @return [Hash] command + # Decodes header command from provided +buf+. def header(buf) peek = buf.getbyte(0) @@ -109,11 +94,7 @@ def header(buf) end end - # Decodes and processes header commands within provided buffer. - # - # @param buf [Buffer] - # @param frame [HTTP2::Frame, nil] - # @return [Array] +[[name, value], ...] + # Decodes and processes header commands within provided +buf+. def decode(buf, frame = nil) list = [] #: Array[header_pair] decoding_pseudo_headers = true diff --git a/lib/http/2/header/encoding_context.rb b/lib/http/2/header/encoding_context.rb index b00c4eb5..9e13cd08 100644 --- a/lib/http/2/header/encoding_context.rb +++ b/lib/http/2/header/encoding_context.rb @@ -86,12 +86,6 @@ class EncodingContext STATIC_TABLE_SIZE = STATIC_TABLE.size - DEFAULT_OPTIONS = { - huffman: :shorter, - index: :all, - table_size: 4096 - }.freeze - STATIC_ALL = %i[all static].freeze STATIC_NEVER = %i[never static].freeze @@ -99,37 +93,27 @@ class EncodingContext # Current table of header key-value pairs. attr_reader :table - # Current encoding options - # - # :table_size Integer maximum dynamic table size in bytes - # :huffman Symbol :always, :never, :shorter - # :index Symbol :all, :static, :never - attr_reader :options + # Current encoding settings + attr_reader :settings # Current table size in octets attr_reader :current_table_size # Initializes compression context with appropriate client/server - # defaults and maximum size of the dynamic table. - # - # @param options [Hash] encoding options - # :table_size Integer maximum dynamic table size in bytes - # :huffman Symbol :always, :never, :shorter - # :index Symbol :all, :static, :never - def initialize(options = {}) + # +settings+ and maximum size of the dynamic table. + def initialize(settings = Settings.new) @table = [] @table_by_field = Hash.new { |hs, k| hs[k] = [] } @unshifts = 0 - @options = DEFAULT_OPTIONS.merge(options) - @limit = @options[:table_size] + @settings = settings + @limit = settings.table_size @_table_updated = false @current_table_size = 0 end # Duplicates current compression context - # @return [EncodingContext] def dup - other = EncodingContext.new(@options) + other = EncodingContext.new(@settings) t = @table tbf = @table_by_field.transform_values(&:dup) unshifts = @unshifts @@ -143,16 +127,13 @@ def dup other end - # Finds an entry in current dynamic table by index. - # Note that index is zero-based in this module. + # Finds an entry in current dynamic table by +index+. + # Note that +index+ is zero-based in this module. # - # If the index is greater than the last index in the static table, + # If the +index+ is greater than the last index in the static table, # an entry in the dynamic table is dereferenced. # - # If the index is greater than the last header index, an error is raised. - # - # @param index [Integer] zero-based index in the dynamic table. - # @return [Array] +[key, value]+ + # If the +index+ is greater than the last header index, an error is raised. def dereference(index) # NOTE: index is zero-based in this module. return STATIC_TABLE[index] if index < STATIC_TABLE_SIZE @@ -166,10 +147,6 @@ def dereference(index) # Header Block Processing # - http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-10#section-4.1 - # - # @param cmd [Hash] { type:, name:, value:, index: } - # @return [Array, nil] +[name, value]+ header field that is added to the decoded header list, - # or nil if +cmd[:type]+ is +:changetablesize+ def process(cmd) type = cmd[:type] name = cmd[:name] @@ -181,7 +158,7 @@ def process(cmd) # we can receive multiple table size change commands inside a header frame. However, # we should blow up if we receive another frame where the new table size is bigger. - table_size_updated = @limit != @options[:table_size] + table_size_updated = @limit != @settings.table_size raise CompressionError, "dynamic table size update exceed limit" if !table_size_updated && value > @limit @@ -232,16 +209,10 @@ def process(cmd) end end - # Plan header compression according to +@options [:index]+ - # :never Do not use dynamic table or static table reference at all. - # :static Use static table only. - # :all Use all of them. - # - # @param headers [Array] +[[name, value], ...]+ - # @return [Array] array of commands + # Plan +headers+ compression. def encode(headers) # Literals commands are marked with :noindex when index is not used - noindex = STATIC_NEVER.include?(@options[:index]) + noindex = STATIC_NEVER.include?(@settings.index) headers.each do |field, value| # Literal header names MUST be translated to lowercase before @@ -255,26 +226,17 @@ def encode(headers) end end - # Emits command for a header. + # Emits command for a +field+/+value+ header. # Prefer static table over dynamic table. # Prefer exact match over name-only match. # - # +@options [:index]+ controls whether to use the dynamic table, - # static table, or both. - # :never Do not use dynamic table or static table reference at all. - # :static Use static table only. - # :all Use all of them. - # - # @param field [String] the header field - # @param value [String] the header value - # @return [Hash] command def addcmd(field, value) # @type var exact: Integer? exact = nil # @type var name_only: Integer? name_only = nil - index_type = @options[:index] + index_type = @settings.index if STATIC_ALL.include?(index_type) && STATIC_TABLE_BY_FIELD.key?(field) @@ -338,11 +300,10 @@ def resize_table(cmdsize) end end + # whether +cmd+ fits in the dynamic table. + # # To keep the dynamic table size lower than or equal to @limit, # remove one or more entries at the end of the dynamic table. - # - # @param cmdsize [Integer] - # @return [Boolean] whether +cmd+ fits in the dynamic table. def size_check?(cmdsize) resize_table(cmdsize) cmdsize <= @limit diff --git a/lib/http/2/header/huffman.rb b/lib/http/2/header/huffman.rb index 5e88602c..ef37e6dd 100644 --- a/lib/http/2/header/huffman.rb +++ b/lib/http/2/header/huffman.rb @@ -22,12 +22,7 @@ module Huffman EOS_PADDING = (0..7).map { |n| ("1" * n).b.freeze }.freeze - # Encodes provided value via huffman encoding. - # Length is not encoded in this method. - # - # @param str [String] - # @param buffer [String] - # @return [String] binary string + # Encodes provided +str+ via huffman encoding into +buffer+ . def encode(str, buffer = "".b) bitstring = String.new("", encoding: Encoding::BINARY, capacity: (str.bytesize * 30) + ((8 - str.size) % 8)) str.each_byte { |chr| append_str(bitstring, ENCODE_TABLE[chr]) } @@ -35,11 +30,9 @@ def encode(str, buffer = "".b) pack([bitstring], "B*", buffer: buffer) end - # Decodes provided Huffman coded string. + # Decodes provided Huffman coded string in +buf+. # - # @param buf [Buffer] - # @return [String] binary string - # @raise [CompressionError] when Huffman coded string is malformed + # raises CompressionError when Huffman coded string is malformed def decode(buf) emit = "".b state = 0 # start state diff --git a/lib/http/2/settings.rb b/lib/http/2/settings.rb index 3f7449f9..13dca924 100644 --- a/lib/http/2/settings.rb +++ b/lib/http/2/settings.rb @@ -13,24 +13,46 @@ module HTTP2 settings_max_header_list_size: MAX_HEADER_LIST_SIZE # unlimited }.freeze + ENCODING_CONTEXT_SETTINGS = %i[huffman index table_size].freeze + Settings = Struct.new( + # connection settings :settings_header_table_size, :settings_enable_push, :settings_max_concurrent_streams, :settings_initial_window_size, :settings_max_frame_size, :settings_max_header_list_size, + # encoding context settings + :huffman, + :index, + :table_size, keyword_init: true ) do def initialize( + # connection settings settings_header_table_size: 4096, settings_enable_push: 1, settings_max_concurrent_streams: 100, settings_initial_window_size: 65_535, settings_max_frame_size: 16_384, - settings_max_header_list_size: MAX_HEADER_LIST_SIZE + settings_max_header_list_size: MAX_HEADER_LIST_SIZE, + # encoding context settings + huffman: :shorter, + index: :all, + table_size: 4096 ) super end + + def connection_settings + return enum_for(__method__) unless block_given? + + each_pair do |k, v| + next if ENCODING_CONTEXT_SETTINGS.include?(k) + + yield k, v + end + end end end diff --git a/sig/header/compressor.rbs b/sig/header/compressor.rbs index 1b6b8eda..f8e94a5c 100644 --- a/sig/header/compressor.rbs +++ b/sig/header/compressor.rbs @@ -18,7 +18,7 @@ module HTTP2 private - def initialize: (?connection_opts options) -> void + def initialize: (?Settings settings) -> void def huffman_string: (String str, ?String buffer) -> String diff --git a/sig/header/decompressor.rbs b/sig/header/decompressor.rbs index 9f0b473a..88e9874e 100644 --- a/sig/header/decompressor.rbs +++ b/sig/header/decompressor.rbs @@ -19,7 +19,7 @@ module HTTP2 | (String) -> Array[header_pair] private - def initialize: (?connection_opts options) -> void + def initialize: (?Settings settings) -> void end end end diff --git a/sig/header/encoding_context.rbs b/sig/header/encoding_context.rbs index 481acc5f..05359dc5 100644 --- a/sig/header/encoding_context.rbs +++ b/sig/header/encoding_context.rbs @@ -11,14 +11,12 @@ module HTTP2 STATIC_NEVER: Array[Symbol] - DEFAULT_OPTIONS: context_hash - UPPER: Regexp attr_reader table: Array[header_pair] - attr_reader options: context_hash + attr_reader settings: Settings attr_reader current_table_size: Integer @@ -46,7 +44,7 @@ module HTTP2 private - def initialize: (?connection_opts options) -> void + def initialize: (?Settings settings) -> void def add_to_table: (string name, string value) -> void diff --git a/sig/settings.rbs b/sig/settings.rbs index 27bee8a6..40d51490 100644 --- a/sig/settings.rbs +++ b/sig/settings.rbs @@ -3,24 +3,69 @@ module HTTP2 SPEC_DEFAULT_CONNECTION_SETTINGS: Hash[Symbol, Integer] + ENCODING_CONTEXT_SETTINGS: Array[Symbol] + + type huffman_type = :always | :never | :shorter + + type index_type = :all | :static | :never + + # Container for all settings required for the management of an HTTP/2 connection. This + # includes the managemenet of the settings negotiated via the SETTINGS frame, as well as + # user-level configuration related with header compression. class Settings # this is actually a Struct, but RBS does not support it yet + # connection settings (send and received in the SETTINGS frame) + + # maximum size of the header compression table used to decode header blocks, in bytes. attr_reader settings_header_table_size: Integer + + # can be used to disable server push (set to 0) attr_reader settings_enable_push: Integer + + # maximum number of concurrent streams that the sender will allow. attr_reader settings_max_concurrent_streams: Integer + + # initial window size (in bytes) for stream-level flow control. attr_reader settings_initial_window_size: Integer + + # max size of for payload that the sender is willing to receive, in bytes. attr_reader settings_max_frame_size: Integer attr_reader settings_max_header_list_size: Integer - def initalize: ( + # encoding context settings + + # controls how to use huffman encoding to encode strings. can be one of the following values: + # + # * `:always` - Always use Huffman encoding + # * `:never` - Do not use Huffman encoding + # * `:shorter` - Use Huffman when the result is strictly shorter + attr_reader huffman: huffman_type + + # controls the configuration of the HPACK indexing tables: + # + # * `:static` - use the static table only + # * `:all` - use the static table and the dynamic table + # * `:never` - do not use any of the tables. + attr_reader index: index_type + + # maximum dynamic table size in bytes + attr_reader table_size: Integer + + def initialize: ( + # connection settings ?settings_header_table_size: Integer, ?settings_enable_push: Integer, ?settings_max_concurrent_streams: Integer, ?settings_initial_window_size: Integer, ?settings_max_frame_size: Integer, - ?settings_max_header_list_size: Integer + ?settings_max_header_list_size: Integer, + # encoding context settings + ?huffman: huffman_type, + ?index: index_type, + ?table_size: Integer ) -> void - def each_pair: () { (Symbol, Integer) -> void } -> self - | () -> Enumerable[[Symbol, Integer]] + # yields connection-level settings only. + def connection_settings: () { (Symbol, Integer) -> void } -> self + | () -> Enumerable[[Symbol, Integer]] end end \ No newline at end of file diff --git a/spec/compressor_spec.rb b/spec/compressor_spec.rb index c2877049..72417b08 100644 --- a/spec/compressor_spec.rb +++ b/spec/compressor_spec.rb @@ -47,7 +47,7 @@ ].each do |datatype, plain| it "should handle #{datatype} #{desc}" do # NOTE: don't put this new in before{} because of test case shuffling - c = Compressor.new(huffman: option) + c = Compressor.new(Settings.new(huffman: option)) str = c.string(plain) expect(str.getbyte(0) & 0x80).to eq msb @@ -62,7 +62,7 @@ ["200", :huffman], ["xq", :plain]].each do |string, choice| it "should return #{choice} representation" do - c = Compressor.new(huffman: :shorter) + c = Compressor.new(Settings.new(huffman: :shorter)) wire = c.string(string) expect(wire.getbyte(0) & 0x80).to eq(choice == :plain ? 0 : 0x80) end @@ -191,7 +191,7 @@ context "size bounds" do it "should drop headers from end of table" do - cc = EncodingContext.new(table_size: 2048) + cc = EncodingContext.new(Settings.new(table_size: 2048)) cc.process(name: "test1", value: "1" * 1024, type: :incremental) cc.process(name: "test2", value: "2" * 500, type: :incremental) @@ -208,7 +208,7 @@ end it "should clear table if entry exceeds table size" do - cc = EncodingContext.new(table_size: 2048) + cc = EncodingContext.new(Settings.new(table_size: 2048)) cc.process(name: "test1", value: "1" * 1024, type: :incremental) cc.process(name: "test2", value: "2" * 500, type: :incremental) @@ -221,7 +221,7 @@ end it "should shrink table if set smaller size" do - cc = EncodingContext.new(table_size: 2048) + cc = EncodingContext.new(Settings.new(table_size: 2048)) cc.listen_on_table do cc.process(name: "test1", value: "1" * 1024, type: :incremental) cc.process(name: "test2", value: "2" * 500, type: :incremental) @@ -233,7 +233,7 @@ end it "should reject table size update if exceed limit" do - cc = EncodingContext.new(table_size: 4096) + cc = EncodingContext.new(Settings.new(table_size: 4096)) expect { cc.process(type: :changetablesize, value: 150_000_000) }.to raise_error(CompressionError) end @@ -553,7 +553,7 @@ context "spec example #{ex[:title]}" do ex[:streams].size.times do |nth| context "request #{nth + 1}" do - let(:dc) { Decompressor.new(table_size: ex[:table_size]) } + let(:dc) { Decompressor.new(Settings.new(table_size: ex[:table_size])) } before do (0...nth).each do |i| bytes = [ex[:streams][i][:wire].delete(" \n")].pack("H*") @@ -601,8 +601,7 @@ ex[:streams].size.times do |nth| context "request #{nth + 1}" do let(:cc) do - Compressor.new(table_size: ex[:table_size], - huffman: ex[:huffman]) + Compressor.new(Settings.new(table_size: ex[:table_size], huffman: ex[:huffman])) end before do (0...nth).each do |i|