magick-feature-flags 1.3.2 → 1.4.2

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: 58eabf5855bcd4578d09090c65cc5b8d1b33ba2caa38ae7dd85bf7ecebb76047
4
- data.tar.gz: e57e675ff58e913e60ef883b4dd8078087c7575ef5ced4355c8811bb15bf2dd3
3
+ metadata.gz: ce69954c64fae4d8ec9d10d177621ef508c1f0d8db98b9012849250d90cc974f
4
+ data.tar.gz: 41024341850d984dc1b16c8e85913160a94eb92b96f5eaf07477d9ff93a46de4
5
5
  SHA512:
6
- metadata.gz: 3fdaf5955fcf00d83047ff4ed785476b91b89e9d53ad80d890459e77134cf974cfe53a204060e471c491f133cd4d88f88e0f91813e042e5bf311821e09d804ea
7
- data.tar.gz: d09483c77bb6af18a7f24565366d08e28a725c95d741c8cdcbf0ae3abcd17d8c3e0626942668a41f9041120a786f0463d35567c4ccfaaca3f56826f2888aa682
6
+ metadata.gz: a16aa4722424020d4d4ab0d45c1dc5bfec6452ae6f24177d713e6f98790d33b4851bb340e39603aecf52c06bf2d669d0876c0b3835bc9ebf4f63cd92e7776ccc
7
+ data.tar.gz: 3a49561daaf30a060abefd7c11413dd5e87e27771b59e089d287cb2788920f5f9c334c4922e064d03c18ce692b6f37a6bfeaf419f09ba12891313c3be1eb1f8a
data/README.md CHANGED
@@ -888,6 +888,43 @@ end
888
888
  - `:inactive` - Feature is disabled for everyone
889
889
  - `:deprecated` - Feature is deprecated (can be enabled with `allow_deprecated: true` in context)
890
890
 
891
+ ## Graceful Shutdown
892
+
893
+ Magick starts a background Redis Pub/Sub subscriber thread for cross-process
894
+ cache invalidation and an asynchronous metrics processor. Both must be
895
+ stopped before the host process exits or Puma's graceful-stop will block on
896
+ the still-running `Redis#subscribe` call.
897
+
898
+ The Railtie registers an `at_exit` hook that calls `Magick.shutdown!`
899
+ automatically, so most Rails apps don't need to do anything. In long-running
900
+ non-Rails processes (rake tasks, CLI tools) call it explicitly:
901
+
902
+ ```ruby
903
+ Magick.shutdown! # default 5 second join timeout
904
+ Magick.shutdown!(timeout: 1) # more aggressive
905
+ ```
906
+
907
+ Fork-based deployments (Puma workers with `preload_app`, Unicorn) are handled
908
+ automatically: `config.to_prepare` calls `ensure_subscriber!` and
909
+ `ensure_async_processor!` on every prepare cycle, so children that inherit
910
+ stale parent threads start fresh. No action required from the host app.
911
+
912
+ ## Admin UI Security
913
+
914
+ The Admin UI is CSRF-protected out of the box (`protect_from_forgery with:
915
+ :exception`) and 404s on unknown feature IDs instead of auto-creating
916
+ features from user-controlled `params[:id]`.
917
+
918
+ Authentication is **opt-in** — if `Magick::AdminUI.config.require_role` is
919
+ left `nil` the UI is reachable by anyone who can hit its routes. Always set
920
+ it behind your app's auth:
921
+
922
+ ```ruby
923
+ Magick::AdminUI.configure do |c|
924
+ c.require_role = ->(controller) { controller.current_user&.admin? }
925
+ end
926
+ ```
927
+
891
928
  ## Testing
892
929
 
893
930
  Use the testing helpers in your RSpec tests:
@@ -3,6 +3,13 @@
3
3
  module Magick
4
4
  module AdminUI
5
5
  class FeaturesController < ActionController::Base
6
+ # Inheriting ActionController::Base does NOT bring in CSRF protection.
7
+ # Include RequestForgeryProtection + explicit protect_from_forgery so
8
+ # cross-site form submissions cannot toggle flags behind a logged-in
9
+ # admin's back.
10
+ include ::ActionController::RequestForgeryProtection
11
+ protect_from_forgery with: :exception
12
+
6
13
  # Include route helpers so views can use magick_admin_ui.* helpers
7
14
  include Magick::AdminUI::Engine.routes.url_helpers
8
15
  layout 'application'
@@ -80,11 +87,8 @@ module Magick
80
87
  end
81
88
 
82
89
  def enable
83
- if @feature.enable
84
- redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
85
- else
86
- redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Cannot enable feature — its dependencies must be enabled first'
87
- end
90
+ @feature.enable
91
+ redirect_to magick_admin_ui.features_path, notice: 'Feature enabled'
88
92
  end
89
93
 
90
94
  def disable
@@ -120,6 +124,10 @@ module Magick
120
124
  def update_targeting
121
125
  # Handle targeting updates from form
122
126
  targeting_params = params[:targeting] || {}
127
+ unless hash_like?(targeting_params)
128
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid targeting payload.'
129
+ return
130
+ end
123
131
 
124
132
  # Ensure we're using the registered feature instance
125
133
  feature_name = @feature.name.to_s
@@ -266,13 +274,18 @@ module Magick
266
274
 
267
275
  redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Targeting updated successfully'
268
276
  rescue StandardError => e
269
- Rails.logger.error "Magick: Error updating targeting: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
270
- redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating targeting: #{e.message}"
277
+ Rails.logger.error "Magick: Error updating targeting for #{@feature.name}: #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" if defined?(Rails)
278
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update targeting — see server logs for details.'
271
279
  end
272
280
 
273
281
  def update_variants
274
282
  variants_data = []
275
283
 
284
+ if params[:variants].present? && !hash_like?(params[:variants])
285
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Invalid variants payload.'
286
+ return
287
+ end
288
+
276
289
  if params[:variants].present?
277
290
  params[:variants].each do |_index, variant_params|
278
291
  next if variant_params[:name].blank?
@@ -295,8 +308,8 @@ module Magick
295
308
 
296
309
  redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Variants updated successfully'
297
310
  rescue StandardError => e
298
- Rails.logger.error "Magick: Error updating variants: #{e.message}" if defined?(Rails)
299
- redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating variants: #{e.message}"
311
+ Rails.logger.error "Magick: Error updating variants for #{@feature.name}: #{e.class}: #{e.message}" if defined?(Rails)
312
+ redirect_to magick_admin_ui.feature_path(@feature.name), alert: 'Could not update variants — see server logs for details.'
300
313
  end
301
314
 
302
315
  private
@@ -313,13 +326,25 @@ module Magick
313
326
  end
314
327
  end
315
328
 
329
+ # A hash-like payload is either a raw Hash or an
330
+ # ActionController::Parameters (both respond to :each with key/value).
331
+ # Rejecting anything else lets us give a 400-style redirect instead of
332
+ # a 500 with a NoMethodError stack trace.
333
+ def hash_like?(obj)
334
+ obj.is_a?(Hash) || (defined?(ActionController::Parameters) && obj.is_a?(ActionController::Parameters))
335
+ end
336
+
316
337
  def set_feature
317
338
  feature_name = params[:id].to_s
318
- @feature = Magick.features[feature_name] || Magick[feature_name]
319
- redirect_to magick_admin_ui.features_path, alert: 'Feature not found' unless @feature
320
-
321
- # Ensure we're working with the registered feature instance to keep state in sync
322
- @feature = Magick.features[feature_name] if Magick.features.key?(feature_name)
339
+ # Do NOT fall back to Magick[feature_name] that would lazily create
340
+ # and persist a brand-new feature from user-controlled input, letting
341
+ # an attacker (or even a stray crawler) pollute Redis/AR with arbitrary
342
+ # keys. Look up only registered features; 404 otherwise.
343
+ @feature = Magick.features[feature_name]
344
+ return if @feature
345
+
346
+ redirect_to magick_admin_ui.features_path, alert: 'Feature not found'
347
+ nil
323
348
  end
324
349
  end
325
350
  end
@@ -3,6 +3,9 @@
3
3
  module Magick
4
4
  module AdminUI
5
5
  class StatsController < ActionController::Base
6
+ include ::ActionController::RequestForgeryProtection
7
+ protect_from_forgery with: :exception
8
+
6
9
  include Magick::AdminUI::Engine.routes.url_helpers
7
10
  layout 'application'
8
11
 
@@ -13,8 +16,13 @@ module Magick
13
16
  end
14
17
 
15
18
  def show
16
- @feature = Magick.features[params[:id].to_s] || Magick[params[:id]]
17
- @stats = Magick.feature_stats(params[:id].to_sym) || {} if @feature
19
+ feature_name = params[:id].to_s
20
+ @feature = Magick.features[feature_name]
21
+ unless @feature
22
+ head :not_found
23
+ return
24
+ end
25
+ @stats = Magick.feature_stats(feature_name.to_sym) || {}
18
26
  end
19
27
  end
20
28
  end
@@ -32,3 +32,21 @@ Magick.configure do
32
32
  # Enable deprecation warnings in logs
33
33
  warn_on_deprecated enabled: true
34
34
  end
35
+
36
+ # Admin UI configuration (optional).
37
+ # The Admin UI is CSRF-protected and 404s on unknown feature IDs, BUT
38
+ # authentication is opt-in — if you mount the engine without configuring
39
+ # `require_role`, the UI will be reachable by anyone who can hit its routes.
40
+ # Always wire it to your host app's auth layer:
41
+ #
42
+ # Magick::AdminUI.configure do |c|
43
+ # c.require_role = ->(controller) { controller.current_user&.admin? }
44
+ # c.available_roles = %w[admin manager]
45
+ # c.available_tags = -> { Tag.pluck(:name) }
46
+ # end
47
+
48
+ # Graceful shutdown is handled automatically in Rails via an at_exit hook
49
+ # wired by the Railtie. In long-running non-Rails processes (rake tasks,
50
+ # CLI tools), call `Magick.shutdown!` explicitly before exit so the
51
+ # Redis Pub/Sub subscriber and async metrics thread terminate cleanly.
52
+
@@ -33,9 +33,13 @@ module Magick
33
33
  {}
34
34
  end
35
35
 
36
- # Bulk set multiple keys for a feature in one call (override for efficiency)
37
- def set_all_data(_feature_name, _data_hash)
38
- # Default: no-op, subclasses override
36
+ # Bulk set multiple keys for a feature in one call.
37
+ # Subclasses MUST implement this — a no-op default silently drops
38
+ # bulk writes, which is why this used to cause hard-to-diagnose lost
39
+ # updates for custom adapters (audit P2-Co6).
40
+ def set_all_data(feature_name, data_hash)
41
+ raise NotImplementedError,
42
+ "#{self.class} must implement #set_all_data (feature=#{feature_name}, keys=#{data_hash.keys.inspect})"
39
43
  end
40
44
  end
41
45
  end
@@ -31,6 +31,7 @@ module Magick
31
31
 
32
32
  def set(feature_name, key, value)
33
33
  mutex.synchronize do
34
+ cleanup_expired_if_needed
34
35
  feature_name_str = feature_name.to_s
35
36
  store[feature_name_str] ||= {}
36
37
  store[feature_name_str][key.to_s] = serialize_value(value)
@@ -91,6 +92,7 @@ module Magick
91
92
  # Bulk set all data for a feature (used by preloading)
92
93
  def set_all_data(feature_name, data_hash)
93
94
  mutex.synchronize do
95
+ cleanup_expired_if_needed
94
96
  feature_name_str = feature_name.to_s
95
97
  store[feature_name_str] ||= {}
96
98
  data_hash.each do |key, value|
@@ -103,13 +103,24 @@ module Magick
103
103
 
104
104
  attr_reader :redis, :namespace
105
105
 
106
- # Use SCAN instead of KEYS to avoid blocking Redis
106
+ # Use SCAN instead of KEYS to avoid blocking Redis.
107
+ # A mid-scan timeout would otherwise lose the cursor; retry once with
108
+ # exponential backoff before surfacing the error to the caller.
107
109
  def scan_keys
108
110
  pattern = "#{namespace}:*"
109
111
  keys = []
110
112
  cursor = '0'
113
+ retries = 0
111
114
  loop do
112
- cursor, batch = redis.scan(cursor, match: pattern, count: 100)
115
+ begin
116
+ cursor, batch = redis.scan(cursor, match: pattern, count: 100)
117
+ rescue StandardError => e
118
+ raise if retries >= 1
119
+
120
+ retries += 1
121
+ sleep(0.05 * retries)
122
+ retry
123
+ end
113
124
  keys.concat(batch)
114
125
  break if cursor == '0'
115
126
  end
@@ -7,6 +7,12 @@ module Magick
7
7
 
8
8
  LOCAL_WRITE_TTL = 2.0 # seconds to ignore self-invalidation after a local write
9
9
 
10
+ # Accept only conservative feature identifiers coming off the wire.
11
+ # Anything outside this alphabet (newlines, spaces, Unicode punctuation,
12
+ # 200-char garbage) is a sign of a malformed or malicious publisher and
13
+ # must not be fed back into Magick.features[...] / feature.reload.
14
+ FEATURE_NAME_PATTERN = /\A[a-zA-Z0-9_\-.:]{1,120}\z/.freeze
15
+
10
16
  def initialize(memory_adapter, redis_adapter = nil, active_record_adapter: nil, circuit_breaker: nil,
11
17
  async: false, primary: nil)
12
18
  @memory_adapter = memory_adapter
@@ -23,11 +29,31 @@ module Magick
23
29
  @reload_mutex = Mutex.new
24
30
  @stopping = false
25
31
  @shutdown_mutex = Mutex.new
32
+ @owner_pid = Process.pid
26
33
  # Only start Pub/Sub subscriber if Redis is available
27
34
  # In memory-only mode, each process has isolated cache (no cross-process invalidation)
28
35
  start_cache_invalidation_subscriber if redis_adapter
29
36
  end
30
37
 
38
+ # Restart the Pub/Sub subscriber after a fork. The subscriber thread is
39
+ # not carried into child processes, so a worker inheriting a stale
40
+ # reference must re-create its own subscription. Safe to call on every
41
+ # request; it only does work when Process.pid changes.
42
+ def ensure_subscriber!
43
+ return if @owner_pid == Process.pid
44
+
45
+ @shutdown_mutex.synchronize do
46
+ return if @owner_pid == Process.pid
47
+
48
+ @subscriber_thread = nil
49
+ @subscriber = nil
50
+ @owner_pid = Process.pid
51
+ @stopping = false
52
+ end
53
+
54
+ start_cache_invalidation_subscriber if redis_adapter
55
+ end
56
+
31
57
  # Gracefully terminate the Pub/Sub subscriber thread and its Redis connection.
32
58
  # Without this, Ruby/Puma shutdown waits on the blocking `subscribe` call.
33
59
  def shutdown(timeout: 5)
@@ -99,11 +125,7 @@ module Magick
99
125
  end
100
126
 
101
127
  if @async && defined?(Thread)
102
- Thread.new do
103
- update_redis.call
104
- # Publish AFTER Redis write so other processes read fresh data
105
- publish_cache_invalidation(feature_name)
106
- end
128
+ spawn_async_write(feature_name, update_redis)
107
129
  else
108
130
  update_redis.call
109
131
  publish_cache_invalidation(feature_name)
@@ -249,11 +271,7 @@ module Magick
249
271
  end
250
272
 
251
273
  if @async && defined?(Thread)
252
- Thread.new do
253
- update_redis.call
254
- # Publish AFTER Redis write so other processes read fresh data
255
- publish_cache_invalidation(feature_name)
256
- end
274
+ spawn_async_write(feature_name, update_redis)
257
275
  else
258
276
  update_redis.call
259
277
  publish_cache_invalidation(feature_name)
@@ -337,11 +355,31 @@ module Magick
337
355
  thread.join(1) # give it a moment to actually unwind
338
356
  end
339
357
 
358
+ # Fire-and-forget async Redis write. Wrapped so that a failure in the
359
+ # update or publish step is logged rather than silently killing the
360
+ # thread — Thread#abort_on_exception is false, which otherwise swallows
361
+ # the error completely.
362
+ def spawn_async_write(feature_name, update_redis)
363
+ thread = Thread.new do
364
+ update_redis.call
365
+ publish_cache_invalidation(feature_name)
366
+ rescue StandardError => e
367
+ warn "Magick: async Redis write failed for '#{Magick::LogSafe.sanitize(feature_name)}': #{e.class}: #{Magick::LogSafe.sanitize(e.message)}"
368
+ end
369
+ thread.name = "magick-async-write-#{feature_name}" if thread.respond_to?(:name=)
370
+ thread.abort_on_exception = false
371
+ thread
372
+ end
373
+
340
374
  # Record that this process just wrote a feature, so the subscriber
341
375
  # ignores its own Pub/Sub messages and doesn't revert the correct in-memory state.
342
376
  def record_local_write(feature_name)
343
377
  @reload_mutex.synchronize do
344
378
  @local_writes[feature_name.to_s] = Time.now.to_f
379
+ # Also sweep stale tracking entries on the write path — a write-heavy
380
+ # process that rarely reads would otherwise never trigger cleanup,
381
+ # letting @local_writes and @last_reload_times grow unboundedly.
382
+ cleanup_stale_tracking_entries
345
383
  end
346
384
  end
347
385
 
@@ -406,6 +444,16 @@ module Magick
406
444
  on.message do |_channel, feature_name|
407
445
  feature_name_str = feature_name.to_s
408
446
 
447
+ # Reject malformed payloads before doing anything with them.
448
+ # A shared Redis DB is not a trust boundary — reject anything
449
+ # that isn't a plausible feature identifier.
450
+ unless FEATURE_NAME_PATTERN.match?(feature_name_str)
451
+ if defined?(Rails) && Rails.env.development?
452
+ warn "Magick: ignoring malformed pubsub payload (#{feature_name_str.bytesize}B)"
453
+ end
454
+ next
455
+ end
456
+
409
457
  # Skip self-invalidation: if this process just wrote this feature,
410
458
  # memory already has the correct value. Reloading from Redis would
411
459
  # revert it to stale data (especially with async writes).
@@ -457,7 +505,7 @@ module Magick
457
505
  end
458
506
 
459
507
  if defined?(Rails) && Rails.env.development?
460
- warn "Magick: Error processing cache invalidation for '#{feature_name}': #{e.message}"
508
+ warn "Magick: Error processing cache invalidation for '#{Magick::LogSafe.sanitize(feature_name)}': #{Magick::LogSafe.sanitize(e.message)}"
461
509
  end
462
510
  end
463
511
  end
@@ -4,28 +4,33 @@ module Magick
4
4
  module AdminUI
5
5
  module Helpers
6
6
  def self.feature_status_badge(status)
7
- case status.to_sym
8
- when :active
9
- '<span class="badge badge-success">Active</span>'
10
- when :deprecated
11
- '<span class="badge badge-warning">Deprecated</span>'
12
- when :inactive
13
- '<span class="badge badge-danger">Inactive</span>'
7
+ klass = case status.to_sym
8
+ when :active then 'badge badge-success'
9
+ when :deprecated then 'badge badge-warning'
10
+ when :inactive then 'badge badge-danger'
11
+ else 'badge'
12
+ end
13
+ label = case status.to_sym
14
+ when :active then 'Active'
15
+ when :deprecated then 'Deprecated'
16
+ when :inactive then 'Inactive'
17
+ else 'Unknown'
18
+ end
19
+
20
+ if defined?(ActionController::Base)
21
+ ActionController::Base.helpers.content_tag(:span, label, class: klass)
14
22
  else
15
- '<span class="badge">Unknown</span>'
23
+ # Fallback when Rails is not present. Label is a whitelisted literal.
24
+ "<span class=\"#{klass}\">#{label}</span>"
16
25
  end
17
26
  end
18
27
 
19
28
  def self.feature_type_label(type)
20
29
  case type.to_sym
21
- when :boolean
22
- 'Boolean'
23
- when :string
24
- 'String'
25
- when :number
26
- 'Number'
27
- else
28
- type.to_s.capitalize
30
+ when :boolean then 'Boolean'
31
+ when :string then 'String'
32
+ when :number then 'Number'
33
+ else type.to_s.capitalize
29
34
  end
30
35
  end
31
36
  end
@@ -26,20 +26,27 @@ module Magick
26
26
  end
27
27
  end
28
28
 
29
- def initialize(adapter = nil)
29
+ DEFAULT_MAX_ENTRIES = 10_000
30
+
31
+ def initialize(adapter = nil, max_entries: DEFAULT_MAX_ENTRIES)
30
32
  @adapter = adapter || default_adapter
31
33
  @logs = []
34
+ @max_entries = max_entries.to_i.positive? ? max_entries.to_i : DEFAULT_MAX_ENTRIES
32
35
  @mutex = Mutex.new
33
36
  end
34
37
 
38
+ attr_reader :max_entries
39
+
35
40
  def log(feature_name, action, user_id: nil, changes: {}, metadata: {})
36
41
  entry = Entry.new(feature_name, action, user_id: user_id, changes: changes, metadata: metadata)
37
42
  @mutex.synchronize do
38
43
  @logs << entry
44
+ # Cap in-memory ring; older entries fall out once we cross the limit.
45
+ # This keeps long-running processes from growing @logs unboundedly.
46
+ @logs.shift while @logs.size > @max_entries
39
47
  @adapter.append(entry) if @adapter.respond_to?(:append)
40
48
  end
41
49
 
42
- # Rails 8+ event
43
50
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
44
51
  Magick::Rails::Events.audit_logged(feature_name, action: action, user_id: user_id, changes: changes, **metadata)
45
52
  end
@@ -48,9 +55,13 @@ module Magick
48
55
  end
49
56
 
50
57
  def entries(feature_name: nil, limit: 100)
51
- result = @logs
52
- result = result.select { |e| e.feature_name == feature_name.to_s } if feature_name
53
- result.last(limit)
58
+ snapshot = @mutex.synchronize { @logs.dup }
59
+ snapshot = snapshot.select { |e| e.feature_name == feature_name.to_s } if feature_name
60
+ snapshot.last(limit)
61
+ end
62
+
63
+ def size
64
+ @mutex.synchronize { @logs.size }
54
65
  end
55
66
 
56
67
  private
data/lib/magick/config.rb CHANGED
@@ -317,9 +317,29 @@ module Magick
317
317
  config
318
318
  end
319
319
 
320
+ # Load a Magick configuration DSL file by path.
321
+ #
322
+ # SECURITY: This method evaluates the file's contents as Ruby via
323
+ # instance_eval. Never pass a path derived from HTTP input, ENV
324
+ # variables, build artifacts, or any other untrusted source — doing so
325
+ # is remote code execution. Callers must guarantee the path points at a
326
+ # file that lives inside the project tree (typical use:
327
+ # Rails.root.join('config/features.rb')).
328
+ #
329
+ # The path is resolved with File.realpath and must be inside the
330
+ # current working directory. An explicit opt-in env var
331
+ # (MAGICK_ALLOW_CONFIG_EVAL=1) is required to load paths outside CWD.
320
332
  def self.load_from_file(file_path)
333
+ resolved = File.realpath(file_path)
334
+ allow_outside_cwd = ENV['MAGICK_ALLOW_CONFIG_EVAL'] == '1'
335
+ unless allow_outside_cwd || resolved.start_with?(Dir.pwd)
336
+ raise SecurityError,
337
+ "Refusing to load Magick config from outside the project tree: #{resolved}. " \
338
+ 'Set MAGICK_ALLOW_CONFIG_EVAL=1 to override (only if you trust the file).'
339
+ end
340
+
321
341
  config = Config.new
322
- config.instance_eval(File.read(file_path), file_path)
342
+ config.instance_eval(File.read(resolved), resolved)
323
343
  config.apply!
324
344
  config
325
345
  end
@@ -4,12 +4,23 @@ require 'json'
4
4
 
5
5
  module Magick
6
6
  class ExportImport
7
+ # Hard cap on the number of features accepted by a single import call.
8
+ # Guards against DoS via an oversized payload and (combined with Admin UI
9
+ # auth) stops an attacker from using import as a flag-replacement
10
+ # primitive. Override with the MAGICK_MAX_IMPORT_FEATURES env var.
11
+ DEFAULT_MAX_IMPORT_FEATURES = 10_000
12
+
13
+ class ImportError < StandardError; end
14
+
15
+ def self.max_import_features
16
+ ENV.fetch('MAGICK_MAX_IMPORT_FEATURES', DEFAULT_MAX_IMPORT_FEATURES).to_i
17
+ end
18
+
7
19
  def self.export(features_hash)
8
20
  result = features_hash.map do |_name, feature|
9
21
  feature.to_h
10
22
  end
11
23
 
12
- # Rails 8+ event
13
24
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
14
25
  Magick::Rails::Events.exported(format: :hash, feature_count: result.length)
15
26
  end
@@ -20,7 +31,6 @@ module Magick
20
31
  def self.export_json(features_hash)
21
32
  result = JSON.pretty_generate(export(features_hash))
22
33
 
23
- # Rails 8+ event
24
34
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
25
35
  Magick::Rails::Events.exported(format: :json, feature_count: features_hash.length)
26
36
  end
@@ -31,54 +41,135 @@ module Magick
31
41
  def self.import(data, adapter_registry)
32
42
  features = {}
33
43
  data = JSON.parse(data) if data.is_a?(String)
44
+ list = Array(data)
34
45
 
35
- Array(data).each do |feature_data|
36
- name = feature_data['name'] || feature_data[:name]
37
- next unless name
46
+ cap = max_import_features
47
+ if list.size > cap
48
+ raise ImportError,
49
+ "Magick.import: refused to import #{list.size} features; limit is #{cap}. " \
50
+ 'Set MAGICK_MAX_IMPORT_FEATURES to override.'
51
+ end
38
52
 
39
- feature = Feature.new(
40
- name,
41
- adapter_registry,
42
- type: (feature_data['type'] || feature_data[:type] || :boolean).to_sym,
43
- status: (feature_data['status'] || feature_data[:status] || :active).to_sym,
44
- default_value: feature_data['default_value'] || feature_data[:default_value],
45
- description: feature_data['description'] || feature_data[:description]
46
- )
47
-
48
- if feature_data['value'] || feature_data[:value]
49
- feature.set_value(feature_data['value'] || feature_data[:value])
53
+ list.each do |feature_data|
54
+ unless feature_data.is_a?(Hash)
55
+ raise ImportError, "Magick.import: each feature payload must be a Hash, got #{feature_data.class}"
50
56
  end
51
57
 
52
- # Import targeting
53
- if feature_data['targeting'] || feature_data[:targeting]
54
- targeting = feature_data['targeting'] || feature_data[:targeting]
55
- targeting.each do |type, values|
56
- Array(values).each do |value|
57
- case type.to_sym
58
- when :user
59
- feature.enable_for_user(value)
60
- when :group
61
- feature.enable_for_group(value)
62
- when :role
63
- feature.enable_for_role(value)
64
- when :percentage_users
65
- feature.enable_percentage_of_users(value)
66
- when :percentage_requests
67
- feature.enable_percentage_of_requests(value)
68
- end
69
- end
70
- end
71
- end
58
+ name = fetch(feature_data, :name)
59
+ next unless name
60
+
61
+ feature = build_feature(name, feature_data, adapter_registry)
62
+ apply_value(feature, feature_data)
63
+ apply_targeting(feature, fetch(feature_data, :targeting) || {})
64
+ apply_variants(feature, fetch(feature_data, :variants) || [])
65
+ apply_dependencies(feature, fetch(feature_data, :dependencies) || [])
72
66
 
73
67
  features[name.to_s] = feature
74
68
  end
75
69
 
76
- # Rails 8+ event
77
70
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
78
71
  Magick::Rails::Events.imported(format: :json, feature_count: features.length)
79
72
  end
80
73
 
81
74
  features
82
75
  end
76
+
77
+ def self.fetch(hash, key)
78
+ # Must not use `||` — falsy legitimate values (false, 0, "") would
79
+ # silently fall through to the string-key lookup (and then to nil).
80
+ return hash[key] if hash.key?(key)
81
+
82
+ string_key = key.to_s
83
+ return hash[string_key] if hash.key?(string_key)
84
+
85
+ nil
86
+ end
87
+
88
+ def self.build_feature(name, feature_data, adapter_registry)
89
+ Feature.new(
90
+ name,
91
+ adapter_registry,
92
+ type: (fetch(feature_data, :type) || :boolean).to_sym,
93
+ status: (fetch(feature_data, :status) || :active).to_sym,
94
+ default_value: fetch(feature_data, :default_value),
95
+ description: fetch(feature_data, :description),
96
+ display_name: fetch(feature_data, :display_name),
97
+ group: fetch(feature_data, :group)
98
+ )
99
+ end
100
+
101
+ def self.apply_value(feature, feature_data)
102
+ value = fetch(feature_data, :value)
103
+ feature.set_value(value) if !value.nil? && !(value.is_a?(String) && value.empty?)
104
+ end
105
+
106
+ # rubocop:disable Metrics/MethodLength
107
+ # rubocop:disable Metrics/CyclomaticComplexity
108
+ def self.apply_targeting(feature, targeting)
109
+ targeting.each do |type, values|
110
+ case type.to_sym
111
+ when :user, :users
112
+ Array(values).each { |v| feature.enable_for_user(v) }
113
+ when :excluded_users
114
+ Array(values).each { |v| feature.exclude_user(v) }
115
+ when :group, :groups
116
+ Array(values).each { |v| feature.enable_for_group(v) }
117
+ when :excluded_groups
118
+ Array(values).each { |v| feature.exclude_group(v) }
119
+ when :role, :roles
120
+ Array(values).each { |v| feature.enable_for_role(v) }
121
+ when :excluded_roles
122
+ Array(values).each { |v| feature.exclude_role(v) }
123
+ when :tag, :tags
124
+ Array(values).each { |v| feature.enable_for_tag(v) }
125
+ when :excluded_tags
126
+ Array(values).each { |v| feature.exclude_tag(v) }
127
+ when :ip_address, :ip_addresses
128
+ feature.enable_for_ip_addresses(Array(values))
129
+ when :excluded_ip_addresses
130
+ feature.exclude_ip_addresses(Array(values))
131
+ when :percentage_users
132
+ feature.enable_percentage_of_users(values)
133
+ when :percentage_requests
134
+ feature.enable_percentage_of_requests(values)
135
+ when :date_range
136
+ range = values.is_a?(Hash) ? values.transform_keys(&:to_sym) : values
137
+ feature.enable_for_date_range(range[:start], range[:end]) if range.is_a?(Hash) && range[:start] && range[:end]
138
+ when :custom_attributes
139
+ apply_custom_attributes(feature, values)
140
+ end
141
+ end
142
+ end
143
+ # rubocop:enable Metrics/MethodLength
144
+ # rubocop:enable Metrics/CyclomaticComplexity
145
+
146
+ def self.apply_custom_attributes(feature, values)
147
+ return unless values.is_a?(Hash)
148
+
149
+ values.each do |attr, rule|
150
+ rule_h = rule.is_a?(Hash) ? rule.transform_keys(&:to_sym) : {}
151
+ next unless rule_h[:values]
152
+
153
+ feature.enable_for_custom_attribute(attr, rule_h[:values], operator: (rule_h[:operator] || :equals).to_sym)
154
+ end
155
+ end
156
+
157
+ def self.apply_variants(feature, variants)
158
+ return unless feature.respond_to?(:add_variant)
159
+
160
+ Array(variants).each do |v|
161
+ h = v.is_a?(Hash) ? v.transform_keys(&:to_sym) : {}
162
+ next unless h[:name]
163
+
164
+ feature.add_variant(h[:name], weight: h[:weight], value: h[:value])
165
+ end
166
+ end
167
+
168
+ def self.apply_dependencies(feature, deps)
169
+ list = Array(deps).map(&:to_s)
170
+ return if list.empty?
171
+
172
+ feature.instance_variable_set(:@dependencies, list)
173
+ end
83
174
  end
84
175
  end
@@ -85,7 +85,7 @@ module Magick
85
85
  perf_metrics.record(name, 'enabled?', duration, success: false)
86
86
  end
87
87
  # Return false on any error (fail-safe)
88
- warn "Magick: Error checking feature '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
88
+ warn "Magick: Error checking feature '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
89
89
  false
90
90
  end
91
91
 
@@ -107,6 +107,11 @@ module Magick
107
107
  return false if status == :inactive
108
108
  return false if status == :deprecated && !context[:allow_deprecated]
109
109
 
110
+ # Dependency check: a feature with unmet prerequisites evaluates as disabled,
111
+ # regardless of its own configured state. Evaluation-only — prerequisite state
112
+ # is never written into this feature.
113
+ return false unless dependencies_satisfied?(context)
114
+
110
115
  # Fast path: skip targeting checks if targeting is empty (most common case)
111
116
  unless @_targeting_empty
112
117
  # Check exclusions FIRST — exclusions always take priority over inclusions
@@ -152,7 +157,7 @@ module Magick
152
157
  end
153
158
  rescue StandardError => e
154
159
  # Return false on any error (fail-safe)
155
- warn "Magick: Error in check_enabled for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
160
+ warn "Magick: Error in check_enabled for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
156
161
  false
157
162
  end
158
163
 
@@ -223,7 +228,7 @@ module Magick
223
228
  end
224
229
  rescue StandardError => e
225
230
  # Return default value on error (fail-safe)
226
- warn "Magick: Error in get_value for '#{name}': #{e.message}" if defined?(Rails) && Rails.env.development?
231
+ warn "Magick: Error in get_value for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.message)}" if defined?(Rails) && Rails.env.development?
227
232
  default_value
228
233
  end
229
234
 
@@ -310,7 +315,15 @@ module Magick
310
315
  end
311
316
 
312
317
  def exclude_ip_addresses(ip_addresses)
313
- enable_targeting(:excluded_ip_addresses, Array(ip_addresses))
318
+ # excluded_ip_addresses is stored as a flat Array of strings; bypass the
319
+ # generic enable_targeting path whose "array type" branch stringifies the
320
+ # incoming array into a single element.
321
+ @targeting[:excluded_ip_addresses] ||= []
322
+ Array(ip_addresses).each do |ip|
323
+ str = ip.to_s
324
+ @targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str)
325
+ end
326
+ save_targeting
314
327
  true
315
328
  end
316
329
 
@@ -370,7 +383,15 @@ module Magick
370
383
  end
371
384
 
372
385
  def enable_for_ip_addresses(ip_addresses)
373
- enable_targeting(:ip_address, Array(ip_addresses))
386
+ # ip_address is stored as a flat Array of strings; bypass the generic
387
+ # enable_targeting path whose "array type" branch stringifies the
388
+ # incoming array into a single '["x.y.z"]' entry.
389
+ @targeting[:ip_address] ||= []
390
+ Array(ip_addresses).each do |ip|
391
+ str = ip.to_s
392
+ @targeting[:ip_address] << str unless @targeting[:ip_address].include?(str)
393
+ end
394
+ save_targeting
374
395
  true
375
396
  end
376
397
 
@@ -516,18 +537,6 @@ module Magick
516
537
  end
517
538
 
518
539
  def enable(user_id: nil)
519
- # Check that all of this feature's own dependencies are enabled
520
- # e.g. if checkout depends on payments, checkout can't be enabled until payments is
521
- deps = @dependencies || []
522
- unless deps.empty?
523
- disabled_deps = deps.select do |dep_name|
524
- dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
525
- dep_feature && !dep_feature.enabled?
526
- end
527
-
528
- return false unless disabled_deps.empty?
529
- end
530
-
531
540
  # Clear all targeting to enable globally
532
541
  @targeting = {}
533
542
  save_targeting
@@ -573,9 +582,6 @@ module Magick
573
582
  registered.instance_variable_set(:@targeting, {})
574
583
  end
575
584
 
576
- # Cascade disable: disable all features that depend on this one
577
- disable_dependent_features(user_id: user_id)
578
-
579
585
  # Rails 8+ event
580
586
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
581
587
  Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
@@ -645,15 +651,31 @@ module Magick
645
651
  {
646
652
  name: name,
647
653
  display_name: display_name,
654
+ group: group,
648
655
  type: type,
649
656
  status: status,
650
657
  value: stored_value,
651
658
  default_value: default_value,
652
659
  description: description,
653
- targeting: targeting
660
+ targeting: targeting,
661
+ dependencies: (@dependencies || []).dup,
662
+ variants: variants_for_export
654
663
  }
655
664
  end
656
665
 
666
+ def variants_for_export
667
+ return [] unless defined?(Magick::FeatureVariant)
668
+
669
+ raw = @variants || []
670
+ raw.map do |v|
671
+ if v.is_a?(Magick::FeatureVariant)
672
+ { name: v.name, weight: v.weight, value: v.value }
673
+ else
674
+ v
675
+ end
676
+ end
677
+ end
678
+
657
679
  def save_targeting
658
680
  # Save targeting to adapter (this updates memory synchronously, then Redis/AR)
659
681
  # The set method already publishes cache invalidation to other processes via Pub/Sub
@@ -983,42 +1005,18 @@ module Magick
983
1005
  context
984
1006
  end
985
1007
 
986
- def disable_dependent_features(user_id: nil)
987
- # Cascade-disable features that depend ON this feature.
988
- # e.g. if checkout depends on payments, disabling payments also disables checkout.
989
- dependents = find_dependent_features
990
- return if dependents.empty?
991
-
992
- dependents.each do |dep_name|
993
- dep_feature = Magick.features[dep_name.to_s] || Magick[dep_name]
994
- next unless dep_feature
995
-
996
- dep_feature.instance_variable_set(:@targeting, {})
997
- dep_feature.save_targeting
1008
+ def dependencies_satisfied?(context)
1009
+ deps = @dependencies
1010
+ return true if deps.nil? || deps.empty?
998
1011
 
999
- case dep_feature.type
1000
- when :boolean
1001
- dep_feature.set_value(false, user_id: user_id)
1002
- when :string
1003
- dep_feature.set_value('', user_id: user_id)
1004
- when :number
1005
- dep_feature.set_value(0, user_id: user_id)
1006
- end
1007
-
1008
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
1009
- Magick::Rails::Events.feature_disabled_globally(dep_name, user_id: user_id)
1010
- end
1011
- end
1012
- end
1012
+ deps.all? do |dep_name|
1013
+ dep_feature = Magick.features[dep_name.to_s] || Magick.features[dep_name.to_sym]
1014
+ # Unknown dependency is treated as satisfied — matches prior behavior
1015
+ # where missing features were skipped in cascade logic.
1016
+ next true unless dep_feature
1013
1017
 
1014
- def find_dependent_features
1015
- # Find all features that have this feature in their dependencies
1016
- dependent_features = []
1017
- Magick.features.each do |_name, feature|
1018
- feature_deps = feature.instance_variable_get(:@dependencies) || []
1019
- dependent_features << feature.name if feature_deps.include?(name.to_s) || feature_deps.include?(name.to_sym)
1018
+ dep_feature.enabled?(context)
1020
1019
  end
1021
- dependent_features
1022
1020
  end
1023
1021
 
1024
1022
  def excluded?(context)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magick
4
+ # Sanitize strings before they go into logs / warnings.
5
+ # Two concerns:
6
+ # 1) Newlines in a user-influenced string (feature name, exception
7
+ # message) let an attacker forge log entries ("log injection").
8
+ # 2) A long payload can flood a log pipeline.
9
+ # `LogSafe.sanitize` returns a single line at most 256 chars, control
10
+ # characters replaced with spaces.
11
+ module LogSafe
12
+ MAX_LEN = 256
13
+ CONTROL_CHARS = /[\r\n\t\e\u0000-\u001f\u007f]/.freeze
14
+
15
+ def self.sanitize(value, max: MAX_LEN)
16
+ str = value.to_s.dup
17
+ str.gsub!(CONTROL_CHARS, ' ')
18
+ str = str[0, max] if str.length > max
19
+ str
20
+ end
21
+ end
22
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Magick
4
4
  class PerformanceMetrics
5
+ METRICS_RING_CAP = 1_000
6
+
5
7
  class Metric
6
8
  attr_reader :feature_name, :operation, :duration, :timestamp, :success
7
9
 
@@ -45,7 +47,26 @@ module Magick
45
47
  @async_queue = Queue.new
46
48
  @async_thread = nil
47
49
  @async_enabled = true # Enable async by default for performance
50
+ @owner_pid = Process.pid
51
+ start_async_processor
52
+ end
53
+
54
+ # Restart the async processor after a fork. Child processes inherit a dead
55
+ # thread reference + a queue that was populated in the parent; both must
56
+ # be recreated. The inherited thread (if alive in the parent's address
57
+ # space at fork time) is killed so it cannot keep polling a detached queue.
58
+ def ensure_async_processor!
59
+ return if @owner_pid == Process.pid
60
+
61
+ stale_thread = @async_thread
62
+ stale_queue = @async_queue
63
+ @async_queue = Queue.new
64
+ @async_thread = nil
65
+ @owner_pid = Process.pid
48
66
  start_async_processor
67
+
68
+ stale_queue&.close if stale_queue.respond_to?(:close)
69
+ stale_thread&.kill
49
70
  end
50
71
 
51
72
  # Public accessor for redis_enabled
@@ -74,8 +95,9 @@ module Magick
74
95
  pending_count = nil
75
96
  total_pending = nil
76
97
  @mutex.synchronize do
77
- # Only create Metric object if we're keeping metrics in memory
78
- if @metrics.length < 1000
98
+ # Pre-insert cap: construct + append only when there is room.
99
+ # Keeps @metrics bounded at METRICS_RING_CAP under any load.
100
+ if @metrics.length < METRICS_RING_CAP
79
101
  metric = Metric.new(feature_name_str, operation_str, duration, success: success)
80
102
  @metrics << metric
81
103
  end
@@ -83,8 +105,6 @@ module Magick
83
105
  @pending_updates[feature_name_str] += 1
84
106
  pending_count = @pending_updates[feature_name_str]
85
107
  total_pending = @pending_updates.values.sum
86
- # Keep only last 1000 metrics (as a safety limit)
87
- @metrics.shift if @metrics.length > 1000
88
108
  end
89
109
 
90
110
  # Rails 8+ event for usage tracking (cached check)
@@ -119,6 +119,12 @@ if defined?(Rails)
119
119
  config.to_prepare do
120
120
  RequestStore.store[:magick_features] ||= {} if defined?(RequestStore)
121
121
 
122
+ # Restart background threads after Puma fork. ensure_*! is a no-op
123
+ # when Process.pid matches the owner pid, so it is cheap to call.
124
+ registry = Magick.adapter_registry
125
+ registry.ensure_subscriber! if registry.respond_to?(:ensure_subscriber!)
126
+ Magick.performance_metrics&.ensure_async_processor!
127
+
122
128
  # Final check: ensure Redis tracking is enabled (runs on every request in development)
123
129
  # This is the absolute last chance to enable it
124
130
  if Magick.performance_metrics && Magick.adapter_registry.is_a?(Adapters::Registry) && Magick.adapter_registry.redis_available? && !Magick.performance_metrics.redis_enabled
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.3.2'
4
+ VERSION = '1.4.2'
5
5
  end
@@ -30,19 +30,21 @@ module Magick
30
30
 
31
31
  def save_version(feature_name, version: nil, created_by: nil)
32
32
  feature = Magick.features[feature_name.to_s] || Magick[feature_name]
33
- version ||= next_version(feature_name)
34
- version_data = Version.new(version, feature.to_h, created_by: created_by)
35
-
36
- @mutex.synchronize do
37
- @versions[feature_name.to_s] ||= []
38
- @versions[feature_name.to_s] << version_data
39
- # Store in adapter
40
- @adapter_registry.set(feature_name, "version_#{version}", version_data.to_h)
33
+ feature_name_str = feature_name.to_s
34
+
35
+ # Compute version + append under the same mutex so two concurrent
36
+ # save_version calls on the same feature can't both assign version N.
37
+ version_data = @mutex.synchronize do
38
+ list = (@versions[feature_name_str] ||= [])
39
+ resolved_version = version || (list.empty? ? 1 : list.last.version + 1)
40
+ entry = Version.new(resolved_version, feature.to_h, created_by: created_by)
41
+ list << entry
42
+ @adapter_registry.set(feature_name, "version_#{resolved_version}", entry.to_h)
43
+ entry
41
44
  end
42
45
 
43
- # Rails 8+ event
44
46
  if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
45
- Magick::Rails::Events.version_saved(feature_name, version: version, created_by: created_by)
47
+ Magick::Rails::Events.version_saved(feature_name, version: version_data.version, created_by: created_by)
46
48
  end
47
49
 
48
50
  version_data
@@ -83,14 +85,16 @@ module Magick
83
85
  end
84
86
 
85
87
  def get_versions(feature_name)
86
- @versions[feature_name.to_s] || []
88
+ @mutex.synchronize { (@versions[feature_name.to_s] || []).dup }
87
89
  end
88
90
 
89
91
  private
90
92
 
91
93
  def next_version(feature_name)
92
- versions = get_versions(feature_name)
93
- versions.empty? ? 1 : versions.last.version + 1
94
+ @mutex.synchronize do
95
+ list = @versions[feature_name.to_s] || []
96
+ list.empty? ? 1 : list.last.version + 1
97
+ end
94
98
  end
95
99
  end
96
100
  end
data/lib/magick.rb CHANGED
@@ -18,15 +18,19 @@ require_relative 'magick/targeting/group'
18
18
  require_relative 'magick/targeting/role'
19
19
  require_relative 'magick/targeting/percentage'
20
20
  require_relative 'magick/targeting/request_percentage'
21
+ require_relative 'magick/targeting/date_range'
22
+ require_relative 'magick/targeting/ip_address'
23
+ require_relative 'magick/targeting/custom_attribute'
24
+ require_relative 'magick/targeting/complex'
21
25
  require_relative 'magick/errors'
22
26
 
27
+ require_relative 'magick/log_safe'
23
28
  require_relative 'magick/audit_log'
24
29
  require_relative 'magick/performance_metrics'
25
30
  require_relative 'magick/export_import'
26
31
  require_relative 'magick/versioning'
27
32
  require_relative 'magick/circuit_breaker'
28
33
  require_relative 'magick/testing_helpers'
29
- require_relative 'magick/feature_dependency'
30
34
  require_relative 'magick/documentation'
31
35
  # AdminUI is loaded conditionally via configuration
32
36
  # It is not loaded by default - must be enabled in Magick.configure
@@ -114,13 +118,22 @@ module Magick
114
118
  features[feature_name.to_s] || Feature.new(feature_name, adapter_registry || default_adapter_registry)
115
119
  end
116
120
 
121
+ # Mutex that guards writes to @features. Reads are lock-free by design:
122
+ # register_feature swaps the @features reference to a NEW hash via
123
+ # copy-on-write, so a concurrent iterator keeps its own snapshot and
124
+ # never sees "can't add a new key into hash during iteration".
125
+ FEATURES_MUTEX = Mutex.new
126
+ private_constant :FEATURES_MUTEX
127
+
117
128
  def features
118
129
  @features ||= {}
119
130
  end
120
131
 
121
132
  def register_feature(name, **options)
122
133
  feature = Feature.new(name, adapter_registry || default_adapter_registry, **options)
123
- features[name.to_s] = feature
134
+ FEATURES_MUTEX.synchronize do
135
+ @features = (@features || {}).merge(name.to_s => feature)
136
+ end
124
137
  feature
125
138
  end
126
139
 
@@ -193,7 +206,9 @@ module Magick
193
206
 
194
207
  def import(data, format: :json)
195
208
  imported = ExportImport.import(data, adapter_registry || default_adapter_registry)
196
- imported.each { |name, feature| features[name] = feature }
209
+ FEATURES_MUTEX.synchronize do
210
+ @features = (@features || {}).merge(imported)
211
+ end
197
212
  imported
198
213
  end
199
214
 
@@ -259,9 +274,12 @@ module Magick
259
274
  end
260
275
 
261
276
  def reset!
277
+ safely_shutdown(@adapter_registry) { |r| r.shutdown }
278
+ safely_shutdown(@default_adapter_registry) { |r| r.shutdown }
262
279
  @features = {}
263
280
  @adapter_registry = nil
264
281
  @default_adapter = nil
282
+ @default_adapter_registry = nil
265
283
  @performance_metrics&.clear!
266
284
  end
267
285
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 1.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov
@@ -128,8 +128,8 @@ files:
128
128
  - lib/magick/errors.rb
129
129
  - lib/magick/export_import.rb
130
130
  - lib/magick/feature.rb
131
- - lib/magick/feature_dependency.rb
132
131
  - lib/magick/feature_variant.rb
132
+ - lib/magick/log_safe.rb
133
133
  - lib/magick/performance_metrics.rb
134
134
  - lib/magick/rails.rb
135
135
  - lib/magick/rails/event_subscriber.rb
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Magick
4
- class FeatureDependency
5
- def self.check(feature_name, context = {})
6
- feature = Magick.features[feature_name.to_s] || Magick[feature_name]
7
- dependencies = feature.instance_variable_get(:@dependencies) || []
8
-
9
- dependencies.all? do |dep_name|
10
- Magick.enabled?(dep_name, context)
11
- end
12
- end
13
-
14
- def self.add_dependency(feature_name, dependency_name)
15
- feature = Magick.features[feature_name.to_s] || Magick[feature_name]
16
- dependencies = feature.instance_variable_get(:@dependencies) || []
17
- dependencies << dependency_name.to_s unless dependencies.include?(dependency_name.to_s)
18
- feature.instance_variable_set(:@dependencies, dependencies)
19
- end
20
-
21
- def self.remove_dependency(feature_name, dependency_name)
22
- feature = Magick.features[feature_name.to_s] || Magick[feature_name]
23
- dependencies = feature.instance_variable_get(:@dependencies) || []
24
- dependencies.delete(dependency_name.to_s)
25
- feature.instance_variable_set(:@dependencies, dependencies)
26
- end
27
- end
28
- end