packages feed

hsec-tools-0.2.0.2: src/Security/Advisories/Git.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DerivingStrategies #-}

{-|

Helpers for deriving advisory metadata from a Git repo.

-}
module Security.Advisories.Git
  ( AdvisoryGitInfo(..)
  , GitError(..)
  , explainGitError
  , getAdvisoryGitInfo
  , getRepoRoot
  , add
  , commit
  )
  where

import Data.Char (isSpace)
import Data.List (dropWhileEnd)
import Data.Time (UTCTime, zonedTimeToUTC)
import Data.Time.Format.ISO8601 (iso8601ParseM)
import System.Exit (ExitCode(ExitSuccess))
import System.FilePath (splitFileName)
import System.Process (readProcessWithExitCode)
import Control.Applicative ((<|>))

data AdvisoryGitInfo = AdvisoryGitInfo
  { firstAppearanceCommitDate :: UTCTime
  , lastModificationCommitDate :: UTCTime
  }

data GitError
  = GitProcessError ExitCode String String -- ^ exit code, stdout and stderr
  | GitTimeParseError String -- ^ unable to parse this input as a datetime
  deriving stock (Eq, Ord, Show)

explainGitError :: GitError -> String
explainGitError = \case
  GitProcessError status stdout stderr ->
    unlines
      [ "git exited with status " <> show status
      , ">>> standard output:"
      , stdout
      , ">>> standard error:"
      , stderr
      ]
  GitTimeParseError s ->
    "failed to parse time: " <> s

-- | Get top-level directory of the working tree.
--
getRepoRoot :: FilePath -> IO (Either GitError FilePath)
getRepoRoot path = do
  (status, stdout, stderr) <- readProcessWithExitCode
    "git"
    [ "-C", path
    , "rev-parse"
    , "--show-toplevel"
    ]
    "" -- standard input
  pure $ case status of
    ExitSuccess -> Right $ trim stdout
    _ -> Left $ GitProcessError status stdout stderr
  where
    trim = dropWhileEnd isSpace . dropWhile isSpace

-- | Add changes to index
--
add
  :: FilePath   -- ^ path to working tree
  -> [FilePath] -- ^ files to update in index
  -> IO (Either GitError ())
add path pathspecs = do
  (status, stdout, stderr) <- readProcessWithExitCode
    "git"
    ( ["-C", path, "add"] <> pathspecs )
    "" -- standard input
  pure $ case status of
    ExitSuccess -> Right ()
    _ -> Left $ GitProcessError status stdout stderr

-- | Commit changes to repo.
--
commit
  :: FilePath   -- ^ path to working tree
  -> String     -- ^ commit message
  -> IO (Either GitError ())
commit path msg = do
  (status, stdout, stderr) <- readProcessWithExitCode
    "git"
    ["-C", path, "commit", "-m", msg]
    "" -- standard input
  pure $ case status of
    ExitSuccess -> Right ()
    _ -> Left $ GitProcessError status stdout stderr

getAdvisoryGitInfo :: FilePath -> IO (Either GitError AdvisoryGitInfo)
getAdvisoryGitInfo path = do
  let (dir, file) = splitFileName path
  (status, stdout, stderr) <- readProcessWithExitCode
    "git"
    [ "-C", dir
    , "log"
    , "--pretty=format:%cI"  -- print committer date
    , "--find-renames"
    , file
    ]
    "" -- standard input
  let timestamps = filter (not . null) $ lines stdout
  case status of
    ExitSuccess | not (null timestamps) ->
      pure $ AdvisoryGitInfo
        <$> parseTime (last timestamps)  -- first commit is last line
        <*> parseTime (head timestamps)  -- most recent commit is first line
    _ ->
      -- `null lines` should not happen, but if it does we treat it
      -- the same as `ExitFailure`
      pure . Left $ GitProcessError status stdout stderr
  where
    parseTime :: String -> Either GitError UTCTime
    parseTime s = maybe (Left $ GitTimeParseError s) Right $
       iso8601ParseM s
         <|> zonedTimeToUTC <$> iso8601ParseM s