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.
- checksums.yaml +4 -4
- data/.fiber-audit.example.yml +19 -0
- data/ARCHITECTURE.md +726 -0
- data/CHANGELOG.md +30 -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 +279 -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
data/lib/fiber_audit/errors.rb
CHANGED
|
@@ -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
|
|
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
|