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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8e2ce61ef519439f64e0b27b4faef3ed6cacbdb7176d5e20df379bf96c5e6df
4
- data.tar.gz: 0bcb2c49582f27af5cf0d4202e93a33b353d098d8261a1e636546b95c16dc401
3
+ metadata.gz: 31eda555f1f9ab86b17f12a4bff59bdcaa08b886c7ccbfba9e5fb341b73d3c1c
4
+ data.tar.gz: 0e85ad61fc1c9b192cd1d1fe670cfb6dc4feec26b174acc3e83de808fa74255a
5
5
  SHA512:
6
- metadata.gz: 505671ed110011fc6dffa187f16cb16e41fe8e72afb151c7ca311e802828bff64dffde0df94a16ff6e52aa193b963ee5768091fbcbb09d28c0ac7174e928dcfa
7
- data.tar.gz: 188e278c8e03c4621004040283d0b268aec02a6a0ea8e0c9dea6dd60dae94e0ded4ba37fc5b1c83c25981e0edda21fdfd3282476ea95c33c0051998bd2a22548
6
+ metadata.gz: 52e79b34969c3c5ff56e844d9a471ee4db1db13fa0b729f5b68be0d8a27b016a9513f0e731b6073dca0fd3a8dd6db964441b1b48fc8b4e2084e64c8075777fd6
7
+ data.tar.gz: cbb446aeffbf134ce576c49646e5c63edcf5b0b64a690e2fab93f0f8f578d592344167fc35427f9ce2a80cb762e1049bfd049e0a90b4a2c5c95e12f6efac78ac
data/NATIVE_VERSION ADDED
@@ -0,0 +1 @@
1
+ v0.6.109
data/README.md CHANGED
@@ -1,106 +1,386 @@
1
- # Asherah
1
+ # asherah
2
2
 
3
- Asherah is a Ruby FFI wrapper around the Rust version of [Asherah](https://github.com/godaddy/asherah-ffi) application-layer encryption SDK. Asherah provides advanced encryption features and defense in depth against compromise. It uses a technique known as "envelope encryption" and supports cloud-agnostic data storage and key management.
3
+ Ruby bindings for [Asherah](https://github.com/godaddy/asherah-ffi) envelope encryption with automatic key rotation.
4
4
 
5
- Check out the following documentation to get more familiar with the concepts and configuration options:
6
-
7
- - [Design and Architecture](https://github.com/godaddy/asherah/blob/master/docs/DesignAndArchitecture.md)
8
- - [Key Caching](https://github.com/godaddy/asherah/blob/master/docs/KeyCaching.md)
9
- - [Key Management Service](https://github.com/godaddy/asherah/blob/master/docs/KeyManagementService.md)
10
- - [Metastore](https://github.com/godaddy/asherah/blob/master/docs/Metastore.md)
11
- - [System Requirements](https://github.com/godaddy/asherah/blob/master/docs/SystemRequirements.md)
12
-
13
- ## Supported Platforms
14
-
15
- Currently supported platforms are Linux and Darwin operating systems for x64 and arm64 CPU architectures.
5
+ Published to [RubyGems](https://rubygems.org/gems/asherah) with prebuilt native libraries for Linux x64/ARM64 (glibc and musl/Alpine) and macOS x64/ARM64. A fallback source gem is available for other platforms (requires the Rust toolchain to compile).
16
6
 
17
7
  ## Installation
18
8
 
19
- Add this line to your application's Gemfile:
9
+ ```bash
10
+ gem install asherah
11
+ ```
12
+
13
+ Or add to your Gemfile:
20
14
 
21
15
  ```ruby
22
16
  gem 'asherah'
23
17
  ```
24
18
 
25
- ```bash
26
- bundle install
27
- ```
19
+ The gem uses FFI to load the native Asherah library. Platform-specific gems ship the prebuilt library; the source gem builds it during installation.
28
20
 
29
- Or install it yourself as:
21
+ ## Documentation
30
22
 
31
- ```bash
32
- gem install asherah
23
+ Task-oriented walkthroughs under [`docs/`](./docs/):
24
+
25
+ | Guide | When to read |
26
+ |---|---|
27
+ | [Getting started](./docs/getting-started.md) | `gem install` through round-trip encrypt/decrypt. |
28
+ | [Framework integration](./docs/framework-integration.md) | Rails, Sidekiq, Sinatra, Rack middleware, AWS Lambda. |
29
+ | [AWS production setup](./docs/aws-production-setup.md) | KMS keys, DynamoDB, IAM policy, region routing. |
30
+ | [Testing](./docs/testing.md) | RSpec/Minitest fixtures, Testcontainers, mocking patterns. |
31
+ | [Troubleshooting](./docs/troubleshooting.md) | Common errors with what to check first. |
32
+
33
+ ## Quick Start
34
+
35
+ The simplest way to use Asherah is the static module API. Call `setup` once at startup and `shutdown` on exit:
36
+
37
+ ```ruby
38
+ require "asherah"
39
+
40
+ Asherah.setup(
41
+ "ServiceName" => "my-service",
42
+ "ProductID" => "my-product",
43
+ "Metastore" => "memory", # testing only
44
+ "KMS" => "static" # testing only
45
+ )
46
+
47
+ ciphertext = Asherah.encrypt_string("partition-id", "sensitive data")
48
+ plaintext = Asherah.decrypt_string("partition-id", ciphertext)
49
+
50
+ Asherah.shutdown
33
51
  ```
34
52
 
35
- ## Usage
53
+ The static API manages a session cache internally. Sessions are created on first use per partition and reused for subsequent calls.
54
+
55
+ ### Block-style configuration
36
56
 
37
- Configure Asherah:
57
+ For an API compatible with the canonical GoDaddy Asherah Ruby gem, use `configure` with a block:
38
58
 
39
59
  ```ruby
40
60
  Asherah.configure do |config|
41
- config.kms = 'static'
42
- config.metastore = 'memory'
43
- config.service_name = 'service'
44
- config.product_id = 'product'
61
+ config.service_name = "my-service"
62
+ config.product_id = "my-product"
63
+ config.kms = "static" # testing only
64
+ config.metastore = "memory" # testing only
45
65
  end
66
+
67
+ ciphertext = Asherah.encrypt_string("partition-id", "sensitive data")
68
+ plaintext = Asherah.decrypt_string("partition-id", ciphertext)
69
+
70
+ Asherah.shutdown
46
71
  ```
47
72
 
48
- See [config.rb](lib/asherah/config.rb) for all available configuration options.
73
+ ## Session-Based API
49
74
 
50
- Encrypt some data for a `partition_id`
75
+ For direct control over session lifecycle, use `SessionFactory` and `Session`:
51
76
 
52
77
  ```ruby
53
- partition_id = 'user_1'
54
- data = 'PII data'
55
- data_row_record_json = Asherah.encrypt(partition_id, data)
56
- puts data_row_record_json
78
+ require "asherah"
79
+
80
+ Asherah.configure do |config|
81
+ config.service_name = "my-service"
82
+ config.product_id = "my-product"
83
+ config.kms = "static" # testing only
84
+ config.metastore = "memory" # testing only
85
+ end
86
+
87
+ factory = Asherah::SessionFactory.new(
88
+ Asherah::Native.asherah_factory_new_with_config(config_json)
89
+ )
90
+ session = factory.get_session("partition-id")
91
+
92
+ ciphertext = session.encrypt_bytes("sensitive data")
93
+ plaintext = session.decrypt_bytes(ciphertext)
94
+
95
+ session.close
96
+ factory.close
57
97
  ```
58
98
 
59
- Decrypt `data_row_record_json`
99
+ Or via the static API's internal factory (the typical pattern):
60
100
 
61
101
  ```ruby
62
- decrypted_data = Asherah.decrypt(partition_id, data_row_record_json)
63
- puts decrypted_data
64
- ```
102
+ Asherah.setup("ServiceName" => "my-service", "ProductID" => "my-product",
103
+ "Metastore" => "memory", "KMS" => "static") # testing only
65
104
 
66
- ## Development
105
+ # The static API acquires and caches sessions automatically
106
+ ct = Asherah.encrypt("partition-id", "data")
107
+ pt = Asherah.decrypt("partition-id", ct)
67
108
 
68
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
109
+ Asherah.shutdown
110
+ ```
69
111
 
70
- For tests requiring secrets (AWS KMS, database credentials), copy `.env.secrets.example` to `.env.secrets` and fill in the required values. The `.env.secrets` file is already in `.gitignore` to prevent accidental commits.
112
+ ## Async API
71
113
 
72
- ### Cross-Language Tests
114
+ ### Session-level async (true async via Rust tokio)
73
115
 
74
- Cross-language tests verify that data encrypted with the Rust implementation can be decrypted with the Ruby implementation and vice versa.
116
+ The session's async methods dispatch work to Rust's tokio runtime and receive results via FFI callbacks:
75
117
 
76
- **Prerequisites:**
77
- - MySQL running locally
78
- - Go 1.24+ installed
118
+ ```ruby
119
+ session = factory.get_session("partition-id")
79
120
 
80
- **Running the tests:**
121
+ ct = session.encrypt_bytes_async(data)
122
+ pt = session.decrypt_bytes_async(ct)
81
123
 
82
- ```bash
83
- TEST_DB_PASSWORD=pass bin/cross-language-test.sh
124
+ session.close
84
125
  ```
85
126
 
86
- See `bin/cross-language-test.sh` for available environment variables and their defaults.
127
+ ### Static-level async (thread-based)
128
+
129
+ The static API's async methods run in a Ruby `Thread`:
130
+
131
+ ```ruby
132
+ thread = Asherah.encrypt_async("partition-id", data) do |result|
133
+ puts "Encrypted: #{result.bytesize} bytes"
134
+ end
135
+ thread.join
136
+ ```
87
137
 
88
- To install this gem onto your local machine, run `rake install`.
138
+ ### Async Behavior
139
+
140
+ The session-level async methods (`encrypt_bytes_async`, `decrypt_bytes_async`) are true async. The encrypt/decrypt work runs on Rust's tokio worker threads and completes via an FFI callback. The Ruby interpreter is NOT blocked during the native call.
141
+
142
+ However, the implementation uses `Queue#pop` to synchronize the callback result back to the calling Ruby thread. This means `queue.pop` blocks the calling Ruby thread until the result arrives. True concurrency requires multiple Ruby threads or Ractors dispatching async calls in parallel.
143
+
144
+ If the FFI callback never fires (e.g. the worker pool deadlocks), the
145
+ async call raises `Asherah::Error::Timeout` after 30 seconds rather
146
+ than blocking indefinitely. Override the bound by setting the
147
+ `ASHERAH_RUBY_ASYNC_TIMEOUT` environment variable (in seconds) before
148
+ the gem is loaded.
149
+
150
+ The static-level async methods (`Asherah.encrypt_async`, `Asherah.decrypt_async`) simply run the sync operation in a new `Thread`.
151
+
152
+ ## Input contract
153
+
154
+ **Partition ID** (`nil`, `""`): always rejected as programming errors
155
+ with `ArgumentError` (`"partition_id cannot be empty"`). No row is ever
156
+ written to the metastore under a degenerate partition ID.
157
+
158
+ **Plaintext** to encrypt:
159
+ - `nil` → `ArgumentError` from explicit guards in the public API before
160
+ any FFI call.
161
+ - Empty `String` (`""`) and empty bytes (`"".b`) are **valid**
162
+ plaintexts. `Asherah.encrypt_string` / `session.encrypt_bytes`
163
+ produce a real `DataRowRecord` envelope; the matching decrypt returns
164
+ exactly `""` or empty bytes.
165
+
166
+ **Ciphertext** to decrypt:
167
+ - `nil` → `ArgumentError`.
168
+ - Empty `String` → `Asherah::Error::DecryptFailed` (not valid
169
+ `DataRowRecord` JSON).
170
+
171
+ **Do not short-circuit empty plaintext encryption in caller code** —
172
+ empty data is real data, encrypting it produces a genuine envelope, and
173
+ skipping encryption leaks the fact that the value was empty. See
174
+ [docs/input-contract.md](../docs/input-contract.md) for the full
175
+ rationale.
176
+
177
+ ## Migration from Canonical Ruby SDK
178
+
179
+ This replaces the original `asherah` gem which was built on Go via Cobhan FFI. The API is drop-in compatible:
180
+
181
+ | | Canonical (Go/Cobhan) | This binding (Rust/FFI) |
182
+ |---|---|---|
183
+ | Implementation | Go + Cobhan FFI | Rust + Ruby FFI gem |
184
+ | `Asherah.configure` | Supported | Supported (same API) |
185
+ | `Asherah.encrypt` / `decrypt` | Supported | Supported (same API) |
186
+ | `SessionFactory` | Supported | Supported (same API) |
187
+ | Memory protection | None | memguard (locked, wiped pages) |
188
+ | Async support | None | Session-level true async |
189
+
190
+ Migration steps:
191
+ 1. Update the `asherah` gem version in your Gemfile
192
+ 2. No code changes required -- the API is compatible
193
+ 3. Both read the same metastore tables -- no data migration required
194
+
195
+ ## Performance
196
+
197
+ Benchmarked on Apple M4 Max, 64-byte payload, hot session cache:
198
+
199
+ | Operation | Latency |
200
+ |---|---|
201
+ | Encrypt | ~1,170 ns |
202
+ | Decrypt | ~1,110 ns |
203
+
204
+ ## Configuration
205
+
206
+ ### `setup` (hash style)
207
+
208
+ Keys are PascalCase strings matching the Asherah configuration format:
209
+
210
+ | Key | Type | Required | Description |
211
+ |---|---|---|---|
212
+ | `ServiceName` | `String` | Yes | Service identifier for key hierarchy |
213
+ | `ProductID` | `String` | Yes | Product identifier for key hierarchy |
214
+ | `Metastore` | `String` | Yes | `"rdbms"`, `"dynamodb"`, `"memory"` (testing) |
215
+ | `KMS` | `String` | Yes | `"static"` or `"aws"` |
216
+ | `ConnectionString` | `String` | No | RDBMS connection string |
217
+ | `DynamoDBEndpoint` | `String` | No | Custom DynamoDB endpoint |
218
+ | `DynamoDBRegion` | `String` | No | DynamoDB region — drives endpoint URL resolution and (when `DynamoDBSigningRegion` is unset) SigV4 signing |
219
+ | `DynamoDBSigningRegion` | `String` | No | SigV4 signing region. When set distinct from `DynamoDBRegion`, the URL is built from `DynamoDBRegion` but SigV4 signs as `DynamoDBSigningRegion` |
220
+ | `DynamoDBTableName` | `String` | No | DynamoDB table name |
221
+ | `RegionMap` | `Hash` | No | AWS KMS region-to-ARN map |
222
+ | `PreferredRegion` | `String` | No | Preferred AWS KMS region |
223
+ | `AwsProfileName` | `String` | No | AWS shared-credentials profile for KMS, DynamoDB, and Secrets Manager clients (native Rust SDK) |
224
+ | `EnableRegionSuffix` | `Boolean` | No | Append region suffix to key IDs |
225
+ | `EnableSessionCaching` | `Boolean` | No | Enable session caching (default: true) |
226
+ | `SessionCacheMaxSize` | `Integer` | No | Max cached sessions |
227
+ | `SessionCacheDuration` | `Integer` | No | Cache TTL in milliseconds |
228
+ | `ExpireAfter` | `Integer` | No | Key expiration in seconds |
229
+ | `CheckInterval` | `Integer` | No | Key check interval in seconds |
230
+ | `Verbose` | `Boolean` | No | Enable verbose logging (default: false) |
231
+ | `PoolMaxOpen` | `Integer` | No | Max open DB connections (default: 0 = unlimited) |
232
+ | `PoolMaxIdle` | `Integer` | No | Max idle connections to retain (default: 2) |
233
+ | `PoolMaxLifetime` | `Integer` | No | Max connection lifetime in seconds (default: 0 = unlimited) |
234
+ | `PoolMaxIdleTime` | `Integer` | No | Max idle time per connection in seconds (default: 0 = unlimited) |
235
+
236
+ For AWS KMS, DynamoDB, or Secrets Manager, when `AwsProfileName` is omitted the native Rust credential chain applies (including `AWS_PROFILE` and shared config under `~/.aws/`). Setting `AwsProfileName` / `aws_profile_name` selects a named profile explicitly.
237
+
238
+ ### `configure` (block style)
239
+
240
+ Uses snake_case attribute accessors:
241
+
242
+ | Attribute | Maps to |
243
+ |---|---|
244
+ | `service_name` | `ServiceName` |
245
+ | `product_id` | `ProductID` |
246
+ | `metastore` | `Metastore` |
247
+ | `kms` | `KMS` |
248
+ | `connection_string` | `ConnectionString` |
249
+ | `dynamo_db_endpoint` | `DynamoDBEndpoint` |
250
+ | `dynamo_db_region` | `DynamoDBRegion` |
251
+ | `dynamo_db_signing_region` | `DynamoDBSigningRegion` |
252
+ | `dynamo_db_table_name` | `DynamoDBTableName` |
253
+ | `region_map` | `RegionMap` |
254
+ | `preferred_region` | `PreferredRegion` |
255
+ | `aws_profile_name` | `AwsProfileName` |
256
+ | `enable_region_suffix` | `EnableRegionSuffix` |
257
+ | `enable_session_caching` | `EnableSessionCaching` |
258
+ | `session_cache_max_size` | `SessionCacheMaxSize` |
259
+ | `session_cache_duration` | `SessionCacheDuration` |
260
+ | `expire_after` | `ExpireAfter` |
261
+ | `check_interval` | `CheckInterval` |
262
+ | `verbose` | `Verbose` |
263
+ | `pool_max_open` | `PoolMaxOpen` |
264
+ | `pool_max_idle` | `PoolMaxIdle` |
265
+ | `pool_max_lifetime` | `PoolMaxLifetime` |
266
+ | `pool_max_idle_time` | `PoolMaxIdleTime` |
267
+
268
+ ## API Reference
269
+
270
+ ### `Asherah` (module-level static API)
271
+
272
+ | Method | Description |
273
+ |---|---|
274
+ | `setup(config_hash)` | Initialize with PascalCase config hash |
275
+ | `configure { \|c\| ... }` | Initialize with block-style snake_case config |
276
+ | `setup_async(config_hash, &block)` | Async `setup` in a Thread |
277
+ | `shutdown` | Release all resources and cached sessions |
278
+ | `shutdown_async(&block)` | Async `shutdown` in a Thread |
279
+ | `get_setup_status` | Returns `true` if initialized |
280
+ | `encrypt(partition, data)` | Encrypt bytes, returns DRR JSON bytes |
281
+ | `encrypt_string(partition, text)` | Encrypt string, returns DRR JSON string |
282
+ | `encrypt_async(partition, data, &block)` | Encrypt in a Thread |
283
+ | `decrypt(partition, drr)` | Decrypt DRR JSON bytes to plaintext |
284
+ | `decrypt_string(partition, drr)` | Decrypt DRR JSON string to plaintext string |
285
+ | `decrypt_async(partition, drr, &block)` | Decrypt in a Thread |
286
+ | `setenv(hash)` / `set_env(hash)` | Set environment variables |
287
+
288
+ ### `Asherah::SessionFactory`
289
+
290
+ | Method | Description |
291
+ |---|---|
292
+ | `get_session(partition_id)` | Create a session for a partition |
293
+ | `close` | Release the factory |
294
+ | `closed?` | Returns `true` if closed |
295
+
296
+ ### `Asherah::Session`
297
+
298
+ | Method | Description |
299
+ |---|---|
300
+ | `encrypt_bytes(data)` | Encrypt bytes, returns DRR JSON bytes |
301
+ | `decrypt_bytes(json)` | Decrypt DRR JSON bytes to plaintext bytes |
302
+ | `encrypt_bytes_async(data)` | True async encrypt via Rust tokio |
303
+ | `decrypt_bytes_async(json)` | True async decrypt via Rust tokio |
304
+ | `close` | Release the session |
305
+ | `closed?` | Returns `true` if closed |
306
+
307
+ ## Observability hooks
308
+
309
+ ### Log hook
310
+
311
+ Asherah ships first-class stdlib `Logger` integration. The simplest way to
312
+ wire up logging is to hand it any Logger-compatible instance — stdlib
313
+ `Logger`, `ActiveSupport::Logger`, `SemanticLogger`, `Ougai`, etc. — and the
314
+ bridge dispatches each record via `Logger#add(severity, message, target)`
315
+ so the logger's own filter rules and formatters apply.
89
316
 
90
- To release a new version, update the version number in `version.rb`, create and push a version tag:
317
+ ```ruby
318
+ require "logger"
319
+ log = Logger.new($stdout)
320
+ log.level = Logger::WARN
321
+ Asherah.set_log_hook(log)
91
322
 
323
+ # ...later
324
+ Asherah.clear_log_hook
92
325
  ```
93
- git tag -a v$(rake version) -m "Version $(rake version)"
94
- git push origin v$(rake version)
326
+
327
+ For raw access pass a block; the event is a `Hash` with both a
328
+ `Logger::Severity` integer and a matching lowercase symbol:
329
+
330
+ ```ruby
331
+ Asherah.set_log_hook do |event|
332
+ # event[:severity] => Logger::DEBUG | INFO | WARN | ERROR
333
+ # event[:level] => :debug | :info | :warn | :error (symbol, for case dispatch)
334
+ # event[:target] => "asherah::session"
335
+ # event[:message] => "..."
336
+ next if event[:severity] < Logger::WARN
337
+ warn "[asherah #{event[:level]}] #{event[:target]}: #{event[:message]}"
338
+ end
95
339
  ```
96
340
 
97
- And then create a release in Github with title `echo "Version $(rake version)"` that will trigger `.github/workflows/publish.yml` workflow and push the `.gem` file to [rubygems.org](https://rubygems.org):
341
+ The Rust `log` crate has a TRACE level that stdlib `Logger` does not; Asherah
342
+ maps `trace` records to `Logger::DEBUG` so the value is still meaningful.
343
+ The block may fire from any thread (Rust tokio worker threads, DB driver
344
+ threads), so implementations must be thread-safe and should not block.
345
+ Exceptions raised from the callback are caught and silently swallowed —
346
+ propagating an exception across the FFI boundary is undefined behavior.
98
347
 
348
+ ### Metrics hook
349
+
350
+ Receive timing observations (`:encrypt`, `:decrypt`, `:store`, `:load`) and
351
+ cache events (`:cache_hit`, `:cache_miss`, `:cache_stale`) via
352
+ `Asherah.set_metrics_hook`. Installing a hook implicitly enables the global
353
+ metrics gate; clearing it disables the gate.
354
+
355
+ ```ruby
356
+ Asherah.set_metrics_hook do |event|
357
+ case event[:type]
358
+ when :encrypt, :decrypt, :store, :load
359
+ # event[:duration_ns] is the elapsed time in nanoseconds, event[:name] is nil
360
+ Statsd.timing("asherah.#{event[:type]}", event[:duration_ns] / 1_000_000.0)
361
+ when :cache_hit, :cache_miss, :cache_stale
362
+ # event[:name] is the cache identifier, event[:duration_ns] is 0
363
+ Statsd.increment("asherah.#{event[:type]}.#{event[:name]}")
364
+ end
365
+ end
366
+
367
+ # ...later
368
+ Asherah.clear_metrics_hook
369
+ ```
99
370
 
100
- ## Contributing
371
+ | Event type | `:duration_ns` | `:name` |
372
+ |---|---|---|
373
+ | `:encrypt` | elapsed ns | `nil` |
374
+ | `:decrypt` | elapsed ns | `nil` |
375
+ | `:store` | elapsed ns | `nil` |
376
+ | `:load` | elapsed ns | `nil` |
377
+ | `:cache_hit` | `0` | cache identifier |
378
+ | `:cache_miss` | `0` | cache identifier |
379
+ | `:cache_stale` | `0` | cache identifier |
101
380
 
102
- Bug reports and pull requests are welcome on GitHub at https://github.com/godaddy/asherah-ruby.
381
+ The same threading caveats apply as for the log hook — implementations must
382
+ be thread-safe and non-blocking, and exceptions are caught.
103
383
 
104
384
  ## License
105
385
 
106
- The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
386
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,2 @@
1
+ /* No-op C extension — native library is downloaded by fetch_native.rb */
2
+ void Init_asherah(void) {}
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'mkmf'
4
- create_makefile('asherah/asherah')
3
+ require "mkmf"
5
4
 
6
- require_relative 'native_file'
7
- NativeFile.download
5
+ # Create a no-op Makefile (we don't compile C; we download a prebuilt binary)
6
+ create_makefile("asherah/asherah")
7
+
8
+ require_relative "fetch_native"
9
+ AsherahFetchNative.download
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+ require "fileutils"
5
+ require "digest"
6
+ require "rbconfig"
7
+
8
+ # Downloads the prebuilt native library for the current platform from
9
+ # GitHub Releases during `gem install` (fallback gem only — platform gems
10
+ # ship the binary directly and never run this).
11
+ module AsherahFetchNative
12
+ REPO = "godaddy/asherah-ffi"
13
+ MAX_ATTEMPTS = 3
14
+ RETRY_DELAY = 5 # seconds, doubles each retry
15
+ ROOT_DIR = File.expand_path("../../", __dir__)
16
+ NATIVE_DIR = File.join(ROOT_DIR, "lib", "asherah", "native")
17
+
18
+ # Map Ruby platform identifiers to our release asset names.
19
+ # Keys: [os, cpu] from RbConfig. Values: [asset_name, local_name].
20
+ PLATFORM_MAP = {
21
+ ["linux", "x86_64"] => ["libasherah-x64.so", "libasherah_ffi.so"],
22
+ ["linux", "aarch64"] => ["libasherah-arm64.so", "libasherah_ffi.so"],
23
+ ["linux-musl", "x86_64"] => ["libasherah-x64-musl.so", "libasherah_ffi.so"],
24
+ ["linux-musl", "aarch64"] => ["libasherah-arm64-musl.so", "libasherah_ffi.so"],
25
+ ["darwin", "x86_64"] => ["libasherah-x64.dylib", "libasherah_ffi.dylib"],
26
+ ["darwin", "arm64"] => ["libasherah-arm64.dylib", "libasherah_ffi.dylib"],
27
+ ["mingw", "x86_64"] => ["libasherah-x64.dll", "asherah_ffi.dll"],
28
+ ["mingw", "aarch64"] => ["libasherah-arm64.dll", "asherah_ffi.dll"],
29
+ }.freeze
30
+
31
+ class << self
32
+ def download
33
+ asset_name, local_name = resolve_platform
34
+ dest = File.join(NATIVE_DIR, local_name)
35
+
36
+ if File.exist?(dest)
37
+ puts "#{dest} already exists, skipping download"
38
+ return
39
+ end
40
+
41
+ version = resolve_version
42
+ url = "https://github.com/#{REPO}/releases/download/#{version}/#{asset_name}"
43
+
44
+ puts "Downloading native library: #{url}"
45
+ content = download_with_retry(url)
46
+
47
+ if content.bytesize < 1024
48
+ abort "ERROR: Downloaded file is too small (#{content.bytesize} bytes) — likely a 404 or error page"
49
+ end
50
+
51
+ verify_checksum(content, asset_name, version)
52
+
53
+ FileUtils.mkdir_p(NATIVE_DIR)
54
+ File.binwrite(dest, content)
55
+ File.chmod(0o755, dest) unless Gem.win_platform?
56
+ puts "Installed native library: #{dest} (#{content.bytesize} bytes)"
57
+ end
58
+
59
+ private
60
+
61
+ def resolve_platform
62
+ host_os = RbConfig::CONFIG["host_os"]
63
+ host_cpu = RbConfig::CONFIG["host_cpu"]
64
+
65
+ os = case host_os
66
+ when /linux.*musl/ then "linux-musl"
67
+ when /linux/ then musl_libc? ? "linux-musl" : "linux"
68
+ when /darwin/ then "darwin"
69
+ when /mswin|mingw/ then "mingw"
70
+ else host_os
71
+ end
72
+
73
+ cpu = case host_cpu
74
+ when /x86_64|x64|amd64/ then "x86_64"
75
+ when /aarch64|arm64/ then os == "darwin" ? "arm64" : "aarch64"
76
+ else host_cpu
77
+ end
78
+
79
+ key = [os, cpu]
80
+ result = PLATFORM_MAP[key]
81
+ abort "ERROR: Unsupported platform #{os}-#{cpu} (#{RUBY_PLATFORM})" unless result
82
+ result
83
+ end
84
+
85
+ # True when running on musl libc (e.g. Alpine Linux). Modern Ruby on
86
+ # Alpine reports host_os=linux-musl directly, but older builds may
87
+ # report just "linux" — fall back to inspecting RUBY_PLATFORM and
88
+ # the dynamic loader.
89
+ def musl_libc?
90
+ return true if RUBY_PLATFORM.include?("musl")
91
+ return true if File.exist?("/lib/ld-musl-x86_64.so.1") || File.exist?("/lib/ld-musl-aarch64.so.1")
92
+ false
93
+ end
94
+
95
+ def resolve_version
96
+ # NATIVE_VERSION is stamped into published fallback gems by the publish
97
+ # workflow with the release tag (e.g. "v0.6.73"). It is not committed
98
+ # to the repo — the gem version and the native binary version are
99
+ # intentionally decoupled.
100
+ # Environment variable override: NATIVE_VERSION=v0.6.22 bundle install
101
+ env_version = ENV["NATIVE_VERSION"]
102
+ if env_version && !env_version.strip.empty?
103
+ tag = env_version.strip
104
+ tag = "v#{tag}" unless tag.start_with?("v")
105
+ puts "Using native version from environment: #{tag}"
106
+ return tag
107
+ end
108
+
109
+ # Published fallback gems have NATIVE_VERSION stamped by the publish workflow
110
+ native_version_file = File.join(ROOT_DIR, "NATIVE_VERSION")
111
+ if File.exist?(native_version_file)
112
+ tag = File.read(native_version_file).strip
113
+ unless tag.empty?
114
+ puts "Using native version: #{tag}"
115
+ return tag
116
+ end
117
+ end
118
+
119
+ abort <<~MSG
120
+ ERROR: Native binary version not specified.
121
+
122
+ Set the NATIVE_VERSION environment variable to the release tag:
123
+ NATIVE_VERSION=0.6.73 bundle install
124
+
125
+ Or create a NATIVE_VERSION file in the asherah-ruby directory:
126
+ echo "v0.6.73" > asherah-ruby/NATIVE_VERSION
127
+
128
+ Available releases: https://github.com/#{REPO}/releases
129
+ MSG
130
+ end
131
+
132
+ def download_with_retry(url)
133
+ attempt = 0
134
+ delay = RETRY_DELAY
135
+
136
+ loop do
137
+ attempt += 1
138
+ begin
139
+ return URI.parse(url).open(
140
+ "Accept" => "application/octet-stream",
141
+ redirect: true,
142
+ read_timeout: 60,
143
+ open_timeout: 30
144
+ ).read
145
+ rescue OpenURI::HTTPError => e
146
+ if e.message.include?("404")
147
+ abort "ERROR: Release asset not found at #{url}\n" \
148
+ " Ensure the release exists and includes this platform's binary."
149
+ end
150
+ raise unless attempt < MAX_ATTEMPTS
151
+
152
+ puts "Download failed (attempt #{attempt}/#{MAX_ATTEMPTS}): #{e.message}"
153
+ puts "Retrying in #{delay}s..."
154
+ sleep delay
155
+ delay *= 2
156
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED => e
157
+ raise unless attempt < MAX_ATTEMPTS
158
+
159
+ puts "Download failed (attempt #{attempt}/#{MAX_ATTEMPTS}): #{e.class}: #{e.message}"
160
+ puts "Retrying in #{delay}s..."
161
+ sleep delay
162
+ delay *= 2
163
+ end
164
+ end
165
+ end
166
+
167
+ def verify_checksum(content, asset_name, version)
168
+ sums_url = "https://github.com/#{REPO}/releases/download/#{version}/SHA256SUMS"
169
+ begin
170
+ sums = URI.parse(sums_url).open(read_timeout: 15, open_timeout: 10).read
171
+ expected = nil
172
+ sums.each_line do |line|
173
+ hash, name = line.strip.split(/\s+/, 2)
174
+ if name == asset_name
175
+ expected = hash
176
+ break
177
+ end
178
+ end
179
+
180
+ if expected
181
+ actual = Digest::SHA256.hexdigest(content)
182
+ if actual != expected
183
+ abort "ERROR: SHA256 checksum mismatch for #{asset_name}\n" \
184
+ " Expected: #{expected}\n" \
185
+ " Actual: #{actual}"
186
+ end
187
+ puts "SHA256 checksum verified: #{actual}"
188
+ else
189
+ puts "WARNING: No checksum found for #{asset_name} in SHA256SUMS"
190
+ end
191
+ rescue OpenURI::HTTPError, Net::OpenTimeout, Net::ReadTimeout => e
192
+ puts "WARNING: Could not verify checksum (#{e.class}: #{e.message})"
193
+ end
194
+ end
195
+ end
196
+ end