quicsilver 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +3 -4
- data/CHANGELOG.md +49 -0
- data/Gemfile.lock +8 -4
- data/README.md +7 -6
- data/Rakefile +29 -2
- data/benchmarks/components.rb +191 -0
- data/benchmarks/concurrent.rb +110 -0
- data/benchmarks/helpers.rb +88 -0
- data/benchmarks/quicsilver_server.rb +1 -1
- data/benchmarks/rails.rb +170 -0
- data/benchmarks/throughput.rb +113 -0
- data/ext/quicsilver/quicsilver.c +529 -181
- data/lib/quicsilver/client/client.rb +250 -0
- data/lib/quicsilver/client/request.rb +98 -0
- data/lib/quicsilver/{http3.rb → protocol/frames.rb} +133 -28
- data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +189 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +125 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +47 -0
- data/lib/quicsilver/protocol/request_parser.rb +387 -0
- data/lib/quicsilver/protocol/response_encoder.rb +72 -0
- data/lib/quicsilver/protocol/response_parser.rb +249 -0
- data/lib/quicsilver/server/listener_data.rb +14 -0
- data/lib/quicsilver/server/request_handler.rb +86 -0
- data/lib/quicsilver/server/request_registry.rb +50 -0
- data/lib/quicsilver/server/server.rb +336 -0
- data/lib/quicsilver/transport/configuration.rb +132 -0
- data/lib/quicsilver/transport/connection.rb +350 -0
- data/lib/quicsilver/transport/event_loop.rb +38 -0
- data/lib/quicsilver/transport/inbound_stream.rb +33 -0
- data/lib/quicsilver/transport/stream.rb +28 -0
- data/lib/quicsilver/transport/stream_event.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +31 -13
- data/lib/rackup/handler/quicsilver.rb +1 -2
- data/quicsilver.gemspec +3 -1
- metadata +58 -18
- data/benchmarks/benchmark.rb +0 -68
- data/lib/quicsilver/client.rb +0 -261
- data/lib/quicsilver/connection.rb +0 -42
- data/lib/quicsilver/event_loop.rb +0 -38
- data/lib/quicsilver/http3/request_encoder.rb +0 -133
- data/lib/quicsilver/http3/request_parser.rb +0 -176
- data/lib/quicsilver/http3/response_encoder.rb +0 -186
- data/lib/quicsilver/http3/response_parser.rb +0 -160
- data/lib/quicsilver/listener_data.rb +0 -29
- data/lib/quicsilver/quic_stream.rb +0 -36
- data/lib/quicsilver/request_registry.rb +0 -48
- data/lib/quicsilver/server.rb +0 -355
- data/lib/quicsilver/server_configuration.rb +0 -78
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "qpack/header_block_decoder"
|
|
5
|
+
|
|
6
|
+
module Quicsilver
|
|
7
|
+
module Protocol
|
|
8
|
+
class RequestParser
|
|
9
|
+
attr_reader :headers
|
|
10
|
+
|
|
11
|
+
def frames
|
|
12
|
+
@frames || []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Known HTTP/3 request pseudo-headers (RFC 9114 §4.3.1)
|
|
16
|
+
VALID_PSEUDO_HEADERS = %w[:method :scheme :authority :path :protocol].freeze
|
|
17
|
+
VALID_PSEUDO_SET = VALID_PSEUDO_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
|
|
18
|
+
|
|
19
|
+
# Connection-specific headers forbidden in HTTP/3 (RFC 9114 §4.2)
|
|
20
|
+
FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection te].freeze
|
|
21
|
+
FORBIDDEN_SET = FORBIDDEN_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
|
|
22
|
+
|
|
23
|
+
# Frame types forbidden on request streams — use Set-like hash for O(1) lookup
|
|
24
|
+
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
25
|
+
|
|
26
|
+
# Cache for validated header results: payload → headers hash
|
|
27
|
+
# Only used when no custom limits are set (max_header_count, max_header_size)
|
|
28
|
+
HEADERS_CACHE = {}
|
|
29
|
+
HEADERS_CACHE_MAX = 256
|
|
30
|
+
|
|
31
|
+
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
32
|
+
|
|
33
|
+
def initialize(data, **opts)
|
|
34
|
+
@data = data
|
|
35
|
+
if opts.empty?
|
|
36
|
+
@decoder = DEFAULT_DECODER
|
|
37
|
+
@use_parse_cache = true
|
|
38
|
+
else
|
|
39
|
+
@decoder = opts[:decoder] || DEFAULT_DECODER
|
|
40
|
+
@max_body_size = opts[:max_body_size]
|
|
41
|
+
@max_header_size = opts[:max_header_size]
|
|
42
|
+
@max_header_count = opts[:max_header_count]
|
|
43
|
+
@max_frame_payload_size = opts[:max_frame_payload_size]
|
|
44
|
+
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Reset parser with new data for object reuse (avoids allocation overhead)
|
|
49
|
+
def reset(data)
|
|
50
|
+
@data = data
|
|
51
|
+
@headers = nil
|
|
52
|
+
@frames = nil
|
|
53
|
+
@body = nil
|
|
54
|
+
@cached_body_str = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Combined reset + parse for maximum throughput (single method call)
|
|
58
|
+
# Cache values stored as [headers, frames, body_str] for fast index access
|
|
59
|
+
def reparse(data)
|
|
60
|
+
@data = data
|
|
61
|
+
# Fastest path: same data object as last time — skip all cache lookups
|
|
62
|
+
return if data.equal?(@last_data) && @headers
|
|
63
|
+
|
|
64
|
+
if @use_parse_cache
|
|
65
|
+
oid = data.object_id
|
|
66
|
+
cached = PARSE_OID_CACHE[oid]
|
|
67
|
+
unless cached
|
|
68
|
+
cached = PARSE_CACHE[data]
|
|
69
|
+
PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
|
|
70
|
+
end
|
|
71
|
+
if cached
|
|
72
|
+
@headers = cached[0]
|
|
73
|
+
@frames = cached[1]
|
|
74
|
+
@cached_body_str = cached[2]
|
|
75
|
+
@last_data = data
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
@headers = nil
|
|
80
|
+
@frames = nil
|
|
81
|
+
@body = nil
|
|
82
|
+
@cached_body_str = nil
|
|
83
|
+
parse!
|
|
84
|
+
cache_result if @use_parse_cache
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def body
|
|
88
|
+
if @body
|
|
89
|
+
@body
|
|
90
|
+
elsif @cached_body_str
|
|
91
|
+
@body = StringIO.new(@cached_body_str)
|
|
92
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
93
|
+
@body
|
|
94
|
+
else
|
|
95
|
+
EMPTY_BODY
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
100
|
+
|
|
101
|
+
# Class-level parse result cache
|
|
102
|
+
PARSE_CACHE = {}
|
|
103
|
+
PARSE_CACHE_MAX = 128
|
|
104
|
+
# Object-id fast-path for reparse (integer key = faster hash lookup)
|
|
105
|
+
PARSE_OID_CACHE = {}
|
|
106
|
+
PARSE_OID_CACHE_MAX = 128
|
|
107
|
+
|
|
108
|
+
def parse
|
|
109
|
+
# Fast path: full parse result cache for default decoder with no limits
|
|
110
|
+
if @use_parse_cache
|
|
111
|
+
cached = PARSE_CACHE[@data]
|
|
112
|
+
if cached
|
|
113
|
+
@headers = cached[0]
|
|
114
|
+
@frames = cached[1]
|
|
115
|
+
@cached_body_str = cached[2]
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
parse!
|
|
121
|
+
cache_result if @use_parse_cache
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private def cache_result
|
|
125
|
+
if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
|
|
126
|
+
body_str = if @body
|
|
127
|
+
@body.rewind
|
|
128
|
+
s = @body.read
|
|
129
|
+
@body.rewind
|
|
130
|
+
s
|
|
131
|
+
end
|
|
132
|
+
body_str = nil if body_str&.empty?
|
|
133
|
+
key = @data.frozen? ? @data : @data.dup.freeze
|
|
134
|
+
PARSE_CACHE[key] = [
|
|
135
|
+
@headers.dup.freeze,
|
|
136
|
+
(@frames || []).freeze,
|
|
137
|
+
body_str&.freeze
|
|
138
|
+
].freeze
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Validate pseudo-header semantics per RFC 9114 §4.3.1.
|
|
143
|
+
# Call after parse to check CONNECT rules, required headers, host/:authority consistency.
|
|
144
|
+
def validate_headers!
|
|
145
|
+
return if @headers.empty?
|
|
146
|
+
|
|
147
|
+
method = @headers[":method"]
|
|
148
|
+
|
|
149
|
+
if method == "CONNECT"
|
|
150
|
+
raise Protocol::MessageError, "CONNECT request must include :authority" unless @headers[":authority"]
|
|
151
|
+
raise Protocol::MessageError, "CONNECT request must not include :scheme" if @headers[":scheme"]
|
|
152
|
+
raise Protocol::MessageError, "CONNECT request must not include :path" if @headers[":path"]
|
|
153
|
+
else
|
|
154
|
+
raise Protocol::MessageError, "Request missing required pseudo-header :method" unless method
|
|
155
|
+
raise Protocol::MessageError, "Request missing required pseudo-header :scheme" unless @headers[":scheme"]
|
|
156
|
+
raise Protocol::MessageError, "Request missing required pseudo-header :path" unless @headers[":path"]
|
|
157
|
+
|
|
158
|
+
# RFC 9114 §4.3.1: schemes with mandatory authority (http/https) require :authority or host
|
|
159
|
+
scheme = @headers[":scheme"]
|
|
160
|
+
if %w[http https].include?(scheme) && !@headers[":authority"] && !@headers["host"]
|
|
161
|
+
raise Protocol::MessageError, "Request with #{scheme} scheme must include :authority or host"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Host and :authority consistency (RFC 9114 §4.3.1)
|
|
166
|
+
if @headers[":authority"] && @headers["host"]
|
|
167
|
+
unless @headers[":authority"] == @headers["host"]
|
|
168
|
+
raise Protocol::MessageError, ":authority and host header must be consistent"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Content-length vs body size (RFC 9114 §4.1.2)
|
|
173
|
+
if @headers["content-length"]
|
|
174
|
+
expected = @headers["content-length"].to_i
|
|
175
|
+
actual = body.size
|
|
176
|
+
unless expected == actual
|
|
177
|
+
raise Protocol::MessageError, "Content-length mismatch: header=#{expected}, body=#{actual}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def to_rack_env(stream_info = {})
|
|
183
|
+
return nil if @headers.empty?
|
|
184
|
+
|
|
185
|
+
method = @headers[":method"]
|
|
186
|
+
|
|
187
|
+
if method == "CONNECT"
|
|
188
|
+
return nil unless @headers[":authority"]
|
|
189
|
+
else
|
|
190
|
+
return nil unless method && @headers[":scheme"] && @headers[":path"]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
path_full = @headers[":path"] || ""
|
|
194
|
+
path, query = path_full.split("?", 2)
|
|
195
|
+
|
|
196
|
+
authority = @headers[":authority"] || "localhost:4433"
|
|
197
|
+
host, port = authority.split(":", 2)
|
|
198
|
+
port ||= "4433"
|
|
199
|
+
|
|
200
|
+
env = {
|
|
201
|
+
"REQUEST_METHOD" => method,
|
|
202
|
+
"PATH_INFO" => path || "",
|
|
203
|
+
"QUERY_STRING" => query || "",
|
|
204
|
+
"SERVER_NAME" => host,
|
|
205
|
+
"SERVER_PORT" => port,
|
|
206
|
+
"SERVER_PROTOCOL" => "HTTP/3",
|
|
207
|
+
"rack.version" => [1, 3],
|
|
208
|
+
"rack.url_scheme" => @headers[":scheme"] || "https",
|
|
209
|
+
"rack.input" => body,
|
|
210
|
+
"rack.errors" => $stderr,
|
|
211
|
+
"rack.multithread" => true,
|
|
212
|
+
"rack.multiprocess" => false,
|
|
213
|
+
"rack.run_once" => false,
|
|
214
|
+
"rack.hijack?" => false,
|
|
215
|
+
"SCRIPT_NAME" => "",
|
|
216
|
+
"CONTENT_LENGTH" => body.size.to_s,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if @headers[":authority"]
|
|
220
|
+
env["HTTP_HOST"] = @headers[":authority"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
@headers.each do |name, value|
|
|
224
|
+
next if name.start_with?(":")
|
|
225
|
+
key = name.upcase.tr("-", "_")
|
|
226
|
+
if key == "CONTENT_TYPE"
|
|
227
|
+
env["CONTENT_TYPE"] = value
|
|
228
|
+
elsif key == "CONTENT_LENGTH"
|
|
229
|
+
env["CONTENT_LENGTH"] = value
|
|
230
|
+
else
|
|
231
|
+
env["HTTP_#{key}"] = value
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
env
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def parse!
|
|
241
|
+
@headers = {}
|
|
242
|
+
buffer = @data
|
|
243
|
+
offset = 0
|
|
244
|
+
headers_received = false
|
|
245
|
+
buf_size = buffer.bytesize
|
|
246
|
+
|
|
247
|
+
while offset < buf_size
|
|
248
|
+
break if buf_size - offset < 2
|
|
249
|
+
|
|
250
|
+
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
251
|
+
type_byte = buffer.getbyte(offset)
|
|
252
|
+
if type_byte < 0x40
|
|
253
|
+
type = type_byte
|
|
254
|
+
type_len = 1
|
|
255
|
+
else
|
|
256
|
+
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
len_byte = buffer.getbyte(offset + type_len)
|
|
260
|
+
if len_byte < 0x40
|
|
261
|
+
length = len_byte
|
|
262
|
+
length_len = 1
|
|
263
|
+
else
|
|
264
|
+
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
265
|
+
break if length_len == 0
|
|
266
|
+
end
|
|
267
|
+
break if type_len == 0
|
|
268
|
+
|
|
269
|
+
header_len = type_len + length_len
|
|
270
|
+
|
|
271
|
+
break if buf_size < offset + header_len + length
|
|
272
|
+
|
|
273
|
+
payload = buffer.byteslice(offset + header_len, length)
|
|
274
|
+
|
|
275
|
+
if @max_frame_payload_size && length > @max_frame_payload_size
|
|
276
|
+
raise Protocol::FrameError, "Frame payload #{length} exceeds limit #{@max_frame_payload_size}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
(@frames ||= []) << { type: type, length: length, payload: payload }
|
|
280
|
+
|
|
281
|
+
if CONTROL_ONLY_SET.key?(type)
|
|
282
|
+
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
case type
|
|
286
|
+
when 0x01 # HEADERS
|
|
287
|
+
if @max_header_size && length > @max_header_size
|
|
288
|
+
raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
|
|
289
|
+
end
|
|
290
|
+
parse_headers(payload)
|
|
291
|
+
headers_received = true
|
|
292
|
+
when 0x00 # DATA
|
|
293
|
+
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
294
|
+
unless @body
|
|
295
|
+
@body = StringIO.new
|
|
296
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
297
|
+
end
|
|
298
|
+
@body.write(payload)
|
|
299
|
+
if @max_body_size && @body.size > @max_body_size
|
|
300
|
+
raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
offset += header_len + length
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
@body&.rewind
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
|
|
311
|
+
# - Header names MUST be lowercase
|
|
312
|
+
# - Pseudo-headers MUST appear before regular headers
|
|
313
|
+
# - Duplicate pseudo-headers are malformed
|
|
314
|
+
# - Unknown pseudo-headers are malformed
|
|
315
|
+
#
|
|
316
|
+
# QPACK decoding is delegated to @decoder (injectable).
|
|
317
|
+
def parse_headers(payload)
|
|
318
|
+
# Fast path: check validated headers cache (only when no custom limits and default decoder)
|
|
319
|
+
use_cache = !@max_header_count && @decoder.equal?(DEFAULT_DECODER)
|
|
320
|
+
if use_cache
|
|
321
|
+
cached = HEADERS_CACHE[payload]
|
|
322
|
+
if cached
|
|
323
|
+
@headers.merge!(cached)
|
|
324
|
+
return
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
pseudo_done = false
|
|
329
|
+
|
|
330
|
+
@decoder.decode(payload) do |name, value|
|
|
331
|
+
# RFC 9114 §4.2: Header field names MUST be lowercase
|
|
332
|
+
if name =~ /[A-Z]/
|
|
333
|
+
raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
if name.getbyte(0) == 58 # ':'
|
|
337
|
+
raise Protocol::MessageError, "Pseudo-header '#{name}' after regular header" if pseudo_done
|
|
338
|
+
|
|
339
|
+
unless VALID_PSEUDO_SET.key?(name)
|
|
340
|
+
raise Protocol::MessageError, "Unknown pseudo-header '#{name}'"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if @headers.key?(name)
|
|
344
|
+
raise Protocol::MessageError, "Duplicate pseudo-header '#{name}'"
|
|
345
|
+
end
|
|
346
|
+
else
|
|
347
|
+
pseudo_done = true
|
|
348
|
+
|
|
349
|
+
# RFC 9114 §4.2: Connection-specific headers are malformed in HTTP/3
|
|
350
|
+
# Exception: "te: trailers" is permitted (RFC 9114 §4.2)
|
|
351
|
+
if FORBIDDEN_SET.key?(name)
|
|
352
|
+
raise Protocol::MessageError, "Connection-specific header '#{name}' forbidden in HTTP/3" unless name == "te" && value == "trailers"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
store_header(name, value)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Cache the validated result
|
|
360
|
+
if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
|
|
361
|
+
key = payload.frozen? ? payload : payload.dup.freeze
|
|
362
|
+
HEADERS_CACHE[key] = @headers.dup.freeze
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# RFC 9110 §5.3: Combine duplicate header values.
|
|
367
|
+
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
368
|
+
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
369
|
+
# - all others: join with ", "
|
|
370
|
+
def store_header(name, value)
|
|
371
|
+
if @headers.key?(name)
|
|
372
|
+
separator = case name
|
|
373
|
+
when "set-cookie" then "\n"
|
|
374
|
+
when "cookie" then "; "
|
|
375
|
+
else ", "
|
|
376
|
+
end
|
|
377
|
+
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
378
|
+
else
|
|
379
|
+
if @max_header_count && @headers.size >= @max_header_count
|
|
380
|
+
raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
|
|
381
|
+
end
|
|
382
|
+
@headers[name] = value
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
class ResponseEncoder
|
|
6
|
+
def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false)
|
|
7
|
+
@status = status
|
|
8
|
+
@headers = headers
|
|
9
|
+
@body = body
|
|
10
|
+
@encoder = encoder
|
|
11
|
+
@head_request = head_request
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Buffered encode - returns all frames at once
|
|
15
|
+
def encode
|
|
16
|
+
frames = "".b
|
|
17
|
+
frames << build_frame(FRAME_HEADERS, @encoder.encode(all_headers))
|
|
18
|
+
unless @head_request
|
|
19
|
+
@body.each do |chunk|
|
|
20
|
+
frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
@body.close if @body.respond_to?(:close)
|
|
24
|
+
frames
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Streaming encode - yields frames as they're ready
|
|
28
|
+
def stream_encode
|
|
29
|
+
yield build_frame(FRAME_HEADERS, @encoder.encode(all_headers)), false
|
|
30
|
+
|
|
31
|
+
unless @head_request
|
|
32
|
+
last_chunk = nil
|
|
33
|
+
@body.each do |chunk|
|
|
34
|
+
yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
|
|
35
|
+
last_chunk = chunk
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if last_chunk && !last_chunk.empty?
|
|
39
|
+
yield build_frame(FRAME_DATA, last_chunk), true
|
|
40
|
+
else
|
|
41
|
+
yield "".b, true
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
yield "".b, true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@body.close if @body.respond_to?(:close)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# RFC 9114 §4.2: connection-specific header fields must not appear in HTTP/3
|
|
53
|
+
FORBIDDEN_HEADERS = %w[transfer-encoding connection keep-alive upgrade te proxy-connection].freeze
|
|
54
|
+
|
|
55
|
+
def all_headers
|
|
56
|
+
headers = [[":status", @status.to_s]]
|
|
57
|
+
@headers.each do |name, value|
|
|
58
|
+
downcased = name.to_s.downcase
|
|
59
|
+
next if downcased.start_with?("rack.")
|
|
60
|
+
next if FORBIDDEN_HEADERS.include?(downcased)
|
|
61
|
+
headers << [downcased, value.to_s]
|
|
62
|
+
end
|
|
63
|
+
headers
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_frame(type, payload)
|
|
67
|
+
payload = payload.to_s.b
|
|
68
|
+
Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "qpack/header_block_decoder"
|
|
5
|
+
|
|
6
|
+
module Quicsilver
|
|
7
|
+
module Protocol
|
|
8
|
+
class ResponseParser
|
|
9
|
+
attr_reader :headers, :status
|
|
10
|
+
|
|
11
|
+
def frames
|
|
12
|
+
@frames || []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
16
|
+
|
|
17
|
+
def initialize(data, **opts)
|
|
18
|
+
@data = data
|
|
19
|
+
if opts.empty?
|
|
20
|
+
@decoder = DEFAULT_DECODER
|
|
21
|
+
@use_parse_cache = true
|
|
22
|
+
else
|
|
23
|
+
@decoder = opts[:decoder] || DEFAULT_DECODER
|
|
24
|
+
@max_body_size = opts[:max_body_size]
|
|
25
|
+
@max_header_size = opts[:max_header_size]
|
|
26
|
+
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Reset parser with new data for object reuse (avoids allocation overhead)
|
|
31
|
+
def reset(data)
|
|
32
|
+
@data = data
|
|
33
|
+
@status = nil
|
|
34
|
+
@headers = nil
|
|
35
|
+
@frames = nil
|
|
36
|
+
@body_io = nil
|
|
37
|
+
@cached_body_str = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Combined reset + parse for maximum throughput (single method call)
|
|
41
|
+
def reparse(data)
|
|
42
|
+
@data = data
|
|
43
|
+
# Fastest path: same data object as last time — skip all cache lookups
|
|
44
|
+
return if data.equal?(@last_data) && @headers
|
|
45
|
+
|
|
46
|
+
if @use_parse_cache
|
|
47
|
+
oid = data.object_id
|
|
48
|
+
cached = PARSE_OID_CACHE[oid]
|
|
49
|
+
unless cached
|
|
50
|
+
cached = PARSE_CACHE[data]
|
|
51
|
+
PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
|
|
52
|
+
end
|
|
53
|
+
if cached
|
|
54
|
+
@status = cached[0]
|
|
55
|
+
@headers = cached[1]
|
|
56
|
+
@frames = cached[2]
|
|
57
|
+
@cached_body_str = cached[3]
|
|
58
|
+
@last_data = data
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
@status = nil
|
|
63
|
+
@headers = nil
|
|
64
|
+
@frames = nil
|
|
65
|
+
@body_io = nil
|
|
66
|
+
@cached_body_str = nil
|
|
67
|
+
parse!
|
|
68
|
+
cache_result if @use_parse_cache
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def body
|
|
72
|
+
if @body_io
|
|
73
|
+
@body_io.rewind
|
|
74
|
+
@body_io
|
|
75
|
+
elsif @cached_body_str
|
|
76
|
+
@body_io = StringIO.new(@cached_body_str)
|
|
77
|
+
@body_io.set_encoding(Encoding::ASCII_8BIT)
|
|
78
|
+
@body_io
|
|
79
|
+
else
|
|
80
|
+
EMPTY_BODY
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
85
|
+
|
|
86
|
+
# Class-level parse result cache: data → [status, headers, frames, body_str]
|
|
87
|
+
PARSE_CACHE = {}
|
|
88
|
+
PARSE_CACHE_MAX = 128
|
|
89
|
+
PARSE_OID_CACHE = {}
|
|
90
|
+
PARSE_OID_CACHE_MAX = 128
|
|
91
|
+
|
|
92
|
+
def parse
|
|
93
|
+
# Fast path: full parse result cache for default decoder with no limits
|
|
94
|
+
if @use_parse_cache
|
|
95
|
+
cached = PARSE_CACHE[@data]
|
|
96
|
+
if cached
|
|
97
|
+
@status = cached[0]
|
|
98
|
+
@headers = cached[1]
|
|
99
|
+
@frames = cached[2]
|
|
100
|
+
@cached_body_str = cached[3]
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
parse!
|
|
106
|
+
cache_result if @use_parse_cache
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private def cache_result
|
|
110
|
+
if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
|
|
111
|
+
body_str = if @body_io
|
|
112
|
+
@body_io.rewind
|
|
113
|
+
s = @body_io.read
|
|
114
|
+
@body_io.rewind
|
|
115
|
+
s
|
|
116
|
+
end
|
|
117
|
+
key = @data.frozen? ? @data : @data.dup.freeze
|
|
118
|
+
PARSE_CACHE[key] = [
|
|
119
|
+
@status,
|
|
120
|
+
@headers.dup.freeze,
|
|
121
|
+
(@frames || []).freeze,
|
|
122
|
+
body_str&.freeze
|
|
123
|
+
].freeze
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Frame types forbidden on request streams — O(1) lookup
|
|
130
|
+
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
131
|
+
|
|
132
|
+
def parse!
|
|
133
|
+
@headers = {}
|
|
134
|
+
@status = nil
|
|
135
|
+
@body_io = nil
|
|
136
|
+
@frames = nil
|
|
137
|
+
buffer = @data
|
|
138
|
+
offset = 0
|
|
139
|
+
headers_received = false
|
|
140
|
+
buf_size = buffer.bytesize
|
|
141
|
+
|
|
142
|
+
while offset < buf_size
|
|
143
|
+
break if buf_size - offset < 2
|
|
144
|
+
|
|
145
|
+
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
146
|
+
type_byte = buffer.getbyte(offset)
|
|
147
|
+
if type_byte < 0x40
|
|
148
|
+
type = type_byte
|
|
149
|
+
type_len = 1
|
|
150
|
+
else
|
|
151
|
+
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
len_byte = buffer.getbyte(offset + type_len)
|
|
155
|
+
if len_byte < 0x40
|
|
156
|
+
length = len_byte
|
|
157
|
+
length_len = 1
|
|
158
|
+
else
|
|
159
|
+
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
160
|
+
break if length_len == 0
|
|
161
|
+
end
|
|
162
|
+
break if type_len == 0
|
|
163
|
+
|
|
164
|
+
header_len = type_len + length_len
|
|
165
|
+
|
|
166
|
+
break if buf_size < offset + header_len + length
|
|
167
|
+
|
|
168
|
+
payload = buffer.byteslice(offset + header_len, length)
|
|
169
|
+
(@frames ||= []) << { type: type, length: length, payload: payload }
|
|
170
|
+
|
|
171
|
+
if CONTROL_ONLY_SET.key?(type)
|
|
172
|
+
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
case type
|
|
176
|
+
when 0x01 # HEADERS
|
|
177
|
+
if @max_header_size && length > @max_header_size
|
|
178
|
+
raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
|
|
179
|
+
end
|
|
180
|
+
parse_headers(payload)
|
|
181
|
+
headers_received = true
|
|
182
|
+
when 0x00 # DATA
|
|
183
|
+
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
184
|
+
unless @body_io
|
|
185
|
+
@body_io = StringIO.new
|
|
186
|
+
@body_io.set_encoding(Encoding::ASCII_8BIT)
|
|
187
|
+
end
|
|
188
|
+
@body_io.write(payload)
|
|
189
|
+
if @max_body_size && @body_io.size > @max_body_size
|
|
190
|
+
raise Protocol::MessageError, "Body size #{@body_io.size} exceeds limit #{@max_body_size}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
offset += header_len + length
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Cache for validated response header results
|
|
199
|
+
HEADERS_CACHE = {}
|
|
200
|
+
HEADERS_CACHE_MAX = 256
|
|
201
|
+
|
|
202
|
+
# Decode QPACK header block via injectable @decoder.
|
|
203
|
+
# Extracts :status pseudo-header into @status.
|
|
204
|
+
def parse_headers(payload)
|
|
205
|
+
use_cache = @decoder.equal?(DEFAULT_DECODER)
|
|
206
|
+
|
|
207
|
+
if use_cache
|
|
208
|
+
cached = HEADERS_CACHE[payload]
|
|
209
|
+
if cached
|
|
210
|
+
@status = cached[:status]
|
|
211
|
+
@headers.merge!(cached[:headers])
|
|
212
|
+
return
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
@decoder.decode(payload) do |name, value|
|
|
217
|
+
if name == ":status"
|
|
218
|
+
@status = value.to_i
|
|
219
|
+
else
|
|
220
|
+
store_header(name, value)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Cache the result
|
|
225
|
+
if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
|
|
226
|
+
key = payload.frozen? ? payload : payload.dup.freeze
|
|
227
|
+
HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# RFC 9110 §5.3: Combine duplicate header values.
|
|
232
|
+
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
233
|
+
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
234
|
+
# - all others: join with ", "
|
|
235
|
+
def store_header(name, value)
|
|
236
|
+
if @headers.key?(name)
|
|
237
|
+
separator = case name
|
|
238
|
+
when "set-cookie" then "\n"
|
|
239
|
+
when "cookie" then "; "
|
|
240
|
+
else ", "
|
|
241
|
+
end
|
|
242
|
+
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
243
|
+
else
|
|
244
|
+
@headers[name] = value
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|