saboteur 0.6.0 → 0.7.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. metadata +17 -2
@@ -76,8 +76,9 @@ module Saboteur
76
76
  attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
77
77
  :hazard_suppressions, :inline_suppressions
78
78
 
79
- def initialize(err)
79
+ def initialize(err, verbose: false)
80
80
  @err = err
81
+ @verbose = verbose
81
82
  @warnings = []
82
83
  @invalid_mutations = 0
83
84
  @generation_events = []
@@ -102,8 +103,11 @@ module Saboteur
102
103
  @generation_events << GenerationEvent.new(*values)
103
104
  end
104
105
 
106
+ # The selection and the test inference each walk the same changed
107
+ # files in one run; a failure either of them finds is recorded once.
105
108
  def record_discovery_failure(file, reason:)
106
- @discovery_failures << DiscoveryFailure.new(file: file.to_s.dup.freeze, reason: reason.to_s.dup.freeze)
109
+ failure = DiscoveryFailure.new(file: file.to_s.dup.freeze, reason: reason.to_s.dup.freeze)
110
+ @discovery_failures << failure unless @discovery_failures.include?(failure)
107
111
  end
108
112
 
109
113
  # A mutant the hazard guard refused to generate (issue #102): counted
@@ -129,6 +133,23 @@ module Saboteur
129
133
  @err.puts("saboteur: #{message}")
130
134
  end
131
135
 
136
+ # --verbose: one line per finished subject with its result marks, so a
137
+ # survivor is attributable to a method as soon as the subject
138
+ # completes, before the whole run ends (#262).
139
+ def subject_progress(subject, results)
140
+ return unless @verbose
141
+ return if results.empty?
142
+
143
+ marks = results.map { |result| MARKS.fetch(result.status, "?") }.join
144
+ inform("#{subject.id} #{marks}")
145
+ end
146
+
147
+ # A message that is both a report warning and a stderr note.
148
+ def announce(message)
149
+ warn(message)
150
+ inform(message)
151
+ end
152
+
132
153
  # A heartbeat for a long phase: the supervisor's wait loop calls tick
133
154
  # in the parent after the fork, so no extra thread is ever alive at
134
155
  # fork time. Prints "<label>: N s elapsed" about every interval
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../text"
5
+
4
6
  module Saboteur
5
7
  class Engine
6
8
  # Review-shaped diff mode (--since). A reviewer asks "do the tests check
@@ -14,7 +16,7 @@ module Saboteur
14
16
  end
15
17
 
16
18
  def restrict(subject, candidates)
17
- changed = @changed_lines.fetch(subject.file, Set.new)
19
+ changed = lines_for(subject)
18
20
  candidates.select do |mutation, _|
19
21
  mutation.coarse || mutation.edits.any? { |edit| edited_lines(subject, edit).any? { changed.include?(_1) } }
20
22
  end
@@ -24,22 +26,39 @@ module Saboteur
24
26
  # several lines is mutated as one piece and reported at its first line,
25
27
  # which can sit above the line that changed; this names the changed line.
26
28
  def changed_lines_for(subject, mutation)
27
- changed = @changed_lines.fetch(subject.file, Set.new)
29
+ changed = lines_for(subject)
28
30
  mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
29
31
  end
30
32
 
31
33
  # Distance to the nearest changed line in this file. Unknown outside
32
- # the diff is nil, not a made-up nearby location.
34
+ # the diff is nil, not a made-up nearby location. A range answers
35
+ # geometrically - 0 when the edit overlaps, else the gap to the nearer
36
+ # endpoint - so a whole-file range never materializes one entry per
37
+ # line.
33
38
  def distance_for(subject, mutation)
34
- changed = @changed_lines.fetch(subject.file, Set.new)
39
+ changed = lines_for(subject)
35
40
  mutation.edits.map do |edit|
36
41
  first, last = edited_lines(subject, edit).minmax
37
- changed.map { |line| (line - line.clamp(first, last)).abs }.min
42
+ if changed.is_a?(Range)
43
+ if last < changed.begin then changed.begin - last
44
+ elsif first > changed.end then first - changed.end
45
+ else 0
46
+ end
47
+ else
48
+ changed.map { |line| (line - line.clamp(first, last)).abs }.min
49
+ end
38
50
  end.min
39
51
  end
40
52
 
41
53
  private
42
54
 
55
+ # subject.file carries the locale tag under a non-UTF-8 locale
56
+ # while the map keys are UTF-8; retag losslessly or a non-ASCII
57
+ # path never matches and every fine mutant is silently dropped.
58
+ def lines_for(subject)
59
+ @changed_lines.fetch(Text.utf8_identity(subject.file), Set.new)
60
+ end
61
+
43
62
  def edited_lines(subject, edit)
44
63
  first = subject.line_of(edit.start_byte)
45
64
  last = subject.line_of([edit.end_byte - 1, edit.start_byte].max)
@@ -9,23 +9,31 @@ module Saboteur
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
12
+ # without one), and the subjects that started but stopped mid-list as
13
+ # [subject, omitted_candidate_count] pairs (a spent --max-duration
14
+ # breaking a subject's scheduler loop). unverified lists the subjects
15
+ # whose fresh kills the spent budget left unchecked (#296).
16
+ attr_reader :unreached, :truncated, :unverified
14
17
 
15
18
  # stop_after: nil, or a callable that gets one subject's results and
16
19
  # returns true to stop before the next subject (--fail-fast). It only
17
20
  # works on one process: parallel workers have already started the
18
21
  # other subjects.
19
22
  def initialize(jobs:, scheduler:, diagnostics:, cache:, on_start: nil, after_subject: nil,
20
- stop_after: nil)
23
+ stop_after: nil, halt_check: nil,
24
+ drain_timeout: 30) # seconds; two minimum-strength mutant deadlines at default settings
21
25
  @jobs = jobs
22
26
  @stop_after = stop_after
27
+ @halt_check = halt_check
23
28
  @unreached = []
29
+ @truncated = []
30
+ @unverified = []
24
31
  @on_start = on_start
25
32
  @scheduler = scheduler
26
33
  @diagnostics = diagnostics
27
34
  @cache = cache
28
35
  @after_subject = after_subject
36
+ @drain_timeout = drain_timeout
29
37
  end
30
38
 
31
39
  # lists: each subject's [[mutation, text]] candidates, in subject order.
@@ -35,23 +43,116 @@ module Saboteur
35
43
  # The watcher runs as each subject's row arrives, before the pool
36
44
  # hands out more work, so a tripped canary stops the queue (jobs>1
37
45
  # 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]))
46
+ # A tool error no longer raises on sight: one wiped log dir hits
47
+ # every worker, and a bystander whose current job ran no mutant
48
+ # reports "an unknown mutant", which could land before the report
49
+ # of the worker whose mutant did the damage (macOS CI on #285).
50
+ # The pool is halted instead - no new work, and the drain is
51
+ # bounded by drain_timeout so a wedged worker cannot stall the
52
+ # abort - and the abort raises the report with the best
53
+ # attribution.
54
+ # The abort reports and the pool are run state: a Distribution
55
+ # runs once. @pool is only read from arrivals, which fire inside
56
+ # @pool.map after the assignment.
57
+ # A budget already spent by boot, baselines or the coverage map
58
+ # launches nothing: every subject comes back unreached (#245).
59
+ if @halt_check&.call
60
+ @unreached = subjects
61
+ return []
40
62
  end
41
- pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start, on_result: arrival)
42
- rows = pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
63
+
64
+ @abort_reports = []
65
+ arrival = arrival_for(subjects, lists)
66
+ @pool = Execution::WorkerPool.new(jobs: @jobs, on_start: @on_start, on_result: arrival,
67
+ drain_timeout: @drain_timeout)
68
+ rows = @pool.map(subjects.each_index.to_a, weights: lists.map(&:size)) do |i|
43
69
  work(subjects[i], lists[i])
44
70
  end
45
- rows.each_with_index.flat_map { |row, i| rebuild(row, subjects[i], lists[i]) }
71
+ raise_preferred_tool_error(@abort_reports) if @abort_reports.any?
72
+ record_unreached(rows, subjects, lists)
73
+ rows.each_with_index.flat_map do |row, i|
74
+ next [] if row.nil?
75
+
76
+ raise_if_tool_error(row)
77
+ rebuild(row, subjects[i], lists[i])
78
+ end
46
79
  end
47
80
 
48
81
  private
49
82
 
83
+ # @pool is only read here from arrivals, which fire inside @pool.map
84
+ # after the assignment. A spent --max-duration budget stops the queue
85
+ # without the safety-abort drain: in-flight subjects finish and
86
+ # report, and only never-started ones come back as nil holes, so
87
+ # unreached never mislabels an interrupted subject as unrun (#245).
88
+ def arrival_for(subjects, lists)
89
+ lambda do |i, row|
90
+ if row["tool_error"]
91
+ collect_abort_report(row)
92
+ else
93
+ check_subject(subjects, lists, i, row)
94
+ @pool.halt!(drain: false) if @halt_check&.call
95
+ end
96
+ end
97
+ end
98
+
99
+ # nil rows never started; a short results list is a subject whose
100
+ # scheduler stopped mid-list at the budget deadline. Worker-level error
101
+ # rows synthesize an error per candidate, so they never look truncated.
102
+ def unverified_result?(result)
103
+ result.is_a?(MutantResult) && result.status == :unverified
104
+ end
105
+
106
+ def record_unreached(rows, subjects, lists)
107
+ @unreached = []
108
+ @truncated = []
109
+ @unverified = []
110
+ rows.each_with_index do |row, i|
111
+ if row.nil?
112
+ @unreached << subjects[i]
113
+ next
114
+ end
115
+ next unless row["results"] && !row["error"]
116
+
117
+ @truncated << [subjects[i], lists[i].size - row["results"].size] if row["results"].size < lists[i].size
118
+ @unverified << subjects[i] if row["results"].any? { |result| result["status"] == "unverified" }
119
+ end
120
+ end
121
+
122
+ # @pool is always assigned when an arrival fires: arrivals only fire
123
+ # from inside @pool.map, which runs after the assignment.
124
+ def collect_abort_report(row)
125
+ @abort_reports << row
126
+ @pool.halt!
127
+ end
128
+
129
+ # A canary trip from the end-of-subject check folds into the same
130
+ # record + halt + preferred-report path as a worker's own report:
131
+ # escaping the pool here would override a collected report that
132
+ # names the real culprit (#285 review).
133
+ def check_subject(subjects, lists, index, row)
134
+ @after_subject&.call(subjects[index], decode(row, subjects[index], lists[index]))
135
+ rescue Error => e
136
+ collect_abort_report(Execution::WorkerPool.tool_error_row(e))
137
+ end
138
+
50
139
  def run_serially(subjects, lists)
51
140
  results = []
52
141
  subjects.zip(lists).each_with_index do |(subject, list), index|
142
+ # Same pre-dispatch budget check as the parallel first wave: an
143
+ # already-spent baseline starts no further subjects (#245).
144
+ if @halt_check&.call
145
+ @unreached = subjects.drop(index)
146
+ break
147
+ end
148
+
53
149
  subject_results = @scheduler.call(subject, list)
54
150
  results.concat(subject_results)
151
+ # A scheduler that stopped mid-list at the budget deadline: the
152
+ # completed results stay, the omitted candidates are accounted.
153
+ omitted = list.size - subject_results.size
154
+ @truncated << [subject, omitted] if omitted.positive?
155
+ @unverified << subject if subject_results.any? { |result| unverified_result?(result) }
55
156
  @after_subject&.call(subject, subject_results)
56
157
  next unless @stop_after&.call(subject_results)
57
158
 
@@ -91,6 +192,21 @@ module Saboteur
91
192
 
92
193
  # Turns a worker row back into results, without diagnostics or caching:
93
194
  # used both at result arrival (the canary) and at rebuild time.
195
+ # A tool error from the worker (a vanished cache dir, ...) aborts
196
+ # the whole run with exit 3; it is not a per-mutant outcome (#283).
197
+ def raise_if_tool_error(row)
198
+ raise Error, row["tool_error"] if row["tool_error"]
199
+ end
200
+
201
+ # The abort names a culprit when any report can, ranked by
202
+ # attribution quality (#285 review): a report carrying its mutant's
203
+ # per-mutant digest first, then a canary trip (the hazard report
204
+ # still beats a bare report, #283), then the first bare report.
205
+ def raise_preferred_tool_error(rows)
206
+ row = rows.find { |r| r["culprit"] } || rows.find { |r| r["canary"] } || rows.first
207
+ raise Error, row["tool_error"]
208
+ end
209
+
94
210
  def decode(row, subject, list)
95
211
  return failed(row["error"], subject, list) if row["error"]
96
212
 
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Records the subjects an early stop left behind - whole subjects that
7
+ # never started and candidates skipped mid-way through a subject - so
8
+ # the report says the verdict covers less than the selection.
9
+ module EarlyStopRecording
10
+ private
11
+
12
+ # The subjects a stop left unrun or mid-way: the verdict covers less than the selection.
13
+ def record_early_stop(distribution)
14
+ return if distribution.unreached.empty? && distribution.truncated.empty? && distribution.unverified.empty?
15
+
16
+ truncated = distribution.truncated.map { |subject, omitted| { subject: subject.id, omitted: omitted } }
17
+ @early_stop = EarlyStop.new(reason: stop_reason(distribution), unreached: distribution.unreached.map(&:id),
18
+ truncated: truncated, unverified: distribution.unverified.map(&:id))
19
+ end
20
+
21
+ # Mid-subject omissions and skipped checks are budget work: only a
22
+ # stop that left whole subjects unreached can be fail_fast's.
23
+ def stop_reason(distribution)
24
+ return "max_duration" if distribution.truncated.any? || distribution.unverified.any?
25
+
26
+ run_early_stop_reason
27
+ end
28
+
29
+ # max_duration was announced before fail_fast could stop the run: a
30
+ # budget stop overrides a failure stop announced later.
31
+ def run_early_stop_reason
32
+ @budget_announced ? "max_duration" : "fail_fast"
33
+ end
34
+
35
+ # A baseline or coverage phase stopped because --max-duration ate its
36
+ # allowance: budget exhaustion, not a broken suite. The run reports
37
+ # partial with every selected subject unreached (exit 4) (#296).
38
+ def budget_phase_stop(error, subjects)
39
+ @diagnostics.warn(error.message)
40
+ @early_stop = EarlyStop.new(reason: "max_duration", unreached: subjects.map(&:id), truncated: [],
41
+ unverified: [])
42
+ []
43
+ end
44
+ end
45
+ end
46
+ end
@@ -4,6 +4,7 @@
4
4
  require "digest"
5
5
  require "fileutils"
6
6
  require "json"
7
+ require_relative "../text"
7
8
 
8
9
  module Saboteur
9
10
  class Engine
@@ -65,7 +66,7 @@ module Saboteur
65
66
  private
66
67
 
67
68
  def load
68
- data = JSON.parse(File.read(@path))
69
+ data = JSON.parse(Text.utf8_strict(File.read(@path)))
69
70
  data["format"] == FORMAT ? data["entries"] : {}
70
71
  rescue SystemCallError, JSON::ParserError
71
72
  {}
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # The --max-duration wall-clock budget (#245). The clock anchors at
7
+ # engine entry, so boot, baselines and the coverage map spend it too;
8
+ # schedulers get an absolute deadline so a spent budget also stops a
9
+ # long subject between mutants.
10
+ module RunBudget
11
+ private
12
+
13
+ def start_budget_clock(started)
14
+ @run_started = started
15
+ end
16
+
17
+ # The whole run is budgeted, baselines included: an oversized
18
+ # baseline spends the budget by itself.
19
+ def budget_exceeded?
20
+ return false unless config["max_duration"]
21
+
22
+ exceeded = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started >= config["max_duration"]
23
+ announce_budget_once if exceeded
24
+ exceeded
25
+ end
26
+
27
+ # A phase admitted inside the budget still gets only the time left
28
+ # in it: baseline passes and coverage builds cap at the deadline.
29
+ def budget_capped_timeout(timeout)
30
+ deadline = budget_deadline
31
+ return timeout unless deadline
32
+
33
+ (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).clamp(0.0, timeout)
34
+ end
35
+
36
+ # Absolute monotonic deadline shared with the schedulers (forked
37
+ # workers inherit it): a subject's remaining mutants stop once it
38
+ # passes, so the overshoot after a spent budget stays under one
39
+ # mutant deadline.
40
+ def budget_deadline
41
+ config["max_duration"] && (@run_started + config["max_duration"])
42
+ end
43
+
44
+ # Boot and baselines can spend the budget by themselves: stop before
45
+ # an expensive cold coverage build and mark every selected subject
46
+ # unrun.
47
+ def abandon_for_budget?(subjects)
48
+ return false unless budget_exceeded?
49
+
50
+ @early_stop = EarlyStop.new(reason: "max_duration", unreached: subjects.map(&:id), truncated: [],
51
+ unverified: [])
52
+ true
53
+ end
54
+
55
+ def announce_budget_once
56
+ return if @budget_announced
57
+
58
+ @budget_announced = true
59
+ @diagnostics.inform("--max-duration reached: stopping before the next subject; the report marks it partial")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "json"
6
+ require_relative "../text"
6
7
  require_relative "data_inputs"
7
8
 
8
9
  module Saboteur
@@ -252,6 +253,9 @@ module Saboteur
252
253
  # undeclared dependency boundary, not a silently omitted source tree.
253
254
  def project_files(issues, directory = @root)
254
255
  Dir.children(directory).sort.flat_map do |name|
256
+ # Child names arrive locale-tagged; retag losslessly before
257
+ # joining with the UTF-8 root (#231).
258
+ name = Text.utf8_identity(name)
255
259
  path = File.join(directory, name)
256
260
  next [] if excluded?(path, name)
257
261
 
@@ -16,7 +16,10 @@ module Saboteur
16
16
  end
17
17
 
18
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
19
+ # read-only except tmp/ and the system temp dir - the cache dir stays
20
+ # read-only too, so a mutant cannot delete the baseline, the reports
21
+ # or other children's logs; its own log arrives as an inherited open
22
+ # descriptor (#234). The
20
23
  # claim needs Landlock ABI 3 (Linux 6.2+): earlier ABIs cannot restrict
21
24
  # truncate(), so the run degrades honestly - children run with the
22
25
  # guard and canary only, noted once - unless c.sandbox = "strict",
@@ -28,14 +31,15 @@ module Saboteur
28
31
  return restrict_hook if abi >= 3
29
32
 
30
33
  if @config["sandbox"] == "strict"
31
- raise Error, "sandbox is strict but #{sandbox_gap(abi)}; refusing to run without the promised " \
34
+ raise Error, "sandbox is strict but #{self.class.sandbox_gap(abi)}; refusing to run without the promised " \
32
35
  "sandbox - set c.sandbox = true to run degraded, or false to opt out"
33
36
  end
34
37
  # Once, through the report's warnings only: warn + inform printed the
35
38
  # same line twice (D's review, #178).
36
39
  unless @sandbox_note_given
37
40
  @sandbox_note_given = true
38
- @diagnostics.warn("sandbox is on but #{sandbox_gap(abi)}; children run with the hazard guard and canary only")
41
+ @diagnostics.warn("sandbox is on but #{self.class.sandbox_gap(abi)}; children run with the hazard guard " \
42
+ "and canary only")
39
43
  end
40
44
  nil
41
45
  end
@@ -49,7 +53,7 @@ module Saboteur
49
53
  "unsandboxed - set c.sandbox = false to proceed without it"
50
54
  end
51
55
 
52
- def sandbox_gap(abi)
56
+ def self.sandbox_gap(abi)
53
57
  return "this kernel lacks Landlock (needs Linux 6.2+)" if abi.zero?
54
58
 
55
59
  "this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
@@ -62,20 +66,23 @@ module Saboteur
62
66
 
63
67
  # c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
64
68
  # ...): without a grant, writes there fail with EACCES in the child.
69
+ # Only the user grants are created: the project tmp is granted when it
70
+ # already exists, never made for it (#236).
65
71
  def sandbox_writable_dirs
66
72
  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) }
73
+ user_dirs.each { |dir| FileUtils.mkdir_p(dir) }
74
+ tmp = File.join(@root, "tmp")
75
+ dirs = Dir.exist?(tmp) ? [tmp] + user_dirs : user_dirs
69
76
  real = (dirs.map { |dir| File.realpath(dir) } + [File.realpath(Dir.tmpdir)]).uniq
70
77
  check_sandbox_write_set(real, user_dirs.map { |dir| File.realpath(dir) })
71
78
  real
72
79
  end
73
80
 
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
81
+ # The effective write set - every grant realpath'd, the project tmp
82
+ # and the system temp dir included - must not contain the project
83
+ # root: a tmp symlink or a checkout inside the system temp dir would
84
+ # otherwise leave the whole tree writable without a word.
85
+ # c.sandbox = "strict" refuses: strict guards the effective
79
86
  # policy, not only the kernel ABI. A user grant containing another
80
87
  # grant only makes the narrower one redundant, so that stays a warning.
81
88
  def check_sandbox_write_set(all_dirs, user_dirs)