defmodule Formular do require Logger @kernel_functions Formular.DefaultFunctions.kernel_functions() @kernel_macros Formular.DefaultFunctions.kernel_macros() @default_eval_options [] @default_max_heap_size 1_000_000 @default_timeout 5_000 @moduledoc """ A tiny extendable DSL evaluator. It's a wrap around Elixir's `Code.eval_string/3` or `Code.eval_quoted/3`, with the following limitations: - No calling module functions; - No calling some functions which can cause VM to exit; - No sending messages; - (optional) memory usage limit; - (optional) execution time limit. Here's an example using this module to evaluate a discount number against an order struct: ```elixir iex> discount_formula = ~s" ...> case order do ...> # old books get a big promotion ...> %{book: %{year: year}} when year < 2000 -> ...> 0.5 ...> ...> %{book: %{tags: tags}} -> ...> # Elixir books! ...> if ~s{elixir} in tags do ...> 0.9 ...> else ...> 1.0 ...> end ...> ...> _ -> ...> 1.0 ...> end ...> " ...> ...> book_order = %{ ...> book: %{ ...> title: "Elixir in Action", year: 2019, tags: ["elixir"] ...> } ...> } ...> ...> Formular.eval(discount_formula, [order: book_order]) {:ok, 0.9} ``` The code being evaluated is just a piece of Elixir code, so it can be expressive when describing business rules. ## Literals ```elixir # number iex> Formular.eval("1", []) {:ok, 1} # <- note that it's an integer # plain string iex> Formular.eval(~s["some text"], []) {:ok, "some text"} # atom iex> Formular.eval(":foo", []) {:ok, :foo} # list iex> Formular.eval("[:foo, Bar]", []) {:ok, [:foo, Bar]} # keyword list iex> Formular.eval("[a: 1, b: :hi]", []) {:ok, [a: 1, b: :hi]} ``` ## Variables Variables can be passed within the `binding` parameter. ```elixir # bound value iex> Formular.eval("1 + foo", [foo: 42]) {:ok, 43} ``` ## Functions in the code ### Kernel functions and macros Kernel functions and macros are limitedly supported. Only a picked list of them are supported out of the box so that dangerouse functions such as `Kernel.exit/1` will not be invoked. Supported functions from `Kernel` are: ```elixir #{inspect(@kernel_functions, pretty: true)} ``` Supported macros from `Kernel` are: ```elixir #{inspect(@kernel_macros, pretty: true)} ``` Example: ```elixir # Kernel function iex> Formular.eval("min(5, 100)", []) {:ok, 5} iex> Formular.eval("max(5, 100)", []) {:ok, 100} ``` ### Custom functions Custom functions can be provided in two ways, either in a binding lambda: ```elixir # bound function iex> Formular.eval("1 + add.(-1, 5)", [add: &(&1 + &2)]) {:ok, 5} ``` ... or with a context module: ```elixir iex> defmodule MyContext do ...> def foo() do ...> 42 ...> end ...> end ...> Formular.eval("10 + foo", [], context: MyContext) {:ok, 52} ``` **Directly calling to module functions in the code are disallowed** for security reason. For example: ```elixir iex> Formular.eval("Map.new", []) {:error, :no_calling_module_function} iex> Formular.eval("min(0, :os.system_time())", []) {:error, :no_calling_module_function} ``` ## Evaluating AST instead of plain string code You may want to use AST instead of string for performance consideration. In this case, an AST can be passed to `eval/3`: ```elixir iex> "a = b = 10; a * b" |> Code.string_to_quoted!() |> Formular.eval([]) {:ok, 100} ``` ...so that you don't have to parse it every time before evaluating it. ## Limiting execution time The execution time can be limited with the `:timeout` option: ```elixir iex> sleep = fn -> :timer.sleep(:infinity) end ...> Formular.eval("sleep.()", [sleep: sleep], timeout: 10) {:error, :timeout} ``` Default timeout is 5_000 milliseconds. ## Limiting heap usage The evaluation can also be limited in heap size, with `:max_heap_size` option. When the limit is exceeded, an error `{:error, :killed}` will be returned. Example: ```elixir iex> code = "for a <- 0..999_999_999_999, do: to_string(a)" ...> Formular.eval(code, [], timeout: :infinity, max_heap_size: 1_000) {:error, :killed} ``` The default max heap size is 1_000_000 words. """ @supervisor Formular.Tasks @type code :: binary() | Macro.t() @type option :: {:context, module()} | {:max_heap_size, non_neg_integer()} | {:timeout, non_neg_integer() | :infinity} @type options :: [option()] @type eval_result :: {:ok, term()} | {:error, term()} @doc """ Evaluate the code with binding context. ## Parameters - `code` : code to eval. Could be a binary, or parsed AST. - `binding` : the variable binding to support the evaluation - `options` : current these options are supported: - `context` : The module to import before evaluation. - `timeout` : A timer used to terminate the evaluation after x milliseconds. `#{@default_timeout}` milliseconds by default. - `max_heap_size` : A limit on heap memory usage. If set to zero, the max heap size limit is disabled. `#{@default_max_heap_size}` words by default. ## Examples ```elixir iex> Formular.eval("1", []) {:ok, 1} iex> Formular.eval(~s["some text"], []) {:ok, "some text"} iex> Formular.eval("min(5, 100)", []) {:ok, 5} iex> Formular.eval("max(5, 100)", []) {:ok, 100} iex> Formular.eval("count * 5", [count: 6]) {:ok, 30} iex> Formular.eval("add.(1, 2)", [add: &(&1 + &2)]) {:ok, 3} iex> Formular.eval("Map.new", []) {:error, :no_calling_module_function} iex> Formular.eval("Enum.count([1])", []) {:error, :no_calling_module_function} iex> Formular.eval("min(0, :os.system_time())", []) {:error, :no_calling_module_function} iex> Formular.eval("inspect.(System.A)", [inspect: &Kernel.inspect/1]) {:ok, "System.A"} iex> Formular.eval "f = &IO.inspect/1", [] {:error, :no_calling_module_function} iex> Formular.eval("mod = IO; mod.inspect(1)", []) {:error, :no_calling_module_function} iex> "a = b = 10; a * b" |> Code.string_to_quoted!() |> Formular.eval([]) {:ok, 100} ``` """ @spec eval(code, binding :: keyword(), options()) :: eval_result() def eval(code, binding, opts \\ @default_eval_options) def eval(text, binding, opts) when is_binary(text) do with {:ok, ast} <- Code.string_to_quoted(text) do eval_ast(ast, binding, opts) end end def eval(ast, binding, opts), do: eval_ast(ast, binding, opts) defp eval_ast(ast, binding, opts) do with :ok <- valid?(ast) do spawn_and_eval(ast, binding, opts) end end defp spawn_and_eval(ast, binding, opts) do parent = self() context = Keyword.get(opts, :context) timeout = Keyword.get(opts, :timeout, @default_timeout) max_heap_size = Keyword.get(opts, :max_heap_size, @default_max_heap_size) {:ok, pid} = Task.Supervisor.start_child( @supervisor, fn -> Process.flag(:max_heap_size, max_heap_size) ret = do_eval(ast, binding, context) send(parent, {:result, ret}) end ) ref = Process.monitor(pid) receive do {:result, ret} -> Process.demonitor(ref, [:flush]) ret {:DOWN, ^ref, :process, ^pid, reason} -> Logger.error("Evaluating process killed, reason: #{inspect(reason)}") {:error, :killed} after timeout -> Process.demonitor(ref, [:flush]) :ok = Task.Supervisor.terminate_child(@supervisor, pid) {:error, :timeout} end end defp do_eval(ast, binding, context) do {ret, _binding} = ast |> Code.eval_quoted( binding, functions: imported_functions(context), macros: imported_macros(context), requires: [Elixir.Kernel] ) {:ok, ret} rescue err -> {:error, err} end defp imported_functions(nil), do: [{Elixir.Kernel, @kernel_functions}] defp imported_functions(mod) when is_atom(mod), do: [ {mod, mod.__info__(:functions)}, {Elixir.Kernel, @kernel_functions} ] defp imported_macros(nil), do: [{Elixir.Kernel, @kernel_macros}] defp imported_macros(mod) when is_atom(mod), do: [ {mod, mod.__info__(:macros)}, {Elixir.Kernel, @kernel_macros} ] defp valid?(ast) do # credo:disable-for-next-line case check_rules(ast) do false -> :ok ret -> {:error, ret} end end defp check_rules({:., _pos, [Access, :get]}), do: false defp check_rules({:., _pos, [_callee, func]}) when is_atom(func), do: :no_calling_module_function defp check_rules({:import, _pos, [_ | _]}), do: :no_import_or_require defp check_rules({:require, _pos, [_]}), do: :no_import_or_require defp check_rules({op, _pos, args}), do: check_rules(op) || check_rules(args) defp check_rules([]), do: false defp check_rules([ast | rest]), do: check_rules(ast) || check_rules(rest) defp check_rules(_), do: false end