%%% vim:ts=4:sw=4:et
%%%-----------------------------------------------------------------------------
%%% @doc OS supporting commands
%%% @author Serge Aleynikov <saleyn@gmail.com>
%%% @end
%%%-----------------------------------------------------------------------------
%%% Date: 2015-12-10
%%%-----------------------------------------------------------------------------
%%% Copyright (c) 2015 Serge Aleynikov
%%%
%%% Permission is hereby granted, free of charge, to any person
%%% obtaining a copy of this software and associated documentation
%%% files (the "Software"), to deal in the Software without restriction,
%%% including without limitation the rights to use, copy, modify, merge,
%%% publish, distribute, sublicense, and/or sell copies of the Software,
%%% and to permit persons to whom the Software is furnished to do
%%% so, subject to the following conditions:
%%%
%%% The above copyright notice and this permission notice shall be included
%%% in all copies or substantial portions of the Software.
%%%
%%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
%%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
%%% MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
%%% IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
%%% CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
%%% TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
%%% SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
%%%-----------------------------------------------------------------------------
-module(osx).
-author('saleyn@gmail.com').
-export([command/1, command/2, command/3, status/1]).
-export([realpath/1, normalpath/1]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
%%%-----------------------------------------------------------------------------
%%% External API
%%%-----------------------------------------------------------------------------
-spec command(string()) -> {integer(), list()}.
command(Cmd) ->
command(Cmd, [], undefined).
-spec command(string(), list()|undefined|fun((list(),any()) -> any())) ->
{integer(), any()}.
command(Cmd, Fun) when is_function(Fun, 2) ->
command(Cmd, [], Fun);
command(Cmd, Opt) when is_list(Opt) ->
command(Cmd, Opt, undefined).
-spec command(string(), list(), undefined|fun((list(),any()) -> any())) ->
{integer(), any()}.
command(Cmd, Opt, Fun) when is_list(Opt) ->
command(Cmd, Opt, Fun, 600000).
-spec command(string(), list(), undefined|fun((list(),any()) -> any()), integer()) ->
{integer(), any()}.
command(Cmd, Opt, Fun, Timeout) when is_list(Opt)
, (Fun=:=undefined orelse is_function(Fun, 2))
, (is_integer(Timeout) orelse Timeout==infinity) ->
Opts = Opt ++ [stream, exit_status, use_stdio, in, hide, eof],
P = open_port({spawn, Cmd}, Opts),
Ref = erlang:monitor(port, P),
Res = get_data(P, Fun, [], Ref, Timeout),
_ = demonitor(Ref, [flush]),
Res.
-spec status(integer()) ->
{status, ExitStatus :: integer()} |
{signal, Singnal :: integer(), Core :: boolean()}.
status(Status) when is_integer(Status) ->
TermSignal = Status band 16#7F,
IfSignaled = ((TermSignal + 1) bsr 1) > 0,
ExitStatus = (Status band 16#FF00) bsr 8,
case IfSignaled of
true ->
CoreDump = (Status band 16#80) =:= 16#80,
{signal, TermSignal, CoreDump};
false ->
{status, ExitStatus}
end.
%%%-----------------------------------------------------------------------------
%%% Internal functions
%%%-----------------------------------------------------------------------------
get_data(P, Fun, D, Ref, Timeout) ->
receive
{P, {data, {eol, Line}}} when Fun =:= undefined ->
get_data(P, Fun, [Line|D], Ref, Timeout);
{P, {data, {eol, Line}}} when is_function(Fun, 2) ->
get_data(P, Fun, Fun(eol, {Line, D}), Ref, Timeout);
{P, {data, {noeol, Line}}} when Fun =:= undefined ->
get_data(P, Fun, [Line|D], Ref, Timeout);
{P, {data, {noeol, Line}}} when is_function(Fun, 2) ->
get_data(P, Fun, Fun(noeol, {Line, D}), Ref, Timeout);
{P, {data, D1}} when Fun =:= undefined ->
get_data(P, Fun, [D1|D], Ref, Timeout);
{P, {data, D1}} when is_function(Fun, 2) ->
get_data(P, Fun, Fun(data, {D1, D}), Ref, Timeout);
{P, eof} ->
catch port_close(P),
flush_until_down(P, Ref),
receive
{P, {exit_status, 0}} when is_function(Fun, 2) ->
{ok, Fun(eof, D)};
{P, {exit_status, N}} when is_function(Fun, 2) ->
{error, {N, Fun(eof, D)}};
{P, {exit_status, 0}} ->
{ok, lists:reverse(D)};
{P, {exit_status, N}} ->
{error, {N, lists:reverse(D)}}
after 5000 ->
if is_function(Fun, 2) ->
throw({no_exit_status, Fun(eof, D)});
true ->
throw({no_exit_status, timeout_waiting_for_output})
end
end;
{'DOWN', Ref, _, _, Reason} ->
flush_exit(P),
exit({error, Reason, lists:flatten(lists:reverse(D))})
after Timeout ->
exit(timeout)
end.
%% @doc
%% Return a canonicalized pathname, having resolved symlinks to their
%% destination. Modelled on realpath(3).
%% @end
%% Derived from https://github.com/mk270/realpath
%% Copyright 2020 Martin Keegan
-spec realpath(string()) -> string().
realpath(Path) when is_list(Path) ->
check_canonical(Path, 20);
realpath(Path) when is_binary(Path) ->
list_to_binary(realpath(binary_to_list(Path))).
check_canonical(S, TTL) ->
Fragments = make_fragments(S),
check_fragments(Fragments, [], TTL).
check_fragments(_, _, 0) ->
throw(loop_detected);
check_fragments([], AlreadyChecked, _) ->
AlreadyChecked;
check_fragments([Head|Tail], AlreadyChecked, TTL) ->
case is_symlink(AlreadyChecked, Head) of
false ->
check_fragments(Tail, filename:join(AlreadyChecked, Head), TTL);
{true, Referent} ->
TailJoined = join_non_null(Tail),
AllJoined = filename:join(Referent, TailJoined),
check_canonical(AllJoined, TTL - 1)
end.
is_symlink(Dirname, Basename) ->
Path = filename:join(Dirname, Basename),
case file:read_link(Path) of
{ok, Name} ->
case Name of
% absolute link
[$/|_] -> {true, Name};
% relative link
_ ->
{true, filename:join(Dirname, Name)}
end;
_ ->
false
end.
make_fragments(S) ->
filename:split(S).
join_non_null([]) -> "";
join_non_null(SS) -> filename:join(SS).
%% @doc
%% Return a path where the use of ".." to indicate parent directory has
%% been resolved. Currently does not accept relative paths.
%% @end
%% Derived from https://github.com/mk270/realpath
%% Copyright 2020 Martin Keegan
-spec normalpath(list()) -> string().
normalpath(S=[$/|_]) when is_list(S)->
normalpath2(S);
normalpath(S) when is_list(S) ->
normalpath2(filename:absname(S));
normalpath(B) when is_binary(B) ->
list_to_binary(normalpath(binary_to_list(B))).
normalpath2(S) when is_list(S) ->
Parts = filename:split(S),
filename:join(lists:reverse(normalize(Parts, []))).
normalize([], Path) ->
Path;
normalize([".."|T], Path) ->
{_H, Rest} = pop(Path),
normalize(T, Rest);
normalize([H|T], Path) ->
Rest = push(H, Path),
normalize(T, Rest).
pop([]) -> {"/", []};
pop(["/"]) -> {"/", ["/"]};
pop([H|T]) -> {H,T}.
push(H,T) -> [H|T].
flush_until_down(Port, MonRef) ->
receive
{Port, {data, _Bytes}} ->
flush_until_down(Port, MonRef);
{'DOWN', MonRef, _, _, _} ->
flush_exit(Port)
end.
%% The exit signal is always delivered before
%% the down signal, so try to clean up the mailbox.
flush_exit(Port) ->
receive {'EXIT', Port, _} -> ok
after 0 -> ok
end.
%%%-----------------------------------------------------------------------------
%%% Tests
%%%-----------------------------------------------------------------------------
-ifdef(EUNIT).
command_test() ->
{ok, ["ok\n"]} = osx:command("echo ok"),
{error, {1, ""}} = osx:command("false"),
{ok, ok} = osx:command("echo ok", fun(data, {"ok\n", []}) -> []; (eof, []) -> ok end),
{ok,["a","b","c"]} = osx:command("echo -en 'a\nb\nc\n'", [{line, 80}]),
%{error, {143,[]}} = osx:command("kill $$"),
{signal,15,true} = status(143),
{status,0} = status(0),
ok.
make_fragments_test_data() ->
[{"/usr/local/bin", ["/", "usr", "local", "bin"]},
{"usr/local/bin/bash", ["usr", "local", "bin", "bash"]},
{"/usr/local/bin/", ["/", "usr", "local", "bin"]}].
make_fragments_test_() ->
[ ?_assertEqual(Expected, make_fragments(Observed))
|| {Observed, Expected} <- make_fragments_test_data() ].
-endif.