diff --git a/.gitignore b/.gitignore index 5962ecc..20ef9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ erl_crash.dump bash-*.tar .grepai/ +.claude/settings.local.json docs/plans diff --git a/lib/bash/ast/command.ex b/lib/bash/ast/command.ex index 87a698b..368d0b5 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 diff --git a/lib/bash/ast/coproc.ex b/lib/bash/ast/coproc.ex index c830c86..885b877 100644 --- a/lib/bash/ast/coproc.ex +++ b/lib/bash/ast/coproc.ex @@ -8,7 +8,7 @@ defmodule Bash.AST.Coproc do the coproc array variable and PID variable use that name. Otherwise, the default name "COPROC" is used. - Simple commands are executed via `ExCmd.Process` (external OS process). + Simple commands are executed via `CommandPort` (external OS process). Compound commands are executed within the Elixir bash interpreter in a spawned BEAM process with message-passing I/O. diff --git a/lib/bash/ast/helpers.ex b/lib/bash/ast/helpers.ex index b253cc1..a4ef03b 100644 --- a/lib/bash/ast/helpers.ex +++ b/lib/bash/ast/helpers.ex @@ -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..5a9dc3c 100644 --- a/lib/bash/ast/pipeline.ex +++ b/lib/bash/ast/pipeline.ex @@ -23,7 +23,9 @@ defmodule Bash.AST.Pipeline do alias Bash.AST alias Bash.AST.Helpers alias Bash.Builtin + alias Bash.CommandPort alias Bash.Executor + alias Bash.Session alias Bash.OutputCollector alias Bash.Sink alias Bash.Variable @@ -83,7 +85,7 @@ defmodule Bash.AST.Pipeline do end # Execute a mixed pipeline by segmenting into external command runs and non-external commands. - # External segments stream via ExCmd. Non-external commands execute individually. + # External segments stream via CommandPort. Non-external commands execute individually. # Only accumulates at builtin boundaries that require stdin. defp execute_mixed( %__MODULE__{commands: commands, negate: negate, meta: meta} = pipeline, @@ -291,9 +293,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 +499,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 +534,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 @@ -622,16 +644,22 @@ defmodule Bash.AST.Pipeline do end end - # Build nested ExCmd.stream calls from innermost (first command) to outermost (last command) + # Build nested stream pipeline 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 = Session.restricted?(session_state) - ExCmd.stream([name | args], + opts = [ input: stdin, cd: session_state.working_dir, env: env, stderr: :redirect_to_stdout - ) + ] + + case CommandPort.stream([name | args], opts, restricted) do + {:error, :restricted} -> Stream.map([], & &1) + stream -> stream + end end defp build_stream_pipeline([cmd | rest], stdin, session_state) do @@ -639,7 +667,7 @@ defmodule Bash.AST.Pipeline do upstream = build_stream_pipeline(rest, stdin, session_state) # Filter out exit tuples - only pass binary data to downstream command - # ExCmd.stream yields {:exit, exit_info} as last element which can't be used as input + # CommandPort.stream yields {:exit, exit_info} as last element which can't be used as input filtered_upstream = Stream.filter(upstream, fn {:exit, _} -> false @@ -648,13 +676,19 @@ defmodule Bash.AST.Pipeline do end) {name, args, env} = resolve_external_command(cmd, session_state) + restricted = Session.restricted?(session_state) - ExCmd.stream([name | args], + opts = [ input: filtered_upstream, cd: session_state.working_dir, env: env, stderr: :redirect_to_stdout - ) + ] + + case CommandPort.stream([name | args], opts, restricted) do + {: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..8dc2bb0 100644 --- a/lib/bash/ast/while_loop.ex +++ b/lib/bash/ast/while_loop.ex @@ -391,7 +391,14 @@ defmodule Bash.AST.WhileLoop do %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) + file_path = + file_word + |> Bash.AST.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 File.read(file_path) do {:ok, content} -> content diff --git a/lib/bash/builtin/command.ex b/lib/bash/builtin/command.ex index 05d9d15..3060517 100644 --- a/lib/bash/builtin/command.ex +++ b/lib/bash/builtin/command.ex @@ -18,6 +18,8 @@ defmodule Bash.Builtin.Command do use Bash.Builtin alias Bash.Builtin + alias Bash.CommandPort + alias Bash.Session alias Bash.Variable # Standard utilities path that is guaranteed to find all standard utilities @@ -282,7 +284,11 @@ defmodule Bash.Builtin.Command do ] try do - case System.cmd(path, args, cmd_opts) do + case CommandPort.system_cmd(path, args, cmd_opts, Session.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..ec3fc3a 100644 --- a/lib/bash/builtin/coproc.ex +++ b/lib/bash/builtin/coproc.ex @@ -35,7 +35,7 @@ defmodule Bash.Builtin.Coproc do ```mermaid stateDiagram-v2 [*] --> running: start_link external - running --> running: read/write via ExCmd.Process + running --> running: read/write via CommandPort running --> closing: close_stdin closing --> stopped: process exits running --> stopped: process exits @@ -65,6 +65,7 @@ defmodule Bash.Builtin.Coproc do require Logger + alias Bash.CommandPort alias Bash.Executor alias Bash.Variable @@ -263,10 +264,10 @@ defmodule Bash.Builtin.Coproc do stderr: :redirect_to_stdout ] - case ExCmd.Process.start_link(cmd, proc_opts) do + case CommandPort.start_link(cmd, proc_opts, false) do {:ok, pid} -> os_pid = - case ExCmd.Process.os_pid(pid) do + case CommandPort.os_pid(pid) do {:ok, os_pid} -> os_pid os_pid when is_integer(os_pid) -> os_pid end @@ -326,7 +327,7 @@ defmodule Bash.Builtin.Coproc do end def handle_call(:read, _from, %{mode: :external} = state) do - case ExCmd.Process.read(state.proc) do + case CommandPort.read(state.proc) do {:ok, data} -> {:reply, {:ok, data}, state} :eof -> {:reply, :eof, state} {:error, reason} -> {:reply, {:error, reason}, state} @@ -341,7 +342,7 @@ defmodule Bash.Builtin.Coproc do end def handle_call({:write, data}, _from, %{mode: :external} = state) do - case ExCmd.Process.write(state.proc, data) do + case CommandPort.write(state.proc, data) do :ok -> {:reply, :ok, state} {:error, reason} -> {:reply, {:error, reason}, state} end @@ -355,7 +356,7 @@ defmodule Bash.Builtin.Coproc do end def handle_call(:close_stdin, _from, %{mode: :external} = state) do - ExCmd.Process.close_stdin(state.proc) + CommandPort.close_stdin(state.proc) {:reply, :ok, state} end @@ -365,7 +366,7 @@ defmodule Bash.Builtin.Coproc do end def handle_call(:close_stdout, _from, %{mode: :external} = state) do - ExCmd.Process.close_stdout(state.proc) + CommandPort.close_stdout(state.proc) {:reply, :ok, state} end @@ -374,7 +375,7 @@ defmodule Bash.Builtin.Coproc do end def handle_call(:status, _from, %{mode: :external} = state) do - case ExCmd.Process.await_exit(state.proc, 0) do + case CommandPort.await_exit(state.proc, 0) do {:ok, exit_code} -> {:reply, {:exited, exit_code}, state} {:error, :timeout} -> {:reply, :running, state} {:error, reason} -> {:reply, {:error, reason}, state} @@ -409,8 +410,8 @@ defmodule Bash.Builtin.Coproc do @impl true def terminate(_reason, %{mode: :external} = state) do if state.proc do - ExCmd.Process.close_stdin(state.proc) - ExCmd.Process.await_exit(state.proc, 1000) + CommandPort.close_stdin(state.proc) + CommandPort.await_exit(state.proc, 1000) end :ok diff --git a/lib/bash/command_port.ex b/lib/bash/command_port.ex index 695a5fe..7156d80 100644 --- a/lib/bash/command_port.ex +++ b/lib/bash/command_port.ex @@ -1,39 +1,135 @@ defmodule Bash.CommandPort do @moduledoc """ - Executes external commands using ExCmd. + Single interface for all user-facing OS process execution. - ExCmd provides proper stdin/stdout/stderr separation with backpressure, - unlike native Erlang ports which cannot close stdin separately from stdout. + Provides two layers of API: - ## Streaming + ## High-level API - By default, output is accumulated for backwards compatibility. To stream - output without accumulation, pass a `:sink` option: + `execute/3` runs a command to completion, streaming output to a sink: - # Streaming to callback - sink = Bash.Sink.Passthrough.new(fn chunk -> IO.inspect(chunk) end) CommandPort.execute("cat", ["bigfile.txt"], sink: sink) - # Streaming to file - {sink, close} = Bash.Sink.File.new("/tmp/output.txt") - CommandPort.execute("cat", ["bigfile.txt"], sink: sink) - close.() + ## Low-level Process API + + For callers that manage process lifecycles directly (JobProcess, Coproc): + + {:ok, proc} = CommandPort.start_link(["cat"], opts) + CommandPort.write(proc, "data") + CommandPort.close_stdin(proc) + {:ok, data} = CommandPort.read(proc) + {:ok, 0} = CommandPort.await_exit(proc, 5000) + + ## Streaming API + + For pipeline streaming: + + stream = CommandPort.stream(["sort"], input: upstream) + + ## Restricted Mode + + All process-spawning functions accept a `restricted` boolean. When `true`, + they return `{:error, :restricted}` instead of spawning a process. + + Internal plumbing (signal delivery, hostname lookup, named pipe creation) + is exempt and continues to use `System.cmd` directly. + + ```mermaid + graph TD + AST[AST.Command] --> CP[CommandPort] + JP[JobProcess] --> CP + CO[Coproc] --> CP + PL[Pipeline] --> CP + CM[Command builtin] --> CP + CP -->|restricted?| ERR["{:error, :restricted}"] + CP -->|allowed| EX[ExCmd / System.cmd] + ``` """ alias Bash.CommandResult - # Executes a command with optional stdin input. - # - # Returns {:ok, %CommandResult{}} or {:error, %CommandResult{}}. - # Output is streamed to the sink during execution rather than accumulated. - # - # ## Options - # - # - `:stdin` - Binary data to write to the command's stdin - # - `:timeout` - Timeout in milliseconds (default: 5000) - # - `:cd` - Working directory for the command - # - `:env` - Environment variables as a list of `{key, value}` tuples - # - `:sink` - Output sink function (default: uses Sink.List for backwards compat) + defguardp is_restricted(restricted) when restricted == true + + # --------------------------------------------------------------------------- + # Low-level process API (thin delegates to ExCmd.Process) + # --------------------------------------------------------------------------- + + @doc """ + Starts an OS process via `ExCmd.Process.start_link/2`. + + Returns `{:error, :restricted}` when `restricted` is `true`. + """ + @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 "Returns the OS PID of the process." + @spec os_pid(pid()) :: {:ok, non_neg_integer()} | non_neg_integer() + defdelegate os_pid(process), to: ExCmd.Process + + @doc "Reads a chunk from the process stdout. Returns `{:ok, data}`, `:eof`, or `{:error, reason}`." + @spec read(pid()) :: {:ok, binary()} | :eof | {:error, term()} + defdelegate read(process), to: ExCmd.Process + + @doc "Writes data to the process stdin." + @spec write(pid(), binary()) :: :ok | {:error, term()} + defdelegate write(process, data), to: ExCmd.Process + + @doc "Closes the process stdin." + @spec close_stdin(pid()) :: :ok + defdelegate close_stdin(process), to: ExCmd.Process + + @doc "Closes the process stdout." + @spec close_stdout(pid()) :: :ok + defdelegate close_stdout(process), to: ExCmd.Process + + @doc "Waits for the process to exit within the given timeout." + @spec await_exit(pid(), non_neg_integer() | :infinity) :: + {:ok, non_neg_integer()} | {:error, term()} + defdelegate await_exit(process, timeout), to: ExCmd.Process + + # --------------------------------------------------------------------------- + # Streaming API + # --------------------------------------------------------------------------- + + @doc """ + Creates an OS process stream via `ExCmd.stream/2`. + + Returns `{:error, :restricted}` when `restricted` is `true`. + """ + @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) + + # --------------------------------------------------------------------------- + # System.cmd wrapper + # --------------------------------------------------------------------------- + + @doc """ + Executes a command via `System.cmd/3`. + + Returns `{:error, :restricted}` when `restricted` is `true`. + """ + @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) + + # --------------------------------------------------------------------------- + # High-level API + # --------------------------------------------------------------------------- + @doc false def execute(command_name, args, opts \\ []) do stdin = opts[:stdin] @@ -41,15 +137,14 @@ 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 - # ExCmd doesn't return 127 for command not found, so we check manually if System.find_executable(command_name) == nil and not String.starts_with?(command_name, "/") and not String.starts_with?(command_name, "./") do @@ -60,12 +155,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 - # Build ExCmd options - ExCmd 0.18.0 only accepts cd, env, and stderr options + defp execute_with_excmd(cmd_parts, stdin, cd, env, timeout, sink_opt, restricted) do exec_opts = [ cd: cd, env: normalize_env(env), @@ -74,16 +168,23 @@ defmodule Bash.CommandPort do sink = sink_opt || fn _chunk -> :ok end - case ExCmd.Process.start_link(cmd_parts, exec_opts) do - {:ok, process} -> - # Write stdin if provided, then close stdin - write_stdin(process, stdin) + case 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 + }} - # Stream output to sink (does not accumulate in memory) + {:ok, process} -> + write_stdin_then_close(process, stdin) stream_to_sink(process, sink) - # Wait for exit - case ExCmd.Process.await_exit(process, timeout) do + case await_exit(process, timeout) do {:ok, 0} -> {:ok, %CommandResult{ @@ -135,9 +236,8 @@ defmodule Bash.CommandPort do end end - # Stream output chunks to sink without accumulating in memory defp stream_to_sink(process, sink) do - case ExCmd.Process.read(process) do + case read(process) do {:ok, data} -> sink.({:stdout, data}) stream_to_sink(process, sink) @@ -150,7 +250,6 @@ defmodule Bash.CommandPort do end end - # Normalize environment to keyword list format expected by ExCmd defp normalize_env([]), do: [] defp normalize_env(env) when is_list(env) do @@ -167,27 +266,27 @@ defmodule Bash.CommandPort do end) end - defp write_stdin(process, nil) do - ExCmd.Process.close_stdin(process) + defp write_stdin_then_close(process, nil) do + close_stdin(process) end - defp write_stdin(process, data) when is_binary(data) do - case ExCmd.Process.write(process, data) do - :ok -> ExCmd.Process.close_stdin(process) + defp write_stdin_then_close(process, data) when is_binary(data) do + case write(process, data) do + :ok -> close_stdin(process) {:error, reason} -> {:error, reason} end end - defp write_stdin(process, %Bash.Pipe{} = pipe) do + defp write_stdin_then_close(process, %Bash.Pipe{} = pipe) do case Bash.Pipe.read_line(pipe) do {:ok, data} -> - case ExCmd.Process.write(process, data) do - :ok -> write_stdin(process, pipe) - {:error, _} -> ExCmd.Process.close_stdin(process) + case write(process, data) do + :ok -> write_stdin_then_close(process, pipe) + {:error, _} -> close_stdin(process) end :eof -> - ExCmd.Process.close_stdin(process) + close_stdin(process) end end end diff --git a/lib/bash/job_process.ex b/lib/bash/job_process.ex index 68fe9d5..2094745 100644 --- a/lib/bash/job_process.ex +++ b/lib/bash/job_process.ex @@ -3,7 +3,7 @@ defmodule Bash.JobProcess do GenServer wrapping a background OS process. Each background job is managed by a JobProcess GenServer which: - - Starts and monitors the OS process via ExCmd + - Starts and monitors the OS process via CommandPort - Accumulates stdout/stderr output preserving order - Notifies the Session on status changes (done, stopped) - Supports foregrounding (blocks caller until completion) @@ -12,7 +12,7 @@ defmodule Bash.JobProcess do ## Lifecycle 1. Started by Session via `start_link/1` - 2. Spawns OS process via ExCmd + 2. Spawns OS process via CommandPort 3. Spawns reader tasks for stdout/stderr that send messages back 4. On process exit, notifies Session and transitions to :done 5. Can be foregrounded (caller blocks until done) @@ -30,6 +30,7 @@ defmodule Bash.JobProcess do } end + alias Bash.CommandPort alias Bash.CommandResult alias Bash.Job @@ -138,7 +139,7 @@ defmodule Bash.JobProcess do @impl true def init(opts) do - # Trap exits to handle ExCmd.Process exit without crashing + # Trap exits to handle linked process exit without crashing Process.flag(:trap_exit, true) job_number = Keyword.fetch!(opts, :job_number) @@ -408,7 +409,7 @@ defmodule Bash.JobProcess do end def handle_info({:EXIT, _pid, _reason}, state) do - # Handle EXIT messages from linked ExCmd.Process - ignore as we get exit via await_exit + # Handle EXIT messages from linked process - ignore as we get exit via await_exit {:noreply, state} end @@ -446,10 +447,10 @@ defmodule Bash.JobProcess do stderr: :redirect_to_stdout ] - case ExCmd.Process.start_link(cmd_parts, exec_opts) do + case CommandPort.start_link(cmd_parts, exec_opts, false) do {:ok, process} -> os_pid = - case ExCmd.Process.os_pid(process) do + case CommandPort.os_pid(process) do {:ok, pid} -> pid pid when is_integer(pid) -> pid end @@ -457,7 +458,7 @@ defmodule Bash.JobProcess do send(parent, {:worker_started, os_pid}) # Close stdin since we don't need it for background jobs - ExCmd.Process.close_stdin(process) + CommandPort.close_stdin(process) # Read all output first — ExCmd requires reads from the owner process. # read/1 blocks until data is available, returning :eof when the process exits. @@ -465,7 +466,7 @@ defmodule Bash.JobProcess do # Now await exit to get the exit code exit_result = - case ExCmd.Process.await_exit(process, :infinity) do + case CommandPort.await_exit(process, :infinity) do {:ok, code} -> {:ok, code} {:error, :killed} -> :killed {:error, other} -> {:error, other} @@ -479,7 +480,7 @@ defmodule Bash.JobProcess do end defp read_and_forward_output(process, parent) do - case ExCmd.Process.read(process) do + case CommandPort.read(process) do {:ok, data} -> send(parent, {:stdout, data}) read_and_forward_output(process, parent) diff --git a/lib/bash/session.ex b/lib/bash/session.ex index c1ae026..f988f8e 100644 --- a/lib/bash/session.ex +++ b/lib/bash/session.ex @@ -452,6 +452,15 @@ defmodule Bash.Session do GenServer.call(session, :get_state) end + @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 """ Load an Elixir API module into a session. diff --git a/test/bash/command_port_test.exs b/test/bash/command_port_test.exs new file mode 100644 index 0000000..4c829db --- /dev/null +++ b/test/bash/command_port_test.exs @@ -0,0 +1,70 @@ +defmodule Bash.CommandPortTest do + use ExUnit.Case, async: true + + alias Bash.CommandPort + + describe "low-level process API" do + test "start_link + os_pid + read + await_exit" do + {:ok, proc} = + CommandPort.start_link(["echo", "hello"], [stderr: :redirect_to_stdout], false) + + os_pid = + case CommandPort.os_pid(proc) do + {:ok, pid} -> pid + pid when is_integer(pid) -> pid + end + + assert is_integer(os_pid) + assert {:ok, "hello\n"} = CommandPort.read(proc) + assert :eof = CommandPort.read(proc) + assert {:ok, 0} = CommandPort.await_exit(proc, 5000) + end + + test "write + close_stdin" do + {:ok, proc} = CommandPort.start_link(["cat"], [stderr: :redirect_to_stdout], false) + assert :ok = CommandPort.write(proc, "test data") + CommandPort.close_stdin(proc) + assert {:ok, "test data"} = CommandPort.read(proc) + assert {:ok, 0} = CommandPort.await_exit(proc, 5000) + end + + test "stream returns enumerable" do + stream = CommandPort.stream(["echo", "hello"], [stderr: :redirect_to_stdout], false) + chunks = Enum.to_list(stream) + assert Enum.any?(chunks, &is_binary/1) + end + + test "system_cmd delegates to System.cmd" do + {output, 0} = CommandPort.system_cmd("echo", ["hi"], [], false) + assert String.trim(output) == "hi" + end + end + + describe "restricted mode" do + test "start_link returns error when restricted" do + assert {:error, :restricted} = CommandPort.start_link(["ls"], [], true) + end + + test "stream returns error when restricted" do + assert {:error, :restricted} = CommandPort.stream(["ls"], [], true) + end + + test "system_cmd returns error when restricted" do + assert {:error, :restricted} = CommandPort.system_cmd("ls", [], [], true) + end + end + + describe "restricted? helper (on Session)" do + test "returns false for unrestricted state" do + refute Bash.Session.restricted?(%{options: %{restricted: false}}) + end + + test "returns true for restricted state" do + assert Bash.Session.restricted?(%{options: %{restricted: true}}) + end + + test "returns false when options missing" do + refute Bash.Session.restricted?(%{}) + end + end +end diff --git a/test/bash/control_flow_test.exs b/test/bash/control_flow_test.exs index 99bd871..6661212 100644 --- a/test/bash/control_flow_test.exs +++ b/test/bash/control_flow_test.exs @@ -278,6 +278,21 @@ defmodule Bash.ControlFlowTest do assert get_stdout(result) == "read: line1\nread: line2\nread: line3\n" end + @tag :tmp_dir + test "reads from file with relative path input redirect", %{ + session: session, + tmp_dir: tmp_dir + } do + test_file = Path.join(tmp_dir, "input.txt") + File.write!(test_file, "alpha\nbeta\n") + + run_script(session, "cd #{tmp_dir}") + + result = run_script(session, ~S'while read line; do echo "got: $line"; done < input.txt') + assert result.exit_code == 0 + assert get_stdout(result) == "got: alpha\ngot: beta\n" + end + @tag :tmp_dir test "reads from heredoc redirect", %{session: session} do script = ~S""" diff --git a/test/bash/stderr_forwarding_test.exs b/test/bash/stderr_forwarding_test.exs new file mode 100644 index 0000000..d2b9cb8 --- /dev/null +++ b/test/bash/stderr_forwarding_test.exs @@ -0,0 +1,33 @@ +defmodule Bash.StderrForwardingTest do + use Bash.SessionCase, async: true + setup :start_session + + describe "command substitution stderr forwarding" do + test "stderr from command inside $() is forwarded to session", %{session: session} do + result = run_script(session, ~S'x=$(echo out; echo err >&2); echo "captured: $x"') + assert get_stdout(result) == "captured: out\n" + assert get_stderr(result) =~ "err" + end + + test "stderr from backtick substitution is forwarded to session", %{session: session} do + result = run_script(session, ~S'x=`echo out; echo err >&2`; echo "captured: $x"') + assert get_stdout(result) == "captured: out\n" + assert get_stderr(result) =~ "err" + end + + test "stderr is not included in substitution value", %{session: session} do + result = run_script(session, ~S'echo "$(echo good; echo bad >&2)"') + assert get_stdout(result) == "good\n" + assert get_stderr(result) =~ "bad" + end + end + + describe "pipeline stderr forwarding" do + test "stderr from non-tail pipeline command reaches session", %{session: session} do + # In a pipeline, commands run in subshells with temp collectors. + # Stderr from those collectors should be forwarded to the session. + result = run_script(session, ~S'echo good_err >&2 | cat') + assert get_stderr(result) =~ "good_err" + end + end +end diff --git a/test/support/session_case.ex b/test/support/session_case.ex index f601f2a..ff41a32 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