quicsilver 0.3.0 → 0.4.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 +1 -1
- data/.github/workflows/cibuildgem.yaml +93 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +32 -0
- data/Gemfile.lock +20 -2
- data/README.md +92 -29
- data/Rakefile +67 -2
- data/benchmarks/concurrent.rb +2 -2
- data/benchmarks/rails.rb +3 -3
- data/benchmarks/throughput.rb +2 -2
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +111 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +18 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +260 -0
- data/examples/simple_client_test.rb +2 -2
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/ext/quicsilver/extconf.rb +14 -0
- data/ext/quicsilver/quicsilver.c +39 -0
- data/lib/quicsilver/client/client.rb +138 -39
- data/lib/quicsilver/client/connection_pool.rb +106 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +176 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
- data/lib/quicsilver/protocol/frame_parser.rb +142 -0
- data/lib/quicsilver/protocol/frame_reader.rb +55 -0
- data/lib/quicsilver/protocol/frames.rb +18 -7
- data/lib/quicsilver/protocol/priority.rb +56 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- data/lib/quicsilver/protocol/request_parser.rb +28 -140
- data/lib/quicsilver/protocol/response_encoder.rb +27 -2
- data/lib/quicsilver/protocol/response_parser.rb +22 -130
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/quicsilver.bundle +0 -0
- data/lib/quicsilver/server/request_handler.rb +96 -44
- data/lib/quicsilver/server/server.rb +316 -42
- data/lib/quicsilver/transport/configuration.rb +10 -1
- data/lib/quicsilver/transport/connection.rb +92 -63
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +26 -3
- data/quicsilver.gemspec +10 -2
- metadata +69 -5
- data/examples/setup_certs.sh +0 -57
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "frame_reader"
|
|
5
|
+
require_relative "qpack/header_block_decoder"
|
|
6
|
+
|
|
7
|
+
module Quicsilver
|
|
8
|
+
module Protocol
|
|
9
|
+
# Base class for HTTP/3 request and response frame parsing.
|
|
10
|
+
#
|
|
11
|
+
# Handles the shared frame walking loop, HEADERS→DATA→HEADERS ordering,
|
|
12
|
+
# trailer parsing, body accumulation, and size limit enforcement.
|
|
13
|
+
#
|
|
14
|
+
# Subclasses implement:
|
|
15
|
+
# - parse_headers(payload) — decode the first HEADERS frame
|
|
16
|
+
class FrameParser
|
|
17
|
+
# Frame types forbidden on request streams — use hash for O(1) lookup
|
|
18
|
+
# Static-only QPACK decoder (no dynamic table). Used by default.
|
|
19
|
+
# Inject a custom decoder via decoder: kwarg for dynamic QPACK support.
|
|
20
|
+
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
21
|
+
|
|
22
|
+
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
23
|
+
|
|
24
|
+
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
25
|
+
|
|
26
|
+
attr_reader :headers, :trailers, :bytes_consumed
|
|
27
|
+
|
|
28
|
+
def frames
|
|
29
|
+
@frames || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(decoder:, max_body_size: nil, max_header_size: nil, max_header_count: nil, max_frame_payload_size: nil)
|
|
33
|
+
@decoder = decoder
|
|
34
|
+
@max_body_size = max_body_size
|
|
35
|
+
@max_header_size = max_header_size
|
|
36
|
+
@max_header_count = max_header_count
|
|
37
|
+
@max_frame_payload_size = max_frame_payload_size
|
|
38
|
+
@headers = {}
|
|
39
|
+
@trailers = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def body
|
|
43
|
+
if @body
|
|
44
|
+
@body.rewind
|
|
45
|
+
@body
|
|
46
|
+
elsif @cached_body_str
|
|
47
|
+
@body = StringIO.new(@cached_body_str)
|
|
48
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
49
|
+
@body
|
|
50
|
+
else
|
|
51
|
+
EMPTY_BODY
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parse!
|
|
58
|
+
walk_frames(@data)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def walk_frames(buffer)
|
|
62
|
+
@headers = {}
|
|
63
|
+
@trailers = {}
|
|
64
|
+
@body = nil
|
|
65
|
+
@frames = nil
|
|
66
|
+
@bytes_consumed = 0
|
|
67
|
+
headers_received = false
|
|
68
|
+
trailers_received = false
|
|
69
|
+
|
|
70
|
+
@bytes_consumed = FrameReader.each(buffer) do |type, payload|
|
|
71
|
+
if @max_frame_payload_size && payload.bytesize > @max_frame_payload_size
|
|
72
|
+
raise Protocol::FrameError, "Frame payload #{payload.bytesize} exceeds limit #{@max_frame_payload_size}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
(@frames ||= []) << { type: type, length: payload.bytesize, payload: payload }
|
|
76
|
+
|
|
77
|
+
if CONTROL_ONLY_SET.key?(type)
|
|
78
|
+
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
case type
|
|
82
|
+
when 0x01 # HEADERS
|
|
83
|
+
if @max_header_size && payload.bytesize > @max_header_size
|
|
84
|
+
raise Protocol::MessageError, "Header block #{payload.bytesize} exceeds limit #{@max_header_size}"
|
|
85
|
+
end
|
|
86
|
+
if trailers_received
|
|
87
|
+
raise Protocol::FrameError, "HEADERS frame after trailers"
|
|
88
|
+
elsif headers_received
|
|
89
|
+
parse_trailers(payload)
|
|
90
|
+
trailers_received = true
|
|
91
|
+
else
|
|
92
|
+
parse_headers(payload)
|
|
93
|
+
headers_received = true
|
|
94
|
+
end
|
|
95
|
+
when 0x00 # DATA
|
|
96
|
+
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
97
|
+
raise Protocol::FrameError, "DATA frame after trailers" if trailers_received
|
|
98
|
+
unless @body
|
|
99
|
+
@body = StringIO.new
|
|
100
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
101
|
+
end
|
|
102
|
+
@body.write(payload)
|
|
103
|
+
if @max_body_size && @body.size > @max_body_size
|
|
104
|
+
raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@body&.rewind
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# RFC 9110 §5.3: Combine duplicate header values.
|
|
113
|
+
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
114
|
+
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
115
|
+
# - all others: join with ", "
|
|
116
|
+
def store_header(name, value)
|
|
117
|
+
if @headers.key?(name)
|
|
118
|
+
separator = case name
|
|
119
|
+
when "set-cookie" then "\n"
|
|
120
|
+
when "cookie" then "; "
|
|
121
|
+
else ", "
|
|
122
|
+
end
|
|
123
|
+
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
124
|
+
else
|
|
125
|
+
if @max_header_count && @headers.size >= @max_header_count
|
|
126
|
+
raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
|
|
127
|
+
end
|
|
128
|
+
@headers[name] = value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_trailers(payload)
|
|
133
|
+
@decoder.decode(payload) do |name, value|
|
|
134
|
+
if name.start_with?(":")
|
|
135
|
+
raise Protocol::MessageError, "Pseudo-header '#{name}' in trailers"
|
|
136
|
+
end
|
|
137
|
+
@trailers[name] = value
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# Extracts HTTP/3 frames from a byte buffer.
|
|
6
|
+
# Yields [type, payload] pairs. No protocol semantics — just byte-level extraction.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# FrameReader.each(buffer) do |type, payload|
|
|
10
|
+
# case type
|
|
11
|
+
# when FRAME_HEADERS then ...
|
|
12
|
+
# when FRAME_DATA then ...
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
module FrameReader
|
|
16
|
+
def self.each(buffer)
|
|
17
|
+
offset = 0
|
|
18
|
+
buf_size = buffer.bytesize
|
|
19
|
+
|
|
20
|
+
while offset < buf_size
|
|
21
|
+
break if buf_size - offset < 2
|
|
22
|
+
|
|
23
|
+
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
24
|
+
type_byte = buffer.getbyte(offset)
|
|
25
|
+
if type_byte < 0x40
|
|
26
|
+
type = type_byte
|
|
27
|
+
type_len = 1
|
|
28
|
+
else
|
|
29
|
+
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
30
|
+
break if type_len == 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
len_byte = buffer.getbyte(offset + type_len)
|
|
34
|
+
if len_byte < 0x40
|
|
35
|
+
length = len_byte
|
|
36
|
+
length_len = 1
|
|
37
|
+
else
|
|
38
|
+
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
39
|
+
break if length_len == 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
header_len = type_len + length_len
|
|
43
|
+
break if buf_size < offset + header_len + length
|
|
44
|
+
|
|
45
|
+
payload = buffer.byteslice(offset + header_len, length)
|
|
46
|
+
offset += header_len + length
|
|
47
|
+
|
|
48
|
+
yield type, payload
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
offset
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -10,6 +10,7 @@ module Quicsilver
|
|
|
10
10
|
FRAME_PUSH_PROMISE = 0x05
|
|
11
11
|
FRAME_GOAWAY = 0x07
|
|
12
12
|
FRAME_MAX_PUSH_ID = 0x0d
|
|
13
|
+
FRAME_PRIORITY_UPDATE = 0xF0700 # RFC 9218 — request stream priority update
|
|
13
14
|
|
|
14
15
|
# Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
|
|
15
16
|
CONTROL_ONLY_FRAMES = [FRAME_CANCEL_PUSH, FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID].freeze
|
|
@@ -230,15 +231,25 @@ module Quicsilver
|
|
|
230
231
|
frame_type + frame_length + payload
|
|
231
232
|
end
|
|
232
233
|
|
|
234
|
+
# Generate a random GREASE ID (RFC 9297): 31 * n + 33
|
|
235
|
+
def grease_id
|
|
236
|
+
31 * rand(0..20) + 33
|
|
237
|
+
end
|
|
238
|
+
|
|
233
239
|
# Build control stream data
|
|
234
|
-
|
|
240
|
+
# @param max_field_section_size [Integer, nil] Advertise SETTINGS_MAX_FIELD_SECTION_SIZE (0x06)
|
|
241
|
+
# to the peer (RFC 9114 §4.2.2 / §7.2.4.1). nil = don't advertise.
|
|
242
|
+
def build_control_stream(max_field_section_size: nil)
|
|
235
243
|
stream_type = [0x00].pack('C') # Control stream type
|
|
236
|
-
|
|
237
|
-
0x01 => 0,
|
|
238
|
-
0x07 => 0
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
244
|
+
settings_hash = {
|
|
245
|
+
0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
|
|
246
|
+
0x07 => 0, # QPACK_BLOCKED_STREAMS = 0
|
|
247
|
+
0x08 => 1, # SETTINGS_ENABLE_CONNECT_PROTOCOL (RFC 9220)
|
|
248
|
+
}
|
|
249
|
+
settings_hash[0x06] = max_field_section_size if max_field_section_size # SETTINGS_MAX_FIELD_SECTION_SIZE
|
|
250
|
+
settings_hash[grease_id] = grease_id # GREASE setting (RFC 9297)
|
|
251
|
+
|
|
252
|
+
stream_type + build_settings_frame(settings_hash)
|
|
242
253
|
end
|
|
243
254
|
|
|
244
255
|
# Build GOAWAY frame (RFC 9114 Section 7.2.6)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# HTTP Extensible Priorities (RFC 9218).
|
|
6
|
+
#
|
|
7
|
+
# Parses the `priority` header from HTTP requests. Urgency ranges from
|
|
8
|
+
# 0 (highest) to 7 (lowest), defaulting to 3. Incremental indicates
|
|
9
|
+
# whether partial data is useful to the client (e.g. progressive images).
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# priority = Priority.parse("u=0, i")
|
|
13
|
+
# priority.urgency # => 0
|
|
14
|
+
# priority.incremental # => true
|
|
15
|
+
#
|
|
16
|
+
# priority = Priority.parse(nil) # default
|
|
17
|
+
# priority.urgency # => 3
|
|
18
|
+
# priority.incremental # => false
|
|
19
|
+
class Priority
|
|
20
|
+
DEFAULT_URGENCY = 3
|
|
21
|
+
MIN_URGENCY = 0
|
|
22
|
+
MAX_URGENCY = 7
|
|
23
|
+
|
|
24
|
+
attr_reader :urgency, :incremental
|
|
25
|
+
|
|
26
|
+
def initialize(urgency: DEFAULT_URGENCY, incremental: false)
|
|
27
|
+
@urgency = urgency.clamp(MIN_URGENCY, MAX_URGENCY)
|
|
28
|
+
@incremental = incremental
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parse a priority header value (RFC 9218 §4, Structured Field Values).
|
|
32
|
+
# Returns a Priority with defaults for missing or invalid values.
|
|
33
|
+
def self.parse(value)
|
|
34
|
+
return new unless value && !value.empty?
|
|
35
|
+
|
|
36
|
+
urgency = DEFAULT_URGENCY
|
|
37
|
+
incremental = false
|
|
38
|
+
|
|
39
|
+
value.split(",").each do |param|
|
|
40
|
+
param = param.strip
|
|
41
|
+
if param.start_with?("u=")
|
|
42
|
+
urgency = param[2..].to_i
|
|
43
|
+
elsif param == "i"
|
|
44
|
+
incremental = true
|
|
45
|
+
elsif param == "i=?0"
|
|
46
|
+
incremental = false
|
|
47
|
+
elsif param == "i=?1"
|
|
48
|
+
incremental = true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
new(urgency: urgency, incremental: incremental)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -71,7 +71,7 @@ module Quicsilver
|
|
|
71
71
|
name = name.to_s
|
|
72
72
|
value = value.to_s
|
|
73
73
|
# Downcase only if needed (most HTTP/3 headers are already lowercase)
|
|
74
|
-
name = name.downcase if name
|
|
74
|
+
name = name.downcase if name.match?(/[A-Z]/)
|
|
75
75
|
|
|
76
76
|
cache_key = "#{name}\0#{value}"
|
|
77
77
|
|
|
@@ -166,8 +166,46 @@ module Quicsilver
|
|
|
166
166
|
out << value_b
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
+
# Pre-computed prefix integer tables for hot encode paths.
|
|
170
|
+
# Key: [prefix_bits, pattern] => Array of frozen encoded strings indexed by value.
|
|
171
|
+
# Covers all values up to max_prefix (single-byte) and a range beyond.
|
|
172
|
+
PREFIXED_INT_CACHE = {}
|
|
173
|
+
[
|
|
174
|
+
[6, 0xC0], # Indexed Field Line
|
|
175
|
+
[4, 0x50], # Literal with Name Reference (static)
|
|
176
|
+
[7, 0x80], # Huffman string length
|
|
177
|
+
[7, 0x00], # Raw string length
|
|
178
|
+
[3, 0x28], # Huffman literal name length
|
|
179
|
+
[3, 0x20], # Raw literal name length
|
|
180
|
+
].each do |prefix_bits, pattern|
|
|
181
|
+
max_prefix = (1 << prefix_bits) - 1
|
|
182
|
+
# Pre-compute single-byte range + a bit beyond for multi-byte
|
|
183
|
+
limit = [max_prefix + 64, 256].min
|
|
184
|
+
table = Array.new(limit) do |value|
|
|
185
|
+
if value < max_prefix
|
|
186
|
+
(pattern | value).chr(Encoding::BINARY).freeze
|
|
187
|
+
else
|
|
188
|
+
buf = (pattern | max_prefix).chr(Encoding::BINARY)
|
|
189
|
+
v = value - max_prefix
|
|
190
|
+
while v >= 128
|
|
191
|
+
buf << ((v & 0x7F) | 0x80).chr(Encoding::BINARY)
|
|
192
|
+
v >>= 7
|
|
193
|
+
end
|
|
194
|
+
buf << v.chr(Encoding::BINARY)
|
|
195
|
+
buf.freeze
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
PREFIXED_INT_CACHE[[prefix_bits, pattern]] = table.freeze
|
|
199
|
+
end
|
|
200
|
+
PREFIXED_INT_CACHE.freeze
|
|
201
|
+
|
|
169
202
|
# RFC 7541 prefix integer encoding
|
|
170
203
|
def encode_prefixed_int(value, prefix_bits, pattern)
|
|
204
|
+
table = PREFIXED_INT_CACHE[[prefix_bits, pattern]]
|
|
205
|
+
if table && value < table.size
|
|
206
|
+
return table[value]
|
|
207
|
+
end
|
|
208
|
+
|
|
171
209
|
max_prefix = (1 << prefix_bits) - 1
|
|
172
210
|
|
|
173
211
|
if value < max_prefix
|
|
@@ -51,7 +51,22 @@ module Quicsilver
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
headers = []
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
# Decode Required Insert Count (RFC 9204 §4.5.1) — 8-bit prefix integer
|
|
56
|
+
required_insert_count, ric_bytes = decode_prefix_integer_str(payload, 0, 8, 0x00)
|
|
57
|
+
offset = ric_bytes
|
|
58
|
+
|
|
59
|
+
# Decode Delta Base (RFC 9204 §4.5.1) — 7-bit prefix integer with sign bit
|
|
60
|
+
delta_base, db_bytes = decode_prefix_integer_str(payload, offset, 7, 0x80)
|
|
61
|
+
offset += db_bytes
|
|
62
|
+
|
|
63
|
+
# Static-only mode: Required Insert Count and Delta Base must be 0
|
|
64
|
+
if required_insert_count != 0 || delta_base != 0
|
|
65
|
+
raise Protocol::FrameError.new(
|
|
66
|
+
"Dynamic QPACK table not supported (required_insert_count=#{required_insert_count}, delta_base=#{delta_base})",
|
|
67
|
+
error_code: Protocol::QPACK_DECOMPRESSION_FAILED
|
|
68
|
+
)
|
|
69
|
+
end
|
|
55
70
|
|
|
56
71
|
while offset < payload.bytesize
|
|
57
72
|
byte = payload.getbyte(offset)
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "
|
|
3
|
+
require_relative "frame_parser"
|
|
4
|
+
require_relative "priority"
|
|
5
5
|
|
|
6
6
|
module Quicsilver
|
|
7
7
|
module Protocol
|
|
8
|
-
class RequestParser
|
|
9
|
-
attr_reader :headers
|
|
8
|
+
class RequestParser < FrameParser
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# The parsed priority from the `priority` header (RFC 9218).
|
|
11
|
+
# Returns a Priority with defaults if no header present.
|
|
12
|
+
def priority
|
|
13
|
+
@priority ||= Priority.parse(@headers["priority"])
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
METHOD_CONNECT = "CONNECT"
|
|
17
|
+
|
|
15
18
|
# Known HTTP/3 request pseudo-headers (RFC 9114 §4.3.1)
|
|
16
19
|
VALID_PSEUDO_HEADERS = %w[:method :scheme :authority :path :protocol].freeze
|
|
17
20
|
VALID_PSEUDO_SET = VALID_PSEUDO_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
|
|
@@ -20,35 +23,26 @@ module Quicsilver
|
|
|
20
23
|
FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection te].freeze
|
|
21
24
|
FORBIDDEN_SET = FORBIDDEN_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
|
|
22
25
|
|
|
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
26
|
# Cache for validated header results: payload → headers hash
|
|
27
27
|
# Only used when no custom limits are set (max_header_count, max_header_size)
|
|
28
28
|
HEADERS_CACHE = {}
|
|
29
29
|
HEADERS_CACHE_MAX = 256
|
|
30
30
|
|
|
31
|
-
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
32
|
-
|
|
33
31
|
def initialize(data, **opts)
|
|
32
|
+
decoder = opts.delete(:decoder) || DEFAULT_DECODER
|
|
33
|
+
super(decoder: decoder, max_body_size: opts[:max_body_size],
|
|
34
|
+
max_header_size: opts[:max_header_size],
|
|
35
|
+
max_header_count: opts[:max_header_count],
|
|
36
|
+
max_frame_payload_size: opts[:max_frame_payload_size])
|
|
34
37
|
@data = data
|
|
35
|
-
|
|
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
|
|
38
|
+
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
|
|
46
39
|
end
|
|
47
40
|
|
|
48
41
|
# Reset parser with new data for object reuse (avoids allocation overhead)
|
|
49
42
|
def reset(data)
|
|
50
43
|
@data = data
|
|
51
|
-
@headers =
|
|
44
|
+
@headers = {}
|
|
45
|
+
@trailers = {}
|
|
52
46
|
@frames = nil
|
|
53
47
|
@body = nil
|
|
54
48
|
@cached_body_str = nil
|
|
@@ -76,7 +70,8 @@ module Quicsilver
|
|
|
76
70
|
return
|
|
77
71
|
end
|
|
78
72
|
end
|
|
79
|
-
@headers =
|
|
73
|
+
@headers = {}
|
|
74
|
+
@trailers = {}
|
|
80
75
|
@frames = nil
|
|
81
76
|
@body = nil
|
|
82
77
|
@cached_body_str = nil
|
|
@@ -84,20 +79,6 @@ module Quicsilver
|
|
|
84
79
|
cache_result if @use_parse_cache
|
|
85
80
|
end
|
|
86
81
|
|
|
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
82
|
# Class-level parse result cache
|
|
102
83
|
PARSE_CACHE = {}
|
|
103
84
|
PARSE_CACHE_MAX = 128
|
|
@@ -146,7 +127,13 @@ module Quicsilver
|
|
|
146
127
|
|
|
147
128
|
method = @headers[":method"]
|
|
148
129
|
|
|
149
|
-
if method == "
|
|
130
|
+
if method == METHOD_CONNECT && @headers[":protocol"]
|
|
131
|
+
# Extended CONNECT (RFC 9220) — requires :scheme, :path, :authority, :protocol
|
|
132
|
+
raise Protocol::MessageError, "Extended CONNECT must include :scheme" unless @headers[":scheme"]
|
|
133
|
+
raise Protocol::MessageError, "Extended CONNECT must include :path" unless @headers[":path"]
|
|
134
|
+
raise Protocol::MessageError, "Extended CONNECT must include :authority" unless @headers[":authority"]
|
|
135
|
+
elsif method == METHOD_CONNECT
|
|
136
|
+
# Regular CONNECT (RFC 9114 §4.4)
|
|
150
137
|
raise Protocol::MessageError, "CONNECT request must include :authority" unless @headers[":authority"]
|
|
151
138
|
raise Protocol::MessageError, "CONNECT request must not include :scheme" if @headers[":scheme"]
|
|
152
139
|
raise Protocol::MessageError, "CONNECT request must not include :path" if @headers[":path"]
|
|
@@ -168,15 +155,6 @@ module Quicsilver
|
|
|
168
155
|
raise Protocol::MessageError, ":authority and host header must be consistent"
|
|
169
156
|
end
|
|
170
157
|
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
158
|
end
|
|
181
159
|
|
|
182
160
|
def to_rack_env(stream_info = {})
|
|
@@ -184,7 +162,7 @@ module Quicsilver
|
|
|
184
162
|
|
|
185
163
|
method = @headers[":method"]
|
|
186
164
|
|
|
187
|
-
if method ==
|
|
165
|
+
if method == METHOD_CONNECT
|
|
188
166
|
return nil unless @headers[":authority"]
|
|
189
167
|
else
|
|
190
168
|
return nil unless method && @headers[":scheme"] && @headers[":path"]
|
|
@@ -237,76 +215,6 @@ module Quicsilver
|
|
|
237
215
|
|
|
238
216
|
private
|
|
239
217
|
|
|
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
218
|
# Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
|
|
311
219
|
# - Header names MUST be lowercase
|
|
312
220
|
# - Pseudo-headers MUST appear before regular headers
|
|
@@ -329,7 +237,7 @@ module Quicsilver
|
|
|
329
237
|
|
|
330
238
|
@decoder.decode(payload) do |name, value|
|
|
331
239
|
# RFC 9114 §4.2: Header field names MUST be lowercase
|
|
332
|
-
if name
|
|
240
|
+
if name.match?(/[A-Z]/)
|
|
333
241
|
raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
|
|
334
242
|
end
|
|
335
243
|
|
|
@@ -362,26 +270,6 @@ module Quicsilver
|
|
|
362
270
|
HEADERS_CACHE[key] = @headers.dup.freeze
|
|
363
271
|
end
|
|
364
272
|
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
273
|
end
|
|
386
274
|
end
|
|
387
275
|
end
|
|
@@ -3,12 +3,27 @@
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
module Protocol
|
|
5
5
|
class ResponseEncoder
|
|
6
|
-
|
|
6
|
+
# Encode an informational (1xx) response as a single HEADERS frame.
|
|
7
|
+
# RFC 9114 §4.1: informational responses are encoded as HEADERS with no body.
|
|
8
|
+
def self.encode_informational(status, headers, encoder: Qpack::Encoder.new)
|
|
9
|
+
raise ArgumentError, "Informational status must be 1xx, got #{status}" unless (100..199).include?(status)
|
|
10
|
+
|
|
11
|
+
pairs = [[":status", status.to_s]]
|
|
12
|
+
headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
|
|
13
|
+
encoded = encoder.encode(pairs)
|
|
14
|
+
|
|
15
|
+
Protocol.encode_varint(FRAME_HEADERS) +
|
|
16
|
+
Protocol.encode_varint(encoded.bytesize) +
|
|
17
|
+
encoded
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false, trailers: nil)
|
|
7
21
|
@status = status
|
|
8
22
|
@headers = headers
|
|
9
23
|
@body = body
|
|
10
24
|
@encoder = encoder
|
|
11
25
|
@head_request = head_request
|
|
26
|
+
@trailers = trailers
|
|
12
27
|
end
|
|
13
28
|
|
|
14
29
|
# Buffered encode - returns all frames at once
|
|
@@ -20,6 +35,7 @@ module Quicsilver
|
|
|
20
35
|
frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
|
|
21
36
|
end
|
|
22
37
|
end
|
|
38
|
+
frames << build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)) if @trailers&.any?
|
|
23
39
|
@body.close if @body.respond_to?(:close)
|
|
24
40
|
frames
|
|
25
41
|
end
|
|
@@ -35,7 +51,10 @@ module Quicsilver
|
|
|
35
51
|
last_chunk = chunk
|
|
36
52
|
end
|
|
37
53
|
|
|
38
|
-
if
|
|
54
|
+
if @trailers&.any?
|
|
55
|
+
yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
|
|
56
|
+
yield build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)), true
|
|
57
|
+
elsif last_chunk && !last_chunk.empty?
|
|
39
58
|
yield build_frame(FRAME_DATA, last_chunk), true
|
|
40
59
|
else
|
|
41
60
|
yield "".b, true
|
|
@@ -63,10 +82,16 @@ module Quicsilver
|
|
|
63
82
|
headers
|
|
64
83
|
end
|
|
65
84
|
|
|
85
|
+
def trailer_headers
|
|
86
|
+
@trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
87
|
+
end
|
|
88
|
+
|
|
66
89
|
def build_frame(type, payload)
|
|
67
90
|
payload = payload.to_s.b
|
|
68
91
|
Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
|
|
69
92
|
end
|
|
93
|
+
|
|
94
|
+
|
|
70
95
|
end
|
|
71
96
|
end
|
|
72
97
|
end
|