asherah 0.10.0-aarch64-linux-musl

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.
data/lib/asherah.rb ADDED
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
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"
12
+
13
+ module Asherah
14
+ DEFAULT_SESSION_CACHE_MAX_SIZE = 1000
15
+
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
29
+
30
+ class << self
31
+ # Configure Asherah using a block with snake_case accessors.
32
+ # Compatible with the canonical godaddy/asherah-ruby gem API.
33
+ #
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
43
+
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
57
+ end
58
+
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)
64
+
65
+ pointer = Native.asherah_factory_new_with_config(json)
66
+ factory = SessionFactory.new(pointer)
67
+
68
+ @mutex.synchronize do
69
+ raise Error::AlreadyInitialized if @initialized
70
+
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
83
+ end
84
+
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
104
+
105
+ def shutdown
106
+ factory = nil
107
+ sessions = nil
108
+ @mutex.synchronize do
109
+ raise Error::NotInitialized unless @initialized
110
+
111
+ factory = @factory
112
+ sessions = @sessions.values
113
+ @factory = nil
114
+ @sessions = {}
115
+ @initialized = false
116
+ end
117
+
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
127
+ end
128
+
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
139
+
140
+ def get_setup_status
141
+ @mutex.synchronize { @initialized }
142
+ end
143
+
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
164
+
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)
169
+ end
170
+
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
219
+
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
241
+ end
242
+
243
+ private
244
+
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
312
+
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
319
+ end
320
+ end
321
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asherah
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.0
5
+ platform: aarch64-linux-musl
6
+ authors:
7
+ - Jay Gowdy
8
+ - Bo Thompson
9
+ - Michael Micco
10
+ - Dalibor Nasevic
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+ date: 2026-06-05 00:00:00.000000000 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: ffi
18
+ requirement: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - "~>"
21
+ - !ruby/object:Gem::Version
22
+ version: '1.15'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '1.15'
30
+ - !ruby/object:Gem::Dependency
31
+ name: logger
32
+ requirement: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - "~>"
35
+ - !ruby/object:Gem::Version
36
+ version: '1.6'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '1.6'
44
+ description: Asherah application-layer encryption for Ruby with automatic key rotation,
45
+ powered by the native Rust implementation.
46
+ email: oss@godaddy.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - README.md
52
+ - lib/asherah.rb
53
+ - lib/asherah/config.rb
54
+ - lib/asherah/error.rb
55
+ - lib/asherah/hooks.rb
56
+ - lib/asherah/native.rb
57
+ - lib/asherah/native/libasherah_ffi.so
58
+ - lib/asherah/session.rb
59
+ - lib/asherah/session_factory.rb
60
+ - lib/asherah/version.rb
61
+ homepage: https://github.com/godaddy/asherah-ffi
62
+ licenses:
63
+ - Apache-2.0
64
+ metadata:
65
+ homepage_uri: https://github.com/godaddy/asherah-ffi
66
+ source_code_uri: https://github.com/godaddy/asherah-ffi
67
+ github_repo: https://github.com/godaddy/asherah-ffi
68
+ changelog_uri: https://github.com/godaddy/asherah-ffi/releases
69
+ bug_tracker_uri: https://github.com/godaddy/asherah-ffi/issues
70
+ rubygems_mfa_required: 'true'
71
+ post_install_message:
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '3.0'
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.5.22
87
+ signing_key:
88
+ specification_version: 4
89
+ summary: Asherah application-layer encryption for Ruby with automatic key rotation.
90
+ test_files: []