packages feed

hsec-sync 0.1.0.0 → 0.2.0.2

raw patch · 12 files changed

+467/−367 lines, 12 filesdep +eitherdep +tardep +zlibdep −feeddep −processdep ~aesondep ~basedep ~extranew-uploaderbinary-addedPVP ok

version bump matches the API change (PVP)

Dependencies added: either, tar, zlib

Dependencies removed: feed, process

Dependency ranges changed: aeson, base, extra, filepath, hsec-core, lens, tasty, time

API changes (from Hackage documentation)

- Security.Advisories.Sync: DirectoryEmpty :: RepositoryStatus
- Security.Advisories.Sync: Repository :: FilePath -> String -> String -> Repository
- Security.Advisories.Sync: [repositoryBranch] :: Repository -> String
- Security.Advisories.Sync: [repositoryRoot] :: Repository -> FilePath
- Security.Advisories.Sync: [repositoryUrl] :: Repository -> String
- Security.Advisories.Sync: data Repository
- Security.Advisories.Sync: defaultRepository :: Repository
+ Security.Advisories.Sync: DirectoryIncoherent :: RepositoryStatus
+ Security.Advisories.Sync: Snapshot :: FilePath -> SnapshotUrl -> Snapshot
+ Security.Advisories.Sync: SnapshotUrl :: String -> SnapshotUrl
+ Security.Advisories.Sync: [getSnapshotUrl] :: SnapshotUrl -> String
+ Security.Advisories.Sync: [snapshotRoot] :: Snapshot -> FilePath
+ Security.Advisories.Sync: [snapshotUrl] :: Snapshot -> SnapshotUrl
+ Security.Advisories.Sync: data Snapshot
+ Security.Advisories.Sync: defaultSnapshot :: Snapshot
+ Security.Advisories.Sync: githubSnapshot :: FilePath -> String -> String -> Snapshot
+ Security.Advisories.Sync: newtype SnapshotUrl
- Security.Advisories.Sync: status :: Repository -> IO RepositoryStatus
+ Security.Advisories.Sync: status :: Snapshot -> IO RepositoryStatus
- Security.Advisories.Sync: sync :: Repository -> IO (Either String SyncStatus)
+ Security.Advisories.Sync: sync :: Snapshot -> IO (Either String SyncStatus)

Files

CHANGELOG.md view
@@ -0,0 +1,11 @@+## 0.2.0.2++* Update `tasty` dependency bounds++## 0.2.0.0++* Rewrite, using `hsec-tools` snapshots++## 0.1.0.0++* Introduction, `git`-based
+ README.md view
@@ -0,0 +1,29 @@+# hsec-sync++Synchronize with the [Haskell advisories database](https://github.com/haskell/security-advisories).++## Building++We aim to support both regular cabal-based and nix-based builds.++## Design++[hsec-tools](../hsec-tools/) is the main entry point for dealing with [security advisories](https://github.com/haskell/security-advisories).++Libraries implementors and services providers will mainly be interested by+`Security.Advisories.Queries` and `hsec-tools query` which allows querying+against a directory containing the advisories.++There are two ways for maintaining this local directory up-to-date:++* Manually (based on `git` of fetching archive from GitHub)+* Relying on `hsec-sync` (either via `Security.Advisories.Sync.sync` or `hsec-sync sync`)++![overview](./overview.png)++The recommended workflow is:++1. Use `hsec-sync` to ensure having an up-to-date advisories directory (created or updated)+2. Use `hsec-tools` to perform queries against it++![recommended-workflow](./recommended-workflow.png)
app/Main.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-}  module Main where@@ -7,6 +6,7 @@ import Options.Applicative import Security.Advisories.Sync import System.Exit (die)+import System.IO (hPutStrLn, stderr)  main :: IO () main =@@ -21,60 +21,83 @@     commandsParser :: Parser (IO ())     commandsParser =       hsubparser-        ( command "sync" (info commandSync (progDesc "Synchronize a local Haskell Security Advisory repository"))-        <>  command "status" (info commandStatus (progDesc "Check the status of a local Haskell Security Advisory repository"))+        ( command "sync" (info commandSync (progDesc "Synchronize a local Haskell Security Advisory repository snapshot"))+            <> command "status" (info commandStatus (progDesc "Check the status of a local Haskell Security Advisory repository snapshot"))         )  commandSync :: Parser (IO ()) commandSync = go <$> repositoryParser   where-    go repo = do-      result <- sync repo+    go snapshot = do+      result <- sync snapshot       case result of         Left e ->           die e         Right s -> do           putStrLn $-            "Repository at "-              <> show (repositoryRoot repo)+            "Snapshot at "+              <> show (snapshotRoot snapshot)               <> " from "-              <> show (repositoryUrl repo <> "@" <> repositoryBranch repo)+              <> show (getSnapshotUrl $ snapshotUrl snapshot)           putStrLn $             case s of-              Created -> "Repository just created"-              Updated -> "Repository updated"-              AlreadyUpToDate -> "Repository already up-to-date"+              Created -> "Snapshot just created"+              Updated -> "Snapshot updated"+              AlreadyUpToDate -> "Snapshot already up-to-date"  commandStatus :: Parser (IO ()) commandStatus = go <$> repositoryParser   where-    go repo = do-      result <- status repo-      putStrLn $+    go snapshot = do+      result <- status snapshot+      hPutStrLn stderr $         case result of           DirectoryMissing -> "Directory is missing"-          DirectoryEmpty -> "Directory is empty"+          DirectoryIncoherent -> "Directory is incoherent"           DirectoryUpToDate -> "Repository is up-to-date"           DirectoryOutDated -> "Repository is out-dated" -repositoryParser :: Parser Repository+repositoryParser :: Parser Snapshot repositoryParser =-  Repository+  mkSnapshotSnapshot     <$> strOption-      ( long "repository-root"+      ( long "snapshot-root"           <> short 'd'-          <> metavar "REPOSITORY-ROOT"-          <> value (repositoryRoot defaultRepository)+          <> metavar "SNAPSHOT-ROOT"+          <> value (snapshotRoot defaultSnapshot)       )-    <*> strOption+    <*> (fmap Left repositoryGithubParser <|> fmap Right repositoryUrlParser)+  where mkSnapshotSnapshot root params =+          case params of+            Left (repoUrl, repoBranch) ->+              githubSnapshot root repoUrl repoBranch+            Right snapshotUrl' ->+              Snapshot+                { snapshotRoot = root,+                  snapshotUrl = SnapshotUrl snapshotUrl'+                }+++repositoryGithubParser :: Parser (String, String)+repositoryGithubParser =+  (,)+    <$> strOption       ( long "repository-url"           <> short 'r'           <> metavar "REPOSITORY-URL"-          <> value (repositoryUrl defaultRepository)+          <> value "https://github.com/haskell/security-advisories"       )     <*> strOption       ( long "repository-branch"           <> short 'b'           <> metavar "REPOSITORY-BRANCH"-          <> value (repositoryBranch defaultRepository)+          <> value "generated/snapshot-export"       )++repositoryUrlParser :: Parser String+repositoryUrlParser =+  strOption+    ( long "archive-url"+        <> short 'u'+        <> metavar "ARCHIVE-URL"+    )
hsec-sync.cabal view
@@ -1,6 +1,6 @@ cabal-version:   2.4 name:            hsec-sync-version:         0.1.0.0+version:         0.2.0.2  -- A short (one-line) description of the package. synopsis:        Synchronize with the Haskell security advisory database@@ -19,30 +19,34 @@ -- A copyright notice. -- copyright: category:        Data-extra-doc-files: CHANGELOG.md+extra-doc-files: CHANGELOG.md, overview.png, recommended-workflow.png, README.md tested-with:-  GHC ==8.10.7 || ==9.0.2 || ==9.2.8 || ==9.4.8 || ==9.6.3 || ==9.8.1+  GHC ==8.10.7 || ==9.0.2 || ==9.2.8 || ==9.4.8 || ==9.6.6 || ==9.8.3 || ==9.10.1 || ==9.12.1  library   exposed-modules:  Security.Advisories.Sync   other-modules:-    Security.Advisories.Sync.Atom-    Security.Advisories.Sync.Git+    Security.Advisories.Sync.Snapshot+    Security.Advisories.Sync.Url    build-depends:-    , base         >=4.14  && <4.20-    , directory    >=1.3   && <1.4-    , extra        >=1.7   && <1.8-    , feed         >=1.3   && <1.4-    , filepath     >=1.4   && <1.5-    , hsec-core    >= 1.0  && < 2.0-    , http-client  >=0.7.0 && <0.8-    , lens         >=5.1   && <5.3-    , process      >=1.6   && <1.7-    , text         >=1.2   && <3-    , time         >=1.9   && <1.14-    , transformers >=0.5   && <0.7-    , wreq         >=0.5   && <0.6+    , aeson         >=2.0   && <3+    , base          >=4.14  && <5+    , bytestring    >=0.10  && <0.13+    , directory     >=1.3   && <1.4+    , either        >=5.0   && <5.1+    , extra         >=1.7   && <1.9+    , filepath      >=1.4   && <1.6+    , hsec-core     ^>=0.2+    , http-client   >=0.7.0 && <0.8+    , lens          >=5.1   && <5.4+    , tar           >=0.5   && <0.7+    , temporary     >=1     && <2+    , text          >=1.2   && <3+    , time          >=1.9   && <1.15+    , transformers  >=0.5   && <0.7+    , wreq          >=0.5   && <0.6+    , zlib          >=0.6   && <0.8    hs-source-dirs:   src   default-language: Haskell2010@@ -60,9 +64,9 @@   -- other-extensions:   build-depends:     , aeson                 >=2.0.1.0 && <3-    , base                  >=4.14    && <4.20+    , base                  >=4.14    && <5     , bytestring            >=0.10    && <0.13-    , filepath              >=1.4     && <1.5+    , filepath              >=1.4     && <1.6     , hsec-sync     , optparse-applicative  >=0.17    && <0.19     , text                  >=1.2     && <3@@ -79,14 +83,13 @@   main-is:          Spec.hs   other-modules:    Spec.SyncSpec   build-depends:-    , base         <5+    , base     , directory-    , hsec-sync     , filepath-    , process-    , tasty        <1.5+    , hsec-sync+    , tasty        <2     , tasty-hunit  <0.11-    , temporary    ==1.*+    , temporary    >=1   && <2     , text     , time 
+ overview.png view

binary file changed (absent → 68298 bytes)

binary file changed (absent → 64005 bytes)

src/Security/Advisories/Sync.hs view
@@ -1,8 +1,11 @@ {-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE LambdaCase #-}  module Security.Advisories.Sync-  ( Repository (..),-    defaultRepository,+  ( Snapshot (..),+    SnapshotUrl (..),+    defaultSnapshot,+    githubSnapshot,     SyncStatus (..),     sync,     RepositoryStatus (..),@@ -10,67 +13,81 @@   ) where -import Data.Time (zonedTimeToUTC)-import Security.Advisories.Sync.Atom-import Security.Advisories.Sync.Git+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Except (runExceptT, withExceptT)+import Security.Advisories.Sync.Snapshot+import Security.Advisories.Sync.Url +data Snapshot = Snapshot+  { snapshotRoot :: FilePath,+    snapshotUrl :: SnapshotUrl+  }++defaultSnapshot :: Snapshot+defaultSnapshot =+  githubSnapshot+    "security-advisories"+    "https://github.com/haskell/security-advisories"+    "generated/snapshot-export"++githubSnapshot :: FilePath -> String -> String -> Snapshot+githubSnapshot root repoUrl repoBranch =+  Snapshot+    { snapshotRoot = root,+      snapshotUrl = SnapshotUrl $ ensureFile (mkUrl [repoUrl, "archive/refs/heads", repoBranch]) <> ".tar.gz"+    }+ data SyncStatus   = Created   | Updated   | AlreadyUpToDate   deriving stock (Eq, Show) -sync :: Repository -> IO (Either String SyncStatus)-sync repo = do-  gitStatus <- gitRepositoryStatus repo-  ensured <- ensureGitRepositoryWithRemote repo gitStatus-  let mkGitError = Left . explainGitError-  case ensured of-    Left e -> return $ mkGitError e-    Right s ->-      case s of-        GitRepositoryCreated ->-          return $ Right Created-        GitRepositoryExisting -> do-          repoStatus <- status' repo gitStatus-          if repoStatus == DirectoryOutDated-            then either mkGitError (const $ Right Updated) <$> updateGitRepository repo-            else return $ Right AlreadyUpToDate+sync :: Snapshot -> IO (Either String SyncStatus)+sync s =+  runExceptT $ do+    snapshotStatus <- liftIO $ snapshotRepositoryStatus $ snapshotRoot s+    ensuredStatus <- withExceptT explainSnapshotError $ ensureSnapshot (snapshotRoot s) (snapshotUrl s) snapshotStatus+    case ensuredStatus of+      SnapshotRepositoryCreated ->+        return Created+      SnapshotRepositoryExisting -> do+        repoStatus <- liftIO $ status' s snapshotStatus+        if repoStatus == DirectoryOutDated+          then do+            withExceptT explainSnapshotError $ overwriteSnapshot (snapshotRoot s) (snapshotUrl s)+            return Updated+          else return AlreadyUpToDate  data RepositoryStatus   = DirectoryMissing-  | DirectoryEmpty+  | -- | Used when expected files/directories are missing or not readable+    DirectoryIncoherent   | DirectoryUpToDate   | DirectoryOutDated   deriving stock (Eq, Show) -status :: Repository -> IO RepositoryStatus-status repo =-  status' repo =<< gitRepositoryStatus repo+status :: Snapshot -> IO RepositoryStatus+status s =+  status' s =<< snapshotRepositoryStatus (snapshotRoot s) -status' :: Repository -> GitRepositoryStatus -> IO RepositoryStatus-status' repo gitStatus = do-  case gitStatus of-    GitDirectoryMissing ->+status' :: Snapshot -> SnapshotRepositoryStatus -> IO RepositoryStatus+status' s =+  \case+    SnapshotDirectoryMissing ->       return DirectoryMissing-    GitDirectoryEmpty ->-      return DirectoryEmpty-    GitDirectoryInitialized -> do-      gitInfo <- getDirectoryGitInfo $ repositoryRoot repo-      case gitInfo of+    SnapshotDirectoryIncoherent ->+      return DirectoryIncoherent+    SnapshotDirectoryInitialized -> do+      snapshotInfo <- getDirectorySnapshotInfo $ snapshotRoot s+      case snapshotInfo of         Left _ ->           return DirectoryOutDated         Right info -> do-          update <- latestUpdate (repositoryUrl repo) (repositoryBranch repo)+          update <- runExceptT $ latestUpdate $ snapshotUrl s           return $-            if update == Right (zonedTimeToUTC $ lastModificationCommitDate info)-              then DirectoryUpToDate-              else DirectoryOutDated--defaultRepository :: Repository-defaultRepository =-  Repository-    { repositoryUrl = "https://github.com/haskell/security-advisories",-      repositoryRoot = "security-advisories",-      repositoryBranch = "main"-    }+            case update of+              Right latestETag | latestETag == etag info ->+                DirectoryUpToDate+              _ ->+                DirectoryOutDated
− src/Security/Advisories/Sync/Atom.hs
@@ -1,59 +0,0 @@-module Security.Advisories.Sync.Atom-  ( latestUpdate,-  )-where--import Control.Exception (try)-import Control.Lens-import Data.Either.Extra (maybeToEither)-import qualified Data.Text as T-import Data.Time (UTCTime, defaultTimeLocale, parseTimeM, rfc822DateFormat)-import Data.Time.Format.ISO8601 (iso8601ParseM)-import Network.HTTP.Client (HttpException (..), HttpExceptionContent (..))-import Network.Wreq-import qualified Text.Atom.Feed as FeedAtom-import qualified Text.Feed.Import as FeedImport-import qualified Text.Feed.Types as FeedTypes-import qualified Text.RSS.Syntax as FeedRSS--latestUpdate :: String -> String -> IO (Either String UTCTime)-latestUpdate repoUrl branch = do-  resultE <- try $ get $ repoUrl </> "commits" </> branch </> "advisories.atom"-  return $-    case resultE of-      Left e ->-        Left $-          case e of-            InvalidUrlException url reason ->-              "Invalid URL " <> show url <> ": " <> show reason-            HttpExceptionRequest _ content ->-              case content of-                StatusCodeException response body ->-                  "Request failed with " <> show (response ^. responseStatus) <> ": " <> show body-                _ ->-                  "Request failed: " <> show content-      Right result ->-        case FeedImport.parseFeedSource $ result ^. responseBody of-          Just (FeedTypes.AtomFeed x) ->-            maybeToEither "Invalid feed date" $-              iso8601ParseM $-                T.unpack $-                  FeedAtom.feedUpdated x-          Just (FeedTypes.RSSFeed x) ->-            maybeToEither "Invalid feed date" $-              FeedRSS.rssLastUpdate (FeedRSS.rssChannel x)-                >>= parseTimeM True defaultTimeLocale rfc822DateFormat . T.unpack-          Just (FeedTypes.RSS1Feed _) -> Left "RSS1 feed are not supported"-          Just (FeedTypes.XMLFeed _) -> Left "XML feed are not supported"-          Nothing -> Left "No feed found"--infixr 5 </>--(</>) :: String -> String -> String-"/" </> ('/' : ys) = '/' : ys-"/" </> ys = '/' : ys-"" </> ('/' : ys) = '/' : ys-"" </> ys = '/' : ys-[x] </> ('/' : ys) = x : '/' : ys-[x] </> ys = x : '/' : ys-(x0 : x1 : xs) </> ys = x0 : ((x1 : xs) </> ys)
− src/Security/Advisories/Sync/Git.hs
@@ -1,172 +0,0 @@-{-# LANGUAGE DerivingStrategies #-}-{-# LANGUAGE LambdaCase #-}---- |------ Helpers for deriving advisory metadata from a Git repo.-module Security.Advisories.Sync.Git-  ( GitDirectoryInfo (..),-    GitError (..),-    GitErrorCase (..),-    explainGitError,-    Repository (..),-    GitRepositoryEnsuredStatus (..),-    ensureGitRepositoryWithRemote,-    getDirectoryGitInfo,-    updateGitRepository,-    GitRepositoryStatus (..),-    gitRepositoryStatus,-  )-where--import Control.Monad (when)-import Control.Monad.IO.Class (liftIO)-import Control.Monad.Trans.Except (runExceptT, throwE)-import Data.Time (ZonedTime)-import Data.Time.Format.ISO8601 (iso8601ParseM)-import qualified System.Directory as D-import System.Exit (ExitCode (ExitSuccess))-import System.Process (readProcessWithExitCode)--type Cmd = (FilePath, [String])--data GitError = GitError-  { gitCmd :: Cmd,-    gitError :: GitErrorCase-  }--runGit :: [String] -> IO (Cmd, ExitCode, String, String)-runGit args =-  (\(status, stdout, stderr) -> (("git", args), status, stdout, stderr))-    <$> readProcessWithExitCode "git" args ""--data GitErrorCase-  = -- | exit code, stdout and stderr-    GitProcessError ExitCode String String-  | -- | unable to parse this input as a datetime-    GitTimeParseError String-  deriving (Show)--explainGitError :: GitError -> String-explainGitError e =-  unlines-    [ "Called " <> show (fst $ gitCmd e) <> " with " <> show (snd $ gitCmd e),-      case gitError e of-        GitProcessError status stdout stderr ->-          unlines-            [ "git exited with status " <> show status,-              ">>> standard output:",-              stdout,-              ">>> standard error:",-              stderr-            ]-        GitTimeParseError s ->-          "failed to parse time: " <> s-    ]--data Repository = Repository-  { repositoryRoot :: FilePath,-    repositoryUrl :: String,-    repositoryBranch :: String-  }--data GitRepositoryStatus-  = GitDirectoryMissing-  | GitDirectoryEmpty-  | GitDirectoryInitialized--gitRepositoryStatus :: Repository -> IO GitRepositoryStatus-gitRepositoryStatus repo = do-  exists <- D.doesDirectoryExist $ repositoryRoot repo-  if exists-    then D.withCurrentDirectory (repositoryRoot repo) $ do-      (_, checkStatus, checkStdout, _) <--        runGit ["rev-parse", "--is-inside-work-tree"]-      let out = filter (not . null) $ lines checkStdout-      case checkStatus of-        ExitSuccess-          | not (null out) && head out == "true" ->-              return GitDirectoryInitialized-        _ ->-          return GitDirectoryEmpty-    else return GitDirectoryMissing--data GitRepositoryEnsuredStatus-  = GitRepositoryCreated-  | GitRepositoryExisting--ensureGitRepositoryWithRemote ::-  Repository ->-  GitRepositoryStatus ->-  IO (Either GitError GitRepositoryEnsuredStatus)-ensureGitRepositoryWithRemote repo =-  \case-    GitDirectoryMissing ->-      clone-    GitDirectoryEmpty ->-      clone-    GitDirectoryInitialized ->-      return $ Right GitRepositoryExisting-  where-    clone = do-      (cmd, status, stdout, stderr) <--        runGit ["clone", "-b", repositoryBranch repo, repositoryUrl repo, repositoryRoot repo]-      return $-        if status /= ExitSuccess-          then Left $ GitError cmd $ GitProcessError status stdout stderr-          else Right GitRepositoryCreated--updateGitRepository :: Repository -> IO (Either GitError ())-updateGitRepository repo =-  D.withCurrentDirectory (repositoryRoot repo) $-    runExceptT $ do-      _ <- liftIO $ runGit ["remote", "add", "origin", repositoryUrl repo] -- can fail if it exists-      (setUrlCmd, setUrlStatus, setUrlStdout, setUrlStderr) <--        liftIO $ runGit ["remote", "set-url", "origin", repositoryUrl repo]-      when (setUrlStatus /= ExitSuccess) $-        throwE $-          GitError setUrlCmd $-            GitProcessError setUrlStatus setUrlStdout setUrlStderr--      (fetchAllCmd, fetchAllStatus, fetchAllStdout, fetchAllStderr) <--        liftIO $ runGit ["fetch", "--all"]-      when (fetchAllStatus /= ExitSuccess) $-        throwE $-          GitError fetchAllCmd $-            GitProcessError fetchAllStatus fetchAllStdout fetchAllStderr--      (checkoutBranchCmd, checkoutBranchStatus, checkoutBranchStdout, checkoutBranchStderr) <--        liftIO $ runGit ["checkout", repositoryBranch repo]-      when (checkoutBranchStatus /= ExitSuccess) $-        throwE $-          GitError checkoutBranchCmd $-            GitProcessError checkoutBranchStatus checkoutBranchStdout checkoutBranchStderr--      (resetCmd, resetStatus, resetStdout, resetStderr) <--        liftIO $ runGit ["reset", "--hard", "origin/" <> repositoryBranch repo]--      when (resetStatus /= ExitSuccess) $-        throwE $-          GitError resetCmd $-            GitProcessError resetStatus resetStdout resetStderr--newtype GitDirectoryInfo = GitDirectoryInfo-  { lastModificationCommitDate :: ZonedTime-  }--getDirectoryGitInfo :: FilePath -> IO (Either GitError GitDirectoryInfo)-getDirectoryGitInfo path = do-  (cmd, status, stdout, stderr) <--    runGit ["-C", path, "log", "--pretty=format:%cI", "--find-renames", "advisories"]-  let timestamps = filter (not . null) $ lines stdout-      onError = Left . GitError cmd-  case status of-    ExitSuccess-      | not (null timestamps) ->-          return $-            GitDirectoryInfo-              <$> parseTime onError (head timestamps)-    _ ->-      return $ onError $ GitProcessError status stdout stderr-  where-    parseTime onError s = maybe (onError $ GitTimeParseError s) Right $ iso8601ParseM s
+ src/Security/Advisories/Sync/Snapshot.hs view
@@ -0,0 +1,214 @@+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+--+-- Helpers for deriving advisory metadata from a Snapshot s.+module Security.Advisories.Sync.Snapshot+  ( SnapshotDirectoryInfo (..),+    ETag (..),+    SnapshotError (..),+    explainSnapshotError,+    SnapshotUrl (..),+    SnapshotRepositoryEnsuredStatus (..),+    ensureSnapshot,+    getDirectorySnapshotInfo,+    overwriteSnapshot,+    SnapshotRepositoryStatus (..),+    snapshotRepositoryStatus,+    latestUpdate,+  )+where++import qualified Codec.Archive.Tar as Tar+import qualified Codec.Archive.Tar.Entry as Tar+import qualified Codec.Compression.GZip as GZip+import Control.Exception (Exception (displayException), IOException, try)+import Control.Lens+import Control.Monad.Extra (unlessM, whenM)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE, withExceptT)+import qualified Data.ByteString.Lazy as BL+import Data.Either.Combinators (whenLeft, fromRight)+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Text.IO as T+import Network.HTTP.Client (HttpException (..), HttpExceptionContent (..))+import Network.Wreq+import qualified System.Directory as D+import System.FilePath ((</>), hasTrailingPathSeparator, joinPath, splitPath)+import System.IO.Temp (withSystemTempDirectory)++data SnapshotError+  = SnapshotDirectoryMissingE+  | SnapshotIncoherent String+  | SnapshotProcessError SnapshotProcessError++data SnapshotProcessError+  = FetchSnapshotArchive String+  | DirectorySetupSnapshotArchive IOException+  | ExtractSnapshotArchive IOException++explainSnapshotError :: SnapshotError -> String+explainSnapshotError =+  \case+    SnapshotDirectoryMissingE -> "Snapshot directory is missing"+    SnapshotIncoherent e -> "Snapshot directory is incoherent: " <> e+    SnapshotProcessError e ->+      unlines+        [ "An exception occurred during snapshot processing:",+          case e of+            FetchSnapshotArchive x -> "Fetching failed with: " <> show x+            DirectorySetupSnapshotArchive x -> "Directory setup got an exception: " <> show x+            ExtractSnapshotArchive x -> "Extraction got an exception: " <> displayException x+        ]++newtype SnapshotUrl = SnapshotUrl {getSnapshotUrl :: String}++data SnapshotRepositoryStatus+  = SnapshotDirectoryMissing+  | SnapshotDirectoryInitialized+  | SnapshotDirectoryIncoherent++snapshotRepositoryStatus :: FilePath -> IO SnapshotRepositoryStatus+snapshotRepositoryStatus root = do+  dirExists <- D.doesDirectoryExist root+  if dirExists+    then do+      dirAdvisoriesExists <- D.doesDirectoryExist $ root </> "advisories"+      etagMetadataExists <- D.doesFileExist $ root </> "snapshot-etag"+      return $+        if dirAdvisoriesExists && etagMetadataExists+          then SnapshotDirectoryInitialized+          else SnapshotDirectoryIncoherent+    else return SnapshotDirectoryMissing++data SnapshotRepositoryEnsuredStatus+  = SnapshotRepositoryCreated+  | SnapshotRepositoryExisting++ensureSnapshot ::+  FilePath ->+  SnapshotUrl ->+  SnapshotRepositoryStatus ->+  ExceptT SnapshotError IO SnapshotRepositoryEnsuredStatus+ensureSnapshot root url =+  \case+    SnapshotDirectoryMissing -> do+      overwriteSnapshot root url+      return SnapshotRepositoryCreated+    SnapshotDirectoryIncoherent -> do+      overwriteSnapshot root url+      return SnapshotRepositoryCreated+    SnapshotDirectoryInitialized ->+      return SnapshotRepositoryExisting++overwriteSnapshot :: FilePath -> SnapshotUrl -> ExceptT SnapshotError IO ()+overwriteSnapshot root url =+  withExceptT SnapshotProcessError $ do+    ensuringPerformed <- liftIO $ try $ ensureEmptyRoot root+    whenLeft ensuringPerformed $+      throwE . DirectorySetupSnapshotArchive++    resultE <- liftIO $ try $ get $ getSnapshotUrl url+    case resultE of+      Left e ->+        throwE $+          FetchSnapshotArchive $+            case e of+              InvalidUrlException url' reason ->+                "Invalid URL " <> show url' <> ": " <> show reason+              HttpExceptionRequest _ content ->+                case content of+                  StatusCodeException response body ->+                    "Request (GET " <> getSnapshotUrl url <> ") failed with " <> show (response ^. responseStatus) <> ": " <> show body+                  _ ->+                    "Request (GET " <> getSnapshotUrl url <> ") failed: " <> show content+      Right result -> do+        performed <-+          liftIO $+            try $+              withSystemTempDirectory "security-advisories" $ \tempDir -> do+                let archivePath = tempDir <> "/snapshot-export.tar.gz"+                BL.writeFile archivePath $ result ^. responseBody+                contents <- BL.readFile archivePath+                let fixEntry e = e { Tar.entryTarPath = fixEntryPath $ Tar.entryTarPath e }+                    fixEntryPath :: Tar.TarPath -> Tar.TarPath+                    fixEntryPath p =+                      fromRight p $+                        maybe+                          (Right p)+                          (Tar.toTarPath (hasTrailingPathSeparator $ Tar.fromTarPath p) . joinPath) $+                        stripRootPath $+                        splitPath $+                        Tar.fromTarPath p+                    stripRootPath =+                      \case+                        ("/":_:p:ps) -> Just (p:ps)+                        (_:p:ps) -> Just (p:ps)+                        [p] | hasTrailingPathSeparator p -> Nothing+                        ps -> Just ps+                Tar.unpack root $ Tar.mapEntriesNoFail fixEntry $ Tar.read $ GZip.decompress contents+        whenLeft performed $+          throwE . ExtractSnapshotArchive++        etagWritten <-+          liftIO $+            try $+              T.writeFile (root </> "snapshot-etag") $+                T.decodeUtf8 $+                  result ^. responseHeader "etag"+        whenLeft etagWritten $+          throwE . ExtractSnapshotArchive++ensureEmptyRoot :: FilePath -> IO ()+ensureEmptyRoot root = do+  D.createDirectoryIfMissing False root++  whenM (D.doesDirectoryExist $ root </> "advisories") $+    D.removeDirectoryRecursive $+      root </> "advisories"++  whenM (D.doesFileExist $ root </> "snapshot-etag") $+    D.removeFile $+      root </> "snapshot-etag"++newtype SnapshotDirectoryInfo = SnapshotDirectoryInfo+  { etag :: ETag+  }+  deriving stock (Eq, Show)++newtype ETag = ETag T.Text+  deriving stock (Eq, Show)++getDirectorySnapshotInfo :: FilePath -> IO (Either SnapshotError SnapshotDirectoryInfo)+getDirectorySnapshotInfo root =+  runExceptT $ do+    let metadataPath = root </> "snapshot-etag"+    unlessM (liftIO $ D.doesFileExist metadataPath) $+      throwE SnapshotDirectoryMissingE++    SnapshotDirectoryInfo . ETag <$> liftIO (T.readFile metadataPath)++latestUpdate :: SnapshotUrl -> ExceptT SnapshotError IO ETag+latestUpdate url =+  withExceptT SnapshotProcessError $ do+    resultE <- liftIO $ try $ headWith (defaults & redirects .~ 3) $ getSnapshotUrl url+    case resultE of+      Left e ->+        throwE $+          FetchSnapshotArchive $+            case e of+              InvalidUrlException url' reason ->+                "Invalid URL " <> show url' <> ": " <> show reason+              HttpExceptionRequest _ content ->+                case content of+                  StatusCodeException response body ->+                    "Request (HEAD " <> getSnapshotUrl url <> ") failed with " <> show (response ^. responseStatus) <> ": " <> show body+                  _ ->+                    "Request (HEAD " <> getSnapshotUrl url <> ") failed: " <> show content+      Right result ->+        case result ^? responseHeader "etag" of+          Nothing -> throwE $ FetchSnapshotArchive "Missing ETag header"+          Just rawETag -> return $ ETag $ T.decodeUtf8 rawETag
+ src/Security/Advisories/Sync/Url.hs view
@@ -0,0 +1,27 @@+{-# LANGUAGE LambdaCase #-}+module Security.Advisories.Sync.Url+  ( mkUrl+  , ensureFile+  )+where++mkUrl :: [String] -> String+mkUrl = foldl1 (</>)++infixr 5 </>++(</>) :: String -> String -> String+"/" </> ('/' : ys) = '/' : ys+"/" </> ys = '/' : ys+"" </> ('/' : ys) = '/' : ys+"" </> ys = '/' : ys+[x] </> ('/' : ys) = x : '/' : ys+[x] </> ys = x : '/' : ys+(x0 : x1 : xs) </> ys = x0 : ((x1 : xs) </> ys)++ensureFile :: String -> String+ensureFile =+  \case+    "" -> ""+    "/" -> ""+    (x:xs) -> x : ensureFile xs
test/Spec/SyncSpec.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-}  module Spec.SyncSpec (spec) where@@ -8,79 +7,87 @@ import Security.Advisories.Sync import qualified System.Directory as D import System.Environment (lookupEnv)-import System.Exit (ExitCode (ExitSuccess)) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory)-import System.Process (readProcessWithExitCode) import Test.Tasty import Test.Tasty.HUnit  spec :: TestTree-spec =+spec = testGroup "Sync" []++_spec :: TestTree+_spec =   testGroup     "Sync"     [ testGroup         "sync"         [ testCase "Invalid root should fail" $ do-            let repo = withRepositoryAt "/dev/advisories"-            status repo >>= (@?= DirectoryMissing)+            let snapshot = snapshotAt "/dev/advisories"+            status snapshot >>= (@?= DirectoryMissing)             isGitHubActionRunner <- lookupEnv "GITHUB_ACTIONS"             unless (isGitHubActionRunner == Just "true") $ do               -- GitHub Action runners let you write anywhere-              result <- sync repo+              result <- sync snapshot               first (const ("<Redacted error>" :: String)) result @?= Left "<Redacted error>"-            status repo >>= (@?= DirectoryMissing),+            status snapshot >>= (@?= DirectoryMissing),           testCase "Subdirectory creation should work" $             withSystemTempDirectory "hsec-sync" $ \p -> do-              let repo = withRepositoryAt $ p </> "repo"-              status repo >>= (@?= DirectoryMissing)-              result <- sync repo+              let snapshot = snapshotAt $ p </> "snapshot"+              status snapshot >>= (@?= DirectoryMissing)+              result <- sync snapshot               result @?= Right Created-              status repo >>= (@?= DirectoryUpToDate),+              status snapshot >>= (@?= DirectoryUpToDate),           testCase "With existing subdirectory creation should work" $             withSystemTempDirectory "hsec-sync" $ \p -> do-              D.createDirectory $ p </> "repo"-              let repo = withRepositoryAt $ p </> "repo"-              result <- sync repo+              D.createDirectory $ p </> "snapshot"+              let snapshot = snapshotAt $ p </> "snapshot"+              result <- sync snapshot               result @?= Right Created,           testCase "Sync twice should be a no-op" $             withSystemTempDirectory "hsec-sync" $ \p -> do-              let repo = withRepositoryAt p-              status repo >>= (@?= DirectoryEmpty)-              resultCreate <- sync repo+              let snapshot = snapshotAt p+              status snapshot >>= (@?= DirectoryIncoherent)+              resultCreate <- sync snapshot               resultCreate @?= Right Created-              resultResync <- sync repo+              resultResync <- sync snapshot               resultResync @?= Right AlreadyUpToDate,           testCase "Sync behind should update" $             withSystemTempDirectory "hsec-sync" $ \p -> do-              let repo = withRepositoryAt p-              resultCreate <- sync repo+              let snapshot = snapshotAt p+              resultCreate <- sync snapshot               resultCreate @?= Right Created-              D.withCurrentDirectory p $ do-                (statusReset, _, _) <--                  readProcessWithExitCode "git" ["reset", "--hard", "HEAD~50"] ""-                statusReset @?= ExitSuccess-              status repo >>= (@?= DirectoryOutDated)-              resultResync <- sync repo+              writeFile+                (p </> "snapshot.json")+                "{\"latestUpdate\":\"2020-03-11T12:26:51Z\",\"snapshotVersion\":\"0.1.0.0\"}"+              status snapshot >>= (@?= DirectoryOutDated)+              resultResync <- sync snapshot               resultResync @?= Right Updated-              status repo >>= (@?= DirectoryUpToDate),-          testCase "Sync behind and changed remote should update" $+              status snapshot >>= (@?= DirectoryUpToDate),+          testCase "Sync a broken snapshot.json" $             withSystemTempDirectory "hsec-sync" $ \p -> do-              let repo = withRepositoryAt p-              resultCreate <- sync repo+              let snapshot = snapshotAt p+              resultCreate <- sync snapshot               resultCreate @?= Right Created-              D.withCurrentDirectory p $ do-                (statusReset, _, _) <--                  readProcessWithExitCode "git" ["reset", "--hard", "HEAD~50"] ""-                statusReset @?= ExitSuccess-                (statusRemote, _, _) <--                  readProcessWithExitCode "git" ["remote", "rename", "origin", "old"] ""-                statusRemote @?= ExitSuccess-              resultResync <- sync repo+              writeFile+                (p </> "snapshot.json")+                "{\"latestpdate\":\"2020-03-11T12:26:51Z\",\"snapshotVersion\":\"0.1.0.0\"}"+              status snapshot >>= (@?= DirectoryIncoherent)+              resultResync <- sync snapshot               resultResync @?= Right Updated+              status snapshot >>= (@?= DirectoryUpToDate),+          testCase "Sync a deleted snapshot.json" $+            withSystemTempDirectory "hsec-sync" $ \p -> do+              let snapshot = snapshotAt p+              resultCreate <- sync snapshot+              resultCreate @?= Right Created+              D.removeFile (p </> "snapshot.json")+              status snapshot >>= (@?= DirectoryOutDated)+              resultResync <- sync snapshot+              resultResync @?= Right Updated+              status snapshot >>= (@?= DirectoryIncoherent)         ]     ] -withRepositoryAt :: FilePath -> Repository-withRepositoryAt root =-  defaultRepository {repositoryRoot = root}+snapshotAt :: FilePath -> Snapshot+snapshotAt root =+  defaultSnapshot {snapshotRoot = root}