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
|
@@ -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
data/lib/asherah.rb
CHANGED
|
@@ -1,135 +1,321 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require 'asherah/config'
|
|
5
|
-
require 'asherah/error'
|
|
6
|
-
require 'cobhan'
|
|
3
|
+
require "json"
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
require_relative "asherah/version"
|
|
6
|
+
require_relative "asherah/error"
|
|
7
|
+
require_relative "asherah/config"
|
|
8
|
+
require_relative "asherah/native"
|
|
9
|
+
require_relative "asherah/session_factory"
|
|
10
|
+
require_relative "asherah/session"
|
|
11
|
+
require_relative "asherah/hooks"
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
[:SetEnv, [:pointer], :int32],
|
|
15
|
-
[:SetupJson, [:pointer], :int32],
|
|
16
|
-
[:EncryptToJson, [:pointer, :pointer, :pointer], :int32],
|
|
17
|
-
[:DecryptFromJson, [:pointer, :pointer, :pointer], :int32],
|
|
18
|
-
[:Shutdown, [], :void]
|
|
19
|
-
].freeze)
|
|
13
|
+
module Asherah
|
|
14
|
+
DEFAULT_SESSION_CACHE_MAX_SIZE = 1000
|
|
20
15
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@factory = nil
|
|
18
|
+
# @sessions is an LRU cache. Ruby Hash preserves insertion order, so we
|
|
19
|
+
# implement LRU by delete-and-reinsert on hit (moves the entry to the
|
|
20
|
+
# end), and `shift` on overflow (removes the oldest, i.e. least-recently
|
|
21
|
+
# used). Bounded by @session_cache_max_size, which honors the
|
|
22
|
+
# SessionCacheMaxSize config field — same default (1000) as the Rust
|
|
23
|
+
# core and the other bindings.
|
|
24
|
+
@sessions = {}
|
|
25
|
+
@initialized = false
|
|
26
|
+
@session_cache_enabled = true
|
|
27
|
+
@session_cache_max_size = DEFAULT_SESSION_CACHE_MAX_SIZE
|
|
28
|
+
@verbose = false
|
|
24
29
|
|
|
25
30
|
class << self
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# References:
|
|
29
|
-
# https://github.com/golang/go/wiki/cgo#environmental-variables
|
|
30
|
-
# https://github.com/golang/go/issues/44108
|
|
31
|
+
# Configure Asherah using a block with snake_case accessors.
|
|
32
|
+
# Compatible with the canonical godaddy/asherah-ruby gem API.
|
|
31
33
|
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
# Asherah.configure do |config|
|
|
35
|
+
# config.service_name = "MyService"
|
|
36
|
+
# config.product_id = "MyProduct"
|
|
37
|
+
# config.kms = "static"
|
|
38
|
+
# config.metastore = "memory"
|
|
39
|
+
# end
|
|
40
|
+
def configure
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
raise Error::AlreadyInitialized if @initialized
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
config = Config.new
|
|
45
|
+
yield config
|
|
46
|
+
config.validate!
|
|
47
|
+
|
|
48
|
+
json = config.to_json
|
|
49
|
+
pointer = Native.asherah_factory_new_with_config(json)
|
|
50
|
+
@factory = SessionFactory.new(pointer)
|
|
51
|
+
@sessions = {}
|
|
52
|
+
@initialized = true
|
|
53
|
+
@session_cache_enabled = config.enable_session_caching != false
|
|
54
|
+
@session_cache_max_size = positive_int(config.session_cache_max_size) || DEFAULT_SESSION_CACHE_MAX_SIZE
|
|
55
|
+
@verbose = config.verbose == true
|
|
56
|
+
end
|
|
42
57
|
end
|
|
43
58
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
raise Asherah::Error::AlreadyInitialized if @initialized
|
|
59
|
+
# Initialize Asherah with a PascalCase config hash.
|
|
60
|
+
# Also accepts snake_case string/symbol keys (auto-normalized).
|
|
61
|
+
def setup(config)
|
|
62
|
+
normalized = normalize_config(config)
|
|
63
|
+
json = JSON.generate(normalized)
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
config.validate!
|
|
54
|
-
@intermediated_key_overhead_bytesize = config.product_id.bytesize + config.service_name.bytesize
|
|
65
|
+
pointer = Native.asherah_factory_new_with_config(json)
|
|
66
|
+
factory = SessionFactory.new(pointer)
|
|
55
67
|
|
|
56
|
-
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
raise Error::AlreadyInitialized if @initialized
|
|
57
70
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
@factory = factory
|
|
72
|
+
@sessions = {}
|
|
73
|
+
@initialized = true
|
|
74
|
+
@session_cache_enabled = truthy(normalized["EnableSessionCaching"], default: true)
|
|
75
|
+
@session_cache_max_size = positive_int(normalized["SessionCacheMaxSize"]) || DEFAULT_SESSION_CACHE_MAX_SIZE
|
|
76
|
+
@verbose = truthy(normalized["Verbose"], default: false)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
nil
|
|
80
|
+
rescue StandardError
|
|
81
|
+
factory&.close if defined?(factory) && factory
|
|
82
|
+
raise
|
|
63
83
|
end
|
|
64
84
|
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
# Run setup in a background Thread. Yields the result to +block+ on
|
|
86
|
+
# success. Exceptions raised by setup propagate via the Thread's
|
|
87
|
+
# joined error — the previous implementation called the block with
|
|
88
|
+
# the result on success but silently dropped the Thread's error
|
|
89
|
+
# state on failure, leaving callers unable to distinguish completion
|
|
90
|
+
# from an unsetup factory. The Thread aborts on exception
|
|
91
|
+
# (`Thread.report_on_exception = true`), so a stack trace lands on
|
|
92
|
+
# stderr; callers that need programmatic access should
|
|
93
|
+
# `thread.join` to re-raise. T-finding "setup_async/shutdown_async/
|
|
94
|
+
# encrypt_async/decrypt_async swallow Thread exceptions" in
|
|
95
|
+
# `docs/review-2026-05-05-findings.md`.
|
|
96
|
+
def setup_async(config, &block)
|
|
97
|
+
Thread.new do
|
|
98
|
+
Thread.current.report_on_exception = true
|
|
99
|
+
result = setup(config)
|
|
100
|
+
block&.call(result)
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
end
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
def shutdown
|
|
106
|
+
factory = nil
|
|
107
|
+
sessions = nil
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
raise Error::NotInitialized unless @initialized
|
|
87
110
|
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
factory = @factory
|
|
112
|
+
sessions = @sessions.values
|
|
113
|
+
@factory = nil
|
|
114
|
+
@sessions = {}
|
|
115
|
+
@initialized = false
|
|
116
|
+
end
|
|
90
117
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
Array(sessions).each do |session|
|
|
119
|
+
begin
|
|
120
|
+
session.close unless session.closed?
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
warn "asherah: error closing session during shutdown: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
factory&.close unless factory&.closed?
|
|
126
|
+
nil
|
|
94
127
|
end
|
|
95
128
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
129
|
+
# Run shutdown in a background Thread; see `setup_async` for the
|
|
130
|
+
# exception-propagation contract.
|
|
131
|
+
def shutdown_async(&block)
|
|
132
|
+
Thread.new do
|
|
133
|
+
Thread.current.report_on_exception = true
|
|
134
|
+
result = shutdown
|
|
135
|
+
block&.call(result)
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
end
|
|
103
139
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
140
|
+
def get_setup_status
|
|
141
|
+
@mutex.synchronize { @initialized }
|
|
142
|
+
end
|
|
107
143
|
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
def setenv(env = {})
|
|
145
|
+
data = case env
|
|
146
|
+
when String
|
|
147
|
+
JSON.parse(env)
|
|
148
|
+
else
|
|
149
|
+
env
|
|
150
|
+
end
|
|
151
|
+
unless data.respond_to?(:each_pair)
|
|
152
|
+
raise ArgumentError, "environment payload must be a Hash or JSON object"
|
|
153
|
+
end
|
|
154
|
+
data.each_pair do |k, v|
|
|
155
|
+
if v.nil?
|
|
156
|
+
ENV.delete(String(k))
|
|
157
|
+
else
|
|
158
|
+
ENV[String(k)] = v.to_s
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
alias_method :set_env, :setenv
|
|
110
164
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
165
|
+
def encrypt(partition_id, payload)
|
|
166
|
+
raise ArgumentError, "payload cannot be nil" if payload.nil?
|
|
167
|
+
session = resolve_session(partition_id)
|
|
168
|
+
session.encrypt_bytes(payload)
|
|
114
169
|
end
|
|
115
170
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
171
|
+
def encrypt_string(partition_id, text)
|
|
172
|
+
raise ArgumentError, "text cannot be nil" if text.nil?
|
|
173
|
+
encrypt(partition_id, text)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def decrypt(partition_id, data_row_record)
|
|
177
|
+
raise ArgumentError, "data_row_record cannot be nil" if data_row_record.nil?
|
|
178
|
+
session = resolve_session(partition_id)
|
|
179
|
+
session.decrypt_bytes(data_row_record).force_encoding(Encoding::UTF_8)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def decrypt_string(partition_id, data_row_record)
|
|
183
|
+
raise ArgumentError, "data_row_record cannot be nil" if data_row_record.nil?
|
|
184
|
+
decrypt(partition_id, data_row_record).force_encoding(Encoding::UTF_8)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Run encrypt in a background Thread; see `setup_async` for the
|
|
188
|
+
# exception-propagation contract.
|
|
189
|
+
def encrypt_async(partition_id, payload, &block)
|
|
190
|
+
Thread.new do
|
|
191
|
+
Thread.current.report_on_exception = true
|
|
192
|
+
result = encrypt(partition_id, payload)
|
|
193
|
+
block&.call(result)
|
|
194
|
+
result
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Run decrypt in a background Thread; see `setup_async` for the
|
|
199
|
+
# exception-propagation contract.
|
|
200
|
+
def decrypt_async(partition_id, data_row_record, &block)
|
|
201
|
+
Thread.new do
|
|
202
|
+
Thread.current.report_on_exception = true
|
|
203
|
+
result = decrypt(partition_id, data_row_record)
|
|
204
|
+
block&.call(result)
|
|
205
|
+
result
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Install a log hook. Yields a +Hash+ +{level:, target:, message:}+ for
|
|
210
|
+
# every log record emitted by the underlying Rust crates. The block may
|
|
211
|
+
# fire from any thread; implementations must be thread-safe and
|
|
212
|
+
# non-blocking. Pass +nil+ to clear (equivalent to {clear_log_hook}).
|
|
213
|
+
#
|
|
214
|
+
# Replaces any previously installed log hook. Exceptions raised from the
|
|
215
|
+
# callback are caught and silently swallowed.
|
|
216
|
+
def set_log_hook(callback = nil, &block)
|
|
217
|
+
Hooks.set_log_hook(callback, &block)
|
|
218
|
+
end
|
|
119
219
|
|
|
120
|
-
|
|
121
|
-
|
|
220
|
+
# Remove the active log hook, if any. Idempotent.
|
|
221
|
+
def clear_log_hook
|
|
222
|
+
Hooks.clear_log_hook
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Install a metrics hook. Yields a +Hash+ +{type:, duration_ns:, name:}+
|
|
226
|
+
# for every metrics event. Timing events ({:encrypt, :decrypt, :store,
|
|
227
|
+
# :load}) carry a positive +duration_ns+ and a +nil+ +name+; cache events
|
|
228
|
+
# ({:cache_hit, :cache_miss, :cache_stale}) carry +duration_ns+ == 0 and
|
|
229
|
+
# the cache identifier in +name+.
|
|
230
|
+
#
|
|
231
|
+
# Installing a hook implicitly enables the global metrics gate; clearing
|
|
232
|
+
# it disables the gate. Replaces any previously installed metrics hook.
|
|
233
|
+
# Pass +nil+ to clear (equivalent to {clear_metrics_hook}).
|
|
234
|
+
def set_metrics_hook(callback = nil, &block)
|
|
235
|
+
Hooks.set_metrics_hook(callback, &block)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Remove the active metrics hook and disable metrics. Idempotent.
|
|
239
|
+
def clear_metrics_hook
|
|
240
|
+
Hooks.clear_metrics_hook
|
|
122
241
|
end
|
|
123
242
|
|
|
124
243
|
private
|
|
125
244
|
|
|
126
|
-
|
|
127
|
-
|
|
245
|
+
REQUIRED_KEYS = %w[ServiceName ProductID Metastore].freeze
|
|
246
|
+
|
|
247
|
+
def normalize_config(config)
|
|
248
|
+
unless config.respond_to?(:each_pair)
|
|
249
|
+
raise ArgumentError, "config must be a Hash-like object"
|
|
250
|
+
end
|
|
251
|
+
normalized = {}
|
|
252
|
+
config.each_pair do |key, value|
|
|
253
|
+
normalized[String(key)] = value
|
|
254
|
+
end
|
|
255
|
+
REQUIRED_KEYS.each do |key|
|
|
256
|
+
raise ArgumentError, "#{key} is required" if normalized[key].nil? || normalized[key].to_s.strip.empty?
|
|
257
|
+
end
|
|
258
|
+
normalized
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def truthy(value, default: false)
|
|
262
|
+
return default if value.nil?
|
|
263
|
+
|
|
264
|
+
case value
|
|
265
|
+
when true, "1", "true", "TRUE", "yes", "on" then true
|
|
266
|
+
when false, "0", "false", "FALSE", "no", "off" then false
|
|
267
|
+
else
|
|
268
|
+
default
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def resolve_session(partition_id)
|
|
273
|
+
raise ArgumentError, "partition_id cannot be empty" if String(partition_id).empty?
|
|
274
|
+
|
|
275
|
+
evicted = nil
|
|
276
|
+
session = @mutex.synchronize do
|
|
277
|
+
raise Error::NotInitialized unless @initialized
|
|
278
|
+
|
|
279
|
+
unless @session_cache_enabled
|
|
280
|
+
break @factory.get_session(partition_id)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# LRU: on hit, delete + reinsert to move the entry to the
|
|
284
|
+
# most-recently-used end of the Hash (Ruby Hash is insertion-
|
|
285
|
+
# ordered). On miss + overflow, `shift` removes the oldest entry,
|
|
286
|
+
# which is the least-recently-used.
|
|
287
|
+
if (existing = @sessions.delete(partition_id))
|
|
288
|
+
@sessions[partition_id] = existing
|
|
289
|
+
break existing
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
fresh = @factory.get_session(partition_id)
|
|
293
|
+
@sessions[partition_id] = fresh
|
|
294
|
+
if @sessions.size > @session_cache_max_size
|
|
295
|
+
_, evicted = @sessions.shift
|
|
296
|
+
end
|
|
297
|
+
fresh
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Close evicted session outside the lock — close hits the FFI and
|
|
301
|
+
# we don't want to serialize all encrypts behind one eviction.
|
|
302
|
+
if evicted
|
|
303
|
+
begin
|
|
304
|
+
evicted.close unless evicted.closed?
|
|
305
|
+
rescue StandardError => e
|
|
306
|
+
warn "asherah: error closing evicted session: #{e.message}"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
session
|
|
311
|
+
end
|
|
128
312
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
313
|
+
def positive_int(value)
|
|
314
|
+
return nil if value.nil?
|
|
315
|
+
n = Integer(value)
|
|
316
|
+
n.positive? ? n : nil
|
|
317
|
+
rescue ArgumentError, TypeError
|
|
318
|
+
nil
|
|
133
319
|
end
|
|
134
320
|
end
|
|
135
321
|
end
|