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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../errors'
4
+ require_relative '../validation'
5
+ require_relative 'schema'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ module JSONL
10
+ class Writer
11
+ attr_reader :bytes_written, :max_record_bytes
12
+
13
+ def self.open(path:, max_record_bytes:)
14
+ safe_path = Validation.string(path, 'runtime output path', max_bytes: 4_096)
15
+ # The returned Writer deliberately owns this descriptor until #close.
16
+ io = File.open(safe_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) # rubocop:disable Style/FileOpen
17
+ io.binmode
18
+ io.sync = true
19
+ new(io: io, max_record_bytes: max_record_bytes, owns_io: true)
20
+ rescue StandardError
21
+ io&.close
22
+ raise
23
+ end
24
+
25
+ def initialize(io:, max_record_bytes:, owns_io: false)
26
+ raise RuntimeContractError, 'io must respond to write' unless io.respond_to?(:write)
27
+ unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
28
+ raise RuntimeContractError, 'max_record_bytes must be a positive Integer'
29
+ end
30
+ raise RuntimeContractError, 'owns_io must be a Boolean' unless [true, false].include?(owns_io)
31
+
32
+ @io = io
33
+ @max_record_bytes = max_record_bytes
34
+ @owns_io = owns_io
35
+ @bytes_written = 0
36
+ @state = :active
37
+ end
38
+
39
+ def prepare(record)
40
+ ensure_active!
41
+ Schema.dump(record, max_record_bytes: max_record_bytes)
42
+ end
43
+
44
+ def write(record)
45
+ write_line(prepare(record))
46
+ end
47
+
48
+ def write_line(line)
49
+ ensure_active!
50
+ validate_line!(line)
51
+ completed = false
52
+ begin
53
+ write_all(line)
54
+ @io.flush if @io.respond_to?(:flush)
55
+ completed = true
56
+ ensure
57
+ @state = :failed unless completed
58
+ end
59
+ line.bytesize
60
+ end
61
+
62
+ def active?
63
+ @state == :active
64
+ end
65
+
66
+ def failed?
67
+ @state == :failed
68
+ end
69
+
70
+ def closed?
71
+ @state == :closed
72
+ end
73
+
74
+ def close
75
+ return if closed?
76
+
77
+ completed = false
78
+ begin
79
+ @io.close if @owns_io && @io.respond_to?(:close) && !@io.closed?
80
+ completed = true
81
+ ensure
82
+ @state = :failed unless completed
83
+ end
84
+ @state = :closed unless failed?
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ def write_all(line)
91
+ offset = 0
92
+ while offset < line.bytesize
93
+ written = @io.write(line.byteslice(offset, line.bytesize - offset))
94
+ validate_write_result!(written, line.bytesize - offset)
95
+ offset += written
96
+ @bytes_written += written
97
+ end
98
+ end
99
+
100
+ def ensure_active!
101
+ raise RuntimeSafetyError, "runtime JSONL writer is #{@state}" unless active?
102
+ end
103
+
104
+ def validate_line!(line)
105
+ valid = line.is_a?(String) && line.valid_encoding? && line.end_with?("\n") && line.count("\n") == 1
106
+ raise RuntimeContractError, 'runtime JSONL line must be one valid complete line' unless valid
107
+ return unless line.bytesize > max_record_bytes
108
+
109
+ raise RuntimeSafetyError,
110
+ "runtime JSONL record is #{line.bytesize} bytes; limit is #{max_record_bytes}"
111
+ end
112
+
113
+ def validate_write_result!(written, remaining)
114
+ return if written.is_a?(Integer) && written.positive? && written <= remaining
115
+
116
+ @state = :failed
117
+ raise RuntimeSafetyError, "runtime JSONL write returned invalid byte count: #{written.inspect}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -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