dalli 5.2.0 → 5.2.2

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: 23a3890b1ca95b3668eea5b0f2f6eabfb3ad6062daebf5723e5b99c2c7a74854
4
- data.tar.gz: 4f0c1775badbf133dab91d32a19f84ffafab7f1c0a6c9d7c195f968bed01c089
3
+ metadata.gz: cd910ad2e0c6e5aa47b8591e9ba8c5806b6208fbab0ae0852637aecc48939051
4
+ data.tar.gz: bd0db96211060011acb2135feb337d8acc88638b08c57b3fa875445695a09b59
5
5
  SHA512:
6
- metadata.gz: b1624d8d1024956e90ab86a5d5b7f56833d9d195b95d2e8f7ed92774775eebb5ae19baf798773e17ce8fdb7b8fa5abf728a5899d085ecd11335fe11daac52bdb
7
- data.tar.gz: 76c4e96a6ac88ec7ce86d213d58bf4f709f6ebc52e747ffa05661343173c654696e6e2625090b14d31874f160f3778a750e9cd524cb4f8985ea86716f267793e
6
+ metadata.gz: 82da7aebd50661e1ab9af25dab7d66e5f70cb15260ff9ca4b3476e63eeb60cbb2ad1299718e56312f9a6a61d7a2b14d394fcc470325329f1152db42ac921ee6d
7
+ data.tar.gz: a036421fac121aa342adc3beced711ce64a1bd241673a9314f1f1454a951515890732c4c9651e52baf65c94cd01c4b5cbb655c8dcca4fa4db03d656c3cfc4abe
data/CHANGELOG.md CHANGED
@@ -4,6 +4,82 @@ Dalli Changelog
4
4
  Unreleased
5
5
  ==========
6
6
 
7
+ 5.2.2
8
+ ==========
9
+
10
+ Security:
11
+
12
+ - Keep the caller's key when retrying a request (GHSA-m252-9cgf-vx2w)
13
+ - With a `namespace`, a request retried after a transient network error (a timeout, or a connection closed by memcached or a proxy) applied the namespace a second time, so a retried read could return a different key's value and a retried write could overwrite a different key
14
+ - Affects single-key operations since 5.0.3, single-server `get_multi` since 5.1.0, and `get_with_metadata` and `fetch_with_lock` since 4.1.0; fixed in 5.2.2, 5.1.4, 5.0.10 and 4.3.7. Clients without a namespace aren't affected
15
+ - Complete the fix for per-request `raw: true` on reads (GHSA-wr87-m4jw-29x5)
16
+ - A read made with `raw: true` doesn't ask for flags, but a reply carrying them anyway (from a proxy or a hostile server) still had its value deserialized. Raw reads now ignore flags in the reply
17
+ - Affects 5.2.1 and earlier; fixed in 5.2.2, 5.1.4, 5.0.10, 4.3.7 and 3.2.13
18
+
19
+ Bug fixes:
20
+
21
+ - `get_with_metadata` and `fetch_with_lock` retried the final error raised when a server is marked down, so with `down_retry_delay: 0` they retried an unresponsive server forever. They now retry only retryable errors, like other operations
22
+ - Fix a regression in 5.2.1: 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
23
+ - Fix a regression in 5.2.1: over TLS, every request buffered in a `quiet` block was sent as its own TLS record and system call (2.5 times slower for a block of 2000 deletes). Each flush is one write again
24
+ - Handle malformed replies from a broken or hostile server or proxy: a negative value size, a hit with no key, or a hit with no `s` flag could leave the connection out of step or raise a non-Dalli error
25
+ - 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
26
+ - With a `digest_class` whose digests aren't short hex strings, shortening a long key could loop forever. It now raises `ArgumentError`
27
+
28
+ Notes:
29
+
30
+ - Document how long keys are shortened, and that a short key written in the same form names the same item, in the README's security note (#1200)
31
+
32
+ Development:
33
+
34
+ - Run the Tests, RuboCop and Profiles workflows on pushes to `main` and the `*-stable` branches only, so a pull request's branch isn't tested twice (once for the push and once for the pull request) (#1198)
35
+
36
+ 5.2.1
37
+ ==========
38
+
39
+ Security:
40
+
41
+ - Fix pipelined `get_multi` returning one key's value for another after an error reply (GHSA-p6pm-ch9v-44vx)
42
+ - The pipelined reply parser took any reply without a body for the end of the batch. An error reply for one key (`CLIENT_ERROR`, `SERVER_ERROR`, or `EN` from a proxy) ended it early, and the replies still on the connection were read as the replies to later commands, so a `get` could return another key's value
43
+ - Keys are now measured in bytes as sent, after base64 encoding when the key needs it. A key under 250 characters but over 250 bytes on the wire (for example, one with many non-ASCII characters) was the easiest way to cause that error reply. Such keys are now truncated like other long keys; keys that worked before are unchanged
44
+ - Affects the meta protocol since 3.2.0 (the default since 5.0.0); fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
45
+ - Reject routing tokens that add meta flags, and stop retrying an unresponsive server forever (GHSA-4qp6-2jcr-596v)
46
+ - `p_token` and `l_token` now reject whitespace and control characters, not just CR, LF and NUL. A space let a token add meta flags to the request it was sent with: changing an item's TTL on a read, creating stub items on a miss, turning a delete into a stale tombstone, or reading a different key
47
+ - Affects 5.1.0 and later; fixed in 5.2.1 and 5.1.3
48
+ - A server that accepted connections but never answered (or dropped the connection on a request) was retried forever, hanging the caller, because each successful reconnect reset the failure count. Requests now fail after `socket_max_failures` attempts and the server is marked down, as when it can't be reached at all; it gets a full set of attempts again after `down_retry_delay`
49
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
50
+ - Limit decompressed and reply value sizes (GHSA-3553-vcg5-72jw)
51
+ - Values flagged as compressed were inflated without limit, so a small stored value could expand to gigabytes on read, whatever the client's `compress` or `serializer` settings. The new `decompressed_max_bytes` option (default 128 MiB; `nil` disables it) makes a read that would pass it raise `Dalli::UnmarshalError`. Custom compressors whose `decompress` takes only the data keep working, without the limit
52
+ - The value size in a reply was used to read or buffer that many bytes, so a malicious server could make the client allocate gigabytes. Sizes over 1 GiB, memcached's largest item, now raise `Dalli::DalliError` before reading
53
+ - Affects all versions; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
54
+ - Honor per-request `raw: true` on reads, and add `Dalli::JSONSerializer` (GHSA-wr87-m4jw-29x5)
55
+ - `get_multi`, `get_multi_cas`, `get_multi_with_metadata`, `get_cas` and `get_with_metadata` ignored a per-request `raw: true` and deserialized the value according to its stored flags, so a caller who asked for raw bytes could still have `Marshal.load` run on data someone else wrote to memcached. They now return the stored bytes
56
+ - The `raw` part affects `get_with_metadata` since 4.2.0, and the other methods since they gained per-request options in 5.1.0
57
+ - `serializer: JSON` reads values with `JSON.load`, which on json gem versions before 3.0 creates an object of the class named in a stored `json_class` key when the json additions are loaded. The new `Dalli::JSONSerializer` reads with `JSON.parse` and only returns plain JSON types. The README and the Marshal security warning now recommend it; `serializer: JSON` itself is unchanged
58
+ - The `serializer: JSON` part affects any version used with the json gem before 3.0
59
+ - Keep a forked child from writing to or closing the parent's connection (GHSA-w39f-xq2m-4g8x)
60
+ - A child forked while the parent had requests buffered but not yet sent, such as quiet writes inside a `quiet` block, sent them again when it closed the client or reconnected: quiet writes ran twice, and the parent's later replies could be read as the replies to other requests, returning one key's value for another. Dalli now buffers requests itself and a forked child discards them
61
+ - Affects 4.2.0 and later; fixed in 5.2.1, 5.1.3, 5.0.9 and 4.3.6
62
+ - With TLS, a forked child that closed the client (or reconnected after detecting the fork) sent a TLS close on the connection the parent was still using, ending the parent's session. A forked child now closes only its own copy of the socket
63
+ - Affects all versions with TLS; fixed in 5.2.1, 5.1.3, 5.0.9, 4.3.6 and 3.2.12
64
+
65
+ Bug fixes:
66
+
67
+ - An exception raised by a `get_multi` block (such as `Timeout::Error`) was treated as a network failure: swallowed, with the whole `get_multi` retried and keys yielded twice. It now reaches the caller
68
+
69
+ Notes:
70
+
71
+ - Document [Ruby bug 21195](https://bugs.ruby-lang.org/issues/21195), a VM crash (`[BUG] rb_sys_fail_path_in(io_fillbuf, ...) - errno == 0`) when a socket read using `IO#timeout` is interrupted by a signal, in the README (#1189)
72
+ - Affects Ruby 3.3.0–3.3.7 and 3.4.0–3.4.2; fixed in 3.3.8 and 3.4.3
73
+
74
+ Development:
75
+
76
+ - Add property tests for request building and fuzz tests for the reply parsers
77
+ - Add a security policy (`SECURITY.md`), give every workflow a least-privilege token, and pin third-party actions by commit SHA (#1190)
78
+ - Fix flaky failover tests (#1184)
79
+ - Their ports were inside Linux's ephemeral port range (32768-60999), so an earlier client connection could hold one as its local port. memcached then couldn't bind it on IPv4 and listened on IPv6 only, and the test's client marked that server down. They now use ports in the 26xxx range
80
+ - The test helper now waits until memcached accepts connections, instead of sleeping a fixed 0.1s after starting it
81
+ - Refresh apt's package index before installing libevent in CI, so a stale runner image can't break every memcached build (#1172)
82
+
7
83
  5.2.0
8
84
  ==========
9
85
 
data/README.md CHANGED
@@ -24,6 +24,16 @@ latest release. Earlier 1.6.x servers are not supported: the meta protocol
24
24
  rejects unknown flags outright, so features added after a server's release fail
25
25
  with `CLIENT_ERROR invalid flag` rather than degrading.
26
26
 
27
+ ### Known Ruby issue: crash on an interrupted socket read
28
+
29
+ Dalli sets socket timeouts with `IO#timeout`. Ruby 3.3.0–3.3.7 and 3.4.0–3.4.2 have a bug ([Ruby #21195](https://bugs.ruby-lang.org/issues/21195)) where a timed read interrupted by a signal aborts the whole process:
30
+
31
+ ```
32
+ [BUG] rb_sys_fail_path_in(io_fillbuf, fd:N ) - errno == 0
33
+ ```
34
+
35
+ The crash is inside Ruby itself, so it can't be rescued. It's fixed in Ruby 3.3.8 and 3.4.3. If you see this crash, upgrade to one of those versions or later.
36
+
27
37
  ## Configuration Options
28
38
 
29
39
  ### Namespace
@@ -80,9 +90,26 @@ end
80
90
  By default, Dalli uses Ruby's Marshal for serialization. Deserializing untrusted data with Marshal can lead to remote code execution. If you cache user-controlled data, consider using a safer serializer:
81
91
 
82
92
  ```ruby
83
- Dalli::Client.new('localhost:11211', serializer: JSON)
93
+ Dalli::Client.new('localhost:11211', serializer: Dalli::JSONSerializer)
84
94
  ```
85
95
 
96
+ `Dalli::JSONSerializer` reads values with `JSON.parse`, so it only ever returns plain hashes, arrays, strings, numbers, booleans and `nil`. Passing the `JSON` module itself (`serializer: JSON`) reads values with `JSON.load`, which can create objects of other classes from a stored `json_class` key and is not recommended for data an attacker might write.
97
+
98
+ Per-request `raw: true` returns the stored bytes without deserializing or decompressing them, on every read method (`get`, `get_multi`, `get_cas`, `get_with_metadata` and `fetch`).
99
+
100
+ ### Long keys
101
+
102
+ memcached limits keys to 250 bytes. Dalli shortens a longer key (counting its namespace) to the start of the key, followed by `:md5:` and a hex digest of the whole key:
103
+
104
+ ```ruby
105
+ dc.set("report:#{'x' * 300}", 'data')
106
+ # stored under "report:xxx...xxx:md5:<32 hex characters>"
107
+ ```
108
+
109
+ The shortened key is an ordinary memcached key, so a short key written in exactly that form names the same item, and reading or writing it reads or overwrites the long key's value. This only matters if untrusted input can choose an entire cache key. If it can, hash the untrusted part yourself so the key never needs shortening, or reject keys that contain `:md5:`.
110
+
111
+ The digest is MD5 by default. `digest_class:` takes any object that responds to `hexdigest`, such as `Digest::SHA256`. Changing it renames every shortened key, so existing entries for long keys are missed once.
112
+
86
113
  See the [5.0-Upgrade.md](5.0-Upgrade.md) guide for upgrade information.
87
114
 
88
115
  ## OpenTelemetry Tracing
data/lib/dalli/client.rb CHANGED
@@ -144,14 +144,18 @@ module Dalli
144
144
  #
145
145
  def get_with_metadata(key, options = {})
146
146
  validate_routing_tokens!(options)
147
- key = key.to_s
148
- key = @key_manager.validate_key(key)
147
+ # A retry re-runs this method with its original arguments, so `key` must
148
+ # stay as the caller gave it; reassigning it would add the namespace again.
149
+ validated_key = @key_manager.validate_key(key.to_s)
149
150
 
150
- server = ring.server_for_key(key)
151
- Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', key, server)) do
152
- server.request(:meta_get, key, options)
151
+ server = ring.server_for_key(validated_key)
152
+ Instrumentation.trace('get_with_metadata', trace_attrs('get_with_metadata', validated_key, server)) do
153
+ server.request(:meta_get, validated_key, options)
153
154
  end
154
- rescue NetworkError => e
155
+ # Only a retryable error is retried. The NetworkError raised when a server
156
+ # is marked down is final, as in #perform; retrying it looped forever once
157
+ # down_retry_delay let the same server be tried again.
158
+ rescue RetryableNetworkError => e
155
159
  Dalli.logger.debug { e.inspect }
156
160
  Dalli.logger.debug { 'retrying get_with_metadata with new server' }
157
161
  retry
@@ -347,14 +351,17 @@ module Dalli
347
351
  validate_integer!(:lock_ttl, lock_ttl)
348
352
  validate_integer!(:recache_threshold, recache_threshold)
349
353
  validate_routing_tokens!(req_options)
350
- key = key.to_s
351
- key = @key_manager.validate_key(key)
354
+ # Left unchanged for the retry below; see get_with_metadata
355
+ validated_key = @key_manager.validate_key(key.to_s)
352
356
 
353
- server = ring.server_for_key(key)
354
- Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', key, server)) do
355
- fetch_with_lock_request(key, ttl, lock_ttl, recache_threshold, req_options, &block)
357
+ server = ring.server_for_key(validated_key)
358
+ Instrumentation.trace('fetch_with_lock', trace_attrs('fetch_with_lock', validated_key, server)) do
359
+ fetch_with_lock_request(validated_key, ttl, lock_ttl, recache_threshold, req_options, &block)
356
360
  end
357
- rescue NetworkError => e
361
+ # Only a retryable error is retried. The NetworkError raised when a server
362
+ # is marked down is final, as in #perform; retrying it looped forever once
363
+ # down_retry_delay let the same server be tried again.
364
+ rescue RetryableNetworkError => e
358
365
  Dalli.logger.debug { e.inspect }
359
366
  Dalli.logger.debug { 'retrying fetch_with_lock with new server' }
360
367
  retry
@@ -787,10 +794,11 @@ module Dalli
787
794
  # to route to at all is still silent, matching Ring#keys_grouped_by_server
788
795
  # dropping a key it can't route on both the single- and multi-server paths.
789
796
  def single_server_get_multi(keys, req_options = nil)
790
- keys.map! { |k| @key_manager.validate_key(k.to_s) }
797
+ # A new array, so the retry below starts again from the caller's keys
798
+ validated_keys = keys.map { |k| @key_manager.validate_key(k.to_s) }
791
799
  return {} unless (server = single_server)
792
800
 
793
- result = server.request(:read_multi_req, keys, req_options)
801
+ result = server.request(:read_multi_req, validated_keys, req_options)
794
802
  result.transform_keys! { |k| @key_manager.key_without_namespace(k) }
795
803
  result
796
804
  rescue Dalli::RetryableNetworkError => e
@@ -881,7 +889,7 @@ module Dalli
881
889
  # check means unwinding through Protocol::Base#request, which logs the
882
890
  # failure as unexpected and closes the connection -- a caller passing a
883
891
  # bad token should get a clean ArgumentError and keep its connection.
884
- ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
892
+ ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
885
893
  private_constant :ROUTING_TOKEN_FORBIDDEN
886
894
 
887
895
  def validate_routing_tokens!(req_options)
@@ -897,7 +905,9 @@ module Dalli
897
905
  # here (it would also excuse [] / {} from the type check below).
898
906
  return if value.nil? || (value.is_a?(String) && value.empty?)
899
907
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
900
- raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
908
+ return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
909
+
910
+ raise ArgumentError, "#{name} must not contain whitespace or control characters"
901
911
  end
902
912
 
903
913
  def cas_core(key, always_set, ttl = nil, req_options = nil)
@@ -955,18 +965,20 @@ module Dalli
955
965
  # rubocop:enable Naming/MethodParameterName
956
966
  return yield if block_given?
957
967
 
958
- key = key.to_s
959
- key = @key_manager.validate_key(key)
968
+ # `retry` below re-runs this method with its original arguments, so `key`
969
+ # must stay as the caller gave it: reassigning it would apply the
970
+ # namespace a second time and send the retry to a different key.
971
+ validated_key = @key_manager.validate_key(key.to_s)
960
972
 
961
- server = ring.server_for_key(key)
973
+ server = ring.server_for_key(validated_key)
962
974
 
963
975
  if Instrumentation.enabled?
964
976
  op_name = op.name
965
- Instrumentation.trace(op_name, trace_attrs(op_name, key, server)) do
966
- server.request(op, key, ...)
977
+ Instrumentation.trace(op_name, trace_attrs(op_name, validated_key, server)) do
978
+ server.request(op, validated_key, ...)
967
979
  end
968
980
  else
969
- server.request(op, key, ...)
981
+ server.request(op, validated_key, ...)
970
982
  end
971
983
  rescue RetryableNetworkError => e
972
984
  Dalli.logger.debug { e.inspect }
@@ -13,8 +13,54 @@ module Dalli
13
13
  Zlib::Deflate.deflate(data)
14
14
  end
15
15
 
16
- def self.decompress(data)
17
- Zlib::Inflate.inflate(data)
16
+ # DEFLATE can't expand data by more than about 1032:1
17
+ MAX_EXPANSION = 1032
18
+ # Compressed input is fed to zlib this many bytes at a time. MRI's zlib
19
+ # yields the output in small chunks; one that returns it instead still
20
+ # produces at most MAX_EXPANSION times this per step.
21
+ INFLATE_SLICE_BYTES = 64 * 1024
22
+
23
+ # max_bytes caps the decompressed size; inflating stops and raises as soon
24
+ # as the output passes it, so a small compressed value can't expand into
25
+ # gigabytes of memory.
26
+ def self.decompress(data, max_bytes: nil)
27
+ return Zlib::Inflate.inflate(data) unless may_exceed?(data, max_bytes)
28
+
29
+ inflate_within_limit(Zlib::Inflate.new, data, max_bytes)
30
+ end
31
+
32
+ # False when even the largest possible expansion stays within max_bytes,
33
+ # so the value can be inflated in one step
34
+ def self.may_exceed?(data, max_bytes)
35
+ max_bytes ? data.bytesize * MAX_EXPANSION > max_bytes : false
36
+ end
37
+
38
+ def self.inflate_within_limit(inflater, data, max_bytes)
39
+ out = String.new(encoding: Encoding::BINARY)
40
+ 0.step(data.bytesize - 1, INFLATE_SLICE_BYTES) do |pos|
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?
45
+ end
46
+ return out if inflater.finished?
47
+
48
+ append_within_limit(out, inflater.finish, max_bytes)
49
+ ensure
50
+ inflater.close
51
+ end
52
+
53
+ # Both yielded and returned output count toward the limit
54
+ def self.inflate_slice(inflater, slice, out, max_bytes)
55
+ rest = inflater.inflate(slice) { |chunk| append_within_limit(out, chunk, max_bytes) }
56
+ append_within_limit(out, rest, max_bytes)
57
+ end
58
+
59
+ def self.append_within_limit(out, chunk, max_bytes)
60
+ out << chunk if chunk
61
+ return out if out.bytesize <= max_bytes
62
+
63
+ raise Dalli::UnmarshalError, "Decompressed value exceeds #{max_bytes} bytes (decompressed_max_bytes)"
18
64
  end
19
65
  end
20
66
 
@@ -32,9 +78,11 @@ module Dalli
32
78
  io.string
33
79
  end
34
80
 
35
- def self.decompress(data)
36
- io = StringIO.new(data, 'rb')
37
- Zlib::GzipReader.new(io).read
81
+ def self.decompress(data, max_bytes: nil)
82
+ return Zlib::GzipReader.new(StringIO.new(data, 'rb')).read unless Compressor.may_exceed?(data, max_bytes)
83
+
84
+ # MAX_WBITS + 16 makes zlib read the gzip format (and check its CRC)
85
+ Compressor.inflate_within_limit(Zlib::Inflate.new(Zlib::MAX_WBITS + 16), data, max_bytes)
38
86
  end
39
87
  end
40
88
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Dalli
6
+ ##
7
+ # A serializer that stores values as JSON and never creates Ruby objects
8
+ # from what it reads.
9
+ #
10
+ # Passing the JSON module itself as the serializer (serializer: JSON) reads
11
+ # values back with JSON.load, which on json gem versions before 3.0 honors
12
+ # "json_class" and instantiates other classes when the json gem's additions
13
+ # are loaded. This serializer reads with JSON.parse, so a value written to
14
+ # memcached by someone else can only come back as plain hashes, arrays,
15
+ # strings, numbers, booleans and nil.
16
+ #
17
+ # Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)
18
+ ##
19
+ module JSONSerializer
20
+ def self.dump(value)
21
+ JSON.generate(value)
22
+ end
23
+
24
+ def self.load(data)
25
+ JSON.parse(data)
26
+ end
27
+ end
28
+ end
@@ -58,7 +58,7 @@ module Dalli
58
58
  raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?
59
59
 
60
60
  key = key_with_namespace(key)
61
- key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
61
+ wire_length(key) > MAX_KEY_LENGTH ? truncated_key(key) : key
62
62
  end
63
63
 
64
64
  ##
@@ -122,7 +122,28 @@ module Dalli
122
122
  ##
123
123
  def truncated_key(key)
124
124
  digest = digest_class.hexdigest(key)
125
- "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
125
+ prefix = key[0, [prefix_length(digest), 0].max]
126
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
127
+ # A prefix with whitespace or non-ASCII characters sends the whole key
128
+ # base64-encoded, which can still be too long on the wire; shorten it.
129
+ while wire_length(truncated) > MAX_KEY_LENGTH && !prefix.empty?
130
+ prefix = prefix[0...-1]
131
+ truncated = "#{prefix}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
132
+ end
133
+ return truncated if wire_length(truncated) <= MAX_KEY_LENGTH
134
+
135
+ # Only possible with a digest_class whose digests aren't short hex strings
136
+ raise ArgumentError, "#{digest_class} digests are too long to fit in a truncated key"
137
+ end
138
+
139
+ # memcached's key limit applies to the key as sent: in bytes, and after
140
+ # base64 encoding when the key needs it. A key can be within the limit in
141
+ # characters and still be rejected, and memcached's CLIENT_ERROR for it
142
+ # must never reach a pipelined get.
143
+ def wire_length(key)
144
+ return key.bytesize unless Dalli::Protocol::Meta::KeyRegularizer.required?(key)
145
+
146
+ ((key.bytesize + 2) / 3) * 4
126
147
  end
127
148
 
128
149
  def prefix_length(digest)
@@ -53,6 +53,14 @@ module Dalli
53
53
  @raw_mode
54
54
  end
55
55
 
56
+ # True when values should come back as stored: the client is in raw mode,
57
+ # or this request passed raw: true. Retrieval paths use it to skip
58
+ # requesting flags, so a stored value is never deserialized or
59
+ # decompressed for a caller who asked for raw bytes.
60
+ def raw_request?(options)
61
+ raw_mode? || (options.is_a?(Hash) && options[:raw]) ? true : false
62
+ end
63
+
56
64
  # Chokepoint method for error handling and ensuring liveness
57
65
  def request(opkey, *args)
58
66
  verify_state(opkey)
@@ -148,7 +156,15 @@ module Dalli
148
156
  reconnect_on_pipeline_complete!
149
157
  values = nil
150
158
 
151
- response_buffer.read
159
+ # Only the read is socket I/O. The caller's block runs below, outside
160
+ # this rescue, so an Errno or Timeout::Error it raises reaches the
161
+ # caller instead of being mistaken for a network failure (which closed
162
+ # the connection and retried the whole get_multi, yielding keys twice).
163
+ begin
164
+ response_buffer.read
165
+ rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
166
+ @connection_manager.error_on_request!(e)
167
+ end
152
168
 
153
169
  status, cas, key, value = response_buffer.process_single_getk_response
154
170
  # status is not nil only if we have a full response to parse
@@ -174,8 +190,6 @@ module Dalli
174
190
  end
175
191
 
176
192
  values || {}
177
- rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
178
- @connection_manager.error_on_request!(e)
179
193
  end
180
194
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
181
195
 
@@ -189,10 +203,13 @@ module Dalli
189
203
  @connection_manager.abort_request!
190
204
  return true unless connected?
191
205
 
192
- # Closes the connection, which ensures that our connection
193
- # is in a clean state for future requests
194
- @connection_manager.error_on_request!('External timeout')
195
- rescue NetworkError
206
+ # Closes the connection, which ensures that our connection is in a
207
+ # clean state for future requests. The get_multi ran out of time
208
+ # overall rather than hitting a socket error, so it doesn't count
209
+ # toward socket_max_failures: two slow get_multis in a row would
210
+ # otherwise mark a healthy server down.
211
+ Dalli.logger.warn { "#{name} didn't finish a get_multi within socket_timeout; closing the connection" }
212
+ @connection_manager.close
196
213
  true
197
214
  end
198
215
 
@@ -328,6 +345,7 @@ module Dalli
328
345
  # Use clear (not reset) to keep pipeline_complete? = true, which is
329
346
  # the expected state before pipeline_response_setup is called.
330
347
  response_buffer.clear
348
+ response_buffer.raw = raw_request?(options)
331
349
 
332
350
  # The terminating noop is sent by pipeline_response_setup
333
351
  write(quiet_get_requests(keys, options, return_cas: return_cas))
@@ -339,6 +357,7 @@ module Dalli
339
357
  def pipelined_get_interleaved(keys, chunk_size, results, options = nil)
340
358
  # Initialize the response buffer for draining during send phase
341
359
  response_buffer.ensure_ready
360
+ response_buffer.raw = raw_request?(options)
342
361
 
343
362
  keys.each_slice(chunk_size) do |chunk|
344
363
  # Build and write this chunk of requests
@@ -36,7 +36,10 @@ module Dalli
36
36
  @quiet_responses_pending = false
37
37
  @sock = nil
38
38
  @pid = nil
39
+ @write_buffer = []
40
+ @write_buffer_bytes = 0
39
41
 
42
+ @fail_count = 0
40
43
  reset_down_info
41
44
  end
42
45
 
@@ -52,7 +55,8 @@ module Dalli
52
55
  Dalli.logger.debug { "Dalli::Server#connect #{name}" }
53
56
 
54
57
  @sock = memcached_socket
55
- @sock.sync = false # Enable buffered I/O for better performance
58
+ # Writes are buffered in @write_buffer instead; see WRITE_BUFFER_FLUSH_BYTES
59
+ @sock.sync = true
56
60
  @pid = Process.pid
57
61
  @request_in_progress = false
58
62
  @quiet_responses_pending = false
@@ -85,6 +89,8 @@ module Dalli
85
89
  def down!
86
90
  close
87
91
  log_down_detected
92
+ # Once down_retry_delay passes, the server gets a full set of attempts
93
+ @fail_count = 0
88
94
 
89
95
  @error = $ERROR_INFO&.class&.name
90
96
  @msg ||= $ERROR_INFO&.message
@@ -114,7 +120,7 @@ module Dalli
114
120
  return unless @sock
115
121
 
116
122
  begin
117
- @sock.close
123
+ close_socket
118
124
  rescue StandardError
119
125
  nil
120
126
  ensure
@@ -126,10 +132,18 @@ module Dalli
126
132
  @sock = nil
127
133
  @pid = nil
128
134
  @quiet_responses_pending = false
135
+ discard_write_buffer
129
136
  abort_request!
130
137
  end
131
138
  end
132
139
 
140
+ # A forked child shares the parent's connection, so it closes only its
141
+ # own file descriptor. Closing the TLS socket itself would send
142
+ # close_notify and end the parent's TLS session.
143
+ def close_socket
144
+ fork_detected? ? @sock.to_io.close : @sock.close
145
+ end
146
+
133
147
  def connected?
134
148
  !@sock.nil?
135
149
  end
@@ -163,6 +177,11 @@ module Dalli
163
177
  raise '[Dalli] No request in progress. This may be a bug in Dalli.' unless @request_in_progress
164
178
 
165
179
  @request_in_progress = false
180
+ # A completed request is what proves the server healthy again, so the
181
+ # failure count resets here rather than on reconnect: a server that
182
+ # accepts connections but never answers would otherwise reset it on
183
+ # every retry and never reach socket_max_failures.
184
+ @fail_count = 0
166
185
  end
167
186
 
168
187
  def abort_request!
@@ -170,6 +189,7 @@ module Dalli
170
189
  end
171
190
 
172
191
  def read_line
192
+ flush_write_buffer
173
193
  data = @sock.gets("\r\n")
174
194
  error_on_request!('EOF in read_line') if data.nil?
175
195
  data
@@ -179,14 +199,29 @@ module Dalli
179
199
 
180
200
  # JRuby doesn't support IO#timeout=, so use custom readfull implementation
181
201
  # CRuby 3.3+ has IO#timeout= which makes IO#read work with timeouts
202
+ # memcached can't store an item larger than 1 GiB (its -I maximum), so a
203
+ # reply claiming more (or a negative size) is malformed or hostile.
204
+ # IO#read allocates the full count up front, so check before reading.
205
+ MAX_READ_BYTES = (1024 * 1024 * 1024) + 2 # plus the value's trailing "\r\n"
206
+
207
+ def check_read_size!(count)
208
+ return if count.between?(0, MAX_READ_BYTES)
209
+
210
+ raise Dalli::DalliError, "Reply size #{count} from #{name} is out of range"
211
+ end
212
+
182
213
  if RUBY_ENGINE == 'jruby'
183
214
  def read(count)
215
+ check_read_size!(count)
216
+ flush_write_buffer
184
217
  @sock.readfull(count)
185
218
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
186
219
  error_on_request!(e)
187
220
  end
188
221
  else
189
222
  def read(count)
223
+ check_read_size!(count)
224
+ flush_write_buffer
190
225
  read_bytes(count)
191
226
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, EOFError => e
192
227
  error_on_request!(e)
@@ -197,22 +232,34 @@ module Dalli
197
232
  # at the call site.
198
233
  alias read_exact read
199
234
 
235
+ # Requests are buffered here rather than in the socket's own IO buffer
236
+ # (the socket is sync). Ruby flushes an IO's write buffer when any process
237
+ # holding it closes or finalizes the IO, so after a fork a child could
238
+ # send bytes the parent had buffered but not sent yet: a quiet write would
239
+ # run twice, or the parent's replies would shift onto the wrong requests.
240
+ # A forked child discards this buffer instead. Like IO's own buffer, it
241
+ # is sent once it grows past this size, or when a reply is read.
242
+ WRITE_BUFFER_FLUSH_BYTES = 64 * 1024
243
+
200
244
  def write(bytes)
201
- @sock.write(bytes)
245
+ @write_buffer << bytes
246
+ @write_buffer_bytes += bytes.bytesize
247
+ flush_write_buffer if @write_buffer_bytes >= WRITE_BUFFER_FLUSH_BYTES
248
+ bytes.bytesize
202
249
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
203
250
  error_on_request!(e)
204
251
  end
205
252
 
206
253
  def flush
207
- @sock.flush
254
+ flush_write_buffer
208
255
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
209
256
  error_on_request!(e)
210
257
  end
211
258
 
212
259
  def flushed_write(bytes)
213
- written = @sock.write(bytes)
214
- @sock.flush
215
- written
260
+ @write_buffer << bytes
261
+ flush_write_buffer
262
+ bytes.bytesize
216
263
  rescue SystemCallError, *TIMEOUT_ERRORS, *SSL_ERRORS, IOError => e
217
264
  error_on_request!(e)
218
265
  end
@@ -220,6 +267,7 @@ module Dalli
220
267
  # Non-blocking read. Here to support the operation
221
268
  # of the get_multi operation
222
269
  def read_available(...)
270
+ flush_write_buffer
223
271
  @sock.read_available(...)
224
272
  end
225
273
 
@@ -246,8 +294,9 @@ module Dalli
246
294
  raise Dalli::RetryableNetworkError, message
247
295
  end
248
296
 
297
+ # Called on connect. Deliberately leaves @fail_count alone; see
298
+ # finish_request!.
249
299
  def reset_down_info
250
- @fail_count = 0
251
300
  @down_at = nil
252
301
  @last_down_at = nil
253
302
  @msg = nil
@@ -272,7 +321,8 @@ module Dalli
272
321
  def reconnect_on_fork
273
322
  message = 'Fork detected, re-connecting child process...'
274
323
  Dalli.logger.info { message }
275
- # Close socket on a fork and reconnect immediately
324
+ # Drops anything the parent had buffered, closes the inherited socket
325
+ # without touching the parent's connection, and reconnects immediately
276
326
  close
277
327
  establish_connection
278
328
  end
@@ -302,6 +352,37 @@ module Dalli
302
352
 
303
353
  private
304
354
 
355
+ # One write per flush. A TLS socket sends each argument of
356
+ # write(*parts) as its own record and system call, so the parts are
357
+ # joined first.
358
+ def flush_write_buffer
359
+ return if @write_buffer.empty?
360
+
361
+ @sock.write(@write_buffer.size == 1 ? @write_buffer.first : joined_write_buffer)
362
+ discard_write_buffer
363
+ end
364
+
365
+ # Joined as bytes: requests can mix UTF-8 and binary strings, which
366
+ # String#<< refuses to combine when both hold non-ASCII bytes
367
+ if String.method_defined?(:append_as_bytes)
368
+ def joined_write_buffer
369
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
370
+ @write_buffer.each { |part| out.append_as_bytes(part) }
371
+ out
372
+ end
373
+ else
374
+ def joined_write_buffer
375
+ out = String.new(capacity: @write_buffer_bytes, encoding: Encoding::BINARY)
376
+ @write_buffer.each { |part| out << part.b }
377
+ out
378
+ end
379
+ end
380
+
381
+ def discard_write_buffer
382
+ @write_buffer.clear
383
+ @write_buffer_bytes = 0
384
+ end
385
+
305
386
  # Reads exactly `count` bytes. IO#read(count) on a blocking socket blocks
306
387
  # until it has `count` bytes, accumulating across TCP chunks internally,
307
388
  # and only hands back a shorter (or nil) buffer when the stream hits EOF.
@@ -38,20 +38,20 @@ module Dalli
38
38
  RequestFormatter.plain_meta_get(key, skip_flags)
39
39
  end
40
40
  flushed_write(req)
41
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
41
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
42
42
  end
43
43
 
44
44
  def quiet_get_request(key, options = nil)
45
45
  # Skip bitflags in raw mode - saves 2 bytes per request and skips parsing
46
- RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_mode?,
46
+ RequestFormatter.meta_get(key: key, return_cas: true, quiet: true, skip_flags: raw_request?(options),
47
47
  **routing_token_kwargs(options))
48
48
  end
49
49
 
50
50
  # Same requests as quiet_get_request for each key, built in one pass
51
51
  # and without the trailing noop.
52
52
  def quiet_get_requests(keys, options = nil, return_cas: true)
53
- RequestFormatter.multi_meta_get(keys, skip_flags: raw_mode?, return_cas: return_cas, terminate: false,
54
- **routing_token_kwargs(options))
53
+ RequestFormatter.multi_meta_get(keys, skip_flags: raw_request?(options), return_cas: return_cas,
54
+ terminate: false, **routing_token_kwargs(options))
55
55
  end
56
56
 
57
57
  def gat(key, ttl, options = nil)
@@ -59,7 +59,7 @@ module Dalli
59
59
  skip_flags = raw_mode? || (options && options[:raw])
60
60
  req = RequestFormatter.meta_get(key: key, ttl: ttl, skip_flags: skip_flags, **routing_token_kwargs(options))
61
61
  flushed_write(req)
62
- response_processor.meta_get_with_value(cache_nils: cache_nils?(options))
62
+ response_processor.meta_get_with_value(cache_nils: cache_nils?(options), raw: raw_request?(options))
63
63
  end
64
64
 
65
65
  def touch(key, ttl)
@@ -72,9 +72,10 @@ module Dalli
72
72
  # TODO: This is confusing, as there's a cas command in memcached
73
73
  # and this isn't it. Maybe rename? Maybe eliminate?
74
74
  def cas(key, options = nil)
75
- req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, **routing_token_kwargs(options))
75
+ req = RequestFormatter.meta_get(key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
76
+ **routing_token_kwargs(options))
76
77
  flushed_write(req)
77
- response_processor.meta_get_with_value_and_cas
78
+ response_processor.meta_get_with_value_and_cas(raw: raw_request?(options))
78
79
  end
79
80
 
80
81
  # Comprehensive meta get with support for all metadata flags.
@@ -103,7 +104,7 @@ module Dalli
103
104
  # - :last_access - seconds since last access (only if return_last_access: true)
104
105
  def meta_get(key, options = {})
105
106
  req = RequestFormatter.meta_get(
106
- key: key, value: true, return_cas: true,
107
+ key: key, value: true, return_cas: true, skip_flags: raw_request?(options),
107
108
  vivify_ttl: options[:vivify_ttl], recache_ttl: options[:recache_ttl],
108
109
  return_hit_status: options[:return_hit_status],
109
110
  return_last_access: options[:return_last_access],
@@ -115,7 +116,7 @@ module Dalli
115
116
  response_processor.meta_get_with_metadata(
116
117
  cache_nils: cache_nils?(options), return_hit_status: options[:return_hit_status],
117
118
  return_last_access: options[:return_last_access],
118
- return_ttl_remaining: options[:return_ttl_remaining]
119
+ return_ttl_remaining: options[:return_ttl_remaining], raw: raw_request?(options)
119
120
  )
120
121
  end
121
122
 
@@ -288,7 +289,7 @@ module Dalli
288
289
  # response parsing to minimize per-key overhead. Avoids the PipelinedGetter
289
290
  # machinery (IO.select, response buffering, server grouping).
290
291
  def read_multi_req(keys, options = nil)
291
- is_raw = raw_mode?
292
+ is_raw = raw_request?(options)
292
293
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, **routing_token_kwargs(options))
293
294
  flushed_write(buffer)
294
295
  buffer.clear
@@ -304,7 +305,7 @@ module Dalli
304
305
  # Shared by both the single-server fast path and PipelinedGetter's
305
306
  # per-server-group request, so this one change covers both routes.
306
307
  def read_multi_with_metadata_req(keys, options = nil)
307
- is_raw = raw_mode?
308
+ is_raw = raw_request?(options)
308
309
  buffer = RequestFormatter.multi_meta_get(keys, skip_flags: is_raw, return_cas: true,
309
310
  **routing_token_kwargs(options))
310
311
  flushed_write(buffer)
@@ -321,7 +322,9 @@ module Dalli
321
322
  next unless line.start_with?('VA ')
322
323
 
323
324
  tokens = line.chomp!(TERMINATOR).split
324
- value = @connection_manager.read(tokens[1].to_i + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
325
+ size = tokens[1].to_i
326
+ response_processor.check_value_size!(size)
327
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
325
328
  stale = response_processor.stale_from_tokens(tokens)
326
329
  cas = response_processor.cas_from_tokens(tokens)
327
330
  bitflags = is_raw ? 0 : response_processor.bitflags_from_tokens(tokens)
@@ -349,7 +352,9 @@ module Dalli
349
352
  # splitting it into tokens
350
353
  def parse_multi_get_value(line, is_raw)
351
354
  processor = response_processor
352
- value = @connection_manager.read(processor.size_from_va_line(line) + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
355
+ size = processor.size_from_va_line(line)
356
+ processor.check_value_size!(size)
357
+ value = @connection_manager.read(size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
353
358
  key = processor.key_from_va_line(line)
354
359
  return unless key
355
360
 
@@ -218,11 +218,11 @@ module Dalli
218
218
  # specifications to a proxy or router inbetween a client and a
219
219
  # memcached daemon."
220
220
  #
221
- # Empty / nil tokens are treated as no-ops. CRLF and null bytes are
222
- # rejected with ArgumentError to prevent the token from being used as a
223
- # wire-protocol injection vector (e.g. "foo\r\nflush_all\r\n" would
224
- # otherwise be parsed as a second command by memcached or any
225
- # intermediate proxy/LB).
221
+ # Empty / nil tokens are treated as no-ops. Whitespace and control
222
+ # characters are rejected with ArgumentError so a token can't be used
223
+ # for wire-protocol injection: CRLF would add a second command (e.g.
224
+ # "foo\r\nflush_all\r\n"), and a space would add meta flags to this one
225
+ # (e.g. "foo T1" changing an item's TTL on a read).
226
226
  def routing_tokens(p_token: nil, l_token: nil)
227
227
  # Only an empty *String* is a no-op. Checking respond_to?(:empty?)
228
228
  # instead would also swallow p_token: [] / {} before the type check
@@ -279,15 +279,18 @@ module Dalli
279
279
 
280
280
  private
281
281
 
282
- # Disallowed bytes: CR, LF, NUL. Any of these embedded in a routing
283
- # token would let the caller inject a second wire-protocol command.
284
- ROUTING_TOKEN_FORBIDDEN = /[\r\n\0]/
282
+ # Disallowed bytes: whitespace and control characters. CR, LF or NUL
283
+ # would let a token inject a second command; a space would let it add
284
+ # meta flags (T, N, I, q, ...) to the request it rides on.
285
+ ROUTING_TOKEN_FORBIDDEN = /[\x00-\x20\x7F]/
285
286
  private_constant :ROUTING_TOKEN_FORBIDDEN
286
287
 
287
288
  def validate_routing_token!(name, value)
288
289
  return if value.nil?
289
290
  raise ArgumentError, "#{name} must be a String, got #{value.class}" unless value.is_a?(String)
290
- raise ArgumentError, "#{name} must not contain CRLF or null bytes" if value.match?(ROUTING_TOKEN_FORBIDDEN)
291
+ return unless value.match?(ROUTING_TOKEN_FORBIDDEN)
292
+
293
+ raise ArgumentError, "#{name} must not contain whitespace or control characters"
291
294
  end
292
295
 
293
296
  # Numeric flag values are written straight into the command line, so a
@@ -15,6 +15,9 @@ module Dalli
15
15
  # when advancing through parsed responses.
16
16
  ##
17
17
  class ResponseBuffer
18
+ # Whether the current pipeline was requested with raw: true
19
+ attr_writer :raw
20
+
18
21
  def initialize(io_source, response_processor)
19
22
  @io_source = io_source
20
23
  @response_processor = response_processor
@@ -39,7 +42,7 @@ module Dalli
39
42
  # Attempts to process a single response from the buffer,
40
43
  # advancing the offset past the consumed bytes.
41
44
  def process_single_getk_response
42
- response = @response_processor.getk_response_from_buffer(@buffer, @offset)
45
+ response = @response_processor.getk_response_from_buffer(@buffer, @offset, raw: @raw || false)
43
46
  @offset += response.pop
44
47
  response
45
48
  end
@@ -24,6 +24,8 @@ module Dalli
24
24
  SERVER_ERROR = 'SERVER_ERROR'
25
25
 
26
26
  VA_PREFIX = 'VA '
27
+ # memcached can't store an item larger than 1 GiB (its -I maximum)
28
+ MAX_VALUE_BYTES = 1024 * 1024 * 1024
27
29
  HD_PREFIX = 'HD '
28
30
  FLAGS_TOKEN_PREFIX = ' f'
29
31
  CAS_TOKEN_PREFIX = ' c'
@@ -49,12 +51,15 @@ module Dalli
49
51
  @value_marshaller = value_marshaller
50
52
  end
51
53
 
52
- def meta_get_with_value(cache_nils: false)
54
+ # raw: true (a request made with raw: true) returns the value as stored,
55
+ # ignoring any flags in the reply. The request didn't ask for flags, so
56
+ # a reply carrying them anyway must not get the value deserialized.
57
+ def meta_get_with_value(cache_nils: false, raw: false)
53
58
  line = read_line
54
59
  # A hit ("VA <size> f<flags>") is parsed in place rather than split
55
60
  # into tokens, which saves several allocations on the hottest path.
56
61
  if line&.start_with?(VA_PREFIX)
57
- return @value_marshaller.retrieve(read_data(size_from_va_line(line)), bitflags_from_va_line(line))
62
+ return retrieve(read_data(size_from_va_line(line)), raw ? 0 : bitflags_from_va_line(line), raw)
58
63
  end
59
64
 
60
65
  tokens = line&.split || []
@@ -62,7 +67,7 @@ module Dalli
62
67
  when EN
63
68
  cache_nils ? ::Dalli::NOT_FOUND : nil
64
69
  when VA # only reached for an unusually formatted hit line
65
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
70
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
66
71
  when HD
67
72
  true
68
73
  else
@@ -70,14 +75,14 @@ module Dalli
70
75
  end
71
76
  end
72
77
 
73
- def meta_get_with_value_and_cas
78
+ def meta_get_with_value_and_cas(raw: false)
74
79
  tokens = error_on_unexpected!(T_VA_EN_HD)
75
80
  return [nil, 0] if tokens.first == EN
76
81
 
77
82
  cas = cas_from_tokens(tokens)
78
83
  return [nil, cas] unless tokens.first == VA
79
84
 
80
- [@value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens)), cas]
85
+ [retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw), cas]
81
86
  end
82
87
 
83
88
  def meta_get_without_value
@@ -97,13 +102,13 @@ module Dalli
97
102
  # Used by meta_get for comprehensive metadata retrieval.
98
103
  # Supports thundering herd protection (N/R flags) and metadata flags (h/l/u).
99
104
  def meta_get_with_metadata(cache_nils: false, return_hit_status: false, return_last_access: false,
100
- return_ttl_remaining: false)
105
+ return_ttl_remaining: false, raw: false)
101
106
  tokens = error_on_unexpected!(T_VA_EN_HD)
102
107
  result = build_metadata_result(tokens)
103
108
  result[:hit_before] = hit_status_from_tokens(tokens) if return_hit_status
104
109
  result[:last_access] = last_access_from_tokens(tokens) if return_last_access
105
110
  result[:ttl_remaining] = ttl_remaining_from_tokens(tokens) if return_ttl_remaining
106
- result[:value] = parse_value_from_tokens(tokens, cache_nils)
111
+ result[:value] = parse_value_from_tokens(tokens, cache_nils, raw)
107
112
  result
108
113
  end
109
114
 
@@ -120,11 +125,11 @@ module Dalli
120
125
  }
121
126
  end
122
127
 
123
- def parse_value_from_tokens(tokens, cache_nils)
128
+ def parse_value_from_tokens(tokens, cache_nils, raw = false) # rubocop:disable Style/OptionalBooleanParameter
124
129
  return cache_nils ? ::Dalli::NOT_FOUND : nil if tokens.first == EN
125
130
  return unless tokens.first == VA
126
131
 
127
- @value_marshaller.retrieve(read_data(tokens[1].to_i), bitflags_from_tokens(tokens))
132
+ retrieve(read_data(tokens[1].to_i), raw ? 0 : bitflags_from_tokens(tokens), raw)
128
133
  end
129
134
 
130
135
  def meta_set_with_cas
@@ -227,13 +232,15 @@ module Dalli
227
232
  # The remaining three values in the array are the ResponseHeader,
228
233
  # key, and value.
229
234
  ##
230
- def getk_response_from_buffer(buf, offset = 0)
235
+ # raw: true returns values as stored, ignoring flags in the replies;
236
+ # see meta_get_with_value.
237
+ def getk_response_from_buffer(buf, offset = 0, raw: false)
231
238
  # Find the header terminator starting from offset
232
239
  term_idx = buf.byteindex(TERMINATOR, offset)
233
240
  return [0] unless term_idx
234
241
 
235
242
  if buf.byteslice(offset, VA_PREFIX.bytesize) == VA_PREFIX
236
- response = va_response_from_buffer(buf, offset, term_idx)
243
+ response = va_response_from_buffer(buf, offset, term_idx, raw)
237
244
  return response if response
238
245
  end
239
246
 
@@ -254,16 +261,20 @@ module Dalli
254
261
  when BYTE_B then base64 ||= token == 'b'
255
262
  end
256
263
  end
257
- body_len = flag_int(size, 's')
258
-
259
- # We have a complete response that has no body.
260
- # This is either the response to the terminating
261
- # noop or, if the status is not MN, an intermediate
262
- # error response that needs to be discarded. A hit
263
- # on an empty value (VA 0) still has a body -- just
264
- # its terminator -- so it's parsed below as a value.
265
- return [true, header_len] if body_len.zero? && tokens.first != VA
266
-
264
+ # memcached also gives the size positionally ("VA <size> ..."); use it
265
+ # when there's no s flag rather than reading the value as replies
266
+ body_len = size ? flag_int(size, 's') : positional_size(tokens)
267
+
268
+ # A complete response with no body. Only the terminating noop's MN
269
+ # ends the pipeline. Any other bodyless reply (CLIENT_ERROR,
270
+ # SERVER_ERROR, ...) answers one key's request: report it with a
271
+ # false status so the caller skips it and keeps reading. Treating it
272
+ # as the end would leave the remaining replies on the connection for
273
+ # later commands to read as their own. A hit on an empty value (VA 0)
274
+ # still has a body -- just its terminator -- so it's parsed below.
275
+ return [tokens.first == MN, header_len] if body_len.zero? && tokens.first != VA
276
+
277
+ check_value_size!(body_len)
267
278
  resp_size = header_len + body_len + TERMINATOR.length
268
279
  # The header is in the buffer, but the body is not. As we don't have
269
280
  # a complete response, don't advance the buffer
@@ -272,8 +283,12 @@ module Dalli
272
283
  # The full response is in our buffer, so parse it and return
273
284
  # the values
274
285
  body = buf.byteslice(offset + header_len, body_len)
275
- value = @value_marshaller.retrieve(body, flag_int(bitflags, 'f'))
276
- key = key ? key.delete_prefix!('k') : 0
286
+ value = retrieve(body, raw ? 0 : flag_int(bitflags, 'f'), raw)
287
+ # A hit with no key can't be matched to a request: skip it (false
288
+ # status) rather than yield it, or take it for the end of the pipeline
289
+ return [false, resp_size] unless key
290
+
291
+ key = key.delete_prefix!('k')
277
292
  key = KeyRegularizer.decode(key) if base64
278
293
  [tokens.first == VA, flag_int(cas, 'c'), key, value, resp_size]
279
294
  end
@@ -282,7 +297,7 @@ module Dalli
282
297
  # without allocating the header, its tokens or the flag strings.
283
298
  # Gives the same result as the token path. Returns nil to fall back to
284
299
  # it when the header has no s flag or a zero size.
285
- def va_response_from_buffer(buf, offset, term_idx)
300
+ def va_response_from_buffer(buf, offset, term_idx, raw = false) # rubocop:disable Style/OptionalBooleanParameter
286
301
  size = bitflags = cas = key = nil
287
302
  base64 = false
288
303
  pos = buf.byteindex(' ', offset + VA_PREFIX.bytesize)
@@ -300,13 +315,22 @@ module Dalli
300
315
  end
301
316
  return nil if size.nil? || size.zero?
302
317
 
318
+ check_value_size!(size)
303
319
  header_len = term_idx - offset + TERMINATOR.length
304
320
  resp_size = header_len + size + TERMINATOR.length
305
321
  return [0] unless buf.bytesize >= offset + resp_size
306
322
 
307
- value = @value_marshaller.retrieve(buf.byteslice(offset + header_len, size), bitflags || 0)
308
- key = KeyRegularizer.decode(key) if base64 && key
309
- [true, cas || 0, key || 0, value, resp_size]
323
+ value = retrieve(buf.byteslice(offset + header_len, size), raw ? 0 : bitflags || 0, raw)
324
+ # Skipped like the token path does; see getk_response_from_buffer
325
+ return [false, resp_size] unless key
326
+
327
+ key = KeyRegularizer.decode(key) if base64
328
+ [true, cas || 0, key, value, resp_size]
329
+ end
330
+
331
+ # Values from a raw request are returned as stored
332
+ def retrieve(value, bitflags, raw)
333
+ raw ? value : @value_marshaller.retrieve(value, bitflags)
310
334
  end
311
335
 
312
336
  # Integer value of a flag's token in buf, after its one-byte prefix,
@@ -424,6 +448,19 @@ module Dalli
424
448
  end
425
449
  end
426
450
 
451
+ # A pipelined reply claiming an impossible size would otherwise have the
452
+ # buffer wait for (and accumulate) that many bytes
453
+ # The size given right after "VA", or 0 when it isn't a number
454
+ def positional_size(tokens)
455
+ tokens.first == VA && tokens[1]&.match?(/\A\d+\z/) ? tokens[1].to_i : 0
456
+ end
457
+
458
+ def check_value_size!(size)
459
+ return if size.between?(0, MAX_VALUE_BYTES)
460
+
461
+ raise Dalli::DalliError, "Reply value size #{size} is out of range"
462
+ end
463
+
427
464
  def read_line
428
465
  @io_source.read_line&.chomp!(TERMINATOR)
429
466
  end
@@ -434,6 +471,9 @@ module Dalli
434
471
  end
435
472
 
436
473
  def read_data(data_size)
474
+ # Checked before the terminator is added: -1 or -2 would otherwise
475
+ # read 1 or 0 bytes and leave the connection out of step
476
+ check_value_size!(data_size)
437
477
  @io_source.read(data_size + TERMINATOR.bytesize)&.chomp!(TERMINATOR)
438
478
  end
439
479
  end
@@ -15,11 +15,17 @@ module Dalli
15
15
  compress: true,
16
16
  compressor: ::Dalli::Compressor,
17
17
  # min byte size to attempt compression
18
- compression_min_size: 4 * 1024 # 4K
18
+ compression_min_size: 4 * 1024, # 4K
19
+ # max size a stored value may decompress to (nil: no limit). Guards
20
+ # against a small compressed value expanding into gigabytes on read.
21
+ decompressed_max_bytes: 128 * 1024 * 1024 # 128 MiB
19
22
  }.freeze
20
23
 
21
24
  OPTIONS = DEFAULTS.keys.freeze
22
25
 
26
+ KEYWORD_PARAMETER_TYPES = %i[key keyreq].freeze
27
+ private_constant :KEYWORD_PARAMETER_TYPES
28
+
23
29
  def initialize(client_options)
24
30
  @compression_options =
25
31
  DEFAULTS.merge(client_options.slice(*OPTIONS))
@@ -34,8 +40,16 @@ module Dalli
34
40
  end
35
41
 
36
42
  def retrieve(value, bitflags)
37
- compressed = bitflags.anybits?(Flags::COMPRESSED)
38
- compressed ? compressor.decompress(value) : value
43
+ return value unless bitflags.anybits?(Flags::COMPRESSED)
44
+
45
+ max_bytes = @compression_options[:decompressed_max_bytes]
46
+ # Custom compressors that only define decompress(data) keep working,
47
+ # without the limit
48
+ if max_bytes && compressor_accepts_max_bytes?
49
+ compressor.decompress(value, max_bytes: max_bytes)
50
+ else
51
+ compressor.decompress(value)
52
+ end
39
53
 
40
54
  # TODO: We likely want to move this rescue into the Dalli::Compressor / Dalli::GzipCompressor
41
55
  # itself, since not all compressors necessarily use Zlib. For now keep it here, so the behavior
@@ -44,6 +58,19 @@ module Dalli
44
58
  raise UnmarshalError, "Unable to uncompress value: #{$ERROR_INFO.message}"
45
59
  end
46
60
 
61
+ def compressor_accepts_max_bytes?
62
+ return @compressor_accepts_max_bytes if defined?(@compressor_accepts_max_bytes)
63
+
64
+ @compressor_accepts_max_bytes =
65
+ begin
66
+ compressor.method(:decompress).parameters.any? do |type, name|
67
+ name == :max_bytes && KEYWORD_PARAMETER_TYPES.include?(type)
68
+ end
69
+ rescue NameError # an object without a reflectable decompress method
70
+ false
71
+ end
72
+ end
73
+
47
74
  def compress_by_default?
48
75
  @compression_options[:compress]
49
76
  end
@@ -100,7 +100,7 @@ module Dalli
100
100
  Dalli.logger.warn 'SECURITY WARNING: Dalli is using Marshal for serialization. ' \
101
101
  'Marshal can execute arbitrary code during deserialization. ' \
102
102
  'If your memcached server could be compromised, consider using ' \
103
- 'a safer serializer like JSON: Dalli::Client.new(servers, serializer: JSON)'
103
+ 'a safer serializer: Dalli::Client.new(servers, serializer: Dalli::JSONSerializer)'
104
104
  @@marshal_warning_logged = true # rubocop:disable Style/ClassVars
105
105
  end
106
106
  end
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.2.0'
4
+ VERSION = '5.2.2'
5
5
 
6
6
  MIN_SUPPORTED_MEMCACHED_VERSION = '1.6.27'
7
7
  end
data/lib/dalli.rb CHANGED
@@ -62,6 +62,7 @@ require_relative 'dalli/version'
62
62
  require_relative 'dalli/instrumentation'
63
63
 
64
64
  require_relative 'dalli/flags'
65
+ require_relative 'dalli/json_serializer'
65
66
  require_relative 'dalli/compressor'
66
67
  require_relative 'dalli/cache_result'
67
68
  require_relative 'dalli/client'
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.2.0
4
+ version: 5.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter M. Goldstein
@@ -43,6 +43,7 @@ files:
43
43
  - lib/dalli/compressor.rb
44
44
  - lib/dalli/flags.rb
45
45
  - lib/dalli/instrumentation.rb
46
+ - lib/dalli/json_serializer.rb
46
47
  - lib/dalli/key_manager.rb
47
48
  - lib/dalli/options.rb
48
49
  - lib/dalli/pipelined_deleter.rb