packages feed

oglyad-0.1.2.0: src/Oglyad/Process.hs

-- | running external tools (rpmspec, git) without ever throwing, hanging, or
-- leaking secrets
module Oglyad.Process
  ( run,
    runCapture,
    childEnv,
  )
where

import Control.Exception (IOException, try)
import Data.ByteString.Lazy qualified as LBS
import Data.Char (isUpper)
import Data.List (isPrefixOf)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.Process.Typed (nullStream, proc, readProcess, setEnv, setStdin, setWorkingDir)
import System.Timeout (timeout)

-- | run a command and return its stdout. a missing executable, a non-zero
-- exit, or a timeout becomes a Left, so callers can report it as a finding
-- instead of crashing the whole run.
--
-- rpmspec runs shell code from specs (%(...) and %{lua:...}), so children get
-- an allowlisted environment: GITHUB_TOKEN and anything else secret in ours
-- never reaches them. stdin is closed so nothing can wait on a prompt
run :: FilePath -> [String] -> IO (Either Text Text)
run cmd args = do
  env <- childEnv <$> getEnvironment
  r <- try (timeout (5 * 60 * 1_000_000) (readProcess (setEnv env (setStdin nullStream (proc cmd args)))))
  pure $ case r of
    Left (e :: IOException) -> Left (T.pack cmd <> ": " <> T.pack (show e))
    Right Nothing -> Left (T.pack cmd <> " timed out after 5 minutes")
    Right (Just (ExitSuccess, out, _)) -> Right (decode out)
    Right (Just (ExitFailure _, _, err)) -> Left (T.strip (decode err))
  where
    decode = TE.decodeUtf8Lenient . LBS.toStrict

-- | like 'run', but in a working directory, with extra variables on top of
-- the allowlist, and handing back the exit code, stdout and stderr even when
-- the command succeeded. only for tools that need something the allowlist
-- leaves out, like anda needing GITHUB_TOKEN
runCapture :: FilePath -> [(String, String)] -> FilePath -> [String] -> IO (Either Text (ExitCode, Text, Text))
runCapture dir extra cmd args = do
  env <- (extra <>) . filter ((`notElem` map fst extra) . fst) . childEnv <$> getEnvironment
  r <- try (timeout (5 * 60 * 1_000_000) (readProcess (setWorkingDir dir (setEnv env (setStdin nullStream (proc cmd args))))))
  pure $ case r of
    Left (e :: IOException) -> Left (T.pack cmd <> ": " <> T.pack (show e))
    Right Nothing -> Left (T.pack cmd <> " timed out after 5 minutes")
    Right (Just (code, out, err)) -> Right (code, decode out, decode err)
  where
    decode = TE.decodeUtf8Lenient . LBS.toStrict

-- | the environment children get: only what rpm and git need to find their
-- config and behave, plus GIT_TERMINAL_PROMPT=0 so a git remote asking for
-- credentials fails fast instead of hanging
childEnv :: [(String, String)] -> [(String, String)]
childEnv parent = ("GIT_TERMINAL_PROMPT", "0") : filter (allowed . fst) parent
  where
    allowed k =
      k `elem` ["PATH", "HOME", "USER", "LOGNAME", "LANG", "TZ", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR"]
        || ("LC_" `isPrefixOf` k && all (\c -> isUpper c || c == '_') k)