asherah 0.9.1 → 0.10.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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "native"
5
+
6
+ module Asherah
7
+ class Session
8
+ # Maximum wall time the async API will wait for the FFI callback to
9
+ # deliver a result before giving up. Without this bound, a hung tokio
10
+ # worker (or any callback-delivery race) would block the calling Ruby
11
+ # thread until the process exits — observed as 6-hour CI hangs on the
12
+ # round-trip tests. Override via ASHERAH_RUBY_ASYNC_TIMEOUT (seconds).
13
+ DEFAULT_ASYNC_TIMEOUT_SECONDS = 30
14
+
15
+ # Maximum time {#close} will wait for in-flight async operations to
16
+ # drain before forcibly freeing the session. Independent of the
17
+ # per-call async timeout above.
18
+ DEFAULT_CLOSE_DRAIN_SECONDS = 5
19
+
20
+ def self.async_timeout_seconds
21
+ val = ENV["ASHERAH_RUBY_ASYNC_TIMEOUT"]
22
+ return DEFAULT_ASYNC_TIMEOUT_SECONDS if val.nil? || val.empty?
23
+ Float(val)
24
+ rescue ArgumentError, TypeError
25
+ DEFAULT_ASYNC_TIMEOUT_SECONDS
26
+ end
27
+
28
+ def initialize(pointer)
29
+ raise Asherah::Error::GetSessionFailed, Native.last_error if pointer.null?
30
+ @pointer = pointer
31
+ @close_mu = Mutex.new
32
+ @pending_ops = 0
33
+ end
34
+
35
+ def encrypt_bytes(data)
36
+ raise ArgumentError, "data cannot be nil" if data.nil?
37
+ raise Asherah::Error::EncryptFailed, "session closed" if @pointer.null?
38
+ buf = thread_local_buffer
39
+ status = Native.asherah_encrypt_to_json(@pointer, data, data.bytesize, buf.pointer)
40
+ raise Asherah::Error::EncryptFailed, Native.last_error unless status.zero?
41
+ # `begin/ensure` so the FFI buffer is always freed (and its
42
+ # plaintext bytes wiped via `asherah_buffer_free`'s zeroize) even
43
+ # if `read_bytes` throws — without this the thread-local buffer
44
+ # would leak the previous call's plaintext until the next
45
+ # successful encrypt/decrypt on the same thread.
46
+ begin
47
+ buf[:data].read_bytes(buf[:len])
48
+ ensure
49
+ Native.asherah_buffer_free(buf.pointer)
50
+ end
51
+ end
52
+
53
+ def decrypt_bytes(json)
54
+ raise ArgumentError, "json cannot be nil" if json.nil?
55
+ raise Asherah::Error::DecryptFailed, "session closed" if @pointer.null?
56
+ buf = thread_local_buffer
57
+ status = Native.asherah_decrypt_from_json(@pointer, json, json.bytesize, buf.pointer)
58
+ raise Asherah::Error::DecryptFailed, Native.last_error unless status.zero?
59
+ # See encrypt_bytes — `ensure` guarantees the wipe runs even if
60
+ # the read_bytes call somehow raises.
61
+ begin
62
+ buf[:data].read_bytes(buf[:len])
63
+ ensure
64
+ Native.asherah_buffer_free(buf.pointer)
65
+ end
66
+ end
67
+
68
+ # True async encrypt — runs on Rust's tokio runtime, does not block the Ruby thread.
69
+ # Returns the result; internally uses a Queue to wait for the tokio callback.
70
+ def encrypt_bytes_async(data)
71
+ raise ArgumentError, "data cannot be nil" if data.nil?
72
+ raise Asherah::Error::EncryptFailed, "session closed" if @pointer.null?
73
+ @close_mu.synchronize { @pending_ops += 1 }
74
+ queue = Queue.new
75
+ session = self
76
+ callback = FFI::Function.new(:void, [:pointer, :pointer, :size_t, :string]) do |_ud, result_ptr, result_len, error|
77
+ begin
78
+ if error
79
+ queue.push(Asherah::Error::EncryptFailed.new(error))
80
+ else
81
+ queue.push(result_ptr.read_bytes(result_len))
82
+ end
83
+ ensure
84
+ session.send(:decrement_pending_ops)
85
+ end
86
+ end
87
+ status = Native.asherah_encrypt_to_json_async(@pointer, data, data.bytesize, callback, nil)
88
+ unless status.zero?
89
+ @close_mu.synchronize { @pending_ops -= 1 }
90
+ raise Asherah::Error::EncryptFailed, Native.last_error
91
+ end
92
+ # Bound the wait so a wedged callback can't block the calling
93
+ # thread forever. We use Timeout.timeout (not Queue#pop(timeout:),
94
+ # which only landed in Ruby 3.2) so the lib remains usable on the
95
+ # 3.0/3.1 Ruby builds still in some CI/test images.
96
+ result = await_async_result(queue, "encrypt_bytes_async")
97
+ raise result if result.is_a?(Exception)
98
+ result
99
+ end
100
+
101
+ # True async decrypt — runs on Rust's tokio runtime, does not block the Ruby thread.
102
+ def decrypt_bytes_async(json)
103
+ raise ArgumentError, "json cannot be nil" if json.nil?
104
+ raise Asherah::Error::DecryptFailed, "session closed" if @pointer.null?
105
+ @close_mu.synchronize { @pending_ops += 1 }
106
+ queue = Queue.new
107
+ session = self
108
+ callback = FFI::Function.new(:void, [:pointer, :pointer, :size_t, :string]) do |_ud, result_ptr, result_len, error|
109
+ begin
110
+ if error
111
+ queue.push(Asherah::Error::DecryptFailed.new(error))
112
+ else
113
+ queue.push(result_ptr.read_bytes(result_len))
114
+ end
115
+ ensure
116
+ session.send(:decrement_pending_ops)
117
+ end
118
+ end
119
+ status = Native.asherah_decrypt_from_json_async(@pointer, json, json.bytesize, callback, nil)
120
+ unless status.zero?
121
+ @close_mu.synchronize { @pending_ops -= 1 }
122
+ raise Asherah::Error::DecryptFailed, Native.last_error
123
+ end
124
+ result = await_async_result(queue, "decrypt_bytes_async")
125
+ raise result if result.is_a?(Exception)
126
+ result
127
+ end
128
+
129
+ def close
130
+ ptr = @close_mu.synchronize do
131
+ return if @pointer.null?
132
+ # Wait for in-flight async operations before freeing, but bound
133
+ # the wait — a wedged callback used to make this spin forever
134
+ # (and silently wedge any process trying to shut down cleanly).
135
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + DEFAULT_CLOSE_DRAIN_SECONDS
136
+ while @pending_ops > 0 && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
137
+ sleep 0.001
138
+ end
139
+ if @pending_ops > 0
140
+ warn "asherah: closing session with #{@pending_ops} async operation(s) " \
141
+ "still in flight after #{DEFAULT_CLOSE_DRAIN_SECONDS}s drain"
142
+ end
143
+ p = @pointer
144
+ @pointer = FFI::Pointer::NULL
145
+ p
146
+ end
147
+ Native.asherah_session_free(ptr)
148
+ end
149
+
150
+ def closed?
151
+ @pointer.null?
152
+ end
153
+
154
+ private
155
+
156
+ # Wait up to {Session.async_timeout_seconds} for the FFI callback
157
+ # to push a result onto +queue+. On expiry, raise
158
+ # {Asherah::Error::Timeout}; the late callback (if it eventually
159
+ # fires) still decrements the pending-op counter via its `ensure`
160
+ # block, so close() can drain cleanly.
161
+ def await_async_result(queue, label)
162
+ ::Timeout.timeout(Session.async_timeout_seconds) { queue.pop }
163
+ rescue ::Timeout::Error
164
+ raise Asherah::Error::Timeout,
165
+ "#{label} timed out after #{Session.async_timeout_seconds}s"
166
+ end
167
+
168
+ def decrement_pending_ops
169
+ @close_mu.synchronize { @pending_ops -= 1 }
170
+ end
171
+
172
+ def thread_local_buffer
173
+ Thread.current[:asherah_buffer] ||= Native::AsherahBuffer.new
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "native"
4
+ require_relative "session"
5
+
6
+ module Asherah
7
+ class SessionFactory
8
+ def initialize(pointer)
9
+ raise Asherah::Error::BadConfig, Native.last_error if pointer.null?
10
+ @pointer = pointer
11
+ @close_mu = Mutex.new
12
+ end
13
+
14
+ def get_session(partition_id)
15
+ raise Asherah::Error::NotInitialized, "factory closed" if @pointer.null?
16
+ id = String(partition_id)
17
+ raise ArgumentError, "partition_id cannot be empty" if id.empty?
18
+ Session.new(Native.asherah_factory_get_session(@pointer, id))
19
+ end
20
+
21
+ def close
22
+ ptr = @close_mu.synchronize do
23
+ return if @pointer.null?
24
+ p = @pointer
25
+ @pointer = FFI::Pointer::NULL
26
+ p
27
+ end
28
+ Native.asherah_factory_free(ptr)
29
+ end
30
+
31
+ def closed?
32
+ @pointer.null?
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asherah
4
- VERSION = '0.9.1'
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/asherah.rb CHANGED
@@ -1,135 +1,321 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'asherah/version'
4
- require 'asherah/config'
5
- require 'asherah/error'
6
- require 'cobhan'
3
+ require "json"
7
4
 
8
- # Asherah is a Ruby wrapper around Asherah Go application-layer encryption SDK.
9
- module Asherah
10
- extend Cobhan
5
+ require_relative "asherah/version"
6
+ require_relative "asherah/error"
7
+ require_relative "asherah/config"
8
+ require_relative "asherah/native"
9
+ require_relative "asherah/session_factory"
10
+ require_relative "asherah/session"
11
+ require_relative "asherah/hooks"
11
12
 
12
- LIB_ROOT_PATH = File.expand_path('asherah/native', __dir__)
13
- load_library(LIB_ROOT_PATH, 'libasherah', [
14
- [:SetEnv, [:pointer], :int32],
15
- [:SetupJson, [:pointer], :int32],
16
- [:EncryptToJson, [:pointer, :pointer, :pointer], :int32],
17
- [:DecryptFromJson, [:pointer, :pointer, :pointer], :int32],
18
- [:Shutdown, [], :void]
19
- ].freeze)
13
+ module Asherah
14
+ DEFAULT_SESSION_CACHE_MAX_SIZE = 1000
20
15
 
21
- ESTIMATED_ENCRYPTION_OVERHEAD = 48
22
- ESTIMATED_ENVELOPE_OVERHEAD = 185
23
- BASE64_OVERHEAD = 1.34
16
+ @mutex = Mutex.new
17
+ @factory = nil
18
+ # @sessions is an LRU cache. Ruby Hash preserves insertion order, so we
19
+ # implement LRU by delete-and-reinsert on hit (moves the entry to the
20
+ # end), and `shift` on overflow (removes the oldest, i.e. least-recently
21
+ # used). Bounded by @session_cache_max_size, which honors the
22
+ # SessionCacheMaxSize config field — same default (1000) as the Rust
23
+ # core and the other bindings.
24
+ @sessions = {}
25
+ @initialized = false
26
+ @session_cache_enabled = true
27
+ @session_cache_max_size = DEFAULT_SESSION_CACHE_MAX_SIZE
28
+ @verbose = false
24
29
 
25
30
  class << self
26
- # Set environment variables needed by Asherah dependencies for when
27
- # Go os.Getenv() doesn't see variables set by C.setenv().
28
- # References:
29
- # https://github.com/golang/go/wiki/cgo#environmental-variables
30
- # https://github.com/golang/go/issues/44108
31
+ # Configure Asherah using a block with snake_case accessors.
32
+ # Compatible with the canonical godaddy/asherah-ruby gem API.
31
33
  #
32
- # @yield [Config]
33
- # @param env [Hash], Key-value pairs to set Asherah ENV
34
- # @return [void]
35
- def set_env(env = {})
36
- env_buffer = string_to_cbuffer(env.to_json)
34
+ # Asherah.configure do |config|
35
+ # config.service_name = "MyService"
36
+ # config.product_id = "MyProduct"
37
+ # config.kms = "static"
38
+ # config.metastore = "memory"
39
+ # end
40
+ def configure
41
+ @mutex.synchronize do
42
+ raise Error::AlreadyInitialized if @initialized
37
43
 
38
- result = SetEnv(env_buffer)
39
- Error.check_result!(result, 'SetEnv failed')
40
- ensure
41
- env_buffer&.free
44
+ config = Config.new
45
+ yield config
46
+ config.validate!
47
+
48
+ json = config.to_json
49
+ pointer = Native.asherah_factory_new_with_config(json)
50
+ @factory = SessionFactory.new(pointer)
51
+ @sessions = {}
52
+ @initialized = true
53
+ @session_cache_enabled = config.enable_session_caching != false
54
+ @session_cache_max_size = positive_int(config.session_cache_max_size) || DEFAULT_SESSION_CACHE_MAX_SIZE
55
+ @verbose = config.verbose == true
56
+ end
42
57
  end
43
58
 
44
- # Configures Asherah
45
- #
46
- # @yield [Config]
47
- # @return [void]
48
- def configure
49
- raise Asherah::Error::AlreadyInitialized if @initialized
59
+ # Initialize Asherah with a PascalCase config hash.
60
+ # Also accepts snake_case string/symbol keys (auto-normalized).
61
+ def setup(config)
62
+ normalized = normalize_config(config)
63
+ json = JSON.generate(normalized)
50
64
 
51
- config = Config.new
52
- yield config
53
- config.validate!
54
- @intermediated_key_overhead_bytesize = config.product_id.bytesize + config.service_name.bytesize
65
+ pointer = Native.asherah_factory_new_with_config(json)
66
+ factory = SessionFactory.new(pointer)
55
67
 
56
- config_buffer = string_to_cbuffer(config.to_json)
68
+ @mutex.synchronize do
69
+ raise Error::AlreadyInitialized if @initialized
57
70
 
58
- result = SetupJson(config_buffer)
59
- Error.check_result!(result, 'SetupJson failed')
60
- @initialized = true
61
- ensure
62
- config_buffer&.free
71
+ @factory = factory
72
+ @sessions = {}
73
+ @initialized = true
74
+ @session_cache_enabled = truthy(normalized["EnableSessionCaching"], default: true)
75
+ @session_cache_max_size = positive_int(normalized["SessionCacheMaxSize"]) || DEFAULT_SESSION_CACHE_MAX_SIZE
76
+ @verbose = truthy(normalized["Verbose"], default: false)
77
+ end
78
+
79
+ nil
80
+ rescue StandardError
81
+ factory&.close if defined?(factory) && factory
82
+ raise
63
83
  end
64
84
 
65
- # Encrypts data for a given partition_id and returns DataRowRecord in JSON format.
66
- #
67
- # DataRowRecord contains the encrypted key and data, as well as the information
68
- # required to decrypt the key encryption key. This object data should be stored
69
- # in your data persistence as it's required to decrypt data.
70
- #
71
- # EnvelopeKeyRecord represents an encrypted key and is the data structure used
72
- # to persist the key in the key table. It also contains the meta data
73
- # of the key used to encrypt it.
74
- #
75
- # KeyMeta contains the `id` and `created` timestamp for an encryption key.
76
- #
77
- # @param partition_id [String]
78
- # @param data [String]
79
- # @return [String], DataRowRecord in JSON format
80
- def encrypt(partition_id, data)
81
- raise Asherah::Error::NotInitialized unless @initialized
85
+ # Run setup in a background Thread. Yields the result to +block+ on
86
+ # success. Exceptions raised by setup propagate via the Thread's
87
+ # joined error the previous implementation called the block with
88
+ # the result on success but silently dropped the Thread's error
89
+ # state on failure, leaving callers unable to distinguish completion
90
+ # from an unsetup factory. The Thread aborts on exception
91
+ # (`Thread.report_on_exception = true`), so a stack trace lands on
92
+ # stderr; callers that need programmatic access should
93
+ # `thread.join` to re-raise. T-finding "setup_async/shutdown_async/
94
+ # encrypt_async/decrypt_async swallow Thread exceptions" in
95
+ # `docs/review-2026-05-05-findings.md`.
96
+ def setup_async(config, &block)
97
+ Thread.new do
98
+ Thread.current.report_on_exception = true
99
+ result = setup(config)
100
+ block&.call(result)
101
+ result
102
+ end
103
+ end
82
104
 
83
- partition_id_buffer = string_to_cbuffer(partition_id)
84
- data_buffer = string_to_cbuffer(data)
85
- estimated_buffer_bytesize = estimate_buffer(data.bytesize, partition_id.bytesize)
86
- output_buffer = allocate_cbuffer(estimated_buffer_bytesize)
105
+ def shutdown
106
+ factory = nil
107
+ sessions = nil
108
+ @mutex.synchronize do
109
+ raise Error::NotInitialized unless @initialized
87
110
 
88
- result = EncryptToJson(partition_id_buffer, data_buffer, output_buffer)
89
- Error.check_result!(result, 'EncryptToJson failed')
111
+ factory = @factory
112
+ sessions = @sessions.values
113
+ @factory = nil
114
+ @sessions = {}
115
+ @initialized = false
116
+ end
90
117
 
91
- cbuffer_to_string(output_buffer)
92
- ensure
93
- [partition_id_buffer, data_buffer, output_buffer].compact.each(&:free)
118
+ Array(sessions).each do |session|
119
+ begin
120
+ session.close unless session.closed?
121
+ rescue StandardError => e
122
+ warn "asherah: error closing session during shutdown: #{e.message}"
123
+ end
124
+ end
125
+ factory&.close unless factory&.closed?
126
+ nil
94
127
  end
95
128
 
96
- # Decrypts a DataRowRecord in JSON format for a partition_id and returns decrypted data.
97
- #
98
- # @param partition_id [String]
99
- # @param json [String], DataRowRecord in JSON format
100
- # @return [String], Decrypted data
101
- def decrypt(partition_id, json)
102
- raise Asherah::Error::NotInitialized unless @initialized
129
+ # Run shutdown in a background Thread; see `setup_async` for the
130
+ # exception-propagation contract.
131
+ def shutdown_async(&block)
132
+ Thread.new do
133
+ Thread.current.report_on_exception = true
134
+ result = shutdown
135
+ block&.call(result)
136
+ result
137
+ end
138
+ end
103
139
 
104
- partition_id_buffer = string_to_cbuffer(partition_id)
105
- data_buffer = string_to_cbuffer(json)
106
- output_buffer = allocate_cbuffer(json.bytesize)
140
+ def get_setup_status
141
+ @mutex.synchronize { @initialized }
142
+ end
107
143
 
108
- result = DecryptFromJson(partition_id_buffer, data_buffer, output_buffer)
109
- Error.check_result!(result, 'DecryptFromJson failed')
144
+ def setenv(env = {})
145
+ data = case env
146
+ when String
147
+ JSON.parse(env)
148
+ else
149
+ env
150
+ end
151
+ unless data.respond_to?(:each_pair)
152
+ raise ArgumentError, "environment payload must be a Hash or JSON object"
153
+ end
154
+ data.each_pair do |k, v|
155
+ if v.nil?
156
+ ENV.delete(String(k))
157
+ else
158
+ ENV[String(k)] = v.to_s
159
+ end
160
+ end
161
+ nil
162
+ end
163
+ alias_method :set_env, :setenv
110
164
 
111
- cbuffer_to_string(output_buffer)
112
- ensure
113
- [partition_id_buffer, data_buffer, output_buffer].compact.each(&:free)
165
+ def encrypt(partition_id, payload)
166
+ raise ArgumentError, "payload cannot be nil" if payload.nil?
167
+ session = resolve_session(partition_id)
168
+ session.encrypt_bytes(payload)
114
169
  end
115
170
 
116
- # Stop the Asherah instance
117
- def shutdown
118
- raise Asherah::Error::NotInitialized unless @initialized
171
+ def encrypt_string(partition_id, text)
172
+ raise ArgumentError, "text cannot be nil" if text.nil?
173
+ encrypt(partition_id, text)
174
+ end
175
+
176
+ def decrypt(partition_id, data_row_record)
177
+ raise ArgumentError, "data_row_record cannot be nil" if data_row_record.nil?
178
+ session = resolve_session(partition_id)
179
+ session.decrypt_bytes(data_row_record).force_encoding(Encoding::UTF_8)
180
+ end
181
+
182
+ def decrypt_string(partition_id, data_row_record)
183
+ raise ArgumentError, "data_row_record cannot be nil" if data_row_record.nil?
184
+ decrypt(partition_id, data_row_record).force_encoding(Encoding::UTF_8)
185
+ end
186
+
187
+ # Run encrypt in a background Thread; see `setup_async` for the
188
+ # exception-propagation contract.
189
+ def encrypt_async(partition_id, payload, &block)
190
+ Thread.new do
191
+ Thread.current.report_on_exception = true
192
+ result = encrypt(partition_id, payload)
193
+ block&.call(result)
194
+ result
195
+ end
196
+ end
197
+
198
+ # Run decrypt in a background Thread; see `setup_async` for the
199
+ # exception-propagation contract.
200
+ def decrypt_async(partition_id, data_row_record, &block)
201
+ Thread.new do
202
+ Thread.current.report_on_exception = true
203
+ result = decrypt(partition_id, data_row_record)
204
+ block&.call(result)
205
+ result
206
+ end
207
+ end
208
+
209
+ # Install a log hook. Yields a +Hash+ +{level:, target:, message:}+ for
210
+ # every log record emitted by the underlying Rust crates. The block may
211
+ # fire from any thread; implementations must be thread-safe and
212
+ # non-blocking. Pass +nil+ to clear (equivalent to {clear_log_hook}).
213
+ #
214
+ # Replaces any previously installed log hook. Exceptions raised from the
215
+ # callback are caught and silently swallowed.
216
+ def set_log_hook(callback = nil, &block)
217
+ Hooks.set_log_hook(callback, &block)
218
+ end
119
219
 
120
- Shutdown()
121
- @initialized = false
220
+ # Remove the active log hook, if any. Idempotent.
221
+ def clear_log_hook
222
+ Hooks.clear_log_hook
223
+ end
224
+
225
+ # Install a metrics hook. Yields a +Hash+ +{type:, duration_ns:, name:}+
226
+ # for every metrics event. Timing events ({:encrypt, :decrypt, :store,
227
+ # :load}) carry a positive +duration_ns+ and a +nil+ +name+; cache events
228
+ # ({:cache_hit, :cache_miss, :cache_stale}) carry +duration_ns+ == 0 and
229
+ # the cache identifier in +name+.
230
+ #
231
+ # Installing a hook implicitly enables the global metrics gate; clearing
232
+ # it disables the gate. Replaces any previously installed metrics hook.
233
+ # Pass +nil+ to clear (equivalent to {clear_metrics_hook}).
234
+ def set_metrics_hook(callback = nil, &block)
235
+ Hooks.set_metrics_hook(callback, &block)
236
+ end
237
+
238
+ # Remove the active metrics hook and disable metrics. Idempotent.
239
+ def clear_metrics_hook
240
+ Hooks.clear_metrics_hook
122
241
  end
123
242
 
124
243
  private
125
244
 
126
- def estimate_buffer(data_bytesize, partition_bytesize)
127
- est_data_len = (((data_bytesize + ESTIMATED_ENCRYPTION_OVERHEAD) * BASE64_OVERHEAD).to_i + 1)
245
+ REQUIRED_KEYS = %w[ServiceName ProductID Metastore].freeze
246
+
247
+ def normalize_config(config)
248
+ unless config.respond_to?(:each_pair)
249
+ raise ArgumentError, "config must be a Hash-like object"
250
+ end
251
+ normalized = {}
252
+ config.each_pair do |key, value|
253
+ normalized[String(key)] = value
254
+ end
255
+ REQUIRED_KEYS.each do |key|
256
+ raise ArgumentError, "#{key} is required" if normalized[key].nil? || normalized[key].to_s.strip.empty?
257
+ end
258
+ normalized
259
+ end
260
+
261
+ def truthy(value, default: false)
262
+ return default if value.nil?
263
+
264
+ case value
265
+ when true, "1", "true", "TRUE", "yes", "on" then true
266
+ when false, "0", "false", "FALSE", "no", "off" then false
267
+ else
268
+ default
269
+ end
270
+ end
271
+
272
+ def resolve_session(partition_id)
273
+ raise ArgumentError, "partition_id cannot be empty" if String(partition_id).empty?
274
+
275
+ evicted = nil
276
+ session = @mutex.synchronize do
277
+ raise Error::NotInitialized unless @initialized
278
+
279
+ unless @session_cache_enabled
280
+ break @factory.get_session(partition_id)
281
+ end
282
+
283
+ # LRU: on hit, delete + reinsert to move the entry to the
284
+ # most-recently-used end of the Hash (Ruby Hash is insertion-
285
+ # ordered). On miss + overflow, `shift` removes the oldest entry,
286
+ # which is the least-recently-used.
287
+ if (existing = @sessions.delete(partition_id))
288
+ @sessions[partition_id] = existing
289
+ break existing
290
+ end
291
+
292
+ fresh = @factory.get_session(partition_id)
293
+ @sessions[partition_id] = fresh
294
+ if @sessions.size > @session_cache_max_size
295
+ _, evicted = @sessions.shift
296
+ end
297
+ fresh
298
+ end
299
+
300
+ # Close evicted session outside the lock — close hits the FFI and
301
+ # we don't want to serialize all encrypts behind one eviction.
302
+ if evicted
303
+ begin
304
+ evicted.close unless evicted.closed?
305
+ rescue StandardError => e
306
+ warn "asherah: error closing evicted session: #{e.message}"
307
+ end
308
+ end
309
+
310
+ session
311
+ end
128
312
 
129
- ESTIMATED_ENVELOPE_OVERHEAD +
130
- @intermediated_key_overhead_bytesize +
131
- partition_bytesize +
132
- est_data_len
313
+ def positive_int(value)
314
+ return nil if value.nil?
315
+ n = Integer(value)
316
+ n.positive? ? n : nil
317
+ rescue ArgumentError, TypeError
318
+ nil
133
319
  end
134
320
  end
135
321
  end