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.
@@ -1,28 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require "json"
4
4
 
5
5
  module Asherah
6
- # @attr [String] service_name, The name of this service
7
- # @attr [String] product_id, The name of the product that owns this service
8
- # @attr [String] kms, The master key management service (static or aws)
9
- # @attr [String] metastore, The type of metastore for persisting keys (rdbms, dynamodb, memory)
10
- # @attr [String] connection_string, The database connection string (required when metastore is rdbms)
11
- # @attr [String] replica_read_consistency, For Aurora sessions using write forwarding (eventual, global, session)
12
- # @attr [String] sql_metastore_db_type, Which SQL driver to use (mysql, postgres, oracle), defaults to mysql
13
- # @attr [String] dynamo_db_endpoint, An optional endpoint URL (for dynamodb metastore)
14
- # @attr [String] dynamo_db_region, The AWS region for DynamoDB requests (for dynamodb metastore)
15
- # @attr [String] dynamo_db_table_name, The table name for DynamoDB (for dynamodb metastore)
16
- # @attr [Boolean] enable_region_suffix, Configure the metastore to use regional suffixes (for dynamodb metastore)
17
- # @attr [String] region_map, List of key-value pairs in the form of REGION1=ARN1[,REGION2=ARN2] (required for aws kms)
18
- # @attr [String] preferred_region, The preferred AWS region (required for aws kms)
19
- # @attr [Integer] session_cache_max_size, The maximum number of sessions to cache
20
- # @attr [Integer] session_cache_duration, The amount of time in seconds a session will remain cached
21
- # @attr [Integer] expire_after, The amount of time in seconds a key is considered valid
22
- # @attr [Integer] check_interval, The amount of time in seconds before cached keys are considered stale
23
- # @attr [Boolean] enable_session_caching, Enable shared session caching
24
- # @attr [Boolean] disable_zero_copy, Disable zero-copy FFI input buffers to prevent use-after-free from caller runtime
25
- # @attr [Boolean] verbose, Enable verbose logging output
6
+ # Configuration class compatible with the canonical godaddy/asherah-ruby gem.
7
+ # Provides snake_case attr_accessors that map to PascalCase config keys
8
+ # expected by the Rust FFI layer.
9
+ #
10
+ # Usage:
11
+ # Asherah.configure do |config|
12
+ # config.service_name = "MyService"
13
+ # config.product_id = "MyProduct"
14
+ # config.kms = "static"
15
+ # config.metastore = "memory"
16
+ # end
26
17
  class Config
27
18
  MAPPING = {
28
19
  service_name: :ServiceName,
@@ -34,83 +25,82 @@ module Asherah
34
25
  sql_metastore_db_type: :SQLMetastoreDBType,
35
26
  dynamo_db_endpoint: :DynamoDBEndpoint,
36
27
  dynamo_db_region: :DynamoDBRegion,
28
+ dynamo_db_signing_region: :DynamoDBSigningRegion,
37
29
  dynamo_db_table_name: :DynamoDBTableName,
38
30
  enable_region_suffix: :EnableRegionSuffix,
39
31
  region_map: :RegionMap,
40
32
  preferred_region: :PreferredRegion,
33
+ aws_profile_name: :AwsProfileName,
41
34
  session_cache_max_size: :SessionCacheMaxSize,
42
35
  session_cache_duration: :SessionCacheDuration,
43
36
  enable_session_caching: :EnableSessionCaching,
44
37
  disable_zero_copy: :DisableZeroCopy,
45
38
  expire_after: :ExpireAfter,
46
39
  check_interval: :CheckInterval,
47
- verbose: :Verbose
40
+ verbose: :Verbose,
41
+ # Connection pool
42
+ pool_max_open: :PoolMaxOpen,
43
+ pool_max_idle: :PoolMaxIdle,
44
+ pool_max_lifetime: :PoolMaxLifetime,
45
+ pool_max_idle_time: :PoolMaxIdleTime,
46
+ # KMS: AWS
47
+ kms_key_id: :KmsKeyId,
48
+ # KMS: AWS Secrets Manager
49
+ secrets_manager_secret_id: :SecretsManagerSecretId,
50
+ # KMS: HashiCorp Vault Transit
51
+ vault_addr: :VaultAddr,
52
+ vault_token: :VaultToken,
53
+ vault_auth_method: :VaultAuthMethod,
54
+ vault_auth_role: :VaultAuthRole,
55
+ vault_auth_mount: :VaultAuthMount,
56
+ vault_approle_role_id: :VaultApproleRoleId,
57
+ vault_approle_secret_id: :VaultApproleSecretId,
58
+ vault_client_cert: :VaultClientCert,
59
+ vault_client_key: :VaultClientKey,
60
+ vault_k8s_token_path: :VaultK8sTokenPath,
61
+ vault_transit_key: :VaultTransitKey,
62
+ vault_transit_mount: :VaultTransitMount,
48
63
  }.freeze
49
64
 
50
- KMS_TYPES = ['static', 'aws', 'test-debug-static'].freeze
51
- METASTORE_TYPES = ['rdbms', 'dynamodb', 'memory', 'test-debug-memory'].freeze
52
- SQL_METASTORE_DB_TYPES = ['mysql', 'postgres', 'oracle'].freeze
65
+ KMS_TYPES = ["static", "aws", "vault", "vault-transit", "secrets-manager", "test-debug-static"].freeze
66
+ METASTORE_TYPES = ["rdbms", "dynamodb", "memory", "test-debug-memory"].freeze
67
+ SQL_METASTORE_DB_TYPES = ["mysql", "postgres", "oracle"].freeze
53
68
 
54
69
  attr_accessor(*MAPPING.keys)
55
70
 
56
71
  def validate!
57
- validate_service_name
58
- validate_product_id
59
- validate_kms
60
- validate_metastore
61
- validate_sql_metastore_db_type
62
- validate_kms_attributes
63
- end
64
-
65
- def to_json(*args)
66
- config = {}.tap do |c|
67
- MAPPING.each_pair do |our_key, their_key|
68
- value = public_send(our_key)
69
- c[their_key] = value unless value.nil?
70
- end
71
- end
72
-
73
- JSON.generate(config, *args)
74
- end
75
-
76
- private
77
-
78
- def validate_service_name
79
- raise Error::ConfigError, 'config.service_name not set' if service_name.nil?
80
- end
81
-
82
- def validate_product_id
83
- raise Error::ConfigError, 'config.product_id not set' if product_id.nil?
84
- end
85
-
86
- def validate_kms
87
- raise Error::ConfigError, 'config.kms not set' if kms.nil?
72
+ raise Error::ConfigError, "config.service_name not set" if service_name.nil?
73
+ raise Error::ConfigError, "config.product_id not set" if product_id.nil?
74
+ raise Error::ConfigError, "config.kms not set" if kms.nil?
88
75
  unless KMS_TYPES.include?(kms)
89
- raise Error::ConfigError, "config.kms must be one of these: #{KMS_TYPES.join(', ')}"
76
+ raise Error::ConfigError, "config.kms must be one of these: #{KMS_TYPES.join(", ")}"
90
77
  end
91
- end
92
-
93
- def validate_metastore
94
- raise Error::ConfigError, 'config.metastore not set' if metastore.nil?
78
+ raise Error::ConfigError, "config.metastore not set" if metastore.nil?
95
79
  unless METASTORE_TYPES.include?(metastore)
96
- raise Error::ConfigError, "config.metastore must be one of these: #{METASTORE_TYPES.join(', ')}"
80
+ raise Error::ConfigError, "config.metastore must be one of these: #{METASTORE_TYPES.join(", ")}"
81
+ end
82
+ if sql_metastore_db_type && !SQL_METASTORE_DB_TYPES.include?(sql_metastore_db_type)
83
+ raise Error::ConfigError, "config.sql_metastore_db_type must be one of these: #{SQL_METASTORE_DB_TYPES.join(", ")}"
84
+ end
85
+ if kms == "aws"
86
+ raise Error::ConfigError, "config.region_map not set" if region_map.nil?
87
+ raise Error::ConfigError, "config.region_map must be a Hash" unless region_map.is_a?(Hash)
88
+ raise Error::ConfigError, "config.preferred_region not set" if preferred_region.nil?
97
89
  end
98
90
  end
99
91
 
100
- def validate_sql_metastore_db_type
101
- return if sql_metastore_db_type.nil?
102
-
103
- unless SQL_METASTORE_DB_TYPES.include?(sql_metastore_db_type)
104
- raise Error::ConfigError,
105
- "config.sql_metastore_db_type must be one of these: #{SQL_METASTORE_DB_TYPES.join(', ')}"
106
- end
92
+ def to_json(*args)
93
+ JSON.generate(to_h, *args)
107
94
  end
108
95
 
109
- def validate_kms_attributes
110
- return unless kms == 'aws'
111
- raise Error::ConfigError, 'config.region_map not set' if region_map.nil?
112
- raise Error::ConfigError, 'config.region_map must be a Hash' unless region_map.is_a?(Hash)
113
- raise Error::ConfigError, 'config.preferred_region not set' if preferred_region.nil?
96
+ # Convert to the PascalCase Hash expected by Asherah.setup
97
+ def to_h
98
+ hash = {}
99
+ MAPPING.each_pair do |attr, key|
100
+ value = public_send(attr)
101
+ hash[key] = value unless value.nil?
102
+ end
103
+ hash
114
104
  end
115
105
  end
116
106
  end
data/lib/asherah/error.rb CHANGED
@@ -1,30 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asherah
4
- # Asherah Error converts the error code to error message
5
- module Error
6
- ConfigError = Class.new(StandardError)
7
- NotInitialized = Class.new(StandardError)
8
- AlreadyInitialized = Class.new(StandardError)
9
- GetSessionFailed = Class.new(StandardError)
10
- EncryptFailed = Class.new(StandardError)
11
- DecryptFailed = Class.new(StandardError)
12
- BadConfig = Class.new(StandardError)
13
-
14
- CODES = {
15
- -100 => NotInitialized,
16
- -101 => AlreadyInitialized,
17
- -102 => GetSessionFailed,
18
- -103 => EncryptFailed,
19
- -104 => DecryptFailed,
20
- -105 => BadConfig
21
- }.freeze
22
-
23
- def self.check_result!(result, message)
24
- return unless result.negative?
25
-
26
- error_class = Error::CODES.fetch(result, StandardError)
27
- raise error_class, "#{message} (#{result})"
28
- end
4
+ # Base error class. Also serves as a namespace for specific error types
5
+ # compatible with the canonical godaddy/asherah-ruby gem.
6
+ class Error < StandardError
7
+ ConfigError = Class.new(self)
8
+ NotInitialized = Class.new(self)
9
+ AlreadyInitialized = Class.new(self)
10
+ GetSessionFailed = Class.new(self)
11
+ EncryptFailed = Class.new(self)
12
+ DecryptFailed = Class.new(self)
13
+ BadConfig = Class.new(self)
14
+ Timeout = Class.new(self)
29
15
  end
30
16
  end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "native"
6
+
7
+ module Asherah
8
+ # Log + metrics observability hooks.
9
+ #
10
+ # The C ABI accepts a single function pointer per hook. We marshal each
11
+ # invocation into a Ruby Hash with symbol keys and yield it to the
12
+ # user-provided +Proc+. Exceptions raised by the user's callback are caught
13
+ # and silently swallowed — propagating an exception across the FFI boundary
14
+ # would be undefined behavior (and since Rust 1.81 aborts the process).
15
+ #
16
+ # The user's callback may fire from any thread (Rust tokio worker threads,
17
+ # database driver threads). Implementations must be thread-safe and should
18
+ # not block; expensive forwarding (e.g. to a logging framework) should be
19
+ # done by enqueueing work onto a background thread you own.
20
+ module Hooks
21
+ # Map C ABI integer log level → +Logger::Severity+ constant. The Rust
22
+ # +log+ crate has a TRACE level that stdlib +Logger+ does not; it is
23
+ # surfaced as +Logger::DEBUG+ so the value is still meaningful when the
24
+ # caller dispatches via +Logger#add+.
25
+ LOG_LEVEL_TO_SEVERITY = {
26
+ Native::LOG_TRACE => Logger::DEBUG,
27
+ Native::LOG_DEBUG => Logger::DEBUG,
28
+ Native::LOG_INFO => Logger::INFO,
29
+ Native::LOG_WARN => Logger::WARN,
30
+ Native::LOG_ERROR => Logger::ERROR
31
+ }.freeze
32
+ private_constant :LOG_LEVEL_TO_SEVERITY
33
+
34
+ # Map +Logger::Severity+ → lowercase symbol for ergonomic dispatch on
35
+ # the raw block API.
36
+ SEVERITY_TO_SYMBOL = {
37
+ Logger::DEBUG => :debug,
38
+ Logger::INFO => :info,
39
+ Logger::WARN => :warn,
40
+ Logger::ERROR => :error,
41
+ Logger::FATAL => :fatal,
42
+ Logger::UNKNOWN => :unknown
43
+ }.freeze
44
+ private_constant :SEVERITY_TO_SYMBOL
45
+
46
+ METRIC_TYPE_NAMES = {
47
+ Native::METRIC_ENCRYPT => :encrypt,
48
+ Native::METRIC_DECRYPT => :decrypt,
49
+ Native::METRIC_STORE => :store,
50
+ Native::METRIC_LOAD => :load,
51
+ Native::METRIC_CACHE_HIT => :cache_hit,
52
+ Native::METRIC_CACHE_MISS => :cache_miss,
53
+ Native::METRIC_CACHE_STALE => :cache_stale
54
+ }.freeze
55
+ private_constant :METRIC_TYPE_NAMES
56
+
57
+ # Module state: pinning the active FFI::Function trampolines is required
58
+ # so the GC does not free them while the C ABI still holds the pointer.
59
+ @mutex = Mutex.new
60
+ @log_trampoline = nil
61
+ @metrics_trampoline = nil
62
+ @log_callback = nil
63
+ @metrics_callback = nil
64
+ # Re-entrancy guard. The +ffi+ gem itself can log via its own internal
65
+ # paths during marshalling, and the Rust crates we bridge to log freely.
66
+ # Without this guard a user callback that itself produces log output
67
+ # would re-enter the trampoline and recurse.
68
+ @log_in_callback = {}
69
+ @metrics_in_callback = {}
70
+
71
+ class << self
72
+ # Install a log hook. Three forms are supported:
73
+ #
74
+ # 1. A stdlib +Logger+ instance (or any Logger-compatible object that
75
+ # responds to +#add+, +#debug+, +#info+, +#warn+, +#error+):
76
+ #
77
+ # Asherah.set_log_hook(Logger.new($stdout))
78
+ #
79
+ # Each Asherah record is forwarded via
80
+ # +Logger#add(severity, message, target)+ so the logger's own filter
81
+ # rules and formatters apply. The +target+ argument is passed as
82
+ # +progname+ for routing.
83
+ #
84
+ # 2. A +Proc+ or block, yielded a Hash:
85
+ #
86
+ # Asherah.set_log_hook do |event|
87
+ # # event[:level] => :debug | :info | :warn | :error (symbol)
88
+ # # event[:severity] => Logger::DEBUG ... (Logger::Severity int)
89
+ # # event[:target] => "asherah::session"
90
+ # # event[:message] => "..."
91
+ # end
92
+ #
93
+ # 3. +nil+ to clear (equivalent to {clear_log_hook}).
94
+ #
95
+ # Replaces any previously installed log hook. Exceptions raised from
96
+ # the callback are caught and silently swallowed.
97
+ def set_log_hook(callback = nil, &block)
98
+ callback ||= block
99
+ if callback.nil?
100
+ clear_log_hook
101
+ return
102
+ end
103
+ callback = logger_to_callback(callback) if logger_like?(callback)
104
+ unless callback.respond_to?(:call)
105
+ raise ArgumentError, "log hook must be a Logger, Proc, or block"
106
+ end
107
+
108
+ @mutex.synchronize do
109
+ @log_callback = callback
110
+ # Allocate the trampoline OUTSIDE the user block so a slow user
111
+ # callback can't hold the mutex.
112
+ @log_trampoline = FFI::Function.new(
113
+ :void,
114
+ [:pointer, :int, :string, :string]
115
+ ) do |_user_data, level, target, message|
116
+ dispatch_log(level, target, message)
117
+ end
118
+ rc = Native.asherah_set_log_hook(@log_trampoline, FFI::Pointer::NULL)
119
+ raise Error, "asherah_set_log_hook failed: rc=#{rc}" if rc != 0
120
+ end
121
+ nil
122
+ end
123
+
124
+ # Remove the active log hook. Idempotent.
125
+ def clear_log_hook
126
+ @mutex.synchronize do
127
+ Native.asherah_clear_log_hook
128
+ @log_callback = nil
129
+ @log_trampoline = nil
130
+ end
131
+ nil
132
+ end
133
+
134
+ # Install a metrics hook. +block+ receives a Hash:
135
+ #
136
+ # # Timing event:
137
+ # { type: :encrypt|:decrypt|:store|:load, duration_ns: Integer, name: nil }
138
+ # # Cache event:
139
+ # { type: :cache_hit|:cache_miss|:cache_stale, duration_ns: 0, name: String }
140
+ #
141
+ # Installing a hook implicitly enables the global metrics gate; clearing
142
+ # it disables the gate. Replaces any previously installed metrics hook.
143
+ # Pass +nil+ to clear.
144
+ def set_metrics_hook(callback = nil, &block)
145
+ callback ||= block
146
+ if callback.nil?
147
+ clear_metrics_hook
148
+ return
149
+ end
150
+ unless callback.respond_to?(:call)
151
+ raise ArgumentError, "metrics hook must be callable (Proc or block)"
152
+ end
153
+
154
+ @mutex.synchronize do
155
+ @metrics_callback = callback
156
+ @metrics_trampoline = FFI::Function.new(
157
+ :void,
158
+ [:pointer, :int, :uint64, :string]
159
+ ) do |_user_data, type, duration_ns, name|
160
+ dispatch_metric(type, duration_ns, name)
161
+ end
162
+ rc = Native.asherah_set_metrics_hook(@metrics_trampoline, FFI::Pointer::NULL)
163
+ raise Error, "asherah_set_metrics_hook failed: rc=#{rc}" if rc != 0
164
+ end
165
+ nil
166
+ end
167
+
168
+ # Remove the active metrics hook and disable the metrics gate. Idempotent.
169
+ def clear_metrics_hook
170
+ @mutex.synchronize do
171
+ Native.asherah_clear_metrics_hook
172
+ @metrics_callback = nil
173
+ @metrics_trampoline = nil
174
+ end
175
+ nil
176
+ end
177
+
178
+ private
179
+
180
+ # Duck-typed Logger detection: anything that is NOT a +Proc+/+Method+
181
+ # and responds to the core stdlib +Logger+ API is treated as a Logger
182
+ # instance. This catches stdlib +Logger+, +ActiveSupport::Logger+,
183
+ # +SemanticLogger+, +Ougai+, etc.
184
+ def logger_like?(obj)
185
+ return false if obj.is_a?(Proc) || obj.is_a?(Method)
186
+ %i[debug info warn error add].all? { |m| obj.respond_to?(m) }
187
+ end
188
+
189
+ def logger_to_callback(logger)
190
+ lambda do |event|
191
+ severity = event[:severity] || Logger::ERROR
192
+ # Logger#add(severity, msg, progname) — progname carries target so
193
+ # custom formatters can route on it.
194
+ logger.add(severity, event[:message], event[:target])
195
+ end
196
+ end
197
+
198
+ def dispatch_log(level, target, message)
199
+ tid = Thread.current.object_id
200
+ return if @log_in_callback[tid]
201
+ @log_in_callback[tid] = true
202
+ cb = @log_callback
203
+ return if cb.nil?
204
+ begin
205
+ severity = LOG_LEVEL_TO_SEVERITY[level] || Logger::ERROR
206
+ cb.call(
207
+ level: SEVERITY_TO_SYMBOL[severity] || :error,
208
+ severity: severity,
209
+ target: target.to_s,
210
+ message: message.to_s
211
+ )
212
+ rescue StandardError, ScriptError
213
+ # swallow — exceptions across FFI are undefined behavior
214
+ ensure
215
+ @log_in_callback.delete(tid)
216
+ end
217
+ end
218
+
219
+ def dispatch_metric(type, duration_ns, name)
220
+ tid = Thread.current.object_id
221
+ return if @metrics_in_callback[tid]
222
+ @metrics_in_callback[tid] = true
223
+ cb = @metrics_callback
224
+ return if cb.nil?
225
+ begin
226
+ cb.call(
227
+ type: METRIC_TYPE_NAMES[type] || :encrypt,
228
+ duration_ns: duration_ns,
229
+ name: name
230
+ )
231
+ rescue StandardError, ScriptError
232
+ # swallow
233
+ ensure
234
+ @metrics_in_callback.delete(tid)
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require "rbconfig"
5
+ require_relative "error"
6
+
7
+ module Asherah
8
+ module Native
9
+ extend FFI::Library
10
+
11
+ class AsherahBuffer < FFI::Struct
12
+ layout :data, :pointer, :len, :size_t, :capacity, :size_t
13
+ end
14
+
15
+ class << self
16
+ def library_basenames
17
+ host = RbConfig::CONFIG["host_os"]
18
+ if host =~ /mswin|mingw|cygwin/
19
+ ["asherah_ffi.dll"]
20
+ elsif host =~ /darwin/
21
+ ["libasherah_ffi.dylib"]
22
+ else
23
+ ["libasherah_ffi.so"]
24
+ end
25
+ end
26
+
27
+ def candidate_paths
28
+ names = library_basenames
29
+ paths = []
30
+
31
+ # 1. Explicit override via environment variable
32
+ env = ENV.fetch("ASHERAH_RUBY_NATIVE", "").strip
33
+ unless env.empty?
34
+ if File.directory?(env)
35
+ names.each { |name| paths << File.join(env, name) }
36
+ else
37
+ paths << env
38
+ end
39
+ end
40
+
41
+ # 2. Bundled in gem (platform-specific gem ships it here)
42
+ native_dir = File.expand_path("native", __dir__)
43
+ names.each { |name| paths << File.join(native_dir, name) }
44
+
45
+ # 3. CARGO_TARGET_DIR (development)
46
+ cargo_target = ENV.fetch("CARGO_TARGET_DIR", "").strip
47
+ unless cargo_target.empty?
48
+ %w[debug release].each do |profile|
49
+ names.each { |name| paths << File.join(cargo_target, profile, name) }
50
+ end
51
+ end
52
+
53
+ # 4. Workspace target directory (development)
54
+ root = File.expand_path("../../..", __dir__)
55
+ %w[target/release target/debug].each do |sub|
56
+ names.each { |name| paths << File.join(root, sub, name) }
57
+ end
58
+
59
+ # 5. System library path fallback
60
+ names.each { |name| paths << name }
61
+ paths.uniq
62
+ end
63
+
64
+ def resolve_library
65
+ candidate_paths.find { |path| File.exist?(path) } || candidate_paths.first
66
+ end
67
+ end
68
+
69
+ LIBRARY_PATH = resolve_library
70
+ ffi_lib LIBRARY_PATH
71
+
72
+ attach_function :asherah_last_error_message, [], :pointer
73
+ # Factory creation can hit AWS KMS / DynamoDB / TLS handshakes, so it
74
+ # must release the GVL — otherwise every other Ruby thread blocks for
75
+ # the duration of the SDK init. T5 in
76
+ # docs/review-2026-05-05-findings.md.
77
+ attach_function :asherah_factory_new_from_env, [], :pointer, blocking: true
78
+ attach_function :asherah_factory_new_with_config, [:string], :pointer, blocking: true
79
+ attach_function :asherah_apply_config_json, [:string], :int, blocking: true
80
+ attach_function :asherah_factory_free, [:pointer], :void
81
+ attach_function :asherah_factory_get_session, [:pointer, :string], :pointer
82
+ attach_function :asherah_session_free, [:pointer], :void
83
+ # encrypt/decrypt block on the metastore (MySQL/Postgres/DynamoDB) and
84
+ # KMS during cache misses. blocking: true frees the GVL so other Ruby
85
+ # threads stay schedulable while the native call is in-flight.
86
+ attach_function :asherah_encrypt_to_json,
87
+ [:pointer, :buffer_in, :size_t, :pointer], :int, blocking: true
88
+ attach_function :asherah_decrypt_from_json,
89
+ [:pointer, :buffer_in, :size_t, :pointer], :int, blocking: true
90
+ attach_function :asherah_buffer_free, [:pointer], :void
91
+
92
+ # Async callback type: void(user_data, result_data, result_len, error_message)
93
+ callback :asherah_completion_fn, [:pointer, :pointer, :size_t, :string], :void
94
+ # The async entry points only enqueue work onto the Rust tokio runtime
95
+ # before returning, so they're brief — but mark them blocking anyway so
96
+ # a queue-full backpressure stall doesn't pin the GVL.
97
+ attach_function :asherah_encrypt_to_json_async,
98
+ [:pointer, :buffer_in, :size_t, :asherah_completion_fn, :pointer], :int,
99
+ blocking: true
100
+ attach_function :asherah_decrypt_from_json_async,
101
+ [:pointer, :buffer_in, :size_t, :asherah_completion_fn, :pointer], :int,
102
+ blocking: true
103
+
104
+ # Log + metrics hooks. The C ABI does not own the callback closure — Ruby
105
+ # must keep a reference to the FFI::Function it passes here for as long as
106
+ # the hook is registered, otherwise the GC will collect it and the next
107
+ # invocation segfaults. The Asherah module pins the active hooks in module
108
+ # state.
109
+ callback :asherah_log_callback, [:pointer, :int, :string, :string], :void
110
+ callback :asherah_metrics_callback, [:pointer, :int, :uint64, :string], :void
111
+
112
+ # Hook set/clear functions must release the GVL (blocking: true) because
113
+ # they may join the async dispatcher worker thread (via Drop on the old
114
+ # AsyncLogSink/AsyncMetricsSink). That worker thread needs the GVL to
115
+ # execute FFI callbacks into Ruby — holding the GVL here deadlocks.
116
+ attach_function :asherah_set_log_hook, [:asherah_log_callback, :pointer], :int, blocking: true
117
+ attach_function :asherah_clear_log_hook, [], :int, blocking: true
118
+ attach_function :asherah_set_metrics_hook, [:asherah_metrics_callback, :pointer], :int, blocking: true
119
+ attach_function :asherah_clear_metrics_hook, [], :int, blocking: true
120
+
121
+ # Internal: integer constants that mirror the C ABI severity/event
122
+ # codes in hooks.rs. The public Ruby API exposes them as
123
+ # +Logger::Severity+ values and +Symbol+ types respectively, so
124
+ # callers should never need to reference these directly.
125
+ LOG_TRACE = 0
126
+ LOG_DEBUG = 1
127
+ LOG_INFO = 2
128
+ LOG_WARN = 3
129
+ LOG_ERROR = 4
130
+
131
+ METRIC_ENCRYPT = 0
132
+ METRIC_DECRYPT = 1
133
+ METRIC_STORE = 2
134
+ METRIC_LOAD = 3
135
+ METRIC_CACHE_HIT = 4
136
+ METRIC_CACHE_MISS = 5
137
+ METRIC_CACHE_STALE = 6
138
+
139
+ def self.last_error
140
+ ptr = asherah_last_error_message
141
+ ptr.null? ? "unknown error" : ptr.read_string
142
+ end
143
+
144
+ private_class_method :library_basenames, :candidate_paths, :resolve_library
145
+ end
146
+ end