fiber_audit 0.1.0 → 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +19 -0
  3. data/ARCHITECTURE.md +726 -0
  4. data/CHANGELOG.md +30 -0
  5. data/LICENSE +201 -0
  6. data/README.md +66 -8
  7. data/lib/fiber_audit/cli.rb +87 -2
  8. data/lib/fiber_audit/configuration.rb +106 -6
  9. data/lib/fiber_audit/errors.rb +2 -0
  10. data/lib/fiber_audit/operation_vocabulary.rb +42 -0
  11. data/lib/fiber_audit/reporters/text.rb +1 -1
  12. data/lib/fiber_audit/runtime/active_operations.rb +146 -0
  13. data/lib/fiber_audit/runtime/boot.rb +83 -0
  14. data/lib/fiber_audit/runtime/clock.rb +35 -0
  15. data/lib/fiber_audit/runtime/environment.rb +289 -0
  16. data/lib/fiber_audit/runtime/event.rb +86 -0
  17. data/lib/fiber_audit/runtime/execution_context.rb +89 -0
  18. data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
  19. data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
  20. data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
  21. data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
  22. data/lib/fiber_audit/runtime/limits.rb +102 -0
  23. data/lib/fiber_audit/runtime/location.rb +44 -0
  24. data/lib/fiber_audit/runtime/policy.rb +121 -0
  25. data/lib/fiber_audit/runtime/probes/base.rb +333 -0
  26. data/lib/fiber_audit/runtime/probes/http.rb +80 -0
  27. data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
  28. data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
  29. data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
  30. data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
  31. data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
  32. data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
  33. data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
  34. data/lib/fiber_audit/runtime/rails_integration.rb +279 -0
  35. data/lib/fiber_audit/runtime/recorder.rb +333 -0
  36. data/lib/fiber_audit/runtime/redactor.rb +102 -0
  37. data/lib/fiber_audit/runtime/sampler.rb +26 -0
  38. data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
  39. data/lib/fiber_audit/runtime/session.rb +112 -0
  40. data/lib/fiber_audit/runtime/supervisor.rb +114 -0
  41. data/lib/fiber_audit/runtime/validation.rb +68 -0
  42. data/lib/fiber_audit/runtime/watchdog.rb +479 -0
  43. data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
  44. data/lib/fiber_audit/runtime.rb +37 -0
  45. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
  46. data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
  47. data/lib/fiber_audit/static/rules/io_select.rb +2 -4
  48. data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
  49. data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
  50. data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
  51. data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
  52. data/lib/fiber_audit/version.rb +1 -1
  53. data/lib/fiber_audit.rb +1 -0
  54. metadata +38 -2
@@ -5,4 +5,6 @@ module FiberAudit
5
5
  class EmptyEvidenceError < StandardError; end
6
6
  class ReporterError < StandardError; end
7
7
  class ProjectError < StandardError; end
8
+ class RuntimeContractError < ArgumentError; end
9
+ class RuntimeSafetyError < StandardError; end
8
10
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ # Canonical operation names shared by static findings and runtime evidence.
5
+ module OperationVocabulary
6
+ FA1001_TARGETS = {
7
+ 'Kernel' => %i[system exec spawn].freeze,
8
+ 'Open3' => %i[capture2 capture2e capture3 pipeline].freeze,
9
+ 'IO' => %i[popen].freeze,
10
+ 'Process' => %i[waitall detach].freeze
11
+ }.freeze
12
+ FA1001_KERNEL_METHODS = %i[system exec spawn].freeze
13
+
14
+ FA1002_METHODS = %i[join value].freeze
15
+ FA1002_OPERATIONS = %w[Thread.join Thread.value].freeze
16
+
17
+ FA1003_TARGETS = {
18
+ 'Mutex' => %i[lock synchronize try_lock].freeze,
19
+ 'ConditionVariable' => %i[wait].freeze,
20
+ 'Monitor' => %i[synchronize].freeze,
21
+ 'MonitorMixin' => %i[synchronize].freeze
22
+ }.freeze
23
+
24
+ FA1004_THREAD_VARIABLE_METHODS = %i[thread_variable_get thread_variable_set].freeze
25
+ FA1004_INDEX_METHODS = %i[[] []=].freeze
26
+
27
+ FA1005_TARGETS = {
28
+ 'IO' => :select,
29
+ 'Kernel' => :select
30
+ }.freeze
31
+
32
+ FA1006_EXACT = %w[
33
+ TCPSocket TCPServer UDPSocket UNIXSocket UNIXServer Socket IPSocket
34
+ ].freeze
35
+
36
+ FA1007_NET_HTTP_METHODS = %i[get get_response start request].freeze
37
+ FA1007_URI_METHODS = {
38
+ 'URI' => :open,
39
+ 'OpenURI' => :open_uri
40
+ }.freeze
41
+ end
42
+ end
@@ -11,7 +11,7 @@ module FiberAudit
11
11
  # Calls Schema.build then Schema.validate! to enforce the contract.
12
12
  #
13
13
  # Exact contract:
14
- # - Header: `FiberAudit 0.1.0 — static analysis`
14
+ # - Header: `FiberAudit <version> — static analysis`
15
15
  # - Summary counts, suppressed, status
16
16
  # - Disclaimer under status
17
17
  # - Findings: RULE SEVERITY path:line
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'event'
4
+ require_relative 'location'
5
+ require_relative 'validation'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ # Bounded process-local registry shared by the watchdog and future probes.
10
+ class ActiveOperations
11
+ MAX_ENTRIES = 10_000
12
+ MAX_SNAPSHOT = 100
13
+
14
+ Handle = Data.define(:pid, :thread_id, :fiber_id, :sequence)
15
+ Entry = Data.define(
16
+ :thread_id,
17
+ :fiber_id,
18
+ :sequence,
19
+ :operation,
20
+ :location,
21
+ :execution_context,
22
+ :started_monotonic_ns
23
+ )
24
+
25
+ def initialize(pid_source: Process.method(:pid), capacity: MAX_ENTRIES, snapshot_limit: MAX_SNAPSHOT)
26
+ validate_source!(pid_source)
27
+ validate_limit!(capacity, 'capacity', MAX_ENTRIES)
28
+ validate_limit!(snapshot_limit, 'snapshot_limit', MAX_SNAPSHOT)
29
+ @pid_source = pid_source
30
+ @capacity = capacity
31
+ @snapshot_limit = snapshot_limit
32
+ reset_for_process!(current_pid)
33
+ end
34
+
35
+ def register(
36
+ operation:,
37
+ monotonic_ns:,
38
+ location: nil,
39
+ execution_context: :unknown,
40
+ thread: Thread.current,
41
+ fiber: Fiber.current
42
+ )
43
+ ensure_current_process!
44
+ values = normalize_entry(
45
+ operation: operation,
46
+ location: location,
47
+ execution_context: execution_context,
48
+ monotonic_ns: monotonic_ns,
49
+ thread: thread,
50
+ fiber: fiber
51
+ )
52
+
53
+ @mutex.synchronize do
54
+ return if @entries.size >= @capacity
55
+
56
+ @sequence += 1
57
+ entry = Entry.new(sequence: @sequence, **values)
58
+ handle = Handle.new(
59
+ pid: @owner_pid,
60
+ thread_id: entry.thread_id,
61
+ fiber_id: entry.fiber_id,
62
+ sequence: entry.sequence
63
+ )
64
+ @entries[handle] = entry
65
+ handle
66
+ end
67
+ end
68
+
69
+ def finish(handle)
70
+ ensure_current_process!
71
+ return unless handle.is_a?(Handle) && handle.pid == @owner_pid
72
+
73
+ @mutex.synchronize { @entries.delete(handle) }
74
+ end
75
+
76
+ def snapshot(thread_id: nil)
77
+ ensure_current_process!
78
+ normalized_thread_id = Validation.integer(thread_id, 'thread_id', allow_nil: true)
79
+ @mutex.synchronize do
80
+ entries = @entries.values
81
+ entries = entries.select { |entry| entry.thread_id == normalized_thread_id } if normalized_thread_id
82
+ entries.sort_by(&:sequence).first(@snapshot_limit).freeze
83
+ end
84
+ end
85
+
86
+ def size
87
+ ensure_current_process!
88
+ @mutex.synchronize { @entries.size }
89
+ end
90
+
91
+ private
92
+
93
+ def normalize_entry(operation:, location:, execution_context:, monotonic_ns:, thread:, fiber:)
94
+ canonical_operation = Validation.operation(operation)
95
+ unless location.nil? || location.is_a?(Location)
96
+ raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
97
+ end
98
+
99
+ normalized_context = execution_context.to_sym if execution_context.is_a?(String) || execution_context.is_a?(Symbol)
100
+ raise RuntimeContractError, 'execution_context is invalid' unless Context::ALL.include?(normalized_context)
101
+
102
+ unless thread.respond_to?(:object_id) && fiber.respond_to?(:object_id)
103
+ raise RuntimeContractError, 'thread and fiber identities are invalid'
104
+ end
105
+
106
+ {
107
+ thread_id: Validation.integer(thread.object_id, 'thread_id'),
108
+ fiber_id: Validation.integer(fiber.object_id, 'fiber_id'),
109
+ operation: canonical_operation,
110
+ location: location,
111
+ execution_context: normalized_context,
112
+ started_monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns')
113
+ }
114
+ end
115
+
116
+ def ensure_current_process!
117
+ pid = current_pid
118
+ reset_for_process!(pid) unless pid == @owner_pid
119
+ end
120
+
121
+ def reset_for_process!(pid)
122
+ @owner_pid = pid
123
+ @mutex = Mutex.new
124
+ @entries = {}
125
+ @sequence = 0
126
+ end
127
+
128
+ def current_pid
129
+ value = @pid_source.call
130
+ return value if value.is_a?(Integer) && value.positive?
131
+
132
+ raise RuntimeContractError, 'pid source must return a positive Integer'
133
+ end
134
+
135
+ def validate_source!(source)
136
+ raise RuntimeContractError, 'pid_source must respond to call' unless source.respond_to?(:call)
137
+ end
138
+
139
+ def validate_limit!(value, name, maximum)
140
+ return if value.is_a?(Integer) && value.between?(1, maximum)
141
+
142
+ raise RuntimeContractError, "#{name} must be an Integer in 1..#{maximum}"
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require_relative 'environment'
5
+ require_relative 'lifecycle'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ module Boot
10
+ class << self
11
+ def activate_from_environment!(
12
+ environment: ENV,
13
+ clock: Clock.new,
14
+ session_id_source: SecureRandom.method(:uuid),
15
+ pid_source: Process.method(:pid),
16
+ writer_factory: JSONL::Writer.method(:open),
17
+ random: Sampler::RANDOM_SOURCE
18
+ )
19
+ return current if activated_for_current_process?
20
+
21
+ mode = Environment.failure_mode(environment)
22
+ return unless Environment.activated?(environment)
23
+
24
+ settings = Environment.load(environment)
25
+ watchdog_policy = if environment.key?(Environment::WATCHDOG_SETTINGS_KEY)
26
+ Environment.load_watchdog_policy(environment)
27
+ end
28
+ @lifecycle = Lifecycle.start(
29
+ settings: settings,
30
+ watchdog_policy: watchdog_policy,
31
+ probes_enabled: Environment.probes_enabled?(environment),
32
+ clock: clock,
33
+ session_id_source: session_id_source,
34
+ pid_source: pid_source,
35
+ writer_factory: writer_factory,
36
+ random: random
37
+ )
38
+ @pid_source = pid_source
39
+ @activation_pid = pid_source.call
40
+ install_shutdown_hook!
41
+ @lifecycle
42
+ rescue StandardError
43
+ raise if defined?(mode) && mode == :closed
44
+
45
+ nil
46
+ end
47
+
48
+ def current
49
+ return unless @lifecycle
50
+
51
+ @lifecycle.ensure_current_process!
52
+ @activation_pid = @pid_source.call
53
+ @lifecycle
54
+ end
55
+
56
+ def activated?
57
+ !current.nil?
58
+ end
59
+
60
+ def shutdown(exception: nil)
61
+ current&.shutdown(exception: exception)
62
+ end
63
+
64
+ private
65
+
66
+ def activated_for_current_process?
67
+ return false unless @lifecycle && @pid_source && @activation_pid
68
+
69
+ @activation_pid == @pid_source.call
70
+ end
71
+
72
+ def install_shutdown_hook!
73
+ return if @shutdown_hook_installed
74
+
75
+ at_exit { FiberAudit::Runtime::Boot.shutdown(exception: $ERROR_INFO) }
76
+ @shutdown_hook_installed = true
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ FiberAudit::Runtime::Boot.activate_from_environment! if ENV.key?(FiberAudit::Runtime::Environment::ACTIVATION_KEY)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ class Clock
8
+ MAX_NANOSECONDS = 9_223_372_036_854_775_807
9
+ WALL_SOURCE = -> { Time.now }.freeze
10
+ MONOTONIC_SOURCE = lambda {
11
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
12
+ }.freeze
13
+
14
+ def initialize(wall: WALL_SOURCE, monotonic: MONOTONIC_SOURCE)
15
+ raise RuntimeContractError, 'wall clock source must respond to call' unless wall.respond_to?(:call)
16
+ raise RuntimeContractError, 'monotonic clock source must respond to call' unless monotonic.respond_to?(:call)
17
+
18
+ @wall = wall
19
+ @monotonic = monotonic
20
+ freeze
21
+ end
22
+
23
+ def wall_time
24
+ Validation.utc_time(@wall.call, 'wall clock result')
25
+ end
26
+
27
+ def monotonic_ns
28
+ value = Validation.integer(@monotonic.call, 'monotonic clock result')
29
+ raise RuntimeSafetyError, 'monotonic clock result exceeds signed 64-bit range' if value > MAX_NANOSECONDS
30
+
31
+ value
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'securerandom'
7
+ require_relative 'policy'
8
+ require_relative 'validation'
9
+ require_relative 'watchdog_policy'
10
+
11
+ module FiberAudit
12
+ module Runtime
13
+ # rubocop:disable Metrics/ModuleLength
14
+ module Environment
15
+ PROTOCOL_VERSION = 1
16
+ ACTIVATION_KEY = 'FIBER_AUDIT_RUNTIME_BOOT'
17
+ SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_SETTINGS'
18
+ FAILURE_MODE_KEY = 'FIBER_AUDIT_RUNTIME_FAILURE_MODE'
19
+ WATCHDOG_SETTINGS_KEY = 'FIBER_AUDIT_RUNTIME_WATCHDOG_SETTINGS'
20
+ PROBES_KEY = 'FIBER_AUDIT_RUNTIME_PROBES'
21
+ BOOT_REQUIRE = '-rfiber_audit/runtime/boot'
22
+ MAX_SETTINGS_BYTES = 16_384
23
+ MAX_WATCHDOG_SETTINGS_BYTES = 1_024
24
+ SETTINGS_KEYS = %w[protocol_version launch_id project_root output_directory policy].freeze
25
+ POLICY_KEYS = %w[
26
+ redaction sampling_rate max_events_per_second max_events_per_session
27
+ max_record_bytes max_session_bytes fail_open
28
+ ].freeze
29
+ WATCHDOG_KEYS = %w[
30
+ protocol_version enabled heartbeat_interval_ms stall_threshold_ms max_frames
31
+ ].freeze
32
+
33
+ Settings = Data.define(:protocol_version, :launch_id, :project_root, :output_directory, :policy) do
34
+ def initialize(protocol_version:, launch_id:, project_root:, output_directory:, policy:)
35
+ unless protocol_version == PROTOCOL_VERSION
36
+ raise RuntimeContractError, "runtime activation protocol must be #{PROTOCOL_VERSION}"
37
+ end
38
+ unless launch_id.is_a?(String) && launch_id.match?(Validation::UUID)
39
+ raise RuntimeContractError, 'runtime launch_id must be a canonical lowercase UUID'
40
+ end
41
+ unless policy.is_a?(Policy)
42
+ raise RuntimeContractError, 'runtime activation policy must be a FiberAudit::Runtime::Policy'
43
+ end
44
+
45
+ super(
46
+ protocol_version: protocol_version,
47
+ launch_id: launch_id.dup.freeze,
48
+ project_root: Environment.normalize_directory(project_root, 'project_root'),
49
+ output_directory: Environment.normalize_directory(output_directory, 'output_directory'),
50
+ policy: policy
51
+ )
52
+ end
53
+ end
54
+
55
+ module_function
56
+
57
+ def build(policy:, output_directory:, project_root:, launch_id: SecureRandom.uuid)
58
+ Settings.new(
59
+ protocol_version: PROTOCOL_VERSION,
60
+ launch_id: launch_id,
61
+ project_root: project_root,
62
+ output_directory: output_directory,
63
+ policy: policy
64
+ )
65
+ end
66
+
67
+ def dump(settings)
68
+ require_settings!(settings)
69
+ payload = {
70
+ 'protocol_version' => settings.protocol_version,
71
+ 'launch_id' => settings.launch_id,
72
+ 'project_root' => settings.project_root,
73
+ 'output_directory' => settings.output_directory,
74
+ 'policy' => policy_payload(settings.policy)
75
+ }
76
+ encoded = JSON.generate(payload)
77
+ raise RuntimeSafetyError, 'runtime activation settings are too large' if encoded.bytesize > MAX_SETTINGS_BYTES
78
+
79
+ encoded.freeze
80
+ end
81
+
82
+ def load(environment = ENV)
83
+ return unless activated?(environment)
84
+
85
+ value = environment.fetch(SETTINGS_KEY) do
86
+ raise RuntimeContractError, "missing runtime activation variable: #{SETTINGS_KEY}"
87
+ end
88
+ unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_SETTINGS_BYTES
89
+ raise RuntimeContractError, 'runtime activation settings are invalid'
90
+ end
91
+
92
+ payload = JSON.parse(value)
93
+ require_exact_keys!(payload, SETTINGS_KEYS, 'runtime activation settings')
94
+ policy_values = payload.fetch('policy')
95
+ require_exact_keys!(policy_values, POLICY_KEYS, 'runtime activation policy')
96
+ settings = Settings.new(
97
+ protocol_version: payload.fetch('protocol_version'),
98
+ launch_id: payload.fetch('launch_id'),
99
+ project_root: payload.fetch('project_root'),
100
+ output_directory: payload.fetch('output_directory'),
101
+ policy: Policy.new(**symbolize_policy(policy_values))
102
+ )
103
+ validate_failure_mode!(environment, settings.policy)
104
+ settings
105
+ rescue JSON::ParserError, ArgumentError => e
106
+ raise RuntimeContractError, "invalid runtime activation settings: #{e.message}"
107
+ end
108
+
109
+ def dump_watchdog_policy(policy)
110
+ require_watchdog_policy!(policy)
111
+ payload = {
112
+ 'protocol_version' => PROTOCOL_VERSION,
113
+ 'enabled' => policy.enabled,
114
+ 'heartbeat_interval_ms' => policy.heartbeat_interval_ms,
115
+ 'stall_threshold_ms' => policy.stall_threshold_ms,
116
+ 'max_frames' => policy.max_frames
117
+ }
118
+ encoded = JSON.generate(payload)
119
+ if encoded.bytesize > MAX_WATCHDOG_SETTINGS_BYTES
120
+ raise RuntimeSafetyError, 'runtime watchdog activation settings are too large'
121
+ end
122
+
123
+ encoded.freeze
124
+ end
125
+
126
+ def load_watchdog_policy(environment = ENV)
127
+ value = environment[WATCHDOG_SETTINGS_KEY]
128
+ return WatchdogPolicy::DISABLED if value.nil?
129
+ unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_WATCHDOG_SETTINGS_BYTES
130
+ raise RuntimeContractError, 'runtime watchdog activation settings are invalid'
131
+ end
132
+
133
+ payload = JSON.parse(value)
134
+ require_exact_keys!(payload, WATCHDOG_KEYS, 'runtime watchdog activation settings')
135
+ unless payload.fetch('protocol_version') == PROTOCOL_VERSION
136
+ raise RuntimeContractError, "runtime watchdog activation protocol must be #{PROTOCOL_VERSION}"
137
+ end
138
+
139
+ WatchdogPolicy.new(
140
+ enabled: payload.fetch('enabled'),
141
+ heartbeat_interval_ms: payload.fetch('heartbeat_interval_ms'),
142
+ stall_threshold_ms: payload.fetch('stall_threshold_ms'),
143
+ max_frames: payload.fetch('max_frames')
144
+ )
145
+ rescue JSON::ParserError, ArgumentError => e
146
+ raise RuntimeContractError, "invalid runtime watchdog activation settings: #{e.message}"
147
+ end
148
+
149
+ def activated?(environment = ENV)
150
+ marker = environment[ACTIVATION_KEY]
151
+ return false if marker.nil?
152
+ return true if marker == '1'
153
+
154
+ raise RuntimeContractError, "#{ACTIVATION_KEY} must be 1"
155
+ end
156
+
157
+ def failure_mode(environment = ENV)
158
+ value = environment[FAILURE_MODE_KEY]
159
+ return :open if value.nil? || value == 'open'
160
+ return :closed if value == 'closed'
161
+
162
+ raise RuntimeContractError, "#{FAILURE_MODE_KEY} must be open or closed"
163
+ end
164
+
165
+ def probes_enabled?(environment = ENV)
166
+ value = environment[PROBES_KEY]
167
+ return false if value.nil?
168
+ return true if value == '1'
169
+
170
+ raise RuntimeContractError, "#{PROBES_KEY} must be 1 when present"
171
+ end
172
+
173
+ def child_environment(
174
+ settings:,
175
+ watchdog_policy: nil,
176
+ probes_enabled: false,
177
+ base_environment: ENV,
178
+ library_path: default_library_path
179
+ )
180
+ require_settings!(settings)
181
+ require_watchdog_policy!(watchdog_policy) if watchdog_policy
182
+ raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes_enabled)
183
+ raise RuntimeContractError, 'base_environment must be a Hash-like object' unless base_environment.respond_to?(:[])
184
+
185
+ environment = {
186
+ ACTIVATION_KEY => '1',
187
+ SETTINGS_KEY => dump(settings),
188
+ FAILURE_MODE_KEY => settings.policy.fail_open? ? 'open' : 'closed',
189
+ 'RUBYOPT' => prepend_token(base_environment['RUBYOPT'], BOOT_REQUIRE, separator: ' '),
190
+ 'RUBYLIB' => prepend_token(base_environment['RUBYLIB'], library_path, separator: File::PATH_SEPARATOR)
191
+ }
192
+ environment[WATCHDOG_SETTINGS_KEY] = dump_watchdog_policy(watchdog_policy) if watchdog_policy
193
+ environment[PROBES_KEY] = '1' if probes_enabled
194
+ environment.transform_values(&:freeze).freeze
195
+ end
196
+
197
+ def prepare_output_directory(path)
198
+ normalized = normalize_absolute_path(path, 'output directory')
199
+ if File.exist?(normalized)
200
+ raise RuntimeSafetyError, "runtime output is not a directory: #{normalized}" unless File.directory?(normalized)
201
+
202
+ return normalized
203
+ end
204
+
205
+ FileUtils.mkdir_p(normalized, mode: 0o700)
206
+ File.chmod(0o700, normalized)
207
+ normalized
208
+ rescue SystemCallError => e
209
+ raise RuntimeSafetyError, "cannot prepare runtime output directory: #{e.message}"
210
+ end
211
+
212
+ def normalize_directory(value, field)
213
+ normalized = normalize_absolute_path(value, field)
214
+ raise RuntimeContractError, "#{field} must be an existing directory" unless File.directory?(normalized)
215
+
216
+ normalized.freeze
217
+ end
218
+
219
+ def default_library_path
220
+ File.expand_path('../..', __dir__).freeze
221
+ end
222
+ private_class_method :default_library_path
223
+
224
+ def normalize_absolute_path(value, field)
225
+ text = Validation.string(value, field, max_bytes: 4_096)
226
+ path = Pathname.new(text)
227
+ raise RuntimeContractError, "#{field} must be absolute" unless path.absolute?
228
+
229
+ path.cleanpath.to_s.freeze
230
+ rescue ArgumentError
231
+ raise RuntimeContractError, "#{field} is invalid"
232
+ end
233
+ private_class_method :normalize_absolute_path
234
+
235
+ def prepend_token(existing, token, separator:)
236
+ current = existing.to_s
237
+ parts = current.split(separator)
238
+ return current.dup.freeze if parts.include?(token)
239
+
240
+ current.empty? ? token.dup.freeze : "#{token}#{separator}#{current}".freeze
241
+ end
242
+ private_class_method :prepend_token
243
+
244
+ def require_settings!(value)
245
+ return if value.is_a?(Settings)
246
+
247
+ raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
248
+ end
249
+ private_class_method :require_settings!
250
+
251
+ def require_watchdog_policy!(value)
252
+ return if value.is_a?(WatchdogPolicy)
253
+
254
+ raise RuntimeContractError, 'watchdog_policy must be FiberAudit::Runtime::WatchdogPolicy'
255
+ end
256
+ private_class_method :require_watchdog_policy!
257
+
258
+ def require_exact_keys!(value, expected, path)
259
+ raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
260
+
261
+ unknown = value.keys - expected
262
+ missing = expected - value.keys
263
+ raise RuntimeContractError, "unknown key #{unknown.first.inspect} at #{path}" unless unknown.empty?
264
+ raise RuntimeContractError, "missing key #{missing.first.inspect} at #{path}" unless missing.empty?
265
+ end
266
+ private_class_method :require_exact_keys!
267
+
268
+ def policy_payload(policy)
269
+ policy.to_h.transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }.transform_keys(&:to_s)
270
+ end
271
+ private_class_method :policy_payload
272
+
273
+ def symbolize_policy(values)
274
+ values.to_h { |key, value| [key.to_sym, value] }
275
+ end
276
+ private_class_method :symbolize_policy
277
+
278
+ def validate_failure_mode!(environment, policy)
279
+ expected = policy.fail_open? ? :open : :closed
280
+ actual = failure_mode(environment)
281
+ return if actual == expected
282
+
283
+ raise RuntimeContractError, 'runtime activation failure mode does not match policy'
284
+ end
285
+ private_class_method :validate_failure_mode!
286
+ end
287
+ # rubocop:enable Metrics/ModuleLength
288
+ end
289
+ end