File: key_manager.rb

package info (click to toggle)
ruby-dalli 5.0.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 992 kB
  • sloc: ruby: 9,447; sh: 19; makefile: 4
file content (136 lines) | stat: -rw-r--r-- 4,310 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# frozen_string_literal: true

require 'digest/md5'

module Dalli
  ##
  # This class manages and validates keys sent to Memcached, ensuring
  # that they meet Memcached key length requirements, and supporting
  # the implementation of optional namespaces on a per-Dalli client
  # basis.
  ##
  class KeyManager
    MAX_KEY_LENGTH = 250

    DEFAULT_NAMESPACE_SEPARATOR = ':'

    # This is a hard coded md5 for historical reasons
    TRUNCATED_KEY_SEPARATOR = ':md5:'

    # This is 249 for historical reasons
    TRUNCATED_KEY_TARGET_SIZE = 249

    DEFAULTS = {
      digest_class: ::Digest::MD5,
      namespace_separator: DEFAULT_NAMESPACE_SEPARATOR
    }.freeze

    OPTIONS = %i[digest_class namespace namespace_separator].freeze

    attr_reader :namespace, :namespace_separator

    # Valid separators: non-alphanumeric, single printable ASCII characters
    # Excludes: alphanumerics, whitespace, control characters
    VALID_NAMESPACE_SEPARATORS = /\A[^a-zA-Z0-9 \x00-\x1F\x7F]\z/

    def initialize(client_options)
      @key_options =
        DEFAULTS.merge(client_options.slice(*OPTIONS))
      validate_digest_class_option(@key_options)
      validate_namespace_separator_option(@key_options)

      @namespace = namespace_from_options
      @namespace_separator = @key_options[:namespace_separator]
    end

    ##
    # Validates the key, and transforms as needed.
    #
    # If the key is nil or empty, raises ArgumentError.  Whitespace
    # characters are allowed for historical reasons, but likely shouldn't
    # be used.
    # If the key (with namespace) is shorter than the memcached maximum
    # allowed key length, just returns the argument key
    # Otherwise computes a "truncated" key that uses a truncated prefix
    # combined with a 32-byte hex digest of the whole key.
    ##
    def validate_key(key)
      raise ArgumentError, 'key cannot be blank' unless key&.length&.positive?

      key = key_with_namespace(key)
      key.length > MAX_KEY_LENGTH ? truncated_key(key) : key
    end

    ##
    # Returns the key with the namespace prefixed, if a namespace is
    # defined.  Otherwise just returns the key
    ##
    def key_with_namespace(key)
      return key if namespace.nil?

      "#{evaluate_namespace}#{namespace_separator}#{key}"
    end

    def key_without_namespace(key)
      return key if namespace.nil?

      key.sub(namespace_regexp, '')
    end

    def digest_class
      @digest_class ||= @key_options[:digest_class]
    end

    def namespace_regexp
      return /\A#{Regexp.escape(evaluate_namespace)}#{Regexp.escape(namespace_separator)}/ if namespace.is_a?(Proc)

      @namespace_regexp ||= /\A#{Regexp.escape(namespace)}#{Regexp.escape(namespace_separator)}/ unless namespace.nil?
    end

    def validate_digest_class_option(opts)
      return if opts[:digest_class].respond_to?(:hexdigest)

      raise ArgumentError, 'The digest_class object must respond to the hexdigest method'
    end

    def validate_namespace_separator_option(opts)
      sep = opts[:namespace_separator]
      return if VALID_NAMESPACE_SEPARATORS.match?(sep)

      raise ArgumentError,
            'namespace_separator must be a single non-alphanumeric character (e.g., ":", "/", "|")'
    end

    def namespace_from_options
      raw_namespace = @key_options[:namespace]
      return nil unless raw_namespace
      return raw_namespace.to_s unless raw_namespace.is_a?(Proc)

      raw_namespace
    end

    def evaluate_namespace
      return namespace.call.to_s if namespace.is_a?(Proc)

      namespace
    end

    ##
    # Produces a truncated key, if the raw key is longer than the maximum allowed
    # length.  The truncated key is produced by generating a hex digest
    # of the key, and appending that to a truncated section of the key.
    ##
    def truncated_key(key)
      digest = digest_class.hexdigest(key)
      "#{key[0, prefix_length(digest)]}#{TRUNCATED_KEY_SEPARATOR}#{digest}"
    end

    def prefix_length(digest)
      return TRUNCATED_KEY_TARGET_SIZE - (TRUNCATED_KEY_SEPARATOR.length + digest.length) if namespace.nil?

      # For historical reasons, truncated keys with namespaces had a length of 250 rather
      # than 249
      TRUNCATED_KEY_TARGET_SIZE + 1 - (TRUNCATED_KEY_SEPARATOR.length + digest.length)
    end
  end
end