packages feed

oglyad-0.1.1.0: src/Oglyad/Source/Repodata.hs

-- | reads published dnf repodata: repomd.xml → primary.xml.{zst,gz,xz} → packages
module Oglyad.Source.Repodata
  ( RpmEntry (..),
    fetchRepo,
    decompressAndParse,
    parseRepomd,
    parsePrimary,
    srpmName,
    latestBySource,
  )
where

import Conduit (foldlC, runConduit, sourceHandle, sourceLazy, (.|))
import Control.DeepSeq (deepseq)
import Control.Exception (SomeException, try)
import Control.Monad ((<$!>), (>=>))
import Control.Monad.Reader (liftIO)
import Data.Bifunctor (first)
import Data.ByteString.Lazy qualified as LBS
import Data.Char (isAlphaNum)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Read qualified as TR
import Data.Time (UTCTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Data.XML.Types (Content (..), Event (..))
import Oglyad.App (App, HttpError (..), httpGetE, note)
import Oglyad.Process (childEnv)
import Oglyad.Source.Cache (loadJson, saveJson)
import Oglyad.Types (RepoBuild (..))
import Oglyad.Version (EVR (..))
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.IO (Handle)
import System.Process.Typed (byteStringInput, createPipe, getStdout, nullStream, proc, setEnv, setStderr, setStdin, setStdout, waitExitCode, withProcessTerm)
import Text.XML (Name (..), def, parseLBS)
import Text.XML.Cursor (attribute, attributeIs, element, fromDocument, ($//), (&/))
import Text.XML.Stream.Parse (parseBytes)

-- | one binary (or source) rpm from primary.xml
-- | strict fields, filled with copied text (see 'finish' in 'parsePrimary'),
-- so a parsed entry never keeps the decompressed file alive
data RpmEntry = RpmEntry
  { rpmName :: !Text,
    rpmArch :: !Text,
    rpmEVR :: !EVR,
    -- | e.g. zed-0.190.0-1.fc44.src.rpm, absent on src rpms
    rpmSourceRpm :: !(Maybe Text),
    rpmBuildTime :: !UTCTime
  }
  deriving stock (Eq, Show)

-- | builds per source package for a repo, reusing the last parse when the
-- repo hasn't changed. repomd.xml is tiny, and the primary.xml href in it
-- embeds a checksum, so an unchanged href means unchanged contents
fetchRepo :: Text -> App (Either String (Map Text RepoBuild))
fetchRepo base = do
  let file = "repodata-" <> T.unpack (T.map (\c -> if isAlphaNum c then c else '_') base) <> ".json"
  repomd <- httpGetE (base <> "/repodata/repomd.xml")
  case first httpErr repomd >>= parseRepomd of
    Left err -> pure (Left err)
    Right href ->
      liftIO (loadJson file) >>= \case
        Just (cachedHref, builds) | cachedHref == href -> pure (Right builds)
        _ -> do
          note ("downloading " <> T.unpack base <> "/" <> T.unpack href)
          r <- fmap latestBySource <$> fetchPrimaryAt base href
          case r of
            Right builds -> liftIO (saveJson file (href, builds))
            Left _ -> pure ()
          pure r

-- | fetch and parse the primary.xml at a href from repomd.xml
fetchPrimaryAt :: Text -> Text -> App (Either String [RpmEntry])
fetchPrimaryAt base href = do
  download <- httpGetE (base <> "/" <> href)
  case first httpErr download of
    Left err -> pure (Left err)
    Right compressed -> liftIO (decompressAndParse href compressed)

httpErr :: HttpError -> String
httpErr = \case
  RateLimited why -> T.unpack why
  HttpFailed why -> T.unpack why

-- | decompress a primary.xml by its file extension and parse it, streaming
-- from the decompressor's pipe so even Fedora's ~250MB file never sits in
-- memory.
--
-- decompression goes through the zstd, gzip and xz commands, which every
-- Fedora install has. Fedora doesn't package a Haskell zstd binding, and
-- oglyad should build from Fedora's own libraries
decompressAndParse :: Text -> LBS.ByteString -> IO (Either String [RpmEntry])
decompressAndParse href compressed
  | ".zst" `T.isSuffixOf` href = viaCommand "zstd" ["--decompress", "--stdout", "--quiet"]
  | ".gz" `T.isSuffixOf` href = viaCommand "gzip" ["--decompress", "--stdout", "--quiet"]
  | ".xz" `T.isSuffixOf` href = viaCommand "xz" ["--decompress", "--stdout", "--quiet"]
  | ".xml" `T.isSuffixOf` href = pure (parsePrimary compressed)
  | otherwise = pure (Left ("don't know how to decompress " <> T.unpack href))
  where
    -- feed the download to the command's stdin and parse its stdout as it
    -- arrives. a corrupt archive makes the command fail, which shows up as
    -- a parse error or a non-zero exit, never a crash
    viaCommand cmd args = do
      env <- childEnv <$> getEnvironment
      let cfg = setEnv env (setStdin (byteStringInput compressed) (setStdout createPipe (setStderr nullStream (proc cmd args))))
      r <- try $ withProcessTerm cfg $ \p -> do
        parsed <- parsePrimaryHandle (getStdout p)
        code <- waitExitCode p
        pure $ case (code, parsed) of
          (ExitFailure n, _) -> Left (cmd <> " exited with " <> show n <> " decompressing " <> T.unpack href)
          (ExitSuccess, _) -> parsed
      pure (either (\e -> Left (cmd <> ": " <> show (e :: SomeException))) id r)

repoNs, commonNs, rpmNs :: Text -> Name
repoNs n = Name n (Just "http://linux.duke.edu/metadata/repo") Nothing
commonNs n = Name n (Just "http://linux.duke.edu/metadata/common") Nothing
rpmNs n = Name n (Just "http://linux.duke.edu/metadata/rpm") Nothing

-- | find the location of primary.xml in repomd.xml.
-- this uses the xml-conduit Cursor api, which builds the whole document in
-- memory. that's fine for a 2KB file:
--   $//  = all descendants matching
--   &/   = children matching
--   >=>  = compose two axes (it's just Kleisli composition on lists)
parseRepomd :: LBS.ByteString -> Either String Text
parseRepomd bs = do
  doc <- first show (parseLBS def bs)
  let hrefs =
        fromDocument doc
          $// (element (repoNs "data") >=> attributeIs "type" "primary")
          &/ (element (repoNs "location") >=> attribute "href")
  case hrefs of
    (h : _) -> Right h
    [] -> Left "no primary entry in repomd.xml"

-- | what we've seen so far of the <package> we're currently inside
data PkgAcc = PkgAcc
  { accName :: Text,
    accArch :: Text,
    accEVR :: Maybe EVR,
    accTime :: Maybe UTCTime,
    accSrpm :: Maybe Text
  }

emptyAcc :: PkgAcc
emptyAcc = PkgAcc "" "" Nothing Nothing Nothing

-- | which element's text we're currently collecting
data Capture = CapName | CapArch | CapSrpm | CapNone
  deriving stock (Eq)

data St = St
  { stAcc :: !PkgAcc,
    stCap :: !Capture,
    -- | finished packages, newest first
    stOut :: [RpmEntry],
    -- | open elements, nonzero at the end means a truncated file
    stDepth :: !Int
  }

-- | parse decompressed primary.xml from memory. the real runs stream from a
-- pipe with 'parsePrimaryHandle'. this one is for small inputs and tests.
--
-- unlike repomd.xml this file is big (Terra's is ~20MB, Fedora's is ~200MB)
-- so we never build a DOM. instead the XML is turned into a stream of events
-- (element opened, text, element closed, ...) and folded over with a small
-- state machine. memory use stays proportional to the results, not the file
parsePrimary :: LBS.ByteString -> Either String [RpmEntry]
parsePrimary bs =
  first (\e -> show (e :: SomeException)) (runConduit (sourceLazy bs .| parseBytes def .| foldlC stepPrimary st0))
    >>= finishPrimary

-- | parse primary.xml straight from a handle.
--
-- reading the handle chunk by chunk matters: handing the parser a lazy
-- ByteString instead lets the thunk evaluating it hold on to the start of the
-- stream, which keeps the whole decompressed file (250MB for Fedora) alive
-- until parsing ends
parsePrimaryHandle :: Handle -> IO (Either String [RpmEntry])
parsePrimaryHandle h = do
  r <- try (runConduit (sourceHandle h .| parseBytes def .| foldlC stepPrimary st0))
  pure (either (\e -> Left (show (e :: SomeException))) finishPrimary r)

st0 :: St
st0 = St emptyAcc CapNone [] 0

finishPrimary :: St -> Either String [RpmEntry]
finishPrimary final
  | stDepth final /= 0 = Left "primary.xml ends in the middle of an element, truncated download?"
  | otherwise = Right (reverse (stOut final))

-- | one xml event. tracks element depth so truncation can be detected
stepPrimary :: St -> Event -> St
stepPrimary cur ev = case ev of
  EventBeginElement _ _ -> step cur {stDepth = stDepth cur + 1} ev
  EventEndElement _ -> step cur {stDepth = stDepth cur - 1} ev
  _ -> step cur ev
  where
    step st = \case
      EventBeginElement name attrs
        | name == commonNs "package" -> st {stAcc = emptyAcc, stCap = CapNone}
        | name == commonNs "name" -> st {stCap = CapName}
        | name == commonNs "arch" -> st {stCap = CapArch}
        | name == rpmNs "sourcerpm" -> st {stCap = CapSrpm}
        | name == commonNs "version" ->
            let attr k = lookupAttr k attrs
                evr = EVR <$> (attr "epoch" >>= readInt) <*> attr "ver" <*> attr "rel"
             in st {stAcc = (stAcc st) {accEVR = evr}}
        | name == commonNs "time" ->
            let t = posixSecondsToUTCTime . fromIntegral <$> (lookupAttr "build" attrs >>= readInt)
             in st {stAcc = (stAcc st) {accTime = t}}
        | otherwise -> st
      EventContent (ContentText t) -> case stCap st of
        CapName -> st {stAcc = (stAcc st) {accName = accName (stAcc st) <> t}}
        CapArch -> st {stAcc = (stAcc st) {accArch = accArch (stAcc st) <> t}}
        CapSrpm -> st {stAcc = (stAcc st) {accSrpm = Just (maybe t (<> t) (accSrpm (stAcc st)))}}
        CapNone -> st
      EventEndElement name
        | name == commonNs "package" -> case finish (stAcc st) of
            Just e -> st {stOut = e : stOut st, stCap = CapNone}
            Nothing -> st {stCap = CapNone}
        | otherwise -> st {stCap = CapNone}
      _ -> st

    -- the text we collected is sliced out of the parser's buffers, and a
    -- slice keeps its whole buffer alive. copying is what stops 75k Fedora
    -- entries from pinning the entire decompressed file in memory
    --
    -- the build time needs the same care. UTCTime's fields are lazy, so
    -- without a deep force it stays a thunk pointing at the xml attribute it
    -- came from, which pins that attribute's whole decoder chunk
    finish PkgAcc {..} = do
      EVR e v r <- accEVR
      t <- accTime
      if T.null accName
        then Nothing
        else t `deepseq` (Just $! RpmEntry (T.copy accName) (T.copy accArch) (EVR e (T.copy v) (T.copy r)) (T.copy <$!> accSrpm) t)

    lookupAttr :: Name -> [(Name, [Content])] -> Maybe Text
    lookupAttr k attrs = do
      cs <- lookup k attrs
      pure (T.concat (mapMaybe contentText cs))

    contentText = \case
      ContentText t -> Just t
      ContentEntity _ -> Nothing

    readInt :: Text -> Maybe Int
    readInt t = case TR.decimal t of
      Right (n, "") -> Just n
      _ -> Nothing

-- | source package name from a srpm filename: strip ".src.rpm", then the last
-- two dash-separated fields (version and release)
srpmName :: Text -> Maybe Text
srpmName file = do
  base <- T.stripSuffix ".src.rpm" file
  let parts = T.splitOn "-" base
  if length parts < 3
    then Nothing
    else Just (T.intercalate "-" (take (length parts - 2) parts))

-- | collapse binary rpms down to one build per source package, keeping the
-- newest build time. this is what lets us line repodata up with the monorepo
latestBySource :: [RpmEntry] -> Map Text RepoBuild
latestBySource = foldr step Map.empty
  where
    step e acc = case srcOf e of
      Nothing -> acc
      Just src -> Map.insertWith newer src (RepoBuild (srcEVR e) (rpmBuildTime e)) acc
    srcOf e = maybe (Just (rpmName e)) srpmName (rpmSourceRpm e)
    newer a b = if rbBuildTime a >= rbBuildTime b then a else b
    -- binary subpackages can carry their own Version/Release, so prefer the
    -- EVR spelled out in the srpm filename when there is one
    srcEVR e = case rpmSourceRpm e >>= srpmEVR of
      Just evr -> evr {evrEpoch = evrEpoch (rpmEVR e)}
      Nothing -> rpmEVR e
    srpmEVR file = do
      base <- T.stripSuffix ".src.rpm" file
      let parts = T.splitOn "-" base
      case reverse parts of
        rel : ver : _ : _ -> Just (EVR 0 ver rel)
        _ -> Nothing