asherah 0.9.1-aarch64-linux → 0.10.1-aarch64-linux
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/README.md +336 -56
- 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/{libasherah-arm64.so → libasherah_ffi.so} +0 -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 +43 -39
- 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/extconf.rb +0 -7
- data/ext/asherah/native_file.rb +0 -64
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
|
|
Binary file
|
|
@@ -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
|
|
@@ -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
|
data/lib/asherah/version.rb
CHANGED