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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +166 -0
  4. data/lib/rspec/hermetic/allowlist.rb +75 -0
  5. data/lib/rspec/hermetic/candidate_report.rb +47 -0
  6. data/lib/rspec/hermetic/change.rb +61 -0
  7. data/lib/rspec/hermetic/configuration.rb +93 -0
  8. data/lib/rspec/hermetic/corpus_evaluation.rb +124 -0
  9. data/lib/rspec/hermetic/diff.rb +63 -0
  10. data/lib/rspec/hermetic/evaluation.rb +184 -0
  11. data/lib/rspec/hermetic/evaluation_task.rb +53 -0
  12. data/lib/rspec/hermetic/forensic.rb +22 -0
  13. data/lib/rspec/hermetic/formatter.rb +93 -0
  14. data/lib/rspec/hermetic/minitest.rb +80 -0
  15. data/lib/rspec/hermetic/probe/base.rb +17 -0
  16. data/lib/rspec/hermetic/probe/constants.rb +87 -0
  17. data/lib/rspec/hermetic/probe/env.rb +15 -0
  18. data/lib/rspec/hermetic/probe/filesystem.rb +109 -0
  19. data/lib/rspec/hermetic/probe/globals.rb +31 -0
  20. data/lib/rspec/hermetic/probe/rails.rb +146 -0
  21. data/lib/rspec/hermetic/probe/randomness.rb +78 -0
  22. data/lib/rspec/hermetic/probe/resources.rb +110 -0
  23. data/lib/rspec/hermetic/probe/ruby_runtime.rb +37 -0
  24. data/lib/rspec/hermetic/probe/time.rb +54 -0
  25. data/lib/rspec/hermetic/probe.rb +38 -0
  26. data/lib/rspec/hermetic/resource_tracker.rb +111 -0
  27. data/lib/rspec/hermetic/restorer.rb +330 -0
  28. data/lib/rspec/hermetic/runner.rb +183 -0
  29. data/lib/rspec/hermetic/snapshot.rb +37 -0
  30. data/lib/rspec/hermetic/stable_value.rb +140 -0
  31. data/lib/rspec/hermetic/verdict.rb +39 -0
  32. data/lib/rspec/hermetic/verify_task.rb +65 -0
  33. data/lib/rspec/hermetic/version.rb +11 -0
  34. data/lib/rspec/hermetic.rb +59 -0
  35. data/sig/rspec/hermetic.rbs +112 -0
  36. 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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RSpec
6
+ module Hermetic
7
+ module Probe
8
+ class Env < Base
9
+ def capture(_context)
10
+ ENV.to_h
11
+ end
12
+ end
13
+ end
14
+ end
15
+ 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