From f2080cd397762052488bdcfe2d626c77d0321ea0 Mon Sep 17 00:00:00 2001 From: Ivar Vong Date: Thu, 2 Jul 2026 16:55:14 -0400 Subject: [PATCH] fix: mkdir and rm report backend errors instead of crashing the exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands matched only the error kinds the in-memory backend can produce (:eexist/:enoent for mkdir, :enoent/:enotempty for rm); a mount whose backend rejects the mutation with anything else — :enotsup from a git-tree-backed workspace, :erofs from a read-only archive — crashed the whole exec with a CaseClauseError. Any other backend error now reports through FS.strerror/1 with exit code 1, matching how ln already degrades on backends without symlink support. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FpxHS5iGm9ktgpnL9QZu9x --- lib/just_bash/commands/mkdir.ex | 6 +-- lib/just_bash/commands/rm.ex | 4 +- test/mount_integration_test.exs | 32 +++++++++++++++ test/support/no_empty_dirs_backend.ex | 56 +++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 test/support/no_empty_dirs_backend.ex diff --git a/lib/just_bash/commands/mkdir.ex b/lib/just_bash/commands/mkdir.ex index 238d59e..6e0d277 100644 --- a/lib/just_bash/commands/mkdir.ex +++ b/lib/just_bash/commands/mkdir.ex @@ -26,9 +26,9 @@ defmodule JustBash.Commands.Mkdir do {:error, %VFS.Error{kind: :eexist}} -> {err_acc <> "mkdir: cannot create directory '#{path}': File exists\n", 1, fs_acc} - {:error, %VFS.Error{kind: :enoent}} -> - {err_acc <> "mkdir: cannot create directory '#{path}': No such file or directory\n", - 1, fs_acc} + {:error, %VFS.Error{} = error} -> + {err_acc <> "mkdir: cannot create directory '#{path}': #{FS.strerror(error)}\n", 1, + fs_acc} end end) diff --git a/lib/just_bash/commands/rm.ex b/lib/just_bash/commands/rm.ex index 89fdd4e..9a9b13a 100644 --- a/lib/just_bash/commands/rm.ex +++ b/lib/just_bash/commands/rm.ex @@ -26,8 +26,8 @@ defmodule JustBash.Commands.Rm do {:error, %VFS.Error{kind: :enoent}} -> {err_acc <> "rm: cannot remove '#{path}': No such file or directory\n", 1, fs_acc} - {:error, %VFS.Error{kind: :enotempty}} -> - {err_acc <> "rm: cannot remove '#{path}': Directory not empty\n", 1, fs_acc} + {:error, %VFS.Error{} = error} -> + {err_acc <> "rm: cannot remove '#{path}': #{FS.strerror(error)}\n", 1, fs_acc} end end) diff --git a/test/mount_integration_test.exs b/test/mount_integration_test.exs index d39f7f2..a1f8784 100644 --- a/test/mount_integration_test.exs +++ b/test/mount_integration_test.exs @@ -1,6 +1,38 @@ defmodule JustBash.MountIntegrationTest do use ExUnit.Case, async: true + alias JustBash.Test.NoEmptyDirsBackend + + describe "mutations on restricted mounts" do + test "mkdir on a backend without directory support fails with exit 1, not a crash" do + bash = JustBash.new() |> JustBash.mount("/mnt", %NoEmptyDirsBackend{}) + + {result, _bash} = JustBash.exec(bash, "mkdir /mnt/newdir") + assert result.exit_code == 1 + assert result.stderr =~ "mkdir: cannot create directory '/mnt/newdir'" + assert result.stderr =~ "Operation not supported" + end + + test "mkdir -p on a backend without directory support fails with exit 1, not a crash" do + bash = JustBash.new() |> JustBash.mount("/mnt", %NoEmptyDirsBackend{}) + + {result, _bash} = JustBash.exec(bash, "mkdir -p /mnt/a/b") + assert result.exit_code == 1 + assert result.stderr =~ "Operation not supported" + end + + test "rm on a read-only backend fails with exit 1, not a crash" do + bash = + JustBash.new() + |> JustBash.mount("/mnt", %NoEmptyDirsBackend{files: %{"/f.txt" => "x"}, read_only: true}) + + {result, _bash} = JustBash.exec(bash, "rm /mnt/f.txt") + assert result.exit_code == 1 + assert result.stderr =~ "rm: cannot remove '/mnt/f.txt'" + assert result.stderr =~ "Read-only file system" + end + end + describe "JustBash.mount/3" do test "bash commands read a mounted VFS.Memory backend" do bash = diff --git a/test/support/no_empty_dirs_backend.ex b/test/support/no_empty_dirs_backend.ex new file mode 100644 index 0000000..bff7cf2 --- /dev/null +++ b/test/support/no_empty_dirs_backend.ex @@ -0,0 +1,56 @@ +# A backend where files are writable but empty directories cannot exist — +# the shape of any git-tree-backed mount (e.g. an exgit workspace). With +# `read_only: true`, mutations fail with :erofs instead. +defmodule JustBash.Test.NoEmptyDirsBackend do + @moduledoc false + defstruct files: %{}, read_only: false +end + +defimpl VFS.Mountable, for: JustBash.Test.NoEmptyDirsBackend do + use VFS.Skeleton + + alias VFS.{Error, Stat} + + @mtime ~U[1970-01-01 00:00:00Z] + + def exists?(b, path), do: {path == "/" or Map.has_key?(b.files, path), b} + + def stat(b, "/"), do: {:ok, Stat.directory(@mtime), b} + + def stat(b, path) do + case Map.fetch(b.files, path) do + {:ok, content} -> {:ok, Stat.regular(byte_size(content), @mtime), b} + :error -> {:error, Error.new(:enoent, path: path)} + end + end + + def readdir(b, "/"), + do: {:ok, b.files |> Map.keys() |> Enum.map(&String.trim_leading(&1, "/")), b} + + def readdir(_b, path), do: {:error, Error.new(:enoent, path: path)} + + def stream_read(b, path, opts) do + with {:ok, content} <- Map.fetch(b.files, path), + {:ok, stream} <- VFS.StreamOptions.apply(content, opts) do + {:ok, stream, b} + else + :error -> {:error, Error.new(:enoent, path: path)} + {:error, kind} -> {:error, Error.new(kind, path: path)} + end + end + + def write_file(%{read_only: true}, path, _content, _opts), + do: {:error, Error.new(:erofs, path: path)} + + def write_file(b, path, content, _opts), + do: {:ok, %{b | files: Map.put(b.files, path, content)}} + + def mkdir(_b, path, _opts) do + {:error, Error.new(:enotsup, path: path, message: "backend cannot store empty directories")} + end + + def rm(%{read_only: true}, path, _opts), do: {:error, Error.new(:erofs, path: path)} + def rm(b, path, _opts), do: {:ok, %{b | files: Map.delete(b.files, path)}} + + def capabilities(_), do: MapSet.new([:read, :write]) +end