defmodule BDS.Scripting.Lua do @moduledoc """ Lua runtime adapter backed by Luerl. Execution starts from a sandboxed Lua state. Host capabilities are explicit and opt-in. """ @behaviour BDS.Scripting.Runtime @type lua_state :: tuple() @type runtime_result :: {:ok, term(), lua_state} | {:error, term()} @impl true def validate(source) when is_binary(source) do case :luerl.load(source, :luerl_sandbox.init()) do {:ok, _chunk, _state} -> :ok {:error, errors, warnings} -> {:error, {:compile_error, %{errors: errors, warnings: warnings}}} end end @impl true def execute(source, entrypoint, args, opts) when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do with {:ok, state} <- initial_state(opts), {:ok, state} <- put_args(state, args), {:ok, result, _state} <- run_entrypoint(source, entrypoint, state, opts) do {:ok, unwrap_result(result)} end end defp initial_state(opts) do state = :luerl_sandbox.init() capabilities = Keyword.get(opts, :capabilities, %{}) with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state), {:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)), {:ok, state} <- install_capabilities(state, capabilities) do {:ok, state} end end defp install_progress_callback(state, nil), do: {:ok, state} defp install_progress_callback(state, callback) when is_function(callback, 1) do progress_function = fn args, current_state -> decoded_args = :luerl.decode_list(args, current_state) progress_event = case decoded_args do [payload | _] when is_map(payload) -> payload [payload | _] -> normalize_progress_payload(payload) [] -> %{} end callback.(progress_event) :luerl.encode_list([true], current_state) end case :luerl.set_table_keys_dec(["bds", "report_progress"], progress_function, state) do {:ok, next_state} -> {:ok, next_state} error -> {:error, {:progress_callback_install_failed, error}} end end defp install_progress_callback(_state, callback), do: {:error, {:invalid_progress_callback, callback}} defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state} defp install_capabilities(state, capabilities) when is_map(capabilities) do Enum.reduce_while(capabilities, {:ok, state}, fn {name, function}, {:ok, current_state} -> path = ["bds", to_string(name)] case :luerl.set_table_keys_dec(path, function, current_state) do {:ok, next_state} -> {:cont, {:ok, next_state}} error -> {:halt, {:error, {:capability_install_failed, path, error}}} end end) end defp install_capabilities(_state, capabilities), do: {:error, {:invalid_capabilities, capabilities}} defp normalize_progress_payload(payload) when is_list(payload) do if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do Map.new(payload, fn {key, value} -> {to_string(key), value} end) else %{value: payload} end end defp normalize_progress_payload(payload), do: %{value: payload} defp put_args(state, args) do case Luerl.set_table_keys_dec(state, ["__bds_args__"], args) do {:ok, next_state} -> {:ok, next_state} error -> {:error, {:argument_encoding_failed, error}} end end @spec run_entrypoint(binary(), binary(), lua_state, keyword()) :: runtime_result defp run_entrypoint(source, entrypoint, state, opts) do script = IO.iodata_to_binary([ source, "\nreturn ", entrypoint, "(table.unpack(__bds_args__))\n" ]) |> String.to_charlist() script |> then(&sandbox_run(&1, sandbox_flags(opts), state)) |> normalize_sandbox_result() end @spec sandbox_run(term(), term(), lua_state) :: term() defp sandbox_run(script, flags, state), do: apply(:luerl_sandbox, :run, [script, flags, state]) defp normalize_sandbox_result({:ok, result, next_state}), do: {:ok, result, next_state} defp normalize_sandbox_result({:error, {:reductions, count}}), do: {:error, {:reductions_exceeded, count}} defp normalize_sandbox_result({:error, :timeout}), do: {:error, :timeout} defp normalize_sandbox_result({:error, reason}), do: {:error, reason} defp normalize_sandbox_result({reply, next_state}) when is_tuple(next_state) do case reply do {:ok, result} -> {:ok, result, next_state} {:ok, result, _reply_state} -> {:ok, result, next_state} {:error, {:reductions, count}} -> {:error, {:reductions_exceeded, count}} {:error, :timeout} -> {:error, :timeout} {:error, reason} -> {:error, reason} other -> {:error, {:unexpected_result, {other, next_state}}} end end defp normalize_sandbox_result(other), do: {:error, {:unexpected_result, other}} defp sandbox_flags(opts) do config = Application.fetch_env!(:bds, :scripting) [ max_time: Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)), max_reductions: Keyword.get(opts, :max_reductions, Keyword.fetch!(config, :max_reductions)), spawn_opts: Keyword.get(opts, :spawn_opts, []) ] end defp unwrap_result(values) when is_list(values) do case values do [] -> nil [value] -> value _other -> values end end defp unwrap_result(values), do: values end