Skip to content

Commit 49bc4fc

Browse files
committed
Add System.shell/2 (#10965)
1 parent fe3c393 commit 49bc4fc

File tree

3 files changed

+125
-80
lines changed

3 files changed

+125
-80
lines changed

lib/elixir/lib/system.ex

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,55 @@ defmodule System do
881881
:init.stop(String.to_charlist(status))
882882
end
883883

884+
@doc ~S"""
885+
Executes the given `command` in the OS shell.
886+
887+
It uses `sh` for Unix-like systems and `cmd` for Windows.
888+
889+
**Important**: Use this function with care. In particular, **never
890+
pass untrusted user input to this function**, as the user would be
891+
able to perform "command injection attacks" by executing any code
892+
directly on the machine. Generally speaking, prefer to use `cmd/3`
893+
over this function.
894+
895+
## Examples
896+
897+
iex> System.shell("echo hello")
898+
{"hello\n", 0}
899+
900+
## Options
901+
902+
It accepts the same options as `cmd/3`, except for `arg0`.
903+
"""
904+
@spec shell(binary, keyword) :: {Collectable.t(), exit_status :: non_neg_integer}
905+
def shell(command, opts \\ []) when is_binary(command) do
906+
assert_no_null_byte!(command, "System.shell/2")
907+
908+
# Finding shell command logic from :os.cmd in OTP
909+
# https://github.com/erlang/otp/blob/8deb96fb1d017307e22d2ab88968b9ef9f1b71d0/lib/kernel/src/os.erl#L184
910+
command =
911+
case :os.type() do
912+
{:unix, _} ->
913+
command =
914+
command
915+
|> String.replace("\"", "\\\"")
916+
|> String.to_charlist()
917+
918+
'sh -c "' ++ command ++ '"'
919+
920+
{:win32, osname} ->
921+
command = String.to_charlist(command)
922+
923+
case {System.get_env("COMSPEC"), osname} do
924+
{nil, :windows} -> 'command.com /s /c ' ++ command
925+
{nil, _} -> 'cmd /s /c ' ++ command
926+
{cmd, _} -> '#{cmd} /s /c ' ++ command
927+
end
928+
end
929+
930+
do_cmd({:spawn, command}, [], opts)
931+
end
932+
884933
@doc ~S"""
885934
Executes the given `command` with `args`.
886935
@@ -964,7 +1013,7 @@ defmodule System do
9641013
## Shell commands
9651014
9661015
If you desire to execute a trusted command inside a shell, with pipes,
967-
redirecting and so on, please check `:os.cmd/1`.
1016+
redirecting and so on, please check `shell/2`.
9681017
"""
9691018
@spec cmd(binary, [binary], keyword) :: {Collectable.t(), exit_status :: non_neg_integer}
9701019
def cmd(command, args, opts \\ []) when is_binary(command) and is_list(args) do
@@ -983,11 +1032,15 @@ defmodule System do
9831032
:os.find_executable(cmd) || :erlang.error(:enoent, [command, args, opts])
9841033
end
9851034

986-
{into, opts} = cmd_opts(opts, [:use_stdio, :exit_status, :binary, :hide, args: args], "")
1035+
do_cmd({:spawn_executable, cmd}, [args: args], opts)
1036+
end
1037+
1038+
defp do_cmd(port_init, base_opts, opts) do
1039+
{into, opts} = cmd_opts(opts, [:use_stdio, :exit_status, :binary, :hide] ++ base_opts, "")
9871040
{initial, fun} = Collectable.into(into)
9881041

9891042
try do
990-
do_cmd(Port.open({:spawn_executable, cmd}, opts), initial, fun)
1043+
do_port(Port.open(port_init, opts), initial, fun)
9911044
catch
9921045
kind, reason ->
9931046
fun.(initial, :halt)
@@ -997,17 +1050,18 @@ defmodule System do
9971050
end
9981051
end
9991052

1000-
defp do_cmd(port, acc, fun) do
1053+
defp do_port(port, acc, fun) do
10011054
receive do
10021055
{^port, {:data, data}} ->
1003-
do_cmd(port, fun.(acc, {:cont, data}), fun)
1056+
do_port(port, fun.(acc, {:cont, data}), fun)
10041057

10051058
{^port, {:exit_status, status}} ->
10061059
{acc, status}
10071060
end
10081061
end
10091062

1010-
defp cmd_opts([{:into, any} | t], opts, _into), do: cmd_opts(t, opts, any)
1063+
defp cmd_opts([{:into, any} | t], opts, _into),
1064+
do: cmd_opts(t, opts, any)
10111065

10121066
defp cmd_opts([{:cd, bin} | t], opts, into) when is_binary(bin),
10131067
do: cmd_opts(t, [{:cd, bin} | opts], into)
@@ -1018,7 +1072,8 @@ defmodule System do
10181072
defp cmd_opts([{:stderr_to_stdout, true} | t], opts, into),
10191073
do: cmd_opts(t, [:stderr_to_stdout | opts], into)
10201074

1021-
defp cmd_opts([{:stderr_to_stdout, false} | t], opts, into), do: cmd_opts(t, opts, into)
1075+
defp cmd_opts([{:stderr_to_stdout, false} | t], opts, into),
1076+
do: cmd_opts(t, opts, into)
10221077

10231078
defp cmd_opts([{:parallelism, bool} | t], opts, into) when is_boolean(bool),
10241079
do: cmd_opts(t, [{:parallelism, bool} | opts], into)
@@ -1029,7 +1084,8 @@ defmodule System do
10291084
defp cmd_opts([{key, val} | _], _opts, _into),
10301085
do: raise(ArgumentError, "invalid option #{inspect(key)} with value #{inspect(val)}")
10311086

1032-
defp cmd_opts([], opts, into), do: {into, opts}
1087+
defp cmd_opts([], opts, into),
1088+
do: {into, opts}
10331089

10341090
defp validate_env(enum) do
10351091
Enum.map(enum, fn

lib/elixir/test/elixir/system_test.exs

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,22 +96,21 @@ defmodule SystemTest do
9696
end
9797

9898
test "cmd/3 (with options)" do
99-
assert {["hello\r\n"], 0} =
100-
System.cmd(
101-
"cmd",
102-
~w[/c echo hello],
103-
into: [],
104-
cd: File.cwd!(),
105-
env: %{"foo" => "bar", "baz" => nil},
106-
arg0: "echo",
107-
stderr_to_stdout: true,
108-
parallelism: true
109-
)
99+
opts = [
100+
into: [],
101+
cd: File.cwd!(),
102+
env: %{"foo" => "bar", "baz" => nil},
103+
arg0: "echo",
104+
stderr_to_stdout: true,
105+
parallelism: true
106+
]
107+
108+
assert {["hello\r\n"], 0} = System.cmd("cmd", ~w[/c echo hello], opts)
110109
end
111110

112111
@echo "echo-elixir-test"
113112
@tag :tmp_dir
114-
test "cmd/2 with absolute and relative paths", config do
113+
test "cmd/3 with absolute and relative paths", config do
115114
echo = Path.join(config.tmp_dir, @echo)
116115
File.mkdir_p!(Path.dirname(echo))
117116
File.cp!(System.find_executable("cmd"), echo)
@@ -128,6 +127,22 @@ defmodule SystemTest do
128127
System.cmd(Path.join(File.cwd!(), @echo), ~w[/c echo hello], [{:arg0, "echo"}])
129128
end)
130129
end
130+
131+
test "shell/1" do
132+
assert {"hello\r\n", 0} = System.shell("echo hello")
133+
end
134+
135+
test "shell/2 (with options)" do
136+
opts = [
137+
into: [],
138+
cd: File.cwd!(),
139+
env: %{"foo" => "bar", "baz" => nil},
140+
stderr_to_stdout: true,
141+
parallelism: true
142+
]
143+
144+
assert {["bar\r\n"], 0} = System.shell("echo %foo%", opts)
145+
end
131146
end
132147

133148
describe "Unix" do
@@ -152,7 +167,7 @@ defmodule SystemTest do
152167

153168
@echo "echo-elixir-test"
154169
@tag :tmp_dir
155-
test "cmd/2 with absolute and relative paths", config do
170+
test "cmd/3 with absolute and relative paths", config do
156171
echo = Path.join(config.tmp_dir, @echo)
157172
File.mkdir_p!(Path.dirname(echo))
158173
File.cp!(System.find_executable("echo"), echo)
@@ -169,6 +184,21 @@ defmodule SystemTest do
169184
System.cmd(Path.join(File.cwd!(), @echo), ["hello"], [{:arg0, "echo"}])
170185
end)
171186
end
187+
188+
test "shell/1" do
189+
assert {"hello\n", 0} = System.shell("echo hello")
190+
end
191+
192+
test "shell/2 (with options)" do
193+
opts = [
194+
into: [],
195+
cd: File.cwd!(),
196+
env: %{"foo" => "bar", "baz" => nil},
197+
stderr_to_stdout: true
198+
]
199+
200+
assert {["bar\n"], 0} = System.shell("echo $foo", opts)
201+
end
172202
end
173203

174204
@tag :unix

lib/mix/lib/mix/shell.ex

Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ defmodule Mix.Shell do
33
Defines `Mix.Shell` contract.
44
"""
55

6+
@doc false
7+
defstruct [:callback]
8+
69
@doc """
710
Prints the given ANSI message to the shell.
811
"""
@@ -87,73 +90,29 @@ defmodule Mix.Shell do
8790
"""
8891
def cmd(command, options \\ [], callback) when is_function(callback, 1) do
8992
callback =
90-
if Keyword.get(options, :quiet, false) do
93+
if options[:quiet] do
9194
fn x -> x end
9295
else
9396
callback
9497
end
9598

96-
env = validate_env(Keyword.get(options, :env, []))
97-
98-
args =
99-
if Keyword.get(options, :stderr_to_stdout, true) do
100-
[:stderr_to_stdout]
101-
else
102-
[]
103-
end
99+
options =
100+
options
101+
|> Keyword.take([:cd, :stderr_to_stdout, :env])
102+
|> Keyword.put(:into, %Mix.Shell{callback: callback})
103+
|> Keyword.put_new(:stderr_to_stdout, true)
104104

105-
opts =
106-
[:stream, :binary, :exit_status, :hide, :use_stdio, {:env, env}] ++
107-
args ++ Keyword.take(options, [:cd])
108-
109-
port = Port.open({:spawn, shell_command(command)}, opts)
110-
port_read(port, callback)
105+
{_, status} = System.shell(command, options)
106+
status
111107
end
112108

113-
defp port_read(port, callback) do
114-
receive do
115-
{^port, {:data, data}} ->
116-
_ = callback.(data)
117-
port_read(port, callback)
118-
119-
{^port, {:exit_status, status}} ->
120-
status
109+
defimpl Collectable do
110+
def into(%Mix.Shell{callback: fun}) do
111+
{:ok,
112+
fn
113+
_, {:cont, data} -> fun.(data)
114+
_, _ -> :ok
115+
end}
121116
end
122117
end
123-
124-
# Finding shell command logic from :os.cmd in OTP
125-
# https://github.com/erlang/otp/blob/8deb96fb1d017307e22d2ab88968b9ef9f1b71d0/lib/kernel/src/os.erl#L184
126-
defp shell_command(command) do
127-
case :os.type() do
128-
{:unix, _} ->
129-
command =
130-
command
131-
|> String.replace("\"", "\\\"")
132-
|> String.to_charlist()
133-
134-
'sh -c "' ++ command ++ '"'
135-
136-
{:win32, osname} ->
137-
command = '"' ++ String.to_charlist(command) ++ '"'
138-
139-
case {System.get_env("COMSPEC"), osname} do
140-
{nil, :windows} -> 'command.com /s /c ' ++ command
141-
{nil, _} -> 'cmd /s /c ' ++ command
142-
{cmd, _} -> '#{cmd} /s /c ' ++ command
143-
end
144-
end
145-
end
146-
147-
defp validate_env(enum) do
148-
Enum.map(enum, fn
149-
{k, nil} ->
150-
{String.to_charlist(k), false}
151-
152-
{k, v} ->
153-
{String.to_charlist(k), String.to_charlist(v)}
154-
155-
other ->
156-
raise ArgumentError, "invalid environment key-value #{inspect(other)}"
157-
end)
158-
end
159118
end

0 commit comments

Comments
 (0)