# ©AngelaMos | 2026 # scanner_test.rb # frozen_string_literal: true require_relative "test_helper" require "tmpdir" module Marshalsea class ScannerTest < Minitest::Test SUPPRESSION_NAMESPACE = "Marshalsea::ScannerSuppressionFixture" MISSING_SOURCE_PATH = "/nonexistent/marshalsea-scanner-fixture.rb" Object.class_eval(<<~SOURCE, MISSING_SOURCE_PATH, 1) module Marshalsea module ScannerSuppressionFixture class VanishedSource def hash @seed.to_i end def to_s "vanished" end end end end SOURCE def scan(**) Scanner.new(**).scan end def with_exploding(*fixtures) fixtures.each { |fixture| fixture.explode = true } yield ensure fixtures.each { |fixture| fixture.explode = false } end def suppressions_at(report, site) report.suppressions.select { |suppression| suppression.site == site } end def local_scan scan(namespace: "Marshalsea::ScannerTest") end def candidates_for(class_name) local_scan.candidates.select { |c| c.class_name == class_name } end def test_finds_gated_sink_defined_on_a_class found = candidates_for("Marshalsea::ScannerTest::GatedFixture") assert_equal ["marshal_load"], found.map(&:method_name) assert_equal :gated, found.first.gate end def test_finds_singleton_load_as_gated_sink found = candidates_for("Marshalsea::ScannerTest::UserDefFixture") assert_includes found.map(&:method_name), "_load" end def test_finds_ungated_dispatch_method found = candidates_for("Marshalsea::ScannerTest::UngatedFixture") assert_equal ["hash"], found.map(&:method_name) assert_equal :ungated, found.first.gate end def test_finds_method_missing_as_ungated found = candidates_for("Marshalsea::ScannerTest::ProxyFixture") assert_includes found.map(&:method_name), "method_missing" end def test_negative_control_class_with_no_auto_invoked_methods_is_not_reported assert_empty candidates_for("Marshalsea::ScannerTest::InertFixture") end def test_precision_control_inherited_methods_are_not_reported assert_empty candidates_for("Marshalsea::ScannerTest::InheritsOnlyFixture") end def test_candidates_carry_source_location candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first refute_nil candidate.source_location assert_includes candidate.source_location, "scanner_test.rb" end def test_candidates_report_arity candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first assert_equal 1, candidate.arity end def test_ungated_methods_report_zero_arity candidate = candidates_for("Marshalsea::ScannerTest::UngatedFixture").first assert_predicate candidate, :zero_arity? end def test_reachability_via_instance_variable_read candidate = candidates_for("Marshalsea::ScannerTest::StatefulFixture").first assert_predicate candidate, :touches_state? assert_predicate candidate, :reachable? end def test_reachability_via_implicit_self_call candidate = candidates_for("Marshalsea::ScannerTest::AccessorFixture").first assert_predicate candidate, :touches_state?, "attr_reader access must count as touching state" assert_predicate candidate, :reachable? end def test_negative_control_stateless_method_is_not_reachable candidate = candidates_for("Marshalsea::ScannerTest::StatelessFixture").first refute_predicate candidate, :touches_state? refute_predicate candidate, :reachable? end def test_gated_sinks_are_reachable_regardless_of_state candidate = candidates_for("Marshalsea::ScannerTest::GatedFixture").first assert_predicate candidate, :reachable? end def test_reachable_is_a_strict_subset_of_candidates report = scan(namespace: "Gem") refute_empty report.reachable assert_operator report.reachable.length, :<, report.candidates.length, "reachability filter kept everything, so it is not filtering" end def test_rediscovers_accessor_backed_stdlib_sink report = scan(namespace: "Gem") names = report.reachable.map(&:to_s) assert_includes names, "Gem::Requirement#hash" end def test_rediscovers_a_real_stdlib_sink_without_hardcoding report = scan(namespace: "Gem") names = report.gated.map { |c| "#{c.class_name}##{c.method_name}" } assert_includes names, "Gem::Requirement#marshal_load" refute_includes Scanner::GATED_METHODS + Scanner::UNGATED_METHODS, "Gem::Requirement" end def test_namespace_filter_excludes_everything_else report = scan(namespace: "Marshalsea::ScannerTest") assert(report.candidates.all? { |c| c.class_name.start_with?("Marshalsea::ScannerTest") }) end def test_report_partitions_every_candidate_by_gate report = local_scan soft = report.candidates.select(&:soft_gated?) buckets = { gated: report.gated, ungated: report.ungated, links: report.links, soft: soft } buckets.except(:soft).each do |name, bucket| refute_empty bucket, "a partition test proves nothing if #{name} is empty" end assert_equal report.candidates.length, buckets.values.sum(&:length), "every gate value must land in exactly one bucket, or a candidate is invisible" buckets.values.combination(2) { |left, right| assert_empty(left & right) } end def test_scan_is_deterministic first = local_scan.candidates.map(&:to_s).sort second = local_scan.candidates.map(&:to_s).sort assert_equal first, second end def test_anonymous_classes_contribute_no_candidate anonymous = Class.new { def marshal_load(data); end } location = anonymous.instance_method(:marshal_load).source_location.join(":") report = scan refute_empty report.gated, "the global scan found nothing, so absence proves nothing" refute_includes report.candidates.map(&:source_location), location, "a class with no name reached the report" end def test_scanning_does_not_instantiate_anything refute GatedFixture.instantiated local_scan refute GatedFixture.instantiated, "scanner constructed a candidate class" end def test_a_clean_scan_reports_no_suppressions report = local_scan assert_empty report.suppressions assert_equal 0, report.suppressed_count assert_predicate report, :complete? refute_predicate report, :candidates_lost? end def test_a_lost_candidate_is_counted_and_names_the_class_it_came_from control = candidates_for("Marshalsea::ScannerTest::ExplodingHandleFixture") assert_equal ["marshal_load"], control.map(&:method_name), "control: this fixture must be discoverable when it is not exploding" with_exploding(ExplodingHandleFixture) do report = local_scan assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingHandleFixture") }) lost = suppressions_at(report, Scanner::SITE_CANDIDATE) assert_equal 1, lost.length assert_equal "Marshalsea::ScannerTest::ExplodingHandleFixture#marshal_load", lost.first.subject assert_predicate report, :candidates_lost? refute_predicate report, :complete? end end def test_a_suppressed_script_error_is_recorded_by_class with_exploding(ExplodingHandleFixture) do suppression = suppressions_at(local_scan, Scanner::SITE_CANDIDATE).first assert_equal "ScriptError", suppression.error_class, "record rescues ScriptError as well as StandardError, so it must report which" end end def test_an_unreadable_method_list_is_counted_as_a_lost_candidate with_exploding(ExplodingMethodListFixture) do report = local_scan assert_empty(report.candidates.select { |c| c.class_name.end_with?("ExplodingMethodListFixture") }) assert_equal 1, suppressions_at(report, Scanner::SITE_OWN_METHODS).length assert_predicate report, :candidates_lost? end end def test_a_module_that_cannot_report_its_name_is_counted with_exploding(ExplodingNameFixture) do named = suppressions_at(local_scan, Scanner::SITE_MODULE_NAME) refute_empty named assert_equal Scanner::SUBJECT_UNNAMED, named.first.subject end end def test_unparseable_source_is_counted_without_losing_the_candidate report = scan(namespace: SUPPRESSION_NAMESPACE) assert_equal ["#{SUPPRESSION_NAMESPACE}::VanishedSource#hash", "#{SUPPRESSION_NAMESPACE}::VanishedSource#to_s"], report.candidates.map(&:to_s), "control: the candidates must survive, only their state analysis failed" assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length refute_predicate report, :candidates_lost? refute_predicate report, :complete? end def test_a_candidate_whose_source_cannot_be_read_stays_reachable candidate = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first refute_predicate candidate, :state_known? refute_predicate candidate, :touches_state? assert_predicate candidate, :reachable?, "an unreadable source cannot prove a method inert, and a scanner that " \ "drops what it failed to analyse under-reports silently" end def test_a_c_defined_method_is_reported_as_unanalysable_not_as_inert candidate = scan(namespace: "Gem").candidates.find { |c| c.source_location.nil? } || scan.candidates.find { |c| c.source_location.nil? } refute_nil candidate, "control: the stdlib must supply at least one C-defined candidate" assert_predicate candidate, :unanalysable? refute_predicate candidate, :state_known? refute_predicate candidate, :touches_state?, "no Ruby source exists, so the answer is not false, it is unavailable" end def test_unanalysable_is_distinct_from_an_analysis_that_failed vanished = scan(namespace: SUPPRESSION_NAMESPACE).candidates.first c_defined = scan.candidates.find { |c| c.source_location.nil? } refute_predicate vanished, :unanalysable?, "a source that exists but could not be read is a failure, not an absence" assert_predicate c_defined, :unanalysable? refute_predicate vanished, :state_known? refute_predicate c_defined, :state_known? end def test_an_unanalysable_candidate_is_never_reachable_on_that_basis report = scan flooded = report.unanalysable.reject(&:gated?).select(&:reachable?) refute_empty report.unanalysable, "control: a stock image must have C-defined candidates" assert_empty flooded.first(5).map(&:to_s), "#{flooded.length} C-defined candidates were called reachable purely because " \ "they could not be analysed; that is a pass-through, not a filter" end def test_control_an_unreadable_candidate_is_reachable_on_exactly_that_basis unreadable = scan.candidates.select(&:unreadable_source?) .select(&:entry_point?).reject(&:gated?).reject(&:soft_gated?) .select(&:accepts_dispatch?) refute_empty unreadable, "control: without one of these the previous test is vacuous" assert(unreadable.all?(&:reachable?), "the two non-verdicts must behave differently, or splitting them bought nothing") end def test_the_report_counts_what_it_could_not_analyse report = scan assert_equal report.candidates.count(&:unanalysable?), report.unanalysable.length assert_operator report.unanalysable.length, :>, 0 refute_predicate report, :fully_analysed? end def test_a_report_over_analysable_code_only_is_fully_analysed report = local_scan assert_empty report.unanalysable assert_predicate report, :fully_analysed? end def test_an_analysed_candidate_reports_its_state_as_known %w[StatefulFixture StatelessFixture].each do |fixture| candidate = candidates_for("Marshalsea::ScannerTest::#{fixture}").first assert_predicate candidate, :state_known?, "control: a readable source must produce a verdict, or unknown means nothing" end end def test_one_unreadable_file_is_counted_once_not_once_per_candidate report = scan(namespace: SUPPRESSION_NAMESPACE) assert_operator report.candidates.length, :>, 1, "control: one candidate cannot expose per-candidate inflation" assert_equal 1, report.suppressed_count, "the parse cache must remember a failure, or the count inflates per candidate" end def test_suppressions_by_site_accounts_for_every_suppression with_exploding(ExplodingHandleFixture, ExplodingMethodListFixture, ExplodingNameFixture) do report = local_scan by_site = report.suppressions_by_site assert_equal report.suppressed_count, by_site.values.sum assert_equal 3, by_site.keys.length assert(by_site.keys.all? { |site| Scanner::SITES.include?(site) }) end end def test_a_gated_hook_that_cannot_accept_the_call_is_not_reachable wrong = candidates_for("Marshalsea::ScannerTest::WrongArityFixture").first right = candidates_for("Marshalsea::ScannerTest::GatedFixture").first assert_equal 0, wrong.arity refute_predicate wrong, :accepts_dispatch?, "Marshal.load calls marshal_load with one argument, so an arity-0 hook " \ "raises ArgumentError and the chain is dead" refute_predicate wrong, :reachable? assert_predicate right, :accepts_dispatch?, "control: the arity-1 hook must still qualify" assert_predicate right, :reachable? end def test_an_ungated_entry_point_that_takes_an_argument_is_still_reachable candidate = candidates_for("Marshalsea::ScannerTest::ComparableFixture").first assert_equal "<=>", candidate.method_name assert_equal 1, candidate.arity refute_predicate candidate, :zero_arity? assert_predicate candidate, :reachable?, "Range#marshal_load supplies the argument, so arity 1 is what <=> must " \ "have, not a reason to drop it" end def test_a_link_method_is_recorded_and_never_called_an_entry_point candidate = candidates_for("Marshalsea::ScannerTest::LinkFixture").first assert_equal "to_s", candidate.method_name assert_predicate candidate, :link? refute_predicate candidate, :entry_point? refute_predicate candidate, :reachable?, "research 02 4.2 verified Marshal.load never invokes to_s directly, so " \ "reporting it as reachable is a false positive" assert_includes local_scan.links.map(&:to_s), "Marshalsea::ScannerTest::LinkFixture#to_s", "a link is how a chain continues and must not be discarded either" end def test_a_private_singleton_load_is_discovered found = candidates_for("Marshalsea::ScannerTest::PrivateLoadFixture") assert_equal ["_load"], found.map(&:method_name), "Marshal.load reaches _load through rb_funcallv, which ignores visibility" assert_predicate found.first, :gated? assert_predicate found.first, :reachable? end def test_control_a_public_singleton_load_is_still_discovered assert_includes candidates_for("Marshalsea::ScannerTest::UserDefFixture").map(&:method_name), "_load" end BROKEN_SOURCE_PATH = File.join(Dir.tmpdir, "marshalsea-broken-fixture.rb") File.write(BROKEN_SOURCE_PATH, "class Unterminated\n def hash\n \"open\n") Object.class_eval(<<~SOURCE, BROKEN_SOURCE_PATH, 2) module Marshalsea module ScannerRecoveredFixture class Recovered def hash @seed.to_i end end end end SOURCE def test_a_recovered_prism_parse_is_a_suppression_not_a_verdict report = scan(namespace: "Marshalsea::ScannerRecoveredFixture") candidate = report.candidates.first assert_equal 1, suppressions_at(report, Scanner::SITE_SOURCE_PARSE).length, "Prism is error tolerant, so a file it could not parse must be counted" refute_predicate candidate, :state_known?, "a tree recovered from syntax errors cannot support a true or false verdict" assert_predicate candidate, :unreadable_source? assert_predicate candidate, :reachable?, "an unreadable source must fail open" end def test_control_prism_really_does_recover_a_definition_from_that_file parsed = Prism.parse_file(BROKEN_SOURCE_PATH) assert_predicate parsed, :failure? refute_empty parsed.errors refute_nil parsed.value, "control: if Prism returned nothing there would be no wrong verdict to prevent" end class WrongArityFixture def marshal_load; end end class ComparableFixture def <=>(other) @seed <=> other end end class LinkFixture def to_s @seed.to_s end end class PrivateLoadFixture def self._load(_data) allocate end private_class_method :_load end class ExplodingNameFixture @explode = false class << self attr_accessor :explode def name raise NameError, "name unavailable" if @explode super end end end class ExplodingMethodListFixture @explode = false class << self attr_accessor :explode def instance_methods(include_super = true) raise NoMethodError, "method list unavailable" if @explode super end end def marshal_load(data); end end class ExplodingHandleFixture @explode = false class << self attr_accessor :explode def instance_method(name) raise ScriptError, "handle unavailable" if @explode super end end def marshal_load(data); end end class GatedFixture @instantiated = false class << self attr_accessor :instantiated end def marshal_load(data); end end class UserDefFixture def self._load(_data) allocate end end class UngatedFixture def hash super end end class ProxyFixture def method_missing(name, *args) super end def respond_to_missing?(name, include_private = false) super end end class StatefulFixture def hash @seed.to_i end end class AccessorFixture attr_reader :seed def hash seed.to_i end end class StatelessFixture def hash 42 end end class InertFixture def ordinary_method; end def another_one(argument); end end class InheritsOnlyFixture end end end