Ruby: Parse, replace, and evaluate a string formul

2019-04-02 16:20发布

I'm creating a simple Ruby on Rails survey application for a friend's psychological survey project. So we have surveys, each survey has a bunch of questions, and each question has one of the options participants can choose from. Nothing exciting.

One of the interesting aspects is that each answer option has a score value associated with it. And so for each survey a total score needs to be calculated based on these values.

Now my idea is instead of hard-coding calculations is to allow user add a formula by which the total survey score will be calculated. Example formulas:

"Q1 + Q2 + Q3"
"(Q1 + Q2 + Q3) / 3"
"(10 - Q1) + Q2 + (Q3 * 2)"

So just basic math (with some extra parenthesis for clarity). The idea is to keep the formulas very simple such that anyone with basic math can enter them without resolving to some fancy syntax.

My idea is to take any given formula and replace placeholders such as Q1, Q2, etc with the score values based on what the participant chooses. And then eval() the newly formed string. Something like this:

f = "(Q1 + Q2 + Q3) / 2"  # some crazy formula for this survey
values = {:Q1 => 1, :Q2 => 2, :Q3 => 2}  # values for substitution 
result = f.gsub(/(Q\d+)/) {|m| values[$1.to_sym] }   # string to be eval()-ed
eval(result)

So my questions are:

  1. Is there a better way to do this? I'm open to any suggestions.

  2. How to handle formulas where not all placeholders were successfully replaced (e.g. one question wasn't answered)? Ex: {:Q2 => 2} wasn't in values hash? My idea was to rescue eval() but it wouldn't fail in this case coz (1 + + 2) / 2 can still be eval()-ed... any thoughts?

  3. How to get proper result? Should be 2.5, but due to integer arithmetic, it will truncate to 2. I can't expect people who provide the correct formula (e.g. / 2.0 ) to understand this nuance.

  4. I do not expect this, but how to best protect eval() from abuse (e.g. bad formula, manipulated values coming in)? Example: f = 'system("ruby -v"); (Q1 + (Q2 / 3) + Q3 + (Q4 * 2)) / 2 '

Thank you!

5条回答
迷人小祖宗
2楼-- · 2019-04-02 16:50

It might not be worth the effort, but if I were to do this I would use Treetop to define a parsing grammar. There are even examples out there for using PEG-style grammars like this for simple arithmetic, so you'd be 90% of the way for the grammar, and most of the way towards evaluating the weighting.

查看更多
Evening l夕情丶
3楼-- · 2019-04-02 16:52

Re 2) Even though that's ugly, you could just create a Hash with default values, and make sure that that fails when to_s is called on it (I did say that's ugly, right?):

>> class NaN ; def to_s; raise ArgumentError ; end; end #=> nil
>> h = Hash.new { NaN.new } #=> {}
>> h[:q1] = 12 #=> 12
>> h[:q1] #=> 12
>> h[:q2]
ArgumentError: ArgumentError

Re 3) Just make sure you have at least one float in your calculation. The easiest way would be to just turn all of the provided values into floats during the replacements:

>> result = f.gsub(/(Q\d+)/) {|m| values[$1.to_sym].to_f } #=> "(1.0 + 2.0 + 2.0) / 2"
>> eval result #=> 2.5

Re 4) you might want to read up on $SAFE. The "Pickaxe" actually contains an example about evaling something entered in a web form:

http://ruby-doc.org/docs/ProgrammingRuby/html/taint.html

This is if you really wanna go down the eval route, don't ignore the alternatives provided in this discussion.

查看更多
该账号已被封号
4楼-- · 2019-04-02 17:05

OK, now it's totally safe. I swear!

I would normally clone the formula variable but in this case since you're worried about a hostile user I cleaned the variable in place:

class Evaluator

  def self.formula(formula, values)
    # remove anything but Q's, numbers, ()'s, decimal points, and basic math operators 
    formula.gsub!(/((?![qQ0-9\s\.\-\+\*\/\(\)]).)*/,'').upcase!
    begin
      formula.gsub!(/Q\d+/) { |match|
        ( 
          values[match.to_sym] && 
          values[match.to_sym].class.ancestors.include?(Numeric) ?
          values[match.to_sym].to_s :
          '0'
        )+'.0'
      }
      instance_eval(formula)
    rescue Exception => e
      e.inspect
    end
  end

end

f = '(q1 + (q2 / 3) + q3 + (q4 * 2))'  # some crazy formula for this survey
values = {:Q2 => 1, :Q4 => 2}  # values for substitution 
puts "formula: #{f} = #{Evaluator.formula(f,values)}"  
=> formula: (0.0 + (1.0 / 3) + 0.0 + (2.0 * 2)) = 4.333333333333333

f = '(Q1 + (Q2 / 3) + Q3 + (Q4 * 2)) / 2'  # some crazy formula for this survey
values = {:Q1 => 1, :Q3 => 2}  # values for substitution 
puts "formula: #{f} = #{Evaluator.formula(f,values)}"  
=> formula: (1.0 + (0.0 / 3) + 2.0 + (0.0 * 2)) / 2 = 1.5

f = '(Q1 + (Q2 / 3) + Q3 + (Q4 * 2)) / 2'  # some crazy formula for this survey
values = {:Q1 => 'delete your hard drive', :Q3 => 2}  # values for substitution 
puts "formula: #{f} = #{Evaluator.formula(f,values)}"  
=> formula: (0.0 + (0.0 / 3) + 2.0 + (0.0 * 2)) / 2 = 1.0

f = 'system("ruby -v")'  # some crazy formula for this survey
values = {:Q1 => 'delete your hard drive', :Q3 => 2}  # values for substitution 
puts "formula: #{f} = #{Evaluator.formula(f,values)}"  
=> formula: ( -) = #<SyntaxError: (eval):1: syntax error, unexpected ')'>
查看更多
做自己的国王
5楼-- · 2019-04-02 17:07

Use Dentaku:

Dentaku is a parser and evaluator for a mathematical and logical formula language that allows run-time binding of values to variables referenced in the formulas. It is intended to safely evaluate untrusted expressions without opening security holes.

查看更多
Luminary・发光体
6楼-- · 2019-04-02 17:12

You can use RubyParser to interpret the expression e iterate by the nodes to check if exist any dangerous code, like a function call. Look:

require 'ruby_parser'
def valid_formula?(str, consts=[])
  !!valid_formula_node?(RubyParser.new.process(str), consts)
rescue Racc::ParseError
  false
end
def valid_formula_node?(node, consts)
  case node.shift
  when :call
    node[1].to_s !~ /^[a-z_0-9]+$/i and
    valid_formula_node?(node[0], consts) and
    valid_formula_node?(node[2], consts)
  when :arglist
    node.all? {|inner| valid_formula_node?(inner, consts) }
  when :lit
    Numeric === node[0]
  when :const
    consts.include? node[0]
  end
end

This just allow operators, numbers and specifc constants.

valid_formula?("(Q1 + Q2 + Q3) / 2", [:Q1, :Q2, :Q3]) #=> true
valid_formula?("exit!", [:Q1, :Q2, :Q3])              #=> false
valid_formula?("!(%&$)%*", [:Q1, :Q2, :Q3])           #=> false
查看更多
登录 后发表回答