saboteur 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/README.md +393 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +105 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +114 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +101 -121
  19. data/lib/saboteur/config.rb +76 -6
  20. data/lib/saboteur/doctor.rb +40 -8
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +59 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +6 -4
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +52 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +192 -18
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/gates.rb +83 -0
  48. data/lib/saboteur/help.rb +31 -7
  49. data/lib/saboteur/ignore_list.rb +5 -0
  50. data/lib/saboteur/inline_suppression.rb +65 -0
  51. data/lib/saboteur/integration/base.rb +1 -0
  52. data/lib/saboteur/integration/focus_guard.rb +83 -0
  53. data/lib/saboteur/integration/rspec.rb +21 -1
  54. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  55. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  56. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  57. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  58. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  59. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  60. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  61. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  62. data/lib/saboteur/preview.rb +106 -0
  63. data/lib/saboteur/reporter/agent_commands.rb +7 -1
  64. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  65. data/lib/saboteur/reporter/agent_summary.rb +11 -5
  66. data/lib/saboteur/reporter/findings.rb +4 -19
  67. data/lib/saboteur/reporter/history_paths.rb +38 -0
  68. data/lib/saboteur/reporter/history_record.rb +132 -0
  69. data/lib/saboteur/reporter/html.rb +12 -1
  70. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  71. data/lib/saboteur/reporter/next_steps.rb +30 -0
  72. data/lib/saboteur/reporter/palette.rb +37 -0
  73. data/lib/saboteur/reporter/score_history.rb +123 -0
  74. data/lib/saboteur/reporter/score_table.rb +57 -0
  75. data/lib/saboteur/reporter/stryker.rb +15 -2
  76. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  77. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  78. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  79. data/lib/saboteur/reporter/terminal.rb +131 -79
  80. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  81. data/lib/saboteur/result.rb +41 -6
  82. data/lib/saboteur/selection/coverage_map.rb +119 -13
  83. data/lib/saboteur/selection/git_diff.rb +28 -1
  84. data/lib/saboteur/selection/recorder.rb +55 -13
  85. data/lib/saboteur/subject.rb +40 -4
  86. data/lib/saboteur/subject_finder.rb +62 -12
  87. data/lib/saboteur/version.rb +1 -1
  88. data/lib/saboteur/working_tree.rb +8 -0
  89. data/lib/saboteur.rb +6 -0
  90. metadata +43 -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
- @diagnostics.inform("#{@label}: #{(now - @started).round} s elapsed")
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
- attr_reader :warnings, :invalid_mutations
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
- @err.print(MARKS.fetch(result.status, "?"))
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
- def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil)
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.zip(lists).flat_map { |subject, list| @scheduler.call(subject, list) } if @jobs <= 1
33
+ return run_serially(subjects, lists) if @jobs <= 1
22
34
 
23
- pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start)
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
- return failed(row["error"], subject, list) if row["error"]
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
- result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
57
- mutated_source: text, reason: data["reason"], runtime: data["runtime"],
58
- killed_by: data["killed_by"], covered_by: data["covered_by"],
59
- kill_cause: data["kill_cause"], reused: data["reused"],
60
- rejected_kill: data.fetch("rejected_kill", false))
61
- @cache&.record(result) unless result.reused?
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 mutant, the set of covering tests, the
15
- # content of those tests' files, the lockfile, the run's inputs (config,
16
- # saboteur version). Only killed, survived and timeout are stored;
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 a method this one
20
- # calls is not seen, so a stored verdict may be stale after it.
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)
@@ -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
- outputs = %w[json_report html_report].filter_map { |key| @config.fetch(key) }
185
- .map { |path| canonical_path(path) }
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