dalli 4.3.7 → 4.3.9

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: 4b1a68fbdf9ea89024d6e9bc5803f94d3342c2f7b8bd7cfea3a97f7a873f7b76
4
- data.tar.gz: 93d1c2fcb71fa739e6a00808cddb11567ad9fed3caa4d0751c924576bc04bfe0
3
+ metadata.gz: 2e7ed5e67a328d8850a50358f988c46803599cd7df6327d3318c6e3951aef6a8
4
+ data.tar.gz: ce71002a269001f7c8e620aad263425aadd2a170cdcf557df843653afebeabfd
5
5
  SHA512:
6
- metadata.gz: bc42cde8b6f61ba60768bf43a9ebf8b1088dc8b2efbb4e32b5ed1abedd3c3be81c66aa02dc1a2558fd319debcc8074e61471996ad8825afed32d4fd741f8a312
7
- data.tar.gz: 3a8320685a39edef196feaa387ef954caea41650b540c7f37d32d4821b5804f41631e5346e14b21912de0963f147c7d55e8c1ad08e24d2f7d25073975ba64d72
6
+ metadata.gz: f8f59848e325e4f66519c5dc291fd35c617e5e958548dd2b7415014f6d0d7933aaad510f467c2c1fc799395b2388fe394a24689a5eb7032ec93333d0348d4ad7
7
+ data.tar.gz: 3a4bcb6aadc3247ffbfb10006e946bfd40e55fe3481ee705313146151b3e7a1b9dac35bb17e6ac06d4da161e624c072deca4406ae15baa242ba69e6ff06058df
data/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 4.3.9
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Keep a connection shared by several threads or fibers in step during a `quiet` block (GHSA-5xvx-r8rg-5qm2)
13
+ - 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
14
+ - Don't let a `get_multi` that stopped early leave replies for a later request (GHSA-5xvx-r8rg-5qm2)
15
+ - 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
16
+ - Affects every supported line (the `get_multi` issue, 4.3 and later), with both protocols; with the binary protocol the connection stayed out of step after the block ended. Fixed in 5.2.4, 5.1.6, 5.0.12, 4.3.9 and 3.2.15
17
+
18
+ Bug fixes:
19
+
20
+ - A per-request `raw: true` write no longer compresses the value (backport of #1213)
21
+ - 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
22
+ - 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
23
+ - Handle failed TLS handshakes like other connection failures, and warn when TLS verification is off (backport of #1215)
24
+ - 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)
25
+ - 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
26
+ - 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)
27
+ - One value that can't be stored no longer discards other buffered writes (backport of #1218)
28
+ - 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`, and `set_multi` did this without raising
29
+ - `set_multi` now stores every other key, skips the bad one, and then raises
30
+
31
+ 4.3.8
32
+ ==========
33
+
34
+ Security:
35
+
36
+ - Don't rely on memcached's auto-create for keys that need base64 encoding (GHSA-r47x-6p6r-mvcp)
37
+ - memcached stores an item made by auto-create (`ma` with `N`/`J`, or `mg` with `N`) for a base64-encoded key without its binary-key flag, and later replies name the item by its raw key bytes. A key containing spaces or CR/LF could then make a multi-get read the replies after it as different ones
38
+ - `incr` and `decr` with an initial value now create such counters with an add instead. Return values are unchanged
39
+ - The new `verify_reply_keys` option makes `get_multi` and `get_multi_cas` ignore replies for keys they didn't request, which guards against items created by older Dalli versions or other clients until memcached is upgraded. It's off by default, since it costs a multi-get about 15%
40
+ - Affects `protocol: :meta` since 3.2.0; fixed in 5.2.3, 5.1.5, 5.0.11, 4.3.8 and 3.2.14. memcached 1.6.45 and earlier still name these items by their raw key bytes
41
+
7
42
  4.3.7
8
43
  ==========
9
44
 
data/README.md CHANGED
@@ -82,6 +82,29 @@ Per-request `raw: true` returns the stored bytes without deserializing or decomp
82
82
 
83
83
  See the [4.0-Upgrade.md](4.0-Upgrade.md) guide for more information.
84
84
 
85
+ ### Counters and verify_reply_keys
86
+
87
+ 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 (with `protocol: :meta`). Since 4.3.8, 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.
88
+
89
+ ## TLS
90
+
91
+ 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:
92
+
93
+ ```ruby
94
+ ssl_context = OpenSSL::SSL::SSLContext.new
95
+ ssl_context.set_params(
96
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
97
+ verify_hostname: true,
98
+ ca_file: '/path/to/ca.crt' # omit to use the system's trusted certificates
99
+ )
100
+
101
+ Dalli::Client.new('memcached.example.com:11211', ssl_context: ssl_context)
102
+ ```
103
+
104
+ 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.
105
+
106
+ 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.
107
+
85
108
  ## OpenTelemetry Tracing
86
109
 
87
110
  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
@@ -42,9 +42,10 @@ module Dalli
42
42
  # #fetch operations.
43
43
  # - :raw - If set, disables serialization and compression entirely at the client level.
44
44
  # Only String values are supported. This is useful when the caller handles its own
45
- # serialization (e.g., Rails' ActiveSupport::Cache). Note: this is different from
46
- # the per-request :raw option which converts values to strings but still uses the
47
- # serialization pipeline.
45
+ # serialization (e.g., Rails' ActiveSupport::Cache). The per-request :raw option
46
+ # (e.g. set(key, value, ttl, raw: true)) does the same for a single call: String
47
+ # values are stored as-is, uncompressed, and non-String values raise
48
+ # Dalli::MarshalError.
48
49
  # - :digest_class - defaults to Digest::MD5, allows you to pass in an object that responds to the hexdigest method,
49
50
  # useful for injecting a FIPS compliant hash object.
50
51
  # - :protocol - one of either :binary or :meta, defaulting to :binary. This sets the protocol that Dalli uses
@@ -53,6 +54,11 @@ module Dalli
53
54
  # +:include+ logs the full operation and key(s), +:obfuscate+ replaces keys with "?",
54
55
  # +nil+ (default) omits the attribute entirely.
55
56
  # - :otel_peer_service - when set, adds a +peer.service+ span attribute with this value for logical service naming.
57
+ # - :verify_reply_keys - defaults to false. When true, get_multi and get_multi_cas only accept replies for the keys
58
+ # they requested; after a reply naming another key, the rest of that server's replies are
59
+ # treated as misses. With the meta protocol, memcached can name an item by its raw key bytes
60
+ # when the item was created by auto-create from a base64-encoded key, which Dalli no longer
61
+ # does. Costs a multi-get about 15%.
56
62
  #
57
63
  def initialize(servers = nil, options = {})
58
64
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -301,6 +307,11 @@ module Dalli
301
307
  # Any error replies will be discarded at the end of the block, and
302
308
  # Dalli client methods invoked inside the block will not
303
309
  # have return values
310
+ #
311
+ # The block is quiet only for the thread or fiber that runs it. Another
312
+ # thread or fiber sharing this client can make requests meanwhile; a
313
+ # non-quiet one first sends the block's buffered requests and discards
314
+ # their replies, so it reads only its own.
304
315
  def quiet
305
316
  old = Thread.current[::Dalli::QUIET]
306
317
  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
@@ -26,15 +26,21 @@ module Dalli
26
26
  return {} if keys.empty?
27
27
 
28
28
  @ring.lock do
29
- # Stores partial results collected during interleaved send phase
30
- @partial_results = {}
31
- servers = setup_requests(keys)
32
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ # The servers with replies still to read, updated in place
30
+ servers = []
31
+ begin
32
+ # Stores partial results collected during interleaved send phase
33
+ @partial_results = {}
34
+ setup_requests(servers, keys)
35
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
36
 
34
- # First yield any partial results collected during interleaved send
35
- yield_partial_results(&block)
37
+ # First yield any partial results collected during interleaved send
38
+ yield_partial_results(&block)
36
39
 
37
- servers = fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
40
+ fetch_responses(servers, start_time, @ring.socket_timeout, &block) until servers.empty?
41
+ ensure
42
+ abort_unfinished(servers)
43
+ end
38
44
  end
39
45
  rescue Dalli::RetryableNetworkError => e
40
46
  Dalli.logger.debug { e.inspect }
@@ -51,12 +57,15 @@ module Dalli
51
57
  @partial_results.clear
52
58
  end
53
59
 
54
- def setup_requests(keys)
60
+ # Adds the servers with a pending response to servers before writing
61
+ # their queries, so #process can abort them if it stops early.
62
+ def setup_requests(servers, keys)
55
63
  groups = groups_for_keys(keys)
64
+ servers.concat(groups.keys)
56
65
  make_getkq_requests(groups)
57
66
 
58
67
  # TODO: How does this exit on a NetworkError
59
- finish_queries(groups.keys)
68
+ finish_queries(servers)
60
69
  end
61
70
 
62
71
  ##
@@ -118,29 +127,39 @@ module Dalli
118
127
  raise
119
128
  end
120
129
 
121
- # Swallows Dalli::NetworkError
130
+ # Swallows Dalli::NetworkError. Empties servers, so they aren't aborted
131
+ # again.
122
132
  def abort_without_timeout(servers)
123
133
  servers.each(&:pipeline_abort)
134
+ servers.clear
135
+ end
136
+
137
+ # When #process stops before reading every server's replies (the caller's
138
+ # block raises or breaks, a Timeout.timeout fires, a value can't be
139
+ # deserialized), the rest are still on the socket and some may be in the
140
+ # response buffer. Closing those connections keeps them from being read
141
+ # as a later request's replies. Aborts that #process already handled
142
+ # emptied servers.
143
+ def abort_unfinished(servers)
144
+ servers.each { |server| server.pipeline_abort(timed_out: false) }
145
+ servers.clear
124
146
  end
125
147
 
148
+ # Reads the replies available, removing finished servers from servers
126
149
  def fetch_responses(servers, start_time, timeout, &block)
127
150
  # Remove any servers which are not connected
128
151
  servers.select!(&:connected?)
129
- return [] if servers.empty?
152
+ return if servers.empty?
130
153
 
131
154
  time_left = remaining_time(start_time, timeout)
132
155
  readable_servers = servers_with_response(servers, time_left)
133
- if readable_servers.empty?
134
- abort_with_timeout(servers)
135
- return []
136
- end
156
+ return abort_with_timeout(servers) if readable_servers.empty?
137
157
 
138
158
  # Loop through the servers with responses, and
139
159
  # delete any from our list that are finished
140
160
  readable_servers.each do |server|
141
161
  servers.delete(server) if process_server(server, &block)
142
162
  end
143
- servers
144
163
  rescue NetworkError
145
164
  # Abort and raise if we encountered a network error. This triggers
146
165
  # a retry at the top level on RetryableNetworkError.
@@ -157,12 +176,10 @@ module Dalli
157
176
 
158
177
  # Swallows Dalli::NetworkError
159
178
  def abort_with_timeout(servers)
160
- abort_without_timeout(servers)
161
179
  servers.each do |server|
162
180
  Dalli.logger.debug { "memcached at #{server.name} did not response within timeout" }
163
181
  end
164
-
165
- true # Required to simplify caller
182
+ abort_without_timeout(servers)
166
183
  end
167
184
 
168
185
  # 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' }
@@ -52,6 +57,10 @@ module Dalli
52
57
  original_key = @key_manager.key_without_namespace(key)
53
58
  value = hash[original_key]
54
59
  server.request(:pipelined_set, key, value, ttl, req_options)
60
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
61
+ # Nothing was written for this key, so the server's other keys are
62
+ # unaffected; raised by #process once the batch is complete
63
+ @value_error ||= e
55
64
  rescue DalliError, NetworkError => e
56
65
  Dalli.logger.debug { e.inspect }
57
66
  Dalli.logger.debug { "unable to set key #{key} for server #{server.name}" }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
+ require 'set'
4
5
  require 'socket'
5
6
  require 'timeout'
6
7
 
@@ -24,6 +25,7 @@ module Dalli
24
25
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
25
26
  @options = client_options.merge(user_creds)
26
27
  @raw_mode = client_options[:raw]
28
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
27
29
  @value_marshaller = @raw_mode ? StringMarshaller.new(@options) : ValueMarshaller.new(@options)
28
30
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
29
31
  end
@@ -47,14 +49,23 @@ module Dalli
47
49
 
48
50
  begin
49
51
  @connection_manager.start_request!
52
+ drain_pending_quiet_responses(opkey)
50
53
  response = send(opkey, *args)
54
+ # Quiet requests can leave replies on the socket; remember that so a
55
+ # later non-quiet request knows to read them first
56
+ @connection_manager.mark_quiet_responses_pending! if quiet? && opkey != :noop
51
57
 
52
58
  # pipelined_get/pipelined_get_interleaved emit query but don't read the response(s)
53
59
  @connection_manager.finish_request! unless %i[pipelined_get pipelined_get_interleaved].include?(opkey)
54
60
 
55
61
  response
56
- rescue Dalli::MarshalError => e
57
- log_marshal_err(args.first, e)
62
+ rescue Dalli::MarshalError, Dalli::ValueOverMaxSize => e
63
+ log_marshal_err(args.first, e) if e.is_a?(Dalli::MarshalError)
64
+ # Raised only while the connection is clean: a value is marshalled
65
+ # before any of its request is written. Leaving the request in
66
+ # progress would close the connection on the next request, which
67
+ # discards earlier quiet writes still waiting in the write buffer.
68
+ @connection_manager.abort_request!
58
69
  raise
59
70
  rescue Dalli::DalliError
60
71
  raise
@@ -89,7 +100,7 @@ module Dalli
89
100
  verify_pipelined_state(:getkq)
90
101
  write_noop
91
102
  # Use ensure_ready instead of reset to preserve any data already buffered
92
- # during interleaved pipelined get draining
103
+ # while this get_multi's interleaved requests were sent
93
104
  response_buffer.ensure_ready
94
105
  end
95
106
 
@@ -103,6 +114,8 @@ module Dalli
103
114
  # Without a block, returns a Hash of { key => [value, cas] }.
104
115
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
105
116
  def pipeline_next_responses(&block)
117
+ return abandon_pipeline if @pipeline_out_of_step
118
+
106
119
  reconnect_on_pipeline_complete!
107
120
  values = nil
108
121
 
@@ -124,6 +137,13 @@ module Dalli
124
137
  # to the noop at the end of the pipeline
125
138
  finish_pipeline && break if status && key.nil?
126
139
 
140
+ # A key that wasn't requested means the replies are out of step
141
+ # (see expect_reply_keys): keep what was read so far, drop the rest
142
+ if unexpected_reply_key?(key)
143
+ abandon_pipeline
144
+ break
145
+ end
146
+
127
147
  # If the status is ok and the key is not nil, then this is a
128
148
  # getkq response with a value that we want to set in the response hash
129
149
  unless key.nil?
@@ -135,6 +155,10 @@ module Dalli
135
155
  end
136
156
  end
137
157
 
158
+ # A request to this server from inside the block closes the
159
+ # connection, and with it the rest of this get_multi's replies
160
+ raise_pipeline_closed unless response_buffer.in_progress?
161
+
138
162
  # Get the next response from the buffer
139
163
  status, cas, key, value = response_buffer.process_single_getk_response
140
164
  end
@@ -143,21 +167,24 @@ module Dalli
143
167
  end
144
168
 
145
169
  # Abort current pipelined get. Generally used to signal an external
146
- # timeout during pipelined get. The underlying socket is
170
+ # timeout during pipelined get, or that the get_multi stopped before
171
+ # reading all its replies (timed_out: false). The underlying socket is
147
172
  # disconnected, and the exception is swallowed.
148
173
  #
149
174
  # Returns nothing.
150
- def pipeline_abort
175
+ def pipeline_abort(timed_out: true)
151
176
  response_buffer.clear
177
+ @pipeline_out_of_step = false
178
+ @expected_reply_keys = nil
152
179
  @connection_manager.abort_request!
153
180
  return true unless connected?
154
181
 
155
182
  # Closes the connection, which ensures that our connection is in a
156
183
  # clean state for future requests. The get_multi ran out of time
157
- # overall rather than hitting a socket error, so it doesn't count
158
- # toward socket_max_failures: two slow get_multis in a row would
159
- # otherwise mark a healthy server down.
160
- Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
184
+ # overall, or stopped early, rather than hitting a socket error, so it
185
+ # doesn't count toward socket_max_failures: two slow get_multis in a
186
+ # row would otherwise mark a healthy server down.
187
+ log_pipeline_abort(timed_out)
161
188
  @connection_manager.close
162
189
  true
163
190
  end
@@ -191,6 +218,18 @@ module Dalli
191
218
  ALLOWED_QUIET_OPS = %i[add replace set delete incr decr append prepend flush noop].freeze
192
219
  private_constant :ALLOWED_QUIET_OPS
193
220
 
221
+ # Reads the replies still pending from quiet requests before any
222
+ # non-quiet request, which reads a reply of its own. A quiet block reads
223
+ # them at its end, but the connection is shared: another thread or fiber
224
+ # (quiet? is fiber-local) can make a request while the block is still
225
+ # open, and would otherwise read the block's replies as its own. The
226
+ # noop also sends any quiet requests still buffered.
227
+ def drain_pending_quiet_responses(opkey)
228
+ return unless @connection_manager.quiet_responses_pending? && opkey != :noop && !quiet?
229
+
230
+ noop
231
+ end
232
+
194
233
  def verify_allowed_quiet!(opkey)
195
234
  return if ALLOWED_QUIET_OPS.include?(opkey)
196
235
 
@@ -252,6 +291,7 @@ module Dalli
252
291
  # Use clear (not reset) to keep pipeline_complete? = true, which is
253
292
  # the expected state before pipeline_response_setup is called.
254
293
  response_buffer.clear
294
+ expect_reply_keys(keys)
255
295
 
256
296
  req = +''
257
297
  keys.each do |key|
@@ -265,8 +305,10 @@ module Dalli
265
305
  # This prevents socket buffer deadlock when sending many keys.
266
306
  # Populates the provided results hash with any responses drained during send.
267
307
  def pipelined_get_interleaved(keys, chunk_size, results)
268
- # Initialize the response buffer for draining during send phase
269
- response_buffer.ensure_ready
308
+ # Start with an empty buffer for draining during the send phase. Bytes
309
+ # left by an earlier get_multi that stopped early belong to its replies.
310
+ response_buffer.reset
311
+ expect_reply_keys(keys)
270
312
 
271
313
  keys.each_slice(chunk_size) do |chunk|
272
314
  # Build and write this chunk of requests
@@ -299,6 +341,12 @@ module Dalli
299
341
  status, cas, key, value = response_buffer.process_single_getk_response
300
342
  break if status.nil? # No complete response available
301
343
 
344
+ # Handled by the next pipeline_next_responses, once sending is done
345
+ if unexpected_reply_key?(key)
346
+ @pipeline_out_of_step = true
347
+ break
348
+ end
349
+
302
350
  results[key] = [value, cas] unless key.nil?
303
351
  end
304
352
  rescue SystemCallError, Dalli::NetworkError
@@ -306,8 +354,48 @@ module Dalli
306
354
  nil
307
355
  end
308
356
 
357
+ # Registered with the connection manager, which clears it whenever the
358
+ # connection closes, so a new connection never parses an old one's bytes
309
359
  def response_buffer
310
- @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
360
+ @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor).tap do |buffer|
361
+ @connection_manager.response_buffer = buffer
362
+ end
363
+ end
364
+
365
+ # A multi-get only accepts replies for the keys it asked for. memcached
366
+ # names an item created by auto-create (ma or mg with N) for a
367
+ # base64-encoded key by its raw bytes, without the b flag; such a key
368
+ # can contain spaces or CR/LF, which split the reply header and make the
369
+ # replies after it parse as different ones. A key that wasn't requested
370
+ # is how that shows up.
371
+ # Only with the verify_reply_keys option, since building the set costs a
372
+ # multi-get about 15%. Dalli itself no longer creates such items (see
373
+ # Meta#decr_incr); the check guards against ones that older versions or
374
+ # other clients created, on memcached versions with the behavior.
375
+ def expect_reply_keys(keys)
376
+ @pipeline_out_of_step = false
377
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
378
+ end
379
+
380
+ def unexpected_reply_key?(key)
381
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
382
+ end
383
+
384
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
385
+ def reply_key_form(key)
386
+ key.ascii_only? ? key : key.b
387
+ end
388
+
389
+ # Keeps what was read, drops the rest of this server's replies, and
390
+ # closes the connection so nothing unread is left on it. Not a socket
391
+ # failure, so it doesn't count toward socket_max_failures; the keys not
392
+ # read are simply missing from the result.
393
+ def abandon_pipeline
394
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
395
+ @pipeline_out_of_step = false
396
+ response_buffer.clear
397
+ @connection_manager.close
398
+ {}
311
399
  end
312
400
 
313
401
  # Called after the noop response is received at the end of a set
@@ -323,6 +411,19 @@ module Dalli
323
411
  @connection_manager.reconnect! 'pipelined get has completed' if pipeline_complete?
324
412
  end
325
413
 
414
+ def raise_pipeline_closed
415
+ raise Dalli::DalliError, "The connection to #{name} closed while a get_multi was reading its replies " \
416
+ '(a request made from inside the get_multi block?)'
417
+ end
418
+
419
+ def log_pipeline_abort(timed_out)
420
+ if timed_out
421
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
422
+ else
423
+ Dalli.logger.debug { "#{name} get_multi stopped before reading all replies; closing the connection" }
424
+ end
425
+ end
426
+
326
427
  def log_marshal_err(key, err)
327
428
  Dalli.logger.error "Marshalling error for key '#{key}': #{err.message}"
328
429
  Dalli.logger.error 'You are trying to cache a Ruby object which cannot be serialized to memcached.'
@@ -161,7 +161,10 @@ module Dalli
161
161
  # We need to read all the responses at once.
162
162
  def noop
163
163
  write_noop
164
- response_processor.consume_all_responses_until_noop
164
+ result = response_processor.consume_all_responses_until_noop
165
+ # Everything up to and including the noop reply has been read
166
+ @connection_manager.clear_quiet_responses_pending!
167
+ result
165
168
  end
166
169
 
167
170
  def stats(info = '')
@@ -28,6 +28,9 @@ module Dalli
28
28
 
29
29
  attr_accessor :hostname, :port, :socket_type, :options
30
30
  attr_reader :sock
31
+ # The buffer replies are read into (see Base#response_buffer). It's
32
+ # cleared with the write buffer whenever the connection closes.
33
+ attr_writer :response_buffer
31
34
 
32
35
  def initialize(hostname, port, socket_type, client_options)
33
36
  @hostname = hostname
@@ -35,10 +38,12 @@ module Dalli
35
38
  @socket_type = socket_type
36
39
  @options = DEFAULTS.merge(client_options)
37
40
  @request_in_progress = false
41
+ @quiet_responses_pending = false
38
42
  @sock = nil
39
43
  @pid = nil
40
44
  @write_buffer = []
41
45
  @write_buffer_bytes = 0
46
+ @response_buffer = nil
42
47
 
43
48
  @fail_count = 0
44
49
  reset_down_info
@@ -60,8 +65,9 @@ module Dalli
60
65
  @sock.sync = true
61
66
  @pid = Process.pid
62
67
  @request_in_progress = false
63
- rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
64
- # SocketError = DNS resolution failure
68
+ @quiet_responses_pending = false
69
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError, SocketError => e
70
+ # SocketError = DNS resolution failure; SSL_ERRORS = failed TLS handshake
65
71
  error_on_request!(e)
66
72
  end
67
73
 
@@ -126,7 +132,9 @@ module Dalli
126
132
  end
127
133
  @sock = nil
128
134
  @pid = nil
135
+ @quiet_responses_pending = false
129
136
  discard_write_buffer
137
+ @response_buffer&.clear
130
138
  abort_request!
131
139
  end
132
140
 
@@ -141,6 +149,21 @@ module Dalli
141
149
  !@sock.nil?
142
150
  end
143
151
 
152
+ # True when a quiet request was written on this connection and its
153
+ # replies (memcached only suppresses some of them) haven't been
154
+ # consumed by a noop yet.
155
+ def quiet_responses_pending?
156
+ @quiet_responses_pending
157
+ end
158
+
159
+ def mark_quiet_responses_pending!
160
+ @quiet_responses_pending = true
161
+ end
162
+
163
+ def clear_quiet_responses_pending!
164
+ @quiet_responses_pending = false
165
+ end
166
+
144
167
  def request_in_progress?
145
168
  @request_in_progress
146
169
  end
@@ -166,10 +189,22 @@ module Dalli
166
189
  @request_in_progress = false
167
190
  end
168
191
 
192
+ # The longest reply line Dalli reads, in bytes, CRLF included. A meta
193
+ # reply header is at most about 500 bytes: "VA <size>" plus its flags,
194
+ # the largest being a 250-byte key base64-encoded to 336 bytes, a
195
+ # 20-digit CAS, a 32-byte opaque and a few numeric fields. The longest
196
+ # stats line (stats settings) carries a file path such as ext_path, up
197
+ # to PATH_MAX (4096). 8 KiB leaves room for both, while a server that
198
+ # never sends CRLF can't grow the read buffer without bound.
199
+ MAX_LINE_BYTES = 8 * 1024
200
+
169
201
  def read_line
170
202
  flush_write_buffer
171
- data = @sock.gets("\r\n")
203
+ data = @sock.gets("\r\n", MAX_LINE_BYTES)
172
204
  error_on_request!('EOF in read_line') if data.nil?
205
+ # Without its CRLF the line either ran past MAX_LINE_BYTES or was cut
206
+ # short by EOF; either way the connection is out of step
207
+ error_on_request!("Reply line without CRLF in the first #{MAX_LINE_BYTES} bytes") unless data.end_with?("\r\n")
173
208
  data
174
209
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
175
210
  error_on_request!(e)
@@ -213,12 +213,45 @@ module Dalli
213
213
  def decr_incr(incr, key, delta, ttl, initial)
214
214
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
215
215
  encoded_key, base64 = KeyRegularizer.encode(key)
216
+ # memcached stores an item created by ma's auto-create (N/J) for a
217
+ # base64-encoded key without its binary-key flag, and later replies
218
+ # name it with the raw key bytes. Create such counters with an add.
219
+ return decr_incr_with_add(incr, key, delta, ttl, initial) if initial && base64
220
+
216
221
  write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: initial, incr: incr, ttl: ttl,
217
222
  quiet: quiet?, base64: base64))
218
223
  @connection_manager.flush unless quiet?
219
224
  response_processor.decr_incr unless quiet?
220
225
  end
221
226
 
227
+ # Like decr_incr with an initial value, without memcached's auto-create:
228
+ # on a miss the counter is added with the initial value (which is
229
+ # returned, as auto-create would), and the add losing a race to another
230
+ # client means the counter now exists, so the incr is tried again. In a
231
+ # quiet block both requests are sent and whichever applies takes effect.
232
+ def decr_incr_with_add(incr, key, delta, ttl, initial)
233
+ add_options = { raw: true } # counters are stored raw
234
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options) if quiet?
235
+
236
+ 2.times do
237
+ value = decr_incr(incr, key, delta, nil, nil)
238
+ return value unless value.nil?
239
+
240
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: false)
241
+ return initial if response_processor.meta_set_with_cas
242
+ end
243
+ decr_incr(incr, key, delta, nil, nil)
244
+ end
245
+
246
+ # rubocop:disable-next Metrics/ParameterLists
247
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial, add_options)
248
+ encoded_key, base64 = KeyRegularizer.encode(key)
249
+ write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: nil, incr: incr, ttl: nil,
250
+ quiet: true, base64: base64))
251
+ # Sent with the rest of the quiet block, by its closing noop
252
+ write_storage_req(:add, key, initial.to_s, ttl, nil, add_options, quiet: true)
253
+ end
254
+
222
255
  # Other Commands
223
256
  def flush(delay = 0)
224
257
  write(RequestFormatter.flush(delay: delay))
@@ -230,7 +263,10 @@ module Dalli
230
263
  # We need to read all the responses at once.
231
264
  def noop
232
265
  write_noop
233
- response_processor.consume_all_responses_until_mn
266
+ result = response_processor.consume_all_responses_until_mn
267
+ # Everything up to and including the MN has been read
268
+ @connection_manager.clear_quiet_responses_pending!
269
+ result
234
270
  end
235
271
 
236
272
  def stats(info = nil)
@@ -88,11 +88,13 @@ module Dalli
88
88
  end
89
89
 
90
90
  # Checks whether we should apply compression when serializing a value
91
- # based on the specified options. Returns false unless the value
92
- # is greater than the minimum compression size. Otherwise returns
93
- # based on a method-level option if specified, falling back to the
94
- # server default.
91
+ # based on the specified options. Returns false for a raw request
92
+ # (raw reads ignore bitflags, so the value must be stored as given) or
93
+ # unless the value is greater than the minimum compression size.
94
+ # Otherwise returns based on a method-level option if specified,
95
+ # falling back to the server default.
95
96
  def compress_value?(value, req_options)
97
+ return false if req_options&.dig(:raw)
96
98
  return false unless value.bytesize >= compression_min_size
97
99
  return compress_by_default? unless req_options && !req_options[:compress].nil?
98
100
 
@@ -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
@@ -181,12 +181,41 @@ module Dalli
181
181
  [seconds, microseconds].pack(timeval_pack_format(sock))
182
182
  end
183
183
 
184
+ # Closes the TCP socket if the handshake fails or times out, so a failed
185
+ # attempt doesn't hold a file descriptor until GC.
184
186
  def self.wrapping_ssl_socket(tcp_socket, host, ssl_context)
187
+ warn_if_unverified(ssl_context)
188
+ connected = false
185
189
  ssl_socket = Dalli::Socket::SSLSocket.new(tcp_socket, ssl_context)
186
190
  ssl_socket.hostname = host
187
191
  ssl_socket.sync_close = true
188
192
  ssl_socket.connect
193
+ connected = true
189
194
  ssl_socket
195
+ ensure
196
+ tcp_socket.close unless connected
197
+ end
198
+
199
+ # Class variable to track whether the TLS verification warning has been logged
200
+ @@ssl_verification_warning_logged = false # rubocop:disable Style/ClassVars
201
+
202
+ # Verification comes entirely from the caller's ssl_context, and a bare
203
+ # SSLContext.new verifies nothing, so warn (once per process) when the
204
+ # certificate or the hostname won't be checked.
205
+ def self.warn_if_unverified(ssl_context)
206
+ return if @@ssl_verification_warning_logged
207
+
208
+ problem = if !ssl_context.verify_mode.to_i.anybits?(::OpenSSL::SSL::VERIFY_PEER)
209
+ 'verify_mode is VERIFY_NONE, so the server certificate is not checked'
210
+ elsif !ssl_context.verify_hostname
211
+ 'verify_hostname is false, so the certificate is not checked against the hostname'
212
+ end
213
+ return unless problem
214
+
215
+ Dalli.logger.warn "SECURITY WARNING: Dalli's ssl_context #{problem}. " \
216
+ 'Use ssl_context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, ' \
217
+ 'verify_hostname: true) to verify the server.'
218
+ @@ssl_verification_warning_logged = true # rubocop:disable Style/ClassVars
190
219
  end
191
220
  end
192
221
 
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '4.3.7'
4
+ VERSION = '4.3.9'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.4'
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: 4.3.7
4
+ version: 4.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -94,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
96
  requirements: []
97
- rubygems_version: 4.0.21
97
+ rubygems_version: 4.0.23
98
98
  specification_version: 4
99
99
  summary: High performance memcached client for Ruby
100
100
  test_files: []