scrubber_rb 0.1.0

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.
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scrubber
4
+ # A compiled, immutable scrubber.
5
+ #
6
+ # Compiling ~35 regexes into one automaton is the expensive part; scanning is
7
+ # cheap. Build one of these at boot and reuse it forever:
8
+ #
9
+ # SCRUBBER = Scrubber.new(replacement: :hash)
10
+ # SCRUBBER.scrub(line)
11
+ #
12
+ # Instances are frozen and hold no per-call state, so a single instance is
13
+ # safe to share across every thread in the process.
14
+ class Instance
15
+ REPLACEMENTS = %i[label mask hash remove].freeze
16
+
17
+ # Read in 1MB slices, and never cut closer than this to the end of the
18
+ # buffer, so a match straddling a chunk boundary still sees both halves.
19
+ CHUNK_SIZE = 1024 * 1024
20
+ CARRY_SIZE = 8 * 1024
21
+
22
+ attr_reader :detectors, :custom, :replacement, :hash_salt
23
+
24
+ def initialize(detectors: Scrubber::DEFAULTS, custom: {}, replacement: :label, hash_salt: nil)
25
+ @detectors = normalize_detectors(detectors)
26
+ @custom = normalize_custom(custom)
27
+ @replacement = normalize_replacement(replacement)
28
+ @hash_salt = hash_salt&.to_s
29
+
30
+ @native = Native.new(
31
+ @detectors.map(&:to_s),
32
+ @custom.map { |name, regexp| [name.to_s, regexp.source, regexp.options] },
33
+ @replacement.to_s,
34
+ @hash_salt
35
+ )
36
+ freeze
37
+ end
38
+
39
+ # Redact every match in `text`, returning a new String with the same
40
+ # encoding. The input is never mutated, and frozen input is fine.
41
+ def scrub(text)
42
+ str = coerce(text)
43
+ replaced = @native.scrub(str)
44
+ return str.dup if replaced.nil?
45
+
46
+ replaced.force_encoding(str.encoding)
47
+ end
48
+
49
+ # Redact in place. Returns the same object, so it raises FrozenError on a
50
+ # frozen string exactly like every other Ruby bang method.
51
+ def scrub!(text)
52
+ str = coerce(text)
53
+ replaced = @native.scrub(str)
54
+ return str if replaced.nil?
55
+
56
+ str.replace(replaced.force_encoding(str.encoding))
57
+ end
58
+
59
+ # Find matches without replacing them.
60
+ #
61
+ # Scrubber.detect("mail nik@example.com")
62
+ # # => [#<Scrubber::Match type=:email 5...20 preview="n**@e******.com">]
63
+ #
64
+ # Offsets are character offsets into `text`, not byte offsets, so they index
65
+ # the Ruby string correctly even when it contains emoji or Devanagari.
66
+ def detect(text)
67
+ str = coerce(text)
68
+ # Only UTF-8 needs byte->character conversion; in single-byte encodings
69
+ # the two are the same number.
70
+ char_offsets = str.encoding == Encoding::UTF_8
71
+ @native.detect(str, char_offsets).map do |type, from, to, preview|
72
+ Match.new(type: type.to_sym, begin: from, end: to, preview: preview)
73
+ end
74
+ end
75
+
76
+ # True if anything at all would be redacted. Cheaper than `scrub` when you
77
+ # only need a yes/no (an audit check, a test assertion, a CI gate).
78
+ def match?(text)
79
+ # `detect` stops at the match list; it never builds the output string.
80
+ !@native.detect(coerce(text), false).empty?
81
+ end
82
+
83
+ # Stream a file through the engine.
84
+ #
85
+ # Reads in 1MB chunks and cuts each chunk at the last line break before an
86
+ # 8KB carry window, so a match spanning a chunk boundary is never sliced in
87
+ # half. Multi-line PEM blocks get an extra guard: if a chunk would end
88
+ # between BEGIN and END, the cut moves back before the BEGIN.
89
+ #
90
+ # Returns the number of bytes written.
91
+ def scrub_file(input_path, output_path, chunk_size: CHUNK_SIZE)
92
+ written = 0
93
+ File.open(input_path, "rb") do |input|
94
+ File.open(output_path, "wb") do |output|
95
+ each_safe_chunk(input, chunk_size) do |chunk|
96
+ written += output.write(@native.scrub(chunk) || chunk)
97
+ end
98
+ end
99
+ end
100
+ written
101
+ end
102
+
103
+ # How many compiled patterns back this instance. Detectors expand to more
104
+ # than one rule each in a few cases (`:api_key` alone is ~19).
105
+ def rule_count
106
+ @native.rule_count
107
+ end
108
+
109
+ def inspect
110
+ "#<Scrubber::Instance detectors=#{@detectors.size} rules=#{rule_count} " \
111
+ "replacement=#{@replacement.inspect}>"
112
+ end
113
+
114
+ # Config identity, used to memoize module-level `Scrubber.scrub` calls.
115
+ def cache_key
116
+ self.class.cache_key(
117
+ detectors: @detectors, custom: @custom,
118
+ replacement: @replacement, hash_salt: @hash_salt
119
+ )
120
+ end
121
+
122
+ def self.cache_key(detectors:, custom:, replacement:, hash_salt:)
123
+ customs = custom.to_a.map do |name, regexp|
124
+ source = regexp.respond_to?(:source) ? regexp.source : regexp.to_s
125
+ options = regexp.respond_to?(:options) ? regexp.options : 0
126
+ [name.to_sym, source, options]
127
+ end
128
+ [Array(detectors).map(&:to_sym).sort, customs.sort, replacement.to_sym, hash_salt&.to_s]
129
+ end
130
+
131
+ private
132
+
133
+ def coerce(text)
134
+ return text if text.is_a?(String)
135
+ raise TypeError, "expected a String, got #{text.class}" unless text.respond_to?(:to_str)
136
+
137
+ text.to_str
138
+ end
139
+
140
+ def normalize_detectors(detectors)
141
+ list = Array(detectors).map do |d|
142
+ unless d.respond_to?(:to_sym)
143
+ raise ConfigurationError,
144
+ "detector names must be Symbols or Strings, got #{d.class}"
145
+ end
146
+
147
+ d.to_sym
148
+ end
149
+ list.uniq.freeze
150
+ end
151
+
152
+ def normalize_custom(custom)
153
+ raise ConfigurationError, "custom: must be a Hash of name => Regexp" unless custom.respond_to?(:to_h)
154
+
155
+ custom.to_h.each_with_object({}) do |(name, regexp), out|
156
+ unless regexp.is_a?(Regexp)
157
+ raise ConfigurationError,
158
+ "custom detector #{name.inspect} must be a Regexp, got #{regexp.class}"
159
+ end
160
+
161
+ out[name.to_sym] = regexp
162
+ end.freeze
163
+ end
164
+
165
+ def normalize_replacement(replacement)
166
+ sym = replacement.to_sym
167
+ unless REPLACEMENTS.include?(sym)
168
+ raise ConfigurationError,
169
+ "unknown replacement #{replacement.inspect}. Expected one of: #{REPLACEMENTS.join(", ")}"
170
+ end
171
+
172
+ sym
173
+ end
174
+
175
+ # Yields byte slices that are safe to scan independently.
176
+ def each_safe_chunk(input, chunk_size)
177
+ buffer = +""
178
+ buffer.force_encoding(Encoding::BINARY)
179
+
180
+ while (chunk = input.read(chunk_size))
181
+ buffer << chunk
182
+ next if buffer.bytesize <= CARRY_SIZE
183
+
184
+ cut = safe_cut(buffer)
185
+ next if cut.zero?
186
+
187
+ yield buffer.byteslice(0, cut)
188
+ buffer = buffer.byteslice(cut, buffer.bytesize - cut)
189
+ end
190
+
191
+ yield buffer unless buffer.empty?
192
+ end
193
+
194
+ # Where can this buffer be cut without slicing a match in half?
195
+ def safe_cut(buffer)
196
+ limit = buffer.bytesize - CARRY_SIZE
197
+ return 0 if limit <= 0
198
+
199
+ cut = buffer.rindex("\n", limit - 1)
200
+ cut = cut ? cut + 1 : limit
201
+ pem_safe_cut(buffer, cut)
202
+ end
203
+
204
+ # A PEM block can be longer than the carry window, so cutting at a line
205
+ # break is not enough: if the head we are about to emit opens a block it
206
+ # does not close, back the cut up to just before that BEGIN.
207
+ def pem_safe_cut(buffer, cut)
208
+ # Give up on the guard rather than buffer a whole file: a PEM block this
209
+ # big is malformed, and unbounded memory growth is the worse failure.
210
+ return cut if buffer.bytesize > CHUNK_SIZE * 8
211
+
212
+ head = buffer.byteslice(0, cut)
213
+ opens = head.scan("-----BEGIN").size
214
+ closes = head.scan("-----END").size
215
+ return cut if opens <= closes
216
+
217
+ last_begin = head.rindex("-----BEGIN")
218
+ return cut if last_begin.nil? || last_begin.zero?
219
+
220
+ last_begin
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scrubber
4
+ # Redact prompts on their way to a third-party model API.
5
+ #
6
+ # guard = Scrubber::LLMGuard.new(replacement: :hash)
7
+ # chat.ask(guard.call(user_message))
8
+ #
9
+ # `:hash` is the interesting default here. With `[EMAIL:9f86d081]` tokens the
10
+ # model can still reason about "the same customer" across a conversation, and
11
+ # your logs of that conversation stay correlatable, without the value ever
12
+ # crossing the network.
13
+ #
14
+ # It accepts the shapes prompts actually come in:
15
+ #
16
+ # guard.call("my email is nik@example.com")
17
+ # guard.call([{ role: "user", content: "..." }])
18
+ # guard.call({ role: "user", content: "..." })
19
+ #
20
+ # There is no framework hook here on purpose. `LLMGuard` is a plain callable,
21
+ # so it drops into RubyLLM, langchainrb, ruby-openai, anthropic-sdk-ruby, or a
22
+ # hand-rolled `Net::HTTP` call the same way. See the README for the RubyLLM
23
+ # wrapper pattern.
24
+ class LLMGuard
25
+ # Message hash keys whose values are prompt text.
26
+ CONTENT_KEYS = %w[content text prompt input].freeze
27
+
28
+ attr_reader :scrubber
29
+
30
+ def initialize(scrubber: nil, replacement: :hash, **options)
31
+ @scrubber = scrubber || Scrubber.new(replacement: replacement, **options)
32
+ end
33
+
34
+ # Redact `input`, preserving its shape.
35
+ def call(input)
36
+ case input
37
+ when String then @scrubber.scrub(input)
38
+ when Array then input.map { |item| call(item) }
39
+ when Hash then scrub_hash(input)
40
+ when Symbol, Numeric, NilClass, TrueClass, FalseClass then input
41
+ else
42
+ input.respond_to?(:to_str) ? @scrubber.scrub(input.to_str) : input
43
+ end
44
+ end
45
+ alias scrub call
46
+
47
+ # Usable directly as a block: `messages.map(&guard)`.
48
+ def to_proc
49
+ method(:call).to_proc
50
+ end
51
+
52
+ # What would have been redacted. Handy for a "we blocked N leaks today"
53
+ # metric, or for failing a test when a prompt builder regresses.
54
+ def findings(input)
55
+ case input
56
+ when String then @scrubber.detect(input)
57
+ when Array then input.flat_map { |item| findings(item) }
58
+ when Hash then content_values(input).flat_map { |v| findings(v) }
59
+ else []
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def scrub_hash(hash)
66
+ hash.each_with_object(hash.class.new) do |(key, value), out|
67
+ out[key] = content_key?(key) || value.is_a?(Array) || value.is_a?(Hash) ? call(value) : value
68
+ end
69
+ end
70
+
71
+ def content_values(hash)
72
+ hash.filter_map { |key, value| value if content_key?(key) }
73
+ end
74
+
75
+ def content_key?(key)
76
+ CONTENT_KEYS.include?(key.to_s)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scrubber
4
+ # Wraps an existing Logger formatter and redacts whatever it produces.
5
+ #
6
+ # # config/initializers/scrubber.rb
7
+ # Rails.logger.formatter = Scrubber::LogFormatter.new(Rails.logger.formatter)
8
+ #
9
+ # Wrapping the formatter rather than the logger means it catches everything:
10
+ # your own `Rails.logger.info`, Active Record's SQL echo, Rack's request
11
+ # lines, and the exception messages your error middleware logs on the way out.
12
+ #
13
+ # It runs on the log write path, so it is worth knowing what it costs: one
14
+ # Rust scan per line, which on typical log lines is a few microseconds. If
15
+ # your app is log-bound, narrow `detectors:` to what you actually care about.
16
+ class LogFormatter
17
+ attr_reader :scrubber
18
+
19
+ # @param formatter [#call] the formatter to wrap. Defaults to
20
+ # `Logger::Formatter`, which is what a bare `Logger` uses.
21
+ # @param scrubber [Scrubber::Instance] a prebuilt engine, if you have one.
22
+ # @param options [Hash] otherwise, options for {Scrubber.new}.
23
+ def initialize(formatter = nil, scrubber: nil, **options)
24
+ @formatter = formatter || self.class.default_formatter
25
+ @scrubber = scrubber || Scrubber.new(**options)
26
+ end
27
+
28
+ def call(severity, time, progname, msg)
29
+ formatted = @formatter.call(severity, time, progname, msg)
30
+ return formatted unless formatted.is_a?(String)
31
+
32
+ @scrubber.scrub(formatted)
33
+ end
34
+
35
+ def self.default_formatter
36
+ require "logger"
37
+ ::Logger::Formatter.new
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scrubber
4
+ # One thing the engine found, without the thing itself.
5
+ #
6
+ # `begin` and `end` are character offsets into the string you passed, so
7
+ # `text[match.begin...match.end]` gives you the raw value back if you really
8
+ # want it. `preview` is already masked — it exists so you can log "we found an
9
+ # email here" without logging the email.
10
+ Match = Struct.new(:type, :begin, :end, :preview, keyword_init: true) do
11
+ # The span as a Range, ready for `String#[]`.
12
+ def to_range
13
+ self.begin...self.end
14
+ end
15
+
16
+ # Length of the match in characters.
17
+ def length
18
+ self.end - self.begin
19
+ end
20
+
21
+ def inspect
22
+ "#<Scrubber::Match type=#{type.inspect} #{self.begin}...#{self.end} " \
23
+ "preview=#{preview.inspect}>"
24
+ end
25
+ alias_method :to_s, :inspect
26
+ end
27
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scrubber
4
+ # Rack middleware that makes a redacted view of the request available to
5
+ # everything downstream, and redacts exception messages on the way out.
6
+ #
7
+ # use Scrubber::Middleware, detectors: Scrubber::DEFAULTS
8
+ #
9
+ # == What it does not do
10
+ #
11
+ # It does not rewrite `QUERY_STRING`, `rack.input`, or `params`. Redacting the
12
+ # request the application is about to act on would break every login form in
13
+ # the world: the app needs the real password to check it. What leaks is not
14
+ # the request, it is the *record* of the request — the log line, the error
15
+ # tracker payload, the APM trace.
16
+ #
17
+ # So this middleware gives you redacted copies to record:
18
+ #
19
+ # env["scrubber.query_string"] # "user=x&password=[PASSWORD_PAIR]"
20
+ # env["scrubber.params"] # { "user" => "x", "password" => "[PASSWORD_PAIR]" }
21
+ # env["scrubber.instance"] # the engine, for anything else you log
22
+ #
23
+ # and it redacts the message of any exception raised further down the stack
24
+ # before re-raising it, so an error tracker that never heard of this gem still
25
+ # gets a clean payload.
26
+ class Middleware
27
+ QUERY_STRING = "QUERY_STRING"
28
+ ENV_QUERY = "scrubber.query_string"
29
+ ENV_PARAMS = "scrubber.params"
30
+ ENV_INSTANCE = "scrubber.instance"
31
+
32
+ attr_reader :scrubber
33
+
34
+ # @param app [#call] the next Rack app.
35
+ # @param scrubber [Scrubber::Instance] a prebuilt engine, if you have one.
36
+ # @param scrub_exceptions [Boolean] redact exception messages (default true).
37
+ # @param options [Hash] otherwise, options for {Scrubber.new}.
38
+ def initialize(app, scrubber: nil, scrub_exceptions: true, **options)
39
+ @app = app
40
+ @scrubber = scrubber || Scrubber.new(**options)
41
+ @scrub_exceptions = scrub_exceptions
42
+ end
43
+
44
+ def call(env)
45
+ annotate(env)
46
+ @app.call(env)
47
+ rescue StandardError => e
48
+ raise unless @scrub_exceptions
49
+
50
+ raise redacted(e)
51
+ end
52
+
53
+ private
54
+
55
+ def annotate(env)
56
+ env[ENV_INSTANCE] = @scrubber
57
+ query = env[QUERY_STRING]
58
+ return if query.nil? || query.empty?
59
+
60
+ # Decode *before* scrubbing. `email=nik%40example.com` does not look like
61
+ # an email address until the `%40` is a `@`, and a redactor that can be
62
+ # defeated by percent-encoding is not a redactor. The decoded key is put
63
+ # back in front of the value before scrubbing, because `:password_pair`
64
+ # needs that context to recognise `hunter2` as a password at all.
65
+ pairs = parse_pairs(query).map { |key, value| [key, scrub_value(key, value)] }
66
+
67
+ env[ENV_PARAMS] = pairs.to_h
68
+ # Rebuilt from the decoded pairs, so this is a string for logs to print,
69
+ # not a query string to re-issue a request with.
70
+ env[ENV_QUERY] = pairs.map { |key, value| "#{key}=#{value}" }.join("&")
71
+ end
72
+
73
+ # Deliberately not `Rack::Utils.parse_nested_query`: this is a logging aid,
74
+ # it must never raise on a malformed query string, and the gem must stay
75
+ # loadable without Rack.
76
+ def parse_pairs(query)
77
+ query.split("&").filter_map do |pair|
78
+ key, _, value = pair.partition("=")
79
+ next if key.empty?
80
+
81
+ [unescape(key), unescape(value)]
82
+ end
83
+ end
84
+
85
+ def scrub_value(key, value)
86
+ scrubbed = @scrubber.scrub("#{key}=#{value}")
87
+ prefix = "#{key}="
88
+ scrubbed.start_with?(prefix) ? scrubbed[prefix.length..] : scrubbed.partition("=").last
89
+ end
90
+
91
+ def unescape(str)
92
+ decoded = str.tr("+", " ").gsub(/%([0-9a-fA-F]{2})/) { [Regexp.last_match(1)].pack("H2") }
93
+ decoded.force_encoding(Encoding::UTF_8)
94
+ decoded.valid_encoding? ? decoded : decoded.force_encoding(Encoding::BINARY)
95
+ rescue StandardError
96
+ str
97
+ end
98
+
99
+ # Rebuild the exception with a redacted message, keeping its class and
100
+ # backtrace so error groupers still group it the same way.
101
+ def redacted(error)
102
+ message = error.message
103
+ return error unless message.is_a?(String)
104
+
105
+ clean = @scrubber.scrub(message)
106
+ return error if clean == message
107
+
108
+ replacement = rebuild(error, clean)
109
+ return error if replacement.nil?
110
+
111
+ replacement.set_backtrace(error.backtrace) if error.backtrace
112
+ replacement
113
+ end
114
+
115
+ # Some exception classes have a non-standard `initialize` and cannot be
116
+ # rebuilt from a message. Better the original than a crash in the middleware
117
+ # that was supposed to protect you.
118
+ def rebuild(error, message)
119
+ error.exception(message)
120
+ rescue StandardError
121
+ nil
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scrubber
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scrubber/version"
4
+
5
+ # Precompiled platform gems ship the extension under a Ruby ABI directory
6
+ # (lib/scrubber_rb/3.3/scrubber_rb.so); a source build puts it one level up.
7
+ begin
8
+ RUBY_VERSION =~ /(\d+\.\d+)/
9
+ require_relative "scrubber_rb/#{Regexp.last_match(1)}/scrubber_rb"
10
+ rescue LoadError
11
+ require_relative "scrubber_rb/scrubber_rb"
12
+ end
13
+
14
+ require_relative "scrubber/match"
15
+ require_relative "scrubber/instance"
16
+ require_relative "scrubber/log_formatter"
17
+ require_relative "scrubber/middleware"
18
+ require_relative "scrubber/llm_guard"
19
+
20
+ # Fast PII and secret redaction, with the scanning done in Rust.
21
+ #
22
+ # Scrubber.scrub("contact nik@example.com") # => "contact [EMAIL]"
23
+ #
24
+ # The module-level methods are the convenient path and memoize one compiled
25
+ # engine per distinct configuration. For hot loops, build a {Scrubber::Instance}
26
+ # yourself with {Scrubber.new} and keep it around.
27
+ module Scrubber
28
+ # Detectors enabled unless you say otherwise. Sourced from the Rust registry
29
+ # so there is exactly one copy of this list in the project.
30
+ DEFAULTS = Native.default_detectors.map(&:to_sym).freeze
31
+
32
+ # Opt-in India pack, for DPDP Act workloads:
33
+ #
34
+ # Scrubber.new(detectors: Scrubber::DEFAULTS + Scrubber::INDIA)
35
+ INDIA = Native.india_detectors.map(&:to_sym).freeze
36
+
37
+ # Every detector this build knows about.
38
+ ALL = Native.all_detectors.map(&:to_sym).freeze
39
+
40
+ class << self
41
+ # Build a reusable, frozen scrubber. See {Scrubber::Instance}.
42
+ def new(detectors: DEFAULTS, custom: {}, replacement: :label, hash_salt: nil)
43
+ Instance.new(
44
+ detectors: detectors, custom: custom,
45
+ replacement: replacement, hash_salt: hash_salt
46
+ )
47
+ end
48
+
49
+ # Redact `text` using a memoized engine for these options.
50
+ def scrub(text, **options)
51
+ engine(**options).scrub(text)
52
+ end
53
+
54
+ # Redact `text` in place.
55
+ def scrub!(text, **options)
56
+ engine(**options).scrub!(text)
57
+ end
58
+
59
+ # Locate matches without replacing them. Returns {Scrubber::Match} structs.
60
+ def detect(text, **options)
61
+ engine(**options).detect(text)
62
+ end
63
+
64
+ # True if anything would be redacted.
65
+ def match?(text, **options)
66
+ engine(**options).match?(text)
67
+ end
68
+
69
+ # Stream a file through the engine. Returns bytes written.
70
+ def scrub_file(input_path, output_path, **options)
71
+ chunk_size = options.delete(:chunk_size) || Instance::CHUNK_SIZE
72
+ engine(**options).scrub_file(input_path, output_path, chunk_size: chunk_size)
73
+ end
74
+
75
+ # The memoized {Scrubber::Instance} for a set of options. Exposed because
76
+ # asking for it explicitly beats accidentally rebuilding one per request.
77
+ def engine(detectors: DEFAULTS, custom: {}, replacement: :label, hash_salt: nil)
78
+ key = Instance.cache_key(
79
+ detectors: detectors, custom: custom,
80
+ replacement: replacement, hash_salt: hash_salt
81
+ )
82
+ # Double-checked: reads are lock-free on the happy path, and building the
83
+ # same engine twice under a race is harmless (they are identical and
84
+ # immutable), so the mutex only prevents wasted work.
85
+ cache[key] || cache_mutex.synchronize do
86
+ cache[key] ||= new(
87
+ detectors: detectors, custom: custom,
88
+ replacement: replacement, hash_salt: hash_salt
89
+ )
90
+ end
91
+ end
92
+
93
+ # Drop memoized engines. Only useful in tests.
94
+ def reset!
95
+ cache_mutex.synchronize { cache.clear }
96
+ nil
97
+ end
98
+
99
+ private
100
+
101
+ def cache
102
+ @cache ||= {}
103
+ end
104
+
105
+ def cache_mutex
106
+ @cache_mutex ||= Mutex.new
107
+ end
108
+ end
109
+ end