{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
module Cabal.Index (
indexMetadata,
cachedHackageMetadata,
MetadataParseError (..),
InvalidHash (..),
InvalidIndexFile (..),
NoHackageRepository (..),
PackageInfo (..),
piPreferredVersions,
ReleaseInfo (..),
SHA256,
sha256,
validSHA256,
mkSHA256,
unsafeMkSHA256,
getSHA256,
foldIndex,
IndexEntry (..),
IndexFileType (..),
) where
import Prelude hiding (pi)
import Control.Exception
(Exception, IOException, bracket, evaluate, handle, throwIO)
import Control.Monad (unless, void)
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Data.Text (Text)
import GHC.Generics (Generic)
import qualified Codec.Archive.Tar as Tar
import qualified Codec.Archive.Tar.Entry as Tar
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.Aeson as A
import qualified Data.Binary as Binary
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Map.Strict as Map
import qualified Data.Text.Encoding as TE
import qualified Data.Time.Clock.POSIX as Time
import qualified Distribution.Compat.CharParsing as C
import qualified Distribution.Package as C
import qualified Distribution.Parsec as C
import qualified Distribution.Parsec.FieldLineStream as C
import qualified Distribution.Pretty as C
import qualified Distribution.Simple.Utils as C
import qualified Distribution.Version as C
import qualified Lukko
import qualified System.Directory as D
import qualified System.FilePath as FP
import qualified Text.PrettyPrint as PP
import Data.Binary.Instances ()
import Cabal.Config (cfgRepoIndex, hackageHaskellOrg, readConfig)
foldIndex
:: FilePath
-> a
-> (IndexEntry -> ByteString -> a -> IO a)
-> IO a
foldIndex fp ini action = do
contents <- LBS.readFile fp
foldEntries go throwIO ini (Tar.read contents)
where
go !acc entry = case Tar.entryContent entry of
Tar.NormalFile contents _ -> do
bs <- evaluate $ LBS.toStrict contents
idxFile <- either (throwIO . InvalidIndexFile) return (elaborateIndexFile fpath)
let entry' = IndexEntry
{ entryPath = Tar.fromTarPath (Tar.entryTarPath entry)
, entryPermissions = Tar.entryPermissions entry
, entryOwnership = Tar.entryOwnership entry
, entryTime = Tar.entryTime entry
, entryType = idxFile
}
action entry' bs acc
Tar.Directory -> return acc
_ -> return acc
where
fpath = Tar.entryPath entry
foldEntries :: (a -> Tar.Entry -> IO a) -> (e -> IO a) -> a -> Tar.Entries e -> IO a
foldEntries next fail' = go where
go !acc (Tar.Next e es) = next acc e >>= \acc' -> go acc' es
go _ (Tar.Fail e) = fail' e
go acc Tar.Done = return acc
data IndexEntry = IndexEntry
{ entryPath :: FilePath
, entryType :: IndexFileType
, entryPermissions :: Tar.Permissions
, entryOwnership :: Tar.Ownership
, entryTime :: Tar.EpochTime
}
deriving Show
data IndexFileType
= CabalFile C.PackageName C.Version
| PackageJson C.PackageName C.Version
| PreferredVersions C.PackageName
deriving (Show)
newtype InvalidIndexFile = InvalidIndexFile String
deriving (Show)
instance Exception InvalidIndexFile
elaborateIndexFile :: FilePath -> Either String IndexFileType
elaborateIndexFile fp = case FP.splitDirectories fp of
[ pn, v, pnF ]
| Just pn' <- C.simpleParsec pn
, Just v' <- C.simpleParsec v
, pnF == pn ++ ".cabal"
-> Right (CabalFile pn' v')
[ pn, v, pj ]
| Just pn' <- C.simpleParsec pn
, Just v' <- C.simpleParsec v
, pj == "package.json"
-> Right (PackageJson pn' v')
[ pn, pref ]
| Just pn' <- C.simpleParsec pn
, pref == "preferred-versions"
-> Right (PreferredVersions pn')
xs -> Left $ show xs
newtype SHA256 = SHA256 ByteString
deriving (Eq, Ord)
sha256 :: ByteString -> SHA256
sha256 = SHA256 . SHA256.hash
mkSHA256 :: Text -> Either String SHA256
mkSHA256 t = case Base16.decode (TE.encodeUtf8 t) of
(bs, rest) | not (BS.null rest) -> Left $ "Base16 encoding leftovers" ++ show rest
| BS.length bs /= 32 -> Left $ "Base16 of wrong length, expected 32, got " ++ show (BS.length bs)
| otherwise -> Right (SHA256 bs)
unsafeMkSHA256 :: Text -> SHA256
unsafeMkSHA256 = either error id . mkSHA256
emptySHA256 :: SHA256
emptySHA256 = SHA256 BS.empty
validSHA256 :: SHA256 -> Bool
validSHA256 (SHA256 bs) = BS.length bs == 32
getSHA256 :: SHA256 -> ByteString
getSHA256 (SHA256 bs) = bs
instance C.Pretty SHA256 where
pretty = PP.text . C.fromUTF8BS . Base16.encode . getSHA256
instance Show SHA256 where
showsPrec d (SHA256 bs)
= showParen (d > 10)
$ showString "unsafeMkSHA256 "
. shows (Base16.encode bs)
instance Binary.Binary SHA256 where
put (SHA256 bs) = Binary.put bs
get = do
bs <- Binary.get
case BS.length bs of
32 -> return (SHA256 bs)
l -> fail $ "Invalid SHA256 length " ++ show l
newtype MD5 = MD5 ByteString
deriving (Eq, Ord)
instance Show MD5 where
showsPrec d (MD5 bs)
= showParen (d > 10)
$ showString "unsafeMkMD5 "
. shows (Base16.encode bs)
mkMD5 :: Text -> Either String MD5
mkMD5 t = case Base16.decode (TE.encodeUtf8 t) of
(bs, rest) | not (BS.null rest) -> Left $ "Base16 encoding leftovers" ++ show rest
| BS.length bs /= 16 -> Left $ "Base16 of wrong length, expected 16, got " ++ show (BS.length bs)
| otherwise -> Right (MD5 bs)
data PackageInfo = PackageInfo
{ piVersions :: Map C.Version ReleaseInfo
, piPreferred :: C.VersionRange
}
deriving (Eq, Show, Generic)
instance Binary.Binary PackageInfo
piPreferredVersions :: PackageInfo -> Map C.Version ReleaseInfo
piPreferredVersions pi =
Map.filterWithKey (\v _ -> v `C.withinRange` piPreferred pi) (piVersions pi)
data ReleaseInfo = ReleaseInfo
{ riRevision :: Word
, riCabal :: SHA256
, riTarball :: SHA256
}
deriving (Eq, Show, Generic)
instance Binary.Binary ReleaseInfo
indexMetadata
:: FilePath
-> Maybe Tar.EpochTime
-> IO (Map C.PackageName PackageInfo)
indexMetadata indexFilepath mindexState = do
result <- foldIndex indexFilepath Map.empty $ \indexEntry contents m ->
if maybe False (entryTime indexEntry >) mindexState
then return m
else case entryType indexEntry of
CabalFile pn ver -> return $ Map.alter f pn m where
f :: Maybe PackageInfo -> Maybe PackageInfo
f Nothing = Just PackageInfo
{ piVersions = Map.singleton ver (ReleaseInfo 0 (sha256 contents) emptySHA256)
, piPreferred = C.anyVersion
}
f (Just pi) = Just pi { piVersions = Map.alter g ver (piVersions pi) }
g :: Maybe ReleaseInfo -> Maybe ReleaseInfo
g Nothing = Just $ ReleaseInfo 0 (sha256 contents) emptySHA256
g (Just (ReleaseInfo r c t))
| r == 0 && not (validSHA256 c) = Just $ ReleaseInfo 0 (sha256 contents) t
| otherwise = Just $ ReleaseInfo (succ r) (sha256 contents) t
PackageJson pn ver -> case A.eitherDecodeStrict contents of
Left err -> throwIO $ MetadataParseError (entryPath indexEntry) err
Right (PJ (Signed (Targets ts))) ->
case Map.lookup ("<repo>/package/" ++ C.prettyShow pn ++ "-" ++ C.prettyShow ver ++ ".tar.gz") ts of
Just t -> return $ Map.alter (f t) pn m
Nothing -> throwIO $ MetadataParseError (entryPath indexEntry) $ "Invalid targets in " ++ entryPath indexEntry ++ " -- " ++ show ts
where
f :: Target -> Maybe PackageInfo -> Maybe PackageInfo
f t Nothing = Just PackageInfo
{ piVersions = Map.singleton ver (ReleaseInfo 0 emptySHA256 (hashSHA256 (targetHashes t)))
, piPreferred = C.anyVersion
}
f t (Just pi) = Just pi { piVersions = Map.alter (g t) ver (piVersions pi) }
g :: Target -> Maybe ReleaseInfo -> Maybe ReleaseInfo
g t Nothing = Just $ ReleaseInfo 0 emptySHA256 (hashSHA256 (targetHashes t))
g t (Just (ReleaseInfo r c _)) = Just $ ReleaseInfo r c (hashSHA256 (targetHashes t))
PreferredVersions pn
| BS.null contents -> return m
| otherwise -> case explicitEitherParsecBS preferredP contents of
Right vr -> return $ Map.alter (f vr) pn m
Left err -> throwIO $ MetadataParseError (entryPath indexEntry) err
where
preferredP = do
_ <- C.string (C.prettyShow pn)
C.spaces
C.parsec
f :: C.VersionRange -> Maybe PackageInfo -> Maybe PackageInfo
f vr Nothing = Just PackageInfo
{ piVersions = Map.empty
, piPreferred = vr
}
f vr (Just pi) = Just pi { piPreferred = vr }
postCheck result
return result
postCheck :: Map C.PackageName PackageInfo -> IO ()
postCheck meta = ifor_ meta $ \pn pi -> ifor_ (piVersions pi) $ \ver ri -> do
unless (validSHA256 (riCabal ri)) $ throwIO $ InvalidHash pn ver "cabal"
unless (validSHA256 (riTarball ri)) $ throwIO $ InvalidHash pn ver "tarball"
where
ifor_ :: Map k v -> (k -> v -> IO a) -> IO ()
ifor_ xs f = Map.foldlWithKey' (\m k a -> m >> void (f k a)) (return ()) xs
data MetadataParseError = MetadataParseError FilePath String
deriving (Show)
instance Exception MetadataParseError
data InvalidHash = InvalidHash C.PackageName C.Version String
deriving (Show)
instance Exception InvalidHash
cachedHackageMetadata :: IO (Map C.PackageName PackageInfo)
cachedHackageMetadata = do
cfg <- readConfig
indexPath <- maybe
(throwIO NoHackageRepository)
return
(cfgRepoIndex cfg hackageHaskellOrg)
cacheDir <- D.getXdgDirectory D.XdgCache "cabal-parsers"
D.createDirectoryIfMissing True cacheDir
let cacheFile = cacheDir FP.</> "hackage.binary"
bracket (takeLock supported cacheDir) (releaseLock supported) $ \_ -> do
(size, time) <- getStat indexPath
mcache <- readCache cacheFile
case mcache of
Just cache | cacheSize cache == size && cacheTime cache == time ->
return $ cacheData cache
_ -> do
meta <- indexMetadata indexPath Nothing
LBS.writeFile cacheFile $ Binary.encode Cache
{ cacheMagic = Magic
, cacheTime = time
, cacheSize = size
, cacheData = meta
}
return meta
where
readCache :: FilePath -> IO (Maybe Cache)
readCache fp = handle onIOError $ do
contents <- LBS.readFile fp
case Binary.decodeOrFail contents of
Right (lo,_,x) | LBS.null lo -> return (Just x)
_ -> return Nothing
onIOError :: IOException -> IO (Maybe a)
onIOError _ = return Nothing
supported :: SBool Lukko.FileLockingSupported
supported = sbool
takeLock :: SBool b -> FilePath -> IO (FDType b)
takeLock STrue dir = do
fd <- Lukko.fdOpen (dir FP.</> "lock")
Lukko.fdLock fd Lukko.ExclusiveLock
return fd
takeLock SFalse _ = return ()
releaseLock :: SBool b -> FDType b -> IO ()
releaseLock STrue fd = Lukko.fdUnlock fd >> Lukko.fdClose fd
releaseLock SFalse () = return ()
getStat :: FilePath -> IO (Int64, Int64)
getStat p = do
size <- D.getFileSize p
time <- D.getModificationTime p
return (fromIntegral size, truncate (Time.utcTimeToPOSIXSeconds time))
data NoHackageRepository = NoHackageRepository
deriving Show
instance Exception NoHackageRepository
data Cache = Cache
{ cacheMagic :: !Magic
, cacheSize :: !Int64
, cacheTime :: !Int64
, cacheData :: Map C.PackageName PackageInfo
}
deriving Generic
instance Binary.Binary Cache
data Magic = Magic
instance Binary.Binary Magic where
put _ = Binary.put magicNumber
get = do
m <- Binary.get
if m == magicNumber then return Magic else fail "Got wrong magic number"
magicNumber :: Int64
magicNumber = 0xfedcba09
class SBoolI (b :: Bool) where
type FDType b
sbool :: SBool b
instance SBoolI 'True where
type FDType 'True = Lukko.FD
sbool = STrue
instance SBoolI 'False where
type FDType 'False = ()
sbool = SFalse
data SBool (b :: Bool) where
STrue :: SBool 'True
SFalse :: SBool 'False
explicitEitherParsecBS :: C.ParsecParser a -> ByteString -> Either String a
explicitEitherParsecBS parser
= either (Left . show) Right
. C.runParsecParser (parser <* C.spaces) "<eitherParsec>"
. C.fieldLineStreamFromBS
newtype PJ = PJ (Signed Targets)
deriving Show
newtype Signed a = Signed a
deriving Show
newtype Targets = Targets (Map FilePath Target)
deriving Show
data Target = Target
{ _targetLength :: Word
, targetHashes :: Hashes
}
deriving Show
data Hashes = Hashes
{ _hashMD5 :: MD5
, hashSHA256 :: SHA256
}
deriving Show
instance A.FromJSON PJ where
parseJSON = A.withObject "package.json" $ \obj ->
PJ <$> obj A..: "signed"
instance A.FromJSON a => A.FromJSON (Signed a) where
parseJSON = A.withObject "signed (targets)" $ \obj -> do
A.String "Targets" <- obj A..: "_type"
A.Null <- obj A..: "expires"
Signed <$> obj A..: "targets"
instance A.FromJSON Targets where
parseJSON = fmap Targets . A.parseJSON
instance A.FromJSON Target where
parseJSON = A.withObject "Target" $ \obj -> Target
<$> obj A..: "length"
<*> obj A..: "hashes"
instance A.FromJSON Hashes where
parseJSON = A.withObject "Hashes" $ \obj -> Hashes
<$> (obj A..: "md5" >>= either fail return . mkMD5)
<*> (obj A..: "sha256" >>= either fail return . mkSHA256)