packages feed

lsm-tree-1.1.0.0: test/Test/Util/FS.hs

{-# OPTIONS_GHC -Wno-orphans #-}


module Test.Util.FS (
    -- * Real file system
    withTempIOHasFS
  , withTempIOHasBlockIO
    -- * Simulated file system
  , withSimHasFS
  , withSimHasBlockIO
    -- * Simulated file system with errors
  , withSimErrorHasFS
  , withSimErrorHasBlockIO
    -- * Simulated file system properties
  , propTrivial
  , propNumOpenHandles
  , propNoOpenHandles
  , numDirEntries
  , propNumDirEntries
  , propNoDirEntries
  , propEqNumDirEntries
  , assertNoOpenHandles
  , assertNumOpenHandles
    -- * Equality
  , approximateEqStream
    -- * List directory
  , DirEntry (..)
  , listDirectoryFiles
  , listDirectoryRecursive
  , listDirectoryRecursiveFiles
    -- * Corruption
  , flipRandomBitInRandomFileHardlinkSafe
  , flipRandomBitInRandomFile
  , flipFileBitHardlinkSafe
  , flipFileBit
  , hFlipBit
    -- * Errors
  , noHCloseE
  , noRemoveFileE
  , noRemoveDirectoryRecursiveE
  , filterHGetBufSomeE
  , isFsReachedEOF
    -- * Arbitrary
  , FsPathComponent (..)
  , fsPathComponentFsPath
  , fsPathComponentString
    -- ** Modifiers
  , NoCleanupErrors (..)
    -- ** Orphans
  , isPathChar
  , pathChars
  ) where

import           Control.Concurrent.Class.MonadMVar
import           Control.Concurrent.Class.MonadSTM.Strict
import           Control.Exception (assert)
import           Control.Monad (void)
import           Control.Monad.Class.MonadThrow (MonadMask, MonadThrow)
import           Control.Monad.IOSim (runSimOrThrow)
import           Control.Monad.Primitive (PrimMonad)
import           Data.Bit (MVector (..), flipBit)
import           Data.Char (isAscii, isDigit, isLetter)
import           Data.Foldable (Foldable (..), foldlM, for_)
import           Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import           Data.Primitive.ByteArray (newPinnedByteArray, setByteArray)
import           Data.Primitive.Types (sizeOf)
import           Data.Set (Set)
import qualified Data.Set as Set
import qualified Data.Text as T
import           Data.Traversable (for)
import           Database.LSMTree.Internal.CRC32C (CRC32C (..), readFileCRC32C)
import           GHC.Stack
import           System.FS.API as FS
import qualified System.FS.API.Lazy as FSL
import           System.FS.BlockIO.API
import           System.FS.BlockIO.IO hiding (unsafeFromHasFS)
import           System.FS.BlockIO.Sim (unsafeFromHasFS)
import           System.FS.IO
import           System.FS.Sim.Error
import           System.FS.Sim.MockFS (HandleMock, MockFS, numOpenHandles,
                     openHandles, pretty)
import           System.FS.Sim.STM
import qualified System.FS.Sim.Stream as Stream
import           System.FS.Sim.Stream (InternalInfo (..), Stream (..))
import           System.IO.Temp
import           Test.QuickCheck
import           Test.QuickCheck.Instances ()
import           Test.Util.QC (Choice, getChoice)
import           Text.Printf

{-------------------------------------------------------------------------------
  Real file system
-------------------------------------------------------------------------------}

withTempIOHasFS :: FilePath -> (HasFS IO HandleIO -> IO a) -> IO a
withTempIOHasFS path action = withSystemTempDirectory path $ \dir -> do
    let hfs = ioHasFS (MountPoint dir)
    action hfs

withTempIOHasBlockIO :: FilePath -> (HasFS IO HandleIO -> HasBlockIO IO HandleIO -> IO a) -> IO a
withTempIOHasBlockIO path action = withSystemTempDirectory path $ \dir -> do
    withIOHasBlockIO (MountPoint dir) defaultIOCtxParams action

{-------------------------------------------------------------------------------
  Simulated file system
-------------------------------------------------------------------------------}

{-# INLINABLE withSimHasFS #-}
withSimHasFS ::
     (MonadSTM m, MonadMask m, PrimMonad m, Testable prop1, Testable prop2)
  => (MockFS -> prop1)
  -> MockFS
  -> (  HasFS m HandleMock
     -> StrictTMVar m MockFS
     -> m prop2
     )
  -> m Property
withSimHasFS post fs k = do
    var <- newTMVarIO fs
    let hfs = simHasFS var
    x <- k hfs var
    fs' <- atomically $ readTMVar var
    pure (x .&&. post fs')

{-# INLINABLE withSimHasBlockIO #-}
withSimHasBlockIO ::
     (MonadMVar m, MonadSTM m, MonadMask m, PrimMonad m, Testable prop1, Testable prop2)
  => (MockFS -> prop1)
  -> MockFS
  -> (  HasFS m HandleMock
     -> HasBlockIO m HandleMock
     -> StrictTMVar m MockFS
     -> m prop2
     )
  -> m Property
withSimHasBlockIO post fs k = do
    withSimHasFS post fs $ \hfs fsVar -> do
      hbio <- unsafeFromHasFS hfs
      k hfs hbio fsVar

{-------------------------------------------------------------------------------
  Simulated file system with errors
-------------------------------------------------------------------------------}

{-# INLINABLE withSimErrorHasFS #-}
withSimErrorHasFS ::
     (MonadSTM m, MonadMask m, PrimMonad m, Testable prop1, Testable prop2)
  => (MockFS -> prop1)
  -> MockFS
  -> Errors
  -> (  HasFS m HandleMock
     -> StrictTMVar m MockFS
     -> StrictTVar m Errors
     -> m prop2
     )
  -> m Property
withSimErrorHasFS post fs errs k = do
    fsVar <- newTMVarIO fs
    errVar <- newTVarIO errs
    let hfs = simErrorHasFS fsVar errVar
    x <- k hfs fsVar errVar
    fs' <- atomically $ readTMVar fsVar
    pure (x .&&. post fs')

{-# INLINABLE withSimErrorHasBlockIO #-}
withSimErrorHasBlockIO ::
     ( MonadSTM m, MonadMask m, MonadMVar m, PrimMonad m
     , Testable prop1, Testable prop2
     )
  => (MockFS -> prop1)
  -> MockFS
  -> Errors
  -> (  HasFS m HandleMock
     -> HasBlockIO m HandleMock
     -> StrictTMVar m MockFS
     -> StrictTVar m Errors
     -> m prop2
     )
  -> m Property
withSimErrorHasBlockIO post fs errs k =
    withSimErrorHasFS post fs errs $ \hfs fsVar errsVar -> do
      hbio <- unsafeFromHasFS hfs
      k hfs hbio fsVar errsVar

{-------------------------------------------------------------------------------
  Simulated file system properties
-------------------------------------------------------------------------------}

propTrivial :: MockFS -> Property
propTrivial _ = property True

{-# INLINABLE propNumOpenHandles #-}
propNumOpenHandles :: Int -> MockFS -> Property
propNumOpenHandles expected fs =
    counterexample (printf "Expected %d open handles, but found %d" expected actual) $
    counterexample ("Open handles: " <> show (openHandles fs)) $
    printMockFSOnFailure fs $
    expected == actual
  where actual = numOpenHandles fs

{-# INLINABLE propNoOpenHandles #-}
propNoOpenHandles :: MockFS -> Property
propNoOpenHandles fs = propNumOpenHandles 0 fs

numDirEntries :: FsPath -> MockFS -> Int
numDirEntries path fs = Set.size contents
  where
    (contents, _) =
        runSimOrThrow $ runSimFS fs $ \hfs -> FS.listDirectory hfs path

{-# INLINABLE propNumDirEntries #-}
propNumDirEntries :: FsPath -> Int -> MockFS -> Property
propNumDirEntries path expected fs =
    counterexample
      (printf "Expected %d entries in the directory at %s, but found %d"
        expected
        (show path) actual) $
    printMockFSOnFailure fs $
    expected === actual
  where actual = numDirEntries path fs

{-# INLINABLE propNoDirEntries #-}
propNoDirEntries :: FsPath -> MockFS -> Property
propNoDirEntries path fs = propNumDirEntries path 0 fs

{-# INLINABLE propEqNumDirEntries #-}
propEqNumDirEntries :: FsPath -> MockFS -> MockFS -> Property
propEqNumDirEntries path lhsFs rhsFs =
    counterexample
      (printf "The LHS has %d entries in the directory at %s, but the RHS has %d"
        lhs (show path) rhs) $
      printMockFSOnFailureWith "Mocked file system (LHS)" lhsFs $
      printMockFSOnFailureWith "Mocked file system (RHS)" rhsFs $
      lhs === rhs
  where
    lhs = numDirEntries path lhsFs
    rhs = numDirEntries path rhsFs

printMockFSOnFailure :: Testable prop => MockFS -> prop -> Property
printMockFSOnFailure = printMockFSOnFailureWith "Mocked file system"

printMockFSOnFailureWith :: Testable prop => String -> MockFS -> prop -> Property
printMockFSOnFailureWith s fs = counterexample (s <> ": " <> pretty fs)

assertNoOpenHandles :: HasCallStack => MockFS -> a -> a
assertNoOpenHandles fs = assertNumOpenHandles fs 0

assertNumOpenHandles :: HasCallStack => MockFS -> Int -> a -> a
assertNumOpenHandles fs m =
    assert $
      if n /= m then
        error (printf "Expected %d open handles, but found %d" m n)
      else
        True
  where n = numOpenHandles fs

{-------------------------------------------------------------------------------
  Equality
-------------------------------------------------------------------------------}

-- | Approximate equality for streams.
--
-- Equality is checked as follows:
-- * Infinite streams are equal: any infinity is as good as another infinity
-- * Finite streams are checked for pointwise equality on their elements.
-- * Other streams are trivially unequal: they do not have matching finiteness
--
-- This approximate equality satisfies the __Reflexivity__, __Symmetry__,
-- __Transitivity__ and __Negation__ laws for the 'Eq' class, but not
-- __Substitutivity.
--
-- TODO: upstream to fs-sim
approximateEqStream :: Eq a => Stream a -> Stream a -> Bool
approximateEqStream (UnsafeStream infoXs xs) (UnsafeStream infoYs ys) =
    case (infoXs, infoYs) of
      (Infinite, Infinite) -> True
      (Finite, Finite)     ->  xs == ys
      (_, _)               -> False

{-------------------------------------------------------------------------------
  List directory
-------------------------------------------------------------------------------}

-- TODO: test the list directory functions

data DirEntry a = Directory a | File a
  deriving stock (Show, Eq, Ord, Functor)

-- | List all files and directories in the given directory and recursively in all
-- sub-directories.
listDirectoryRecursive ::
     Monad m
  => HasFS m h
  -> FsPath
  -> m (Set (DirEntry FsPath))
listDirectoryRecursive hfs = go Set.empty (mkFsPath [])
  where
    go !acc relPath absPath = do
        pcs <- listDirectory hfs absPath
        foldlM (\acc' -> go' acc' relPath absPath) acc pcs

    go' !acc relPath absPath pc = do
        let
          p = mkFsPath [pc]
          relPath' = relPath </> p
          absPath' = absPath </> p
        isFile <- doesFileExist hfs absPath'
        if isFile then
          pure (File relPath' `Set.insert` acc)
        else do
          isDirectory <- doesDirectoryExist hfs absPath'
          if isDirectory then
            go (Directory relPath' `Set.insert` acc) relPath' absPath'
          else
            error $ printf
              "listDirectoryRecursive: %s is not a file or directory"
              (show relPath')

-- | List files in the given directory and recursively in all sub-directories.
listDirectoryRecursiveFiles ::
     Monad m
  => HasFS m h
  -> FsPath
  -> m (Set FsPath)
listDirectoryRecursiveFiles hfs dir = do
    dirEntries <- listDirectoryRecursive hfs dir
    foldlM f Set.empty dirEntries
  where
    f !acc (File p) = pure $ Set.insert p acc
    f !acc _        = pure acc

-- | List files in the given directory
listDirectoryFiles ::
     Monad m
  => HasFS m h
  -> FsPath
  -> m (Set FsPath)
listDirectoryFiles hfs = go Set.empty
  where
    go !acc absPath = do
        pcs <- listDirectory hfs absPath
        foldlM go' acc pcs

    go' !acc pc = do
        let path = mkFsPath [pc]
        isFile <- doesFileExist hfs path
        if isFile then
          pure (path `Set.insert` acc)
        else
          pure acc
{-------------------------------------------------------------------------------
  Corruption
-------------------------------------------------------------------------------}

-- | Flip a random bit in a random file in a given directory.
flipRandomBitInRandomFile ::
     (PrimMonad m, MonadThrow m)
  => HasFS m h
  -> Choice
  -> FsPath
  -> m (Maybe (FsPath, Int))
flipRandomBitInRandomFile hfs bitChoice dir = do
  maybeFileBit <- pickRandomBitInRandomFile hfs bitChoice dir
  for_ maybeFileBit $ \(file, bit) -> flipFileBit hfs file bit
  pure maybeFileBit

-- | Flip a random bit in a random file in a given directory.
flipRandomBitInRandomFileHardlinkSafe ::
     (PrimMonad m, MonadThrow m)
  => HasFS m h
  -> Choice
  -> FsPath
  -> m (Maybe (FsPath, Int))
flipRandomBitInRandomFileHardlinkSafe hfs bitChoice dir = do
  maybeFileBit <- pickRandomBitInRandomFile hfs bitChoice dir
  for_ maybeFileBit $ \(file, bit) -> flipFileBitHardlinkSafe hfs file bit
  pure maybeFileBit

-- | Pick a random bit in a random file in a given directory.
pickRandomBitInRandomFile ::
     (PrimMonad m, MonadThrow m)
  => HasFS m h
  -> Choice
  -> FsPath
  -> m (Maybe (FsPath, Int))
pickRandomBitInRandomFile hfs bitChoice dir = do
  -- List all files
  files <- fmap (dir </>) . toList <$> listDirectoryRecursiveFiles hfs dir
  -- Handle the situation where there are no files
  if null files then pure Nothing else do
    filesAndFileSizeBits <-
      for files $ \file -> do
        fileSizeBytes <- withFile hfs file ReadMode (hGetSize hfs)
        pure (file, fileSizeBytes * 8)
    let totalFileSizeBits = sum (snd <$> filesAndFileSizeBits)
    -- Handle the situation where there are no non-empty files
    if totalFileSizeBits == 0 then pure Nothing else do
      assert (totalFileSizeBits > 0) $ pure ()
      -- Internal helper: find the file/bit that a choice points to.
      let pickFileBitAt bitIndex [] =
            error $ printf "flipFileBitAt: bit index out of bounds (%d)" bitIndex
          pickFileBitAt bitIndex ((file, fileSize) : filesAndSizes)
            | bitIndex < fileSize = pure (file, fromIntegral $ bitIndex `min` fromIntegral (maxBound @Int))
            | otherwise = pickFileBitAt (bitIndex - fileSize) filesAndSizes
      -- Interpret `index` to point to a bit between `0` and `totalFileSize - 1`
      let bitIndex = getChoice bitChoice (0, totalFileSizeBits - 1)
      Just <$> pickFileBitAt bitIndex filesAndFileSizeBits

-- | Flip a single bit in the given file, ensuring that it is not hardlinked.
flipFileBitHardlinkSafe ::
     (PrimMonad m, MonadThrow m)
  => HasFS m h
  -> FsPath
  -> Int -- ^ Bit offset.
  -> m ()
flipFileBitHardlinkSafe hfs fileOrig bitOffset = do
  -- Compute the CRC of fileOrig:
  CRC32C crc <- readFileCRC32C hfs fileOrig
  -- Copy fileOrig to fileCorr:
  let copyFile fileFrom fileTo =
        withFile hfs fileFrom ReadMode $ \hFrom ->
          withFile hfs fileTo (WriteMode MustBeNew) $ \hTo -> do
            bs <- FSL.hGetAll hfs hFrom
            void $ FSL.hPutAll hfs hTo bs
  let fileCorr = (fileOrig <.> show crc) <.> "corrupted"
  copyFile fileOrig fileCorr
  -- Corrupt fileCorr:
  flipFileBit hfs fileCorr bitOffset
  -- Hardlink fileCorr over fileOrig:
  removeFile hfs fileOrig
  renameFile hfs fileCorr fileOrig

-- | Flip a single bit in the given file.
flipFileBit :: (MonadThrow m, PrimMonad m) => HasFS m h -> FsPath -> Int -> m ()
flipFileBit hfs p bitOffset =
    withFile hfs p (ReadWriteMode AllowExisting) $ \h -> hFlipBit hfs h bitOffset

-- | Flip a single bit in the file pointed to by the given handle.
hFlipBit ::
     (MonadThrow m, PrimMonad m)
  => HasFS m h
  -> Handle h
  -> Int -- ^ Bit offset
  -> m ()
hFlipBit hfs h bitOffset = do
    -- Check that the bit offset is within the file
    fileSize <- hGetSize hfs h
    let fileSizeBits = 8 * fileSize
    assert (bitOffset >= 0) $ pure ()
    assert (bitOffset < fromIntegral fileSizeBits) $ pure ()
    -- Create an empty buffer initialised to all 0 bits. The buffer must have at
    -- least the size of a machine word.
    let n = sizeOf (0 :: Word)
    buf <- newPinnedByteArray n
    setByteArray buf 0 1 (0 :: Word)
    -- Read the bit at the given offset
    let (byteOffset, i) = bitOffset `quotRem` 8
        bufOff = BufferOffset 0
        count = 1
        off = AbsOffset (fromIntegral byteOffset)
    -- Check that the byte offset is within the file
    assert (byteOffset >= 0) $ pure ()
    assert (byteOffset < fromIntegral fileSize) $ pure ()
    assert (i >= 0 && i < 8) $ pure ()
    void $ hGetBufExactlyAt hfs h buf bufOff count off
    -- Flip the bit in memory, and then write it back
    let bvec = BitMVec 0 8 buf
    flipBit bvec i
    void $ hPutBufExactlyAt hfs h buf bufOff count off

{-------------------------------------------------------------------------------
  Errors
-------------------------------------------------------------------------------}

noHCloseE :: Errors -> Errors
noHCloseE errs = errs { hCloseE = Stream.empty }

noRemoveFileE :: Errors -> Errors
noRemoveFileE errs = errs { removeFileE = Stream.empty }

noRemoveDirectoryRecursiveE :: Errors -> Errors
noRemoveDirectoryRecursiveE errs = errs { removeDirectoryRecursiveE = Stream.empty }

filterHGetBufSomeE :: (Maybe (Either FsErrorType Partial) -> Bool) -> Errors -> Errors
filterHGetBufSomeE p e = e {
      hGetBufSomeE = Stream.filter p (hGetBufSomeE e)
    }

isFsReachedEOF :: FsErrorType -> Bool
isFsReachedEOF FsReachedEOF = True
isFsReachedEOF _            = False

{-------------------------------------------------------------------------------
  Arbitrary
-------------------------------------------------------------------------------}

--
-- FsPathComponent
--

-- | A single component in an 'FsPath'.
--
-- If we have a path @a/b/c/d@, then @a@, @b@ and @c@ are components, but for
-- example @a/b@ is not.
newtype FsPathComponent = FsPathComponent (NonEmpty Char)
  deriving stock (Eq, Ord)

instance Show FsPathComponent where
  show = show . fsPathComponentFsPath

fsPathComponentFsPath :: FsPathComponent -> FsPath
fsPathComponentFsPath (FsPathComponent s) = FS.mkFsPath [NE.toList s]

fsPathComponentString :: FsPathComponent -> String
fsPathComponentString (FsPathComponent s) = NE.toList s

instance Arbitrary FsPathComponent where
  arbitrary = resize 5 $ -- path components don't have to be very long
      FsPathComponent <$> liftArbitrary genPathChar
  shrink :: FsPathComponent -> [FsPathComponent]
  shrink (FsPathComponent s) = FsPathComponent <$> liftShrink shrinkPathChar s

{-------------------------------------------------------------------------------
  Arbitrary: modifiers
-------------------------------------------------------------------------------}

--
-- NoCleanupErrors
--

-- | No errors on closing file handles and removing files
newtype NoCleanupErrors = NoCleanupErrors Errors
  deriving stock Show

mkNoCleanupErrors :: Errors -> NoCleanupErrors
mkNoCleanupErrors errs = NoCleanupErrors $
      noHCloseE
    $ noRemoveFileE
    $ noRemoveDirectoryRecursiveE
    $ errs

instance Arbitrary NoCleanupErrors where
  arbitrary = do
      errs <- arbitrary
      pure $ mkNoCleanupErrors errs

  -- The shrinker for 'Errors' does not re-introduce 'hCloseE' and 'removeFile'.
  shrink (NoCleanupErrors errs) = NoCleanupErrors <$> shrink errs

{-------------------------------------------------------------------------------
  Arbitrary: orphans
-------------------------------------------------------------------------------}

instance Arbitrary FsPath where
  arbitrary = scale (`div` 10) $ -- paths don't have to be very long
      FS.mkFsPath <$> listOf (fsPathComponentString <$> arbitrary)
  shrink p =
      let ss = T.unpack <$> fsPathToList p
      in  FS.mkFsPath <$> shrinkList shrinkAsComponent ss
    where
      shrinkAsComponent s = fsPathComponentString <$>
          shrink (FsPathComponent $ NE.fromList s)

-- >>> all isPathChar pathChars
-- True
isPathChar :: Char -> Bool
isPathChar c = isAscii c && (isLetter c || isDigit c)

-- >>> pathChars
-- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
pathChars :: [Char]
pathChars = concat [['a'..'z'], ['A'..'Z'], ['0'..'9']]

genPathChar :: Gen Char
genPathChar = elements pathChars

shrinkPathChar :: Char -> [Char]
shrinkPathChar c = [ c' | c' <- shrink c, isPathChar c']

instance Arbitrary OpenMode where
  arbitrary = genOpenMode
  shrink = shrinkOpenMode

genOpenMode :: Gen OpenMode
genOpenMode = oneof [
      pure ReadMode
    , WriteMode <$> genAllowExisting
    , ReadWriteMode <$> genAllowExisting
    , AppendMode <$> genAllowExisting
    ]
  where
    _coveredAllCases x = case x of
        ReadMode{}      -> ()
        WriteMode{}     -> ()
        ReadWriteMode{} -> ()
        AppendMode{}    -> ()

shrinkOpenMode :: OpenMode -> [OpenMode]
shrinkOpenMode = \case
    ReadMode -> []
    WriteMode ae ->
        ReadMode
      : (WriteMode <$> shrinkAllowExisting ae)
    ReadWriteMode ae ->
        ReadMode
      : WriteMode ae
      : (ReadWriteMode <$> shrinkAllowExisting ae)
    AppendMode ae ->
        ReadMode
      : WriteMode ae
      : ReadWriteMode ae
      : (AppendMode <$> shrinkAllowExisting ae)

instance Arbitrary AllowExisting where
  arbitrary = genAllowExisting
  shrink = shrinkAllowExisting

genAllowExisting :: Gen AllowExisting
genAllowExisting = elements [
      AllowExisting
    , MustBeNew
    , MustExist
    ]
  where
    _coveredAllCases x = case x of
        AllowExisting -> ()
        MustBeNew     -> ()
        MustExist     -> ()

shrinkAllowExisting :: AllowExisting -> [AllowExisting]
shrinkAllowExisting AllowExisting = []
shrinkAllowExisting MustBeNew     = [AllowExisting]
shrinkAllowExisting MustExist     = [AllowExisting, MustBeNew]