quicsilver 0.1.0 → 0.2.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 +42 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +27 -5
- data/Gemfile.lock +10 -0
- data/LICENSE +21 -0
- data/README.md +33 -54
- data/benchmarks/benchmark.rb +68 -0
- data/benchmarks/quicsilver_server.rb +46 -0
- data/examples/minimal_http3_server.rb +0 -6
- data/examples/rack_http3_server.rb +0 -6
- data/examples/simple_client_test.rb +26 -0
- data/ext/quicsilver/quicsilver.c +165 -36
- data/lib/quicsilver/client.rb +171 -101
- data/lib/quicsilver/connection.rb +42 -0
- data/lib/quicsilver/event_loop.rb +38 -0
- data/lib/quicsilver/http3/request_encoder.rb +41 -20
- data/lib/quicsilver/http3/request_parser.rb +42 -24
- data/lib/quicsilver/http3/response_encoder.rb +138 -25
- data/lib/quicsilver/http3/response_parser.rb +160 -0
- data/lib/quicsilver/http3.rb +205 -51
- data/lib/quicsilver/quic_stream.rb +36 -0
- data/lib/quicsilver/request_registry.rb +48 -0
- data/lib/quicsilver/server.rb +257 -160
- data/lib/quicsilver/server_configuration.rb +36 -7
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -0
- data/lib/rackup/handler/quicsilver.rb +78 -0
- data/quicsilver.gemspec +7 -2
- metadata +72 -7
- data/examples/minimal_http3_client.rb +0 -89
data/lib/quicsilver/client.rb
CHANGED
|
@@ -1,37 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'http3/request_encoder'
|
|
4
|
+
require_relative 'http3/response_parser'
|
|
5
|
+
require_relative "event_loop"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
3
8
|
module Quicsilver
|
|
4
9
|
class Client
|
|
5
10
|
attr_reader :hostname, :port, :unsecure, :connection_timeout
|
|
6
|
-
|
|
11
|
+
|
|
12
|
+
AlreadyConnectedError = Class.new(StandardError)
|
|
13
|
+
NotConnectedError = Class.new(StandardError)
|
|
14
|
+
StreamFailedToOpenError = Class.new(StandardError)
|
|
15
|
+
|
|
16
|
+
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE].freeze
|
|
17
|
+
|
|
7
18
|
def initialize(hostname, port = 4433, options = {})
|
|
8
19
|
@hostname = hostname
|
|
9
20
|
@port = port
|
|
10
|
-
@unsecure = options
|
|
11
|
-
@connection_timeout = options
|
|
12
|
-
|
|
21
|
+
@unsecure = options.fetch(:unsecure, true)
|
|
22
|
+
@connection_timeout = options.fetch(:connection_timeout, 5000)
|
|
23
|
+
|
|
13
24
|
@connection_data = nil
|
|
14
25
|
@connected = false
|
|
15
26
|
@connection_start_time = nil
|
|
27
|
+
|
|
28
|
+
@response_buffers = {} # stream_id => accumulated data
|
|
29
|
+
@pending_requests = {}
|
|
30
|
+
@mutex = Mutex.new
|
|
16
31
|
end
|
|
17
32
|
|
|
18
33
|
def connect
|
|
19
|
-
raise
|
|
34
|
+
raise AlreadyConnectedError if @connected
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
result = Quicsilver.open_connection
|
|
36
|
+
Quicsilver.open_connection
|
|
23
37
|
|
|
24
|
-
# Create configuration
|
|
25
38
|
config = Quicsilver.create_configuration(@unsecure)
|
|
26
39
|
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
27
|
-
|
|
40
|
+
|
|
28
41
|
# Create connection (returns [handle, context])
|
|
29
|
-
|
|
42
|
+
# Pass self so C extension can route callbacks to this instance
|
|
43
|
+
@connection_data = Quicsilver.create_connection(self)
|
|
30
44
|
raise ConnectionError, "Failed to create connection" if @connection_data.nil?
|
|
31
45
|
|
|
32
|
-
connection_handle = @connection_data
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
connection_handle, context_handle = @connection_data
|
|
47
|
+
|
|
35
48
|
# Start the connection
|
|
36
49
|
success = Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
37
50
|
unless success
|
|
@@ -40,30 +53,17 @@ module Quicsilver
|
|
|
40
53
|
raise ConnectionError, "Failed to start connection"
|
|
41
54
|
end
|
|
42
55
|
|
|
43
|
-
# Wait for connection to establish or fail
|
|
44
56
|
result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
|
|
45
|
-
|
|
46
|
-
if result.key?("error")
|
|
47
|
-
error_status = result["status"]
|
|
48
|
-
error_code = result["code"]
|
|
49
|
-
Quicsilver.close_configuration(config)
|
|
50
|
-
cleanup_failed_connection
|
|
51
|
-
error_msg = "Connection failed with status: 0x#{error_status.to_s(16)}, code: #{error_code}"
|
|
52
|
-
raise ConnectionError, error_msg
|
|
53
|
-
elsif result.key?("timeout")
|
|
54
|
-
Quicsilver.close_configuration(config)
|
|
55
|
-
cleanup_failed_connection
|
|
56
|
-
error_msg = "Connection timed out after #{@connection_timeout}ms"
|
|
57
|
-
raise TimeoutError, error_msg
|
|
58
|
-
end
|
|
57
|
+
handle_connection_result(result, config)
|
|
59
58
|
|
|
60
59
|
@connected = true
|
|
61
60
|
@connection_start_time = Time.now
|
|
62
|
-
|
|
61
|
+
|
|
63
62
|
send_control_stream
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Quicsilver.
|
|
63
|
+
Quicsilver.close_configuration(config) # Clean up config since connection is established
|
|
64
|
+
|
|
65
|
+
Quicsilver.event_loop.start
|
|
66
|
+
self
|
|
67
67
|
rescue => e
|
|
68
68
|
cleanup_failed_connection
|
|
69
69
|
|
|
@@ -75,68 +75,86 @@ module Quicsilver
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def disconnect
|
|
78
|
-
return unless @
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
ensure
|
|
88
|
-
@connected = false
|
|
89
|
-
@connection_start_time = nil
|
|
78
|
+
return unless @connection_data
|
|
79
|
+
|
|
80
|
+
@connected = false
|
|
81
|
+
|
|
82
|
+
# Wake up pending requests
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@pending_requests.each_value { |q| q.push(nil) }
|
|
85
|
+
@pending_requests.clear
|
|
86
|
+
@response_buffers.clear
|
|
90
87
|
end
|
|
88
|
+
|
|
89
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
90
|
+
@connection_data = nil
|
|
91
91
|
end
|
|
92
|
-
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
92
|
+
|
|
93
|
+
def get(path, **opts)
|
|
94
|
+
request("GET", path, **opts)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def post(path, **opts)
|
|
98
|
+
request("POST", path, **opts)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def patch(path, **opts)
|
|
102
|
+
request("PATCH", path, **opts)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def delete(path, **opts)
|
|
106
|
+
request("DELETE", path, **opts)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def head(path, **opts)
|
|
110
|
+
request("HEAD", path, **opts)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def request(method, path, headers: {}, body: nil, timeout: 5000)
|
|
114
|
+
raise NotConnectedError unless @connected
|
|
115
|
+
response_queue = Queue.new
|
|
116
|
+
|
|
117
|
+
request = HTTP3::RequestEncoder.new(
|
|
118
|
+
method: method,
|
|
119
|
+
path: path,
|
|
120
|
+
scheme: "https",
|
|
121
|
+
authority: authority,
|
|
122
|
+
headers: headers,
|
|
123
|
+
body: body
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
stream = open_stream
|
|
127
|
+
raise StreamFailedToOpenError unless stream
|
|
128
|
+
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
@pending_requests[stream] = response_queue
|
|
114
131
|
end
|
|
115
|
-
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
|
|
133
|
+
# Send data with FIN flag
|
|
134
|
+
result = Quicsilver.send_stream(stream, request.encode, true)
|
|
135
|
+
|
|
136
|
+
unless result
|
|
137
|
+
@mutex.synchronize { @pending_requests.delete(stream) }
|
|
138
|
+
raise Error, "Failed to send request"
|
|
119
139
|
end
|
|
120
|
-
|
|
140
|
+
|
|
141
|
+
response = response_queue.pop(timeout: timeout / 1000.0)
|
|
142
|
+
|
|
143
|
+
raise ConnectionError, "Connection closed" if response.nil? && !@connected
|
|
144
|
+
raise TimeoutError, "Request timeout after #{timeout}ms" if response.nil?
|
|
145
|
+
|
|
146
|
+
response
|
|
147
|
+
rescue Timeout::Error
|
|
148
|
+
@mutex.synchronize { @pending_requests.delete(stream) } if stream
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def connected?
|
|
152
|
+
@connected && @connection_data && connection_alive?
|
|
121
153
|
end
|
|
122
154
|
|
|
123
155
|
def connection_info
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
context_handle = @connection_data[1]
|
|
127
|
-
Quicsilver.connection_status(context_handle) || {}
|
|
128
|
-
rescue
|
|
129
|
-
{}
|
|
130
|
-
end
|
|
131
|
-
else
|
|
132
|
-
{}
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
base_info.merge({
|
|
136
|
-
hostname: @hostname,
|
|
137
|
-
port: @port,
|
|
138
|
-
uptime: connection_uptime
|
|
139
|
-
})
|
|
156
|
+
info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
|
|
157
|
+
info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
|
|
140
158
|
end
|
|
141
159
|
|
|
142
160
|
def connection_uptime
|
|
@@ -144,21 +162,50 @@ module Quicsilver
|
|
|
144
162
|
Time.now - @connection_start_time
|
|
145
163
|
end
|
|
146
164
|
|
|
147
|
-
def
|
|
148
|
-
|
|
165
|
+
def authority
|
|
166
|
+
"#{@hostname}:#{@port}"
|
|
167
|
+
end
|
|
149
168
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
# Called directly by C extension via process_events
|
|
170
|
+
# C extension routes to this instance based on client_obj stored in connection context
|
|
171
|
+
# Clients should never call this method directly.
|
|
172
|
+
def handle_stream_event(stream_id, event, data)
|
|
173
|
+
return unless FINISHED_EVENTS.include?(event)
|
|
174
|
+
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
case event
|
|
177
|
+
when "RECEIVE"
|
|
178
|
+
@response_buffers[stream_id] ||= StringIO.new
|
|
179
|
+
@response_buffers[stream_id].write(data) # Buffer incoming response data
|
|
180
|
+
when "RECEIVE_FIN"
|
|
181
|
+
stream_handle = data[0, 8].unpack1('Q') if data.bytesize >= 8
|
|
182
|
+
actual_data = data[8..-1] || ""
|
|
183
|
+
|
|
184
|
+
# Get all buffered data
|
|
185
|
+
buffer = @response_buffers.delete(stream_id)
|
|
186
|
+
full_data = ( buffer&.string || "") + actual_data
|
|
155
187
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
188
|
+
# TODO: needed for streaming later
|
|
189
|
+
@stream_handles ||= {}
|
|
190
|
+
@stream_handles[stream_id] = stream_handle if stream_handle
|
|
191
|
+
|
|
192
|
+
response_parser = Quicsilver::HTTP3::ResponseParser.new(full_data)
|
|
193
|
+
response_parser.parse
|
|
194
|
+
|
|
195
|
+
# Store complete response with body as string
|
|
196
|
+
response = {
|
|
197
|
+
status: response_parser.status,
|
|
198
|
+
headers: response_parser.headers,
|
|
199
|
+
body: response_parser.body.read
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
queue = @pending_requests.delete(stream_handle)
|
|
203
|
+
queue&.push(response) # Unblocks request
|
|
204
|
+
end
|
|
205
|
+
end
|
|
159
206
|
rescue => e
|
|
160
|
-
|
|
161
|
-
|
|
207
|
+
Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
|
|
208
|
+
Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
|
|
162
209
|
end
|
|
163
210
|
|
|
164
211
|
private
|
|
@@ -170,11 +217,11 @@ module Quicsilver
|
|
|
170
217
|
end
|
|
171
218
|
|
|
172
219
|
def open_stream
|
|
173
|
-
Quicsilver.open_stream(@connection_data
|
|
220
|
+
Quicsilver.open_stream(@connection_data, false)
|
|
174
221
|
end
|
|
175
222
|
|
|
176
223
|
def open_unidirectional_stream
|
|
177
|
-
Quicsilver.open_stream(@connection_data
|
|
224
|
+
Quicsilver.open_stream(@connection_data, true)
|
|
178
225
|
end
|
|
179
226
|
|
|
180
227
|
def send_control_stream
|
|
@@ -187,5 +234,28 @@ module Quicsilver
|
|
|
187
234
|
|
|
188
235
|
@control_stream = stream
|
|
189
236
|
end
|
|
237
|
+
|
|
238
|
+
def handle_connection_result(result, config)
|
|
239
|
+
if result.key?("error")
|
|
240
|
+
error_status = result["status"]
|
|
241
|
+
error_code = result["code"]
|
|
242
|
+
Quicsilver.close_configuration(config)
|
|
243
|
+
cleanup_failed_connection
|
|
244
|
+
error_msg = "Connection failed with status: 0x#{error_status.to_s(16)}, code: #{error_code}"
|
|
245
|
+
raise ConnectionError, error_msg
|
|
246
|
+
elsif result.key?("timeout")
|
|
247
|
+
Quicsilver.close_configuration(config)
|
|
248
|
+
cleanup_failed_connection
|
|
249
|
+
error_msg = "Connection timed out after #{@connection_timeout}ms"
|
|
250
|
+
raise TimeoutError, error_msg
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def connection_alive?
|
|
255
|
+
return false unless (info = Quicsilver.connection_status(@connection_data[1]))
|
|
256
|
+
info["connected"] && !info["failed"]
|
|
257
|
+
rescue
|
|
258
|
+
false
|
|
259
|
+
end
|
|
190
260
|
end
|
|
191
261
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Connection
|
|
5
|
+
attr_reader :handle, :data, :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
|
|
6
|
+
attr_reader :streams
|
|
7
|
+
attr_accessor :server_control_stream # Handle for server's outbound control stream (used to send GOAWAY)
|
|
8
|
+
|
|
9
|
+
def initialize(handle, data)
|
|
10
|
+
@handle = handle
|
|
11
|
+
@data = data
|
|
12
|
+
@streams = {}
|
|
13
|
+
@control_stream_id = nil
|
|
14
|
+
@qpack_encoder_stream_id = nil
|
|
15
|
+
@qpack_decoder_stream_id = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_qpack_encoder_stream(stream_id)
|
|
19
|
+
@qpack_encoder_stream_id = stream_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set_qpack_decoder_stream(stream_id)
|
|
23
|
+
@qpack_decoder_stream_id = stream_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set_control_stream(stream_id)
|
|
27
|
+
@control_stream_id = stream_id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add_stream(stream)
|
|
31
|
+
@streams[stream.stream_id] = stream
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_stream(stream_id)
|
|
35
|
+
@streams[stream_id]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remove_stream(stream_id)
|
|
39
|
+
@streams.delete(stream_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class EventLoop
|
|
5
|
+
def initialize
|
|
6
|
+
@running = false
|
|
7
|
+
@thread = nil
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def start
|
|
12
|
+
@mutex.synchronize do
|
|
13
|
+
return if @running
|
|
14
|
+
|
|
15
|
+
@running = true
|
|
16
|
+
@thread = Thread.new do
|
|
17
|
+
while @running
|
|
18
|
+
processed = Quicsilver.process_events
|
|
19
|
+
processed == 0 ? sleep(0.001) : Thread.pass
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stop
|
|
26
|
+
@running = false
|
|
27
|
+
@thread&.join(2)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def join
|
|
31
|
+
@thread&.join
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.event_loop
|
|
36
|
+
@event_loop ||= EventLoop.new.tap(&:start)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -17,12 +17,12 @@ module Quicsilver
|
|
|
17
17
|
|
|
18
18
|
# Build HEADERS frame
|
|
19
19
|
headers_payload = encode_headers
|
|
20
|
-
frames << build_frame(
|
|
20
|
+
frames << build_frame(HTTP3::FRAME_HEADERS, headers_payload)
|
|
21
21
|
|
|
22
22
|
# Build DATA frame if body present
|
|
23
23
|
if @body && !@body.empty?
|
|
24
24
|
body_data = @body.is_a?(String) ? @body : @body.join
|
|
25
|
-
frames << build_frame(
|
|
25
|
+
frames << build_frame(HTTP3::FRAME_DATA, body_data)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
frames.join.force_encoding(Encoding::BINARY)
|
|
@@ -69,30 +69,51 @@ module Quicsilver
|
|
|
69
69
|
# Literal field line with literal name for pseudo-headers
|
|
70
70
|
# Pattern: 0x50 (indexed name from static table) + value
|
|
71
71
|
def encode_literal_pseudo_header(name, value)
|
|
72
|
-
|
|
73
|
-
# with literal value (pattern: 0101xxxx where xxxx = static table index)
|
|
74
|
-
static_index = case name
|
|
75
|
-
when ':authority' then 0
|
|
76
|
-
when ':path' then 1
|
|
77
|
-
when ':method' then (@method == 'GET' ? 17 : 20) # GET=17, POST=20
|
|
78
|
-
when ':scheme' then (@scheme == 'http' ? 22 : 23)
|
|
79
|
-
else nil
|
|
80
|
-
end
|
|
72
|
+
result = "".b
|
|
81
73
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
74
|
+
case name
|
|
75
|
+
when ':method'
|
|
76
|
+
# Check if exact match in static table
|
|
77
|
+
index = case @method
|
|
78
|
+
when 'GET' then HTTP3::QPACK_METHOD_GET
|
|
79
|
+
when 'POST' then HTTP3::QPACK_METHOD_POST
|
|
80
|
+
when 'PUT' then HTTP3::QPACK_METHOD_PUT
|
|
81
|
+
when 'DELETE' then HTTP3::QPACK_METHOD_DELETE
|
|
82
|
+
when 'CONNECT' then HTTP3::QPACK_METHOD_CONNECT
|
|
83
|
+
when 'HEAD' then HTTP3::QPACK_METHOD_HEAD
|
|
84
|
+
when 'OPTIONS' then HTTP3::QPACK_METHOD_OPTIONS
|
|
85
|
+
else nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if index
|
|
89
|
+
# Exact match - use indexed field line (0x80 | index)
|
|
90
|
+
result += [0x80 | index].pack('C')
|
|
91
|
+
else
|
|
92
|
+
# No exact match - use literal with name reference
|
|
93
|
+
result += [0x40 | HTTP3::QPACK_METHOD_GET].pack('C') # Use any :method index for name
|
|
88
94
|
result += HTTP3.encode_varint(value.bytesize)
|
|
89
|
-
result += value.
|
|
95
|
+
result += value.b
|
|
90
96
|
end
|
|
91
|
-
|
|
97
|
+
|
|
98
|
+
when ':scheme'
|
|
99
|
+
# Check if exact match
|
|
100
|
+
index = (@scheme == 'https' ? HTTP3::QPACK_SCHEME_HTTPS : HTTP3::QPACK_SCHEME_HTTP)
|
|
101
|
+
# Exact match - use indexed field line
|
|
102
|
+
result += [0x80 | index].pack('C')
|
|
103
|
+
|
|
104
|
+
when ':authority', ':path'
|
|
105
|
+
# Name in static table, but value is custom - use literal with name reference
|
|
106
|
+
index = (name == ':authority' ? HTTP3::QPACK_AUTHORITY : HTTP3::QPACK_PATH)
|
|
107
|
+
result += [0x40 | index].pack('C')
|
|
108
|
+
result += HTTP3.encode_varint(value.bytesize)
|
|
109
|
+
result += value.b
|
|
110
|
+
|
|
92
111
|
else
|
|
93
112
|
# Fallback to literal name
|
|
94
|
-
encode_literal_header(name, value)
|
|
113
|
+
return encode_literal_header(name, value)
|
|
95
114
|
end
|
|
115
|
+
|
|
116
|
+
result
|
|
96
117
|
end
|
|
97
118
|
|
|
98
119
|
# Literal field line with literal name
|
|
@@ -12,6 +12,7 @@ module Quicsilver
|
|
|
12
12
|
@frames = []
|
|
13
13
|
@headers = {}
|
|
14
14
|
@body = StringIO.new
|
|
15
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def parse
|
|
@@ -49,10 +50,21 @@ module Quicsilver
|
|
|
49
50
|
'CONTENT_LENGTH' => @body.size.to_s,
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
# Add
|
|
53
|
+
# Add HTTP_HOST from :authority pseudo-header
|
|
54
|
+
if @headers[':authority']
|
|
55
|
+
env['HTTP_HOST'] = @headers[':authority']
|
|
56
|
+
end
|
|
57
|
+
|
|
53
58
|
@headers.each do |name, value|
|
|
54
59
|
next if name.start_with?(':')
|
|
55
|
-
|
|
60
|
+
key = name.upcase.tr('-', '_')
|
|
61
|
+
if key == 'CONTENT_TYPE'
|
|
62
|
+
env['CONTENT_TYPE'] = value
|
|
63
|
+
elsif key == 'CONTENT_LENGTH'
|
|
64
|
+
env['CONTENT_LENGTH'] = value
|
|
65
|
+
else
|
|
66
|
+
env["HTTP_#{key}"] = value
|
|
67
|
+
end
|
|
56
68
|
end
|
|
57
69
|
|
|
58
70
|
env
|
|
@@ -97,26 +109,35 @@ module Quicsilver
|
|
|
97
109
|
while offset < payload.bytesize
|
|
98
110
|
byte = payload.bytes[offset]
|
|
99
111
|
|
|
100
|
-
# Indexed
|
|
101
|
-
|
|
112
|
+
# Pattern 1: Indexed Field Line (1Txxxxxx)
|
|
113
|
+
# Use both name AND value from static table
|
|
114
|
+
if (byte & 0x80) == 0x80
|
|
102
115
|
index = byte & 0x3F
|
|
103
116
|
offset += 1
|
|
104
117
|
|
|
105
|
-
# Check if this is a complete field (name+value) or just name
|
|
106
118
|
field = decode_static_table_field(index)
|
|
107
|
-
|
|
108
119
|
if field.is_a?(Hash)
|
|
109
|
-
# Complete field with both name and value (e.g., :method GET)
|
|
110
120
|
@headers.merge!(field)
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
end
|
|
122
|
+
# Pattern 3: Literal with Name Reference (01NTxxxx)
|
|
123
|
+
# Use name from static table, but value is provided as literal
|
|
124
|
+
elsif (byte & 0xC0) == 0x40
|
|
125
|
+
index = byte & 0x3F
|
|
126
|
+
offset += 1
|
|
127
|
+
|
|
128
|
+
# Get the name from static table
|
|
129
|
+
entry = HTTP3::STATIC_TABLE[index] if index < HTTP3::STATIC_TABLE.size
|
|
130
|
+
name = entry ? entry[0] : nil
|
|
131
|
+
|
|
132
|
+
if name
|
|
133
|
+
# Read literal value that follows
|
|
113
134
|
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
114
135
|
offset += len_bytes
|
|
115
136
|
value = payload[offset, value_len]
|
|
116
137
|
offset += value_len
|
|
117
|
-
@headers[
|
|
138
|
+
@headers[name] = value
|
|
118
139
|
end
|
|
119
|
-
# Literal with literal name (
|
|
140
|
+
# Pattern 5: Literal with literal name (001NHxxx)
|
|
120
141
|
elsif (byte & 0xE0) == 0x20
|
|
121
142
|
name_len = byte & 0x1F
|
|
122
143
|
offset += 1
|
|
@@ -138,19 +159,16 @@ module Quicsilver
|
|
|
138
159
|
# QPACK static table decoder (RFC 9204 Appendix A)
|
|
139
160
|
# Returns Hash for complete fields, String for name-only fields
|
|
140
161
|
def decode_static_table_field(index)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
when 22 then {':scheme' => 'http'}
|
|
152
|
-
when 23 then {':scheme' => 'https'}
|
|
153
|
-
else nil
|
|
162
|
+
return nil if index >= HTTP3::STATIC_TABLE.size
|
|
163
|
+
|
|
164
|
+
name, value = HTTP3::STATIC_TABLE[index]
|
|
165
|
+
|
|
166
|
+
# If value is empty, return just the name (caller provides value)
|
|
167
|
+
# Otherwise return complete field as hash
|
|
168
|
+
if value.empty?
|
|
169
|
+
name
|
|
170
|
+
else
|
|
171
|
+
{name => value}
|
|
154
172
|
end
|
|
155
173
|
end
|
|
156
174
|
end
|