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
|
@@ -9,65 +9,178 @@ module Quicsilver
|
|
|
9
9
|
@body = body
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
# Buffered encode - returns all frames at once (legacy)
|
|
12
13
|
def encode
|
|
13
|
-
frames = ""
|
|
14
|
+
frames = "".b
|
|
15
|
+
frames << encode_headers_frame
|
|
16
|
+
@body.each do |chunk|
|
|
17
|
+
frames << encode_data_frame(chunk) unless chunk.empty?
|
|
18
|
+
end
|
|
19
|
+
@body.close if @body.respond_to?(:close)
|
|
20
|
+
frames
|
|
21
|
+
end
|
|
14
22
|
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
# Streaming encode - yields frames as they're ready
|
|
24
|
+
def stream_encode
|
|
25
|
+
yield encode_headers_frame, false
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
last_chunk = nil
|
|
19
28
|
@body.each do |chunk|
|
|
20
|
-
|
|
29
|
+
yield encode_data_frame(last_chunk), false if last_chunk && !last_chunk.empty?
|
|
30
|
+
last_chunk = chunk
|
|
21
31
|
end
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
# Send final chunk with FIN=true
|
|
34
|
+
if last_chunk && !last_chunk.empty?
|
|
35
|
+
yield encode_data_frame(last_chunk), true
|
|
36
|
+
else
|
|
37
|
+
yield "".b, true # Empty frame to signal FIN
|
|
38
|
+
end
|
|
24
39
|
|
|
25
|
-
|
|
40
|
+
@body.close if @body.respond_to?(:close)
|
|
26
41
|
end
|
|
27
42
|
|
|
28
43
|
private
|
|
29
44
|
|
|
30
45
|
def encode_headers_frame
|
|
31
46
|
payload = encode_qpack_response
|
|
32
|
-
|
|
33
|
-
frame_type = HTTP3.encode_varint(0x01) # HEADERS
|
|
47
|
+
frame_type = HTTP3.encode_varint(HTTP3::FRAME_HEADERS)
|
|
34
48
|
frame_length = HTTP3.encode_varint(payload.bytesize)
|
|
35
|
-
|
|
36
49
|
frame_type + frame_length + payload
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
def encode_data_frame(data)
|
|
40
|
-
frame_type = HTTP3.encode_varint(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
frame_type + frame_length +
|
|
53
|
+
frame_type = HTTP3.encode_varint(HTTP3::FRAME_DATA)
|
|
54
|
+
data_bytes = data.to_s.b
|
|
55
|
+
frame_length = HTTP3.encode_varint(data_bytes.bytesize)
|
|
56
|
+
frame_type + frame_length + data_bytes
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
def encode_qpack_response
|
|
47
60
|
# QPACK prefix: Required Insert Count = 0, Delta Base = 0
|
|
48
61
|
encoded = [0x00, 0x00].pack('C*')
|
|
49
62
|
|
|
50
|
-
# :status pseudo-header
|
|
51
|
-
|
|
52
|
-
encoded += [0x58, status_str.bytesize].pack('C*')
|
|
53
|
-
encoded += status_str
|
|
63
|
+
# :status pseudo-header - use indexed if possible
|
|
64
|
+
encoded += encode_status(@status)
|
|
54
65
|
|
|
55
|
-
# Regular headers
|
|
66
|
+
# Regular headers
|
|
56
67
|
@headers.each do |name, value|
|
|
57
68
|
next if name.start_with?('rack.') # Skip Rack internals
|
|
58
69
|
|
|
59
|
-
name = name.downcase
|
|
70
|
+
name = name.to_s.downcase
|
|
60
71
|
value = value.to_s
|
|
61
72
|
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
# Try to use indexed encoding for common headers
|
|
74
|
+
index = find_static_index(name, value)
|
|
75
|
+
if index
|
|
76
|
+
# Pattern 1: Indexed Field Line (1Txxxxxx where T=1 for static)
|
|
77
|
+
encoded += encode_indexed_field(index)
|
|
78
|
+
else
|
|
79
|
+
# Check if just the name exists in static table
|
|
80
|
+
name_index = find_static_name_index(name)
|
|
81
|
+
if name_index
|
|
82
|
+
# Pattern 3: Literal with name reference (01NTxxxx)
|
|
83
|
+
encoded += encode_literal_with_name_ref(name_index, value)
|
|
84
|
+
else
|
|
85
|
+
# Pattern 5: Literal with literal name (001NHxxx)
|
|
86
|
+
encoded += encode_literal_with_literal_name(name, value)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
67
89
|
end
|
|
68
90
|
|
|
69
91
|
encoded
|
|
70
92
|
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def encode_status(status)
|
|
97
|
+
status_str = status.to_s
|
|
98
|
+
# Map common status codes to static table indices
|
|
99
|
+
index = case status_str
|
|
100
|
+
when '200' then HTTP3::QPACK_STATUS_200 # 25
|
|
101
|
+
when '404' then HTTP3::QPACK_STATUS_404 # 27
|
|
102
|
+
when '500' then HTTP3::QPACK_STATUS_500 # 71
|
|
103
|
+
when '400' then HTTP3::QPACK_STATUS_400 # 67
|
|
104
|
+
when '100' then 63
|
|
105
|
+
when '204' then 64
|
|
106
|
+
when '304' then 26
|
|
107
|
+
when '403' then 68
|
|
108
|
+
else nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if index
|
|
112
|
+
# Indexed field line: 11 (pattern) + T (1=static) + index (prefix integer with N=6)
|
|
113
|
+
encode_indexed_field_with_prefix(index)
|
|
114
|
+
else
|
|
115
|
+
# Literal with name reference - use index 25 (:status 200) for name
|
|
116
|
+
name_ref = [0x40 | 25].pack('C')
|
|
117
|
+
status_bytes = status_str.b
|
|
118
|
+
length = [status_bytes.bytesize].pack('C')
|
|
119
|
+
name_ref + length + status_bytes
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Encode indexed field line using prefix integer encoding (RFC 7541)
|
|
124
|
+
# Pattern: 11 + T(1 bit) + index as prefix integer with N=6
|
|
125
|
+
def encode_indexed_field_with_prefix(index, prefix_bits: 6, pattern: 0xC0)
|
|
126
|
+
max_prefix = (1 << prefix_bits) - 1 # 2^6 - 1 = 63
|
|
127
|
+
|
|
128
|
+
if index < max_prefix
|
|
129
|
+
# Fits in prefix bits
|
|
130
|
+
[pattern | index].pack('C')
|
|
131
|
+
else
|
|
132
|
+
# Needs continuation bytes
|
|
133
|
+
result = [pattern | max_prefix].pack('C') # First byte: all prefix bits set to 1
|
|
134
|
+
remaining = index - max_prefix
|
|
135
|
+
|
|
136
|
+
# Encode remaining value using 7-bit continuation bytes
|
|
137
|
+
while remaining >= 128
|
|
138
|
+
result += [(remaining & 0x7F) | 0x80].pack('C') # MSB=1 means more bytes
|
|
139
|
+
remaining >>= 7
|
|
140
|
+
end
|
|
141
|
+
result += [remaining].pack('C') # Last byte: MSB=0
|
|
142
|
+
|
|
143
|
+
result
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def find_static_index(name, value)
|
|
148
|
+
HTTP3::STATIC_TABLE.each_with_index do |(tbl_name, tbl_value), idx|
|
|
149
|
+
return idx if tbl_name == name && tbl_value == value
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def find_static_name_index(name)
|
|
155
|
+
HTTP3::STATIC_TABLE.each_with_index do |(tbl_name, _), idx|
|
|
156
|
+
return idx if tbl_name == name
|
|
157
|
+
end
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def encode_indexed_field(index)
|
|
162
|
+
# Pattern 1: Indexed Field Line with prefix integer encoding
|
|
163
|
+
encode_indexed_field_with_prefix(index)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def encode_literal_with_name_ref(name_index, value)
|
|
167
|
+
# Pattern 3: Literal Field Line with Name Reference
|
|
168
|
+
# 01 N T + 4-bit index (N=0, T=1 for static)
|
|
169
|
+
prefix = 0x40 | name_index
|
|
170
|
+
prefix_byte = [prefix].pack('C')
|
|
171
|
+
value_bytes = value.to_s.b
|
|
172
|
+
value_length = HTTP3.encode_varint(value_bytes.bytesize)
|
|
173
|
+
prefix_byte + value_length + value_bytes
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def encode_literal_with_literal_name(name, value)
|
|
177
|
+
# Pattern 5: Literal Field Line with Literal Name
|
|
178
|
+
# 001 N H + 3-bit name length (N=0, H=0 for no Huffman)
|
|
179
|
+
prefix = 0x20 | name.bytesize
|
|
180
|
+
name_bytes = name.to_s.b
|
|
181
|
+
value_bytes = value.to_s.b
|
|
182
|
+
[prefix].pack('C') + name_bytes + HTTP3.encode_varint(value_bytes.bytesize) + value_bytes
|
|
183
|
+
end
|
|
71
184
|
end
|
|
72
185
|
end
|
|
73
186
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
module HTTP3
|
|
7
|
+
class ResponseParser
|
|
8
|
+
attr_reader :frames, :headers, :status
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@data = data
|
|
12
|
+
@frames = []
|
|
13
|
+
@headers = {}
|
|
14
|
+
@body_io = StringIO.new
|
|
15
|
+
@status = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def body
|
|
19
|
+
@body_io.rewind
|
|
20
|
+
@body_io
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse
|
|
24
|
+
parse!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parse!
|
|
30
|
+
buffer = @data.dup
|
|
31
|
+
offset = 0
|
|
32
|
+
|
|
33
|
+
while offset < buffer.bytesize
|
|
34
|
+
break if buffer.bytesize - offset < 2
|
|
35
|
+
|
|
36
|
+
type, type_len = HTTP3.decode_varint(buffer.bytes, offset)
|
|
37
|
+
length, length_len = HTTP3.decode_varint(buffer.bytes, offset + type_len)
|
|
38
|
+
header_len = type_len + length_len
|
|
39
|
+
|
|
40
|
+
break if buffer.bytesize < offset + header_len + length
|
|
41
|
+
|
|
42
|
+
payload = buffer[offset + header_len, length]
|
|
43
|
+
@frames << { type: type, length: length, payload: payload }
|
|
44
|
+
|
|
45
|
+
case type
|
|
46
|
+
when 0x01 # HEADERS
|
|
47
|
+
parse_headers(payload)
|
|
48
|
+
when 0x00 # DATA
|
|
49
|
+
@body_io.write(payload)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
offset += header_len + length
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_headers(payload)
|
|
57
|
+
# Skip QPACK required insert count (1 byte) + delta base (1 byte)
|
|
58
|
+
offset = 2
|
|
59
|
+
return if payload.bytesize < offset
|
|
60
|
+
|
|
61
|
+
while offset < payload.bytesize
|
|
62
|
+
byte = payload.bytes[offset]
|
|
63
|
+
|
|
64
|
+
# Pattern 1: Indexed Field Line (1Txxxxxx)
|
|
65
|
+
if (byte & 0x80) == 0x80
|
|
66
|
+
# Decode prefix integer with N=6 bits
|
|
67
|
+
index, bytes_consumed = decode_prefix_integer(payload.bytes, offset, 6, 0xC0)
|
|
68
|
+
offset += bytes_consumed
|
|
69
|
+
|
|
70
|
+
field = decode_static_table_field(index)
|
|
71
|
+
if field.is_a?(Hash)
|
|
72
|
+
field.each do |name, value|
|
|
73
|
+
if name == ":status"
|
|
74
|
+
@status = value.to_i
|
|
75
|
+
else
|
|
76
|
+
@headers[name] = value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
# Pattern 3: Literal with Name Reference (01NTxxxx)
|
|
81
|
+
elsif (byte & 0xC0) == 0x40
|
|
82
|
+
index = byte & 0x3F
|
|
83
|
+
offset += 1
|
|
84
|
+
|
|
85
|
+
entry = HTTP3::STATIC_TABLE[index] if index < HTTP3::STATIC_TABLE.size
|
|
86
|
+
name = entry ? entry[0] : nil
|
|
87
|
+
|
|
88
|
+
if name
|
|
89
|
+
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
90
|
+
offset += len_bytes
|
|
91
|
+
value = payload[offset, value_len]
|
|
92
|
+
offset += value_len
|
|
93
|
+
|
|
94
|
+
if name == ":status"
|
|
95
|
+
@status = value.to_i
|
|
96
|
+
else
|
|
97
|
+
@headers[name] = value
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
# Pattern 5: Literal with literal name (001NHxxx)
|
|
101
|
+
elsif (byte & 0xE0) == 0x20
|
|
102
|
+
name_len = byte & 0x1F
|
|
103
|
+
offset += 1
|
|
104
|
+
name = payload[offset, name_len]
|
|
105
|
+
offset += name_len
|
|
106
|
+
|
|
107
|
+
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
108
|
+
offset += len_bytes
|
|
109
|
+
value = payload[offset, value_len]
|
|
110
|
+
offset += value_len
|
|
111
|
+
|
|
112
|
+
@headers[name] = value
|
|
113
|
+
else
|
|
114
|
+
break
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def decode_static_table_field(index)
|
|
120
|
+
return nil if index >= HTTP3::STATIC_TABLE.size
|
|
121
|
+
|
|
122
|
+
name, value = HTTP3::STATIC_TABLE[index]
|
|
123
|
+
|
|
124
|
+
if value.empty?
|
|
125
|
+
name
|
|
126
|
+
else
|
|
127
|
+
{name => value}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Decode prefix integer (RFC 7541)
|
|
132
|
+
# Returns [value, bytes_consumed]
|
|
133
|
+
def decode_prefix_integer(bytes, offset, prefix_bits, pattern_mask)
|
|
134
|
+
max_prefix = (1 << prefix_bits) - 1 # 2^N - 1
|
|
135
|
+
|
|
136
|
+
first_byte = bytes[offset]
|
|
137
|
+
value = first_byte & max_prefix
|
|
138
|
+
bytes_consumed = 1
|
|
139
|
+
|
|
140
|
+
# If all prefix bits are 1, value continues in next byte(s)
|
|
141
|
+
if value == max_prefix
|
|
142
|
+
multiplier = 1
|
|
143
|
+
loop do
|
|
144
|
+
return [value, bytes_consumed] if offset + bytes_consumed >= bytes.size
|
|
145
|
+
|
|
146
|
+
next_byte = bytes[offset + bytes_consumed]
|
|
147
|
+
bytes_consumed += 1
|
|
148
|
+
|
|
149
|
+
value += (next_byte & 0x7F) * multiplier
|
|
150
|
+
break if (next_byte & 0x80) == 0 # MSB=0 means last byte
|
|
151
|
+
|
|
152
|
+
multiplier *= 128
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[value, bytes_consumed]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/quicsilver/http3.rb
CHANGED
|
@@ -2,65 +2,219 @@
|
|
|
2
2
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
module HTTP3
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
5
|
+
# HTTP/3 Frame Types (RFC 9114)
|
|
6
|
+
FRAME_DATA = 0x00
|
|
7
|
+
FRAME_HEADERS = 0x01
|
|
8
|
+
FRAME_CANCEL_PUSH = 0x03
|
|
9
|
+
FRAME_SETTINGS = 0x04
|
|
10
|
+
FRAME_PUSH_PROMISE = 0x05
|
|
11
|
+
FRAME_GOAWAY = 0x07
|
|
12
|
+
FRAME_MAX_PUSH_ID = 0x0d
|
|
13
|
+
|
|
14
|
+
# QPACK Static Table Indices (RFC 9204 Appendix A)
|
|
15
|
+
STATIC_TABLE = [
|
|
16
|
+
[':authority', ''], # 0
|
|
17
|
+
[':path', '/'], # 1
|
|
18
|
+
['age', '0'], # 2
|
|
19
|
+
['content-disposition', ''], # 3
|
|
20
|
+
['content-length', '0'], # 4
|
|
21
|
+
['cookie', ''], # 5
|
|
22
|
+
['date', ''], # 6
|
|
23
|
+
['etag', ''], # 7
|
|
24
|
+
['if-modified-since', ''], # 8
|
|
25
|
+
['if-none-match', ''], # 9
|
|
26
|
+
['last-modified', ''], # 10
|
|
27
|
+
['link', ''], # 11
|
|
28
|
+
['location', ''], # 12
|
|
29
|
+
['referer', ''], # 13
|
|
30
|
+
['set-cookie', ''], # 14
|
|
31
|
+
[':method', 'CONNECT'], # 15
|
|
32
|
+
[':method', 'DELETE'], # 16
|
|
33
|
+
[':method', 'GET'], # 17
|
|
34
|
+
[':method', 'HEAD'], # 18
|
|
35
|
+
[':method', 'OPTIONS'], # 19
|
|
36
|
+
[':method', 'POST'], # 20
|
|
37
|
+
[':method', 'PUT'], # 21
|
|
38
|
+
[':scheme', 'http'], # 22
|
|
39
|
+
[':scheme', 'https'], # 23
|
|
40
|
+
[':status', '103'], # 24
|
|
41
|
+
[':status', '200'], # 25
|
|
42
|
+
[':status', '304'], # 26
|
|
43
|
+
[':status', '404'], # 27
|
|
44
|
+
[':status', '503'], # 28
|
|
45
|
+
['accept', '*/*'], # 29
|
|
46
|
+
['accept', 'application/dns-message'], # 30
|
|
47
|
+
['accept-encoding', 'gzip, deflate, br'], # 31
|
|
48
|
+
['accept-ranges', 'bytes'], # 32
|
|
49
|
+
['access-control-allow-headers', 'cache-control'], # 33
|
|
50
|
+
['access-control-allow-headers', 'content-type'], # 34
|
|
51
|
+
['access-control-allow-origin', '*'], # 35
|
|
52
|
+
['cache-control', 'max-age=0'], # 36
|
|
53
|
+
['cache-control', 'max-age=2592000'], # 37
|
|
54
|
+
['cache-control', 'max-age=604800'], # 38
|
|
55
|
+
['cache-control', 'no-cache'], # 39
|
|
56
|
+
['cache-control', 'no-store'], # 40
|
|
57
|
+
['cache-control', 'public, max-age=31536000'], # 41
|
|
58
|
+
['content-encoding', 'br'], # 42
|
|
59
|
+
['content-encoding', 'gzip'], # 43
|
|
60
|
+
['content-type', 'application/dns-message'], # 44
|
|
61
|
+
['content-type', 'application/javascript'], # 45
|
|
62
|
+
['content-type', 'application/json'], # 46
|
|
63
|
+
['content-type', 'application/x-www-form-urlencoded'], # 47
|
|
64
|
+
['content-type', 'image/gif'], # 48
|
|
65
|
+
['content-type', 'image/jpeg'], # 49
|
|
66
|
+
['content-type', 'image/png'], # 50
|
|
67
|
+
['content-type', 'text/css'], # 51
|
|
68
|
+
['content-type', 'text/html; charset=utf-8'], # 52
|
|
69
|
+
['content-type', 'text/plain'], # 53
|
|
70
|
+
['content-type', 'text/plain;charset=utf-8'], # 54
|
|
71
|
+
['range', 'bytes=0-'], # 55
|
|
72
|
+
['strict-transport-security', 'max-age=31536000'], # 56
|
|
73
|
+
['strict-transport-security', 'max-age=31536000; includesubdomains'], # 57
|
|
74
|
+
['strict-transport-security', 'max-age=31536000; includesubdomains; preload'], # 58
|
|
75
|
+
['vary', 'accept-encoding'], # 59
|
|
76
|
+
['vary', 'origin'], # 60
|
|
77
|
+
['x-content-type-options', 'nosniff'], # 61
|
|
78
|
+
['x-xss-protection', '1; mode=block'], # 62
|
|
79
|
+
[':status', '100'], # 63
|
|
80
|
+
[':status', '204'], # 64
|
|
81
|
+
[':status', '206'], # 65
|
|
82
|
+
[':status', '302'], # 66
|
|
83
|
+
[':status', '400'], # 67
|
|
84
|
+
[':status', '403'], # 68
|
|
85
|
+
[':status', '421'], # 69
|
|
86
|
+
[':status', '425'], # 70
|
|
87
|
+
[':status', '500'], # 71
|
|
88
|
+
['accept-language', ''], # 72
|
|
89
|
+
['access-control-allow-credentials', 'FALSE'], # 73
|
|
90
|
+
['access-control-allow-credentials', 'TRUE'], # 74
|
|
91
|
+
['access-control-allow-headers', '*'], # 75
|
|
92
|
+
['access-control-allow-methods', 'get'], # 76
|
|
93
|
+
['access-control-allow-methods', 'get, post, options'], # 77
|
|
94
|
+
['access-control-allow-methods', 'options'], # 78
|
|
95
|
+
['access-control-expose-headers', 'content-length'], # 79
|
|
96
|
+
['access-control-request-headers', 'content-type'], # 80
|
|
97
|
+
['access-control-request-method', 'get'], # 81
|
|
98
|
+
['access-control-request-method', 'post'], # 82
|
|
99
|
+
['alt-svc', 'clear'], # 83
|
|
100
|
+
['authorization', ''], # 84
|
|
101
|
+
['content-security-policy', "script-src 'none'; object-src 'none'; base-uri 'none'"], # 85
|
|
102
|
+
['early-data', '1'], # 86
|
|
103
|
+
['expect-ct', ''], # 87
|
|
104
|
+
['forwarded', ''], # 88
|
|
105
|
+
['if-range', ''], # 89
|
|
106
|
+
['origin', ''], # 90
|
|
107
|
+
['purpose', 'prefetch'], # 91
|
|
108
|
+
['server', ''], # 92
|
|
109
|
+
['timing-allow-origin', '*'], # 93
|
|
110
|
+
['upgrade-insecure-requests', '1'], # 94
|
|
111
|
+
['user-agent', ''], # 95
|
|
112
|
+
['x-forwarded-for', ''], # 96
|
|
113
|
+
['x-frame-options', 'deny'], # 97
|
|
114
|
+
['x-frame-options', 'sameorigin'] # 98
|
|
115
|
+
].freeze
|
|
116
|
+
|
|
117
|
+
# Commonly used indices
|
|
118
|
+
QPACK_AUTHORITY = 0
|
|
119
|
+
QPACK_PATH = 1
|
|
120
|
+
QPACK_CONTENT_LENGTH = 4
|
|
121
|
+
QPACK_METHOD_CONNECT = 15
|
|
122
|
+
QPACK_METHOD_DELETE = 16
|
|
123
|
+
QPACK_METHOD_GET = 17
|
|
124
|
+
QPACK_METHOD_HEAD = 18
|
|
125
|
+
QPACK_METHOD_OPTIONS = 19
|
|
126
|
+
QPACK_METHOD_POST = 20
|
|
127
|
+
QPACK_METHOD_PUT = 21
|
|
128
|
+
QPACK_SCHEME_HTTP = 22
|
|
129
|
+
QPACK_SCHEME_HTTPS = 23
|
|
130
|
+
QPACK_STATUS_200 = 25
|
|
131
|
+
QPACK_STATUS_404 = 27
|
|
132
|
+
QPACK_STATUS_500 = 71
|
|
133
|
+
QPACK_STATUS_400 = 67
|
|
134
|
+
QPACK_CONTENT_TYPE_JSON = 46
|
|
135
|
+
QPACK_CONTENT_TYPE_PLAIN = 53
|
|
136
|
+
|
|
137
|
+
class << self
|
|
138
|
+
# Encode variable-length integer
|
|
139
|
+
def encode_varint(value)
|
|
140
|
+
case value
|
|
141
|
+
when 0..63
|
|
142
|
+
[value].pack('C')
|
|
143
|
+
when 64..16383
|
|
144
|
+
[0x40 | (value >> 8), value & 0xFF].pack('C*')
|
|
145
|
+
when 16384..1073741823
|
|
146
|
+
[0x80 | (value >> 24), (value >> 16) & 0xFF,
|
|
147
|
+
(value >> 8) & 0xFF, value & 0xFF].pack('C*')
|
|
148
|
+
else
|
|
149
|
+
[0xC0 | (value >> 56), (value >> 48) & 0xFF,
|
|
150
|
+
(value >> 40) & 0xFF, (value >> 32) & 0xFF,
|
|
151
|
+
(value >> 24) & 0xFF, (value >> 16) & 0xFF,
|
|
152
|
+
(value >> 8) & 0xFF, value & 0xFF].pack('C*')
|
|
153
|
+
end
|
|
20
154
|
end
|
|
21
|
-
end
|
|
22
155
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
156
|
+
def build_settings_frame(settings = {})
|
|
157
|
+
payload = ""
|
|
158
|
+
settings.each do |id, value|
|
|
159
|
+
payload += encode_varint(id)
|
|
160
|
+
payload += encode_varint(value)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
frame_type = encode_varint(FRAME_SETTINGS)
|
|
164
|
+
frame_length = encode_varint(payload.bytesize)
|
|
165
|
+
|
|
166
|
+
frame_type + frame_length + payload
|
|
28
167
|
end
|
|
29
168
|
|
|
30
|
-
|
|
31
|
-
|
|
169
|
+
# Build control stream data
|
|
170
|
+
def build_control_stream
|
|
171
|
+
stream_type = [0x00].pack('C') # Control stream type
|
|
172
|
+
settings = build_settings_frame({})
|
|
32
173
|
|
|
33
|
-
|
|
34
|
-
|
|
174
|
+
stream_type + settings
|
|
175
|
+
end
|
|
35
176
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
177
|
+
# Build GOAWAY frame (RFC 9114 Section 7.2.6)
|
|
178
|
+
# stream_id: The last client-initiated bidirectional stream ID the server will process
|
|
179
|
+
def build_goaway_frame(stream_id)
|
|
180
|
+
frame_type = encode_varint(FRAME_GOAWAY)
|
|
181
|
+
payload = encode_varint(stream_id)
|
|
182
|
+
frame_length = encode_varint(payload.bytesize)
|
|
43
183
|
|
|
44
|
-
|
|
45
|
-
|
|
184
|
+
frame_type + frame_length + payload
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Maximum stream ID for initial GOAWAY (2^62 - 4, per RFC 9114)
|
|
188
|
+
MAX_STREAM_ID = (2**62) - 4
|
|
189
|
+
|
|
190
|
+
# Decode variable-length integer (RFC 9000)
|
|
191
|
+
# Returns [value, bytes_consumed]
|
|
192
|
+
def decode_varint(bytes, offset = 0)
|
|
193
|
+
return [0, 0] if offset >= bytes.size
|
|
194
|
+
|
|
195
|
+
first = bytes[offset]
|
|
196
|
+
return [0, 0] if first.nil?
|
|
197
|
+
|
|
198
|
+
prefix = (first & 0xC0) >> 6 # Extract 2 MSB
|
|
199
|
+
length = 1 << prefix # 1, 2, 4, or 8 bytes
|
|
200
|
+
|
|
201
|
+
# Check if we have enough bytes
|
|
202
|
+
return [0, 0] if offset + length > bytes.size
|
|
46
203
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
bytes[offset + 2] << 40 | bytes[offset + 3] << 32 |
|
|
62
|
-
bytes[offset + 4] << 24 | bytes[offset + 5] << 16 |
|
|
63
|
-
bytes[offset + 6] << 8 | bytes[offset + 7], 8]
|
|
204
|
+
case prefix
|
|
205
|
+
when 0
|
|
206
|
+
[first & 0x3F, 1]
|
|
207
|
+
when 1
|
|
208
|
+
[(first & 0x3F) << 8 | bytes[offset + 1], 2]
|
|
209
|
+
when 2
|
|
210
|
+
[(first & 0x3F) << 24 | bytes[offset + 1] << 16 |
|
|
211
|
+
bytes[offset + 2] << 8 | bytes[offset + 3], 4]
|
|
212
|
+
else # when 3
|
|
213
|
+
[(first & 0x3F) << 56 | bytes[offset + 1] << 48 |
|
|
214
|
+
bytes[offset + 2] << 40 | bytes[offset + 3] << 32 |
|
|
215
|
+
bytes[offset + 4] << 24 | bytes[offset + 5] << 16 |
|
|
216
|
+
bytes[offset + 6] << 8 | bytes[offset + 7], 8]
|
|
217
|
+
end
|
|
64
218
|
end
|
|
65
219
|
end
|
|
66
220
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class QuicStream
|
|
5
|
+
attr_reader :stream_id, :is_unidirectional, :buffer
|
|
6
|
+
attr_accessor :stream_handle
|
|
7
|
+
|
|
8
|
+
def initialize(stream_id, is_unidirectional: nil)
|
|
9
|
+
@stream_id = stream_id
|
|
10
|
+
@is_unidirectional = is_unidirectional.nil? ? !bidirectional? : is_unidirectional
|
|
11
|
+
@buffer = StringIO.new.tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
12
|
+
@stream_handle = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bidirectional?
|
|
16
|
+
(stream_id & 0x02) == 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ready_to_send?
|
|
20
|
+
!stream_handle.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def append_data(data)
|
|
24
|
+
@buffer.write(data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def data
|
|
28
|
+
@buffer.string
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear_buffer
|
|
32
|
+
@buffer.truncate(0)
|
|
33
|
+
@buffer.rewind
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|