Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions internal/vm/crash_hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,52 @@ func TestRecursiveStructureInspect(t *testing.T) {
}
}

// ⛔ `(1..).to_set` DID NOT CRASH — IT NEVER CAME BACK, which is worse.
//
// Measured on main: `rbgo -e '(1..).to_set'` had to be killed (`timeout 6` →
// exit 124). Enumerable#to_set enumerated the endless range forever, allocating
// as it went. One expression from any untrusted Ruby an embedding host
// evaluates, and the host is gone — and unlike a stack overflow there is
// nothing to recover from, because nothing faults.
//
// Every other member of the family was already guarded (`to_a`, `last`, `max`,
// `size`), which is how this one hid: `Range#to_set` is new in Ruby 4.0, so
// there was no 3.4-era guard to copy, and its spec sits behind a
// `ruby_version_is "4.0"` block the gate cannot reach at 3.4.1. The sweep
// recorded `core/range/to_set_spec.rb` as "failed to load" — it had not failed,
// the per-file TIMEOUT had killed it.
//
// The message is MRI's, character for character (range.c at v4.0.9,
// `range_to_set`), and core/range/to_set_spec.rb:22 asserts it.
//
// There is no timeout in this test and there does not need to be one: if the
// guard goes away, `go test` hangs here instead of failing, which is itself the
// signal — and the four rows below it are what keep the guard from being
// "fixed" into refusing a bounded range.
func TestEndlessRangeToSetRaisesInsteadOfHanging(t *testing.T) {
cases := []struct{ src, want string }{
{`begin; (1..).to_set; rescue => e; puts "#{e.class}: #{e.message}"; end`,
"RangeError: cannot convert endless range to a set\n"},
{`begin; (1...).to_set; rescue => e; puts "#{e.class}: #{e.message}"; end`,
"RangeError: cannot convert endless range to a set\n"},

// Bounded ranges are untouched, with and without a block.
{`p (1..4).to_set`, "Set[1, 2, 3, 4]\n"},
{`p (1...4).to_set`, "Set[1, 2, 3]\n"},
{`p (1..3).to_set { |x| x * x }`, "Set[1, 4, 9]\n"},

// A beginless range still fails the way it did, from the iteration
// rather than from this guard: the check is on `end`, not on either end.
{`begin; (..0).to_set; rescue => e; puts "#{e.class}: #{e.message}"; end`,
"TypeError: can't iterate from NilClass\n"},
}
for _, c := range cases {
if got := eval(t, c.src); got != c.want {
t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want)
}
}
}

// ⛔ THE FIVE TYPES THE TEST ABOVE DID NOT COVER, each of which killed the
// process.
//
Expand Down
38 changes: 38 additions & 0 deletions internal/vm/prelude.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,44 @@ def rfind(ifnone = nil)
class Range
include Enumerable

# ⛔ to_set REFUSES AN ENDLESS RANGE, because otherwise it hangs.
#
# `(1..).to_set` enumerated forever through Enumerable#to_set, allocating as
# it went: measured on main, `rbgo -e '(1..).to_set'` had to be killed
# (`timeout 6` → exit 124). One expression, from any untrusted Ruby an
# embedding host evaluates, and the host never comes back — the same threat
# model as the repr recursion fixed in #852, and worse, because there is no
# stack to overflow and so nothing to recover from.
#
# Every OTHER member of this family was already guarded, which is how this one
# hid: `(1..).to_a` raises "cannot convert endless range to an array",
# `(1..).last` and `(1..).max` each raise, `(1..).size` answers Infinity.
# `Range#to_set` is **new in Ruby 4.0**, so there was no 3.4-era guard to copy
# — and its spec sits behind a `ruby_version_is "4.0"` block, so the gate
# cannot see it while the shim resolves at 3.4.1 (#799). The sweep recorded the
# file as "failed to load"; it had not failed, it had been killed by the
# per-file TIMEOUT.
#
# MRI's own, range.c at v4.0.9:
#
# static VALUE
# range_to_set(int argc, VALUE *argv, VALUE range)
# {
# if (NIL_P(RANGE_END(range))) {
# rb_raise(rb_eRangeError, "cannot convert endless range to a set");
# }
# return rb_call_super(argc, argv);
# }
#
# The message is asserted character for character by
# core/range/to_set_spec.rb:22. Unconditional rather than version-gated: no
# spec asserts the old behaviour — nothing can assert a hang — so the only
# thing a version gate would preserve is the way to wedge the interpreter.
def to_set(klass = Set, *args, &block)
raise RangeError, "cannot convert endless range to a set" if self.end.nil?
super
end

# Range#== is true when other is a Range (or subclass) with == begin, == end
# and the same exclude_end? flag, dispatching #== on the endpoints so that
# custom Comparable endpoints and Range subclasses compare by value (MRI's
Expand Down
Loading
Loading