Compare commits

...

9 Commits

Author SHA1 Message Date
Simon Génier
a61903da54 Add support for iterating ranges with float boundaries.
The meaning I gave is to start at the lower bound and step by one. This has the
advantage of having (n..m) and ((n.to_f)..(m.to_f)) behave the same. Another
thing we could do that have this same property is to cast bounds to integer.
This would do the right thing if there is a floating point rounding error, but I
feel it is more surprising for cases such as (1.5..8).

Note that it is not possible to create ranges with floating point boundaries
directly in Liquid at the moment. However, since there is no distinction between
Ruby and Liquid ranges, a drop can introduce such a value by returning it.
2017-08-09 12:13:00 -04:00
Rene
59162f7a0e added attr_readers for collection and variable names in for tag (#909) 2017-07-06 09:41:48 -04:00
Thierry Joyal
c582b86f16 Merge pull request #898 from Shopify/cgi-powered-standard-filters-to-handle-non-string-inputs
CGI powered standard filters to handle non string inputs
2017-05-26 18:05:42 +00:00
Thierry Joyal
e340803d12 CGI powered standard filters to handle non string inputs 2017-05-25 15:53:41 +00:00
Dylan Thacker-Smith
48a6d86ac2 Use stackprof to test to lack of object allocations (#896) 2017-05-12 09:20:51 -04:00
Dylan Thacker-Smith
3bb29d5456 Replace assert_equal nil, with a assert_nil (#895) 2017-05-11 14:05:03 -04:00
Dylan Thacker-Smith
9c72ccb82f Limit how much blocks can be nested during parsing (#894) 2017-05-11 09:37:53 -04:00
Dylan Thacker-Smith
62d4625468 Use a loop to strictly parse binary comparisons to avoid recursion (#892)
Using recursion allows a malicious template to cause a SystemStackError
2017-05-10 10:41:52 -04:00
Dylan Thacker-Smith
8928454e29 Use a loop to evaluate binary comparisions to avoid recursion (#891)
Using recursion allows a malicious template to cause a SystemStackError
2017-05-10 10:41:24 -04:00
15 changed files with 119 additions and 57 deletions

View File

@@ -1,14 +1,14 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gemspec gemspec
gem 'stackprof', platforms: :mri_21
gem 'stackprof', platforms: :mri
group :benchmark, :test do group :benchmark, :test do
gem 'benchmark-ips' gem 'benchmark-ips'
end end
group :test do group :test do
gem 'spy', '0.4.1'
gem 'rubocop', '0.34.2' gem 'rubocop', '0.34.2'
platform :mri do platform :mri do

View File

@@ -1,5 +1,7 @@
module Liquid module Liquid
class Block < Tag class Block < Tag
MAX_DEPTH = 100
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
@blank = true @blank = true
@@ -48,6 +50,11 @@ module Liquid
protected protected
def parse_body(body, tokens) def parse_body(body, tokens)
if parse_context.depth >= MAX_DEPTH
raise StackLevelError, "Nesting too deep".freeze
end
parse_context.depth += 1
begin
body.parse(tokens, parse_context) do |end_tag_name, end_tag_params| body.parse(tokens, parse_context) do |end_tag_name, end_tag_params|
@blank &&= body.blank? @blank &&= body.blank?
@@ -60,6 +67,9 @@ module Liquid
# pass it to the current block for special handling or error reporting # pass it to the current block for special handling or error reporting
unknown_tag(end_tag_name, end_tag_params, tokens) unknown_tag(end_tag_name, end_tag_params, tokens)
end end
ensure
parse_context.depth -= 1
end
true true
end end

View File

@@ -41,16 +41,22 @@ module Liquid
end end
def evaluate(context = Context.new) def evaluate(context = Context.new)
result = interpret_condition(left, right, operator, context) condition = self
result = nil
loop do
result = interpret_condition(condition.left, condition.right, condition.operator, context)
case @child_relation case condition.child_relation
when :or when :or
result || @child_condition.evaluate(context) break if result
when :and when :and
result && @child_condition.evaluate(context) break unless result
else else
result break
end end
condition = condition.child_condition
end
result
end end
def or(condition) def or(condition)
@@ -75,6 +81,10 @@ module Liquid
"#<Condition #{[@left, @operator, @right].compact.join(' '.freeze)}>" "#<Condition #{[@left, @operator, @right].compact.join(' '.freeze)}>"
end end
protected
attr_reader :child_relation, :child_condition
private private
def equal_variables(left, right) def equal_variables(left, right)

View File

@@ -89,7 +89,7 @@ module Liquid
# Push new local scope on the stack. use <tt>Context#stack</tt> instead # Push new local scope on the stack. use <tt>Context#stack</tt> instead
def push(new_scope = {}) def push(new_scope = {})
@scopes.unshift(new_scope) @scopes.unshift(new_scope)
raise StackLevelError, "Nesting too deep".freeze if @scopes.length > 100 raise StackLevelError, "Nesting too deep".freeze if @scopes.length > Block::MAX_DEPTH
end end
# Merge a hash of variables in the current local scope # Merge a hash of variables in the current local scope

View File

@@ -1,12 +1,13 @@
module Liquid module Liquid
class ParseContext class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :warnings, :error_mode attr_reader :partial, :warnings, :error_mode
def initialize(options = {}) def initialize(options = {})
@template_options = options ? options.dup : {} @template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new @locale = @template_options[:locale] ||= I18n.new
@warnings = [] @warnings = []
self.depth = 0
self.partial = false self.partial = false
end end

View File

@@ -33,7 +33,7 @@ module Liquid
end end
def escape(input) def escape(input)
CGI.escapeHTML(input).untaint unless input.nil? CGI.escapeHTML(input.to_s).untaint unless input.nil?
end end
alias_method :h, :escape alias_method :h, :escape
@@ -42,11 +42,11 @@ module Liquid
end end
def url_encode(input) def url_encode(input)
CGI.escape(input) unless input.nil? CGI.escape(input.to_s) unless input.nil?
end end
def url_decode(input) def url_decode(input)
CGI.unescape(input) unless input.nil? CGI.unescape(input.to_s) unless input.nil?
end end
def slice(input, offset, length = nil) def slice(input, offset, length = nil)

View File

@@ -46,6 +46,9 @@ module Liquid
class For < Block class For < Block
Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o
attr_reader :collection_name
attr_reader :variable_name
def initialize(tag_name, markup, options) def initialize(tag_name, markup, options)
super super
@from = @limit = nil @from = @limit = nil
@@ -126,7 +129,7 @@ module Liquid
end end
collection = context.evaluate(@collection_name) collection = context.evaluate(@collection_name)
collection = collection.to_a if collection.is_a?(Range) collection = collection.step(1).to_a if collection.is_a?(Range)
limit = context.evaluate(@limit) limit = context.evaluate(@limit)
to = limit ? limit.to_i + from : nil to = limit ? limit.to_i + from : nil

View File

@@ -83,17 +83,20 @@ module Liquid
def strict_parse(markup) def strict_parse(markup)
p = Parser.new(markup) p = Parser.new(markup)
condition = parse_binary_comparison(p) condition = parse_binary_comparisons(p)
p.consume(:end_of_string) p.consume(:end_of_string)
condition condition
end end
def parse_binary_comparison(p) def parse_binary_comparisons(p)
condition = parse_comparison(p) condition = parse_comparison(p)
if op = (p.id?('and'.freeze) || p.id?('or'.freeze)) first_condition = condition
condition.send(op, parse_binary_comparison(p)) while op = (p.id?('and'.freeze) || p.id?('or'.freeze))
child_condition = parse_comparison(p)
condition.send(op, child_condition)
condition = child_condition
end end
condition first_condition
end end
def parse_comparison(p) def parse_comparison(p)

View File

@@ -63,4 +63,18 @@ class SecurityTest < Minitest::Test
assert_equal [], (Symbol.all_symbols - current_symbols) assert_equal [], (Symbol.all_symbols - current_symbols)
end end
def test_max_depth_nested_blocks_does_not_raise_exception
depth = Liquid::Block::MAX_DEPTH
code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
assert_equal "rendered", Template.parse(code).render!
end
def test_more_than_max_depth_nested_blocks_raises_exception
depth = Liquid::Block::MAX_DEPTH + 1
code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth
assert_raises(Liquid::StackLevelError) do
Template.parse(code).render!
end
end
end # SecurityTest end # SecurityTest

View File

@@ -128,8 +128,16 @@ class StandardFiltersTest < Minitest::Test
def test_escape def test_escape
assert_equal '&lt;strong&gt;', @filters.escape('<strong>') assert_equal '&lt;strong&gt;', @filters.escape('<strong>')
assert_equal nil, @filters.escape(nil) assert_equal '1', @filters.escape(1)
assert_equal '2001-02-03', @filters.escape(Date.new(2001, 2, 3))
assert_nil @filters.escape(nil)
end
def test_h
assert_equal '&lt;strong&gt;', @filters.h('<strong>') assert_equal '&lt;strong&gt;', @filters.h('<strong>')
assert_equal '1', @filters.h(1)
assert_equal '2001-02-03', @filters.h(Date.new(2001, 2, 3))
assert_nil @filters.h(nil)
end end
def test_escape_once def test_escape_once
@@ -138,14 +146,18 @@ class StandardFiltersTest < Minitest::Test
def test_url_encode def test_url_encode
assert_equal 'foo%2B1%40example.com', @filters.url_encode('foo+1@example.com') assert_equal 'foo%2B1%40example.com', @filters.url_encode('foo+1@example.com')
assert_equal nil, @filters.url_encode(nil) assert_equal '1', @filters.url_encode(1)
assert_equal '2001-02-03', @filters.url_encode(Date.new(2001, 2, 3))
assert_nil @filters.url_encode(nil)
end end
def test_url_decode def test_url_decode
assert_equal 'foo bar', @filters.url_decode('foo+bar') assert_equal 'foo bar', @filters.url_decode('foo+bar')
assert_equal 'foo bar', @filters.url_decode('foo%20bar') assert_equal 'foo bar', @filters.url_decode('foo%20bar')
assert_equal 'foo+1@example.com', @filters.url_decode('foo%2B1%40example.com') assert_equal 'foo+1@example.com', @filters.url_decode('foo%2B1%40example.com')
assert_equal nil, @filters.url_decode(nil) assert_equal '1', @filters.url_decode(1)
assert_equal '2001-02-03', @filters.url_decode(Date.new(2001, 2, 3))
assert_nil @filters.url_decode(nil)
end end
def test_truncatewords def test_truncatewords
@@ -330,7 +342,7 @@ class StandardFiltersTest < Minitest::Test
assert_equal "#{Date.today.year}", @filters.date('today', '%Y') assert_equal "#{Date.today.year}", @filters.date('today', '%Y')
assert_equal "#{Date.today.year}", @filters.date('Today', '%Y') assert_equal "#{Date.today.year}", @filters.date('Today', '%Y')
assert_equal nil, @filters.date(nil, "%B") assert_nil @filters.date(nil, "%B")
assert_equal '', @filters.date('', "%B") assert_equal '', @filters.date('', "%B")
@@ -343,8 +355,8 @@ class StandardFiltersTest < Minitest::Test
def test_first_last def test_first_last
assert_equal 1, @filters.first([1, 2, 3]) assert_equal 1, @filters.first([1, 2, 3])
assert_equal 3, @filters.last([1, 2, 3]) assert_equal 3, @filters.last([1, 2, 3])
assert_equal nil, @filters.first([]) assert_nil @filters.first([])
assert_equal nil, @filters.last([]) assert_nil @filters.last([])
end end
def test_replace def test_replace

View File

@@ -48,6 +48,10 @@ HERE
def test_for_with_variable_range def test_for_with_variable_range
assert_template_result(' 1 2 3 ', '{%for item in (1..foobar) %} {{item}} {%endfor%}', "foobar" => 3) assert_template_result(' 1 2 3 ', '{%for item in (1..foobar) %} {{item}} {%endfor%}', "foobar" => 3)
assert_template_result(' 1.0 2.0 3.0 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1..3.0))
assert_template_result(' 1.0 2.0 3.0 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1.0..3))
assert_template_result(' 1.0 2.0 3.0 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1.0..3.0))
assert_template_result(' 1.5 2.5 ', '{%for item in foobar %} {{item}} {%endfor%}', "foobar" => (1.5..3))
end end
def test_for_with_hash_value_range def test_for_with_hash_value_range

View File

@@ -137,7 +137,7 @@ class IncludeTagTest < Minitest::Test
Liquid::Template.file_system = infinite_file_system.new Liquid::Template.file_system = infinite_file_system.new
assert_raises(Liquid::StackLevelError, SystemStackError) do assert_raises(Liquid::StackLevelError) do
Template.parse("{% include 'loop' %}").render! Template.parse("{% include 'loop' %}").render!
end end
end end

View File

@@ -2,7 +2,6 @@
ENV["MT_NO_EXPECTATIONS"] = "1" ENV["MT_NO_EXPECTATIONS"] = "1"
require 'minitest/autorun' require 'minitest/autorun'
require 'spy/integration'
$LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib')) $LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib'))
require 'liquid.rb' require 'liquid.rb'

View File

@@ -65,8 +65,8 @@ class ConditionUnitTest < Minitest::Test
end end
def test_hash_compare_backwards_compatibility def test_hash_compare_backwards_compatibility
assert_equal nil, Condition.new({}, '>', 2).evaluate assert_nil Condition.new({}, '>', 2).evaluate
assert_equal nil, Condition.new(2, '>', {}).evaluate assert_nil Condition.new(2, '>', {}).evaluate
assert_equal false, Condition.new({}, '==', 2).evaluate assert_equal false, Condition.new({}, '==', 2).evaluate
assert_equal true, Condition.new({ 'a' => 1 }, '==', { 'a' => 1 }).evaluate assert_equal true, Condition.new({ 'a' => 1 }, '==', { 'a' => 1 }).evaluate
assert_equal true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate assert_equal true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate

View File

@@ -70,10 +70,6 @@ class ContextUnitTest < Minitest::Test
@context = Liquid::Context.new @context = Liquid::Context.new
end end
def teardown
Spy.teardown
end
def test_variables def test_variables
@context['string'] = 'string' @context['string'] = 'string'
assert_equal 'string', @context['string'] assert_equal 'string', @context['string']
@@ -98,12 +94,12 @@ class ContextUnitTest < Minitest::Test
assert_equal false, @context['bool'] assert_equal false, @context['bool']
@context['nil'] = nil @context['nil'] = nil
assert_equal nil, @context['nil'] assert_nil @context['nil']
assert_equal nil, @context['nil'] assert_nil @context['nil']
end end
def test_variables_not_existing def test_variables_not_existing
assert_equal nil, @context['does_not_exist'] assert_nil @context['does_not_exist']
end end
def test_scoping def test_scoping
@@ -185,7 +181,7 @@ class ContextUnitTest < Minitest::Test
@context['test'] = 'test' @context['test'] = 'test'
assert_equal 'test', @context['test'] assert_equal 'test', @context['test']
@context.pop @context.pop
assert_equal nil, @context['test'] assert_nil @context['test']
end end
def test_hierachical_data def test_hierachical_data
@@ -300,7 +296,7 @@ class ContextUnitTest < Minitest::Test
@context['hash'] = { 'first' => 'Hello' } @context['hash'] = { 'first' => 'Hello' }
assert_equal 1, @context['array.first'] assert_equal 1, @context['array.first']
assert_equal nil, @context['array["first"]'] assert_nil @context['array["first"]']
assert_equal 'Hello', @context['hash["first"]'] assert_equal 'Hello', @context['hash["first"]']
end end
@@ -450,14 +446,10 @@ class ContextUnitTest < Minitest::Test
assert_equal @context, @context['category'].context assert_equal @context, @context['category'].context
end end
def test_use_empty_instead_of_any_in_interrupt_handling_to_avoid_lots_of_unnecessary_object_allocations def test_interrupt_avoids_object_allocations
mock_any = Spy.on_instance_method(Array, :any?) assert_no_object_allocations do
mock_empty = Spy.on_instance_method(Array, :empty?)
@context.interrupt? @context.interrupt?
end
refute mock_any.has_been_called?
assert mock_empty.has_been_called?
end end
def test_context_initialization_with_a_proc_in_environment def test_context_initialization_with_a_proc_in_environment
@@ -480,4 +472,18 @@ class ContextUnitTest < Minitest::Test
context = Context.new context = Context.new
assert_equal 'hi', context.apply_global_filter('hi') assert_equal 'hi', context.apply_global_filter('hi')
end end
private
def assert_no_object_allocations
unless RUBY_ENGINE == 'ruby'
skip "stackprof needed to count object allocations"
end
require 'stackprof'
profile = StackProf.run(mode: :object) do
yield
end
assert_equal 0, profile[:samples]
end
end # ContextTest end # ContextTest