diff --git a/.gitignore b/.gitignore index 5962ecc..e76db26 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ bash-*.tar .grepai/ docs/plans +.claude diff --git a/README.md b/README.md index 79546dc..3b2e7cd 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ power - call Elixir functions directly from Bash pipelines. | **Full I/O support** | Redirections, pipes, heredocs, process substitution | | **Job control** | Background jobs, fg/bg switching, signal handling | | **Streaming output** | Process stdout/stderr incrementally with configurable sinks | +| **Sandboxing** | Pluggable virtual filesystem and restricted mode for isolated execution | ## Usage @@ -217,6 +218,26 @@ Bash.stdout(result) #=> "HELLO\n" ``` +### Sandboxing + +Run scripts against a virtual filesystem to isolate them from the host OS: + +```elixir +# Provide a custom filesystem adapter +{:ok, session} = Bash.Session.new(filesystem: {MyVFS, vfs_config}) + +# Builtins (echo, test, cd, etc.) operate on the VFS. +# External commands (ls, cat, grep, etc.) are automatically blocked — +# restricted mode is enforced whenever a non-LocalDisk filesystem is used. +{:ok, result, _} = Bash.run("test -f /data/input.csv && echo found", session) +``` + +To use restricted mode with the real filesystem (block external commands without a VFS): + +```elixir +{:ok, session} = Bash.Session.new(options: %{restricted: true}) +``` + ## Supported Features ### Control Flow diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0dc32a5..e4f06e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,6 +12,8 @@ The shell is built around GenServer-based session management with a multi-layer - **Coproc** - GenServer managing coprocess I/O in external or internal mode - **ProcessSubst** - GenServer managing process substitution FIFOs - **Sink** - Pluggable output destinations +- **ExternalProcess** - Centralized OS process gateway (restricted mode enforcement) +- **Filesystem** - Pluggable filesystem behaviour (sandboxed I/O) ## Supervision Tree @@ -87,12 +89,15 @@ stateDiagram-v2 | `special_vars` | Map | `$?`, `$!`, `$$`, `$0`, `$_` | | `positional_params` | list | Function argument stack | | `file_descriptors` | Map | FD number to pid or `{:coproc, pid, :read \| :write}` | +| `options` | Map | Shell options including `restricted`, `hashall`, `braceexpand` | +| `filesystem` | `{module, config}` | Pluggable filesystem adapter (default: `{LocalDisk, nil}`) | **Internal fields (opaque):** - `job_supervisor`, `output_collector` - Process pids - `stdout_sink`, `stderr_sink` - Sink functions - `executions`, `current`, `is_pipeline_tail` - Execution tracking - `file_descriptors` - Routes FD reads/writes to coproc GenServers or StringIO devices +- `filesystem` - Dispatches all file I/O through the pluggable adapter ### OutputCollector GenServer @@ -466,6 +471,57 @@ Background job state (output flows to OutputCollector via sinks): } ``` +## Sandboxing + +### ExternalProcess + +Centralized gateway wrapping all user-facing OS process spawning (`ExCmd.Process`, `ExCmd.stream`, `System.cmd`). When restricted mode is active, all calls return `{:error, :restricted}`. + +```mermaid +graph TD + CP[CommandPort] --> EP[ExternalProcess] + JP[JobProcess] --> EP + CO[Coproc] --> EP + PL[Pipeline] --> EP + CM[Command builtin] --> EP + EP -->|restricted?| ERR["{:error, :restricted}"] + EP -->|allowed| EX[ExCmd / System.cmd] +``` + +Internal plumbing (signal delivery via `kill`, hostname/uname lookup, `mkfifo`) bypasses this gateway. + +Enabled via `Bash.Session.new(restricted: true)` or automatically when a non-LocalDisk filesystem is provided (see below). The flag is immutable — `set` cannot toggle it, and `shopt restricted_shell` reflects actual state read-only. + +### Filesystem + +Behaviour and dispatcher for pluggable filesystem implementations. Stored in session state as a `{module, config}` tuple. All filesystem access (57+ call sites across 14 files) dispatches through `Bash.Filesystem` instead of calling `File.*` directly. + +```mermaid +graph TD + subgraph Callers + T[Test builtin] + CD[cd/pwd] + SRC[source] + CMD[Command/Pipeline] + R[Redirections] + G[Glob expansion] + end + + Callers --> FS[Bash.Filesystem] + FS --> LD[LocalDisk — default] + FS --> VFS[Custom VFS adapter] +``` + +The default adapter `Bash.Filesystem.LocalDisk` delegates to Elixir's `File` and Erlang's `:file` modules. Custom adapters implement the `Bash.Filesystem` behaviour (16 callbacks). + +Enabled via `Bash.Session.new(filesystem: {MyVFS, config})`. Inherits to child sessions. + +### Auto-enforcement + +When a non-LocalDisk filesystem is provided, `Session.init/1` automatically enables restricted mode. This prevents a split-brain state where builtins operate on the virtual filesystem while external commands (`ls`, `cat`, etc.) bypass it and hit the real OS. With auto-enforcement, there is no way to create a session that uses a VFS for builtins but allows unrestricted external process execution. + +`Bash.Filesystem.local_disk?/1` is the predicate used to decide whether enforcement applies. + ## Design Patterns ### Async Output Collection diff --git a/lib/bash/ast/command.ex b/lib/bash/ast/command.ex index 87a698b..10ed92f 100644 --- a/lib/bash/ast/command.ex +++ b/lib/bash/ast/command.ex @@ -398,7 +398,8 @@ defmodule Bash.AST.Command do @doc false def process_input_redirects(redirects, _session_state, default_stdin) - when redirects in [nil, []], do: default_stdin + when redirects in [nil, []], + do: default_stdin def process_input_redirects(redirects, session_state, default_stdin) do redirects @@ -537,17 +538,17 @@ defmodule Bash.AST.Command do append = dir == :append # Check noclobber (set -C): cannot overwrite existing file with > - if noclobber && !append && File.exists?(file_path) do + if noclobber && !append && Bash.Filesystem.exists?(session_state.filesystem, file_path) do error_msg = "bash: #{file_path}: cannot overwrite existing file\n" {:error, error_msg, state} else - create_file_sink_for_redirect(file_path, append, fd, state) + create_file_sink_for_redirect(file_path, append, fd, state, session_state) end end - defp create_file_sink_for_redirect(file_path, append, fd, state) do + defp create_file_sink_for_redirect(file_path, append, fd, state, session_state) do # Ensure parent directory exists - file_path |> Path.dirname() |> File.mkdir_p() + file_path |> Path.dirname() |> then(&Bash.Filesystem.mkdir_p(session_state.filesystem, &1)) # Set stream_type based on which fd this sink is for # This matters when FD duplication re-tags chunks (e.g., 1>&2 sends :stderr to this sink) @@ -558,7 +559,12 @@ defmodule Bash.AST.Command do _ -> :stdout end - {sink, close_fn} = Sink.file(file_path, append: append, stream_type: stream_type) + {sink, close_fn} = + Sink.file(file_path, + append: append, + stream_type: stream_type, + filesystem: session_state.filesystem + ) new_state = case fd do @@ -672,7 +678,7 @@ defmodule Bash.AST.Command do ) do file_word |> resolve_redirect_path(session_state) - |> File.read() + |> then(&Bash.Filesystem.read(session_state.filesystem, &1)) |> case do {:ok, content} -> content {:error, _} -> default_stdin @@ -881,7 +887,14 @@ defmodule Bash.AST.Command do # This ensures external command output is interleaved correctly with builtin output combined_sink = build_combined_sink(session_state) - base_opts = [cd: session_state.working_dir, env: env, stdin: stdin, timeout: 5000] + base_opts = [ + cd: session_state.working_dir, + env: env, + stdin: stdin, + timeout: 5000, + restricted: Bash.ExternalProcess.restricted?(session_state) + ] + opts = if combined_sink, do: [{:sink, combined_sink} | base_opts], else: base_opts # Resolve command through hash table or PATH @@ -926,7 +939,8 @@ defmodule Bash.AST.Command do case Map.get(hash_table, command_name) do {hit_count, cached_path} -> # Found in hash table - verify path still exists - if File.exists?(cached_path) and not File.dir?(cached_path) do + if Bash.Filesystem.exists?(session_state.filesystem, cached_path) and + not Bash.Filesystem.dir?(session_state.filesystem, cached_path) do # Path valid - increment hit count and use cached path {cached_path, %{hash_updates: %{command_name => {hit_count + 1, cached_path}}}} else @@ -979,7 +993,8 @@ defmodule Bash.AST.Command do Enum.find_value(path_dirs, fn dir -> full_path = Path.join(dir, command_name) - if File.exists?(full_path) and not File.dir?(full_path) do + if Bash.Filesystem.exists?(session_state.filesystem, full_path) and + not Bash.Filesystem.dir?(session_state.filesystem, full_path) do full_path end end) @@ -1018,7 +1033,14 @@ defmodule Bash.AST.Command do # Create a combined sink that routes stdout/stderr to the session's sinks combined_sink = build_combined_sink(session_state) - base_opts = [cd: session_state.working_dir, env: env, stdin: stdin, timeout: 5000] + base_opts = [ + cd: session_state.working_dir, + env: env, + stdin: stdin, + timeout: 5000, + restricted: Bash.ExternalProcess.restricted?(session_state) + ] + opts = if combined_sink, do: [{:sink, combined_sink} | base_opts], else: base_opts CommandPort.execute("bash", ["-c", full_cmd], opts) diff --git a/lib/bash/ast/helpers.ex b/lib/bash/ast/helpers.ex index b253cc1..eaed82a 100644 --- a/lib/bash/ast/helpers.ex +++ b/lib/bash/ast/helpers.ex @@ -987,7 +987,7 @@ defmodule Bash.AST.Helpers do {Path.join(session_state.working_dir, pattern), false, has_dot} end - case Path.wildcard(glob_path, match_dot: false) do + case Bash.Filesystem.wildcard(session_state.filesystem, glob_path, match_dot: false) do [] -> pattern @@ -1027,7 +1027,7 @@ defmodule Bash.AST.Helpers do listing_dir = if is_absolute, do: dir, else: Path.join(session_state.working_dir, dir || "") - case File.ls(listing_dir) do + case Bash.Filesystem.ls(session_state.filesystem, listing_dir) do {:ok, entries} -> matches = entries @@ -1077,7 +1077,7 @@ defmodule Bash.AST.Helpers do Path.join(session_state.working_dir, dir) end - if File.dir?(full_dir) do + if Bash.Filesystem.dir?(session_state.filesystem, full_dir) do {is_absolute, has_dot_prefix, dir, file} else # Directory part contains glob chars, fall back to standard @@ -1379,9 +1379,18 @@ defmodule Bash.AST.Helpers do _result = Executor.execute(ast, subst_session, nil) # Extract stdout from the temporary collector - {stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector) + {stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector) GenServer.stop(temp_collector, :normal) + stderr_output = IO.iodata_to_binary(stderr_iodata) + + if stderr_output != "" do + case Map.get(session_state, :stderr_sink) do + sink when is_function(sink) -> sink.({:stderr, stderr_output}) + _ -> :ok + end + end + # Convert iodata to string and trim trailing newline (bash behavior) IO.iodata_to_binary(stdout_iodata) |> String.trim_trailing("\n") diff --git a/lib/bash/ast/pipeline.ex b/lib/bash/ast/pipeline.ex index 2ba2c3f..e3bc762 100644 --- a/lib/bash/ast/pipeline.ex +++ b/lib/bash/ast/pipeline.ex @@ -24,6 +24,7 @@ defmodule Bash.AST.Pipeline do alias Bash.AST.Helpers alias Bash.Builtin alias Bash.Executor + alias Bash.ExternalProcess alias Bash.OutputCollector alias Bash.Sink alias Bash.Variable @@ -291,9 +292,18 @@ defmodule Bash.AST.Pipeline do end # Extract output from the temporary collector - {stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector) + {stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector) GenServer.stop(temp_collector, :normal) + stderr_output = IO.iodata_to_binary(stderr_iodata) + + if stderr_output != "" do + case Map.get(session_state, :stderr_sink) do + sink when is_function(sink) -> sink.({:stderr, stderr_output}) + _ -> :ok + end + end + output = IO.iodata_to_binary(stdout_iodata) {exit_code, env_updates} = result @@ -488,9 +498,18 @@ defmodule Bash.AST.Pipeline do {result.exit_code || 0, %{}} end - {stdout_iodata, _stderr_iodata} = OutputCollector.flush_split(temp_collector) + {stdout_iodata, stderr_iodata} = OutputCollector.flush_split(temp_collector) GenServer.stop(temp_collector, :normal) + stderr_output = IO.iodata_to_binary(stderr_iodata) + + if stderr_output != "" do + case Map.get(session_state, :stderr_sink) do + sink when is_function(sink) -> sink.({:stderr, stderr_output}) + _ -> :ok + end + end + output = IO.iodata_to_binary(stdout_iodata) {exit_code, env_updates} = result @@ -514,6 +533,8 @@ defmodule Bash.AST.Pipeline do # Check if a single command is external (not a builtin or function) and has no redirects. # Commands with redirects need sequential execution to handle the redirect logic. + defp external_command?(_command, %{options: %{restricted: true}}), do: false + defp external_command?(%AST.Command{name: name, redirects: redirects}, session_state) do # Commands with redirects can't use simple streaming if redirects != [] do @@ -625,13 +646,21 @@ defmodule Bash.AST.Pipeline do # Build nested ExCmd.stream calls from innermost (first command) to outermost (last command) defp build_stream_pipeline([cmd], stdin, session_state) do {name, args, env} = resolve_external_command(cmd, session_state) + restricted = ExternalProcess.restricted?(session_state) - ExCmd.stream([name | args], + opts = [ input: stdin, cd: session_state.working_dir, env: env, stderr: :redirect_to_stdout - ) + ] + + case ExternalProcess.stream([name | args], opts, restricted) do + # Safety net: external_command?/2 returns false in restricted mode, + # so this branch should be unreachable. Returns empty stream as a fallback. + {:error, :restricted} -> Stream.map([], & &1) + stream -> stream + end end defp build_stream_pipeline([cmd | rest], stdin, session_state) do @@ -648,13 +677,21 @@ defmodule Bash.AST.Pipeline do end) {name, args, env} = resolve_external_command(cmd, session_state) + restricted = ExternalProcess.restricted?(session_state) - ExCmd.stream([name | args], + opts = [ input: filtered_upstream, cd: session_state.working_dir, env: env, stderr: :redirect_to_stdout - ) + ] + + case ExternalProcess.stream([name | args], opts, restricted) do + # Safety net: external_command?/2 returns false in restricted mode, + # so this branch should be unreachable. Returns empty stream as a fallback. + {:error, :restricted} -> Stream.map([], & &1) + stream -> stream + end end # Resolve command name, args, and environment from AST diff --git a/lib/bash/ast/while_loop.ex b/lib/bash/ast/while_loop.ex index 58c0e29..d9e130c 100644 --- a/lib/bash/ast/while_loop.ex +++ b/lib/bash/ast/while_loop.ex @@ -390,10 +390,16 @@ defmodule Bash.AST.WhileLoop do nil %AST.Redirect{direction: :input, target: {:file, file_word}} -> - # Read from file (including process substitution results via /dev/fd/N) - file_path = Bash.AST.Helpers.word_to_string(file_word, session_state) - - case File.read(file_path) do + file_path = + file_word + |> Helpers.word_to_string(session_state) + |> then(fn p -> + if Path.type(p) == :relative, + do: Path.join(session_state.working_dir, p), + else: p + end) + + case Bash.Filesystem.read(session_state.filesystem, file_path) do {:ok, content} -> content {:error, _} -> nil end diff --git a/lib/bash/builtin/cd.ex b/lib/bash/builtin/cd.ex index 4b6f1ac..27df086 100644 --- a/lib/bash/builtin/cd.ex +++ b/lib/bash/builtin/cd.ex @@ -99,7 +99,7 @@ defmodule Bash.Builtin.Cd do resolved_path = resolve_path(expanded_target, session_state, flags) # Check if it's a directory and exists - case validate_directory(resolved_path, flags) do + case validate_directory(resolved_path, flags, session_state) do :ok -> # Success - prepare state updates old_pwd = session_state.working_dir @@ -107,7 +107,7 @@ defmodule Bash.Builtin.Cd do new_pwd = if flags.physical do # Resolve all symlinks for physical path - case resolve_symlinks(Path.expand(resolved_path)) do + case resolve_symlinks(Path.expand(resolved_path), session_state) do {:ok, real_path} -> real_path {:error, _} -> Path.expand(resolved_path) end @@ -145,7 +145,7 @@ defmodule Bash.Builtin.Cd do else candidate = Path.join(session_state.working_dir, path) - if File.dir?(candidate) do + if Bash.Filesystem.dir?(session_state.filesystem, candidate) do candidate else # Try CDPATH if not starting with . or / @@ -173,7 +173,7 @@ defmodule Bash.Builtin.Cd do search_path = if search_path == "", do: ".", else: search_path candidate = Path.join(search_path, dir) - if File.dir?(candidate) do + if Bash.Filesystem.dir?(session_state.filesystem, candidate) do candidate end end) @@ -198,12 +198,12 @@ defmodule Bash.Builtin.Cd do defp expand_tilde(path, _session_state), do: path # Validate that the path is a directory - defp validate_directory(path, _flags) do + defp validate_directory(path, _flags, session_state) do cond do - not File.exists?(path) -> + not Bash.Filesystem.exists?(session_state.filesystem, path) -> {:error, "No such file or directory"} - not File.dir?(path) -> + not Bash.Filesystem.dir?(session_state.filesystem, path) -> {:error, "Not a directory"} true -> @@ -212,10 +212,11 @@ defmodule Bash.Builtin.Cd do end # Resolve symlinks in a path (for -P flag) - defp resolve_symlinks(path) do - case :file.read_link_all(to_charlist(path)) do - {:ok, real_path} -> {:ok, List.to_string(real_path)} + defp resolve_symlinks(path, session_state) do + case Bash.Filesystem.read_link_all(session_state.filesystem, path) do + {:ok, real_path} -> {:ok, real_path} {:error, :einval} -> {:ok, path} + {:error, :enotsup} -> {:ok, path} {:error, reason} -> {:error, reason} end end diff --git a/lib/bash/builtin/command.ex b/lib/bash/builtin/command.ex index 05d9d15..e4ab7fb 100644 --- a/lib/bash/builtin/command.ex +++ b/lib/bash/builtin/command.ex @@ -18,6 +18,7 @@ defmodule Bash.Builtin.Command do use Bash.Builtin alias Bash.Builtin + alias Bash.ExternalProcess alias Bash.Variable # Standard utilities path that is guaranteed to find all standard utilities @@ -231,23 +232,35 @@ defmodule Bash.Builtin.Command do builtin.execute(args, nil, state) nil -> - case find_in_path(command_name, lookup_state) do - nil -> - error("bash: #{command_name}: command not found") - {:ok, 127} - - path -> - # Execute external command - execute_external(path, args, state, opts) + if Bash.ExternalProcess.restricted?(state) do + error("bash: #{command_name}: restricted") + {:ok, 1} + else + case find_in_path(command_name, lookup_state) do + nil -> + error("bash: #{command_name}: command not found") + {:ok, 127} + + path -> + execute_external(path, args, state, opts) + end end end end - # Find command in PATH + # In restricted mode with LocalDisk, block PATH lookup entirely. + # With a VFS, allow lookup since execution is separately guarded by ExternalProcess. + defp find_in_path(_name, %{ + options: %{restricted: true}, + filesystem: {Bash.Filesystem.LocalDisk, _} + }), + do: nil + defp find_in_path(name, state) do if String.contains?(name, "/") do # Absolute or relative path - check directly - if File.exists?(name) and not File.dir?(name) do + if Bash.Filesystem.exists?(state.filesystem, name) and + not Bash.Filesystem.dir?(state.filesystem, name) do Path.expand(name, state.working_dir) else nil @@ -260,7 +273,8 @@ defmodule Bash.Builtin.Command do Enum.find_value(path_dirs, fn dir -> full_path = Path.join(dir, name) - if File.exists?(full_path) and not File.dir?(full_path) do + if Bash.Filesystem.exists?(state.filesystem, full_path) and + not Bash.Filesystem.dir?(state.filesystem, full_path) do full_path end end) @@ -282,7 +296,16 @@ defmodule Bash.Builtin.Command do ] try do - case System.cmd(path, args, cmd_opts) do + case ExternalProcess.system_cmd( + path, + args, + cmd_opts, + ExternalProcess.restricted?(state) + ) do + {:error, :restricted} -> + error("bash: #{path}: restricted") + {:ok, 1} + {stdout, exit_code} -> write(stdout) {:ok, exit_code} diff --git a/lib/bash/builtin/coproc.ex b/lib/bash/builtin/coproc.ex index 5f0252c..d61108a 100644 --- a/lib/bash/builtin/coproc.ex +++ b/lib/bash/builtin/coproc.ex @@ -66,6 +66,7 @@ defmodule Bash.Builtin.Coproc do require Logger alias Bash.Executor + alias Bash.ExternalProcess alias Bash.Variable @doc false @@ -133,7 +134,8 @@ defmodule Bash.Builtin.Coproc do command: command, args: cmd_args, working_dir: session_state.working_dir, - env: build_env(session_state) + env: build_env(session_state), + restricted: Bash.ExternalProcess.restricted?(session_state) } ]}, restart: :temporary @@ -185,6 +187,10 @@ defmodule Bash.Builtin.Coproc do {:error, 1} end + {:error, :restricted} -> + Bash.Sink.write_stderr(session_state, "bash: coproc: restricted\n") + {:error, 1} + {:error, reason} -> Bash.Sink.write_stderr(session_state, "coproc: failed to start: #{inspect(reason)}\n") {:error, 1} @@ -263,7 +269,7 @@ defmodule Bash.Builtin.Coproc do stderr: :redirect_to_stdout ] - case ExCmd.Process.start_link(cmd, proc_opts) do + case ExternalProcess.start_link(cmd, proc_opts, opts[:restricted] || false) do {:ok, pid} -> os_pid = case ExCmd.Process.os_pid(pid) do @@ -273,6 +279,9 @@ defmodule Bash.Builtin.Coproc do {:ok, %{mode: :external, proc: pid, os_pid: os_pid}} + {:error, :restricted} -> + {:stop, :restricted} + {:error, reason} -> {:stop, reason} end diff --git a/lib/bash/builtin/exec.ex b/lib/bash/builtin/exec.ex index 3e3d405..cc13c79 100644 --- a/lib/bash/builtin/exec.ex +++ b/lib/bash/builtin/exec.ex @@ -109,9 +109,12 @@ defmodule Bash.Builtin.Exec do end) end - # Execute the command with the given options + defp execute_command(command, _args, _opts, %{options: %{restricted: true}}) do + error("bash: exec: #{command}: restricted") + {:ok, 1} + end + defp execute_command(command, args, opts, session_state) do - # Build environment env = if opts.clear_env do [] diff --git a/lib/bash/builtin/hash.ex b/lib/bash/builtin/hash.ex index 731f5ac..9e4a866 100644 --- a/lib/bash/builtin/hash.ex +++ b/lib/bash/builtin/hash.ex @@ -241,7 +241,10 @@ defmodule Bash.Builtin.Hash do defp find_in_path(name, session_state) do if String.contains?(name, "/") do # Contains slash - treat as path - if File.exists?(name) and not File.dir?(name), do: name, else: nil + if Bash.Filesystem.exists?(session_state.filesystem, name) and + not Bash.Filesystem.dir?(session_state.filesystem, name), + do: name, + else: nil else path_var = Map.get(session_state.variables, "PATH", Variable.new("/usr/bin:/bin")) path_dirs = path_var |> Variable.get(nil) |> String.split(":") @@ -249,7 +252,8 @@ defmodule Bash.Builtin.Hash do Enum.find_value(path_dirs, fn dir -> full_path = Path.join(dir, name) - if File.exists?(full_path) and not File.dir?(full_path) do + if Bash.Filesystem.exists?(session_state.filesystem, full_path) and + not Bash.Filesystem.dir?(session_state.filesystem, full_path) do full_path end end) diff --git a/lib/bash/builtin/popd.ex b/lib/bash/builtin/popd.ex index b7cd6a3..da8ccb5 100644 --- a/lib/bash/builtin/popd.ex +++ b/lib/bash/builtin/popd.ex @@ -107,7 +107,7 @@ defmodule Bash.Builtin.Popd do :ok else # Change to new top and update stack - case validate_directory(new_top) do + case validate_directory(new_top, session_state) do :ok -> write(format_stack_output(new_top, rest, session_state)) old_pwd = session_state.working_dir @@ -173,12 +173,12 @@ defmodule Bash.Builtin.Popd do end # Validate that the path is a directory - defp validate_directory(path) do + defp validate_directory(path, session_state) do cond do - not File.exists?(path) -> + not Bash.Filesystem.exists?(session_state.filesystem, path) -> {:error, "No such file or directory"} - not File.dir?(path) -> + not Bash.Filesystem.dir?(session_state.filesystem, path) -> {:error, "Not a directory"} true -> diff --git a/lib/bash/builtin/pushd.ex b/lib/bash/builtin/pushd.ex index 36db93f..f804fbc 100644 --- a/lib/bash/builtin/pushd.ex +++ b/lib/bash/builtin/pushd.ex @@ -107,7 +107,7 @@ defmodule Bash.Builtin.Pushd do :ok else # Swap and change directory - case validate_directory(second) do + case validate_directory(second, session_state) do :ok -> new_stack = [cwd | rest] write(format_stack_output(second, new_stack, session_state)) @@ -139,7 +139,7 @@ defmodule Bash.Builtin.Pushd do # Resolve relative paths resolved_dir = resolve_path(expanded_dir, session_state) - case validate_directory(resolved_dir) do + case validate_directory(resolved_dir, session_state) do :ok -> cwd = session_state.working_dir stack = Map.get(session_state, :dir_stack, []) @@ -214,7 +214,7 @@ defmodule Bash.Builtin.Pushd do :ok else - case validate_directory(new_cwd) do + case validate_directory(new_cwd, session_state) do :ok -> write(format_stack_output(new_cwd, rotated_stack, session_state)) @@ -238,12 +238,12 @@ defmodule Bash.Builtin.Pushd do end # Validate that the path is a directory - defp validate_directory(path) do + defp validate_directory(path, session_state) do cond do - not File.exists?(path) -> + not Bash.Filesystem.exists?(session_state.filesystem, path) -> {:error, "No such file or directory"} - not File.dir?(path) -> + not Bash.Filesystem.dir?(session_state.filesystem, path) -> {:error, "Not a directory"} true -> diff --git a/lib/bash/builtin/pwd.ex b/lib/bash/builtin/pwd.ex index ae60388..a92e3b5 100644 --- a/lib/bash/builtin/pwd.ex +++ b/lib/bash/builtin/pwd.ex @@ -17,7 +17,7 @@ defmodule Bash.Builtin.Pwd do output = if flags.physical do # Physical mode: resolve symlinks to get real path - resolve_physical_path(state.working_dir) + resolve_physical_path(state.working_dir, state) else # Logical mode: return working_dir as-is state.working_dir @@ -65,25 +65,24 @@ defmodule Bash.Builtin.Pwd do # Resolve symlinks in a path to get the physical path (for -P flag) # This resolves the path component by component, following symlinks - defp resolve_physical_path(path) do + defp resolve_physical_path(path, session_state) do try do expanded = Path.expand(path) - resolve_path_components(String.split(expanded, "/", trim: true), "/") + resolve_path_components(String.split(expanded, "/", trim: true), "/", session_state) rescue _ -> path end end # Resolve path component by component, following any symlinks - defp resolve_path_components([], acc), do: acc + defp resolve_path_components([], acc, _session_state), do: acc - defp resolve_path_components([component | rest], acc) do + defp resolve_path_components([component | rest], acc, session_state) do current = Path.join(acc, component) - case :file.read_link(to_charlist(current)) do + case Bash.Filesystem.read_link(session_state.filesystem, current) do {:ok, target} -> - # It's a symlink - follow it - target_str = List.to_string(target) + target_str = target resolved = if String.starts_with?(target_str, "/") do @@ -92,11 +91,10 @@ defmodule Bash.Builtin.Pwd do Path.join(acc, target_str) |> Path.expand() end - resolve_path_components(rest, resolved) + resolve_path_components(rest, resolved, session_state) {:error, _} -> - # Not a symlink, continue - resolve_path_components(rest, current) + resolve_path_components(rest, current, session_state) end end end diff --git a/lib/bash/builtin/set.ex b/lib/bash/builtin/set.ex index ca6542e..97bf3d0 100644 --- a/lib/bash/builtin/set.ex +++ b/lib/bash/builtin/set.ex @@ -134,12 +134,16 @@ defmodule Bash.Builtin.Set do # Get current options, starting with defaults current_options = Map.merge(@default_options, state.options || %{}) - # Apply changes - # Set options to true for set, and false for unset (not drop, so merge works correctly) + # Apply changes, filtering out :restricted which is immutable once set. + # Without this filter, `set -o restricted` or `set +o restricted` could + # alter the flag through the merge maps below. + safe_to_set = Enum.reject(options_to_set, &(&1 == :restricted)) + safe_to_unset = Enum.reject(options_to_unset, &(&1 == :restricted)) + new_options = current_options - |> Map.merge(Map.new(options_to_set, fn opt -> {opt, true} end)) - |> Map.merge(Map.new(options_to_unset, fn opt -> {opt, false} end)) + |> Map.merge(Map.new(safe_to_set, fn opt -> {opt, true} end)) + |> Map.merge(Map.new(safe_to_unset, fn opt -> {opt, false} end)) # Build updates update_state(options: new_options) diff --git a/lib/bash/builtin/shopt.ex b/lib/bash/builtin/shopt.ex index a32d1b0..5621f40 100644 --- a/lib/bash/builtin/shopt.ex +++ b/lib/bash/builtin/shopt.ex @@ -263,20 +263,29 @@ defmodule Bash.Builtin.Shopt do else {valid, invalid} = validate_optnames(optnames, opts.use_set_o) - if Enum.empty?(invalid) do - # Build option updates - option_updates = - Enum.reduce(valid, %{}, fn optname, acc -> - key = option_key(optname, opts.use_set_o) - Map.put(acc, key, true) - end) - - new_options = Map.merge(session_state.options || %{}, option_updates) - update_state(options: new_options) - :ok - else + if not Enum.empty?(invalid) do Enum.each(invalid, fn name -> error("shopt: #{name}: invalid shell option name") end) {:ok, 1} + else + {read_only, settable} = split_read_only_options(valid, opts.use_set_o) + + if not Enum.empty?(read_only) do + Enum.each(read_only, fn name -> + error("shopt: #{name}: cannot be set") + end) + + {:ok, 1} + else + option_updates = + Enum.reduce(settable, %{}, fn optname, acc -> + key = option_key(optname, opts.use_set_o) + Map.put(acc, key, true) + end) + + new_options = Map.merge(session_state.options || %{}, option_updates) + update_state(options: new_options) + :ok + end end end end @@ -288,20 +297,29 @@ defmodule Bash.Builtin.Shopt do else {valid, invalid} = validate_optnames(optnames, opts.use_set_o) - if Enum.empty?(invalid) do - # Build option updates - option_updates = - Enum.reduce(valid, %{}, fn optname, acc -> - key = option_key(optname, opts.use_set_o) - Map.put(acc, key, false) - end) - - new_options = Map.merge(session_state.options || %{}, option_updates) - update_state(options: new_options) - :ok - else + if not Enum.empty?(invalid) do Enum.each(invalid, fn name -> error("shopt: #{name}: invalid shell option name") end) {:ok, 1} + else + {read_only, settable} = split_read_only_options(valid, opts.use_set_o) + + if not Enum.empty?(read_only) do + Enum.each(read_only, fn name -> + error("shopt: #{name}: cannot be unset") + end) + + {:ok, 1} + else + option_updates = + Enum.reduce(settable, %{}, fn optname, acc -> + key = option_key(optname, opts.use_set_o) + Map.put(acc, key, false) + end) + + new_options = Map.merge(session_state.options || %{}, option_updates) + update_state(options: new_options) + :ok + end end end end @@ -391,7 +409,14 @@ defmodule Bash.Builtin.Shopt do end) end - # Get the key to use in the options map + @read_only_shopt_options ~w(restricted_shell login_shell) + + defp split_read_only_options(optnames, true = _use_set_o), do: {[], optnames} + + defp split_read_only_options(optnames, false = _use_set_o) do + Enum.split_with(optnames, fn name -> name in @read_only_shopt_options end) + end + defp option_key(optname, true = _use_set_o) do # For set -o options, use the atom key Map.get(@set_o_options, optname, String.to_atom(optname)) @@ -408,6 +433,10 @@ defmodule Bash.Builtin.Shopt do Map.get(session_state.options || %{}, key, false) end + defp get_option_value("restricted_shell", false = _use_set_o, session_state) do + (session_state.options || %{})[:restricted] || false + end + defp get_option_value(optname, false = _use_set_o, session_state) do key = String.to_atom("shopt_" <> optname) # Get default value from @shopt_options if not set diff --git a/lib/bash/builtin/source.ex b/lib/bash/builtin/source.ex index bc9f197..86d398d 100644 --- a/lib/bash/builtin/source.ex +++ b/lib/bash/builtin/source.ex @@ -49,7 +49,7 @@ defmodule Bash.Builtin.Source do {:error, "source: #{filename}: No such file or directory"} path -> - case File.read(path) do + case Bash.Filesystem.read(session_state.filesystem, path) do {:ok, content} -> execute_content(content, args, session_state) @@ -63,12 +63,15 @@ defmodule Bash.Builtin.Source do cond do # Absolute path String.starts_with?(filename, "/") -> - if File.exists?(filename), do: filename, else: nil + if Bash.Filesystem.exists?(session_state.filesystem, filename), do: filename, else: nil # Relative path (contains /) String.contains?(filename, "/") -> full_path = Path.join(session_state.working_dir, filename) - if File.exists?(full_path), do: full_path, else: nil + + if Bash.Filesystem.exists?(session_state.filesystem, full_path), + do: full_path, + else: nil # Search in PATH true -> @@ -80,7 +83,10 @@ defmodule Bash.Builtin.Source do Enum.find_value(path_dirs, fn dir -> full_path = Path.join(dir, filename) - if File.exists?(full_path), do: full_path, else: nil + + if Bash.Filesystem.exists?(session_state.filesystem, full_path), + do: full_path, + else: nil end) end end diff --git a/lib/bash/builtin/test.ex b/lib/bash/builtin/test.ex index 26c7f2b..90dd4c0 100644 --- a/lib/bash/builtin/test.ex +++ b/lib/bash/builtin/test.ex @@ -81,26 +81,26 @@ defmodule Bash.Builtin.Test do @doc false def file_exists?(path, state) do full_path = resolve_path(path, state.working_dir) - File.exists?(full_path) + Bash.Filesystem.exists?(state.filesystem, full_path) end @doc false def file_regular?(path, state) do full_path = resolve_path(path, state.working_dir) - File.regular?(full_path) + Bash.Filesystem.regular?(state.filesystem, full_path) end @doc false def file_directory?(path, state) do full_path = resolve_path(path, state.working_dir) - File.dir?(full_path) + Bash.Filesystem.dir?(state.filesystem, full_path) end @doc false def file_symlink?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.lstat(full_path) do + case Bash.Filesystem.lstat(state.filesystem, full_path) do {:ok, stat} -> stat.type == :symlink _ -> false end @@ -110,7 +110,7 @@ defmodule Bash.Builtin.Test do def file_readable?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> (stat.mode &&& 0o444) != 0 _ -> false end @@ -120,7 +120,7 @@ defmodule Bash.Builtin.Test do def file_writable?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> (stat.mode &&& 0o222) != 0 _ -> false end @@ -130,7 +130,7 @@ defmodule Bash.Builtin.Test do def file_executable?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> (stat.mode &&& 0o111) != 0 _ -> false end @@ -140,7 +140,7 @@ defmodule Bash.Builtin.Test do def file_not_empty?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> stat.size > 0 _ -> false end @@ -150,7 +150,7 @@ defmodule Bash.Builtin.Test do def file_block_special?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.lstat(full_path) do + case Bash.Filesystem.lstat(state.filesystem, full_path) do {:ok, stat} -> stat.type == :device _ -> false end @@ -160,7 +160,7 @@ defmodule Bash.Builtin.Test do def file_char_special?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.lstat(full_path) do + case Bash.Filesystem.lstat(state.filesystem, full_path) do {:ok, stat} -> stat.type == :device _ -> false end @@ -170,7 +170,7 @@ defmodule Bash.Builtin.Test do def file_named_pipe?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.lstat(full_path) do + case Bash.Filesystem.lstat(state.filesystem, full_path) do {:ok, stat} -> stat.type == :other _ -> false end @@ -180,7 +180,7 @@ defmodule Bash.Builtin.Test do def file_socket?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.lstat(full_path) do + case Bash.Filesystem.lstat(state.filesystem, full_path) do {:ok, stat} -> stat.type == :other _ -> false end @@ -190,7 +190,7 @@ defmodule Bash.Builtin.Test do def file_setgid?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> (stat.mode &&& 0o2000) != 0 _ -> false end @@ -200,7 +200,7 @@ defmodule Bash.Builtin.Test do def file_sticky_bit?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> (stat.mode &&& 0o1000) != 0 _ -> false end @@ -210,7 +210,7 @@ defmodule Bash.Builtin.Test do def file_setuid?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> (stat.mode &&& 0o4000) != 0 _ -> false end @@ -220,7 +220,7 @@ defmodule Bash.Builtin.Test do def file_owned_by_user?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> stat.uid == "UID" |> System.get_env("0") |> String.to_integer() _ -> false end @@ -230,7 +230,7 @@ defmodule Bash.Builtin.Test do def file_owned_by_group?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> stat.gid == "GID" |> System.get_env("0") |> String.to_integer() _ -> false end @@ -240,7 +240,7 @@ defmodule Bash.Builtin.Test do def file_modified_since_read?(path, state) do full_path = resolve_path(path, state.working_dir) - case File.stat(full_path) do + case Bash.Filesystem.stat(state.filesystem, full_path) do {:ok, stat} -> stat.mtime > stat.atime _ -> false end @@ -251,8 +251,8 @@ defmodule Bash.Builtin.Test do path1 = resolve_path(file1, state.working_dir) path2 = resolve_path(file2, state.working_dir) - with {:ok, stat1} <- File.stat(path1), - {:ok, stat2} <- File.stat(path2) do + with {:ok, stat1} <- Bash.Filesystem.stat(state.filesystem, path1), + {:ok, stat2} <- Bash.Filesystem.stat(state.filesystem, path2) do stat1.mtime > stat2.mtime else _ -> false @@ -264,8 +264,8 @@ defmodule Bash.Builtin.Test do path1 = resolve_path(file1, state.working_dir) path2 = resolve_path(file2, state.working_dir) - with {:ok, stat1} <- File.stat(path1), - {:ok, stat2} <- File.stat(path2) do + with {:ok, stat1} <- Bash.Filesystem.stat(state.filesystem, path1), + {:ok, stat2} <- Bash.Filesystem.stat(state.filesystem, path2) do stat1.mtime < stat2.mtime else _ -> false @@ -277,8 +277,8 @@ defmodule Bash.Builtin.Test do path1 = resolve_path(file1, state.working_dir) path2 = resolve_path(file2, state.working_dir) - with {:ok, stat1} <- File.stat(path1), - {:ok, stat2} <- File.stat(path2) do + with {:ok, stat1} <- Bash.Filesystem.stat(state.filesystem, path1), + {:ok, stat2} <- Bash.Filesystem.stat(state.filesystem, path2) do stat1.inode == stat2.inode and stat1.major_device == stat2.major_device else _ -> false diff --git a/lib/bash/builtin/type.ex b/lib/bash/builtin/type.ex index 35980ca..60f4224 100644 --- a/lib/bash/builtin/type.ex +++ b/lib/bash/builtin/type.ex @@ -176,7 +176,10 @@ defmodule Bash.Builtin.Type do defp find_in_path(name, state) do if String.contains?(name, "/") do - if File.exists?(name) and not File.dir?(name), do: name, else: nil + if Bash.Filesystem.exists?(state.filesystem, name) and + not Bash.Filesystem.dir?(state.filesystem, name), + do: name, + else: nil else path_var = Map.get(state.variables, "PATH", Variable.new("/usr/bin:/bin")) path_dirs = path_var |> Variable.get(nil) |> String.split(":") @@ -184,7 +187,8 @@ defmodule Bash.Builtin.Type do Enum.find_value(path_dirs, fn dir -> full_path = Path.join(dir, name) - if File.exists?(full_path) and not File.dir?(full_path) do + if Bash.Filesystem.exists?(state.filesystem, full_path) and + not Bash.Filesystem.dir?(state.filesystem, full_path) do full_path end end) diff --git a/lib/bash/command_port.ex b/lib/bash/command_port.ex index 695a5fe..35dd264 100644 --- a/lib/bash/command_port.ex +++ b/lib/bash/command_port.ex @@ -21,6 +21,7 @@ defmodule Bash.CommandPort do """ alias Bash.CommandResult + alias Bash.ExternalProcess # Executes a command with optional stdin input. # @@ -41,11 +42,12 @@ defmodule Bash.CommandPort do cd = opts[:cd] || File.cwd!() env = opts[:env] || [] sink_opt = opts[:sink] + restricted = opts[:restricted] || false - execute_command(command_name, args, stdin, cd, env, timeout, sink_opt) + execute_command(command_name, args, stdin, cd, env, timeout, sink_opt, restricted) end - defp execute_command(command_name, args, stdin, cd, env, timeout, sink_opt) do + defp execute_command(command_name, args, stdin, cd, env, timeout, sink_opt, restricted) do cmd_parts = [command_name | args] # Check if command exists before trying to execute @@ -60,11 +62,11 @@ defmodule Bash.CommandPort do error: :command_not_found }} else - execute_with_excmd(cmd_parts, stdin, cd, env, timeout, sink_opt) + execute_with_excmd(cmd_parts, stdin, cd, env, timeout, sink_opt, restricted) end end - defp execute_with_excmd(cmd_parts, stdin, cd, env, timeout, sink_opt) do + defp execute_with_excmd(cmd_parts, stdin, cd, env, timeout, sink_opt, restricted) do # Build ExCmd options - ExCmd 0.18.0 only accepts cd, env, and stderr options exec_opts = [ cd: cd, @@ -74,7 +76,18 @@ defmodule Bash.CommandPort do sink = sink_opt || fn _chunk -> :ok end - case ExCmd.Process.start_link(cmd_parts, exec_opts) do + case ExternalProcess.start_link(cmd_parts, exec_opts, restricted) do + {:error, :restricted} -> + command_name = hd(cmd_parts) + sink.({:stderr, "bash: #{command_name}: restricted\n"}) + + {:error, + %CommandResult{ + command: Enum.join(cmd_parts, " "), + exit_code: 1, + error: :restricted + }} + {:ok, process} -> # Write stdin if provided, then close stdin write_stdin(process, stdin) diff --git a/lib/bash/external_process.ex b/lib/bash/external_process.ex new file mode 100644 index 0000000..e2f6917 --- /dev/null +++ b/lib/bash/external_process.ex @@ -0,0 +1,71 @@ +defmodule Bash.ExternalProcess do + @moduledoc """ + Centralized gateway for all user-facing OS process spawning. + + All external command execution flows through this module, enabling + restricted mode enforcement at a single point. Internal plumbing + (signal delivery, hostname lookup, named pipe creation) is exempt + and continues to use `System.cmd` directly. + + ```mermaid + graph TD + CP[CommandPort] --> EP[ExternalProcess] + JP[JobProcess] --> EP + CO[Coproc] --> EP + PL[Pipeline] --> EP + CM[Command builtin] --> EP + EP -->|restricted?| ERR["{:error, :restricted}"] + EP -->|allowed| EX[ExCmd / System.cmd] + ``` + """ + + defguardp is_restricted(restricted) when restricted == true + + @doc """ + Returns whether restricted mode is active for the given session state. + + Safely traverses the nested options map, defaulting to `false` when keys + are absent (e.g. bare state maps in tests). + """ + @spec restricted?(map()) :: boolean() + def restricted?(state), do: state |> Map.get(:options, %{}) |> Map.get(:restricted, false) + + @doc """ + Starts an OS process via `ExCmd.Process.start_link/2`. + + Returns `{:error, :restricted}` when restricted mode is active. + """ + @spec start_link(list(String.t()), keyword(), boolean()) :: + {:ok, pid()} | {:error, :restricted} | {:error, term()} + def start_link(_cmd_parts, _opts, restricted) when is_restricted(restricted), + do: {:error, :restricted} + + def start_link(cmd_parts, opts, _restricted), + do: ExCmd.Process.start_link(cmd_parts, opts) + + @doc """ + Creates an OS process stream via `ExCmd.stream/2`. + + Returns `{:error, :restricted}` when restricted mode is active. + """ + @spec stream(list(String.t()), keyword(), boolean()) :: + Enumerable.t() | {:error, :restricted} + def stream(_cmd_parts, _opts, restricted) when is_restricted(restricted), + do: {:error, :restricted} + + def stream(cmd_parts, opts, _restricted), + do: ExCmd.stream(cmd_parts, opts) + + @doc """ + Executes a command via `System.cmd/3`. + + Returns `{:error, :restricted}` when restricted mode is active. + """ + @spec system_cmd(String.t(), list(String.t()), keyword(), boolean()) :: + {String.t(), non_neg_integer()} | {:error, :restricted} + def system_cmd(_path, _args, _opts, restricted) when is_restricted(restricted), + do: {:error, :restricted} + + def system_cmd(path, args, opts, _restricted), + do: System.cmd(path, args, opts) +end diff --git a/lib/bash/filesystem.ex b/lib/bash/filesystem.ex new file mode 100644 index 0000000..d5c0df0 --- /dev/null +++ b/lib/bash/filesystem.ex @@ -0,0 +1,137 @@ +defmodule Bash.Filesystem do + @moduledoc """ + Behaviour and dispatcher for pluggable filesystem implementations. + + The filesystem is stored in session state as a `{module, config}` tuple. Every + filesystem callback takes `config` as the first argument. This module provides + convenience functions that unwrap the tuple and dispatch to the appropriate + implementation. + + ## Default + + When no filesystem option is provided, sessions use + `{Bash.Filesystem.LocalDisk, nil}` which passes through to Elixir's `File` + and Erlang's `:file` modules. + + ## Custom Implementations + + Implement the `Bash.Filesystem` behaviour to provide a virtual filesystem: + + defmodule MyVFS do + @behaviour Bash.Filesystem + + @impl true + def exists?(config, path), do: ... + # ... implement all required callbacks + end + + {:ok, session} = Bash.Session.new(filesystem: {MyVFS, my_config}) + + Non-LocalDisk filesystems automatically enable restricted mode in the + session, blocking external process execution. This prevents a split-brain + state where builtins see the VFS while commands like `ls` hit the real OS. + See `local_disk?/1`. + """ + + @type fs :: {module(), config :: term()} + + @callback exists?(config :: term(), path :: String.t()) :: boolean() + @callback dir?(config :: term(), path :: String.t()) :: boolean() + @callback regular?(config :: term(), path :: String.t()) :: boolean() + @callback stat(config :: term(), path :: String.t()) :: + {:ok, File.Stat.t()} | {:error, term()} + @callback lstat(config :: term(), path :: String.t()) :: + {:ok, File.Stat.t()} | {:error, term()} + + @callback read(config :: term(), path :: String.t()) :: + {:ok, binary()} | {:error, term()} + + @callback write(config :: term(), path :: String.t(), content :: iodata(), opts :: keyword()) :: + :ok | {:error, term()} + @callback mkdir_p(config :: term(), path :: String.t()) :: :ok | {:error, term()} + @callback rm(config :: term(), path :: String.t()) :: :ok | {:error, term()} + + @callback open(config :: term(), path :: String.t(), modes :: [atom()]) :: + {:ok, io_device :: term()} | {:error, term()} + @callback handle_write(config :: term(), io_device :: term(), data :: iodata()) :: + :ok | {:error, term()} + @callback handle_close(config :: term(), io_device :: term()) :: + :ok | {:error, term()} + + @callback ls(config :: term(), path :: String.t()) :: + {:ok, [String.t()]} | {:error, term()} + + @callback wildcard(config :: term(), pattern :: String.t(), opts :: keyword()) :: + [String.t()] + + @callback read_link(config :: term(), path :: String.t()) :: + {:ok, String.t()} | {:error, term()} + @callback read_link_all(config :: term(), path :: String.t()) :: + {:ok, String.t()} | {:error, term()} + + @optional_callbacks [lstat: 2, read_link: 2, read_link_all: 2] + + @spec local_disk?(fs()) :: boolean() + def local_disk?({Bash.Filesystem.LocalDisk, _}), do: true + def local_disk?(_), do: false + + @spec exists?(fs(), String.t()) :: boolean() + def exists?({mod, config}, path), do: mod.exists?(config, path) + + @spec dir?(fs(), String.t()) :: boolean() + def dir?({mod, config}, path), do: mod.dir?(config, path) + + @spec regular?(fs(), String.t()) :: boolean() + def regular?({mod, config}, path), do: mod.regular?(config, path) + + @spec stat(fs(), String.t()) :: {:ok, File.Stat.t()} | {:error, term()} + def stat({mod, config}, path), do: mod.stat(config, path) + + @spec lstat(fs(), String.t()) :: {:ok, File.Stat.t()} | {:error, term()} + def lstat({mod, config}, path) do + if function_exported?(mod, :lstat, 2), + do: mod.lstat(config, path), + else: mod.stat(config, path) + end + + @spec read(fs(), String.t()) :: {:ok, binary()} | {:error, term()} + def read({mod, config}, path), do: mod.read(config, path) + + @spec write(fs(), String.t(), iodata(), keyword()) :: :ok | {:error, term()} + def write({mod, config}, path, content, opts), do: mod.write(config, path, content, opts) + + @spec mkdir_p(fs(), String.t()) :: :ok | {:error, term()} + def mkdir_p({mod, config}, path), do: mod.mkdir_p(config, path) + + @spec rm(fs(), String.t()) :: :ok | {:error, term()} + def rm({mod, config}, path), do: mod.rm(config, path) + + @spec open(fs(), String.t(), [atom()]) :: {:ok, term()} | {:error, term()} + def open({mod, config}, path, modes), do: mod.open(config, path, modes) + + @spec handle_write(fs(), term(), iodata()) :: :ok | {:error, term()} + def handle_write({mod, config}, device, data), do: mod.handle_write(config, device, data) + + @spec handle_close(fs(), term()) :: :ok | {:error, term()} + def handle_close({mod, config}, device), do: mod.handle_close(config, device) + + @spec ls(fs(), String.t()) :: {:ok, [String.t()]} | {:error, term()} + def ls({mod, config}, path), do: mod.ls(config, path) + + @spec wildcard(fs(), String.t(), keyword()) :: [String.t()] + def wildcard({mod, config}, pattern, opts), do: mod.wildcard(config, pattern, opts) + + @spec read_link(fs(), String.t()) :: {:ok, String.t()} | {:error, term()} + def read_link({mod, config}, path) do + if function_exported?(mod, :read_link, 2), + do: mod.read_link(config, path), + else: {:error, :enotsup} + end + + @spec read_link_all(fs(), String.t()) :: {:ok, String.t()} | {:error, term()} + def read_link_all({mod, config}, path) do + if function_exported?(mod, :read_link_all, 2), + do: mod.read_link_all(config, path), + else: {:error, :enotsup} + end +end diff --git a/lib/bash/filesystem/local_disk.ex b/lib/bash/filesystem/local_disk.ex new file mode 100644 index 0000000..2952b8d --- /dev/null +++ b/lib/bash/filesystem/local_disk.ex @@ -0,0 +1,75 @@ +defmodule Bash.Filesystem.LocalDisk do + @moduledoc """ + Default filesystem implementation that delegates to the host OS filesystem. + + All operations pass through to Elixir's `File` module or Erlang's `:file` + module. This is the default adapter used when no `filesystem:` option is + provided to `Bash.Session.new/1`. + + Config is unused (`nil`). + """ + + @behaviour Bash.Filesystem + + @impl true + def exists?(_config, path), do: File.exists?(path) + + @impl true + def dir?(_config, path), do: File.dir?(path) + + @impl true + def regular?(_config, path), do: File.regular?(path) + + @impl true + def stat(_config, path), do: File.stat(path) + + @impl true + def lstat(_config, path), do: File.lstat(path) + + @impl true + def read(_config, path), do: File.read(path) + + @impl true + def write(_config, path, content, opts) do + if Keyword.get(opts, :append, false), + do: File.write(path, content, [:append]), + else: File.write(path, content) + end + + @impl true + def mkdir_p(_config, path), do: File.mkdir_p(path) + + @impl true + def rm(_config, path), do: File.rm(path) + + @impl true + def open(_config, path, modes), do: File.open(path, modes) + + @impl true + def handle_write(_config, device, data), do: :file.write(device, data) + + @impl true + def handle_close(_config, device), do: :file.close(device) + + @impl true + def ls(_config, path), do: File.ls(path) + + @impl true + def wildcard(_config, pattern, opts), do: Path.wildcard(pattern, opts) + + @impl true + def read_link(_config, path) do + case :file.read_link(to_charlist(path)) do + {:ok, target} -> {:ok, List.to_string(target)} + error -> error + end + end + + @impl true + def read_link_all(_config, path) do + case :file.read_link_all(to_charlist(path)) do + {:ok, target} -> {:ok, List.to_string(target)} + error -> error + end + end +end diff --git a/lib/bash/job_process.ex b/lib/bash/job_process.ex index 68fe9d5..10d5042 100644 --- a/lib/bash/job_process.ex +++ b/lib/bash/job_process.ex @@ -31,6 +31,7 @@ defmodule Bash.JobProcess do end alias Bash.CommandResult + alias Bash.ExternalProcess alias Bash.Job defstruct [ @@ -46,11 +47,10 @@ defmodule Bash.JobProcess do :working_dir, :env, :last_signal, - # Sinks for streaming output directly to destination :stdout_sink, :stderr_sink, - # Session's persistent output collector for later retrieval - :output_collector + :output_collector, + restricted: false ] @type t :: %__MODULE__{ @@ -148,7 +148,8 @@ defmodule Bash.JobProcess do working_dir = Keyword.fetch!(opts, :working_dir) env = Keyword.get(opts, :env, []) - # Get sinks for streaming output directly + restricted = Keyword.get(opts, :restricted, false) + stdout_sink = Keyword.get(opts, :stdout_sink) stderr_sink = Keyword.get(opts, :stderr_sink) output_collector = Keyword.get(opts, :output_collector) @@ -177,7 +178,8 @@ defmodule Bash.JobProcess do env: env, stdout_sink: stdout_sink, stderr_sink: stderr_sink, - output_collector: output_collector + output_collector: output_collector, + restricted: restricted } # Start the process asynchronously @@ -186,7 +188,7 @@ defmodule Bash.JobProcess do @impl true def handle_continue({:start_process, command, args}, state) do - case start_os_process(command, args, state.working_dir, state.env) do + case start_os_process(command, args, state.working_dir, state.env, state.restricted) do {:ok, excmd_process, os_pid, stdout_reader, stderr_reader} -> job = %{state.job | os_pid: os_pid} @@ -414,15 +416,13 @@ defmodule Bash.JobProcess do def handle_info(_msg, state), do: {:noreply, state} - defp start_os_process(command, args, working_dir, env) do + defp start_os_process(command, args, working_dir, env, restricted) do cmd_parts = [command | args] parent = self() - # Spawn a worker process that owns the ExCmd process - # This avoids blocking the GenServer on await_exit worker = spawn(fn -> - run_command_worker(cmd_parts, working_dir, env, parent) + run_command_worker(cmd_parts, working_dir, env, parent, restricted) end) # Wait for the worker to report the OS PID @@ -439,14 +439,14 @@ defmodule Bash.JobProcess do end end - defp run_command_worker(cmd_parts, working_dir, env, parent) do + defp run_command_worker(cmd_parts, working_dir, env, parent, restricted) do exec_opts = [ cd: working_dir, env: normalize_env(env), stderr: :redirect_to_stdout ] - case ExCmd.Process.start_link(cmd_parts, exec_opts) do + case ExternalProcess.start_link(cmd_parts, exec_opts, restricted) do {:ok, process} -> os_pid = case ExCmd.Process.os_pid(process) do diff --git a/lib/bash/session.ex b/lib/bash/session.ex index 020af2b..d2377a1 100644 --- a/lib/bash/session.ex +++ b/lib/bash/session.ex @@ -126,7 +126,8 @@ defmodule Bash.Session do # Callback for starting background jobs synchronously (used by Script executor) start_background_job_fn: nil, signal_jobs_fn: nil, - pipe_stdin: nil + pipe_stdin: nil, + filesystem: {Bash.Filesystem.LocalDisk, nil} ] @type t :: %__MODULE__{ @@ -165,13 +166,28 @@ defmodule Bash.Session do completed_jobs: [Job.t()], command_history: [CommandResult.t()], special_vars: %{String.t() => integer() | String.t() | nil}, - positional_params: [[String.t()]] + positional_params: [[String.t()]], + filesystem: Bash.Filesystem.fs() } # Client API @doc """ Creates a new session with default environment. + + ## Options + + * `:filesystem` — `{module, config}` filesystem adapter. Defaults to + `{Bash.Filesystem.LocalDisk, nil}`. When a non-LocalDisk filesystem is + provided, restricted mode is automatically enabled to prevent external + commands from bypassing the virtual filesystem. + * `:options` — map of shell options (e.g. `%{restricted: true}`). + * `:env` — map of initial environment variables. + * `:working_dir` — initial working directory. Defaults to `File.cwd!()`. + * `:aliases` — map of shell aliases. + * `:functions` — map of shell functions. + * `:args` — positional parameters (`$1`, `$2`, …). + * `:script_name` — value of `$0`. Defaults to `"bash"`. """ def new(opts \\ []) do supervisor = opts[:supervisor] || SessionSupervisor @@ -221,6 +237,7 @@ defmodule Bash.Session do variables: parent_state.variables, functions: parent_state.functions, options: parent_state.options, + filesystem: parent_state.filesystem, # NOT inherited in subshells (bash behavior): # - aliases are NOT inherited # - hash table is NOT inherited @@ -856,7 +873,7 @@ defmodule Bash.Session do """ @spec open_fd(t(), non_neg_integer(), String.t(), [atom()]) :: {:ok, t()} | {:error, term()} def open_fd(%__MODULE__{} = session, fd, path, modes) when fd >= 3 do - case File.open(path, modes) do + case Bash.Filesystem.open(session.filesystem, path, modes) do {:ok, device} -> new_fds = Map.put(session.file_descriptors, fd, device) {:ok, %{session | file_descriptors: new_fds}} @@ -896,7 +913,7 @@ defmodule Bash.Session do %{session | file_descriptors: Map.delete(fds, fd)} device when is_pid(device) -> - File.close(device) + Bash.Filesystem.handle_close(session.filesystem, device) %{session | file_descriptors: Map.delete(fds, fd)} end end @@ -976,7 +993,7 @@ defmodule Bash.Session do aliases = opts[:aliases] || %{} functions = opts[:functions] || %{} - default_options = %{hashall: true, braceexpand: true} + default_options = %{hashall: true, braceexpand: true, restricted: false} options = Map.merge(default_options, opts[:options] || %{}) {:ok, job_supervisor} = DynamicSupervisor.start_link(strategy: :one_for_one) @@ -993,6 +1010,15 @@ defmodule Bash.Session do positional_params = [opts[:args] || []] {:ok, output_collector} = OutputCollector.start_link() + filesystem = opts[:filesystem] || {Bash.Filesystem.LocalDisk, nil} + + options = + if not Bash.Filesystem.local_disk?(filesystem) do + Map.put(options, :restricted, true) + else + options + end + state = %__MODULE__{ id: id, variables: variables, @@ -1015,7 +1041,8 @@ defmodule Bash.Session do start_runtime_ms: start_runtime_ms, special_vars: special_vars, positional_params: positional_params, - call_timeout: opts[:call_timeout] || :infinity + call_timeout: opts[:call_timeout] || :infinity, + filesystem: filesystem } # Load any API modules provided at creation @@ -1034,7 +1061,7 @@ defmodule Bash.Session do end def handle_call({:chdir, path}, _from, state) do - if File.dir?(path) do + if Bash.Filesystem.dir?(state.filesystem, path) do new_state = %{state | working_dir: Path.expand(path)} {:reply, :ok, new_state} else @@ -1438,7 +1465,8 @@ defmodule Bash.Session do stdout_sink: state.stdout_sink, stderr_sink: state.stderr_sink, # Also pass the session's persistent output collector for later retrieval - output_collector: state.output_collector + output_collector: state.output_collector, + restricted: Map.get(state.options, :restricted, false) ] case DynamicSupervisor.start_child(state.job_supervisor, {JobProcess, job_opts}) do @@ -1792,7 +1820,7 @@ defmodule Bash.Session do :ok {_fd, device} when is_pid(device) -> - File.close(device) + Bash.Filesystem.handle_close(state.filesystem, device) end) end @@ -1954,7 +1982,8 @@ defmodule Bash.Session do stdout_sink: state.stdout_sink, stderr_sink: state.stderr_sink, # Also pass the session's persistent output collector for later retrieval - output_collector: state.output_collector + output_collector: state.output_collector, + restricted: Map.get(state.options, :restricted, false) ] case DynamicSupervisor.start_child(state.job_supervisor, {JobProcess, job_opts}) do @@ -2048,16 +2077,22 @@ defmodule Bash.Session do end), stdout_sink: persistent_stdout_sink, stderr_sink: persistent_stderr_sink, - output_collector: original_state.output_collector + output_collector: original_state.output_collector, + restricted: Map.get(original_state.options, :restricted, false) ] case DynamicSupervisor.start_child(original_state.job_supervisor, {JobProcess, job_opts}) do {:ok, job_pid} -> # Wait for the OS process to actually start os_pid_str = - case JobProcess.await_start(job_pid) do - {:ok, os_pid} -> to_string(os_pid) - {:error, _} -> "" + try do + case JobProcess.await_start(job_pid) do + {:ok, os_pid} -> to_string(os_pid) + {:error, _} -> "" + end + catch + :exit, {reason, {GenServer, :call, _}} when reason in [:noproc, :normal, :timeout] -> + "" end # Write job notification to stderr (matches bash behavior) diff --git a/lib/bash/sink.ex b/lib/bash/sink.ex index 31160ab..136b0c9 100644 --- a/lib/bash/sink.ex +++ b/lib/bash/sink.ex @@ -173,25 +173,26 @@ defmodule Bash.Sink do def file(path, opts \\ []) do append = Keyword.get(opts, :append, false) stream_type = Keyword.get(opts, :stream_type, :stdout) + filesystem = Keyword.get(opts, :filesystem, {Bash.Filesystem.LocalDisk, nil}) mode = if append, do: [:write, :append, :raw], else: [:write, :raw] - case :file.open(path, mode) do + case Bash.Filesystem.open(filesystem, path, mode) do {:ok, fd} -> sink = fn {:stdout, data} when stream_type in [:stdout, :both] and is_binary(data) -> - :file.write(fd, data) + Bash.Filesystem.handle_write(filesystem, fd, data) :ok {:stderr, data} when stream_type in [:stderr, :both] and is_binary(data) -> - :file.write(fd, data) + Bash.Filesystem.handle_write(filesystem, fd, data) :ok _ -> :ok end - close = fn -> :file.close(fd) end + close = fn -> Bash.Filesystem.handle_close(filesystem, fd) end {sink, close} {:error, reason} -> diff --git a/test/bash/builtin/pushd_test.exs b/test/bash/builtin/pushd_test.exs index 0f0afe8..ae10c32 100644 --- a/test/bash/builtin/pushd_test.exs +++ b/test/bash/builtin/pushd_test.exs @@ -12,7 +12,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [tmp_dir], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -26,7 +27,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 1}} = @@ -41,7 +43,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -57,7 +60,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -71,7 +75,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 1}} = @@ -86,7 +91,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -103,7 +109,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [tmp_dir], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -121,7 +128,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [tmp_dir], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -137,7 +145,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [tmp_dir, "/var"], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -151,7 +160,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: ["/"], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 1}} = @@ -166,7 +176,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: ["/", tmp_dir], working_dir: "/var", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -186,7 +197,8 @@ defmodule Bash.Builtin.PushdTest do working_dir: "/", variables: %{ "HOME" => Variable.new(home) - } + }, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 0}, updates} = @@ -203,7 +215,8 @@ defmodule Bash.Builtin.PushdTest do working_dir: "/", variables: %{ "HOME" => Variable.new(home) - } + }, + filesystem: {Bash.Filesystem.LocalDisk, nil} } # Use a directory that's likely to exist @@ -217,7 +230,8 @@ defmodule Bash.Builtin.PushdTest do session_state = %{ dir_stack: [], working_dir: "/", - variables: %{} + variables: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil} } assert {:ok, %CommandResult{exit_code: 1}} = diff --git a/test/bash/heredoc_test.exs b/test/bash/heredoc_test.exs index 98f1d29..b410e69 100644 --- a/test/bash/heredoc_test.exs +++ b/test/bash/heredoc_test.exs @@ -20,6 +20,7 @@ defmodule Bash.HeredocTest do aliases: %{}, hash: %{}, functions: %{}, + filesystem: {Bash.Filesystem.LocalDisk, nil}, output_collector: collector, stdout_sink: sink, stderr_sink: sink diff --git a/test/bash/restricted_mode_test.exs b/test/bash/restricted_mode_test.exs new file mode 100644 index 0000000..77f3ac7 --- /dev/null +++ b/test/bash/restricted_mode_test.exs @@ -0,0 +1,310 @@ +defmodule Bash.RestrictedModeTest do + use Bash.SessionCase, async: true + + alias Bash.Session + + defmodule TestAPI do + @moduledoc false + use Bash.Interop, namespace: "restricted_test" + + defbash greet(args, _state) do + name = List.first(args, "world") + Bash.puts("hello #{name}\n") + :ok + end + + defbash add(args, _state) do + sum = + args + |> Enum.map(&String.to_integer/1) + |> Enum.sum() + + Bash.puts("#{sum}\n") + :ok + end + end + + defp start_restricted_session(context) do + registry_name = Module.concat([context.module, RestrictedRegistry, context.test]) + supervisor_name = Module.concat([context.module, RestrictedSupervisor, context.test]) + + _registry = start_supervised!({Registry, keys: :unique, name: registry_name}) + + _supervisor = + start_supervised!({DynamicSupervisor, strategy: :one_for_one, name: supervisor_name}) + + {:ok, session} = + Session.new( + id: "#{context.test}", + registry: registry_name, + supervisor: supervisor_name, + options: %{restricted: true} + ) + + {:ok, %{session: session}} + end + + describe "builtins work in restricted mode" do + setup :start_restricted_session + + test "echo produces output", %{session: session} do + result = run_script(session, "echo hello world") + assert get_stdout(result) == "hello world\n" + end + + test "printf produces formatted output", %{session: session} do + result = run_script(session, ~s(printf "%s %s\\n" foo bar)) + assert get_stdout(result) == "foo bar\n" + end + + test "cd to relative path works", %{session: session} do + result = run_script(session, "cd /tmp && pwd") + assert get_stdout(result) =~ "/tmp" + end + + test "pwd prints working directory", %{session: session} do + result = run_script(session, "pwd") + assert get_stdout(result) != "" + end + + test "variable assignment and expansion", %{session: session} do + result = run_script(session, ~s(x=hello; echo "$x")) + assert get_stdout(result) == "hello\n" + end + + test "arithmetic evaluation", %{session: session} do + result = run_script(session, "echo $((2 + 3))") + assert get_stdout(result) == "5\n" + end + + test "if/else conditional", %{session: session} do + result = run_script(session, ~s(if true; then echo yes; else echo no; fi)) + assert get_stdout(result) == "yes\n" + end + + test "for loop", %{session: session} do + result = run_script(session, "for i in a b c; do echo $i; done") + assert get_stdout(result) == "a\nb\nc\n" + end + + test "while loop", %{session: session} do + script = """ + i=0 + while [ $i -lt 3 ]; do + echo $i + i=$((i + 1)) + done + """ + + result = run_script(session, script) + assert get_stdout(result) == "0\n1\n2\n" + end + + test "function definition and invocation", %{session: session} do + script = """ + greet() { echo "hi $1"; } + greet alice + """ + + result = run_script(session, script) + assert get_stdout(result) == "hi alice\n" + end + + test "array operations", %{session: session} do + script = """ + arr=(one two three) + echo ${arr[1]} + """ + + result = run_script(session, script) + assert get_stdout(result) == "two\n" + end + + test "string manipulation via parameter expansion", %{session: session} do + script = """ + str="hello world" + echo ${str^^} + """ + + result = run_script(session, script) + assert get_stdout(result) == "HELLO WORLD\n" + end + end + + describe "external commands blocked in restricted mode" do + setup :start_restricted_session + + test "simple external command is rejected", %{session: session} do + result = run_script(session, "ls") + assert get_stderr(result) =~ "restricted" + end + + test "absolute path command is rejected", %{session: session} do + result = run_script(session, "/bin/ls") + assert get_stderr(result) =~ "restricted" + end + + test "command builtin with external is rejected", %{session: session} do + result = run_script(session, "command ls") + assert get_stderr(result) =~ "restricted" + end + + test "command -v for external returns failure", %{session: session} do + result = run_script(session, "command -v ls; echo $?") + assert get_stdout(result) == "1\n" + end + + test "exec with external is rejected", %{session: session} do + result = run_script(session, "exec ls") + assert get_stderr(result) =~ "restricted" + end + + test "pipeline containing external command is rejected", %{session: session} do + result = run_script(session, "echo hello | cat") + assert get_stderr(result) =~ "restricted" + end + + test "pipeline of only external commands is rejected", %{session: session} do + result = run_script(session, "ls | cat") + assert get_stderr(result) =~ "restricted" + end + + test "command substitution with external is rejected", %{session: session} do + result = run_script(session, ~s[echo "$(ls)"]) + assert get_stderr(result) =~ "restricted" + end + + test "subshell with external is rejected", %{session: session} do + result = run_script(session, "(ls)") + assert get_stderr(result) =~ "restricted" + end + + test "external command returns non-zero exit code", %{session: session} do + result = run_script(session, "ls; echo $?") + stdout = get_stdout(result) + refute stdout =~ "0\n" + end + + test "background job with external is rejected", %{session: session} do + run_script(session, "ls & wait") + {_stdout, stderr} = Session.get_output(session) + assert stderr =~ "restricted" + end + end + + describe "interop works in restricted mode" do + setup :start_restricted_session + + setup %{session: session} do + Session.load_api(session, TestAPI) + :ok + end + + test "interop function executes normally", %{session: session} do + result = run_script(session, "restricted_test.greet alice") + assert get_stdout(result) == "hello alice\n" + end + + test "interop function with computation", %{session: session} do + result = run_script(session, "restricted_test.add 10 20") + assert get_stdout(result) == "30\n" + end + + test "mixing builtins and interop in a script", %{session: session} do + script = """ + name="world" + restricted_test.greet $name + echo "done" + """ + + result = run_script(session, script) + assert get_stdout(result) =~ "hello world" + assert get_stdout(result) =~ "done" + end + + test "external command still blocked alongside interop", %{session: session} do + script = """ + restricted_test.greet ok + ls + """ + + result = run_script(session, script) + assert get_stderr(result) =~ "restricted" + end + end + + describe "restricted mode inherits to child contexts" do + setup :start_restricted_session + + test "subshell inherits restricted mode", %{session: session} do + result = run_script(session, "(ls)") + assert get_stderr(result) =~ "restricted" + end + + test "command substitution inherits restricted mode", %{session: session} do + result = run_script(session, ~s[x=$(ls); echo "$x"]) + assert get_stderr(result) =~ "restricted" + end + + test "eval inherits restricted mode", %{session: session} do + result = run_script(session, ~s[eval "ls"]) + assert get_stderr(result) =~ "restricted" + end + + test "nested subshell inherits restricted mode", %{session: session} do + result = run_script(session, "( (ls) )") + assert get_stderr(result) =~ "restricted" + end + + test "eval in subshell inherits restricted mode", %{session: session} do + result = run_script(session, ~s[(eval "ls")]) + assert get_stderr(result) =~ "restricted" + end + end + + describe "restricted flag is immutable" do + setup :start_restricted_session + + test "set +o restricted does not disable restricted mode", %{session: session} do + run_script(session, "set +o restricted") + result = run_script(session, "ls") + assert get_stderr(result) =~ "restricted" + end + + test "set -o restricted is a no-op when already restricted", %{session: session} do + run_script(session, "set -o restricted") + state = Session.get_state(session) + assert state.options[:restricted] == true + end + + test "shopt -u restricted_shell does not disable restricted mode", %{session: session} do + run_script(session, "shopt -u restricted_shell") + result = run_script(session, "ls") + assert get_stderr(result) =~ "restricted" + end + + test "shopt restricted_shell reflects actual state", %{session: session} do + result = run_script(session, "shopt restricted_shell") + assert get_stdout(result) =~ "on" + end + end + + describe "unrestricted mode is unaffected" do + setup :start_session + + test "external commands work in unrestricted mode", %{session: session} do + result = run_script(session, "echo hello") + assert get_stdout(result) == "hello\n" + end + + test "session options do not include restricted by default", %{session: session} do + state = Session.get_state(session) + refute state.options[:restricted] + end + + test "builtins work normally in unrestricted mode", %{session: session} do + result = run_script(session, "echo test && true") + assert get_stdout(result) == "test\n" + end + end +end diff --git a/test/bash/virtual_filesystem_test.exs b/test/bash/virtual_filesystem_test.exs new file mode 100644 index 0000000..f9967e5 --- /dev/null +++ b/test/bash/virtual_filesystem_test.exs @@ -0,0 +1,1114 @@ +defmodule Bash.VirtualFilesystemTest do + use Bash.SessionCase, async: true + + alias Bash.Session + + defmodule InMemory do + @moduledoc false + @behaviour Bash.Filesystem + + def start(initial_files \\ %{}) do + {:ok, pid} = Agent.start(fn -> initial_files end) + {__MODULE__, pid} + end + + def stop({__MODULE__, pid}) do + Agent.stop(pid) + catch + :exit, _ -> :ok + end + + defp normalize(path), do: Path.expand(path) + + @impl true + def exists?(pid, path) do + path = normalize(path) + + Agent.get(pid, fn files -> + case Map.get(files, path) do + nil -> + Enum.any?(files, fn + {{:_device, _}, _} -> false + {k, _v} -> String.starts_with?(k, path <> "/") + end) + + _ -> + true + end + end) + end + + @impl true + def dir?(pid, path) do + path = normalize(path) + + Agent.get(pid, fn files -> + Map.get(files, path) == :directory or + Enum.any?(files, fn + {{:_device, _}, _} -> + false + + {k, _v} -> + k != path and String.starts_with?(k, path <> "/") + end) + end) + end + + @impl true + def regular?(pid, path) do + path = normalize(path) + + Agent.get(pid, fn files -> + case Map.get(files, path) do + nil -> false + :directory -> false + {_content, opts} when is_list(opts) -> Keyword.get(opts, :type, :regular) != :directory + _ -> true + end + end) + end + + @impl true + def stat(pid, path) do + path = normalize(path) + + Agent.get(pid, fn files -> + case Map.get(files, path) do + nil -> + if Enum.any?(files, fn + {{:_device, _}, _} -> false + {k, _} -> k != path and String.starts_with?(k, path <> "/") + end) do + {:ok, + %File.Stat{ + type: :directory, + size: 0, + mode: 0o755, + mtime: {{2024, 1, 1}, {0, 0, 0}}, + atime: {{2024, 1, 1}, {0, 0, 0}}, + inode: 0, + major_device: 0 + }} + else + {:error, :enoent} + end + + :directory -> + {:ok, + %File.Stat{ + type: :directory, + size: 0, + mode: 0o755, + mtime: {{2024, 1, 1}, {0, 0, 0}}, + atime: {{2024, 1, 1}, {0, 0, 0}}, + inode: 0, + major_device: 0 + }} + + {content, opts} when is_binary(content) and is_list(opts) -> + {:ok, + %File.Stat{ + type: Keyword.get(opts, :type, :regular), + size: byte_size(content), + mode: Keyword.get(opts, :mode, 0o644), + mtime: Keyword.get(opts, :mtime, {{2024, 1, 1}, {0, 0, 0}}), + atime: Keyword.get(opts, :atime, {{2024, 1, 1}, {0, 0, 0}}), + inode: Keyword.get(opts, :inode, 0), + major_device: Keyword.get(opts, :major_device, 0) + }} + + content when is_binary(content) -> + {:ok, + %File.Stat{ + type: :regular, + size: byte_size(content), + mode: 0o644, + mtime: {{2024, 1, 1}, {0, 0, 0}}, + atime: {{2024, 1, 1}, {0, 0, 0}}, + inode: 0, + major_device: 0 + }} + end + end) + end + + @impl true + def read(pid, path) do + path = normalize(path) + + Agent.get(pid, fn files -> + case Map.get(files, path) do + nil -> {:error, :enoent} + :directory -> {:error, :eisdir} + {content, opts} when is_binary(content) and is_list(opts) -> {:ok, content} + content when is_binary(content) -> {:ok, content} + end + end) + end + + @impl true + def write(pid, path, content, opts) do + path = normalize(path) + + Agent.update(pid, fn files -> + current = Map.get(files, path, "") + + current_content = + case current do + {bin, _opts} when is_binary(bin) -> bin + bin when is_binary(bin) -> bin + _ -> "" + end + + new_content = + if Keyword.get(opts, :append, false) do + current_content <> IO.iodata_to_binary(content) + else + IO.iodata_to_binary(content) + end + + Map.put(files, path, new_content) + end) + end + + @impl true + def mkdir_p(pid, path) do + path = normalize(path) + Agent.update(pid, fn files -> Map.put(files, path, :directory) end) + end + + @impl true + def rm(pid, path) do + path = normalize(path) + Agent.update(pid, fn files -> Map.delete(files, path) end) + end + + @impl true + def open(pid, path, modes) do + path = normalize(path) + + cond do + :write in modes or :append in modes -> + is_append = :append in modes + + existing_content = + if is_append do + case Agent.get(pid, &Map.get(&1, path, "")) do + {bin, _opts} when is_binary(bin) -> bin + bin when is_binary(bin) -> bin + _ -> "" + end + else + "" + end + + {:ok, device} = StringIO.open("") + + Agent.update(pid, fn files -> + Map.put(files, {:_device, device}, {:write_to, path, is_append, existing_content}) + end) + + {:ok, device} + + :read in modes -> + case Agent.get(pid, &Map.get(&1, path)) do + nil -> + {:error, :enoent} + + :directory -> + {:error, :eisdir} + + {content, _opts} when is_binary(content) -> + {:ok, device} = StringIO.open(content) + {:ok, device} + + content when is_binary(content) -> + {:ok, device} = StringIO.open(content) + {:ok, device} + end + + true -> + {:error, :einval} + end + end + + @impl true + def handle_write(_pid, device, data) do + IO.binwrite(device, data) + end + + @impl true + def handle_close(pid, device) do + case Agent.get(pid, &Map.get(&1, {:_device, device})) do + {:write_to, path, is_append, existing_content} -> + {_input, output} = StringIO.contents(device) + + final_content = + if is_append do + existing_content <> output + else + output + end + + Agent.update(pid, fn files -> + files + |> Map.delete({:_device, device}) + |> Map.put(path, final_content) + end) + + StringIO.close(device) + :ok + + nil -> + StringIO.close(device) + :ok + end + end + + @impl true + def ls(pid, dir_path) do + dir_path = normalize(dir_path) + + Agent.get(pid, fn files -> + entries = + files + |> Enum.filter(fn + {{:_device, _}, _} -> + false + + {k, _v} -> + parent = Path.dirname(k) + parent == dir_path or (dir_path == "/" and parent == "/") + end) + |> Enum.map(fn {k, _v} -> Path.basename(k) end) + |> Enum.uniq() + |> Enum.sort() + + if entries == [] do + if Map.has_key?(files, dir_path) or + Enum.any?(files, fn + {{:_device, _}, _} -> false + {k, _} -> k != dir_path and String.starts_with?(k, dir_path <> "/") + end) do + {:ok, []} + else + {:error, :enoent} + end + else + {:ok, entries} + end + end) + end + + @impl true + def wildcard(pid, pattern, _opts) do + Agent.get(pid, fn files -> + regex_str = + pattern + |> Regex.escape() + |> String.replace("\\*", "[^/]*") + |> String.replace("\\?", "[^/]") + + case Regex.compile("^#{regex_str}$") do + {:ok, regex} -> + files + |> Map.keys() + |> Enum.filter(fn + {:_device, _} -> false + k when is_binary(k) -> Regex.match?(regex, k) + _ -> false + end) + |> Enum.sort() + + {:error, _} -> + [] + end + end) + end + end + + @enforcement_base "/nonexistent_vfs_enforcement_path/workspace" + + defp start_enforcement_session(context, initial_files, opts \\ []) do + start_vfs_session(context, initial_files, [{:working_dir, @enforcement_base} | opts]) + end + + defp start_vfs_session(context, initial_files, opts \\ []) do + fs = InMemory.start(initial_files) + working_dir = Keyword.get(opts, :working_dir, "/workspace") + + registry_name = Module.concat([context.module, VFSRegistry, context.test]) + supervisor_name = Module.concat([context.module, VFSSupervisor, context.test]) + + _registry = + start_supervised!({Registry, keys: :unique, name: registry_name}, id: registry_name) + + _supervisor = + start_supervised!( + {DynamicSupervisor, strategy: :one_for_one, name: supervisor_name}, + id: supervisor_name + ) + + session_opts = [ + filesystem: fs, + working_dir: working_dir, + id: "#{context.test}", + registry: registry_name, + supervisor: supervisor_name + ] + + session_opts = + if opts[:restricted], + do: Keyword.put(session_opts, :options, %{restricted: true}), + else: session_opts + + {:ok, session} = Session.new(session_opts) + + on_exit(fn -> InMemory.stop(fs) end) + + {session, fs} + end + + describe "default (no filesystem option) is unchanged" do + setup :start_session + + test "sessions without filesystem option work identically", %{session: session} do + result = run_script(session, "echo hello") + assert get_stdout(result) == "hello\n" + end + end + + describe "file test operators with virtual filesystem" do + test "test -e checks VFS for existence", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/existing.txt" => "content" + }) + + result = run_script(session, "test -e existing.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + + result = run_script(session, "test -e missing.txt && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + + test "test -f checks VFS for regular file", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/file.txt" => "content", + "/workspace/dir" => :directory + }) + + result = run_script(session, "test -f file.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + + result = run_script(session, "test -f dir && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + + test "test -d checks VFS for directory", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/dir" => :directory, + "/workspace/file.txt" => "content" + }) + + result = run_script(session, "test -d dir && echo yes || echo no") + assert get_stdout(result) == "yes\n" + + result = run_script(session, "test -d file.txt && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + + test "test -s checks VFS for non-empty file", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/notempty.txt" => "content", + "/workspace/empty.txt" => "" + }) + + result = run_script(session, "test -s notempty.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + + result = run_script(session, "test -s empty.txt && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + + test "test -r checks VFS for readable file", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/readable.txt" => "content" + }) + + result = run_script(session, "test -r readable.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + end + + describe "source builtin with virtual filesystem" do + test "source reads from VFS", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/config.sh" => "MY_VAR=hello_from_vfs" + }) + + run_script(session, "source ./config.sh") + result = run_script(session, "echo $MY_VAR") + assert get_stdout(result) == "hello_from_vfs\n" + end + + test "source reports error for missing VFS file", context do + {session, _fs} = start_vfs_session(context, %{}) + + result = run_script(session, "source ./missing.sh") + assert get_stderr(result) =~ "No such file or directory" + end + end + + describe "output redirections with virtual filesystem" do + test "echo > file writes to VFS", context do + {session, fs} = + start_vfs_session(context, %{"/workspace" => :directory}) + + run_script(session, "echo hello > output.txt") + + {_, pid} = fs + content = Agent.get(pid, &Map.get(&1, "/workspace/output.txt")) + assert content == "hello\n" + end + + test "echo >> file appends to VFS", context do + {session, fs} = + start_vfs_session(context, %{ + "/workspace" => :directory, + "/workspace/output.txt" => "first\n" + }) + + run_script(session, "echo second >> output.txt") + + {_, pid} = fs + content = Agent.get(pid, &Map.get(&1, "/workspace/output.txt")) + assert content == "first\nsecond\n" + end + end + + describe "input redirections with virtual filesystem" do + test "read builtin receives input from VFS redirect", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/input.txt" => "hello from vfs" + }) + + result = run_script(session, "read line < input.txt && echo $line") + assert get_stdout(result) =~ "hello from vfs" + end + end + + describe "cd / pwd with virtual filesystem" do + test "cd validates against VFS directories", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/subdir" => :directory + }) + + result = run_script(session, "cd subdir && pwd") + assert get_stdout(result) == "/workspace/subdir\n" + end + + test "cd rejects non-existent VFS directory", context do + {session, _fs} = start_vfs_session(context, %{}) + + result = run_script(session, "cd nonexistent 2>&1") + assert get_stdout(result) =~ "No such file or directory" + end + end + + describe "filesystem propagates to child contexts" do + test "subshell inherits VFS", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/data.txt" => "vfs_content" + }) + + result = run_script(session, "(test -f data.txt && echo yes || echo no)") + assert get_stdout(result) == "yes\n" + end + + test "command substitution inherits VFS", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/data.txt" => "vfs_content" + }) + + result = + run_script(session, "result=$(test -f data.txt && echo yes || echo no); echo $result") + + assert get_stdout(result) == "yes\n" + end + end + + describe "fully sandboxed session" do + test "restricted mode + VFS blocks commands and uses virtual files", context do + {session, _fs} = + start_vfs_session( + context, + %{"/workspace/data.txt" => "sandboxed"}, + restricted: true + ) + + result = run_script(session, "test -f data.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + end + + describe "auto-enforced restricted mode for non-LocalDisk filesystems" do + test "VFS session auto-sets restricted: true in state", context do + {session, _fs} = start_vfs_session(context, %{"/workspace/file.txt" => "hello"}) + + state = Session.get_state(session) + assert state.options[:restricted] == true + end + + test "shopt restricted_shell reports on with VFS", context do + {session, _fs} = start_vfs_session(context, %{"/workspace/file.txt" => "hello"}) + + result = run_script(session, "shopt restricted_shell") + assert get_stdout(result) =~ "on" + end + + test "external commands are blocked when only VFS is specified", context do + {session, _fs} = start_vfs_session(context, %{"/workspace/file.txt" => "hello"}) + + result = run_script(session, "ls") + assert get_stderr(result) =~ "restricted" + end + + test "LocalDisk sessions remain unrestricted by default", context do + registry_name = Module.concat([context.module, LDRegistry, context.test]) + supervisor_name = Module.concat([context.module, LDSupervisor, context.test]) + + _registry = + start_supervised!({Registry, keys: :unique, name: registry_name}, id: registry_name) + + _supervisor = + start_supervised!( + {DynamicSupervisor, strategy: :one_for_one, name: supervisor_name}, + id: supervisor_name + ) + + {:ok, session} = + Session.new( + id: "#{context.test}", + registry: registry_name, + supervisor: supervisor_name + ) + + state = Session.get_state(session) + assert state.options[:restricted] == false + end + end + + describe "glob expansion with virtual filesystem" do + test "*.txt expands against VFS files", context do + {session, _fs} = + start_vfs_session(context, %{ + "/workspace/a.txt" => "a", + "/workspace/b.txt" => "b", + "/workspace/c.log" => "c" + }) + + result = run_script(session, "echo *.txt") + stdout = get_stdout(result) + assert stdout =~ "a.txt" + assert stdout =~ "b.txt" + refute stdout =~ "c.log" + end + + test "glob with no matches returns pattern literally", context do + {session, _fs} = start_vfs_session(context, %{}) + + result = run_script(session, "echo *.xyz") + assert get_stdout(result) == "*.xyz\n" + end + end + + describe "VFS enforcement: file test operators on non-host paths" do + test "test -e detects file that only exists in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/data.txt") => "exists only in VFS" + }) + + result = run_script(session, "test -e data.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -f detects regular file in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/file.txt") => "regular file" + }) + + result = run_script(session, "test -f file.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -d detects directory in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/mydir") => :directory + }) + + result = run_script(session, "test -d mydir && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -s detects non-empty file in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/notempty.txt") => "has content" + }) + + result = run_script(session, "test -s notempty.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -r detects readable file via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/readable.txt") => {"content", mode: 0o644} + }) + + result = run_script(session, "test -r readable.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -w detects writable file via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/writable.txt") => {"content", mode: 0o644}, + (@enforcement_base <> "/readonly.txt") => {"content", mode: 0o444} + }) + + result = run_script(session, "test -w writable.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + + result = run_script(session, "test -w readonly.txt && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + + test "test -x detects executable file via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/script.sh") => {"#!/bin/bash", mode: 0o755}, + (@enforcement_base <> "/data.txt") => {"content", mode: 0o644} + }) + + result = run_script(session, "test -x script.sh && echo yes || echo no") + assert get_stdout(result) == "yes\n" + + result = run_script(session, "test -x data.txt && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + + test "test -g detects setgid via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/setgid_file") => {"content", mode: 0o2755} + }) + + result = run_script(session, "test -g setgid_file && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -u detects setuid via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/setuid_file") => {"content", mode: 0o4755} + }) + + result = run_script(session, "test -u setuid_file && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -k detects sticky bit via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/sticky_dir") => {"content", mode: 0o1755} + }) + + result = run_script(session, "test -k sticky_dir && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test -N detects file modified since read via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/modified.txt") => + {"content", mtime: {{2024, 6, 1}, {12, 0, 0}}, atime: {{2024, 1, 1}, {0, 0, 0}}} + }) + + result = run_script(session, "test -N modified.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test file1 -nt file2 compares via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/newer.txt") => {"content", mtime: {{2024, 6, 1}, {0, 0, 0}}}, + (@enforcement_base <> "/older.txt") => {"content", mtime: {{2024, 1, 1}, {0, 0, 0}}} + }) + + result = run_script(session, "test newer.txt -nt older.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test file1 -ot file2 compares via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/newer.txt") => {"content", mtime: {{2024, 6, 1}, {0, 0, 0}}}, + (@enforcement_base <> "/older.txt") => {"content", mtime: {{2024, 1, 1}, {0, 0, 0}}} + }) + + result = run_script(session, "test older.txt -ot newer.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "test file1 -ef file2 compares inode via VFS stat", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/file1.txt") => {"content", inode: 42, major_device: 1}, + (@enforcement_base <> "/hardlink.txt") => {"content", inode: 42, major_device: 1} + }) + + result = run_script(session, "test file1.txt -ef hardlink.txt && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + + test "[[ ]] test form also uses VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/data.txt") => "content" + }) + + result = run_script(session, "[[ -f data.txt ]] && echo yes || echo no") + assert get_stdout(result) == "yes\n" + end + end + + describe "VFS enforcement: source builtin on non-host paths" do + test "source reads script content from VFS only", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/setup.sh") => "SOURCED_VAR=from_vfs" + }) + + run_script(session, "source ./setup.sh") + result = run_script(session, "echo $SOURCED_VAR") + assert get_stdout(result) == "from_vfs\n" + end + end + + describe "VFS enforcement: output redirections on non-host paths" do + test "echo > file writes to VFS, not host", context do + {session, fs} = + start_enforcement_session(context, %{ + @enforcement_base => :directory + }) + + run_script(session, "echo enforced > output.txt") + + {_, pid} = fs + content = Agent.get(pid, &Map.get(&1, @enforcement_base <> "/output.txt")) + assert content == "enforced\n" + refute File.exists?(@enforcement_base <> "/output.txt") + end + + test "echo >> file appends to VFS, not host", context do + {session, fs} = + start_enforcement_session(context, %{ + @enforcement_base => :directory, + (@enforcement_base <> "/log.txt") => "line1\n" + }) + + run_script(session, "echo line2 >> log.txt") + + {_, pid} = fs + content = Agent.get(pid, &Map.get(&1, @enforcement_base <> "/log.txt")) + assert content == "line1\nline2\n" + end + end + + describe "VFS enforcement: input redirections on non-host paths" do + test "read < file reads from VFS only", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/input.txt") => "vfs_input_data" + }) + + result = run_script(session, "read line < input.txt && echo $line") + assert get_stdout(result) == "vfs_input_data\n" + end + end + + describe "VFS enforcement: glob expansion on non-host paths" do + test "glob expands against VFS files only", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/alpha.txt") => "a", + (@enforcement_base <> "/beta.txt") => "b", + (@enforcement_base <> "/gamma.log") => "c" + }) + + result = run_script(session, "echo *.txt") + stdout = get_stdout(result) + assert stdout =~ "alpha.txt" + assert stdout =~ "beta.txt" + refute stdout =~ "gamma.log" + end + end + + describe "VFS enforcement: cd/pwd on non-host paths" do + test "cd validates directory existence in VFS only", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/vfs_only_dir") => :directory + }) + + result = run_script(session, "cd vfs_only_dir && pwd") + assert get_stdout(result) == @enforcement_base <> "/vfs_only_dir\n" + end + + test "cd - switches to previous VFS directory", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/dir_a") => :directory, + (@enforcement_base <> "/dir_b") => :directory + }) + + run_script(session, "cd dir_a") + run_script(session, "cd #{@enforcement_base}/dir_b") + result = run_script(session, "cd - && pwd") + stdout = get_stdout(result) + assert stdout =~ "dir_a" + end + end + + describe "VFS enforcement: noclobber on non-host paths" do + test "set -C checks file existence in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/existing.txt") => "original" + }) + + result = run_script(session, "set -C; echo overwrite > existing.txt") + assert get_stderr(result) =~ "cannot overwrite existing file" + + result = run_script(session, "set -C; echo new > fresh.txt; echo $?") + assert get_stdout(result) =~ "0" + end + end + + describe "VFS enforcement: while loop input redirect on non-host paths" do + test "while read line; done < file reads from VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/lines.txt") => "line1\nline2\nline3\n" + }) + + result = + run_script(session, ~s|while read line; do echo "got: $line"; done < lines.txt|) + + stdout = get_stdout(result) + assert stdout =~ "got: line1" + assert stdout =~ "got: line2" + assert stdout =~ "got: line3" + end + end + + describe "VFS enforcement: subshell and command substitution on non-host paths" do + test "subshell inherits VFS with non-host paths", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/vfs_only.txt") => "content" + }) + + result = run_script(session, "(test -f vfs_only.txt && echo yes || echo no)") + assert get_stdout(result) == "yes\n" + end + + test "command substitution inherits VFS with non-host paths", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/vfs_only.txt") => "content" + }) + + result = + run_script(session, "x=$(test -f vfs_only.txt && echo yes || echo no); echo $x") + + assert get_stdout(result) == "yes\n" + end + + test "subshell cd validates against VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/sub") => :directory + }) + + result = run_script(session, "(cd sub && pwd)") + assert get_stdout(result) == @enforcement_base <> "/sub\n" + end + end + + describe "VFS enforcement: pushd/popd on non-host paths" do + test "pushd validates directory in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/pushdir") => :directory + }) + + result = run_script(session, "pushd pushdir && pwd") + assert get_stdout(result) =~ @enforcement_base <> "/pushdir" + end + + test "pushd rejects non-existent VFS directory", context do + {session, _fs} = start_enforcement_session(context, %{}) + + result = run_script(session, "pushd ghost 2>&1") + assert get_stdout(result) =~ "No such file or directory" + end + + test "popd validates directory in VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/first") => :directory, + (@enforcement_base <> "/second") => :directory + }) + + run_script(session, "pushd first") + run_script(session, "pushd #{@enforcement_base}/second") + result = run_script(session, "popd && pwd") + assert get_stdout(result) =~ "first" + end + end + + describe "VFS enforcement: file descriptors on non-host paths" do + test "exec 3>file writes through VFS", context do + {session, fs} = + start_enforcement_session(context, %{ + @enforcement_base => :directory + }) + + run_script(session, "exec 3> fdout.txt; echo hello >&3; exec 3>&-") + + {_, pid} = fs + content = Agent.get(pid, &Map.get(&1, @enforcement_base <> "/fdout.txt")) + assert content =~ "hello" + end + + test "read < file reads through VFS file descriptor", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/fdinput.txt") => "fd_line1\nfd_line2\n" + }) + + result = run_script(session, "read line < fdinput.txt; echo $line") + assert get_stdout(result) =~ "fd_line1" + end + end + + describe "VFS enforcement: CDPATH on non-host paths" do + test "cd searches CDPATH directories in VFS", context do + cdpath_base = "/nonexistent_vfs_enforcement_path/cdpath_root" + + {session, _fs} = + start_enforcement_session(context, %{ + (cdpath_base <> "/target") => :directory + }) + + run_script(session, "export CDPATH=#{cdpath_base}") + result = run_script(session, "cd target && pwd") + assert get_stdout(result) =~ cdpath_base <> "/target" + end + end + + describe "VFS enforcement: eval and function VFS inheritance" do + test "eval inherits VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/evaldata.txt") => "eval_content" + }) + + result = run_script(session, "eval 'test -f evaldata.txt && echo yes || echo no'") + assert get_stdout(result) == "yes\n" + end + + test "function calls inherit VFS", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/funcdata.txt") => "func_content" + }) + + result = + run_script(session, """ + check_file() { test -f funcdata.txt && echo yes || echo no; } + check_file + """) + + assert get_stdout(result) == "yes\n" + end + end + + describe "VFS enforcement: symlink test fallback" do + test "test -L returns false for regular VFS files (no lstat support)", context do + {session, _fs} = + start_enforcement_session(context, %{ + (@enforcement_base <> "/regular.txt") => "content" + }) + + result = run_script(session, "test -L regular.txt && echo yes || echo no") + assert get_stdout(result) == "no\n" + end + end + + describe "VFS enforcement: command/type/hash PATH lookup on non-host paths" do + test "type finds command in VFS PATH", context do + vfs_bin = "/nonexistent_vfs_enforcement_path/bin" + + {session, _fs} = + start_enforcement_session(context, %{ + (vfs_bin <> "/mycmd") => {"#!/bin/bash", mode: 0o755} + }) + + run_script(session, "export PATH=#{vfs_bin}") + result = run_script(session, "type -t mycmd") + assert get_stdout(result) == "file\n" + end + + test "command -v finds command in VFS PATH", context do + vfs_bin = "/nonexistent_vfs_enforcement_path/bin" + + {session, _fs} = + start_enforcement_session(context, %{ + (vfs_bin <> "/findme") => {"#!/bin/bash", mode: 0o755} + }) + + run_script(session, "export PATH=#{vfs_bin}") + result = run_script(session, "command -v findme") + assert get_stdout(result) =~ "findme" + end + + test "hash caches command path from VFS", context do + vfs_bin = "/nonexistent_vfs_enforcement_path/bin" + + {session, _fs} = + start_enforcement_session(context, %{ + (vfs_bin <> "/hashme") => {"#!/bin/bash", mode: 0o755} + }) + + run_script(session, "export PATH=#{vfs_bin}") + result = run_script(session, "hash hashme && hash -t hashme") + assert get_stdout(result) =~ vfs_bin <> "/hashme" + end + end +end diff --git a/test/support/session_case.ex b/test/support/session_case.ex index 13c1a1c..ae12cb5 100644 --- a/test/support/session_case.ex +++ b/test/support/session_case.ex @@ -53,6 +53,7 @@ defmodule Bash.SessionCase do case Session.execute(session, ast) do {:ok, result} -> result {:exit, result} -> result + {:exec, result} -> result {:error, result} -> result end end