fix(rube): clear the entire S1 backlog tier - depth, budgets, gates, fidelity

Every item contracted to clear before M7 is closed. 151 tests from 119,
58 corpus cases from 48, all six gates green.

Depth accounting (B3, B4). TAG_IVAR charged no depth at all, so an I-chain
of any length parsed under any ceiling. Proven end to end against a rebuilt
target image: a 12,936-byte cookie returned HTTP 500 with a SystemStackError
that no rescue StreamError can catch, and a 724,287-byte response body
leaking absolute container paths for every file in lib/. Fixed, the same
cookie returns 400 DepthLimitError, and so does a 53,340-byte one.
read_userdef also hard-coded a depth of 1 for its class-name slot.

Budget axes (B12, B13, B14). Bignum magnitude bypassed the scalar budget
entirely and the sign byte accepted anything as positive where Marshal.load
raises ArgumentError. Added max_symbol_references, max_symbol_name_bytes,
max_class_name_bytes, max_instance_variables and max_struct_members.
Parser.new now enforces Limits.new instead of resolving to an unbounded
config; Limits.permissive became a class method.

Hash-key dispatch (B11). Nothing rejected an allowlisted class used as a
hash KEY, where #hash and #eql? run during load before any allowlist can
act. Measured against real Marshal.load: a key dispatches iff it carries a
class name and its underlying value is not a T_STRING. So TAG_REGEXP is not
a key-position risk and TAG_USERCLASS only conditionally - rejecting either
outright would have been a false positive. No opt-out allowlist was added,
because the dispatch happens before any check could run.

Fidelity (B15, B16, B9). The parser already matched Marshal.load on header
versions, so the 4.8 contradiction was resolved by giving the detector the
policy check and leaving the forensic parser permissive. Class-name slots
now accept only a symbol, an ivar-wrapped symbol, or a symlink. Wrapper
tags C and e no longer take an object-table slot, which Ruby does not give
them - link index 3 resolved to "bbb" for us and "ccc" for Ruby.

Gate soundness (B6, B7). Three discarded check() return values now register
as failures; section 6 no longer reports a vacuous 0/0; section 7 requires
reachable > 0, and prism absence is a named failure rather than a silent
zero. version-matrix.sh exits non-zero when any image produces no probe
result. control_check.rb no longer pulls in minitest, which was printing
"0 runs, 0 assertions" directly under ALL CONTROLS PASSED. Rewrote the
vacuous tests: the regexp options byte had zero minitest coverage and its
mutant survived the whole suite, and read_count's negative guard was
alibied by take's own guard.

The target app (B5) lost its hand-rolled copy of the sink-plus-allowlist
policy and now runs one BoundaryDetector with real limits, branching on
rejected? rather than accepted?.

Everything here is mutation-proven. Notable misses that mutation caught:
B14 had no test at all until reverting it stayed green, and a struct-member
test was vacuous on the first attempt because struct member names are always
symbols.

lib/rube/marshal/parser.rb carries eight backlog items at once and cannot be
split without interactive hunk staging, so this is one commit rather than
eight.
This commit is contained in:
CarterPerez-dev 2026-07-29 05:12:25 -04:00
parent 43ea87dffc
commit 582e032cc0
18 changed files with 827 additions and 92 deletions

View File

@ -9,4 +9,9 @@ Rake::TestTask.new(:test) do |t|
t.warning = true
end
task default: :test
desc "run the standalone control checks that the minitest suite cannot express"
task :control do
ruby "-Ilib -Itest test/control_check.rb"
end
task default: %i[test control]

View File

@ -18,6 +18,10 @@ module Rube
REASON_MALFORMED = "stream is not canonical Marshal: %s"
REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run"
REASON_UNAPPROVED = "stream references unapproved class %s"
REASON_KEY_DISPATCH = "stream puts %s in a hash key, so its #hash and #eql? run during " \
"load, before any allowlist can act"
REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \
"this format emits %d.%d"
LIMITATION_NOTICE = <<~NOTICE.freeze
SECURITY LIMITATION
@ -105,8 +109,17 @@ module Rube
def violation_for(result)
sink = result.sinks.first
return format(REASON_SINK, sink.class_name, sink.sink_method) if sink
key = result.dispatching_hash_keys.first
return format(REASON_KEY_DISPATCH, key.effective_class_name) if key
return nil if policy == POLICY_DENY_SINKS_ONLY
unless result.canonical_version?
return format(REASON_NONCANONICAL_VERSION, result.major, result.minor,
Constants::MAJOR_VERSION, Constants::MINOR_VERSION)
end
unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) }
return format(REASON_UNAPPROVED, unapproved.join(", ")) unless unapproved.empty?

View File

@ -36,6 +36,7 @@ module Rube
BIGNUM_SIGN_POSITIVE = "+"
BIGNUM_SIGN_NEGATIVE = "-"
BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze
BIGNUM_WORD_BYTES = 2
FIXNUM_INLINE_OFFSET = 5
@ -56,6 +57,10 @@ module Rube
ROLE_STRUCT = "struct member"
ROLE_BIGNUM = "bignum word"
CLASS_NAME_TYPES = %i[symbol symlink].freeze
REGISTERED_WRAPPER_TYPES = %i[data].freeze
SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
SINK_METHODS = {

View File

@ -19,6 +19,8 @@ module Rube
class MalformedCountError < StreamError; end
class MalformedValueError < StreamError; end
class LimitExceededError < StreamError; end
class InputTypeError < StreamError; end

View File

@ -13,6 +13,11 @@ module Rube
DEFAULT_MAX_SCALAR_BYTES = 262_144
DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288
DEFAULT_MAX_OBJECT_LINKS = 2_048
DEFAULT_MAX_SYMBOL_REFERENCES = 2_048
DEFAULT_MAX_SYMBOL_NAME_BYTES = 1_024
DEFAULT_MAX_CLASS_NAME_BYTES = 1_024
DEFAULT_MAX_INSTANCE_VARIABLES = 256
DEFAULT_MAX_STRUCT_MEMBERS = 256
ROLE_BYTES = "stream bytes"
ROLE_NODES = "nodes"
@ -22,10 +27,19 @@ module Rube
ROLE_SCALAR = "scalar bytes"
ROLE_TOTAL_SCALAR = "total scalar bytes"
ROLE_LINKS = "object links"
ROLE_SYMBOL_REFERENCES = "symbol references"
ROLE_SYMBOL_NAME = "symbol name bytes"
ROLE_CLASS_NAME = "class name bytes"
ROLE_INSTANCE_VARIABLES = "instance variables"
ROLE_STRUCT_MEMBERS = "struct members"
UNBOUNDED = Float::INFINITY
attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects,
:max_symbol_definitions, :max_collection_entries,
:max_scalar_bytes, :max_total_scalar_bytes, :max_object_links
:max_scalar_bytes, :max_total_scalar_bytes, :max_object_links,
:max_symbol_references, :max_symbol_name_bytes, :max_class_name_bytes,
:max_instance_variables, :max_struct_members
def initialize(
max_bytes: DEFAULT_MAX_BYTES,
@ -36,7 +50,12 @@ module Rube
max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES,
max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES,
max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES,
max_object_links: DEFAULT_MAX_OBJECT_LINKS
max_object_links: DEFAULT_MAX_OBJECT_LINKS,
max_symbol_references: DEFAULT_MAX_SYMBOL_REFERENCES,
max_symbol_name_bytes: DEFAULT_MAX_SYMBOL_NAME_BYTES,
max_class_name_bytes: DEFAULT_MAX_CLASS_NAME_BYTES,
max_instance_variables: DEFAULT_MAX_INSTANCE_VARIABLES,
max_struct_members: DEFAULT_MAX_STRUCT_MEMBERS
)
@max_bytes = max_bytes
@max_depth = max_depth
@ -47,19 +66,29 @@ module Rube
@max_scalar_bytes = max_scalar_bytes
@max_total_scalar_bytes = max_total_scalar_bytes
@max_object_links = max_object_links
@max_symbol_references = max_symbol_references
@max_symbol_name_bytes = max_symbol_name_bytes
@max_class_name_bytes = max_class_name_bytes
@max_instance_variables = max_instance_variables
@max_struct_members = max_struct_members
end
def permissive
self.class.new(
max_bytes: Float::INFINITY,
def self.permissive
new(
max_bytes: UNBOUNDED,
max_depth: Constants::DEFAULT_MAX_DEPTH,
max_nodes: Float::INFINITY,
max_registered_objects: Float::INFINITY,
max_symbol_definitions: Float::INFINITY,
max_collection_entries: Float::INFINITY,
max_scalar_bytes: Float::INFINITY,
max_total_scalar_bytes: Float::INFINITY,
max_object_links: Float::INFINITY
max_nodes: UNBOUNDED,
max_registered_objects: UNBOUNDED,
max_symbol_definitions: UNBOUNDED,
max_collection_entries: UNBOUNDED,
max_scalar_bytes: UNBOUNDED,
max_total_scalar_bytes: UNBOUNDED,
max_object_links: UNBOUNDED,
max_symbol_references: UNBOUNDED,
max_symbol_name_bytes: UNBOUNDED,
max_class_name_bytes: UNBOUNDED,
max_instance_variables: UNBOUNDED,
max_struct_members: UNBOUNDED
)
end
end
@ -71,6 +100,7 @@ module Rube
@registered = 0
@symbols = 0
@links = 0
@symbol_references = 0
@scalar_total = 0
end
@ -94,6 +124,27 @@ module Rube
check(@links, limits.max_object_links, Limits::ROLE_LINKS)
end
def symbol_reference!
@symbol_references += 1
check(@symbol_references, limits.max_symbol_references, Limits::ROLE_SYMBOL_REFERENCES)
end
def symbol_name!(size)
check(size, limits.max_symbol_name_bytes, Limits::ROLE_SYMBOL_NAME)
end
def class_name!(size)
check(size, limits.max_class_name_bytes, Limits::ROLE_CLASS_NAME)
end
def instance_variables!(count)
check(count, limits.max_instance_variables, Limits::ROLE_INSTANCE_VARIABLES)
end
def struct_members!(count)
check(count, limits.max_struct_members, Limits::ROLE_STRUCT_MEMBERS)
end
def entries!(count)
check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES)
end

View File

@ -4,8 +4,11 @@
module Rube
module Marshal
class Node
STRING_BACKED_TYPES = %i[string regexp].freeze
WRAPPER_TYPES = %i[user_class extended].freeze
attr_reader :type, :tag, :children, :instance_variables_map, :auxiliary
attr_accessor :value, :class_name
attr_accessor :value, :class_name, :link_target
def initialize(type:, tag: nil, value: nil, class_name: nil)
@type = type
@ -29,6 +32,25 @@ module Rube
Constants::GATED_SINK_TAGS.include?(tag)
end
def dispatches_key_methods?
return link_target ? link_target.dispatches_key_methods? : false if type == :object_link
return false unless class_name
return !string_backed? if WRAPPER_TYPES.include?(type)
true
end
def effective_class_name
link_target ? link_target.class_name : class_name
end
def string_backed?
wrapped = children.first
return false unless wrapped
STRING_BACKED_TYPES.include?(wrapped.type)
end
def each(&block)
return enum_for(:each) unless block
@ -39,10 +61,16 @@ module Rube
end
class Result
attr_reader :root
attr_reader :root, :major, :minor
def initialize(root)
def initialize(root, major:, minor:)
@root = root
@major = major
@minor = minor
end
def canonical_version?
major == Constants::MAJOR_VERSION && minor == Constants::MINOR_VERSION
end
def nodes
@ -60,6 +88,17 @@ module Rube
def gated_sinks
sinks.select(&:gated?)
end
def hash_keys
nodes.select { |node| node.type == :hash }
.flat_map(&:children)
.select { |child| child.type == :pair }
.filter_map { |pair| pair.children.first }
end
def dispatching_hash_keys
hash_keys.select(&:dispatches_key_methods?)
end
end
end
end

View File

@ -6,14 +6,14 @@ module Rube
class Parser
include Constants
def initialize(source, max_depth: DEFAULT_MAX_DEPTH, limits: nil)
def initialize(source, max_depth: nil, limits: Limits.new)
raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String)
@limits = limits
@max_depth = limits ? limits.max_depth : max_depth
@max_depth = max_depth || limits.max_depth
enforce_size(source)
@source = source.dup.force_encoding(Encoding::BINARY)
@budget = Budget.new(limits || Limits.new.permissive)
@budget = Budget.new(limits)
@position = 0
@symbols = []
@objects = []
@ -24,7 +24,7 @@ module Rube
root = read_value(1)
raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero?
Result.new(root)
Result.new(root, major: @major, minor: @minor)
end
private
@ -32,7 +32,6 @@ module Rube
attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits
def enforce_size(candidate)
return unless limits
return if candidate.bytesize <= limits.max_bytes
raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}"
@ -68,11 +67,10 @@ module Rube
end
def read_header
header = take(HEADER_LENGTH)
major, minor = header.unpack("CC")
return if major == MAJOR_VERSION && minor <= MINOR_VERSION
@major, @minor = take(HEADER_LENGTH).unpack("CC")
return if @major == MAJOR_VERSION && @minor <= MINOR_VERSION
raise UnsupportedVersionError, "stream declares #{major}.#{minor}"
raise UnsupportedVersionError, "stream declares #{@major}.#{@minor}"
end
def read_fixnum
@ -133,7 +131,7 @@ module Rube
when TAG_IVAR then read_ivar(tag, depth)
when TAG_OBJECT then read_object(tag, depth)
when TAG_STRUCT then read_struct(tag, depth)
when TAG_USERDEF then register(read_userdef(tag))
when TAG_USERDEF then register(read_userdef(tag, depth))
when TAG_USERMARSHAL then read_usermarshal(tag, depth)
when TAG_DATA then read_wrapped(tag, :data, depth)
when TAG_USERCLASS then read_wrapped(tag, :user_class, depth)
@ -146,12 +144,16 @@ module Rube
def read_symbol(tag)
budget.symbol!
node = Node.new(type: :symbol, tag: tag, value: read_counted_bytes.to_sym)
size = read_count(ROLE_LENGTH)
budget.symbol_name!(size)
budget.scalar!(size)
node = Node.new(type: :symbol, tag: tag, value: take(size).to_sym)
symbols << node.value
node
end
def read_symlink(tag)
budget.symbol_reference!
index = read_fixnum
raise InvalidLinkError, "symlink #{index} of #{symbols.length}" unless symbols[index] && index >= 0
@ -163,13 +165,20 @@ module Rube
index = read_fixnum
raise InvalidLinkError, "object link #{index} of #{objects.length}" unless objects[index] && index >= 0
Node.new(type: :object_link, tag: tag, value: index)
node = Node.new(type: :object_link, tag: tag, value: index)
node.link_target = objects[index]
node
end
def read_bignum(tag)
negative = take(1) == BIGNUM_SIGN_NEGATIVE
magnitude = little_endian(take(read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES))
Node.new(type: :bignum, tag: tag, value: negative ? -magnitude : magnitude)
sign = take(1)
raise MalformedValueError, "bignum sign #{sign.inspect}" unless BIGNUM_SIGNS.include?(sign)
size = read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES
budget.scalar!(size)
magnitude = little_endian(take(size))
Node.new(type: :bignum, tag: tag,
value: sign == BIGNUM_SIGN_NEGATIVE ? -magnitude : magnitude)
end
def read_float(tag)
@ -210,13 +219,20 @@ module Rube
def read_class_name(node, depth)
class_node = read_value(depth)
unless CLASS_NAME_TYPES.include?(class_node.type)
raise MalformedValueError,
"class name slot holds #{class_node.type}, not a symbol"
end
node.class_name = class_node.value.to_s
node.auxiliary << class_node
node
end
def read_instance_variables(node, depth)
read_entry_count(ROLE_IVAR).times do
count = read_entry_count(ROLE_IVAR)
budget.instance_variables!(count)
count.times do
name = read_value(depth + 1)
value = read_value(depth + 1)
node.auxiliary << name
@ -227,7 +243,7 @@ module Rube
end
def read_ivar(tag, depth)
read_instance_variables(read_value(depth), depth)
read_instance_variables(read_value(depth + 1), depth)
end
def read_object(tag, depth)
@ -239,13 +255,15 @@ module Rube
def read_struct(tag, depth)
node = register(Node.new(type: :struct, tag: tag))
read_class_name(node, depth + 1)
read_entry_count(ROLE_STRUCT).times { node.children << read_pair(depth) }
count = read_entry_count(ROLE_STRUCT)
budget.struct_members!(count)
count.times { node.children << read_pair(depth) }
node
end
def read_userdef(tag)
def read_userdef(tag, depth)
node = Node.new(type: :userdef, tag: tag)
read_class_name(node, 1)
read_class_name(node, depth + 1)
node.value = read_counted_bytes
node
end
@ -258,14 +276,18 @@ module Rube
end
def read_wrapped(tag, type, depth)
node = register(Node.new(type: type, tag: tag))
node = Node.new(type: type, tag: tag)
register(node) if REGISTERED_WRAPPER_TYPES.include?(type)
read_class_name(node, depth + 1)
node.children << read_value(depth + 1)
node
end
def read_named(tag, type)
Node.new(type: type, tag: tag, class_name: read_counted_bytes)
size = read_count(ROLE_LENGTH)
budget.class_name!(size)
budget.scalar!(size)
Node.new(type: type, tag: tag, class_name: take(size))
end
end
end

View File

@ -79,6 +79,10 @@ module Rube
def reachable
candidates.select(&:reachable?)
end
def prism_available?
PRISM_AVAILABLE
end
end
def initialize(namespace: nil)

View File

@ -2,6 +2,7 @@
# render_matrix.rb
require "json"
require "rube"
TRACKED_CLASSES = %w[Gem::SpecFetcher Gem::Source::Git Gem::URI Net::WriteAdapter].freeze
MARK_YES = "yes"
@ -9,7 +10,11 @@ MARK_NO = "no"
MARK_UNKNOWN = "?"
RULE_WIDTH = 78
CVE_PATCHED_ERB = ["4.0.3.1", "4.0.4.1", "6.0.1.1", "6.0.4"].freeze
CHAIN = Rube::Chains::ErbDefMethod
def cve_patched?(version)
!CHAIN.affects?(version)
end
rows = File.readlines(ARGV.fetch(0)).reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) }
abort "no probe results" if rows.empty?
@ -54,7 +59,7 @@ section("ERB @_init GUARD (CVE-2026-41316), anchor = def_method") do
puts format(" %-14s %-9s %-9s %-24s %s", "image", "erb", "guarded", "delegating", "cve says")
rows.each do |r|
guard = r["erb_guard"]
expected = CVE_PATCHED_ERB.include?(r["erb"]) ? "patched" : "affected"
expected = cve_patched?(r["erb"]) ? "patched" : "affected"
puts format(" %-14s %-9s %-9s %-24s %s",
short(r["image"]), r["erb"], mark(guard["guarded"]),
guard["delegating"].join(","), expected)
@ -65,8 +70,7 @@ git_states = rows.map { |r| r["git_gadget"] }.uniq
guard_states = rows.map { |r| r["erb_guard"]["guarded"] }.uniq
agreements = rows.map do |r|
expected_patched = CVE_PATCHED_ERB.include?(r["erb"])
[short(r["image"]), r["erb_guard"]["guarded"] == expected_patched]
[short(r["image"]), r["erb_guard"]["guarded"] == cve_patched?(r["erb"])]
end
disagreements = agreements.reject { |_, ok| ok }.map(&:first)

View File

@ -28,12 +28,15 @@ mkdir -p "${HERE}/tmp"
echo "probing ${#IMAGES[@]} images"
incomplete=()
for image in "${IMAGES[@]}"; do
printf ' %-18s ' "${image}"
if ! docker image inspect "${image}" >/dev/null 2>&1; then
if ! docker pull -q "${image}" >/dev/null 2>&1; then
echo "UNAVAILABLE"
incomplete+=("${image} unavailable")
continue
fi
fi
@ -46,11 +49,29 @@ for image in "${IMAGES[@]}"; do
echo "ok"
else
echo "PROBE FAILED"
incomplete+=("${image} probe failed")
fi
done
echo
render_status=0
docker run --rm --network none \
-v "${OUT}:/matrix.jsonl:ro" \
-v "${RENDER}:/render.rb:ro" \
"${RENDER_IMAGE}" ruby /render.rb /matrix.jsonl
-v "${HERE}/lib:/app/lib:ro" \
-w /app \
"${RENDER_IMAGE}" ruby -Ilib /render.rb /matrix.jsonl || render_status=$?
echo
if [[ ${#incomplete[@]} -gt 0 ]]; then
echo "GATE FAILED - ${#incomplete[@]} of ${#IMAGES[@]} images produced no probe result:"
printf ' %s\n' "${incomplete[@]}"
echo " a matrix built from a subset cannot certify a version boundary"
exit 1
fi
if [[ ${render_status} -ne 0 ]]; then
echo "GATE FAILED - renderer rejected the matrix"
fi
exit ${render_status}

View File

@ -19,11 +19,15 @@ module Rube
ALLOWED_CLASSES = %w[Hash String Symbol Integer Array].freeze
BENIGN_TEMPLATE = "hello"
REJECTED_SINK = "rejected: payload reaches %s#%s before any allowlist can run"
REJECTED_CLASS = "rejected: payload references %s"
DETECTOR = Rube::Marshal::BoundaryDetector.new(
policy: Rube::Marshal::BoundaryDetector::POLICY_STRICT_ALLOWLIST,
allowed_class_names: ALLOWED_CLASSES,
limits: Rube::Marshal::Limits.new
)
REJECTED = "rejected: %s"
RENDERED = "rendered template for %s"
NO_SESSION = "no session cookie"
MALFORMED = "rejected: malformed stream (%s)"
class App < Sinatra::Base
set :host_authorization, permitted_hosts: []
@ -63,10 +67,10 @@ module Rube
blob = decode(request.cookies[COOKIE_NAME])
halt STATUS_BAD_REQUEST, NO_SESSION unless blob
verdict = inspect_stream(blob)
halt STATUS_BAD_REQUEST, verdict if verdict
decision = DETECTOR.inspect_stream(blob)
halt STATUS_BAD_REQUEST, format(REJECTED, decision.reason) if decision.rejected?
compile(::Marshal.load(blob))
compile(::Marshal.load(decision.snapshot))
end
get "/canary" do
@ -93,20 +97,6 @@ module Rube
template.def_method(Module.new, "render_it") if template.respond_to?(:def_method)
format(RENDERED, state[:user])
end
def inspect_stream(blob)
result = Rube::Marshal::Parser.new(blob).parse
sink = result.sinks.first
return format(REJECTED_SINK, sink.class_name, sink.sink_method) if sink
unknown = result.class_names.reject { |name| ALLOWED_CLASSES.include?(name) }
return format(REJECTED_CLASS, unknown.join(", ")) unless unknown.empty?
nil
rescue Rube::Marshal::StreamError => e
format(MALFORMED, e.class.name.split("::").last)
end
end
end
end

View File

@ -65,10 +65,11 @@ module Rube
assert_match(/\A#\nend\n/, chain.src)
end
def test_serialize_produces_a_loadable_marshal_stream
def test_serialize_produces_a_parseable_marshal_stream
blob = chain.serialize
assert_equal 4, blob.getbyte(0)
assert_equal 8, blob.getbyte(1)
assert_equal [Rube::Marshal::Constants::MAJOR_VERSION, Rube::Marshal::Constants::MINOR_VERSION],
[blob.getbyte(0), blob.getbyte(1)]
assert_equal :object, Rube::Marshal::Parser.new(blob).parse.root.type
end
def test_payload_is_visible_to_the_parser_without_deserializing

View File

@ -1,7 +1,9 @@
# ©AngelaMos | 2026
# control_check.rb
require_relative "test_helper"
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "rube"
Pair = Struct.new(:x, :y)
@ -77,15 +79,24 @@ puts
puts "=== 5 payload inspection ==="
result = Rube::Marshal::Parser.new(Marshal.dump(Gem::Requirement.new(">= 0"))).parse
from_stream = result.gated_sinks.map { |s| "#{s.class_name}##{s.sink_method}" }.uniq.sort
check("classes extracted", !result.class_names.empty?, result.class_names.join(", "))
check("gated sinks flagged", !from_stream.empty?, from_stream.join(", "))
failures << "class names" unless check("classes extracted", !result.class_names.empty?,
result.class_names.join(", "))
failures << "gated sinks" unless check("gated sinks flagged", !from_stream.empty?,
from_stream.join(", "))
puts
puts "=== 6 parser and scanner agreement ==="
scanned = Rube::Scanner.new(namespace: "Gem").scan.gated.map(&:to_s).sort
missing = from_stream - scanned
failures << "agreement" unless check("parser sinks located by reflection", missing.empty?,
missing.empty? ? "#{from_stream.length}/#{from_stream.length}" : "missing #{missing.join(', ')}")
located = !from_stream.empty? && missing.empty?
agreement_detail = if from_stream.empty?
"vacuous, section 5 produced no sinks to locate"
elsif missing.empty?
"#{from_stream.length}/#{from_stream.length}"
else
"missing #{missing.join(', ')}"
end
failures << "agreement" unless check("parser sinks located by reflection", located, agreement_detail)
puts
puts "=== 7 scanner precision ==="
@ -93,9 +104,13 @@ full = Rube::Scanner.new.scan
ungated = full.ungated.length
reachable = full.reachable.reject(&:gated?).length
kept = ungated.zero? ? 0 : (100.0 * reachable / ungated).round(1)
failures << "precision" unless check("reachability filter discriminates", reachable < ungated,
failures << "prism" unless check("prism backend live, so reachability is real", full.prism_available?,
full.prism_available? ? "Prism.parse_file in use" : "ABSENT, touches_state? is always false")
failures << "precision" unless check("reachability filter discriminates",
reachable.positive? && reachable < ungated,
"#{ungated} ungated -> #{reachable} reachable, #{kept}% kept")
check("gated sinks located", !full.gated.empty?, full.gated.map(&:to_s).join(", "))
failures << "gated located" unless check("gated sinks located", !full.gated.empty?,
full.gated.map(&:to_s).join(", "))
puts format(" %-6s %-46s %s", "INFO", "ObjectSpace coverage is load-bounded",
"#{full.scanned_modules} modules loaded")

View File

@ -37,7 +37,7 @@ module Rube
leaks = AdversarialCorpus::CASES.filter_map do |kase|
detector(kase[:allowed]).inspect_stream(kase[:bytes])
nil
rescue StandardError => e
rescue Exception => e
"#{kase[:name]}: #{e.class}"
end

View File

@ -2,6 +2,7 @@
# boundary_detector_test.rb
require_relative "../test_helper"
require_relative "../support/adversarial_corpus"
module Rube
module Marshal
@ -108,6 +109,28 @@ module Rube
assert_predicate decision, :rejected?
end
def test_rejects_a_version_the_parser_accepts_but_no_ruby_emits
older = "\x04\x07\x30".b
assert_predicate Parser.new(older).parse, :root,
"control: the parser must still accept it, it is a forensic tool"
decision = detector.inspect_stream(older)
assert_predicate decision, :rejected?
assert_includes decision.reason, "4.7"
end
def test_canonical_version_is_accepted
assert_predicate detector.inspect_stream(benign_blob), :accepted?
end
def test_deny_sinks_only_does_not_police_the_version
decision = detector(policy: BoundaryDetector::POLICY_DENY_SINKS_ONLY)
.inspect_stream("\x04\x07\x30".b)
assert_predicate decision, :accepted?,
"version canonicality runs no code during load, so it belongs with " \
"the allowlist, not with the sink checks"
end
def test_rejects_unknown_policy
assert_raises(ArgumentError) { detector(policy: :yolo) }
end
@ -130,36 +153,132 @@ module Rube
refute_includes decision.snapshot, "tampered"
end
def test_enforces_a_byte_ceiling_before_parsing
limits = Limits.new(max_bytes: 8)
decision = detector(limits: limits).inspect_stream(benign_blob)
def assert_ceiling_rejects(blob, error_name, allowed: [], **narrow)
assert_predicate detector(allowed_class_names: allowed).inspect_stream(blob), :accepted?,
"control: this payload must be accepted under default limits, " \
"or the ceiling is not what rejected it"
decision = detector(allowed_class_names: allowed, limits: Limits.new(**narrow)).inspect_stream(blob)
assert_predicate decision, :rejected?
assert_includes decision.reason, "LimitExceededError"
assert_includes decision.reason, error_name
decision
end
def test_enforces_a_byte_ceiling_before_parsing
assert_ceiling_rejects(benign_blob, "LimitExceededError", max_bytes: 8)
end
def test_enforces_a_depth_ceiling
deep = "\x04\x08" + ("[\x06" * 80) + "0"
shallow = "\x04\x08" + ("[\x06" * 8) + "0"
assert_predicate detector.inspect_stream(shallow), :accepted?,
"control: nesting inside the ceiling must be accepted"
decision = detector.inspect_stream(deep)
assert_predicate decision, :rejected?
assert_includes decision.reason, "DepthLimitError"
end
def test_enforces_a_collection_entry_ceiling
limits = Limits.new(max_collection_entries: 2)
decision = detector(limits: limits).inspect_stream(::Marshal.dump([1, 2, 3, 4, 5]))
assert_predicate decision, :rejected?
assert_ceiling_rejects(::Marshal.dump([1, 2, 3, 4, 5]), "LimitExceededError",
max_collection_entries: 2)
end
def test_enforces_a_symbol_ceiling
limits = Limits.new(max_symbol_definitions: 2)
blob = ::Marshal.dump(%i[a b c d e f])
decision = detector(limits: limits).inspect_stream(blob)
assert_predicate decision, :rejected?
assert_ceiling_rejects(::Marshal.dump(%i[a b c d e f]), "LimitExceededError",
max_symbol_definitions: 2)
end
def test_enforces_a_node_ceiling
limits = Limits.new(max_nodes: 5)
decision = detector(limits: limits).inspect_stream(::Marshal.dump((1..50).to_a))
assert_predicate decision, :rejected?
assert_ceiling_rejects(::Marshal.dump((1..50).to_a), "LimitExceededError",
max_nodes: 5)
end
def test_enforces_a_bignum_magnitude_ceiling
words = AdversarialCorpus::BIGNUM_HUGE_WORDS
blob = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words))
decision = detector.inspect_stream(blob)
assert_predicate decision, :rejected?,
"#{words * AdversarialCorpus::BIGNUM_WORD_BYTES} magnitude bytes must be " \
"charged the same budget a string of that size is charged"
assert_includes decision.reason, "LimitExceededError"
end
def test_enforces_a_symbol_reference_ceiling
count = AdversarialCorpus::BULK_ENTRY_COUNT
blob = AdversarialCorpus.stream(
"[#{AdversarialCorpus.fixnum(count + 1)}#{AdversarialCorpus.symlink_run(count)}"
)
assert_ceiling_rejects(blob, "LimitExceededError", max_symbol_references: 8)
end
def test_enforces_a_symbol_name_byte_ceiling
blob = AdversarialCorpus.stream(AdversarialCorpus.sym("A" * AdversarialCorpus::MODEST_NAME_BYTES))
assert_ceiling_rejects(blob, "LimitExceededError", max_symbol_name_bytes: 32)
end
def test_enforces_a_class_name_byte_ceiling
name = "A" * AdversarialCorpus::MODEST_NAME_BYTES
blob = AdversarialCorpus.stream("c#{AdversarialCorpus.fixnum(name.bytesize)}#{name}")
assert_ceiling_rejects(blob, "LimitExceededError", allowed: [name], max_class_name_bytes: 32)
end
def test_enforces_an_instance_variable_ceiling
blob = AdversarialCorpus.stream(
"o#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(AdversarialCorpus::MODEST_ENTRY_COUNT)}"
)
assert_ceiling_rejects(blob, "LimitExceededError", allowed: %w[F], max_instance_variables: 8)
end
def test_enforces_a_struct_member_ceiling
blob = AdversarialCorpus.stream(
"S#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(AdversarialCorpus::MODEST_ENTRY_COUNT)}"
)
assert_ceiling_rejects(blob, "LimitExceededError", allowed: %w[F], max_struct_members: 8)
end
def default_limit_probes
long = "A" * AdversarialCorpus::LONG_NAME_BYTES
bulk = AdversarialCorpus::BULK_ENTRY_COUNT
{
"bignum magnitude" =>
[AdversarialCorpus.stream(AdversarialCorpus.bignum("+", AdversarialCorpus::BIGNUM_HUGE_WORDS)), []],
"symbol references" =>
[AdversarialCorpus.stream(
AdversarialCorpus.symlink_groups(AdversarialCorpus::SYMLINK_BULK_GROUPS,
AdversarialCorpus::SYMLINK_BULK_PER_GROUP)
), []],
"symbol name bytes" =>
[AdversarialCorpus.stream(AdversarialCorpus.sym(long)), []],
"class name bytes" =>
[AdversarialCorpus.stream("c#{AdversarialCorpus.fixnum(long.bytesize)}#{long}"), [long]],
"instance variables" =>
[AdversarialCorpus.stream("o#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(bulk)}"), %w[F]],
"struct members" =>
[AdversarialCorpus.stream("S#{AdversarialCorpus.sym('F')}#{AdversarialCorpus.ivar_run(bulk)}"), %w[F]]
}
end
def test_default_limits_bound_every_axis_without_being_asked
admitted = default_limit_probes.reject do |_axis, (blob, allowed)|
decision = detector(allowed_class_names: allowed).inspect_stream(blob)
decision.rejected? && decision.reason.include?("LimitExceededError")
end
assert_empty admitted.keys,
"Limits.new must bound these without a caller opting in: #{admitted.keys.join(', ')}"
end
def test_permissive_limits_admit_what_the_defaults_reject
admitted = default_limit_probes.count do |_axis, (blob, allowed)|
detector(allowed_class_names: allowed, limits: Limits.permissive).inspect_stream(blob).accepted?
end
assert_operator admitted, :>, 0,
"control: permissive must actually differ from the defaults, " \
"otherwise the previous test proves nothing about the ceilings"
end
def test_never_exposes_a_safety_claiming_api

View File

@ -35,6 +35,85 @@ module Rube
assert_equal :nil, parse("\x04\x07\x30").root.type
end
def ruby_accepts_stream?(bytes)
::Marshal.load(bytes)
true
rescue ArgumentError, TypeError
false
end
def parser_accepts_stream?(bytes)
parse(bytes)
true
rescue StreamError
false
end
def test_header_version_agreement_with_real_ruby
probes = (0..9).to_h { |minor| ["4.#{minor}", "\x04#{minor.chr}0".b] }
observed = probes.transform_values { |bytes| ruby_accepts_stream?(bytes) }
assert_includes observed.values, true, "no version accepted, so the oracle is dead"
assert_includes observed.values, false, "every version accepted, so the oracle proves nothing"
mismatches = probes.filter_map do |label, bytes|
mine = parser_accepts_stream?(bytes)
next if mine == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label)} parser=#{mine}"
end
assert_empty mismatches,
"the parser is a forensic tool and must accept exactly what Marshal.load " \
"accepts:\n #{mismatches.join("\n ")}"
end
def test_result_reports_the_declared_version
result = parse("\x04\x07\x30")
assert_equal [4, 7], [result.major, result.minor]
refute_predicate result, :canonical_version?
assert_predicate parse(::Marshal.dump(nil)), :canonical_version?
end
class RoleFixture; end
def class_name_slot_streams
name = "Rube::Marshal::ParserTest::RoleFixture"
symbol = AdversarialCorpus.sym(name)
{
"symbol" => AdversarialCorpus.stream("o#{symbol}#{AdversarialCorpus.fixnum(0)}"),
"ivar wrapped symbol" => AdversarialCorpus.stream(
"oI#{symbol}#{AdversarialCorpus.fixnum(1)}#{AdversarialCorpus.sym('E')}T#{AdversarialCorpus.fixnum(0)}"
),
"symlink" => AdversarialCorpus.stream(
"[#{AdversarialCorpus.fixnum(2)}#{symbol}o#{AdversarialCorpus.symlink(0)}#{AdversarialCorpus.fixnum(0)}"
),
"fixnum" => AdversarialCorpus.stream("oi\x0a#{AdversarialCorpus.fixnum(0)}"),
"string" => AdversarialCorpus.stream("o#{AdversarialCorpus.str(name)}#{AdversarialCorpus.fixnum(0)}"),
"nil" => AdversarialCorpus.stream("o0#{AdversarialCorpus.fixnum(0)}")
}
end
def test_class_name_slot_role_agreement_with_real_ruby
observed = class_name_slot_streams.transform_values { |bytes| ruby_accepts_stream?(bytes) }
assert_includes observed.values, true, "no slot accepted, so the oracle is dead"
assert_includes observed.values, false, "every slot accepted, so the oracle proves nothing"
mismatches = class_name_slot_streams.filter_map do |label, bytes|
mine = parser_accepts_stream?(bytes)
next if mine == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label)} parser=#{mine}"
end
assert_empty mismatches,
"a class-name slot accepts only a symbol, an ivar-wrapped symbol, or a " \
"symlink:\n #{mismatches.join("\n ")}"
end
def test_rejects_unknown_tag
assert_raises(UnknownTagError) { parse("\x04\x08\x00") }
end
@ -65,6 +144,55 @@ module Rube
end
end
def ruby_rejects_bignum_sign?(bytes)
::Marshal.load(bytes)
false
rescue ArgumentError, TypeError
true
end
def parser_rejects_bignum_sign?(bytes)
parse(bytes)
false
rescue StreamError
true
end
def test_bignum_sign_byte_agreement_with_real_ruby
probes = AdversarialCorpus::BIGNUM_SIGNS.to_h do |sign|
[sign, AdversarialCorpus.stream(AdversarialCorpus.bignum(sign, 1))]
end
observed = probes.transform_values { |bytes| ruby_rejects_bignum_sign?(bytes) }
assert_includes observed.values, true, "no sign was rejected, so the oracle is dead"
assert_includes observed.values, false, "every sign was rejected, so the oracle proves nothing"
mismatches = probes.filter_map do |sign, bytes|
mine = parser_rejects_bignum_sign?(bytes)
next if mine == observed.fetch(sign)
"#{sign.inspect}: ruby_rejects=#{observed.fetch(sign)} parser_rejects=#{mine}"
end
assert_empty mismatches, "bignum sign handling diverges from Marshal.load:\n #{mismatches.join("\n ")}"
end
def test_bignum_magnitude_is_charged_the_same_budget_as_a_string
words = AdversarialCorpus::BIGNUM_HUGE_WORDS
magnitude = words * AdversarialCorpus::BIGNUM_WORD_BYTES
limits = Limits.new
big = AdversarialCorpus.stream(AdversarialCorpus.bignum("+", words))
str = AdversarialCorpus.stream(AdversarialCorpus.str("A" * magnitude))
assert_raises(LimitExceededError, "a #{magnitude}-byte string is rejected") do
Parser.new(str, limits: limits).parse
end
assert_raises(LimitExceededError, "so #{magnitude} bignum magnitude bytes must be too") do
Parser.new(big, limits: limits).parse
end
end
def test_parses_float
assert_in_delta 3.5, roundtrip(3.5).value, 0.0
end
@ -105,6 +233,40 @@ module Rube
assert_equal 0, link.value
end
class LinkStringSubclass < String; end
module LinkExtension; end
def link_probe(first)
shared = +"ccc"
::Marshal.dump([first, +"bbb", shared, shared])
end
def link_probes
{
"plain object (o)" => link_probe(Object.new),
"user_class (C)" => link_probe(LinkStringSubclass.new("aaa")),
"extended (e)" => link_probe((+"aaa").extend(LinkExtension))
}
end
def test_object_link_indices_match_ruby_for_wrapper_tags
mismatches = link_probes.filter_map do |label, blob|
link = parse(blob).nodes.find { |node| node.type == :object_link }
flunk "#{label}: fixture carries no object link, so it proves nothing" unless link
ruby_target = ::Marshal.load(blob)[3]
next if link.link_target&.value == ruby_target
"#{label}: index #{link.value} resolves to " \
"#{link.link_target&.value.inspect}, Ruby resolves it to #{ruby_target.inspect}"
end
assert_empty mismatches,
"a wrapper tag must occupy the same object-table slot Ruby gives it:\n " \
"#{mismatches.join("\n ")}"
end
def test_rejects_out_of_bounds_object_link
assert_raises(InvalidLinkError) { parse("\x04\x08@\x0a") }
end
@ -184,9 +346,35 @@ module Rube
assert_raises(MalformedCountError) { parse("\x04\x08S:\x06A\xFA") }
end
def test_negative_length_is_caught_by_the_count_guard_not_the_take_guard
error = assert_raises(MalformedCountError) { parse("\x04\x08\"\xFA") }
assert_includes error.message, Constants::ROLE_LENGTH,
"take's own guard caught it, so read_count is not enforcing anything"
end
def test_negative_counts_never_rewind_the_cursor
parser = Parser.new("\x04\x08\"\xFA")
before = parser.instance_variable_get(:@position)
assert_raises(MalformedCountError) { parser.parse }
assert_operator parser.instance_variable_get(:@position), :>=, before,
"the cursor moved backwards, which is a loop primitive"
end
def test_parses_regexp_and_consumes_its_options_byte
node = roundtrip(/pattern/i)
assert_equal :regexp, node.type
assert_equal "pattern", node.value
end
def test_parses_regexp_nested_in_a_collection
node = roundtrip([/first/, /second/mx])
assert_equal %w[first second], node.children.map(&:value)
end
def test_regexp_options_byte_is_not_mistaken_for_the_next_value
node = roundtrip([/pattern/ix, :sentinel])
assert_equal :symbol, node.children.last.type
assert_equal :sentinel, node.children.last.value
end
def test_every_malformed_count_stays_inside_the_stream_error_hierarchy
@ -261,11 +449,61 @@ module Rube
assert_raises(DepthLimitError) { parse(deep) }
end
def test_parser_defaults_to_bounded_limits_not_permissive
blob = AdversarialCorpus.stream(AdversarialCorpus.sym("A" * AdversarialCorpus::LONG_NAME_BYTES))
assert_raises(LimitExceededError, "a bare Parser.new must not be unbounded") do
Parser.new(blob).parse
end
assert_equal :symbol, Parser.new(blob, limits: Limits.permissive).parse.root.type,
"control: permissive must admit it, or a ceiling is not what rejected it"
end
def test_default_depth_ceiling_is_the_limits_value_not_the_permissive_one
deep = AdversarialCorpus.stream(AdversarialCorpus.array_nest(Limits::DEFAULT_MAX_DEPTH + 5) + "0")
assert_raises(DepthLimitError) { Parser.new(deep).parse }
assert_operator Limits::DEFAULT_MAX_DEPTH, :<, Constants::DEFAULT_MAX_DEPTH,
"control: the two depth constants must differ, or this test cannot discriminate"
end
def test_honours_custom_depth_limit
blob = ::Marshal.dump([[[1]]])
assert_raises(DepthLimitError) { Parser.new(blob, max_depth: 2).parse }
end
def test_instance_variable_wrapper_counts_toward_the_depth_limit
chain = AdversarialCorpus.stream(
AdversarialCorpus.ivar_chain(Constants::DEFAULT_MAX_DEPTH + 5)
)
assert_raises(DepthLimitError) { parse(chain) }
end
def test_deep_instance_variable_chain_rejects_before_the_ruby_stack_runs_out
chain = AdversarialCorpus.stream(
AdversarialCorpus.ivar_chain(AdversarialCorpus::IVAR_CHAIN_DEPTH)
)
assert_raises(DepthLimitError) { parse(chain) }
end
def parse_class_name_slot_at_ceiling(tail)
depth = AdversarialCorpus::USERDEF_NEST_DEPTH
blob = AdversarialCorpus.stream(AdversarialCorpus.array_nest(depth) + tail)
Parser.new(blob, max_depth: depth + 1).parse
end
def test_object_class_name_slot_is_charged_the_enclosing_depth
tail = "o#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}#{AdversarialCorpus.fixnum(0)}"
assert_raises(DepthLimitError, "control failed, so the userdef test proves nothing") do
parse_class_name_slot_at_ceiling(tail)
end
end
def test_userdef_class_name_slot_does_not_reset_the_depth_budget
tail = "u#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}#{AdversarialCorpus.fixnum(1)}x"
assert_raises(DepthLimitError) { parse_class_name_slot_at_ceiling(tail) }
end
def load_watcher
fired = false
tracer = TracePoint.new(:call, :c_call) do |tp|
@ -290,6 +528,107 @@ module Rube
refute load_watcher { parse(blob) }, "parser invoked Marshal.load"
end
FIRED = []
module RecordsHash
def hash
FIRED << self.class.to_s
super
end
end
class PlainKey
include RecordsHash
end
class StringKey < String
include RecordsHash
end
class ArrayKey < Array
include RecordsHash
end
StructKey = Struct.new(:a) { include RecordsHash }
def dispatch_probes
{
"plain object (o)" => PlainKey.new,
"struct (S)" => StructKey.new(1),
"Array subclass (C)" => ArrayKey.new,
"String subclass (C)" => StringKey.new("x"),
"extended object (e)" => PlainKey.new.extend(RecordsHash),
"extended string (e)" => (+"x").extend(RecordsHash),
"plain string" => "x",
"symbol" => :x,
"integer" => 7,
"regexp" => /ab/
}
end
def ruby_dispatches?(key)
FIRED.clear
::Marshal.load(::Marshal.dump({ key => nil }))
!FIRED.empty?
end
def parser_predicts_dispatch?(key)
parse(::Marshal.dump({ key => nil })).dispatching_hash_keys.any?
end
def test_hash_key_dispatch_prediction_matches_real_ruby
observed = dispatch_probes.to_h { |label, key| [label, ruby_dispatches?(key)] }
assert_includes observed.values, true, "no probe dispatched, so the oracle is dead"
assert_includes observed.values, false, "every probe dispatched, so the oracle proves nothing"
mismatches = dispatch_probes.filter_map do |label, key|
predicted = parser_predicts_dispatch?(key)
next if predicted == observed.fetch(label)
"#{label}: ruby=#{observed.fetch(label)} parser=#{predicted}"
end
assert_empty mismatches, "parser disagrees with Marshal.load:\n #{mismatches.join("\n ")}"
end
def test_the_same_class_is_only_flagged_in_key_position
in_key = parse(AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH[:object])
in_value = parse(AdversarialCorpus::OBJECT_IN_VALUE_POSITION)
assert_equal 1, in_key.dispatching_hash_keys.length
assert_empty in_value.dispatching_hash_keys,
"position is the whole signal, so a value-position object must not be flagged"
end
def test_every_dispatching_key_shape_is_detected
blind = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DISPATCH.reject do |_shape, bytes|
parse(bytes).dispatching_hash_keys.any?
end
assert_empty blind.keys, "key-position dispatch missed in: #{blind.keys.join(', ')}"
end
def test_non_dispatching_key_shapes_are_not_flagged
noisy = AdversarialCorpus::HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH.select do |_shape, bytes|
parse(bytes).dispatching_hash_keys.any?
end
assert_empty noisy.keys, "false positive on: #{noisy.keys.join(', ')}"
end
def test_struct_member_name_slot_is_not_scanned_as_a_hash_key
blob = AdversarialCorpus.stream(
"S#{AdversarialCorpus.sym(AdversarialCorpus::OBJECT_CLASS)}" \
"#{AdversarialCorpus.fixnum(1)}#{AdversarialCorpus::PLAIN_OBJECT}0"
)
assert_equal 1, parse(blob).nodes.count { |node| node.type == :pair },
"control: the fixture must actually contain a struct pair"
assert_empty parse(blob).dispatching_hash_keys,
"read_pair is shared with read_struct, so struct pairs must not be scanned"
end
class Fixture
@instantiated = false

View File

@ -113,6 +113,8 @@ module Rube
def test_report_partitions_gated_and_ungated
report = local_scan
refute_empty report.gated, "a partition test proves nothing if one side is empty"
refute_empty report.ungated, "a partition test proves nothing if one side is empty"
assert_equal report.candidates.length, report.gated.length + report.ungated.length
assert_empty(report.gated & report.ungated)
end
@ -123,9 +125,14 @@ module Rube
assert_equal first, second
end
def test_anonymous_classes_are_skipped
Class.new { def marshal_load(data); end }
refute(local_scan.candidates.any? { |c| c.class_name.nil? || c.class_name.empty? })
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

View File

@ -52,10 +52,50 @@ module Rube
allowed: allowed.freeze }.freeze
end
def ivar_chain(depth)
(("I" * depth) + "0" + ("\x00" * depth)).b
end
def array_nest(depth)
("[#{fixnum(1)}" * depth).b
end
def regexp(source)
"/#{fixnum(source.bytesize)}#{source}\x00".b
end
def bignum(sign, word_count, fill = "\x01")
"l#{sign}#{fixnum(word_count)}#{fill * (word_count * BIGNUM_WORD_BYTES)}".b
end
def symlink_run(count)
(sym("s") + (symlink(0) * count)).b
end
def symlink_groups(groups, per_group)
inner = "[#{fixnum(per_group)}#{symlink(0) * per_group}"
"[#{fixnum(groups + 1)}#{sym('s')}#{inner * groups}".b
end
def ivar_run(count)
"#{fixnum(count)}#{sym('@a')}0#{"#{symlink(1)}0" * (count - 1)}".b
end
NESTED_DEPTH = 80
WIDE_COUNT = 4_096
MANY_SYMBOLS = 400
HUGE_SCALAR = 300_000
IVAR_CHAIN_DEPTH = 6_000
USERDEF_NEST_DEPTH = 3
BIGNUM_WORD_BYTES = 2
BIGNUM_HUGE_WORDS = 200_000
LONG_NAME_BYTES = 100_000
BULK_ENTRY_COUNT = 1_000
MODEST_NAME_BYTES = 64
MODEST_ENTRY_COUNT = 16
SYMLINK_BULK_GROUPS = 3
SYMLINK_BULK_PER_GROUP = 1_000
BIGNUM_SIGNS = ["-", "+", "!", "\x00", "\xFF", "0"].freeze
TRIPWIRE_CLASS = "Tripwire"
HOST_CLASS = "Comparable"
@ -95,6 +135,27 @@ module Rube
IVAR_DISTINCT_NAMES_CONTROL =
stream("I#{str('hello')}#{fixnum(2)}#{sym(COLLIDING_IVAR)}#{TRIPWIRE}#{sym(HIDDEN_IVAR)}0")
PLAIN_OBJECT = "o#{sym(OBJECT_CLASS)}#{fixnum(0)}".b
HASH_KEY_SHAPES_THAT_DISPATCH = {
object: stream("{#{fixnum(1)}#{PLAIN_OBJECT}0"),
object_link: stream("[#{fixnum(2)}#{PLAIN_OBJECT}{#{fixnum(1)}@#{fixnum(1)}0"),
struct: stream("{#{fixnum(1)}S#{sym(OBJECT_CLASS)}#{fixnum(0)}0"),
extended: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{PLAIN_OBJECT}0"),
user_class_over_array: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}[#{fixnum(0)}0")
}.freeze
HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH = {
user_class_over_string: stream("{#{fixnum(1)}C#{sym(OBJECT_CLASS)}#{str('x')}0"),
extended_over_string: stream("{#{fixnum(1)}e#{sym(HOST_CLASS)}#{str('x')}0"),
regexp: stream("{#{fixnum(1)}#{regexp('ab')}0"),
symbol: stream("{#{fixnum(1)}#{sym('k')}0"),
string: stream("{#{fixnum(1)}#{str('k')}0")
}.freeze
OBJECT_IN_VALUE_POSITION =
stream("{#{fixnum(1)}#{sym('k')}#{PLAIN_OBJECT}")
CASES = [
entry(:nil_literal, stream("0"), VERDICT_ACCEPT,
"the smallest legal stream"),
@ -161,6 +222,9 @@ module Rube
"declares a three-byte integer and supplies one byte"),
entry(:deep_nesting, stream(("[\x06" * NESTED_DEPTH) + "0"), VERDICT_REJECT,
"nesting past the boundary depth ceiling"),
entry(:deep_instance_variable_chain, stream(ivar_chain(IVAR_CHAIN_DEPTH)), VERDICT_REJECT,
"the I wrapper must charge depth, or a 12 KB cookie exhausts the Ruby stack " \
"with a SystemStackError that no rescue StreamError can catch"),
entry(:wide_collection, stream("[#{fixnum(WIDE_COUNT)}#{'0' * WIDE_COUNT}"), VERDICT_REJECT,
"one collection consuming the whole node budget"),
entry(:many_symbols, stream("[#{fixnum(MANY_SYMBOLS)}#{(0...MANY_SYMBOLS).map { |i| sym("s#{i}") }.join}"),
@ -207,7 +271,41 @@ module Rube
allowed: [HOST_CLASS]),
entry(:class_name_slot_extended, CLASS_NAME_SLOTS[:extended], VERDICT_REJECT,
"byte-identical gadget in an extend wrapper slot",
allowed: [HOST_CLASS])
allowed: [HOST_CLASS]),
entry(:object_in_value_position_control, OBJECT_IN_VALUE_POSITION, VERDICT_ACCEPT,
"control: an allowlisted class is fine as a hash VALUE, which is what makes the " \
"key-position cases below a statement about position rather than about the class",
allowed: [OBJECT_CLASS]),
entry(:hash_key_object, HASH_KEY_SHAPES_THAT_DISPATCH[:object], VERDICT_REJECT,
"an allowlisted class in KEY position runs its #hash during load",
allowed: [OBJECT_CLASS]),
entry(:hash_key_object_link, HASH_KEY_SHAPES_THAT_DISPATCH[:object_link], VERDICT_REJECT,
"the same dispatch reached through an object link instead of a fresh object",
allowed: [OBJECT_CLASS]),
entry(:hash_key_struct, HASH_KEY_SHAPES_THAT_DISPATCH[:struct], VERDICT_REJECT,
"a struct in key position dispatches too",
allowed: [OBJECT_CLASS]),
entry(:hash_key_extended, HASH_KEY_SHAPES_THAT_DISPATCH[:extended], VERDICT_REJECT,
"an extended object in key position dispatches the module's #hash",
allowed: [OBJECT_CLASS, HOST_CLASS]),
entry(:hash_key_user_class_over_array, HASH_KEY_SHAPES_THAT_DISPATCH[:user_class_over_array],
VERDICT_REJECT,
"an Array subclass in key position dispatches the subclass #hash",
allowed: [OBJECT_CLASS]),
entry(:hash_key_user_class_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:user_class_over_string],
VERDICT_ACCEPT,
"precision control: rb_any_hash keys on T_STRING, so a String subclass never " \
"reaches a user #hash and rejecting it would be a false positive",
allowed: [OBJECT_CLASS]),
entry(:hash_key_extended_over_string, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:extended_over_string],
VERDICT_ACCEPT,
"same exemption when the string is reached through an extend wrapper",
allowed: [HOST_CLASS]),
entry(:hash_key_regexp, HASH_KEY_SHAPES_THAT_DO_NOT_DISPATCH[:regexp], VERDICT_ACCEPT,
"a regexp key names no class, so the payload cannot choose whose #hash runs")
].freeze
end
end