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.
- checksums.yaml +4 -4
- data/NATIVE_VERSION +1 -0
- data/README.md +336 -56
- data/ext/asherah/asherah.c +2 -0
- data/ext/asherah/extconf.rb +6 -4
- data/ext/asherah/fetch_native.rb +196 -0
- data/lib/asherah/config.rb +64 -74
- data/lib/asherah/error.rb +11 -25
- data/lib/asherah/hooks.rb +239 -0
- data/lib/asherah/native.rb +146 -0
- data/lib/asherah/session.rb +176 -0
- data/lib/asherah/session_factory.rb +35 -0
- data/lib/asherah/version.rb +1 -1
- data/lib/asherah.rb +286 -100
- metadata +44 -34
- data/.env.secrets.example +0 -9
- data/.rspec +0 -3
- data/.rubocop.yml +0 -112
- data/.ruby-version +0 -1
- data/CHANGELOG.md +0 -135
- data/CODE_OF_CONDUCT.md +0 -77
- data/CONTRIBUTING.md +0 -118
- data/Gemfile +0 -14
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -29
- data/SECURITY.md +0 -19
- data/asherah.gemspec +0 -39
- data/ext/asherah/checksums.yml +0 -5
- data/ext/asherah/native_file.rb +0 -64
data/lib/asherah/config.rb
CHANGED
|
@@ -1,28 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "json"
|
|
4
4
|
|
|
5
5
|
module Asherah
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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 = [
|
|
51
|
-
METASTORE_TYPES = [
|
|
52
|
-
SQL_METASTORE_DB_TYPES = [
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|