diff --git a/lib/just_bash/commands/chmod.ex b/lib/just_bash/commands/chmod.ex index 3d0373f..079b9e6 100644 --- a/lib/just_bash/commands/chmod.ex +++ b/lib/just_bash/commands/chmod.ex @@ -20,8 +20,8 @@ defmodule JustBash.Commands.Chmod do {opts, positional} = parse_args(args) case positional do - [_mode | paths] when paths != [] -> - check_paths(bash, paths, opts.recursive) + [mode | paths] when paths != [] -> + check_paths(bash, mode, paths, opts.recursive) _ -> {Command.error("chmod: missing operand\n"), bash} @@ -41,14 +41,21 @@ defmodule JustBash.Commands.Chmod do defp parse_args([arg | rest], opts, pos), do: parse_args(rest, opts, [arg | pos]) - defp check_paths(bash, paths, _recursive) do + defp check_paths(bash, _mode, paths, _recursive) do {stderr, exit_code, fs} = Enum.reduce(paths, {"", 0, bash.fs}, fn path, {err, code, fs} -> resolved = FS.resolve_path(bash.cwd, path) case FS.stat(fs, resolved) do - {:ok, _, new_fs} -> - {err, code, new_fs} + {:ok, stat, new_fs} -> + case FS.chmod(new_fs, resolved, stat.mode || 0) do + {:ok, newer_fs} -> + {err, code, newer_fs} + + {:error, error} -> + {err <> "chmod: changing permissions of '#{path}': #{FS.strerror(error)}\n", 1, + new_fs} + end {:error, _} -> {err <> "chmod: cannot access '#{path}': No such file or directory\n", 1, fs} diff --git a/lib/just_bash/fs/fs.ex b/lib/just_bash/fs/fs.ex index 5cbbb4f..31b7931 100644 --- a/lib/just_bash/fs/fs.ex +++ b/lib/just_bash/fs/fs.ex @@ -225,6 +225,42 @@ defmodule JustBash.FS do end end + @doc """ + Read every regular file under `root` into a `%{path => content}` map — + the filesystem as a git-style tree of absolute paths to blobs. The + inverse of `new/1` up to what a tree can express: `FS.new(tree)` + rebuilds an equivalent filesystem, but modes, mtimes, empty + directories, and symlink identity are not captured (symlinks are + materialized as their target's content; dangling symlinks and cyclic + directory symlinks follow `VFS.walk/3` semantics and are omitted). + + Walks the whole mount table, so mounted backends contribute their + files. Returns `{:ok, tree, fs}` per vfs read conventions — thread the + returned `fs` forward when the mount table contains lazy backends. + + ## Examples + + iex> fs = JustBash.FS.new(%{"/a.txt" => "alpha", "/notes/b.txt" => "beta"}) + iex> {:ok, tree, _fs} = JustBash.FS.to_tree(fs) + iex> tree + %{"/a.txt" => "alpha", "/notes/b.txt" => "beta"} + """ + @spec to_tree(t(), String.t()) :: {:ok, %{String.t() => binary()}, t()} | {:error, Error.t()} + def to_tree(fs, root \\ "/") do + fs + |> walk(normalize_path(root)) + |> Enum.reduce_while({:ok, %{}, fs}, fn + {path, %VFS.Stat{type: :regular}}, {:ok, tree, fs} -> + case read_file(fs, path) do + {:ok, content, fs} -> {:cont, {:ok, Map.put(tree, path, content), fs}} + {:error, %Error{} = err} -> {:halt, {:error, err}} + end + + {_path, %VFS.Stat{}}, acc -> + {:cont, acc} + end) + end + # ── error formatting ───────────────────────────────────────────────────── @doc """ diff --git a/lib/just_bash/fs/zip.ex b/lib/just_bash/fs/zip.ex new file mode 100644 index 0000000..9772318 --- /dev/null +++ b/lib/just_bash/fs/zip.ex @@ -0,0 +1,571 @@ +defmodule JustBash.FS.Zip do + @moduledoc """ + Read-only `VFS.Mountable` backend backed by a ZIP archive. + + ZIP contents are loaded eagerly at construction time into immutable Elixir + data structures. That keeps shell execution deterministic: once mounted, + `ls`, `cat`, glob expansion, `cp` out of the mount, and `find` operate without + further network access. Mutations fail with `:erofs`. + + Use `from_binary/2` when the caller already has archive bytes. Use + `from_url/3` to fetch through `JustBash`'s network policy and HTTP client. + + ## Examples + + iex> {:ok, {_name, bytes}} = :zip.create(~c"docs.zip", [{~c"README.md", "hello"}], [:memory]) + iex> {:ok, zip} = JustBash.FS.Zip.from_binary(bytes) + iex> {:ok, content, _zip} = VFS.read_file(zip, "/README.md") + iex> content + "hello" + """ + + alias JustBash.Network + alias VFS.Error + alias VFS.Path, as: VPath + + @enforce_keys [:files, :dirs, :mtime] + defstruct [:files, :dirs, :mtime] + + @type files :: %{String.t() => binary()} + + @type t :: %__MODULE__{ + files: files(), + dirs: MapSet.t(String.t()), + mtime: DateTime.t() + } + + @type from_binary_opts :: [ + mtime: DateTime.t(), + max_archive_bytes: pos_integer(), + max_uncompressed_bytes: pos_integer(), + max_entries: pos_integer(), + max_path_bytes: pos_integer() + ] + + @type from_url_opts :: [ + timeout: pos_integer(), + mtime: DateTime.t(), + max_archive_bytes: pos_integer(), + max_uncompressed_bytes: pos_integer(), + max_entries: pos_integer(), + max_path_bytes: pos_integer() + ] + + @default_mtime ~U[1970-01-01 00:00:00Z] + @default_timeout 15_000 + @default_max_archive_bytes 50 * 1024 * 1024 + @default_max_uncompressed_bytes 250 * 1024 * 1024 + @default_max_entries 100_000 + @default_max_path_bytes 4_096 + + @doc """ + Build a read-only ZIP filesystem from archive bytes. + + Archive entry paths must be relative POSIX paths. Absolute paths, `.`/`..` + segments, empty path segments, NUL bytes, backslashes, duplicate entries, and + file/directory collisions return `{:error, %VFS.Error{kind: :einval}}`. + + Safety limits default to: + + * `:max_archive_bytes` - 50 MiB compressed input + * `:max_uncompressed_bytes` - 250 MiB total uncompressed entries + * `:max_entries` - 100,000 entries + * `:max_path_bytes` - 4,096 bytes per entry path + """ + @spec from_binary(binary(), from_binary_opts()) :: {:ok, t()} | {:error, Error.t()} + def from_binary(zip_bytes, opts \\ []) when is_binary(zip_bytes) and is_list(opts) do + mtime = Keyword.get(opts, :mtime, @default_mtime) + + with :ok <- validate_mtime(mtime), + :ok <- validate_archive(zip_bytes, opts), + {:ok, entries} <- unzip(zip_bytes), + {:ok, files, dirs} <- build_index(entries) do + {:ok, %__MODULE__{files: files, dirs: dirs, mtime: mtime}} + end + end + + @doc """ + Build a read-only ZIP filesystem from archive bytes, raising on failure. + """ + @spec from_binary!(binary(), from_binary_opts()) :: t() + def from_binary!(zip_bytes, opts \\ []) do + case from_binary(zip_bytes, opts) do + {:ok, zip} -> zip + {:error, %Error{} = error} -> raise error + end + end + + @doc """ + Fetch a ZIP archive through `JustBash`'s network policy and build a backend. + + The fetch uses `bash.http_client` when present, otherwise + `JustBash.HttpClient.Default`. Redirects are followed manually by + `JustBash.Network`, so each redirect target is checked against the same + allow-list and HTTPS policy as `curl`/`wget`. + """ + @spec from_url(JustBash.t(), String.t(), from_url_opts()) :: {:ok, t()} | {:error, Error.t()} + def from_url(%JustBash{} = bash, url, opts \\ []) when is_binary(url) and is_list(opts) do + with :ok <- validate_url_access(bash, url), + {:ok, body} <- fetch_url(bash, url, opts) do + from_binary(body, opts) + end + end + + @doc """ + Fetch a ZIP archive through `JustBash`'s network policy, raising on failure. + """ + @spec from_url!(JustBash.t(), String.t(), from_url_opts()) :: t() + def from_url!(%JustBash{} = bash, url, opts \\ []) do + case from_url(bash, url, opts) do + {:ok, zip} -> zip + {:error, %Error{} = error} -> raise error + end + end + + @doc false + @spec __normalize__(String.t()) :: String.t() + def __normalize__(path), do: normalize(path) + + @doc false + @spec __file__(t(), String.t()) :: binary() | nil + def __file__(%__MODULE__{files: files}, path), do: Map.get(files, path) + + @doc false + @spec __file?(t(), String.t()) :: boolean() + def __file?(%__MODULE__{files: files}, path), do: Map.has_key?(files, path) + + @doc false + @spec __dir?(t(), String.t()) :: boolean() + def __dir?(%__MODULE__{dirs: dirs}, path), do: MapSet.member?(dirs, path) + + @doc false + @spec __children__(t(), String.t()) :: [String.t()] + def __children__(%__MODULE__{files: files, dirs: dirs}, dir) do + prefix = if dir == "/", do: "/", else: dir <> "/" + + file_children = files |> Map.keys() |> direct_children(prefix) + + dir_children = + dirs + |> MapSet.delete(dir) + |> MapSet.to_list() + |> direct_children(prefix) + + (file_children ++ dir_children) + |> Enum.uniq() + |> Enum.sort() + end + + defp unzip(zip_bytes) do + case :zip.unzip(zip_bytes, [:memory]) do + {:ok, entries} -> + {:ok, entries} + + {:error, reason} -> + {:error, + Error.new(:einval, + message: "invalid zip archive: #{inspect(reason)}" + )} + end + end + + defp validate_archive(zip_bytes, opts) do + with {:ok, max_archive_bytes} <- + positive_integer_opt(opts, :max_archive_bytes, @default_max_archive_bytes), + {:ok, max_uncompressed_bytes} <- + positive_integer_opt(opts, :max_uncompressed_bytes, @default_max_uncompressed_bytes), + {:ok, max_entries} <- positive_integer_opt(opts, :max_entries, @default_max_entries), + {:ok, max_path_bytes} <- + positive_integer_opt(opts, :max_path_bytes, @default_max_path_bytes), + :ok <- validate_archive_size(zip_bytes, max_archive_bytes), + {:ok, table_entries} <- zip_table(zip_bytes) do + validate_table_entries(table_entries, max_uncompressed_bytes, max_entries, max_path_bytes) + end + end + + defp zip_table(zip_bytes) do + case :zip.table(zip_bytes) do + {:ok, table_entries} -> + {:ok, table_entries} + + {:error, reason} -> + {:error, + Error.new(:einval, + message: "invalid zip archive: #{inspect(reason)}" + )} + end + end + + defp validate_archive_size(zip_bytes, max_archive_bytes) do + if byte_size(zip_bytes) <= max_archive_bytes do + :ok + else + {:error, + Error.new(:einval, + message: + "zip archive is too large: #{byte_size(zip_bytes)} bytes exceeds #{max_archive_bytes} bytes" + )} + end + end + + defp validate_table_entries(table_entries, max_uncompressed_bytes, max_entries, max_path_bytes) do + table_entries + |> Enum.reduce_while({:ok, 0, 0}, fn + {:zip_file, raw_name, file_info, _comment, _offset, _comp_size}, {:ok, entries, bytes} -> + with :ok <- validate_path_size(raw_name, max_path_bytes), + {:ok, _entry} <- normalize_entry_name(raw_name) do + entries = entries + 1 + bytes = bytes + file_info_size(file_info) + + cond do + entries > max_entries -> + {:halt, + {:error, + Error.new(:einval, + message: "zip archive has too many entries: #{entries} exceeds #{max_entries}" + )}} + + bytes > max_uncompressed_bytes -> + {:halt, + {:error, + Error.new(:einval, + message: + "zip archive expands to too many bytes: #{bytes} exceeds #{max_uncompressed_bytes}" + )}} + + true -> + {:cont, {:ok, entries, bytes}} + end + else + {:error, %Error{} = error} -> {:halt, {:error, error}} + end + + _entry, acc -> + {:cont, acc} + end) + |> case do + {:ok, _entries, _bytes} -> :ok + {:error, %Error{} = error} -> {:error, error} + end + end + + defp validate_path_size(raw_name, max_path_bytes) do + name = to_string(raw_name) + + if byte_size(name) <= max_path_bytes do + :ok + else + {:error, + Error.new(:einval, + message: + "zip entry path is too long: #{byte_size(name)} bytes exceeds #{max_path_bytes} bytes" + )} + end + end + + defp file_info_size( + {:file_info, size, _type, _access, _atime, _mtime, _ctime, _mode, _links, _major_device, + _minor_device, _inode, _uid, _gid} + ) + when is_integer(size) and size >= 0, + do: size + + defp file_info_size(_file_info), do: 0 + + defp build_index(entries) do + Enum.reduce_while(entries, {:ok, %{}, MapSet.new(["/"])}, fn {raw_name, content}, + {:ok, files, dirs} -> + case normalize_entry_name(raw_name) do + {:ok, {:dir, path}} -> + add_dir(path, files, dirs) + + {:ok, {:file, path}} when is_binary(content) -> + add_file(path, content, files, dirs) + + {:ok, {:file, path}} -> + error = + Error.new(:einval, path: path, message: "zip entry content is not binary: #{path}") + + {:halt, {:error, error}} + + {:error, %Error{} = error} -> + {:halt, {:error, error}} + end + end) + end + + defp add_dir(path, files, dirs) do + cond do + Map.has_key?(files, path) -> + error = Error.new(:einval, path: path, message: "zip entry collides with file: #{path}") + {:halt, {:error, error}} + + file_parent = file_parent(path, files) -> + error = Error.new(:einval, path: path, message: "zip entry is below file: #{file_parent}") + {:halt, {:error, error}} + + true -> + {:cont, {:ok, files, path |> parent_dirs() |> put_dirs(dirs) |> MapSet.put(path)}} + end + end + + defp add_file(path, content, files, dirs) do + cond do + Map.has_key?(files, path) -> + error = Error.new(:einval, path: path, message: "duplicate zip file entry: #{path}") + {:halt, {:error, error}} + + MapSet.member?(dirs, path) -> + error = + Error.new(:einval, path: path, message: "zip entry collides with directory: #{path}") + + {:halt, {:error, error}} + + file_parent = file_parent(path, files) -> + error = Error.new(:einval, path: path, message: "zip entry is below file: #{file_parent}") + {:halt, {:error, error}} + + true -> + {:cont, {:ok, Map.put(files, path, content), put_dirs(parent_dirs(path), dirs)}} + end + end + + defp normalize_entry_name(raw_name) do + name = to_string(raw_name) + dir? = String.ends_with?(name, "/") + trimmed = if dir?, do: binary_part(name, 0, byte_size(name) - 1), else: name + + cond do + name == "" or trimmed == "" -> + invalid_entry(name, "empty zip entry path") + + String.contains?(name, <<0>>) -> + invalid_entry(name, "zip entry path contains NUL byte") + + String.starts_with?(name, "/") -> + invalid_entry(name, "zip entry path must be relative") + + String.contains?(name, "\\") -> + invalid_entry(name, "zip entry path must use POSIX / separators") + + true -> + normalize_entry_segments(name, trimmed, dir?) + end + end + + defp normalize_entry_segments(original, trimmed, dir?) do + parts = String.split(trimmed, "/") + + if Enum.any?(parts, &(&1 in ["", ".", ".."])) do + invalid_entry(original, "zip entry path contains unsafe segment") + else + kind = if dir?, do: :dir, else: :file + {:ok, {kind, "/" <> Enum.join(parts, "/")}} + end + end + + defp invalid_entry(name, reason) do + {:error, Error.new(:einval, message: "#{reason}: #{inspect(name)}")} + end + + defp parent_dirs(path) do + path + |> VPath.dirname() + |> do_parent_dirs(["/"]) + end + + defp do_parent_dirs("/", acc), do: acc + + defp do_parent_dirs(path, acc) do + do_parent_dirs(VPath.dirname(path), [path | acc]) + end + + defp put_dirs(paths, dirs) do + Enum.reduce(paths, dirs, &MapSet.put(&2, &1)) + end + + defp file_parent(path, files) do + path + |> parent_dirs() + |> Enum.reject(&(&1 == "/")) + |> Enum.find(&Map.has_key?(files, &1)) + end + + defp direct_children(paths, prefix) do + paths + |> Enum.filter(&String.starts_with?(&1, prefix)) + |> Enum.map(fn path -> + path + |> String.replace_prefix(prefix, "") + |> String.split("/", parts: 2) + |> hd() + end) + |> Enum.reject(&(&1 == "")) + end + + defp validate_url_access(bash, url) do + case Network.validate_access(bash, url, "zip mount") do + :ok -> :ok + {:error, message} -> {:error, Error.new(:eacces, message: String.trim_trailing(message))} + end + end + + defp fetch_url(%JustBash{} = bash, url, opts) do + with {:ok, timeout} <- positive_integer_opt(opts, :timeout, @default_timeout) do + client = bash.http_client || JustBash.HttpClient.Default + + request = %{ + method: :get, + url: url, + headers: %{}, + body: nil, + timeout: timeout, + follow_redirects: false, + insecure: false + } + + case Network.follow_redirects(bash, request, "zip mount", &client.request/1) do + {:response, %{status: status, body: body}} when status in 200..299 and is_binary(body) -> + {:ok, body} + + {:response, %{status: status}} -> + {:error, Error.new(:eio, message: "zip mount fetch failed with HTTP #{status}")} + + {:error, %{reason: reason}} -> + {:error, Error.new(:eio, message: "zip mount fetch failed: #{inspect(reason)}")} + end + end + end + + defp validate_mtime(%DateTime{}), do: :ok + + defp validate_mtime(other) do + {:error, + Error.new(:einval, message: "expected :mtime to be a DateTime, got: #{inspect(other)}")} + end + + defp positive_integer_opt(opts, key, default) do + value = Keyword.get(opts, key, default) + + if is_integer(value) and value > 0 do + {:ok, value} + else + invalid_positive_integer_opt(key, value) + end + end + + defp invalid_positive_integer_opt(key, value) do + {:error, + Error.new(:einval, + message: "expected #{inspect(key)} to be a positive integer, got: #{inspect(value)}" + )} + end + + defp normalize(""), do: "/" + defp normalize("/" <> _ = path), do: VPath.normalize(path) + defp normalize(path), do: VPath.normalize("/" <> path) +end + +defimpl VFS.Mountable, for: JustBash.FS.Zip do + use VFS.Skeleton + + alias JustBash.FS.Zip + alias VFS.Error + alias VFS.Stat + + def exists?(%Zip{} = zip, path) do + normalized = Zip.__normalize__(path) + {Zip.__file?(zip, normalized) or Zip.__dir?(zip, normalized), zip} + end + + def stat(%Zip{} = zip, path) do + normalized = Zip.__normalize__(path) + + cond do + Zip.__file?(zip, normalized) -> + size = zip |> Zip.__file__(normalized) |> byte_size() + {:ok, Stat.regular(size, zip.mtime, 0o444), zip} + + Zip.__dir?(zip, normalized) -> + {:ok, Stat.directory(zip.mtime, 0o555), zip} + + true -> + {:error, Error.new(:enoent, path: normalized)} + end + end + + def readdir(%Zip{} = zip, path) do + normalized = Zip.__normalize__(path) + + cond do + Zip.__dir?(zip, normalized) -> + {:ok, Zip.__children__(zip, normalized), zip} + + Zip.__file?(zip, normalized) -> + {:error, Error.new(:enotdir, path: normalized)} + + true -> + {:error, Error.new(:enoent, path: normalized)} + end + end + + def stream_read(%Zip{} = zip, path, opts) do + normalized = Zip.__normalize__(path) + + cond do + Zip.__file?(zip, normalized) -> + case VFS.StreamOptions.apply(Zip.__file__(zip, normalized), opts) do + {:ok, stream} -> {:ok, stream, zip} + {:error, kind} -> {:error, Error.new(kind, path: normalized)} + end + + Zip.__dir?(zip, normalized) -> + {:error, Error.new(:eisdir, path: normalized)} + + true -> + {:error, Error.new(:enoent, path: normalized)} + end + end + + def write_file(_zip, path, _content, _opts) do + {:error, Error.new(:erofs, path: Zip.__normalize__(path))} + end + + def mkdir(_zip, path, _opts) do + {:error, Error.new(:erofs, path: Zip.__normalize__(path))} + end + + def rm(_zip, path, _opts) do + {:error, Error.new(:erofs, path: Zip.__normalize__(path))} + end + + def capabilities(_zip), do: MapSet.new([:read]) +end + +defimpl JustBash.FS.POSIX, for: JustBash.FS.Zip do + alias JustBash.FS.Zip + alias VFS.Error + + def lstat(%Zip{} = zip, path), do: VFS.stat(zip, path) + + def readlink(%Zip{} = zip, path) do + normalized = Zip.__normalize__(path) + + if Zip.__file?(zip, normalized) or Zip.__dir?(zip, normalized) do + {:error, Error.new(:einval, path: normalized)} + else + {:error, Error.new(:enoent, path: normalized)} + end + end + + def symlink(_zip, _target, link_path), + do: {:error, Error.new(:erofs, path: Zip.__normalize__(link_path))} + + def link(_zip, _existing_path, new_path), + do: {:error, Error.new(:erofs, path: Zip.__normalize__(new_path))} + + def chmod(_zip, path, _mode), do: {:error, Error.new(:erofs, path: Zip.__normalize__(path))} + + def append_file(_zip, path, _content), + do: {:error, Error.new(:erofs, path: Zip.__normalize__(path))} +end diff --git a/lib/just_bash/interpreter/executor.ex b/lib/just_bash/interpreter/executor.ex index c541069..b122228 100644 --- a/lib/just_bash/interpreter/executor.ex +++ b/lib/just_bash/interpreter/executor.ex @@ -286,8 +286,15 @@ defmodule JustBash.Interpreter.Executor do _stdin ) do {_redir_stdin, non_stdin_redirs} = Redirection.extract_heredoc_stdin(bash, redirs) - {result, new_bash} = Loop.execute_for(bash, variable, words, body, &execute_body/2) - Redirection.apply_redirections(result, new_bash, non_stdin_redirs) + + case Redirection.prepare_redirections(bash, non_stdin_redirs) do + {:ok, bash} -> + {result, new_bash} = Loop.execute_for(bash, variable, words, body, &execute_body/2) + Redirection.apply_redirections(result, new_bash, non_stdin_redirs) + + {:error, result, bash} -> + {result, bash} + end end def execute_command( @@ -298,10 +305,16 @@ defmodule JustBash.Interpreter.Executor do {redir_stdin, non_stdin_redirs} = Redirection.extract_heredoc_stdin(bash, redirs) effective_stdin = redir_stdin || stdin - {result, new_bash} = - Loop.execute_while(bash, condition, body, effective_stdin, &execute_body/2) + case Redirection.prepare_redirections(bash, non_stdin_redirs) do + {:ok, bash} -> + {result, new_bash} = + Loop.execute_while(bash, condition, body, effective_stdin, &execute_body/2) - Redirection.apply_redirections(result, new_bash, non_stdin_redirs) + Redirection.apply_redirections(result, new_bash, non_stdin_redirs) + + {:error, result, bash} -> + {result, bash} + end end def execute_command( @@ -312,10 +325,16 @@ defmodule JustBash.Interpreter.Executor do {redir_stdin, non_stdin_redirs} = Redirection.extract_heredoc_stdin(bash, redirs) effective_stdin = redir_stdin || stdin - {result, new_bash} = - Loop.execute_until(bash, condition, body, effective_stdin, &execute_body/2) + case Redirection.prepare_redirections(bash, non_stdin_redirs) do + {:ok, bash} -> + {result, new_bash} = + Loop.execute_until(bash, condition, body, effective_stdin, &execute_body/2) - Redirection.apply_redirections(result, new_bash, non_stdin_redirs) + Redirection.apply_redirections(result, new_bash, non_stdin_redirs) + + {:error, result, bash} -> + {result, bash} + end end def execute_command(bash, %AST.Case{word: word, items: items}, _stdin) do @@ -324,16 +343,28 @@ defmodule JustBash.Interpreter.Executor do end def execute_command(bash, %AST.Subshell{body: body, redirections: redirs}, _stdin) do - {result, _subshell_bash} = execute_body(bash, body) - # Apply any redirections attached to the subshell - {result, bash} = Redirection.apply_redirections(result, bash, redirs) - {result, bash} + case Redirection.prepare_redirections(bash, redirs) do + {:ok, bash} -> + {result, _subshell_bash} = execute_body(bash, body) + # Apply any redirections attached to the subshell + {result, bash} = Redirection.apply_redirections(result, bash, redirs) + {result, bash} + + {:error, result, bash} -> + {result, bash} + end end def execute_command(bash, %AST.Group{body: body, redirections: redirs}, stdin) do - {result, new_bash} = execute_body_with_stdin(bash, body, stdin) - # Apply any redirections attached to the group - Redirection.apply_redirections(result, new_bash, redirs) + case Redirection.prepare_redirections(bash, redirs) do + {:ok, bash} -> + {result, new_bash} = execute_body_with_stdin(bash, body, stdin) + # Apply any redirections attached to the group + Redirection.apply_redirections(result, new_bash, redirs) + + {:error, result, bash} -> + {result, bash} + end end def execute_command(bash, %AST.FunctionDef{name: name, body: body}, _stdin) do @@ -389,48 +420,16 @@ defmodule JustBash.Interpreter.Executor do {heredoc_stdin, non_heredoc_redirs} = Redirection.extract_heredoc_stdin(temp_bash, redirs) effective_stdin = heredoc_stdin || stdin - {result, exec_bash} = - case Map.get(temp_bash.functions, cmd_name) do - nil -> - JustBash.Telemetry.command_span(cmd_name, expanded_args, fn -> - {result, new_bash} = - case Map.get(temp_bash.commands, cmd_name) do - nil -> - execute_builtin(temp_bash, cmd_name, expanded_args, effective_stdin) - - module -> - execute_custom_command( - temp_bash, - cmd_name, - module, - expanded_args, - effective_stdin - ) - end - - # A JustBash.CLI router stashes the resolved subcommand path here; surface it - # in telemetry and strip it before the result reaches the shell. - {subcommand, result} = Map.pop(result, :__subcommand__) - - stop_metadata = %{ - exit_code: result.exit_code, - bytes_in: byte_size(effective_stdin), - bytes_out: byte_size(result.stdout) + byte_size(result.stderr) - } - - stop_metadata = - if subcommand, - do: Map.put(stop_metadata, :subcommand, subcommand), - else: stop_metadata - - {{result, new_bash}, stop_metadata} - end) - - func_body -> - execute_function(temp_bash, func_body, expanded_args) - end + case Redirection.prepare_redirections(temp_bash, non_heredoc_redirs) do + {:ok, temp_bash} -> + {result, exec_bash} = + execute_prepared_simple_command(temp_bash, cmd_name, expanded_args, effective_stdin) + + Redirection.apply_redirections(result, exec_bash, non_heredoc_redirs) - Redirection.apply_redirections(result, exec_bash, non_heredoc_redirs) + {:error, result, bash} -> + {result, bash} + end rescue e in Expansion.UnsetVariableError -> {%{stdout: "", stderr: "bash: #{Exception.message(e)}\n", exit_code: 1}, bash} @@ -439,6 +438,51 @@ defmodule JustBash.Interpreter.Executor do {%{stdout: "", stderr: "bash: #{Exception.message(e)}\n", exit_code: 1}, bash} end + defp execute_prepared_simple_command(temp_bash, cmd_name, expanded_args, effective_stdin) do + case Map.get(temp_bash.functions, cmd_name) do + nil -> + execute_external_or_builtin(temp_bash, cmd_name, expanded_args, effective_stdin) + + func_body -> + execute_function(temp_bash, func_body, expanded_args) + end + end + + defp execute_external_or_builtin(temp_bash, cmd_name, expanded_args, effective_stdin) do + JustBash.Telemetry.command_span(cmd_name, expanded_args, fn -> + {result, new_bash} = dispatch_command(temp_bash, cmd_name, expanded_args, effective_stdin) + + # A JustBash.CLI router stashes the resolved subcommand path here; surface it + # in telemetry and strip it before the result reaches the shell. + {subcommand, result} = Map.pop(result, :__subcommand__) + + stop_metadata = command_stop_metadata(result, effective_stdin, subcommand) + {{result, new_bash}, stop_metadata} + end) + end + + defp dispatch_command(temp_bash, cmd_name, expanded_args, effective_stdin) do + case Map.get(temp_bash.commands, cmd_name) do + nil -> + execute_builtin(temp_bash, cmd_name, expanded_args, effective_stdin) + + module -> + execute_custom_command(temp_bash, cmd_name, module, expanded_args, effective_stdin) + end + end + + defp command_stop_metadata(result, effective_stdin, subcommand) do + metadata = %{ + exit_code: result.exit_code, + bytes_in: byte_size(effective_stdin), + bytes_out: byte_size(result.stdout) + byte_size(result.stderr) + } + + if subcommand, + do: Map.put(metadata, :subcommand, subcommand), + else: metadata + end + # --- If/Case Execution --- defp execute_if(bash, [], else_body) do diff --git a/lib/just_bash/interpreter/executor/redirection.ex b/lib/just_bash/interpreter/executor/redirection.ex index 71aebcc..d54ecf7 100644 --- a/lib/just_bash/interpreter/executor/redirection.ex +++ b/lib/just_bash/interpreter/executor/redirection.ex @@ -48,6 +48,26 @@ defmodule JustBash.Interpreter.Executor.Redirection do apply_redirections(result, bash, rest) end + @doc """ + Prepare output redirections before command execution. + + Shells open redirection targets left-to-right before running the command. The + actual command output is still written by `apply_redirections/3` after command + execution, but this preflight step performs the observable open-time effects + that matter for correctness: truncating `>` targets, creating `>>` targets, + and failing before the command runs when a target is not writable. + """ + @spec prepare_redirections(JustBash.t(), [AST.Redirection.t()]) :: + {:ok, JustBash.t()} | {:error, result(), JustBash.t()} + def prepare_redirections(bash, redirections) do + Enum.reduce_while(redirections, {:ok, bash}, fn redir, {:ok, current_bash} -> + case prepare_redirection(current_bash, redir) do + {:ok, next_bash} -> {:cont, {:ok, next_bash}} + {:error, result, next_bash} -> {:halt, {:error, result, next_bash}} + end + end) + end + @doc """ Extract heredoc or here-string content as stdin. Returns `{stdin_content, non_heredoc_redirections}`. @@ -81,6 +101,13 @@ defmodule JustBash.Interpreter.Executor.Redirection do apply_classified_redirection(redir_type, result, bash, resolved) end + defp prepare_redirection(bash, %AST.Redirection{fd: fd, operator: operator, target: target}) do + target_path = Expansion.expand_redirect_target(bash, target) + resolved = FS.resolve_path(bash.cwd, target_path) + redir_type = classify_redirection(fd, operator, target_path) + prepare_classified_redirection(redir_type, bash, resolved) + end + @spec classify_redirection(non_neg_integer(), atom(), String.t()) :: redir_type() # Combined redirection &> must be checked before /dev/null catch-all defp classify_redirection(_fd, :"&>", "/dev/null"), do: :combined_dev_null @@ -162,6 +189,24 @@ defmodule JustBash.Interpreter.Executor.Redirection do {result, bash} end + defp prepare_classified_redirection(type, bash, resolved) + when type in [:stdout_write, :stderr_write, :combined_write] do + case FS.write_file(bash.fs, resolved, "") do + {:ok, fs} -> {:ok, %{bash | fs: fs}} + {:error, error} -> {:error, redirection_error_result(resolved, error), bash} + end + end + + defp prepare_classified_redirection(type, bash, resolved) + when type in [:stdout_append, :stderr_append, :combined_append] do + case FS.append_file(bash.fs, resolved, "") do + {:ok, fs} -> {:ok, %{bash | fs: fs}} + {:error, error} -> {:error, redirection_error_result(resolved, error), bash} + end + end + + defp prepare_classified_redirection(_type, bash, _resolved), do: {:ok, bash} + defp write_to_file(bash, path, content, result, stream) do Limit.check_file_size!(bash, content) @@ -172,7 +217,7 @@ defmodule JustBash.Interpreter.Executor.Redirection do {:error, error} -> error_msg = format_redirection_error(path, error) - {%{result | stderr: result.stderr <> error_msg, exit_code: 1}, bash} + {%{result | stdout: "", stderr: error_msg, exit_code: 1}, bash} end end @@ -186,7 +231,7 @@ defmodule JustBash.Interpreter.Executor.Redirection do {:error, error} -> error_msg = format_redirection_error(path, error) - {%{result | stderr: result.stderr <> error_msg, exit_code: 1}, bash} + {%{result | stdout: "", stderr: error_msg, exit_code: 1}, bash} end end @@ -199,7 +244,7 @@ defmodule JustBash.Interpreter.Executor.Redirection do {:error, error} -> error_msg = format_redirection_error(path, error) - {%{result | stderr: error_msg, exit_code: 1}, bash} + {%{result | stdout: "", stderr: error_msg, exit_code: 1}, bash} end end @@ -212,7 +257,7 @@ defmodule JustBash.Interpreter.Executor.Redirection do {:error, error} -> error_msg = format_redirection_error(path, error) - {%{result | stderr: error_msg, exit_code: 1}, bash} + {%{result | stdout: "", stderr: error_msg, exit_code: 1}, bash} end end @@ -236,6 +281,10 @@ defmodule JustBash.Interpreter.Executor.Redirection do "bash: #{path}: #{FS.strerror(error)}\n" end + defp redirection_error_result(path, error) do + %{stdout: "", stderr: format_redirection_error(path, error), exit_code: 1} + end + # --- Stdin Content Extraction --- # Here-string: <<< "string" diff --git a/lib/just_bash/network.ex b/lib/just_bash/network.ex index 2fd3909..641bbd1 100644 --- a/lib/just_bash/network.ex +++ b/lib/just_bash/network.ex @@ -117,7 +117,9 @@ defmodule JustBash.Network do nil -> {:response, response} - redirect_url -> + redirect_location -> + redirect_url = resolve_redirect_url(request.url, redirect_location) + case validate_access(bash, redirect_url, command_name) do {:error, msg} -> {:error, %{reason: msg}} @@ -146,6 +148,12 @@ defmodule JustBash.Network do # Default on_redirect — returns the request unchanged (appropriate for GET-only commands). defp identity_redirect(_status, request), do: request + defp resolve_redirect_url(current_url, location) do + current_url + |> URI.merge(location) + |> URI.to_string() + end + defp scheme_allowed?(url, allow_insecure) do case URI.parse(url).scheme do "https" -> true diff --git a/mix.exs b/mix.exs index 146e9ee..4cf88ec 100644 --- a/mix.exs +++ b/mix.exs @@ -81,7 +81,7 @@ defmodule JustBash.MixProject do Core: [JustBash], Parser: [JustBash.Parser, JustBash.Parser.Lexer, JustBash.Parser.WordParts], AST: [JustBash.AST], - Filesystem: [JustBash.FS, JustBash.FS.Memory, JustBash.FS.POSIX], + Filesystem: [JustBash.FS, JustBash.FS.Memory, JustBash.FS.POSIX, JustBash.FS.Zip], Utilities: [JustBash.Arithmetic] ] ] diff --git a/test/just_bash/fs_to_tree_test.exs b/test/just_bash/fs_to_tree_test.exs new file mode 100644 index 0000000..db46ff4 --- /dev/null +++ b/test/just_bash/fs_to_tree_test.exs @@ -0,0 +1,117 @@ +defmodule JustBash.FSToTreeTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias JustBash.FS + + describe "to_tree/2" do + test "empty filesystem yields an empty tree" do + assert {:ok, %{}, _fs} = FS.to_tree(FS.new()) + end + + test "returns every regular file keyed by absolute path" do + files = %{ + "/a.txt" => "alpha", + "/notes/deep/nested/b.txt" => "beta", + "/empty" => "", + "/bin/blob" => <<0, 255, 254, 1>>, + "/data/café/naïve résumé.txt" => "unicode paths", + "/with spaces/file name.txt" => "spaces" + } + + assert {:ok, tree, _fs} = FS.to_tree(FS.new(files)) + assert tree == files + end + + test "round-trips through new/1" do + files = %{"/a/b.txt" => "one", "/c.bin" => <<7, 0, 9>>} + + assert {:ok, tree, _fs} = FS.to_tree(FS.new(files)) + assert {:ok, ^tree, _fs} = FS.to_tree(FS.new(tree)) + end + + test "captures content but not modes" do + fs = FS.new(%{"/bin/script" => %{content: "#!/bin/bash", mode: 0o755}}) + assert {:ok, %{"/bin/script" => "#!/bin/bash"}, _fs} = FS.to_tree(fs) + end + + test "omits directories, including empty ones" do + {:ok, fs} = FS.mkdir(FS.new(%{"/kept.txt" => "x"}), "/empty/dir", parents: true) + assert {:ok, %{"/kept.txt" => "x"}, _fs} = FS.to_tree(fs) + end + + test "scopes to a subtree root, keeping absolute keys" do + fs = FS.new(%{"/data/in.txt" => "in", "/other/out.txt" => "out"}) + assert {:ok, %{"/data/in.txt" => "in"}, _fs} = FS.to_tree(fs, "/data") + end + + test "a file root yields a single-entry tree" do + fs = FS.new(%{"/only.txt" => "just me", "/other.txt" => "not me"}) + assert {:ok, %{"/only.txt" => "just me"}, _fs} = FS.to_tree(fs, "/only.txt") + end + + test "a missing root yields an empty tree" do + assert {:ok, %{}, _fs} = FS.to_tree(FS.new(%{"/a.txt" => "x"}), "/nope") + end + + test "materializes symlinks as their target's content" do + {:ok, fs} = FS.symlink(FS.new(%{"/target.txt" => "real"}), "/target.txt", "/link.txt") + + assert {:ok, tree, _fs} = FS.to_tree(fs) + assert tree == %{"/target.txt" => "real", "/link.txt" => "real"} + end + + test "omits dangling symlinks" do + {:ok, fs} = FS.symlink(FS.new(%{"/a.txt" => "x"}), "/gone", "/dangling") + assert {:ok, %{"/a.txt" => "x"}, _fs} = FS.to_tree(fs) + end + + test "captures files from every mount in the table" do + fs = + VFS.mount( + FS.new(%{"/local.txt" => "local"}), + "/mnt", + FS.Memory.new(%{"/x.txt" => "mounted"}) + ) + + assert {:ok, tree, _fs} = FS.to_tree(fs) + assert tree == %{"/local.txt" => "local", "/mnt/x.txt" => "mounted"} + end + + property "any conflict-free file map round-trips exactly" do + check all(files <- file_map()) do + assert {:ok, tree, _fs} = FS.to_tree(FS.new(files)) + assert tree == files + end + end + end + + defp file_map do + segment = + string(:printable, min_length: 1, max_length: 8) + |> filter(&(not String.contains?(&1, "/") and &1 not in [".", ".."])) + + path = + segment + |> list_of(min_length: 1, max_length: 4) + |> map(&("/" <> Enum.join(&1, "/"))) + + map_of(path, binary(), max_length: 8) + |> map(&drop_ancestor_conflicts/1) + end + + # A file cannot live below another file, so drop any path with an + # already-kept path as a directory ancestor (or vice versa). + defp drop_ancestor_conflicts(files) do + files + |> Enum.sort_by(fn {path, _} -> path end) + |> Enum.reduce(%{}, fn {path, content}, kept -> + conflict? = + Enum.any?(Map.keys(kept), fn other -> + String.starts_with?(path, other <> "/") or String.starts_with?(other, path <> "/") + end) + + if conflict?, do: kept, else: Map.put(kept, path, content) + end) + end +end diff --git a/test/just_bash/fs_zip_test.exs b/test/just_bash/fs_zip_test.exs new file mode 100644 index 0000000..76c233b --- /dev/null +++ b/test/just_bash/fs_zip_test.exs @@ -0,0 +1,263 @@ +defmodule JustBash.FS.ZipTest do + use ExUnit.Case, async: true + + alias JustBash.FS.Zip + alias VFS.Error + + defmodule ZipHttpClient do + @moduledoc false + @behaviour JustBash.HttpClient + + @impl true + def request(%{url: "https://archives.example/fixture.zip", method: :get}) do + {:ok, + %{ + status: 200, + headers: %{}, + body: zip_bytes([{~c"README.md", "from url\n"}]) + }} + end + + def request(%{url: "https://archives.example/redirect.zip", method: :get}) do + {:ok, + %{ + status: 302, + headers: %{"location" => ["https://archives.example/fixture.zip"]}, + body: "" + }} + end + + def request(%{url: "https://archives.example/relative-redirect.zip", method: :get}) do + {:ok, + %{ + status: 302, + headers: %{"location" => ["/fixture.zip"]}, + body: "" + }} + end + + def request(%{method: :get}) do + {:ok, %{status: 404, headers: %{}, body: "not found"}} + end + + defp zip_bytes(entries) do + {:ok, {_name, bytes}} = :zip.create(~c"fixture.zip", entries, [:memory]) + bytes + end + end + + describe "from_binary/2" do + test "builds a read-only VFS backend from zip bytes" do + {:ok, zip} = + [ + {~c"README.md", "hello\n"}, + {~c"docs/guide.txt", "guide\n"}, + {~c"empty/", ""} + ] + |> zip_bytes() + |> Zip.from_binary() + + assert {:ok, ["README.md", "docs", "empty"], ^zip} = VFS.readdir(zip, "/") + assert {:ok, ["guide.txt"], ^zip} = VFS.readdir(zip, "/docs") + assert {:ok, [], ^zip} = VFS.readdir(zip, "/empty") + assert {:ok, "hello\n", ^zip} = VFS.read_file(zip, "/README.md") + + assert {:error, %Error{kind: :erofs, path: "/README.md"}} = + VFS.write_file(zip, "/README.md", "x") + + assert VFS.Mountable.capabilities(zip) == MapSet.new([:read]) + end + + test "handles UTF-8 entry paths" do + {:ok, zip} = Zip.from_binary(zip_bytes([{~c"café/", ""}, {~c"café/menu.txt", "coffee\n"}])) + + assert {:ok, ["café"], ^zip} = VFS.readdir(zip, "/") + assert {:ok, ["menu.txt"], ^zip} = VFS.readdir(zip, "/café") + assert {:ok, "coffee\n", ^zip} = VFS.read_file(zip, "/café/menu.txt") + end + + test "returns precise VFS error kinds" do + {:ok, zip} = Zip.from_binary(zip_bytes([{~c"dir/file.txt", "content"}])) + + assert {:error, %Error{kind: :enotdir, path: "/dir/file.txt"}} = + VFS.readdir(zip, "/dir/file.txt") + + assert {:error, %Error{kind: :enoent, path: "/missing"}} = VFS.readdir(zip, "/missing") + assert {:error, %Error{kind: :eisdir, path: "/dir"}} = VFS.read_file(zip, "/dir") + end + + test "honors stream read options" do + {:ok, zip} = Zip.from_binary(zip_bytes([{~c"lines.txt", "one\ntwo\nthree\n"}])) + + assert {:ok, stream, ^zip} = VFS.stream_read(zip, "/lines.txt", line_range: {2, 2}) + assert Enum.join(stream) == "two" + + assert {:error, %Error{kind: :einval, path: "/lines.txt"}} = + VFS.stream_read(zip, "/lines.txt", line_range: {3, 2}) + end + + test "rejects unsafe archive entry paths" do + assert {:error, %Error{kind: :einval}} = + Zip.from_binary(renamed_zip_bytes(~c"safe/entryx", "../evil.txt")) + + assert {:error, %Error{kind: :einval}} = + Zip.from_binary(renamed_zip_bytes(~c"sabsolute.txt", "/absolute.txt")) + + assert {:error, %Error{kind: :einval}} = + Zip.from_binary(renamed_zip_bytes(~c"a/xb.txt", "a//b.txt")) + + assert {:error, %Error{kind: :einval}} = + Zip.from_binary(renamed_zip_bytes(~c"a-b.txt", "a\\b.txt")) + + assert {:error, %Error{kind: :einval}} = + Zip.from_binary(renamed_zip_bytes(~c"a/x", "a//")) + end + + test "rejects file directory collisions" do + assert {:error, %Error{kind: :einval, path: "/a"}} = + Zip.from_binary(zip_bytes([{~c"a/", ""}, {~c"a", "file"}])) + + assert {:error, %Error{kind: :einval}} = + Zip.from_binary(zip_bytes([{~c"a", "file"}, {~c"a/b", "child"}])) + + assert {:error, %Error{kind: :einval, path: "/a"}} = + Zip.from_binary(zip_bytes([{~c"a/b", "child"}, {~c"a", "file"}])) + end + + test "enforces construction resource limits before mounting" do + bytes = zip_bytes([{~c"one.txt", "12345"}, {~c"two.txt", "67890"}]) + + assert {:error, %Error{kind: :einval}} = Zip.from_binary(bytes, max_archive_bytes: 1) + assert {:error, %Error{kind: :einval}} = Zip.from_binary(bytes, max_uncompressed_bytes: 9) + assert {:error, %Error{kind: :einval}} = Zip.from_binary(bytes, max_entries: 1) + assert {:error, %Error{kind: :einval}} = Zip.from_binary(bytes, max_path_bytes: 3) + + assert {:ok, %Zip{}} = + Zip.from_binary(bytes, + max_archive_bytes: byte_size(bytes), + max_uncompressed_bytes: 10, + max_entries: 2, + max_path_bytes: 7 + ) + end + end + + describe "from_url/3" do + test "fetches through JustBash network policy and http client" do + bash = + JustBash.new( + network: %{enabled: true, allow_list: ["archives.example"]}, + http_client: ZipHttpClient + ) + + assert {:ok, zip} = Zip.from_url(bash, "https://archives.example/fixture.zip") + assert {:ok, "from url\n", ^zip} = VFS.read_file(zip, "/README.md") + end + + test "follows redirects through the same policy" do + bash = + JustBash.new( + network: %{enabled: true, allow_list: ["archives.example"]}, + http_client: ZipHttpClient + ) + + assert {:ok, zip} = Zip.from_url(bash, "https://archives.example/redirect.zip") + assert {:ok, "from url\n", ^zip} = VFS.read_file(zip, "/README.md") + end + + test "resolves relative redirects through the same policy" do + bash = + JustBash.new( + network: %{enabled: true, allow_list: ["archives.example"]}, + http_client: ZipHttpClient + ) + + assert {:ok, zip} = Zip.from_url(bash, "https://archives.example/relative-redirect.zip") + assert {:ok, "from url\n", ^zip} = VFS.read_file(zip, "/README.md") + end + + test "refuses URLs blocked by JustBash network policy" do + bash = + JustBash.new( + network: %{enabled: true, allow_list: ["allowed.example"]}, + http_client: ZipHttpClient + ) + + assert {:error, %Error{kind: :eacces}} = + Zip.from_url(bash, "https://archives.example/fixture.zip") + end + + test "validates timeout option before HTTP fetch" do + bash = JustBash.new(network: %{enabled: true, allow_list: :all}, http_client: ZipHttpClient) + + assert {:error, %Error{kind: :einval}} = + Zip.from_url(bash, "https://archives.example/fixture.zip", timeout: 0) + end + end + + describe "JustBash.mount/3 integration" do + test "bash commands can read and copy out of a mounted zip" do + {:ok, zip} = + Zip.from_binary( + zip_bytes([{~c"README.md", "hello\n"}, {~c"src/app.ex", "defmodule App do end\n"}]) + ) + + bash = JustBash.new() |> JustBash.mount("/zip", zip) + + {result, bash} = + JustBash.exec(bash, "ls /zip && cat /zip/README.md && cp /zip/src/app.ex /tmp/app.ex") + + assert result.exit_code == 0 + assert result.stdout == "README.md\nsrc\nhello\n" + + {result, _bash} = JustBash.exec(bash, "cat /tmp/app.ex") + assert result.stdout == "defmodule App do end\n" + end + + test "bash mutations into the mounted zip fail read-only" do + {:ok, zip} = Zip.from_binary(zip_bytes([{~c"README.md", "hello\n"}])) + bash = JustBash.new() |> JustBash.mount("/zip", zip) + + {result, _bash} = JustBash.exec(bash, "echo nope > /zip/new.txt") + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr =~ "Read-only file system" + end + + test "redirection failures happen before command side effects" do + {:ok, zip} = Zip.from_binary(zip_bytes([{~c"README.md", "hello\n"}])) + bash = JustBash.new() |> JustBash.mount("/zip", zip) + + {result, bash} = JustBash.exec(bash, "touch /tmp/side-effect > /zip/new.txt") + assert result.exit_code == 1 + assert result.stderr =~ "Read-only file system" + + {exists?, _fs} = JustBash.FS.exists?(bash.fs, "/tmp/side-effect") + refute exists? + end + + test "POSIX mutations on the mounted zip fail read-only" do + {:ok, zip} = Zip.from_binary(zip_bytes([{~c"README.md", "hello\n"}])) + bash = JustBash.new() |> JustBash.mount("/zip", zip) + + {result, _bash} = JustBash.exec(bash, "chmod 600 /zip/README.md") + assert result.exit_code == 1 + assert result.stderr =~ "Read-only file system" + end + end + + defp zip_bytes(entries) do + {:ok, {_name, bytes}} = :zip.create(~c"fixture.zip", entries, [:memory]) + bytes + end + + defp renamed_zip_bytes(safe_name, unsafe_name) do + safe = to_string(safe_name) + unsafe = to_string(unsafe_name) + true = byte_size(safe) == byte_size(unsafe) + + [{String.to_charlist(safe), "x"}] + |> zip_bytes() + |> :binary.replace(safe, unsafe, [:global]) + end +end