dalli 5.0.11 → 5.0.13

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43164df9e3e36d747346b72c44ebe1a7a3be0dee501c83b2ef813a39d14d9bb9
4
- data.tar.gz: e8e1ecbf3d019891da3935c2d0c805f09bfc8a9746df14d9a6954f8cdecfdc10
3
+ metadata.gz: f386fb06afa43acffc1a4c63b7c611930d6302c90d10df138b8a0c24182fadcb
4
+ data.tar.gz: 5fd63314770f91b3f3668613e37f19fbe582c7e31ba1a5043155c5cd999b3560
5
5
  SHA512:
6
- metadata.gz: e5b73ad3b6a075030a75f7f7903a097611cfd862f65a80a549d11f1c34c900205ac4ed4783392be4ee139c598b6de346ddaf782460c0ec11e8fc31847b4cb4aa
7
- data.tar.gz: 5978a35d174e82c4cdc1dafdd439395f64035517f5a4592ee0ad1724e6b9b4241551c5b5990e6a6823d433e68604697bb43940e8bf3b82b15952325264e8b446
6
+ metadata.gz: 0d5368195322b97078321fe4385be067cd004e3d66dcf1e65329474d9161fcfeee3a227ec1988dbc693b27cd5bd91dfc6ab9332ee8a5509e321fa8baafe16b55
7
+ data.tar.gz: c18b9a743113cf553698a0238d9d773d498cd93a21f1593b798ff5432438a6be00b86ae049157026cc06541491117b19cead5c432777a4e372c902df5b17fa68
data/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.0.13
8
+ ==========
9
+
10
+ Bug fixes:
11
+
12
+ - `set_multi` across more than one server stored `nil` for a Symbol key or a key over 250 bytes, in place of the value given
13
+
14
+ 5.0.12
15
+ ==========
16
+
17
+ Security:
18
+
19
+ - Keep a connection shared by several threads or fibers in step during a `quiet` block (GHSA-5xvx-r8rg-5qm2)
20
+ - A `quiet` (or `multi`) block is quiet only for the thread or fiber that runs it. memcached still replies to a quiet request that fails (for example an `add` of a key that exists), and another thread or fiber using the same client while the block was open read that reply as its own, so its later requests could return another key's value. A non-quiet request now reads any pending quiet replies first
21
+ - Don't let a `get_multi` that stopped early leave replies for a later request (GHSA-5xvx-r8rg-5qm2)
22
+ - A pipelined `get_multi` stopped by a non-network exception (its block raising or breaking, `Timeout.timeout`, or a value that can't be deserialized) left its unread replies in the response buffer, and a later `get_multi` large enough to use the interleaved path returned them as its own. The buffer is now cleared when a connection closes or a pipelined get starts, and unfinished pipelines are aborted
23
+ - Affects every supported line (the `get_multi` issue, 4.3 and later); fixed in 5.2.4, 5.1.6, 5.0.12, 4.3.9 and 3.2.15
24
+
25
+ Bug fixes:
26
+
27
+ - A per-request `raw: true` write no longer compresses the value (backport of #1213)
28
+ - Values at or above the compression threshold (4 KB by default) were compressed and flagged even with `raw: true`. Raw reads ignore flags, so reading them back with `raw: true` returned the compressed bytes
29
+ - Raw values are now stored exactly as given. Callers that pass `raw: true` without `compress: false` store large values uncompressed; Rails' `MemCacheStore` passes `compress: false` and isn't affected
30
+ - Handle failed TLS handshakes like other connection failures, and warn when TLS verification is off (backport of #1215)
31
+ - A failed or timed-out handshake left the TCP socket open until GC and raised `OpenSSL::SSL::SSLError` directly, so the server was never marked down. The socket is now closed, and the error goes through the usual retry and `socket_max_failures` handling, raising `Dalli::NetworkError` (or `Dalli::RingError` once no server is available)
32
+ - Certificate and hostname checks come only from the `ssl_context` you pass, and a bare `OpenSSL::SSL::SSLContext.new` checks neither. Dalli now logs a warning once per process when `verify_mode` doesn't include `VERIFY_PEER` or `verify_hostname` is false; see the new TLS section in the README
33
+ - A reply line longer than 8 KiB without a CRLF, or one cut short by the connection closing, is now a network error that closes the connection. Before, the line had no length limit (backport of #1216)
34
+ - One value that can't be stored no longer discards other buffered writes (backport of #1218)
35
+ - A value that raised `Dalli::MarshalError` or `Dalli::ValueOverMaxSize` closed the connection, which dropped writes still buffered from the same `quiet` block or `set_multi`. Multi-server `set_multi` did this without raising
36
+ - Both `set_multi` paths now store every other key, skip the bad one, and then raise. Single-server `set_multi` used to store nothing
37
+ - When single-server `set_multi` failed to marshal a value, the error log included every value in the batch. It now lists only the keys
38
+
7
39
  5.0.11
8
40
  ==========
9
41
 
data/README.md CHANGED
@@ -62,6 +62,25 @@ See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
62
62
 
63
63
  memcached names an item made by auto-create for a base64-encoded key (one with whitespace, control or non-ASCII characters) by its raw key bytes in later replies. Since 5.0.11, Dalli creates counters for such keys with an add rather than auto-create. Items created before then, or by other clients, can still be named that way until memcached releases a fix (1.6.45 and earlier behave this way). If keys passed to `incr` or `decr` with an initial value can contain untrusted input, set `verify_reply_keys: true` until then: multi-gets then ignore replies for keys they didn't request, at a cost of about 15% per multi-get.
64
64
 
65
+ ## TLS
66
+
67
+ Pass an `OpenSSL::SSL::SSLContext` as `ssl_context:` to connect over TLS. Certificate and hostname verification come only from that context: Dalli adds no checks of its own. A bare `OpenSSL::SSL::SSLContext.new` verifies nothing, so it accepts any certificate from any server. Configure the context to verify both:
68
+
69
+ ```ruby
70
+ ssl_context = OpenSSL::SSL::SSLContext.new
71
+ ssl_context.set_params(
72
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
73
+ verify_hostname: true,
74
+ ca_file: '/path/to/ca.crt' # omit to use the system's trusted certificates
75
+ )
76
+
77
+ Dalli::Client.new('memcached.example.com:11211', ssl_context: ssl_context)
78
+ ```
79
+
80
+ Dalli logs a warning once per process when the context's `verify_mode` is `VERIFY_NONE`, or when it is `VERIFY_PEER` but `verify_hostname` is false.
81
+
82
+ A failed TLS handshake counts as a failed connection attempt, like a refused connection: once `socket_max_failures` attempts have failed, the server is marked down and requests fail over to the other servers.
83
+
65
84
  ## OpenTelemetry Tracing
66
85
 
67
86
  Dalli automatically instruments operations with [OpenTelemetry](https://opentelemetry.io/) when the SDK is present. No configuration is required - just add the OpenTelemetry gems to your application:
data/lib/dalli/client.rb CHANGED
@@ -41,9 +41,10 @@ module Dalli
41
41
  # #fetch operations.
42
42
  # - :raw - If set, disables serialization and compression entirely at the client level.
43
43
  # Only String values are supported. This is useful when the caller handles its own
44
- # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
45
- # the per-request :raw option which converts values to strings but still uses the
46
- # serialization pipeline.
44
+ # serialization (e.g., Rails' ActiveSupport::Cache). The per-request :raw option
45
+ # (e.g. set(key, value, ttl, raw: true)) does the same for a single call: String
46
+ # values are stored as-is, uncompressed, and non-String values raise
47
+ # Dalli::MarshalError.
47
48
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
48
49
  # useful for injecting a FIPS compliant hash object.
49
50
  # - :otel_db_statement - controls the +db.query.text+ span attribute when OpenTelemetry is loaded.
@@ -293,6 +294,11 @@ module Dalli
293
294
  # Any error replies will be discarded at the end of the block, and
294
295
  # Dalli client methods invoked inside the block will not
295
296
  # have return values
297
+ #
298
+ # The block is quiet only for the thread or fiber that runs it. Another
299
+ # thread or fiber sharing this client can make requests meanwhile; a
300
+ # non-quiet one first sends the block's buffered requests and discards
301
+ # their replies, so it reads only its own.
296
302
  def quiet
297
303
  old = Thread.current[::Dalli::QUIET]
298
304
  Thread.current[::Dalli::QUIET] = true
data/lib/dalli/options.rb CHANGED
@@ -43,7 +43,7 @@ module Dalli
43
43
  end
44
44
  end
45
45
 
46
- def pipeline_abort
46
+ def pipeline_abort(...)
47
47
  @lock.synchronize do
48
48
  super
49
49
  end
@@ -24,15 +24,21 @@ module Dalli
24
24
  return {} if keys.empty?
25
25
 
26
26
  @ring.lock do
27
- # Stores partial results collected during interleaved send phase
28
- @partial_results = {}
29
- servers = setup_requests(keys)
30
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ # The servers with replies still to read, updated in place
28
+ servers = []
29
+ begin
30
+ # Stores partial results collected during interleaved send phase
31
+ @partial_results = {}
32
+ setup_requests(servers, keys)
33
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
34
 
32
- # First yield any partial results collected during interleaved send
33
- yield_partial_results(&block)
35
+ # First yield any partial results collected during interleaved send
36
+ yield_partial_results(&block)
34
37
 
35
- servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
38
+ fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
39
+ ensure
40
+ abort_unfinished(servers)
41
+ end
36
42
  end
37
43
  rescue Dalli::RetryableNetworkError => e
38
44
  Dalli.logger.debug { e.inspect }
@@ -49,12 +55,15 @@ module Dalli
49
55
  @partial_results.clear
50
56
  end
51
57
 
52
- def setup_requests(keys)
58
+ # Adds the servers with a pending response to servers before writing
59
+ # their queries, so #process can abort them if it stops early.
60
+ def setup_requests(servers, keys)
53
61
  groups = groups_for_keys(keys)
62
+ servers.concat(groups.keys)
54
63
  make_getkq_requests(groups)
55
64
 
56
65
  # TODO: How does this exit on a NetworkError
57
- finish_queries(groups.keys)
66
+ finish_queries(servers)
58
67
  end
59
68
 
60
69
  ##
@@ -116,29 +125,39 @@ module Dalli
116
125
  raise
117
126
  end
118
127
 
119
- # Swallows Dalli::NetworkError
128
+ # Swallows Dalli::NetworkError. Empties servers, so they aren't aborted
129
+ # again.
120
130
  def abort_without_timeout(servers)
121
131
  servers.each(&:pipeline_abort)
132
+ servers.clear
133
+ end
134
+
135
+ # When #process stops before reading every server's replies (the caller's
136
+ # block raises or breaks, a Timeout.timeout fires, a value can't be
137
+ # deserialized), the rest are still on the socket and some may be in the
138
+ # response buffer. Closing those connections keeps them from being read
139
+ # as a later request's replies. Aborts that #process already handled
140
+ # emptied servers.
141
+ def abort_unfinished(servers)
142
+ servers.each { |server| server.pipeline_abort(timed_out: false) }
143
+ servers.clear
122
144
  end
123
145
 
146
+ # Reads the replies available, removing finished servers from servers
124
147
  def fetch_responses(servers, start_time, timeout, &block)
125
148
  # Remove any servers which are not connected
126
149
  servers.select!(&:connected?)
127
- return [] if servers.empty?
150
+ return if servers.empty?
128
151
 
129
152
  time_left = remaining_time(start_time, timeout)
130
153
  readable_servers = servers_with_response(servers, time_left)
131
- if readable_servers.empty?
132
- abort_with_timeout(servers)
133
- return []
134
- end
154
+ return abort_with_timeout(servers) if readable_servers.empty?
135
155
 
136
156
  # Loop through the servers with responses, and
137
157
  # delete any from our list that are finished
138
158
  readable_servers.each do |server|
139
159
  servers.delete(server) if process_server(server, &block)
140
160
  end
141
- servers
142
161
  rescue NetworkError
143
162
  # Abort and raise if we encountered a network error. This triggers
144
163
  # a retry at the top level on RetryableNetworkError.
@@ -155,12 +174,10 @@ module Dalli
155
174
 
156
175
  # Swallows Dalli::NetworkError
157
176
  def abort_with_timeout(servers)
158
- abort_without_timeout(servers)
159
177
  servers.each do |server|
160
178
  Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
161
179
  end
162
-
163
- true # Required to simplify caller
180
+ abort_without_timeout(servers)
164
181
  end
165
182
 
166
183
  # Processes responses from a server. Returns true if there are no
@@ -16,6 +16,9 @@ module Dalli
16
16
  # Writes multiple key-value pairs to memcached.
17
17
  # Raises an error if any server is unavailable.
18
18
  #
19
+ # A value that can't be marshalled or is over value_max_bytes is skipped;
20
+ # the other keys are still stored, then the first such error is raised.
21
+ #
19
22
  # @param hash [Hash] key-value pairs to set
20
23
  # @param ttl [Integer] time-to-live in seconds
21
24
  # @param req_options [Hash] options passed to each set operation
@@ -24,10 +27,12 @@ module Dalli
24
27
  def process(hash, ttl, req_options)
25
28
  return if hash.empty?
26
29
 
30
+ @value_error = nil
27
31
  @ring.lock do
28
32
  servers = setup_requests(hash, ttl, req_options)
29
33
  finish_requests(servers)
30
34
  end
35
+ raise @value_error if @value_error
31
36
  rescue Dalli::RetryableNetworkError => e
32
37
  Dalli.logger.debug { e.inspect }
33
38
  Dalli.logger.debug { 'retrying pipelined sets because of network error' }
@@ -36,9 +41,12 @@ module Dalli
36
41
 
37
42
  private
38
43
 
44
+ # Values are looked up by each key as sent. The caller's key can't be
45
+ # rebuilt from it: a long key is digested, and a Symbol becomes a String.
39
46
  def setup_requests(hash, ttl, req_options)
40
- groups = groups_for_keys(hash.keys)
41
- make_set_requests(groups, hash, ttl, req_options)
47
+ pairs = hash.transform_keys { |k| @key_manager.validate_key(k.to_s) }
48
+ groups = groups_for_keys(pairs.keys)
49
+ make_set_requests(groups, pairs, ttl, req_options)
42
50
  groups.keys
43
51
  end
44
52
 
@@ -46,12 +54,14 @@ module Dalli
46
54
  # Loop through the server-grouped sets of keys, writing
47
55
  # the corresponding quiet set requests to the appropriate servers
48
56
  ##
49
- def make_set_requests(groups, hash, ttl, req_options)
57
+ def make_set_requests(groups, pairs, ttl, req_options)
50
58
  groups.each do |server, keys_for_server|
51
59
  keys_for_server.each do |key|
52
- original_key = @key_manager.key_without_namespace(key)
53
- value = hash[original_key]
54
- server.request(:pipelined_set, key, value, ttl, req_options)
60
+ server.request(:pipelined_set, key, pairs[key], ttl, req_options)
61
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
62
+ # Nothing was written for this key, so the server's other keys are
63
+ # unaffected; raised by #process once the batch is complete
64
+ @value_error ||= e
55
65
  rescue DalliError, NetworkError => e
56
66
  Dalli.logger.debug { e.inspect }
57
67
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
@@ -72,8 +82,7 @@ module Dalli
72
82
  end
73
83
 
74
84
  def groups_for_keys(keys)
75
- validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
76
- groups = @ring.keys_grouped_by_server(validated_keys)
85
+ groups = @ring.keys_grouped_by_server(keys)
77
86
 
78
87
  if (unfound_keys = groups.delete(nil))
79
88
  Dalli.logger.debug do
@@ -51,15 +51,25 @@ module Dalli
51
51
  begin
52
52
  request_completed = false
53
53
  @connection_manager.start_request!
54
+ drain_pending_quiet_responses(opkey)
54
55
  response = send(opkey, *args)
56
+ # Quiet requests can leave replies on the socket; remember that so a
57
+ # later non-quiet request knows to read them first
58
+ @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
55
59
 
56
60
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
57
61
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
58
62
 
59
63
  request_completed = true
60
64
  response
61
- rescue Dalli::MarshalError => e
62
- log_marshal_err(args.first, e)
65
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
66
+ log_marshal_err(args.first, e) if e.is_a?(Dalli::MarshalError)
67
+ # Raised only while the connection is clean: a value is marshalled
68
+ # before any of its request is written, and write_multi_req raises
69
+ # only after reading every reply. Closing the connection would
70
+ # discard earlier quiet writes still waiting in the write buffer.
71
+ @connection_manager.abort_request!
72
+ request_completed = true
63
73
  raise
64
74
  rescue Dalli::DalliError
65
75
  raise
@@ -106,7 +116,7 @@ module Dalli
106
116
  verify_pipelined_state(:getkq)
107
117
  write_noop
108
118
  # Use ensure_ready instead of reset to preserve any data already buffered
109
- # during interleaved pipelined get draining
119
+ # while this get_multi's interleaved requests were sent
110
120
  response_buffer.ensure_ready
111
121
  end
112
122
 
@@ -160,6 +170,10 @@ module Dalli
160
170
  end
161
171
  end
162
172
 
173
+ # A request to this server from inside the block closes the
174
+ # connection, and with it the rest of this get_multi's replies
175
+ raise_pipeline_closed unless response_buffer.in_progress?
176
+
163
177
  # Get the next response from the buffer
164
178
  status, cas, key, value = response_buffer.process_single_getk_response
165
179
  end
@@ -169,21 +183,24 @@ module Dalli
169
183
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
170
184
 
171
185
  # Abort current pipelined get. Generally used to signal an external
172
- # timeout during pipelined get. The underlying socket is
186
+ # timeout during pipelined get, or that the get_multi stopped before
187
+ # reading all its replies (timed_out: false). The underlying socket is
173
188
  # disconnected, and the exception is swallowed.
174
189
  #
175
190
  # Returns nothing.
176
- def pipeline_abort
191
+ def pipeline_abort(timed_out: true)
177
192
  response_buffer.clear
193
+ @pipeline_out_of_step = false
194
+ @expected_reply_keys = nil
178
195
  @connection_manager.abort_request!
179
196
  return true unless connected?
180
197
 
181
198
  # Closes the connection, which ensures that our connection is in a
182
199
  # clean state for future requests. The get_multi ran out of time
183
- # overall rather than hitting a socket error, so it doesn't count
184
- # toward socket_max_failures: two slow get_multis in a row would
185
- # otherwise mark a healthy server down.
186
- Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
200
+ # overall, or stopped early, rather than hitting a socket error, so it
201
+ # doesn't count toward socket_max_failures: two slow get_multis in a
202
+ # row would otherwise mark a healthy server down.
203
+ log_pipeline_abort(timed_out)
187
204
  @connection_manager.close
188
205
  true
189
206
  end
@@ -215,6 +232,18 @@ module Dalli
215
232
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
216
233
  private_constant :ALLOWED_QUIET_OPS
217
234
 
235
+ # Reads the replies still pending from quiet requests before any
236
+ # non-quiet request, which reads a reply of its own. A quiet block reads
237
+ # them at its end, but the connection is shared: another thread or fiber
238
+ # (quiet? is fiber-local) can make a request while the block is still
239
+ # open, and would otherwise read the block's replies as its own. The
240
+ # noop also sends any quiet requests still buffered.
241
+ def drain_pending_quiet_responses(opkey)
242
+ return unless @connection_manager.quiet_responses_pending? && opkey != :noop && !quiet?
243
+
244
+ noop
245
+ end
246
+
218
247
  def verify_allowed_quiet!(opkey)
219
248
  return if ALLOWED_QUIET_OPS.include?(opkey)
220
249
 
@@ -289,8 +318,9 @@ module Dalli
289
318
  # This prevents socket buffer deadlock when sending many keys.
290
319
  # Populates the provided results hash with any responses drained during send.
291
320
  def pipelined_get_interleaved(keys, chunk_size, results)
292
- # Initialize the response buffer for draining during send phase
293
- response_buffer.ensure_ready
321
+ # Start with an empty buffer for draining during the send phase. Bytes
322
+ # left by an earlier get_multi that stopped early belong to its replies.
323
+ response_buffer.reset
294
324
  expect_reply_keys(keys)
295
325
 
296
326
  keys.each_slice(chunk_size) do |chunk|
@@ -337,8 +367,12 @@ module Dalli
337
367
  nil
338
368
  end
339
369
 
370
+ # Registered with the connection manager, which clears it whenever the
371
+ # connection closes, so a new connection never parses an old one's bytes
340
372
  def response_buffer
341
- @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
373
+ @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor).tap do |buffer|
374
+ @connection_manager.response_buffer = buffer
375
+ end
342
376
  end
343
377
 
344
378
  # A multi-get only accepts replies for the keys it asked for. memcached
@@ -390,7 +424,23 @@ module Dalli
390
424
  @connection_manager.reconnect! 'pipelined get has completed' if pipeline_complete?
391
425
  end
392
426
 
427
+ def raise_pipeline_closed
428
+ raise Dalli::DalliError, "The connection to #{name} closed while a get_multi was reading its replies " \
429
+ '(a request made from inside the get_multi block?)'
430
+ end
431
+
432
+ def log_pipeline_abort(timed_out)
433
+ if timed_out
434
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
435
+ else
436
+ Dalli.logger.debug { "#{name} get_multi stopped before reading all replies; closing the connection" }
437
+ end
438
+ end
439
+
440
+ # The single-server set_multi path passes its whole pairs hash as the
441
+ # first argument; log only its keys, never the values
393
442
  def log_marshal_err(key, err)
443
+ key = key.keys.join("', '") if key.is_a?(Hash)
394
444
  Dalli.logger.error "Marshalling error for key '#{key}': #{err.message}"
395
445
  Dalli.logger.error 'You are trying to cache a Ruby object which cannot be serialized to memcached.'
396
446
  end
@@ -26,6 +26,9 @@ module Dalli
26
26
 
27
27
  attr_accessor :hostname, :port, :socket_type, :options
28
28
  attr_reader :sock
29
+ # The buffer replies are read into (see Base#response_buffer). It's
30
+ # cleared with the write buffer whenever the connection closes.
31
+ attr_writer :response_buffer
29
32
 
30
33
  def initialize(hostname, port, socket_type, client_options)
31
34
  @hostname = hostname
@@ -33,10 +36,12 @@ module Dalli
33
36
  @socket_type = socket_type
34
37
  @options = DEFAULTS.merge(client_options)
35
38
  @request_in_progress = false
39
+ @quiet_responses_pending = false
36
40
  @sock = nil
37
41
  @pid = nil
38
42
  @write_buffer = []
39
43
  @write_buffer_bytes = 0
44
+ @response_buffer = nil
40
45
 
41
46
  @fail_count = 0
42
47
  reset_down_info
@@ -58,8 +63,9 @@ module Dalli
58
63
  @sock.sync = true
59
64
  @pid = Process.pid
60
65
  @request_in_progress = false
61
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
62
- # SocketError = DNS resolution failure
66
+ @quiet_responses_pending = false
67
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError, SocketError => e
68
+ # SocketError = DNS resolution failure; SSL_ERRORS = failed TLS handshake
63
69
  error_on_request!(e)
64
70
  end
65
71
 
@@ -129,7 +135,9 @@ module Dalli
129
135
  # @request_in_progress == true.
130
136
  @sock = nil
131
137
  @pid = nil
138
+ @quiet_responses_pending = false
132
139
  discard_write_buffer
140
+ @response_buffer&.clear
133
141
  abort_request!
134
142
  end
135
143
  end
@@ -145,6 +153,21 @@ module Dalli
145
153
  !@sock.nil?
146
154
  end
147
155
 
156
+ # True when a quiet request was written on this connection and its
157
+ # replies (memcached only suppresses some of them) haven't been
158
+ # consumed by a noop yet.
159
+ def quiet_responses_pending?
160
+ @quiet_responses_pending
161
+ end
162
+
163
+ def mark_quiet_responses_pending!
164
+ @quiet_responses_pending = true
165
+ end
166
+
167
+ def clear_quiet_responses_pending!
168
+ @quiet_responses_pending = false
169
+ end
170
+
148
171
  def request_in_progress?
149
172
  @request_in_progress
150
173
  end
@@ -170,10 +193,22 @@ module Dalli
170
193
  @request_in_progress = false
171
194
  end
172
195
 
196
+ # The longest reply line Dalli reads, in bytes, CRLF included. A meta
197
+ # reply header is at most about 500 bytes: "VA <size>" plus its flags,
198
+ # the largest being a 250-byte key base64-encoded to 336 bytes, a
199
+ # 20-digit CAS, a 32-byte opaque and a few numeric fields. The longest
200
+ # stats line (stats settings) carries a file path such as ext_path, up
201
+ # to PATH_MAX (4096). 8 KiB leaves room for both, while a server that
202
+ # never sends CRLF can't grow the read buffer without bound.
203
+ MAX_LINE_BYTES = 8 * 1024
204
+
173
205
  def read_line
174
206
  flush_write_buffer
175
- data = @sock.gets("\r\n")
207
+ data = @sock.gets("\r\n", MAX_LINE_BYTES)
176
208
  error_on_request!('EOF in read_line') if data.nil?
209
+ # Without its CRLF the line either ran past MAX_LINE_BYTES or was cut
210
+ # short by EOF; either way the connection is out of step
211
+ error_on_request!("Reply line without CRLF in the first #{MAX_LINE_BYTES} bytes") unless data.end_with?("\r\n")
177
212
  data
178
213
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
179
214
  error_on_request!(e)
@@ -242,7 +242,10 @@ module Dalli
242
242
  # We need to read all the responses at once.
243
243
  def noop
244
244
  write_noop
245
- response_processor.consume_all_responses_until_mn
245
+ result = response_processor.consume_all_responses_until_mn
246
+ # Everything up to and including the MN has been read
247
+ @connection_manager.clear_quiet_responses_pending!
248
+ result
246
249
  end
247
250
 
248
251
  def stats(info = nil)
@@ -313,16 +316,27 @@ module Dalli
313
316
 
314
317
  # Single-server fast path for set_multi. Inlines request formatting to
315
318
  # minimize per-key overhead. Avoids PipelinedSetter server grouping.
319
+ #
320
+ # A value that can't be marshalled or is over value_max_bytes is skipped;
321
+ # the other keys are still stored, then the first such error is raised
322
+ # (as PipelinedSetter does on the multi-server path).
316
323
  def write_multi_req(pairs, ttl, req_options)
317
324
  ttl = TtlSanitizer.sanitize(ttl) if ttl
318
- entries = pairs.map do |key, raw_value|
325
+ value_error = nil
326
+ entries = pairs.filter_map do |key, raw_value|
319
327
  [key, @value_marshaller.store(key, raw_value, req_options)]
328
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
329
+ value_error ||= e
330
+ nil
320
331
  end
321
332
 
322
- buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl)
323
- flushed_write(buffer)
324
- buffer.clear
325
- response_processor.consume_all_responses_until_mn
333
+ unless entries.empty?
334
+ buffer = RequestFormatter.multi_meta_set(entries, ttl: ttl)
335
+ flushed_write(buffer)
336
+ buffer.clear
337
+ response_processor.consume_all_responses_until_mn
338
+ end
339
+ raise value_error if value_error
326
340
  end
327
341
 
328
342
  # Single-server fast path for delete_multi. Writes all quiet delete requests
@@ -84,11 +84,13 @@ module Dalli
84
84
  end
85
85
 
86
86
  # Checks whether we should apply compression when serializing a value
87
- # based on the specified options. Returns false unless the value
88
- # is greater than the minimum compression size. Otherwise returns
89
- # based on a method-level option if specified, falling back to the
90
- # server default.
87
+ # based on the specified options. Returns false for a raw request
88
+ # (raw reads ignore bitflags, so the value must be stored as given) or
89
+ # unless the value is greater than the minimum compression size.
90
+ # Otherwise returns based on a method-level option if specified,
91
+ # falling back to the server default.
91
92
  def compress_value?(value, req_options)
93
+ return false if req_options&.dig(:raw)
92
94
  return false unless value.bytesize >= compression_min_size
93
95
  return compress_by_default? unless req_options && !req_options[:compress].nil?
94
96
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
3
4
  require 'timeout'
4
5
 
5
6
  module Dalli
@@ -16,14 +17,11 @@ module Dalli
16
17
  [Timeout::Error]
17
18
  end
18
19
 
19
- # SSL errors that occur during read/write operations (not during initial
20
- # handshake) should trigger reconnection. These indicate transient network
21
- # issues, not configuration problems.
22
- SSL_ERRORS =
23
- if defined?(OpenSSL::SSL::SSLError)
24
- [OpenSSL::SSL::SSLError]
25
- else
26
- []
27
- end
20
+ # SSL errors during the handshake or during read/write operations count as
21
+ # a failed request: they trigger reconnection and, after
22
+ # socket_max_failures, mark the server down.
23
+ # openssl is required above: this file loads before dalli/socket, so a
24
+ # defined? check here would find OpenSSL missing and leave this empty.
25
+ SSL_ERRORS = [OpenSSL::SSL::SSLError].freeze
28
26
  end
29
27
  end
data/lib/dalli/socket.rb CHANGED
@@ -203,12 +203,41 @@ module Dalli
203
203
  [seconds, microseconds].pack(timeval_pack_format(sock))
204
204
  end
205
205
 
206
+ # Closes the TCP socket if the handshake fails or times out, so a failed
207
+ # attempt doesn't hold a file descriptor until GC.
206
208
  def self.wrapping_ssl_socket(tcp_socket, host, ssl_context)
209
+ warn_if_unverified(ssl_context)
210
+ connected = false
207
211
  ssl_socket = Dalli::Socket::SSLSocket.new(tcp_socket, ssl_context)
208
212
  ssl_socket.hostname = host
209
213
  ssl_socket.sync_close = true
210
214
  ssl_socket.connect
215
+ connected = true
211
216
  ssl_socket
217
+ ensure
218
+ tcp_socket.close unless connected
219
+ end
220
+
221
+ # Class variable to track whether the TLS verification warning has been logged
222
+ @@ssl_verification_warning_logged = false # rubocop:disable Style/ClassVars
223
+
224
+ # Verification comes entirely from the caller's ssl_context, and a bare
225
+ # SSLContext.new verifies nothing, so warn (once per process) when the
226
+ # certificate or the hostname won't be checked.
227
+ def self.warn_if_unverified(ssl_context)
228
+ return if @@ssl_verification_warning_logged
229
+
230
+ problem = if !ssl_context.verify_mode.to_i.anybits?(::OpenSSL::SSL::VERIFY_PEER)
231
+ 'verify_mode is VERIFY_NONE, so the server certificate is not checked'
232
+ elsif !ssl_context.verify_hostname
233
+ 'verify_hostname is false, so the certificate is not checked against the hostname'
234
+ end
235
+ return unless problem
236
+
237
+ Dalli.logger.warn "SECURITY WARNING: Dalli's ssl_context #{problem}. " \
238
+ 'Use ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, ' \
239
+ 'verify_hostname: true) to verify the server.'
240
+ @@ssl_verification_warning_logged = true # rubocop:disable Style/ClassVars
212
241
  end
213
242
  end
214
243
 
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '5.0.11'
4
+ VERSION = '5.0.13'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dalli
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.11
4
+ version: 5.0.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein