lib/dune.ex

defmodule Dune do
  @moduledoc """
  A sandbox for Elixir to safely evaluate untrusted code from user input.

  ## Features

  - only authorized modules and functions can be executed (see
  `Dune.Allowlist.Default`)
  - no access to environment variables, file system, network...
  - code executed in an isolated process
  - execution within configurable limits: timeout, maximum reductions and memory
  (inspired by [Luerl](https://github.com/rvirding/luerl))
  - captured standard output
  - atoms, without atom leaks: parsing and runtime do not
  [leak atoms](https://hexdocs.pm/elixir/String.html#to_atom/1) (i.e. does not
  keep
  [filling the atom table](https://learnyousomeerlang.com/starting-out-for-real#atoms)
  until the VM crashes)
  - modules, without actual module creation: Dune does not let users define any
  actual module (would leak memory and modify the state of the VM globally), but
  `defmodule` simulates the basic behavior of a module, including private and
  recursive functions

  The list of modules and functions authorized by default is defined by the
  `Dune.Allowlist.Default` module, but this list can be extended and customized
  (at your own risk!) using `Dune.Allowlist`.

  If you need to keep the state between evaluations, you might consider
  `Dune.Session`.

  """

  alias Dune.{Success, Failure, Parser, Eval, Opts}

  @doc ~S"""
  Evaluates the `string` in the sandbox.

  Available options are detailed in `Dune.Opts`.

  Returns a `Dune.Success` struct if the execution went successfully,
  a `Dune.Failure` else.

  ## Examples

      iex> Dune.eval_string("IO.puts('Hello world!')")
      %Dune.Success{inspected: ":ok", stdio: "Hello world!\n", value: :ok}

      iex> Dune.eval_string("File.cwd!()")
      %Dune.Failure{message: "** (DuneRestrictedError) function File.cwd!/0 is restricted", type: :restricted}

      iex> Dune.eval_string("List.duplicate(:spam, 100_000)")
      %Dune.Failure{message: "Execution stopped - memory limit exceeded", stdio: "", type: :memory}

      iex> Dune.eval_string("Foo.bar()")
      %Dune.Failure{message: "** (UndefinedFunctionError) function Foo.bar/0 is undefined (module Foo is not available)", type: :exception}

      iex> Dune.eval_string("][")
      %Dune.Failure{message: "unexpected token: ]", type: :parsing}

  Atoms used during parsing and execution might be transformed to prevent atom leaks:

      iex> Dune.eval_string("some_variable = IO.inspect(:some_atom)")
      %Dune.Success{inspected: ":some_atom", stdio: ":some_atom\n", value: :a__Dune_atom_2__}

  The `value` field shows the actual runtime value, but `inspected` and `stdio` are safe to display to the user.

  """
  @spec eval_string(String.t(), Keyword.t()) :: Success.t() | Failure.t()
  def eval_string(string, opts \\ []) when is_binary(string) do
    opts = Opts.validate!(opts)

    string
    |> Parser.parse_string(opts)
    |> Eval.run(opts)
  end

  @doc ~S"""
  Evaluates the quoted `ast` in the sandbox.

  Available options are detailed in `Dune.Opts` (parsing restrictions have no effect)..

  Returns a `Dune.Success` struct if the execution went successfully,
  a `Dune.Failure` else.

  ## Examples

      iex> Dune.eval_quoted(quote do: [1, 2] ++ [3, 4])
      %Dune.Success{inspected: "[1, 2, 3, 4]", stdio: "", value: [1, 2, 3, 4]}

      iex> Dune.eval_quoted(quote do: System.get_env())
      %Dune.Failure{message: "** (DuneRestrictedError) function System.get_env/0 is restricted", type: :restricted}

      iex> Dune.eval_quoted(quote do: Process.sleep(500))
      %Dune.Failure{message: "Execution timeout - 50ms", type: :timeout}

  """
  @spec eval_quoted(Macro.t(), Keyword.t()) :: Success.t() | Failure.t()
  def eval_quoted(ast, opts \\ []) do
    opts = Opts.validate!(opts)

    ast
    |> Parser.parse_quoted(opts)
    |> Eval.run(opts)
  end

  @doc ~S"""
  Returns the AST corresponding to the provided `string`, without leaking atoms.

  Available options are detailed in `Dune.Opts` (runtime restrictions have no effect).

  Returns a `Dune.Success` struct if the execution went successfully,
  a `Dune.Failure` else.

  ## Examples

      iex> Dune.string_to_quoted("1 + 2")
      %Dune.Success{inspected: "{:+, [line: 1], [1, 2]}", stdio: "", value: {:+, [line: 1], [1, 2]}}

      iex> Dune.string_to_quoted("[invalid")
      %Dune.Failure{stdio: "", message: "missing terminator: ] (for \"[\" starting at line 1)", type: :parsing}

  The `pretty` option can make the AST more readable by adding newlines to `inspected`:

      iex> Dune.string_to_quoted("IO.puts(:hello)", pretty: true).inspected
      "{{:., [line: 1], [{:__aliases__, [line: 1], [:IO]}, :puts]}, [line: 1],\n [:hello]}"

      iex> Dune.string_to_quoted("IO.puts(:hello)").inspected
      "{{:., [line: 1], [{:__aliases__, [line: 1], [:IO]}, :puts]}, [line: 1], [:hello]}"

  Since the code isn't executed, there is no allowlist restriction:

      iex> Dune.string_to_quoted("System.halt()")
      %Dune.Success{
        inspected: "{{:., [line: 1], [{:__aliases__, [line: 1], [:System]}, :halt]}, [line: 1], []}",
        stdio: "",
        value: {{:., [line: 1], [{:__aliases__, [line: 1], [:System]}, :halt]}, [line: 1], []}
      }

  Atoms might be transformed during parsing to prevent atom leaks:

      iex> Dune.string_to_quoted("some_variable = :some_atom")
      %Dune.Success{
        inspected: "{:=, [line: 1], [{:some_variable, [line: 1], nil}, :some_atom]}",
        stdio: "",
        value: {:=, [line: 1], [{:a__Dune_atom_1__, [line: 1], nil}, :a__Dune_atom_2__]}
      }

  The `value` field shows the actual runtime value, but `inspected` is safe to display to the user.

  """
  @spec string_to_quoted(String.t(), Keyword.t()) :: Success.t() | Failure.t()
  def string_to_quoted(string, opts \\ []) when is_binary(string) do
    opts = Opts.validate!(opts)
    Parser.string_to_quoted(string, opts)
  end
end