packages feed

hackage-server-0.4: Distribution/Server/Packages/Unpack.hs

-- Unpack a tarball containing a Cabal package
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Distribution.Server.Packages.Unpack (
    unpackPackage,
    unpackPackageRaw,
  ) where

import qualified Codec.Archive.Tar as Tar

import Distribution.Version
         ( Version(..) )
import Distribution.Package
         ( PackageIdentifier, packageVersion, packageName, PackageName(..) )
import Distribution.PackageDescription
         ( GenericPackageDescription(..), PackageDescription(..)
         , exposedModules )
import Distribution.PackageDescription.Parse
         ( parsePackageDescription )
import Distribution.PackageDescription.Configuration
         ( flattenPackageDescription )
import Distribution.PackageDescription.Check
         ( PackageCheck(..), checkPackage )
import Distribution.ParseUtils
         ( ParseResult(..), locatedErrorMsg, showPWarning )
import Distribution.Text
         ( display, simpleParse )
import Distribution.ModuleName
         ( components )
import Distribution.Server.Util.Parse
         ( unpackUTF8 )

import Data.List
         ( nub, (\\), partition, intercalate )
import Control.Monad
         ( unless, when )
import Control.Monad.Error
         ( ErrorT(..) )
import Control.Monad.Writer
         ( WriterT(..), MonadWriter, tell )
import Control.Monad.Identity
         ( Identity(..) )
import qualified Distribution.Server.Util.GZip as GZip
import Data.ByteString.Lazy
         ( ByteString )
import qualified Data.ByteString.Lazy as LBS
import System.FilePath
         ( (</>), (<.>), splitExtension, splitDirectories, normalise )
import qualified System.FilePath.Windows
         ( takeFileName )

-- | Upload or check a tarball containing a Cabal package.
-- Returns either an fatal error or a package description and a list
-- of warnings.
unpackPackage :: FilePath -> ByteString
              -> Either String
                        ((GenericPackageDescription, ByteString), [String])
unpackPackage tarGzFile contents =
  runUploadMonad $ do
    (entries, pkgDesc, warnings, cabalEntry) <- basicChecks False tarGzFile contents
    mapM_ fail warnings
    extraChecks entries pkgDesc
    return (pkgDesc, cabalEntry)

unpackPackageRaw :: FilePath -> ByteString
                 -> Either String
                           ((GenericPackageDescription, ByteString), [String])
unpackPackageRaw tarGzFile contents =
  runUploadMonad $ do
    (_entries, pkgDesc, _warnings, cabalEntry) <- basicChecks True tarGzFile contents
    return (pkgDesc, cabalEntry)

basicChecks :: Bool -> FilePath -> ByteString
            -> UploadMonad (Tar.Entries Tar.FormatError, GenericPackageDescription, [String], ByteString)
basicChecks lax tarGzFile contents = do
  let (pkgidStr, ext) = (base, tar ++ gz)
        where (tarFile, gz) = splitExtension (portableTakeFileName tarGzFile)
              (base,   tar) = splitExtension tarFile
  unless (ext == ".tar.gz") $
    fail $ tarGzFile ++ " is not a gzipped tar file, it must have the .tar.gz extension"

  pkgid <- case simpleParse pkgidStr of
    Just pkgid
      | display pkgid == pkgidStr -> return (pkgid :: PackageIdentifier)

      | not . null . versionTags . packageVersion $ pkgid
      -> fail $ "Hackage no longer accepts packages with version tags: "
             ++ intercalate ", " (versionTags (packageVersion pkgid))
    _ -> fail $ "Invalid package id " ++ show pkgidStr
             ++ ". The tarball must use the name of the package."

  -- Extract the .cabal file from the tarball
  let selectEntry entry = case Tar.entryContent entry of
        Tar.NormalFile bs _ | cabalFileName == normalise (Tar.entryPath entry)
                           -> Just bs
        _                  -> Nothing
      PackageName name  = packageName pkgid
      cabalFileName     = display pkgid </> name <.> "cabal"
      entries           = Tar.read (GZip.decompressNamed tarGzFile contents)
  cabalEntries <- selectEntries lax selectEntry entries
  cabalEntry   <- case cabalEntries of
    -- NB: tar files *can* contain more than one entry for the same filename.
    -- (This was observed in practice with the package CoreErlang-0.0.1).
    -- In this case, after extracting the tar the *last* file in the archive
    -- wins. Since selectEntries returns results in reverse order we use the head:
    cabalEntry:_ -> -- We tend to keep hold of the .cabal file, but
                    -- cabalEntry itself is part of a much larger
                    -- ByteString (the whole tar file), so we make a
                    -- copy of it
                    return $ LBS.copy cabalEntry
    [] -> fail $ "The " ++ quote cabalFileName
              ++ " file is missing from the package tarball."

  -- Parse the Cabal file
  let cabalFileContent = unpackUTF8 cabalEntry
  (pkgDesc, warnings) <- case parsePackageDescription cabalFileContent of
    ParseFailed err -> fail $ showError (locatedErrorMsg err)
    ParseOk warnings pkgDesc ->
      return (pkgDesc, map (showPWarning cabalFileName) warnings)

  -- Check that the name and version in Cabal file match
  when (packageName pkgDesc /= packageName pkgid) $
    fail "Package name in the cabal file does not match the file name."
  when (packageVersion pkgDesc /= packageVersion pkgid) $
    fail "Package version in the cabal file does not match the file name."

  return (entries, pkgDesc, warnings, cabalEntry)

  where
    showError (Nothing, msg) = msg
    showError (Just n, msg) = "line " ++ show n ++ ": " ++ msg

-- | The issue is that browsers can upload the file name using either unix
-- or windows convention, so we need to take the basename using either
-- convention. Since windows allows the unix '/' as a separator then we can
-- use the Windows.takeFileName as a portable solution.
--
portableTakeFileName :: FilePath -> String
portableTakeFileName = System.FilePath.Windows.takeFileName

-- Miscellaneous checks on package description
extraChecks :: Tar.Entries Tar.FormatError -> GenericPackageDescription -> UploadMonad ()
extraChecks entries genPkgDesc = do
  let pkgDesc = flattenPackageDescription genPkgDesc
  -- various checks

  --FIXME: do the content checks. The dev version of Cabal generalises
  -- checkPackageContent to work in any monad, we just need to provide
  -- a record of ops that will do checks inside the tarball. We should
  -- gather a map of files and dirs and have these just to map lookups:
  --
  -- > checkTarballContents = CheckPackageContentOps {
  -- >   doesFileExist      = Set.member fileMap,
  -- >   doesDirectoryExist = Set.member dirsMap
  -- > }
  -- > fileChecks <- checkPackageContent checkTarballContents pkgDesc

  let pureChecks = checkPackage genPkgDesc (Just pkgDesc)
      checks = pureChecks -- ++ fileChecks
      isDistError (PackageDistSuspicious {}) = False
      isDistError _                          = True
      (errors, warnings) = partition isDistError checks
  mapM_ (fail . explanation) errors
  mapM_ (warn . explanation) warnings

  -- Check sanity of the tarball. Some of the tarballs we import from
  -- old Hackage fail this check because e.g. they contain files
  -- using the ././@LongLink hack for long file names
  let checkEntries f = Tar.foldEntries (\x mr -> case f x of Nothing -> mr; Just s -> fail (show s)) (return ()) (fail . show) entries
  checkEntries (checkTarFilePath (package (packageDescription genPkgDesc)))
  checkEntries checkTarFileType

  -- Check reasonableness of names of exposed modules
  let topLevel = case library pkgDesc of
                 Nothing -> []
                 Just l ->
                     nub $ map head $ filter (not . null) $ map components $ exposedModules l
      badTopLevel = topLevel \\ allocatedTopLevelNodes

  unless (null badTopLevel) $
          warn $ "Exposed modules use unallocated top-level names: " ++
                          unwords badTopLevel

-- Monad for uploading packages:
--      WriterT for warning messages
--      Either for fatal errors
newtype UploadMonad a = UploadMonad (WriterT [String] (ErrorT String Identity) a)
  deriving (Monad, MonadWriter [String])

warn :: String -> UploadMonad ()
warn msg = tell [msg]

runUploadMonad :: UploadMonad a -> Either String (a, [String])
runUploadMonad (UploadMonad m) = runIdentity . runErrorT . runWriterT $ m

-- | Registered top-level nodes in the class hierarchy.
allocatedTopLevelNodes :: [String]
allocatedTopLevelNodes = [
        "Algebra", "Codec", "Control", "Data", "Database", "Debug",
        "Distribution", "DotNet", "Foreign", "Graphics", "Language",
        "Network", "Numeric", "Prelude", "Sound", "System", "Test", "Text"]

selectEntries :: Bool -> (Tar.Entry -> Maybe a)
              -> Tar.Entries Tar.FormatError
              -> UploadMonad [a]
selectEntries lax select = extract []
  where
    -- We ignore those errors that are present in the historical hackage
    -- DB in lax mode (used when mirroring hackage)
    extract selected (Tar.Fail Tar.ShortTrailer)
     | lax                                    = return selected
    extract _        (Tar.Fail err)           = fail (show err)
    extract selected  Tar.Done                = return selected
    extract selected (Tar.Next entry entries) =
      case select entry of
        Nothing    -> extract          selected  entries
        Just saved -> extract (saved : selected) entries

checkTarFileType :: Tar.Entry -> Maybe String
checkTarFileType entry
  | case Tar.entryContent entry of
      Tar.NormalFile _ _ -> True
      Tar.Directory      -> True
      Tar.SymbolicLink _ -> True
      Tar.HardLink     _ -> True
      _                  -> False
  = Nothing

  | otherwise
  = Just $ "Bad file type in package tarball: " ++ Tar.entryPath entry
        ++ "\nFor portability, package tarballs should use the 'ustar' format "
        ++ "and only contain normal files, directories and file links. "
        ++ "Your tar program may be using non-standard extensions. For "
        ++ "example with GNU tar, use --format=ustar to get the portable "
        ++ "format."

checkTarFilePath :: PackageIdentifier -> Tar.Entry -> Maybe String
checkTarFilePath pkgid entry
  | not (all (/= "..") dirs)
  = Just $ "Bad file name in package tarball: " ++ quote (Tar.entryPath entry)
        ++ "\nFor security reasons, files in package tarballs may not use"
        ++ " \"..\" components in their path."
  | not (inPkgSubdir dirs)
  = Just $ "Bad file name in package tarball: " ++ quote (Tar.entryPath entry)
        ++ "\nAll the file in the package tarball must be in the subdirectory "
        ++ quote pkgstr ++ "."
  | otherwise
  = Nothing
  where
    dirs = splitDirectories (Tar.entryPath entry)
    pkgstr = display pkgid
    inPkgSubdir (".":pkgstr':_) = pkgstr == pkgstr'
    inPkgSubdir (    pkgstr':_) = pkgstr == pkgstr'
    inPkgSubdir _               = False

quote :: String -> String
quote s = "'" ++ s ++ "'"