quicsilver 0.1.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.
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Client
5
+ attr_reader :hostname, :port, :unsecure, :connection_timeout
6
+
7
+ def initialize(hostname, port = 4433, options = {})
8
+ @hostname = hostname
9
+ @port = port
10
+ @unsecure = options[:unsecure] || true
11
+ @connection_timeout = options[:connection_timeout] || 5000
12
+
13
+ @connection_data = nil
14
+ @connected = false
15
+ @connection_start_time = nil
16
+ end
17
+
18
+ def connect
19
+ raise Error, "Already connected" if @connected
20
+
21
+ # Initialize MSQUIC if not already done
22
+ result = Quicsilver.open_connection
23
+
24
+ # Create configuration
25
+ config = Quicsilver.create_configuration(@unsecure)
26
+ raise ConnectionError, "Failed to create configuration" if config.nil?
27
+
28
+ # Create connection (returns [handle, context])
29
+ @connection_data = Quicsilver.create_connection
30
+ raise ConnectionError, "Failed to create connection" if @connection_data.nil?
31
+
32
+ connection_handle = @connection_data[0]
33
+ context_handle = @connection_data[1]
34
+
35
+ # Start the connection
36
+ success = Quicsilver.start_connection(connection_handle, config, @hostname, @port)
37
+ unless success
38
+ Quicsilver.close_configuration(config)
39
+ cleanup_failed_connection
40
+ raise ConnectionError, "Failed to start connection"
41
+ end
42
+
43
+ # Wait for connection to establish or fail
44
+ 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
59
+
60
+ @connected = true
61
+ @connection_start_time = Time.now
62
+
63
+ send_control_stream
64
+
65
+ # Clean up config since connection is established
66
+ Quicsilver.close_configuration(config)
67
+ rescue => e
68
+ cleanup_failed_connection
69
+
70
+ if e.is_a?(ConnectionError) || e.is_a?(TimeoutError)
71
+ raise e
72
+ else
73
+ raise ConnectionError, "Connection failed: #{e.message}"
74
+ end
75
+ end
76
+
77
+ def disconnect
78
+ return unless @connected || @connection_data
79
+
80
+ begin
81
+ if @connection_data
82
+ Quicsilver.close_connection_handle(@connection_data)
83
+ @connection_data = nil
84
+ end
85
+ rescue
86
+ # Ignore disconnect errors
87
+ ensure
88
+ @connected = false
89
+ @connection_start_time = nil
90
+ end
91
+ end
92
+
93
+ def connected?
94
+ return false unless @connected && @connection_data
95
+
96
+ # Get connection status from the C extension
97
+ context_handle = @connection_data[1]
98
+ info = Quicsilver.connection_status(context_handle)
99
+ if info && info.key?("connected")
100
+ is_connected = info["connected"] && !info["failed"]
101
+
102
+ # If C extension says we're disconnected, update our state
103
+ if !is_connected && @connected
104
+ @connected = false
105
+ end
106
+
107
+ is_connected
108
+ else
109
+ # If we can't get status, assume disconnected
110
+ if @connected
111
+ @connected = false
112
+ end
113
+ false
114
+ end
115
+ rescue
116
+ # If there's an error checking status, assume disconnected
117
+ if @connected
118
+ @connected = false
119
+ end
120
+ false
121
+ end
122
+
123
+ def connection_info
124
+ base_info = if @connection_data
125
+ begin
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
+ })
140
+ end
141
+
142
+ def connection_uptime
143
+ return 0 unless @connection_start_time
144
+ Time.now - @connection_start_time
145
+ end
146
+
147
+ def send_data(data)
148
+ raise Error, "Not connected" unless @connected
149
+
150
+ stream = open_stream
151
+ unless stream
152
+ puts "❌ Failed to open stream"
153
+ return false
154
+ end
155
+
156
+ result = Quicsilver.send_stream(stream, data, true)
157
+ puts "✅ Sent #{data.bytesize} bytes"
158
+ result
159
+ rescue => e
160
+ puts "❌ Send data error: #{e.class} - #{e.message}"
161
+ false
162
+ end
163
+
164
+ private
165
+
166
+ def cleanup_failed_connection
167
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
168
+ @connection_data = nil
169
+ @connected = false
170
+ end
171
+
172
+ def open_stream
173
+ Quicsilver.open_stream(@connection_data[0], false)
174
+ end
175
+
176
+ def open_unidirectional_stream
177
+ Quicsilver.open_stream(@connection_data[0], true)
178
+ end
179
+
180
+ def send_control_stream
181
+ # Open unidirectional stream
182
+ stream = open_unidirectional_stream
183
+
184
+ # Build and send control stream data
185
+ control_data = Quicsilver::HTTP3.build_control_stream
186
+ Quicsilver.send_stream(stream, control_data, false)
187
+
188
+ @control_stream = stream
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module HTTP3
5
+ class RequestEncoder
6
+ def initialize(method:, path:, scheme: 'https', authority: 'localhost:4433', headers: {}, body: nil)
7
+ @method = method.upcase
8
+ @path = path
9
+ @scheme = scheme
10
+ @authority = authority
11
+ @headers = headers
12
+ @body = body
13
+ end
14
+
15
+ def encode
16
+ frames = []
17
+
18
+ # Build HEADERS frame
19
+ headers_payload = encode_headers
20
+ frames << build_frame(0x01, headers_payload) # 0x01 = HEADERS frame
21
+
22
+ # Build DATA frame if body present
23
+ if @body && !@body.empty?
24
+ body_data = @body.is_a?(String) ? @body : @body.join
25
+ frames << build_frame(0x00, body_data) # 0x00 = DATA frame
26
+ end
27
+
28
+ frames.join.force_encoding(Encoding::BINARY)
29
+ end
30
+
31
+ private
32
+
33
+ def build_frame(type, payload)
34
+ frame_type = HTTP3.encode_varint(type)
35
+ frame_length = HTTP3.encode_varint(payload.bytesize)
36
+ frame_type + frame_length + payload
37
+ end
38
+
39
+ def encode_headers
40
+ payload = "".b
41
+
42
+ # QPACK prefix: Required Insert Count = 0, Delta Base = 0
43
+ payload += "\x00\x00".b
44
+
45
+ # Encode pseudo-headers using Indexed Field Line with Post-Base Index
46
+ # Pattern: 0x50 (0101 0000) for :method, :scheme
47
+ # Pattern: 0x40 | index for :authority, :path (literal name, literal value)
48
+
49
+ # :method (use literal since GET/POST have specific indices but we want flexibility)
50
+ payload += encode_literal_pseudo_header(':method', @method)
51
+
52
+ # :scheme
53
+ payload += encode_literal_pseudo_header(':scheme', @scheme)
54
+
55
+ # :authority
56
+ payload += encode_literal_pseudo_header(':authority', @authority)
57
+
58
+ # :path
59
+ payload += encode_literal_pseudo_header(':path', @path)
60
+
61
+ # Encode regular headers
62
+ @headers.each do |name, value|
63
+ payload += encode_literal_header(name.to_s.downcase, value.to_s)
64
+ end
65
+
66
+ payload
67
+ end
68
+
69
+ # Literal field line with literal name for pseudo-headers
70
+ # Pattern: 0x50 (indexed name from static table) + value
71
+ def encode_literal_pseudo_header(name, value)
72
+ # For pseudo-headers, use indexed name reference from static table
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
81
+
82
+ if static_index
83
+ # Use indexed field line (0x40 | index)
84
+ result = "".b
85
+ result += [0x40 | static_index].pack('C')
86
+ # For non-exact matches, append literal value
87
+ if name == ':authority' || name == ':path'
88
+ result += HTTP3.encode_varint(value.bytesize)
89
+ result += value.to_s.b
90
+ end
91
+ result
92
+ else
93
+ # Fallback to literal name
94
+ encode_literal_header(name, value)
95
+ end
96
+ end
97
+
98
+ # Literal field line with literal name
99
+ # Pattern: 0x20 | name_length, name_bytes, value_length, value_bytes
100
+ def encode_literal_header(name, value)
101
+ result = "".b
102
+ # 0x20 = literal with literal name (no indexing)
103
+ name_len = name.bytesize
104
+ result += [0x20 | (name_len & 0x1F)].pack('C')
105
+ result += name.b
106
+ result += HTTP3.encode_varint(value.bytesize)
107
+ result += value.to_s.b
108
+ result
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Quicsilver
6
+ module HTTP3
7
+ class RequestParser
8
+ attr_reader :frames, :headers, :body
9
+
10
+ def initialize(data)
11
+ @data = data
12
+ @frames = []
13
+ @headers = {}
14
+ @body = StringIO.new
15
+ end
16
+
17
+ def parse
18
+ parse!
19
+ end
20
+
21
+ def to_rack_env(stream_info = {})
22
+ return nil if @headers.empty?
23
+
24
+ # Extract path and query string
25
+ path_full = @headers[':path'] || '/'
26
+ path, query = path_full.split('?', 2)
27
+
28
+ # Extract host and port
29
+ authority = @headers[':authority'] || 'localhost:4433'
30
+ host, port = authority.split(':', 2)
31
+ port ||= '4433'
32
+
33
+ env = {
34
+ 'REQUEST_METHOD' => @headers[':method'] || 'GET',
35
+ 'PATH_INFO' => path,
36
+ 'QUERY_STRING' => query || '',
37
+ 'SERVER_NAME' => host,
38
+ 'SERVER_PORT' => port,
39
+ 'SERVER_PROTOCOL' => 'HTTP/3',
40
+ 'rack.version' => [1, 3],
41
+ 'rack.url_scheme' => @headers[':scheme'] || 'https',
42
+ 'rack.input' => @body,
43
+ 'rack.errors' => $stderr,
44
+ 'rack.multithread' => true,
45
+ 'rack.multiprocess' => false,
46
+ 'rack.run_once' => false,
47
+ 'rack.hijack?' => false,
48
+ 'SCRIPT_NAME' => '',
49
+ 'CONTENT_LENGTH' => @body.size.to_s,
50
+ }
51
+
52
+ # Add regular headers as HTTP_*
53
+ @headers.each do |name, value|
54
+ next if name.start_with?(':')
55
+ env["HTTP_#{name.upcase.tr('-', '_')}"] = value
56
+ end
57
+
58
+ env
59
+ end
60
+
61
+ private
62
+
63
+ def parse!
64
+ buffer = @data.dup
65
+ offset = 0
66
+
67
+ while offset < buffer.bytesize
68
+ break if buffer.bytesize - offset < 2
69
+
70
+ type, type_len = HTTP3.decode_varint(buffer.bytes, offset)
71
+ length, length_len = HTTP3.decode_varint(buffer.bytes, offset + type_len)
72
+ header_len = type_len + length_len
73
+
74
+ break if buffer.bytesize < offset + header_len + length
75
+
76
+ payload = buffer[offset + header_len, length]
77
+ @frames << { type: type, length: length, payload: payload }
78
+
79
+ case type
80
+ when 0x01 # HEADERS
81
+ parse_headers(payload)
82
+ when 0x00 # DATA
83
+ @body.write(payload)
84
+ end
85
+
86
+ offset += header_len + length
87
+ end
88
+
89
+ @body.rewind
90
+ end
91
+
92
+ def parse_headers(payload)
93
+ # Skip QPACK required insert count (1 byte) + delta base (1 byte)
94
+ offset = 2
95
+ return if payload.bytesize < offset
96
+
97
+ while offset < payload.bytesize
98
+ byte = payload.bytes[offset]
99
+
100
+ # Indexed field line (static table, starts with 0x4X or 0x5X)
101
+ if (byte & 0xC0) == 0x40 || (byte & 0xC0) == 0x80
102
+ index = byte & 0x3F
103
+ offset += 1
104
+
105
+ # Check if this is a complete field (name+value) or just name
106
+ field = decode_static_table_field(index)
107
+
108
+ if field.is_a?(Hash)
109
+ # Complete field with both name and value (e.g., :method GET)
110
+ @headers.merge!(field)
111
+ elsif field
112
+ # Name-only entry, value follows
113
+ value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
114
+ offset += len_bytes
115
+ value = payload[offset, value_len]
116
+ offset += value_len
117
+ @headers[field] = value
118
+ end
119
+ # Literal with literal name (starts with 0x2X)
120
+ elsif (byte & 0xE0) == 0x20
121
+ name_len = byte & 0x1F
122
+ offset += 1
123
+ name = payload[offset, name_len]
124
+ offset += name_len
125
+
126
+ value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
127
+ offset += len_bytes
128
+ value = payload[offset, value_len]
129
+ offset += value_len
130
+
131
+ @headers[name] = value
132
+ else
133
+ break # Unknown encoding
134
+ end
135
+ end
136
+ end
137
+
138
+ # QPACK static table decoder (RFC 9204 Appendix A)
139
+ # Returns Hash for complete fields, String for name-only fields
140
+ def decode_static_table_field(index)
141
+ case index
142
+ when 0 then ':authority' # Name only
143
+ when 1 then ':path' # Name only
144
+ when 15 then {':method' => 'CONNECT'}
145
+ when 16 then {':method' => 'DELETE'}
146
+ when 17 then {':method' => 'GET'}
147
+ when 18 then {':method' => 'HEAD'}
148
+ when 19 then {':method' => 'OPTIONS'}
149
+ when 20 then {':method' => 'POST'}
150
+ when 21 then {':method' => 'PUT'}
151
+ when 22 then {':scheme' => 'http'}
152
+ when 23 then {':scheme' => 'https'}
153
+ else nil
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module HTTP3
5
+ class ResponseEncoder
6
+ def initialize(status, headers, body)
7
+ @status = status
8
+ @headers = headers
9
+ @body = body
10
+ end
11
+
12
+ def encode
13
+ frames = ""
14
+
15
+ # HEADERS frame
16
+ frames += encode_headers_frame
17
+
18
+ # DATA frame(s)
19
+ @body.each do |chunk|
20
+ frames += encode_data_frame(chunk) unless chunk.empty?
21
+ end
22
+
23
+ @body.close if @body.respond_to?(:close)
24
+
25
+ frames
26
+ end
27
+
28
+ private
29
+
30
+ def encode_headers_frame
31
+ payload = encode_qpack_response
32
+
33
+ frame_type = HTTP3.encode_varint(0x01) # HEADERS
34
+ frame_length = HTTP3.encode_varint(payload.bytesize)
35
+
36
+ frame_type + frame_length + payload
37
+ end
38
+
39
+ def encode_data_frame(data)
40
+ frame_type = HTTP3.encode_varint(0x00) # DATA
41
+ frame_length = HTTP3.encode_varint(data.bytesize)
42
+
43
+ frame_type + frame_length + data
44
+ end
45
+
46
+ def encode_qpack_response
47
+ # QPACK prefix: Required Insert Count = 0, Delta Base = 0
48
+ encoded = [0x00, 0x00].pack('C*')
49
+
50
+ # :status pseudo-header (literal indexed)
51
+ status_str = @status.to_s
52
+ encoded += [0x58, status_str.bytesize].pack('C*')
53
+ encoded += status_str
54
+
55
+ # Regular headers (literal with literal name)
56
+ @headers.each do |name, value|
57
+ next if name.start_with?('rack.') # Skip Rack internals
58
+
59
+ name = name.downcase
60
+ value = value.to_s
61
+
62
+ # Literal field line with literal name (0x2X prefix)
63
+ encoded += [0x20 | name.bytesize].pack('C')
64
+ encoded += name
65
+ encoded += HTTP3.encode_varint(value.bytesize)
66
+ encoded += value
67
+ end
68
+
69
+ encoded
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module HTTP3
5
+ # Encode variable-length integer
6
+ def self.encode_varint(value)
7
+ case value
8
+ when 0..63
9
+ [value].pack('C')
10
+ when 64..16383
11
+ [0x40 | (value >> 8), value & 0xFF].pack('C*')
12
+ when 16384..1073741823
13
+ [0x80 | (value >> 24), (value >> 16) & 0xFF,
14
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*')
15
+ else
16
+ [0xC0 | (value >> 56), (value >> 48) & 0xFF,
17
+ (value >> 40) & 0xFF, (value >> 32) & 0xFF,
18
+ (value >> 24) & 0xFF, (value >> 16) & 0xFF,
19
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*')
20
+ end
21
+ end
22
+
23
+ def self.build_settings_frame(settings = {})
24
+ payload = ""
25
+ settings.each do |id, value|
26
+ payload += encode_varint(id)
27
+ payload += encode_varint(value)
28
+ end
29
+
30
+ frame_type = encode_varint(0x04) # SETTINGS
31
+ frame_length = encode_varint(payload.bytesize)
32
+
33
+ frame_type + frame_length + payload
34
+ end
35
+
36
+ # Build control stream data
37
+ def self.build_control_stream
38
+ stream_type = [0x00].pack('C') # Control stream type
39
+ settings = build_settings_frame({
40
+ # 0x01 => 4096, # QPACK_MAX_TABLE_CAPACITY (optional)
41
+ # 0x06 => 16384 # MAX_HEADER_LIST_SIZE (optional)
42
+ })
43
+
44
+ stream_type + settings
45
+ end
46
+
47
+ # Decode variable-length integer (RFC 9000)
48
+ # Returns [value, bytes_consumed]
49
+ def self.decode_varint(bytes, offset = 0)
50
+ first = bytes[offset]
51
+ case (first & 0xC0) >> 6
52
+ when 0
53
+ [first & 0x3F, 1]
54
+ when 1
55
+ [(first & 0x3F) << 8 | bytes[offset + 1], 2]
56
+ when 2
57
+ [(first & 0x3F) << 24 | bytes[offset + 1] << 16 |
58
+ bytes[offset + 2] << 8 | bytes[offset + 3], 4]
59
+ else # when 3
60
+ [(first & 0x3F) << 56 | bytes[offset + 1] << 48 |
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]
64
+ end
65
+ end
66
+ end
67
+ end
68
+
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class ListenerData
5
+ attr_reader :listener_handle, :context_handle, :started, :stopped, :failed, :configuration
6
+
7
+ def initialize(listener_handle, context_handle)
8
+ @listener_handle = listener_handle # The MSQUIC listener handle
9
+ @context_handle = context_handle # The C context pointer
10
+ # NOTE: Fetch this from the context handle, or improve return values from the C extension
11
+ @started = false
12
+ @stopped = false
13
+ @failed = false
14
+ @configuration = nil
15
+ end
16
+
17
+ def started?
18
+ @started
19
+ end
20
+
21
+ def stopped?
22
+ @stopped
23
+ end
24
+
25
+ def failed?
26
+ @failed
27
+ end
28
+ end
29
+ end