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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/README.md +10 -0
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +3 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +3 -0
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli.rb +29 -307
- data/lib/saboteur/config.rb +72 -6
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +20 -12
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +1 -1
- data/lib/saboteur/doctor.rb +6 -0
- data/lib/saboteur/engine/baseline_guard.rb +50 -13
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +15 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +275 -22
- data/lib/saboteur/engine/diagnostics.rb +23 -6
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +1 -0
- data/lib/saboteur/engine/load_coverage.rb +54 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +7 -7
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +5 -5
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +5 -2
- data/lib/saboteur/engine/scheduler.rb +1 -1
- data/lib/saboteur/engine/selection_announcement.rb +1 -0
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +33 -37
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +82 -18
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +41 -9
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +105 -1
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/sandbox.rb +30 -17
- data/lib/saboteur/execution/supervisor.rb +40 -7
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +46 -7
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/builtin.rb +32 -22
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +5 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +9 -1
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +29 -4
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +136 -10
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +8 -0
- 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
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
87
|
-
|
|
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", &
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|