File: decompressor.rb

package info (click to toggle)
ruby-protocol-hpack 1.5.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 196 kB
  • sloc: ruby: 887; makefile: 4
file content (209 lines) | stat: -rw-r--r-- 5,778 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2018-2024, by Samuel Williams.
# Copyright, 2024, by Maruth Goyal.
# Copyright, 2024, by Nathan Froyd.

require_relative "context"
require_relative "huffman"

module Protocol
	module HPACK
		# 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.
		class Decompressor
			
			MASK_SHIFT_4 = (~0x0 >> 4) << 4

			def initialize(buffer, context = Context.new, table_size_limit: nil)
				@buffer = buffer
				@context = context
				@offset = 0
				
				@table_size_limit = table_size_limit
			end

			attr :buffer
			attr :context
			attr :offset
			
			attr :table_size_limit
			
			def end?
				@offset >= @buffer.bytesize
			end

			def read_byte
				if byte = @buffer.getbyte(@offset)
					@offset += 1
				end
				
				return byte
			end
			
			def peek_byte
				@buffer.getbyte(@offset)
			end

			def read_bytes(length)
				slice = @buffer.byteslice(@offset, length)
				
				@offset += length
				
				return slice
			end

			# Decodes integer value from provided buffer.
			#
			# @param bits [Integer] number of available bits
			# @return [Integer]
			def read_integer(bits)
				limit = 2**bits - 1
				value = bits.zero? ? 0 : (read_byte & limit)
				
				shift = 0
				
				while byte = read_byte
					value += ((byte & 127) << shift)
					shift += 7
					
					break if (byte & 128).zero?
				end if (value == limit)
				
				return value
			end

			# Decodes string value from provided buffer.
			#
			# @return [String] UTF-8 encoded string
			# @raise [CompressionError] when input is malformed
			def read_string
				huffman = (peek_byte & 0x80) == 0x80
				
				length = read_integer(7)
				
				raise CompressionError, "Invalid string length!" unless length
				
				string = read_bytes(length)
				
				raise CompressionError, "Invalid string length, got #{string.bytesize}, expecting #{length}!" unless string.bytesize == length
				
				string = Huffman.decode(string) if huffman
				
				return string.force_encoding(Encoding::UTF_8)
			end

			# Decodes header command from provided buffer.
			#
			# @param buffer [Buffer]
			# @return [Hash] command
			def read_header
				pattern = peek_byte

				header = {}

				type = nil

				# (pattern & MASK_SHIFT_4) clears bottom 4 bits,
				# equivalent to (pattern >> 4) << 4. For the
				# no-index and never-indexed type we only need to clear
				# the bottom 4 bits (as specified by NO_INDEX_TYPE[:prefix])
				# so we directly check against NO_INDEX_TYPE[:pattern].
				# But for change-table-size, incremental, and indexed
				# we must clear 5,6, and 7 bits respectively.
				# Consider indexed where we need to clear 7 bits.
				# Since (pattern & MASK_SHIFT_4)'s bottom 4 bits are cleared
				# you can visualize it as
				#
				# INDEXED_TYPE[:pattern] = <some bits>      0  0  0  0 0 0 0
				#                                           ^^^^^^^^^^^^^^^^ 7 bits
				# (pattern & MASK_SHIFT_4) = <pattern bits> b1 b2 b3 0 0 0 0
				#
				# Computing equality after masking bottom 7 bits (i.e., set b1 = b2 = b3 = 0)
				# is the same as checking equality against
				#                 <some bits> x1 x2 x3 0 0 0 0
				# For *every* possible value of x1, x2, x3 (that is, 2^3 = 8 values).
				# INDEXED_TYPE[:pattern] = 0x80, so we check against 0x80, 0x90 = 0x80 + (0b001 << 4)
				# 0xa0 = 0x80 + (0b001 << 5), ..., 0xf0 = 0x80 + (0b111 << 4).
				# While not the most readable, we have written out everything as constant literals
				# so Ruby can optimize this case-when to a hash lookup.
				#
				# There's no else case as this list is exhaustive.
				# (0..255).map { |x| (x & -16).to_s(16) }.uniq will show this

				case (pattern & MASK_SHIFT_4)
				when 0x00
					header[:type] = :no_index
					type = NO_INDEX_TYPE
				when 0x10
					header[:type] = :never_indexed
					type = NEVER_INDEXED_TYPE
				# checking if (pattern >> 5) << 5 == 0x20
				# Since we cleared bottom 4 bits, the 5th
				# bit can be either 0 or 1, so check both
				# cases.
				when 0x20, 0x30
					header[:type] = :change_table_size
					type = CHANGE_TABLE_SIZE_TYPE
				# checking if (pattern >> 6) << 6 == 0x40
				# Same logic as above, but now over the 4
				# possible combinations of 2 bits (5th, 6th)
				when 0x40, 0x50, 0x60, 0x70
					header[:type] = :incremental
					type = INCREMENTAL_TYPE
				# checking if (pattern >> 7) << 7 == 0x80
				when 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0
					header[:type] = :indexed
					type = INDEXED_TYPE
				end

				header_name = read_integer(type[:prefix])

				case header[:type]
				when :indexed
					raise CompressionError if header_name.zero?
					header[:name] = header_name - 1
				when :change_table_size
					header[:name] = header_name
					header[:value] = header_name
					
					if @table_size_limit and header[:value] > @table_size_limit
						raise CompressionError, "Table size #{header[:value]} exceeds limit #{@table_size_limit}!"
					end
				else
					if header_name.zero?
						header[:name] = read_string
					else
						header[:name] = header_name - 1
					end
					
					header[:value] = read_string
				end

				return header
			end

			# Decodes and processes header commands within provided buffer.
			#
			# @param buffer [Buffer]
			# @return [Array] +[[name, value], ...]+
			def decode(list = [])
				while !end?
					command = read_header
					
					if pair = @context.decode(command)
						list << pair
					end
				end
				
				if command and command[:type] == :change_table_size
					raise CompressionError, "Trailing table size update!"
				end
				
				return list
			end
		end
	end
end