okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
31ff834
main
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 ) -> 28case resolve () of 29false -> 30{ error , << "usage is not built; run nix build or put it on PATH" >>}; 31Bin -> 32Port = open_port ({ spawn_executable , Bin }, 33[{ args , [ "advance" , "-" ]}, exit_status , binary , hide ]), 34Port ! { self (), { command , [ Envelope , $\n ]}}, 35drain ( Port , [], deadline ( ?ADVANCE_TIMEOUT_MS )) 36end . 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 ) -> 42RequestBody = case Body of 43none -> none ; 44{ some , Payload } -> { content_type ( Headers ), Payload } 45end , 46HeaderList = without_content_type ( Headers ), 47case albedo_http : request ( method ( Method ), Url , HeaderList , RequestBody , 30000 , 10000 ) of 48{ ok , { Status , _ResponseHeaders , ResponseBody }} -> 49{ Status , ResponseBody }; 50{ error , Reason } -> 51ReasonText = unicode : characters_to_binary ( io_lib : format ( "~0p" , [ Reason ])), 52{ 0 , ReasonText } 53end . 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 ) -> 60run_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 ) -> 65Timeout = case TimeoutMs of 66{ some , Ms } when is_integer ( Ms ), Ms > 0 -> min ( Ms , ?COMMAND_TIMEOUT_CAP_MS ); 67_ -> ?COMMAND_TIMEOUT_MS 68end , 69case executable ( Command ) of 70false -> 71Body = iolist_to_binary ([ Command , << " is not on PATH" >>]), 72{ 126 , Body }; 73Exe -> 74Port = open_port ({ spawn_executable , Exe }, 75[{ args , Args }, exit_status , binary , hide , { env , Env }]), 76case Stdin of 77{ some , Data } -> Port ! { self (), { command , Data }}; 78none -> ok 79end , 80case drain_capped ( Port , [], 0 , deadline ( Timeout )) of 81{ ok , Result } -> Result ; 82timeout -> { 126 , << "the command timed out" >>} 83end 84end . 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 () -> 90case os : getenv ( "ALBEDO_USAGE_CORE" ) of 91Override when Override =/= false , Override =/= "" -> 92case filelib : is_file ( Override ) of 93true -> Override ; 94false -> on_path ( Override ) 95end ; 96_ -> 97Bin = case code : priv_dir ( albedo ) of 98{ error , _ } -> false ; 99Priv -> filename : join ( Priv , "bin/usage" ) 100end , 101case is_file ( Bin ) of 102true -> Bin ; 103false -> on_path ( "usage" ) 104end 105end . 106 107%% os:find_executable wants a list, and the commands arrive as binaries. 108on_path ( Name ) -> 109os : 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 ) -> 116case binary : match ( Command , << "/" >>) of 117nomatch -> on_path ( Command ); 118_ -> case filelib : is_file ( Command ) of 119true -> Command ; 120false -> on_path ( Command ) 121end 122end . 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 () -> 128lists : map ( 129fun ({ K , V }) -> 130case lists : member ( K , ?ALLOWED_ENV ) of 131true -> { K , V }; 132false -> { K , false } 133end 134end , 135os : env ()). 136 137method ( Method ) -> 138binary_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 ) -> 143case [ V || { K , V } <- Headers , is_content_type ( K )] of 144[ Type | _ ] -> unicode : characters_to_list ( Type ); 145[] -> "application/json" 146end . 147 148without_content_type ( Headers ) -> 149[{ K , V } || { K , V } <- Headers , not is_content_type ( K )]. 150 151is_content_type ( K ) -> 152string : 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 ) -> 158erlang : monotonic_time ( millisecond ) + Timeout . 159 160remaining ( Deadline ) -> 161max ( 0 , Deadline - erlang : monotonic_time ( millisecond )). 162 163drain ( Port , Acc , Deadline ) -> 164receive 165{ Port , { data , Data }} -> drain ( Port , [ Data | Acc ], Deadline ); 166{ Port , { exit_status , 0 }} -> 167close_port ( Port ), 168{ ok , iolist_to_binary ( lists : reverse ( Acc ))}; 169{ Port , { exit_status , Status }} -> 170close_port ( Port ), 171Text = integer_to_binary ( Status ), 172{ error , << "the usage CLI exited with status " , Text / binary >>} 173after remaining ( Deadline ) -> 174kill ( Port ), 175{ error , << "the usage CLI timed out" >>} 176end . 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 ) -> 181receive 182{ Port , { data , Data }} -> 183{ Kept , Size2 } = cap ( Size , Data ), 184drain_capped ( Port , [ Kept | Acc ], Size2 , Deadline ); 185{ Port , { exit_status , Status }} -> 186close_port ( Port ), 187{ ok , { Status , iolist_to_binary ( lists : reverse ( Acc ))}} 188after remaining ( Deadline ) -> 189kill ( Port ), 190timeout 191end . 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 ) -> 198Pid = case erlang : port_info ( Port , os_pid ) of 199{ os_pid , OsPid } when is_integer ( OsPid ) -> OsPid ; 200_ -> false 201end , 202close_port ( Port ), 203case Pid of 204false -> ok ; 205_ -> 206Kill = case on_path ( "kill" ) of 207false -> erlang : error ({ missing_executable , "kill executable not found in PATH" }); 208Executable -> Executable 209end , 210Killer = open_port ({ spawn_executable , Kill }, 211[{ args , [ "-KILL" , integer_to_list ( Pid )]}, 212exit_status , binary , hide ]), 213receive 214{ Killer , { exit_status , _ }} -> ok 215after 1000 -> 216close_port ( Killer ) 217end 218end . 219 220close_port ( Port ) -> 221try port_close ( Port ) catch _ : _ -> true end . 222 223cap ( Size , _Data ) when Size >= ?STDOUT_CAP -> 224{<<>>, Size }; 225cap ( Size , Data ) -> 226Room = ?STDOUT_CAP - Size , 227case byte_size ( Data ) =< Room of 228true -> { Data , Size + byte_size ( Data )}; 229false -> { binary : part ( Data , 0 , Room ), ?STDOUT_CAP } 230end .