saboteur 0.7.0 → 0.8.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/CHANGELOG.md +657 -0
- data/README.md +198 -1077
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +6 -2
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +120 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +13 -0
- metadata +35 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "process_table"
|
|
5
|
+
require_relative "marker_sweep"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
module Execution
|
|
@@ -12,14 +13,19 @@ module Saboteur
|
|
|
12
13
|
# setsid (a nested saboteur run does exactly that) and leave the group.
|
|
13
14
|
# Parent links survive that, so the tree is rebuilt from the process table
|
|
14
15
|
# on every poll, and every pid ever seen in it is remembered, so a
|
|
15
|
-
# descendant whose parent exits (re-parented to init) is still known
|
|
16
|
+
# descendant whose parent exits (re-parented to init) is still known -
|
|
17
|
+
# but only if some poll SAW the parent link while the intermediate lived.
|
|
18
|
+
# A descendant that leaves the group and loses that parent between two
|
|
19
|
+
# polls is never seen (#365); the marker sweep in kill_all is the net
|
|
20
|
+
# for exactly that case.
|
|
16
21
|
#
|
|
17
22
|
# Identity is pid plus start time, so a recycled pid is never signalled.
|
|
18
23
|
class ProcessTree
|
|
19
24
|
attr_reader :root
|
|
20
25
|
|
|
21
|
-
def initialize(root)
|
|
26
|
+
def initialize(root, marker: nil)
|
|
22
27
|
@root = root
|
|
28
|
+
@marker = marker
|
|
23
29
|
@known = {} # pid => start time
|
|
24
30
|
end
|
|
25
31
|
|
|
@@ -34,6 +40,27 @@ module Saboteur
|
|
|
34
40
|
end
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
# kill_all minus an excluded root, with the pre-kill member
|
|
44
|
+
# descriptions: [killed pids, notes]. The notes are taken first -
|
|
45
|
+
# after the kill the command lines are gone (#486).
|
|
46
|
+
def kill_all_except(root)
|
|
47
|
+
notes = describe_except(root)
|
|
48
|
+
[kill_all - [root], notes]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One "pid (command)" note per live member other than an excluded
|
|
52
|
+
# root, for the leftover-children refusal (#486). Best effort: a
|
|
53
|
+
# process that vanished mid-look lists its pid alone. Call before the
|
|
54
|
+
# kill - after it, the command lines are gone.
|
|
55
|
+
def describe_except(root, table = ProcessTable.snapshot)
|
|
56
|
+
live_members(table).filter_map do |entry|
|
|
57
|
+
next if entry.pid == root
|
|
58
|
+
|
|
59
|
+
command = ProcessTable.command(entry.pid)
|
|
60
|
+
command ? "#{entry.pid} (#{command})" : entry.pid.to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
37
64
|
# Count and resident memory of the live members. rss_baseline_kb is the
|
|
38
65
|
# supervisor's own RSS: forked children share its pages, so only growth
|
|
39
66
|
# beyond it is counted per process.
|
|
@@ -44,15 +71,20 @@ module Saboteur
|
|
|
44
71
|
end
|
|
45
72
|
|
|
46
73
|
# Stops every member (a stopped process cannot fork), rescans until no new
|
|
47
|
-
# member appears, then kills them all.
|
|
74
|
+
# member appears, then kills them all. Each round also sweeps for
|
|
75
|
+
# never-seen escapees carrying the run's marker (#365). Returns the
|
|
76
|
+
# pids it killed.
|
|
48
77
|
def kill_all(rounds: 20)
|
|
49
78
|
killed = []
|
|
50
79
|
rounds.times do
|
|
51
80
|
live = stop_until_stable
|
|
52
|
-
|
|
81
|
+
escaped = marked_escapees
|
|
82
|
+
break if live.empty? && escaped.empty?
|
|
53
83
|
|
|
54
84
|
live.each { |entry| signal("KILL", entry.pid) }
|
|
55
|
-
|
|
85
|
+
signalled = escaped.select { |entry| escapee_alive?(entry) }
|
|
86
|
+
signalled.each { |entry| signal("KILL", entry.pid) }
|
|
87
|
+
killed.concat(live.map(&:pid), signalled.map(&:pid))
|
|
56
88
|
sleep 0.01
|
|
57
89
|
end
|
|
58
90
|
killed.uniq
|
|
@@ -60,6 +92,34 @@ module Saboteur
|
|
|
60
92
|
|
|
61
93
|
private
|
|
62
94
|
|
|
95
|
+
# Live, non-zombie pids carrying the run's marker that the tree never
|
|
96
|
+
# learned: the setsid-and-orphan escapees (#365). Members match the
|
|
97
|
+
# marker too, but the same round kills them anyway.
|
|
98
|
+
def marked_escapees
|
|
99
|
+
return [] unless @marker && MarkerSweep.available?
|
|
100
|
+
|
|
101
|
+
table = ProcessTable.snapshot
|
|
102
|
+
root_started = @known[root]
|
|
103
|
+
MarkerSweep.marked_pids(@marker, Supervisor::TREE_ENV).filter_map do |pid|
|
|
104
|
+
entry = table[pid]
|
|
105
|
+
next unless entry && !entry.zombie && !@known.key?(pid)
|
|
106
|
+
|
|
107
|
+
# A process started before the run's root carries the marker only
|
|
108
|
+
# through an exec chain the run did not spawn: never ours (#389).
|
|
109
|
+
next if root_started && entry.started < root_started
|
|
110
|
+
|
|
111
|
+
entry
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A pid is not an identity: the escapee can exit between the sweep's
|
|
116
|
+
# snapshot and the KILL, and the kernel can hand the pid to someone
|
|
117
|
+
# else. Signal only the process the sweep actually saw (#389).
|
|
118
|
+
def escapee_alive?(entry)
|
|
119
|
+
fresh = ProcessTable.snapshot[entry.pid]
|
|
120
|
+
fresh && !fresh.zombie && fresh.started == entry.started
|
|
121
|
+
end
|
|
122
|
+
|
|
63
123
|
def grow(table)
|
|
64
124
|
@known[root] = table[root].started if @known.empty? && table[root]
|
|
65
125
|
loop do
|
|
@@ -54,9 +54,54 @@ module Saboteur
|
|
|
54
54
|
nil
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# The method object the subject's def currently resolves to,
|
|
58
|
+
# normalized through a known def-time wrapper. The runner compares
|
|
59
|
+
# what the mutant's evaluation installed with what is in place after
|
|
60
|
+
# the tests: a suite that re-loads the file mid-run (a before(:each)
|
|
61
|
+
# `load "lib/..."`, rolify-style) quietly replaces the mutant, and a
|
|
62
|
+
# "survivor" then only proves the tests never ran it. nil when this
|
|
63
|
+
# cannot be told (a missing or non-Ruby method): the caller keeps the
|
|
64
|
+
# verdict.
|
|
65
|
+
def installed_definition(subject)
|
|
66
|
+
owner = resolve_owner(subject)
|
|
67
|
+
target = subject.singleton ? owner.singleton_class : owner
|
|
68
|
+
method = target.instance_method(subject.name)
|
|
69
|
+
Wrapper.original_def(method) || method
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The definition now in place for the subject versus the one the
|
|
75
|
+
# mutant's evaluation installed: a "file:line" site when the tests
|
|
76
|
+
# provably replaced it - a before(:each) re-loading the subject's
|
|
77
|
+
# own file (rolify-style `load "lib/..."`, #597) puts the original
|
|
78
|
+
# back before any test calls it, so a "survivor" proves nothing.
|
|
79
|
+
# Only a positively identified overwrite counts: the replacement
|
|
80
|
+
# comes from the subject's own file, and no prepend or
|
|
81
|
+
# allow_any_instance_of-style wrapper whose super chain still
|
|
82
|
+
# reaches the mutant (those tests did run it). Anything
|
|
83
|
+
# undecidable returns nil and the verdict stands.
|
|
84
|
+
def overwritten_site(subject, installed)
|
|
85
|
+
now = installed_definition(subject)
|
|
86
|
+
return nil if now.nil? || now == installed
|
|
87
|
+
|
|
88
|
+
method = now
|
|
89
|
+
method = method.super_method until method.nil? || method == installed
|
|
90
|
+
return nil if method # the mutant is still reachable through super
|
|
91
|
+
|
|
92
|
+
file, line = now.source_location
|
|
93
|
+
return nil unless file && File.expand_path(file) == File.expand_path(subject.file)
|
|
94
|
+
|
|
95
|
+
"#{file}:#{line}"
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
57
100
|
# True when the subject's id matches a whole_file entry (exact or a
|
|
58
101
|
# File.fnmatch glob).
|
|
59
102
|
def whole_file?(subject)
|
|
103
|
+
return true if subject.whole_file_only?
|
|
104
|
+
|
|
60
105
|
@whole_file.any? { |pattern| File.fnmatch?(pattern, subject.id) }
|
|
61
106
|
end
|
|
62
107
|
|
|
@@ -87,12 +132,15 @@ module Saboteur
|
|
|
87
132
|
|
|
88
133
|
# Resolves the subject's owner the way Ruby would resolve the nesting in
|
|
89
134
|
# the file. const_get also triggers autoload (Zeitwerk) before any reopening.
|
|
135
|
+
# A `class << self` scope resolves the scopes after it (a factory
|
|
136
|
+
# constant assigned there binds on the singleton class, #352); when the
|
|
137
|
+
# subject's own scope is the singleton class, the owner is still the
|
|
138
|
+
# attached class.
|
|
90
139
|
def resolve_owner(subject)
|
|
91
|
-
scopes = [Object]
|
|
92
|
-
|
|
93
|
-
scopes << resolve_path(scope.path, scopes)
|
|
140
|
+
scopes = subject.nesting.each_with_object([Object]) do |scope, resolved|
|
|
141
|
+
resolved << (scope.sclass? ? resolved.last.singleton_class : resolve_path(scope.path, resolved))
|
|
94
142
|
end
|
|
95
|
-
owner = scopes.last
|
|
143
|
+
owner = subject.sclass ? scopes[-2] : scopes.last
|
|
96
144
|
raise Error, "#{subject.owner} is not a class or module" unless owner.is_a?(Module)
|
|
97
145
|
|
|
98
146
|
owner
|
|
@@ -139,28 +187,36 @@ module Saboteur
|
|
|
139
187
|
|
|
140
188
|
# The file as it is on disk, with the method's bytes replaced by text,
|
|
141
189
|
# evaluated at top level under the file's own name and first line.
|
|
142
|
-
# Redefinition warnings are expected and silenced.
|
|
143
190
|
def eval_whole_file(subject, text)
|
|
144
191
|
source = subject.file_source.dup.force_encoding(Encoding::UTF_8)
|
|
145
192
|
spliced = source.byteslice(0, subject.start_byte) + text + source.byteslice(subject.end_byte..)
|
|
146
|
-
|
|
147
|
-
$VERBOSE = nil
|
|
148
|
-
TOPLEVEL_BINDING.eval(spliced, subject.file, 1)
|
|
149
|
-
ensure
|
|
150
|
-
$VERBOSE = verbose
|
|
193
|
+
silencing_warnings { TOPLEVEL_BINDING.eval(spliced, subject.file, 1) }
|
|
151
194
|
end
|
|
152
195
|
|
|
153
196
|
def eval_in_owner(owner, subject, text, extra_lines)
|
|
154
197
|
code, line = with_magic_comment(subject, text, extra_lines)
|
|
155
198
|
eval_target = subject.sclass ? owner.singleton_class : owner
|
|
156
|
-
eval_target.class_eval(code, subject.file, line)
|
|
199
|
+
silencing_warnings { eval_target.class_eval(code, subject.file, line) }
|
|
157
200
|
end
|
|
158
201
|
|
|
159
202
|
def eval_nested(subject, text, extra_lines)
|
|
160
203
|
headers = subject.nesting.map(&:header)
|
|
161
204
|
wrapped = "#{headers.join('; ')}; #{text}; #{(['end'] * headers.size).join('; ')}"
|
|
162
205
|
code, line = with_magic_comment(subject, wrapped, extra_lines)
|
|
163
|
-
TOPLEVEL_BINDING.eval(code, subject.file, line)
|
|
206
|
+
silencing_warnings { TOPLEVEL_BINDING.eval(code, subject.file, line) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Re-evaluation redefines the method by design, and a "method
|
|
210
|
+
# redefined" warning is only ever emitted under $VERBOSE. Suites that
|
|
211
|
+
# raise on warnings (--warnings plus a raising Warning.warn or
|
|
212
|
+
# Warning.process handler, dry-rb/hanami-style) must not see it:
|
|
213
|
+
# before the guard every mutant errored as RuntimeError (#603).
|
|
214
|
+
def silencing_warnings
|
|
215
|
+
verbose = $VERBOSE
|
|
216
|
+
$VERBOSE = nil
|
|
217
|
+
yield
|
|
218
|
+
ensure
|
|
219
|
+
$VERBOSE = verbose
|
|
164
220
|
end
|
|
165
221
|
|
|
166
222
|
# The first line of code sits extra_lines above the def, so the def
|
|
@@ -144,6 +144,7 @@ module Saboteur
|
|
|
144
144
|
@reevaluator.call(job.subject, job.text, prefix: job.eval_prefix)
|
|
145
145
|
replaced = @reevaluator.replaced_definition(job.subject)
|
|
146
146
|
Protocol.emit(writer, "replaced", site: replaced) if replaced
|
|
147
|
+
@installed_definition = job.mutation ? @reevaluator.installed_definition(job.subject) : nil
|
|
147
148
|
end
|
|
148
149
|
|
|
149
150
|
# Capture detection is best-effort: a subject shape it cannot scan
|
|
@@ -156,18 +157,40 @@ module Saboteur
|
|
|
156
157
|
|
|
157
158
|
def emit_result(writer, job, has_captures)
|
|
158
159
|
method = replacement_method(job) if has_captures && job.mutation
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
flags = { replacement_called: method ? false : nil, mutant_called: false }
|
|
161
|
+
target = method || (job.mutation ? @installed_definition : nil)
|
|
162
|
+
trace = call_probe(target, flags)
|
|
161
163
|
started_at = Time.now
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
else
|
|
165
|
-
@hooks.around(job.mutation) { run_tests(job.test_ids) }
|
|
166
|
-
end
|
|
164
|
+
suite = proc { @hooks.around(job.mutation) { run_tests(job.test_ids) } }
|
|
165
|
+
result = trace ? trace.enable(target: target, &suite) : suite.call
|
|
167
166
|
if result.nil?
|
|
168
167
|
Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
|
|
169
168
|
else
|
|
169
|
+
called = flags[:replacement_called]
|
|
170
170
|
Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called)
|
|
171
|
+
# A reload after the mutant already ran (an after-hook restore)
|
|
172
|
+
# is no reason to doubt the verdict; only a never-invoked mutant
|
|
173
|
+
# can have been hidden by the overwrite.
|
|
174
|
+
emit_overwritten(writer, job, flags[:mutant_called])
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A reload after the mutant already ran (an after-hook restore) is
|
|
179
|
+
# no reason to doubt the verdict; only a never-invoked mutant can
|
|
180
|
+
# have been hidden by the overwrite.
|
|
181
|
+
def emit_overwritten(writer, job, mutant_called)
|
|
182
|
+
overwritten = job.mutation && @installed_definition &&
|
|
183
|
+
@reevaluator.overwritten_site(job.subject, @installed_definition)
|
|
184
|
+
Protocol.emit(writer, "overwritten", site: overwritten) if overwritten && !mutant_called
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# One call probe on the installed definition feeds both verdicts:
|
|
188
|
+
# replacement_called for stale captures, and overwrite detection for
|
|
189
|
+
# a mutant the suite replaced before any test ran it (#597).
|
|
190
|
+
def call_probe(target, flags)
|
|
191
|
+
target && TracePoint.new(:call) do
|
|
192
|
+
flags[:mutant_called] = true
|
|
193
|
+
flags[:replacement_called] = true unless flags[:replacement_called].nil?
|
|
171
194
|
end
|
|
172
195
|
end
|
|
173
196
|
|
|
@@ -392,6 +415,9 @@ module Saboteur
|
|
|
392
415
|
captures = events.find { |event| event["event"] == "captures" }
|
|
393
416
|
return unreachable(outcome, captures) if unobservable?(outcome, captures, job, result["replacement_called"])
|
|
394
417
|
|
|
418
|
+
overwritten = events.find { |event| event["event"] == "overwritten" }
|
|
419
|
+
return overwritten_by_later_load(outcome, overwritten) if replaced_while_running?(outcome, overwritten, job)
|
|
420
|
+
|
|
395
421
|
outcome
|
|
396
422
|
end
|
|
397
423
|
|
|
@@ -414,6 +440,19 @@ module Saboteur
|
|
|
414
440
|
captures && job.mutation && outcome.status == :survived && replacement_called == false
|
|
415
441
|
end
|
|
416
442
|
|
|
443
|
+
# A surviving mutant the tests themselves overwrote: a before(:each)
|
|
444
|
+
# re-loading the file replaced it before any test ran (rolify's suite
|
|
445
|
+
# shape, #597), so the verdict says nothing about the tests.
|
|
446
|
+
def replaced_while_running?(outcome, overwritten, job)
|
|
447
|
+
overwritten && job.mutation && outcome.status == :survived
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def overwritten_by_later_load(outcome, overwritten)
|
|
451
|
+
Outcome.new(status: :unreachable, runtime: outcome.runtime,
|
|
452
|
+
message: "the tests (or their hooks) replaced the definition while they ran " \
|
|
453
|
+
"(#{overwritten['site']}), so this mutant never ran")
|
|
454
|
+
end
|
|
455
|
+
|
|
417
456
|
# A survivor that no test could have observed: a Method object
|
|
418
457
|
# captured at load still calls the original. Not a verdict on the
|
|
419
458
|
# tests, so it is its own status, out of the score.
|
|
@@ -57,8 +57,15 @@ module Saboteur
|
|
|
57
57
|
return @abi_version if defined?(@abi_version)
|
|
58
58
|
return @abi_version = 0 unless linux?
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
errno
|
|
60
|
+
# Build the errno reader BEFORE the probe: its lazy dlopen/dlsym run
|
|
61
|
+
# libc calls that can set errno, so building it between the probe and
|
|
62
|
+
# the read can replace the real answer with a stale 0.
|
|
63
|
+
LibC.errno_location_fn
|
|
64
|
+
version, errno = probe_once
|
|
65
|
+
# A failed syscall always sets errno, so (failure, errno 0) is never
|
|
66
|
+
# the kernel's real answer - the read was clobbered between the call
|
|
67
|
+
# and here. Probe once more, now with every Fiddle function warm.
|
|
68
|
+
version, errno = probe_once if !version.positive? && errno.zero?
|
|
62
69
|
@abi_version = probe_abi(version, errno)
|
|
63
70
|
rescue Fiddle::DLError
|
|
64
71
|
# No usable libc (an unusual musl/ldso layout): the sandbox cannot
|
|
@@ -70,6 +77,12 @@ module Saboteur
|
|
|
70
77
|
# no Landlock to give: run unsandboxed, noted once. Anything else
|
|
71
78
|
# (EPERM under a seccomp policy, ...) means the kernel is there but
|
|
72
79
|
# refused - fail closed rather than run without the promised sandbox.
|
|
80
|
+
# One create-ruleset version probe with its errno read paired to it.
|
|
81
|
+
def probe_once
|
|
82
|
+
version = LibC.syscall_fn.call(SYS_CREATE_RULESET, 0, 0, CREATE_RULESET_VERSION, 0)
|
|
83
|
+
[version, last_errno]
|
|
84
|
+
end
|
|
85
|
+
|
|
73
86
|
def probe_abi(version, errno)
|
|
74
87
|
return version if version.positive?
|
|
75
88
|
return 0 if UNSUPPORTED_ERRNOS.include?(errno)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fiddle"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Execution
|
|
8
|
+
# The macOS counterpart of Sandbox (Landlock): a Seatbelt profile
|
|
9
|
+
# applied with sandbox_init inside the mutant child. sandbox-exec and
|
|
10
|
+
# sandbox_init are deprecated by Apple with no published replacement
|
|
11
|
+
# and no removal timeline (#436 tracks how Apple, Chrome, Codex,
|
|
12
|
+
# Claude Code, Bazel and Nix respond), so Seatbelt is opt-in and the
|
|
13
|
+
# probe below fails closed: a child that cannot be confined runs with
|
|
14
|
+
# the hazard guard and canary only, never silently "sandboxed".
|
|
15
|
+
#
|
|
16
|
+
# Like Sandbox, requiring this file is inert off macOS: libsandbox is
|
|
17
|
+
# dlopened lazily by the first probe, and the probe forks so the
|
|
18
|
+
# irreversible sandbox_init never touches the run's own process.
|
|
19
|
+
module Seatbelt
|
|
20
|
+
LIBSANDBOX = "/usr/lib/system/libsystem_sandbox.dylib"
|
|
21
|
+
LIBSYSTEM = "/usr/lib/libSystem.B.dylib"
|
|
22
|
+
F_GETPATH = 50
|
|
23
|
+
PATH_MAX = 1024
|
|
24
|
+
|
|
25
|
+
# The probe profile must deny the write it then attempts.
|
|
26
|
+
PROBE_PROFILE = "(version 1)(deny default)"
|
|
27
|
+
|
|
28
|
+
# sandbox_init error texts that say the facility is genuinely gone
|
|
29
|
+
# (a future macOS without Seatbelt): degrade, noted once. Anything
|
|
30
|
+
# else (a profile syntax tightening, EPERM, a lost child) fails
|
|
31
|
+
# closed: SandboxPolicy#seatbelt_hook refuses the run rather than
|
|
32
|
+
# confining nothing, exactly like the Landlock probe (#178) - never
|
|
33
|
+
# silently unsandboxed.
|
|
34
|
+
UNSUPPORTED_ERRORS = ["operation not supported", "function not implemented"].freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def darwin?
|
|
39
|
+
RUBY_PLATFORM.include?("darwin")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One forked behavioral probe, memoized per process:
|
|
43
|
+
# :unsupported_platform off macOS, :ok when a deny-default profile
|
|
44
|
+
# really blocks a write, :unavailable when sandbox_init reports the
|
|
45
|
+
# facility missing, :unenforced when the API claims success but the
|
|
46
|
+
# write goes through, :error for anything else. Behavioral, not
|
|
47
|
+
# symbol presence: a macOS update that rejects the profile language
|
|
48
|
+
# shows up here, not in a dlopen.
|
|
49
|
+
def probe
|
|
50
|
+
return @probe if defined?(@probe)
|
|
51
|
+
return @probe = :unsupported_platform unless darwin?
|
|
52
|
+
|
|
53
|
+
@probe = run_probe
|
|
54
|
+
rescue Fiddle::DLError
|
|
55
|
+
@probe = :unavailable
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def available?
|
|
59
|
+
probe == :ok
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Called at the top of a mutant child, mirroring Sandbox.restrict!:
|
|
63
|
+
# irreversible and fail-closed - a child whose profile does not apply
|
|
64
|
+
# must not run the mutant. keep_fds is Sandbox's hook contract
|
|
65
|
+
# (#234): Landlock gates opens, so its children keep writing through
|
|
66
|
+
# descriptors the parent opened (their logs); Seatbelt checks
|
|
67
|
+
# file-write* on EVERY write, so those inherited files join the
|
|
68
|
+
# profile as literal grants instead. Pipes and sockets among them
|
|
69
|
+
# have no path and need no grant.
|
|
70
|
+
def restrict!(writable:, keep_fds: [])
|
|
71
|
+
profile = Profile.build(writable: writable.map { |dir| resolved_grant(dir) },
|
|
72
|
+
inherited_files: fd_paths(keep_fds))
|
|
73
|
+
rc, message = sandbox_init(profile)
|
|
74
|
+
unless rc.zero?
|
|
75
|
+
raise Error, "the Seatbelt sandbox refused to engage (#{message}); the mutant does not run unconfined"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Seatbelt compares the kernel's resolved path, so a grant spelled
|
|
82
|
+
# through a symlink (macOS /var is /private/var) never matches and the
|
|
83
|
+
# "granted" write is denied. Resolve what exists; a dir not yet
|
|
84
|
+
# created grants nothing either way, so keep its spelling rather than
|
|
85
|
+
# fail the child on a dead grant. fcntl(F_GETPATH) already returns
|
|
86
|
+
# the resolved path for inherited files, so they need no such pass.
|
|
87
|
+
def resolved_grant(dir)
|
|
88
|
+
File.exist?(dir) ? File.realpath(dir) : dir
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# fcntl(F_GETPATH) maps an inherited descriptor back to its path; a
|
|
92
|
+
# descriptor without one (pipe, socket) is skipped.
|
|
93
|
+
def fd_paths(keep_fds)
|
|
94
|
+
keep_fds.filter_map do |fd|
|
|
95
|
+
buffer = Fiddle::Pointer.malloc(PATH_MAX)
|
|
96
|
+
LibSystem.fcntl_fn.call(fd, F_GETPATH, buffer).negative? ? nil : buffer.to_s
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def classify(verdict)
|
|
101
|
+
case verdict
|
|
102
|
+
when "ok" then :ok
|
|
103
|
+
when "unenforced" then :unenforced
|
|
104
|
+
when /\Ainit_failed:(?<message>.*)\z/m
|
|
105
|
+
message = Regexp.last_match[:message].downcase
|
|
106
|
+
UNSUPPORTED_ERRORS.any? { |text| message.include?(text) } ? :unavailable : :error
|
|
107
|
+
else
|
|
108
|
+
:error
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def run_probe
|
|
113
|
+
reader, writer = IO.pipe
|
|
114
|
+
pid = fork { probe_child(writer) }
|
|
115
|
+
writer.close
|
|
116
|
+
verdict = reader.read
|
|
117
|
+
reader.close
|
|
118
|
+
Process.waitpid(pid)
|
|
119
|
+
classify(verdict)
|
|
120
|
+
ensure
|
|
121
|
+
reader.close unless reader.closed?
|
|
122
|
+
writer.close unless writer.closed?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Child only: confine, then attempt the write the profile must block.
|
|
126
|
+
# The parent reads a single verdict word; exit! so no at_exit hook or
|
|
127
|
+
# finalizer runs under the sandbox.
|
|
128
|
+
def probe_child(writer)
|
|
129
|
+
rc, message = sandbox_init(PROBE_PROFILE)
|
|
130
|
+
verdict =
|
|
131
|
+
if rc.zero?
|
|
132
|
+
begin
|
|
133
|
+
File.open(File::NULL, "w") { true }
|
|
134
|
+
"unenforced"
|
|
135
|
+
rescue SystemCallError, IOError
|
|
136
|
+
"ok"
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
"init_failed:#{message}"
|
|
140
|
+
end
|
|
141
|
+
writer.write(verdict)
|
|
142
|
+
writer.close
|
|
143
|
+
exit!(0)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# int sandbox_init(const char *profile, uint64_t flags, char **errorbuf)
|
|
147
|
+
def sandbox_init(profile)
|
|
148
|
+
errbuf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
|
|
149
|
+
rc = LibSandbox.sandbox_init_fn.call(profile, 0, errbuf)
|
|
150
|
+
return [0, nil] if rc.zero?
|
|
151
|
+
|
|
152
|
+
# errorbuf is a char**: dereference once to reach the message.
|
|
153
|
+
message = begin
|
|
154
|
+
address = errbuf[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
|
|
155
|
+
address&.positive? ? Fiddle::Pointer.new(address).to_s : "unknown"
|
|
156
|
+
rescue StandardError
|
|
157
|
+
"unknown"
|
|
158
|
+
end
|
|
159
|
+
[rc, "sandbox_init: #{message}"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# fcntl for the F_GETPATH lookup; lazy like LibSandbox.
|
|
163
|
+
module LibSystem
|
|
164
|
+
module_function
|
|
165
|
+
|
|
166
|
+
def fcntl_fn
|
|
167
|
+
@fcntl_fn ||= Fiddle::Function.new(
|
|
168
|
+
Fiddle.dlopen(Seatbelt::LIBSYSTEM)["fcntl"],
|
|
169
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
170
|
+
Fiddle::TYPE_INT
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Lazy, so requiring the file on Linux or a stripped macOS stays inert
|
|
176
|
+
# (mirrors Sandbox::LibC, #178).
|
|
177
|
+
module LibSandbox
|
|
178
|
+
module_function
|
|
179
|
+
|
|
180
|
+
def handle
|
|
181
|
+
@handle ||= Fiddle.dlopen(Seatbelt::LIBSANDBOX)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def sandbox_init_fn
|
|
185
|
+
@sandbox_init_fn ||= Fiddle::Function.new(
|
|
186
|
+
handle["sandbox_init"],
|
|
187
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
|
|
188
|
+
Fiddle::TYPE_INT
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A Landlock-shaped Seatbelt profile: reads, execution, network and
|
|
194
|
+
# IPC stay allowed everywhere (allow default); only writes are
|
|
195
|
+
# denied, then re-allowed under the granted dirs and /dev/null -
|
|
196
|
+
# exactly the Sandbox grant semantics. allow-default is deliberate:
|
|
197
|
+
# deny-default profiles break on macOS updates whenever Apple adds a
|
|
198
|
+
# mach service the profile did not name, the failure mode behind the
|
|
199
|
+
# macOS 26 sandbox breakage reports (#436).
|
|
200
|
+
module Profile
|
|
201
|
+
HEADER = "(version 1)\n"
|
|
202
|
+
|
|
203
|
+
module_function
|
|
204
|
+
|
|
205
|
+
# inherited_files: already-open logs the child must keep writing
|
|
206
|
+
# (#234) - granted per file, never the cache dir that holds them.
|
|
207
|
+
def build(writable:, inherited_files: [])
|
|
208
|
+
lines = [HEADER, "(allow default)\n", "(deny file-write*)\n"]
|
|
209
|
+
writable.each do |dir|
|
|
210
|
+
lines << %((allow file-write* (subpath "#{escape(dir)}"))\n)
|
|
211
|
+
end
|
|
212
|
+
(inherited_files + [File::NULL]).uniq.each do |file|
|
|
213
|
+
lines << %((allow file-write* (literal "#{escape(file)}"))\n)
|
|
214
|
+
end
|
|
215
|
+
lines.join
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# SBPL string literals are double-quoted with backslash escapes; a
|
|
219
|
+
# grant path must never inject profile source.
|
|
220
|
+
def escape(path)
|
|
221
|
+
path.gsub(/["\\]/) { |char| "\\#{char}" }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|