dalli 3.2.12 → 3.2.14

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: f8f2b8a277c11f36013ba0a3997f962e38a9ce19a4dbe05dfc42a647016d03e0
4
- data.tar.gz: 19945129c63f04cf6dc704063eb31bd243998a987b8f8b729345d5b554d2ca36
3
+ metadata.gz: 5b52d92d26a7277b6f65b402c082be2f99b98ca9cb7596af709ac70848167a53
4
+ data.tar.gz: 2cc59e69e536f07ec79ec6e37ffcae997c1e47231aaa213e32fdce02b3b3e123
5
5
  SHA512:
6
- metadata.gz: 3cc736edcbbcb13054fba7ace600d0e88b4b6e323b84ee11446dd57071b6f9a8db525050b0a7b3c8166c943efb042d791dc62a38f930bed7a5b818ad96fc9152
7
- data.tar.gz: db46135255631a70767867449b0ed61b14d59ce2a8e8b65fe809a17f660a8d4f46a5c62197fa8d408371f7697711d4fae92031721e13809519b52a94427dd380
6
+ metadata.gz: 8db03f1265942f604b68bdded36f70aa00e859269950367df91c62ef1b2d1011ba6166269125f4ab377c4b4acd4acfd763c3f17a867d2e357479b259c0592ffc
7
+ data.tar.gz: 7f15a910062c45a776e3bc3964a3555b4a3c0c0a5897877dc170ee5a80d16a313bb4eb0da64821ca14d930ffbea1445855e2f1ca7fe2a068a33765aabc064636
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 3.2.14
8
+ ==========
9
+
10
+ - Don't rely on memcached's auto-create for keys that need base64 encoding (GHSA-r47x-6p6r-mvcp)
11
+ - 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
12
+ - `incr` and `decr` with an initial value now create such counters with an add instead. Return values are unchanged
13
+ - The new `verify_reply_keys` option makes `get_multi` 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%
14
+ - 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
15
+
16
+ 3.2.13
17
+ ==========
18
+
19
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
20
+ - `cas` and `cas!` accepted `raw: true` but didn't pass it to the read, so the value was still deserialized. They now honor it, in both protocols
21
+ - Affects 3.2.12 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
22
+ - Complete the fork-safety fix (GHSA-w39f-xq2m-4g8x)
23
+ - Fork detection used a pid cache refreshed by a `Process._fork` hook. When another library's fork hook ran first in the child (connection_pool, loaded before Dalli as in Rails, closes its connections there), Dalli didn't know it was in a forked child and ended the parent's TLS session. It now checks `Process.pid`
24
+ - Fixed in 4.3.7 and 3.2.13
25
+
26
+ - Fix a regression in 3.2.12: a `get_multi` that didn't finish within `socket_timeout` counted toward `socket_max_failures`, so two slow `get_multi` calls in a row marked a healthy server down. It now just closes the connection
27
+ - Handle malformed pipelined replies from a broken or hostile server or proxy: a hit with no key, or with no `s` flag, could leave the connection out of step or raise an error
28
+ - With `decompressed_max_bytes` in effect, data after the end of a compressed value's stream was returned as part of the value. It's now ignored, as without the limit
29
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
30
+
31
+ - Run the Tests and RuboCop workflows on pushes to `main` and the `*-stable` branches only, so a pull request's branch isn't tested twice (backport of #1198)
32
+
7
33
  3.2.12
8
34
  ==========
9
35
 
data/README.md CHANGED
@@ -37,6 +37,10 @@ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
37
37
 
38
38
  Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method that accepts it (`get` and `fetch`).
39
39
 
40
+ ### Counters and verify_reply_keys
41
+
42
+ 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 3.2.14, 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.
43
+
40
44
  ## Development
41
45
 
42
46
  After checking out the repo, run `bin/setup` to install dependencies. You can run `bin/console` for an interactive prompt that will allow you to experiment.
data/lib/dalli/client.rb CHANGED
@@ -45,6 +45,11 @@ module Dalli
45
45
  # useful for injecting a FIPS compliant hash object.
46
46
  # - :protocol - one of either :binary or :meta, defaulting to :binary. This sets the protocol that Dalli uses
47
47
  # to communicate with memcached.
48
+ # - :verify_reply_keys - defaults to false. When true, get_multi only accepts replies for the keys it requested;
49
+ # after a reply naming another key, the rest of that server's replies are treated as misses.
50
+ # With the meta protocol, memcached can name an item by its raw key bytes when the item was
51
+ # created by auto-create from a base64-encoded key, which Dalli no longer does. Costs a
52
+ # multi-get about 15%.
48
53
  #
49
54
  def initialize(servers = nil, options = {})
50
55
  @normalized_servers = ::Dalli::ServersArgNormalizer.normalize_servers(servers)
@@ -388,7 +393,8 @@ module Dalli
388
393
  end
389
394
 
390
395
  def cas_core(key, always_set, ttl = nil, req_options = nil)
391
- (value, cas) = perform(:cas, key)
396
+ # req_options reaches the read too, so raw: true returns the stored bytes
397
+ (value, cas) = perform(:cas, key, req_options)
392
398
  return if value.nil? && !always_set
393
399
 
394
400
  newvalue = yield(value)
@@ -39,7 +39,12 @@ module Dalli
39
39
  out = String.new(encoding: Encoding::BINARY)
40
40
  0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
41
41
  inflate_slice(inflater, data.byteslice(pos, INFLATE_SLICE_BYTES), out, max_bytes)
42
+ # Anything after the end of the stream isn't part of the value, as
43
+ # with Zlib::Inflate.inflate; finish would return it as output
44
+ break if inflater.finished?
42
45
  end
46
+ return out if inflater.finished?
47
+
43
48
  append_within_limit(out, inflater.finish, max_bytes)
44
49
  ensure
45
50
  inflater.close
@@ -110,15 +110,18 @@ module Dalli
110
110
  ##
111
111
  def truncated_key(key)
112
112
  digest = digest_class.hexdigest(key)
113
- prefix = key[0, prefix_length(digest)]
113
+ prefix = key[0, [prefix_length(digest), 0].max]
114
114
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
115
115
  # A prefix with whitespace or non-ASCII characters sends the whole key
116
116
  # base64-encoded, which can still be too long on the wire; shorten it.
117
- while wire_length(truncated) > MAX_KEY_LENGTH
117
+ while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
118
118
  prefix = prefix[0...-1]
119
119
  truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
120
120
  end
121
- truncated
121
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
122
+
123
+ # Only possible with a digest_class whose digests aren't short hex strings
124
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
122
125
  end
123
126
 
124
127
  # memcached's key limit applies to the key as sent: in bytes, and, with
@@ -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
 
@@ -23,6 +24,7 @@ module Dalli
23
24
  def initialize(attribs, client_options = {})
24
25
  hostname, port, socket_type, @weight, user_creds = ServerConfigParser.parse(attribs)
25
26
  @options = client_options.merge(user_creds)
27
+ @verify_reply_keys = client_options[:verify_reply_keys] ? true : false
26
28
  @value_marshaller = ValueMarshaller.new(@options)
27
29
  @connection_manager = ConnectionManager.new(hostname, port, socket_type, @options)
28
30
  end
@@ -105,7 +107,7 @@ module Dalli
105
107
 
106
108
  # If the status is ok and the key is not nil, then this is a
107
109
  # getkq response with a value that we want to set in the response hash
108
- values[key] = [value, cas] unless key.nil?
110
+ break unless keep_reading?(values, key, value, cas)
109
111
 
110
112
  # Get the next response from the buffer
111
113
  status, cas, key, value = response_buffer.process_single_getk_response
@@ -124,13 +126,16 @@ module Dalli
124
126
  def pipeline_abort
125
127
  response_buffer.clear
126
128
  @connection_manager.abort_request!
127
- return true unless connected?
128
-
129
- # Closes the connection, which ensures that our connection
130
- # is in a clean state for future requests
131
- @connection_manager.error_on_request!('External timeout')
132
- rescue NetworkError
133
- true
129
+ return unless connected?
130
+
131
+ # Closes the connection, which ensures that our connection is in a
132
+ # clean state for future requests. The get_multi ran out of time
133
+ # overall rather than hitting a socket error, so it doesn't count
134
+ # toward socket_max_failures: two slow get_multis in a row would
135
+ # otherwise mark a healthy server down.
136
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
137
+ @connection_manager.close
138
+ nil
134
139
  end
135
140
 
136
141
  # Did the last call to #pipeline_response_setup complete successfully?
@@ -217,6 +222,7 @@ module Dalli
217
222
  end
218
223
 
219
224
  def pipelined_get(keys)
225
+ expect_reply_keys(keys)
220
226
  req = +''
221
227
  keys.each do |key|
222
228
  req << quiet_get_request(key)
@@ -229,6 +235,50 @@ module Dalli
229
235
  @response_buffer ||= ResponseBuffer.new(@connection_manager, response_processor)
230
236
  end
231
237
 
238
+ # A multi-get only accepts replies for the keys it asked for. With the
239
+ # meta protocol, memcached names an item created by auto-create (ma or mg
240
+ # with N) for a base64-encoded key by its raw bytes, without the b flag;
241
+ # such a key can contain spaces or CR/LF, which split the reply header and
242
+ # make the replies after it parse as different ones. A key that wasn't
243
+ # requested is how that shows up. Only with the verify_reply_keys option,
244
+ # since building the set costs a multi-get about 15%.
245
+ def expect_reply_keys(keys)
246
+ @expected_reply_keys = (keys.to_set { |k| reply_key_form(k) } if @verify_reply_keys)
247
+ end
248
+
249
+ # Stores a getkq reply's value and returns true, unless its key wasn't
250
+ # requested: then the replies are out of step, so what was read so far is
251
+ # kept, the rest dropped, and false returned to stop reading
252
+ def keep_reading?(values, key, value, cas)
253
+ return true if key.nil?
254
+
255
+ if unexpected_reply_key?(key)
256
+ abandon_pipeline
257
+ return false
258
+ end
259
+
260
+ values[key] = [value, cas]
261
+ true
262
+ end
263
+
264
+ def unexpected_reply_key?(key)
265
+ !key.nil? && @expected_reply_keys && !@expected_reply_keys.include?(reply_key_form(key))
266
+ end
267
+
268
+ # Keys compared as bytes; ASCII keys compare equal in any encoding as is
269
+ def reply_key_form(key)
270
+ key.ascii_only? ? key : key.b
271
+ end
272
+
273
+ # Drops the rest of this server's replies and closes the connection so
274
+ # nothing unread is left on it. Not a socket failure, so it doesn't count
275
+ # toward socket_max_failures; the keys not read are simply missing.
276
+ def abandon_pipeline
277
+ Dalli.logger.warn { "#{name} replied to a get_multi with a key that wasn't requested; closing the connection" }
278
+ response_buffer.clear
279
+ @connection_manager.close
280
+ end
281
+
232
282
  # Called after the noop response is received at the end of a set
233
283
  # of pipelined gets
234
284
  def finish_pipeline
@@ -102,7 +102,8 @@ module Dalli
102
102
  true
103
103
  end
104
104
 
105
- def data_cas_response
105
+ # raw: true returns the value as stored; see #get
106
+ def data_cas_response(raw: false)
106
107
  resp_header, body = read_response
107
108
  return [nil, resp_header.cas] if resp_header.not_found?
108
109
  return [nil, false] if resp_header.not_stored?
@@ -110,7 +111,7 @@ module Dalli
110
111
  raise_on_not_ok!(resp_header)
111
112
  return [nil, resp_header.cas] unless body
112
113
 
113
- [unpack_response_body(resp_header, body, true).last, resp_header.cas]
114
+ [unpack_response_body(resp_header, body, !raw).last, resp_header.cas]
114
115
  end
115
116
 
116
117
  # Returns the new value for the key, if found and updated
@@ -44,10 +44,10 @@ module Dalli
44
44
 
45
45
  # TODO: This is confusing, as there's a cas command in memcached
46
46
  # and this isn't it. Maybe rename? Maybe eliminate?
47
- def cas(key)
47
+ def cas(key, options = nil)
48
48
  req = RequestFormatter.standard_request(opkey: :get, key: key)
49
49
  write(req)
50
- response_processor.data_cas_response
50
+ response_processor.data_cas_response(raw: raw_request?(options))
51
51
  end
52
52
 
53
53
  # Storage Commands
@@ -54,7 +54,7 @@ module Dalli
54
54
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
55
55
 
56
56
  @sock = memcached_socket
57
- @pid = PIDCache.pid
57
+ @pid = Process.pid
58
58
  @request_in_progress = false
59
59
  rescue SystemCallError, *TIMEOUT_ERRORS, EOFError, SocketError => e
60
60
  # SocketError = DNS resolution failure
@@ -257,7 +257,11 @@ module Dalli
257
257
  end
258
258
 
259
259
  def fork_detected?
260
- @pid && @pid != PIDCache.pid
260
+ # Process.pid rather than PIDCache: PIDCache is refreshed by a
261
+ # Process._fork hook, and another library's fork hook (such as
262
+ # connection_pool closing its connections) can run in the child before
263
+ # it, which would close the parent's TLS session as if it were ours.
264
+ @pid && @pid != Process.pid
261
265
  end
262
266
 
263
267
  def log_down_detected
@@ -38,14 +38,16 @@ module Dalli
38
38
  raw ? value : @value_marshaller.retrieve(value, bitflags_from_tokens(tokens))
39
39
  end
40
40
 
41
- def meta_get_with_value_and_cas
41
+ # raw: true returns the value as stored; see #meta_get_with_value
42
+ def meta_get_with_value_and_cas(raw: false)
42
43
  tokens = error_on_unexpected!([VA, EN, HD])
43
44
  return [nil, 0] if tokens.first == EN
44
45
 
45
46
  cas = cas_from_tokens(tokens)
46
47
  return [nil, cas] unless tokens.first == VA
47
48
 
48
- [@value_marshaller.retrieve(read_line, bitflags_from_tokens(tokens)), cas]
49
+ value = read_line
50
+ [raw ? value : @value_marshaller.retrieve(value, bitflags_from_tokens(tokens)), cas]
49
51
  end
50
52
 
51
53
  def meta_get_without_value
@@ -123,6 +125,10 @@ module Dalli
123
125
  end
124
126
 
125
127
  def full_response_from_buffer(tokens, body, resp_size)
128
+ # A hit with no key can't be matched to a request: skip it (false
129
+ # status) rather than yield it, or take it for the end of the pipeline
130
+ return [resp_size, false, nil, nil, nil] unless tokens.any? { |t| t.start_with?('k') }
131
+
126
132
  value = @value_marshaller.retrieve(body, bitflags_from_tokens(tokens))
127
133
  [resp_size, tokens.first == VA, cas_from_tokens(tokens), key_from_tokens(tokens), value]
128
134
  end
@@ -168,6 +174,11 @@ module Dalli
168
174
  # buffer wait for (and accumulate) that many bytes.
169
175
  MAX_VALUE_BYTES = 1024 * 1024 * 1024
170
176
 
177
+ # The size given right after "VA", or 0 when it isn't a number
178
+ def positional_size(tokens)
179
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
180
+ end
181
+
171
182
  def check_value_size!(size)
172
183
  return if size.between?(0, MAX_VALUE_BYTES)
173
184
 
@@ -209,7 +220,9 @@ module Dalli
209
220
  end
210
221
 
211
222
  def body_len_from_tokens(tokens)
212
- size = value_from_tokens(tokens, 's')&.to_i
223
+ # memcached also gives the size positionally ("VA <size> ..."); use it
224
+ # when there's no s flag rather than reading the value as replies
225
+ size = tokens.any? { |t| t.start_with?('s') } ? value_from_tokens(tokens, 's')&.to_i : positional_size(tokens)
213
226
  check_value_size!(size) if size
214
227
  size
215
228
  end
@@ -13,6 +13,7 @@ module Dalli
13
13
  ##
14
14
  class Meta < Base
15
15
  TERMINATOR = "\r\n"
16
+ COUNTER_ADD_OPTIONS = { raw: true }.freeze # counters are stored raw
16
17
 
17
18
  def response_processor
18
19
  @response_processor ||= ResponseProcessor.new(@connection_manager, @value_marshaller)
@@ -53,11 +54,11 @@ module Dalli
53
54
 
54
55
  # TODO: This is confusing, as there's a cas command in memcached
55
56
  # and this isn't it. Maybe rename? Maybe eliminate?
56
- def cas(key)
57
+ def cas(key, options = nil)
57
58
  encoded_key, base64 = KeyRegularizer.encode(key)
58
59
  req = RequestFormatter.meta_get(key: encoded_key, value: true, return_cas: true, base64: base64)
59
60
  write(req)
60
- response_processor.meta_get_with_value_and_cas
61
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
61
62
  end
62
63
 
63
64
  # Storage Commands
@@ -129,11 +130,42 @@ module Dalli
129
130
  def decr_incr(incr, key, delta, ttl, initial)
130
131
  ttl = initial ? TtlSanitizer.sanitize(ttl) : nil # Only set a TTL if we want to set a value on miss
131
132
  encoded_key, base64 = KeyRegularizer.encode(key)
133
+ # memcached stores an item created by ma's auto-create (N/J) for a
134
+ # base64-encoded key without its binary-key flag, and later replies
135
+ # name it with the raw key bytes. Create such counters with an add.
136
+ return decr_incr_with_add(incr, key, delta, ttl, initial) if initial && base64
137
+
132
138
  write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: initial, incr: incr, ttl: ttl,
133
139
  quiet: quiet?, base64: base64))
134
140
  response_processor.decr_incr unless quiet?
135
141
  end
136
142
 
143
+ # Like decr_incr with an initial value, without memcached's auto-create:
144
+ # on a miss the counter is added with the initial value (which is
145
+ # returned, as auto-create would), and the add losing a race to another
146
+ # client means the counter now exists, so the incr is tried again. In a
147
+ # quiet block both requests are sent and whichever applies takes effect.
148
+ def decr_incr_with_add(incr, key, delta, ttl, initial)
149
+ return quiet_decr_incr_with_add(incr, key, delta, ttl, initial) if quiet?
150
+
151
+ 2.times do
152
+ value = decr_incr(incr, key, delta, nil, nil)
153
+ return value unless value.nil?
154
+
155
+ write_storage_req(:add, key, initial.to_s, ttl, nil, COUNTER_ADD_OPTIONS)
156
+ return initial if response_processor.meta_set_with_cas
157
+ end
158
+ decr_incr(incr, key, delta, nil, nil)
159
+ end
160
+
161
+ def quiet_decr_incr_with_add(incr, key, delta, ttl, initial)
162
+ encoded_key, base64 = KeyRegularizer.encode(key)
163
+ write(RequestFormatter.meta_arithmetic(key: encoded_key, delta: delta, initial: nil, incr: incr, ttl: nil,
164
+ quiet: true, base64: base64))
165
+ # Sent with the rest of the quiet block, by its closing noop
166
+ write_storage_req(:add, key, initial.to_s, ttl, nil, COUNTER_ADD_OPTIONS)
167
+ end
168
+
137
169
  # Other Commands
138
170
  def flush(delay = 0)
139
171
  write(RequestFormatter.flush(delay: delay))
data/lib/dalli/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dalli
4
- VERSION = '3.2.12'
4
+ VERSION = '3.2.14'
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: 3.2.12
4
+ version: 3.2.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -90,7 +90,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
90
  - !ruby/object:Gem::Version
91
91
  version: '0'
92
92
  requirements: []
93
- rubygems_version: 4.0.21
93
+ rubygems_version: 4.0.23
94
94
  specification_version: 4
95
95
  summary: High performance memcached client for Ruby
96
96
  test_files: []