fiber_audit 0.1.0 → 0.2.1

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 +41 -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 +320 -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
@@ -4,13 +4,34 @@ require 'yaml'
4
4
  require 'pathname'
5
5
  require_relative 'errors'
6
6
  require_relative 'findings/severity'
7
+ require_relative 'runtime/policy'
8
+ require_relative 'runtime/watchdog_policy'
7
9
 
8
10
  module FiberAudit
11
+ # rubocop:disable Metrics/ClassLength
9
12
  class Configuration
10
- KNOWN_TOP_LEVEL_KEYS = %w[static rules report].freeze
13
+ KNOWN_TOP_LEVEL_KEYS = %w[static rules report runtime].freeze
11
14
  KNOWN_STATIC_KEYS = %w[include exclude suppressions_path].freeze
12
15
  KNOWN_REPORT_KEYS = %w[formats min_severity].freeze
13
16
  KNOWN_RULE_KEYS = %w[enabled severity].freeze
17
+ KNOWN_RUNTIME_KEYS = %w[redaction sampling overhead watchdog fail_open].freeze
18
+ KNOWN_REDACTION_KEYS = %w[mode].freeze
19
+ KNOWN_SAMPLING_KEYS = %w[rate].freeze
20
+ KNOWN_OVERHEAD_KEYS = %w[
21
+ max_events_per_second max_events_per_session max_record_bytes max_session_bytes
22
+ ].freeze
23
+ KNOWN_WATCHDOG_KEYS = %w[
24
+ enabled heartbeat_interval_ms stall_threshold_ms max_frames
25
+ ].freeze
26
+ RUNTIME_POLICY_PATHS = {
27
+ 'redaction' => 'runtime.redaction.mode',
28
+ 'sampling_rate' => 'runtime.sampling.rate',
29
+ 'max_events_per_second' => 'runtime.overhead.max_events_per_second',
30
+ 'max_events_per_session' => 'runtime.overhead.max_events_per_session',
31
+ 'max_record_bytes' => 'runtime.overhead.max_record_bytes',
32
+ 'max_session_bytes' => 'runtime.overhead.max_session_bytes',
33
+ 'fail_open' => 'runtime.fail_open'
34
+ }.freeze
14
35
  VALID_FORMATS = %w[text json].freeze
15
36
 
16
37
  DEFAULT_STATIC_INCLUDE = %w[
@@ -28,7 +49,8 @@ module FiberAudit
28
49
  ].freeze
29
50
 
30
51
  attr_reader :static_include, :static_exclude, :rules_config,
31
- :report_formats, :min_severity, :suppressions_path
52
+ :report_formats, :min_severity, :suppressions_path,
53
+ :runtime_policy, :runtime_watchdog_policy
32
54
 
33
55
  def initialize(
34
56
  static_include: DEFAULT_STATIC_INCLUDE,
@@ -36,11 +58,14 @@ module FiberAudit
36
58
  rules_config: {},
37
59
  report_formats: %w[text],
38
60
  min_severity: :low,
39
- suppressions_path: nil
61
+ suppressions_path: nil,
62
+ runtime_policy: Runtime::Policy.new,
63
+ runtime_watchdog_policy: Runtime::WatchdogPolicy.new
40
64
  )
41
65
  validate_types!(
42
66
  static_include, static_exclude, rules_config,
43
- report_formats, min_severity, suppressions_path
67
+ report_formats, min_severity, suppressions_path, runtime_policy,
68
+ runtime_watchdog_policy
44
69
  )
45
70
 
46
71
  @static_include = static_include
@@ -49,6 +74,8 @@ module FiberAudit
49
74
  @report_formats = report_formats
50
75
  @min_severity = coerce_severity(min_severity, 'report.min_severity')
51
76
  @suppressions_path = suppressions_path
77
+ @runtime_policy = runtime_policy
78
+ @runtime_watchdog_policy = runtime_watchdog_policy
52
79
  end
53
80
 
54
81
  def rule_enabled?(rule_id)
@@ -76,6 +103,7 @@ module FiberAudit
76
103
  static = yaml['static'] || {}
77
104
  rules = yaml['rules'] || {}
78
105
  report = yaml['report'] || {}
106
+ runtime = yaml['runtime'] || {}
79
107
 
80
108
  new(
81
109
  static_include: static['include'] || DEFAULT_STATIC_INCLUDE,
@@ -83,7 +111,9 @@ module FiberAudit
83
111
  rules_config: rules,
84
112
  report_formats: report['formats'] || %w[text],
85
113
  min_severity: report.fetch('min_severity', :low),
86
- suppressions_path: static['suppressions_path']
114
+ suppressions_path: static['suppressions_path'],
115
+ runtime_policy: runtime_policy_from(runtime),
116
+ runtime_watchdog_policy: runtime_watchdog_policy_from(runtime)
87
117
  )
88
118
  end
89
119
 
@@ -115,6 +145,8 @@ module FiberAudit
115
145
  check_unknown_keys(report, KNOWN_REPORT_KEYS, 'report')
116
146
  end
117
147
 
148
+ validate_runtime_structure!(yaml['runtime']) if yaml.key?('runtime')
149
+
118
150
  return unless yaml.key?('rules')
119
151
 
120
152
  rules = yaml['rules']
@@ -124,6 +156,64 @@ module FiberAudit
124
156
  "rules must be a mapping, got #{rules.class}"
125
157
  end
126
158
 
159
+ def validate_runtime_structure!(runtime)
160
+ unless runtime.is_a?(Hash)
161
+ raise ConfigurationError,
162
+ "runtime must be a mapping, got #{runtime.class}"
163
+ end
164
+ check_unknown_keys(runtime, KNOWN_RUNTIME_KEYS, 'runtime')
165
+ validate_runtime_mapping!(runtime, 'redaction', KNOWN_REDACTION_KEYS)
166
+ validate_runtime_mapping!(runtime, 'sampling', KNOWN_SAMPLING_KEYS)
167
+ validate_runtime_mapping!(runtime, 'overhead', KNOWN_OVERHEAD_KEYS)
168
+ validate_runtime_mapping!(runtime, 'watchdog', KNOWN_WATCHDOG_KEYS)
169
+ end
170
+
171
+ def validate_runtime_mapping!(runtime, key, allowed)
172
+ return unless runtime.key?(key)
173
+
174
+ value = runtime[key]
175
+ unless value.is_a?(Hash)
176
+ raise ConfigurationError,
177
+ "runtime.#{key} must be a mapping, got #{value.class}"
178
+ end
179
+ check_unknown_keys(value, allowed, "runtime.#{key}")
180
+ end
181
+
182
+ def runtime_policy_from(runtime)
183
+ defaults = Runtime::Policy::DEFAULTS
184
+ redaction = runtime.fetch('redaction', {})
185
+ sampling = runtime.fetch('sampling', {})
186
+ overhead = runtime.fetch('overhead', {})
187
+ Runtime::Policy.new(
188
+ redaction: redaction.fetch('mode', defaults[:redaction]),
189
+ sampling_rate: sampling.fetch('rate', defaults[:sampling_rate]),
190
+ max_events_per_second: overhead.fetch('max_events_per_second', defaults[:max_events_per_second]),
191
+ max_events_per_session: overhead.fetch('max_events_per_session', defaults[:max_events_per_session]),
192
+ max_record_bytes: overhead.fetch('max_record_bytes', defaults[:max_record_bytes]),
193
+ max_session_bytes: overhead.fetch('max_session_bytes', defaults[:max_session_bytes]),
194
+ fail_open: runtime.fetch('fail_open', defaults[:fail_open])
195
+ )
196
+ rescue RuntimeContractError => e
197
+ field = e.message.split.first
198
+ path = RUNTIME_POLICY_PATHS.fetch(field, 'runtime')
199
+ raise ConfigurationError, "#{path} is invalid: #{e.message}"
200
+ end
201
+
202
+ def runtime_watchdog_policy_from(runtime)
203
+ values = runtime.fetch('watchdog', {})
204
+ defaults = Runtime::WatchdogPolicy::DEFAULTS
205
+ Runtime::WatchdogPolicy.new(
206
+ enabled: values.fetch('enabled', defaults[:enabled]),
207
+ heartbeat_interval_ms: values.fetch('heartbeat_interval_ms', defaults[:heartbeat_interval_ms]),
208
+ stall_threshold_ms: values.fetch('stall_threshold_ms', defaults[:stall_threshold_ms]),
209
+ max_frames: values.fetch('max_frames', defaults[:max_frames])
210
+ )
211
+ rescue RuntimeContractError => e
212
+ field = e.message.split.first
213
+ path = field == 'enabled' ? 'runtime.watchdog.enabled' : "runtime.watchdog.#{field}"
214
+ raise ConfigurationError, "#{path} is invalid: #{e.message}"
215
+ end
216
+
127
217
  def check_unknown_keys(hash, allowed, path)
128
218
  unknown = hash.keys - allowed
129
219
  return if unknown.empty?
@@ -139,7 +229,7 @@ module FiberAudit
139
229
 
140
230
  def validate_types!(
141
231
  include_patterns, exclude_patterns, rules,
142
- formats, _severity, suppressions
232
+ formats, _severity, suppressions, runtime_policy, watchdog_policy
143
233
  )
144
234
  unless include_patterns.is_a?(Array) &&
145
235
  include_patterns.all?(String)
@@ -158,6 +248,15 @@ module FiberAudit
158
248
  validate_rules!(rules)
159
249
  validate_report_formats!(formats)
160
250
 
251
+ unless runtime_policy.is_a?(Runtime::Policy)
252
+ raise ConfigurationError,
253
+ 'runtime_policy must be a FiberAudit::Runtime::Policy'
254
+ end
255
+ unless watchdog_policy.is_a?(Runtime::WatchdogPolicy)
256
+ raise ConfigurationError,
257
+ 'runtime_watchdog_policy must be a FiberAudit::Runtime::WatchdogPolicy'
258
+ end
259
+
161
260
  return if suppressions.nil? || suppressions.is_a?(String)
162
261
 
163
262
  raise ConfigurationError,
@@ -233,4 +332,5 @@ module FiberAudit
233
332
  "#{path} is not a valid severity: #{value.inspect}"
234
333
  end
235
334
  end
335
+ # rubocop:enable Metrics/ClassLength
236
336
  end
@@ -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