connectrpc-async 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,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Async
5
+ module Internal
6
+ # One queued fragment and the output task's one in-flight fragment. Queue
7
+ # ownership transfers before waking the producer; no wire-flush promise.
8
+ class OutputBody < ::Protocol::HTTP::Body::Readable
9
+ attr_reader :queued_bytes, :maximum_queued_bytes
10
+
11
+ def initialize
12
+ super
13
+ @changed = ::Async::Condition.new
14
+ @fragment = nil
15
+ @queued_bytes = @maximum_queued_bytes = 0
16
+ @ended = @closed = false
17
+ end
18
+
19
+ def write(bytes, timeout: nil)
20
+ raise ArgumentError, "Output fragments must fit one frame" if bytes.bytesize > 16_384
21
+
22
+ expires = Internal.now + timeout if timeout
23
+ while @fragment && !@closed
24
+ if expires
25
+ waiter = ::Async::Task.current.async(finished: false) { @changed.wait }
26
+ begin
27
+ Internal.wait(waiter, expires)
28
+ ensure
29
+ Internal.cancel(waiter)
30
+ Internal.join(waiter)
31
+ end
32
+ else
33
+ @changed.wait
34
+ end
35
+ end
36
+ raise(@error || IOError.new("Output is closed")) if @closed || @ended
37
+
38
+ @fragment = bytes.dup.freeze
39
+ @queued_bytes = bytes.bytesize
40
+ @maximum_queued_bytes = [@maximum_queued_bytes, @queued_bytes].max
41
+ @changed.signal
42
+ nil
43
+ end
44
+
45
+ def read
46
+ @changed.wait until @fragment || @ended || @closed
47
+ raise @error if @error
48
+
49
+ fragment = @fragment
50
+ @fragment = nil
51
+ @queued_bytes = 0
52
+ @changed.signal
53
+ fragment
54
+ end
55
+
56
+ def close_write
57
+ @ended = true
58
+ @changed.signal
59
+ nil
60
+ end
61
+
62
+ def close(error = nil)
63
+ return if @closed
64
+
65
+ @closed = true
66
+ @error = error
67
+ @fragment = nil
68
+ @queued_bytes = 0
69
+ @changed.signal
70
+ nil
71
+ end
72
+ end
73
+
74
+ # Publication and processing completion are independent single-assignment
75
+ # events. The processing task owns both, including exceptional cleanup.
76
+ class Publication
77
+ def initialize
78
+ @head = ::Async::Promise.new
79
+ @completion = ::Async::Promise.new
80
+ end
81
+
82
+ def publish(response)
83
+ raise LifecycleError, "Response head already published" if @published
84
+
85
+ @published = true
86
+ @transform&.call(response)
87
+ @body = response&.body
88
+ response.body = CompletionBody.new(@body, self) if response
89
+ @head.resolve([:response, response])
90
+ @head_ready = true
91
+ nil
92
+ rescue StandardError => e
93
+ @body = response&.body
94
+ complete(e)
95
+ raise
96
+ end
97
+
98
+ def complete(error = nil)
99
+ return if @completed
100
+
101
+ @completed = true
102
+ begin
103
+ @body&.close(error) if error
104
+ rescue StandardError => e
105
+ error ||= e
106
+ ensure
107
+ unless @head_ready
108
+ @head_ready = true
109
+ @head.resolve([:error, error || IOError.new("Processing ended without a response")])
110
+ end
111
+ @completion.resolve(error)
112
+ end
113
+ nil
114
+ end
115
+
116
+ def on_publish(&transform)
117
+ raise LifecycleError, "Response head already published" if @published
118
+
119
+ previous = @transform
120
+ @transform = lambda { |response|
121
+ previous&.call(response)
122
+ transform.call(response)
123
+ }
124
+ nil
125
+ end
126
+
127
+ def published? = @published
128
+
129
+ def response
130
+ tag, value = @head.wait
131
+ raise value if tag == :error
132
+
133
+ value
134
+ end
135
+
136
+ def wait
137
+ error = @completion.wait
138
+ raise error if error
139
+ end
140
+ end
141
+
142
+ class CompletionBody < ::Protocol::HTTP::Body::Wrapper
143
+ def initialize(body, publication)
144
+ super(body || ::Protocol::HTTP::Body::Buffered.wrap([]))
145
+ @publication = publication
146
+ end
147
+
148
+ # Even an empty body must retain this completion boundary.
149
+ def empty? = false
150
+ def length = nil
151
+
152
+ def read
153
+ bytes = super
154
+ @publication.wait unless bytes
155
+ bytes
156
+ end
157
+ end
158
+
159
+ class ServerOutput
160
+ def initialize(body)
161
+ @body = body
162
+ end
163
+
164
+ def write(bytes, timeout: nil) = @body.write(bytes, timeout: timeout)
165
+ def abort = @body.close(IOError.new("Processing abandoned"))
166
+ end
167
+
168
+ class GRPCInput < Input
169
+ def read(length, timeout: nil)
170
+ super
171
+ rescue ::Async::TimeoutError
172
+ raise Error.new(:unavailable, "gRPC input operation expired"), cause: nil
173
+ end
174
+ end
175
+
176
+ class GRPCServerOutput < ServerOutput
177
+ attr_writer :headers
178
+
179
+ def initialize(body, _trailers)
180
+ super(body)
181
+ end
182
+
183
+ def write(bytes, timeout: nil)
184
+ super
185
+ rescue ::Async::TimeoutError
186
+ raise Error.new(:unavailable, "gRPC output operation expired"), cause: nil
187
+ end
188
+
189
+ def finish(headers:)
190
+ GRPCProtocol.check_fields!(headers)
191
+ headers.each { |key, values| values.each { |value| @headers.add(key, value, trailer: true) } }
192
+ @body.close_write
193
+ nil
194
+ end
195
+ end
196
+
197
+ # ALL terminal bytes wait for the original processor, including an outer
198
+ # application's ensure. The consumer only slices already prepared bytes.
199
+ class GRPCWebBody < OutputBody
200
+ def initialize(publication)
201
+ super()
202
+ @publication = publication
203
+ @terminal_offset = 0
204
+ end
205
+
206
+ def stage_terminal(bytes)
207
+ @terminal = bytes.dup.freeze
208
+ close_write
209
+ nil
210
+ end
211
+
212
+ def read
213
+ bytes = super
214
+ return bytes if bytes
215
+
216
+ @publication.wait
217
+ return unless @terminal && @terminal_offset < @terminal.bytesize
218
+
219
+ bytes = @terminal.byteslice(@terminal_offset, 16_384)
220
+ @terminal_offset += bytes.bytesize
221
+ bytes
222
+ end
223
+
224
+ def close(error = nil)
225
+ @terminal = nil
226
+ super
227
+ end
228
+ end
229
+
230
+ class GRPCWebServerOutput < ServerOutput
231
+ def finish(terminal:)
232
+ @body.stage_terminal(terminal)
233
+ end
234
+ end
235
+
236
+ class StreamExchange
237
+ def initialize(transport, operation, body)
238
+ @transport = transport
239
+ @operation = operation
240
+ @body = body
241
+ @response = operation.fetch(:response)
242
+ end
243
+
244
+ def response_head(timeout: nil)
245
+ @response_head ||= perform(timeout: timeout) do
246
+ @operation.fetch(:connection).read_response(@response)
247
+ @input = Input.new(@response.body)
248
+ StreamHead.new(status: @response.status, headers: Internal.headers(@response))
249
+ end
250
+ end
251
+
252
+ def read(length, timeout: nil)
253
+ response_head(timeout: timeout)
254
+ perform(timeout: timeout) { @input.read(length) }
255
+ end
256
+
257
+ def write(bytes)
258
+ perform { @body.write(bytes) }
259
+ nil
260
+ end
261
+
262
+ def close_send
263
+ perform { @body.close_write }
264
+ nil
265
+ end
266
+
267
+ def finish(timeout: nil)
268
+ perform(timeout: timeout) { @response.stream.output_task&.wait }
269
+ nil
270
+ end
271
+
272
+ def abort
273
+ @body.close(IOError.new("Stream abandoned"))
274
+ nil
275
+ end
276
+
277
+ private
278
+
279
+ def perform(timeout: nil, &)
280
+ @transport.send(:stream_operation, @operation, timeout: timeout, &)
281
+ end
282
+ end
283
+
284
+ class GRPCStreamExchange < StreamExchange
285
+ def response_head(timeout: nil)
286
+ @response_head ||= perform(timeout: timeout) do
287
+ @operation.fetch(:connection).read_response(@response)
288
+ @input = Input.new(@response.body)
289
+ stream = @response.stream
290
+ raise stream.grpc_error if stream.grpc_error
291
+
292
+ GRPCHead.new(status: @response.status, headers: stream.grpc_initial_headers,
293
+ end_stream: stream.grpc_initial_end_stream)
294
+ end
295
+ end
296
+
297
+ def read(length, timeout: nil)
298
+ bytes = super
299
+ @read_eof = true if bytes.nil? || bytes.empty?
300
+ raise @response.stream.grpc_error if @response.stream.grpc_error
301
+
302
+ bytes
303
+ end
304
+
305
+ def response_trailers
306
+ raise StreamStateError, "Trailers require response EOF" unless @read_eof
307
+
308
+ @response.stream.grpc_trailers
309
+ end
310
+
311
+ def finish(timeout: nil)
312
+ super
313
+ stream = @response.stream
314
+ raise stream.grpc_error if stream.grpc_error
315
+ unless stream.grpc_initial_end_stream || stream.grpc_trailers
316
+ raise Error.new(:internal, "Missing final gRPC HEADERS"), cause: nil
317
+ end
318
+
319
+ nil
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+
6
+ module ConnectRPC
7
+ module Async
8
+ module Internal
9
+ class TLS
10
+ CERTIFICATE = %r{\A-----BEGIN CERTIFICATE-----\s+[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----\z}
11
+ PRIVATE_KEY = %r{
12
+ \A-----BEGIN\ (RSA\ PRIVATE\ KEY|EC\ PRIVATE\ KEY|PRIVATE\ KEY)-----
13
+ \s+[A-Za-z0-9+/=\s]+-----END\ \1-----\z
14
+ }x
15
+
16
+ def self.certificate(pem)
17
+ raise ArgumentError unless pem.is_a?(String) && CERTIFICATE.match?(pem.strip)
18
+
19
+ certificates = OpenSSL::X509::Certificate.load(pem.dup)
20
+ raise ArgumentError unless certificates.size == 1
21
+
22
+ certificate = certificates.first
23
+ raise ArgumentError unless certificate.not_before <= Time.now && certificate.not_after > Time.now
24
+
25
+ certificate
26
+ end
27
+
28
+ def self.identity(chain, pem)
29
+ raise ArgumentError unless chain.is_a?(Array) && !chain.empty?
30
+ raise ArgumentError unless pem.is_a?(String) && PRIVATE_KEY.match?(pem.strip)
31
+
32
+ certificates = chain.map { |element| certificate(element) }
33
+ key = OpenSSL::PKey.read(pem.dup) { nil }
34
+ digest = %w[ED25519 ED448].include?(key.oid) ? nil : "SHA256"
35
+ challenge = SecureRandom.random_bytes(32)
36
+ signature = key.sign(digest, challenge)
37
+ raise ArgumentError unless certificates.first.public_key.verify(digest, signature, challenge)
38
+
39
+ certificates.each_cons(2) do |child, issuer|
40
+ raise ArgumentError unless child.issuer == issuer.subject && child.verify(issuer.public_key)
41
+ end
42
+ [certificates, key]
43
+ end
44
+
45
+ def self.configure(context)
46
+ context.min_version = OpenSSL::SSL::TLS1_2_VERSION
47
+ context.options |= OpenSSL::SSL::OP_NO_TICKET
48
+ context.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_OFF
49
+ context.session_id_context = SecureRandom.random_bytes(16)
50
+ end
51
+
52
+ def self.server(certificate_chain:, private_key:, client_ca:, alpn_protocols: ["h2"])
53
+ certificates, key = identity(certificate_chain, private_key)
54
+ context = OpenSSL::SSL::SSLContext.new
55
+ configure(context)
56
+ context.add_certificate(certificates.first, key, certificates.drop(1))
57
+ unless client_ca.nil?
58
+ raise ArgumentError unless client_ca.is_a?(Array) && !client_ca.empty?
59
+
60
+ store = OpenSSL::X509::Store.new
61
+ anchors = client_ca.map { |pem| certificate(pem) }
62
+ anchors.uniq(&:to_der).each { |anchor| store.add_cert(anchor) }
63
+ store.flags = OpenSSL::X509::V_FLAG_PARTIAL_CHAIN
64
+ context.cert_store = store
65
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
66
+ end
67
+ context.alpn_select_cb = lambda do |protocols|
68
+ selected = alpn_protocols.find { |protocol| protocols.include?(protocol) }
69
+ raise OpenSSL::SSL::SSLError, "Unsupported ALPN" unless selected
70
+
71
+ selected
72
+ end
73
+ context.setup
74
+ trust = anchors.map(&:to_der).uniq.sort if anchors
75
+ canonical = [certificates.map(&:to_der), key.public_to_der, trust]
76
+ new(context, !client_ca.nil?, fingerprint: Digest::SHA256.digest(Marshal.dump(canonical)).freeze,
77
+ expires_at: certificates.map(&:not_after).min.freeze)
78
+ rescue StandardError
79
+ raise ArgumentError, "Invalid TLS snapshot", cause: nil
80
+ end
81
+
82
+ def self.client(ca_file:, certificate_chain:, private_key:, alpn_protocols: ["h2"])
83
+ context = OpenSSL::SSL::SSLContext.new
84
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, verify_hostname: true)
85
+ configure(context)
86
+ store = OpenSSL::X509::Store.new
87
+ store.set_default_paths
88
+ store.add_file(ca_file) if ca_file
89
+ context.cert_store = store
90
+ if certificate_chain || private_key
91
+ certificates, key = identity(certificate_chain, private_key)
92
+ context.add_certificate(certificates.first, key, certificates.drop(1))
93
+ end
94
+ context.alpn_protocols = alpn_protocols
95
+ context.setup
96
+ new(context, false)
97
+ rescue StandardError
98
+ raise ArgumentError, "Invalid TLS client configuration", cause: nil
99
+ end
100
+
101
+ def initialize(context, authenticated, fingerprint: nil, expires_at: nil)
102
+ @context = context
103
+ @authenticated = authenticated
104
+ @fingerprint = fingerprint
105
+ @expires_at = expires_at
106
+ freeze
107
+ end
108
+
109
+ attr_reader :fingerprint
110
+
111
+ def check_validity!
112
+ raise ArgumentError, "Expired TLS snapshot", cause: nil if @expires_at && Time.now >= @expires_at
113
+ end
114
+
115
+ def socket(raw)
116
+ OpenSSL::SSL::SSLSocket.new(raw, @context).tap { |socket| socket.sync_close = true }
117
+ end
118
+
119
+ def authenticated? = @authenticated
120
+ def inspect = "#<#{self.class} private TLS snapshot>"
121
+ end
122
+ end
123
+ end
124
+ end