saboteur 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. metadata +58 -2
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require_relative "stale_captures"
5
6
 
6
7
  module Saboteur
7
8
  module Execution
@@ -14,8 +15,12 @@ module Saboteur
14
15
  class Runner
15
16
  # kill_cause: "assertion" when the killing test failed an expectation,
16
17
  # else the class of the error it died with (a crash, not a check).
17
- Outcome = Data.define(:status, :runtime, :failed_ids, :message, :leftover_pids, :kill_cause) do
18
- def initialize(status:, runtime:, failed_ids: [], message: nil, leftover_pids: [], kill_cause: nil)
18
+ # executed_ids: the tests the child reports it ran; empty when it could
19
+ # not say (a test called exit).
20
+ Outcome = Data.define(:status, :runtime, :failed_ids, :message, :leftover_pids, :kill_cause,
21
+ :executed_ids) do
22
+ def initialize(status:, runtime:, failed_ids: [], message: nil, leftover_pids: [], kill_cause: nil,
23
+ executed_ids: [])
19
24
  super
20
25
  end
21
26
  end
@@ -27,7 +32,12 @@ module Saboteur
27
32
  ].freeze
28
33
 
29
34
  # Everything one child needs. mutation is nil for the verdict check.
30
- Job = Data.define(:subject, :text, :test_ids, :timeout, :mutation, :log_path)
35
+ # eval_prefix: wrapper source (a sorbet sig) evaluated with the def.
36
+ Job = Data.define(:subject, :text, :test_ids, :timeout, :mutation, :log_path, :eval_prefix) do
37
+ def initialize(subject:, text:, test_ids:, timeout:, log_path:, mutation: nil, eval_prefix: nil)
38
+ super
39
+ end
40
+ end
31
41
 
32
42
  LOG_TAIL_BYTES = 2000
33
43
 
@@ -35,13 +45,18 @@ module Saboteur
35
45
  # only when it ends in an error.
36
46
  # seed: every child seeds Ruby's random numbers with it before its
37
47
  # tests, so a test that uses rand sees the same values in every run.
38
- def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1)
48
+ # sandbox: when given, a callable run first in every child (Landlock
49
+ # makes the project tree read-only there, #152). It raises rather than
50
+ # run half-restricted, which the child reports as an error.
51
+ def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1,
52
+ sandbox: nil)
39
53
  @integration = integration
40
54
  @reevaluator = reevaluator
41
55
  @hooks = hooks
42
56
  @log_dir = log_dir
43
57
  @supervisor = supervisor
44
58
  @seed = seed
59
+ @sandbox = sandbox
45
60
  end
46
61
 
47
62
  # text: method source to evaluate in the child (the unchanged source for
@@ -49,10 +64,10 @@ module Saboteur
49
64
  # the neutral check). Returns an Outcome with status
50
65
  # :killed, :survived, :timeout, :resource_limit (memory or process cap) or
51
66
  # :error.
52
- def run(subject:, text:, test_ids:, timeout:, mutation: nil)
67
+ def run(subject:, text:, test_ids:, timeout:, mutation: nil, eval_prefix: nil)
53
68
  @hooks.run(:before_fork, mutation)
54
69
  @sequence = @sequence.to_i + 1
55
- job = Job.new(subject:, text:, test_ids:, timeout:, mutation:,
70
+ job = Job.new(subject:, text:, test_ids:, timeout:, mutation:, eval_prefix:,
56
71
  log_path: File.join(@log_dir, "child-#{Process.pid}-#{@sequence}.log"))
57
72
  result = @supervisor.run(timeout: timeout) { |writer| child(writer, job) }
58
73
  outcome = outcome_for(result, job)
@@ -65,21 +80,67 @@ module Saboteur
65
80
  # --- child ----------------------------------------------------------
66
81
 
67
82
  def child(writer, job)
68
- Process.setproctitle(title(job))
83
+ # Redirect before restricting: the sweep closes inherited stdio
84
+ # redirected to a regular file outside the grants (#178), and a
85
+ # reopened-afterwards $stdout would start from a closed stream.
86
+ # The log lives under the granted cache dir, so it survives.
69
87
  redirect_output(job.log_path)
88
+ @sandbox&.call([writer.fileno])
89
+ Process.setproctitle(title(job))
70
90
  @hooks.run(:after_fork)
71
- @reevaluator.call(job.subject, job.text)
91
+ # Before the evaluation: a whole-file subject runs the file's own
92
+ # top-level code there, and it must see the run's random numbers.
93
+ Kernel.srand(@seed)
94
+ evaluate_mutant(writer, job)
95
+ stale = stale_captures(job)
96
+ Protocol.emit(writer, "captures", site: stale.first, stale: stale.size) if stale.any?
72
97
  Protocol.emit(writer, "started")
73
98
  Kernel.srand(@seed)
74
- result = @hooks.around(job.mutation) { run_tests(job.test_ids) }
99
+ emit_result(writer, job, stale.any?)
100
+ rescue Exception => e # rubocop:disable Lint/RescueException
101
+ Protocol.emit(writer, "error", message: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(5))
102
+ exit!(1)
103
+ end
104
+
105
+ def evaluate_mutant(writer, job)
106
+ @reevaluator.call(job.subject, job.text, prefix: job.eval_prefix)
107
+ replaced = @reevaluator.replaced_definition(job.subject)
108
+ Protocol.emit(writer, "replaced", site: replaced) if replaced
109
+ end
110
+
111
+ # Capture detection is best-effort: a subject shape it cannot scan
112
+ # must never turn the mutant run itself into an error.
113
+ def stale_captures(job)
114
+ StaleCaptures.scan(@reevaluator, job.subject)
115
+ rescue StandardError
116
+ []
117
+ end
118
+
119
+ def emit_result(writer, job, has_captures)
120
+ method = replacement_method(job) if has_captures && job.mutation
121
+ called = method ? false : nil
122
+ trace = TracePoint.new(:call) { called = true } if method
123
+ result = if trace
124
+ trace.enable(target: method) { @hooks.around(job.mutation) { run_tests(job.test_ids) } }
125
+ else
126
+ @hooks.around(job.mutation) { run_tests(job.test_ids) }
127
+ end
75
128
  if result.nil?
76
129
  Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
77
130
  else
78
- Protocol.emit(writer, "result", **result)
131
+ Protocol.emit(writer, "result", **result, replacement_called: called)
79
132
  end
80
- rescue Exception => e # rubocop:disable Lint/RescueException
81
- Protocol.emit(writer, "error", message: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(5))
82
- exit!(1)
133
+ end
134
+
135
+ # Targeted tracing distinguishes the new Ruby body from an old Method
136
+ # capture, even though both have the same owner, name and source line.
137
+ def replacement_method(job)
138
+ owner = @reevaluator.resolve_owner(job.subject)
139
+ target = job.subject.singleton ? owner.singleton_class : owner
140
+ method = target.instance_method(job.subject.name)
141
+ Wrapper.original_def(method) || method
142
+ rescue StandardError
143
+ nil
83
144
  end
84
145
 
85
146
  # What `ps` shows for this child: which method, and which mutant.
@@ -124,7 +185,7 @@ module Saboteur
124
185
  error = events.find { |event| event["event"] == "error" }
125
186
 
126
187
  if result
127
- classify_result(result, runtime, job.test_ids)
188
+ classify_events(result, events, runtime, job)
128
189
  else
129
190
  message = if error
130
191
  error["message"]
@@ -145,17 +206,59 @@ module Saboteur
145
206
  unexpected = failed - test_ids
146
207
  return mismatch(runtime, "failed tests not requested: #{unexpected.first(3).join(', ')}") if unexpected.any?
147
208
 
148
- return Outcome.new(status: :killed, runtime: runtime, failed_ids: failed,
209
+ return Outcome.new(status: :killed, runtime: runtime, failed_ids: failed, executed_ids: executed,
149
210
  message: result.fetch("failure_messages", {})[failed.first],
150
211
  kill_cause: kill_cause(result.fetch("failure_classes", {})[failed.first]))
151
212
  end
152
- return Outcome.new(status: :killed, runtime: runtime, message: "suite-level failure") unless result["passed"]
213
+ unless result["passed"]
214
+ return Outcome.new(status: :killed, runtime: runtime, executed_ids: executed,
215
+ message: "suite-level failure")
216
+ end
153
217
 
154
218
  if executed.sort != test_ids.sort
155
219
  return mismatch(runtime, "executed #{executed.size} of #{test_ids.size} requested tests")
156
220
  end
157
221
 
158
- Outcome.new(status: :survived, runtime: runtime)
222
+ Outcome.new(status: :survived, runtime: runtime, executed_ids: executed)
223
+ end
224
+
225
+ def classify_events(result, events, runtime, job)
226
+ outcome = classify_result(result, runtime, job.test_ids)
227
+ replaced = events.find { |event| event["event"] == "replaced" }
228
+ return replaced_by_later_definition(outcome, replaced) if never_ran?(outcome, replaced, job)
229
+
230
+ captures = events.find { |event| event["event"] == "captures" }
231
+ return unreachable(outcome, captures) if unobservable?(outcome, captures, job, result["replacement_called"])
232
+
233
+ outcome
234
+ end
235
+
236
+ # A surviving mutant whose definition a later one in the same file
237
+ # replaced: the tests could not have run it, so it is no verdict on them.
238
+ def never_ran?(outcome, replaced, job)
239
+ replaced && job.mutation && outcome.status == :survived
240
+ end
241
+
242
+ def replaced_by_later_definition(outcome, replaced)
243
+ Outcome.new(status: :unreachable, runtime: outcome.runtime,
244
+ message: "the whole-file evaluation left another definition in place " \
245
+ "(#{replaced['site']}), so this mutant never ran")
246
+ end
247
+
248
+ # Only mutant runs convert: the verdict check re-evaluates the
249
+ # unchanged method, and a stale capture there must not overturn a real
250
+ # kill.
251
+ def unobservable?(outcome, captures, job, replacement_called)
252
+ captures && job.mutation && outcome.status == :survived && replacement_called == false
253
+ end
254
+
255
+ # A survivor that no test could have observed: a Method object
256
+ # captured at load still calls the original. Not a verdict on the
257
+ # tests, so it is its own status, out of the score.
258
+ def unreachable(outcome, captures)
259
+ Outcome.new(status: :unreachable, runtime: outcome.runtime,
260
+ message: "a Method object captured before re-evaluation still calls the original " \
261
+ "(captured definition at #{captures['site']})")
159
262
  end
160
263
 
161
264
  def kill_cause(failure_class)
@@ -0,0 +1,271 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fiddle"
5
+ require "fiddle/import"
6
+
7
+ module Saboteur
8
+ module Execution
9
+ # The third hazard layer (issue #152): a mutant child restricts itself
10
+ # with Landlock so the project tree is read-only, except the cache dir,
11
+ # the project's tmp/ and the system temp dir.
12
+ #
13
+ # Landlock needs no privileges and works from a multithreaded process,
14
+ # which a mount/user namespace cannot (Ruby's timer thread makes
15
+ # unshare(CLONE_NEWUSER) fail with EINVAL). What it guarantees depends
16
+ # on the kernel's Landlock ABI: ABI 3 (Linux 6.2) also restricts
17
+ # truncate(); on ABI 1-2 (Linux 5.13-6.1) a mutant can still truncate
18
+ # project files, and the run notes that once. Where the kernel has no
19
+ # Landlock at all (before 5.13) the child runs unsandboxed, exactly as
20
+ # before - and a probe that errors for any other reason refuses the
21
+ # run instead of silently dropping the sandbox. Landlock restricts the
22
+ # filesystem only: network, sockets and IPC are never covered.
23
+ #
24
+ # libc is dlopened lazily on first use, and first use is always behind
25
+ # the linux? gate: requiring this file (which saboteur.rb does
26
+ # unconditionally) must stay inert on macOS and non-glibc Linux, where
27
+ # libc.so.6 does not exist (#178).
28
+ module Sandbox
29
+ SYS_CREATE_RULESET = 444
30
+ SYS_ADD_RULE = 445
31
+ SYS_RESTRICT_SELF = 446
32
+ CREATE_RULESET_VERSION = 1
33
+ RULE_PATH_BENEATH = 1
34
+ PR_SET_NO_NEW_PRIVS = 38
35
+ O_PATH = 0o10000000
36
+ O_ACCMODE = 0o3
37
+ F_GETFL = 3
38
+
39
+ # Every write-side right, so reads and execution keep working anywhere.
40
+ BASE_RIGHTS = (1 << 1) | (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7) | (1 << 8) |
41
+ (1 << 9) | (1 << 10) | (1 << 11) | (1 << 12)
42
+ REFER = 1 << 13 # ABI 2 (5.19)
43
+ TRUNCATE = 1 << 14 # ABI 3 (6.2)
44
+ WRITE_FILE = 1 << 1
45
+
46
+ # A probe answered with these says Landlock is genuinely absent.
47
+ UNSUPPORTED_ERRNOS = [Errno::ENOSYS::Errno, Errno::EOPNOTSUPP::Errno].freeze
48
+
49
+ module_function
50
+
51
+ def linux?
52
+ RUBY_PLATFORM.include?("linux")
53
+ end
54
+
55
+ # The kernel's Landlock ABI (0 when unsupported), memoized per process.
56
+ def abi_version
57
+ return @abi_version if defined?(@abi_version)
58
+ return @abi_version = 0 unless linux?
59
+
60
+ version = LibC.syscall_fn.call(SYS_CREATE_RULESET, 0, 0, CREATE_RULESET_VERSION, 0)
61
+ errno = last_errno
62
+ @abi_version = probe_abi(version, errno)
63
+ rescue Fiddle::DLError
64
+ # No usable libc (an unusual musl/ldso layout): the sandbox cannot
65
+ # be built here, so degrade to the unsandboxed note, not a crash.
66
+ @abi_version = 0
67
+ end
68
+
69
+ # A positive answer is the ABI. ENOSYS/EOPNOTSUPP mean the kernel has
70
+ # no Landlock to give: run unsandboxed, noted once. Anything else
71
+ # (EPERM under a seccomp policy, ...) means the kernel is there but
72
+ # refused - fail closed rather than run without the promised sandbox.
73
+ def probe_abi(version, errno)
74
+ return version if version.positive?
75
+ return 0 if UNSUPPORTED_ERRNOS.include?(errno)
76
+
77
+ raise SystemCallError.new("landlock_create_ruleset probe", errno)
78
+ end
79
+
80
+ def available?
81
+ abi_version >= 1
82
+ end
83
+
84
+ # The rights mask this kernel understands; newer rights are gated by
85
+ # the probed ABI so old kernels do not reject the ruleset as invalid.
86
+ def rights_mask
87
+ mask = BASE_RIGHTS
88
+ mask |= REFER if abi_version >= 2
89
+ mask |= TRUNCATE if abi_version >= 3
90
+ mask
91
+ end
92
+
93
+ # Called at the top of a mutant child. Irreversible and fail-closed: a
94
+ # child whose sandbox cannot be fully built must not run the mutant.
95
+ # keep_fds: descriptors the child still needs after restricting (its
96
+ # report pipe to the parent).
97
+ def restrict!(writable:, keep_fds: [])
98
+ InheritedFds.close_writable_files(writable, keep_fds)
99
+ ruleset = create_ruleset
100
+ begin
101
+ writable.each { |dir| allow(ruleset, dir, rights_mask) }
102
+ allow(ruleset, File::NULL, WRITE_FILE)
103
+ check("prctl(PR_SET_NO_NEW_PRIVS)") { LibC.prctl_fn.call(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }
104
+ check("landlock_restrict_self") { LibC.syscall_fn.call(SYS_RESTRICT_SELF, ruleset, 0, 0, 0) }
105
+ ensure
106
+ LibC.close_fn.call(ruleset)
107
+ end
108
+ nil
109
+ end
110
+
111
+ def create_ruleset
112
+ attr = [rights_mask].pack("Q<")
113
+ fd = LibC.syscall_fn.call(SYS_CREATE_RULESET, Fiddle::Pointer[attr], attr.bytesize, 0, 0)
114
+ errno = last_errno
115
+ check_fd("landlock_create_ruleset", fd, errno)
116
+ fd
117
+ end
118
+
119
+ def allow(ruleset, path, rights)
120
+ parent = LibC.open_fn.call(path, O_PATH)
121
+ errno = last_errno
122
+ check_fd("open #{path}", parent, errno)
123
+ attr = "#{[rights].pack('Q<')}#{[parent].pack('l<')}\0\0\0\0"
124
+ check("landlock_add_rule #{path}") do
125
+ LibC.syscall_fn.call(SYS_ADD_RULE, ruleset, RULE_PATH_BENEATH, Fiddle::Pointer[attr], 0)
126
+ end
127
+ ensure
128
+ LibC.close_fn.call(parent) if parent && parent >= 0
129
+ end
130
+
131
+ # Landlock only gates opens after restrict: an already open writable
132
+ # file keeps writing through its descriptor. Close every inherited
133
+ # regular-file descriptor open for writing outside the granted dirs.
134
+ # Sockets, pipes and anonymous inodes have no path and are untouched.
135
+ module InheritedFds
136
+ module_function
137
+
138
+ # stdio (0/1/2) is swept like any other descriptor: a terminal,
139
+ # pipe or /dev/null is not a regular file and stays, but stdio
140
+ # redirected to a writable regular file outside the grants is a
141
+ # write channel Landlock cannot gate, so it is closed.
142
+ def close_writable_files(writable, keep_fds)
143
+ Dir.children("/proc/self/fd").each do |entry|
144
+ descriptor = entry.to_i
145
+ next if keep_fds.include?(descriptor)
146
+
147
+ sweep_fd(descriptor, File.join("/proc/self/fd", entry), writable)
148
+ end
149
+ nil
150
+ end
151
+
152
+ # Fail closed throughout: a descriptor whose target or flags cannot
153
+ # be read may be a writable file we cannot see, so it is closed
154
+ # rather than skipped. Deleted files still stat through the fd (the
155
+ # inode lives until the last descriptor closes), so they are swept
156
+ # on their stripped path.
157
+ def sweep_fd(descriptor, link, writable)
158
+ target = File.readlink(link)
159
+ stat = File.stat(link)
160
+ return unless stat.file?
161
+
162
+ granted = target.delete_suffix(" (deleted)")
163
+ return if writable.any? { |dir| granted == dir || granted.start_with?("#{dir}/") }
164
+
165
+ flags = LibC.fcntl_fn.call(descriptor, F_GETFL, 0)
166
+ return close_fd(descriptor) if flags.negative?
167
+ return if flags.nobits?(O_ACCMODE) # O_RDONLY
168
+
169
+ close_fd(descriptor)
170
+ rescue SystemCallError
171
+ begin
172
+ close_fd(descriptor)
173
+ rescue IOError, SystemCallError
174
+ nil # the descriptor raced away; nothing left to close
175
+ end
176
+ end
177
+
178
+ # Close through the owning Ruby IO when there is one: a raw close
179
+ # leaves the IO object holding a dead fd, whose number a later open
180
+ # recycles - and the stale IO's finalizer would then close the NEW
181
+ # file (seen as corrupted writes in the spec suite).
182
+ def close_fd(descriptor)
183
+ owner = ObjectSpace.each_object(IO).find do |io|
184
+ !io.closed? && io.fileno == descriptor
185
+ rescue IOError
186
+ false
187
+ end
188
+ owner ? owner.close : LibC.close_fn.call(descriptor)
189
+ end
190
+ end
191
+
192
+ def check(what)
193
+ result = yield
194
+ return if result.zero?
195
+
196
+ raise SystemCallError.new(what, last_errno)
197
+ end
198
+
199
+ def check_fd(what, handle, errno)
200
+ raise SystemCallError.new(what, errno) if handle.negative?
201
+ end
202
+
203
+ # Read errno before anything else can clobber it: any Ruby allocation
204
+ # between the failed call and this read can reset it to 0.
205
+ def last_errno
206
+ LibC.errno_location_fn.call.to_s(4).unpack1("i") # errno is an int, not a long (LP64)
207
+ end
208
+
209
+ # Every libc touchpoint, each built on first call - never at require
210
+ # time, so loading this file stays inert off glibc/Linux.
211
+ module LibC
212
+ module_function
213
+
214
+ # glibc's soname first, then musl's (arch-qualified), then the
215
+ # process global symbol table: a musl system has no libc.so.6, but
216
+ # its loader already mapped the C library Ruby is linked against,
217
+ # and Fiddle::Handle::DEFAULT resolves symbols from there.
218
+ def handle
219
+ @handle ||= libc_candidates.lazy.map { |name| try_dlopen(name) }.find(&:itself) ||
220
+ Fiddle::Handle::DEFAULT
221
+ end
222
+
223
+ def libc_candidates
224
+ arch = RbConfig::CONFIG["host_cpu"]
225
+ ["libc.so.6", "libc.musl-#{arch}.so.1", "ld-musl-#{arch}.so.1"]
226
+ end
227
+
228
+ def try_dlopen(name)
229
+ Fiddle.dlopen(name)
230
+ rescue Fiddle::DLError
231
+ nil
232
+ end
233
+
234
+ def syscall_fn
235
+ @syscall_fn ||= Fiddle::Function.new(
236
+ handle["syscall"],
237
+ [Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG],
238
+ Fiddle::TYPE_LONG
239
+ )
240
+ end
241
+
242
+ def prctl_fn
243
+ @prctl_fn ||= Fiddle::Function.new(
244
+ handle["prctl"],
245
+ [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG],
246
+ Fiddle::TYPE_INT
247
+ )
248
+ end
249
+
250
+ def open_fn
251
+ @open_fn ||=
252
+ Fiddle::Function.new(handle["open"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
253
+ end
254
+
255
+ def close_fn
256
+ @close_fn ||= Fiddle::Function.new(handle["close"], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
257
+ end
258
+
259
+ def fcntl_fn
260
+ @fcntl_fn ||= Fiddle::Function.new(
261
+ handle["fcntl"], [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG], Fiddle::TYPE_INT
262
+ )
263
+ end
264
+
265
+ def errno_location_fn
266
+ @errno_location_fn ||= Fiddle::Function.new(handle["__errno_location"], [], Fiddle::TYPE_VOIDP)
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Execution
6
+ # Method and UnboundMethod objects captured before a re-evaluation keep
7
+ # pointing at the original body; a handler registry such as
8
+ # `RULES = [Limits.method(:min_ok?)]` is the common case. Tests that call
9
+ # through such a capture can never observe a mutant, so after each
10
+ # re-evaluation the child scans for captures of the original def.
11
+ module StaleCaptures
12
+ # "file:line" sites of the original def for which a stale capture
13
+ # exists, deduplicated. Empty when nothing captured the method.
14
+ def self.scan(reevaluator, subject)
15
+ owner = reevaluator.resolve_owner(subject)
16
+ target = subject.singleton ? owner.singleton_class : owner
17
+ current = target.instance_method(subject.name)
18
+ # A def-time wrapper (sorbet's sig) is what instance_method returns;
19
+ # the def it dispatches to is the definition captures can hold.
20
+ current = Wrapper.original_def(current) || current
21
+ # The scan must not mistake transient Method objects of its own
22
+ # making (the re-eval, this instance_method) for captures; a full GC
23
+ # clears them, and only long-lived captures from load time remain.
24
+ GC.start
25
+ stale = [UnboundMethod, Method].flat_map do |kind|
26
+ ObjectSpace.each_object(kind).select do |captured|
27
+ stale?(captured, target, subject, current)
28
+ end
29
+ end
30
+ stale.map { |captured| captured.source_location.join(":") }.uniq
31
+ end
32
+
33
+ def self.stale?(captured, target, subject, current)
34
+ return false unless captured.owner == target && captured.name == subject.name
35
+ return false if captured == current
36
+
37
+ file, line = captured.source_location
38
+ file && line == subject.start_line &&
39
+ Selection::CoverageMap.canonical(file) == Selection::CoverageMap.canonical(subject.file)
40
+ end
41
+ end
42
+ end
43
+ end