okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.peterl: replace deprecated catch expressions with try for OTP 2931ff834

main
8.7 KiB230 linesraw
1%% The I/O half of albedo/harness/usage_feed: usage-core is a pure Zig core,
2%% so every byte of I/O happens here. One `usage advance -` process per round,
3%% fed the envelope on stdin because it carries the credential; `http`
4%% requests go through albedo_http; `command` requests run with an environment
5%% allowlist, a timeout and a stdout cap. Nothing the envelope or a response
6%% carries is ever logged: both can hold credentials.
7-module(albedo_usage_core).
8
9-export([advance/1, http_request/4, run_command/4, run_command/5, command_env/0, kill/1]).
10
11-define(ADVANCE_TIMEOUT_MS, 15000).
12-define(COMMAND_TIMEOUT_MS, 15000).
13-define(COMMAND_TIMEOUT_CAP_MS, 60000).
14-define(STDOUT_CAP, 65536).
15
16%% PATH, HOME, locale, timezone and proxies: what a CLI needs to find and
17%% speak to its own backend, and nothing that could leak this session.
18-define(ALLOWED_ENV,
19    ["PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ",
20     "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY",
21     "http_proxy", "https_proxy", "all_proxy", "no_proxy"]).
22
23%% One round. The envelope line is the whole message: the CLI stops reading
24%% at the first newline, which is what an Erlang port needs (it cannot
25%% half-close stdin), and stdout is read to exit. The last output line is
26%% the step; the caller parses it, this side only moves bytes.
27advance(Envelope) when is_binary(Envelope) ->
28    case resolve() of
29        false ->
30            {error, <<"usage is not built; run nix build or put it on PATH">>};
31        Bin ->
32            Port = open_port({spawn_executable, Bin},
33                             [{args, ["advance", "-"]}, exit_status, binary, hide]),
34            Port ! {self(), {command, [Envelope, $\n]}},
35            drain(Port, [], deadline(?ADVANCE_TIMEOUT_MS))
36    end.
37
38%% An `http` request through albedo's one front door. A transport failure is
39%% data for the feed, not a driver failure: the protocol says "no response"
40%% with status 0, and the reason travels in the body.
41http_request(Method, Url, Headers, Body) ->
42    RequestBody = case Body of
43        none -> none;
44        {some, Payload} -> {content_type(Headers), Payload}
45    end,
46    HeaderList = without_content_type(Headers),
47    case albedo_http:request(method(Method), Url, HeaderList, RequestBody, 30000, 10000) of
48        {ok, {Status, _ResponseHeaders, ResponseBody}} ->
49            {Status, ResponseBody};
50        {error, Reason} ->
51            ReasonText = unicode:characters_to_binary(io_lib:format("~0p", [Reason])),
52            {0, ReasonText}
53    end.
54
55%% A `command` request, run with only the allowed environment, a timeout and
56%% a stdout cap. 127 is reserved for "this host will not run commands", so a
57%% command that cannot run or timed out answers 126: the feed routes around
58%% either the same way.
59run_command(Command, Args, Stdin, TimeoutMs) ->
60    run_command(Command, Args, Stdin, TimeoutMs, command_env()).
61
62%% Explicit port environment for trusted local credential helpers. The usage
63%% feed continues to use /4 and its unchanged allowlist.
64run_command(Command, Args, Stdin, TimeoutMs, Env) ->
65    Timeout = case TimeoutMs of
66        {some, Ms} when is_integer(Ms), Ms > 0 -> min(Ms, ?COMMAND_TIMEOUT_CAP_MS);
67        _ -> ?COMMAND_TIMEOUT_MS
68    end,
69    case executable(Command) of
70        false ->
71            Body = iolist_to_binary([Command, <<" is not on PATH">>]),
72            {126, Body};
73        Exe ->
74            Port = open_port({spawn_executable, Exe},
75                             [{args, Args}, exit_status, binary, hide, {env, Env}]),
76            case Stdin of
77                {some, Data} -> Port ! {self(), {command, Data}};
78                none -> ok
79            end,
80            case drain_capped(Port, [], 0, deadline(Timeout)) of
81                {ok, Result} -> Result;
82                timeout -> {126, <<"the command timed out">>}
83            end
84    end.
85
86%% ALBEDO_USAGE_CORE overrides resolution, so tests can point at a fake; the
87%% shipped binary sits in priv/bin beside albedo-render, and PATH is the last
88%% resort.
89resolve() ->
90    case os:getenv("ALBEDO_USAGE_CORE") of
91        Override when Override =/= false, Override =/= "" ->
92            case filelib:is_file(Override) of
93                true -> Override;
94                false -> on_path(Override)
95            end;
96        _ ->
97            Bin = case code:priv_dir(albedo) of
98                {error, _} -> false;
99                Priv -> filename:join(Priv, "bin/usage")
100            end,
101            case is_file(Bin) of
102                true -> Bin;
103                false -> on_path("usage")
104            end
105    end.
106
107%% os:find_executable wants a list, and the commands arrive as binaries.
108on_path(Name) ->
109    os:find_executable(unicode:characters_to_list(Name)).
110
111is_file(false) -> false;
112is_file(Path) -> filelib:is_file(Path).
113
114%% A path in the command is used as given; a bare name is looked up on PATH.
115executable(Command) ->
116    case binary:match(Command, <<"/">>) of
117        nomatch -> on_path(Command);
118        _ -> case filelib:is_file(Command) of
119            true -> Command;
120            false -> on_path(Command)
121        end
122    end.
123
124%% open_port's {env, ...} adds to the inherited environment rather than
125%% replacing it, so every other variable is removed by name: the allowlist is
126%% the whole environment a command sees.
127command_env() ->
128    lists:map(
129        fun({K, V}) ->
130            case lists:member(K, ?ALLOWED_ENV) of
131                true -> {K, V};
132                false -> {K, false}
133            end
134        end,
135        os:env()).
136
137method(Method) ->
138    binary_to_atom(string:lowercase(Method), utf8).
139
140%% httpc wants the body's content type in the request tuple rather than the
141%% header list, so it moves there and the rest travel as headers.
142content_type(Headers) ->
143    case [V || {K, V} <- Headers, is_content_type(K)] of
144        [Type | _] -> unicode:characters_to_list(Type);
145        [] -> "application/json"
146    end.
147
148without_content_type(Headers) ->
149    [{K, V} || {K, V} <- Headers, not is_content_type(K)].
150
151is_content_type(K) ->
152    string:equal(string:lowercase(K), "content-type").
153
154%% A receive's `after` is an idle timeout: a child that trickles a byte now
155%% and then never trips it. The deadline is computed once and counts down
156%% across every chunk.
157deadline(Timeout) ->
158    erlang:monotonic_time(millisecond) + Timeout.
159
160remaining(Deadline) ->
161    max(0, Deadline - erlang:monotonic_time(millisecond)).
162
163drain(Port, Acc, Deadline) ->
164    receive
165        {Port, {data, Data}} -> drain(Port, [Data | Acc], Deadline);
166        {Port, {exit_status, 0}} ->
167            close_port(Port),
168            {ok, iolist_to_binary(lists:reverse(Acc))};
169        {Port, {exit_status, Status}} ->
170            close_port(Port),
171            Text = integer_to_binary(Status),
172            {error, <<"the usage CLI exited with status ", Text/binary>>}
173    after remaining(Deadline) ->
174        kill(Port),
175        {error, <<"the usage CLI timed out">>}
176    end.
177
178%% Stdout up to the cap, then discarded, so a runaway command cannot fill
179%% memory. Stderr is not merged: the body the feed sees stays stdout only.
180drain_capped(Port, Acc, Size, Deadline) ->
181    receive
182        {Port, {data, Data}} ->
183            {Kept, Size2} = cap(Size, Data),
184            drain_capped(Port, [Kept | Acc], Size2, Deadline);
185        {Port, {exit_status, Status}} ->
186            close_port(Port),
187            {ok, {Status, iolist_to_binary(lists:reverse(Acc))}}
188    after remaining(Deadline) ->
189        kill(Port),
190        timeout
191    end.
192
193%% port_close closes the pipes; a child blocked on the network would keep
194%% running. The child is ours, so the timeout path takes its pid and sends
195%% KILL. A pid that already exited and was reaped makes kill itself fail,
196%% which is the outcome we wanted anyway.
197kill(Port) ->
198    Pid = case erlang:port_info(Port, os_pid) of
199        {os_pid, OsPid} when is_integer(OsPid) -> OsPid;
200        _ -> false
201    end,
202    close_port(Port),
203    case Pid of
204        false -> ok;
205        _ ->
206            Kill = case on_path("kill") of
207                false -> erlang:error({missing_executable, "kill executable not found in PATH"});
208                Executable -> Executable
209            end,
210            Killer = open_port({spawn_executable, Kill},
211                               [{args, ["-KILL", integer_to_list(Pid)]},
212                                exit_status, binary, hide]),
213            receive
214                {Killer, {exit_status, _}} -> ok
215            after 1000 ->
216                close_port(Killer)
217            end
218    end.
219
220close_port(Port) ->
221    try port_close(Port) catch _:_ -> true end.
222
223cap(Size, _Data) when Size >= ?STDOUT_CAP ->
224    {<<>>, Size};
225cap(Size, Data) ->
226    Room = ?STDOUT_CAP - Size,
227    case byte_size(Data) =< Room of
228        true -> {Data, Size + byte_size(Data)};
229        false -> {binary:part(Data, 0, Room), ?STDOUT_CAP}
230    end.