capybara-simulated 0.7.0 → 0.8.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.
@@ -122,16 +122,31 @@ module Capybara
122
122
  self
123
123
  end
124
124
 
125
- def scroll_to(*, **) ; self ; end
125
+ # Capybara's driver `scroll_to` contract (from Element#scroll_to): a target element + align
126
+ # symbol, a position symbol (`:top`/`:bottom`/`:center`), or an `[x, y]` coordinate pair.
127
+ # `self` is the element it was called on — the document root for a session-level scroll (routed
128
+ # via `find('/html')`), else an element. Drives a real scroll offset in the layout engine.
129
+ def scroll_to(target = nil, arg = nil, coords = nil)
130
+ check_stale
131
+ if target
132
+ browser.scroll_to(handle_id, target.handle_id, arg)
133
+ elsif coords
134
+ browser.scroll_to(handle_id, nil, nil, coords[0], coords[1])
135
+ elsif arg
136
+ browser.scroll_to(handle_id, nil, arg)
137
+ end
138
+ self
139
+ end
126
140
 
127
141
  # Capybara's standard rect API. No layout engine — but
128
- # Discourse's `wait_for_animation` helper polls `element.rect[:x]`
129
- # twice and waits for the values to stabilise, which they
130
- # immediately do here (constant zeros) since we never animate.
131
- # That unblocks every test guarded by an animation settle.
142
+ # The element's coarse border-box (viewport-relative), from the layout engine — backs the
143
+ # spatial selectors (`:above`/`:below`/`:near`) and coordinate drag. Deterministic per layout
144
+ # generation (memoised; only a DOM/style mutation changes it), so Discourse's
145
+ # `wait_for_animation` which polls `element.rect[:x]` twice and waits for it to stabilise —
146
+ # still settles immediately when nothing is animating.
132
147
  def rect
133
148
  check_stale
134
- {x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0}
149
+ browser.rect(handle_id)
135
150
  end
136
151
 
137
152
  def send_keys(*keys)
@@ -195,7 +210,10 @@ module Capybara
195
210
  def selected? = browser.option_selected?(handle_id)
196
211
  def checked? = !!self['checked']
197
212
  def readonly? = !!self['readonly']
198
- def obscured?(*) = !visible?
213
+ def obscured?(*)
214
+ check_stale
215
+ browser.obscured?(handle_id)
216
+ end
199
217
  def synchronize(*) = yield
200
218
  def style(names = [])
201
219
  check_stale
@@ -13,6 +13,23 @@
13
13
 
14
14
  require 'digest'
15
15
  require 'quickjs'
16
+ begin
17
+ # Intl moved out of the quickjs gem in 0.19. bridge.js patches Intl.DateTimeFormat, so the
18
+ # companion gem is required for the QuickJS engine — say so plainly rather than letting a bare
19
+ # LoadError name a file the user never asked for.
20
+ require 'quickjs-polyfill-intl/datetimeformat'
21
+ rescue LoadError
22
+ raise LoadError, <<~MSG
23
+ capybara-simulated's QuickJS engine needs the `quickjs-polyfill-intl` gem
24
+ (quickjs 0.19 moved Intl out of the core gem, and the DOM bridge uses
25
+ Intl.DateTimeFormat). Add it next to quickjs:
26
+
27
+ gem 'quickjs', '>= 0.19'
28
+ gem 'quickjs-polyfill-intl'
29
+
30
+ Or use the V8 engine (`gem 'rusty_racer'`), which has ICU built in.
31
+ MSG
32
+ end
16
33
 
17
34
  require_relative 'runtime_shared'
18
35
  require_relative 'worker_runtime'
@@ -150,6 +167,19 @@ module Capybara
150
167
  normalize(result)
151
168
  end
152
169
 
170
+ # QuickJS has no per-frame realms (iframes share the one VM): `within_frame` falls back to
171
+ # the same realm, and any realm id routes to the single global context.
172
+ def supports_frames? = false
173
+
174
+ def realm_call(_realm_id, name, *args) = call(name, *args)
175
+
176
+ # No per-frame realms, so no realm is ever "alive" as a distinct browsing context —
177
+ # every client id resolves to 'client-window' (the single global). Defined so a
178
+ # caller that gates realm routing on it (deliver_worker_messages) works on both engines.
179
+ def frame_realm_alive?(_realm_id) = false
180
+
181
+ def frame_realm_ids = []
182
+
153
183
  # bridge.js owns the virtual clock; we drive it from Ruby because
154
184
  # Capybara's polling cadence is wall-clock-anchored.
155
185
  def drain_timers(max_ms = nil)
@@ -230,14 +260,17 @@ module Capybara
230
260
  # URL / TextEncoder / atob/btoa / crypto — already route through Ruby host fns,
231
261
  # so POLYFILL_INTL is the only one we strictly need).
232
262
  #
233
- # PERF (rule 3): quickjs is pinned to `~> 0.18.0` in the Gemfile. quickjs 0.19
234
- # both split the Intl polyfills into a separate quickjs-polyfill-intl gem (which
235
- # eval's them per VM at ~226 ms — vs this single bundled flag's ~140 ms) AND
236
- # regressed interpreter execution ~2.8× (measured: the QuickJS spec suite ran
237
- # 5.6 min on 0.18 vs 15.5 min on 0.19 with an equivalent Intl set). The 0.19
238
- # migration is recorded in the cross-window / quickjs-CI memory; re-migrate to
239
- # 0.19 + quickjs-polyfill-intl once that upstream perf regression is fixed.
240
- INTL_FEATURES = [Quickjs::POLYFILL_INTL].freeze
263
+ # Since 0.19 the Intl polyfills live in the separate `quickjs-polyfill-intl`
264
+ # gem, registered by name instead of quickjs's old bundled `POLYFILL_INTL`
265
+ # flag. `:polyfill_intl_datetimeformat_all` is the whole DateTimeFormat chain
266
+ # (getcanonicallocales locale pluralrules numberformat datetimeformat)
267
+ # as ONE registration the only chain bridge.js needs, and cheaper than
268
+ # requiring the five links separately.
269
+ #
270
+ # PERF (rule 3): the polyfill source is eval'd per VM, and the VM-pool pre-warm
271
+ # used to be GVL-serial, which put every feature on the critical path. quickjs
272
+ # now releases the GVL while loading polyfills, so the pre-warm overlaps.
273
+ INTL_FEATURES = [:polyfill_intl_datetimeformat_all].freeze
241
274
  #
242
275
  # `max_stack_size: 0` — `JS_SetMaxStackSize` measures C stack
243
276
  # delta from runtime construction; Ruby callers reach QuickJS
@@ -352,11 +385,21 @@ module Capybara
352
385
  # host fns attached *after* the replay (so snapshot_stubs.js's
353
386
  # no-ops don't overwrite real ones), `__csim_isWorker` set, +
354
387
  # the per-worker postMessage routed through `post_back`.
355
- def self.build_worker(browser, post_back)
388
+ def self.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {})
356
389
  vm = Quickjs::VM.new(**VM_OPTIONS)
357
390
  bridge_runnable.run(on: vm)
358
391
  attach_host_fns(vm, browser)
359
392
  vm.define_function('__csim_workerPostMessage') {|data| post_back.call(data); nil }
393
+ # A worker's BroadcastChannel fan-out routes through the thread-safe outbox, not
394
+ # `browser.broadcast_to_windows` (cross-thread inbox mutation). See v8_runtime#build_worker.
395
+ vm.define_function('__csimBroadcast') {|name, data, _rid, origin| broadcast_out&.call(name, data, origin); nil } if broadcast_out
396
+ # Service-worker → main-thread signals via the outbox (see v8_runtime#build_worker).
397
+ vm.define_function('__csim_swPostToClient') {|client_id, data| sw_hooks[:post_to_client]&.call(client_id, data); nil } if sw_hooks[:post_to_client]
398
+ vm.define_function('__csim_swClaim') { sw_hooks[:claim]&.call; nil } if sw_hooks[:claim]
399
+ vm.define_function('__csim_swFetchRespond') {|fetch_id, resp, realm_id| sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil } if sw_hooks[:fetch_respond]
400
+ vm.define_function('__csim_swFetchStream') {|fetch_id, kind, payload, realm_id| sw_hooks[:fetch_stream]&.call(fetch_id, kind, payload, realm_id); nil } if sw_hooks[:fetch_stream]
401
+ vm.define_function('__csim_workerPortPost') {|channel, data| sw_hooks[:port_post]&.call(channel, data); nil } if sw_hooks[:port_post]
402
+ vm.define_function('__csim_workerPortEndpoint') {|channel| sw_hooks[:port_endpoint]&.call(channel); nil } if sw_hooks[:port_endpoint]
360
403
  # Override main's __setTimersActive so worker's empty-timer-map
361
404
  # flip doesn't race main's `polling?` gate. See v8_runtime's
362
405
  # build_worker for the long-form rationale.
@@ -41,7 +41,7 @@ module Capybara
41
41
  # JS exception that crashes the whole script chain. Bodies take
42
42
  # `(browser, *js_args)` and return whatever the JS caller expects.
43
43
  BROWSER_HOST_FNS = {
44
- '__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default') },
44
+ '__rackFetch' => ->(b, *a) { b.rack_fetch(a[0], a[1], a[2], a[3], a[4], a[5], credentials: a[6] || 'same-origin', referrer_policy: a[7], referrer: a[8], cache_mode: a[9] || 'default', initiator: a[10], site_seed: a[11], origin_null: a[12]) },
45
45
  '__csimExternalAsset' => ->(b, *a) { b.external_asset_source(a[0]) },
46
46
  '__locationAssign' => ->(b, *a) { b.location_assign(a[0]); nil },
47
47
  '__locationReload' => ->(b, *_) { b.location_reload; nil },
@@ -62,6 +62,10 @@ module Capybara
62
62
  # like __csimFrameNavigate — see Browser#frame_submit_self.
63
63
  '__csimFrameSubmit' => ->(b, *a) { b.frame_submit_self(a[0].to_i); nil },
64
64
  '__csimFrameHistoryGo' => ->(b, *a) { b.frame_history_go(a[0].to_i, a[1].to_i); nil },
65
+ # A frame `src=` re-navigation records a session-history entry (snapshotting the OUTGOING
66
+ # document) so history.go(-1) can traverse back — and a controlling SW sees
67
+ # isHistoryNavigation. Must run while the outgoing realm (a[0]) is still alive.
68
+ '__csim_recordFrameNav' => ->(b, *a) { b.record_frame_nav(a[0].to_i, a[1]); nil },
65
69
  '__setTimersActive' => ->(b, *a) { b.timers_active = !!a[0]; nil },
66
70
  '__setCurrentUrl' => ->(b, *a) { b.history_state(a[0], a[1]); nil },
67
71
  '__pushHistoryEntry' => ->(b, *a) { b.history_push(a[0], a[1]); nil },
@@ -72,11 +76,24 @@ module Capybara
72
76
  '__setDocumentCookie' => ->(b, *a) { b.write_document_cookie(a[0].to_s); nil },
73
77
  '__getDocumentReferrer' => ->(b, *_) { b.current_referer },
74
78
  '__csim_storageGet' => ->(b, *a) { b.storage_get(a[0], a[1]) },
75
- '__csim_storageSet' => ->(b, *a) { b.storage_set(a[0], a[1], a[2]); nil },
79
+ '__csim_storageSet' => ->(b, *a) { b.storage_set(a[0], a[1], a[2]) },
76
80
  '__csim_storageRemove' => ->(b, *a) { b.storage_remove(a[0], a[1]); nil },
77
81
  '__csim_storageClear' => ->(b, *a) { b.storage_clear(a[0]); nil },
78
82
  '__csim_storageKey' => ->(b, *a) { b.storage_key(a[0], a[1]) },
79
83
  '__csim_storageLength' => ->(b, *a) { b.storage_length(a[0]) },
84
+ '__csimStorageChanged' => ->(b, *a) { b.storage_changed(a[0], a[1], a[2], a[3], a[4], a[5]); nil },
85
+ # Cache Storage — origin-partitioned (a[0] = origin key), Ruby-backed so it
86
+ # survives the per-visit VM rebuild and is shared between a service worker and
87
+ # the client it controls. The JS side (cache-storage.js) owns the spec matching;
88
+ # Ruby is a dumb ordered store keyed by (origin, cache name).
89
+ '__csim_cacheStorageOpen' => ->(b, *a) { b.cache_storage_open(a[0], a[1]) },
90
+ '__csim_cacheStorageHas' => ->(b, *a) { b.cache_storage_has(a[0], a[1]) },
91
+ '__csim_cacheStorageDelete' => ->(b, *a) { b.cache_storage_delete(a[0], a[1]) },
92
+ '__csim_cacheStorageKeys' => ->(b, *a) { b.cache_storage_keys(a[0]) },
93
+ '__csim_cacheEntries' => ->(b, *a) { b.cache_entries(a[0], a[1]) },
94
+ '__csim_cacheEntryResponse' => ->(b, *a) { b.cache_entry_response(a[0], a[1], a[2]) },
95
+ '__csim_cachePut' => ->(b, *a) { b.cache_put(a[0], a[1], a[2], a[3], a[4]); nil },
96
+ '__csim_cacheDeleteEntries' => ->(b, *a) { b.cache_delete_entries(a[0], a[1], a[2]) },
80
97
  '__csimGeolocationState' => ->(b, *_) { b.geolocation_state_json },
81
98
  '__modalDialog' => ->(b, *a) { b.handle_modal(a[0], a[1], a[2]) },
82
99
  '__csim_pushImportmap' => ->(b, *a) { b.set_importmap(a[0]); nil },
@@ -98,7 +115,15 @@ module Capybara
98
115
  # forces noopener for a cross-partition blob: target.
99
116
  '__csimOpenAuxFromRealm' => ->(b, *a) { b.open_aux_from_realm(a[0], a[1], a[2]); nil },
100
117
  '__csimWindowPostMessage' => ->(b, *a) { b.post_message_to_window(a[0], a[1], a[2]); nil },
101
- '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i); nil },
118
+ '__csimBroadcast' => ->(b, *a) { b.broadcast_to_windows(a[0], a[1], a[2].to_i, a[3]); nil },
119
+ # BroadcastChannel isolate-wide, creation-ordered registry (the multi-realm delivery path). A
120
+ # channel registers on construction / unregisters on close; `bc_post` snapshots the eligible
121
+ # targets at post time and queues one ordered delivery per target. Only consulted when a sibling
122
+ # same-isolate realm exists (`bc_siblings_exist?`); single-window pages never touch it.
123
+ '__csimBcRegister' => ->(b, *a) { b.bc_register(a[0], a[1], a[2], a[3]); nil },
124
+ '__csimBcUnregister' => ->(b, *a) { b.bc_unregister(a[0], a[1]); nil },
125
+ '__csimBcPost' => ->(b, *a) { b.bc_post(a[0], a[1], a[2], a[3], a[4], a[5]); nil },
126
+ '__csimBcSiblingsExist' => ->(b, *_a) { b.bc_siblings_exist? },
102
127
  '__csimWindowGet' => ->(b, *a) { b.window_get(a[0], a[1]) },
103
128
  '__csimWindowDocGet' => ->(b, *a) { b.window_doc_get(a[0], a[1]) },
104
129
  # Cross-window remote-ref RPC: route an opener's node/object proxy op to
@@ -115,13 +140,43 @@ module Capybara
115
140
  # Fire an aux window's OWN `load` event (in its VM) — deferred by the
116
141
  # opener so a child `window.onload` runs after the opener's current task.
117
142
  '__csimFireAuxWindowLoad' => ->(b, *a) { b.fire_aux_window_load(a[0]); nil },
118
- '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1]) },
143
+ '__csim_workerSpawn' => ->(b, *a) { b.worker_spawn(a[0], shared: !!a[1], creator_key: a[2]) },
119
144
  # navigator.serviceWorker.register (universal-server only) — spawn a worker
120
145
  # running the SW script as an executor context. Returns its handle.
121
- '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true) },
146
+ '__csim_serviceWorkerRegister' => ->(b, *a) { b.worker_spawn(a[0], service: true, creator_key: a[1]) },
122
147
  '__csim_workerPostToWorker' => ->(b, *a) { b.worker_post_to_worker(a[0], a[1]); nil },
148
+ # ServiceWorker.postMessage from a client window → the SW's `message` event (source = client).
149
+ '__csim_serviceWorkerPostMessage' => ->(b, *a) { b.service_worker_post_message(a[0], a[1], a[2], a[3]); nil },
150
+ # Cross-isolate MessagePort channel (client-realm side): register this realm's endpoint, and
151
+ # relay a client-realm port's postMessage to its remote (worker/SW) peer.
152
+ '__csimClientPortEndpoint' => ->(b, *a) { b.port_channel_endpoint_realm(a[0], a[1]); nil },
153
+ '__csimClientPortPost' => ->(b, *a) { b.client_port_post(a[0], a[1]); nil },
154
+ # A controlled client's fetch → the controlling SW's `fetch` event. Returns false if the SW
155
+ # is gone (client falls back to the network).
156
+ '__csim_serviceWorkerControllerFetch' => ->(b, *a) { b.service_worker_controller_fetch(a[0], a[1], a[2], a[3]) },
157
+ # A controlled client cancelled a streaming respondWith body → cancel the SW's source stream
158
+ # (routed to the worker that owns this [realm, fetch] stream). See sw_stream_cancel.
159
+ '__csim_swStreamCancel' => ->(b, *a) { b.sw_stream_cancel(a[0], a[1]); nil },
160
+ # Client lifecycle mirrors scope→active-worker into Ruby so a navigation (fetched
161
+ # Ruby-side before the destination realm's JS exists) can find its controlling SW.
162
+ '__csim_swRegisterScope' => ->(b, *a) { b.sw_register_scope(a[0], a[1]); nil },
163
+ '__csim_swUnregisterScope' => ->(b, *a) { b.sw_unregister_scope(a[0]); nil },
164
+ # The active worker handle at an EXACT scope (0 if none), so a register() from a realm with no
165
+ # local registration (a different iframe registering an already-active scope) can synthesize a
166
+ # registration reflecting the shared active worker instead of installing a duplicate.
167
+ '__csim_swActiveHandleForScope' => ->(b, *a) { b.sw_active_handle_for_scope(a[0]) },
168
+ # Navigation Preload state (NavigationPreloadManager), keyed by the registration's active
169
+ # worker handle — reached identically from the client (registration.active._handle) and the
170
+ # worker (__csimWorkerHandle). Get returns {enabled, headerValue}; set leaves a nil field as-is.
171
+ '__csim_swNavPreloadState' => ->(b, *a) { b.nav_preload_state(a[0]) },
172
+ '__csim_swNavPreloadSet' => ->(b, *a) { b.nav_preload_set(a[0], a[1], a[2]); nil },
173
+ # A navigation (iframe/document load) → its controlling SW's `fetch` event, awaited
174
+ # synchronously. Returns the response wire hash, or nil to load from the network.
175
+ '__csim_swNavigationFetch' => ->(b, *a) { b.service_worker_navigation_fetch(a[0], is_reload: !!a[1], is_history: !!a[2], referrer_source: a[3], method: a[4] || 'GET', body_b64: a[5] || '', content_type: a[6]) },
123
176
  '__csim_workerTerminate' => ->(b, *a) { b.worker_terminate(a[0]); nil },
124
177
  '__csim_decodeImage' => ->(b, *a) { b.decode_image(a[0], a[1], a[2]) },
178
+ '__csim_renderText' => ->(b, *a) { b.render_text(a[0], a[1], a[2], a[3], a[4]) },
179
+ '__csim_loadImage' => ->(b, *a) { b.load_image(a[0], !!a[1], a[2] || 'same-origin') },
125
180
  '__csim_blobRegister' => ->(b, *a) { b.blob_register(a[0], a[1], a[2]); nil },
126
181
  # WHATWG/UTS46 IDNA for the URL parser's host processing (the JS tr46 stub
127
182
  # delegates non-ASCII / xn-- hosts here; ASCII stays in-VM).
@@ -144,6 +199,7 @@ module Capybara
144
199
  # eager-@app.calls a foreign URL (side effects: extra visit / log row).
145
200
  '__csim_allHostsLocal' => ->(b, *a) { b.send(:all_hosts_local?) },
146
201
  '__csim_decodeVideoFrame' => ->(b, *a) { b.decode_video_frame(a[0]) },
202
+ '__csim_videoBytesB64' => ->(b, *a) { b.video_bytes_b64(a[0]) },
147
203
  '__csim_encodeImage' => ->(b, *a) { b.encode_image(a[0], a[1], a[2], a[3], a[4]) },
148
204
  # WebAuthn create / get raise `WebauthnState::Error` carrying
149
205
  # the DOMException name (`InvalidStateError`, …); rescue here
@@ -215,15 +271,377 @@ module Capybara
215
271
  '__csim_utf8Decode' => ->(*a) { a[0].pack('C*').force_encoding('UTF-8') },
216
272
  # `__csim_parseUrl` is defined in JS now (js/src/url-parse.js, backed by
217
273
  # the vendored whatwg-url) — spec-correct + no V8↔Ruby boundary per parse.
218
- # Web Crypto SubtleCrypto.digest algo is "SHA-1"/"SHA-256"/etc.
219
- # JS hands us the byte array; we return the digest as bytes.
274
+ # Web Crypto raw primitives, backed by OpenSSL. The JS side (js/src/webcrypto.js)
275
+ # owns the whole SubtleCrypto contract algorithm normalization, CryptoKey
276
+ # objects, usage validation, the DOMException grammar — and calls these only for
277
+ # the actual number-crunching. Byte arguments cross as plain Arrays (JS packs a
278
+ # BufferSource into one); results return as byte Arrays.
279
+ #
280
+ # digest — algo is "SHA1"/"SHA256"/… (JS strips the dash and upcases).
220
281
  '__csim_subtleDigest' => lambda {|*a|
221
282
  algo = a[0].to_s.upcase.tr('-', '')
222
283
  bytes = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
223
284
  OpenSSL::Digest.new(algo).digest(bytes).bytes
285
+ },
286
+ # HMAC sign — args: (hash "SHA256", keyBytes, dataBytes) → mac bytes. verify is
287
+ # done JS-side by re-signing and comparing, so no separate host fn is needed.
288
+ '__csim_hmacSign' => lambda {|*a|
289
+ hash = a[0].to_s.upcase.tr('-', '')
290
+ key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
291
+ data = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
292
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new(hash), key, data).bytes
293
+ },
294
+ # AES encrypt/decrypt — args: (cipher "aes-256-gcm", key, iv, data, aad, tagBytes).
295
+ # For GCM the JS side follows the WebCrypto layout (ciphertext ‖ truncated tag), so
296
+ # encrypt appends the tagBytes-long tag and decrypt splits it back off before
297
+ # verifying; a tag mismatch / bad padding raises CipherError, which the JS layer
298
+ # maps to OperationError. CBC/CTR pass tagBytes 0 and an empty aad.
299
+ '__csim_aesEncrypt' => lambda {|*a|
300
+ name = a[0].to_s
301
+ key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
302
+ iv = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
303
+ data = a[3].is_a?(Array) ? a[3].pack('C*') : a[3].to_s
304
+ aad = a[4].is_a?(Array) ? a[4].pack('C*') : a[4].to_s
305
+ tagbytes = a[5].to_i
306
+ c = OpenSSL::Cipher.new(name)
307
+ c.encrypt
308
+ c.key = key
309
+ c.iv_len = iv.bytesize if name.end_with?('-gcm') # GCM defaults to 12; allow any length
310
+ c.iv = iv
311
+ if name.end_with?('-gcm')
312
+ c.auth_data = aad
313
+ ct = c.update(data) + c.final
314
+ (ct + c.auth_tag(tagbytes)).bytes
315
+ else
316
+ (c.update(data) + c.final).bytes
317
+ end
318
+ },
319
+ '__csim_aesDecrypt' => lambda {|*a|
320
+ name = a[0].to_s
321
+ key = a[1].is_a?(Array) ? a[1].pack('C*') : a[1].to_s
322
+ iv = a[2].is_a?(Array) ? a[2].pack('C*') : a[2].to_s
323
+ data = a[3].is_a?(Array) ? a[3].pack('C*') : a[3].to_s
324
+ aad = a[4].is_a?(Array) ? a[4].pack('C*') : a[4].to_s
325
+ tagbytes = a[5].to_i
326
+ c = OpenSSL::Cipher.new(name)
327
+ c.decrypt
328
+ c.key = key
329
+ c.iv_len = iv.bytesize if name.end_with?('-gcm')
330
+ c.iv = iv
331
+ if name.end_with?('-gcm')
332
+ ct = data[0, data.bytesize - tagbytes]
333
+ tag = data[data.bytesize - tagbytes, tagbytes]
334
+ c.auth_data = aad
335
+ c.auth_tag = tag
336
+ (c.update(ct) + c.final).bytes
337
+ else
338
+ (c.update(data) + c.final).bytes
339
+ end
340
+ },
341
+ # RSA — keys cross as their DER encoding (SPKI for public, PKCS#8 for private)
342
+ # in a byte Array; the host re-parses per call (OpenSSL::PKey.read auto-detects).
343
+ # generateKey returns the private PKCS#8 DER, from which the JS side derives the
344
+ # public key and parameters via the export / key-info fns below.
345
+ '__csim_rsaGenerate' => lambda {|*a|
346
+ bits = a[0].to_i
347
+ exp = OpenSSL::BN.new(crypto_bytes(a[1]), 2).to_i
348
+ OpenSSL::PKey::RSA.generate(bits, exp).private_to_der.bytes
349
+ },
350
+ # Re-encode a key to 'spki' (public) or 'pkcs8' (private). Asking a private key
351
+ # for 'spki' yields its public half — how generateKey obtains the public key.
352
+ '__csim_rsaExport' => lambda {|*a|
353
+ key = RuntimeShared.pkey_read(a[0])
354
+ a[1].to_s == 'spki' ? key.public_to_der.bytes : key.private_to_der.bytes
355
+ },
356
+ # Modulus length (bits) + public exponent (big-endian bytes) for key.algorithm.
357
+ '__csim_rsaKeyInfo' => lambda {|*a|
358
+ key = RuntimeShared.pkey_read(a[0])
359
+ { 'modulusLength' => key.n.num_bits, 'publicExponent' => key.e.to_s(2).bytes }
360
+ },
361
+ # Build a key DER from JWK components (big-endian byte arrays). Public keys pass
362
+ # only n/e; private keys pass the full CRT set. Returns SPKI / PKCS#8 DER bytes.
363
+ '__csim_rsaImportJwk' => lambda {|*a|
364
+ RuntimeShared.rsa_jwk_to_der(*a).bytes
365
+ },
366
+ # Explode a key DER into JWK components (big-endian byte arrays). Private keys
367
+ # include the CRT parameters; public keys carry only n/e.
368
+ '__csim_rsaExportJwk' => lambda {|*a|
369
+ RuntimeShared.rsa_der_to_jwk(a[0])
370
+ },
371
+ # sign/verify — scheme 'pkcs1' (RSASSA-PKCS1-v1_5) or 'pss' (RSA-PSS, MGF1 with
372
+ # the same hash, saltLength in bytes). verify swallows OpenSSL errors to a false.
373
+ '__csim_rsaSign' => lambda {|*a|
374
+ key = RuntimeShared.pkey_read(a[0])
375
+ hash = a[1].to_s
376
+ data = crypto_bytes(a[2])
377
+ if a[3].to_s == 'pss'
378
+ key.sign_pss(hash, data, salt_length: a[4].to_i, mgf1_hash: hash).bytes
379
+ else
380
+ key.sign(hash, data).bytes
381
+ end
382
+ },
383
+ '__csim_rsaVerify' => lambda {|*a|
384
+ key = RuntimeShared.pkey_read(a[0])
385
+ hash = a[1].to_s
386
+ data = crypto_bytes(a[2])
387
+ sig = crypto_bytes(a[3])
388
+ begin
389
+ if a[4].to_s == 'pss'
390
+ key.verify_pss(hash, sig, data, salt_length: a[5].to_i, mgf1_hash: hash)
391
+ else
392
+ key.verify(hash, sig, data)
393
+ end
394
+ rescue OpenSSL::PKey::PKeyError
395
+ false
396
+ end
397
+ },
398
+ # RSA-OAEP encrypt/decrypt — MGF1 + OAEP with the key's hash and an optional label.
399
+ # `rsa_oaep_label` takes a HEX string, not raw bytes (OpenSSL parses it via
400
+ # prepare_from_text), so the label crosses hex-encoded.
401
+ '__csim_rsaEncrypt' => lambda {|*a|
402
+ key = RuntimeShared.pkey_read(a[0])
403
+ opts = { rsa_padding_mode: 'oaep', rsa_oaep_md: a[1].to_s }
404
+ label = crypto_bytes(a[3])
405
+ opts[:rsa_oaep_label] = label.unpack1('H*') unless label.empty?
406
+ key.encrypt(crypto_bytes(a[2]), opts).bytes
407
+ },
408
+ '__csim_rsaDecrypt' => lambda {|*a|
409
+ key = RuntimeShared.pkey_read(a[0])
410
+ opts = { rsa_padding_mode: 'oaep', rsa_oaep_md: a[1].to_s }
411
+ label = crypto_bytes(a[3])
412
+ opts[:rsa_oaep_label] = label.unpack1('H*') unless label.empty?
413
+ key.decrypt(crypto_bytes(a[2]), opts).bytes
414
+ },
415
+ # Elliptic curve (ECDSA / ECDH). The JS side passes the OpenSSL curve name
416
+ # ('prime256v1' / 'secp384r1' / 'secp521r1') and the field byte length so the
417
+ # host stays curve-agnostic. Keys cross as SPKI / PKCS#8 DER.
418
+ '__csim_ecGenerate' => lambda {|*a|
419
+ OpenSSL::PKey::EC.generate(a[0].to_s).private_to_der.bytes
420
+ },
421
+ '__csim_ecExport' => lambda {|*a|
422
+ key = RuntimeShared.pkey_read(a[0])
423
+ case a[1].to_s
424
+ when 'spki' then key.public_to_der.bytes
425
+ when 'pkcs8' then key.private_to_der.bytes
426
+ else key.public_key.to_octet_string(:uncompressed).bytes # 'raw' → uncompressed point
427
+ end
428
+ },
429
+ '__csim_ecKeyInfo' => lambda {|*a|
430
+ { 'curve' => RuntimeShared.pkey_read(a[0]).group.curve_name }
431
+ },
432
+ '__csim_ecImportRaw' => lambda {|*a|
433
+ RuntimeShared.ec_point_spki(a[0].to_s, crypto_bytes(a[1])).bytes
434
+ },
435
+ # args: (curve, isPrivate, x, y, [d, n]). The uncompressed public point is
436
+ # 0x04 ‖ x ‖ y; a private key additionally carries the scalar d (padded to n).
437
+ '__csim_ecImportJwk' => lambda {|*a|
438
+ curve = a[0].to_s
439
+ point = "\x04".b + crypto_bytes(a[2]) + crypto_bytes(a[3])
440
+ if a[1]
441
+ RuntimeShared.ec_priv_pkcs8(curve, crypto_bytes(a[4]), point, a[5].to_i).bytes
442
+ else
443
+ RuntimeShared.ec_point_spki(curve, point).bytes
444
+ end
445
+ },
446
+ '__csim_ecExportJwk' => lambda {|*a|
447
+ RuntimeShared.ec_der_to_jwk(a[0], a[1].to_i)
448
+ },
449
+ '__csim_ecdsaSign' => lambda {|*a|
450
+ key = RuntimeShared.pkey_read(a[0])
451
+ der = key.sign(OpenSSL::Digest.new(a[1].to_s), crypto_bytes(a[2]))
452
+ RuntimeShared.ecdsa_der_to_raw(der, a[3].to_i).bytes
453
+ },
454
+ '__csim_ecdsaVerify' => lambda {|*a|
455
+ key = RuntimeShared.pkey_read(a[0])
456
+ der = RuntimeShared.ecdsa_raw_to_der(crypto_bytes(a[3]), a[4].to_i)
457
+ begin
458
+ der ? key.verify(OpenSSL::Digest.new(a[1].to_s), der, crypto_bytes(a[2])) : false
459
+ rescue OpenSSL::PKey::PKeyError
460
+ false
461
+ end
462
+ },
463
+ # Key derivation — HKDF / PBKDF2 produce `nbytes` bytes; ECDH computes the raw
464
+ # shared secret (field size). The JS side truncates to the requested bit length.
465
+ '__csim_hkdf' => lambda {|*a|
466
+ OpenSSL::KDF.hkdf(crypto_bytes(a[1]), salt: crypto_bytes(a[2]), info: crypto_bytes(a[3]), length: a[4].to_i, hash: a[0].to_s).bytes
467
+ },
468
+ '__csim_pbkdf2' => lambda {|*a|
469
+ OpenSSL::KDF.pbkdf2_hmac(crypto_bytes(a[1]), salt: crypto_bytes(a[2]), iterations: a[3].to_i, length: a[4].to_i, hash: a[0].to_s).bytes
470
+ },
471
+ '__csim_ecdhDerive' => lambda {|*a|
472
+ priv = RuntimeShared.pkey_read(a[0])
473
+ pub = RuntimeShared.pkey_read(a[1])
474
+ priv.dh_compute_key(pub.public_key).bytes
475
+ },
476
+ # AES-KW (RFC 3394 key wrap). The cipher width follows the wrapping key; OpenSSL's
477
+ # wrap mode supplies the default A6A6… IV. A tampered wrap raises on unwrap, which
478
+ # the JS layer maps to OperationError.
479
+ '__csim_aesKwWrap' => lambda {|*a|
480
+ key = crypto_bytes(a[0])
481
+ c = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-wrap")
482
+ c.encrypt
483
+ c.key = key
484
+ (c.update(crypto_bytes(a[1])) + c.final).bytes
485
+ },
486
+ '__csim_aesKwUnwrap' => lambda {|*a|
487
+ key = crypto_bytes(a[0])
488
+ c = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-wrap")
489
+ c.decrypt
490
+ c.key = key
491
+ (c.update(crypto_bytes(a[1])) + c.final).bytes
492
+ },
493
+ # OKP curves (Ed25519 signatures / X25519 key agreement). The JS side passes the
494
+ # OpenSSL curve name ('ED25519' / 'X25519'); keys cross as SPKI / PKCS#8 DER, and
495
+ # the raw form is the 32-byte public (or private, via JWK `d`) key.
496
+ '__csim_okpGenerate' => lambda {|*a|
497
+ OpenSSL::PKey.generate_key(a[0].to_s).private_to_der.bytes
498
+ },
499
+ '__csim_okpExport' => lambda {|*a|
500
+ key = RuntimeShared.pkey_read(a[0])
501
+ case a[1].to_s
502
+ when 'spki' then key.public_to_der.bytes
503
+ when 'pkcs8' then key.private_to_der.bytes
504
+ else key.raw_public_key.bytes # 'raw' → 32-byte public key
505
+ end
506
+ },
507
+ '__csim_okpImportRaw' => lambda {|*a|
508
+ OpenSSL::PKey.new_raw_public_key(a[0].to_s, crypto_bytes(a[1])).public_to_der.bytes
509
+ },
510
+ # Parse + re-emit a canonical OKP DER — validates the SPKI/PKCS#8 structure at
511
+ # import time (a truncated / malformed key raises, which JS maps to DataError)
512
+ # instead of storing the bytes blindly and only failing on first use.
513
+ '__csim_okpImportDer' => lambda {|*a|
514
+ key = RuntimeShared.pkey_read(a[1])
515
+ a[0].to_s == 'private' ? key.private_to_der.bytes : key.public_to_der.bytes
516
+ },
517
+ '__csim_okpImportJwk' => lambda {|*a|
518
+ type = a[0].to_s
519
+ if a[1]
520
+ key = OpenSSL::PKey.new_raw_private_key(type, crypto_bytes(a[3]))
521
+ # The JWK "x" (public) must equal the public key derived from "d" — a mismatch is
522
+ # an invalid key pair (DataError on the JS side).
523
+ raise OpenSSL::PKey::PKeyError, 'OKP JWK x does not match d' unless key.raw_public_key == crypto_bytes(a[2])
524
+ key.private_to_der.bytes
525
+ else
526
+ OpenSSL::PKey.new_raw_public_key(type, crypto_bytes(a[2])).public_to_der.bytes
527
+ end
528
+ },
529
+ '__csim_okpExportJwk' => lambda {|*a|
530
+ key = RuntimeShared.pkey_read(a[0])
531
+ jwk = { 'x' => key.raw_public_key.bytes }
532
+ jwk['d'] = key.raw_private_key.bytes if a[1] # a[1] = the key is private
533
+ jwk
534
+ },
535
+ '__csim_ed25519Sign' => lambda {|*a|
536
+ RuntimeShared.pkey_read(a[0]).sign(nil, crypto_bytes(a[1])).bytes
537
+ },
538
+ '__csim_ed25519Verify' => lambda {|*a|
539
+ key = RuntimeShared.pkey_read(a[0])
540
+ begin
541
+ key.verify(nil, crypto_bytes(a[2]), crypto_bytes(a[1]))
542
+ rescue OpenSSL::PKey::PKeyError
543
+ false
544
+ end
545
+ },
546
+ '__csim_x25519Derive' => lambda {|*a|
547
+ RuntimeShared.pkey_read(a[0]).derive(RuntimeShared.pkey_read(a[1])).bytes
224
548
  }
225
549
  }.freeze
226
550
 
551
+ # Pack a host-fn byte argument (a JS BufferSource crosses as an Array) into a
552
+ # binary String for OpenSSL.
553
+ def self.crypto_bytes(a)
554
+ a.is_a?(Array) ? a.pack('C*') : a.to_s
555
+ end
556
+
557
+ # Parse an RSA / EC key from its DER bytes (SPKI public or PKCS#8 private —
558
+ # OpenSSL::PKey.read auto-detects both).
559
+ def self.pkey_read(der_bytes)
560
+ OpenSSL::PKey.read(crypto_bytes(der_bytes))
561
+ end
562
+
563
+ # ECDSA signatures cross as the WebCrypto IEEE-P1363 form — r ‖ s, each padded to
564
+ # the curve's field byte length — while OpenSSL speaks DER. Convert both ways.
565
+ def self.ecdsa_der_to_raw(der, n)
566
+ asn = OpenSSL::ASN1.decode(der)
567
+ r = asn.value[0].value.to_s(2).rjust(n, "\x00".b)
568
+ s = asn.value[1].value.to_s(2).rjust(n, "\x00".b)
569
+ r + s
570
+ end
571
+
572
+ def self.ecdsa_raw_to_der(raw, n)
573
+ return nil unless raw.bytesize == 2 * n # wrong length → verify fails, not raises
574
+ r = OpenSSL::BN.new(raw[0, n], 2)
575
+ s = OpenSSL::BN.new(raw[n, n], 2)
576
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
577
+ end
578
+
579
+ def self.ec_algid(curve)
580
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('id-ecPublicKey'), OpenSSL::ASN1::ObjectId(curve)])
581
+ end
582
+
583
+ # SPKI DER for an EC public key from its uncompressed point (0x04 ‖ x ‖ y).
584
+ def self.ec_point_spki(curve, point)
585
+ OpenSSL::ASN1::Sequence([ec_algid(curve), OpenSSL::ASN1::BitString(point)]).to_der
586
+ end
587
+
588
+ # PKCS#8 DER for an EC private key from the private scalar `d` and public point,
589
+ # via the RFC 5915 ECPrivateKey structure (OpenSSL 3 dropped component setters).
590
+ def self.ec_priv_pkcs8(curve, d, point, n)
591
+ ec_priv = OpenSSL::ASN1::Sequence([
592
+ OpenSSL::ASN1::Integer(1),
593
+ OpenSSL::ASN1::OctetString(d.rjust(n, "\x00".b)),
594
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId(curve)], 0, :CONTEXT_SPECIFIC),
595
+ OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString(point)], 1, :CONTEXT_SPECIFIC)
596
+ ]).to_der
597
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0), ec_algid(curve), OpenSSL::ASN1::OctetString(ec_priv)]).to_der
598
+ end
599
+
600
+ # Explode an EC key DER into JWK coordinates (x, y, and d for a private key), each a
601
+ # big-endian byte Array padded to the field length; plus the OpenSSL curve name.
602
+ def self.ec_der_to_jwk(der_bytes, n)
603
+ key = pkey_read(der_bytes)
604
+ point = key.public_key.to_octet_string(:uncompressed) # 0x04 ‖ x ‖ y
605
+ jwk = { 'curve' => key.group.curve_name, 'x' => point[1, n].bytes, 'y' => point[1 + n, n].bytes }
606
+ jwk['d'] = key.private_key.to_s(2).rjust(n, "\x00".b).bytes if key.private_key?
607
+ jwk
608
+ end
609
+
610
+ # Assemble an RSA key DER from JWK components. `is_private` selects between a
611
+ # public SPKI (n, e) and a private PKCS#8 with the full CRT set. Each component is
612
+ # a big-endian byte Array. Building the ASN.1 by hand is the OpenSSL-3-supported
613
+ # path (the deprecated `rsa.n = …` setters are gone).
614
+ def self.rsa_jwk_to_der(is_private, n, e, d = nil, p = nil, q = nil, dp = nil, dq = nil, qi = nil)
615
+ bn = ->(b) { OpenSSL::BN.new(crypto_bytes(b), 2) }
616
+ alg_id = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId('rsaEncryption'), OpenSSL::ASN1::Null(nil)])
617
+ if is_private
618
+ pkcs1 = OpenSSL::ASN1::Sequence([
619
+ OpenSSL::ASN1::Integer(0),
620
+ OpenSSL::ASN1::Integer(bn.(n)), OpenSSL::ASN1::Integer(bn.(e)), OpenSSL::ASN1::Integer(bn.(d)),
621
+ OpenSSL::ASN1::Integer(bn.(p)), OpenSSL::ASN1::Integer(bn.(q)),
622
+ OpenSSL::ASN1::Integer(bn.(dp)), OpenSSL::ASN1::Integer(bn.(dq)), OpenSSL::ASN1::Integer(bn.(qi))
623
+ ]).to_der
624
+ OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(0), alg_id, OpenSSL::ASN1::OctetString(pkcs1)]).to_der
625
+ else
626
+ pkcs1 = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(bn.(n)), OpenSSL::ASN1::Integer(bn.(e))]).to_der
627
+ OpenSSL::ASN1::Sequence([alg_id, OpenSSL::ASN1::BitString(pkcs1)]).to_der
628
+ end
629
+ end
630
+
631
+ # Explode an RSA key DER into JWK components (big-endian byte Arrays). Private keys
632
+ # carry the full CRT set; public keys only n/e.
633
+ def self.rsa_der_to_jwk(der_bytes)
634
+ key = pkey_read(der_bytes)
635
+ jwk = { 'n' => key.n.to_s(2).bytes, 'e' => key.e.to_s(2).bytes }
636
+ if key.private?
637
+ jwk.merge!(
638
+ 'd' => key.d.to_s(2).bytes, 'p' => key.p.to_s(2).bytes, 'q' => key.q.to_s(2).bytes,
639
+ 'dp' => key.dmp1.to_s(2).bytes, 'dq' => key.dmq1.to_s(2).bytes, 'qi' => key.iqmp.to_s(2).bytes
640
+ )
641
+ end
642
+ jwk
643
+ end
644
+
227
645
  def self.safe_call
228
646
  yield
229
647
  rescue StandardError => e