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
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'execution_context'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ # Process-local, idempotent Rails integration.
8
+ # Installs prepend hooks for Rails boundaries when detected.
9
+ # Becomes inert after deactivation or fork.
10
+ # Supports late-load rescanning for Rails components loaded after boot.
11
+ class RailsIntegration
12
+ attr_reader :owner_pid, :context_store, :recorder
13
+
14
+ def initialize(context_store:, recorder: nil)
15
+ @context_store = context_store
16
+ @recorder = recorder
17
+ @owner_pid = Process.pid
18
+ @active = true
19
+ @middleware_stack = nil
20
+ @require_hook_installed = false
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ def active_for_current_process?
25
+ @active && @owner_pid == Process.pid
26
+ end
27
+
28
+ def deactivate!
29
+ @active = false
30
+ self
31
+ end
32
+
33
+ def install!
34
+ return unless active_for_current_process?
35
+
36
+ @mutex.synchronize do
37
+ install_require_hook
38
+ install_controller_hook
39
+ install_job_hook
40
+ install_cable_hook
41
+ try_install_middleware_hook
42
+ end
43
+ self
44
+ rescue StandardError => e
45
+ handle_installation_failure(e)
46
+ self
47
+ end
48
+
49
+ def rescan!
50
+ return unless active_for_current_process?
51
+
52
+ @mutex.synchronize do
53
+ install_controller_hook
54
+ install_job_hook
55
+ install_cable_hook
56
+ try_install_middleware_hook
57
+ end
58
+ self
59
+ rescue StandardError => e
60
+ handle_installation_failure(e)
61
+ self
62
+ end
63
+
64
+ class << self
65
+ def activate(context_store: ExecutionContext, recorder: nil)
66
+ integration = new(context_store: context_store, recorder: recorder)
67
+ integration.install!
68
+ @current = integration
69
+ @current
70
+ rescue StandardError
71
+ @current = nil
72
+ raise
73
+ end
74
+
75
+ attr_reader :current
76
+
77
+ def deactivate(integration = nil)
78
+ target = integration || @current
79
+ return unless target
80
+
81
+ target.deactivate!
82
+ @current = nil if @current.equal?(target)
83
+ end
84
+
85
+ def rescan!
86
+ @current&.rescan! if @current&.active_for_current_process?
87
+ end
88
+
89
+ def rescan_after_require!
90
+ key = :fiber_audit_rails_require_rescan
91
+ thread = Thread.current
92
+ acquired = false
93
+ return if thread.thread_variable_get(key)
94
+
95
+ thread.thread_variable_set(key, true)
96
+ acquired = true
97
+ rescan!
98
+ ensure
99
+ thread&.thread_variable_set(key, false) if acquired
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def handle_installation_failure(error)
106
+ unless error.instance_variable_defined?(:@fiber_audit_runtime_accounted)
107
+ recorder&.internal_error! unless recorder&.disabled?
108
+ error.instance_variable_set(:@fiber_audit_runtime_accounted, true)
109
+ end
110
+ raise error unless fail_open?
111
+ end
112
+
113
+ def fail_open?
114
+ return true unless recorder
115
+
116
+ recorder.session.policy.fail_open?
117
+ end
118
+
119
+ def account_internal_error
120
+ return unless recorder
121
+
122
+ recorder.internal_error! unless recorder.disabled?
123
+ end
124
+
125
+ def install_require_hook
126
+ return if @require_hook_installed
127
+
128
+ # Install narrow guarded require hook for late Rails loading
129
+ # Independent of probe registry
130
+ ::Kernel.prepend(RequireHook) unless ::Kernel.ancestors.include?(RequireHook)
131
+ @require_hook_installed = true
132
+ end
133
+
134
+ def install_controller_hook
135
+ return unless defined?(::ActionController::Metal)
136
+
137
+ ::ActionController::Metal.prepend(ControllerHook) unless ::ActionController::Metal.ancestors.include?(ControllerHook)
138
+ end
139
+
140
+ def install_job_hook
141
+ return unless defined?(::ActiveJob::Base)
142
+
143
+ ::ActiveJob::Base.prepend(JobHook) unless ::ActiveJob::Base.ancestors.include?(JobHook)
144
+ end
145
+
146
+ def install_cable_hook
147
+ return unless defined?(::ActionCable::Channel::Base)
148
+
149
+ ::ActionCable::Channel::Base.prepend(CableHook) unless ::ActionCable::Channel::Base.ancestors.include?(CableHook)
150
+ end
151
+
152
+ def try_install_middleware_hook
153
+ return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
154
+
155
+ stack = ::Rails.application.config.middleware
156
+ return if @middleware_stack.equal?(stack)
157
+
158
+ # Try to insert middleware into Rails stack. A replacement stack (for
159
+ # example after a Rails reload) is eligible for installation again.
160
+ begin
161
+ stack.use(Middleware)
162
+ @middleware_stack = stack
163
+ rescue StandardError => e
164
+ # Stack may be finalized - that's okay, middleware is optional
165
+ handle_installation_failure(e)
166
+ end
167
+ end
168
+
169
+ # Require hook for late Rails loading - independent of probe registry
170
+ # Narrow, guarded, behavior-preserving
171
+ module RequireHook
172
+ def require(path)
173
+ result = super
174
+ # Only rescan after a successful require that loaded a feature.
175
+ RailsIntegration.rescan_after_require! if result
176
+ result
177
+ end
178
+
179
+ private :require
180
+ end
181
+
182
+ # Prepend hooks - consult active integration before setting context
183
+ # Resilient to context store failures in fail-open mode
184
+ # Critical: must not catch application exceptions or invoke app twice
185
+ module ControllerHook
186
+ def process_action(...)
187
+ integration = RailsIntegration.current
188
+ return super unless integration&.active_for_current_process?
189
+
190
+ executed = false
191
+ begin
192
+ integration.context_store.with(:request) do
193
+ executed = true
194
+ super
195
+ end
196
+ rescue StandardError
197
+ raise if executed
198
+ # Context setup failed before application code ran
199
+ raise unless integration.send(:fail_open?)
200
+
201
+ integration.send(:account_internal_error)
202
+ super
203
+ end
204
+ end
205
+ end
206
+
207
+ module JobHook
208
+ def perform_now(...)
209
+ integration = RailsIntegration.current
210
+ return super unless integration&.active_for_current_process?
211
+
212
+ executed = false
213
+ begin
214
+ integration.context_store.with(:job) do
215
+ executed = true
216
+ super
217
+ end
218
+ rescue StandardError
219
+ raise if executed
220
+ # Context setup failed before application code ran
221
+ raise unless integration.send(:fail_open?)
222
+
223
+ integration.send(:account_internal_error)
224
+ super
225
+ end
226
+ end
227
+ end
228
+
229
+ module CableHook
230
+ def dispatch_action(...)
231
+ integration = RailsIntegration.current
232
+ return super unless integration&.active_for_current_process?
233
+
234
+ executed = false
235
+ begin
236
+ integration.context_store.with(:websocket) do
237
+ executed = true
238
+ super
239
+ end
240
+ rescue StandardError
241
+ raise if executed
242
+ # Context setup failed before application code ran
243
+ raise unless integration.send(:fail_open?)
244
+
245
+ integration.send(:account_internal_error)
246
+ super
247
+ end
248
+ end
249
+ end
250
+
251
+ # Rack middleware for :middleware context
252
+ class Middleware
253
+ def initialize(app)
254
+ @app = app
255
+ end
256
+
257
+ def call(env)
258
+ integration = RailsIntegration.current
259
+ return @app.call(env) unless integration&.active_for_current_process?
260
+
261
+ executed = false
262
+ begin
263
+ integration.context_store.with(:middleware) do
264
+ executed = true
265
+ @app.call(env)
266
+ end
267
+ rescue StandardError
268
+ raise if executed
269
+ # Context setup failed before application code ran
270
+ raise unless integration.send(:fail_open?)
271
+
272
+ integration.send(:account_internal_error)
273
+ @app.call(env)
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'clock'
4
+ require_relative 'event'
5
+ require_relative 'jsonl/writer'
6
+ require_relative 'limits'
7
+ require_relative 'sampler'
8
+ require_relative 'session'
9
+
10
+ module FiberAudit
11
+ module Runtime
12
+ # Thread-safe coordinator for one bounded append-only runtime session.
13
+ # rubocop:disable Metrics/ClassLength
14
+ class Recorder
15
+ RESULTS = %i[
16
+ emitted sampled_out rate_limited session_event_limited session_byte_limited
17
+ oversize internal_error inactive
18
+ ].freeze
19
+ MAX_COUNTER = Limits::MAX_COUNTER
20
+ OUTCOME_COUNTERS = 6
21
+
22
+ attr_reader :session, :writer
23
+
24
+ def self.start(...)
25
+ new(...)
26
+ end
27
+
28
+ def initialize(session:, writer:, clock: Clock.new, random: Sampler::RANDOM_SOURCE)
29
+ validate_dependencies!(session, writer, clock, random)
30
+ @session = session
31
+ @writer = writer
32
+ @clock = clock
33
+ @mutex = Mutex.new
34
+ @state = :starting
35
+ @sequence = 1
36
+ @in_flight = 0
37
+ @start_written = false
38
+ @summary = nil
39
+ @limits = Limits.new(policy: session.policy, started_monotonic_ns: session.started_monotonic_ns)
40
+ @sampler = Sampler.new(policy: session.policy, random: random)
41
+ @end_reserve_bytes = end_reserve_bytes
42
+ start_session!
43
+ rescue StandardError => e
44
+ startup_failure!(e)
45
+ end
46
+
47
+ def record(&factory)
48
+ record_observation(sample: true, factory: factory)
49
+ end
50
+
51
+ # Control evidence is never sampled, but still consumes every configured
52
+ # rate, count, record-size, and session-size budget.
53
+ def record_control(&factory)
54
+ record_observation(sample: false, factory: factory)
55
+ end
56
+
57
+ # Accounts an instrumentation failure without retaining exception data.
58
+ def internal_error!
59
+ @mutex.synchronize do
60
+ return false if @state == :closed
61
+
62
+ @limits.internal_error!
63
+ true
64
+ end
65
+ end
66
+
67
+ def close(status: :completed)
68
+ requested_status = normalize_status(status)
69
+ @mutex.synchronize do
70
+ return @summary if @summary
71
+
72
+ @state = :closing
73
+ @limits.internal_error!(count: @in_flight) if @in_flight.positive?
74
+ ended_at, ended_monotonic_ns, clock_error = closing_times
75
+ errors = [clock_error].compact
76
+ summary = build_summary(requested_status, ended_at, ended_monotonic_ns)
77
+ errors.concat(write_end_record(summary))
78
+ errors.concat(close_writer)
79
+ summary = build_summary(requested_status, ended_at, ended_monotonic_ns) unless errors.empty?
80
+ @summary = summary
81
+ @state = :closed
82
+ raise errors.first if errors.any? && !session.policy.fail_open?
83
+
84
+ @summary
85
+ end
86
+ end
87
+
88
+ def active?
89
+ state?(:active)
90
+ end
91
+
92
+ def disabled?
93
+ state?(:disabled)
94
+ end
95
+
96
+ def closed?
97
+ state?(:closed)
98
+ end
99
+
100
+ def summary
101
+ @mutex.synchronize { @summary }
102
+ end
103
+
104
+ private
105
+
106
+ def validate_dependencies!(candidate_session, candidate_writer, candidate_clock, candidate_random)
107
+ raise RuntimeContractError, 'session must be a FiberAudit::Runtime::Session' unless candidate_session.is_a?(Session)
108
+ unless candidate_writer.is_a?(JSONL::Writer)
109
+ raise RuntimeContractError, 'writer must be a FiberAudit::Runtime::JSONL::Writer'
110
+ end
111
+ raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
112
+ raise RuntimeContractError, 'random source must respond to call' unless candidate_random.respond_to?(:call)
113
+ end
114
+
115
+ def start_session!
116
+ raise RuntimeSafetyError, 'runtime JSONL writer is not empty' unless writer.bytes_written.zero?
117
+ if session.started_monotonic_ns > MAX_COUNTER
118
+ raise RuntimeSafetyError, 'session monotonic start exceeds signed 64-bit range'
119
+ end
120
+
121
+ line = writer.prepare(JSONL::Schema.start_record(session))
122
+ required = line.bytesize + @end_reserve_bytes
123
+ unless session.policy.session_bytes_allowed?(written_bytes: 0, next_record_bytes: required)
124
+ raise RuntimeSafetyError, 'runtime session limit cannot contain start and end records'
125
+ end
126
+
127
+ writer.write_line(line)
128
+ @start_written = true
129
+ @state = :active
130
+ end
131
+
132
+ def startup_failure!(error)
133
+ raise error unless defined?(@session) && session.policy.fail_open?
134
+
135
+ @limits&.internal_error!
136
+ @state = :disabled
137
+ end
138
+
139
+ def record_observation(sample:, factory:)
140
+ raise ArgumentError, 'record requires an event factory block' unless factory
141
+
142
+ decision = begin_observation(sample: sample)
143
+ return decision unless decision == :selected
144
+
145
+ completed = false
146
+ begin
147
+ event = factory.call
148
+ completed = true
149
+ ensure
150
+ release_failed_factory unless completed
151
+ end
152
+ finish_observation(event)
153
+ end
154
+
155
+ def begin_observation(sample:)
156
+ @mutex.synchronize do
157
+ return :inactive unless @state == :active
158
+
159
+ @limits.observe!
160
+ if sample && !@sampler.sample?
161
+ @limits.drop!(:sampled_out)
162
+ return :sampled_out
163
+ end
164
+
165
+ @in_flight += 1
166
+ :selected
167
+ rescue StandardError => e
168
+ handle_internal_error!(e)
169
+ end
170
+ end
171
+
172
+ def finish_observation(event)
173
+ @mutex.synchronize do
174
+ @in_flight -= 1
175
+ return finish_inactive_observation unless @state == :active
176
+
177
+ emit_or_drop(event)
178
+ rescue StandardError => e
179
+ handle_internal_error!(e)
180
+ ensure
181
+ if writer.failed? && @state == :active
182
+ @limits.internal_error!
183
+ @state = :disabled
184
+ end
185
+ end
186
+ end
187
+
188
+ def finish_inactive_observation
189
+ @limits.internal_error! if @state == :disabled
190
+ :inactive
191
+ end
192
+
193
+ def emit_or_drop(event)
194
+ raise RuntimeContractError, 'event factory must return a FiberAudit::Runtime::Event' unless event.is_a?(Event)
195
+
196
+ now_ns = @clock.monotonic_ns
197
+ reason = @limits.preflight_event(now_ns: now_ns)
198
+ return account_preflight_drop(reason) if reason
199
+
200
+ line = prepare_event(event)
201
+ return :oversize unless line
202
+
203
+ unless event_bytes_allowed?(line)
204
+ @limits.drop!(:session_byte_limited)
205
+ return :session_byte_limited
206
+ end
207
+
208
+ writer.write_line(line)
209
+ @limits.emitted!(now_ns: now_ns)
210
+ @sequence += 1
211
+ :emitted
212
+ end
213
+
214
+ def account_preflight_drop(reason)
215
+ @limits.drop!(reason)
216
+ reason
217
+ end
218
+
219
+ def prepare_event(event)
220
+ record = JSONL::Schema.event_record(session_id: session.id, sequence: @sequence, event: event)
221
+ writer.prepare(record)
222
+ rescue RuntimeSafetyError
223
+ @limits.drop!(:oversize)
224
+ nil
225
+ end
226
+
227
+ def event_bytes_allowed?(line)
228
+ required = line.bytesize + @end_reserve_bytes
229
+ session.policy.session_bytes_allowed?(
230
+ written_bytes: writer.bytes_written,
231
+ next_record_bytes: required
232
+ )
233
+ end
234
+
235
+ def release_failed_factory
236
+ @mutex.synchronize do
237
+ @in_flight -= 1
238
+ @limits.internal_error! if @state == :disabled
239
+ end
240
+ end
241
+
242
+ def handle_internal_error!(error)
243
+ @limits.internal_error!
244
+ @state = :disabled
245
+ raise error unless session.policy.fail_open?
246
+
247
+ :internal_error
248
+ end
249
+
250
+ def closing_times
251
+ [@clock.wall_time, @clock.monotonic_ns, nil]
252
+ rescue StandardError => e
253
+ @limits.internal_error!
254
+ [session.started_at, session.started_monotonic_ns, e]
255
+ end
256
+
257
+ def write_end_record(summary)
258
+ return [] unless @start_written && writer.active?
259
+
260
+ record = JSONL::Schema.end_record(session_id: session.id, sequence: @sequence, summary: summary)
261
+ line = writer.prepare(record)
262
+ if line.bytesize > @end_reserve_bytes
263
+ raise RuntimeSafetyError, 'runtime session end record exceeded its reserved bytes'
264
+ end
265
+ unless session.policy.session_bytes_allowed?(written_bytes: writer.bytes_written, next_record_bytes: line.bytesize)
266
+ raise RuntimeSafetyError, 'runtime session end record exceeded the session byte limit'
267
+ end
268
+
269
+ writer.write_line(line)
270
+ []
271
+ rescue StandardError => e
272
+ @limits.internal_error!
273
+ [e]
274
+ end
275
+
276
+ def close_writer
277
+ writer.close
278
+ []
279
+ rescue StandardError => e
280
+ @limits.internal_error!
281
+ [e]
282
+ end
283
+
284
+ def build_summary(requested_status, ended_at, ended_monotonic_ns)
285
+ status = summary_status(requested_status)
286
+ SessionSummary.new(
287
+ ended_at: ended_at,
288
+ ended_monotonic_ns: ended_monotonic_ns,
289
+ status: status,
290
+ **@limits.counters
291
+ )
292
+ end
293
+
294
+ def summary_status(requested)
295
+ return :aborted if requested == :aborted
296
+ return :degraded if requested == :degraded || @limits.counters[:internal_errors].positive?
297
+
298
+ :completed
299
+ end
300
+
301
+ def normalize_status(value)
302
+ normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
303
+ return normalized if SessionSummary::STATUSES.include?(normalized)
304
+
305
+ raise RuntimeContractError, "status must be one of: #{SessionSummary::STATUSES.join(', ')}"
306
+ end
307
+
308
+ def end_reserve_bytes
309
+ outcome = MAX_COUNTER / OUTCOME_COUNTERS
310
+ summary = SessionSummary.new(
311
+ ended_at: session.started_at,
312
+ ended_monotonic_ns: MAX_COUNTER,
313
+ status: :completed,
314
+ events_observed: outcome * OUTCOME_COUNTERS,
315
+ events_emitted: outcome,
316
+ sampled_out: outcome,
317
+ rate_limited: outcome,
318
+ session_event_limited: outcome,
319
+ session_byte_limited: outcome,
320
+ oversize: outcome,
321
+ internal_errors: MAX_COUNTER
322
+ )
323
+ record = JSONL::Schema.end_record(session_id: session.id, sequence: MAX_COUNTER, summary: summary)
324
+ writer.prepare(record).bytesize
325
+ end
326
+
327
+ def state?(expected)
328
+ @mutex.synchronize { @state == expected }
329
+ end
330
+ end
331
+ # rubocop:enable Metrics/ClassLength
332
+ end
333
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative 'location'
5
+ require_relative 'policy'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ class Redactor
10
+ REDACTED = '[redacted]'
11
+ EXTERNAL = '[external]'
12
+ WINDOWS_ABSOLUTE = %r{\A[A-Za-z]:/}
13
+
14
+ attr_reader :root, :policy
15
+
16
+ def initialize(root:, policy: Policy.new)
17
+ raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
18
+
19
+ safe_root = Validation.string(root, 'root', max_bytes: 4_096)
20
+ @root = canonical_root(safe_root).freeze
21
+ @policy = policy
22
+ freeze
23
+ rescue ArgumentError
24
+ raise RuntimeContractError, 'root is invalid'
25
+ end
26
+
27
+ def location(path:, line: nil, column: nil)
28
+ Location.new(path: redact_path(path), line: line, column: column)
29
+ rescue RuntimeContractError
30
+ Location.new(path: REDACTED, line: nil, column: nil)
31
+ end
32
+
33
+ def operation(value)
34
+ return if value.nil?
35
+
36
+ text = value.is_a?(String) || value.is_a?(Symbol) ? value.to_s : nil
37
+ return REDACTED unless text&.valid_encoding? && text.bytesize <= 256
38
+ return REDACTED if text.match?(Validation::CONTROL)
39
+ return REDACTED unless text.match?(Validation::OPERATION)
40
+
41
+ text.dup.freeze
42
+ end
43
+
44
+ private
45
+
46
+ def canonical_root(value)
47
+ normalized = value.tr('\\', '/')
48
+ return Pathname.new(normalized).cleanpath.to_s if windows_absolute?(normalized)
49
+
50
+ Pathname.new(normalized).expand_path.cleanpath.to_s.tr('\\', '/')
51
+ end
52
+
53
+ def redact_path(value)
54
+ return REDACTED unless value.is_a?(String) && value.valid_encoding? && !value.empty?
55
+ return REDACTED if value.bytesize > Location::MAX_PATH_BYTES || value.match?(Validation::CONTROL)
56
+
57
+ normalized = value.tr('\\', '/')
58
+ return absolute_path(normalized) if windows_absolute?(normalized)
59
+
60
+ path = Pathname.new(normalized)
61
+ return relative_path(path) unless path.absolute?
62
+
63
+ absolute_path(path.expand_path.cleanpath.to_s.tr('\\', '/'))
64
+ rescue ArgumentError
65
+ REDACTED
66
+ end
67
+
68
+ def absolute_path(path)
69
+ clean = Pathname.new(path).cleanpath.to_s.tr('\\', '/')
70
+ return EXTERNAL unless inside_root?(clean)
71
+
72
+ clean[(root.length + 1)..]
73
+ end
74
+
75
+ def relative_path(path)
76
+ clean = path.cleanpath
77
+ value = clean.to_s
78
+ return EXTERNAL if value == '..' || value.start_with?('../')
79
+ return REDACTED if value == '.'
80
+
81
+ value
82
+ end
83
+
84
+ def inside_root?(path)
85
+ candidate, boundary = comparable_paths(path)
86
+ candidate.start_with?("#{boundary}/")
87
+ end
88
+
89
+ def comparable_paths(path)
90
+ if windows_absolute?(path) && windows_absolute?(root)
91
+ [path.downcase, root.downcase]
92
+ else
93
+ [path, root]
94
+ end
95
+ end
96
+
97
+ def windows_absolute?(path)
98
+ path.match?(WINDOWS_ABSOLUTE)
99
+ end
100
+ end
101
+ end
102
+ end