saboteur 0.7.0 → 0.8.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. metadata +35 -2
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "process_table"
5
+ require_relative "marker_sweep"
5
6
 
6
7
  module Saboteur
7
8
  module Execution
@@ -12,14 +13,19 @@ module Saboteur
12
13
  # setsid (a nested saboteur run does exactly that) and leave the group.
13
14
  # Parent links survive that, so the tree is rebuilt from the process table
14
15
  # on every poll, and every pid ever seen in it is remembered, so a
15
- # descendant whose parent exits (re-parented to init) is still known.
16
+ # descendant whose parent exits (re-parented to init) is still known -
17
+ # but only if some poll SAW the parent link while the intermediate lived.
18
+ # A descendant that leaves the group and loses that parent between two
19
+ # polls is never seen (#365); the marker sweep in kill_all is the net
20
+ # for exactly that case.
16
21
  #
17
22
  # Identity is pid plus start time, so a recycled pid is never signalled.
18
23
  class ProcessTree
19
24
  attr_reader :root
20
25
 
21
- def initialize(root)
26
+ def initialize(root, marker: nil)
22
27
  @root = root
28
+ @marker = marker
23
29
  @known = {} # pid => start time
24
30
  end
25
31
 
@@ -34,6 +40,27 @@ module Saboteur
34
40
  end
35
41
  end
36
42
 
43
+ # kill_all minus an excluded root, with the pre-kill member
44
+ # descriptions: [killed pids, notes]. The notes are taken first -
45
+ # after the kill the command lines are gone (#486).
46
+ def kill_all_except(root)
47
+ notes = describe_except(root)
48
+ [kill_all - [root], notes]
49
+ end
50
+
51
+ # One "pid (command)" note per live member other than an excluded
52
+ # root, for the leftover-children refusal (#486). Best effort: a
53
+ # process that vanished mid-look lists its pid alone. Call before the
54
+ # kill - after it, the command lines are gone.
55
+ def describe_except(root, table = ProcessTable.snapshot)
56
+ live_members(table).filter_map do |entry|
57
+ next if entry.pid == root
58
+
59
+ command = ProcessTable.command(entry.pid)
60
+ command ? "#{entry.pid} (#{command})" : entry.pid.to_s
61
+ end
62
+ end
63
+
37
64
  # Count and resident memory of the live members. rss_baseline_kb is the
38
65
  # supervisor's own RSS: forked children share its pages, so only growth
39
66
  # beyond it is counted per process.
@@ -44,15 +71,20 @@ module Saboteur
44
71
  end
45
72
 
46
73
  # Stops every member (a stopped process cannot fork), rescans until no new
47
- # member appears, then kills them all. Returns the pids it killed.
74
+ # member appears, then kills them all. Each round also sweeps for
75
+ # never-seen escapees carrying the run's marker (#365). Returns the
76
+ # pids it killed.
48
77
  def kill_all(rounds: 20)
49
78
  killed = []
50
79
  rounds.times do
51
80
  live = stop_until_stable
52
- break if live.empty?
81
+ escaped = marked_escapees
82
+ break if live.empty? && escaped.empty?
53
83
 
54
84
  live.each { |entry| signal("KILL", entry.pid) }
55
- killed.concat(live.map(&:pid))
85
+ signalled = escaped.select { |entry| escapee_alive?(entry) }
86
+ signalled.each { |entry| signal("KILL", entry.pid) }
87
+ killed.concat(live.map(&:pid), signalled.map(&:pid))
56
88
  sleep 0.01
57
89
  end
58
90
  killed.uniq
@@ -60,6 +92,34 @@ module Saboteur
60
92
 
61
93
  private
62
94
 
95
+ # Live, non-zombie pids carrying the run's marker that the tree never
96
+ # learned: the setsid-and-orphan escapees (#365). Members match the
97
+ # marker too, but the same round kills them anyway.
98
+ def marked_escapees
99
+ return [] unless @marker && MarkerSweep.available?
100
+
101
+ table = ProcessTable.snapshot
102
+ root_started = @known[root]
103
+ MarkerSweep.marked_pids(@marker, Supervisor::TREE_ENV).filter_map do |pid|
104
+ entry = table[pid]
105
+ next unless entry && !entry.zombie && !@known.key?(pid)
106
+
107
+ # A process started before the run's root carries the marker only
108
+ # through an exec chain the run did not spawn: never ours (#389).
109
+ next if root_started && entry.started < root_started
110
+
111
+ entry
112
+ end
113
+ end
114
+
115
+ # A pid is not an identity: the escapee can exit between the sweep's
116
+ # snapshot and the KILL, and the kernel can hand the pid to someone
117
+ # else. Signal only the process the sweep actually saw (#389).
118
+ def escapee_alive?(entry)
119
+ fresh = ProcessTable.snapshot[entry.pid]
120
+ fresh && !fresh.zombie && fresh.started == entry.started
121
+ end
122
+
63
123
  def grow(table)
64
124
  @known[root] = table[root].started if @known.empty? && table[root]
65
125
  loop do
@@ -54,9 +54,54 @@ module Saboteur
54
54
  nil
55
55
  end
56
56
 
57
+ # The method object the subject's def currently resolves to,
58
+ # normalized through a known def-time wrapper. The runner compares
59
+ # what the mutant's evaluation installed with what is in place after
60
+ # the tests: a suite that re-loads the file mid-run (a before(:each)
61
+ # `load "lib/..."`, rolify-style) quietly replaces the mutant, and a
62
+ # "survivor" then only proves the tests never ran it. nil when this
63
+ # cannot be told (a missing or non-Ruby method): the caller keeps the
64
+ # verdict.
65
+ def installed_definition(subject)
66
+ owner = resolve_owner(subject)
67
+ target = subject.singleton ? owner.singleton_class : owner
68
+ method = target.instance_method(subject.name)
69
+ Wrapper.original_def(method) || method
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ # The definition now in place for the subject versus the one the
75
+ # mutant's evaluation installed: a "file:line" site when the tests
76
+ # provably replaced it - a before(:each) re-loading the subject's
77
+ # own file (rolify-style `load "lib/..."`, #597) puts the original
78
+ # back before any test calls it, so a "survivor" proves nothing.
79
+ # Only a positively identified overwrite counts: the replacement
80
+ # comes from the subject's own file, and no prepend or
81
+ # allow_any_instance_of-style wrapper whose super chain still
82
+ # reaches the mutant (those tests did run it). Anything
83
+ # undecidable returns nil and the verdict stands.
84
+ def overwritten_site(subject, installed)
85
+ now = installed_definition(subject)
86
+ return nil if now.nil? || now == installed
87
+
88
+ method = now
89
+ method = method.super_method until method.nil? || method == installed
90
+ return nil if method # the mutant is still reachable through super
91
+
92
+ file, line = now.source_location
93
+ return nil unless file && File.expand_path(file) == File.expand_path(subject.file)
94
+
95
+ "#{file}:#{line}"
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
57
100
  # True when the subject's id matches a whole_file entry (exact or a
58
101
  # File.fnmatch glob).
59
102
  def whole_file?(subject)
103
+ return true if subject.whole_file_only?
104
+
60
105
  @whole_file.any? { |pattern| File.fnmatch?(pattern, subject.id) }
61
106
  end
62
107
 
@@ -87,12 +132,15 @@ module Saboteur
87
132
 
88
133
  # Resolves the subject's owner the way Ruby would resolve the nesting in
89
134
  # the file. const_get also triggers autoload (Zeitwerk) before any reopening.
135
+ # A `class << self` scope resolves the scopes after it (a factory
136
+ # constant assigned there binds on the singleton class, #352); when the
137
+ # subject's own scope is the singleton class, the owner is still the
138
+ # attached class.
90
139
  def resolve_owner(subject)
91
- scopes = [Object]
92
- subject.nesting.reject(&:sclass?).each do |scope|
93
- scopes << resolve_path(scope.path, scopes)
140
+ scopes = subject.nesting.each_with_object([Object]) do |scope, resolved|
141
+ resolved << (scope.sclass? ? resolved.last.singleton_class : resolve_path(scope.path, resolved))
94
142
  end
95
- owner = scopes.last
143
+ owner = subject.sclass ? scopes[-2] : scopes.last
96
144
  raise Error, "#{subject.owner} is not a class or module" unless owner.is_a?(Module)
97
145
 
98
146
  owner
@@ -139,28 +187,36 @@ module Saboteur
139
187
 
140
188
  # The file as it is on disk, with the method's bytes replaced by text,
141
189
  # evaluated at top level under the file's own name and first line.
142
- # Redefinition warnings are expected and silenced.
143
190
  def eval_whole_file(subject, text)
144
191
  source = subject.file_source.dup.force_encoding(Encoding::UTF_8)
145
192
  spliced = source.byteslice(0, subject.start_byte) + text + source.byteslice(subject.end_byte..)
146
- verbose = $VERBOSE
147
- $VERBOSE = nil
148
- TOPLEVEL_BINDING.eval(spliced, subject.file, 1)
149
- ensure
150
- $VERBOSE = verbose
193
+ silencing_warnings { TOPLEVEL_BINDING.eval(spliced, subject.file, 1) }
151
194
  end
152
195
 
153
196
  def eval_in_owner(owner, subject, text, extra_lines)
154
197
  code, line = with_magic_comment(subject, text, extra_lines)
155
198
  eval_target = subject.sclass ? owner.singleton_class : owner
156
- eval_target.class_eval(code, subject.file, line)
199
+ silencing_warnings { eval_target.class_eval(code, subject.file, line) }
157
200
  end
158
201
 
159
202
  def eval_nested(subject, text, extra_lines)
160
203
  headers = subject.nesting.map(&:header)
161
204
  wrapped = "#{headers.join('; ')}; #{text}; #{(['end'] * headers.size).join('; ')}"
162
205
  code, line = with_magic_comment(subject, wrapped, extra_lines)
163
- TOPLEVEL_BINDING.eval(code, subject.file, line)
206
+ silencing_warnings { TOPLEVEL_BINDING.eval(code, subject.file, line) }
207
+ end
208
+
209
+ # Re-evaluation redefines the method by design, and a "method
210
+ # redefined" warning is only ever emitted under $VERBOSE. Suites that
211
+ # raise on warnings (--warnings plus a raising Warning.warn or
212
+ # Warning.process handler, dry-rb/hanami-style) must not see it:
213
+ # before the guard every mutant errored as RuntimeError (#603).
214
+ def silencing_warnings
215
+ verbose = $VERBOSE
216
+ $VERBOSE = nil
217
+ yield
218
+ ensure
219
+ $VERBOSE = verbose
164
220
  end
165
221
 
166
222
  # The first line of code sits extra_lines above the def, so the def
@@ -144,6 +144,7 @@ module Saboteur
144
144
  @reevaluator.call(job.subject, job.text, prefix: job.eval_prefix)
145
145
  replaced = @reevaluator.replaced_definition(job.subject)
146
146
  Protocol.emit(writer, "replaced", site: replaced) if replaced
147
+ @installed_definition = job.mutation ? @reevaluator.installed_definition(job.subject) : nil
147
148
  end
148
149
 
149
150
  # Capture detection is best-effort: a subject shape it cannot scan
@@ -156,18 +157,40 @@ module Saboteur
156
157
 
157
158
  def emit_result(writer, job, has_captures)
158
159
  method = replacement_method(job) if has_captures && job.mutation
159
- called = method ? false : nil
160
- trace = TracePoint.new(:call) { called = true } if method
160
+ flags = { replacement_called: method ? false : nil, mutant_called: false }
161
+ target = method || (job.mutation ? @installed_definition : nil)
162
+ trace = call_probe(target, flags)
161
163
  started_at = Time.now
162
- result = if trace
163
- trace.enable(target: method) { @hooks.around(job.mutation) { run_tests(job.test_ids) } }
164
- else
165
- @hooks.around(job.mutation) { run_tests(job.test_ids) }
166
- end
164
+ suite = proc { @hooks.around(job.mutation) { run_tests(job.test_ids) } }
165
+ result = trace ? trace.enable(target: target, &suite) : suite.call
167
166
  if result.nil?
168
167
  Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
169
168
  else
169
+ called = flags[:replacement_called]
170
170
  Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called)
171
+ # A reload after the mutant already ran (an after-hook restore)
172
+ # is no reason to doubt the verdict; only a never-invoked mutant
173
+ # can have been hidden by the overwrite.
174
+ emit_overwritten(writer, job, flags[:mutant_called])
175
+ end
176
+ end
177
+
178
+ # A reload after the mutant already ran (an after-hook restore) is
179
+ # no reason to doubt the verdict; only a never-invoked mutant can
180
+ # have been hidden by the overwrite.
181
+ def emit_overwritten(writer, job, mutant_called)
182
+ overwritten = job.mutation && @installed_definition &&
183
+ @reevaluator.overwritten_site(job.subject, @installed_definition)
184
+ Protocol.emit(writer, "overwritten", site: overwritten) if overwritten && !mutant_called
185
+ end
186
+
187
+ # One call probe on the installed definition feeds both verdicts:
188
+ # replacement_called for stale captures, and overwrite detection for
189
+ # a mutant the suite replaced before any test ran it (#597).
190
+ def call_probe(target, flags)
191
+ target && TracePoint.new(:call) do
192
+ flags[:mutant_called] = true
193
+ flags[:replacement_called] = true unless flags[:replacement_called].nil?
171
194
  end
172
195
  end
173
196
 
@@ -392,6 +415,9 @@ module Saboteur
392
415
  captures = events.find { |event| event["event"] == "captures" }
393
416
  return unreachable(outcome, captures) if unobservable?(outcome, captures, job, result["replacement_called"])
394
417
 
418
+ overwritten = events.find { |event| event["event"] == "overwritten" }
419
+ return overwritten_by_later_load(outcome, overwritten) if replaced_while_running?(outcome, overwritten, job)
420
+
395
421
  outcome
396
422
  end
397
423
 
@@ -414,6 +440,19 @@ module Saboteur
414
440
  captures && job.mutation && outcome.status == :survived && replacement_called == false
415
441
  end
416
442
 
443
+ # A surviving mutant the tests themselves overwrote: a before(:each)
444
+ # re-loading the file replaced it before any test ran (rolify's suite
445
+ # shape, #597), so the verdict says nothing about the tests.
446
+ def replaced_while_running?(outcome, overwritten, job)
447
+ overwritten && job.mutation && outcome.status == :survived
448
+ end
449
+
450
+ def overwritten_by_later_load(outcome, overwritten)
451
+ Outcome.new(status: :unreachable, runtime: outcome.runtime,
452
+ message: "the tests (or their hooks) replaced the definition while they ran " \
453
+ "(#{overwritten['site']}), so this mutant never ran")
454
+ end
455
+
417
456
  # A survivor that no test could have observed: a Method object
418
457
  # captured at load still calls the original. Not a verdict on the
419
458
  # tests, so it is its own status, out of the score.
@@ -57,8 +57,15 @@ module Saboteur
57
57
  return @abi_version if defined?(@abi_version)
58
58
  return @abi_version = 0 unless linux?
59
59
 
60
- version = LibC.syscall_fn.call(SYS_CREATE_RULESET, 0, 0, CREATE_RULESET_VERSION, 0)
61
- errno = last_errno
60
+ # Build the errno reader BEFORE the probe: its lazy dlopen/dlsym run
61
+ # libc calls that can set errno, so building it between the probe and
62
+ # the read can replace the real answer with a stale 0.
63
+ LibC.errno_location_fn
64
+ version, errno = probe_once
65
+ # A failed syscall always sets errno, so (failure, errno 0) is never
66
+ # the kernel's real answer - the read was clobbered between the call
67
+ # and here. Probe once more, now with every Fiddle function warm.
68
+ version, errno = probe_once if !version.positive? && errno.zero?
62
69
  @abi_version = probe_abi(version, errno)
63
70
  rescue Fiddle::DLError
64
71
  # No usable libc (an unusual musl/ldso layout): the sandbox cannot
@@ -70,6 +77,12 @@ module Saboteur
70
77
  # no Landlock to give: run unsandboxed, noted once. Anything else
71
78
  # (EPERM under a seccomp policy, ...) means the kernel is there but
72
79
  # refused - fail closed rather than run without the promised sandbox.
80
+ # One create-ruleset version probe with its errno read paired to it.
81
+ def probe_once
82
+ version = LibC.syscall_fn.call(SYS_CREATE_RULESET, 0, 0, CREATE_RULESET_VERSION, 0)
83
+ [version, last_errno]
84
+ end
85
+
73
86
  def probe_abi(version, errno)
74
87
  return version if version.positive?
75
88
  return 0 if UNSUPPORTED_ERRNOS.include?(errno)
@@ -0,0 +1,226 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fiddle"
5
+
6
+ module Saboteur
7
+ module Execution
8
+ # The macOS counterpart of Sandbox (Landlock): a Seatbelt profile
9
+ # applied with sandbox_init inside the mutant child. sandbox-exec and
10
+ # sandbox_init are deprecated by Apple with no published replacement
11
+ # and no removal timeline (#436 tracks how Apple, Chrome, Codex,
12
+ # Claude Code, Bazel and Nix respond), so Seatbelt is opt-in and the
13
+ # probe below fails closed: a child that cannot be confined runs with
14
+ # the hazard guard and canary only, never silently "sandboxed".
15
+ #
16
+ # Like Sandbox, requiring this file is inert off macOS: libsandbox is
17
+ # dlopened lazily by the first probe, and the probe forks so the
18
+ # irreversible sandbox_init never touches the run's own process.
19
+ module Seatbelt
20
+ LIBSANDBOX = "/usr/lib/system/libsystem_sandbox.dylib"
21
+ LIBSYSTEM = "/usr/lib/libSystem.B.dylib"
22
+ F_GETPATH = 50
23
+ PATH_MAX = 1024
24
+
25
+ # The probe profile must deny the write it then attempts.
26
+ PROBE_PROFILE = "(version 1)(deny default)"
27
+
28
+ # sandbox_init error texts that say the facility is genuinely gone
29
+ # (a future macOS without Seatbelt): degrade, noted once. Anything
30
+ # else (a profile syntax tightening, EPERM, a lost child) fails
31
+ # closed: SandboxPolicy#seatbelt_hook refuses the run rather than
32
+ # confining nothing, exactly like the Landlock probe (#178) - never
33
+ # silently unsandboxed.
34
+ UNSUPPORTED_ERRORS = ["operation not supported", "function not implemented"].freeze
35
+
36
+ module_function
37
+
38
+ def darwin?
39
+ RUBY_PLATFORM.include?("darwin")
40
+ end
41
+
42
+ # One forked behavioral probe, memoized per process:
43
+ # :unsupported_platform off macOS, :ok when a deny-default profile
44
+ # really blocks a write, :unavailable when sandbox_init reports the
45
+ # facility missing, :unenforced when the API claims success but the
46
+ # write goes through, :error for anything else. Behavioral, not
47
+ # symbol presence: a macOS update that rejects the profile language
48
+ # shows up here, not in a dlopen.
49
+ def probe
50
+ return @probe if defined?(@probe)
51
+ return @probe = :unsupported_platform unless darwin?
52
+
53
+ @probe = run_probe
54
+ rescue Fiddle::DLError
55
+ @probe = :unavailable
56
+ end
57
+
58
+ def available?
59
+ probe == :ok
60
+ end
61
+
62
+ # Called at the top of a mutant child, mirroring Sandbox.restrict!:
63
+ # irreversible and fail-closed - a child whose profile does not apply
64
+ # must not run the mutant. keep_fds is Sandbox's hook contract
65
+ # (#234): Landlock gates opens, so its children keep writing through
66
+ # descriptors the parent opened (their logs); Seatbelt checks
67
+ # file-write* on EVERY write, so those inherited files join the
68
+ # profile as literal grants instead. Pipes and sockets among them
69
+ # have no path and need no grant.
70
+ def restrict!(writable:, keep_fds: [])
71
+ profile = Profile.build(writable: writable.map { |dir| resolved_grant(dir) },
72
+ inherited_files: fd_paths(keep_fds))
73
+ rc, message = sandbox_init(profile)
74
+ unless rc.zero?
75
+ raise Error, "the Seatbelt sandbox refused to engage (#{message}); the mutant does not run unconfined"
76
+ end
77
+
78
+ nil
79
+ end
80
+
81
+ # Seatbelt compares the kernel's resolved path, so a grant spelled
82
+ # through a symlink (macOS /var is /private/var) never matches and the
83
+ # "granted" write is denied. Resolve what exists; a dir not yet
84
+ # created grants nothing either way, so keep its spelling rather than
85
+ # fail the child on a dead grant. fcntl(F_GETPATH) already returns
86
+ # the resolved path for inherited files, so they need no such pass.
87
+ def resolved_grant(dir)
88
+ File.exist?(dir) ? File.realpath(dir) : dir
89
+ end
90
+
91
+ # fcntl(F_GETPATH) maps an inherited descriptor back to its path; a
92
+ # descriptor without one (pipe, socket) is skipped.
93
+ def fd_paths(keep_fds)
94
+ keep_fds.filter_map do |fd|
95
+ buffer = Fiddle::Pointer.malloc(PATH_MAX)
96
+ LibSystem.fcntl_fn.call(fd, F_GETPATH, buffer).negative? ? nil : buffer.to_s
97
+ end
98
+ end
99
+
100
+ def classify(verdict)
101
+ case verdict
102
+ when "ok" then :ok
103
+ when "unenforced" then :unenforced
104
+ when /\Ainit_failed:(?<message>.*)\z/m
105
+ message = Regexp.last_match[:message].downcase
106
+ UNSUPPORTED_ERRORS.any? { |text| message.include?(text) } ? :unavailable : :error
107
+ else
108
+ :error
109
+ end
110
+ end
111
+
112
+ def run_probe
113
+ reader, writer = IO.pipe
114
+ pid = fork { probe_child(writer) }
115
+ writer.close
116
+ verdict = reader.read
117
+ reader.close
118
+ Process.waitpid(pid)
119
+ classify(verdict)
120
+ ensure
121
+ reader.close unless reader.closed?
122
+ writer.close unless writer.closed?
123
+ end
124
+
125
+ # Child only: confine, then attempt the write the profile must block.
126
+ # The parent reads a single verdict word; exit! so no at_exit hook or
127
+ # finalizer runs under the sandbox.
128
+ def probe_child(writer)
129
+ rc, message = sandbox_init(PROBE_PROFILE)
130
+ verdict =
131
+ if rc.zero?
132
+ begin
133
+ File.open(File::NULL, "w") { true }
134
+ "unenforced"
135
+ rescue SystemCallError, IOError
136
+ "ok"
137
+ end
138
+ else
139
+ "init_failed:#{message}"
140
+ end
141
+ writer.write(verdict)
142
+ writer.close
143
+ exit!(0)
144
+ end
145
+
146
+ # int sandbox_init(const char *profile, uint64_t flags, char **errorbuf)
147
+ def sandbox_init(profile)
148
+ errbuf = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP)
149
+ rc = LibSandbox.sandbox_init_fn.call(profile, 0, errbuf)
150
+ return [0, nil] if rc.zero?
151
+
152
+ # errorbuf is a char**: dereference once to reach the message.
153
+ message = begin
154
+ address = errbuf[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
155
+ address&.positive? ? Fiddle::Pointer.new(address).to_s : "unknown"
156
+ rescue StandardError
157
+ "unknown"
158
+ end
159
+ [rc, "sandbox_init: #{message}"]
160
+ end
161
+
162
+ # fcntl for the F_GETPATH lookup; lazy like LibSandbox.
163
+ module LibSystem
164
+ module_function
165
+
166
+ def fcntl_fn
167
+ @fcntl_fn ||= Fiddle::Function.new(
168
+ Fiddle.dlopen(Seatbelt::LIBSYSTEM)["fcntl"],
169
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
170
+ Fiddle::TYPE_INT
171
+ )
172
+ end
173
+ end
174
+
175
+ # Lazy, so requiring the file on Linux or a stripped macOS stays inert
176
+ # (mirrors Sandbox::LibC, #178).
177
+ module LibSandbox
178
+ module_function
179
+
180
+ def handle
181
+ @handle ||= Fiddle.dlopen(Seatbelt::LIBSANDBOX)
182
+ end
183
+
184
+ def sandbox_init_fn
185
+ @sandbox_init_fn ||= Fiddle::Function.new(
186
+ handle["sandbox_init"],
187
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
188
+ Fiddle::TYPE_INT
189
+ )
190
+ end
191
+ end
192
+
193
+ # A Landlock-shaped Seatbelt profile: reads, execution, network and
194
+ # IPC stay allowed everywhere (allow default); only writes are
195
+ # denied, then re-allowed under the granted dirs and /dev/null -
196
+ # exactly the Sandbox grant semantics. allow-default is deliberate:
197
+ # deny-default profiles break on macOS updates whenever Apple adds a
198
+ # mach service the profile did not name, the failure mode behind the
199
+ # macOS 26 sandbox breakage reports (#436).
200
+ module Profile
201
+ HEADER = "(version 1)\n"
202
+
203
+ module_function
204
+
205
+ # inherited_files: already-open logs the child must keep writing
206
+ # (#234) - granted per file, never the cache dir that holds them.
207
+ def build(writable:, inherited_files: [])
208
+ lines = [HEADER, "(allow default)\n", "(deny file-write*)\n"]
209
+ writable.each do |dir|
210
+ lines << %((allow file-write* (subpath "#{escape(dir)}"))\n)
211
+ end
212
+ (inherited_files + [File::NULL]).uniq.each do |file|
213
+ lines << %((allow file-write* (literal "#{escape(file)}"))\n)
214
+ end
215
+ lines.join
216
+ end
217
+
218
+ # SBPL string literals are double-quoted with backslash escapes; a
219
+ # grant path must never inject profile source.
220
+ def escape(path)
221
+ path.gsub(/["\\]/) { |char| "\\#{char}" }
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end