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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- 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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|