mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-18 02:10:41 -07:00
Bound resource use when iterating ranges (#2117)
This commit is contained in:
@@ -465,4 +465,85 @@ HERE
|
||||
|
||||
assert(context.registers[:for_stack].empty?)
|
||||
end
|
||||
|
||||
def test_integer_range_is_not_materialized_and_charges_empty_iterations
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse('{% for i in numbers %}{% endfor %}')
|
||||
template.resource_limits.render_score_limit = 3
|
||||
|
||||
assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
|
||||
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_equal(4, template.resource_limits.render_score)
|
||||
end
|
||||
|
||||
def test_integer_range_uses_arithmetic_offsets_reversal_and_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% for i in numbers reversed offset:997 limit:2 %}{{ forloop.length }}:{{ i }}{% endfor %}',
|
||||
)
|
||||
template.resource_limits.render_score_limit = 10
|
||||
|
||||
assert_equal('2:9992:998', template.render!('numbers' => range))
|
||||
end
|
||||
|
||||
def test_range_scores_cannot_be_bypassed_by_repeated_renders
|
||||
template = Template.parse('{% for i in (1..2) %}{% endfor %}')
|
||||
template.resource_limits.cumulative_render_score_limit = 3
|
||||
assert_equal('', template.render!)
|
||||
assert_raises(Liquid::MemoryError) { template.render! }
|
||||
end
|
||||
|
||||
def test_range_break_charges_only_visited_items_and_preserves_full_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% for i in numbers reversed %}{{ forloop.length }}:{{ i }}{% break %}{% endfor %}',
|
||||
)
|
||||
template.resource_limits.render_score_limit = 10
|
||||
|
||||
assert_equal('1000:1000', template.render!('numbers' => range))
|
||||
assert_operator(template.resource_limits.render_score, :<=, 10)
|
||||
end
|
||||
|
||||
def test_range_subclass_uses_its_custom_each
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(nil, 3)
|
||||
|
||||
assert_template_result('1020', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
|
||||
end
|
||||
|
||||
def test_range_subclass_custom_to_a_is_honored_for_finite_and_open_bounds
|
||||
range_class = Class.new(Range) do
|
||||
def to_a
|
||||
[42]
|
||||
end
|
||||
end
|
||||
|
||||
[range_class.new(1, 3), range_class.new(nil, 3), range_class.new(1, nil)].each do |range|
|
||||
assert_template_result('42', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
|
||||
end
|
||||
end
|
||||
|
||||
def test_endless_range_remains_unsupported_with_a_limit
|
||||
template = Template.parse('{% for i in numbers limit:2 %}{{ i }}{% endfor %}')
|
||||
|
||||
assert_raises(RangeError) { template.render!('numbers' => (1..)) }
|
||||
end
|
||||
|
||||
def test_endless_range_subclass_with_custom_each_remains_unsupported
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(1, nil)
|
||||
|
||||
assert_raises(RangeError) do
|
||||
Template.parse('{% for i in numbers %}{{ i }}{% endfor %}').render!('numbers' => range)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -465,4 +465,94 @@ class TableRowTest < Minitest::Test
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_integer_range_is_not_materialized_and_charges_empty_iterations
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse('{% tablerow i in numbers %}{% endtablerow %}')
|
||||
template.resource_limits.render_score_limit = 3
|
||||
|
||||
assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
|
||||
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_equal(4, template.resource_limits.render_score)
|
||||
end
|
||||
|
||||
def test_integer_range_uses_arithmetic_offsets_limit_and_full_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% tablerow i in numbers offset:997 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}',
|
||||
)
|
||||
|
||||
assert_equal(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">2:1:998</td><td class=\"col2\">2:2:999</td></tr>\n",
|
||||
template.render!('numbers' => range),
|
||||
)
|
||||
end
|
||||
|
||||
def test_tablerow_checks_generated_output_during_empty_body_iteration
|
||||
template = Template.parse('{% tablerow i in (1..100) %}{% endtablerow %}')
|
||||
template.resource_limits.render_length_limit = 40
|
||||
checked_lengths = []
|
||||
limits = template.resource_limits
|
||||
original_increment_write_score = limits.method(:increment_write_score)
|
||||
limits.define_singleton_method(:increment_write_score) do |output|
|
||||
checked_lengths << output.bytesize
|
||||
original_increment_write_score.call(output)
|
||||
end
|
||||
|
||||
assert_equal('Liquid error: Memory limits exceeded', template.render)
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_operator(checked_lengths.last, :<, 1000)
|
||||
end
|
||||
|
||||
def test_tablerow_range_scores_persist_across_renders
|
||||
template = Template.parse('{% tablerow i in (1..2) %}{% endtablerow %}')
|
||||
template.resource_limits.cumulative_render_score_limit = 3
|
||||
template.render!
|
||||
assert_raises(Liquid::MemoryError) { template.render! }
|
||||
end
|
||||
|
||||
def test_range_subclass_uses_its_custom_each_with_beginless_bounds
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(nil, 3)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">10</td><td class=\"col2\">20</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
|
||||
def test_endless_range_subclass_uses_its_custom_each_without_a_limit
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(1, nil)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">10</td><td class=\"col2\">20</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
|
||||
def test_range_subclass_custom_to_a_is_not_used
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
[42]
|
||||
end
|
||||
end.new(1, 3)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td><td class=\"col3\">3</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -132,7 +132,7 @@ class TemplateTest < Minitest::Test
|
||||
assert_equal("Liquid error: Memory limits exceeded", t.render)
|
||||
assert(t.resource_limits.reached?)
|
||||
|
||||
t.resource_limits.render_score_limit = 200
|
||||
t.resource_limits.render_score_limit = 201
|
||||
assert_equal(" foo " * 100, t.render!)
|
||||
refute_nil(t.resource_limits.render_score)
|
||||
end
|
||||
|
||||
@@ -32,6 +32,15 @@ module Minitest
|
||||
module Assertions
|
||||
include Liquid
|
||||
|
||||
# Exact Range fixture for fast-path tests; singleton tripwires must remain
|
||||
# untouched because the arithmetic path does not materialize or traverse it.
|
||||
def bounded_integer_range_with_tripwires
|
||||
range = (1..1000).dup
|
||||
range.define_singleton_method(:to_a) { raise 'range was materialized' }
|
||||
range.define_singleton_method(:each) { raise 'range was traversed' }
|
||||
range
|
||||
end
|
||||
|
||||
def assert_template_result(
|
||||
expected, template, assigns = {},
|
||||
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class RangeSliceUnitTest < Minitest::Test
|
||||
def test_selects_and_reverses_an_integer_range_without_enumerating_it
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 997, 999, limits)
|
||||
|
||||
assert_equal(2, slice.length)
|
||||
refute(slice.empty?)
|
||||
slice.reverse!
|
||||
assert_equal([999, 998], slice.each.to_a)
|
||||
assert_equal(2, limits.render_score)
|
||||
end
|
||||
|
||||
def test_charges_only_values_yielded_before_a_break
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 0, nil, limits)
|
||||
|
||||
slice.each { break }
|
||||
|
||||
assert_equal(1, limits.render_score)
|
||||
end
|
||||
|
||||
def test_non_integer_ranges_are_sliced_without_to_a_and_charge_visited_values
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
raise 'range was materialized'
|
||||
end
|
||||
end.new('a', 'c')
|
||||
|
||||
assert_equal(['b', 'c'], Liquid::Utils.slice_collection_for_iteration(range, 1, nil, limits))
|
||||
assert_equal(3, limits.render_score)
|
||||
|
||||
limited = Liquid::ResourceLimits.new(render_score_limit: 2)
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
Liquid::Utils.slice_collection_for_iteration('a'..'z', 10, 11, limited)
|
||||
end
|
||||
end
|
||||
|
||||
def test_non_integer_range_empty_windows_do_not_visit_a_sentinel_value
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
|
||||
assert_equal([], Liquid::Utils.slice_collection_for_iteration('a'..'z', 2, 2, limits))
|
||||
assert_equal(0, limits.render_score)
|
||||
assert_equal(['a', 'b'], Liquid::Utils.slice_collection_for_iteration('a'..'z', 0, 2, limits))
|
||||
assert_equal(2, limits.render_score)
|
||||
end
|
||||
|
||||
def test_standard_beginless_range_raises_for_empty_windows
|
||||
[[0, 0], [1, 0]].each do |from, to|
|
||||
assert_raises(TypeError) do
|
||||
Liquid::Utils.slice_collection_for_iteration(Range.new(nil, 3), from, to, Liquid::ResourceLimits.new({}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_custom_range_to_a_is_sliced_with_a_budget
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
[1, 2, 3]
|
||||
end
|
||||
end.new(nil, 3)
|
||||
limits = Liquid::ResourceLimits.new(render_score_limit: 2)
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
Liquid::Utils.slice_collection_for_iteration(range, 1, 3, limits, use_range_to_a: true)
|
||||
end
|
||||
assert_equal(3, limits.render_score)
|
||||
end
|
||||
|
||||
def test_preserves_slice_bounds_for_negative_offsets_and_limits
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
|
||||
assert_equal([1, 2], Liquid::RangeSlice.new(1..5, -2, 2, limits).each.to_a)
|
||||
empty = Liquid::RangeSlice.new(1..5, 2, 1, limits)
|
||||
assert(empty.empty?)
|
||||
assert_equal([], empty.each.to_a)
|
||||
assert_equal(2, limits.render_score) # the empty window performs no work
|
||||
assert(Liquid::RangeSlice.new(5..1, 0, nil, limits).empty?)
|
||||
assert_equal([1, 2, 3, 4], Liquid::RangeSlice.new(1...5, 0, nil, limits).each.to_a)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user