SafeExpression

SafeExpression evaluates a small expression language against values in an Elixir map. Use it for configurable rules, filters, and routing decisions that need comparisons and boolean logic but must not execute Elixir code.

The language supports literals, comparisons, boolean logic, and string-keyed path lookup. The library has no runtime dependencies.

SafeExpression uses a hand-written tokenizer, parser, and evaluator instead of Code.eval_*. Expressions cannot call functions, spawn processes, or perform I/O.

Evaluation does not limit CPU time or memory use. Applications that accept untrusted expressions should limit the length of source before they call SafeExpression.eval/2.

Installation

Add the Hex package to mix.exs:

{:safe_expression, "~> 0.1.0"}

To use the tagged source directly, add the Git dependency:

{:safe_expression,
git: "https://github.com/logandonley/safe_expression.git",
tag: "v0.1.0"}

Usage

bindings = %{
"count" => 10,
"kind" => "webhook",
"user" => %{"active" => true}
}
SafeExpression.eval(~s(count >= 5 && kind == "webhook"), bindings)
#=> {:ok, true}
SafeExpression.eval("user.active", bindings)
#=> {:ok, true}
SafeExpression.eval("payload", %{"payload" => {:queued, 42}})
#=> {:ok, {:queued, 42}}

eval/2 returns an Elixir value, which is not necessarily a boolean. A bare path can return any term supplied by the caller. A literal returns its corresponding Elixir value.

Language

The grammar is listed from lowest to highest precedence:

expr := or
or := and ("||" and)*
and := eq ("&&" eq)*
eq := rel (("==" | "!=") rel)*
rel := unary (("<" | ">" | "<=" | ">=") unary)*
unary := "!" unary | primary
primary := number | string | "true" | "false" | "null" | "nil"
| path | "(" expr ")"
path := ident ("." ident)*

Operators at the same precedence are left-associative. Parentheses override precedence.

Identifiers begin with an ASCII letter or underscore and continue with ASCII letters, digits, or underscores. Spaces, tabs, newlines, and carriage returns are whitespace.

Number literals are non-negative integers or floats. Negative numbers can still be compared when they come from bindings. String literals can use single or double quotes. A backslash escapes the next character verbatim. For example, "a\nb" evaluates to "anb", not to a string that contains a newline.

nil is an alias for the null literal.

Bindings and value semantics

Paths read string keys only:

SafeExpression.eval("user.name", %{"user" => %{"name" => "Ada"}})
#=> {:ok, "Ada"}
SafeExpression.eval("value", %{value: 42})
#=> {:ok, nil}

A missing segment, a non-map intermediate value, and an explicitly stored nil all resolve to nil. The language cannot distinguish those cases.

Bindings may contain any Elixir term:

Errors

When source is a binary and bindings is a map, malformed expressions and operator type mismatches return {:error, reason}. The complete reason contract is:

:invalid_utf8
:unterminated_string
:unexpected_end
:missing_closing_parenthesis
:trailing_dot
:unexpected_token
{:unexpected_character, character}
{:invalid_number, source}
{:not_boolean, value}
{:not_comparable, operator, left, right}

character and the source value in {:invalid_number, source} are binaries. operator is one of :lt, :gt, :lte, or :gte. eval/2 raises FunctionClauseError when source is not a binary or bindings is not a map.

License

SafeExpression uses the MIT License.