rspec-hermetic 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +166 -0
- data/lib/rspec/hermetic/allowlist.rb +75 -0
- data/lib/rspec/hermetic/candidate_report.rb +47 -0
- data/lib/rspec/hermetic/change.rb +61 -0
- data/lib/rspec/hermetic/configuration.rb +93 -0
- data/lib/rspec/hermetic/corpus_evaluation.rb +124 -0
- data/lib/rspec/hermetic/diff.rb +63 -0
- data/lib/rspec/hermetic/evaluation.rb +184 -0
- data/lib/rspec/hermetic/evaluation_task.rb +53 -0
- data/lib/rspec/hermetic/forensic.rb +22 -0
- data/lib/rspec/hermetic/formatter.rb +93 -0
- data/lib/rspec/hermetic/minitest.rb +80 -0
- data/lib/rspec/hermetic/probe/base.rb +17 -0
- data/lib/rspec/hermetic/probe/constants.rb +87 -0
- data/lib/rspec/hermetic/probe/env.rb +15 -0
- data/lib/rspec/hermetic/probe/filesystem.rb +109 -0
- data/lib/rspec/hermetic/probe/globals.rb +31 -0
- data/lib/rspec/hermetic/probe/rails.rb +146 -0
- data/lib/rspec/hermetic/probe/randomness.rb +78 -0
- data/lib/rspec/hermetic/probe/resources.rb +110 -0
- data/lib/rspec/hermetic/probe/ruby_runtime.rb +37 -0
- data/lib/rspec/hermetic/probe/time.rb +54 -0
- data/lib/rspec/hermetic/probe.rb +38 -0
- data/lib/rspec/hermetic/resource_tracker.rb +111 -0
- data/lib/rspec/hermetic/restorer.rb +330 -0
- data/lib/rspec/hermetic/runner.rb +183 -0
- data/lib/rspec/hermetic/snapshot.rb +37 -0
- data/lib/rspec/hermetic/stable_value.rb +140 -0
- data/lib/rspec/hermetic/verdict.rb +39 -0
- data/lib/rspec/hermetic/verify_task.rb +65 -0
- data/lib/rspec/hermetic/version.rb +11 -0
- data/lib/rspec/hermetic.rb +59 -0
- data/sig/rspec/hermetic.rbs +112 -0
- metadata +117 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "../hermetic"
|
|
9
|
+
|
|
10
|
+
module RSpec
|
|
11
|
+
module Hermetic
|
|
12
|
+
class Evaluation
|
|
13
|
+
Example = Struct.new(:metadata, :full_description, :location, :block, keyword_init: true) do
|
|
14
|
+
attr_reader :exception
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
block.call
|
|
18
|
+
rescue StandardError => error
|
|
19
|
+
@exception = error
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reporter
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def message(_text); end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(output_path: "tmp/rspec_hermetic_evaluation.json")
|
|
31
|
+
@output_path = output_path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run
|
|
35
|
+
report = {
|
|
36
|
+
"seeded_pollution" => seeded_pollution_results,
|
|
37
|
+
"generated_at" => Time.now.utc.iso8601
|
|
38
|
+
}
|
|
39
|
+
write(report)
|
|
40
|
+
report
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def seeded_pollution_results
|
|
46
|
+
cases = [
|
|
47
|
+
seeded_case(:env) { ENV["RSPEC_HERMETIC_EVAL"] = "dirty" },
|
|
48
|
+
seeded_case(
|
|
49
|
+
:constants,
|
|
50
|
+
setup: method(:setup_constant_case),
|
|
51
|
+
cleanup: method(:cleanup_constant_case),
|
|
52
|
+
configure: proc { |config| config.constant_namespaces = [HermeticEvaluationState] }
|
|
53
|
+
) do
|
|
54
|
+
HermeticEvaluationState::STORE << :dirty
|
|
55
|
+
end,
|
|
56
|
+
seeded_case(:globals, cleanup: method(:cleanup_global_case)) { $LOAD_PATH << "/tmp/rspec-hermetic-eval" },
|
|
57
|
+
seeded_case(:ruby_runtime, cleanup: method(:cleanup_ruby_runtime_case)) do
|
|
58
|
+
Thread.abort_on_exception = !Thread.abort_on_exception
|
|
59
|
+
end,
|
|
60
|
+
seeded_case(:rails, setup: method(:setup_rails_case), cleanup: method(:cleanup_rails_case)) do
|
|
61
|
+
I18n.locale = :ja
|
|
62
|
+
end,
|
|
63
|
+
seeded_case(:time, cleanup: method(:cleanup_time_case)) { setup_time_case },
|
|
64
|
+
seeded_case(:randomness, configure: proc { |config| config.randomness_seed_probe = true }) { Kernel.srand(12_345) },
|
|
65
|
+
seeded_case(:resources, cleanup: method(:cleanup_resource_case)) do
|
|
66
|
+
@evaluation_thread = Thread.new { sleep 5 }
|
|
67
|
+
sleep 0.05
|
|
68
|
+
end,
|
|
69
|
+
seeded_filesystem_case
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
total = cases.length
|
|
73
|
+
detected = cases.count { |result| result["detected"] }
|
|
74
|
+
{
|
|
75
|
+
"total" => total,
|
|
76
|
+
"detected" => detected,
|
|
77
|
+
"recall" => total.zero? ? 0.0 : detected.fdiv(total),
|
|
78
|
+
"cases" => cases
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def seeded_case(probe, setup: nil, cleanup: nil, configure: nil, &block)
|
|
83
|
+
setup&.call
|
|
84
|
+
config = Configuration.new
|
|
85
|
+
config.probes = [probe]
|
|
86
|
+
config.on_pollution = :report
|
|
87
|
+
configure&.call(config)
|
|
88
|
+
runner = Runner.new(config)
|
|
89
|
+
example = Example.new(
|
|
90
|
+
metadata: {},
|
|
91
|
+
full_description: "seeded #{probe} pollution",
|
|
92
|
+
location: "evaluation/#{probe}",
|
|
93
|
+
block: block
|
|
94
|
+
)
|
|
95
|
+
runner.call(example, nil)
|
|
96
|
+
result_for(probe, runner)
|
|
97
|
+
ensure
|
|
98
|
+
cleanup&.call
|
|
99
|
+
ENV.delete("RSPEC_HERMETIC_EVAL") if probe == :env
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def seeded_filesystem_case
|
|
103
|
+
Dir.mktmpdir do |dir|
|
|
104
|
+
seeded_case(
|
|
105
|
+
:filesystem,
|
|
106
|
+
configure: proc do |config|
|
|
107
|
+
config.root_path = dir
|
|
108
|
+
config.filesystem_paths = ["."]
|
|
109
|
+
end
|
|
110
|
+
) { File.write(File.join(dir, "dirty.txt"), "dirty") }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def result_for(probe, runner)
|
|
115
|
+
pollutions = runner.records.sum { |record| record[:verdict].pollutions.count }
|
|
116
|
+
warnings = runner.records.sum { |record| record[:verdict].warnings.count }
|
|
117
|
+
{
|
|
118
|
+
"probe" => probe.to_s,
|
|
119
|
+
"detected" => pollutions.positive? || warnings.positive?,
|
|
120
|
+
"pollutions" => pollutions,
|
|
121
|
+
"warnings" => warnings
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def setup_constant_case
|
|
126
|
+
Object.const_set(:HermeticEvaluationState, Module.new)
|
|
127
|
+
HermeticEvaluationState.const_set(:STORE, [])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cleanup_constant_case
|
|
131
|
+
Object.__send__(:remove_const, :HermeticEvaluationState) if Object.const_defined?(:HermeticEvaluationState)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def cleanup_global_case
|
|
135
|
+
$LOAD_PATH.delete("/tmp/rspec-hermetic-eval")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cleanup_ruby_runtime_case
|
|
139
|
+
Thread.abort_on_exception = false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def setup_rails_case
|
|
143
|
+
@previous_i18n = Object.const_get(:I18n) if Object.const_defined?(:I18n)
|
|
144
|
+
Object.__send__(:remove_const, :I18n) if Object.const_defined?(:I18n)
|
|
145
|
+
i18n = Class.new do
|
|
146
|
+
class << self
|
|
147
|
+
attr_accessor :locale, :default_locale
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
i18n.locale = :en
|
|
151
|
+
i18n.default_locale = :en
|
|
152
|
+
Object.const_set(:I18n, i18n)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def cleanup_rails_case
|
|
156
|
+
Object.__send__(:remove_const, :I18n) if Object.const_defined?(:I18n)
|
|
157
|
+
Object.const_set(:I18n, @previous_i18n) if @previous_i18n
|
|
158
|
+
@previous_i18n = nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def setup_time_case
|
|
162
|
+
@previous_time_now = Time.method(:now)
|
|
163
|
+
previous = @previous_time_now
|
|
164
|
+
Time.define_singleton_method(:now) { previous.call + 3600 }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def cleanup_time_case
|
|
168
|
+
Time.define_singleton_method(:now, @previous_time_now) if @previous_time_now
|
|
169
|
+
@previous_time_now = nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def cleanup_resource_case
|
|
173
|
+
@evaluation_thread&.kill
|
|
174
|
+
@evaluation_thread&.join
|
|
175
|
+
@evaluation_thread = nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def write(report)
|
|
179
|
+
FileUtils.mkdir_p(File.dirname(@output_path))
|
|
180
|
+
File.write(@output_path, "#{JSON.pretty_generate(report)}\n")
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
|
|
5
|
+
require_relative "corpus_evaluation"
|
|
6
|
+
require_relative "evaluation"
|
|
7
|
+
|
|
8
|
+
module RSpec
|
|
9
|
+
module Hermetic
|
|
10
|
+
class EvaluationTask
|
|
11
|
+
extend ::Rake::DSL
|
|
12
|
+
|
|
13
|
+
DEFAULT_OUTPUT = "tmp/rspec_hermetic_evaluation.json"
|
|
14
|
+
|
|
15
|
+
def self.install(name = "hermetic:evaluate")
|
|
16
|
+
namespace_name, short_name = name.to_s.split(":", 2)
|
|
17
|
+
|
|
18
|
+
namespace namespace_name do
|
|
19
|
+
desc "Run rspec-hermetic seeded pollution evaluation and write a JSON recall report"
|
|
20
|
+
task short_name, [:output] do |_task, args|
|
|
21
|
+
output = args[:output] || ENV["HERMETIC_EVALUATION_OUTPUT"] || DEFAULT_OUTPUT
|
|
22
|
+
report = Evaluation.new(output_path: output).run
|
|
23
|
+
seeded = report.fetch("seeded_pollution")
|
|
24
|
+
puts "seeded recall #{seeded.fetch("detected")}/#{seeded.fetch("total")} (#{seeded.fetch("recall")})"
|
|
25
|
+
puts "wrote #{output}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.install_corpus(name = "hermetic:evaluate_corpus")
|
|
31
|
+
namespace_name, short_name = name.to_s.split(":", 2)
|
|
32
|
+
|
|
33
|
+
namespace namespace_name do
|
|
34
|
+
desc "Run corpus evaluation using HERMETIC_COMMAND and optional HERMETIC_BASELINE_COMMAND"
|
|
35
|
+
task short_name, [:output] do |_task, args|
|
|
36
|
+
command = ENV.fetch("HERMETIC_COMMAND")
|
|
37
|
+
output = args[:output] || ENV["HERMETIC_CORPUS_OUTPUT"] || "tmp/rspec_hermetic_corpus.json"
|
|
38
|
+
report = CorpusEvaluation.new(
|
|
39
|
+
output_path: output,
|
|
40
|
+
project_path: ENV["HERMETIC_PROJECT"] || Dir.pwd,
|
|
41
|
+
baseline_command: ENV["HERMETIC_BASELINE_COMMAND"],
|
|
42
|
+
hermetic_command: command,
|
|
43
|
+
candidate_report_path: ENV["HERMETIC_CANDIDATES"] || "tmp/rspec_hermetic_candidates.json",
|
|
44
|
+
judgments_path: ENV["HERMETIC_JUDGMENTS"]
|
|
45
|
+
).run
|
|
46
|
+
puts "corpus candidates #{report.fetch("candidate_count")}"
|
|
47
|
+
puts "wrote #{output}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../hermetic"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
module Forensic
|
|
8
|
+
def self.install!(rspec_config)
|
|
9
|
+
Hermetic.configure(rspec_config) do |configuration|
|
|
10
|
+
configuration.forensic = true
|
|
11
|
+
configuration.on_pollution = :report if configuration.on_pollution == :risky
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if defined?(::RSpec) && ::RSpec.respond_to?(:configure)
|
|
19
|
+
::RSpec.configure do |config|
|
|
20
|
+
::RSpec::Hermetic::Forensic.install!(config)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "change"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Hermetic
|
|
7
|
+
class Formatter
|
|
8
|
+
def self.pollution_message(example, result, change_set)
|
|
9
|
+
new.pollution_message(example, result, change_set)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.forensic_message(example, changes, last_writers)
|
|
13
|
+
new.forensic_message(example, changes, last_writers)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.probe_error_message(example, change_set)
|
|
17
|
+
new.probe_error_message(example, change_set)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pollution_message(example, result, change_set)
|
|
21
|
+
lines = ["POLLUTER #{example_location(example)}"]
|
|
22
|
+
result.pollutions.each { |change| lines << " #{format_change(change)}" }
|
|
23
|
+
result.warnings.each { |change| lines << " #{format_change(change)} (append-only warning)" }
|
|
24
|
+
append_probe_errors(lines, change_set)
|
|
25
|
+
append_timings(lines, change_set)
|
|
26
|
+
lines << " hint: this example left shared state changed for later examples"
|
|
27
|
+
lines.join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def forensic_message(example, changes, last_writers)
|
|
31
|
+
lines = ["VICTIM #{example_location(example)} started with dirty shared state"]
|
|
32
|
+
changes.each do |change|
|
|
33
|
+
writer = last_writers[[change.probe, change.key]]
|
|
34
|
+
suffix = writer ? " (last changed by #{writer})" : ""
|
|
35
|
+
lines << " #{format_change(change)}#{suffix}"
|
|
36
|
+
end
|
|
37
|
+
lines.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def probe_error_message(example, change_set)
|
|
41
|
+
lines = ["HERMETIC PROBE ERROR #{example_location(example)}"]
|
|
42
|
+
append_probe_errors(lines, change_set)
|
|
43
|
+
append_timings(lines, change_set)
|
|
44
|
+
lines.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def example_location(example)
|
|
50
|
+
location = example.respond_to?(:location) ? example.location : nil
|
|
51
|
+
description = example.respond_to?(:full_description) ? example.full_description : nil
|
|
52
|
+
[location, description].compact.join(" ")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_change(change)
|
|
56
|
+
"#{display_key(change)} #{display_value(change.before)} -> #{display_value(change.after)} (probe: #{change.probe})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def display_key(change)
|
|
60
|
+
case change.probe
|
|
61
|
+
when :env
|
|
62
|
+
"ENV[#{change.key.inspect}]"
|
|
63
|
+
else
|
|
64
|
+
change.key.to_s
|
|
65
|
+
end.ljust(28)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def display_value(value)
|
|
69
|
+
return "<missing>" if value.equal?(Change::MISSING)
|
|
70
|
+
|
|
71
|
+
value.inspect
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def append_probe_errors(lines, change_set)
|
|
75
|
+
change_set.before_errors.each do |probe, error|
|
|
76
|
+
lines << " before probe error #{probe}: #{error}"
|
|
77
|
+
end
|
|
78
|
+
change_set.after_errors.each do |probe, error|
|
|
79
|
+
lines << " after probe error #{probe}: #{error}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def append_timings(lines, change_set)
|
|
84
|
+
return if change_set.timings.empty?
|
|
85
|
+
|
|
86
|
+
timing = change_set.timings.map do |probe, seconds|
|
|
87
|
+
"#{probe}=#{(seconds * 1000).round(2)}ms"
|
|
88
|
+
end.join(", ")
|
|
89
|
+
lines << " probe timings: #{timing}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
gem "minitest"
|
|
5
|
+
rescue Gem::LoadError
|
|
6
|
+
nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require "minitest"
|
|
10
|
+
|
|
11
|
+
require_relative "../hermetic"
|
|
12
|
+
|
|
13
|
+
module RSpec
|
|
14
|
+
module Hermetic
|
|
15
|
+
module Minitest
|
|
16
|
+
class Example
|
|
17
|
+
attr_reader :metadata, :full_description, :location, :exception
|
|
18
|
+
|
|
19
|
+
def initialize(test, block)
|
|
20
|
+
@test = test
|
|
21
|
+
@block = block
|
|
22
|
+
@metadata = {}
|
|
23
|
+
@full_description = "#{test.class}##{test.name}"
|
|
24
|
+
@location = source_location(test)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
result = @block.call
|
|
29
|
+
@exception = result.failures.first if result.respond_to?(:failures) && result.failures.any?
|
|
30
|
+
result
|
|
31
|
+
rescue StandardError => error
|
|
32
|
+
@exception = error
|
|
33
|
+
raise
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reporter
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def source_location(test)
|
|
43
|
+
location = test.method(test.name).source_location
|
|
44
|
+
location ? location.join(":") : full_description
|
|
45
|
+
rescue NameError
|
|
46
|
+
full_description
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module TestMethods
|
|
51
|
+
def run
|
|
52
|
+
result = nil
|
|
53
|
+
example = Example.new(self, proc { result = super() })
|
|
54
|
+
RSpec::Hermetic.minitest_runner.call(example, self)
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.install!
|
|
60
|
+
return if @installed
|
|
61
|
+
|
|
62
|
+
::Minitest::Test.prepend(TestMethods)
|
|
63
|
+
@installed = true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
attr_reader :minitest_runner
|
|
69
|
+
|
|
70
|
+
def configure_minitest
|
|
71
|
+
yield configuration if block_given?
|
|
72
|
+
@minitest_runner = Runner.new(configuration)
|
|
73
|
+
Minitest.install!
|
|
74
|
+
configuration
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
RSpec::Hermetic.configure_minitest unless ENV["RSPEC_HERMETIC_MINITEST_AUTO_INSTALL"] == "false"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hermetic
|
|
5
|
+
module Probe
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def name
|
|
12
|
+
self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, "\\1_\\2").downcase.to_sym
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../stable_value"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hermetic
|
|
8
|
+
module Probe
|
|
9
|
+
class Constants < Base
|
|
10
|
+
def capture(_context)
|
|
11
|
+
roots = constant_roots
|
|
12
|
+
@seen = {}
|
|
13
|
+
@entry_count = 0
|
|
14
|
+
roots.each_with_object({}) do |(mod, prefix), values|
|
|
15
|
+
capture_module(values, mod, prefix, @configuration.constants_max_depth)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def constant_roots
|
|
22
|
+
namespaces = Array(@configuration.constant_namespaces)
|
|
23
|
+
return [[Object, nil]] if namespaces.empty?
|
|
24
|
+
|
|
25
|
+
namespaces.filter_map { |namespace| resolve_namespace(namespace) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_namespace(namespace)
|
|
29
|
+
case namespace
|
|
30
|
+
when Module
|
|
31
|
+
[namespace, namespace.name]
|
|
32
|
+
else
|
|
33
|
+
mod = namespace.to_s.split("::").reject(&:empty?).inject(Object) do |parent, name|
|
|
34
|
+
parent.const_get(name, false)
|
|
35
|
+
end
|
|
36
|
+
[mod, namespace.to_s]
|
|
37
|
+
end
|
|
38
|
+
rescue NameError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def capture_module(values, mod, prefix, depth)
|
|
43
|
+
return if @seen[mod.object_id]
|
|
44
|
+
|
|
45
|
+
@seen[mod.object_id] = true
|
|
46
|
+
mod.constants(false).each do |const_name|
|
|
47
|
+
full_name = [prefix, const_name].compact.join("::")
|
|
48
|
+
next if excluded?(full_name)
|
|
49
|
+
|
|
50
|
+
increment_entry_count!
|
|
51
|
+
value = mod.const_get(const_name, false)
|
|
52
|
+
values[full_name] = capture_value(value)
|
|
53
|
+
capture_module(values, value, full_name, depth - 1) if depth.positive? && value.is_a?(Module)
|
|
54
|
+
rescue NameError, StandardError
|
|
55
|
+
values[full_name] = :unreadable
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def excluded?(full_name)
|
|
60
|
+
@configuration.constant_exclude_patterns.any? do |pattern|
|
|
61
|
+
pattern.is_a?(Regexp) ? pattern.match?(full_name) : File.fnmatch?(pattern.to_s, full_name)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def increment_entry_count!
|
|
66
|
+
@entry_count += 1
|
|
67
|
+
return if @entry_count <= @configuration.constants_max_entries
|
|
68
|
+
|
|
69
|
+
raise Error, "constants probe exceeded #{@configuration.constants_max_entries} entries"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def capture_value(value)
|
|
73
|
+
return StableValue.capture(value) unless @configuration.constants_graph
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
value: StableValue.capture(value),
|
|
77
|
+
reachable_graph: StableValue.reachable_graph_fingerprint(
|
|
78
|
+
value,
|
|
79
|
+
max_depth: @configuration.constants_graph_max_depth,
|
|
80
|
+
max_nodes: @configuration.constants_graph_max_nodes
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "find"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
require_relative "base"
|
|
8
|
+
|
|
9
|
+
module RSpec
|
|
10
|
+
module Hermetic
|
|
11
|
+
module Probe
|
|
12
|
+
class Filesystem < Base
|
|
13
|
+
def capture(_context)
|
|
14
|
+
roots.each_with_object({}) do |root, values|
|
|
15
|
+
scan_root(Pathname(root), values)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def roots
|
|
22
|
+
Array(@configuration.filesystem_paths).map do |path|
|
|
23
|
+
Pathname(path).absolute? ? path : File.join(@configuration.root_path, path)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def scan_root(root, values)
|
|
28
|
+
return unless root.exist?
|
|
29
|
+
|
|
30
|
+
count = 0
|
|
31
|
+
Find.find(root.to_s) do |path|
|
|
32
|
+
pathname = Pathname(path)
|
|
33
|
+
if excluded?(pathname)
|
|
34
|
+
Find.prune if pathname.directory?
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
rel = pathname.relative_path_from(Pathname(@configuration.root_path)).to_s
|
|
39
|
+
if too_deep?(rel)
|
|
40
|
+
Find.prune if pathname.directory?
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
values[rel] = file_entry(pathname)
|
|
45
|
+
count += 1
|
|
46
|
+
raise Error, "filesystem probe exceeded #{@configuration.filesystem_max_entries} entries" if count > @configuration.filesystem_max_entries
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def excluded?(pathname)
|
|
51
|
+
rel = pathname.relative_path_from(Pathname(@configuration.root_path)).to_s
|
|
52
|
+
@configuration.filesystem_exclude_patterns.any? do |pattern|
|
|
53
|
+
File.fnmatch?(pattern.to_s, rel, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
54
|
+
end
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def too_deep?(relative_path)
|
|
60
|
+
return false unless @configuration.filesystem_max_depth
|
|
61
|
+
return false if relative_path == "."
|
|
62
|
+
|
|
63
|
+
relative_path.split(File::SEPARATOR).length > @configuration.filesystem_max_depth
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def file_entry(pathname)
|
|
67
|
+
stat = pathname.lstat
|
|
68
|
+
{
|
|
69
|
+
type: file_type(stat),
|
|
70
|
+
size: stat.file? ? stat.size : nil,
|
|
71
|
+
mtime_nsec: stat.mtime.nsec,
|
|
72
|
+
mtime: stat.mtime.to_i,
|
|
73
|
+
target: stat.symlink? ? pathname.readlink.to_s : nil,
|
|
74
|
+
sha256: content_hash(pathname, stat),
|
|
75
|
+
content: content_snapshot(pathname, stat)
|
|
76
|
+
}.compact
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def file_type(stat)
|
|
80
|
+
return :file if stat.file?
|
|
81
|
+
return :directory if stat.directory?
|
|
82
|
+
return :symlink if stat.symlink?
|
|
83
|
+
|
|
84
|
+
:other
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def content_hash(pathname, stat)
|
|
88
|
+
return nil unless stat.file?
|
|
89
|
+
return nil if @configuration.filesystem_content_hash_bytes.to_i <= 0
|
|
90
|
+
return nil if stat.size > @configuration.filesystem_content_hash_bytes
|
|
91
|
+
|
|
92
|
+
Digest::SHA256.file(pathname.to_s).hexdigest
|
|
93
|
+
rescue StandardError => error
|
|
94
|
+
"#{error.class}: #{error.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def content_snapshot(pathname, stat)
|
|
98
|
+
return nil unless stat.file?
|
|
99
|
+
return nil if @configuration.filesystem_content_hash_bytes.to_i <= 0
|
|
100
|
+
return nil if stat.size > @configuration.filesystem_content_hash_bytes
|
|
101
|
+
|
|
102
|
+
File.binread(pathname.to_s)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../stable_value"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module RSpec
|
|
7
|
+
module Hermetic
|
|
8
|
+
module Probe
|
|
9
|
+
class Globals < Base
|
|
10
|
+
WATCHED_GLOBALS = %i[
|
|
11
|
+
$LOAD_PATH
|
|
12
|
+
$LOADED_FEATURES
|
|
13
|
+
$PROGRAM_NAME
|
|
14
|
+
$VERBOSE
|
|
15
|
+
$DEBUG
|
|
16
|
+
$stdin
|
|
17
|
+
$stdout
|
|
18
|
+
$stderr
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def capture(_context)
|
|
22
|
+
WATCHED_GLOBALS.each_with_object({}) do |name, values|
|
|
23
|
+
values[name.to_s] = StableValue.capture(eval(name.to_s, TOPLEVEL_BINDING))
|
|
24
|
+
rescue StandardError => error
|
|
25
|
+
values[name.to_s] = "#{error.class}: #{error.message}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|