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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'policy'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
class Sampler
|
|
8
|
+
RANDOM_SOURCE = -> { Random.rand }.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :policy
|
|
11
|
+
|
|
12
|
+
def initialize(policy:, random: RANDOM_SOURCE)
|
|
13
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
|
|
14
|
+
raise RuntimeContractError, 'random source must respond to call' unless random.respond_to?(:call)
|
|
15
|
+
|
|
16
|
+
@policy = policy
|
|
17
|
+
@random = random
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sample?
|
|
22
|
+
policy.sample?(draw: @random.call)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'watchdog'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Explicit-runtime-only hooks for schedulers installed after RUBYOPT boot.
|
|
8
|
+
class SchedulerObserver
|
|
9
|
+
module FiberHook
|
|
10
|
+
# Ruby's scheduler API fixes this writer-like method name.
|
|
11
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
12
|
+
def set_scheduler(scheduler)
|
|
13
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_replacing(thread: Thread.current) if Fiber.scheduler
|
|
14
|
+
result = super
|
|
15
|
+
if scheduler
|
|
16
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_installed(
|
|
17
|
+
scheduler: scheduler,
|
|
18
|
+
thread: Thread.current
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module SchedulerCloseHook
|
|
27
|
+
def close(...)
|
|
28
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_closing(thread: Thread.current)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def activate(watchdog:)
|
|
35
|
+
raise RuntimeContractError, 'watchdog must be a FiberAudit::Runtime::Watchdog' unless watchdog.is_a?(Watchdog)
|
|
36
|
+
|
|
37
|
+
install_fiber_hook!
|
|
38
|
+
observer = new(watchdog: watchdog)
|
|
39
|
+
@current = observer
|
|
40
|
+
observer.attach_existing!
|
|
41
|
+
observer
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scheduler_replacing(thread:)
|
|
45
|
+
current_for_process&.scheduler_closing(thread: thread)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scheduler_installed(scheduler:, thread:)
|
|
49
|
+
observer = current_for_process
|
|
50
|
+
return unless observer
|
|
51
|
+
|
|
52
|
+
observer.scheduler_installed(scheduler: scheduler, thread: thread)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scheduler_closing(thread:)
|
|
56
|
+
current_for_process&.scheduler_closing(thread: thread)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deactivate(observer)
|
|
60
|
+
@current = nil if @current.equal?(observer)
|
|
61
|
+
observer.deactivate
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def current_for_process
|
|
67
|
+
observer = @current
|
|
68
|
+
return observer if observer&.active_for_current_process?
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def install_fiber_hook!
|
|
74
|
+
singleton = Fiber.singleton_class
|
|
75
|
+
singleton.prepend(FiberHook) unless singleton.ancestors.include?(FiberHook)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_reader :watchdog, :owner_pid
|
|
80
|
+
|
|
81
|
+
def initialize(watchdog:)
|
|
82
|
+
@watchdog = watchdog
|
|
83
|
+
@owner_pid = Process.pid
|
|
84
|
+
@active = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def active_for_current_process?
|
|
88
|
+
@active && owner_pid == Process.pid
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def attach_existing!
|
|
92
|
+
scheduler = Fiber.scheduler
|
|
93
|
+
scheduler_installed(scheduler: scheduler, thread: Thread.current) if scheduler
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def scheduler_installed(scheduler:, thread:)
|
|
98
|
+
return self unless active_for_current_process? && watchdog.enabled?
|
|
99
|
+
|
|
100
|
+
install_close_hook!(scheduler)
|
|
101
|
+
watchdog.scheduler_installed(thread: thread)
|
|
102
|
+
self
|
|
103
|
+
rescue StandardError
|
|
104
|
+
watchdog.scheduler_unsupported(thread: thread)
|
|
105
|
+
raise unless watchdog.fail_open?
|
|
106
|
+
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def scheduler_closing(thread:)
|
|
111
|
+
watchdog.scheduler_closing(thread: thread) if active_for_current_process?
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def deactivate
|
|
116
|
+
@active = false
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def install_close_hook!(scheduler)
|
|
123
|
+
singleton = scheduler.singleton_class
|
|
124
|
+
singleton.prepend(SchedulerCloseHook) unless singleton.ancestors.include?(SchedulerCloseHook)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../version'
|
|
4
|
+
require_relative 'policy'
|
|
5
|
+
require_relative 'validation'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
Session = Data.define(
|
|
10
|
+
:id,
|
|
11
|
+
:started_at,
|
|
12
|
+
:started_monotonic_ns,
|
|
13
|
+
:policy,
|
|
14
|
+
:tool_version,
|
|
15
|
+
:ruby_version
|
|
16
|
+
) do
|
|
17
|
+
def initialize(
|
|
18
|
+
id:,
|
|
19
|
+
started_at:,
|
|
20
|
+
started_monotonic_ns:,
|
|
21
|
+
policy: Policy.new,
|
|
22
|
+
tool_version: FiberAudit::VERSION,
|
|
23
|
+
ruby_version: RUBY_VERSION
|
|
24
|
+
)
|
|
25
|
+
super(
|
|
26
|
+
id: normalize_id(id),
|
|
27
|
+
started_at: Validation.utc_time(started_at, 'started_at'),
|
|
28
|
+
started_monotonic_ns: Validation.integer(started_monotonic_ns, 'started_monotonic_ns'),
|
|
29
|
+
policy: normalize_policy(policy),
|
|
30
|
+
tool_version: Validation.string(tool_version, 'tool_version', max_bytes: 64),
|
|
31
|
+
ruby_version: Validation.string(ruby_version, 'ruby_version', max_bytes: 64)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize_id(value)
|
|
38
|
+
unless value.is_a?(String) && value.match?(Validation::UUID)
|
|
39
|
+
raise RuntimeContractError, 'id must be a canonical lowercase UUID'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
value.dup.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_policy(value)
|
|
46
|
+
return value if value.is_a?(Policy)
|
|
47
|
+
|
|
48
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
SessionSummary = Data.define(
|
|
53
|
+
:ended_at,
|
|
54
|
+
:ended_monotonic_ns,
|
|
55
|
+
:status,
|
|
56
|
+
:events_observed,
|
|
57
|
+
:events_emitted,
|
|
58
|
+
:sampled_out,
|
|
59
|
+
:rate_limited,
|
|
60
|
+
:session_event_limited,
|
|
61
|
+
:session_byte_limited,
|
|
62
|
+
:oversize,
|
|
63
|
+
:internal_errors
|
|
64
|
+
) do
|
|
65
|
+
def initialize(ended_at:, ended_monotonic_ns:, status:, **counters)
|
|
66
|
+
unknown = counters.keys - SessionSummary::COUNTERS
|
|
67
|
+
missing = SessionSummary::COUNTERS - counters.keys
|
|
68
|
+
raise RuntimeContractError, "unknown session summary field: #{unknown.first}" unless unknown.empty?
|
|
69
|
+
raise RuntimeContractError, "missing session summary field: #{missing.first}" unless missing.empty?
|
|
70
|
+
|
|
71
|
+
normalized_status = status.is_a?(String) || status.is_a?(Symbol) ? status.to_sym : nil
|
|
72
|
+
unless SessionSummary::STATUSES.include?(normalized_status)
|
|
73
|
+
raise RuntimeContractError, "status must be one of: #{SessionSummary::STATUSES.join(', ')}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
normalized_counters = counters.to_h do |name, value|
|
|
77
|
+
[name, Validation.integer(value, name.to_s)]
|
|
78
|
+
end
|
|
79
|
+
accounted = %i[
|
|
80
|
+
events_emitted
|
|
81
|
+
sampled_out
|
|
82
|
+
rate_limited
|
|
83
|
+
session_event_limited
|
|
84
|
+
session_byte_limited
|
|
85
|
+
oversize
|
|
86
|
+
].sum { |name| normalized_counters.fetch(name) }
|
|
87
|
+
if accounted > normalized_counters.fetch(:events_observed)
|
|
88
|
+
raise RuntimeContractError, 'event accounting exceeds events_observed'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
super(
|
|
92
|
+
ended_at: Validation.utc_time(ended_at, 'ended_at'),
|
|
93
|
+
ended_monotonic_ns: Validation.integer(ended_monotonic_ns, 'ended_monotonic_ns'),
|
|
94
|
+
status: normalized_status,
|
|
95
|
+
**normalized_counters
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
SessionSummary.const_set(:STATUSES, %i[completed degraded aborted].freeze)
|
|
101
|
+
SessionSummary.const_set(:COUNTERS, %i[
|
|
102
|
+
events_observed
|
|
103
|
+
events_emitted
|
|
104
|
+
sampled_out
|
|
105
|
+
rate_limited
|
|
106
|
+
session_event_limited
|
|
107
|
+
session_byte_limited
|
|
108
|
+
oversize
|
|
109
|
+
internal_errors
|
|
110
|
+
].freeze)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
class Supervisor
|
|
8
|
+
SIGNALS = %w[INT TERM HUP QUIT].select { |name| Signal.list.key?(name) }.freeze
|
|
9
|
+
|
|
10
|
+
class SystemAdapter
|
|
11
|
+
def spawn(environment, command, cwd)
|
|
12
|
+
executable = command.fetch(0)
|
|
13
|
+
Process.spawn(
|
|
14
|
+
environment,
|
|
15
|
+
[executable, executable],
|
|
16
|
+
*command.drop(1),
|
|
17
|
+
chdir: cwd,
|
|
18
|
+
pgroup: true
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def wait2(pid)
|
|
23
|
+
Process.wait2(pid)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def trap(signal, handler = nil, &)
|
|
27
|
+
Signal.trap(signal, handler, &)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def kill(signal, pid)
|
|
31
|
+
Process.kill(signal, pid)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(command:, environment:, cwd:, adapter: SystemAdapter.new)
|
|
36
|
+
validate_command!(command)
|
|
37
|
+
raise RuntimeContractError, 'child environment must be a Hash' unless environment.is_a?(Hash)
|
|
38
|
+
raise RuntimeContractError, 'runtime cwd must be an existing directory' unless File.directory?(cwd)
|
|
39
|
+
|
|
40
|
+
@command = command.map { |argument| argument.dup.freeze }.freeze
|
|
41
|
+
@environment = environment.transform_values { |value| value&.dup&.freeze }.freeze
|
|
42
|
+
@cwd = File.expand_path(cwd).freeze
|
|
43
|
+
@adapter = adapter
|
|
44
|
+
@child_pid = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run
|
|
48
|
+
handlers = {}
|
|
49
|
+
begin
|
|
50
|
+
# Install traps only after spawn so POSIX-spawned children cannot inherit
|
|
51
|
+
# FiberAudit's forwarding handlers and accidentally ignore termination.
|
|
52
|
+
@child_pid = @adapter.spawn(@environment, @command, @cwd)
|
|
53
|
+
install_handlers(handlers)
|
|
54
|
+
_pid, status = wait_for_child
|
|
55
|
+
status_code(status)
|
|
56
|
+
ensure
|
|
57
|
+
restore_handlers(handlers)
|
|
58
|
+
@child_pid = nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def validate_command!(command)
|
|
65
|
+
unless command.is_a?(Array) && !command.empty? && command.all?(String)
|
|
66
|
+
raise RuntimeContractError, 'runtime command must be a nonempty Array of Strings'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
command.each_with_index do |argument, index|
|
|
70
|
+
next if argument.valid_encoding? && !argument.include?("\0") && (index.positive? || !argument.empty?)
|
|
71
|
+
|
|
72
|
+
raise RuntimeContractError, 'runtime command contains an invalid argument'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def install_handlers(handlers)
|
|
77
|
+
SIGNALS.each do |signal|
|
|
78
|
+
handlers[signal] = @adapter.trap(signal) { receive_signal(signal) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def restore_handlers(handlers)
|
|
83
|
+
handlers.each { |signal, previous| @adapter.trap(signal, previous) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def receive_signal(signal)
|
|
87
|
+
forward_signal(signal) if @child_pid
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def forward_signal(signal)
|
|
91
|
+
@adapter.kill(signal, -@child_pid)
|
|
92
|
+
rescue Errno::ESRCH, Errno::EINVAL, NotImplementedError
|
|
93
|
+
begin
|
|
94
|
+
@adapter.kill(signal, @child_pid)
|
|
95
|
+
rescue Errno::ESRCH, Errno::EINVAL, NotImplementedError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def wait_for_child
|
|
101
|
+
@adapter.wait2(@child_pid)
|
|
102
|
+
rescue Errno::EINTR
|
|
103
|
+
retry
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def status_code(status)
|
|
107
|
+
return status.exitstatus if status.exited?
|
|
108
|
+
return 128 + status.termsig if status.signaled?
|
|
109
|
+
|
|
110
|
+
raise RuntimeSafetyError, 'wrapped command ended without an exit status'
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../errors'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
module Validation
|
|
8
|
+
IDENTIFIER = /\A[a-z][a-z0-9_]*\z/
|
|
9
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
|
|
10
|
+
CONTROL = /[[:cntrl:]]/
|
|
11
|
+
OPERATION = /\A(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*(?:[.#](?:[a-zA-Z_][a-zA-Z0-9_]*[!?=]?|\[\]=?))\z/
|
|
12
|
+
SPECIAL_OPERATIONS = %w[Thread.current.[] Thread.current.[]=].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def identifier(value, field, max_bytes: 64)
|
|
17
|
+
normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_s : nil
|
|
18
|
+
unless normalized&.match?(IDENTIFIER) && normalized.bytesize <= max_bytes
|
|
19
|
+
raise RuntimeContractError, "#{field} must be a snake-case identifier"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
normalized.to_sym
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def string(value, field, max_bytes:, allow_nil: false)
|
|
26
|
+
return if value.nil? && allow_nil
|
|
27
|
+
unless value.is_a?(String) && value.valid_encoding? && !value.empty? &&
|
|
28
|
+
value.bytesize <= max_bytes && !value.match?(CONTROL)
|
|
29
|
+
raise RuntimeContractError, "#{field} must be a non-empty UTF-8 String without control characters"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
value.dup.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def operation(value, allow_nil: false)
|
|
36
|
+
return if value.nil? && allow_nil
|
|
37
|
+
|
|
38
|
+
normalized = string(value, 'operation', max_bytes: 256)
|
|
39
|
+
unless normalized == '[redacted]' || SPECIAL_OPERATIONS.include?(normalized) || normalized.match?(OPERATION)
|
|
40
|
+
raise RuntimeContractError, 'operation must be canonical or redacted'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
normalized
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def integer(value, field, minimum: 0, allow_nil: false)
|
|
47
|
+
return if value.nil? && allow_nil
|
|
48
|
+
unless value.is_a?(Integer) && value >= minimum
|
|
49
|
+
raise RuntimeContractError, "#{field} must be an Integer >= #{minimum}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def finite_number(value, field)
|
|
56
|
+
raise RuntimeContractError, "#{field} must be a finite number" unless value.is_a?(Numeric) && value.finite?
|
|
57
|
+
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def utc_time(value, field)
|
|
62
|
+
raise RuntimeContractError, "#{field} must be a Time" unless value.is_a?(Time)
|
|
63
|
+
|
|
64
|
+
value.getutc.freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|