Repository: functional-rewire/dune Branch: main Commit: 8a6f7ddfb6a3 Files: 57 Total size: 191.6 KB Directory structure: gitextract_bo31q11i/ ├── .formatter.exs ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── lib/ │ ├── dune/ │ │ ├── allowlist/ │ │ │ ├── default.ex │ │ │ ├── docs.ex │ │ │ └── spec.ex │ │ ├── allowlist.ex │ │ ├── atom_mapping.ex │ │ ├── eval/ │ │ │ ├── env.ex │ │ │ ├── fake_module.ex │ │ │ ├── function_clause_error.ex │ │ │ ├── macro_env.ex │ │ │ └── process.ex │ │ ├── eval.ex │ │ ├── failure.ex │ │ ├── helpers/ │ │ │ ├── diagnostics.ex │ │ │ └── term_checker.ex │ │ ├── opts.ex │ │ ├── parser/ │ │ │ ├── atom_encoder.ex │ │ │ ├── compile_env.ex │ │ │ ├── debug.ex │ │ │ ├── real_module.ex │ │ │ ├── safe_ast.ex │ │ │ ├── sanitizer.ex │ │ │ ├── string_parser.ex │ │ │ └── unsafe_ast.ex │ │ ├── parser.ex │ │ ├── session.ex │ │ ├── shims/ │ │ │ ├── atom.ex │ │ │ ├── enum.ex │ │ │ ├── io.ex │ │ │ ├── json.ex │ │ │ ├── kernel.ex │ │ │ ├── list.ex │ │ │ └── string.ex │ │ └── success.ex │ └── dune.ex ├── mix.exs └── test/ ├── dune/ │ ├── allowlist/ │ │ └── default_test.exs │ ├── allowlist_test.exs │ ├── atom_mapping_test.exs │ ├── opts_test.exs │ ├── parser/ │ │ ├── atom_encoder_test.exs │ │ └── string_parser_test.exs │ ├── session_test.exs │ ├── shims_test.exs │ └── validation_test.exs ├── dune_modules_test.exs ├── dune_oom_safety_test.exs ├── dune_quoted_test.exs ├── dune_string_test.exs ├── dune_string_to_quoted_test.exs ├── dune_test.exs └── test_helper.exs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .formatter.exs ================================================ # Used by "mix format" locals_without_parens = [allow: 2] [ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], locals_without_parens: locals_without_parens, export: [locals_without_parens: locals_without_parens] ] ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} env: MIX_ENV: test strategy: matrix: include: - elixir: "1.14.5" otp: "26.0" testArgs: "--exclude=lts_only" - elixir: "1.15.4" otp: "26.0" testArgs: "--exclude=lts_only" - elixir: "1.16.0" otp: "26.0" testArgs: "--exclude=lts_only" - elixir: "1.17.3" otp: "27.0" testArgs: "--exclude=lts_only" - elixir: "1.18.0" otp: "27.0" testArgs: "--exclude=lts_only" - elixir: "1.18.4" otp: "27.0" testArgs: "--exclude=lts_only" - elixir: "1.19.0" otp: "28.1" testArgs: "" steps: - uses: actions/checkout@v2 - uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - name: Install Dependencies run: mix deps.get - name: Check compile warnings run: mix compile --warnings-as-errors - name: Check format run: mix format --check-formatted # TODO add dialyzer? - name: Unit tests run: mix test ${{ matrix.testArgs }} ================================================ FILE: .gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where third-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). dune-*.tar # Temporary files, for example, from tests. /tmp/ ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## Dev ## v0.3.15 (2025-10-19) - Support Elixir 1.19 ## v0.3.14 (2025-07-29) ### Security fixes - Use safe shims for chardata -> string conversions (`List.to_string/1`, ...) in `Dune.Allowlist.Default` - Restrict more unsafe modules and functions in `Dune.Allowlist.Default`: - `:unicode` - `:erts_debug.flat_size` ## v0.3.13 (2025-07-27) - Fix older versions pre-1.18 that don't have `JSON` ## v0.3.12 (2025-07-27) - `Dune.Allowlist.Default` exposes a safe shim for the `JSON` module ## v0.3.11 (2024-12-21) - Enable support for Elixir 1.18 ## v0.3.10 (2024-07-14) - Enable support for Elixir 1.17 ## v0.3.9 (2024-06-25) - `Dune.Allowlist.Default` allows the `Version` module ## v0.3.8 (2024-05-26) ### Bug fixes - Make sure the `Duration` atom is available ## v0.3.7 (2024-05-26) ### Bug fixes - Fix incorrect type definitions, remove unused ones ### Enhancements - `Dune.Allowlist.Default` allows the new `Duration` module and new kernel functions from Elixir 1.17 - Add an `:inspect_sort_maps` option for deterministic outputs - Capture and return parser warnings in `stdio` ## v0.3.6 (2023-12-23) - Support Elixir 1.16 - `Dune.Allowlist.Default` allows `**/2` ## v0.3.5 (2023-11-10) ### Enhancements - Prepare Elixir 1.16 support(handle line-column positions in diagnostics) ## v0.3.4 (2023-09-14) ### Bug fixes - Fix `UndefinedFunctionError` when using external modules in a custom allowlist ## v0.3.3 (2023-08-13) ### Bug fixes - Fix vulnerability allowing an attacker to crash the VM using bitstrings ## v0.3.2 (2023-08-12) ### Enhancements - `dbg/1` uses pretty printing ### Bug fixes - Fix error message on restricted `dbg/0` ## v0.3.1 (2023-08-12) ### Enhancements - Add support for `dbg/1` ### Bug fixes - Properly distinguish user code `throw/1` from internal ones ## v0.3.0 (2023-08-09) ### Breaking changes - Drop support for Elixir 1.13 - Compile errors are now returned as a separate type `:compile_error` ### Enhancements - Support Elixir 1.15 - Capture compile diagnostics (Elixir >= 1.15) ### Bug fixes - Better handle `UndefinedFunctionError` for dynamic module names ## v0.2.6 (2022-10-17) ### Enhancements - Support Elixir 1.14 ## v0.2.5 (2022-08-25) ### Bug fixes - Restrict the use of `:counters` in `Dune.Allowlist.Default`, since it can leak memory ## v0.2.4 (2022-07-13) ### Bug fixes - Validate module names in `defmodule`, reject `nil` or booleans ## v0.2.3 (2022-04-13) ### Bug fixes - `Dune.string_to_quoted/2` quotes modules with `.` correctly - OTP 25 regression: keep a clean stacktrace for exceptions ## v0.2.2 (2022-04-05) ### Enhancements - Add `Dune.string_to_quoted/2` to make it possible to visualize AST - Merged parsing and eval options in a single `Dune.Opts` for simplicity - Add a `pretty` option to inspect result - Better error message when `def/2` and `defp/2` called outside a module ### Breaking changes - Removed Dune.Parser.Opts and Dune.Eval.Opts ## v0.2.1 (2022-03-19) ### Bug fixes - Handle default arguments in functions - Handle conflicting `def` and `defp` with same name/arity ## v0.2.0 (2022-01-02) ### Breaking changes - Support Elixir 1.13, drop support for 1.12 - This fixes a [bug in atoms](https://github.com/elixir-lang/elixir/pull/11313) was due to the Elixir parser ## v0.1.2 (2021-10-17) ### Enhancements - Allow safe functions from the `:erlang` module ### Bug fixes - Fix bug when calling custom function in nested AST ## v0.1.1 (2021-10-16) ### Bug fixes - Prevent atom leaks due to `Code.string_to_quoted/2` not respecting `static_atoms_encoder` - Handle Elixir 1.12 bug on single atom ASTs - Handle atoms prefixed with `Elixir.` properly - Fix inspect for quoted atoms ## v0.1.0 (2021-09-19) - Initial release ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2021 Sabiwara Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Dune [![Hex Version](https://img.shields.io/hexpm/v/dune.svg)](https://hex.pm/packages/dune) [![docs](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/dune/) [![CI](https://github.com/functional-rewire/dune/workflows/CI/badge.svg)](https://github.com/functional-rewire/dune/actions?query=workflow%3ACI) A sandbox for Elixir to safely evaluate untrusted code from user input. [**Try it out on our online playground!**](https://playground.functional-rewire.com/) `Dune` can be useful to develop playgrounds, online REPL, coding games, or customizable business logic. **Warning:** `Dune` cannot offer strong security guarantees (see the [Security guarantees](#security-guarantees) section below). Besides, it is still early stage: expect bugs and vulnerabilities. ## Features - allowlist mechanism (customizable) to restrict execution to safe modules and functions: 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 ```elixir 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("Enum.product(1..100_000)") %Dune.Failure{message: "Execution stopped - reductions limit exceeded", stdio: "", type: :reductions} ``` The list of modules and functions authorized by default is defined by the [`Dune.Allowlist.Default`](https://hexdocs.pm/dune/Dune.Allowlist.Default.html#module-allowed-modules-functions) module, but this list can be extended and customized (at your own risk!) using [`Dune.Allowlist`](https://hexdocs.pm/dune/Dune.Allowlist.html). If you need to keep the state between evaluations, you might consider [`Dune.Session`](https://hexdocs.pm/dune/Dune.Session.html): ```elixir iex> Dune.Session.new() ...> |> Dune.Session.eval_string("x = 1") ...> |> Dune.Session.eval_string("x + 2") #Dune.Session ``` `Dune.string_to_quoted/2` returns the AST corresponding to the provided `string`, without leaking atoms: ```elixir iex> Dune.string_to_quoted("foo(:bar)").inspected "{:foo, [line: 1], [:bar]}" ``` ## Limitations `Dune` supports a fair subset of the base language, but it cannot safely support advanced features (at least at this stage) such as: - custom structs / behaviours / protocols - concurrency / processes / OTP - metaprogramming ## Security guarantees Because of the approch being used, Dune cannot offer strong security guarantees, and should not be considered a sufficient security layer by itself. A best-effort approach is made to prevent attackers from getting outside of the original process and from calling any function/macro outside of the allowlist. However, it is impossible to prove that all escape paths have been completely blocked. Due to how the Erlang VM works, an attacker able to escape the sandbox could get full access to the VM without restriction. See the [EEF guidelines about sandboxing](https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/sandboxing) for more information. Use at your own risk and avoid running it directly on a server with any sensitive access, e.g. to a database. ## Installation The package can be installed by adding `dune` to your list of dependencies in `mix.exs`: ```elixir def deps do [ {:dune, "~> 0.3.15"} ] end ``` Documentation can be found at [https://hexdocs.pm/dune](https://hexdocs.pm/dune). ## FAQ ### Why can I still create atoms? Atoms are converted by the parser and mapped to always use a given set of atoms. So when you type `foo = bar(:baz)`, the atoms `:foo`, `:bar` and `:baz` won't actually be created, but other atoms like `:atom1`, `:atom2` and `:atom3` are going to be used instead. So even if many user run various codes using many different variable and function names, they will all pull from the same pool of atoms. ### Why can I still create modules? Modules are being defined globally within the VM, making it both an isolation concern and a memory concern. But modules are an important part of the Elixir language, and are especially important for learning platforms. Dune implements an alternative `defmodule`, relying on maps of anonymous functions at runtime and should reproduce the basic behavior of actual modules, with support for recursive and private functions. ### Why is the behavior different than Elixir when doing X? As explained above, some parts of the language are actually being completely reimplemented because the original version could not be safely sandboxed: atoms, modules... While these alternative implementation aim to be as close as possible to the original ones, they might differ in some cases, because the code being executed is actually different. ### Why can't I do X? Some parts of the language are being restricted because they present a direct security risk, while some other would need to be reimplemented in an alternative way and therefore need a consequent amount of work that hasn't been done yet. ================================================ FILE: lib/dune/allowlist/default.ex ================================================ defmodule Dune.Allowlist.Default do @moduledoc """ The default `Dune.Allowlist` module to be used to allow or restrict functions and macros that can be safely executed. ## Examples iex> Dune.Allowlist.Default.fun_status(Kernel, :+, 2) :allowed iex> Dune.Allowlist.Default.fun_status(String, :to_atom, 1) :restricted iex> Dune.Allowlist.Default.fun_status(Atom, :to_string, 1) {:shimmed, Dune.Shims.Atom, :to_string} iex> Dune.Allowlist.Default.fun_status(Kernel, :foo, 1) :undefined_function iex> Dune.Allowlist.Default.fun_status(Bar, :foo, 1) :undefined_module iex> Dune.Allowlist.Default.fun_status(Kernel.SpecialForms, :quote, 2) :restricted ## Allowed modules / functions __DUNE_ALLOWLIST_FUNCTIONS__ """ use Dune.Allowlist alias Dune.Shims @special_forms_allowed ~w[ {} %{} <<>> = ^ case cond fn for with :: __aliases__ ]a @kernel_operators ~w[ |> + ++ - -- * ** / <> == === != !== =~ > >= < <= and or && || ! .. ..// ]a @kernel_guards ~w[ is_integer is_binary is_bitstring is_atom is_boolean is_integer is_float is_number is_list is_map is_map_key is_nil is_reference is_tuple is_exception is_struct is_function ]a @kernel_macros ~w[ if unless in match? then tap raise ]a @kernel_sigils ~w[ sigil_C sigil_D sigil_N sigil_R sigil_S sigil_T sigil_U sigil_c sigil_r sigil_s sigil_w ]a @kernel_functions ~w[ abs binary_part bit_size byte_size ceil div elem floor get_and_update_in get_in hd length make_ref map_size max min not pop_in put_elem put_in rem round self tl trunc tuple_size update_in ]a # TODO Remove when dropping support for Elixir 1.16 extra_kernel_functions = if System.version() |> Version.compare("1.17.0-rc.0") != :lt, do: [:to_timeout, :is_non_struct_map], else: [] @kernel_allowed extra_kernel_functions ++ @kernel_operators ++ @kernel_guards ++ @kernel_macros ++ @kernel_sigils ++ @kernel_functions @kernel_shims [ apply: {Shims.Kernel, :safe_apply}, inspect: {Shims.Kernel, :safe_inspect}, to_string: {Shims.Kernel, :safe_to_string}, to_charlist: {Shims.Kernel, :safe_to_charlist}, sigil_w: {Shims.Kernel, :safe_sigil_w}, sigil_W: {Shims.Kernel, :safe_sigil_W}, throw: {Shims.Kernel, :safe_throw}, dbg: {Shims.Kernel, :safe_dbg} ] @erlang_allowed [ :*, :+, :++, :-, :--, :/, :"/=", :<, :"=/=", :"=:=", :"=<", :==, :>, :>=, :abs, :adler32, :adler32_combine, :and, :append_element, :band, :binary_part, :binary_to_float, :binary_to_integer, :binary_to_list, :bit_size, :bitstring_to_list, :bnot, :bor, :bsl, :bsr, :bxor, :byte_size, :ceil, :convert_time_unit, :crc32, :crc32_combine, :date, :delete_element, :div, :element, :float, :float_to_binary, :float_to_list, :floor, :hd, :insert_element, :integer_to_binary, :integer_to_list, :iolist_size, :iolist_to_binary, :iolist_to_iovec, :is_atom, :is_binary, :is_bitstring, :is_boolean, :is_float, :is_function, :is_integer, :is_list, :is_map, :is_map_key, :is_number, :is_pid, :is_port, :is_record, :is_reference, :is_tuple, :length, :list_to_binary, :list_to_bitstring, :list_to_float, :list_to_integer, :localtime, :localtime_to_universaltime, :make_ref, :make_tuple, :map_get, :map_size, :max, :md5, :md5_final, :md5_init, :md5_update, :min, :monotonic_time, :not, :or, :phash2, :ref_to_list, :rem, :round, :setelement, :size, :split_binary, :system_time, :time, :time_offset, :timestamp, :tl, :trunc, :tuple_size, :tuple_to_list, :unique_integer, :universaltime, :universaltime_to_localtime, :xor ] @erlang_shims [ apply: {Shims.Kernel, :safe_apply} ] @io_shims [ puts: {Shims.IO, :puts}, inspect: {Shims.IO, :inspect}, chardata_to_string: {Shims.List, :to_string} ] allow Kernel.SpecialForms, only: @special_forms_allowed allow Kernel, only: @kernel_allowed, shims: @kernel_shims allow Access, :all allow String, except: ~w[to_atom to_existing_atom]a allow Regex, :all allow Map, :all allow MapSet, :all allow Keyword, :all allow Tuple, :all allow List, shims: [to_string: {Shims.List, :to_string}], except: ~w[to_atom to_existing_atom]a allow Enum, shims: [join: {Shims.Enum, :join}, map_join: {Shims.Enum, :map_join}] # TODO double check allow Stream, :all allow Range, :all allow Integer, :all allow Float, :all allow Atom, except: ~w[to_char_list]a, shims: [to_string: {Shims.Atom, :to_string}, to_charlist: {Shims.Atom, :to_charlist}] if Code.ensure_loaded?(JSON) do allow JSON, only: ~w[decode decode!]a, shims: Enum.map(~w[protocol_encode encode! encode_to_iodata!]a, &{&1, {Shims.JSON, &1}}) end allow Date, :all allow DateTime, :all allow NaiveDateTime, :all # TODO Remove when dropping support for Elixir 1.16 if System.version() |> Version.compare("1.17.0-rc.0") != :lt do allow Duration, :all end allow Calendar, except: ~w[put_time_zone_database]a allow Calendar.ISO, :all allow Time, :all allow Base, :all allow URI, :all allow Version, :all allow Bitwise, :all allow Function, only: ~w[identity]a allow IO, only: ~w[iodata_length iodata_to_binary]a, shims: @io_shims allow Process, only: [:sleep] allow :erlang, only: @erlang_allowed, shims: @erlang_shims allow :math, :all allow :binary, :all allow :lists, :all allow :array, :all allow :maps, :all allow :gb_sets, :all allow :gb_trees, :all allow :ordsets, :all allow :orddict, :all allow :proplists, :all allow :queue, :all allow :string, :all allow :rand, :all # note: :unicode is not safe and should be shimmed due to "structural sharing bombs" # note: flat_size is unsafe due to "structural sharing bombs" allow :erts_debug, only: ~w[same size size_shared]a allow :zlib, only: ~w[zip unzip gzip gunzip compress uncompress]a end ================================================ FILE: lib/dune/allowlist/docs.ex ================================================ defmodule Dune.Allowlist.Docs do @moduledoc false def document_allowlist(spec) do spec |> Dune.Allowlist.Spec.list_ordered_modules() |> Enum.map_join("\n", &do_doc_funs/1) end def public_functions(module) when is_atom(module) do case Code.fetch_docs(module) do {:docs_v1, _, _, _, _, _, list} -> for {{:function, function_name, _}, _, _, %{}, %{}} <- list, into: MapSet.new() do function_name end _ -> [] end end defp do_doc_funs({module, grouped_funs}) do public_funs = public_functions(module) head = ["- `", inspect(module), "`"] tail = Enum.map(grouped_funs, fn {status, funs} -> [ "**", format_status(status), "**: " | Enum.map_intersperse(funs, ", ", &format_fun(module, &1, status, public_funs)) ] end) Enum.intersperse([head | tail], "\n - ") |> to_string() end defp format_fun(module, {fun, arity}, status, public_funs) do if fun in public_funs or module in [Kernel, Kernel.SpecialForms] do [ ?[, maybe_strike(status), ?`, Atom.to_string(fun), ?`, maybe_strike(status), "](`", inspect(module), ?., to_string(fun), ?/, to_string(arity), "`)" ] else [ maybe_strike(status), ?`, Atom.to_string(fun), ?`, maybe_strike(status) ] end end defp maybe_strike(:restricted), do: "~~" defp maybe_strike(_status), do: [] defp format_status(:allowed), do: "Allowed" defp format_status(:shimmed), do: "Alernative implementation" defp format_status(:restricted), do: "Restricted" end Dune.Allowlist.Docs.public_functions(:rand) ================================================ FILE: lib/dune/allowlist/spec.ex ================================================ defmodule Dune.Allowlist.Spec do @moduledoc false alias Dune.Allowlist alias Dune.Parser.RealModule @type t :: %__MODULE__{ modules: %{optional(module) => [{atom, non_neg_integer, Allowlist.status()}]} } @enforce_keys [:modules] defstruct @enforce_keys def new do %__MODULE__{modules: %{}} end @spec list_fun_statuses(t) :: list({module, atom, Allowlist.status()}) def list_fun_statuses(%__MODULE__{modules: modules}) do for {module, funs} <- modules, {fun_name, _arity, status} <- funs do {module, fun_name, status} end |> Enum.sort() |> Enum.dedup() end @spec list_ordered_modules(t) :: list({module, {atom, Allowlist.status()}}) def list_ordered_modules(%__MODULE__{modules: modules}) do modules |> Enum.map(fn {module, funs} -> {module, group_funs_by_status(funs)} end) |> Enum.sort() end defp group_funs_by_status(funs) do Enum.group_by( funs, fn {_fun, _arity, status} -> extract_status_atom(status) end, fn {fun, arity, _status} -> {fun, arity} end ) |> Enum.map(fn {status, funs} -> {status, Enum.sort(funs) |> Enum.dedup_by(&elem(&1, 0))} end) |> Enum.sort_by(fn {status, _} -> status_sort(status) end) end defp extract_status_atom(:restricted), do: :restricted defp extract_status_atom(:allowed), do: :allowed defp extract_status_atom({:shimmed, _, _}), do: :shimmed defp status_sort(:allowed), do: 1 defp status_sort(:shimmed), do: 2 defp status_sort(:restricted), do: 3 @spec add_new_module(t, module, :all) :: t def add_new_module(%__MODULE__{modules: modules}, module, _status) when :erlang.map_get(module, modules) != nil do # TODO proper error type raise "ModuleConflict: module #{inspect(module)} already defined" end def add_new_module(spec = %__MODULE__{modules: modules}, module, status) when is_atom(module) do Code.ensure_compiled!(module) functions = RealModule.list_functions(module) |> classify_functions(status) %{spec | modules: Map.put(modules, module, functions)} end defp classify_functions(functions, :all) do Enum.map(functions, fn {fun_name, arity} -> {fun_name, arity, :allowed} end) end defp classify_functions(functions, only: only) when is_list(only) do do_classify(functions, only, :allowed, :restricted) end defp classify_functions(functions, except: except) when is_list(except) do do_classify(functions, except, :restricted, :allowed) end defp classify_functions(functions, opts) do case Keyword.pop(opts, :shims) do {shims, remaining_opts} when is_list(shims) -> new_opts = case remaining_opts do [] -> :all other -> other end classify_functions(functions, new_opts) |> shim_functions(shims) {nil, _} -> raise "Invalid opts #{inspect(opts)}" end end defp do_classify(functions, set_list, member_atom, non_member_atom) do set = to_atom_set(set_list) functions |> Enum.map_reduce(set, fn {fun_name, arity}, acc -> case set do %{^fun_name => _} -> {{fun_name, arity, member_atom}, Map.delete(acc, fun_name)} _ -> {{fun_name, arity, non_member_atom}, acc} end end) |> unwrap_classify() end defp to_atom_set(list) do Enum.each(list, fn atom when is_atom(atom) -> :ok end) :maps.from_keys(list, nil) end defp unwrap_classify({result, remaining}) when remaining == %{}, do: result defp unwrap_classify({_, remaining}) do [{key, _}] = Enum.take(remaining, 1) raise "Unknown function #{key}" end defp shim_functions(functions, shims) do # TODO validate shims Enum.map(functions, fn fun = {fun_name, arity, _status} -> case Keyword.get(shims, fun_name) do nil -> fun {shim_module, shim_fun_name} -> validate_shim!(shim_module, shim_fun_name, arity + 1) {fun_name, arity, {:shimmed, shim_module, shim_fun_name}} end end) end defp validate_shim!(module, fun_name, arity) do Code.ensure_compiled!(module) unless function_exported?(module, fun_name, arity) or macro_exported?(module, fun_name, arity) do raise "Invalid shim: function #{inspect(module)}.#{fun_name}/#{arity} doesn't exist!" end end end ================================================ FILE: lib/dune/allowlist.ex ================================================ defmodule Dune.Allowlist do @moduledoc """ Behaviour to customize the modules and functions that are allowed or restricted. ## Warning: security considerations The default implementation is `Dune.Allowlist.Default`, and should only allow safe functions: no atom leaks, no execution of arbitrary code, no access to the filesystem / network... Defining or extending a custom `Dune.Allowlist` module can introduce security risks or bugs. Please also note that using custom allowlists is still **experimental** and the API for it might change faster than the rest of the library. ## Defining a new allowlist In order to define a custom allowlist from scratch, `use Dune.Allowlist` can be used: defmodule CustomAllowlist do use Dune.Allowlist allow Kernel, only: [:+, :*, :-, :/, :div, :rem] end Dune.eval_string("4 + 9", allowlist: CustomAllowlist) ## Extending an existing allowlist Defining an allowlist from scratch can be both daunting and risky. It is possible to extend an exisiting allowlist instead using the `extend` option: defmodule ExtendedAllowlist do use Dune.Allowlist, extend: Dune.Allowlist.Default allow SomeModule, only: [:authorized] end Dune.eval_string("SomeModule.authorized(123)", allowlist: ExtendedAllowlist) Note: currently, it is not possible to add or restrict functions from modules that have already been specified. ## Documentation generation The list of modules and functions with their status can be generated in the `@moduledoc`. An example can be found in the `Dune.Allowlist.Default` documentation. If the `__DUNE_ALLOWLIST_FUNCTIONS__` string is found in the `@moduledoc` string, it will be replaced. defmodule CustomAllowlist do @moduledoc \"\"\" Only allows simple arithmetic ## Allowlist functions __DUNE_ALLOWLIST_FUNCTIONS__ \"\"\" use Dune.Allowlist allow Kernel, only: [:+, :*, :-, :/, :div, :rem] end """ @type status :: :allowed | :restricted | {:shimmed, module, atom} @doc """ Returns the trust status of a function or macro, specified as a `module`, `fun_name` and `arity` (`mfa`): - `:allowed` if can be safely use - `:restricted` if its usage should be forbidden - a `{:shimmed, module, function_name}` if the function call should be replaced with an alternative implementation """ @callback fun_status(module, atom, non_neg_integer) :: Dune.Allowlist.status() @doc """ Validates the fact that a module implements the `Dune.Allowlist` behaviour. Raises if not the case. ## Examples iex> Dune.Allowlist.ensure_implements_behaviour!(DoesNotExists) ** (ArgumentError) could not load module DoesNotExists due to reason :nofile iex> Dune.Allowlist.ensure_implements_behaviour!(List) ** (ArgumentError) List does not implement the Dune.Allowlist behaviour """ @spec ensure_implements_behaviour!(module) :: module def ensure_implements_behaviour!(module) when is_atom(module) do Code.ensure_compiled!(module) implemented? = module.module_info(:attributes) |> Keyword.get(:behaviour, []) |> Enum.member?(Dune.Allowlist) unless implemented? do raise ArgumentError, message: "#{inspect(module)} does not implement the Dune.Allowlist behaviour" end module end defmacro __using__(opts) do extend = extract_extend_opt(opts, __CALLER__) quote do import Dune.Allowlist, only: [allow: 2] @behaviour Dune.Allowlist Module.register_attribute(__MODULE__, :allowlist, accumulate: true) Module.put_attribute(__MODULE__, :extend_allowlist, unquote(extend)) @before_compile Dune.Allowlist end end @doc """ Adds a new module to the allowlist and specifices which functions to use. The module must not be already specified in the allowlist. Must be called after `use Dune.Allowlist`. ## Examples # allow all functions in a module allow Time, :all # only allow specific functions allow Function, only: [:identity] # exclude specific functions allow Calendar, except: [:put_time_zone_database] Note: `only` and `except` will cover all arities if several functions share a name. """ defmacro allow(module, status) do quote do Module.put_attribute(__MODULE__, :allowlist, {unquote(module), unquote(status)}) end end defmacro __before_compile__(env) do Dune.Allowlist.__postprocess__(env.module) end defp extract_extend_opt(opts, caller) do case Keyword.fetch(opts, :extend) do {:ok, module_ast} -> Macro.expand(module_ast, caller) |> ensure_implements_behaviour!() _ -> nil end end @doc false def __postprocess__(module) do extend = Module.get_attribute(module, :extend_allowlist) spec = generate_spec(module, extend) update_module_doc(module, spec) quote do unquote(def_spec(spec)) unquote(def_fun_status(spec)) @on_load :ensure_alias_atoms # Aliases like Foo.Bar are represented on the AST level as {:alias, _, [:Foo, :Bar]} # We need to force the creation of these atoms, which might otherwise not be # available when we parse due to atom encoding logic. def ensure_alias_atoms do # do nothing and returns :ok Enum.each(unquote(alias_atoms(spec)), & &1) end end end defp generate_spec(module, extend) do base_spec = case extend do nil -> Dune.Allowlist.Spec.new() allowlist when is_atom(allowlist) -> allowlist.spec() end Module.get_attribute(module, :allowlist) |> Enum.reduce(base_spec, fn {module, status}, acc -> Dune.Allowlist.Spec.add_new_module(acc, module, status) end) end defp def_spec(spec) do quote do @doc false @spec spec :: Dune.Allowlist.Spec.t() def spec do unquote(Macro.escape(spec)) end end end defp def_fun_status(spec) do defps = for {m, f, status} = _ <- Dune.Allowlist.Spec.list_fun_statuses(spec) do quote do defp do_fun_status(unquote(m), unquote(f)), do: unquote(Macro.escape(status)) end end quote do @impl Dune.Allowlist @doc "Implements `c:Dune.Allowlist.fun_status/3`" def fun_status(module, fun_name, arity) when is_atom(module) and is_atom(fun_name) and is_integer(arity) and arity >= 0 do with :defined <- Dune.Parser.RealModule.fun_status(module, fun_name, arity) do do_fun_status(module, fun_name) end end unquote(defps) defp do_fun_status(_module, _fun_name), do: :restricted end end defp update_module_doc(module, spec) do case Module.get_attribute(module, :moduledoc) do {line, doc} when is_binary(doc) -> doc = String.replace(doc, "__DUNE_ALLOWLIST_FUNCTIONS__", fn _ -> Dune.Allowlist.Docs.document_allowlist(spec) end) Module.put_attribute(module, :moduledoc, {line, doc}) _other -> :ok end end defp alias_atoms(spec) do spec.modules |> Enum.flat_map(fn {mod, _} -> try do Module.split(mod) rescue ArgumentError -> # "expected an Elixir module" error -> erlang module [] end end) |> Enum.map(&String.to_atom/1) |> Enum.uniq() end end ================================================ FILE: lib/dune/atom_mapping.ex ================================================ defmodule Dune.AtomMapping do @moduledoc false alias Dune.{Success, Failure} @type substitute_atom :: atom @type original_string :: String.t() @type sub_mapping :: %{optional(substitute_atom) => original_string} @type extra_info :: %{optional(substitute_atom) => :wrapped} @typedoc """ Should be considered opaque """ @type t :: %__MODULE__{atoms: sub_mapping, modules: sub_mapping, extra_info: extra_info} @enforce_keys [:atoms, :modules, :extra_info] defstruct @enforce_keys @spec new :: t() def new do %__MODULE__{atoms: %{}, modules: %{}, extra_info: %{}} end @spec from_atoms([{substitute_atom, original_string}], [{substitute_atom, :wrapped}]) :: t def from_atoms(list, extra_info) when is_list(list) do atoms = build_mapping(list) extra_info = Map.new(extra_info) %__MODULE__{atoms: atoms, modules: %{}, extra_info: extra_info} end @spec add_modules(t, [{substitute_atom, original_string}]) :: t def add_modules(mapping = %__MODULE__{}, list) do %{mapping | modules: build_mapping(list)} end defp build_mapping(list) do Map.new(list, fn {substitute_atom, original_string} when is_atom(substitute_atom) and is_binary(original_string) -> {substitute_atom, original_string} end) end @spec to_string(t, atom) :: String.t() def to_string(mapping = %__MODULE__{}, atom) when is_atom(atom) do case lookup_original_string(mapping, atom) do {:atom, string} -> string {:wrapped_atom, string} -> string {:module, string} -> "Elixir.#{string}" :error -> Atom.to_string(atom) end end @spec inspect(t, atom) :: String.t() def inspect(mapping = %__MODULE__{}, atom) when is_atom(atom) do case lookup_original_string(mapping, atom) do {:atom, string} -> ":#{string}" {:wrapped_atom, string} -> ~s(:"#{string}") {:module, string} -> string :error -> inspect(atom) end end @spec lookup_original_string(t, atom) :: {:atom | :wrapped_atom | :module, String.t()} | :error def lookup_original_string(mapping = %__MODULE__{}, atom) when is_atom(atom) do case mapping.modules do %{^atom => string} -> {:module, string} _ -> case mapping.atoms do %{^atom => string} -> case mapping.extra_info do %{^atom => :wrapped} -> {:wrapped_atom, string} _ -> {:atom, string} end _ -> :error end end end @spec replace_in_string(t, String.t()) :: String.t() def replace_in_string(mapping, string) when is_binary(string) do if string =~ "Dune" do do_replace_in_string(mapping, string) else string end end @dune_atom_regex ~r/(Dune_Atom_\d+__|a?__Dune_atom_\d+__|Dune_Module_\d+__)/ defp do_replace_in_string(mapping = %__MODULE__{}, string) do string_replace_map = %{} |> build_replace_map(mapping.modules, nil, &inspect/1) |> build_replace_map(mapping.atoms, mapping.extra_info, &Atom.to_string/1) String.replace(string, @dune_atom_regex, &Map.get(string_replace_map, &1, &1)) end defp build_replace_map(map, sub_mapping, extra_info, to_string_fun) do for {subsitute_atom, original_string} <- sub_mapping, into: map do replace_by = case extra_info do %{^subsitute_atom => :wrapped} -> ~s("#{original_string}") _ -> original_string end {to_string_fun.(subsitute_atom), replace_by} end end @spec replace_in_result(t, Success.t() | Failure.t()) :: Success.t() | Failure.t() def replace_in_result(mapping, result) def replace_in_result(mapping, %Success{} = success) do %Success{ success | inspected: replace_in_string(mapping, success.inspected), stdio: replace_in_string(mapping, success.stdio) } end def replace_in_result(mapping, %Failure{} = error) do %Failure{ error | message: replace_in_string(mapping, error.message), stdio: replace_in_string(mapping, error.stdio) } end @spec to_existing_atom(t, String.t()) :: atom def to_existing_atom(mapping = %__MODULE__{}, string) when is_binary(string) do case fetch_existing_atom(mapping, string) do nil -> String.to_existing_atom(string) atom -> atom end end defp fetch_existing_atom(mapping, "Elixir." <> module_name) do Enum.find_value(mapping.modules, fn {subsitute_atom, original_string} -> if original_string == module_name do subsitute_atom end end) end defp fetch_existing_atom(mapping, atom_name) do Enum.find_value(mapping.atoms, fn {subsitute_atom, original_string} -> if original_string == atom_name do subsitute_atom end end) end end ================================================ FILE: lib/dune/eval/env.ex ================================================ defmodule Dune.Eval.Env do @moduledoc false alias Dune.AtomMapping alias Dune.Eval.FakeModule @type t :: %__MODULE__{ atom_mapping: AtomMapping.t(), allowlist: module, fake_modules: %{optional(atom) => FakeModule.t()} } @enforce_keys [:atom_mapping, :allowlist, :fake_modules] defstruct @enforce_keys def new(atom_mapping = %AtomMapping{}, allowlist) when is_atom(allowlist) do %__MODULE__{atom_mapping: atom_mapping, allowlist: allowlist, fake_modules: %{}} end def add_module(env = %__MODULE__{fake_modules: modules}, module_name, module = %FakeModule{}) when is_atom(module_name) do # TODO check a bunch of things here: # - warn if module redefined # - fail if overriding existing module # - fail if overriding Kernel/Special forms %{env | fake_modules: Map.put(modules, module_name, module)} end def apply_fake(env = %__MODULE__{}, module, fun_name, args) when is_atom(module) and is_atom(fun_name) and is_list(args) do arity = length(args) case fetch_fake_function(env, module, fun_name, arity) do {:def, fun} -> fun.(env, args) other -> throw({other, module, fun_name, arity}) end end defp fetch_fake_function(%{fake_modules: modules}, module, fun_name, arity) do case modules do %{^module => fake_module} -> case FakeModule.get_function(fake_module, fun_name, arity) do nil -> :undefined_function {:def, fun} -> {:def, fun} end _ -> :undefined_module end end end ================================================ FILE: lib/dune/eval/fake_module.ex ================================================ defmodule Dune.Eval.FakeModule do @moduledoc false @type t :: %__MODULE__{ public_funs: %{optional(atom) => %{required(non_neg_integer) => function}} } @enforce_keys [:public_funs] defstruct @enforce_keys def get_function(%__MODULE__{public_funs: funs}, fun_name, arity) when is_atom(fun_name) and is_integer(arity) do case funs do %{^fun_name => %{^arity => fun}} -> {:def, fun} _ -> nil end end end ================================================ FILE: lib/dune/eval/function_clause_error.ex ================================================ defmodule Dune.Eval.FunctionClauseError do @moduledoc false defexception [:module, :function, :args] def message(err = %__MODULE__{function: function, args: args}) do module = inspect(err.module) arity = length(args) args = inspect(args) |> String.slice(1..-2//1) "no function clause matching in #{module}.#{function}/#{arity}: #{module}.#{function}(#{args})" end end ================================================ FILE: lib/dune/eval/macro_env.ex ================================================ defmodule Dune.Eval.MacroEnv do @moduledoc false # Recommended way to generate a Macro.Env struct # https://hexdocs.pm/elixir/main/Macro.Env.html def make_env do import Dune.Shims.Kernel, only: [safe_sigil_w: 3, safe_sigil_W: 3], warn: false %Macro.Env{__ENV__ | file: "nofile", module: nil, function: nil, line: 1} end end ================================================ FILE: lib/dune/eval/process.ex ================================================ defmodule Dune.Eval.Process do @moduledoc false alias Dune.Failure alias Dune.Helpers.Diagnostics def run(fun, opts = %Dune.Opts{}) when is_function(fun, 0) do with_string_io(fn string_io -> do_run(fun, opts, string_io) end) end defp do_run(fun, opts, string_io) do task = Task.async(fn -> # spawn within a task to avoid trapping exits in the caller spawn_trapped_process( fun, opts.max_heap_size, opts.max_reductions, string_io ) end) result = case Task.yield(task, opts.timeout) || Task.shutdown(task) do {:ok, result} -> result nil -> %Failure{type: :timeout, message: "Execution timeout - #{opts.timeout}ms"} end case result do %Failure{type: :compile_error} -> result _ -> %{result | stdio: result.stdio <> StringIO.flush(string_io)} end end defp with_string_io(fun) do {:ok, string_io} = StringIO.open("") try do fun.(string_io) after StringIO.close(string_io) end end # returns a Dune.Failure struct or exits defp spawn_trapped_process(fun, max_heap_size, max_reductions, string_io) do report_to = self() Process.flag(:trap_exit, true) # unlike plain spawn / Process.spawn, proc_lib doesn't trigger the logger: # | Unlike in "plain Erlang", proc_lib processes will not generate error reports, # | which are written to the terminal by the emulator. All exceptions are converted # | to exits which are ignored by the default logger handler. opts = [ :link, priority: :low, max_heap_size: %{size: max_heap_size, kill: true, error_logger: false} ] pid = :proc_lib.spawn_opt( fn -> Process.group_leader(self(), string_io) fun |> catch_diagnostics() |> then(&send(report_to, &1)) end, opts ) spawn(fn -> check_max_reductions(pid, report_to, max_reductions) end) receive do {:ok, result, diagnostics} -> Diagnostics.prepend_diagnostics(result, diagnostics) {:compile_error, error, diagnostics, stacktrace} -> format_compile_error(error, diagnostics, stacktrace) {:EXIT, ^pid, reason} -> case reason do :normal -> exit(:normal) :killed -> %Failure{type: :memory, message: "Execution stopped - memory limit exceeded"} {error, stacktrace} -> format_error(error, stacktrace) end {:EXIT, _other_pid, reason} -> # avoid the process to become immune to parent death exit(reason) {:reductions_exceeded, _reductions} -> %Failure{type: :reductions, message: "Execution stopped - reductions limit exceeded"} end end defp catch_diagnostics(fun) do {result, diagnostics} = Diagnostics.with_diagnostics_polyfill(fn -> try do {:ok, fun.()} rescue err in CompileError -> {err, __STACKTRACE__} end end) case result do {:ok, value} -> {:ok, value, diagnostics} {%CompileError{} = err, stacktrace} -> {:compile_error, err, diagnostics, stacktrace} end end defp check_max_reductions(pid, report_to, max_reductions) when is_integer(max_reductions) do # approach inspired from luerl # https://github.com/rvirding/luerl/blob/develop/src/luerl_sandbox.erl case Process.info(pid, :reductions) do nil -> :ok {:reductions, reductions} when reductions > max_reductions -> # if send immediately, might arrive before an EXIT signal Process.send_after(report_to, {:reductions_exceeded, reductions}, 1) {:reductions, _reductions} -> check_max_reductions(pid, report_to, max_reductions) end end defp format_error(error, stacktrace) defp format_error({:nocatch, value}, _stacktrace) do case value do {:undefined_module, module, fun, arity} -> Failure.undefined_module(module, fun, arity) {:undefined_function, module, fun, arity} -> Failure.undefined_function(module, fun, arity) {:safe_throw, thrown} -> %Failure{type: :throw, message: "** (throw) " <> inspect(thrown)} end end defp format_error(error, stacktrace) do [head | _] = stacktrace parts = case head do {:erl_eval, :do_apply, _, _} -> 3 {:elixir_eval, :__FILE__, _, _} -> 3 _ -> 2 end message = {error, [head]} |> Exception.format_exit() |> String.split("\n ", parts: parts) |> Enum.at(1) # TODO properly pass stacktrace %Failure{type: :exception, message: message} end defp format_compile_error(error, diagnostics, stacktrace) do message = {error, stacktrace} |> Exception.format_exit() |> String.split("\n ") |> Enum.at(1) %Failure{ type: :compile_error, message: message, stdio: Diagnostics.format_diagnostics(diagnostics) } end end ================================================ FILE: lib/dune/eval.ex ================================================ defmodule Dune.Eval do @moduledoc false alias Dune.{AtomMapping, Success, Failure, Opts} alias Dune.Eval.Env alias Dune.Eval.MacroEnv alias Dune.Parser.SafeAst alias Dune.Shims @typep previous_session :: %{:bindings => keyword, :env => Env.t(), optional(any) => any} @spec run(SafeAst.t() | Failure.t(), Opts.t(), previous_session | nil) :: Success.t() | Failure.t() def run(parsed, opts, previous_session \\ nil) def run( %SafeAst{ ast: ast, atom_mapping: atom_mapping, compile_env: %{allowlist: allowlist}, stdio: parser_stdio }, opts = %Opts{}, previous_session ) do case previous_session do nil -> env = Env.new(atom_mapping, allowlist) do_run(ast, atom_mapping, opts, env, nil) %{bindings: bindings, env: env} -> env = %{env | atom_mapping: atom_mapping, allowlist: allowlist} do_run(ast, atom_mapping, opts, env, bindings) end |> prepend_parser_stdio(parser_stdio) end def run(%Failure{} = failure, _opts, _bindings), do: failure defp do_run(ast, atom_mapping, opts, env, bindings) do result = Dune.Eval.Process.run( fn -> safe_eval(ast, env, bindings, opts.pretty, opts.inspect_sort_maps) end, opts ) AtomMapping.replace_in_result(atom_mapping, result) end defp prepend_parser_stdio(result, ""), do: result defp prepend_parser_stdio(result, parser_stdio) do Map.update!(result, :stdio, &(parser_stdio <> &1)) end defp safe_eval(safe_ast, env, bindings, pretty, sort_maps) do try do inspect_opts = [pretty: pretty, custom_options: [sort_maps: sort_maps]] do_safe_eval(safe_ast, env, bindings, inspect_opts) catch failure = %Failure{} -> failure end end defp do_safe_eval(safe_ast, env, nil, inspect_opts) do binding = [env__Dune__: env] {value, new_env, _new_bindings} = eval_quoted(safe_ast, binding) %Success{ value: value, # another important thing about inspect is that it force-evaluates # potentially huge shared structs => OOM before sending inspected: Shims.Kernel.safe_inspect(new_env, value, inspect_opts), stdio: "" } end defp do_safe_eval(safe_ast, env, bindings, inspect_opts) when is_list(bindings) do binding = [env__Dune__: env] ++ bindings {value, new_env, new_bindings} = eval_quoted(safe_ast, binding) %Success{ value: {value, new_env, new_bindings}, inspected: Shims.Kernel.safe_inspect(new_env, value, inspect_opts), stdio: "" } end defp eval_quoted(safe_ast, binding) do {value, bindings, _env} = Code.eval_quoted_with_env(safe_ast, binding, MacroEnv.make_env()) {new_env, new_bindings} = Keyword.pop!(bindings, :env__Dune__) {value, new_env, new_bindings} end end ================================================ FILE: lib/dune/failure.ex ================================================ defmodule Dune.Failure do @moduledoc """ A struct returned when `Dune` parsing or evaluation fails. Fields: - `message` (string): the error message to display to the user - `type` (atom): the nature of the error - `stdio` (string): captured standard output """ @type error_type :: :restricted | :module_restricted | :module_conflict | :timeout | :exception | :compile_error | :parsing | :memory | :reductions @type t :: %__MODULE__{type: error_type, message: String.t(), stdio: binary} @enforce_keys [:type, :message] defstruct @enforce_keys ++ [stdio: ""] @doc false def restricted_function(module, fun, arity) do formatted_fun = format_function(module, fun, arity) message = "** (DuneRestrictedError) function #{formatted_fun} is restricted" %__MODULE__{type: :restricted, message: message} end @doc false def undefined_module(module, function, arity) do base_message = base_undefined_message(module, function, arity) message = IO.iodata_to_binary([base_message, "(module ", inspect(module), " is not available)"]) %__MODULE__{type: :exception, message: message} end @doc false def undefined_function(module, function, arity) do base_message = base_undefined_message(module, function, arity) message = IO.iodata_to_binary([base_message, "or private"]) %__MODULE__{type: :exception, message: message} end defp base_undefined_message(module, function, arity) do formatted_fun = format_function(module, function, arity) ["** (UndefinedFunctionError) function ", formatted_fun, " is undefined "] end defp format_function(kernel, fun, arity) when kernel in [nil, Kernel, Kernel.SpecialForms] do "#{fun}/#{arity}" end defp format_function(module, fun, arity) do "#{inspect(module)}.#{fun}/#{arity}" end end ================================================ FILE: lib/dune/helpers/diagnostics.ex ================================================ defmodule Dune.Helpers.Diagnostics do @moduledoc false # used for formatting errors and warnings consistently @type result_with_stdio :: %{stdio: binary()} @spec prepend_diagnostics( result_with_stdio(), [Code.diagnostic(:warning | :error)] ) :: result_with_stdio() def prepend_diagnostics(result, []), do: result def prepend_diagnostics(result, diagnostics) do %{result | stdio: format_diagnostics(diagnostics) <> "\n\n"} end @spec format_diagnostics([Code.diagnostic(:warning | :error)]) :: String.t() def format_diagnostics(diagnostics) do Enum.map_join( diagnostics, "\n", &"#{&1.severity}: #{&1.message}\n #{&1.file}:#{format_pos(&1.position)}" ) end defp format_pos(integer) when is_integer(integer), do: Integer.to_string(integer) defp format_pos({line, col}), do: [Integer.to_string(line), ?:, Integer.to_string(col)] @doc """ A polyfill for `Code.with_diagnostics/1` for older versions of Elixir, which returns an empty list of diagnostics if not available. """ # TODO remove then dropping support for 1.14 if System.version() |> Version.compare("1.15.0") != :lt do defdelegate with_diagnostics_polyfill(fun), to: Code, as: :with_diagnostics else def with_diagnostics_polyfill(fun) do {fun.(), []} end end end ================================================ FILE: lib/dune/helpers/term_checker.ex ================================================ defmodule Dune.Helpers.TermChecker do @moduledoc false defguardp is_simple_term(term) when is_atom(term) or is_bitstring(term) or is_number(term) or is_reference(term) or is_function(term) or is_pid(term) or is_port(term) or term == [] @doc """ Walks the term recursively to make sure it is not a humongous tree built using structural sharing """ def check(term), do: do_check(term) defp do_check(term) when is_simple_term(term), do: :ok defp do_check([left | right]) when is_simple_term(left) do do_check(right) end defp do_check([left | right]) do do_check(left) do_check(right) end defp do_check(map) when is_map(map) do Map.to_list(map) |> do_check() end defp do_check(tuple) when is_tuple(tuple) do Tuple.to_list(tuple) |> do_check() end end ================================================ FILE: lib/dune/opts.ex ================================================ defmodule Dune.Opts do @moduledoc """ Defines and validates the options for `Dune`. The available options are explained below: ### Parsing restriction options - `atom_pool_size`: Defines the maximum total number of atoms that can be created. Must be an integer `>= 0`. Defaults to `5000`. See the [section below](#module-extra-note-about-atom_pool_size) for more information. - `max_length`: Defines the maximum length of code strings that can be parsed. Defaults to `5000`. ### Execution restriction options - `allowlist`: Defines which module and functions are considered safe or restricted. Should be a module implementing the `Dune.Allowlist` behaviour. Defaults to `Dune.Allowlist.Default`. - `max_heap_size`: Limits the memory usage of the evaluation process using the [`max_heap_size` flag](https://erlang.org/doc/man/erlang.html#process_flag_max_heap_size). Should be an integer `> 0`. Defaults to `30_000`. - `max_reductions`: Limits the number of CPU cycles of the evaluation process. The erlang pre-emptive scheduler is using reductions to measure work being done by processes, which is useful to prevent users to run CPU intensive code such as infinite loops. Should be an integer `> 0`. Defaults to `30_000`. - `timeout`: Limits the time the evaluation process is authorized to run (in milliseconds). Should be an integer `> 0`. Defaults to `50`. The evaluation process will still need to parse and execute the sanitized AST, so using too low limits here would leave only a small margin to actually run user code. ### Other options - `pretty`: Use pretty printing when inspecting the result. Should be a boolean. Defaults to `false`. - `inspect_sort_maps`: Sort maps when inspecting the result, useful to keep the output deterministic. Should be a boolean. Defaults to `false`. Only works since Elixir >= 1.14.4. ### Extra note about `atom_pool_size` Atoms are reused from one evaluation to the other so the total is not expected to grow. Atoms will not be leaked. Also, the atom pool is actually split into several pools: regular atoms, module names, unused variable names, ... So defining a value of `100` does not mean that `100` atoms will be available, but rather `25` of each type. Atoms being very lightweight, there is no need to use a low value, as long as there is an upper bound preventing atom leaks. """ alias Dune.Allowlist @type t :: %__MODULE__{ atom_pool_size: non_neg_integer, max_length: pos_integer, allowlist: module, max_heap_size: pos_integer, max_reductions: pos_integer, timeout: pos_integer, pretty: boolean, inspect_sort_maps: boolean } defstruct atom_pool_size: 5000, max_length: 5000, allowlist: Dune.Allowlist.Default, max_heap_size: 50_000, max_reductions: 30_000, timeout: 50, pretty: false, inspect_sort_maps: false @doc """ Validates untrusted options from a keyword or a map and returns a `Dune.Opts` struct. ## Examples iex> Dune.Opts.validate!([]) %Dune.Opts{ allowlist: Dune.Allowlist.Default, atom_pool_size: 5000, max_heap_size: 50000, max_length: 5000, max_reductions: 30000, pretty: false, timeout: 50 } iex> Dune.Opts.validate!(atom_pool_size: 10) %Dune.Opts{atom_pool_size: 10, allowlist: Dune.Allowlist.Default} iex> Dune.Opts.validate!(atom_pool_size: -10) ** (ArgumentError) atom_pool_size should be an integer >= 0 iex> Dune.Opts.validate!(max_length: 0) ** (ArgumentError) atom_pool_size should be an integer > 0 iex> Dune.Opts.validate!(allowlist: DoesNotExists) ** (ArgumentError) could not load module DoesNotExists due to reason :nofile iex> Dune.Opts.validate!(allowlist: List) ** (ArgumentError) List does not implement the Dune.Allowlist behaviour iex> Dune.Opts.validate!(max_reductions: 10_000, max_heap_size: 10_000, timeout: 20) %Dune.Opts{max_heap_size: 10_000, max_reductions: 10_000, timeout: 20} iex> Dune.Opts.validate!(max_heap_size: 0) ** (ArgumentError) max_heap_size should be an integer > 0 iex> Dune.Opts.validate!(max_reductions: 0) ** (ArgumentError) max_reductions should be an integer > 0 iex> Dune.Opts.validate!(timeout: "55") ** (ArgumentError) timeout should be an integer > 0 iex> Dune.Opts.validate!(pretty: :maybe) ** (ArgumentError) pretty should be a boolean """ @spec validate!(Keyword.t() | map) :: t def validate!(opts) do struct(__MODULE__, opts) |> do_validate() end defp do_validate(%{atom_pool_size: atom_pool_size}) when not (is_integer(atom_pool_size) and atom_pool_size >= 0) do raise ArgumentError, message: "atom_pool_size should be an integer >= 0" end defp do_validate(%{max_length: max_length}) when not (is_integer(max_length) and max_length > 0) do raise ArgumentError, message: "atom_pool_size should be an integer > 0" end defp do_validate(%{allowlist: allowlist}) when not is_atom(allowlist) do raise ArgumentError, message: "allowlist should be a module" end defp do_validate(%{max_reductions: max_reductions}) when not (is_integer(max_reductions) and max_reductions > 0) do raise ArgumentError, message: "max_reductions should be an integer > 0" end defp do_validate(%{max_heap_size: max_heap_size}) when not (is_integer(max_heap_size) and max_heap_size > 0) do raise ArgumentError, message: "max_heap_size should be an integer > 0" end defp do_validate(%{timeout: timeout}) when not (is_integer(timeout) and timeout > 0) do raise ArgumentError, message: "timeout should be an integer > 0" end defp do_validate(%{pretty: pretty}) when not is_boolean(pretty) do raise ArgumentError, message: "pretty should be a boolean" end defp do_validate(%{inspect_sort_maps: sort}) when not is_boolean(sort) do raise ArgumentError, message: "inspect_sort_maps should be a boolean" end defp do_validate(opts = %{allowlist: allowlist}) do Allowlist.ensure_implements_behaviour!(allowlist) opts end end ================================================ FILE: lib/dune/parser/atom_encoder.ex ================================================ defmodule Dune.Parser.AtomEncoder do @moduledoc false alias Dune.AtomMapping @type atom_category :: :alias | :private_var | :public_var | :other @atom_categories 4 # TODO Remove when dropping support for Elixir 1.16 extra_modules = if System.version() |> Version.compare("1.17.0-rc.0") != :lt, do: [Duration], else: [] @elixir_modules [ Kernel, Kernel.SpecialForms, Atom, Base, Bitwise, Date, DateTime, Duration, Exception, Float, Function, Integer, Module, NaiveDateTime, Record, Regex, String, Time, Tuple, URI, Version, Version.Requirement, Access, Date.Range, Enum, Keyword, List, Map, MapSet, Range, Stream, File, File.Stat, File.Stream, IO, IO.ANSI, IO.Stream, OptionParser, Path, Port, StringIO, System, Calendar, Calendar.ISO, Calendar.TimeZoneDatabase, Calendar.UTCOnlyTimeZoneDatabase, Agent, Application, Config, Config.Provider, Config.Reader, DynamicSupervisor, GenServer, Node, Process, Registry, Supervisor, Task, Task.Supervisor, Collectable, Enumerable, Inspect, Inspect.Algebra, Inspect.Opts, List.Chars, Protocol, String.Chars, Code, Kernel.ParallelCompiler, Macro, Macro.Env, Behaviour, Dict, GenEvent, HashDict, HashSet, Set, Supervisor.Spec, ArgumentError, ArithmeticError, BadArityError, BadBooleanError, BadFunctionError, BadMapError, BadStructError, CaseClauseError, Code.LoadError, CompileError, CondClauseError, Enum.EmptyError, Enum.OutOfBoundsError, ErlangError, File.CopyError, File.Error, File.LinkError, File.RenameError, FunctionClauseError, IO.StreamError, Inspect.Error, KeyError, MatchError, Module.Types.Error, OptionParser.ParseError, Protocol.UndefinedError, Regex.CompileError, RuntimeError, SyntaxError, SystemLimitError, TokenMissingError, TryClauseError, DuneRestrictedError, UnicodeConversionError, Version.InvalidRequirementError, Version.InvalidVersionError, WithClauseError ] ++ extra_modules @module_reprs @elixir_modules |> Enum.flat_map(&Module.split/1) |> Map.new(&{&1, String.to_existing_atom(&1)}) @spec load_atom_mapping(AtomMapping.t() | nil) :: :ok def load_atom_mapping(nil), do: :ok def load_atom_mapping(%AtomMapping{atoms: atoms}) do count = Enum.count(atoms) Process.put(:__Dune_atom_count__, count) Enum.each(atoms, fn {atom, binary} -> Process.put({:__Dune_atom__, binary}, atom) end) end @spec static_atoms_encoder(String.t(), non_neg_integer()) :: {:ok, atom} | {:error, String.t()} def static_atoms_encoder(binary, pool_size) when is_binary(binary) and is_integer(pool_size) do case @module_reprs do %{^binary => atom} -> {:ok, atom} _ -> if binary =~ "Dune" do {:error, "Atoms containing `Dune` are restricted for safety"} else atom_category = categorize_atom_binary(binary) do_static_atoms_encoder(binary, atom_category, pool_size) end end end @spec categorize_atom_binary(binary) :: atom_category def categorize_atom_binary(atom_binary) do charlist = String.to_charlist(atom_binary) case {Code.Fragment.cursor_context(charlist), atom_binary} do {{:alias, ^charlist}, _} -> :alias {{:local_or_var, ^charlist}, "_" <> _} -> :private_var {{:local_or_var, ^charlist}, _} -> :public_var _ -> :other end end defp do_static_atoms_encoder("Elixir." <> rest, :alias, pool_size) do rest |> String.split(".") |> encode_many_atoms(pool_size, []) end defp do_static_atoms_encoder(binary, atom_category, pool_size) do process_key = {:__Dune_atom__, binary} case Process.get(process_key, nil) do nil -> do_static_atoms_encoder(binary, atom_category, process_key, pool_size) atom when is_atom(atom) -> {:ok, atom} end end defp do_static_atoms_encoder(binary, atom_category, process_key, pool_size) do {:ok, String.to_existing_atom(binary)} rescue ArgumentError -> case new_atom(atom_category, pool_size) do {:ok, atom} -> Process.put(process_key, atom) if atom_category == :other do Process.put({:__Dune_atom_extra_info__, atom}, :wrapped) end {:ok, atom} {:error, error} -> {:error, error} end end defp encode_many_atoms([], _pool_size, acc) do {:ok, {:__aliases__, [], [Elixir | Enum.reverse(acc)]}} end defp encode_many_atoms([head | tail], pool_size, acc) do case do_static_atoms_encoder(head, :alias, pool_size) do {:ok, atom} -> encode_many_atoms(tail, pool_size, [atom | acc]) {:error, error} -> {:error, error} end end @spec plain_atom_mapping :: AtomMapping.t() def plain_atom_mapping() do atoms = for {{:__Dune_atom__, binary}, atom} <- Process.get() do {atom, binary} end extra_info = for {{:__Dune_atom_extra_info__, atom}, info} <- Process.get() do {atom, info} end AtomMapping.from_atoms(atoms, extra_info) end defp new_atom(atom_category, pool_size) do count = Process.get(:__Dune_atom_count__, 0) + 1 if count * @atom_categories > pool_size do {:error, "atom_pool_size exceeded, failed to parse atom"} else Process.put(:__Dune_atom_count__, count) atom = do_new_atom(atom_category, count) {:ok, atom} end end defp do_new_atom(:alias, count) do :"Dune_Atom_#{count}__" end defp do_new_atom(:public_var, count) do :"a__Dune_atom_#{count}__" end defp do_new_atom(category, count) when category in [:private_var, :other] do :"__Dune_atom_#{count}__" end @spec encode_modules(Macro.t(), AtomMapping.t(), AtomMapping.t() | nil) :: {Macro.t(), AtomMapping.t()} def encode_modules(ast, plain_atom_mapping, existing_mapping) do initial_acc = get_module_acc(existing_mapping) {new_ast, acc} = Macro.postwalk(ast, initial_acc, fn {:__aliases__, ctx, atoms}, acc -> {modules, new_acc} = remove_elixir_prefix(atoms) |> map_modules_ast(acc) {{:__aliases__, ctx, modules}, new_acc} other, acc -> {other, acc} end) atom_mapping = build_module_mapping(acc, plain_atom_mapping) {new_ast, atom_mapping} end defp get_module_acc(nil), do: %{} defp get_module_acc(%AtomMapping{atoms: atoms, modules: modules}) do reverse_atoms = Map.new(atoms, fn {atom, string} -> {string, atom} end) Map.new(modules, fn {atom, string} -> atoms = String.split(string, ".") |> Enum.map(&Map.fetch!(reverse_atoms, &1)) {atoms, atom} end) end defp remove_elixir_prefix(atoms = [Elixir, Elixir | _]), do: atoms defp remove_elixir_prefix([Elixir | atoms]) when atoms != [], do: atoms defp remove_elixir_prefix(atoms), do: atoms defp map_modules_ast(atoms, acc) do case acc do %{^atoms => module_name} -> {[module_name], acc} _ -> try do atoms |> Enum.join(".") |> then(&"Elixir.#{&1}") |> String.to_existing_atom() rescue ArgumentError -> module_name = :"Dune_Module_#{map_size(acc) + 1}__" {[module_name], Map.put(acc, atoms, module_name)} else _ -> {atoms, acc} end end end defp build_module_mapping(acc, plain_atom_mapping) do modules = Enum.map(acc, fn {atoms, module_name} -> string = Enum.map_join(atoms, ".", &AtomMapping.to_string(plain_atom_mapping, &1)) module = Module.concat([module_name]) {module, string} end) AtomMapping.add_modules(plain_atom_mapping, modules) end end ================================================ FILE: lib/dune/parser/compile_env.ex ================================================ defmodule Dune.Parser.CompileEnv do @moduledoc false @type name_arity :: {atom, non_neg_integer} @type maybe_fake_module :: {:real | :fake, module} @type t :: %__MODULE__{ module: module | nil, allowlist: module, fake_modules: %{optional(module) => %{optional(name_arity) => :def | :defp}} # aliases # struct info # requires } @enforce_keys [:module, :allowlist, :fake_modules] defstruct @enforce_keys def new(allowlist) do %__MODULE__{ allowlist: allowlist, module: nil, fake_modules: %{} } end def define_fake_module(env = %__MODULE__{fake_modules: fake_modules}, module, name_arities) when is_atom(module) and is_map(name_arities) do if module_already_exists?(module, fake_modules) do throw({:module_conflict, module}) end new_modules = Map.put(fake_modules, module, name_arities) %{env | fake_modules: new_modules} end defp module_already_exists?(module, fake_modules) do case fake_modules do %{^module => _conflict} -> true _ -> Code.ensure_loaded?(module) end end def resolve_mfa(%__MODULE__{}, module, fun_name, arity) when module in [Kernel, nil] and fun_name in [:def, :defp] and arity in [1, 2] do :outside_module end def resolve_mfa(env = %__MODULE__{}, module, fun_name, arity) when is_atom(module) and is_atom(fun_name) and is_integer(arity) do actual_module = resolve_module(module, fun_name, arity) case env.allowlist.fun_status(actual_module, fun_name, arity) do :undefined_module -> resolve_fake_module(env, module, fun_name, arity) :undefined_function -> case module do nil -> resolve_fake_module(env, nil, fun_name, arity) _ -> :undefined_function end :restricted -> {:restricted, actual_module} other -> other end end defp resolve_module(nil, fun_name, arity) do if Macro.special_form?(fun_name, arity) do Kernel.SpecialForms else Kernel end end defp resolve_module(module, _fun_name, _arity), do: module defp resolve_fake_module(%{module: nil}, nil, _fun_name, _arity), do: :undefined_function defp resolve_fake_module(env = %{module: module}, nil, fun_name, arity) do resolve_fake_module(env, module, fun_name, arity) end defp resolve_fake_module(env, module, fun_name, arity) do # TODO check current module to know if defp OK fun_with_arity = {fun_name, arity} case env.fake_modules do %{^module => %{^fun_with_arity => def_or_defp}} -> check_private(env, module, def_or_defp) _ -> :undefined_module end end defp check_private(_env, module, :def), do: {:fake, module} defp check_private(%{module: module}, module, :defp), do: {:fake, module} defp check_private(_env, _module, _def), do: :undefined_function end ================================================ FILE: lib/dune/parser/debug.ex ================================================ defmodule Dune.Parser.Debug do @moduledoc false def io_debug(ast) do debug(ast) |> IO.puts() ast end def debug(%{ast: ast}) when is_tuple(ast) do ast_to_string(ast) end def debug(ast) when is_tuple(ast) do ast_to_string(ast) end defp ast_to_string({:__block__, _, list}) do Enum.map_join(list, "\n", &Macro.to_string/1) end defp ast_to_string(ast) do Macro.to_string(ast) end end ================================================ FILE: lib/dune/parser/real_module.ex ================================================ defmodule Dune.Parser.RealModule do @moduledoc false @spec elixir_module?(module) :: boolean def elixir_module?(module) do module |> Atom.to_string() |> String.starts_with?("Elixir.") end def list_functions(module) def list_functions(Kernel.SpecialForms) do [{:%{}, 2}] ++ Kernel.SpecialForms.__info__(:macros) end @spec list_functions(module) :: [{atom, non_neg_integer}] def list_functions(module) when is_atom(module) do if elixir_module?(module) do module.__info__(:functions) ++ module.__info__(:macros) else for {f, _a} = fa <- module.module_info(:exports), f != :module_info, do: fa end end def fun_exists?(module, fun_name, arity) do # TODO replace with fun_status fun_status(module, fun_name, arity) == :defined end def fun_status(module, fun_name, arity) def fun_status(Kernel.SpecialForms, fun_name, arity) do if Macro.special_form?(fun_name, arity) do :defined else :undefined_function end end def fun_status(module, fun_name, arity) do cond do not Code.ensure_loaded?(module) -> :undefined_module function_exported?(module, fun_name, arity) -> :defined macro_exported?(module, fun_name, arity) -> :defined true -> :undefined_function end end end ================================================ FILE: lib/dune/parser/safe_ast.ex ================================================ defmodule Dune.Parser.SafeAst do @moduledoc false @type t :: %__MODULE__{ ast: Macro.t(), atom_mapping: Dune.AtomMapping.t(), compile_env: Dune.Parser.CompileEnv.t(), stdio: binary() } @enforce_keys [:ast, :atom_mapping, :compile_env] defstruct @enforce_keys ++ [stdio: <<>>] end ================================================ FILE: lib/dune/parser/sanitizer.ex ================================================ defmodule Dune.Parser.Sanitizer do @moduledoc false alias Dune.{Failure, AtomMapping, Opts} alias Dune.Parser.{CompileEnv, RealModule, UnsafeAst, SafeAst} @env_variable_name :env__Dune__ @spec sanitize(UnsafeAst.t() | Failure.t(), Opts.t()) :: SafeAst.t() | Failure.t() def sanitize(unsafe = %UnsafeAst{}, compile_env = %CompileEnv{}) do case try_sanitize(unsafe.ast, compile_env) do {:ok, safe_ast, new_env} -> %SafeAst{ ast: safe_ast, atom_mapping: unsafe.atom_mapping, compile_env: new_env, stdio: unsafe.stdio } {:restricted, module, fun, arity} -> failure = Failure.restricted_function(module, fun, arity) AtomMapping.replace_in_result(unsafe.atom_mapping, failure) {:undefined_module, module, func_name, arity} -> failure = Failure.undefined_module(module, func_name, arity) AtomMapping.replace_in_result(unsafe.atom_mapping, failure) {:undefined_function, module, func_name, arity} -> failure = Failure.undefined_function(module, func_name, arity) AtomMapping.replace_in_result(unsafe.atom_mapping, failure) {:outside_module, def_or_defp} -> message = "** (ArgumentError) cannot invoke #{def_or_defp}/2 inside function/macro" new_failure(:exception, message, unsafe.atom_mapping) {:module_invalid, module_ast, error_ctx} -> line = Keyword.get(error_ctx, :line) name = Macro.to_string(module_ast) message = "** (Dune.Eval.CompileError) nofile:#{line}: invalid module name: #{name}" new_failure(:exception, message, unsafe.atom_mapping) {:module_restricted, ast} -> message = "** (DuneRestrictedError) the following syntax is restricted inside defmodule:\n #{Macro.to_string(ast)}" new_failure(:module_restricted, message, unsafe.atom_mapping) {:module_conflict, module} -> message = "** (DuneRestrictedError) Following module cannot be defined/redefined: #{inspect(module)}" new_failure(:module_conflict, message, unsafe.atom_mapping) {:definition_conflict, name_arity, previous_def, previous_ctx, conflict_def, conflict_ctx} -> conflict_line = Keyword.get(conflict_ctx, :line) previous_line = Keyword.get(previous_ctx, :line) {name, arity} = name_arity message = "** (Dune.Eval.CompileError) nofile:#{conflict_line}: " <> "#{conflict_def} #{name}/#{arity} already defined as #{previous_def} in nofile:#{previous_line}" new_failure(:exception, message, unsafe.atom_mapping) {:parsing_error, ast} -> message = "dune parsing error: failed to safe parse\n #{Macro.to_string(ast)}" new_failure(:parsing, message, unsafe.atom_mapping) {:bin_modifier_restricted, ast} -> message = "** (DuneRestrictedError) bitstring modifier is restricted:\n #{Macro.to_string(ast)}" new_failure(:restricted, message, unsafe.atom_mapping) {:bin_modifier_size, max_size} -> message = "** (DuneRestrictedError) size modifiers above #{max_size} are restricted" new_failure(:restricted, message, unsafe.atom_mapping) {:exception, error} -> message = Exception.format(:error, error) new_failure(:exception, message, unsafe.atom_mapping) end end def sanitize(%Failure{} = failure, _opts), do: failure defp new_failure(type, message, atom_mapping) when is_atom(type) and is_binary(message) do failure = %Failure{type: type, message: message, stdio: ""} AtomMapping.replace_in_result(atom_mapping, failure) end # XXX this is a bit hacky and brute-force approach! # ideally the AST transformation is robust enough so we don't need it defp try_sanitize(ast, env) do do_sanitize_main(ast, env) rescue error -> error |> then(&Exception.blame(:error, &1, __STACKTRACE__)) |> elem(0) |> then(&Exception.format(:error, &1)) |> IO.warn() {:parsing_error, ast} catch thrown -> thrown end defp do_sanitize_main({:__block__, ctx, list}, env) do {list_ast, env} = do_sanitize_main_list(list, env) block_ast = {:__block__, ctx, list_ast} {:ok, block_ast, env} end defp do_sanitize_main(single, env) do case do_sanitize_main_list([single], env) do {[safe_single], env} -> {:ok, safe_single, env} {list_ast, env} when is_list(list_ast) -> block_ast = {:__block__, [], list_ast} {:ok, block_ast, env} end end defp do_sanitize_main_list(list, env) when is_list(list) do {defmodules, instructions} = Enum.split_with(list, &defmodule_block?/1) raw_fun_definitions = Enum.map(defmodules, &parse_module_definition/1) env = Enum.reduce(raw_fun_definitions, env, fn {module, fun_defs}, acc -> fun_name_arities = Map.new(fun_defs, fn {name_arity, [raw_definition | _]} -> {name_arity, elem(raw_definition, 0)} end) CompileEnv.define_fake_module(acc, module, fun_name_arities) end) module_definitions = Enum.map(raw_fun_definitions, &sanitize_module_definition(&1, env)) sanitized_instructions = case {raw_fun_definitions, do_sanitize(instructions, env)} do {[_ | _], []} -> {last_module, _} = List.last(raw_fun_definitions) [quote(do: {:module, unquote(last_module), nil, nil})] {_, sanitized_instructions} -> sanitized_instructions end {module_definitions ++ sanitized_instructions, env} end defp defmodule_block?({:defmodule, _, _}), do: true defp defmodule_block?(_), do: false defp parse_module_definition({:defmodule, ctx, [module_name, [do: do_ast]]}) do module_name |> validate_module_name(ctx) |> do_parse_module_definition(do_ast) end defp parse_module_definition(ast = {:defmodule, _, _}) do throw({:parsing_error, ast}) end defp validate_module_name(module, _ctx) when is_atom(module) and module not in [nil, false, true] do module end defp validate_module_name(module_def = {:__aliases__, _, [_module_atom]}, _ctx) do Macro.expand_once(module_def, __ENV__) end defp validate_module_name(module_ast, ctx) do throw({:module_invalid, module_ast, ctx}) end defp do_parse_module_definition(module_name, do_ast) do fun_definitions = block_to_list(do_ast) |> Enum.map(&parse_fun_definition/1) |> Enum.filter(& &1) |> Enum.flat_map(&expand_defaults/1) |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) |> tap(&check_definition_conflicts/1) {module_name, fun_definitions} end defp block_to_list({:__block__, _, list}) when is_list(list), do: list defp block_to_list(single) when is_tuple(single), do: [single] defp parse_fun_definition({def_or_defp, ctx, [signature, [do: body]]}) when def_or_defp in [:def, :defp] do {header, guards} = parse_fun_signature(signature) {name, args} = Macro.decompose_call(header) {args, defaults} = extract_default_args(args, 0, [], []) name_arity = {name, length(args)} definition = {def_or_defp, ctx, args, body, guards} {name_arity, definition, defaults} end defp parse_fun_definition({:@, _, [{doc, _, [value]}]}) when doc in ~w[moduledoc doc]a and (value == false or is_binary(value)) do nil end defp parse_fun_definition({:@, _, [{typespec, _, [{:"::", _, _}]}]}) when typespec in ~w[spec type typep opaque]a do nil end defp parse_fun_definition(unsupported_ast) do throw({:module_restricted, unsupported_ast}) end # TODO else raise unsupported! defp extract_default_args([], _index, arg_acc, defaults) do {Enum.reverse(arg_acc), defaults} end defp extract_default_args([{:\\, _, [arg, default]} | args], index, arg_acc, defaults) do extract_default_args(args, index + 1, [arg | arg_acc], [{index, default} | defaults]) end defp extract_default_args([arg | args], index, arg_acc, defaults) do extract_default_args(args, index + 1, [arg | arg_acc], defaults) end defp expand_defaults({name_arity, definition, _defaults = []}) do [{name_arity, definition}] end defp expand_defaults({name_arity = {name, arity}, definition, defaults}) do def_or_defp = elem(definition, 0) do_expand_defaults(name, arity, def_or_defp, defaults, [{name_arity, definition}]) end defp do_expand_defaults(_name, _arity, _def_or_defp, [], acc) do acc end defp do_expand_defaults( name, arity, def_or_defp, [{default_index, default_value} | defaults], acc ) do args = Macro.generate_arguments(arity, nil) arity = arity - 1 args_without_default = List.delete_at(args, default_index) args_in_expr = Enum.with_index(args, fn _arg, ^default_index -> default_value arg, _index -> arg end) definition = {def_or_defp, [], args_without_default, {name, [], args_in_expr}, nil} acc = [{{name, arity}, definition} | acc] do_expand_defaults(name, arity, def_or_defp, defaults, acc) end defp check_definition_conflicts(grouped_definitions) do Enum.each(grouped_definitions, fn {name_arity, [head | tail]} -> check_definition_conflict(name_arity, head, tail) end) end defp check_definition_conflict(_name_arity, _, []), do: :ok defp check_definition_conflict( name_arity, head = {def_or_defp, _, _, _, _}, [ {def_or_defp, _, _, _, _} | rest ] ) do check_definition_conflict(name_arity, head, rest) end defp check_definition_conflict(name_arity, {previous_def, previous_ctx, _, _, _}, [ {conflict_def, conflict_ctx, _, _, _} | _ ]) do throw( {:definition_conflict, name_arity, previous_def, previous_ctx, conflict_def, conflict_ctx} ) end defp parse_fun_signature({:when, _, [header, guards]}) do {header, guards} end defp parse_fun_signature(header) do {header, nil} end defp sanitize_module_definition({module, fun_defs}, env) do env = %{env | module: module} public_funs_ast = fun_defs |> Enum.map(&sanitize_fun(&1, env)) |> Enum.group_by(&elem(&1, 0), fn {_fun, arity, ast} -> {arity, ast} end) |> Enum.map(fn {fun_name, list} -> {fun_name, to_map_ast(list)} end) |> to_map_ast() quote do unquote(env_variable()) = Dune.Eval.Env.add_module( unquote(env_variable()), unquote(module), %Dune.Eval.FakeModule{public_funs: unquote(public_funs_ast)} ) end end defp to_map_ast(list_ast) when is_list(list_ast), do: {:%{}, [], list_ast} defp sanitize_fun({{fun_name, arity}, definitions}, env) do args = Macro.var(:args, nil) bottom_clause = quote do args -> raise %Dune.Eval.FunctionClauseError{ module: unquote(env.module), function: unquote(fun_name), args: args } end clauses = Enum.map(definitions, &sanitize_fun_clause(&1, env)) ++ bottom_clause env_var = env_variable_if_used(clauses) anonymous_ast = {:fn, [], [ {:->, [], [ [env_var, args], {:case, [], [ args, [do: clauses] ]} ]} ]} {fun_name, arity, anonymous_ast} end defp sanitize_fun_clause({_def_or_defp, ctx, args, body, guards}, env) do safe_args = do_sanitize(args, env) safe_body = do_sanitize(body, env) safe_guards = do_sanitize(guards, env) definition_to_clause(ctx, safe_args, safe_body, safe_guards) end defp definition_to_clause(ctx, args, body, _guards = nil) do {:->, ctx, [[args], body]} end defp definition_to_clause(ctx, args, body, guards) when is_tuple(guards) do args_and_guards = [{:when, [], [args, guards]}] {:->, ctx, [args_and_guards, body]} end defp env_variable_if_used(asts) do uses_variable?(asts, @env_variable_name) case uses_variable?(asts, @env_variable_name) do true -> env_variable() false -> underscore_env_variable() end end defp uses_variable?([], _variable_name), do: false defp uses_variable?([head | tail], variable_name) do case uses_variable?(head, variable_name) do true -> true false -> uses_variable?(tail, variable_name) end end defp uses_variable?({variable_name, _, nil}, variable_name), do: true defp uses_variable?({_, _, list}, variable_name) when is_list(list) do uses_variable?(list, variable_name) end defp uses_variable?({x, y}, variable_name) do uses_variable?(x, variable_name) or uses_variable?(y, variable_name) end defp uses_variable?(_ast, _variable_name), do: false defp do_sanitize(ast, env) defp do_sanitize(raw_value, _env) when is_atom(raw_value) or is_number(raw_value) or is_binary(raw_value) do raw_value end defp do_sanitize(list, env) when is_list(list) do sanitize_args(list, env) end defp do_sanitize({arg1, arg2}, env) do [safe_arg1, safe_arg2] = sanitize_args([arg1, arg2], env) {safe_arg1, safe_arg2} end defp do_sanitize({atom, _, _} = raw, env) when atom in [:__block__, :when, :<-, :->, :|] do sanitize_args_in_node(raw, env) end defp do_sanitize({name, _, atom} = variable, _env) when is_atom(name) and atom in [nil, Elixir] do unless authorized_var_name?(name) do throw({:restricted, Kernel, name, 0}) end variable end defp do_sanitize({:&, _, args}, env) do [ast] = args sanitize_capture(ast, env) end defp do_sanitize({:<<>>, meta, args}, env) do sanitized_args = Enum.map(args, fn {:"::", meta, [expr, modifier]} -> {:"::", meta, [do_sanitize(expr, env), check_bin_modifier(modifier)]} arg -> do_sanitize(arg, env) end) {:<<>>, meta, sanitized_args} end defp do_sanitize({{:., _, [left, right]}, ctx, args} = raw, env) when is_atom(right) and is_list(args) do case left do atom when is_atom(atom) -> do_sanitize_function(raw, env) {:__aliases__, _, list} when is_list(list) -> do_sanitize_function(raw, env) _ -> do_sanitize_dot(left, right, args, ctx, env) end end defp do_sanitize({{:., dot_ctx, [{fn_or_ampersand, _, _} = anonymous]}, ctx, args}, env) when fn_or_ampersand in [:fn, :&] do safe_anonymous = do_sanitize(anonymous, env) safe_args = sanitize_args(args, env) {{:., dot_ctx, [safe_anonymous]}, ctx, safe_args} end defp do_sanitize({:dbg, meta, [expr]}, env) do quote do value = unquote(do_sanitize(expr, env)) IO.puts([ "[nofile:", to_string(unquote(meta[:line])), ": (file)]\n", unquote(Macro.to_string(expr)), " #=> ", inspect(value, pretty: true), ?\n ]) value end end defp do_sanitize({:|>, _, [expr, {:dbg, meta, []}]}, env) do header = quote do ["[nofile:", to_string(unquote(meta[:line])), ": (file)]\n"] end quote do value = unquote(dbg_pipeline(expr, env, header)) IO.write("\n") value end end defp do_sanitize({:|>, _, _} = ast, env) do case try_expand_once(ast) do {atom, _, _} = expanded when atom != :|> -> do_sanitize(expanded, env) end end defp do_sanitize({_, _, args} = raw, env) when is_list(args) do do_sanitize_function(raw, env) end defp try_expand_once(ast) do Macro.expand_once(ast, __ENV__) rescue error -> throw({:exception, error}) end defp do_sanitize_dot(left, key, args, ctx, env) do safe_left = do_sanitize(left, env) safe_args = sanitize_args(args, env) if args == [] and {:no_parens, true} in ctx do quote do Dune.Shims.Kernel.safe_dot( unquote(env_variable()), unquote(safe_left), unquote(key) ) end else quote do Dune.Shims.Kernel.safe_apply( unquote(env_variable()), unquote(safe_left), unquote(key), unquote(safe_args) ) end end end defp do_sanitize_function({func, ctx, atom}, env) when atom in [nil, Elixir] do do_sanitize_function({func, ctx, []}, env) end defp do_sanitize_function({{:., _, [{_variable_function, _, atom}]}, _, args} = raw, env) when atom in [nil, Elixir] and is_list(args) do sanitize_args_in_node(raw, env) end defp do_sanitize_function({func, _, args} = raw, env) when is_list(args) do {module, func_name} = extract_module_and_fun(func) arity = length(args) case CompileEnv.resolve_mfa(env, module, func_name, arity) do {:restricted, resolved_module} -> throw({:restricted, resolved_module, func_name, arity}) {:shimmed, shim_module, shim_func} -> safe_args = sanitize_args(args, env) quote do unquote(shim_module).unquote(shim_func)( unquote(env_variable()), unquote_splicing(safe_args) ) end {:fake, fake_module} -> safe_args = sanitize_args(args, env) quote do Dune.Eval.Env.apply_fake( unquote(env_variable()), unquote(fake_module), unquote(func_name), unquote(safe_args) ) end :allowed -> sanitize_args_in_node(raw, env) error -> handle_mfa_error(error, module, func_name, arity) end end defp extract_module_and_fun({:., _, [{:__aliases__, _, modules}, func_name]}) do {modules |> Module.concat(), func_name} end defp extract_module_and_fun({:., _, [erlang_module, func_name]}) when is_atom(erlang_module) do {erlang_module, func_name} end defp extract_module_and_fun(func_name) when is_atom(func_name) do {nil, func_name} end defp sanitize_capture({:/, _, [{func, _, _}, arity]} = raw, env) when is_integer(arity) do {module, func_name} = extract_module_and_fun(func) case CompileEnv.resolve_mfa(env, module, func_name, arity) do {:restricted, resolved_module} -> throw({:restricted, resolved_module, func_name, arity}) {:fake, fake_module} -> args = Macro.generate_unique_arguments(arity, nil) quote do fn unquote_splicing(args) -> Dune.Eval.Env.apply_fake( unquote(env_variable()), unquote(fake_module), unquote(func_name), unquote(args) ) end end {:shimmed, shim_module, shim_func} -> args = Macro.generate_unique_arguments(arity, nil) quote do # FIXME pass env here! fn unquote_splicing(args) -> unquote(shim_module).unquote(shim_func)( unquote(env_variable()), unquote_splicing(args) ) end end :allowed -> {:&, [], [raw]} error -> handle_mfa_error(error, module, func_name, arity) end end defp handle_mfa_error(:undefined_module, module, func_name, arity) do throw({:undefined_module, module, func_name, arity}) end defp handle_mfa_error(:undefined_function, module, func_name, arity) do throw({:undefined_function, module, func_name, arity}) end defp handle_mfa_error(:outside_module, _module, func_name, _arity) when func_name in [:def, :defp] do throw({:outside_module, func_name}) end defp sanitize_capture(capture_arg, env) do safe_capture_arg = do_sanitize(capture_arg, env) {:&, [], [safe_capture_arg]} end defp sanitize_args(args, env) when is_list(args) do Enum.map(args, &do_sanitize(&1, env)) end defp sanitize_args_in_node({_, _, args} = raw, env) when is_list(args) do safe_args = sanitize_args(args, env) put_elem(raw, 2, safe_args) end defp dbg_pipeline({:|>, _, [left, {fun, meta, args} = right]}, env, header) when is_list(args) and fun != :dbg do ast_with_placeholder = do_sanitize({fun, meta, [:__DUNE_RESERVED__ | args]}, env) ast = Macro.prewalk(ast_with_placeholder, fn :__DUNE_RESERVED__ -> dbg_pipeline(left, env, header) other -> other end) quote do value = unquote(ast) IO.puts([ "|> ", unquote(Macro.to_string(right)), " #=> ", inspect(value, pretty: true) ]) value end end defp dbg_pipeline(expr, env, header) do quote do value = unquote(do_sanitize(expr, env)) IO.puts([ unquote_splicing(header), unquote(Macro.to_string(expr)), " #=> ", inspect(value, pretty: true) ]) value end end @max_segment_size 256 @binary_modifiers [:binary, :bytes] @allowed_modifiers [:integer, :float, :bits, :bitstring, :utf8, :utf16, :utf32] ++ [:signed, :unsigned, :little, :big, :native] defp check_bin_modifier(modifier) do {size, unit} = check_bin_modifier_size(modifier, 8, nil) unit = unit || 1 if size * unit > @max_segment_size do throw({:bin_modifier_size, @max_segment_size}) end modifier end defp check_bin_modifier_size({:-, _, [left, right]}, size, unit) do {size, unit} = check_bin_modifier_size(left, size, unit) check_bin_modifier_size(right, size, unit) end defp check_bin_modifier_size(modifier, size, unit) do case modifier do new_size when is_integer(new_size) -> {new_size, unit} {:size, _, [new_size]} when is_integer(new_size) -> {new_size, unit} {:unit, _, [new_unit]} when is_integer(new_unit) -> {size, new_unit} {:*, _, [new_size, new_unit]} when is_integer(new_size) and is_integer(new_unit) -> {new_size, new_unit} {:size, _, [{:^, _, [{var, _, ctx}]}]} when is_atom(var) and is_atom(ctx) -> {size, unit} {atom, _, ctx} when atom in @binary_modifiers and is_atom(ctx) -> {size, unit || 8} {atom, _, ctx} when atom in @allowed_modifiers and is_atom(ctx) -> {size, unit} other -> throw({:bin_modifier_restricted, other}) end end defp env_variable do Macro.var(@env_variable_name, nil) end defp underscore_env_variable do Macro.var(:_env__Dune__, nil) end defp authorized_var_name?(name) do # e.g. recompile could be interpreted as recompile/0 not (RealModule.fun_exists?(Kernel, name, 0) or Macro.special_form?(name, 0)) end end ================================================ FILE: lib/dune/parser/string_parser.ex ================================================ defmodule Dune.Parser.StringParser do @moduledoc false alias Dune.{AtomMapping, Failure, Opts} alias Dune.Helpers.Diagnostics alias Dune.Parser.{AtomEncoder, UnsafeAst} @typep previous_session :: %{atom_mapping: AtomMapping.t()} # TODO options: parse timeout & max atoms @spec parse_string(String.t(), Opts.t(), previous_session | nil, boolean) :: UnsafeAst.t() | Failure.t() def parse_string(string, opts, previous_session, encode_modules? \\ true) when is_binary(string) and is_boolean(encode_modules?) do # import: do in a different process because the AtomEncoder pollutes the Process dict fn -> do_parse_string(string, opts, previous_session, encode_modules?) end |> Task.async() |> Task.await() end defp do_parse_string( string, %Opts{atom_pool_size: pool_size}, previous_session, encode_modules? ) do maybe_load_atom_mapping(previous_session) encoder = fn binary, _ctx -> AtomEncoder.static_atoms_encoder(binary, pool_size) end {result, diagnostics} = Diagnostics.with_diagnostics_polyfill(fn -> Code.string_to_quoted(string, static_atoms_encoder: encoder, existing_atoms_only: true) end) case result do {:ok, ast} -> maybe_encode_modules(ast, previous_session, encode_modules?) |> Diagnostics.prepend_diagnostics(diagnostics) {:error, {_ctx, error, token}} -> handle_failure(error, token) end end defp maybe_load_atom_mapping(nil), do: :ok defp maybe_load_atom_mapping(%{atom_mapping: atom_mapping}) do AtomEncoder.load_atom_mapping(atom_mapping) end defp maybe_encode_modules(ast, previous_session, encode_modules?) do plain_atom_mapping = AtomEncoder.plain_atom_mapping() {new_ast, atom_mapping} = if encode_modules? do existing_mapping = previous_session[:atom_mapping] AtomEncoder.encode_modules(ast, plain_atom_mapping, existing_mapping) else {ast, plain_atom_mapping} end %UnsafeAst{ast: new_ast, atom_mapping: atom_mapping} end defp handle_failure("Atoms containing" <> _ = error, token) do %Failure{message: error <> token, type: :restricted} end defp handle_failure(error, token) do failure = do_handle_failure(error, token) AtomEncoder.plain_atom_mapping() |> AtomMapping.replace_in_result(failure) end defp do_handle_failure({error, explanation}, token) when is_binary(error) and is_binary(explanation) do message = IO.iodata_to_binary([error, token, explanation]) %Failure{message: message, type: :parsing} end defp do_handle_failure(error, token) when is_binary(error) do %Failure{message: error <> token, type: :parsing} end end ================================================ FILE: lib/dune/parser/unsafe_ast.ex ================================================ defmodule Dune.Parser.UnsafeAst do @moduledoc false @type t :: %__MODULE__{ ast: Macro.t(), atom_mapping: Dune.AtomMapping.t(), stdio: binary() } @enforce_keys [:ast, :atom_mapping] defstruct @enforce_keys ++ [stdio: <<>>] end ================================================ FILE: lib/dune/parser.ex ================================================ defmodule Dune.Parser do @moduledoc false alias Dune.{AtomMapping, Success, Failure, Opts} alias Dune.Parser.{CompileEnv, StringParser, Sanitizer, SafeAst, UnsafeAst} @typep previous_session :: %{ atom_mapping: AtomMapping.t(), compile_env: Dune.Parser.CompileEnv.t() } @spec parse_string(String.t(), Opts.t(), previous_session | nil) :: SafeAst.t() | Failure.t() def parse_string(string, opts = %Opts{}, previous_session \\ nil) when is_binary(string) do compile_env = get_compile_env(opts, previous_session) string |> do_parse_string(opts, previous_session) |> Sanitizer.sanitize(compile_env) end defp do_parse_string(string, opts = %{max_length: max_length}, previous_session) do case String.length(string) do length when length > max_length -> %Failure{type: :parsing, message: "max code length exceeded: #{length} > #{max_length}"} _ -> StringParser.parse_string(string, opts, previous_session) end end @spec parse_quoted(Macro.t(), Opts.t(), previous_session | nil) :: SafeAst.t() def parse_quoted(quoted, opts = %Opts{}, previous_session \\ nil) do compile_env = get_compile_env(opts, previous_session) quoted |> unsafe_quoted() |> Sanitizer.sanitize(compile_env) end def unsafe_quoted(ast) do %UnsafeAst{ast: ast, atom_mapping: AtomMapping.new()} end defp get_compile_env(opts, nil) do CompileEnv.new(opts.allowlist) end defp get_compile_env(opts, %{compile_env: compile_env}) do %{compile_env | allowlist: opts.allowlist} end @spec string_to_quoted(String.t(), Opts.t()) :: Success.t() | Failure.t() def string_to_quoted(string, opts) do with unsafe = %UnsafeAst{} <- StringParser.parse_string(string, opts, nil, false) do inspected = inspect(unsafe.ast, pretty: opts.pretty) inspected = AtomMapping.replace_in_string(unsafe.atom_mapping, inspected) %Success{ value: unsafe.ast, inspected: inspected, stdio: unsafe.stdio } end end end ================================================ FILE: lib/dune/session.ex ================================================ defmodule Dune.Session do @moduledoc """ Sessions provide a way to evaluate code and keep state (bindings, modules...) between evaluations. - Use `Dune.eval_string/2` to execute code as a one-off - Use `Dune.Session.eval_string/3` to execute consecutive code blocks `Dune.Session` could be used to implement something like a safe IEx shell, or to compile a module once and call it several times without the overhead of parsing. `Dune.Session` is also a struct that is used to store the state of an evaluation. Only the following fields are public: - `last_result`: contains the result of the last evaluation, or `nil` for empty sessions Other fields are private and shouldn't be accessed directly. """ alias Dune.{Allowlist, Eval, Parser, Success, Failure, Opts} @opaque private_env :: Eval.Env.t() @opaque private_compile_env :: Parser.CompileEnv.t() @typedoc """ The type of a `Dune.Session`. """ @type t :: %__MODULE__{ last_result: nil | Success.t() | Failure.t(), env: private_env, compile_env: private_compile_env, bindings: keyword } @enforce_keys [:env, :compile_env, :bindings, :last_result] defstruct @enforce_keys @default_env Eval.Env.new(Dune.AtomMapping.new(), Allowlist.Default) @default_compile_env Parser.CompileEnv.new(Allowlist.Default) @doc """ Returns a new empty session. ## Examples iex> Dune.Session.new() #Dune.Session """ @spec new :: t def new do %__MODULE__{ env: @default_env, compile_env: @default_compile_env, bindings: [], last_result: nil } end @doc """ Evaluates the provided `string` in the context of the `session` and returns a new session. The result will be available in the `last_result` key. In case of a success, the variable bindings or created modules will be saved in the session. In case of a failure, the rest of the session state won't be updated, so it is possible to keep executing instructions after a failure ## Examples iex> Dune.Session.new() ...> |> Dune.Session.eval_string("x = 1") ...> |> Dune.Session.eval_string("x + 2") #Dune.Session iex> Dune.Session.new() ...> |> Dune.Session.eval_string("x = 1") ...> |> Dune.Session.eval_string("x = x / 0") # will fail, but the previous state is kept ...> |> Dune.Session.eval_string("x + 2") #Dune.Session """ @spec eval_string(t, String.t(), keyword) :: t def eval_string(session = %__MODULE__{}, string, opts \\ []) do opts = Opts.validate!(opts) parse_state = %{atom_mapping: session.env.atom_mapping, compile_env: session.compile_env} parsed = Parser.parse_string(string, opts, parse_state) parsed |> Eval.run(opts, session) |> add_result_to_session(session, parsed) end defp add_result_to_session(result = %Success{value: {value, env, bindings}}, session, %{ compile_env: compile_env }) do result = %{result | value: value} %{session | env: env, compile_env: compile_env, last_result: result, bindings: bindings} end defp add_result_to_session(result = %Failure{}, session, _) do %{session | last_result: result} end defimpl Inspect do import Inspect.Algebra def inspect(session, opts) do container_doc( "#Dune.Session<", [last_result: session.last_result], ", ...>", opts, &do_inspect/2, break: :strict ) end defp do_inspect({key, value}, opts) do key = inspect_as_key(key) |> color(:atom, opts) concat(key, concat(" ", to_doc(value, opts))) end if Code.ensure_loaded?(Macro) and function_exported?(Macro, :inspect_atom, 2) do defp inspect_as_key(key), do: Macro.inspect_atom(:key, key) else defp inspect_as_key(key), do: Code.Identifier.inspect_as_key(key) end end end ================================================ FILE: lib/dune/shims/atom.ex ================================================ defmodule Dune.Shims.Atom do @moduledoc false alias Dune.AtomMapping def to_string(env, atom) when is_atom(atom) do AtomMapping.to_string(env.atom_mapping, atom) end def to_charlist(env, atom) when is_atom(atom) do __MODULE__.to_string(env, atom) |> String.to_charlist() end end ================================================ FILE: lib/dune/shims/enum.ex ================================================ defmodule Dune.Shims.Enum do @moduledoc false def join(env, enumerable, joiner \\ "") when is_binary(joiner) do enumerable |> Enum.map_intersperse(joiner, &Dune.Shims.Kernel.safe_to_string(env, &1)) |> IO.iodata_to_binary() end def map_join(env, enumerable, joiner \\ "", mapper) when is_binary(joiner) and is_function(mapper, 1) do enumerable |> Enum.map_intersperse(joiner, &Dune.Shims.Kernel.safe_to_string(env, mapper.(&1))) |> IO.iodata_to_binary() end end ================================================ FILE: lib/dune/shims/io.ex ================================================ defmodule Dune.Shims.IO do @moduledoc false alias Dune.{Failure, Shims} def puts(env, device \\ :stdio, item) def puts(env, :stdio, item) do env |> Shims.Kernel.safe_to_string(item) |> then(&IO.puts(:stdio, &1)) end def puts(_env, _device, _item) do error = Failure.restricted_function(IO, :puts, 2) throw(error) end def inspect(env, item, opts \\ []) do inspect(env, :stdio, item, opts) end def inspect(env, :stdio, item, opts) when is_list(opts) do inspected = Shims.Kernel.safe_inspect(env, item, opts) chardata = if label_opt = opts[:label] do [Shims.Kernel.safe_to_string(env, label_opt), ": ", inspected] else inspected end IO.puts(:stdio, chardata) item end def inspect(_env, _device, _item, opts) when is_list(opts) do error = Failure.restricted_function(IO, :inspect, 3) throw(error) end end ================================================ FILE: lib/dune/shims/json.ex ================================================ if Code.ensure_loaded?(JSON) do defmodule Dune.Shims.JSON do @moduledoc false alias Dune.AtomMapping alias Dune.Shims def protocol_encode(env, value, encoder) when is_non_struct_map(value) do case :maps.next(:maps.iterator(value)) do :none -> "{}" {key, value, iterator} -> [ ?{, key(env, key, encoder), ?:, encoder.(value, encoder) | next(env, iterator, encoder) ] end end def protocol_encode(env, value, encoder) when is_atom(value) and value not in [nil, true, false] do encoder.(AtomMapping.to_string(env.atom_mapping, value), encoder) end def protocol_encode(_env, value, encoder) do JSON.protocol_encode(value, encoder) end defp next(env, iterator, encoder) do case :maps.next(iterator) do :none -> "}" {key, value, iterator} -> [ ?,, key(env, key, encoder), ?:, encoder.(value, encoder) | next(env, iterator, encoder) ] end end defp key(_env, key, encoder) when is_binary(key), do: encoder.(key, encoder) defp key(env, key, encoder), do: encoder.(Shims.Kernel.safe_to_string(env, key), encoder) def encode!(env, term) do encode!(env, term, &protocol_encode(env, &1, &2)) end def encode!(_env, term, encoder) do IO.iodata_to_binary(encoder.(term, encoder)) end def encode_to_iodata!(env, term) do encode_to_iodata!(env, term, &protocol_encode(env, &1, &2)) end def encode_to_iodata!(_env, term, encoder) do encoder.(term, encoder) end end end ================================================ FILE: lib/dune/shims/kernel.ex ================================================ defmodule Dune.Shims.Kernel do @moduledoc false alias Dune.{AtomMapping, Failure, Shims} alias Dune.Helpers.TermChecker defmacro safe_sigil_w(_env, _, ~c"a") do error = Failure.restricted_function(Kernel, :sigil_w, 2) throw(error) end defmacro safe_sigil_w(_env, term, modifiers) do quote do sigil_w(unquote(term), unquote(modifiers)) end end defmacro safe_sigil_W(_env, _, ~c"a") do error = Failure.restricted_function(Kernel, :sigil_W, 2) throw(error) end defmacro safe_sigil_W(_env, term, modifiers) do quote do sigil_W(unquote(term), unquote(modifiers)) end end def safe_throw(_env, value) do throw({:safe_throw, value}) end defmacro safe_dbg(_env) do error = Failure.restricted_function(Kernel, :dbg, 0) throw(error) end defmacro safe_dbg(_env, _term) do # should never be called because the sanitizer handles it raise "unexpected call safe_dbg/2" end defmacro safe_dbg(_env, _term, _opts) do error = Failure.restricted_function(Kernel, :dbg, 2) throw(error) end def safe_dot(_env, %{} = map, key) do # TODO test key error Map.fetch!(map, key) end def safe_dot(env, module, fun) when is_atom(module) do safe_apply(env, module, fun, []) end def safe_apply(_env, fun, args) when is_function(fun, 1) do # TODO check if there is a risk / why it is here apply(fun, args) end def safe_apply(env, module, fun, args) when is_atom(module) do arity = length(args) case env.allowlist.fun_status(module, fun, arity) do :restricted -> error = Failure.restricted_function(module, fun, arity) throw(error) {:shimmed, shim_module, shim_fun} -> apply(shim_module, shim_fun, [env | args]) :allowed -> apply(module, fun, args) :undefined_module -> Dune.Eval.Env.apply_fake(env, module, fun, args) :undefined_function -> throw({:undefined_function, module, fun, arity}) other when other in [:undefined_module, :undefined_function] -> Dune.Eval.Env.apply_fake(env, module, fun, args) end end def safe_inspect(env, term, opts \\ []) def safe_inspect(_env, term, opts) when is_number(term) or is_binary(term) or is_boolean(term) do inspect(term, opts) end def safe_inspect(env, atom, _opts) when is_atom(atom) do AtomMapping.inspect(env.atom_mapping, atom) end def safe_inspect(env, term, opts) do TermChecker.check(term) inspected = inspect(term, opts) AtomMapping.replace_in_string(env.atom_mapping, inspected) end def safe_to_string(env, atom) when is_atom(atom) do Shims.Atom.to_string(env, atom) end def safe_to_string(env, atom) when is_list(atom) do Shims.List.to_string(env, atom) end def safe_to_string(_env, other), do: to_string(other) def safe_to_charlist(env, atom) when is_atom(atom) do Shims.Atom.to_charlist(env, atom) end def safe_to_charlist(_env, other), do: to_charlist(other) end ================================================ FILE: lib/dune/shims/list.ex ================================================ defmodule Dune.Shims.List do @moduledoc false alias Dune.Shims def to_string(_env, list) when is_list(list) do do_to_string(list) end defp do_to_string(list) when is_list(list) do if Enum.any?(list, &is_list/1) do # eagerly convert lists to binary to prevent OOM on # structural sharing bombs Enum.map(list, &do_to_string/1) else list end |> List.to_string() end defp do_to_string(elem), do: elem # note: this is probably not safe so not actually used def to_existing_atom(env, list) when is_list(list) do string = to_string(list) # make sure it was actually a flat charlist and not an IO-list case to_charlist(string) do ^list -> Shims.String.to_existing_atom(env, string) _ -> List.to_existing_atom(list) end end def to_existing_atom(_env, list) do List.to_existing_atom(list) end end ================================================ FILE: lib/dune/shims/string.ex ================================================ defmodule Dune.Shims.String do @moduledoc false alias Dune.AtomMapping # note: this is probably not safe so not actually used def to_existing_atom(env, string) when is_binary(string) do AtomMapping.to_existing_atom(env, string) end def to_existing_atom(_env, string) do String.to_existing_atom(string) end end ================================================ FILE: lib/dune/success.ex ================================================ defmodule Dune.Success do @moduledoc """ A struct returned when `Dune` evaluation succeeds. Fields: - `value` (term): the value which was actually returned at runtime. Should not be displayed to the user, might be different from what the user expects. - `inspected` (string): safely inspected `value` to be displayed to the user - `stdio` (string): captured standard output `value` contains the actual value used at runtime, so atoms will be different from the ones displayed to the user (see `Dune.eval_string/2`). """ @type t :: %__MODULE__{ value: term, inspected: String.t(), stdio: binary } @enforce_keys [:value, :inspected, :stdio] defstruct @enforce_keys end ================================================ FILE: 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: ]", 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 ================================================ FILE: mix.exs ================================================ defmodule Dune.MixProject do use Mix.Project @version "0.3.15" @github_url "https://github.com/functional-rewire/dune" def project do [ app: :dune, version: @version, elixir: ">= 1.14.0 and < 1.20.0", start_permanent: Mix.env() == :prod, deps: deps(), dialyzer: [flags: [:missing_return, :extra_return]], # Hex description: "A sandbox for Elixir to safely evaluate untrusted code from user input", package: package(), aliases: aliases(), docs: docs() ] end # Run "mix help compile.app" to learn about applications. def application do [ extra_applications: [:logger] ] end # Run "mix help deps" to learn about dependencies. defp deps do [ # CI {:dialyxir, "~> 1.0", only: :test, runtime: false}, # DOCS {:ex_doc, "~> 0.24", only: :docs, runtime: false} ] end defp package do [ maintainers: ["functional-rewire", "sabiwara"], licenses: ["MIT"], links: %{"GitHub" => @github_url}, files: ~w(lib mix.exs .formatter.exs README.md LICENSE.md CHANGELOG.md) ] end defp aliases do [docs: ["compile --force", "docs"]] end def cli do [preferred_envs: [docs: :docs, "hex.publish": :docs, dialyzer: :test]] end defp docs do [ main: "Dune", source_ref: "v#{@version}", source_url: @github_url, homepage_url: @github_url, extras: ["README.md", "CHANGELOG.md", "LICENSE.md"] ] end end ================================================ FILE: test/dune/allowlist/default_test.exs ================================================ defmodule Dune.Allowlist.DefaultTest do use ExUnit.Case, async: true doctest Dune.Allowlist.Default alias Dune.Allowlist.Default describe "fun_status/3" do test "should not allow module_info/N" do assert :restricted = Default.fun_status(Float, :module_info, 0) assert :restricted = Default.fun_status(Float, :module_info, 1) assert :restricted = Default.fun_status(:math, :module_info, 0) assert :restricted = Default.fun_status(:math, :module_info, 1) end end end ================================================ FILE: test/dune/allowlist_test.exs ================================================ defmodule Dune.AllowlistTest do use ExUnit.Case, async: true doctest Dune.Allowlist describe "use/2" do test "creates a new sandbox " do defmodule CustomAllowlist do use Dune.Allowlist allow Kernel, only: [:+, :*, :-, :/, :div, :rem] allow Integer, only: [:pow] end assert :allowed = CustomAllowlist.fun_status(Kernel, :+, 2) assert :restricted = CustomAllowlist.fun_status(Kernel, :<>, 2) assert :undefined_function = CustomAllowlist.fun_status(Kernel, :foo, 1) assert :allowed = CustomAllowlist.fun_status(Integer, :pow, 2) assert :restricted = CustomAllowlist.fun_status(Integer, :to_string, 1) assert :undefined_function = CustomAllowlist.fun_status(Integer, :foo, 1) assert :restricted = CustomAllowlist.fun_status(String, :upcase, 1) assert :undefined_module = CustomAllowlist.fun_status(Foo, :foo, 1) end test "extends an existing sandbox " do defmodule CustomModule do def authorized(i), do: i + 1 def forbidden(i), do: i - 1 end defmodule ExtendedAllowlist do use Dune.Allowlist, extend: Dune.Allowlist.Default allow CustomModule, only: [:authorized] end assert :allowed = ExtendedAllowlist.fun_status(String, :upcase, 1) assert :restricted = ExtendedAllowlist.fun_status(String, :to_atom, 1) assert :allowed = ExtendedAllowlist.fun_status(CustomModule, :authorized, 1) assert :restricted = ExtendedAllowlist.fun_status(CustomModule, :forbidden, 1) assert :undefined_module = ExtendedAllowlist.fun_status(Foo, :foo, 1) end end end ================================================ FILE: test/dune/atom_mapping_test.exs ================================================ defmodule Dune.AtomMappingTest do use ExUnit.Case, async: true doctest Dune.AtomMapping end ================================================ FILE: test/dune/opts_test.exs ================================================ defmodule Dune.OptsTest do use ExUnit.Case, async: true doctest Dune.Opts end ================================================ FILE: test/dune/parser/atom_encoder_test.exs ================================================ defmodule Dune.Parser.AtomEncoderTest do use ExUnit.Case, async: true import Dune.Parser.AtomEncoder describe "categorize_atom_binary/1" do test "categorizes aliases cases" do assert :alias = categorize_atom_binary("Elixir") assert :alias = categorize_atom_binary("String") assert :alias = categorize_atom_binary("Foo.Bar") end test "categorizes valid public variable names" do assert :public_var = categorize_atom_binary("abc") assert :public_var = categorize_atom_binary("erlang") assert :public_var = categorize_atom_binary("あ") end test "categorizes valid private variable names" do assert :private_var = categorize_atom_binary("_") assert :private_var = categorize_atom_binary("_abc") assert :private_var = categorize_atom_binary("_あ") end test "categorizes other cases" do assert :other = categorize_atom_binary("") assert :other = categorize_atom_binary(" ") assert :other = categorize_atom_binary("a b") assert :other = categorize_atom_binary("A B") assert :other = categorize_atom_binary("Elixir. A") assert :other = categorize_atom_binary("Foo.Bar ") assert :other = categorize_atom_binary(" Foo.Bar") end end end ================================================ FILE: test/dune/parser/string_parser_test.exs ================================================ defmodule Dune.Parser.StringParserTest do use ExUnit.Case, async: true alias Dune.{AtomMapping, Opts} alias Dune.Parser.{StringParser, UnsafeAst} describe "parse_string/2" do test "existing atoms" do assert %UnsafeAst{ast: nil, atom_mapping: AtomMapping.new()} == StringParser.parse_string("nil", %Opts{}, nil) assert %UnsafeAst{ast: true, atom_mapping: AtomMapping.new()} == StringParser.parse_string("true", %Opts{}, nil) assert %UnsafeAst{ast: :atom, atom_mapping: AtomMapping.new()} == StringParser.parse_string(":atom", %Opts{}, nil) assert %UnsafeAst{ast: :Atom, atom_mapping: AtomMapping.new()} == StringParser.parse_string(":Atom", %Opts{}, nil) end test "existing modules" do assert %UnsafeAst{ ast: {:__aliases__, [line: 1], [:Module]}, atom_mapping: AtomMapping.new() } == StringParser.parse_string("Module", %Opts{}, nil) assert %UnsafeAst{ ast: {:__aliases__, [line: 1], [:Date, :Range]}, atom_mapping: AtomMapping.new() } == StringParser.parse_string("Date.Range", %Opts{}, nil) end test "non-existing atoms" do assert %UnsafeAst{ ast: :a__Dune_atom_1__, atom_mapping: %AtomMapping{ atoms: %{a__Dune_atom_1__: "my_atom"}, modules: %{}, extra_info: %{} } } == StringParser.parse_string(":my_atom", %Opts{}, nil) assert %UnsafeAst{ ast: :__Dune_atom_1__, atom_mapping: %AtomMapping{ atoms: %{__Dune_atom_1__: "_my_atom"}, modules: %{}, extra_info: %{} } } == StringParser.parse_string(":_my_atom", %Opts{}, nil) assert %UnsafeAst{ ast: :Dune_Atom_1__, atom_mapping: %AtomMapping{ atoms: %{Dune_Atom_1__: "MyAtom"}, modules: %{}, extra_info: %{} } } == StringParser.parse_string(":MyAtom", %Opts{}, nil) end test "non-existing modules" do assert %UnsafeAst{ ast: {:__aliases__, [line: 1], [:Dune_Module_1__]}, atom_mapping: %AtomMapping{ atoms: %{Dune_Atom_1__: "MyModule"}, modules: %{Dune_Module_1__ => "MyModule"}, extra_info: %{} } } == StringParser.parse_string("MyModule", %Opts{}, nil) assert %UnsafeAst{ ast: {:__aliases__, [line: 1], [:Dune_Module_1__]}, atom_mapping: %AtomMapping{ atoms: %{Dune_Atom_1__: "My", Dune_Atom_2__: "AwesomeModule"}, modules: %{Dune_Module_1__ => "My.AwesomeModule"}, extra_info: %{} } } == StringParser.parse_string("My.AwesomeModule", %Opts{}, nil) assert %UnsafeAst{ ast: {:__aliases__, [line: 1], [:Dune_Module_1__]}, atom_mapping: %AtomMapping{ atoms: %{Dune_Atom_1__: "My"}, modules: %{Dune_Module_1__ => "My.Module"}, extra_info: %{} } } == StringParser.parse_string("My.Module", %Opts{}, nil) end test ~S[non-existing "wrapped" atoms (with whitespace)] do assert %UnsafeAst{ ast: :__Dune_atom_1__, atom_mapping: %AtomMapping{ atoms: %{__Dune_atom_1__: " "}, modules: %{}, extra_info: %{__Dune_atom_1__: :wrapped} } } == StringParser.parse_string(~S(:" "), %Opts{}, nil) assert %UnsafeAst{ ast: :__Dune_atom_1__, atom_mapping: %AtomMapping{ atoms: %{__Dune_atom_1__: "my atom"}, modules: %{}, extra_info: %{__Dune_atom_1__: :wrapped} } } == StringParser.parse_string(~S(:"my atom"), %Opts{}, nil) end end end ================================================ FILE: test/dune/session_test.exs ================================================ defmodule Dune.SessionTest do use ExUnit.Case doctest Dune.Session, tags: [lts_only: true] alias Dune.{Session, Success, Failure} @module_code """ defmodule MySum do def sum(xs), do: sum(xs, 0) defp sum([], acc), do: acc defp sum([x | xs], acc) do sum(xs, acc + x) end end """ describe "eval_string/3" do test "keeps variable bindings" do session = Session.new() |> Session.eval_string("abcd = 5") |> Session.eval_string("abcd") assert %Session{ last_result: %Success{inspected: "5"}, bindings: [a__Dune_atom_1__: 5] } = session end test "ignores failed steps" do session = Session.new() |> Session.eval_string("abcd = 5") |> Session.eval_string("abcd = 1 / 0") |> Session.eval_string("abcd") assert %Session{ last_result: %Success{inspected: "5"}, bindings: [a__Dune_atom_1__: 5] } = session end test "keeps variable bindings on errors" do session = Session.new() |> Session.eval_string("abcd = 5") |> Session.eval_string("abcd / 0") assert %Session{ last_result: %Failure{ message: "** (ArithmeticError) bad argument in arithmetic expression" <> _ }, bindings: [a__Dune_atom_1__: 5] } = session end test "keeps the atom mapping" do session = Session.new() |> Session.eval_string("abcd = :abcd") |> Session.eval_string("efgh = :efgh") |> Session.eval_string("[abcd, efgh]") assert %Session{ last_result: %Success{inspected: "[:abcd, :efgh]"}, bindings: [ a__Dune_atom_2__: :a__Dune_atom_2__, a__Dune_atom_1__: :a__Dune_atom_1__ ] } = session end test "keeps the module mapping" do session = Session.new() |> Session.eval_string("acbd = [Aa]") |> Session.eval_string("acbd = acbd ++ [Bb]") |> Session.eval_string("acbd = acbd ++ [Aa.Bb]") |> Session.eval_string("acbd ++ [Cc.Dd]") assert %Session{ last_result: %Success{inspected: "[Aa, Bb, Aa.Bb, Cc.Dd]"}, bindings: [a__Dune_atom_1__: [Dune_Module_1__, Dune_Module_2__, Dune_Module_3__]] } = session end test "handles modules" do session = Session.new() |> Session.eval_string(@module_code) |> Session.eval_string("MySum.sum([1, 2, 100])") assert %Session{ last_result: %Success{inspected: "103"}, bindings: [] } = session end test "does not break due to Elixir single atom bug" do session = Session.new() |> Session.eval_string(":foo") assert %Session{ last_result: %Success{inspected: ":foo"}, bindings: [] } = session end end end ================================================ FILE: test/dune/shims_test.exs ================================================ defmodule Dune.ShimsTest do use ExUnit.Case, async: true alias Dune.Success alias Dune.Failure defmacrop sigil_E(call, _expr) do quote do Dune.eval_string(unquote(call), timeout: 100, inspect_sort_maps: true) end end describe "JSON" do @describetag :lts_only test "encode atoms" do assert %Success{value: ~S("json101"), inspected: ~S("\"json101\"")} = ~E'JSON.encode!(:json101)' assert %Success{value: ~S("json102"), inspected: ~S("\"json102\"")} = ~E'JSON.encode_to_iodata!(:json102) |> IO.iodata_to_binary()' assert %Success{ value: ~S({"json201":["json202",123,"foo",null,true]}), inspected: ~S("{\"json201\":[\"json202\",123,\"foo\",null,true]}") } = ~E'JSON.encode!(%{json201: [:json202, 123, "foo", nil, true]})' end test "decode atoms" do assert %Success{value: "json301", inspected: ~S("json301")} = ~E'JSON.decode!("\"json301\"")' end end describe "iodata / chardata" do test "List.to_string" do assert %Success{value: <<1, 2, 3>>} = ~E'List.to_string([1, 2, 3])' assert %Success{value: <<1, 2, 3>>} = ~E'List.to_string([1, [[2], 3]])' assert %Success{value: "abc"} = ~E'List.to_string(["a", [["b"], "c"]])' assert %Failure{message: "** (ArgumentError) cannot convert the given list" <> _} = ~E'List.to_string([1, :foo])' end test "IO.chardata_to_string" do assert %Success{value: <<1, 2, 3>>} = ~E'IO.chardata_to_string([1, 2, 3])' assert %Success{value: <<1, 2, 3>>} = ~E'IO.chardata_to_string([1, [[2], 3]])' assert %Success{value: "abc"} = ~E'IO.chardata_to_string(["a", [["b"], "c"]])' assert %Failure{message: "** (ArgumentError) cannot convert the given list" <> _} = ~E'IO.chardata_to_string([1, :foo])' end end end ================================================ FILE: test/dune/validation_test.exs ================================================ defmodule Dune.ValidationTest do end ================================================ FILE: test/dune_modules_test.exs ================================================ defmodule DuneModulesTest do use ExUnit.Case, async: true alias Dune.{Success, Failure} defmacro sigil_E(call, _expr) do # TODO fix memory needs quote do Dune.eval_string(unquote(call), max_reductions: 25_000, max_heap_size: 50_000, timeout: 100) end end describe "Dune authorized" do test "basic module" do result = ~E''' defmodule Hello do def greet(value) do IO.puts "Hello #{value}" end end Hello.greet(:world!) ''' assert %Success{value: :ok, stdio: "Hello world!\n"} = result end test "plain atom module" do result = ~E''' defmodule :hello do def greet(value) do IO.puts "Hello #{value}" end end :hello.greet(:world!) ''' assert %Success{value: :ok, stdio: "Hello world!\n"} = result end test "module without other code" do result = ~E''' defmodule Hello do end ''' assert %Success{ value: {:module, Dune_Module_1__, nil, nil}, inspected: "{:module, Hello, nil, nil}", stdio: "" } = result end test "default argument" do result = ~E''' defmodule My.Default do def incr(x \\ 0), do: x + 1 end [My.Default.incr(), My.Default.incr(100)] ''' assert %Success{value: [1, 101]} = result end test "default arguments" do result = ~E''' defmodule My.Defaults do def defaults(a \\ 1, b \\ 2, c) do [a, b, c] end end {My.Defaults.defaults(:c), My.Defaults.defaults(:a, :c)} ''' assert %Success{value: {[1, 2, :c], [:a, 2, :c]}} = result end test "recursive functions with guards" do result = ~E''' defmodule My.List do def my_sum([]), do: 0 def my_sum([h | t]) when is_number(h), do: h + my_sum(t) end My.List.my_sum([1, 100, 1000]) ''' assert %Success{value: 1101} = result end test "recursive functions in a nested block" do result = ~E''' defmodule My.List do def my_sum([]), do: 0 def my_sum([h | t]) do if is_number(h) do h + my_sum(t) else :NaN end end end My.List.my_sum([1, 100, 1000]) ''' assert %Success{value: 1101} = result end test "public and private functions" do assert %Success{value: "success!"} = ~E''' defmodule My.Module do def public, do: private() defp private, do: "success!" end My.Module.public ''' end test "recursive private function with guards" do result = ~E''' defmodule My.List do def my_sum(list) when is_list(list), do: my_sum(list, 0) defp my_sum([], acc), do: acc defp my_sum([h | t], acc) when is_number(h), do: my_sum(t, h + acc) end My.List.my_sum([1, 100, 1000]) ''' assert %Success{value: 1101} = result end test "captured fake module functions (external)" do assert %Success{value: ["Joe (20)", "Jane (27)"]} = ~E''' defmodule My.Captures do def format(%{name: name, age: age}) do "#{name} (#{age})" end end Enum.map( [%{name: "Joe", age: 20}, %{name: "Jane", age: 27}], &My.Captures.format/1 ) ''' assert %Success{inspected: ":foo"} = ~E''' defmodule My.Captures do def const, do: :foo end f = &My.Captures.const/0 f.() ''' end test "captured fake module functions (internal)" do assert %Success{value: ["Joe (20)", "Jane (27)"]} = ~E''' defmodule My.Captures do def format_many(list) do Enum.map(list, &format_one/1) end def format_one(%{name: name, age: age}) do "#{name} (#{age})" end end My.Captures.format_many([%{name: "Joe", age: 20}, %{name: "Jane", age: 27}]) ''' end test "apply fake module functions" do assert %Success{value: "Joe (20)"} = ~E''' defmodule My.Formatter do def format(%{name: name, age: age}) do "#{name} (#{age})" end end apply( My.Formatter, :format, [%{name: "Joe", age: 20}] ) ''' end test "accept docs and typespecs" do assert %Success{value: "Joe (20)"} = ~E''' defmodule My.Formatter do @moduledoc "Format all the things!" @typep name :: String.t() @type user :: %{name: name, age: integer} @doc "Formats a user" @spec format(user) :: String.t() def format(%{name: name, age: age}) do "#{name} (#{age})" end end My.Formatter.format(%{name: "Joe", age: 20}) ''' end test "0-arity call without parenthesis" do assert %Success{value: "success!"} = ~E''' defmodule My.Module do def public, do: private() defp private, do: "success!" end My.Module.public ''' end # TODO: apply private function end describe "exceptions" do test "function clause error" do assert %Failure{ type: :exception, message: "** (Dune.Eval.FunctionClauseError) no function clause matching in My.Checker.check_age/1: My.Checker.check_age(:invalid)" } = ~E''' defmodule My.Checker do def check_age(age) when is_integer(age) and age >= 18, do: :ok end My.Checker.check_age(:invalid) ''' end test "calling private function" do assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function My.Module.private/0 is undefined or private" } = ~E''' defmodule My.Module do def public, do: :public defp private, do: :private end My.Module.private ''' end test "conflicting public and private functions" do assert %Failure{ type: :exception, message: "** (Dune.Eval.CompileError) nofile:3: defp conflicting/0 already defined as def in nofile:2" } = ~E''' defmodule My.Module do def conflicting, do: "public" defp conflicting, do: "private" end ''' end end describe "Dune restricted" do test "unsafe function body" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function System.get_env/0 is restricted" } = ~E''' defmodule Danger do def danger() do System.get_env() end end Danger.danger() ''' end test "unsafe function default arg" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function System.get_env/0 is restricted" } = ~E''' defmodule Danger do def danger(env \\ System.get_env()) do env end end Danger.danger() ''' end test "restrictions in the module top level" do assert %Failure{ type: :module_restricted, message: "** (DuneRestrictedError) the following syntax is restricted inside defmodule:\n def no_block" } = ~E''' defmodule My.Module do def no_block end ''' assert %Failure{ type: :module_restricted, message: "** (DuneRestrictedError) the following syntax is restricted inside defmodule:\n @foo 1 + 1" } = ~E''' defmodule My.Module do @foo 1 + 1 end ''' end test "trying to redefine existing module" do assert %Failure{ type: :module_conflict, message: "** (DuneRestrictedError) Following module cannot be defined/redefined: System" } = ~E''' defmodule System do def foo, do: :bar end ''' assert %Failure{ type: :module_conflict, message: "** (DuneRestrictedError) Following module cannot be defined/redefined: String" } = ~E''' defmodule String do def foo, do: :bar end ''' assert %Failure{ type: :module_conflict, message: "** (DuneRestrictedError) Following module cannot be defined/redefined: ExUnit" } = ~E''' defmodule ExUnit do def foo, do: :bar end ''' assert %Failure{ type: :module_conflict, message: "** (DuneRestrictedError) Following module cannot be defined/redefined: Foo" } = ~E''' defmodule Foo do def foo, do: :bar end defmodule Foo do def foo, do: :bar end ''' end test "defmodule used with different arities" do assert %Failure{ type: :parsing, message: "dune parsing error: failed to safe parse\n defmodule" } = ~E'defmodule' assert %Failure{ type: :parsing, message: "dune parsing error: failed to safe parse\n defmodule do\n" <> _ } = ~E''' defmodule do def foo, do: :bar end ''' end test "defmodule with invalid module names" do assert %Failure{ type: :exception, message: "** (Dune.Eval.CompileError) nofile:1: invalid module name: true" } = ~E''' defmodule true do end ''' assert %Failure{ type: :exception, message: "** (Dune.Eval.CompileError) nofile:1: invalid module name: nil" } = ~E''' defmodule nil do end ''' assert %Failure{ type: :exception, message: "** (Dune.Eval.CompileError) nofile:1: invalid module name: 1" } = ~E''' defmodule 1 do end ''' end end end ================================================ FILE: test/dune_oom_safety_test.exs ================================================ defmodule Dune.AssertionHelper do alias Dune.Failure defmacro test_execution_stops(test_name, do: expr) do quote do test unquote(test_name) do # not sure if reductions or memory limit this first assert %Failure{message: "Execution stopped - " <> _} = Dune.eval_quoted(unquote(Macro.escape(expr)), timeout: 100) end end end end defmodule Dune.OOMSafetyTest do # Safety integration tests for "structural-sharing bombs" edge cases # that would cause BIFs to hang and use enormous amounts of memory use ExUnit.Case, async: true import Dune.AssertionHelper test_execution_stops "List.duplicate" do List.duplicate(:foo, 200_000) end # TODO figure out why this fails since Elixir 1.18 @tag :skip test_execution_stops "String.duplicate" do String.duplicate("foo", 200_000) end describe "structural sharing bombs" do test_execution_stops "returning value directly" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) end test_execution_stops "inspect" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> inspect(limit: :infinity) end test_execution_stops "string interpolation" do bomb = Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) "#{bomb}!" end test_execution_stops "to_string" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> to_string() end test_execution_stops "List.to_string" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> List.to_string() end test_execution_stops "IO.iodata_to_binary" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> IO.iodata_to_binary() end test_execution_stops "IO.iodata_length" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> IO.iodata_length() end test_execution_stops "IO.chardata_to_string" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> IO.chardata_to_string() end test_execution_stops "Enum.join" do Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) |> Enum.join() end @tag :lts_only test_execution_stops "JSON encode key" do bomb = Enum.reduce(1..100, ["foo", "bar"], fn _, acc -> [acc, acc] end) JSON.encode!(%{bomb => 123}) end end end ================================================ FILE: test/dune_quoted_test.exs ================================================ defmodule DuneQuotedTest do use ExUnit.Case, async: true import ExUnit.CaptureIO alias Dune.{Success, Failure} defmacrop dune(do: ast) do escaped_ast = Macro.escape(ast) quote do unquote(escaped_ast) |> Dune.eval_quoted(timeout: 100) end end describe "Dune authorized" do test "simple operations" do assert %Success{value: 5} = dune(do: 2 + 3) assert %Success{value: 15} = dune(do: 5 * 3) assert %Success{value: 0.5} = dune(do: 1 / 2) assert %Success{value: [1, 2, 3, 4]} = dune(do: [1, 2] ++ [3, 4]) assert %Success{value: "abcd"} = dune(do: "ab" <> "cd") end test "basic Kernel functions" do assert %Success{value: 10} = dune(do: max(5, 10)) assert %Success{value: "foo"} = dune(do: to_string(:foo)) assert %Success{value: true} = dune(do: is_atom(:foo)) end test "Kernel guards" do assert %Success{value: false} = dune(do: is_binary(55)) assert %Success{value: true} = dune(do: is_number(55)) end test "basic String functions" do assert %Success{value: "JoJo"} = dune(do: String.replace("jojo", "j", "J")) end test "basic Map functions" do assert %Success{value: %{foo: 5}} = dune(do: Map.put(%{}, :foo, 5)) assert %Success{value: %{}} = dune(do: Map.new()) end test "basic :math functions" do assert %Success{value: 3.0} = dune(do: :math.log10(1000)) assert %Success{value: 3.141592653589793} = dune(do: :math.pi()) end test "tuples" do assert %Success{value: {}} = dune(do: {}) assert %Success{value: {:foo}} = dune(do: {:foo}) assert %Success{value: {"hello", ~c"world"}} = dune(do: {"hello", ~c"world"}) assert %Success{value: {1, 2, 3}} = dune(do: {1, 2, 3}) end test "map operations" do assert %Success{value: %{a: :foo, b: 6}} = (dune do map = %{a: 5, b: 6} %{map | a: :foo} end) assert %Success{value: "Dio"} = (dune do user = %{first_name: "Dio", last_name: "Brando"} user.first_name end) end test "dynamic module names (authorized functions)" do assert %Success{value: %{}} = (dune do module = Map module.new() end) assert %Success{value: [%{}, %MapSet{}]} = (dune do Enum.map([Map, MapSet], fn module -> module.new end) end) assert %Success{value: [%{}, %MapSet{}]} = (dune do Enum.map([Map, MapSet], fn module -> module.new() end) end) end test "captures" do assert %Success{value: ["1", "2", "3"]} = (dune do 1..3 |> Enum.map(&inspect/1) end) assert %Success{value: [[0], [1, 0], [2, 0], [3, 0]]} = (dune do 0..30//10 |> Enum.map(&Integer.digits/1) end) assert %Success{value: 3.317550714905183e39} = (dune do 1..100//10 |> Enum.map(&:math.exp/1) |> Enum.sum() end) end test "sigils" do assert %Success{value: %Regex{source: "(a|b)?c"}} = dune(do: ~r/(a|b)?c/) assert %Success{value: ~U[2021-05-20 01:02:03Z]} = dune(do: ~U[2021-05-20 01:02:03Z]) assert %Success{value: [~c"foo", ~c"bar", ~c"baz"]} = dune(do: ~W[foo bar baz]c) assert %Success{value: [~c"foo", ~c"bar", ~c"baz"]} = dune(do: ~w[#{String.downcase("FOO")} bar baz]c) assert %Dune.Failure{ message: "** (DuneRestrictedError) function sigil_W/2 is restricted", type: :restricted } = dune(do: ~W[foo bar baz]a) end test "block of code" do assert %Success{value: "quick-brown-fox-jumps-over-lazy-dog"} = (dune do sentence = "the quick brown fox jumps over the lazy dog" words = String.split(sentence) filtered = Enum.reject(words, &(&1 == "the")) Enum.join(filtered, "-") end) end test "pipe operator" do assert %Success{value: "quick-brown-fox-jumps-over-lazy-dog"} = (dune do "the quick brown fox jumps over the lazy dog" |> String.split() |> Enum.reject(&(&1 == "the")) |> Enum.join("-") end) end test "if block" do assert %Success{value: {:foo, 6}} = (dune do x = 6 if x > 5 do {:foo, x} else {:bar, x} end end) end test "case block" do assert %Success{value: {:bar, 4}} = (dune do case 4 do x when x > 5 -> {:foo, x} y -> {:bar, y} end end) end test "for block" do assert %Success{value: [:b, :c]} = (dune do for {x, y} <- [a: 1, b: 2, c: 3], y > 1, do: x end) end end describe "Dune restricted" do test "System calls" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function System.get_env/0 is restricted" } = dune(do: System.get_env()) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function System.get_env/1 is restricted" } = dune(do: System.get_env("TEST")) end test "Code calls" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = dune(do: Code.eval_string("IO.puts(:hello)")) end test "String/List restricted methods" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function String.to_atom/1 is restricted" } = dune(do: String.to_atom("foo")) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function List.to_atom/1 is restricted" } = dune(do: List.to_atom(~c"foo")) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function String.to_existing_atom/1 is restricted" } = dune(do: String.to_existing_atom("foo")) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function List.to_existing_atom/1 is restricted" } = dune(do: List.to_existing_atom(~c"foo")) end test "atom interpolation" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.binary_to_atom/2 is restricted" } = dune(do: :"#{1 + 1} is two") end test "Kernel apply/3" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Process.get/0 is restricted" } = (dune do apply(Process, :get, []) end) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function IO.puts/2 is restricted" } = (dune do apply(IO, :puts, [:stderr, "Hello"]) end) end test ". operator with variable modules" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.yield/0 is restricted" } = (dune do module = :erlang module.yield end) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.processes/0 is restricted" } = (dune do Enum.map([:erlang], fn module -> module.processes end) end) end test "capture operator" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = dune(do: f = &Code.eval_string/1) end test "Kernel 0-arity functions" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function binding/0 is restricted" } = (dune do binding end) end test "erlang unsafe libs" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.processes/0 is restricted" } = dune(do: :erlang.processes()) assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.get/0 is restricted" } = dune(do: :erlang.get()) end test "nested restricted code" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = (dune do f = fn -> Code.eval_string("IO.puts(:hello)") end end) end end describe "process restrictions" do test "execution timeout" do assert %Failure{type: :timeout, message: "Execution timeout - 100ms"} = dune(do: Process.sleep(101)) end test "too many reductions" do assert %Failure{type: :reductions, message: "Execution stopped - reductions limit exceeded"} = dune(do: Enum.any?(1..1_000_000, &(&1 < 0))) end test "uses to much memory" do assert %Failure{type: :memory, message: "Execution stopped - memory limit exceeded"} = dune(do: List.duplicate(:foo, 100_000)) end end describe "error handling" do test "math error" do assert %Failure{ type: :exception, message: "** (ArithmeticError) bad argument in arithmetic expression\n" <> rest } = dune(do: 42 / 0) assert rest =~ ":erlang./(42, 0)" end test "throw" do assert %Failure{type: :throw, message: "** (throw) :yo"} = dune(do: throw(:yo)) end test "raise" do assert %Failure{type: :exception, message: "** (ArgumentError) kaboom!"} = (dune do raise ArgumentError, "kaboom!" end) end test "actual UndefinedFunctionError" do assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function Code.baz/0 is undefined or private" } = dune(do: Code.baz()) assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function FooBar.baz/0 is undefined (module FooBar is not available)" } = dune(do: FooBar.baz()) assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function :foo_bar.baz/0 is undefined (module :foo_bar is not available)" } = dune(do: :foo_bar.baz()) end @tag :lts_only test "undefined variable" do capture_io(:stderr, fn -> # TODO better error messages assert %Failure{ type: :compile_error, message: "** (CompileError) nofile: cannot compile file (errors have been logged)", stdio: "error: undefined variable \"y\"\n" <> _ } = dune(do: y) assert %Failure{ type: :compile_error, message: "** (CompileError) nofile: cannot compile file (errors have been logged)", stdio: "error: undefined variable \"x\"\n" <> _ } = dune(do: if(x, do: x)) end) end end end ================================================ FILE: test/dune_string_test.exs ================================================ defmodule DuneStringTest do use ExUnit.Case, async: true alias Dune.{Success, Failure} defmacro sigil_E(call, _expr) do quote do Dune.eval_string(unquote(call), timeout: 100, inspect_sort_maps: true) end end describe "Dune authorized" do test "simple operations" do assert %Success{value: 5, inspected: ~S'5'} = ~E'2 + 3' assert %Success{value: 15, inspected: ~S'15'} = ~E'5 * 3' assert %Success{value: 0.5, inspected: ~S'0.5'} = ~E'1 / 2' assert %Success{value: [1, 2, 3, 4], inspected: ~S'[1, 2, 3, 4]'} = ~E'[1, 2] ++ [3, 4]' assert %Success{value: "abcd", inspected: ~S'"abcd"'} = ~E'"ab" <> "cd"' assert %Success{value: true, inspected: ~S'true'} = ~E'"abc!" =~ ~r/abc/' end test "basic Kernel functions" do assert %Success{value: 10, inspected: ~S'10'} = ~E'max(5, 10)' assert %Success{value: "foo", inspected: ~S'"foo"'} = ~E'to_string(:foo)' assert %Success{value: true, inspected: ~S'true'} = ~E'is_atom(:foo)' end test "Kernel guards" do assert %Success{value: false, inspected: ~S'false'} = ~E'is_binary(55)' assert %Success{value: true, inspected: ~S'true'} = ~E'is_number(55)' end test "basic String functions" do assert %Success{value: "JoJo", inspected: ~S'"JoJo"'} = ~E'String.replace("jojo", "j", "J")' end test "basic Map functions" do assert %Success{value: %{foo: 5}, inspected: ~S'%{foo: 5}'} = ~E'Map.put(%{}, :foo, 5)' assert %Success{value: %{}, inspected: ~S'%{}'} = ~E'Map.new()' end test "basic :math functions" do assert %Success{value: 3.0, inspected: ~S'3.0'} = ~E':math.log10(1000)' assert %Success{value: 3.141592653589793, inspected: ~S'3.141592653589793'} = ~E':math.pi()' end @tag :lts_only test "tuples" do assert %Success{value: {}, inspected: ~S'{}'} = ~E'{}' assert %Success{value: {:foo}, inspected: ~S'{:foo}'} = ~E'{:foo}' assert %Success{value: {"hello", ~c"world"}, inspected: ~S/{"hello", ~c"world"}/} = ~E/{"hello", 'world'}/ assert %Success{value: {1, 2, 3}, inspected: ~S'{1, 2, 3}'} = ~E'{1, 2, 3}' end test "map operations" do assert %Success{value: %{a: :foo, b: 6}, inspected: ~S'%{a: :foo, b: 6}'} = ~E' map = %{a: 5, b: 6} %{map | a: :foo} ' assert %Success{ value: "Dio", inspected: ~S'"Dio"' } = ~E' user = %{first_name: "Dio", last_name: "Brando"} user.first_name ' end test "dynamic module names (authorized functions)" do assert %Success{value: %{}, inspected: ~S'%{}'} = ~E'module = Map; module.new()' assert %Success{value: [%{}], inspected: ~S'[%{}]'} = ~E'Enum.map([Map], fn module -> module.new end)' assert %Success{value: [%{}], inspected: ~S'[%{}]'} = ~E'Enum.map([Map], fn module -> module.new() end)' assert %Success{value: 3, inspected: ~S'3'} = ~E'apply(List, :last, [[1, 2, 3]])' end test "captures" do assert %Success{value: 33, inspected: ~S'33'} = ~E'f = &+/2; f.(11, 22)' assert %Success{value: 35, inspected: ~S'35'} = ~E'f = & &1*&2; f.(5, 7)' assert %Success{value: 1.5, inspected: ~S'1.5'} = ~E'f = & &1/&2; 3 |> f.(2)' assert %Success{value: 20, inspected: ~S'20'} = ~E'apply(& &1 * 2, [10])' assert %Success{value: ["1", "2", "3"], inspected: ~S'["1", "2", "3"]'} = ~E'1..3 |> Enum.map(&inspect/1)' assert %Success{ value: [[0], [1, 0], [2, 0], [3, 0]], inspected: ~S'[[0], [1, 0], [2, 0], [3, 0]]' } = ~E'0..30//10 |> Enum.map(&Integer.digits/1)' assert %Success{value: 3.317550714905183e39, inspected: ~S'3.317550714905183e39'} = ~E'1..100//10 |> Enum.map(&:math.exp/1) |> Enum.sum()' end test "anonymous functions" do assert %Success{value: 0, inspected: ~S'0'} = ~E'f = fn -> _x = 0 end; f.()' assert %Success{value: 0, inspected: ~S'0'} = ~E'(fn -> _x = 0 end).()' assert %Success{value: 3, inspected: ~S'3'} = ~E'2 |> (fn x -> x + 1 end).()' assert %Success{value: -4, inspected: ~S'-4'} = ~E'(&(&2 - &1)).(7, 3)' assert %Success{value: -4, inspected: ~S'-4'} = ~E'(& &2 - &1).(7, 3)' assert %Success{value: 3, inspected: ~S'3'} = ~E'2 |> (& &1 + 1).()' assert %Success{value: 0.2, inspected: ~S'0.2'} = ~E'(& &2 / &1).(10, 2)' end @tag :lts_only test "sigils" do assert %Success{value: %Regex{source: "(a|b)?c"}, inspected: ~S'~r/(a|b)?c/'} = ~E'~r/(a|b)?c/' assert %Success{value: ~U[2021-05-20 01:02:03Z], inspected: ~S'~U[2021-05-20 01:02:03Z]'} = ~E'~U[2021-05-20 01:02:03Z]' assert %Success{ value: [~c"foo", ~c"bar", ~c"baz"], inspected: ~S'[~c"foo", ~c"bar", ~c"baz"]' } = ~E'~W[foo bar baz]c' assert %Success{ value: [~c"foo", ~c"bar", ~c"baz"], inspected: ~S'[~c"foo", ~c"bar", ~c"baz"]' } = ~E'~w[#{String.downcase("FOO")} bar baz]c' assert %Dune.Failure{ message: "** (DuneRestrictedError) function sigil_W/2 is restricted", type: :restricted } = ~E'~W[foo bar baz]a' assert %Dune.Failure{ message: "** (DuneRestrictedError) function sigil_w/2 is restricted", type: :restricted } = ~E'~w[#{String.downcase("FOO")} bar baz]a' end @tag :lts_only test "bitstring modifiers" do assert %Success{ value: <<3>>, inspected: "<<3>>" } = ~E'<<3>>' assert %Success{ value: <<3::4>>, inspected: "<<3::size(4)>>" } = ~E'<<3::4>>' assert %Success{ value: "ߧ", inspected: ~S'"ߧ"' } = ~E'<<2023::utf8>>' assert %Success{ value: 12520, inspected: "12520" } = ~E'<> = "ヨ"; c' assert %Success{ value: <<3::4>>, inspected: "<<3::size(4)>>" } = ~E'<<3::size(4)>>' assert %Success{ value: 6_382_179, inspected: "6382179" } = ~E'<> = "abc"; c' assert %Success{ value: <<2, 3>>, inspected: "<<2, 3>>" } = ~E'<<_::binary-size(2), rest::binary>> = <<0, 1, 2, 3>>; rest' assert %Success{ value: <<0, 0, 0, 1>>, inspected: "<<0, 0, 0, 1>>" } = ~E''' x = 1 <> ''' assert %Success{ value: <<0, 0, 0, 1>>, inspected: "<<0, 0, 0, 1>>" } = ~E''' x = 1 <> ''' assert %Success{ value: {"Frank", "Walrus"}, inspected: ~S'{"Frank", "Walrus"}' } = ~E''' name_size = 5 <> = <<"Frank the Walrus">> {name, species} {"Frank", "Walrus"} ''' end test "binary comprehensions" do assert %Success{ value: [{213, 45, 132}, {64, 76, 32}], inspected: "[{213, 45, 132}, {64, 76, 32}]" } = ~E''' pixels = <<213, 45, 132, 64, 76, 32>> for <>, do: {r, g, b} ''' end test "block of code" do assert %Success{ value: "quick-brown-fox-jumps-over-lazy-dog", inspected: ~S'"quick-brown-fox-jumps-over-lazy-dog"' } = ~E' sentence = "the quick brown fox jumps over the lazy dog" words = String.split(sentence) filtered = Enum.reject(words, &(&1 == "the")) Enum.join(filtered, "-") ' end test "pipe operator" do assert %Success{ value: "quick-brown-fox-jumps-over-lazy-dog", inspected: ~S'"quick-brown-fox-jumps-over-lazy-dog"' } = ~E' "the quick brown fox jumps over the lazy dog" |> String.split() |> Enum.reject(&(&1 == "the")) |> Enum.join("-") ' assert %Success{value: ":foo", inspected: ~S'":foo"'} = ~E':foo |> inspect()' end test "atoms" do assert %Success{value: "foo51", inspected: ~s'"foo51"'} = ~E'to_string(:foo51)' assert %Success{value: "foo52", inspected: ~s'"foo52"'} = ~E'Atom.to_string(:foo52)' assert %Success{value: "foo53", inspected: ~s'"foo53"'} = ~E'Enum.join([:foo53])' assert %Success{value: "boo57", inspected: ~s'"boo57"'} = ~E':foo57 |> to_string() |> String.replace("f", "b")' assert %Success{value: "Hello boo58", inspected: ~s'"Hello boo58"'} = ~E'"Hello #{:foo58}" |> String.replace("f", "b")' assert %Success{value: :Dune_Atom_1__, inspected: ~s':Foo12'} = ~E':Foo12' assert %Success{value: Dune_Module_1__, inspected: ~s'Foo13'} = ~E'Foo13' assert %Success{value: "Foo14", inspected: ~s'"Foo14"'} = ~E'Atom.to_string(:Foo14)' assert %Success{value: "Elixir.Foo15", inspected: ~s("Elixir.Foo15")} = ~E'Atom.to_string(Foo15)' assert %Success{value: ":Foo16", inspected: ~s'":Foo16"'} = ~E'inspect(:Foo16)' assert %Success{value: "Foo17", inspected: ~s'"Foo17"'} = ~E'inspect(Foo17)' assert %Success{value: "Elixir.Foo.Bar33", inspected: ~s("Elixir.Foo.Bar33")} = ~E'Atom.to_string(Foo.Bar33)' assert %Success{ value: [ Dune_Module_1__, {:a__Dune_atom_2__, :Dune_Atom_1__}, [a__Dune_atom_2__: 15, Dune_Atom_1__: 6, __Dune_atom_3__: 33] ], inspected: ~s([Foo91, {:foo91, :Foo91}, [foo91: 15, Foo91: 6, _foo92: 33]]) } = ~E'[Foo91, {:foo91, :Foo91}, [foo91: 15, Foo91: 6, _foo92: 33]]' end @tag :lts_only test "atoms to charlist" do assert %Success{value: ~c"Hello foo59", inspected: ~s'~c"Hello foo59"'} = ~E'~c"Hello #{:foo59}"' assert %Success{value: ~c"Elixir.Foo15", inspected: ~s(~c"Elixir.Foo15")} = ~E'Atom.to_charlist(Foo15)' end test "atoms (prefixed by Elixir)" do assert %Success{value: Elixir, inspected: ~s'Elixir'} = ~E'Elixir' assert %Success{value: Dune_Module_1__, inspected: ~s'Foo13'} = ~E'Elixir.Foo13' assert %Success{value: Dune_Module_1__, inspected: ~s'Foo13'} = ~E':"Elixir.Foo13"' assert %Success{value: true, inspected: ~s'true'} = ~E'Elixir.Foo13 == Foo13' assert %Success{value: true, inspected: ~s'true'} = ~E':"Elixir.Foo13" == Foo13' assert %Success{value: true, inspected: ~s'true'} = ~E'Elixir.Foo13.Foo13 == Foo13.Foo13' assert %Success{value: true, inspected: ~s'true'} = ~E':"Elixir.Foo13.Foo13" == Foo13.Foo13' assert %Success{value: String, inspected: ~s'String'} = ~E':"Elixir.String"' assert %Success{value: Dune_Module_1__, inspected: ~s'Elixir.Elixir'} = ~E'Elixir.Elixir' assert %Success{value: Dune_Module_1__, inspected: ~s'Elixir.Elixir'} = ~E':"Elixir.Elixir"' end test "atoms (wrapped with quotes)" do assert %Success{value: :__Dune_atom_1__, inspected: ~s':" "'} = ~E':" "' assert %Success{value: :__Dune_atom_1__, inspected: ~s':"foo/bar"'} = ~E':"foo/bar"' assert %Success{value: " ", inspected: ~s'" "'} = ~E'to_string :" "' assert %Success{value: "foo/bar", inspected: ~s'"foo/bar"'} = ~E'to_string :"foo/bar"' assert %Success{ value: [ __Dune_atom_1__: {:__Dune_atom_2__, :__Dune_atom_3__}, __Dune_atom_4__: %{__Dune_atom_5__: :__Dune_atom_6__, a__Dune_atom_7__: 6} ], inspected: ~s([" ": {:"\t", :" A"}, "ab cd": %{"foo+91": :"15", abc: 6}]) } = ~E([" ": {:"\t", :" A"}, "ab cd": %{"foo+91": :'15', abc: 6}]) end test "function and atom parameters" do assert ":digits" = ~E':digits'.value |> inspect() assert ":turkic" = ~E':turkic'.value |> inspect() end test "stdio capture" do assert %Success{value: :ok, inspected: ~s(:ok), stdio: "yo!\n"} = ~E'IO.puts("yo!")' assert %Success{value: :ok, inspected: ~s(:ok), stdio: "foo987\n"} = ~E'IO.puts(:foo987)' assert %Success{value: :ok, inspected: ~s(:ok), stdio: "hello world!\n"} = ~E'io = IO; io.puts(["hello", ?\s, "world", ?!])' assert %Success{value: :ok, inspected: ~s(:ok), stdio: "1\n2\n3\n"} = ~E'Enum.each(1..3, &IO.puts/1)' assert %Success{value: :a__Dune_atom_1__, inspected: ~s(:foo912), stdio: ":foo912\n"} = ~E'IO.inspect(:foo912)' assert %Success{ value: %{a__Dune_atom_1__: 581}, inspected: ~s(%{foo9101: 581}), stdio: "bar777: %{foo9101: 581}\n" } = ~E'%{foo9101: 581} |> IO.inspect(label: :bar777)' assert %Success{ value: :ok, inspected: ":ok", stdio: ":foo9321\nfoo9321\n" } = ~E'io = IO; io.puts(io.inspect(:foo9321))' end test "dbg" do assert %Success{value: :a__Dune_atom_1__, inspected: ~s(:foo913), stdio: stdio} = ~E'dbg(:foo913)' assert stdio == """ [nofile:1: (file)] :foo913 #=> :foo913 """ assert %Success{value: :a__Dune_atom_1__, inspected: ~s(:foo914), stdio: stdio} = ~E':foo914 |> dbg()' assert stdio == """ [nofile:1: (file)] :foo914 #=> :foo914 """ assert %Success{value: ":foo915", inspected: ~s(":foo915"), stdio: stdio} = ~E':foo915 |> inspect() |> dbg()' assert stdio == """ [nofile:1: (file)] :foo915 #=> :foo915 |> inspect() #=> ":foo915" """ assert %Success{value: "Hello World", inspected: ~s("Hello World"), stdio: stdio} = ~E'"hello world" |> String.split() |> Enum.map_join(" ", &String.capitalize/1) |> dbg()' assert stdio == """ [nofile:1: (file)] "hello world" #=> "hello world" |> String.split() #=> ["hello", "world"] |> Enum.map_join(" ", &String.capitalize/1) #=> "Hello World" """ assert %Success{value: ":foo987398", inspected: ~s(":foo987398"), stdio: stdio} = ~E':foo987398 |> inspect() |> dbg() |> dbg()' assert stdio == """ [nofile:1: (file)] :foo987398 #=> :foo987398 |> inspect() #=> ":foo987398" [nofile:1: (file)] :foo987398 |> inspect() |> dbg() #=> ":foo987398" """ assert %Dune.Failure{ type: :restricted, message: "** (DuneRestrictedError) function dbg/0 is restricted" } = ~E'dbg()' assert %Dune.Failure{ type: :restricted, message: "** (DuneRestrictedError) function dbg/2 is restricted" } = ~E'dbg(:abc, syntax_colors: [])' assert %Dune.Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'Code.eval_string(":hello") |> dbg()' assert %Dune.Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'":hello" |> Code.eval_string() |> dbg()' end test "pretty option" do raw_string = ~S'{"This line is really long, maybe we should break", [%{bar: 1, baz: 2}, %{bar: 55}]}' with_break = ~s'{"This line is really long, maybe we should break",\n [%{bar: 1, baz: 2}, %{bar: 55}]}' assert %Success{inspected: ^raw_string} = Dune.eval_string(raw_string, inspect_sort_maps: true) assert %Success{inspected: ^with_break} = Dune.eval_string(raw_string, pretty: true, inspect_sort_maps: true) end end describe "Dune restricted" do test "System calls" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function System.get_env/0 is restricted" } = ~E'System.get_env()' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function System.get_env/1 is restricted" } = ~E'System.get_env("TEST")' end test "Code calls" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'Code.eval_string("IO.puts(:hello)")' end test "String/List restricted methods" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function String.to_atom/1 is restricted" } = ~E'String.to_atom("foo")' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function List.to_atom/1 is restricted" } = ~E/List.to_atom('foo')/ end test "atom interpolation" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.binary_to_existing_atom/2 is restricted" } = ~E':"#{1 + 1} is two"' end test "Kernel apply/3" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Process.get/0 is restricted" } = ~E'apply(Process, :get, [])' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function IO.puts/2 is restricted" } = ~E'apply(IO, :puts, [:stderr, "Hello"])' end test ". operator with variable modules" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.yield/0 is restricted" } = ~E' module = :erlang module.yield ' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.processes/0 is restricted" } = ~E' Enum.map([:erlang], fn module -> module.processes end) ' end test ". operator as key access" do assert %Success{value: 100, inspected: ~S'100'} = ~E'users = [john: %{age: 100}]; users[:john].age' end test ". operator various failures" do assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function :foo.bar/0 is undefined (module :foo is not available)" } = ~E'module = :foo; module.bar()' assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function List.bar/0 is undefined or private" } = ~E'module = List; module.bar()' assert %Failure{ type: :exception, message: "** (KeyError) key :job not found in" <> rest_message } = ~E'users = [john: %{age: 100}]; users[:john].job' assert rest_message =~ "%{age: 100}" assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function Foo1234.bar567/0 is undefined (module Foo1234 is not available)" } = ~E'Foo1234.bar567.baz890' end test "pipe operator" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'":foo" |> Code.eval_string()' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'code = Code; "1 + 1" |> code.eval_string()' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'code = Code; "1 + 1" |> code.eval_string' end test "capture operator" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'f = &Code.eval_string/1' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'f = &Code.eval_string(&1)' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'(&Code.eval_string/1).(":pawned!")' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'(&Code.eval_string(&1)).(":pawned!")' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'":pawned!" |> (&Code.eval_string/1).()' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'":pawned!" |> (&Code.eval_string(&1)).()' end test "Kernel 0-arity functions" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function binding/0 is restricted" } = ~E'binding' end test "erlang unsafe libs" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.processes/0 is restricted" } = ~E':erlang.processes()' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.get/0 is restricted" } = ~E':erlang.get()' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function :erlang.process_info/1 is restricted" } = ~E':erlang.process_info(self)' end test "nested restricted code" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function Code.eval_string/1 is restricted" } = ~E'f = fn -> Code.eval_string("IO.puts(:hello)") end' end test "partially restricted shims" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function IO.puts/2 is restricted" } = ~E'IO.puts(:stderr, "foo")' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function IO.puts/2 is restricted" } = ~E':stderr |> IO.puts("foo")' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function IO.inspect/3 is restricted" } = ~E'IO.inspect(:stderr, "foo", [])' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function IO.inspect/3 is restricted" } = ~E'io = IO; io.inspect(:stderr, "foo", [])' end test "forbidden atoms" do assert %Failure{ type: :restricted, message: "Atoms containing `Dune` are restricted for safety: Dune" } = ~E'Dune' assert %Failure{ type: :restricted, message: "Atoms containing `Dune` are restricted for safety: Dune" } = ~E'Dune.Foo' assert %Failure{ type: :restricted, message: "Atoms containing `Dune` are restricted for safety: Dune" } = ~E':Dune' assert %Failure{ type: :restricted, message: "Atoms containing `Dune` are restricted for safety: __Dune__" } = ~E':__Dune__' end test "forbidden use" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function use/1 is restricted" } = ~E'use GenServer' end test "forbidden import/requires" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function import/1 is restricted" } = ~E'import Logger' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function import/2 is restricted" } = ~E'import Logger, only: [info: 2]' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function require/1 is restricted" } = ~E'require Logger' end test "forbidden alias" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function alias/1 is restricted" } = ~E'alias Task.Supervised' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function alias/2 is restricted" } = ~E'alias Process, as: P; P.get' end test "forbidden quote/unquote" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function quote/1 is restricted" } = ~E'quote do: 1 + 1' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function quote/1 is restricted" } = ~E'quote do: unquote(a)' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function unquote/1 is restricted" } = ~E'unquote(10)' end test "forbidden receive" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function receive/1 is restricted" } = ~E''' receive do {:ok, foo} -> foo end ''' end test "forbidden __ENV__" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function __ENV__/0 is restricted" } = ~E'__ENV__' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) function __ENV__/0 is restricted" } = ~E'__ENV__.requires' end test "bitstring modifiers" do assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) size modifiers above 256 are restricted" } = ~E'<<0::123456789123456789>>' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) size modifiers above 256 are restricted" } = ~E'<<0::size(257)>>' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) size modifiers above 256 are restricted" } = ~E'<<0::256*2>>' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) size modifiers above 256 are restricted" } = ~E'<<0::integer-size(257)>>' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) size modifiers above 256 are restricted" } = ~E'<<0::integer-size(256)-unit(2)>>' assert %Failure{ type: :restricted, message: "** (DuneRestrictedError) bitstring modifier is restricted:\n size(x)" } = ~E''' x = 123456789123456789 <<0::size(x)>> ''' assert %Failure{ message: "** (DuneRestrictedError) bitstring modifier is restricted:\n unquote(1)" } = ~E'<<1::unquote(1)>>' assert %Failure{ message: "** (DuneRestrictedError) bitstring modifier is restricted:\n unquote(1)" } = ~E'<<1::integer-unquote(1)>>' assert %Failure{ message: "** (DuneRestrictedError) function unquote/1 is restricted" } = ~E'<>' end end describe "process restrictions" do test "execution timeout" do assert %Failure{type: :timeout, message: "Execution timeout - 100ms"} = ~E'Process.sleep(101)' end test "too many reductions" do assert %Failure{type: :reductions, message: "Execution stopped - reductions limit exceeded"} = ~E'Enum.any?(1..1_000_000, &(&1 < 0))' end test "uses to much memory" do assert %Failure{type: :memory, message: "Execution stopped - memory limit exceeded"} = ~E'List.duplicate(:foo, 100_000)' end end describe "error handling" do test "math error" do assert %Failure{ type: :exception, message: "** (ArithmeticError) bad argument in arithmetic expression\n" <> rest } = ~E'42 / 0' assert rest =~ ":erlang./(42, 0)" end test "throw" do assert %Failure{type: :throw, message: "** (throw) :yo"} = ~E'throw(:yo)' assert %Failure{type: :throw, message: "** (throw) {:undefined_function, Kernel, :+, 2}"} = ~E'throw({:undefined_function, Kernel, :+, 2})' end test "raise" do assert %Failure{type: :exception, message: "** (ArgumentError) kaboom!"} = ~E'raise ArgumentError, "kaboom!"' end test "actual UndefinedFunctionError" do assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function Code.baz/0 is undefined or private" } = ~E'Code.baz()' assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function FooBar.baz/0 is undefined (module FooBar is not available)" } = ~E'FooBar.baz()' assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function :foo_bar.baz/0 is undefined (module :foo_bar is not available)" } = ~E':foo_bar.baz()' end @tag :lts_only test "syntax error" do assert %Failure{ type: :parsing, message: "missing terminator: )" } = ~E'foo(' assert %Failure{ type: :parsing, message: "missing terminator: }" } = ~E'{' assert %Failure{ type: :parsing, message: "unexpected reserved word: do. In case you wanted to write " <> _ } = ~E'if true, do' # TODO improve message assert %Failure{type: :parsing, message: "syntax error before: "} = ~E'%' assert %Failure{type: :parsing, message: "syntax error before: foo120987"} = ~E'<<>>foo120987' end test "max length" do assert %Failure{ type: :parsing, message: "max code length exceeded: 26 > 10" } = Dune.eval_string("exceeeds_max_length = true", max_length: 10) end test "atom pool" do assert %Failure{ type: :parsing, message: "atom_pool_size exceeded, failed to parse atom: bar1462" } = Dune.eval_string("{foo5345, bar1462} = {9, 10}", atom_pool_size: 4) end test "invalid pipe" do assert %Failure{ type: :exception, message: "** (ArgumentError) cannot pipe 1 into 2, can only pipe into " <> _ } = ~E'1 |> 2' assert %Failure{ type: :exception, message: "** (UndefinedFunctionError) function b/1 is undefined or private" } = ~E'a |> b' end @tag :lts_only test "compile error" do assert %Failure{ type: :compile_error, message: "** (CompileError) nofile: cannot compile file (errors have been logged)", stdio: "error: expected -> clauses for :do in \"case\"\n nofile:2" } = ~E' case 1 do end ' assert %Failure{ type: :compile_error, message: "** (CompileError) nofile: cannot compile file (errors have been logged)", stdio: "error: undefined variable \"this_var_does_not_exist\"\n nofile:1" } = ~E'this_var_does_not_exist' end @tag :lts_only test "warnings" do assert %Success{value: 1, inspected: "1", stdio: stdio} = ~E' f = fn -> IO.puts("hello") this_var_is_unused = 1 end f.() ' assert stdio == """ warning: variable \"this_var_is_unused\" is unused (if the variable is not meant to be used, prefix it with an underscore) nofile:4 hello """ end @tag :lts_only test "parser warnings" do assert %Success{value: ~c"123abc", inspected: "~c\"123abc\"", stdio: stdio} = ~E"'123' ++ [97, 98, 99]" assert stdio =~ """ warning: using single-quoted strings to represent charlists is deprecated. Use ~c\"\" if you indeed want a charlist or use \"\" instead\ """ end test "def/defp outside of module" do assert %Failure{ type: :exception, message: "** (ArgumentError) cannot invoke def/2 inside function/macro" } = ~E'def foo(x), do: x + x' assert %Failure{ type: :exception, message: "** (ArgumentError) cannot invoke defp/2 inside function/macro" } = ~E'defp foo(x), do: x + x' assert %Failure{ type: :exception, message: "** (ArgumentError) cannot invoke def/2 inside function/macro" } = ~E'&def/2' end end end ================================================ FILE: test/dune_string_to_quoted_test.exs ================================================ defmodule DuneStringToQuotedTest do use ExUnit.Case, async: true alias Dune.Success describe "Dune.string_to_quoted/2" do test "modules" do assert %Success{ value: {:__aliases__, [line: 1], [:Dune_Atom_1__, :Dune_Atom_2__]}, inspected: ~S"{:__aliases__, [line: 1], [:Foooo, :Barrr]}" } = Dune.string_to_quoted(~S(Foooo.Barrr)) end @tag :lts_only test "captures tokenizer warnings" do assert %Success{ value: ~c"single quotes", inspected: ~S(~c"single quotes"), stdio: stdio } = Dune.string_to_quoted(~S('single quotes')) assert stdio =~ "warning: using single-quoted strings to represent charlists is deprecated." end end end ================================================ FILE: test/dune_test.exs ================================================ defmodule DuneTest do use ExUnit.Case, async: true # TODO remove then dropping support for 1.15 doctest Dune, tags: [lts_only: true] end ================================================ FILE: test/test_helper.exs ================================================ ExUnit.start()