faraday 2.14.4 → 2.15.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +11 -5
  3. data/lib/faraday/connection.rb +23 -16
  4. data/lib/faraday/encoders/nested_params_encoder.rb +1 -1
  5. data/lib/faraday/error.rb +29 -2
  6. data/lib/faraday/logging/formatter.rb +1 -1
  7. data/lib/faraday/options/proxy_options.rb +2 -2
  8. data/lib/faraday/options.rb +4 -3
  9. data/lib/faraday/rack_builder.rb +4 -4
  10. data/lib/faraday/response/raise_error.rb +7 -1
  11. data/lib/faraday/response.rb +5 -2
  12. data/lib/faraday/utils/headers.rb +41 -7
  13. data/lib/faraday/utils.rb +15 -3
  14. data/lib/faraday/version.rb +1 -1
  15. data/spec/external_adapters/faraday_specs_setup.rb +2 -0
  16. data/spec/support/integration/test_pki.rb +77 -0
  17. data/spec/support/integration/tls_server.rb +92 -0
  18. data/spec/support/integration_helper.rb +71 -0
  19. metadata +7 -31
  20. data/spec/faraday/adapter/test_spec.rb +0 -460
  21. data/spec/faraday/adapter_registry_spec.rb +0 -28
  22. data/spec/faraday/adapter_spec.rb +0 -55
  23. data/spec/faraday/connection_spec.rb +0 -860
  24. data/spec/faraday/error_spec.rb +0 -175
  25. data/spec/faraday/middleware_registry_spec.rb +0 -31
  26. data/spec/faraday/middleware_spec.rb +0 -213
  27. data/spec/faraday/options/env_spec.rb +0 -76
  28. data/spec/faraday/options/options_spec.rb +0 -297
  29. data/spec/faraday/options/proxy_options_spec.rb +0 -79
  30. data/spec/faraday/options/request_options_spec.rb +0 -19
  31. data/spec/faraday/params_encoders/flat_spec.rb +0 -42
  32. data/spec/faraday/params_encoders/nested_spec.rb +0 -179
  33. data/spec/faraday/rack_builder_spec.rb +0 -317
  34. data/spec/faraday/request/authorization_spec.rb +0 -118
  35. data/spec/faraday/request/instrumentation_spec.rb +0 -74
  36. data/spec/faraday/request/json_spec.rb +0 -199
  37. data/spec/faraday/request/url_encoded_spec.rb +0 -93
  38. data/spec/faraday/request_spec.rb +0 -119
  39. data/spec/faraday/response/json_spec.rb +0 -206
  40. data/spec/faraday/response/logger_spec.rb +0 -299
  41. data/spec/faraday/response/raise_error_spec.rb +0 -286
  42. data/spec/faraday/response_spec.rb +0 -84
  43. data/spec/faraday/utils/headers_spec.rb +0 -109
  44. data/spec/faraday/utils_spec.rb +0 -120
  45. data/spec/faraday_spec.rb +0 -69
  46. data/spec/spec_helper.rb +0 -142
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfb9bdb4e8b26d58a269ecb088231f9b6b3fa41dcba2e45030e148e3fed39c85
4
- data.tar.gz: f14d4a3d9fde7a433af42981764ef381347fbbe96414fe0f576a5d9e7d32c12e
3
+ metadata.gz: a700341d7d0844ede1248c479375ec0b3cb7fd51526c7c4abbaf9ce3db6c8a44
4
+ data.tar.gz: 76de6bace7c8995002b54ce7f19610a311418ef253c19a2fb354dee8ea986866
5
5
  SHA512:
6
- metadata.gz: 3f757a719af96c446b1c90a02ed43a190b551c489c72c4f158f041c8afdcec9385903e28f057929c217cdd29ed2a396316ffc650c66352be26503edd2a70eefa
7
- data.tar.gz: b34d0e1b1816abc2ae2987088abc08153acc0786582e742fe384418e8b8fc3ae4f5ee14b3e768d1f06ba8b1b44b4a10effd61858297db08e47775cf712e7f190
6
+ metadata.gz: 5bc51dff2b4420115e15f36532fa2f9c2a31aa60d28d9acd864dcd4a50185e28fe5ac4dbeab648915d6a605e3606d06e207c083b3387a3253040bc9c108d6264
7
+ data.tar.gz: 82893f3f9d2152459d2a7b9b271601926e723a0723b7c3d20dc615795ce3d210473f2b6f359ec68fb4cd0f0a590ecae3121c325a257040c33701094cf56966fb
data/Rakefile CHANGED
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rspec/core/rake_task'
4
3
  require 'bundler'
5
4
 
6
5
  Bundler::GemHelper.install_tasks
7
6
 
8
- RSpec::Core::RakeTask.new(:spec) do |task|
9
- task.ruby_opts = %w[-W]
10
- end
7
+ # The publish workflow sets FARADAY_RELEASE because its bundle (release/Gemfile) doesn't include RSpec.
8
+ unless ENV['FARADAY_RELEASE']
9
+ require 'rspec/core/rake_task'
10
+
11
+ RSpec::Core::RakeTask.new(:spec) do |task|
12
+ task.ruby_opts = %w[-W]
13
+ # Integration specs need service containers, see .github/workflows/integration-tests.yml
14
+ task.exclude_pattern = 'spec/integration/**/*_spec.rb'
15
+ end
11
16
 
12
- task default: :spec
17
+ task default: :spec
18
+ end
@@ -210,7 +210,7 @@ module Faraday
210
210
  #
211
211
  # @overload options(url, params = nil, headers = nil)
212
212
  # Makes an OPTIONS HTTP request to the given URL.
213
- # @param url [String, URI, nil] String base URL to sue as a prefix for all requests.
213
+ # @param url [String, URI, nil] String base URL to use as a prefix for all requests.
214
214
  # @param params [Hash, nil] Hash of URI query unencoded key/value pairs.
215
215
  # @param headers [Hash, nil] unencoded HTTP header key/value pairs.
216
216
  #
@@ -300,7 +300,10 @@ module Faraday
300
300
  end
301
301
  end
302
302
 
303
- # Determine if this Faraday::Connection can make parallel requests.
303
+ # Determine if this Faraday::Connection is currently making parallel
304
+ # requests, i.e. if it is called inside an {#in_parallel} block.
305
+ # Use {#support_parallel?} to check if the adapter can make parallel
306
+ # requests at all.
304
307
  #
305
308
  # @return [Boolean]
306
309
  def in_parallel?
@@ -315,13 +318,13 @@ module Faraday
315
318
  # @yield a block to execute multiple requests.
316
319
  # @return [void]
317
320
  def in_parallel(manager = nil, &block)
318
- @parallel_manager = manager || default_parallel_manager do
319
- warn 'Warning: `in_parallel` called but no parallel-capable adapter ' \
320
- 'on Faraday stack'
321
- warn caller[2, 10].join("\n")
321
+ previous_manager = @parallel_manager
322
+ @parallel_manager = manager || previous_manager || default_parallel_manager do
323
+ warn('Warning: `in_parallel` called but no parallel-capable adapter ' \
324
+ 'on Faraday stack', caller[2, 10].join("\n"))
322
325
  nil
323
326
  end
324
- return yield unless @parallel_manager
327
+ return yield unless @parallel_manager && !@parallel_manager.equal?(previous_manager)
325
328
 
326
329
  if @parallel_manager.respond_to?(:execute)
327
330
  # Execute is the new method that is responsible for executing the block.
@@ -332,7 +335,7 @@ module Faraday
332
335
  @parallel_manager.run
333
336
  end
334
337
  ensure
335
- @parallel_manager = nil
338
+ @parallel_manager = previous_manager
336
339
  end
337
340
 
338
341
  # Sets the Hash proxy options.
@@ -362,7 +365,7 @@ module Faraday
362
365
  #
363
366
  # conn.get("nigiri?page=2") # accesses https://httpbingo.org/api/nigiri
364
367
  def url_prefix=(url, encoder = nil)
365
- uri = @url_prefix = Utils.URI(url)
368
+ uri = @url_prefix = Utils.URI(url.to_s)
366
369
  self.path_prefix = uri.path
367
370
 
368
371
  params.merge_query(uri.query, encoder)
@@ -445,7 +448,7 @@ module Faraday
445
448
  req.options.proxy = proxy_for_request(url)
446
449
  req.url(url) if url
447
450
  req.headers.update(headers) if headers
448
- req.body = body if body
451
+ req.body = body unless body.nil?
449
452
  yield(req) if block_given?
450
453
  end
451
454
 
@@ -496,8 +499,6 @@ module Faraday
496
499
 
497
500
  # Creates a duplicate of this Faraday::Connection.
498
501
  #
499
- # @api private
500
- #
501
502
  # @return [Faraday::Connection]
502
503
  def dup
503
504
  self.class.new(build_exclusive_url,
@@ -505,7 +506,7 @@ module Faraday
505
506
  params: params.dup,
506
507
  builder: builder.dup,
507
508
  ssl: ssl.dup,
508
- request: options.dup)
509
+ request: options.dup).tap { |copy| copy.proxy = proxy if @manual_proxy }
509
510
  end
510
511
 
511
512
  # Yields username and password extracted from a URI if they both exist.
@@ -519,7 +520,7 @@ module Faraday
519
520
  def with_uri_credentials(uri)
520
521
  return unless uri.user && uri.password
521
522
 
522
- yield(Utils.unescape(uri.user), Utils.unescape(uri.password))
523
+ yield(URI::DEFAULT_PARSER.unescape(uri.user), URI::DEFAULT_PARSER.unescape(uri.password))
523
524
  end
524
525
 
525
526
  def proxy_from_env(url)
@@ -532,7 +533,7 @@ module Faraday
532
533
  uri = if uri.host.nil?
533
534
  find_default_proxy
534
535
  else
535
- URI.parse("#{uri.scheme}://#{uri.host}").find_proxy
536
+ URI.parse(uri.to_s).find_proxy
536
537
  end
537
538
  when URI
538
539
  uri = url.find_proxy
@@ -560,7 +561,13 @@ module Faraday
560
561
  end
561
562
  end
562
563
 
563
- def support_parallel?(adapter)
564
+ # Determine if an adapter can make parallel requests.
565
+ #
566
+ # @param adapter [Class, nil] the adapter class to check. Defaults to the
567
+ # adapter of this Faraday::Connection.
568
+ #
569
+ # @return [Boolean]
570
+ def support_parallel?(adapter = @builder.adapter&.klass)
564
571
  adapter.respond_to?(:supports_parallel?) && adapter&.supports_parallel?
565
572
  end
566
573
  end
@@ -162,7 +162,7 @@ module Faraday
162
162
  end
163
163
 
164
164
  if depth.positive? && !hash.empty? && hash.keys.all? { |k| k =~ /^\d+$/ }
165
- hash.sort.map(&:last)
165
+ hash.sort_by { |key, _| key.to_i }.map(&:last)
166
166
  else
167
167
  hash
168
168
  end
data/lib/faraday/error.rb CHANGED
@@ -70,7 +70,7 @@ module Faraday
70
70
  # to `super`. See NilStatusError.
71
71
  def exc_msg_and_response!(exc, response = nil)
72
72
  if @response.nil? && @wrapped_exception.nil?
73
- @wrapped_exception, msg, @response = exc_msg_and_response(exc, response)
73
+ @wrapped_exception, msg, @response = exc_msg_and_response(exc, omit_request_data(response))
74
74
  return msg
75
75
  end
76
76
 
@@ -85,7 +85,14 @@ module Faraday
85
85
  when Hash
86
86
  [nil, build_error_message_from_hash(exc), exc]
87
87
  when Faraday::Env
88
- [nil, build_error_message_from_env(exc), exc]
88
+ sanitized = omit_request_data(exc)
89
+ message =
90
+ if sanitized.equal?(exc)
91
+ build_error_message_from_env(exc)
92
+ else
93
+ build_error_message_from_hash(sanitized)
94
+ end
95
+ [nil, message, sanitized]
89
96
  else
90
97
  [nil, exc.to_s, response]
91
98
  end
@@ -93,6 +100,26 @@ module Faraday
93
100
 
94
101
  private
95
102
 
103
+ # When the RaiseError middleware opted this request out of request data
104
+ # (`include_request: false`), it marks the Env. Replace env-carrying
105
+ # responses (the Response object raised by the Json middleware, or an Env
106
+ # passed directly) with a request-less hash so that errors raised by other
107
+ # middlewares (e.g. ParsingError) cannot expose request headers and body.
108
+ # The body reads the explicit `response_body` member because `Env#body`
109
+ # aliases to the request body while the status is nil.
110
+ def omit_request_data(response)
111
+ env =
112
+ case response
113
+ when Faraday::Response
114
+ response.env if response.finished?
115
+ when Faraday::Env
116
+ response
117
+ end
118
+ return response unless env.is_a?(Faraday::Env) && env[:include_request] == false
119
+
120
+ { status: env.status, headers: env.response_headers, body: env.response_body }
121
+ end
122
+
96
123
  def build_error_message_from_hash(hash)
97
124
  # Be defensive with external Hash objects - they might be missing keys
98
125
  status = hash.fetch(:status, nil)
@@ -41,7 +41,7 @@ module Faraday
41
41
  def exception(exc)
42
42
  return unless log_errors?
43
43
 
44
- public_send(log_level) { "error: #{exc.full_message}" }
44
+ public_send(log_level) { "error: #{apply_filters(exc.full_message)}" }
45
45
 
46
46
  log_headers('error', exc.response_headers) if exc.respond_to?(:response_headers) && log_headers?(:error)
47
47
  return unless exc.respond_to?(:response_body) && exc.response_body && log_body?(:error)
@@ -42,7 +42,7 @@ module Faraday
42
42
  super
43
43
  end
44
44
 
45
- memoized(:user) { uri&.user && Utils.unescape(uri.user) }
46
- memoized(:password) { uri&.password && Utils.unescape(uri.password) }
45
+ memoized(:user) { uri&.user && URI::DEFAULT_PARSER.unescape(uri.user) }
46
+ memoized(:password) { uri&.password && URI::DEFAULT_PARSER.unescape(uri.password) }
47
47
  end
48
48
  end
@@ -74,7 +74,7 @@ module Faraday
74
74
 
75
75
  # Public
76
76
  def fetch(key, *args)
77
- unless symbolized_key_set.include?(key.to_sym)
77
+ unless members.include?(key.to_sym) && !send(key).nil?
78
78
  key_setter = "#{key}="
79
79
  if !args.empty?
80
80
  send(key_setter, args.first)
@@ -186,14 +186,15 @@ module Faraday
186
186
  def [](key)
187
187
  key = key.to_sym
188
188
  if (method = self.class.memoized_attributes[key])
189
- super || (self[key] = instance_eval(&method))
189
+ value = super
190
+ value.nil? ? (self[key] = instance_eval(&method)) : value
190
191
  else
191
192
  super
192
193
  end
193
194
  end
194
195
 
195
196
  def symbolized_key_set
196
- @symbolized_key_set ||= Set.new(keys.map(&:to_sym))
197
+ Set.new(keys.map(&:to_sym))
197
198
  end
198
199
 
199
200
  def self.inherited(subclass)
@@ -73,6 +73,9 @@ module Faraday
73
73
  super
74
74
  @adapter = original.adapter
75
75
  @handlers = original.handlers.dup
76
+ # @app is memoized on the first request. Drop it so the duplicate
77
+ # rebuilds from the copied handlers and middleware added after dup runs.
78
+ @app = nil
76
79
  end
77
80
 
78
81
  def build
@@ -115,6 +118,7 @@ module Faraday
115
118
  def adapter(klass = NO_ARGUMENT, *args, **kwargs, &block)
116
119
  return @adapter if klass == NO_ARGUMENT || klass.nil?
117
120
 
121
+ raise_if_locked
118
122
  klass = Faraday::Adapter.lookup_middleware(klass) if klass.is_a?(Symbol)
119
123
  @adapter = self.class::Handler.new(klass, *args, **kwargs, &block)
120
124
  end
@@ -230,10 +234,6 @@ module Faraday
230
234
  raise MISSING_ADAPTER_ERROR unless @adapter
231
235
  end
232
236
 
233
- def adapter_set?
234
- !@adapter.nil?
235
- end
236
-
237
237
  def use_symbol(mod, key, ...)
238
238
  use(mod.lookup_middleware(key), ...)
239
239
  end
@@ -20,7 +20,13 @@ module Faraday
20
20
  }.freeze
21
21
  # rubocop:enable Naming/ConstantName
22
22
 
23
- DEFAULT_OPTIONS = { include_request: true, allowed_statuses: [] }.freeze
23
+ DEFAULT_OPTIONS = { include_request: true, allowed_statuses: [].freeze }.freeze
24
+
25
+ def on_request(env)
26
+ # Announce the opt-out on the shared Env so that errors raised by other
27
+ # middlewares (e.g. ParsingError from the Json middleware) honor it too.
28
+ env[:include_request] = false unless options[:include_request]
29
+ end
24
30
 
25
31
  def on_complete(env)
26
32
  return if Array(options[:allowed_statuses]).include?(env[:status])
@@ -54,7 +54,9 @@ module Faraday
54
54
  raise 'response already finished' if finished?
55
55
 
56
56
  @env = env.is_a?(Env) ? env : Env.from(env)
57
- @on_complete_callbacks.each { |callback| callback.call(@env) }
57
+ callbacks = @on_complete_callbacks
58
+ @on_complete_callbacks = []
59
+ callbacks.each { |callback| callback.call(@env) }
58
60
  self
59
61
  end
60
62
 
@@ -76,7 +78,8 @@ module Faraday
76
78
  end
77
79
 
78
80
  def marshal_load(env)
79
- @env = Env.from(env)
81
+ @env = Env.from(env) if env
82
+ @on_complete_callbacks = []
80
83
  end
81
84
 
82
85
  # Expand the env with more properties, without overriding existing ones.
@@ -38,13 +38,11 @@ module Faraday
38
38
 
39
39
  # symbol -> string mapper + cache
40
40
  KeyMap = Hash.new do |map, key|
41
- value = if key.respond_to?(:to_str)
42
- key
43
- else
44
- key.to_s.split('_') # user_agent: %w(user agent)
45
- .each(&:capitalize!) # => %w(User Agent)
46
- .join('-') # => "User-Agent"
47
- end
41
+ next key if key.respond_to?(:to_str)
42
+
43
+ value = key.to_s.split('_') # user_agent: %w(user agent)
44
+ .each(&:capitalize!) # => %w(User Agent)
45
+ .join('-') # => "User-Agent"
48
46
  keymap_mutex.synchronize { map[key] = value }
49
47
  end
50
48
  KeyMap[:etag] = 'ETag'
@@ -62,6 +60,8 @@ module Faraday
62
60
  super
63
61
  end
64
62
 
63
+ alias store []=
64
+
65
65
  def fetch(key, ...)
66
66
  key = KeyMap[key]
67
67
  key = @names.fetch(key.downcase, key)
@@ -77,6 +77,28 @@ module Faraday
77
77
  super
78
78
  end
79
79
 
80
+ def clear
81
+ @names.clear
82
+ super
83
+ end
84
+
85
+ def shift = rebuilding_names { super }
86
+ def compact! = rebuilding_names { super }
87
+ def rehash = rebuilding_names { super }
88
+
89
+ # Without a block these return an enumerator that calls back into the
90
+ # method once iterated, so the index only needs rebuilding in that call.
91
+ def delete_if = block_given? ? rebuilding_names { super } : super
92
+ def keep_if = block_given? ? rebuilding_names { super } : super
93
+ def reject! = block_given? ? rebuilding_names { super } : super
94
+ def select! = block_given? ? rebuilding_names { super } : super
95
+ alias filter! select!
96
+
97
+ # A mapping hash is a valid alternative to a block.
98
+ def transform_keys!(*args, &block)
99
+ args.empty? && !block ? super : rebuilding_names { super }
100
+ end
101
+
80
102
  def dig(key, *rest)
81
103
  key = KeyMap[key]
82
104
  key = @names.fetch(key.downcase, key)
@@ -136,6 +158,18 @@ module Faraday
136
158
 
137
159
  private
138
160
 
161
+ # Runs the block, then rebuilds the name index even if the block raised
162
+ # or exited early, as the underlying Hash may already be partially mutated.
163
+ def rebuilding_names
164
+ yield
165
+ ensure
166
+ rebuild_names
167
+ end
168
+
169
+ def rebuild_names
170
+ @names = each_key.to_h { |key| [KeyMap[key].downcase, key] }
171
+ end
172
+
139
173
  # Join multiple values with a comma.
140
174
  def add_parsed(key, value)
141
175
  if key?(key)
data/lib/faraday/utils.rb CHANGED
@@ -27,10 +27,22 @@ module Faraday
27
27
 
28
28
  ESCAPE_RE = /[^a-zA-Z0-9 .~_-]/
29
29
 
30
+ # CGI.escape works on raw bytes: it matches ESCAPE_RE only where no multibyte character
31
+ # hides an ASCII byte (Shift_JIS 'ア' is "\x83A"), unlike ESCAPE_RE it doesn't raise on
32
+ # invalid bytes, and it returns String subclasses (SafeBuffer) as is when nothing is escaped.
33
+ CGI_ESCAPE_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
34
+ private_constant :CGI_ESCAPE_ENCODINGS
35
+
30
36
  def escape(str)
31
- str.to_s.gsub(ESCAPE_RE) do |match|
32
- "%#{match.unpack('H2' * match.bytesize).join('%').upcase}"
33
- end.gsub(' ', default_space_encoding)
37
+ str = str.to_s
38
+ if str.instance_of?(String) && CGI_ESCAPE_ENCODINGS.include?(str.encoding) && str.valid_encoding?
39
+ escaped = CGI.escape(str)
40
+ default_space_encoding == '+' ? escaped : escaped.gsub('+', default_space_encoding)
41
+ else
42
+ str.gsub(ESCAPE_RE) do |match|
43
+ "%#{match.unpack('H2' * match.bytesize).join('%').upcase}"
44
+ end.gsub(' ', default_space_encoding)
45
+ end
34
46
  end
35
47
 
36
48
  def unescape(str)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Faraday
4
- VERSION = '2.14.4'
4
+ VERSION = '2.15.1'
5
5
  end
@@ -3,6 +3,8 @@
3
3
  require 'webmock/rspec'
4
4
  WebMock.disable_net_connect!(allow_localhost: true)
5
5
 
6
+ # The gem ships only `spec/external_adapters` and `spec/support` (see `spec.files` in
7
+ # faraday.gemspec). Requiring a file from anywhere else means updating the gemspec too.
6
8
  require_relative '../support/helper_methods'
7
9
  require_relative '../support/disabling_stub'
8
10
  require_relative '../support/streaming_response_checker'
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Faraday
6
+ module IntegrationHelpers
7
+ # Throwaway certificate authority used by the SSL and proxy integration
8
+ # specs. Everything is generated in memory, so no certificate files need to
9
+ # be committed or generated by the CI workflow.
10
+ class TestPKI
11
+ Credentials = Struct.new(:cert, :key)
12
+
13
+ attr_reader :ca_cert, :ca_key
14
+
15
+ def initialize
16
+ @ca_key = OpenSSL::PKey::RSA.new(2048)
17
+ @ca_cert = build_certificate('Faraday Test CA', ca_key, ca_key, nil) do |cert, factory|
18
+ [
19
+ factory.create_extension('basicConstraints', 'CA:TRUE', true),
20
+ factory.create_extension('keyUsage', 'keyCertSign,cRLSign', true),
21
+ factory.create_extension('subjectKeyIdentifier', 'hash', false)
22
+ ].each { |ext| cert.add_extension(ext) }
23
+ end
24
+ end
25
+
26
+ # Issues a certificate signed by this CA.
27
+ #
28
+ # @param common_name [String] certificate subject CN
29
+ # @param dns_names [Array<String>] DNS subjectAltNames
30
+ # @param ip_addresses [Array<String>] IP subjectAltNames
31
+ # @return [Credentials]
32
+ def issue(common_name, dns_names: [], ip_addresses: [])
33
+ key = OpenSSL::PKey::RSA.new(2048)
34
+ sans = dns_names.map { |name| "DNS:#{name}" } + ip_addresses.map { |ip| "IP:#{ip}" }
35
+
36
+ cert = build_certificate(common_name, key, ca_key, ca_cert) do |built, factory|
37
+ built.add_extension(factory.create_extension('basicConstraints', 'CA:FALSE', true))
38
+ built.add_extension(factory.create_extension('subjectAltName', sans.join(','), false)) if sans.any?
39
+ end
40
+
41
+ Credentials.new(cert, key)
42
+ end
43
+
44
+ # Writes the CA certificate to disk, as expected by the `ca_file` option.
45
+ def write_ca_file(path)
46
+ File.write(path, ca_cert.to_pem)
47
+ path
48
+ end
49
+
50
+ # @return [OpenSSL::X509::Store] a store that only trusts this CA
51
+ def cert_store
52
+ OpenSSL::X509::Store.new.tap { |store| store.add_cert(ca_cert) }
53
+ end
54
+
55
+ private
56
+
57
+ def build_certificate(common_name, key, signing_key, issuer_cert)
58
+ cert = OpenSSL::X509::Certificate.new
59
+ cert.version = 2
60
+ cert.serial = rand(1..(2**32))
61
+ cert.subject = OpenSSL::X509::Name.parse("/CN=#{common_name}")
62
+ cert.issuer = issuer_cert ? issuer_cert.subject : cert.subject
63
+ cert.public_key = key.public_key
64
+ cert.not_before = Time.now - 60
65
+ cert.not_after = Time.now + 3600
66
+
67
+ factory = OpenSSL::X509::ExtensionFactory.new
68
+ factory.subject_certificate = cert
69
+ factory.issuer_certificate = issuer_cert || cert
70
+ yield cert, factory
71
+
72
+ cert.sign(signing_key, OpenSSL::Digest.new('SHA256'))
73
+ cert
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'socket'
6
+
7
+ module Faraday
8
+ module IntegrationHelpers
9
+ # Minimal HTTPS server running in a background thread, used to exercise
10
+ # SSLOptions and ProxyOptions against real TLS handshakes.
11
+ #
12
+ # Every request is answered with a JSON document describing what the server
13
+ # saw, e.g. `{"path":"/","client_cert_subject":null,"remote_ip":"127.0.0.1"}`.
14
+ class TLSServer
15
+ attr_reader :port
16
+
17
+ # @param credentials [TestPKI::Credentials] server certificate and key
18
+ # @param client_ca [TestPKI, nil] when given, clients must present a
19
+ # certificate signed by this CA (mutual TLS)
20
+ # @param host [String] address to listen on; '0.0.0.0' makes the server
21
+ # reachable from service containers, such as the proxies
22
+ def initialize(credentials, client_ca: nil, host: '127.0.0.1')
23
+ @tcp_server = TCPServer.new(host, 0)
24
+ @port = @tcp_server.addr[1]
25
+ @ssl_server = OpenSSL::SSL::SSLServer.new(@tcp_server, ssl_context(credentials, client_ca))
26
+ @ssl_server.start_immediately = false
27
+ @threads = []
28
+ end
29
+
30
+ def self.start(...)
31
+ new(...).start
32
+ end
33
+
34
+ def start
35
+ @acceptor = Thread.new do
36
+ loop do
37
+ socket = @ssl_server.accept
38
+ @threads << Thread.new { handle(socket) }
39
+ end
40
+ rescue IOError, Errno::EBADF
41
+ nil # server stopped
42
+ end
43
+ self
44
+ end
45
+
46
+ def stop
47
+ @ssl_server.close
48
+ @acceptor&.join(1)
49
+ @threads.each { |thread| thread.join(1) }
50
+ end
51
+
52
+ def url(host = 'localhost')
53
+ "https://#{host}:#{port}"
54
+ end
55
+
56
+ private
57
+
58
+ def ssl_context(credentials, client_ca)
59
+ OpenSSL::SSL::SSLContext.new.tap do |ctx|
60
+ ctx.cert = credentials.cert
61
+ ctx.key = credentials.key
62
+ if client_ca
63
+ ctx.cert_store = client_ca.cert_store
64
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
65
+ end
66
+ end
67
+ end
68
+
69
+ def handle(socket)
70
+ socket.accept
71
+ request_line = socket.gets
72
+ while (line = socket.gets)
73
+ break if line.strip.empty?
74
+ end
75
+ respond(socket, request_line.to_s.split[1])
76
+ rescue OpenSSL::SSL::SSLError, SystemCallError, IOError
77
+ nil # the client rejected the handshake or went away
78
+ ensure
79
+ socket.close unless socket.closed?
80
+ end
81
+
82
+ def respond(socket, path)
83
+ body = JSON.generate(path: path, client_cert_subject: socket.peer_cert&.subject&.to_s,
84
+ remote_ip: socket.io.remote_address.ip_address)
85
+ socket.write("HTTP/1.1 200 OK\r\n" \
86
+ "Content-Type: application/json\r\n" \
87
+ "Content-Length: #{body.bytesize}\r\n" \
88
+ "Connection: close\r\n\r\n#{body}")
89
+ end
90
+ end
91
+ end
92
+ end