packages feed

streamly-archive-0.3.0: test/Streamly/External/Archive/Tests.hs

{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}

module Streamly.External.Archive.Tests (tests) where

import qualified Codec.Archive.Tar as Tar
import Codec.Compression.GZip
import Control.Concurrent.Async
import Control.Monad
import Crypto.Random.Entropy
import Data.Bifunctor
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy as LB
import Data.Char
import Data.Function
import Data.Functor
import Data.List
import Data.List.Split
import Data.Maybe
import qualified Data.Set as Set
import Data.Word
import qualified Streamly.Data.Fold as F
import qualified Streamly.Data.Stream.Prelude as S
import Streamly.External.Archive
import Streamly.External.Archive.Internal.Foreign
import System.Directory
import System.FilePath
import System.IO.Temp
import Test.QuickCheck
import Test.QuickCheck.Monadic
import Test.Tasty
import Test.Tasty.HUnit
import Test.Tasty.QuickCheck

tests :: [TestTree]
tests =
  [ testTar False,
    testTar True,
    testSparse,
    testChunkOnAndChunkOnFold,
    testEitherByLeft
  ]

-- | Use other libraries to create a tar (or tar.gz) file containing random data, read the file back
-- using our library, and check if the results are as expected.
testTar :: Bool -> TestTree
testTar gz = testProperty ("tar (" ++ (if gz then "gz" else "no gz") ++ ")") $ monadicIO $ do
  -- Generate a random file system hierarchy for writing to disk.
  hierarchyToWrite <- pick $ randomHierarchy "" 4 4 5

  numThreads <- pick $ chooseInt (1, 4)

  -- Of the hierarchy we wrote, sometimes we only read some of them back.
  readSome :: Bool <- pick arbitrary
  readSomePaths <-
    pick $
      sublistOf hierarchyToWrite
        <&> Set.fromList
          . map (("files/" ++) . fst) -- Make comparable to what our library reads back.

  -- Create a new temporary directory, write our hierarchy into a "files" subdirectory of the
  -- temporary directory, use other libraries to create files.tar (or files.tar.gz), read the file
  -- back using our library, and check if the results are as expected.
  run . withSystemTempDirectory "archive-streaming-testZip" $ \tmpDir -> do
    let filesDir = joinPath [tmpDir, "files"]
    createDirectoryIfMissing True filesDir
    writePathsAndByteStrings <- writeHierarchy filesDir hierarchyToWrite

    let archFile = joinPath [tmpDir, "files.tar" ++ (if gz then ".gz" else "")]
    LB.writeFile archFile . (if gz then compress else id) . Tar.write =<< Tar.pack tmpDir ["files"]

    let fileFold =
          F.foldlM'
            ( \(mfp, mtyp, msz, mbs) e ->
                case e of
                  Left h -> do
                    mfp_ <- headerPathName h
                    mtyp_ <- headerFileType h
                    msz_ <- headerSize h
                    return (BC.unpack <$> mfp_, mtyp_, msz_, mbs)
                  Right bs ->
                    return
                      ( mfp,
                        mtyp,
                        msz,
                        case mbs of
                          Nothing -> Just bs
                          Just bs' -> Just $ bs' `BC.append` bs
                      )
            )
            (return (Nothing, Nothing, Nothing, Nothing))

    readPathsFileTypesSizesAndByteStringss <-
      replicateConcurrently numThreads $
        S.unfold
          readArchive
          ( if readSome
              then
                mapHeaderMaybe
                  ( \h -> do
                      p <- fromJust <$> headerPathName h
                      return $
                        if BC.unpack p `Set.member` readSomePaths
                          then Just h
                          else Nothing
                  )
              else
                id,
            archFile
          )
          & groupByLeft fileFold
          & fmap (\(mfp, mtyp, msz, mbs) -> (fromJust mfp, fromJust mtyp, msz, mbs))
          & S.toList

    threadResults <- forM readPathsFileTypesSizesAndByteStringss $
      \readPathsFileTypesSizesAndByteStrings -> do
        let readPathAndByteStrings =
              sort . map (\(x, _, _, y) -> (x, y)) $ readPathsFileTypesSizesAndByteStrings

        let writePathsAndByteStrings2 =
              sort
                . (if readSome then filter (\(x, _) -> x `Set.member` readSomePaths) else id)
                . map (first ("files/" ++)) -- Make comparable to what our library reads back.
                $ ("", Nothing) : writePathsAndByteStrings

        let samePathsAndByteStrings = writePathsAndByteStrings2 == readPathAndByteStrings

        -- Check FileType.
        let fileTypesCorrect =
              all
                ( \(fp, typ, _, _) ->
                    if hasTrailingPathSeparator fp
                      then typ == FileTypeDirectory
                      else typ == FileTypeRegular
                )
                readPathsFileTypesSizesAndByteStrings

        -- Check header file size.
        let fileSizeCorrect =
              all
                ( \(_, _, msz, mbs) ->
                    case (msz, mbs) of
                      (Nothing, _) -> False -- The size is always available.
                      (Just sz, Nothing) -> sz == 0 -- File or directory.
                      (Just sz, Just bs) -> fromIntegral sz == B.length bs
                )
                readPathsFileTypesSizesAndByteStrings

        return $ samePathsAndByteStrings && fileTypesCorrect && fileSizeCorrect

    return $ and threadResults

-- | Read a fixed sparse file (sparse.tar) using our library and make sure the results are as
-- expected. (The file was created manually on Linux with "cp --sparse=always" to create the sparse
-- files and "tar -Scvf" to create the archive. We were unable to do the equivalent thing on macOS
-- Mojave / APFS.)
testSparse :: TestTree
testSparse = testProperty "sparse" $ monadicIO $ do
  let fileFold =
        F.foldlM'
          ( \(mfp, mbs) e ->
              case e of
                Left h -> do
                  mfp_ <- headerPathName h
                  return (BC.unpack <$> mfp_, mbs)
                Right bs ->
                  return
                    ( mfp,
                      case mbs of
                        Nothing -> Just bs
                        Just bs' -> Just $ bs' `BC.append` bs
                    )
          )
          (return (Nothing, Nothing))

  numThreads <- pick $ chooseInt (1, 4)

  archives <-
    run $
      replicateConcurrently numThreads $
        S.unfold readArchive (id, "test/data/sparse.tar")
          & groupByLeft fileFold
          & fmap (\(mfp, mbs) -> (fromJust mfp, fromJust mbs))
          & S.toList

  threadResults <- forM archives $ \archive -> do
    let validPaths = map fst archive == ["zero", "zeroZero", "zeroAsdf", "asdfZero"]

    let tenMb = 10_000_000
    let zero = B.replicate tenMb 0
    let asdf = "asdf"

    let validByteString1 = snd (head archive) == zero
    let validByteString2 = snd (archive !! 1) == zero `B.append` zero
    let validByteString3 = snd (archive !! 2) == zero `B.append` asdf
    let validByteString4 = snd (archive !! 3) == asdf `B.append` zero

    return $
      and
        [ validPaths,
          validByteString1,
          validByteString2,
          validByteString3,
          validByteString4
        ]

  return $ and threadResults

testChunkOnAndChunkOnFold :: TestTree
testChunkOnAndChunkOnFold = testProperty "chunkOn/chunkOnFold" $ monadicIO $ do
  -- Although we say “lines,” our splitWd is an arbitrary non-printable ASCII character.
  splitWd :: Word8 <- pick $ elements [0 .. 31]

  let maxLineLen = 50
      maxChunkSz = 100

      genLine = do
        lineLen <- choose (0, maxLineLen)
        B.pack
          <$>
          -- For easier debug visualization, lines have only a to z.
          replicateM lineLen (elements [97 .. 122])

      genLines = do
        numLines <- pick $ choose (0, 10)
        if numLines <= 2
          then
            replicateM numLines $ pick genLine
          else do
            lines' <- replicateM (numLines - 2) $ pick genLine
            -- Make it a bit more likely we begin/end with an empty line
            begLine <- pick $ frequency [(5, return ""), (95, genLine)]
            endLine <- pick $ frequency [(5, return ""), (95, genLine)]
            return $ [begLine] ++ lines' ++ [endLine]

  chunkSz <- pick $ choose (1, maxChunkSz)

  let linesToChunks [""] = [""] -- Special case.
      linesToChunks lns =
        map B.pack
          . chunksOf chunkSz
          . B.unpack
          -- ["line1", "\n" , "line2"] -> "line1\nline2"
          . B.concat
          -- ["line1", "line2"] -> ["line1", "\n" , "line2"]
          . intersperse (B.singleton splitWd)
          $ lns

  -- Most users of streamly-archive will probably have none of these, but we test this case
  -- nonetheless.
  linesBeforeFirstFile <- genLines

  numFiles <- pick $ choose (0, 5)

  files <- replicateM numFiles $ do
    fileLines <- genLines
    fileName :: Int <- pick arbitrary
    return (fileName, fileLines)

  let chunks =
        map Right (linesToChunks linesBeforeFirstFile)
          ++ concatMap
            (\(fileName, fileLines) -> Left fileName : map Right (linesToChunks fileLines))
            files
      expectedChunkOnResult =
        map Right linesBeforeFirstFile
          ++ concatMap
            (\(fileName, fileLines) -> Left fileName : map Right fileLines)
            files

  chunkOnResult <-
    S.fromList chunks
      & chunkOn splitWd
      & S.fold F.toList

  chunkOnFoldResult <-
    S.fromList chunks
      & S.fold (chunkOnFold splitWd F.toList)

  return $
    chunkOnResult == expectedChunkOnResult
      && chunkOnFoldResult == expectedChunkOnResult

testEitherByLeft :: TestTree
testEitherByLeft = testCase "eitherByLeft" $ do
  let getRes = S.fold F.toList . eitherByLeft . S.fromList

  res1 <- getRes [Right 10, Left "a", Right 1, Right 2, Left "b", Left "c", Right 20]
  res1 @?= ([("a", 1), ("a", 2), ("c", 20)] :: [(String, Int)])

  res2 <- getRes []
  res2 @?= ([] :: [(String, Int)])

-- | Writes a given hierarchy of relative paths (created with 'randomHierarchy') to disk in the
-- specified directory and returns the same hierarchy except with actual ByteStrings instead of
-- lengths. Note: The original relative paths are returned back unaltered.
writeHierarchy :: FilePath -> [(FilePath, Maybe Int)] -> IO [(FilePath, Maybe ByteString)]
writeHierarchy writeDir = mapM $ \(p, mBsLen) ->
  let fullp = joinPath [writeDir, p]
   in case mBsLen of
        Just bsLen -> do
          createDirectoryIfMissing True (takeDirectory fullp)
          bs <- getEntropy (fromIntegral bsLen)
          B.writeFile fullp bs
          return
            ( p,
              if bsLen == 0
                then Nothing -- Our library yields no ByteString at all for empty files.
                else Just bs
            )
        Nothing -> createDirectoryIfMissing True fullp >> return (p, Nothing)

-- | Recursively generates a random hierarchy of relative paths to files and directories. (Nothing
-- is written to disk; only the paths are returned.) The initial dirPath should be "". A random
-- bytestring length is provided in case of a file; 'Nothing' in the case of a directory.
randomHierarchy :: FilePath -> Int -> Int -> Int -> Gen [(FilePath, Maybe Int)]
randomHierarchy dirPath maxFiles maxDirs maxDepth = do
  numFiles <- choose (0, maxFiles)
  fileComps <- nub <$> vectorOf numFiles pathComponent
  let filePaths = map (\c -> joinPath [dirPath, c]) fileComps
  bsLengths <-
    map Just
      <$> vectorOf
        (length filePaths)
        ( frequency
            [ (1, choose (0, 5)),
              (1, choose (blockSize - 5, blockSize + 5)),
              (1, choose (0, 3 * blockSize))
            ]
        )

  numDirs <- choose (0, maxDirs)
  dirComps <-
    nub . filter (not . (`elem` fileComps))
      <$> vectorOf (if maxDepth <= 0 then 0 else numDirs) pathComponent
  -- libarchive reads back directory paths with a trailing separator.
  let dirPaths = map (\c -> addTrailingPathSeparator $ joinPath [dirPath, c]) dirComps

  recursion <-
    concat
      <$> forM
        dirPaths
        ( \dirPath' ->
            randomHierarchy dirPath' (maxFiles `div` 2) (maxDirs `div` 2) (maxDepth - 1)
        )

  return $ zip filePaths bsLengths ++ map (,Nothing) dirPaths ++ recursion

-- | Generates a random path component of length between 1 and 10, e.g., "HO53UVKQ". For
-- compatibility with case-insensitive file systems, uses only one case.
pathComponent :: Gen String
pathComponent = do
  len <- choose (1, 10)
  vectorOf len $
    chr
      <$> frequency
        [ (1, choose (ord 'A', ord 'Z')),
          (1, choose (ord '0', ord '9'))
        ]