saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
8
|
# What a run has to say besides results: warnings for the report, the
|
|
7
9
|
# count of mutations dropped for not parsing, and progress marks.
|
|
8
10
|
class Diagnostics
|
|
9
11
|
# See progress_heartbeat. Time-gated itself, so the supervisor can
|
|
10
|
-
# call tick on every poll.
|
|
12
|
+
# call tick on every poll. A phase whose child writes "N/M examples
|
|
13
|
+
# finished" lines to a log (the baseline and coverage children do) can
|
|
14
|
+
# be watched: the heartbeat then adds the count, the percentage and an
|
|
15
|
+
# estimate of the time left to its elapsed-time line.
|
|
11
16
|
class ProgressHeartbeat
|
|
17
|
+
COUNT = %r{(\d+)/([1-9]\d*) examples finished}
|
|
18
|
+
TAIL_BYTES = 4096
|
|
19
|
+
|
|
12
20
|
def initialize(diagnostics, label, interval: 30)
|
|
13
21
|
@diagnostics = diagnostics
|
|
14
22
|
@label = label
|
|
@@ -17,23 +25,66 @@ module Saboteur
|
|
|
17
25
|
@last_print = @started
|
|
18
26
|
end
|
|
19
27
|
|
|
28
|
+
# Reads progress from the end of this log on each print. Returns self.
|
|
29
|
+
def watch(log_path)
|
|
30
|
+
@log = log_path
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
20
34
|
def tick
|
|
21
35
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
36
|
return if now - @last_print < @interval
|
|
23
37
|
|
|
24
38
|
@last_print = now
|
|
25
|
-
|
|
39
|
+
elapsed = now - @started
|
|
40
|
+
@diagnostics.inform("#{@label}: #{elapsed.round} s elapsed#{progress_note(elapsed)}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# ", 250/1000 examples (25 %), about 90 s left", or "" when the log has
|
|
46
|
+
# no count yet. The estimate assumes examples take equally long, so it
|
|
47
|
+
# is rough: slow examples late in the order make it too low.
|
|
48
|
+
def progress_note(elapsed)
|
|
49
|
+
done, total = examples_finished
|
|
50
|
+
return "" if done.nil?
|
|
51
|
+
|
|
52
|
+
note = ", #{done}/#{total} examples (#{(done * 100.0 / total).floor} %)"
|
|
53
|
+
return note if done.zero? || done >= total
|
|
54
|
+
|
|
55
|
+
"#{note}, about #{(elapsed * (total - done) / done).ceil} s left"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def examples_finished
|
|
59
|
+
return unless @log
|
|
60
|
+
|
|
61
|
+
tail = File.read(@log, TAIL_BYTES, [File.size(@log) - TAIL_BYTES, 0].max).to_s.scrub
|
|
62
|
+
tail.scan(COUNT).last&.map(&:to_i)
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
nil
|
|
26
65
|
end
|
|
27
66
|
end
|
|
28
67
|
|
|
29
68
|
MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
|
|
69
|
+
LEGEND = ". killed S survived T timeout L resource limit E error"
|
|
70
|
+
LINE_WIDTH = 50
|
|
30
71
|
|
|
31
|
-
|
|
72
|
+
GenerationEvent = Data.define(:file, :subject, :source, :operator, :reason)
|
|
73
|
+
Omission = Data.define(:subject, :count, :limit)
|
|
74
|
+
DiscoveryFailure = Data.define(:file, :reason)
|
|
75
|
+
|
|
76
|
+
attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
|
|
77
|
+
:hazard_suppressions, :inline_suppressions
|
|
32
78
|
|
|
33
79
|
def initialize(err)
|
|
34
80
|
@err = err
|
|
35
81
|
@warnings = []
|
|
36
82
|
@invalid_mutations = 0
|
|
83
|
+
@generation_events = []
|
|
84
|
+
@omissions = []
|
|
85
|
+
@discovery_failures = []
|
|
86
|
+
@hazard_suppressions = 0
|
|
87
|
+
@inline_suppressions = []
|
|
37
88
|
end
|
|
38
89
|
|
|
39
90
|
def warn(message)
|
|
@@ -44,6 +95,36 @@ module Saboteur
|
|
|
44
95
|
@invalid_mutations += 1
|
|
45
96
|
end
|
|
46
97
|
|
|
98
|
+
def record_generation(subject, source:, reason:, operator: nil)
|
|
99
|
+
values = [subject.file, subject.id, source, operator, reason].map do |value|
|
|
100
|
+
value.nil? ? nil : value.to_s.dup.freeze
|
|
101
|
+
end
|
|
102
|
+
@generation_events << GenerationEvent.new(*values)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def record_discovery_failure(file, reason:)
|
|
106
|
+
@discovery_failures << DiscoveryFailure.new(file: file.to_s.dup.freeze, reason: reason.to_s.dup.freeze)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A mutant the hazard guard refused to generate (issue #102): counted
|
|
110
|
+
# for the summary and recorded like any other generation event.
|
|
111
|
+
def record_hazard_suppression(subject, mutation)
|
|
112
|
+
@hazard_suppressions += 1
|
|
113
|
+
record_generation(subject, source: "hazard_guard", operator: mutation.operator, reason: "hazardous")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_inline_suppression(subject, mutation, directive)
|
|
117
|
+
@inline_suppressions << {
|
|
118
|
+
file: subject.file, line: directive.line, subject: subject.id,
|
|
119
|
+
operator: mutation.operator, reason: directive.reason,
|
|
120
|
+
digest: mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_omission(subject, count:, limit:)
|
|
125
|
+
@omissions << Omission.new(subject: subject.id.dup.freeze, count: count, limit: limit)
|
|
126
|
+
end
|
|
127
|
+
|
|
47
128
|
def inform(message)
|
|
48
129
|
@err.puts("saboteur: #{message}")
|
|
49
130
|
end
|
|
@@ -56,10 +137,75 @@ module Saboteur
|
|
|
56
137
|
ProgressHeartbeat.new(self, label, interval: interval)
|
|
57
138
|
end
|
|
58
139
|
|
|
140
|
+
# Call before the workers fork. total is the number of mutants the run
|
|
141
|
+
# may execute; mutants that are skipped or reused never report progress,
|
|
142
|
+
# so the counter can end below total and the ETA errs on the long side.
|
|
143
|
+
# The count lives in one file the workers inherit, so every worker adds
|
|
144
|
+
# to the same number.
|
|
145
|
+
def start_progress(total)
|
|
146
|
+
@progress_total = total
|
|
147
|
+
@progress_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
148
|
+
@progress_file = Tempfile.new("saboteur-progress")
|
|
149
|
+
@progress_file.binmode
|
|
150
|
+
@progress_file.sync = true
|
|
151
|
+
@err.puts("saboteur: progress #{LEGEND}") unless progress_on_terminal?
|
|
152
|
+
end
|
|
153
|
+
|
|
59
154
|
def progress(result)
|
|
60
|
-
|
|
155
|
+
mark = MARKS.fetch(result.status, "?")
|
|
156
|
+
return plain_progress(mark) unless @progress_file
|
|
157
|
+
|
|
158
|
+
done = record_progress(mark)
|
|
159
|
+
progress_on_terminal? ? terminal_progress(done) : ci_progress(mark, done)
|
|
160
|
+
@err.flush
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def finish_progress
|
|
164
|
+
@err.puts if @progress_file && (progress_on_terminal? || @progress_file.size % LINE_WIDTH != 0)
|
|
165
|
+
@progress_file&.close!
|
|
166
|
+
@progress_file = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def progress_summary(done)
|
|
170
|
+
return "#{done}/#{@progress_total}" unless @progress_total.to_i.positive?
|
|
171
|
+
|
|
172
|
+
percent = (done * 100.0 / @progress_total).floor
|
|
173
|
+
"#{done}/#{@progress_total} (#{percent}%)#{eta_text(done)}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def plain_progress(mark)
|
|
179
|
+
@err.print(mark)
|
|
61
180
|
@err.flush
|
|
62
181
|
end
|
|
182
|
+
|
|
183
|
+
def record_progress(mark)
|
|
184
|
+
@progress_file.write(mark)
|
|
185
|
+
@progress_file.size
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def progress_on_terminal?
|
|
189
|
+
@err.respond_to?(:tty?) && @err.tty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def terminal_progress(done)
|
|
193
|
+
@err.print("\r\e[Ksaboteur: #{progress_summary(done)}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def ci_progress(mark, done)
|
|
197
|
+
@err.print(mark)
|
|
198
|
+
@err.print(" #{progress_summary(done)}\n") if (done % LINE_WIDTH).zero?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def eta_text(done)
|
|
202
|
+
remaining = @progress_total - done
|
|
203
|
+
return "" if remaining <= 0 || done.zero?
|
|
204
|
+
|
|
205
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @progress_started
|
|
206
|
+
seconds = (elapsed / done * remaining).round
|
|
207
|
+
" eta #{seconds / 60}m#{format('%02d', seconds % 60)}s"
|
|
208
|
+
end
|
|
63
209
|
end
|
|
64
210
|
end
|
|
65
211
|
end
|
|
@@ -28,6 +28,16 @@ module Saboteur
|
|
|
28
28
|
mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Distance to the nearest changed line in this file. Unknown outside
|
|
32
|
+
# the diff is nil, not a made-up nearby location.
|
|
33
|
+
def distance_for(subject, mutation)
|
|
34
|
+
changed = @changed_lines.fetch(subject.file, Set.new)
|
|
35
|
+
mutation.edits.map do |edit|
|
|
36
|
+
first, last = edited_lines(subject, edit).minmax
|
|
37
|
+
changed.map { |line| (line - line.clamp(first, last)).abs }.min
|
|
38
|
+
end.min
|
|
39
|
+
end
|
|
40
|
+
|
|
31
41
|
private
|
|
32
42
|
|
|
33
43
|
def edited_lines(subject, edit)
|
|
@@ -8,19 +8,37 @@ module Saboteur
|
|
|
8
8
|
# plain data; the parent rebuilds them against its own copy of the same
|
|
9
9
|
# candidate list, so results look the same either way.
|
|
10
10
|
class Distribution
|
|
11
|
-
|
|
11
|
+
# After run: the subjects a stop_after callable left unrun (always empty
|
|
12
|
+
# without one).
|
|
13
|
+
attr_reader :unreached
|
|
14
|
+
|
|
15
|
+
# stop_after: nil, or a callable that gets one subject's results and
|
|
16
|
+
# returns true to stop before the next subject (--fail-fast). It only
|
|
17
|
+
# works on one process: parallel workers have already started the
|
|
18
|
+
# other subjects.
|
|
19
|
+
def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil, after_subject: nil,
|
|
20
|
+
stop_after: nil)
|
|
12
21
|
@jobs = jobs
|
|
22
|
+
@stop_after = stop_after
|
|
23
|
+
@unreached = []
|
|
13
24
|
@on_start = on_start
|
|
14
25
|
@scheduler = scheduler
|
|
15
26
|
@diagnostics = diagnostics
|
|
16
27
|
@cache = cache
|
|
28
|
+
@after_subject = after_subject
|
|
17
29
|
end
|
|
18
30
|
|
|
19
31
|
# lists: each subject's [[mutation, text]] candidates, in subject order.
|
|
20
32
|
def run(subjects, lists)
|
|
21
|
-
return subjects
|
|
33
|
+
return run_serially(subjects, lists) if @jobs <= 1
|
|
22
34
|
|
|
23
|
-
|
|
35
|
+
# The watcher runs as each subject's row arrives, before the pool
|
|
36
|
+
# hands out more work, so a tripped canary stops the queue (jobs>1
|
|
37
|
+
# used to check only after the whole batch returned, issue #102).
|
|
38
|
+
arrival = lambda do |i, row|
|
|
39
|
+
@after_subject&.call(subjects[i], decode(row, subjects[i], lists[i]))
|
|
40
|
+
end
|
|
41
|
+
pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start, on_result: arrival)
|
|
24
42
|
rows = pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
|
|
25
43
|
work(subjects[i], lists[i])
|
|
26
44
|
end
|
|
@@ -29,6 +47,20 @@ module Saboteur
|
|
|
29
47
|
|
|
30
48
|
private
|
|
31
49
|
|
|
50
|
+
def run_serially(subjects, lists)
|
|
51
|
+
results = []
|
|
52
|
+
subjects.zip(lists).each_with_index do |(subject, list), index|
|
|
53
|
+
subject_results = @scheduler.call(subject, list)
|
|
54
|
+
results.concat(subject_results)
|
|
55
|
+
@after_subject&.call(subject, subject_results)
|
|
56
|
+
next unless @stop_after&.call(subject_results)
|
|
57
|
+
|
|
58
|
+
@unreached = subjects.drop(index + 1)
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
results
|
|
62
|
+
end
|
|
63
|
+
|
|
32
64
|
# Runs in a worker.
|
|
33
65
|
def work(subject, list)
|
|
34
66
|
before = @diagnostics.warnings.size
|
|
@@ -43,29 +75,38 @@ module Saboteur
|
|
|
43
75
|
"status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
|
|
44
76
|
"killed_by" => result.killed_by, "covered_by" => result.covered_by,
|
|
45
77
|
"kill_cause" => result.kill_cause, "reused" => result.reused?,
|
|
46
|
-
"rejected_kill" => result.rejected_kill
|
|
78
|
+
"rejected_kill" => result.rejected_kill?, "executed" => result.executed
|
|
47
79
|
}
|
|
48
80
|
end
|
|
49
81
|
|
|
50
82
|
def rebuild(row, subject, list)
|
|
51
|
-
|
|
83
|
+
if row["error"]
|
|
84
|
+
@diagnostics.warn("#{subject.id}: #{row['error']}")
|
|
85
|
+
return failed(row["error"], subject, list)
|
|
86
|
+
end
|
|
52
87
|
|
|
53
88
|
@diagnostics.warnings.concat(row["warnings"])
|
|
89
|
+
decode(row, subject, list).each { |result| @cache&.record(result) unless result.reused? }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Turns a worker row back into results, without diagnostics or caching:
|
|
93
|
+
# used both at result arrival (the canary) and at rebuild time.
|
|
94
|
+
def decode(row, subject, list)
|
|
95
|
+
return failed(row["error"], subject, list) if row["error"]
|
|
96
|
+
|
|
54
97
|
row["results"].map do |data|
|
|
55
98
|
mutation, text = list.fetch(data["position"])
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
result
|
|
99
|
+
MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
|
|
100
|
+
mutated_source: text, reason: data["reason"], runtime: data["runtime"],
|
|
101
|
+
killed_by: data["killed_by"], covered_by: data["covered_by"],
|
|
102
|
+
kill_cause: data["kill_cause"], reused: data["reused"],
|
|
103
|
+
executed: data["executed"],
|
|
104
|
+
rejected_kill: data.fetch("rejected_kill", false))
|
|
63
105
|
end
|
|
64
106
|
end
|
|
65
107
|
|
|
66
108
|
# The worker died (or raised) while running this subject.
|
|
67
109
|
def failed(message, subject, list)
|
|
68
|
-
@diagnostics.warn("#{subject.id}: #{message}")
|
|
69
110
|
list.map do |mutation, text|
|
|
70
111
|
MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: text,
|
|
71
112
|
reason: message)
|
|
@@ -11,13 +11,16 @@ module Saboteur
|
|
|
11
11
|
# Stryker's history files.
|
|
12
12
|
#
|
|
13
13
|
# A verdict is reused only while everything it was computed from is the
|
|
14
|
-
# same: the method's source, the
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# same: the method's source, the whole file the method lives in (so a
|
|
15
|
+
# constant or helper changed elsewhere in that file is seen), the mutant,
|
|
16
|
+
# the set of covering tests, the content of those tests' files, the
|
|
17
|
+
# lockfile, the run's inputs (config, saboteur version). Only killed, survived
|
|
18
|
+
# and timeout are stored;
|
|
17
19
|
# errors and skips always run again.
|
|
18
20
|
#
|
|
19
|
-
# Known hole, the reason this is opt-in: a change in
|
|
20
|
-
#
|
|
21
|
+
# Known hole, the reason this is opt-in: a change in another file that
|
|
22
|
+
# this method uses (a method it calls, a constant defined elsewhere) is
|
|
23
|
+
# not seen, so a stored verdict may be stale after it.
|
|
21
24
|
class ResultCache
|
|
22
25
|
FORMAT = 1
|
|
23
26
|
STORED = %i[killed survived timeout].freeze
|
|
@@ -75,6 +78,8 @@ module Saboteur
|
|
|
75
78
|
def fingerprint(subject, tests)
|
|
76
79
|
digest = Digest::SHA256.new
|
|
77
80
|
digest << VERSION << "\0" << @inputs << "\0" << @suite_digest << "\0" << subject.source << "\0"
|
|
81
|
+
# A hex digest has a fixed width, so it needs no separator after it.
|
|
82
|
+
digest << Digest::SHA256.hexdigest(subject.file_source)
|
|
78
83
|
tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
|
|
79
84
|
LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
|
|
80
85
|
digest.hexdigest
|
|
@@ -12,7 +12,7 @@ module Saboteur
|
|
|
12
12
|
class RunFingerprint
|
|
13
13
|
FORMAT_VERSION = 1
|
|
14
14
|
MANIFESTS = %w[Gemfile gems.rb Gemfile.lock gems.locked .rspec .rspec-local].freeze
|
|
15
|
-
LOCATION_SETTINGS = %w[cache_dir json_report html_report].freeze
|
|
15
|
+
LOCATION_SETTINGS = %w[cache_dir json_report html_report score_history].freeze
|
|
16
16
|
ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra data].freeze
|
|
17
17
|
EXCLUDED_DIRECTORIES = %w[.git vendor node_modules].freeze
|
|
18
18
|
Entry = Data.define(:path, :kind, :state, :sha256, :required)
|
|
@@ -127,7 +127,7 @@ module Saboteur
|
|
|
127
127
|
issues = output_overlap_issues
|
|
128
128
|
entries = inventory(issues).sort_by(&:path).freeze
|
|
129
129
|
issues = issues.uniq.sort
|
|
130
|
-
settings_digest = hash_value(@config.
|
|
130
|
+
settings_digest = hash_value(@config.selection_settings.except(*LOCATION_SETTINGS))
|
|
131
131
|
runtime_digest = hash_value(@runtime_identity)
|
|
132
132
|
digest = hash_value(format: FORMAT_VERSION, files: entries.map(&:to_h),
|
|
133
133
|
settings: settings_digest, runtime: runtime_digest)
|
|
@@ -181,8 +181,10 @@ module Saboteur
|
|
|
181
181
|
def output_overlap_issues
|
|
182
182
|
paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) } +
|
|
183
183
|
DataInputs.files(@root, extra: @config["fingerprint_extra"])
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
# Compare physical boundaries without changing declared inventory identities.
|
|
185
|
+
paths = paths.map { |path| canonical_path(path) }.uniq
|
|
186
|
+
outputs = %w[json_report html_report score_history].filter_map { |key| @config.fetch(key) }
|
|
187
|
+
.map { |path| canonical_path(path) }
|
|
186
188
|
issues = outputs.filter_map do |path|
|
|
187
189
|
if path.end_with?(".rb") || paths.include?(path) || File.directory?(path) || data_input?(path)
|
|
188
190
|
"output overlaps input: #{display_path(path)}"
|
|
@@ -268,7 +270,7 @@ module Saboteur
|
|
|
268
270
|
|
|
269
271
|
def excluded?(path, name)
|
|
270
272
|
EXCLUDED_DIRECTORIES.include?(name) || path == canonical_path(@config["cache_dir"]) ||
|
|
271
|
-
%w[json_report html_report].filter_map { |key| @config.fetch(key) }.any? do |output|
|
|
273
|
+
%w[json_report html_report score_history].filter_map { |key| @config.fetch(key) }.any? do |output|
|
|
272
274
|
path == canonical_path(output)
|
|
273
275
|
end
|
|
274
276
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
class Engine
|
|
9
|
+
# Decides what each mutant child's Landlock sandbox grants (#152), and
|
|
10
|
+
# notes once per run when the kernel cannot honor it.
|
|
11
|
+
class SandboxPolicy
|
|
12
|
+
def initialize(config:, root:, diagnostics:)
|
|
13
|
+
@config = config
|
|
14
|
+
@root = root
|
|
15
|
+
@diagnostics = diagnostics
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The third C1 layer (#152): each mutant child makes the project tree
|
|
19
|
+
# read-only except tmp/, the cache dir and the system temp dir. The
|
|
20
|
+
# claim needs Landlock ABI 3 (Linux 6.2+): earlier ABIs cannot restrict
|
|
21
|
+
# truncate(), so the run degrades honestly - children run with the
|
|
22
|
+
# guard and canary only, noted once - unless c.sandbox = "strict",
|
|
23
|
+
# which refuses instead.
|
|
24
|
+
def hook
|
|
25
|
+
return nil unless @config["sandbox"]
|
|
26
|
+
|
|
27
|
+
abi = sandbox_abi
|
|
28
|
+
return restrict_hook if abi >= 3
|
|
29
|
+
|
|
30
|
+
if @config["sandbox"] == "strict"
|
|
31
|
+
raise Error, "sandbox is strict but #{sandbox_gap(abi)}; refusing to run without the promised " \
|
|
32
|
+
"sandbox - set c.sandbox = true to run degraded, or false to opt out"
|
|
33
|
+
end
|
|
34
|
+
# Once, through the report's warnings only: warn + inform printed the
|
|
35
|
+
# same line twice (D's review, #178).
|
|
36
|
+
unless @sandbox_note_given
|
|
37
|
+
@sandbox_note_given = true
|
|
38
|
+
@diagnostics.warn("sandbox is on but #{sandbox_gap(abi)}; children run with the hazard guard and canary only")
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ENOSYS/EOPNOTSUPP are a clean "no Landlock here"; any other probe
|
|
44
|
+
# error (EPERM under seccomp, ...) must not silently run unsandboxed.
|
|
45
|
+
def sandbox_abi
|
|
46
|
+
Execution::Sandbox.abi_version
|
|
47
|
+
rescue SystemCallError => e
|
|
48
|
+
raise Error, "sandbox is on but the Landlock probe failed (#{e.message}); refusing to run " \
|
|
49
|
+
"unsandboxed - set c.sandbox = false to proceed without it"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def sandbox_gap(abi)
|
|
53
|
+
return "this kernel lacks Landlock (needs Linux 6.2+)" if abi.zero?
|
|
54
|
+
|
|
55
|
+
"this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def restrict_hook
|
|
59
|
+
writable = sandbox_writable_dirs
|
|
60
|
+
->(keep_fds) { Execution::Sandbox.restrict!(writable: writable, keep_fds: keep_fds) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
|
|
64
|
+
# ...): without a grant, writes there fail with EACCES in the child.
|
|
65
|
+
def sandbox_writable_dirs
|
|
66
|
+
user_dirs = @config["sandbox_writable"].map { |dir| File.expand_path(dir, @root) }
|
|
67
|
+
dirs = [File.join(@root, "tmp"), @config.path("cache_dir")] + user_dirs
|
|
68
|
+
dirs.each { |dir| FileUtils.mkdir_p(dir) }
|
|
69
|
+
real = (dirs.map { |dir| File.realpath(dir) } + [File.realpath(Dir.tmpdir)]).uniq
|
|
70
|
+
check_sandbox_write_set(real, user_dirs.map { |dir| File.realpath(dir) })
|
|
71
|
+
real
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The effective write set - every grant realpath'd, cache_dir, the
|
|
75
|
+
# project tmp and the system temp dir included - must not contain the
|
|
76
|
+
# project root: cache_dir = ".", a tmp symlink or a checkout inside the
|
|
77
|
+
# system temp dir would otherwise leave the whole tree writable without
|
|
78
|
+
# a word. c.sandbox = "strict" refuses: strict guards the effective
|
|
79
|
+
# policy, not only the kernel ABI. A user grant containing another
|
|
80
|
+
# grant only makes the narrower one redundant, so that stays a warning.
|
|
81
|
+
def check_sandbox_write_set(all_dirs, user_dirs)
|
|
82
|
+
holder = all_dirs.find { |dir| path_contains?(dir, @root) }
|
|
83
|
+
if holder
|
|
84
|
+
message = "sandbox grant #{holder} contains the project root; mutant children can write the whole tree"
|
|
85
|
+
raise Error, %(#{message} - refusing under c.sandbox = "strict") if @config["sandbox"] == "strict"
|
|
86
|
+
|
|
87
|
+
@diagnostics.warn(message)
|
|
88
|
+
end
|
|
89
|
+
user_dirs.each do |dir|
|
|
90
|
+
(all_dirs - [dir]).each do |other|
|
|
91
|
+
next unless path_contains?(dir, other)
|
|
92
|
+
|
|
93
|
+
@diagnostics.warn("sandbox grant #{dir} contains the granted dir #{other}; " \
|
|
94
|
+
"the narrower grant is redundant")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Both sides are realpath'd, so a plain "#{dir}/" prefix test would miss
|
|
100
|
+
# dir == "/" (every path is under it; "//" prefixes nothing).
|
|
101
|
+
def path_contains?(dir, path)
|
|
102
|
+
return true if dir == "/"
|
|
103
|
+
|
|
104
|
+
path == dir || path.start_with?("#{dir}/")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|