diff --git a/README.md b/README.md index 6ffde9b4..f511f736 100644 --- a/README.md +++ b/README.md @@ -228,13 +228,13 @@ guards against allocation-bomb denial-of-service by refusing oversized before they allocate. ```elixir -# os.exit is sandboxed by default — calling it raises (catchable) -iex> {[false, message], _} = Lua.eval!(Lua.new(), "return pcall(os.exit)") +# os.execute is sandboxed by default — calling it raises (catchable) +iex> {[false, message], _} = Lua.eval!(Lua.new(), "return pcall(os.execute, \"echo hi\")") iex> message =~ "sandboxed" true ``` -Capability sandboxing (`:sandboxed`, `:exclude`, `Lua.sandbox/2`), +Virtual-by-default sandboxing (`sandbox: true | false`, `env:`), recursion limits (`:max_call_depth`), the built-in allocation guards, and the host-level pattern for bounding CPU time and total memory are all covered in the [Security and sandboxing](guides/sandboxing.md) guide. diff --git a/guides/examples/sandboxing.livemd b/guides/examples/sandboxing.livemd index b55572d1..0802d088 100644 --- a/guides/examples/sandboxing.livemd +++ b/guides/examples/sandboxing.livemd @@ -8,16 +8,17 @@ Mix.install([ ]) ``` -## The default sandbox +## Virtual by default -`Lua.new/0` sandboxes a curated list of functions (`os.execute`, `os.exit`, -`os.getenv`, `require`, `loadfile`, ...). Calling a sandboxed function -raises, which `pcall` turns into a `false, message` pair so the script can -recover in-band. +`Lua.new/0` is virtual and sandboxed: filesystem operations run against an +in-memory virtual filesystem, `os.getenv` reads only injected values, and +process execution is denied. A sandboxed script never touches the real +machine. Calling a denied operation like `os.execute` raises, which `pcall` +turns into a `false, message` pair so the script can recover in-band. ```elixir {[ok?, message], _lua} = - Lua.eval!(Lua.new(), ~S[return pcall(function() return os.getenv("HOME") end)]) + Lua.eval!(Lua.new(), ~S[return pcall(function() return os.execute("echo hi") end)]) {ok?, message} ``` @@ -25,28 +26,30 @@ recover in-band. ``` -{false, "Lua runtime error: os.getenv(_) is sandboxed"} +{false, "Lua runtime error: os.execute(_) is sandboxed"} ``` -## Allowing specific operations +## Injecting environment values -`Lua.new(exclude: [...])` lifts the sandbox for specific paths while keeping -everything else locked down. Here we allow `os.getenv` only. +Sandboxed scripts cannot read the host environment. Pass the values they may +see with `Lua.new(env: [...])`; `os.getenv` reads from there (an unset name +is `nil`). ```elixir -allowed = Lua.new(exclude: [[:os, :getenv]]) +allowed = Lua.new(env: [{"HOME", "/app"}]) -{[kind], _lua} = Lua.eval!(allowed, ~S[return type(os.getenv("HOME"))]) -kind +{[home], _lua} = Lua.eval!(allowed, ~S[return os.getenv("HOME")]) +home ``` ``` -"string" +"/app" ``` -`os.execute` is still blocked on the `allowed` VM — we only lifted `getenv`. +Process execution is still denied on this VM — injecting env does not open +host access. ```elixir {[exec_ok?, _message], _lua} = @@ -61,11 +64,12 @@ exec_ok? false ``` -## Replacing or disabling the sandbox +## Enabling host access -`Lua.new(sandboxed: [...])` replaces the whole sandbox list, and -`Lua.new(sandboxed: [])` disables sandboxing entirely. Reach for these only -when you fully trust the script you are running. +`Lua.new(sandbox: false)` installs the host implementations, so `os`/`io` +and the file loaders behave like normal non-sandboxed Lua against the real +filesystem, environment, and shell. Reach for this only when you fully trust +the script you are running. ## Bounding CPU work diff --git a/lib/lua.ex b/lib/lua.ex index eeca80d0..857c7d52 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -27,36 +27,6 @@ defmodule Lua do defstruct [:state, debug: false] - @default_sandbox [ - [:io, :stdin], - [:io, :stdout], - [:io, :stderr], - [:io, :read], - [:io, :write], - [:io, :open], - [:io, :close], - [:io, :lines], - [:io, :popen], - [:io, :tmpfile], - [:io, :output], - [:io, :input], - [:io, :flush], - [:io, :type], - [:file], - [:os, :execute], - [:os, :exit], - [:os, :getenv], - [:os, :remove], - [:os, :rename], - [:os, :tmpname], - [:package], - [:load], - [:loadfile], - [:require], - [:dofile], - [:loadstring] - ] - defimpl Inspect do def inspect(_lua, _opts) do "#Lua<>" @@ -64,27 +34,32 @@ defmodule Lua do end @doc """ - Initializes a Lua VM sandbox - - iex> Lua.new() + Initializes a Lua VM. - By default, the following Lua functions are sandboxed. + By default the VM is **virtual and sandboxed**: filesystem operations + (`require`, `loadfile`/`dofile`, `io`, the file-oriented `os` functions) run + against an in-memory virtual filesystem, `os.getenv` reads only injected + values, and process execution is denied. A sandboxed script never touches the + real machine. - #{Enum.map_join(@default_sandbox, "\n", fn func -> "* `#{inspect(func)}`" end)} - - To disable, use the `sandboxed` option, passing an empty list + iex> Lua.new() - iex> Lua.new(sandboxed: []) + Seed the virtual filesystem and inject environment variables up front: - Alternatively, you can pass your own list of functions to sandbox. This is equivalent to calling - `Lua.sandbox/2`. + iex> Lua.new(env: [{"HOME", "/app"}]) + ...> |> Lua.write_file("/main.lua", "return 1") - iex> Lua.new(sandboxed: [[:os, :exit]]) + Opt out into the host implementations (real filesystem, environment, and + process execution — behaves like normal non-sandboxed Lua): + iex> Lua.new(sandbox: false) ## Options - * `:sandboxed` - list of paths to be sandboxed, e.g. `sandboxed: [[:require], [:os, :exit]]` - * `:exclude` - list of paths to exclude from the sandbox, e.g. `exclude: [[:require], [:package]]` + * `:sandbox` - (default `true`) when `true`, install the virtual `os`/`io`/ + loader implementations; when `false`, install the host implementations. + * `:env` - environment variables exposed to the sandboxed `os.getenv`, as a + list of `{name, value}` tuples or a map, e.g. `env: [{"HOME", "/app"}]`. + Ignored when `sandbox: false` (the real host environment is read instead). * `:debug` - (default `false`) when `true`, internal Lua VM frames are preserved in stack traces instead of being pruned. Useful when debugging library bugs. * `:max_call_depth` - (default `:infinity`) caps the depth of nested function calls. When a @@ -133,34 +108,69 @@ defmodule Lua do @spec new(keyword()) :: t() def new(opts \\ []) do opts = - Keyword.validate!(opts, - sandboxed: @default_sandbox, - exclude: [], + opts + |> normalize_legacy_opts() + |> Keyword.validate!( + sandbox: true, + env: [], debug: false, max_call_depth: :infinity, max_string_bytes: Lua.VM.Limits.max_string_bytes(), max_instructions: :infinity ) - exclude = Keyword.fetch!(opts, :exclude) + sandbox? = Keyword.fetch!(opts, :sandbox) + env = normalize_env(Keyword.fetch!(opts, :env)) debug = Keyword.fetch!(opts, :debug) max_call_depth = validate_max_call_depth!(Keyword.fetch!(opts, :max_call_depth)) max_string_bytes = validate_max_string_bytes!(Keyword.fetch!(opts, :max_string_bytes)) max_instructions = validate_max_instructions!(Keyword.fetch!(opts, :max_instructions)) - state = %{ - Lua.VM.Stdlib.install(State.new()) - | max_call_depth: max_call_depth, - max_string_bytes: max_string_bytes, - max_instructions: max_instructions - } + # `sandboxed?`/`env` are set before installing the stdlib so the install + # step picks the virtual or host implementations accordingly. + state = + Lua.VM.Stdlib.install(%{ + State.new() + | sandboxed?: sandbox?, + env: env, + max_call_depth: max_call_depth, + max_string_bytes: max_string_bytes, + max_instructions: max_instructions + }) + + %__MODULE__{state: state, debug: debug} + end + + # Bridge the removed `:sandboxed`/`:exclude` per-path options to the single + # `:sandbox` toggle for one minor release. An empty `:sandboxed` list (the + # historic "unrestricted" idiom) or any non-empty `:exclude` maps to + # `sandbox: false`; anything else stays sandboxed. + defp normalize_legacy_opts(opts) do + {legacy, opts} = Keyword.split(opts, [:sandboxed, :exclude]) + + if legacy == [] do + opts + else + IO.warn( + "Lua.new/1 :sandboxed and :exclude are deprecated and will be removed. " <> + "Use `sandbox: true | false` and `env:` instead." + ) + + Keyword.put_new(opts, :sandbox, legacy_sandbox?(legacy)) + end + end - opts - |> Keyword.fetch!(:sandboxed) - |> Enum.reject(fn path -> path in exclude end) - |> Enum.reduce(%__MODULE__{state: state, debug: debug}, &sandbox(&2, &1)) + defp legacy_sandbox?(legacy) do + cond do + Keyword.get(legacy, :exclude, []) != [] -> false + Keyword.get(legacy, :sandboxed, [:_]) == [] -> false + true -> true + end end + defp normalize_env(env) when is_map(env), do: env + defp normalize_env(env) when is_list(env), do: Map.new(env) + defp validate_max_call_depth!(:infinity), do: :infinity defp validate_max_call_depth!(depth) when is_integer(depth) and depth > 0, do: depth @@ -225,37 +235,63 @@ defmodule Lua do end @doc """ - Sandboxes the given path, swapping out the implementation with - a function that raises when called + Writes a file into the VM's virtual filesystem. + + Sandboxed scripts read this via `require`, `loadfile`/`dofile`, and the `io` + library; the path must be absolute. - iex> lua = Lua.new(sandboxed: []) - iex> Lua.sandbox(lua, [:os, :exit]) + iex> Lua.new() |> Lua.write_file("/main.lua", "return 1") + + """ + @spec write_file(t(), String.t(), String.t()) :: t() + def write_file(%__MODULE__{state: state} = lua, path, contents) when is_binary(path) and is_binary(contents) do + case State.vfs_write(state, path, contents) do + {:ok, state} -> %{lua | state: state} + {:error, reason, _state} -> raise ArgumentError, "could not write #{path}: #{reason}" + end + end + + @doc """ + Seeds a Lua dependency into the virtual filesystem under `/lua/deps`. + + A convenience over `write_file/3` for the common case of making a module + loadable by `require`. Dotted names nest (`"a.b"` -> `/lua/deps/a/b.lua`). + + iex> lua = Lua.new() |> Lua.put_dep("greet", "return 'hi'") + iex> {["hi"], _} = Lua.eval!(lua, "return require('greet')") + + """ + @spec put_dep(t(), String.t(), String.t()) :: t() + def put_dep(%__MODULE__{} = lua, modname, source) when is_binary(modname) and is_binary(source) do + path = "/lua/deps/" <> String.replace(modname, ".", "/") <> ".lua" + write_file(lua, path, source) + end + + @doc """ + Reads a file back out of the VM's virtual filesystem. + Returns `{:ok, contents}` or `{:error, reason}`. Useful for harvesting what + sandboxed code wrote (e.g. via `io.write`). """ - @spec sandbox(t(), [atom() | String.t()]) :: t() - def sandbox(lua, path) do - set!(lua, path, fn args -> - raise Lua.RuntimeException, - "#{Util.format_function(path, Enum.count(args))} is sandboxed" - end) + @spec read_file(t(), String.t()) :: {:ok, String.t()} | {:error, atom()} + def read_file(%__MODULE__{state: state}, path) when is_binary(path) do + State.vfs_read(state, path) end @doc """ Sets the path patterns that the VM will look in when requiring Lua scripts. For example, if you store Lua files in your application's priv directory: - #iex> lua = Lua.new(exclude: [[:package], [:require]]) + #iex> lua = Lua.new(sandbox: false) #iex> Lua.set_lua_paths(lua, ["myapp/priv/lua/?.lua", "myapp/lua/?/init.lua"]) Now you can use the [Lua require](https://www.lua.org/pil/8.1.html) function to import these scripts - > #### Warning {: .warning} - > In order to use `Lua.set_lua_paths/2`, the following functions cannot be sandboxed: - > * `[:package]` - > * `[:require]` - > - > By default these are sandboxed, see the `:exclude` option in `Lua.new/1` to allow them. + > #### Note {: .info} + > The host search path is only consulted under `Lua.new(sandbox: false)`. A + > sandboxed VM resolves `require` against the virtual filesystem (see + > `put_dep/3` and `write_file/3`), never the host disk. """ @spec set_lua_paths(t(), [String.t()] | String.t()) :: t() def set_lua_paths(%__MODULE__{} = lua, paths) when is_list(paths) do diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index c2b3b654..0ccc4fda 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -63,7 +63,17 @@ defmodule Lua.VM.State do # the file-oriented `os` functions) reads/writes here instead of the # host disk, so a sandboxed script never reaches the real machine. # Seeded by `new/0`; threaded via the `vfs_*` helpers below. - vfs: nil + vfs: nil, + # Whether the VM is sandboxed (virtual) — the default. When `true`, + # `os`/`io`/loader stdlib touch only the VFS and injected `env`; + # when `false` the host implementations are installed instead. The + # choice is made once at install time (the installed function *is* + # the right one); this flag records it for introspection and for + # the file-loader source fetch. + sandboxed?: true, + # Environment variables the host injects via `Lua.new(env: ...)`. + # The sandboxed `os.getenv` reads from here (never the host env). + env: %{} @type t :: %__MODULE__{ call_stack: list(), @@ -82,7 +92,9 @@ defmodule Lua.VM.State do multi_return_count: non_neg_integer(), g_ref: nil | {:tref, non_neg_integer()}, format_cache: %{optional(binary()) => list()}, - vfs: nil | VFS.t() + vfs: nil | VFS.t(), + sandboxed?: boolean(), + env: %{optional(binary()) => binary()} } @doc """ diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index a1caca26..744b63aa 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -43,6 +43,8 @@ defmodule Lua.VM.Stdlib do |> State.register_function("getmetatable", &lua_getmetatable/2) |> State.register_function("select", &lua_select/2) |> State.register_function("load", &lua_load/2) + |> State.register_function("loadstring", &lua_load/2) + |> State.register_function("loadfile", &lua_loadfile/2) |> State.register_function("require", &lua_require/2) |> State.register_function("collectgarbage", &lua_collectgarbage/2) |> State.register_function("dofile", &lua_dofile/2) @@ -53,6 +55,7 @@ defmodule Lua.VM.Stdlib do |> install_library(Lua.VM.Stdlib.Table) |> install_library(Lua.VM.Stdlib.Utf8) |> install_library(Lua.VM.Stdlib.Os) + |> install_library(Lua.VM.Stdlib.Io) |> install_library(Lua.VM.Stdlib.Debug) |> preload_stdlib_modules() |> install_unpack_alias() @@ -751,7 +754,7 @@ defmodule Lua.VM.Stdlib do defp load_module(modname, search_path, state) do patterns = String.split(search_path, ";", trim: true) - case find_module_file(modname, patterns) do + case find_module_file(modname, patterns, state) do {:ok, file_path, content} -> parse_and_execute_module(modname, file_path, content, state) @@ -817,20 +820,43 @@ defmodule Lua.VM.Stdlib do end end - # Find a module file by searching the patterns - defp find_module_file(modname, patterns) do + # Find a module file by searching the patterns. Sandboxed VMs read the + # virtual filesystem (absolute patterns directly, relative patterns anchored + # under `/lua/deps`); host VMs read the real disk. + defp find_module_file(modname, patterns, state) do resolved = String.replace(modname, ".", "/") Enum.find_value(patterns, {:error, :not_found}, fn pattern -> file_path = String.replace(pattern, "?", resolved) - case File.read(file_path) do + case read_module_source(file_path, state) do {:ok, content} -> {:ok, file_path, content} - {:error, _} -> nil + :error -> nil end end) end + # Fetch Lua source for a path, honoring the VM's sandbox mode. Returns + # `{:ok, content}` or `:error`. Shared by `require`, `loadfile`, and `dofile`. + defp read_module_source(file_path, %State{sandboxed?: true} = state) do + vfs_path = + if String.starts_with?(file_path, "/"), + do: file_path, + else: "/lua/deps/" <> file_path + + case State.vfs_read(state, vfs_path) do + {:ok, content} -> {:ok, content} + {:error, _reason} -> :error + end + end + + defp read_module_source(file_path, %State{sandboxed?: false}) do + case File.read(file_path) do + {:ok, content} -> {:ok, content} + {:error, _reason} -> :error + end + end + # Convert a value to string, checking for __tostring metamethod defp value_to_string_with_mt(value, state) do case value do @@ -877,9 +903,37 @@ defmodule Lua.VM.Stdlib do end end - # dofile stub — not supported in embedded mode + # loadfile([filename [, mode [, env]]]) — load a chunk from a file without + # running it. Reads from the VFS (sandboxed) or the host disk. Returns the + # compiled function, or `(nil, message)` on a read/parse error. + defp lua_loadfile([path | _], state) when is_binary(path) do + case read_module_source(path, state) do + {:ok, content} -> compile_loaded_chunk(content, State.g_ref(state), state) + :error -> {[nil, "cannot open #{path}"], state} + end + end + + defp lua_loadfile(_args, state) do + {[nil, "loadfile from stdin is not supported"], state} + end + + # dofile([filename]) — load and immediately run a chunk from a file. Unlike + # loadfile, a read/parse error is raised rather than returned. + defp lua_dofile([path | _], state) when is_binary(path) do + case read_module_source(path, state) do + {:ok, content} -> + case compile_loaded_chunk(content, State.g_ref(state), state) do + {[nil, msg], _state} -> raise RuntimeError, value: msg + {[closure], state} -> Executor.call_function(closure, [], state) + end + + :error -> + raise RuntimeError, value: "cannot open #{path}" + end + end + defp lua_dofile(_args, _state) do - raise RuntimeError, value: "dofile not supported in embedded mode" + raise RuntimeError, value: "dofile from stdin is not supported" end # Install global 'unpack' as alias for table.unpack diff --git a/lib/lua/vm/stdlib/io.ex b/lib/lua/vm/stdlib/io.ex new file mode 100644 index 00000000..fe477f4f --- /dev/null +++ b/lib/lua/vm/stdlib/io.ex @@ -0,0 +1,38 @@ +defmodule Lua.VM.Stdlib.Io do + @moduledoc """ + Lua 5.3 `io` standard library. + + The `io` table is exposed as a table of functions with `stdin`/`stdout`/ + `stderr` handles, matching reference Lua's shape so user code that lifts + patterns from the wider ecosystem (and the Lua 5.3 suite) does not trip on + `attempt to index a function value`. + + Every entry is currently a sandbox stub that raises on call — virtualizing + `io` over the VFS (and a host-backed variant) is a follow-up. The + table-of-functions shape is preserved regardless. + """ + + @behaviour Lua.VM.Stdlib.Library + + alias Lua.VM.State + alias Lua.VM.Stdlib.Sandbox + + # The full reference `io` surface. Every key is stubbed for now; the shape is + # what matters (see moduledoc). + @io_functions ~w(read write open close lines popen tmpfile output input flush type) + @io_handles ~w(stdin stdout stderr) + + @impl true + def lib_name, do: "io" + + @impl true + def install(state) do + io_table = + Map.new(@io_functions ++ @io_handles, fn name -> + {name, Sandbox.stub([:io, String.to_atom(name)])} + end) + + {tref, state} = State.alloc_table(state, io_table) + State.set_global(state, "io", tref) + end +end diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index 3eea7a16..11b3732b 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -27,32 +27,58 @@ defmodule Lua.VM.Stdlib.Os do alias Lua.VM.ArgumentError alias Lua.VM.RuntimeError alias Lua.VM.State + alias Lua.VM.Stdlib.Sandbox alias Lua.VM.Stdlib.Util @impl true def lib_name, do: "os" @impl true - def install(state) do + def install(%{sandboxed?: sandboxed?} = state) do # Seed the monotonic origin at install time so os.clock() measures from a # stable startup point rather than from whenever it is first called. boot_offset() - os_table = %{ + {tref, state} = State.alloc_table(state, os_table(sandboxed?)) + State.set_global(state, "os", tref) + end + + # Mode-independent functions are installed identically in both modes; the + # filesystem/env/exec functions are swapped for their virtual or host + # implementation at install time, so there is no per-call mode branch. + defp os_table(sandboxed?) do + common = %{ "clock" => {:native_func, &os_clock/2}, "date" => {:native_func, &os_date/2}, "difftime" => {:native_func, &os_difftime/2}, "exit" => {:native_func, &os_exit/2}, - "getenv" => {:native_func, &os_getenv/2}, "setlocale" => {:native_func, &os_setlocale/2}, "time" => {:native_func, &os_time/2}, "time_ms" => {:native_func, &os_time_ms/2}, - "time_us" => {:native_func, &os_time_us/2}, - "tmpname" => {:native_func, &os_tmpname/2} + "time_us" => {:native_func, &os_time_us/2} } - {tref, state} = State.alloc_table(state, os_table) - State.set_global(state, "os", tref) + Map.merge(common, mode_table(sandboxed?)) + end + + defp mode_table(true) do + %{ + "getenv" => {:native_func, &os_getenv_virtual/2}, + "tmpname" => {:native_func, &os_tmpname_virtual/2}, + "remove" => {:native_func, &os_remove_virtual/2}, + "rename" => {:native_func, &os_rename_virtual/2}, + "execute" => Sandbox.stub([:os, :execute]) + } + end + + defp mode_table(false) do + %{ + "getenv" => {:native_func, &os_getenv_host/2}, + "tmpname" => {:native_func, &os_tmpname_host/2}, + "remove" => {:native_func, &os_remove_host/2}, + "rename" => {:native_func, &os_rename_host/2}, + "execute" => {:native_func, &os_execute_host/2} + } end # os.clock() — approximate CPU/elapsed time used, in seconds. Reads the @@ -157,15 +183,26 @@ defmodule Lua.VM.Stdlib.Os do end # os.getenv(name) — value of an environment variable, or nil. - defp os_getenv([name | _], state) when is_binary(name) do + # + # Sandboxed: reads only the env injected via `Lua.new(env: ...)`, never the + # host environment. Host: reads the real process environment. + defp os_getenv_virtual([name | _], %{env: env} = state) when is_binary(name) do + {[Map.get(env, name)], state} + end + + defp os_getenv_virtual(args, _state), do: os_getenv_bad_arg(args) + + defp os_getenv_host([name | _], state) when is_binary(name) do {[System.get_env(name)], state} end - defp os_getenv([name | _], _state) do + defp os_getenv_host(args, _state), do: os_getenv_bad_arg(args) + + defp os_getenv_bad_arg([name | _]) do raise ArgumentError.type_error("os.getenv", 1, "string", Util.typeof(name)) end - defp os_getenv([], _state) do + defp os_getenv_bad_arg([]) do raise ArgumentError.value_expected("os.getenv", 1) end @@ -181,14 +218,100 @@ defmodule Lua.VM.Stdlib.Os do # os.tmpname() — a name usable for a temporary file. # - # FUTURE: this leans on the host filesystem via System.tmp_dir/0. Once the - # VFS layer lands we want tmpname to resolve against the sandboxed virtual - # filesystem instead of the real host, so the VM never touches host paths. - defp os_tmpname(_args, state) do + # Sandboxed: a virtual path under `/tmp`, never touching the host. Host: + # a name under the real temp directory. + defp os_tmpname_virtual(_args, state) do + {["/tmp/lua_#{:erlang.unique_integer([:positive])}"], state} + end + + defp os_tmpname_host(_args, state) do name = Path.join(System.tmp_dir() || "/tmp", "lua_#{:erlang.unique_integer([:positive])}") {[name], state} end + # os.remove(filename) / os.rename(from, to) — Lua 5.3 returns `true` on + # success or `(nil, message, errno)` on failure. Sandboxed variants operate + # on the VFS; host variants touch the real disk. + defp os_remove_virtual([name | _], state) when is_binary(name) do + case State.vfs_rm(state, name) do + {:ok, state} -> {[true], state} + {:error, reason, state} -> {fs_failure(name, reason), state} + end + end + + defp os_remove_virtual(args, _state), do: os_remove_bad_arg(args) + + defp os_remove_host([name | _], state) when is_binary(name) do + case File.rm(name) do + :ok -> {[true], state} + {:error, reason} -> {fs_failure(name, reason), state} + end + end + + defp os_remove_host(args, _state), do: os_remove_bad_arg(args) + + defp os_remove_bad_arg([name | _]), do: raise(ArgumentError.type_error("os.remove", 1, "string", Util.typeof(name))) + + defp os_remove_bad_arg([]), do: raise(ArgumentError.value_expected("os.remove", 1)) + + defp os_rename_virtual([from, to | _], state) when is_binary(from) and is_binary(to) do + with {:ok, contents} <- State.vfs_read(state, from), + {:ok, state} <- State.vfs_write(state, to, contents), + {:ok, state} <- State.vfs_rm(state, from) do + {[true], state} + else + {:error, reason} -> {fs_failure(from, reason), state} + {:error, reason, state} -> {fs_failure(from, reason), state} + end + end + + defp os_rename_virtual(args, _state), do: os_rename_bad_arg(args) + + defp os_rename_host([from, to | _], state) when is_binary(from) and is_binary(to) do + case File.rename(from, to) do + :ok -> {[true], state} + {:error, reason} -> {fs_failure(from, reason), state} + end + end + + defp os_rename_host(args, _state), do: os_rename_bad_arg(args) + + defp os_rename_bad_arg([from | _]) when not is_binary(from), + do: raise(ArgumentError.type_error("os.rename", 1, "string", Util.typeof(from))) + + defp os_rename_bad_arg([_from | rest]) do + raise ArgumentError.type_error("os.rename", 2, "string", Util.typeof(List.first(rest))) + end + + defp os_rename_bad_arg([]), do: raise(ArgumentError.value_expected("os.rename", 1)) + + # os.execute([command]) — host only. With no command, reports shell + # availability (true). With a command, runs it via the system shell and + # returns `(true|nil, "exit", code)` per Lua 5.3. + defp os_execute_host([], state), do: {[true], state} + + defp os_execute_host([command | _], state) when is_binary(command) do + {_output, status} = System.cmd("sh", ["-c", command], stderr_to_stdout: true) + success = if status == 0, do: true + {[success, "exit", status], state} + end + + defp os_execute_host([command | _], _state) do + raise ArgumentError.type_error("os.execute", 1, "string", Util.typeof(command)) + end + + # Map an errno atom to Lua's `(nil, ": ", errno)` failure shape. + defp fs_failure(path, reason) do + [nil, "#{path}: #{:file.format_error(reason)}", errno(reason)] + end + + defp errno(:enoent), do: 2 + defp errno(:eisdir), do: 21 + defp errno(:einval), do: 22 + defp errno(:eacces), do: 13 + defp errno(:eexist), do: 17 + defp errno(_), do: 1 + # os.exit([code [, close]]) — the sandbox cannot terminate the host, so # raise to unwind the current evaluation. defp os_exit(_args, _state) do diff --git a/lib/lua/vm/stdlib/sandbox.ex b/lib/lua/vm/stdlib/sandbox.ex new file mode 100644 index 00000000..b2fa381f --- /dev/null +++ b/lib/lua/vm/stdlib/sandbox.ex @@ -0,0 +1,28 @@ +defmodule Lua.VM.Stdlib.Sandbox do + @moduledoc """ + Shared helper for stdlib functions that are unavailable in the sandbox. + + A virtual VM has no equivalent for some host capabilities (spawning a + subprocess, the real `io` library before it is virtualized). Those entries + are installed as stubs that raise a consistent `"(_) is sandboxed"` + message, matching the wording embedders and the Lua 5.3 suite already key on. + """ + + alias Lua.Util + + @doc """ + Raises the sandbox error for `path` called with `arity` arguments. + """ + @spec sandboxed!([atom() | binary()], non_neg_integer()) :: no_return() + def sandboxed!(path, arity) do + raise Lua.RuntimeException, "#{Util.format_function(path, arity)} is sandboxed" + end + + @doc """ + A `{:native_func, _}` value that raises the sandbox error for `path` on call. + """ + @spec stub([atom() | binary()]) :: {:native_func, (list(), term() -> no_return())} + def stub(path) do + {:native_func, fn args, _state -> sandboxed!(path, length(args)) end} + end +end diff --git a/tasks/suite_runner.ex b/tasks/suite_runner.ex index 9fdf7f1e..6e7a1ce2 100644 --- a/tasks/suite_runner.ex +++ b/tasks/suite_runner.ex @@ -24,7 +24,7 @@ defmodule Lua.SuiteRunner do """ @spec prepare(Path.t()) :: Lua.t() def prepare(test_dir) do - [exclude: [[:package], [:require]]] + [sandbox: false] |> Lua.new() |> add_test_paths(test_dir) |> install_helpers() diff --git a/test/integration/luassert_test.exs b/test/integration/luassert_test.exs index 6339ad9f..b054955d 100644 --- a/test/integration/luassert_test.exs +++ b/test/integration/luassert_test.exs @@ -21,7 +21,7 @@ defmodule Lua.Integration.LuassertTest do @lua_dir Path.expand("luassert/lua", __DIR__) defp new_lua do - [exclude: [[:require], [:package]]] + [sandbox: false] |> Lua.new() |> Lua.set_lua_paths([ Path.join(@lua_dir, "?.lua"), diff --git a/test/language/assert_test.exs b/test/language/assert_test.exs index 38cfe5cd..11f5376c 100644 --- a/test/language/assert_test.exs +++ b/test/language/assert_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.AssertTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "assert returns all arguments", %{lua: lua} do diff --git a/test/language/assignment_test.exs b/test/language/assignment_test.exs index f81cb09e..baff9a3f 100644 --- a/test/language/assignment_test.exs +++ b/test/language/assignment_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.AssignmentTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "vararg.lua early lines", %{lua: lua} do diff --git a/test/language/closure_test.exs b/test/language/closure_test.exs index 17799b19..ca3e4f54 100644 --- a/test/language/closure_test.exs +++ b/test/language/closure_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.ClosureTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "closure upvalue mutation", %{lua: lua} do diff --git a/test/language/control_flow_test.exs b/test/language/control_flow_test.exs index 67e1cd7f..8bf631f6 100644 --- a/test/language/control_flow_test.exs +++ b/test/language/control_flow_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.ControlFlowTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "if false should not execute body", %{lua: lua} do diff --git a/test/language/function_test.exs b/test/language/function_test.exs index 8867cab7..b7b74c77 100644 --- a/test/language/function_test.exs +++ b/test/language/function_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.FunctionTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "redefine local function with same name", %{lua: lua} do diff --git a/test/language/global_test.exs b/test/language/global_test.exs index 041baa2a..a732b0a7 100644 --- a/test/language/global_test.exs +++ b/test/language/global_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.GlobalTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "_G references the global environment", %{lua: lua} do diff --git a/test/language/load_test.exs b/test/language/load_test.exs index 28312858..3fa3a3f9 100644 --- a/test/language/load_test.exs +++ b/test/language/load_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.LoadTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "load compiles and returns a function", %{lua: lua} do @@ -82,7 +82,7 @@ defmodule Lua.Language.LoadTest do describe "load with a reader function" do setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "concatenates pieces returned by the reader", %{lua: lua} do diff --git a/test/language/loop_test.exs b/test/language/loop_test.exs index c374d935..dc00515f 100644 --- a/test/language/loop_test.exs +++ b/test/language/loop_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.LoopTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "loops with external variables", %{lua: lua} do diff --git a/test/language/math_test.exs b/test/language/math_test.exs index 5cd47713..a40bd040 100644 --- a/test/language/math_test.exs +++ b/test/language/math_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.MathTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "priority: power vs multiply", %{lua: lua} do diff --git a/test/language/metamethod_test.exs b/test/language/metamethod_test.exs index 0dfeec06..6e2c291f 100644 --- a/test/language/metamethod_test.exs +++ b/test/language/metamethod_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.MetamethodTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "function __index is called on missing key", %{lua: lua} do diff --git a/test/language/require_test.exs b/test/language/require_test.exs index 4d96b6ea..bfb95bc4 100644 --- a/test/language/require_test.exs +++ b/test/language/require_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.RequireTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "require 'string' returns string table", %{lua: lua} do diff --git a/test/language/stdlib/debug_test.exs b/test/language/stdlib/debug_test.exs index 75213779..3a46e956 100644 --- a/test/language/stdlib/debug_test.exs +++ b/test/language/stdlib/debug_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.Stdlib.DebugTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "debug.getinfo on native function", %{lua: lua} do diff --git a/test/language/stdlib/string_test.exs b/test/language/stdlib/string_test.exs index baf29c14..0582534e 100644 --- a/test/language/stdlib/string_test.exs +++ b/test/language/stdlib/string_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.Stdlib.StringTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "string method syntax works", %{lua: lua} do diff --git a/test/language/stdlib/table_test.exs b/test/language/stdlib/table_test.exs index 0931d357..dddc5cbe 100644 --- a/test/language/stdlib/table_test.exs +++ b/test/language/stdlib/table_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.Stdlib.TableTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "table.unpack with nil third argument", %{lua: lua} do diff --git a/test/language/string_test.exs b/test/language/string_test.exs index d8c25a0e..6a3c7953 100644 --- a/test/language/string_test.exs +++ b/test/language/string_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.StringTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "string concat with shift operator priority", %{lua: lua} do diff --git a/test/language/table_test.exs b/test/language/table_test.exs index bc9caa69..af0e2595 100644 --- a/test/language/table_test.exs +++ b/test/language/table_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.TableTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "table constructors with semicolons", %{lua: lua} do diff --git a/test/language/vararg_test.exs b/test/language/vararg_test.exs index d0423f2f..c2c4c9f7 100644 --- a/test/language/vararg_test.exs +++ b/test/language/vararg_test.exs @@ -2,7 +2,7 @@ defmodule Lua.Language.VarargTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "simple varargs function", %{lua: lua} do diff --git a/test/lua/io_stub_test.exs b/test/lua/io_stub_test.exs index 4d51cc36..9c2cdff6 100644 --- a/test/lua/io_stub_test.exs +++ b/test/lua/io_stub_test.exs @@ -84,7 +84,7 @@ defmodule Lua.IoStubTest do test "rawlen on a number raises" do assert {[false, message], _} = - Lua.eval!(Lua.new(sandboxed: []), "return pcall(rawlen, 34)") + Lua.eval!(Lua.new(sandbox: false), "return pcall(rawlen, 34)") assert message =~ "rawlen" assert message =~ "number" @@ -92,7 +92,7 @@ defmodule Lua.IoStubTest do test "rawlen with no arguments raises" do assert {[false, message], _} = - Lua.eval!(Lua.new(sandboxed: []), "return pcall(rawlen)") + Lua.eval!(Lua.new(sandbox: false), "return pcall(rawlen)") assert message =~ "rawlen" assert message =~ "value expected" @@ -104,18 +104,18 @@ defmodule Lua.IoStubTest do return pcall(rawlen, f) """ - assert {[false, message], _} = Lua.eval!(Lua.new(sandboxed: []), code) + assert {[false, message], _} = Lua.eval!(Lua.new(sandbox: false), code) assert message =~ "function" end test "rawlen on a table still returns its sequence length" do assert {[3], _} = - Lua.eval!(Lua.new(sandboxed: []), "return rawlen({10, 20, 30})") + Lua.eval!(Lua.new(sandbox: false), "return rawlen({10, 20, 30})") end test "rawlen on a string still returns its byte size" do assert {[5], _} = - Lua.eval!(Lua.new(sandboxed: []), ~S[return rawlen("hello")]) + Lua.eval!(Lua.new(sandbox: false), ~S[return rawlen("hello")]) end end end diff --git a/test/lua/runtime_exception_test.exs b/test/lua/runtime_exception_test.exs index e87a6132..a9586b81 100644 --- a/test/lua/runtime_exception_test.exs +++ b/test/lua/runtime_exception_test.exs @@ -403,10 +403,10 @@ defmodule Lua.RuntimeExceptionTest do end test "RuntimeException is raised for sandboxed functions" do - lua = Lua.new(sandboxed: [[:os, :exit]]) + lua = Lua.new() assert_raise RuntimeException, fn -> - Lua.eval!(lua, "os.exit()") + Lua.eval!(lua, ~S[os.execute("echo hi")]) end end diff --git a/test/lua/sandbox_test.exs b/test/lua/sandbox_test.exs new file mode 100644 index 00000000..4ad8d902 --- /dev/null +++ b/test/lua/sandbox_test.exs @@ -0,0 +1,130 @@ +defmodule Lua.SandboxTest do + @moduledoc """ + Pins the virtual-by-default capability model: the default VM never touches + the host, `sandbox: false` opts into the host implementations, and the + filesystem-touching stdlib resolves against the virtual filesystem. + """ + + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + + describe "os.execute" do + test "is sandboxed by default" do + assert {[false, message], _} = + Lua.eval!(Lua.new(), ~S[return pcall(os.execute, "echo hi")]) + + assert message =~ "os.execute(_) is sandboxed" + end + + test "runs the host shell under sandbox: false" do + assert {[true, "exit", 0], _} = + Lua.eval!(Lua.new(sandbox: false), ~S[return os.execute("exit 0")]) + end + end + + describe "os.getenv" do + test "reads only injected env in the default sandbox" do + System.put_env("LUA_SANDBOX_PROBE", "host-visible") + on_exit(fn -> System.delete_env("LUA_SANDBOX_PROBE") end) + + # The host value is invisible; injected values are not. + assert {[nil], _} = Lua.eval!(Lua.new(), ~S[return os.getenv("LUA_SANDBOX_PROBE")]) + + lua = Lua.new(env: %{"TOKEN" => "abc"}) + assert {["abc"], _} = Lua.eval!(lua, ~S[return os.getenv("TOKEN")]) + end + + test "reads the host environment under sandbox: false" do + System.put_env("LUA_SANDBOX_PROBE", "host-visible") + on_exit(fn -> System.delete_env("LUA_SANDBOX_PROBE") end) + + assert {["host-visible"], _} = + Lua.eval!(Lua.new(sandbox: false), ~S[return os.getenv("LUA_SANDBOX_PROBE")]) + end + end + + describe "os file operations (virtual)" do + test "os.remove deletes a seeded virtual file" do + lua = Lua.write_file(Lua.new(), "/data.txt", "hi") + assert {[true], lua} = Lua.eval!(lua, ~S[return os.remove("/data.txt")]) + assert Lua.read_file(lua, "/data.txt") == {:error, :enoent} + end + + test "os.remove reports a POSIX failure tuple for a missing file" do + assert {[nil, message, 2], _} = Lua.eval!(Lua.new(), ~S[return os.remove("/nope.txt")]) + assert message =~ "/nope.txt" + end + + test "os.rename moves a virtual file" do + lua = Lua.write_file(Lua.new(), "/a.txt", "v") + assert {[true], lua} = Lua.eval!(lua, ~S[return os.rename("/a.txt", "/b.txt")]) + assert Lua.read_file(lua, "/b.txt") == {:ok, "v"} + assert Lua.read_file(lua, "/a.txt") == {:error, :enoent} + end + + test "os.tmpname returns a virtual path, never a host one" do + assert {[name], _} = Lua.eval!(Lua.new(), ~S[return os.tmpname()]) + assert String.starts_with?(name, "/tmp/lua_") + end + end + + describe "file loaders resolve from the VFS in the default sandbox" do + test "loadfile compiles a seeded virtual file" do + lua = Lua.write_file(Lua.new(), "/main.lua", "return 1 + 2") + + assert {[3], _} = + Lua.eval!(lua, ~S[local f = loadfile("/main.lua"); return f()]) + end + + test "loadfile returns nil + message for a missing file" do + assert {[nil, message], _} = Lua.eval!(Lua.new(), ~S[return loadfile("/missing.lua")]) + assert message =~ "/missing.lua" + end + + test "dofile runs a seeded virtual file" do + lua = Lua.write_file(Lua.new(), "/run.lua", "return 7 * 6") + assert {[42], _} = Lua.eval!(lua, ~S[return dofile("/run.lua")]) + end + + test "load compiles a string in the default sandbox" do + assert {[5], _} = Lua.eval!(Lua.new(), ~S[local f = load("return 2 + 3"); return f()]) + end + end + + describe "Lua.write_file/3, put_dep/3, read_file/2" do + test "write_file then read_file round-trips" do + lua = Lua.write_file(Lua.new(), "/note.txt", "remember") + assert Lua.read_file(lua, "/note.txt") == {:ok, "remember"} + end + + test "put_dep makes a module require-able by dotted name" do + lua = Lua.put_dep(Lua.new(), "a.b", ~S[return "nested"]) + assert {["nested"], _} = Lua.eval!(lua, ~S[return require("a.b")]) + end + end + + describe "deprecated :sandboxed / :exclude options" do + test "sandboxed: [] warns and behaves as sandbox: false" do + {lua, stderr} = with_stderr(fn -> Lua.new(sandboxed: []) end) + assert stderr =~ "deprecated" + + System.put_env("LUA_SANDBOX_PROBE", "host-visible") + on_exit(fn -> System.delete_env("LUA_SANDBOX_PROBE") end) + + assert {["host-visible"], _} = + Lua.eval!(lua, ~S[return os.getenv("LUA_SANDBOX_PROBE")]) + end + + test "default Lua.new() does not warn" do + {_lua, stderr} = with_stderr(fn -> Lua.new() end) + refute stderr =~ "deprecated" + end + end + + defp with_stderr(fun) do + parent = self() + stderr = capture_io(:stderr, fn -> send(parent, {:result, fun.()}) end) + receive do: ({:result, result} -> {result, stderr}) + end +end diff --git a/test/lua/vm/env_semantics_test.exs b/test/lua/vm/env_semantics_test.exs index 9c74aea9..f3dfa722 100644 --- a/test/lua/vm/env_semantics_test.exs +++ b/test/lua/vm/env_semantics_test.exs @@ -17,7 +17,7 @@ defmodule Lua.VM.EnvSemanticsTest do # has access regardless of which free names it references. setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end describe "_ENV reassignment redirects global access" do diff --git a/test/lua/vm/leak_regression_test.exs b/test/lua/vm/leak_regression_test.exs index 9e6ff77c..c297285a 100644 --- a/test/lua/vm/leak_regression_test.exs +++ b/test/lua/vm/leak_regression_test.exs @@ -58,7 +58,7 @@ defmodule Lua.VM.LeakRegressionTest do test "load() with unique sources does not grow atom table" do # `load` is sandboxed by default; allow it explicitly so we can # exercise the runtime-compile path. - lua = Lua.new(sandboxed: []) + lua = Lua.new(sandbox: false) # Warm-up call to settle any first-call atom interning. {_, _} = Lua.eval!(lua, "return 0") diff --git a/test/lua/vm/limits_test.exs b/test/lua/vm/limits_test.exs index 31a087c5..5b754744 100644 --- a/test/lua/vm/limits_test.exs +++ b/test/lua/vm/limits_test.exs @@ -10,7 +10,7 @@ defmodule Lua.VM.LimitsTest do use ExUnit.Case, async: true setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end defp pcall_error(lua, expr) do @@ -46,7 +46,7 @@ defmodule Lua.VM.LimitsTest do # the behavior an embedder relies on when running the VM under a # process heap cap. setup do - %{small: Lua.new(sandboxed: [], max_string_bytes: 1024)} + %{small: Lua.new(sandbox: false, max_string_bytes: 1024)} end test "string.rep honors a lowered ceiling", %{small: small} do diff --git a/test/lua/vm/max_instructions_test.exs b/test/lua/vm/max_instructions_test.exs index ea9095df..21fcb992 100644 --- a/test/lua/vm/max_instructions_test.exs +++ b/test/lua/vm/max_instructions_test.exs @@ -255,7 +255,7 @@ defmodule Lua.VM.MaxInstructionsTest do # a mid-eval reset the 700 pre-require instruction_count would be forgiven, leaving the # 700 post-require instruction_count under the 1000 budget so the eval would wrongly # succeed. With the budget preserved, pre + post exceed it and it trips. - lua = Lua.new(sandboxed: [], max_instructions: 1000) + lua = Lua.new(sandbox: false, max_instructions: 1000) code = ~S""" package.path = "./test/fixtures/?.lua" @@ -275,7 +275,7 @@ defmodule Lua.VM.MaxInstructionsTest do # Sanity companion: requiring the trivial module under a comfortable # budget, with light surrounding work, must succeed (the fix must not # over-count and spuriously trip a legitimate require). - lua = Lua.new(sandboxed: [], max_instructions: 100_000) + lua = Lua.new(sandbox: false, max_instructions: 100_000) code = ~S""" package.path = "./test/fixtures/?.lua" diff --git a/test/lua/vm/require_open_upvalue_test.exs b/test/lua/vm/require_open_upvalue_test.exs index f92ef30d..972b5f6b 100644 --- a/test/lua/vm/require_open_upvalue_test.exs +++ b/test/lua/vm/require_open_upvalue_test.exs @@ -19,7 +19,7 @@ defmodule Lua.VM.RequireOpenUpvalueTest do end defp eval_with_path(code, tmp_dir) do - lua = Lua.new(exclude: [[:package], [:require]]) + lua = Lua.new(sandbox: false) lua = Lua.set_lua_paths(lua, [Path.join(tmp_dir, "?.lua")]) Lua.eval!(lua, code) end diff --git a/test/lua/vm/short_circuit_test.exs b/test/lua/vm/short_circuit_test.exs index d58027b1..24781f08 100644 --- a/test/lua/vm/short_circuit_test.exs +++ b/test/lua/vm/short_circuit_test.exs @@ -14,7 +14,7 @@ defmodule Lua.VM.ShortCircuitTest do # both are handled correctly. defp eval!(code) do - {results, _state} = Lua.eval!(Lua.new(sandboxed: []), code) + {results, _state} = Lua.eval!(Lua.new(sandbox: false), code) results end diff --git a/test/lua/vm/stdlib/os_test.exs b/test/lua/vm/stdlib/os_test.exs index 56a46410..a27aff04 100644 --- a/test/lua/vm/stdlib/os_test.exs +++ b/test/lua/vm/stdlib/os_test.exs @@ -69,16 +69,21 @@ defmodule Lua.VM.Stdlib.OsTest do assert locale == "C" end - test "os.getenv returns nil for an undefined variable when not sandboxed" do - lua = Lua.new(sandboxed: []) + test "os.getenv returns the real host value under sandbox: false" do + lua = Lua.new(sandbox: false) {[v], _} = Lua.eval!(lua, ~S[return os.getenv("LUA_NONEXISTENT_VAR_XYZ")]) assert v == nil end - test "os.getenv is sandboxed by default" do - assert_raise Lua.RuntimeException, ~r/os\.getenv.*sandboxed/, fn -> - Lua.eval!(~S[return os.getenv("PATH")]) - end + test "os.getenv reads only injected env in the default sandbox" do + # The default VM never reads the host environment: an unset name is nil, + # and only values injected via `env:` are visible. + {[v], _} = Lua.eval!(Lua.new(), ~S[return os.getenv("PATH")]) + assert v == nil + + lua = Lua.new(env: [{"MY_VAR", "hello"}]) + {[v], _} = Lua.eval!(lua, ~S[return os.getenv("MY_VAR")]) + assert v == "hello" end end end diff --git a/test/lua/vm/stdlib/package_test.exs b/test/lua/vm/stdlib/package_test.exs index 06ef701b..ad31717c 100644 --- a/test/lua/vm/stdlib/package_test.exs +++ b/test/lua/vm/stdlib/package_test.exs @@ -67,7 +67,7 @@ defmodule Lua.VM.Stdlib.PackageTest do end defp eval_with_path(code, tmp_dir) do - lua = Lua.new(exclude: [[:package], [:require]]) + lua = Lua.new(sandbox: false) lua = Lua.set_lua_paths(lua, [Path.join(tmp_dir, "?.lua")]) Lua.eval!(lua, code) end diff --git a/test/lua_test.exs b/test/lua_test.exs index bd097db7..593f422d 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -7,7 +7,7 @@ defmodule LuaTest do describe "basic tests" do setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end test "it can return basic values", %{lua: lua} do @@ -371,11 +371,11 @@ defmodule LuaTest do # Original implementation used same message format lua = Lua.new() - message = "Lua runtime error: os.exit(_) is sandboxed" + message = "Lua runtime error: os.execute(_) is sandboxed" assert_raise Lua.RuntimeException, message, fn -> Lua.eval!(lua, """ - os.exit(1) + os.execute("echo hi") """) end end @@ -930,7 +930,7 @@ defmodule LuaTest do # """ # # assert_raise Lua.RuntimeException, error, fn -> - # lua = Lua.new(sandboxed: []) + # lua = Lua.new(sandbox: false) # # Lua.eval!(lua, """ # error("this is an error") @@ -938,7 +938,7 @@ defmodule LuaTest do # end assert_raise Lua.RuntimeException, ~r/runtime error/, fn -> - lua = Lua.new(sandboxed: []) + lua = Lua.new(sandbox: false) Lua.eval!(lua, """ error("this is an error") @@ -1318,8 +1318,8 @@ defmodule LuaTest do end describe "require" do - test "it can find lua code when modifying package.path" do - lua = Lua.new(sandboxed: []) + test "it can find lua code when modifying package.path (host)" do + lua = Lua.new(sandbox: false) assert {["required file successfully"], _} = Lua.eval!(lua, """ @@ -1329,8 +1329,8 @@ defmodule LuaTest do """) end - test "we can use set_lua_paths/2 to add the paths" do - lua = Lua.new(sandboxed: []) + test "we can use set_lua_paths/2 to add the paths (host)" do + lua = Lua.new(sandbox: false) lua = Lua.set_lua_paths(lua, "./test/fixtures/?.lua") @@ -1340,14 +1340,16 @@ defmodule LuaTest do """) end - test "set_lua_paths/2 raises if package is sandboxed" do - lua = Lua.new() + test "require resolves from the virtual filesystem in the default sandbox" do + lua = Lua.put_dep(Lua.new(), "test_require", ~S[return "required file successfully"]) - message = "Lua runtime error: invalid index \"package.path\"" + assert {["required file successfully"], _} = + Lua.eval!(lua, ~S[return require("test_require")]) + end - assert_raise Lua.RuntimeException, message, fn -> - Lua.set_lua_paths(lua, "./test/fixtures/?.lua") - end + test "set_lua_paths/2 sets package.path" do + lua = Lua.set_lua_paths(Lua.new(), "/lua/?.lua") + assert {["/lua/?.lua"], _} = Lua.eval!(lua, "return package.path") end end @@ -1375,13 +1377,13 @@ defmodule LuaTest do describe "string metatable" do setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end end describe "module registration in package.loaded" do setup do - %{lua: Lua.new(sandboxed: [])} + %{lua: Lua.new(sandbox: false)} end end