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.
- checksums.yaml +4 -4
- data/.fiber-audit.example.yml +19 -0
- data/ARCHITECTURE.md +726 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +201 -0
- data/README.md +66 -8
- data/lib/fiber_audit/cli.rb +87 -2
- data/lib/fiber_audit/configuration.rb +106 -6
- data/lib/fiber_audit/errors.rb +2 -0
- data/lib/fiber_audit/operation_vocabulary.rb +42 -0
- data/lib/fiber_audit/reporters/text.rb +1 -1
- data/lib/fiber_audit/runtime/active_operations.rb +146 -0
- data/lib/fiber_audit/runtime/boot.rb +83 -0
- data/lib/fiber_audit/runtime/clock.rb +35 -0
- data/lib/fiber_audit/runtime/environment.rb +289 -0
- data/lib/fiber_audit/runtime/event.rb +86 -0
- data/lib/fiber_audit/runtime/execution_context.rb +89 -0
- data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
- data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
- data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
- data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
- data/lib/fiber_audit/runtime/limits.rb +102 -0
- data/lib/fiber_audit/runtime/location.rb +44 -0
- data/lib/fiber_audit/runtime/policy.rb +121 -0
- data/lib/fiber_audit/runtime/probes/base.rb +333 -0
- data/lib/fiber_audit/runtime/probes/http.rb +80 -0
- data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
- data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
- data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
- data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
- data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
- data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
- data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
- data/lib/fiber_audit/runtime/rails_integration.rb +320 -0
- data/lib/fiber_audit/runtime/recorder.rb +333 -0
- data/lib/fiber_audit/runtime/redactor.rb +102 -0
- data/lib/fiber_audit/runtime/sampler.rb +26 -0
- data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
- data/lib/fiber_audit/runtime/session.rb +112 -0
- data/lib/fiber_audit/runtime/supervisor.rb +114 -0
- data/lib/fiber_audit/runtime/validation.rb +68 -0
- data/lib/fiber_audit/runtime/watchdog.rb +479 -0
- data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
- data/lib/fiber_audit/runtime.rb +37 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
- data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
- data/lib/fiber_audit/static/rules/io_select.rb +2 -4
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
- data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
- data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
- data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
- data/lib/fiber_audit/version.rb +1 -1
- data/lib/fiber_audit.rb +1 -0
- metadata +38 -2
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative 'clock'
|
|
5
|
+
require_relative 'active_operations'
|
|
6
|
+
require_relative 'environment'
|
|
7
|
+
require_relative 'execution_context'
|
|
8
|
+
require_relative 'rails_integration'
|
|
9
|
+
require_relative 'jsonl/writer'
|
|
10
|
+
require_relative 'recorder'
|
|
11
|
+
require_relative 'redactor'
|
|
12
|
+
require_relative 'probes/registry'
|
|
13
|
+
require_relative 'scheduler_observer'
|
|
14
|
+
require_relative 'session'
|
|
15
|
+
require_relative 'watchdog'
|
|
16
|
+
require_relative 'watchdog_policy'
|
|
17
|
+
|
|
18
|
+
module FiberAudit
|
|
19
|
+
module Runtime
|
|
20
|
+
# rubocop:disable Metrics/ClassLength
|
|
21
|
+
class Lifecycle
|
|
22
|
+
attr_reader :settings, :owner_pid, :output_path, :recorder,
|
|
23
|
+
:watchdog, :active_operations, :watchdog_policy,
|
|
24
|
+
:redactor, :probe_registry, :execution_context_store,
|
|
25
|
+
:rails_integration
|
|
26
|
+
|
|
27
|
+
def self.start(...)
|
|
28
|
+
new(...)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(
|
|
32
|
+
settings:,
|
|
33
|
+
watchdog_policy: nil,
|
|
34
|
+
probes_enabled: false,
|
|
35
|
+
clock: Clock.new,
|
|
36
|
+
session_id_source: SecureRandom.method(:uuid),
|
|
37
|
+
pid_source: Process.method(:pid),
|
|
38
|
+
writer_factory: JSONL::Writer.method(:open),
|
|
39
|
+
random: Sampler::RANDOM_SOURCE
|
|
40
|
+
)
|
|
41
|
+
validate_dependencies!(
|
|
42
|
+
settings, watchdog_policy, probes_enabled, clock,
|
|
43
|
+
session_id_source, pid_source, writer_factory, random
|
|
44
|
+
)
|
|
45
|
+
@settings = settings
|
|
46
|
+
@watchdog_policy = watchdog_policy
|
|
47
|
+
@probes_enabled = probes_enabled
|
|
48
|
+
@clock = clock
|
|
49
|
+
@session_id_source = session_id_source
|
|
50
|
+
@pid_source = pid_source
|
|
51
|
+
@writer_factory = writer_factory
|
|
52
|
+
@random = random
|
|
53
|
+
@owner_pid = current_pid
|
|
54
|
+
@state = :starting
|
|
55
|
+
@output_path = nil
|
|
56
|
+
@recorder = nil
|
|
57
|
+
@watchdog = nil
|
|
58
|
+
@scheduler_observer = nil
|
|
59
|
+
@active_operations = nil
|
|
60
|
+
@redactor = nil
|
|
61
|
+
@probe_registry = nil
|
|
62
|
+
@execution_context_store = nil
|
|
63
|
+
@rails_integration = nil
|
|
64
|
+
start_process_session!
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
startup_failure!(e)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def active?
|
|
70
|
+
@state == :active && recorder&.active?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def disabled?
|
|
74
|
+
@state == :disabled || recorder&.disabled?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def closed?
|
|
78
|
+
@state == :closed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_current_process!
|
|
82
|
+
pid = current_pid
|
|
83
|
+
return self if pid == owner_pid
|
|
84
|
+
|
|
85
|
+
abandon_inherited_runtime!
|
|
86
|
+
@owner_pid = pid
|
|
87
|
+
@output_path = nil
|
|
88
|
+
@recorder = nil
|
|
89
|
+
@watchdog = nil
|
|
90
|
+
@scheduler_observer = nil
|
|
91
|
+
@active_operations = nil
|
|
92
|
+
@redactor = nil
|
|
93
|
+
@probe_registry = nil
|
|
94
|
+
@execution_context_store = nil
|
|
95
|
+
@rails_integration = nil
|
|
96
|
+
@state = :starting
|
|
97
|
+
# Reset fiber-local context after fork
|
|
98
|
+
ExecutionContext.reset!
|
|
99
|
+
start_process_session!
|
|
100
|
+
self
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
process_failure!(e)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def shutdown(exception: nil)
|
|
106
|
+
ensure_current_process!
|
|
107
|
+
return @summary if closed?
|
|
108
|
+
|
|
109
|
+
runtime_error = stop_runtime_observers
|
|
110
|
+
status = runtime_error && exception.nil? ? :degraded : shutdown_status(exception)
|
|
111
|
+
@summary = recorder&.close(status: status)
|
|
112
|
+
@state = :closed
|
|
113
|
+
raise runtime_error if runtime_error && !settings.policy.fail_open?
|
|
114
|
+
|
|
115
|
+
@summary
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
@state = :closed
|
|
118
|
+
raise e unless settings.policy.fail_open?
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def validate_dependencies!(candidate_settings, watchdog, probes, candidate_clock, session_ids, pids, writers, random)
|
|
126
|
+
unless candidate_settings.is_a?(Environment::Settings)
|
|
127
|
+
raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
|
|
128
|
+
end
|
|
129
|
+
unless watchdog.nil? || watchdog.is_a?(WatchdogPolicy)
|
|
130
|
+
raise RuntimeContractError, 'watchdog_policy must be a FiberAudit::Runtime::WatchdogPolicy or nil'
|
|
131
|
+
end
|
|
132
|
+
raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes)
|
|
133
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
'session_id_source' => session_ids,
|
|
137
|
+
'pid_source' => pids,
|
|
138
|
+
'writer_factory' => writers,
|
|
139
|
+
'random source' => random
|
|
140
|
+
}.each do |name, source|
|
|
141
|
+
raise RuntimeContractError, "#{name} must respond to call" unless source.respond_to?(:call)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def start_process_session!
|
|
146
|
+
session_id = @session_id_source.call
|
|
147
|
+
started_at = @clock.wall_time
|
|
148
|
+
started_monotonic_ns = @clock.monotonic_ns
|
|
149
|
+
@output_path = build_output_path(session_id)
|
|
150
|
+
session = Session.new(
|
|
151
|
+
id: session_id,
|
|
152
|
+
started_at: started_at,
|
|
153
|
+
started_monotonic_ns: started_monotonic_ns,
|
|
154
|
+
policy: settings.policy
|
|
155
|
+
)
|
|
156
|
+
writer = @writer_factory.call(path: output_path, max_record_bytes: settings.policy.max_record_bytes)
|
|
157
|
+
@recorder = start_recorder(session, writer)
|
|
158
|
+
setup_runtime_observers! if recorder.active?
|
|
159
|
+
@state = recorder.active? ? :active : :disabled
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def start_recorder(session, writer)
|
|
163
|
+
Recorder.start(session: session, writer: writer, clock: @clock, random: @random)
|
|
164
|
+
rescue StandardError
|
|
165
|
+
begin
|
|
166
|
+
writer.close
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
raise
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def setup_runtime_observers!
|
|
174
|
+
@active_operations = ActiveOperations.new(pid_source: @pid_source)
|
|
175
|
+
@redactor = Redactor.new(root: settings.project_root, policy: settings.policy)
|
|
176
|
+
@execution_context_store = ExecutionContext if @probes_enabled
|
|
177
|
+
setup_watchdog! if watchdog_policy
|
|
178
|
+
setup_rails_integration! if @probes_enabled
|
|
179
|
+
setup_probes! if @probes_enabled
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
unless e.instance_variable_defined?(:@fiber_audit_runtime_accounted)
|
|
182
|
+
recorder.internal_error!
|
|
183
|
+
e.instance_variable_set(:@fiber_audit_runtime_accounted, true)
|
|
184
|
+
end
|
|
185
|
+
deactivate_active_components!
|
|
186
|
+
close_failed_startup!(e)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def deactivate_active_components!
|
|
190
|
+
# Deactivate components in reverse order of setup
|
|
191
|
+
begin
|
|
192
|
+
Probes::Registry.deactivate(@probe_registry) if @probe_registry
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
ensure
|
|
196
|
+
@probe_registry = nil
|
|
197
|
+
end
|
|
198
|
+
begin
|
|
199
|
+
RailsIntegration.deactivate(@rails_integration) if @rails_integration
|
|
200
|
+
rescue StandardError
|
|
201
|
+
nil
|
|
202
|
+
ensure
|
|
203
|
+
@rails_integration = nil
|
|
204
|
+
end
|
|
205
|
+
begin
|
|
206
|
+
if @scheduler_observer
|
|
207
|
+
SchedulerObserver.deactivate(@scheduler_observer)
|
|
208
|
+
@watchdog&.stop
|
|
209
|
+
end
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
ensure
|
|
213
|
+
@scheduler_observer = nil
|
|
214
|
+
@watchdog = nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def setup_rails_integration!
|
|
219
|
+
@rails_integration = RailsIntegration.activate(
|
|
220
|
+
context_store: @execution_context_store,
|
|
221
|
+
recorder: recorder
|
|
222
|
+
)
|
|
223
|
+
rescue StandardError
|
|
224
|
+
@rails_integration = nil
|
|
225
|
+
raise unless settings.policy.fail_open?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def setup_watchdog!
|
|
229
|
+
@watchdog = Watchdog.new(
|
|
230
|
+
policy: watchdog_policy,
|
|
231
|
+
recorder: recorder,
|
|
232
|
+
redactor: redactor,
|
|
233
|
+
active_operations: active_operations,
|
|
234
|
+
clock: @clock
|
|
235
|
+
)
|
|
236
|
+
@scheduler_observer = SchedulerObserver.activate(watchdog: watchdog) if watchdog.enabled?
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def setup_probes!
|
|
240
|
+
base = Probes::Base.new(
|
|
241
|
+
recorder: recorder,
|
|
242
|
+
clock: @clock,
|
|
243
|
+
redactor: redactor,
|
|
244
|
+
active_operations: active_operations,
|
|
245
|
+
execution_context_store: @execution_context_store,
|
|
246
|
+
pid_source: @pid_source
|
|
247
|
+
)
|
|
248
|
+
@probe_registry = Probes::Registry.activate(base: base)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def close_failed_startup!(error)
|
|
252
|
+
@probe_registry = nil
|
|
253
|
+
return if settings.policy.fail_open?
|
|
254
|
+
|
|
255
|
+
begin
|
|
256
|
+
recorder.close(status: :degraded)
|
|
257
|
+
rescue StandardError
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
raise error
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def build_output_path(session_id)
|
|
264
|
+
unless session_id.is_a?(String) && session_id.match?(Validation::UUID)
|
|
265
|
+
raise RuntimeContractError, 'session ID source must return a canonical lowercase UUID'
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
filename = "fiber-audit-runtime-#{settings.launch_id}-#{owner_pid}-#{session_id}.jsonl"
|
|
269
|
+
File.join(settings.output_directory, filename).freeze
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def abandon_inherited_runtime!
|
|
273
|
+
recorder&.writer&.close
|
|
274
|
+
ensure
|
|
275
|
+
@recorder = nil
|
|
276
|
+
@watchdog = nil
|
|
277
|
+
@scheduler_observer = nil
|
|
278
|
+
@active_operations = nil
|
|
279
|
+
@redactor = nil
|
|
280
|
+
@probe_registry = nil
|
|
281
|
+
@execution_context_store = nil
|
|
282
|
+
@rails_integration = nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def stop_runtime_observers
|
|
286
|
+
error = nil
|
|
287
|
+
begin
|
|
288
|
+
RailsIntegration.deactivate(@rails_integration) if @rails_integration
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
error ||= e
|
|
291
|
+
ensure
|
|
292
|
+
@rails_integration = nil
|
|
293
|
+
end
|
|
294
|
+
begin
|
|
295
|
+
Probes::Registry.deactivate(probe_registry) if probe_registry
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
error ||= e
|
|
298
|
+
ensure
|
|
299
|
+
@probe_registry = nil
|
|
300
|
+
end
|
|
301
|
+
begin
|
|
302
|
+
SchedulerObserver.deactivate(@scheduler_observer) if @scheduler_observer
|
|
303
|
+
watchdog&.stop
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
error ||= e
|
|
306
|
+
ensure
|
|
307
|
+
@scheduler_observer = nil
|
|
308
|
+
end
|
|
309
|
+
recorder&.internal_error! if error
|
|
310
|
+
error
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def startup_failure!(error)
|
|
314
|
+
raise error unless defined?(@settings) && settings.policy.fail_open?
|
|
315
|
+
|
|
316
|
+
@state = :disabled
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def process_failure!(error)
|
|
320
|
+
raise error unless settings.policy.fail_open?
|
|
321
|
+
|
|
322
|
+
@state = :disabled
|
|
323
|
+
self
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def current_pid
|
|
327
|
+
value = @pid_source.call
|
|
328
|
+
unless value.is_a?(Integer) && value.positive?
|
|
329
|
+
raise RuntimeContractError, 'pid source must return a positive Integer'
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
value
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def shutdown_status(exception)
|
|
336
|
+
return :completed if exception.nil? || exception.is_a?(SystemExit)
|
|
337
|
+
|
|
338
|
+
:aborted
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
# rubocop:enable Metrics/ClassLength
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'policy'
|
|
4
|
+
require_relative 'session'
|
|
5
|
+
require_relative 'validation'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
class Limits
|
|
10
|
+
WINDOW_NS = 1_000_000_000
|
|
11
|
+
MAX_COUNTER = 9_223_372_036_854_775_807
|
|
12
|
+
COUNTERS = SessionSummary::COUNTERS
|
|
13
|
+
DROP_REASONS = %i[
|
|
14
|
+
sampled_out rate_limited session_event_limited session_byte_limited oversize
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :policy, :started_monotonic_ns
|
|
18
|
+
|
|
19
|
+
def initialize(policy:, started_monotonic_ns:)
|
|
20
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
|
|
21
|
+
|
|
22
|
+
@policy = policy
|
|
23
|
+
@started_monotonic_ns = Validation.integer(started_monotonic_ns, 'started_monotonic_ns')
|
|
24
|
+
@counters = COUNTERS.to_h { |name| [name, 0] }
|
|
25
|
+
@window_index = 0
|
|
26
|
+
@emitted_in_window = 0
|
|
27
|
+
@last_admission_ns = @started_monotonic_ns
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def observe!
|
|
31
|
+
increment!(:events_observed)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sampled_out!
|
|
35
|
+
increment!(:sampled_out)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def preflight_event(now_ns:)
|
|
39
|
+
now = normalize_admission_time(now_ns)
|
|
40
|
+
advance_window!(now)
|
|
41
|
+
return :session_event_limited unless policy.session_event_allowed?(emitted_events: @counters[:events_emitted])
|
|
42
|
+
return :rate_limited unless policy.rate_allowed?(emitted_in_window: @emitted_in_window)
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def drop!(reason)
|
|
48
|
+
raise RuntimeContractError, "unknown runtime drop reason: #{reason.inspect}" unless DROP_REASONS.include?(reason)
|
|
49
|
+
|
|
50
|
+
increment!(reason)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def emitted!(now_ns:)
|
|
54
|
+
now = normalize_admission_time(now_ns)
|
|
55
|
+
advance_window!(now)
|
|
56
|
+
increment!(:events_emitted)
|
|
57
|
+
increment_window!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def internal_error!(count: 1)
|
|
61
|
+
amount = Validation.integer(count, 'internal error count', minimum: 1)
|
|
62
|
+
increment!(:internal_errors, amount)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def counters
|
|
66
|
+
@counters.dup.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def normalize_admission_time(value)
|
|
72
|
+
now = Validation.integer(value, 'admission monotonic time')
|
|
73
|
+
raise RuntimeContractError, 'admission monotonic time precedes the session' if now < @started_monotonic_ns
|
|
74
|
+
raise RuntimeSafetyError, 'monotonic clock moved backwards' if now < @last_admission_ns
|
|
75
|
+
|
|
76
|
+
@last_admission_ns = now
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def advance_window!(now)
|
|
80
|
+
index = (now - started_monotonic_ns) / WINDOW_NS
|
|
81
|
+
return if index == @window_index
|
|
82
|
+
|
|
83
|
+
@window_index = index
|
|
84
|
+
@emitted_in_window = 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def increment!(name, amount = 1)
|
|
88
|
+
following = @counters.fetch(name) + amount
|
|
89
|
+
raise RuntimeSafetyError, "runtime counter overflow: #{name}" if following > MAX_COUNTER
|
|
90
|
+
|
|
91
|
+
@counters[name] = following
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def increment_window!
|
|
95
|
+
following = @emitted_in_window + 1
|
|
96
|
+
raise RuntimeSafetyError, 'runtime rate counter overflow' if following > MAX_COUNTER
|
|
97
|
+
|
|
98
|
+
@emitted_in_window = following
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'validation'
|
|
5
|
+
|
|
6
|
+
module FiberAudit
|
|
7
|
+
module Runtime
|
|
8
|
+
Location = Data.define(:path, :line, :column) do
|
|
9
|
+
def initialize(path:, line: nil, column: nil)
|
|
10
|
+
super(
|
|
11
|
+
path: normalize_path(path),
|
|
12
|
+
line: Validation.integer(line, 'line', minimum: 1, allow_nil: true),
|
|
13
|
+
column: Validation.integer(column, 'column', allow_nil: true)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def normalize_path(value)
|
|
20
|
+
path = Validation.string(value, 'path', max_bytes: Location::MAX_PATH_BYTES)
|
|
21
|
+
return path if Location::SENTINELS.include?(path)
|
|
22
|
+
|
|
23
|
+
normalized = path.tr('\\', '/')
|
|
24
|
+
raise RuntimeContractError, 'path must be project-relative' if absolute_path?(normalized)
|
|
25
|
+
|
|
26
|
+
clean = Pathname.new(normalized).cleanpath.to_s
|
|
27
|
+
if clean == '.' || clean == '..' || clean.start_with?('../')
|
|
28
|
+
raise RuntimeContractError, 'path must not escape the project root'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
clean.freeze
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
raise RuntimeContractError, 'path is invalid'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def absolute_path?(path)
|
|
37
|
+
path.start_with?('/', '//') || path.match?(%r{\A[A-Za-z]:/})
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Location.const_set(:SENTINELS, %w[[external] [redacted]].freeze)
|
|
42
|
+
Location.const_set(:MAX_PATH_BYTES, 1_024)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'validation'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
Policy = Data.define(
|
|
8
|
+
:redaction,
|
|
9
|
+
:sampling_rate,
|
|
10
|
+
:max_events_per_second,
|
|
11
|
+
:max_events_per_session,
|
|
12
|
+
:max_record_bytes,
|
|
13
|
+
:max_session_bytes,
|
|
14
|
+
:fail_open
|
|
15
|
+
) do
|
|
16
|
+
def initialize(**values)
|
|
17
|
+
unknown = values.keys - Policy::DEFAULTS.keys
|
|
18
|
+
raise RuntimeContractError, "unknown runtime policy field: #{unknown.first}" unless unknown.empty?
|
|
19
|
+
|
|
20
|
+
fields = Policy::DEFAULTS.merge(values)
|
|
21
|
+
redaction = normalize_redaction(fields[:redaction])
|
|
22
|
+
sampling_rate = normalize_sampling_rate(fields[:sampling_rate])
|
|
23
|
+
limits = Policy::LIMITS.to_h do |name, range|
|
|
24
|
+
[name, normalize_limit(fields[name], name, range)]
|
|
25
|
+
end
|
|
26
|
+
unless limits[:max_session_bytes] >= limits[:max_record_bytes]
|
|
27
|
+
raise RuntimeContractError, 'max_session_bytes must be >= max_record_bytes'
|
|
28
|
+
end
|
|
29
|
+
raise RuntimeContractError, 'fail_open must be a Boolean' unless [true, false].include?(fields[:fail_open])
|
|
30
|
+
|
|
31
|
+
super(
|
|
32
|
+
redaction: redaction,
|
|
33
|
+
sampling_rate: sampling_rate,
|
|
34
|
+
**limits,
|
|
35
|
+
fail_open: fields[:fail_open]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sample?(draw:)
|
|
40
|
+
value = Validation.finite_number(draw, 'draw').to_f
|
|
41
|
+
raise RuntimeContractError, 'draw must be in 0.0...1.0' unless value >= 0.0 && value < 1.0
|
|
42
|
+
|
|
43
|
+
value < sampling_rate
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rate_allowed?(emitted_in_window:)
|
|
47
|
+
count_below_limit?(emitted_in_window, max_events_per_second, 'emitted_in_window')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def session_event_allowed?(emitted_events:)
|
|
51
|
+
count_below_limit?(emitted_events, max_events_per_session, 'emitted_events')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def record_size_allowed?(bytes:)
|
|
55
|
+
size_allowed?(bytes, max_record_bytes, 'bytes')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def session_bytes_allowed?(written_bytes:, next_record_bytes:)
|
|
59
|
+
written = Validation.integer(written_bytes, 'written_bytes')
|
|
60
|
+
following = Validation.integer(next_record_bytes, 'next_record_bytes')
|
|
61
|
+
written + following <= max_session_bytes
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fail_open?
|
|
65
|
+
fail_open
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def strict_redaction?
|
|
69
|
+
redaction == :strict
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize_redaction(value)
|
|
75
|
+
normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
|
|
76
|
+
return :strict if normalized == :strict
|
|
77
|
+
|
|
78
|
+
raise RuntimeContractError, 'redaction must be strict'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_sampling_rate(value)
|
|
82
|
+
rate = Validation.finite_number(value, 'sampling_rate').to_f
|
|
83
|
+
return rate if rate.between?(0.0, 1.0)
|
|
84
|
+
|
|
85
|
+
raise RuntimeContractError, 'sampling_rate must be between 0.0 and 1.0'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_limit(value, field, range)
|
|
89
|
+
unless value.is_a?(Integer) && range.cover?(value)
|
|
90
|
+
raise RuntimeContractError, "#{field} must be an Integer in #{range}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def count_below_limit?(value, limit, field)
|
|
97
|
+
Validation.integer(value, field) < limit
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def size_allowed?(value, limit, field)
|
|
101
|
+
Validation.integer(value, field) <= limit
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Policy.const_set(:DEFAULTS, {
|
|
106
|
+
redaction: :strict,
|
|
107
|
+
sampling_rate: 0.1,
|
|
108
|
+
max_events_per_second: 100,
|
|
109
|
+
max_events_per_session: 10_000,
|
|
110
|
+
max_record_bytes: 16_384,
|
|
111
|
+
max_session_bytes: 10_485_760,
|
|
112
|
+
fail_open: true
|
|
113
|
+
}.freeze)
|
|
114
|
+
Policy.const_set(:LIMITS, {
|
|
115
|
+
max_events_per_second: 1..10_000,
|
|
116
|
+
max_events_per_session: 1..1_000_000,
|
|
117
|
+
max_record_bytes: 1_024..1_048_576,
|
|
118
|
+
max_session_bytes: 4_096..1_073_741_824
|
|
119
|
+
}.freeze)
|
|
120
|
+
end
|
|
121
|
+
end
|