From b50c1ccb467601d4816eef02a299efc773b5b0f4 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sun, 31 May 2026 07:00:49 -0700 Subject: [PATCH 1/6] chore(A48): start plan --- .agents/plans/A48-vfs-sandbox.md | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .agents/plans/A48-vfs-sandbox.md diff --git a/.agents/plans/A48-vfs-sandbox.md b/.agents/plans/A48-vfs-sandbox.md new file mode 100644 index 0000000..1d88ad3 --- /dev/null +++ b/.agents/plans/A48-vfs-sandbox.md @@ -0,0 +1,154 @@ +--- +id: A48 +title: VFS sandbox — route os/require file IO through a virtual filesystem +issue: 297 +pr: null +branch: feat/vfs-sandbox +base: main +status: in-progress +direction: A +unlocks: + - safe-by-default filesystem semantics without sandbox refusals + - a populate/mount API for embedding hosts to seed files + - pulling Lua dependencies from a virtual /lua/deps tree via require +--- + +## Goal + +Make filesystem-touching `os`/`require` operations safe by default by +running them against a **virtual filesystem** instead of refusing or +reaching the host disk. Integrate +[`ivarvong/vfs`](https://github.com/ivarvong/vfs) — a `VFS.Mountable` +protocol with pluggable backends — defaulting to the in-memory backend +(`VFS.Memory`). Give embedding hosts an API to seed files and mount +other backends, and use a special directory (`/lua/deps`) as the +mechanism for resolving Lua `require` dependencies. + +This is a large feature. This plan ships the **smallest coherent green +slice**: + +1. Add `vfs` as a pinned git dependency. +2. Carry a `VFS` value on `Lua.VM.State`, seeded with an empty in-memory + filesystem in `State.new/0`. +3. Expose a public `Lua` API to write files into the VFS and to mount a + backend. +4. Reroute the filesystem-touching `os` functions (`os.tmpname`, + `os.remove`, `os.rename`) through the VFS instead of the host / + stubs. +5. Reroute the `require` module searcher so `find_module_file/2` reads + from the VFS (under `package.path`, anchored at `/lua/deps`) instead + of `File.read/1` on the host disk. + +The full `io.*` library rewire (currently a deliberate stub per +ROADMAP) is **out of scope** and deferred — see `## Discoveries`. + +## Out of scope + +- Rewiring the `io.*` library (`io.open`, `io.read`, `io.write`, + `io.lines`, `io.tmpfile`, etc.). `io.*` is a stub by design today; + pointing it at the VFS is a follow-up plan, not this one. +- Removing or rewriting the existing `@default_sandbox` deny-list in + `lib/lua.ex`. The sandbox stays as-is; this plan adds a safe backing + store, it does not change which functions are exposed. +- Persistent / disk-backed VFS backends beyond what `vfs` already + ships. We only wire the in-memory default and the generic mount path. +- Changing the official Lua 5.3 suite deferrals (`files.lua`, + `attrib.lua`, `verybig.lua`, `main.lua`). Those remain deferred; this + plan does not attempt to flip them green. +- Publishing `vfs` to hex or vendoring it. We consume it as a git dep + pinned to a commit. + +## Success criteria + +- [ ] `vfs` is added to `mix.exs` as a git dep pinned to a specific + `ref:` (commit `32d2ab618ec12c16fe4f675b5ee8b563c660dd69`), with a + matching `mix.lock` entry, and `mix deps.get` succeeds. +- [ ] `Lua.VM.State` has a `vfs` field defaulting to an in-memory + `VFS` (`VFS.new/0` + `VFS.mount/3` with `VFS.Memory.new(%{})`), + with `@type t` updated. `State.new/0` seeds it. +- [ ] A public `Lua` API exists to (a) write a file into the VFS and + (b) mount a backend at a path; both return an updated `%Lua{}`. +- [ ] `os.tmpname`, `os.remove`, and `os.rename` operate against the VFS + on `state.vfs` and thread the updated struct back into `state`; + none of them touch the host filesystem. +- [ ] `require` resolves modules by reading from the VFS (searcher + anchored at `/lua/deps`, honoring `package.path` patterns) instead + of `File.read/1`; a module seeded into `/lua/deps` via the new API + is loadable with `require`. +- [ ] No source file or test references the plan id (per repo + convention). +- [ ] `mix format` is clean. +- [ ] `mix compile --warnings-as-errors` passes. +- [ ] `mix test` passes with no regressions (baseline 1,705 passing, 0 + failing, 30 skipped) plus the new VFS tests. +- [ ] `mix test --only lua53` shows no suite regression (6/29 baseline). + +## Implementation notes + +Exact files: + +- **`mix.exs`** — add `{:vfs, github: "ivarvong/vfs", ref: + "32d2ab618ec12c16fe4f675b5ee8b563c660dd69"}` to `deps/0` (no `only:` + — it is a runtime dependency). Run `mix deps.get` so `mix.lock` picks + up the pinned commit. +- **`lib/lua/vm/state.ex`** — add a `vfs` field to `defstruct` and to + `@type t` (type `VFS.t()`). Seed it in `State.new/0` with an empty + in-memory filesystem: + `VFS.new() |> VFS.mount("/", VFS.Memory.new(%{}))`. Add small + threaded helpers (`State.vfs_read/2`, `State.vfs_write/3`, + `State.vfs_rm/2`, `State.vfs_exists?/2`) that wrap the VFS calls and + fold the returned backend struct back onto `state.vfs`. +- **`lib/lua/vm/stdlib/os.ex`** — `os_tmpname/2` returns a virtual path; + add `os.remove(filename)` and `os.rename(from, to)` against + `state.vfs`. +- **`lib/lua/vm/stdlib.ex`** — reroute the `require` searcher to read + from the VFS anchored at `/lua/deps`. +- **`lib/lua.ex`** — add `Lua.write_file/3` and `Lua.mount/3`. +- **`test/lua/vm/stdlib/os_test.exs`** — os.tmpname / os.remove / + os.rename cases. +- **`test/lua/vfs_test.exs`** (new) — write_file + require, mount + + require, default VFS empty. + +Threading discipline: every `VFS`/`VFS.Mountable` call returns the +(possibly updated) backend struct; always fold it back onto +`state.vfs`. Centralized in the `State.vfs_*` helpers. + +## Verification + +```bash +mix deps.get +mix format +mix compile --warnings-as-errors +mix test +mix test test/lua/vm/stdlib/os_test.exs +mix test test/lua/vfs_test.exs +mix test --only lua53 +``` + +## Risks + +- **vfs requires `elixir ~> 1.18`; this repo declares `~> 1.16`.** + Pulling vfs as a runtime dep effectively raises the floor to 1.18. +- **Git dep, not hex.** Pinning to a commit `ref:` is mandatory. +- **Threading regressions.** Forgetting to fold a returned VFS struct + back onto `state.vfs` is a silent correctness bug. +- **require behavior change.** Moving the searcher off the host disk + means existing host-path require workflows break. +- **Scope creep into io.*.** Resist wiring `io.open` here. + +## Discoveries + +- `vfs` is not published to hex, so it must be consumed as a git dep + pinned to commit `32d2ab618ec12c16fe4f675b5ee8b563c660dd69`. +- `vfs` declares `elixir: "~> 1.18"`; this repo declares `~> 1.16`. +- The `%VFS{}` struct itself implements `VFS.Mountable`, so + `VFS.read_file/2`, `VFS.write_file/4`, `VFS.rm/3`, `VFS.exists?/2` + accept the `%VFS{}` value and route to the mounted backend, returning + an updated `%VFS{}` as the threaded value (`{:ok, content, vfs}` / + `{:ok, vfs}`). Errors are `{:error, %VFS.Error{}}`. +- **Deferred: `io.*` library rewire.** Routing `io.open`, `io.read`, + `io.write`, `io.lines`, `io.tmpfile`, etc. through the VFS is a + coherent follow-up plan once this slice is green. +- **Deferred: flipping suite files green.** `files.lua` / `attrib.lua` + / `verybig.lua` need the `io.*` rewire and suite-harness changes; out + of scope here. From 433d17396c46f796c9685571224f242131689f77 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sun, 31 May 2026 07:07:12 -0700 Subject: [PATCH 2/6] feat(stdlib): route os/require file IO through a virtual filesystem Filesystem-touching stdlib functions now operate against an in-memory virtual filesystem carried on Lua.VM.State instead of refusing or reaching the host disk. Integrates the ivarvong/vfs VFS.Mountable protocol with the in-memory VFS.Memory backend as the default. - State.new/0 seeds an empty in-memory VFS; vfs_read/write/rm/exists?/ mount helpers thread the returned backend struct forward. - os.tmpname returns a virtual path; new os.remove and os.rename operate against state.vfs and never touch the host. - The require searcher consults the VFS first (the resolved path plus a /lua/deps-anchored path), then falls back to host File.read so existing set_lua_paths/package.path workflows keep working. - Lua.write_file/3, Lua.put_dep/3, and Lua.mount/3 let embedding hosts seed files and mount backends. Plan: A48 Closes #297 --- lib/lua.ex | 55 +++++++++++++++++++++++ lib/lua/vm/state.ex | 81 ++++++++++++++++++++++++++++++++-- lib/lua/vm/stdlib.ex | 61 +++++++++++++++++++++---- lib/lua/vm/stdlib/os.ex | 65 ++++++++++++++++++++++++--- mix.exs | 1 + mix.lock | 1 + test/lua/vfs_test.exs | 67 ++++++++++++++++++++++++++++ test/lua/vm/stdlib/os_test.exs | 45 +++++++++++++++++++ 8 files changed, 357 insertions(+), 19 deletions(-) create mode 100644 test/lua/vfs_test.exs diff --git a/lib/lua.ex b/lib/lua.ex index 4f42afc..71016a1 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -940,6 +940,61 @@ defmodule Lua do Lua.API.install(lua, module, scope, opts[:data]) end + @doc """ + Writes a file into the VM's virtual filesystem. + + The contents become readable by sandbox code through the rerouted `os` and + `require` paths. Writing a file under `/lua/deps` makes it loadable with + `require` (see `put_dep/3` for the convenience form). + + iex> lua = Lua.new(sandboxed: []) |> Lua.write_file("/lua/deps/greet.lua", "return 'hi'") + iex> {[result], _lua} = Lua.eval!(lua, "return require('greet')") + iex> result + "hi" + """ + @spec write_file(t(), binary(), binary()) :: 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, error, _state} -> raise "failed to write #{path} to VFS: #{Exception.message(error)}" + end + end + + @doc """ + Writes a Lua module's source into the dependency root (`/lua/deps`). + + Convenience over `write_file/3` for the common case of seeding a module that + sandbox code can then `require`. + + iex> lua = Lua.new(sandboxed: []) |> Lua.put_dep("mymod", "return 42") + iex> {[result], _lua} = Lua.eval!(lua, "return require('mymod')") + iex> result + 42 + """ + @spec put_dep(t(), binary(), binary()) :: t() + def put_dep(%__MODULE__{} = lua, modname, source) when is_binary(modname) and is_binary(source) do + path = Path.join("/lua/deps", String.replace(modname, ".", "/") <> ".lua") + write_file(lua, path, source) + end + + @doc """ + Mounts a `VFS.Mountable` backend at `mountpoint` in the VM's virtual + filesystem. + + Lets an embedding host back part of the virtual tree with another backend + (e.g. another `VFS.Memory`), returning the updated `%Lua{}`. + + iex> backend = VFS.Memory.new(%{"/util.lua" => "return 7"}) + iex> lua = Lua.new(sandboxed: []) |> Lua.mount("/lua/deps", backend) + iex> {[result], _lua} = Lua.eval!(lua, "return require('util')") + iex> result + 7 + """ + @spec mount(t(), binary(), struct()) :: t() + def mount(%__MODULE__{state: state} = lua, mountpoint, backend) when is_binary(mountpoint) do + %{lua | state: State.vfs_mount(state, mountpoint, backend)} + end + @doc """ Puts a private value in storage for use in Elixir functions diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index cbd161f..4e22ab5 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -25,7 +25,13 @@ defmodule Lua.VM.State do # `data` map. Allocated by `new/0`. Plan A16: `_ENV` semantics # require globals to be a real Lua table so `_ENV` reassignment # can redirect global access. - g_ref: nil + g_ref: nil, + # The sandbox's virtual filesystem. Filesystem-touching stdlib + # functions (os.remove/rename/tmpname, the require searcher) + # operate against this in-memory `VFS` value instead of the host + # disk, so the VM never reaches real files. Seeded by `new/0` and + # threaded forward by the `vfs_*` helpers. + vfs: nil @type t :: %__MODULE__{ call_stack: list(), @@ -39,7 +45,8 @@ defmodule Lua.VM.State do userdata_next_id: non_neg_integer(), private: map(), multi_return_count: non_neg_integer(), - g_ref: nil | {:tref, non_neg_integer()} + g_ref: nil | {:tref, non_neg_integer()}, + vfs: nil | VFS.t() } @doc """ @@ -49,11 +56,79 @@ defmodule Lua.VM.State do """ @spec new() :: t() def new do - state = %__MODULE__{} + state = %__MODULE__{vfs: new_vfs()} {g_ref, state} = alloc_table(state) %{state | g_ref: g_ref} end + # An empty in-memory virtual filesystem with a single `/` mount backed by + # `VFS.Memory`. This is the default backing store for all filesystem-touching + # stdlib functions; embedding hosts can seed files or mount other backends. + defp new_vfs do + VFS.mount(VFS.new(), "/", VFS.Memory.new(%{})) + end + + @doc """ + Reads a file from the VM's virtual filesystem. + + Returns `{:ok, contents, state}` with the (possibly updated) VFS threaded + back onto `state`, or `{:error, %VFS.Error{}, state}` on failure. + """ + @spec vfs_read(t(), binary()) :: {:ok, binary(), t()} | {:error, VFS.Error.t(), t()} + def vfs_read(%__MODULE__{vfs: vfs} = state, path) when is_binary(path) do + case VFS.read_file(vfs, path) do + {:ok, contents, vfs} -> {:ok, contents, %{state | vfs: vfs}} + {:error, %VFS.Error{} = error} -> {:error, error, state} + end + end + + @doc """ + Writes a file into the VM's virtual filesystem. + + Returns `{:ok, state}` with the updated VFS threaded back, or + `{:error, %VFS.Error{}, state}` on failure. + """ + @spec vfs_write(t(), binary(), binary()) :: {:ok, t()} | {:error, VFS.Error.t(), t()} + def vfs_write(%__MODULE__{vfs: vfs} = state, path, contents) when is_binary(path) and is_binary(contents) do + case VFS.write_file(vfs, path, contents) do + {:ok, vfs} -> {:ok, %{state | vfs: vfs}} + {:error, %VFS.Error{} = error} -> {:error, error, state} + end + end + + @doc """ + Removes a file from the VM's virtual filesystem. + + Returns `{:ok, state}` with the updated VFS threaded back, or + `{:error, %VFS.Error{}, state}` on failure. + """ + @spec vfs_rm(t(), binary()) :: {:ok, t()} | {:error, VFS.Error.t(), t()} + def vfs_rm(%__MODULE__{vfs: vfs} = state, path) when is_binary(path) do + case VFS.rm(vfs, path) do + {:ok, vfs} -> {:ok, %{state | vfs: vfs}} + {:error, %VFS.Error{} = error} -> {:error, error, state} + end + end + + @doc """ + Reports whether a path exists in the VM's virtual filesystem. + + Returns `{boolean, state}` with the (possibly updated) VFS threaded back. + """ + @spec vfs_exists?(t(), binary()) :: {boolean(), t()} + def vfs_exists?(%__MODULE__{vfs: vfs} = state, path) when is_binary(path) do + {exists?, vfs} = VFS.exists?(vfs, path) + {exists?, %{state | vfs: vfs}} + end + + @doc """ + Mounts a `VFS.Mountable` backend at `mountpoint`, returning the updated state. + """ + @spec vfs_mount(t(), binary(), struct()) :: t() + def vfs_mount(%__MODULE__{vfs: vfs} = state, mountpoint, backend) when is_binary(mountpoint) do + %{state | vfs: VFS.mount(vfs, mountpoint, backend)} + end + @doc """ Guards against unbounded recursion. diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index e239e32..8665c6f 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -700,11 +700,11 @@ 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 - {:ok, file_path, content} -> + case find_module_file(modname, patterns, state) do + {:ok, file_path, content, state} -> parse_and_execute_module(modname, file_path, content, state) - {:error, :not_found} -> + {:error, :not_found, _state} -> raise RuntimeError, value: "module '#{modname}' not found:\n\tno file '#{search_path}'" end @@ -763,20 +763,63 @@ 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 `package.path` patterns. For each + # pattern (with `?` resolved to the module path) the searcher tries, in + # order: + # + # 1. the virtual filesystem at the resolved path, + # 2. the virtual filesystem under the dependency root `/lua/deps` (the + # mechanism for seeding modules via `Lua.write_file/3` / `Lua.put_dep/3` + # / `Lua.mount/3`), and + # 3. the host filesystem via `File.read/1`. + # + # The VFS is consulted before the host so seeded modules take precedence and + # the common embedded case never reaches the disk; the host fallback keeps + # existing `set_lua_paths/2` / `package.path` workflows that point at real + # files working unchanged. + defp find_module_file(modname, patterns, state) do resolved = String.replace(modname, ".", "/") - Enum.find_value(patterns, {:error, :not_found}, fn pattern -> + Enum.reduce_while(patterns, {:error, :not_found, state}, fn pattern, {_, _, state} -> file_path = String.replace(pattern, "?", resolved) - case File.read(file_path) do - {:ok, content} -> {:ok, file_path, content} - {:error, _} -> nil + case search_candidate(file_path, state) do + {:ok, content, state} -> {:halt, {:ok, file_path, content, state}} + {:not_found, state} -> {:cont, {:error, :not_found, state}} end end) end + # Try one resolved pattern against the VFS (the dep-anchored path, plus the + # path itself when it is already absolute) and then the host disk. Returns + # `{:ok, content, state}` or `{:not_found, state}`. + # + # The VFS requires absolute paths, so relative patterns (the default + # `?.lua` / `?/init.lua`) are only tried against the VFS after being anchored + # under `/lua/deps`; the host fallback handles them verbatim. + defp search_candidate(file_path, state) do + with {:error, _, state} <- vfs_read_anchored(state, file_path), + {:error, _, state} <- vfs_read_direct(state, file_path), + {:error, _} <- File.read(file_path) do + {:not_found, state} + else + {:ok, content, state} -> {:ok, content, state} + {:ok, content} -> {:ok, content, state} + end + end + + defp vfs_read_anchored(state, file_path), do: State.vfs_read(state, anchor_dep_path(file_path)) + + # Only the VFS-legal (absolute) form is read directly; relative patterns are + # left to the dep-anchored read and the host fallback. + defp vfs_read_direct(state, "/" <> _ = file_path), do: State.vfs_read(state, file_path) + defp vfs_read_direct(state, _file_path), do: {:error, :relative, state} + + # Anchor a resolved search pattern under the virtual dependency root + # (`/lua/deps`). Absolute patterns are left as-is. + defp anchor_dep_path("/" <> _ = path), do: path + defp anchor_dep_path(path), do: Path.join("/lua/deps", path) + # Convert a value to string, checking for __tostring metamethod defp value_to_string_with_mt(value, state) do case value do diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index c5ca596..410e53c 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -15,8 +15,13 @@ defmodule Lua.VM.Stdlib.Os do - `os.date([format [, time]])` - Formats a time as a string or table. - `os.getenv(name)` - Value of an environment variable, or nil. - `os.setlocale([locale [, category]])` - No-op returning "C". - - `os.tmpname()` - A name usable for a temporary file. + - `os.tmpname()` - A virtual name usable for a temporary file. + - `os.remove(filename)` - Removes a file from the virtual filesystem. + - `os.rename(from, to)` - Renames a file within the virtual filesystem. - `os.exit([code [, close]])` - Raises to unwind; sandbox cannot exit. + + Filesystem operations run against the VM's virtual filesystem + (`state.vfs`), never the host disk. """ @behaviour Lua.VM.Stdlib.Library @@ -37,6 +42,8 @@ defmodule Lua.VM.Stdlib.Os do "difftime" => {:native_func, &os_difftime/2}, "exit" => {:native_func, &os_exit/2}, "getenv" => {:native_func, &os_getenv/2}, + "remove" => {:native_func, &os_remove/2}, + "rename" => {:native_func, &os_rename/2}, "setlocale" => {:native_func, &os_setlocale/2}, "time" => {:native_func, &os_time/2}, "tmpname" => {:native_func, &os_tmpname/2} @@ -152,16 +159,60 @@ defmodule Lua.VM.Stdlib.Os do # sandbox; report the "C" locale as active. defp os_setlocale(_args, state), do: {["C"], state} - # 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. + # os.tmpname() — a virtual name usable for a temporary file. The path lives + # under the sandbox's virtual /tmp root; the VM never touches host paths. defp os_tmpname(_args, state) do - name = Path.join(System.tmp_dir() || "/tmp", "lua_#{:erlang.unique_integer([:positive])}") + name = "/tmp/lua_#{:erlang.unique_integer([:positive])}" {[name], state} end + # os.remove(filename) — removes a file from the virtual filesystem. Returns + # true on success, or (nil, message) when the file cannot be removed. + defp os_remove([filename | _], state) when is_binary(filename) do + case State.vfs_rm(state, filename) do + {:ok, state} -> {[true], state} + {:error, error, state} -> {[nil, vfs_error_message(filename, error)], state} + end + end + + defp os_remove([arg | _], _state) do + raise ArgumentError.type_error("os.remove", 1, "string", Util.typeof(arg)) + end + + defp os_remove([], _state) do + raise ArgumentError.value_expected("os.remove", 1) + end + + # os.rename(from, to) — moves a file within the virtual filesystem by reading + # the source, writing the destination, then removing the source. Returns true + # on success, or (nil, message) when any step fails. + defp os_rename([from, to | _], state) when is_binary(from) and is_binary(to) do + with {:ok, contents, state} <- 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, error, state} -> {[nil, vfs_error_message(from, error)], state} + end + end + + defp os_rename([from, to | _], _state) when is_binary(from) do + raise ArgumentError.type_error("os.rename", 2, "string", Util.typeof(to)) + end + + defp os_rename([from | _], _state) do + raise ArgumentError.type_error("os.rename", 1, "string", Util.typeof(from)) + end + + defp os_rename([], _state) do + raise ArgumentError.value_expected("os.rename", 1) + end + + # Maps a %VFS.Error{} into Lua's ": " error string convention. + defp vfs_error_message(path, %VFS.Error{kind: :enoent}), do: "#{path}: No such file or directory" + defp vfs_error_message(path, %VFS.Error{message: nil}), do: path + defp vfs_error_message(path, %VFS.Error{message: message}), do: "#{path}: #{message}" + # 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/mix.exs b/mix.exs index e39260f..64cfa77 100644 --- a/mix.exs +++ b/mix.exs @@ -62,6 +62,7 @@ defmodule Lua.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:vfs, github: "ivarvong/vfs", ref: "32d2ab618ec12c16fe4f675b5ee8b563c660dd69"}, {:tidewave, "~> 0.5", only: [:dev]}, {:usage_rules, "~> 0.1", only: [:dev]}, {:ex_doc, "~> 0.38", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index fdd7c1d..a5addc7 100644 --- a/mix.lock +++ b/mix.lock @@ -36,4 +36,5 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, "usage_rules": {:hex, :usage_rules, "0.1.26", "19d38c8b9b5c35434eae44f7e4554caeb5f08037a1d45a6b059a9782543ac22e", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9f0d203aa288e1b48318929066778ec26fc423fd51f08518c5b47f58ad5caca9"}, + "vfs": {:git, "https://github.com/ivarvong/vfs.git", "32d2ab618ec12c16fe4f675b5ee8b563c660dd69", [ref: "32d2ab618ec12c16fe4f675b5ee8b563c660dd69"]}, } diff --git a/test/lua/vfs_test.exs b/test/lua/vfs_test.exs new file mode 100644 index 0000000..7d1c8fc --- /dev/null +++ b/test/lua/vfs_test.exs @@ -0,0 +1,67 @@ +defmodule Lua.VFSTest do + use ExUnit.Case, async: true + + # Pins the virtual-filesystem backing for `require` and the populate/mount + # API: sandbox code reads only from the in-memory VFS, never the host disk. + + describe "Lua.write_file/3 + require" do + test "a module written under /lua/deps is loadable with require" do + lua = + [sandboxed: []] + |> Lua.new() + |> Lua.write_file("/lua/deps/mymod.lua", "return { answer = 42 }") + + {[result], _lua} = Lua.eval!(lua, ~S[return require("mymod").answer]) + assert result == 42 + end + + test "Lua.put_dep/3 seeds a requireable module" do + lua = [sandboxed: []] |> Lua.new() |> Lua.put_dep("greet", "return 'hi'") + {[result], _lua} = Lua.eval!(lua, ~S[return require("greet")]) + assert result == "hi" + end + + test "dotted module names resolve to nested VFS paths" do + lua = + [sandboxed: []] + |> Lua.new() + |> Lua.write_file("/lua/deps/a/b.lua", "return 'nested'") + + {[result], _lua} = Lua.eval!(lua, ~S[return require("a.b")]) + assert result == "nested" + end + end + + describe "Lua.mount/3 + require" do + test "a module from a mounted backend is loadable with require" do + backend = VFS.Memory.new(%{"/util.lua" => "return 7"}) + lua = [sandboxed: []] |> Lua.new() |> Lua.mount("/lua/deps", backend) + + {[result], _lua} = Lua.eval!(lua, ~S[return require("util")]) + assert result == 7 + end + end + + describe "default virtual filesystem isolation" do + test "the default VM has an empty in-memory VFS" do + lua = Lua.new() + assert %VFS{} = lua.state.vfs + end + + test "a real host file is not readable via require" do + # Create a real file on disk, then confirm require cannot reach it: the + # searcher is anchored at the virtual /lua/deps, not the host cwd. + path = Path.join(System.tmp_dir!(), "lua_vfs_host_#{:erlang.unique_integer([:positive])}.lua") + File.write!(path, "return 'host'") + + on_exit(fn -> File.rm(path) end) + + modname = Path.basename(path, ".lua") + lua = Lua.new(sandboxed: []) + + assert_raise Lua.RuntimeException, ~r/module '#{modname}' not found/, fn -> + Lua.eval!(lua, ~s[return require("#{modname}")]) + end + end + end +end diff --git a/test/lua/vm/stdlib/os_test.exs b/test/lua/vm/stdlib/os_test.exs index 0b65070..5dd2e61 100644 --- a/test/lua/vm/stdlib/os_test.exs +++ b/test/lua/vm/stdlib/os_test.exs @@ -63,4 +63,49 @@ defmodule Lua.VM.Stdlib.OsTest do end end end + + describe "os filesystem functions (virtual filesystem)" do + test "os.tmpname returns a virtual path and creates no host file" do + lua = Lua.new(sandboxed: []) + {[name], _} = Lua.eval!(lua, "return os.tmpname()") + assert is_binary(name) + assert String.starts_with?(name, "/tmp/lua_") + refute File.exists?(name) + end + + test "os.remove deletes a seeded VFS file and returns true" do + lua = [sandboxed: []] |> Lua.new() |> Lua.write_file("/scratch.txt", "data") + + {[ok], lua} = Lua.eval!(lua, ~S[return os.remove("/scratch.txt")]) + assert ok == true + + {[exists], _} = + Lua.eval!(lua, ~S[local f = os.remove("/scratch.txt"); return f]) + + assert exists == nil + end + + test "os.remove on a missing file returns nil and a message" do + lua = Lua.new(sandboxed: []) + {[result, message], _} = Lua.eval!(lua, ~S[return os.remove("/nope.txt")]) + assert result == nil + assert is_binary(message) + assert message =~ "/nope.txt" + end + + test "os.rename moves file contents within the VFS" do + lua = [sandboxed: []] |> Lua.new() |> Lua.write_file("/from.txt", "hello") + + {[ok], lua} = Lua.eval!(lua, ~S[return os.rename("/from.txt", "/to.txt")]) + assert ok == true + + # The source is gone after the move. + {[from_result | _], lua} = Lua.eval!(lua, ~S[return os.remove("/from.txt")]) + assert from_result == nil + + # The destination holds the moved contents and is then removable. + {[to_result | _], _} = Lua.eval!(lua, ~S[return os.remove("/to.txt")]) + assert to_result == true + end + end end From 2de381ab2aaf464bdf6bda2fa0166deac74ca489 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Sun, 31 May 2026 07:08:02 -0700 Subject: [PATCH 3/6] chore(A48): mark plan as review --- .agents/plans/A48-vfs-sandbox.md | 40 ++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/.agents/plans/A48-vfs-sandbox.md b/.agents/plans/A48-vfs-sandbox.md index 1d88ad3..87d34f7 100644 --- a/.agents/plans/A48-vfs-sandbox.md +++ b/.agents/plans/A48-vfs-sandbox.md @@ -2,10 +2,10 @@ id: A48 title: VFS sandbox — route os/require file IO through a virtual filesystem issue: 297 -pr: null +pr: 302 branch: feat/vfs-sandbox base: main -status: in-progress +status: review direction: A unlocks: - safe-by-default filesystem semantics without sandbox refusals @@ -152,3 +152,39 @@ mix test --only lua53 - **Deferred: flipping suite files green.** `files.lua` / `attrib.lua` / `verybig.lua` need the `io.*` rewire and suite-harness changes; out of scope here. +- **require keeps a host-disk fallback.** The initial design anchored + *all* require resolution at `/lua/deps` and read only from the VFS. + That regressed every existing test relying on host-path `require` + (`set_lua_paths/2`, `package.path = "./test/fixtures/?.lua"`, the + luassert integration suite). The shipped searcher therefore tries the + VFS first (resolved path when absolute, plus the `/lua/deps`-anchored + path) and falls back to `File.read/1` for the host. The "host file not + readable via require" criterion is satisfied by isolation rather than a + hard block: a file outside the search-path patterns (e.g. in the system + tmp dir, not matched by the default `?.lua`) is unreachable. +- **`VFS.read_file/2` raises on non-absolute paths** (it calls + `VFS.Path.normalize/1`, which raises rather than returning + `{:error, _}`). The searcher only issues a direct VFS read for absolute + patterns; relative patterns reach the VFS solely through the + `/lua/deps` anchor. + +## What changed + +- `mix.exs` / `mix.lock`: added `vfs` as a git dep pinned to commit + `32d2ab618ec12c16fe4f675b5ee8b563c660dd69`. +- `lib/lua/vm/state.ex`: added the `vfs` field (default in-memory + `VFS.Memory` mounted at `/`), updated `@type t`, seeded it in + `new/0`, and added the threaded `vfs_read/2`, `vfs_write/3`, + `vfs_rm/2`, `vfs_exists?/2`, and `vfs_mount/3` helpers. +- `lib/lua/vm/stdlib/os.ex`: `os.tmpname` now returns a virtual + `/tmp/lua_*` path; added `os.remove/1` and `os.rename/2` against + `state.vfs` with Lua's `true` / `nil, message` contract. +- `lib/lua/vm/stdlib.ex`: rerouted the `require` searcher through the + VFS (resolved + `/lua/deps`-anchored) with a host `File.read/1` + fallback, threading `state` through `find_module_file/3`. +- `lib/lua.ex`: added `write_file/3`, `put_dep/3`, and `mount/3`. +- Tests: new `test/lua/vfs_test.exs` and VFS cases in + `test/lua/vm/stdlib/os_test.exs`. + +Verification: `mix test` → 2105 passed, 19 skipped, 1 excluded, 0 +failed; `mix test --only lua53` → 17 passed, 12 skipped, 0 failed. From d697e4db25b01c84de905be6f33db51c6e703fb4 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 1 Jun 2026 04:41:58 -0700 Subject: [PATCH 4/6] fix(stdlib): gate require host-disk fallback behind explicit opt-in The require searcher silently fell back to File.read/1, letting any VM that un-sandboxes require reach the host disk and bypass the virtual filesystem. Default the VFS to be the only backing store; an embedding host now opts into the host fallback explicitly via set_lua_paths/2, and the VFS is always consulted first so seeded modules win. Plan: A48 --- lib/lua.ex | 10 +++++++++- lib/lua/vm/state.ex | 30 ++++++++++++++++++++++++++++-- lib/lua/vm/stdlib.ex | 37 +++++++++++++++++++++++++------------ test/lua/vfs_test.exs | 33 +++++++++++++++++++++++++++++++++ test/lua_test.exs | 14 ++++++++++---- 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/lib/lua.ex b/lib/lua.ex index 71016a1..1a028f4 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -186,6 +186,13 @@ defmodule Lua do Now you can use the [Lua require](https://www.lua.org/pil/8.1.html) function to import these scripts + Calling this function also opts the VM into a host-disk fallback for `require`: + by default modules resolve only against the virtual filesystem (see + `write_file/3`, `put_dep/3`, and `mount/3`), so the VM never touches real + files. Pointing the search path at an on-disk module tree with this function + is the explicit signal that the host trusts those paths; the VFS is still + consulted first, so seeded modules take precedence. + > #### Warning {: .warning} > In order to use `Lua.set_lua_paths/2`, the following functions cannot be sandboxed: > * `[:package]` @@ -197,7 +204,8 @@ defmodule Lua do set_lua_paths(lua, Enum.join(paths, ";")) end - def set_lua_paths(%__MODULE__{} = lua, paths) when is_binary(paths) do + def set_lua_paths(%__MODULE__{state: state} = lua, paths) when is_binary(paths) do + lua = %{lua | state: State.allow_vfs_host_fallback(state)} set!(lua, ["package", "path"], paths) end diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index 4e22ab5..6d6ead6 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -31,7 +31,13 @@ defmodule Lua.VM.State do # operate against this in-memory `VFS` value instead of the host # disk, so the VM never reaches real files. Seeded by `new/0` and # threaded forward by the `vfs_*` helpers. - vfs: nil + vfs: nil, + # Whether the `require` searcher may fall back to the host disk + # (`File.read/1`) when a module is not found in the VFS. Defaults to + # `false` so a VFS-only VM never reaches real files; an embedding + # host opts in explicitly via `Lua.set_lua_paths/2`, which targets + # real on-disk module trees. + vfs_host_fallback?: false @type t :: %__MODULE__{ call_stack: list(), @@ -46,7 +52,8 @@ defmodule Lua.VM.State do private: map(), multi_return_count: non_neg_integer(), g_ref: nil | {:tref, non_neg_integer()}, - vfs: nil | VFS.t() + vfs: nil | VFS.t(), + vfs_host_fallback?: boolean() } @doc """ @@ -129,6 +136,25 @@ defmodule Lua.VM.State do %{state | vfs: VFS.mount(vfs, mountpoint, backend)} end + @doc """ + Allows the `require` searcher to fall back to the host disk when a module is + not found in the virtual filesystem. + + Off by default so a VFS-only VM never reaches real files; enabled by + `Lua.set_lua_paths/2` when an embedding host points the search path at a real + on-disk module tree. + """ + @spec allow_vfs_host_fallback(t()) :: t() + def allow_vfs_host_fallback(%__MODULE__{} = state) do + %{state | vfs_host_fallback?: true} + end + + @doc """ + Reports whether host-disk fallback is enabled for the `require` searcher. + """ + @spec vfs_host_fallback?(t()) :: boolean() + def vfs_host_fallback?(%__MODULE__{vfs_host_fallback?: enabled?}), do: enabled? + @doc """ Guards against unbounded recursion. diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index 8665c6f..b921a2f 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -767,16 +767,18 @@ defmodule Lua.VM.Stdlib do # pattern (with `?` resolved to the module path) the searcher tries, in # order: # - # 1. the virtual filesystem at the resolved path, + # 1. the virtual filesystem at the resolved path (used when a pattern is + # already absolute), and # 2. the virtual filesystem under the dependency root `/lua/deps` (the # mechanism for seeding modules via `Lua.write_file/3` / `Lua.put_dep/3` - # / `Lua.mount/3`), and - # 3. the host filesystem via `File.read/1`. + # / `Lua.mount/3`). # - # The VFS is consulted before the host so seeded modules take precedence and - # the common embedded case never reaches the disk; the host fallback keeps - # existing `set_lua_paths/2` / `package.path` workflows that point at real - # files working unchanged. + # By default `require` never reaches the host disk: every read goes through + # the VFS so the VM can only load modules the embedder has explicitly seeded + # or mounted. An embedding host opts into a host-disk fallback by calling + # `Lua.set_lua_paths/2`, which points the search path at a real on-disk + # module tree; only then is `File.read/1` consulted, and always after the + # VFS so seeded modules take precedence. defp find_module_file(modname, patterns, state) do resolved = String.replace(modname, ".", "/") @@ -790,8 +792,9 @@ defmodule Lua.VM.Stdlib do end) end - # Try one resolved pattern against the VFS (the dep-anchored path, plus the - # path itself when it is already absolute) and then the host disk. Returns + # Try one resolved pattern against the VFS: the dep-anchored path, plus the + # path itself when it is already absolute. The host disk is consulted last, + # and only when the embedder has opted in via `Lua.set_lua_paths/2`. Returns # `{:ok, content, state}` or `{:not_found, state}`. # # The VFS requires absolute paths, so relative patterns (the default @@ -800,11 +803,21 @@ defmodule Lua.VM.Stdlib do defp search_candidate(file_path, state) do with {:error, _, state} <- vfs_read_anchored(state, file_path), {:error, _, state} <- vfs_read_direct(state, file_path), - {:error, _} <- File.read(file_path) do + {:error, state} <- host_read(state, file_path) do {:not_found, state} + end + end + + # Host-disk fallback, gated on the embedder having opted in (see + # `State.allow_vfs_host_fallback/1`). A VFS-only VM never reaches the host. + defp host_read(state, file_path) do + if State.vfs_host_fallback?(state) do + case File.read(file_path) do + {:ok, content} -> {:ok, content, state} + {:error, _} -> {:error, state} + end else - {:ok, content, state} -> {:ok, content, state} - {:ok, content} -> {:ok, content, state} + {:error, state} end end diff --git a/test/lua/vfs_test.exs b/test/lua/vfs_test.exs index 7d1c8fc..ae80d3c 100644 --- a/test/lua/vfs_test.exs +++ b/test/lua/vfs_test.exs @@ -63,5 +63,38 @@ defmodule Lua.VFSTest do Lua.eval!(lua, ~s[return require("#{modname}")]) end end + + test "host disk stays unreachable when package.path points at it without set_lua_paths/2" do + # Un-sandboxing require alone must not unlock the host disk. Only an + # explicit Lua.set_lua_paths/2 opts the VM into the host fallback, so + # assigning package.path from inside Lua resolves against the VFS only. + dir = Path.join(System.tmp_dir!(), "lua_vfs_optin_#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(dir) + File.write!(Path.join(dir, "hostmod.lua"), "return 'host'") + on_exit(fn -> File.rm_rf!(dir) end) + + lua = Lua.new(exclude: [[:require], [:package]]) + + assert_raise Lua.RuntimeException, ~r/module 'hostmod' not found/, fn -> + Lua.eval!(lua, ~s[package.path = "#{dir}/?.lua"; return require("hostmod")]) + end + end + + test "Lua.set_lua_paths/2 opts the VM into reading real host files" do + # The documented escape hatch: a host that points the search path at a + # real on-disk module tree gets the host fallback, after the VFS. + dir = Path.join(System.tmp_dir!(), "lua_vfs_hostpath_#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(dir) + File.write!(Path.join(dir, "hostmod.lua"), "return 'from host'") + on_exit(fn -> File.rm_rf!(dir) end) + + lua = + [exclude: [[:require], [:package]]] + |> Lua.new() + |> Lua.set_lua_paths(Path.join(dir, "?.lua")) + + {[result], _lua} = Lua.eval!(lua, ~S[return require("hostmod")]) + assert result == "from host" + end end end diff --git a/test/lua_test.exs b/test/lua_test.exs index bd097db..929e155 100644 --- a/test/lua_test.exs +++ b/test/lua_test.exs @@ -1319,20 +1319,26 @@ defmodule LuaTest do describe "require" do test "it can find lua code when modifying package.path" do - lua = Lua.new(sandboxed: []) + lua = + [sandboxed: []] + |> Lua.new() + |> Lua.write_file("/fixtures/test_require.lua", "return \"required file successfully\"") assert {["required file successfully"], _} = Lua.eval!(lua, """ - package.path = "./test/fixtures/?.lua" + package.path = "/fixtures/?.lua" return require("test_require") """) end test "we can use set_lua_paths/2 to add the paths" do - lua = Lua.new(sandboxed: []) + lua = + [sandboxed: []] + |> Lua.new() + |> Lua.write_file("/fixtures/test_require.lua", "return \"required file successfully\"") - lua = Lua.set_lua_paths(lua, "./test/fixtures/?.lua") + lua = Lua.set_lua_paths(lua, "/fixtures/?.lua") assert {["required file successfully"], _} = Lua.eval!(lua, """ From 29f2f418f06c1810f97a469c29261d532606da93 Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 1 Jun 2026 04:49:48 -0700 Subject: [PATCH 5/6] fix(stdlib): return Lua failures for relative VFS paths os.remove/os.rename and the require searcher funnel paths through the VFS, which raises ArgumentError on non-absolute paths. Guard the State.vfs_read/vfs_write/vfs_rm boundary so a relative path surfaces as a standard {:error, %VFS.Error{}} (enoent) instead of aborting the evaluation, matching real Lua's (nil, ": No such file or directory") contract. Also drop the unreachable message: nil clause in vfs_error_message/2, short-circuit absolute require patterns to a single VFS read, and bump the declared elixir floor to ~> 1.18 to match the vfs dependency. Plan: A48 --- lib/lua/vm/state.ex | 26 +++++++++++++++++++++++--- lib/lua/vm/stdlib.ex | 21 ++++++++++++--------- lib/lua/vm/stdlib/os.ex | 1 - mix.exs | 2 +- test/lua/vm/stdlib/os_test.exs | 18 ++++++++++++++++++ 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index 6d6ead6..9d61680 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -82,13 +82,17 @@ defmodule Lua.VM.State do back onto `state`, or `{:error, %VFS.Error{}, state}` on failure. """ @spec vfs_read(t(), binary()) :: {:ok, binary(), t()} | {:error, VFS.Error.t(), t()} - def vfs_read(%__MODULE__{vfs: vfs} = state, path) when is_binary(path) do + def vfs_read(%__MODULE__{vfs: vfs} = state, "/" <> _ = path) do case VFS.read_file(vfs, path) do {:ok, contents, vfs} -> {:ok, contents, %{state | vfs: vfs}} {:error, %VFS.Error{} = error} -> {:error, error, state} end end + def vfs_read(%__MODULE__{} = state, path) when is_binary(path) do + {:error, relative_path_error(path), state} + end + @doc """ Writes a file into the VM's virtual filesystem. @@ -96,13 +100,17 @@ defmodule Lua.VM.State do `{:error, %VFS.Error{}, state}` on failure. """ @spec vfs_write(t(), binary(), binary()) :: {:ok, t()} | {:error, VFS.Error.t(), t()} - def vfs_write(%__MODULE__{vfs: vfs} = state, path, contents) when is_binary(path) and is_binary(contents) do + def vfs_write(%__MODULE__{vfs: vfs} = state, "/" <> _ = path, contents) when is_binary(contents) do case VFS.write_file(vfs, path, contents) do {:ok, vfs} -> {:ok, %{state | vfs: vfs}} {:error, %VFS.Error{} = error} -> {:error, error, state} end end + def vfs_write(%__MODULE__{} = state, path, contents) when is_binary(path) and is_binary(contents) do + {:error, relative_path_error(path), state} + end + @doc """ Removes a file from the VM's virtual filesystem. @@ -110,13 +118,17 @@ defmodule Lua.VM.State do `{:error, %VFS.Error{}, state}` on failure. """ @spec vfs_rm(t(), binary()) :: {:ok, t()} | {:error, VFS.Error.t(), t()} - def vfs_rm(%__MODULE__{vfs: vfs} = state, path) when is_binary(path) do + def vfs_rm(%__MODULE__{vfs: vfs} = state, "/" <> _ = path) do case VFS.rm(vfs, path) do {:ok, vfs} -> {:ok, %{state | vfs: vfs}} {:error, %VFS.Error{} = error} -> {:error, error, state} end end + def vfs_rm(%__MODULE__{} = state, path) when is_binary(path) do + {:error, relative_path_error(path), state} + end + @doc """ Reports whether a path exists in the VM's virtual filesystem. @@ -155,6 +167,14 @@ defmodule Lua.VM.State do @spec vfs_host_fallback?(t()) :: boolean() def vfs_host_fallback?(%__MODULE__{vfs_host_fallback?: enabled?}), do: enabled? + # The VFS only accepts absolute paths and raises on relative ones; surface a + # relative path as an ordinary `:enoent` failure so callers (os.remove, + # os.rename, require) get the standard `{:error, %VFS.Error{}}` contract + # instead of crashing the evaluation. + defp relative_path_error(path) do + VFS.Error.new(:enoent, path: path, message: "No such file or directory") + end + @doc """ Guards against unbounded recursion. diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index b921a2f..05649d1 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -800,9 +800,17 @@ defmodule Lua.VM.Stdlib do # The VFS requires absolute paths, so relative patterns (the default # `?.lua` / `?/init.lua`) are only tried against the VFS after being anchored # under `/lua/deps`; the host fallback handles them verbatim. + defp search_candidate("/" <> _ = file_path, state) do + # An absolute pattern is already VFS-legal; reading it directly is the same + # path `anchor_dep_path` would produce, so issue a single VFS read. + with {:error, _, state} <- State.vfs_read(state, file_path), + {:error, state} <- host_read(state, file_path) do + {:not_found, state} + end + end + defp search_candidate(file_path, state) do with {:error, _, state} <- vfs_read_anchored(state, file_path), - {:error, _, state} <- vfs_read_direct(state, file_path), {:error, state} <- host_read(state, file_path) do {:not_found, state} end @@ -823,14 +831,9 @@ defmodule Lua.VM.Stdlib do defp vfs_read_anchored(state, file_path), do: State.vfs_read(state, anchor_dep_path(file_path)) - # Only the VFS-legal (absolute) form is read directly; relative patterns are - # left to the dep-anchored read and the host fallback. - defp vfs_read_direct(state, "/" <> _ = file_path), do: State.vfs_read(state, file_path) - defp vfs_read_direct(state, _file_path), do: {:error, :relative, state} - - # Anchor a resolved search pattern under the virtual dependency root - # (`/lua/deps`). Absolute patterns are left as-is. - defp anchor_dep_path("/" <> _ = path), do: path + # Anchor a relative search pattern under the virtual dependency root + # (`/lua/deps`). Absolute patterns are handled by the dedicated + # `search_candidate/2` clause and never reach here. defp anchor_dep_path(path), do: Path.join("/lua/deps", path) # Convert a value to string, checking for __tostring metamethod diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index 410e53c..b8c960f 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -210,7 +210,6 @@ defmodule Lua.VM.Stdlib.Os do # Maps a %VFS.Error{} into Lua's ": " error string convention. defp vfs_error_message(path, %VFS.Error{kind: :enoent}), do: "#{path}: No such file or directory" - defp vfs_error_message(path, %VFS.Error{message: nil}), do: path defp vfs_error_message(path, %VFS.Error{message: message}), do: "#{path}: #{message}" # os.exit([code [, close]]) — the sandbox cannot terminate the host, so diff --git a/mix.exs b/mix.exs index 64cfa77..03a4037 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Lua.MixProject do [ app: :lua, version: @version, - elixir: "~> 1.16", + elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), diff --git a/test/lua/vm/stdlib/os_test.exs b/test/lua/vm/stdlib/os_test.exs index 5dd2e61..e574c68 100644 --- a/test/lua/vm/stdlib/os_test.exs +++ b/test/lua/vm/stdlib/os_test.exs @@ -93,6 +93,24 @@ defmodule Lua.VM.Stdlib.OsTest do assert message =~ "/nope.txt" end + test "os.remove on a relative path returns nil and a message" do + lua = Lua.new(sandboxed: []) + {[result, message], _} = Lua.eval!(lua, ~S[return os.remove("relative.txt")]) + assert result == nil + assert is_binary(message) + assert message =~ "relative.txt" + assert message =~ "No such file or directory" + end + + test "os.rename on a relative path returns nil and a message" do + lua = Lua.new(sandboxed: []) + {[result, message], _} = Lua.eval!(lua, ~S[return os.rename("a.txt", "b.txt")]) + assert result == nil + assert is_binary(message) + assert message =~ "a.txt" + assert message =~ "No such file or directory" + end + test "os.rename moves file contents within the VFS" do lua = [sandboxed: []] |> Lua.new() |> Lua.write_file("/from.txt", "hello") From bc8fdb9257e50637c2bff63e19dae98296288d3e Mon Sep 17 00:00:00 2001 From: Dave Lucia Date: Mon, 1 Jun 2026 04:54:39 -0700 Subject: [PATCH 6/6] fix(stdlib): make os.rename self-safe and return errno on failure Treat os.rename(x, x) as a successful no-op so a self-rename no longer reads/writes/removes the same path and leaves nothing behind, matching POSIX rename(2). Attribute rename failures to the path that actually failed (the destination on a failed write) rather than always the source, and append the conventional POSIX errno as the third return value of os.remove/os.rename on failure to match the Lua 5.3 contract. Plan: A48 --- lib/lua/vm/stdlib/os.ex | 52 +++++++++++++++++++++++++++++----- test/lua/vm/stdlib/os_test.exs | 40 ++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index b8c960f..4213f80 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -167,11 +167,11 @@ defmodule Lua.VM.Stdlib.Os do end # os.remove(filename) — removes a file from the virtual filesystem. Returns - # true on success, or (nil, message) when the file cannot be removed. + # true on success, or (nil, message, errno) when the file cannot be removed. defp os_remove([filename | _], state) when is_binary(filename) do case State.vfs_rm(state, filename) do {:ok, state} -> {[true], state} - {:error, error, state} -> {[nil, vfs_error_message(filename, error)], state} + {:error, error, state} -> {vfs_failure(filename, error), state} end end @@ -183,16 +183,23 @@ defmodule Lua.VM.Stdlib.Os do raise ArgumentError.value_expected("os.remove", 1) end + # os.rename(from, to) — renaming a file to itself is a successful no-op, as in + # POSIX rename(2); short-circuit so we don't read/write/remove the same path. + defp os_rename([path, path | _], state) when is_binary(path) do + {[true], state} + end + # os.rename(from, to) — moves a file within the virtual filesystem by reading # the source, writing the destination, then removing the source. Returns true - # on success, or (nil, message) when any step fails. + # on success, or (nil, message, errno) when any step fails. Each step carries + # the path it operated on so the message names the path that actually failed. defp os_rename([from, to | _], state) when is_binary(from) and is_binary(to) do - with {:ok, contents, state} <- State.vfs_read(state, from), - {:ok, state} <- State.vfs_write(state, to, contents), - {:ok, state} <- State.vfs_rm(state, from) do + with {:ok, contents, state} <- with_path(from, State.vfs_read(state, from)), + {:ok, state} <- with_path(to, State.vfs_write(state, to, contents)), + {:ok, state} <- with_path(from, State.vfs_rm(state, from)) do {[true], state} else - {:error, error, state} -> {[nil, vfs_error_message(from, error)], state} + {:error, path, error, state} -> {vfs_failure(path, error), state} end end @@ -208,10 +215,41 @@ defmodule Lua.VM.Stdlib.Os do raise ArgumentError.value_expected("os.rename", 1) end + # Tags a VFS result with the path the step operated on, so a `with` chain can + # attribute a failure to the path that actually failed rather than a fixed one. + defp with_path(_path, {:ok, _state} = ok), do: ok + defp with_path(_path, {:ok, _contents, _state} = ok), do: ok + defp with_path(path, {:error, error, state}), do: {:error, path, error, state} + + # Builds Lua's failure return for os.remove/os.rename: (nil, message, errno), + # matching the reference contract of `nil, ": ", `. + defp vfs_failure(path, %VFS.Error{} = error) do + [nil, vfs_error_message(path, error), vfs_errno(error)] + end + # Maps a %VFS.Error{} into Lua's ": " error string convention. defp vfs_error_message(path, %VFS.Error{kind: :enoent}), do: "#{path}: No such file or directory" defp vfs_error_message(path, %VFS.Error{message: message}), do: "#{path}: #{message}" + # Maps a %VFS.Error{} kind onto its conventional POSIX errno integer, the + # third value Lua 5.3 returns from os.remove/os.rename on failure. + defp vfs_errno(%VFS.Error{kind: kind}) do + case kind do + :enoent -> 2 + :eio -> 5 + :eacces -> 13 + :eexist -> 17 + :enotdir -> 20 + :eisdir -> 21 + :einval -> 22 + :erofs -> 30 + :eloop -> 40 + :enotsup -> 95 + :exdev -> 18 + _ -> 0 + end + end + # 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/test/lua/vm/stdlib/os_test.exs b/test/lua/vm/stdlib/os_test.exs index e574c68..7f0b395 100644 --- a/test/lua/vm/stdlib/os_test.exs +++ b/test/lua/vm/stdlib/os_test.exs @@ -85,30 +85,60 @@ defmodule Lua.VM.Stdlib.OsTest do assert exists == nil end - test "os.remove on a missing file returns nil and a message" do + test "os.remove on a missing file returns nil, a message, and an errno" do lua = Lua.new(sandboxed: []) - {[result, message], _} = Lua.eval!(lua, ~S[return os.remove("/nope.txt")]) + {[result, message, errno], _} = Lua.eval!(lua, ~S[return os.remove("/nope.txt")]) assert result == nil assert is_binary(message) assert message =~ "/nope.txt" + assert errno == 2 end test "os.remove on a relative path returns nil and a message" do lua = Lua.new(sandboxed: []) - {[result, message], _} = Lua.eval!(lua, ~S[return os.remove("relative.txt")]) + {[result, message, errno], _} = Lua.eval!(lua, ~S[return os.remove("relative.txt")]) assert result == nil assert is_binary(message) assert message =~ "relative.txt" assert message =~ "No such file or directory" + assert errno == 2 end - test "os.rename on a relative path returns nil and a message" do + test "os.rename on a relative path returns nil, a message, and an errno" do lua = Lua.new(sandboxed: []) - {[result, message], _} = Lua.eval!(lua, ~S[return os.rename("a.txt", "b.txt")]) + {[result, message, errno], _} = Lua.eval!(lua, ~S[return os.rename("a.txt", "b.txt")]) assert result == nil assert is_binary(message) assert message =~ "a.txt" assert message =~ "No such file or directory" + assert errno == 2 + end + + test "os.rename names the destination when the write fails" do + lua = [sandboxed: []] |> Lua.new() |> Lua.write_file("/from.txt", "hello") + + {[result, message, errno], lua} = + Lua.eval!(lua, ~S[return os.rename("/from.txt", "relative")]) + + assert result == nil + assert message =~ "relative" + refute message =~ "/from.txt" + assert errno == 2 + + # The source is untouched when the destination write fails. + {[from_result | _], _} = Lua.eval!(lua, ~S[return os.remove("/from.txt")]) + assert from_result == true + end + + test "os.rename to the same path is a no-op that keeps the file" do + lua = [sandboxed: []] |> Lua.new() |> Lua.write_file("/x.txt", "keep") + + {[ok], lua} = Lua.eval!(lua, ~S[return os.rename("/x.txt", "/x.txt")]) + assert ok == true + + # The file still exists after a self-rename. + {[exists | _], _} = Lua.eval!(lua, ~S[return os.remove("/x.txt")]) + assert exists == true end test "os.rename moves file contents within the VFS" do