saboteur 0.8.1 → 0.9.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/README.md +10 -0
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +3 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +2 -1
  7. data/lib/saboteur/cli/agent_provenance.rb +59 -0
  8. data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
  9. data/lib/saboteur/cli/doctor_command.rb +41 -0
  10. data/lib/saboteur/cli/error_cause.rb +40 -0
  11. data/lib/saboteur/cli/execution_options.rb +46 -0
  12. data/lib/saboteur/cli/exit_status.rb +12 -14
  13. data/lib/saboteur/cli/explicit_file_options.rb +21 -0
  14. data/lib/saboteur/cli/explicit_sources.rb +46 -0
  15. data/lib/saboteur/cli/failure_output.rb +35 -0
  16. data/lib/saboteur/cli/help_command.rb +23 -0
  17. data/lib/saboteur/cli/matrix_option.rb +17 -0
  18. data/lib/saboteur/cli/output_options.rb +27 -0
  19. data/lib/saboteur/cli/preview_command.rb +26 -0
  20. data/lib/saboteur/cli/run_budget_options.rb +3 -0
  21. data/lib/saboteur/cli/run_command.rb +33 -0
  22. data/lib/saboteur/cli/run_output.rb +38 -0
  23. data/lib/saboteur/cli/run_reporting.rb +6 -5
  24. data/lib/saboteur/cli/run_validation.rb +54 -0
  25. data/lib/saboteur/cli.rb +29 -307
  26. data/lib/saboteur/config.rb +72 -6
  27. data/lib/saboteur/constant_subject_finder.rb +3 -0
  28. data/lib/saboteur/custom_map.rb +20 -12
  29. data/lib/saboteur/deep_source.rb +49 -0
  30. data/lib/saboteur/dirty_tree_warning.rb +1 -1
  31. data/lib/saboteur/doctor.rb +6 -0
  32. data/lib/saboteur/engine/baseline_guard.rb +50 -13
  33. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  34. data/lib/saboteur/engine/boot.rb +4 -0
  35. data/lib/saboteur/engine/candidate_selection.rb +15 -5
  36. data/lib/saboteur/engine/candidates.rb +4 -1
  37. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  38. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  39. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  40. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  41. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  42. data/lib/saboteur/engine/coverage_store.rb +275 -22
  43. data/lib/saboteur/engine/diagnostics.rb +23 -6
  44. data/lib/saboteur/engine/distribution.rb +2 -2
  45. data/lib/saboteur/engine/gated_run.rb +1 -0
  46. data/lib/saboteur/engine/load_coverage.rb +54 -8
  47. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  48. data/lib/saboteur/engine/partial_merge.rb +40 -0
  49. data/lib/saboteur/engine/pipelines.rb +7 -7
  50. data/lib/saboteur/engine/reference_specs.rb +71 -0
  51. data/lib/saboteur/engine/result_cache.rb +68 -5
  52. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  53. data/lib/saboteur/engine/run_budget.rb +5 -5
  54. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  55. data/lib/saboteur/engine/sandbox_policy.rb +5 -2
  56. data/lib/saboteur/engine/scheduler.rb +1 -1
  57. data/lib/saboteur/engine/selection_announcement.rb +1 -0
  58. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  59. data/lib/saboteur/engine/shadow_run.rb +38 -0
  60. data/lib/saboteur/engine/subject_selection.rb +33 -37
  61. data/lib/saboteur/engine/suite_partition.rb +22 -0
  62. data/lib/saboteur/engine/test_guess.rb +82 -18
  63. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  64. data/lib/saboteur/engine.rb +41 -9
  65. data/lib/saboteur/environment_hints.rb +55 -0
  66. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  67. data/lib/saboteur/execution/external_runner.rb +105 -1
  68. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  69. data/lib/saboteur/execution/sandbox.rb +30 -17
  70. data/lib/saboteur/execution/supervisor.rb +40 -7
  71. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  72. data/lib/saboteur/execution/worker_pool.rb +48 -4
  73. data/lib/saboteur/frameworks/rails.rb +2 -5
  74. data/lib/saboteur/gates.rb +121 -28
  75. data/lib/saboteur/install.rb +6 -1
  76. data/lib/saboteur/integration/base.rb +19 -2
  77. data/lib/saboteur/integration/rspec.rb +46 -7
  78. data/lib/saboteur/line_hits.rb +81 -0
  79. data/lib/saboteur/method_calls.rb +91 -0
  80. data/lib/saboteur/mutation_source/builtin.rb +32 -22
  81. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  82. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  83. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  84. data/lib/saboteur/mutation_source/operator_examples.rb +5 -0
  85. data/lib/saboteur/mutation_source/operator_registry.rb +5 -0
  86. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  87. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  88. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  89. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  90. data/lib/saboteur/preview.rb +9 -1
  91. data/lib/saboteur/refinement_activations.rb +22 -10
  92. data/lib/saboteur/reporter/agent.rb +2 -2
  93. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  94. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  95. data/lib/saboteur/reporter/review_comments.rb +151 -0
  96. data/lib/saboteur/reporter/safe_out.rb +32 -0
  97. data/lib/saboteur/reporter/stryker.rb +8 -6
  98. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  99. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  100. data/lib/saboteur/reporter/terminal.rb +29 -4
  101. data/lib/saboteur/result.rb +14 -4
  102. data/lib/saboteur/selection/coverage_map.rb +136 -10
  103. data/lib/saboteur/selection/external_test_map.rb +4 -0
  104. data/lib/saboteur/selection/git_diff.rb +24 -2
  105. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  106. data/lib/saboteur/selection/recorder.rb +52 -22
  107. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  108. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  109. data/lib/saboteur/subject.rb +16 -0
  110. data/lib/saboteur/subject_finder.rb +91 -6
  111. data/lib/saboteur/text.rb +31 -0
  112. data/lib/saboteur/version.rb +1 -1
  113. data/lib/saboteur/working_tree.rb +7 -2
  114. data/lib/saboteur.rb +8 -0
  115. metadata +44 -2
@@ -0,0 +1,72 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "shellwords"
5
+
6
+ require_relative "test_command_shape"
7
+
8
+ module Saboteur
9
+ module Execution
10
+ # The test command's pinned BUNDLE_GEMFILE, or nil when it does not
11
+ # set one. Every wrapper unwrap strips (VAR= words, env and its
12
+ # flags, timeout, nice, time, bundle exec) keeps assignments in the
13
+ # command's environment, so the prefix it removes is exactly the
14
+ # assignment scope; the LAST BUNDLE_GEMFILE wins like the shell,
15
+ # and an empty last value unsets (#722). A token like
16
+ # A="x"BUNDLE_GEMFILE=g is one assignment to A and sets nothing.
17
+ # Anything BUNDLE_GEMFILE-shaped the scan cannot prove refuses
18
+ # rather than guessing the wrong Gemfile.
19
+ module BundleGemfilePin
20
+ # Raised when BUNDLE_GEMFILE shows up where the scan cannot prove it (#722).
21
+ PIN_UNVERIFIABLE = "the test command sets BUNDLE_GEMFILE where its value cannot be verified; " \
22
+ "pin BUNDLE_GEMFILE as a leading assignment"
23
+
24
+ # A pin value must be a literal: a substitution, tilde, glob, or
25
+ # shell metacharacter makes it unknowable without running the shell (#722).
26
+ PIN_UNVERIFIABLE_VALUE = /[$`\\~;&|<>()*?{}]/
27
+
28
+ module_function
29
+
30
+ def pinned_in(command)
31
+ tokens = Shellwords.split(command)
32
+ rescue ArgumentError
33
+ raise ConfigError, PIN_UNVERIFIABLE if command.include?("BUNDLE_GEMFILE")
34
+ else
35
+ pin = pin_value(tokens)
36
+ raise ConfigError, PIN_UNVERIFIABLE if pin == :unverifiable
37
+
38
+ pin
39
+ end
40
+
41
+ # nil for no pin, :unverifiable for a BUNDLE_GEMFILE the scan
42
+ # cannot prove, the literal value otherwise.
43
+ def pin_value(tokens)
44
+ prefix = tokens.first(tokens.size - TestCommandShape.unwrap(tokens).size)
45
+ value = prefix.reverse.find { |word| word.match?(/\ABUNDLE_GEMFILE=/) }&.split("=", 2)&.last
46
+ return :unverifiable if unverifiable_shape?(tokens, value)
47
+ return nil if value.nil? || value.empty?
48
+ return :unverifiable if value.match?(PIN_UNVERIFIABLE_VALUE)
49
+
50
+ value
51
+ end
52
+
53
+ # BUNDLE_GEMFILE in a shape the scan cannot prove: env rewriting the
54
+ # run's cwd or variables, or the pin outside the leading assignments.
55
+ def unverifiable_shape?(tokens, value)
56
+ mentioned = tokens.any? { |word| word.include?("BUNDLE_GEMFILE") }
57
+ (mentioned && env_unverifiable?(tokens)) ||
58
+ (value.nil? && tokens.any? { |word| word.match?(/(?:\A|[^A-Za-z0-9_])BUNDLE_GEMFILE=/) })
59
+ end
60
+
61
+ # env(1) carrying a cwd-repointing (-C/--chdir), word-reparsing
62
+ # (-S/--split-string), or variable-unsetting (-u/--unset) flag: the
63
+ # pin scan cannot prove the environment the command runs in (#722).
64
+ def env_unverifiable?(tokens)
65
+ tokens.each_index.select { |i| %w[env /usr/bin/env].include?(tokens[i]) }.any? do |i|
66
+ tokens[(i + 1)..].take_while { |word| word.start_with?("-") }
67
+ .any? { |flag| flag.match?(/\A(?:-C|-S|-u|--chdir|--split-string|--unset)/) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -74,12 +74,19 @@ module Saboteur
74
74
  # holding the pipe past this no longer blocks the run.
75
75
  DRAIN_GRACE = 0.5
76
76
 
77
- def initialize(command:, root:)
77
+ def initialize(command:, root:, path_sources: nil)
78
78
  @command = command
79
79
  # realpath, not expand_path: the tree must be judged by where it
80
80
  # really is, so a subject path can be checked against the same
81
81
  # canonical root without a symlink making it look outside (#305 QA).
82
82
  @root = File.realpath(root)
83
+ # The Gemfile the command actually loads, root-relative: its own
84
+ # pinned BUNDLE_GEMFILE when it sets one (Appraisal style), else
85
+ # the root Gemfile (#644).
86
+ pinned = pinned_gemfile
87
+ @gemfile_name = pinned || "Gemfile"
88
+ sources = path_sources || GemfilePathSources.resolve(@root, gemfile: pinned)
89
+ @path_sources = classify_path_sources(sources)
83
90
  @copy_root = nil
84
91
  at_exit { close }
85
92
  rescue SystemCallError
@@ -211,9 +218,106 @@ module Saboteur
211
218
  @copy_root = File.realpath(Dir.mktmpdir("saboteur-external-"))
212
219
  FileUtils.cp_r(File.join(@root, "."), @copy_root, preserve: true)
213
220
  rebuild_symlinks
221
+ rebase_path_sources
214
222
  yield
215
223
  end
216
224
 
225
+ # path: sources are judged against the canonical root (#644): a
226
+ # RELATIVE source resolving outside the tree would escape the
227
+ # disposable copy, so it refuses the run at startup naming the
228
+ # source; an ABSOLUTE source pointing into the original tree is
229
+ # rebased into the copy below so bundler loads the copy's (possibly
230
+ # mutated) code, never the original's. Anything unresolved or
231
+ # ambiguous refuses - never a guess that could load unmutated code.
232
+ def classify_path_sources(sources)
233
+ sources.each_with_object(rebase: []) do |src, groups|
234
+ expanded = GemfilePathSources.canonical_path(src.expanded)
235
+ # The root itself counts as inside: a `gemspec` line makes
236
+ # Bundler report the gem's own path source ".", expanded to the
237
+ # project root - refusing it would refuse every gem-style
238
+ # project (#644 security).
239
+ inside = expanded == @root || expanded.start_with?("#{@root}/")
240
+ next if inside && !Pathname.new(src.original).absolute?
241
+ next if !inside && Pathname.new(src.original).absolute?
242
+
243
+ unless inside
244
+ raise ConfigError, "the Gemfile's path: source \"#{src.name}\" (#{src.original}) resolves outside the " \
245
+ "project root (#{expanded}); external test commands need a self-contained tree"
246
+ end
247
+
248
+ groups[:rebase] << [src, expanded]
249
+ end
250
+ end
251
+
252
+ # Rewrites each rebased source in the COPY's Gemfile (quoted option)
253
+ # and Gemfile.lock (PATH remote line). Zero or several matches mean
254
+ # the file computes the path or disagrees with Bundler's resolution -
255
+ # refuse rather than risk loading the original tree (#644).
256
+ def rebase_path_sources
257
+ @path_sources[:rebase].each do |src, expanded|
258
+ copy_path = expanded == @root ? @copy_root : File.join(@copy_root, expanded.delete_prefix("#{@root}/"))
259
+ rewrite_gemfile_source(src, copy_path)
260
+ rewrite_lockfile_source(src, expanded, copy_path)
261
+ end
262
+ end
263
+
264
+ def rewrite_gemfile_source(src, copy_path)
265
+ path = File.join(@copy_root, @gemfile_name)
266
+ text = File.read(path)
267
+ pattern = /(path:\s*["'])#{Regexp.escape(src.original)}(["'])/
268
+ raise ConfigError, ambiguous_source_message(src) unless text.scan(pattern).size == 1
269
+
270
+ File.write(path, text.sub(pattern) { "#{Regexp.last_match(1)}#{copy_path}#{Regexp.last_match(2)}" })
271
+ end
272
+
273
+ def rewrite_lockfile_source(src, expanded, copy_path)
274
+ # Bundler names the lockfile after the Gemfile: <gemfile>.lock.
275
+ path = File.join(@copy_root, "#{@gemfile_name}.lock")
276
+ return unless File.file?(path)
277
+
278
+ text = File.read(path)
279
+ # Bundler spells an in-project absolute path: as the root-relative
280
+ # remote ("vend/g" for /root/vend/g, "." for the root), and that
281
+ # spelling already resolves inside the copy (#722): accept it as
282
+ # is. Only the absolute spelling needs the rewrite, and only a
283
+ # duplicate or a total miss refuses - the lockfile then disagrees
284
+ # with Bundler's own resolution. The relative spelling must come
285
+ # from the CANONICAL expanded: src.expanded is Bundler's textual
286
+ # expand_path, which never matches the realpath'd root behind a
287
+ # symlinked one (macOS /var -> /private/var, #732).
288
+ spellings = ([src.original, expanded == @root ? "." : expanded.delete_prefix("#{@root}/")] - [""]).uniq
289
+ unless text.scan(/^(\s*remote: )(?:#{spellings.map { |s| Regexp.escape(s) }.join('|')})$/).size == 1
290
+ raise ConfigError, "could not rebase the lockfile's PATH remote for path: source \"#{src.name}\" " \
291
+ "(#{src.original}); the lockfile must record it exactly once - run bundle install"
292
+ end
293
+ absolute = /^(\s*remote: )#{Regexp.escape(src.original)}$/
294
+ # Write back only when the absolute spelling matched: a no-match sub
295
+ # would write identical bytes, which still fails on a read-only
296
+ # lockfile carried into the copy (#729 security).
297
+ File.write(path, text.sub(absolute) { "#{Regexp.last_match(1)}#{copy_path}" }) if text.match?(absolute)
298
+ end
299
+
300
+ def ambiguous_source_message(src)
301
+ "could not rebase the Gemfile's path: source \"#{src.name}\" (#{src.original}) into the disposable copy; " \
302
+ "external test commands need a literal path: option they can rewrite"
303
+ end
304
+
305
+ # A command-pinned Gemfile must live inside the tree, or the copy
306
+ # would read a Gemfile - and its path: sources - from outside (#644).
307
+ # Returns its root-relative path, or nil for the root Gemfile.
308
+ def pinned_gemfile
309
+ pinned = BundleGemfilePin.pinned_in(@command)
310
+ return nil unless pinned
311
+
312
+ expanded = GemfilePathSources.canonical_path(File.expand_path(pinned, @root))
313
+ unless expanded == @root || expanded.start_with?("#{@root}/")
314
+ raise ConfigError, "the test command pins BUNDLE_GEMFILE (#{pinned}) outside the project root " \
315
+ "(#{expanded}); external test commands need the Gemfile inside the tree"
316
+ end
317
+
318
+ expanded.delete_prefix("#{@root}/")
319
+ end
320
+
217
321
  # cp_r keeps symlinks, and a write through a link that escapes the
218
322
  # copy would land in the real tree. Links resolving inside the tree
219
323
  # are REBUILT as relative links inside the copy: alias identity is
@@ -0,0 +1,166 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "open3"
6
+
7
+ module Saboteur
8
+ module Execution
9
+ # Resolves a Gemfile's path: sources with Bundler's OWN parser in a
10
+ # short-lived child process (#644). Never a line scan: a wrong guess
11
+ # about a path: source silently loads unmutated code in the disposable
12
+ # copy. Fail closed - a Gemfile that cannot be parsed, a resolver that
13
+ # times out, or a path: source outside the project root refuses the
14
+ # run with exit 2 (ConfigError) naming the source.
15
+ # Resolving EVALUATES the project's Gemfile in that child at
16
+ # ExternalRunner.new - arbitrary Ruby with the caller's permissions,
17
+ # the same trust level as the bundle exec in the test command itself.
18
+ module GemfilePathSources
19
+ Source = Data.define(:name, :original, :expanded)
20
+
21
+ RESOLVE_TIMEOUT = 15
22
+
23
+ # Bundler::Dsl, not Bundler::Definition: the Gemfile must be PARSED by
24
+ # Bundler itself (it is arbitrary Ruby; a line scan can silently miss
25
+ # or invent a path: source and load unmutated code), but resolving the
26
+ # whole bundle is not needed and fails on half-installed projects.
27
+ RESOLVER = <<~RUBY
28
+ dsl = Bundler::Dsl.new
29
+ dsl.eval_gemfile(ENV["BUNDLE_GEMFILE"] || "Gemfile")
30
+ out = dsl.dependencies.filter_map do |dep|
31
+ src = dep.source
32
+ next unless src.is_a?(Bundler::Source::Path)
33
+
34
+ { "name" => dep.name, "original" => src.path.to_s, "expanded" => src.expanded_original_path.to_s }
35
+ end
36
+ puts JSON.generate(out)
37
+ RUBY
38
+
39
+ module_function
40
+
41
+ # Every path: source in the project's Gemfile, resolved by Bundler
42
+ # itself. Raises ConfigError on any failure to parse or resolve.
43
+ # gemfile pins the command's own BUNDLE_GEMFILE (Appraisal style):
44
+ # that file's path: sources are the ones the run loads, so it is
45
+ # the one parsed (#644).
46
+ def resolve(root, env: ENV, timeout: RESOLVE_TIMEOUT, gemfile: nil)
47
+ path = gemfile ? File.expand_path(gemfile, root) : File.join(root, "Gemfile")
48
+ if gemfile && !File.file?(path)
49
+ raise ConfigError, "the test command pins BUNDLE_GEMFILE (#{gemfile}) but #{path} does not exist"
50
+ end
51
+
52
+ return [] unless File.file?(path)
53
+
54
+ stdout, stderr, status = run_resolver(root, env, timeout, gemfile: path)
55
+ unless status.success?
56
+ detail = stderr.to_s.lines.first(3).join.strip
57
+ detail = "resolver exited #{status.exitstatus}" if detail.empty?
58
+ raise ConfigError, "could not parse the Gemfile's path: sources (#{detail}); " \
59
+ "external test commands need the Gemfile to resolve"
60
+ end
61
+
62
+ JSON.parse(stdout).map do |row|
63
+ Source.new(name: row["name"], original: row["original"], expanded: row["expanded"])
64
+ end
65
+ rescue JSON::ParserError
66
+ raise ConfigError, "could not parse the Gemfile's path: sources (resolver returned unreadable output); " \
67
+ "external test commands need the Gemfile to resolve"
68
+ end
69
+
70
+ # realpath, canonicalizing the deepest EXISTING ancestor when the
71
+ # path itself does not exist yet: behind a symlinked root (macOS
72
+ # /var -> /private/var) a textual expand_path can never match the
73
+ # realpath'd root, so an absolute path: source into the tree read
74
+ # as outside and silently skipped the rebase (#644, #721 macOS).
75
+ def canonical_path(path)
76
+ missing = []
77
+ current = path
78
+ until File.exist?(current)
79
+ missing.unshift(File.basename(current))
80
+ parent = File.dirname(current)
81
+ break if parent == current
82
+
83
+ current = parent
84
+ end
85
+ base = File.exist?(current) ? File.realpath(current) : File.expand_path(current)
86
+ File.join(base, *missing)
87
+ rescue SystemCallError
88
+ File.expand_path(path)
89
+ end
90
+
91
+ def run_resolver(root, env, timeout, gemfile: nil)
92
+ # Spawn env semantics: a key ABSENT from the hash is inherited, so
93
+ # the bundler anchors must be explicitly unset with nil, or the
94
+ # child resolves saboteur's own Gemfile instead of the project's.
95
+ # Both prefixes: rubygems itself requires ENV["BUNDLER_SETUP"] in
96
+ # gem_prelude, which would resolve the whole bundle at child boot.
97
+ child_env = env.each_key.with_object({}) do |key, memo|
98
+ memo[key] = nil if key.start_with?("BUNDLE_", "BUNDLER_") || key == "RUBYOPT"
99
+ end
100
+ # The Gemfile to parse, re-pinned after the anchor wipe: the
101
+ # command's own BUNDLE_GEMFILE when it sets one (Appraisal style),
102
+ # else the root Gemfile (#644).
103
+ child_env["BUNDLE_GEMFILE"] = gemfile || File.join(root, "Gemfile")
104
+ # Plain ruby, NOT bundle exec: bundle exec resolves the whole bundle
105
+ # before running, which refuses half-installed projects we are only
106
+ # trying to parse (#644). The child leads its own process group so a
107
+ # timeout can KILL the whole group, not only the ruby pid (#644).
108
+ cmd = ["ruby", "-rbundler", "-rjson", "-e", RESOLVER]
109
+ stdin, stdout, stderr, wait = Open3.popen3(child_env, *cmd, chdir: root, pgroup: true)
110
+ stdin.close
111
+ await_resolver(stdout, stderr, wait, timeout)
112
+ ensure
113
+ [stdin, stdout, stderr].each { |io| io&.close unless io&.closed? }
114
+ end
115
+
116
+ # Drains both pipes while waiting: a child that fills a pipe buffer
117
+ # (~64 KB) blocks on write and never exits, which would read as a
118
+ # fake timeout (#644). The readers accumulate partial chunks, so a
119
+ # still-open pipe (a spawned grandchild that left the process group
120
+ # holds the write end) can be abandoned after a short grace instead
121
+ # of blocking on EOF - on the timeout path the captured output is
122
+ # discarded, and on a clean exit incomplete output cannot be
123
+ # trusted, so the run refuses either way (#722).
124
+ def await_resolver(stdout, stderr, wait, timeout)
125
+ out_buffer = +""
126
+ err_buffer = +""
127
+ readers = [pipe_reader(stdout, out_buffer), pipe_reader(stderr, err_buffer)]
128
+ timed_out = resolver_timed_out?(wait, timeout)
129
+ readers_done = readers.all? { |reader| reader.join(2) }
130
+ readers.each { |reader| reader.kill if reader.alive? }
131
+ if timed_out
132
+ raise ConfigError, "could not parse the Gemfile's path: sources (resolver timed out after #{timeout} s); " \
133
+ "external test commands need the Gemfile to resolve"
134
+ end
135
+ unless readers_done
136
+ raise ConfigError, "could not parse the Gemfile's path: sources (a resolver-spawned process still holds " \
137
+ "its pipes open); external test commands need the Gemfile to resolve"
138
+ end
139
+
140
+ [out_buffer, err_buffer, wait.value]
141
+ end
142
+
143
+ # true when the resolver missed its deadline and its whole process
144
+ # group was KILLed.
145
+ def resolver_timed_out?(wait, timeout)
146
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
147
+ until wait.join(0.05)
148
+ next unless Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
149
+
150
+ Process.kill("KILL", -wait.pid)
151
+ wait.join
152
+ return true
153
+ end
154
+ false
155
+ end
156
+
157
+ def pipe_reader(io, buffer)
158
+ Thread.new do
159
+ loop { buffer << io.readpartial(65_536) }
160
+ rescue IOError # EOFError < IOError
161
+ buffer
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -62,17 +62,18 @@ module Saboteur
62
62
  @abi_version = probed_abi_version
63
63
  end
64
64
 
65
+ # Fiddle holds the GVL for the call (need_gvl: true) and saves errno in
66
+ # Fiddle.last_error in the same C function, right after ffi_call, so no
67
+ # Ruby code or GVL hand-off runs between the failed syscall and its
68
+ # errno. Without need_gvl, Fiddle releases the GVL for the call and reads
69
+ # errno only after reacquiring it, and the old second Fiddle call to
70
+ # __errno_location came later still: a stale errno could replace the
71
+ # real answer, first 0 (#350), then EINVAL, which the kernel never
72
+ # returns for a valid version probe (it answers EINVAL only for a
73
+ # non-NULL attr or a non-zero size).
65
74
  def probed_abi_version
66
- # Build the errno reader BEFORE the probe: its lazy dlopen/dlsym run
67
- # libc calls that can set errno, so building it between the probe and
68
- # the read can replace the real answer with a stale 0.
69
- LibC.errno_location_fn
70
- version, errno = probe_once
71
- # A failed syscall always sets errno, so (failure, errno 0) is never
72
- # the kernel's real answer - the read was clobbered between the call
73
- # and here. Probe once more, now with every Fiddle function warm.
74
- version, errno = probe_once if !version.positive? && errno.zero?
75
- probe_abi(version, errno)
75
+ version = LibC.probe_fn.call(SYS_CREATE_RULESET, 0, 0, CREATE_RULESET_VERSION, 0)
76
+ probe_abi(version, Fiddle.last_error)
76
77
  rescue Fiddle::DLError
77
78
  # No usable libc (an unusual musl/ldso layout): the sandbox cannot
78
79
  # be built here, so degrade to the unsandboxed note, not a crash.
@@ -83,17 +84,19 @@ module Saboteur
83
84
  # no Landlock to give: run unsandboxed, noted once. Anything else
84
85
  # (EPERM under a seccomp policy, ...) means the kernel is there but
85
86
  # refused - fail closed rather than run without the promised sandbox.
86
- # One create-ruleset version probe with its errno read paired to it.
87
- def probe_once
88
- version = LibC.syscall_fn.call(SYS_CREATE_RULESET, 0, 0, CREATE_RULESET_VERSION, 0)
89
- [version, last_errno]
90
- end
91
-
87
+ # The message names the kernel so a run on an unexpected runner says
88
+ # what it ran on.
92
89
  def probe_abi(version, errno)
93
90
  return version if version.positive?
94
91
  return 0 if UNSUPPORTED_ERRNOS.include?(errno)
95
92
 
96
- raise SystemCallError.new("landlock_create_ruleset probe", errno)
93
+ raise SystemCallError.new("landlock_create_ruleset probe (kernel #{kernel_release})", errno)
94
+ end
95
+
96
+ def kernel_release
97
+ File.read("/proc/sys/kernel/osrelease").strip
98
+ rescue SystemCallError
99
+ "unknown"
97
100
  end
98
101
 
99
102
  def available?
@@ -255,6 +258,16 @@ module Saboteur
255
258
  nil
256
259
  end
257
260
 
261
+ # The same syscall, built to run with the GVL held: see probed_abi_version.
262
+ def probe_fn
263
+ @probe_fn ||= Fiddle::Function.new(
264
+ handle["syscall"],
265
+ [Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG],
266
+ Fiddle::TYPE_LONG,
267
+ need_gvl: true
268
+ )
269
+ end
270
+
258
271
  def syscall_fn
259
272
  @syscall_fn ||= Fiddle::Function.new(
260
273
  handle["syscall"],
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require_relative "process_tree"
7
7
  require_relative "protocol"
8
8
  require_relative "fiddle_loader"
9
+ require_relative "supervisor_batch"
9
10
 
10
11
  module Saboteur
11
12
  module Execution
@@ -30,6 +31,8 @@ module Saboteur
30
31
  # intermediate parent between polls (#365), so kill_all also sweeps
31
32
  # the process table for the marker, which survives both escapes.
32
33
  class Supervisor
34
+ include SupervisorBatch
35
+
33
36
  # Set in every child to its nesting depth (1 for a child of a top-level
34
37
  # run). Engine refuses to start when it is set; the supervisor itself
35
38
  # refuses to fork past MAX_DEPTH, so even a run with that guard removed
@@ -132,7 +135,20 @@ module Saboteur
132
135
  # on_tick is called from the parent's wait loop after the fork, about
133
136
  # every poll, so callers can report progress without a thread alive
134
137
  # at fork time.
135
- def run(timeout:, cpu_seconds: nil, on_tick: nil, label: "supervised child died", &block)
138
+ def run(timeout:, cpu_seconds: nil, on_tick: nil, label: "supervised child died", &)
139
+ pid, reader, started = start(timeout, cpu_seconds, label, &)
140
+ begin
141
+ contain(pid, reader, started, timeout, on_tick)
142
+ ensure
143
+ reader.close unless reader.closed?
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ # Forks the child and returns [pid, reader, started]; the caller owns
150
+ # supervising it with contain and closing the reader.
151
+ def start(timeout, cpu_seconds, label, &block)
136
152
  depth = ENV.fetch(CHILD_ENV, "0").to_i
137
153
  raise Error, "refusing to fork: saboteur children nested #{depth} deep (limit #{MAX_DEPTH})" if
138
154
  depth >= MAX_DEPTH
@@ -150,13 +166,12 @@ module Saboteur
150
166
  @marker = SecureRandom.hex(6)
151
167
  pid = fork { child(reader, writer, supervisor, cpu_seconds, depth + 1, &block) }
152
168
  writer.close
153
- contain(pid, reader, started, timeout, on_tick)
154
- ensure
169
+ [pid, reader, started]
170
+ rescue Exception # rubocop:disable Lint/RescueException
155
171
  reader&.close unless reader&.closed?
172
+ raise
156
173
  end
157
174
 
158
- private
159
-
160
175
  # --- child ----------------------------------------------------------
161
176
 
162
177
  def child(reader, writer, supervisor, cpu_seconds, depth)
@@ -259,12 +274,30 @@ module Saboteur
259
274
  # only ours to claim if we see it alive: stop it first.
260
275
  exit_status, supervisor_killed = freeze_child(pid) if grace_expired
261
276
  # Notes name the leftovers before the kill erases their command lines (#486).
262
- leftover_pids, notes = tree.kill_all_except(pid)
277
+ # A blind process table must not skip containment: when the snapshot
278
+ # raises through kill_all_except (both backends down), reap still
279
+ # SIGKILLs and reaps the root and reap_group its group - only the
280
+ # table-driven orphan sweep is lost, and the entry must still leave
281
+ # the live registry (#762).
282
+ # Containment first, honesty after: a blind scan never becomes a
283
+ # clean Result - the guards reading leftover_pids would treat [] as
284
+ # no leak while setsid grandchildren survive (#762).
285
+ scan_error = nil
286
+ begin
287
+ leftover_pids, notes = tree.kill_all_except(pid)
288
+ rescue SystemCallError, Fiddle::DLError => e
289
+ scan_error = e
290
+ leftover_pids = []
291
+ notes = ["leftover scan skipped: #{e.class}: #{e.message}"]
292
+ end
263
293
  exit_status ||= reap(pid)
264
- self.class.live.delete(pid)
265
294
  reap_adopted(leftover_pids)
266
295
  reap_group(pid)
296
+ raise scan_error if scan_error
297
+
267
298
  [exit_status, supervisor_killed, leftover_pids, notes]
299
+ ensure
300
+ self.class.live.delete(pid)
268
301
  end
269
302
 
270
303
  # After EOF, waits for the child to exit by itself while still checking
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Execution
6
+ # Several supervised children at once (#741), mixed into Supervisor. It
7
+ # relies on Supervisor's start and contain.
8
+ module SupervisorBatch
9
+ # Runs count children at once, each supervised with the same limits and
10
+ # deadline as run, and returns their Results in index order (#741). The
11
+ # block gets (writer, index). Every child is forked from this thread
12
+ # before any watcher thread exists, so no fork happens beside a live
13
+ # thread; each child has its own copy of this supervisor, so its marker,
14
+ # label and memory peak never mix with a sibling's. Watching runs on one
15
+ # thread per child; on_tick is called only from the calling thread. A
16
+ # child that fails or is stopped does not cut its siblings short: every
17
+ # child is still contained and reaped before this returns, and the
18
+ # caller decides what a failed part means.
19
+ def run_many(count, timeout:, cpu_seconds: nil, on_tick: nil, label: "supervised child died", &block)
20
+ raise ArgumentError, "count must be at least 1, got #{count}" if count < 1
21
+
22
+ started = []
23
+ begin
24
+ count.times do |index|
25
+ part = dup
26
+ started << [part, *part.send(:start, timeout, cpu_seconds, label) { |writer| block.call(writer, index) }]
27
+ end
28
+ rescue Exception => e # rubocop:disable Lint/RescueException
29
+ reap_started(started)
30
+ raise e
31
+ end
32
+ watch_all(started, timeout, on_tick)
33
+ end
34
+
35
+ private
36
+
37
+ # One watcher thread per started child. Waits for all of them, even
38
+ # when one raises, so no child outlives this call; then re-raises the
39
+ # first failure. When the calling thread's loop is interrupted (on_tick
40
+ # raised, Interrupt), every child is killed at once instead of being
41
+ # left to run to its deadline.
42
+ def watch_all(started, timeout, on_tick)
43
+ watchers = start_watchers(started, timeout)
44
+ begin
45
+ until watchers.none?(&:alive?)
46
+ on_tick&.call
47
+ sleep 0.01
48
+ end
49
+ rescue Exception # rubocop:disable Lint/RescueException
50
+ kill_started(started, watchers)
51
+ raise
52
+ ensure
53
+ watchers.each(&:join)
54
+ end
55
+ outcomes = watchers.map(&:value)
56
+ failure = outcomes.find { |outcome| outcome.is_a?(Exception) }
57
+ raise failure if failure
58
+
59
+ outcomes
60
+ end
61
+
62
+ # A watcher that cannot start leaves its child unwatched: reap every
63
+ # child, then report the original error.
64
+ def start_watchers(started, timeout)
65
+ started.map do |part, pid, reader, began|
66
+ Thread.new do
67
+ part.send(:contain, pid, reader, began, timeout, nil)
68
+ rescue Exception => e # rubocop:disable Lint/RescueException
69
+ e
70
+ ensure
71
+ reader.close
72
+ end
73
+ end
74
+ rescue Exception => e # rubocop:disable Lint/RescueException
75
+ reap_started(started)
76
+ raise e
77
+ end
78
+
79
+ # Only a child whose watcher is still running can still be ours: once
80
+ # its watcher has reaped it, its pid may belong to another process.
81
+ def kill_started(started, watchers = nil)
82
+ started.each_with_index do |(part, pid, _reader, _began), index|
83
+ next if watchers && !watchers[index].alive?
84
+
85
+ part.send(:signal, "KILL", -pid)
86
+ part.send(:signal, "KILL", pid)
87
+ end
88
+ end
89
+
90
+ # Containment for children already forked when a later fork failed:
91
+ # kill every one, then contain each; one child's failure to be
92
+ # contained never leaves a later one running.
93
+ def reap_started(started)
94
+ kill_started(started)
95
+ started.each do |part, pid, reader, began|
96
+ part.send(:contain, pid, reader, began, 0, nil)
97
+ rescue Exception # rubocop:disable Lint/RescueException
98
+ nil
99
+ ensure
100
+ reader.close
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end