87 lines
3.3 KiB
Ruby
87 lines
3.3 KiB
Ruby
# ©AngelaMos | 2026
|
|
# corpus_test.rb
|
|
|
|
require_relative "test_helper"
|
|
require_relative "support/adversarial_corpus"
|
|
|
|
module Rube
|
|
class CorpusTest < Minitest::Test
|
|
def detector(allowed = [])
|
|
Marshal::BoundaryDetector.new(allowed_class_names: allowed)
|
|
end
|
|
|
|
def test_corpus_covers_both_verdicts
|
|
verdicts = AdversarialCorpus::CASES.map { |c| c[:verdict] }.uniq
|
|
assert_includes verdicts, AdversarialCorpus::VERDICT_ACCEPT
|
|
assert_includes verdicts, AdversarialCorpus::VERDICT_REJECT
|
|
end
|
|
|
|
def test_corpus_case_names_are_unique
|
|
names = AdversarialCorpus::CASES.map { |c| c[:name] }
|
|
assert_equal names.length, names.uniq.length
|
|
end
|
|
|
|
def test_every_corpus_case_matches_its_verdict
|
|
disagreements = AdversarialCorpus::CASES.filter_map do |kase|
|
|
decision = detector(kase[:allowed]).inspect_stream(kase[:bytes])
|
|
expected = kase[:verdict] == AdversarialCorpus::VERDICT_ACCEPT
|
|
next if decision.accepted? == expected
|
|
|
|
"#{kase[:name]}: expected #{kase[:verdict]}, got #{decision.accepted? ? 'accept' : 'reject'} (#{decision.reason})"
|
|
end
|
|
|
|
assert_empty disagreements, "corpus disagreements:\n #{disagreements.join("\n ")}"
|
|
end
|
|
|
|
def test_parser_never_raises_outside_the_stream_error_hierarchy
|
|
leaks = AdversarialCorpus::CASES.filter_map do |kase|
|
|
detector(kase[:allowed]).inspect_stream(kase[:bytes])
|
|
nil
|
|
rescue StandardError => e
|
|
"#{kase[:name]}: #{e.class}"
|
|
end
|
|
|
|
assert_empty leaks, "unhandled exceptions escaped the detector:\n #{leaks.join("\n ")}"
|
|
end
|
|
|
|
def test_allowlisted_cases_would_still_be_rejected_without_their_allowlist
|
|
allowlisted = AdversarialCorpus::CASES.reject { |kase| kase[:allowed].empty? }
|
|
refute_empty allowlisted
|
|
|
|
leaks = allowlisted.reject { |kase| detector.inspect_stream(kase[:bytes]).rejected? }
|
|
assert_empty leaks.map { |kase| kase[:name] },
|
|
"an allowlist must widen what is accepted, never what is rejected"
|
|
end
|
|
|
|
def test_every_class_name_slot_carries_byte_identical_gadget
|
|
blind = AdversarialCorpus::CLASS_NAME_SLOTS.reject do |_slot, bytes|
|
|
bytes.include?(AdversarialCorpus::CLASS_NAME_GADGET)
|
|
end
|
|
|
|
assert_empty blind.keys,
|
|
"slot verdicts only compare if the embedded gadget bytes are identical"
|
|
end
|
|
|
|
def test_class_name_slots_whose_host_is_not_itself_a_sink_are_all_in_the_corpus
|
|
names = AdversarialCorpus::CASES.map { |kase| kase[:name] }
|
|
missing = AdversarialCorpus::CLASS_NAME_SLOTS_WITH_NON_SINK_HOST.reject do |slot|
|
|
names.include?(:"class_name_slot_#{slot}")
|
|
end
|
|
|
|
assert_empty missing, "these slots can flip a detector verdict and must be corpus cases"
|
|
end
|
|
|
|
def test_sink_hosted_class_name_slots_are_excluded_from_the_corpus
|
|
hosts = AdversarialCorpus::CLASS_NAME_SLOTS.keys -
|
|
AdversarialCorpus::CLASS_NAME_SLOTS_WITH_NON_SINK_HOST
|
|
names = AdversarialCorpus::CASES.map { |kase| kase[:name] }
|
|
|
|
hosts.each do |slot|
|
|
refute_includes names, :"class_name_slot_#{slot}",
|
|
"#{slot} rejects on its own tag either way, so a corpus case cannot fail"
|
|
assert detector.inspect_stream(AdversarialCorpus::CLASS_NAME_SLOTS[slot]).rejected?
|
|
end
|
|
end
|
|
end
|
|
end
|