fs-sim-0.3.0.0: test/Test/System/FS/StateMachine.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MagicHash #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE StandaloneKindSignatures #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UnboxedTuples #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{- HLINT ignore "Use camelCase" -}
-- | Tests for our filesystem abstractions.
--
-- The blogpost [An in-depth look at __quickcheck-state-machine__](http://www.well-typed.com/blog/2019/01/qsm-in-depth/)
-- uses these tests of the file system abstraction as a case study and outlines the
-- general approach. Like all of the model based tests, this consists of
--
-- 1. A set of commands
-- 2. A model against which we execute those commands
-- 3. An interpreter of those commands against the real implementation
-- 4. A generator and a shrinker for sequences of these commands
-- 5. A way to /label/ generated sequences so that we can verify that the tests
-- are covering what we think they should be covering.
--
-- The sequences generated are then executed against both the model and the
-- implementation, and the results are compared.
--
-- In this particular case, the commands are file system operations such as
-- create a directory, open a file, write some bytes, etc. The model is a very
-- simple one: we simply model a file system as a tree of 'ByteString's (the raw
-- bytes in the files). The goal of the model, of course, is not to provide one
-- that mirrors the low-level details of the real implementation, but rather one
-- that abstracts over such details and provides a /specification/.
--
-- The file system abstraction however is a bit of an unusual test in that this
-- is really "reverse model testing": the tests compare the model to the real
-- file system, but we are of course not developing a file system. Instead, the
-- tests serve to make sure that we got the model right, which we can then use
-- in the tests of the rest of the consensus layer.
--
module Test.System.FS.StateMachine (
showLabelledExamples
, tests
) where
#if __GLASGOW_HASKELL__<910
import Data.Foldable (foldl')
#endif
import qualified Control.Exception as E
import Control.Monad
import Control.Monad.Primitive
import Control.Monad.ST.Strict (runST)
import Data.Bifoldable
import Data.Bifunctor
import qualified Data.Bifunctor.TH as TH
import Data.Bitraversable
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.Functor.Classes
import Data.Int (Int64)
import qualified Data.List as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromJust)
import Data.Primitive (MutableByteArray, newPinnedByteArray)
import Data.Proxy
import Data.Set (Set)
import qualified Data.Set as Set
import qualified Data.Text as Text
import Data.Word (Word64)
import qualified Generics.SOP as SOP
import GHC.Generics
import GHC.Stack hiding (prettyCallStack)
import System.IO.Temp (withSystemTempDirectory)
import System.Posix.Types (ByteCount)
import System.Random (getStdRandom, randomR)
import Test.StateMachine.TreeDiff
import Text.Read (readMaybe)
import Text.Show.Pretty (ppShow)
import Test.QuickCheck
import qualified Test.QuickCheck.Monadic as QC
import Test.QuickCheck.Random (mkQCGen)
import qualified Test.StateMachine as QSM
import Test.StateMachine (Concrete, Symbolic)
import qualified Test.StateMachine.Labelling as C
import qualified Test.StateMachine.Sequential as QSM
import qualified Test.StateMachine.Types as QSM
import qualified Test.StateMachine.Types.Rank2 as Rank2
import Test.Tasty (TestTree, localOption, testGroup)
import Test.Tasty.QuickCheck
import System.FS.API
import System.FS.CallStack
import System.FS.Condense
import System.FS.IO
import System.FS.Sim.FsTree (FsTree (..))
import qualified System.FS.Sim.MockFS as Mock
import System.FS.Sim.MockFS (HandleMock, MockFS)
import System.FS.Sim.Prim
import qualified Test.Util.RefEnv as RE
import Test.Util.RefEnv (RefEnv)
{-------------------------------------------------------------------------------
Path expressions
-------------------------------------------------------------------------------}
data PathExpr fp =
PExpPath FsPath
| PExpRef fp
| PExpParentOf fp
deriving (Show, Eq, Ord, Functor, Foldable, Traversable, Generic)
evalPathExpr :: PathExpr FsPath -> FsPath
evalPathExpr (PExpPath fp) = fp
evalPathExpr (PExpRef fp) = fp
evalPathExpr (PExpParentOf fp) = fsPathInit fp
{-------------------------------------------------------------------------------
Abstract model
-------------------------------------------------------------------------------}
-- | Commands
--
-- We will be interested in three different instantiations of @h@:
--
-- > Cmd HandleMock
-- > Cmd (Reference (Opaque (FsHandle IOFSE)) Concrete)
-- > Cmd (Reference (Opaque (FsHandle IOFSE)) Symbolic)
--
-- Key idea is that all this infrastructure will be applicable both to
-- the model and to the system under test.
--
-- TODO: Program such as "copy what you read" is currently not expressible
-- in our language. Does this matter?
data Cmd fp h =
Open (PathExpr fp) OpenMode
| Close h
| IsOpen h
| Seek h SeekMode Int64
| Get h Word64
| GetAt h Word64 AbsOffset
| GetBuf h ByteCount
| GetBufAt h ByteCount AbsOffset
| Put h ByteString
| PutBuf h ByteString ByteCount
| PutBufAt h ByteString ByteCount AbsOffset
| Truncate h Word64
| GetSize h
| CreateDir (PathExpr fp)
| CreateDirIfMissing Bool (PathExpr fp)
| ListDirectory (PathExpr fp)
| DoesDirectoryExist (PathExpr fp)
| DoesFileExist (PathExpr fp)
| RemoveDirRecursive (PathExpr fp)
| RemoveFile (PathExpr fp)
| RenameFile (PathExpr fp) (PathExpr fp)
deriving (Generic, Show, Functor, Foldable, Traversable)
deriving instance SOP.Generic (Cmd fp h)
deriving instance SOP.HasDatatypeInfo (Cmd fp h)
-- | Successful result
data Success fp h =
WHandle fp h
| RHandle h
| Unit ()
| Path fp ()
| Word64 Word64
| ByteString ByteString
| ByteCount ByteCount -- PutBuf, PutBufAt
| BCBS ByteCount ByteString -- GetBuf, GetBufAt
| Strings (Set String)
| Bool Bool
deriving (Eq, Show, Functor, Foldable)
-- | Successful semantics
run :: forall m h. (PrimMonad m, HasCallStack)
=> HasFS m h
-> Cmd FsPath (Handle h)
-> m (Success FsPath (Handle h))
run hasFS@HasFS{..} = go
where
go :: Cmd FsPath (Handle h) -> m (Success FsPath (Handle h))
go (Open pe mode) =
case mode of
ReadMode -> withPE pe (\_ -> RHandle) $ \fp -> hOpen fp mode
_otherwise -> withPE pe WHandle $ \fp -> hOpen fp mode
go (CreateDir pe) = withPE pe Path $ createDirectory
go (CreateDirIfMissing b pe) = withPE pe Path $ createDirectoryIfMissing b
go (IsOpen h ) = Bool <$> hIsOpen h
go (Close h ) = Unit <$> hClose h
go (Seek h mode sz ) = Unit <$> hSeek h mode sz
-- Note: we're not using 'hGetSome', 'hGetSomeAt' and 'hPutSome' that may
-- produce partial reads/writes, but wrappers around them that handle
-- partial reads/writes, see #502.
go (Get h n ) = ByteString <$> hGetSomeChecked hasFS h n
go (GetAt h n o ) = ByteString <$> hGetSomeAtChecked hasFS h n o
go (GetBuf h n ) = uncurry BCBS <$> hGetBufSomeChecked hasFS h n
go (GetBufAt h n o ) = uncurry BCBS <$> hGetBufSomeAtChecked hasFS h n o
go (Put h bs ) = Word64 <$> hPutSomeChecked hasFS h bs
go (PutBuf h bs n ) = ByteCount <$> hPutBufSomeChecked hasFS h bs n
go (PutBufAt h bs n o ) = ByteCount <$> hPutBufSomeAtChecked hasFS h bs n o
go (Truncate h sz ) = Unit <$> hTruncate h sz
go (GetSize h ) = Word64 <$> hGetSize h
go (ListDirectory pe ) = withPE pe (const Strings) $ listDirectory
go (DoesDirectoryExist pe ) = withPE pe (const Bool) $ doesDirectoryExist
go (DoesFileExist pe ) = withPE pe (const Bool) $ doesFileExist
go (RemoveDirRecursive pe ) = withPE pe (const Unit) $ removeDirectoryRecursive
go (RemoveFile pe ) = withPE pe (const Unit) $ removeFile
go (RenameFile pe1 pe2 ) = withPEs pe1 pe2 (\_ _ -> Unit) $ renameFile
withPE :: PathExpr FsPath
-> (FsPath -> a -> Success FsPath (Handle h))
-> (FsPath -> m a)
-> m (Success FsPath (Handle h))
withPE pe r f = let fp = evalPathExpr pe in r fp <$> f fp
withPEs :: PathExpr FsPath
-> PathExpr FsPath
-> (FsPath -> FsPath -> a -> Success FsPath (Handle h))
-> (FsPath -> FsPath -> m a)
-> m (Success FsPath (Handle h))
withPEs pe1 pe2 r f =
let fp1 = evalPathExpr pe1
fp2 = evalPathExpr pe2
in r fp1 fp2 <$> f fp1 fp2
{-------------------------------------------------------------------------------
Detecting partial reads/writes of the tested IO implementation
-------------------------------------------------------------------------------}
{- Note [Checking for partial reads/writes]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The functions 'hGetSome', 'hGetSomeAt' and 'hPutSome' might perform partial
reads/writes, depending on the underlying implementation, see
[ouroboros-network#277](https://github.com/IntersectMBO/ouroboros-network/issues/277).
While the model will always perform complete reads/writes, the real IO
implementation we are testing /might/ actually perform partial reads/writes.
This testsuite will fail when such a partial read or write is performed in the
real IO implementation, as these are undeterministic and the model will no
longer correspond to the real implementation. See
[ouroboros-network#502](https://github.com/IntersectMBO/ouroboros-network/issues/502)
were we tracked this issue.
So far, on all systems the tests have been run on, no partial reads/writes
have ever been noticed. However, we cannot be sure that the tests will never
be run on a system or file-system that might result in partial reads/writes.
Therefore, we use checked variants of 'hGetSome', 'hGetSomeAt' and 'hPutSome'
that detect partial reads/writes and that will signal an error so that the
developer noticing the failing test doesn't waste any time debugging the
implementation while the failing test was actually due to an unexpected
partial read/write.
For compound functions like 'hGetExactly' and 'hPutAll', this is not a good
solution. However, since we are only testing primitives, the solution is fine
for our purposes.
The problem with compound functions is that to run a single 'Cmd', we now have
to run multiple primitive 'HasFS' functions. Each of those primitive functions
might update the state of the model and the real world. Now when the second,
third, ..., or n-th primitive functions fails (while running a single 'Cmd'),
the whole 'Cmd' failed and the model is not updated. This means that we
continue with the model as it was /before/ running the 'Cmd'. However, these
primitive functions might have changed the model /and/ the state of the real
implementation. In that case, we can no longer guarantee that the model and
the real implementation are in sync.
-}
hGetSomeChecked :: (Monad m, HasCallStack)
=> HasFS m h -> Handle h -> Word64 -> m ByteString
hGetSomeChecked HasFS{..} h n = do
bytes <- hGetSome h n
when (fromIntegral (BS.length bytes) /= n) $ do
moreBytes <- hGetSome h 1
-- If we can actually read more bytes, the last read was partial. If we
-- cannot, we really were at EOF.
unless (BS.null moreBytes) $
error "Unsupported partial read detected, see Note [Checking for partial reads/writes]"
return bytes
hGetSomeAtChecked :: (Monad m, HasCallStack)
=> HasFS m h -> Handle h -> Word64 -> AbsOffset -> m ByteString
hGetSomeAtChecked HasFS{..} h n o = do
bytes <- hGetSomeAt h n o
when (fromIntegral (BS.length bytes) /= n) $ do
moreBytes <- hGetSomeAt h 1 $ o + fromIntegral (BS.length bytes)
-- If we can actually read more bytes, the last read was partial. If we
-- cannot, we really were at EOF.
unless (BS.null moreBytes) $
error "Unsupported partial read detected, see Note [Checking for partial reads/writes]"
return bytes
hPutSomeChecked :: (Monad m, HasCallStack)
=> HasFS m h -> Handle h -> ByteString -> m Word64
hPutSomeChecked HasFS{..} h bytes = do
n <- hPutSome h bytes
if fromIntegral (BS.length bytes) /= n
then error "Unsupported partial write detected, see Note [Checking for partial reads/writes]"
else return n
hGetBufSomeChecked :: (HasCallStack, PrimMonad m)
=> HasFS m h
-> Handle h -> ByteCount -> m (ByteCount, ByteString)
hGetBufSomeChecked HasFS{..} h n = do
allocaMutableByteArray (fromIntegral n) $ \buf -> do
n' <- hGetBufSome h buf 0 n
bs <- fromJust <$> Mock.fromBuffer buf 0 n'
when (n /= n') $ do
moreBytes <- hGetSome h 1
-- If we can actually read more bytes, the last read was partial. If we
-- cannot, we really were at EOF.
unless (BS.null moreBytes) $
error "Unsupported partial read detected, see #502"
pure (n', bs)
hGetBufSomeAtChecked :: (HasCallStack, PrimMonad m)
=> HasFS m h
-> Handle h -> ByteCount -> AbsOffset -> m (ByteCount, ByteString)
hGetBufSomeAtChecked HasFS{..} h n o = do
allocaMutableByteArray (fromIntegral n) $ \buf -> do
n' <- hGetBufSomeAt h buf 0 n o
bs <- fromJust <$> Mock.fromBuffer buf 0 n'
when (n /= n') $ do
moreBytes <- hGetSomeAt h 1 $ o + fromIntegral n'
-- If we can actually read more bytes, the last read was partial. If we
-- cannot, we really were at EOF.
unless (BS.null moreBytes) $
error "Unsupported partial read detected, see #502"
pure (n', bs)
hPutBufSomeChecked :: (HasCallStack, PrimMonad m)
=> HasFS m h
-> Handle h -> ByteString -> ByteCount -> m ByteCount
hPutBufSomeChecked HasFS{..} h bs n =
allocaMutableByteArray (min (fromIntegral n) (BS.length bs)) $ \buf -> do
void $ Mock.intoBuffer buf 0 (BS.take (fromIntegral n) bs)
n' <- hPutBufSome h buf 0 n
if n /= n'
then error "Unsupported partial write detected, see #502"
else return n
hPutBufSomeAtChecked :: (HasCallStack, PrimMonad m)
=> HasFS m h
-> Handle h -> ByteString -> ByteCount -> AbsOffset -> m ByteCount
hPutBufSomeAtChecked HasFS{..} h bs n o =
allocaMutableByteArray (min (fromIntegral n) (BS.length bs)) $ \buf -> do
void $ Mock.intoBuffer buf 0 (BS.take (fromIntegral n) bs)
n' <- hPutBufSomeAt h buf 0 n o
if n /= n'
then error "Unsupported partial write detected, see #502"
else return n
allocaMutableByteArray :: PrimMonad m => Int -> (MutableByteArray (PrimState m) -> m a) -> m a
allocaMutableByteArray size action = newPinnedByteArray size >>= action
{-------------------------------------------------------------------------------
Instantiating the semantics
-------------------------------------------------------------------------------}
-- | For some error types, our mock FS implementation, which is based on
-- (Ubuntu) Linux might throw different errors than the actual file system. In
-- particular, this problem occurs when the file system under test is a Windows
-- or MacOS one. In these cases, the 'sameError' comparison function, which is
-- used to compare the 'FsError's that the mock throws against the 'FsError's
-- that the SUT throws, is more lenient than the default 'sameFsError'.
sameError :: FsError -> FsError -> Bool
#if defined(mingw32_HOST_OS)
-- For the following error types, our mock FS implementation (and the Posix
-- implementation) throw the same errors:
--
-- * 'FsReachedEOF'
-- * 'FsDeviceFull'
-- * 'FsResourceAlreadyInUse'
--
-- For other cases, Windows throws different errors than the mock FS
-- implementation.
sameError e1 e2 = fsErrorPath e1 == fsErrorPath e2
&& sameFsErrorType (fsErrorType e1) (fsErrorType e2)
where
sameFsErrorType ty1 ty2 = case (ty1, ty2) of
(FsReachedEOF, FsReachedEOF) -> True
(FsReachedEOF, _) -> False
(_, FsReachedEOF) -> False
(FsDeviceFull, FsDeviceFull) -> True
(FsDeviceFull, _) -> False
(_, FsDeviceFull) -> False
(FsResourceAlreadyInUse, FsResourceAlreadyInUse) -> True
(FsResourceAlreadyInUse, _) -> False
(_, FsResourceAlreadyInUse) -> False
(_, _) -> True
#elif defined(darwin_HOST_OS)
-- Check default implementation first using 'sameFsError', and otherwise permit
-- some combinations of error types that are not structurally equal.
sameError e1 e2 = sameFsError e1 e2
|| (fsErrorPath e1 == fsErrorPath e2
&& permitted (fsErrorType e1) (fsErrorType e2))
where
-- error types that are permitted to differ for technical reasons
permitted ty1 ty2 = case (ty1, ty2) of
(FsInsufficientPermissions , FsResourceInappropriateType) -> True
(FsResourceInappropriateType, FsInsufficientPermissions ) -> True
(_ , _ ) -> False
#else
-- treat every other distribution like it is (Ubuntu) Linux
sameError = sameFsError
#endif
-- | Responses are either successful termination or an error
newtype Resp fp h = Resp { getResp :: Either FsError (Success fp h) }
deriving (Show, Functor, Foldable)
-- | The 'Eq' instance for 'Resp' uses 'sameError'
instance (Eq fp, Eq h) => Eq (Resp fp h) where
Resp (Left e) == Resp (Left e') = sameError e e'
Resp (Right a) == Resp (Right a') = a == a'
_ == _ = False
runPure :: Cmd FsPath (Handle HandleMock)
-> MockFS -> (Resp FsPath (Handle HandleMock), MockFS)
runPure cmd mockFS =
aux $ runST $ runFSSimT (run primHasMockFS cmd) mockFS
where
aux :: Either FsError (Success FsPath (Handle HandleMock), MockFS)
-> (Resp FsPath (Handle HandleMock), MockFS)
aux (Left e) = (Resp (Left e), mockFS)
aux (Right (r, mockFS')) = (Resp (Right r), mockFS')
runIO :: HasFS IO HandleIO
-> Cmd FsPath (Handle HandleIO) -> IO (Resp FsPath (Handle HandleIO))
runIO hfs cmd = Resp <$> E.try (run hfs cmd)
{-------------------------------------------------------------------------------
Bitraversable instances
-------------------------------------------------------------------------------}
TH.deriveBifunctor ''Cmd
TH.deriveBifoldable ''Cmd
TH.deriveBitraversable ''Cmd
TH.deriveBifunctor ''Success
TH.deriveBifoldable ''Success
TH.deriveBitraversable ''Success
TH.deriveBifunctor ''Resp
TH.deriveBifoldable ''Resp
TH.deriveBitraversable ''Resp
{-------------------------------------------------------------------------------
Collect arguments
-------------------------------------------------------------------------------}
paths :: Bitraversable t => t fp h -> [fp]
paths = bifoldMap (:[]) (const [])
handles :: Bitraversable t => t fp h -> [h]
handles = bifoldMap (const []) (:[])
{-------------------------------------------------------------------------------
Model
-------------------------------------------------------------------------------}
-- | Concrete or symbolic reference to a path
type PathRef = QSM.Reference FsPath
-- | Concrete or symbolic reference to an IO file handle
type HandleRef = QSM.Reference (Handle HandleIO)
-- | Mapping between real IO file handles and mock file handles
type KnownHandles = RefEnv (Handle HandleIO) (Handle HandleMock)
-- | Mapping between path references and paths
type KnownPaths = RefEnv FsPath FsPath
resolvePathExpr :: Eq1 r => KnownPaths r -> PathExpr (PathRef r) -> FsPath
resolvePathExpr knownPaths = evalPathExpr . fmap (knownPaths RE.!)
-- | Execution model
data Model r = Model {
mockFS :: MockFS
, knownPaths :: KnownPaths r
, knownHandles :: KnownHandles r
}
deriving (Show, Generic)
-- | Initial model
initModel :: Model r
initModel = Model Mock.empty RE.empty RE.empty
-- | Key property of the model is that we can go from real to mock responses
toMock :: (Bifunctor t, Eq1 r)
=> Model r -> t :@ r -> t FsPath (Handle HandleMock)
toMock Model{..} (At r) = bimap (knownPaths RE.!) (knownHandles RE.!) r
-- | Step the mock semantics
--
-- We cannot step the whole Model here (see 'Event', below)
step :: Eq1 r
=> Model r -> Cmd :@ r -> (Resp FsPath (Handle HandleMock), MockFS)
step model@Model{..} cmd = runPure (toMock model cmd) mockFS
-- | Open read handles
openHandles :: Model r -> [Handle HandleMock]
openHandles Model{..} =
filter isOpen (RE.elems knownHandles)
where
isOpen :: Handle HandleMock -> Bool
isOpen (Handle h _) = Mock.handleIsOpen mockFS h
{-------------------------------------------------------------------------------
Wrapping in quickcheck-state-machine references
-------------------------------------------------------------------------------}
-- | Instantiate functor @f@ to @f (PathRef r) (HandleRef r)@
--
-- > Cmd :@ Concrete ~ Cmd (PathRef Concrete) (HandleRef Concrete)
newtype At t r = At {unAt :: (t (PathRef r) (HandleRef r))}
deriving (Generic)
-- | Alias for 'At'
type (:@) t r = At t r
deriving instance Show1 r => Show (Cmd :@ r)
deriving instance Show1 r => Show (Resp :@ r)
deriving instance Eq1 r => Eq (Resp :@ r)
instance Bifoldable t => Rank2.Foldable (At t) where
foldMap = \f (At x) -> bifoldMap (app f) (app f) x
where
app :: (r x -> m) -> QSM.Reference x r -> m
app f (QSM.Reference x) = f x
instance Bifunctor t => Rank2.Functor (At t) where
fmap = \f (At x) -> At (bimap (app f) (app f) x)
where
app :: (r x -> r' x) -> QSM.Reference x r -> QSM.Reference x r'
app f (QSM.Reference x) = QSM.Reference (f x)
instance Bitraversable t => Rank2.Traversable (At t) where
traverse = \f (At x) -> At <$> bitraverse (app f) (app f) x
where
app :: Functor f
=> (r x -> f (r' x)) -> QSM.Reference x r -> f (QSM.Reference x r')
app f (QSM.Reference x) = QSM.Reference <$> f x
{-------------------------------------------------------------------------------
Events
-------------------------------------------------------------------------------}
-- | An event records the model before and after a command along with the
-- command itself and its response
data Event r = Event {
eventBefore :: Model r
, eventCmd :: Cmd :@ r
, eventAfter :: Model r
, eventMockResp :: Resp FsPath (Handle HandleMock)
}
deriving (Show)
eventMockCmd :: Eq1 r => Event r -> Cmd FsPath (Handle HandleMock)
eventMockCmd Event{..} = toMock eventBefore eventCmd
-- | Construct an event
--
-- When we execute both the model and the real implementation in lockstep,
-- we get two responses: this suffices to update the model.
lockstep :: forall r. (Show1 r, Ord1 r, HasCallStack)
=> Model r
-> Cmd :@ r
-> Resp :@ r
-> Event r
lockstep model@Model{..} cmd (At resp) = Event {
eventBefore = model
, eventCmd = cmd
, eventAfter = Model {
mockFS = mockFS'
, knownPaths = knownPaths `RE.union` newPaths
, knownHandles = knownHandles `RE.union` newHandles
}
, eventMockResp = resp'
}
where
(resp', mockFS') = step model cmd
newPaths = RE.fromList $ zip (paths resp) (paths resp')
newHandles = RE.fromList $ zip (handles resp) (handles resp')
{-------------------------------------------------------------------------------
Generator
-------------------------------------------------------------------------------}
generator :: Model Symbolic -> Gen (Cmd :@ Symbolic)
generator Model{..} = oneof $ concat [
withoutHandle
, if RE.null knownHandles then [] else withHandle
]
where
withoutHandle :: [Gen (Cmd :@ Symbolic)]
withoutHandle = [
fmap At $ genOpen
, fmap At $ CreateDir <$> genPathExpr
, fmap At $ CreateDirIfMissing <$> arbitrary <*> genPathExpr
, fmap At $ ListDirectory <$> genPathExpr
, fmap At $ DoesDirectoryExist <$> genPathExpr
, fmap At $ DoesFileExist <$> genPathExpr
, fmap At $ RemoveDirRecursive <$> genPathExpr
, fmap At $ RemoveFile <$> genPathExpr
, fmap At $ RenameFile <$> genPathExpr <*> genPathExpr
]
withHandle :: [Gen (Cmd :@ Symbolic)]
withHandle = [
fmap At $ Close <$> genHandle
, fmap At $ IsOpen <$> genHandle
, fmap At $ Seek <$> genHandle <*> genSeekMode <*> genOffset
, fmap At $ Get <$> genHandle <*> (getSmall <$> arbitrary)
, fmap At $ GetAt <$> genHandle <*> (getSmall <$> arbitrary) <*> arbitrary
, fmap At $ GetBuf <$> genHandle <*> (getSmall <$> arbitrary)
, fmap At $ GetBufAt <$> genHandle <*> (getSmall <$> arbitrary) <*> arbitrary
, fmap At $ Put <$> genHandle <*> (BS.pack <$> arbitrary)
, fmap At $ PutBuf <$> genHandle <*> (BS.pack <$> arbitrary) <*> (getSmall <$> arbitrary)
, fmap At $ PutBufAt <$> genHandle <*> (BS.pack <$> arbitrary) <*> (getSmall <$> arbitrary) <*> arbitrary
, fmap At $ Truncate <$> genHandle <*> (getSmall . getNonNegative <$> arbitrary)
, fmap At $ GetSize <$> genHandle
]
genOpen :: Gen (Cmd (PathRef Symbolic) (HandleRef Symbolic))
genOpen = do
path <- genPath
mode <- genMode $ elem path (RE.elems knownPaths)
return $ Open (PExpPath path) mode
-- Wrap path in a simple path expression
-- (References are generated during shrinking only)
genPathExpr :: Gen (PathExpr fp)
genPathExpr = PExpPath <$> genPath
-- We choose from a small list of names so that we reuse names often
-- We use the same set of files and directories so that we can test
-- things like trying to open a directory as if it were a file
genPath :: Gen FsPath
genPath = do
n <- choose (0, 3)
mkFsPath <$> replicateM n (elements ["x", "y", "z"])
genHandle :: Gen (HandleRef Symbolic)
genHandle = elements (RE.keys knownHandles)
genMode :: Bool -> Gen OpenMode
genMode fileExists = frequency [
(rf, return ReadMode)
, (wf, WriteMode <$> genAllowExisting)
, (wf, AppendMode <$> genAllowExisting)
, (wf, ReadWriteMode <$> genAllowExisting)
]
where
-- we try to avoid 'ReadMode' when the file does not exist.
(rf, wf) = if fileExists then (10,3) else (1,3)
genAllowExisting :: Gen AllowExisting
genAllowExisting = elements [AllowExisting, MustBeNew]
genSeekMode :: Gen SeekMode
genSeekMode = elements [
AbsoluteSeek
, RelativeSeek
, SeekFromEnd
]
genOffset :: Gen Int64
genOffset = oneof
[ return 0
, choose (1, 10)
, choose (-1, -10)
]
instance Arbitrary AbsOffset where
arbitrary = AbsOffset . getSmall <$> arbitrary
shrink ao = AbsOffset <$> shrink (unAbsOffset ao)
{-------------------------------------------------------------------------------
Temporary files (used in shrinking)
-------------------------------------------------------------------------------}
-- | Temp files are numbered from 1
newtype TempFile = TempFile Int
deriving (Show)
instance Condense TempFile where -- basically GNTD
condense (TempFile n) = condense n
tempToExpr :: TempFile -> PathExpr fp
tempToExpr (TempFile n) = PExpPath (mkFsPath ['t' : show n])
tempFromPath :: FsPath -> Maybe TempFile
tempFromPath fp =
case map Text.unpack (fsPathToList fp) of
['t' : suf] -> do n <- readMaybe suf
guard (n >= 1)
return $ TempFile n
_otherwise -> Nothing
{-------------------------------------------------------------------------------
Shrinking
-------------------------------------------------------------------------------}
-- | When we replace one reference with another, we are careful to impose an
-- order so that we don't end up flipping between references. Since shrinking is
-- greedy this does mean that the choice of reference may influence how much we
-- can shrink later. This is hard to avoid in greedy algorithms.
shrinker :: Model Symbolic -> Cmd :@ Symbolic -> [Cmd :@ Symbolic]
shrinker Model{..} (At cmd) =
case cmd of
Open pe mode -> concat [
case tempFromPath fp of
Just n ->
map (\n' -> At $ Open (tempToExpr n') mode)
$ shrinkTempFile n
Nothing ->
let mode' = case mode of
ReadMode -> ReadWriteMode AllowExisting
_otherwise -> mode
in [At $ Open (tempToExpr (TempFile numTempFiles)) mode']
, case mode of
ReadWriteMode ex -> [
At $ Open pe ReadMode
, At $ Open pe (WriteMode ex)
]
_otherwise ->
[]
, map (\pe' -> At $ Open pe' mode) $
replaceWithRef pe (== fp) PExpRef
]
where
fp :: FsPath
fp = resolvePathExpr knownPaths pe
ListDirectory pe -> concat [
map (At . ListDirectory) $
replaceWithRef pe ((== fp) . fsPathInit) PExpParentOf
]
where
fp :: FsPath
fp = resolvePathExpr knownPaths pe
Get h n -> At . Get h <$> shrink n
GetAt h n o -> At <$>
[GetAt h n o' | o' <- shrink o] <>
[GetAt h n' o | n' <- shrink n]
GetBuf h n -> At <$>
[GetBuf h n' | n' <- shrink n]
GetBufAt h n o -> At <$>
[GetBufAt h n' o | n' <- shrink n] <>
[GetBufAt h n o' | o' <- shrink o]
Put h bs -> At . Put h <$> shrinkBytes bs
PutBuf h bs n -> At <$>
[PutBuf h bs' n | bs' <- BS.pack <$> shrink (BS.unpack bs)] <>
[PutBuf h bs n' | n' <- shrink n]
PutBufAt h bs n o -> At <$>
[PutBufAt h bs' n o | bs' <- BS.pack <$> shrink (BS.unpack bs)] <>
[PutBufAt h bs n' o | n' <- shrink n] <>
[PutBufAt h bs n o' | o' <- shrink o]
Truncate h n -> At . Truncate h <$> shrink n
_otherwise ->
[]
where
-- Replace path with reference
--
-- If we are replacing one reference with another, be careful to impose
-- an ordering so that we don't end up toggling between references.
replaceWithRef :: PathExpr (PathRef Symbolic)
-- current
-> (FsPath -> Bool)
-- evaluate candidate
-> (PathRef Symbolic -> PathExpr (PathRef Symbolic))
-- construct replacement
-> [PathExpr (PathRef Symbolic)]
replaceWithRef pe p f =
filter (canReplace pe) $ map f $ RE.reverseLookup p knownPaths
where
canReplace :: PathExpr (PathRef Symbolic) -- current
-> PathExpr (PathRef Symbolic) -- candidate
-> Bool
canReplace (PExpRef ref) (PExpRef ref') = ref' < ref
canReplace (PExpParentOf ref) (PExpParentOf ref') = ref' < ref
canReplace _ _ = True
shrinkTempFile :: TempFile -> [TempFile]
shrinkTempFile (TempFile n) = TempFile . getPositive <$> shrink (Positive n)
shrinkBytes :: ByteString -> [ByteString]
shrinkBytes = map BS.pack . shrink . BS.unpack
numTempFiles :: Int
numTempFiles = 100
{-------------------------------------------------------------------------------
Limitations/known bugs
-------------------------------------------------------------------------------}
-- | Known limitations/bugs that we don't want to test for
--
-- NOTE: Can assume all used handles are in known in the model.
knownLimitation :: Model Symbolic -> Cmd :@ Symbolic -> QSM.Logic
knownLimitation model cmd =
case getResp resp of
Left FsError{..} -> QSM.Boolean fsLimitation
_otherwise -> QSM.Bot
where
(resp, _mockFS') = step model cmd
{-------------------------------------------------------------------------------
The final state machine
-------------------------------------------------------------------------------}
-- | Mock a response
--
-- We do this by running the pure semantics and then generating mock
-- references for any new handles.
mock :: Model Symbolic
-> Cmd :@ Symbolic
-> QSM.GenSym (Resp :@ Symbolic)
mock model cmd = At <$> bitraverse (const QSM.genSym) (const QSM.genSym) resp
where
(resp, _mockFS') = step model cmd
precondition :: Model Symbolic -> Cmd :@ Symbolic -> QSM.Logic
precondition m@Model{..} (At cmd) =
QSM.forAll (handles cmd) (`QSM.member` RE.keys knownHandles)
QSM.:&& QSM.Boolean (Mock.numOpenHandles mockFS < maxNumOpenHandles)
QSM.:&& QSM.Not (knownLimitation m (At cmd))
where
-- Limit number of open handles to avoid exceeding OS limits
maxNumOpenHandles = 100
-- | Step the model
--
-- NOTE: This function /must/ be polymorphic in @r@.
transition :: (Show1 r, Ord1 r) => Model r -> Cmd :@ r -> Resp :@ r -> Model r
transition model cmd = eventAfter . lockstep model cmd
postcondition :: Model Concrete
-> Cmd :@ Concrete
-> Resp :@ Concrete
-> QSM.Logic
postcondition model cmd resp =
toMock (eventAfter ev) resp QSM..== eventMockResp ev
QSM..&& errorHasMountPoint (getResp $ unAt resp)
where
ev = lockstep model cmd resp
errorHasMountPoint :: Either FsError a -> QSM.Logic
errorHasMountPoint (Right _) = QSM.Top
errorHasMountPoint (Left fsError) = QSM.Boolean $ hasMountPoint fsError
semantics :: HasFS IO HandleIO -> Cmd :@ Concrete -> IO (Resp :@ Concrete)
semantics hfs (At cmd) =
At . bimap QSM.reference QSM.reference <$>
runIO hfs (bimap QSM.concrete QSM.concrete cmd)
-- | The state machine proper
sm :: HasFS IO HandleIO -> QSM.StateMachine Model (At Cmd) IO (At Resp)
sm hfs = QSM.StateMachine {
initModel = initModel
, transition = transition
, precondition = precondition
, postcondition = postcondition
, generator = Just . generator
, shrinker = shrinker
, semantics = semantics hfs
, mock = mock
, cleanup = QSM.noCleanup
, invariant = Nothing
}
{-------------------------------------------------------------------------------
Labelling
-------------------------------------------------------------------------------}
data Tag =
-- | Create directory then list its parent
--
-- > CreateDir [x, .., y, z]
-- > ListDirectory [x, .., y]
TagCreateDirThenListDir
-- | Create a directory with its parents, then list its parents
--
-- > CreateDirIfMissing True [x, .., y, z]
-- > ListDirectory [x, .., y]
--
-- Note that this implies all directories must have been created.
| TagCreateDirWithParentsThenListDir
-- | Have a least N open files
--
-- > Open ..
-- > .. --
-- > Open ..
--
-- (with not too many Close calls in between).
| TagAtLeastNOpenFiles Int
-- | Write, then truncate, then write again
--
-- > Put ..
-- > Truncate .. (deleting some but not all of the bytes already written)
-- > Put (write some different bytes)
--
-- Verifies that we correctly modify the file pointer.
| TagPutTruncatePut
-- | Concurrent writer and reader
--
-- > h1 <- Open fp WriteMode ..
-- > h2 <- Open fp ReadMode ..
-- > Put h1 ..
-- > Get h2 ..
| TagConcurrentWriterReader
-- | Writing many times should append the bytes.
--
-- > h1 <- Open fp WriteMode .. | > h2 <- Open fp ReadMode ..
-- > Put h1 .. |
-- > Put h1 .. |
-- > Get h2 ..
| TagWriteWriteRead
-- | Try to open a directory
--
-- > CreateDirectoryIfMissing True fp
-- > Open hp IO.WriteMode
| TagOpenDirectory
-- | Write to a file
--
-- > Put h1
| TagWrite
-- | Seek from end of a file
--
-- > Seek h IO.SeekFromEnd n (n<0)
| TagSeekFromEnd
-- | Create a directory
--
-- > CreateDirIfMissing True ..
| TagCreateDirectory
-- | DoesFileExistOK returns True
| TagDoesFileExistOK
-- | DoesFileExistOK returns False
| TagDoesFileExistKO
-- | DoesDirectoryExistOK returns True
| TagDoesDirectoryExistOK
-- | DoesDirectoryExistOK returns False
| TagDoesDirectoryExistKO
-- | Remove a directory recursively
--
-- > RemoveDirRecursively fe
-- > DoesFileExist fe
| TagRemoveDirectoryRecursive
-- | Remove a file
--
-- > RemoveFile fe
-- > DoesFileExist fe
| TagRemoveFile
-- | Rename a file
--
-- > _ <- Open fe1 WriteMode
-- > RenameFile fe2 fe2
| TagRenameFile
-- | Put truncate and Get
--
-- > Put ..
-- > Truncate ..
-- > Get ..
| TagPutTruncateGet
-- Close a handle 2 times
--
-- > h <- Open ..
-- > close h
-- > close h
| TagClosedTwice
-- Open an existing file with ReadMode and then with WriteMode
--
-- > open fp ReadMode
-- > open fp Write
| TagOpenReadThenWrite
-- Open 2 Readers of a file.
--
-- > open fp ReadMode
-- > open fp ReadMode
| TagOpenReadThenRead
-- ListDir on a non empty dirextory.
--
-- > CreateDirIfMissing True a/b
-- > ListDirectory a
| TagCreateDirWithParentsThenListDirNotNull
-- Read from an AppendMode file
--
-- > h <- Open fp AppendMode
-- > Read h ..
| TagReadInvalid
-- Write to a read only file
--
-- > h <- Open fp ReadMode
-- > Put h ..
| TagWriteInvalid
-- Put Seek and Get
--
-- > Put ..
-- > Seek ..
-- > Get ..
| TagPutSeekGet
-- Put Seek (negative) and Get
--
-- > Put ..
-- > Seek .. (negative)
-- > Get ..
| TagPutSeekNegGet
-- Open with MustBeNew (O_EXCL flag), but the file already existed.
--
-- > h <- Open fp (AppendMode _)
-- > Close h
-- > Open fp (AppendMode MustBeNew)
| TagExclusiveFail
-- Reading returns an empty bytestring when EOF
--
-- > h <- open fp ReadMode
-- > Get h 1 == ""
| TagReadEOF
-- GetAt
--
-- > GetAt ...
| TagPread
-- Roundtrip for I/O with user-supplied buffers
--
-- > PutBuf h bs c
-- > GetBuf h c (==bs)
| TagPutGetBuf
-- Roundtrip for I/O with user-supplied buffers
--
-- > PutBufAt h bs c o
-- > GetBufAt h c o (==bs)
| TagPutGetBufAt
deriving (Show, Eq)
-- | Predicate on events
type EventPred = C.Predicate (Event Symbolic) Tag
-- | Convenience combinator for creating classifiers for successful commands
--
-- For convenience we pair handles with the paths they refer to
successful :: ( Event Symbolic
-> Success FsPath (Handle HandleMock)
-> Either Tag EventPred
)
-> EventPred
successful f = C.predicate $ \ev ->
case eventMockResp ev of
Resp (Left _ ) -> Right $ successful f
Resp (Right ok) -> f ev ok
-- | Tag commands
--
-- Tagging works on symbolic events, so that we can tag without doing real IO.
tag :: [Event Symbolic] -> [Tag]
tag = C.classify [
tagCreateDirThenListDir Set.empty
, tagCreateDirWithParentsThenListDir Set.empty
, tagAtLeastNOpenFiles 0
, tagPutTruncatePut Map.empty Map.empty Map.empty
, tagConcurrentWriterReader Map.empty
, tagWriteWriteRead Map.empty
, tagOpenDirectory Set.empty
, tagWrite
, tagSeekFromEnd
, tagCreateDirectory
, tagDoesFileExistOK
, tagDoesFileExistKO
, tagDoesDirectoryExistOK
, tagDoesDirectoryExistKO
, tagRemoveDirectoryRecursive Set.empty
, tagRemoveFile Set.empty
, tagRenameFile
, tagPutTruncateGet Map.empty Set.empty
, tagClosedTwice Set.empty
, tagOpenReadThenWrite Set.empty
, tagOpenReadThenRead Set.empty
, tagCreateDirWithParentsThenListDirNotNull Set.empty
, tagReadInvalid Set.empty
, tagWriteInvalid Set.empty
, tagPutSeekGet Set.empty Set.empty
, tagPutSeekNegGet Set.empty Set.empty
, tagExclusiveFail
, tagReadEOF
, tagPread
, tagPutGetBuf Set.empty
, tagPutGetBufAt Set.empty
]
where
tagCreateDirThenListDir :: Set FsPath -> EventPred
tagCreateDirThenListDir created = successful $ \ev _ ->
case eventMockCmd ev of
CreateDir fe ->
Right $ tagCreateDirThenListDir (Set.insert fp created)
where
fp = evalPathExpr fe
ListDirectory fe | fp `Set.member` (Set.map fsPathInit created) ->
Left TagCreateDirThenListDir
where
fp = evalPathExpr fe
_otherwise ->
Right $ tagCreateDirThenListDir created
tagCreateDirWithParentsThenListDir :: Set FsPath -> EventPred
tagCreateDirWithParentsThenListDir created = successful $ \ev _ ->
case eventMockCmd ev of
CreateDirIfMissing True fe | length (fsPathToList fp) > 1 ->
Right $ tagCreateDirWithParentsThenListDir (Set.insert fp created)
where
fp = evalPathExpr fe
ListDirectory fe | fp `Set.member` (Set.map fsPathInit created) ->
Left TagCreateDirWithParentsThenListDir
where
fp = evalPathExpr fe
_otherwise ->
Right $ tagCreateDirWithParentsThenListDir created
tagCreateDirWithParentsThenListDirNotNull :: Set FsPath -> EventPred
tagCreateDirWithParentsThenListDirNotNull created = successful $ \ev suc ->
case (eventMockCmd ev, suc) of
(CreateDirIfMissing True fe, _) | length (fsPathToList fp) > 1 ->
Right $ tagCreateDirWithParentsThenListDirNotNull (Set.insert fp created)
where
fp = evalPathExpr fe
(ListDirectory fe, Strings set) | fp `Set.member` (Set.map fsPathInit created)
&& not (Set.null set) ->
Left TagCreateDirWithParentsThenListDirNotNull
where
fp = evalPathExpr fe
_otherwise ->
Right $ tagCreateDirWithParentsThenListDirNotNull created
-- TODO: It turns out we never hit the 10 (or higher) open handles case
-- Not sure if this is a problem or not.
tagAtLeastNOpenFiles :: Int -> EventPred
tagAtLeastNOpenFiles maxNumOpen = C.Predicate {
predApply = \ev ->
let maxNumOpen' = max maxNumOpen (countOpen (eventAfter ev))
in Right $ tagAtLeastNOpenFiles maxNumOpen'
, predFinish = case maxNumOpen of
0 -> Nothing
1 -> Just $ TagAtLeastNOpenFiles 1
2 -> Just $ TagAtLeastNOpenFiles 2
n | n < 10 -> Just $ TagAtLeastNOpenFiles 3
n -> Just $ TagAtLeastNOpenFiles (n `div` 10 * 10)
}
where
countOpen :: Model r -> Int
countOpen = Mock.numOpenHandles . mockFS
tagPutTruncateGet :: Map (HandleMock, FsPath) Int
-> Set (HandleMock, FsPath)
-> EventPred
tagPutTruncateGet put truncated = successful $ \ev _ ->
case eventMockCmd ev of
Put (Handle h fp) bs | BS.length bs /= 0 ->
let
f Nothing = Just $ BS.length bs
f (Just n) = Just $ (BS.length bs) + n
put' = Map.alter f (h, fp) put
in Right $ tagPutTruncateGet put' truncated
Truncate (Handle h fp) sz | sz > 0 -> case Map.lookup (h, fp) put of
Just p | fromIntegral sz < p ->
let truncated' = Set.insert (h, fp) truncated
in Right $ tagPutTruncateGet put truncated'
_otherwise -> Right $ tagPutTruncateGet put truncated
Get (Handle h fp) n | n > 0 && (not $ Set.null $
Set.filter (\(hRead, fp') -> fp' == fp && not (hRead == h)) truncated) ->
Left TagPutTruncateGet
GetAt (Handle h fp) n _ | n > 0 && (not $ Set.null $
Set.filter (\(hRead, fp') -> fp' == fp && not (hRead == h)) truncated) ->
Left TagPutTruncateGet
_otherwise -> Right $ tagPutTruncateGet put truncated
tagPutTruncatePut :: Map HandleMock ByteString
-> Map HandleMock ByteString
-> Map HandleMock ByteString
-> EventPred
tagPutTruncatePut before truncated after = successful $ \ev _ ->
case eventMockCmd ev of
Put (Handle h _) bs | BS.length bs /= 0 ->
case Map.lookup h truncated of
Nothing -> -- not yet truncated
let before' = Map.alter (appTo bs) h before in
Right $ tagPutTruncatePut before' truncated after
Just deleted ->
let putAfter = Map.findWithDefault mempty h after <> bs
after' = Map.insert h putAfter after in
if deleted /= BS.take (BS.length deleted) putAfter
then Left $ TagPutTruncatePut
else Right $ tagPutTruncatePut before truncated after'
Truncate (Handle h _) sz | sz > 0 ->
let putBefore = Map.findWithDefault mempty h before
(putBefore', deleted) = BS.splitAt (fromIntegral sz) putBefore
before' = Map.insert h putBefore' before
truncated' = Map.insert h deleted truncated
after' = Map.delete h after
in Right $ tagPutTruncatePut before' truncated' after'
_otherwise ->
Right $ tagPutTruncatePut before truncated after
where
appTo :: Monoid a => a -> Maybe a -> Maybe a
appTo b Nothing = Just b
appTo b (Just a) = Just (a <> b)
tagConcurrentWriterReader :: Map HandleMock (Set HandleMock) -> EventPred
tagConcurrentWriterReader put = successful $ \ev@Event{..} _ ->
case eventMockCmd ev of
Put (Handle h fp) bs | BS.length bs > 0 ->
-- Remember the other handles to the same file open at this time
let readHs :: Set HandleMock
readHs = Set.fromList
$ map handleRaw
$ filter (\(Handle h' fp') -> h /= h' && fp == fp')
$ openHandles eventBefore
put' :: Map HandleMock (Set HandleMock)
put' = Map.alter (Just . maybe readHs (Set.union readHs)) h put
in Right $ tagConcurrentWriterReader put'
Close (Handle h _) ->
Right $ tagConcurrentWriterReader (Map.delete h put)
Get (Handle h _) n | h `elem` Set.unions (Map.elems put), n > 0 ->
Left TagConcurrentWriterReader
GetAt (Handle h _) n _ | h `elem` Set.unions (Map.elems put), n > 0 ->
Left TagConcurrentWriterReader
_otherwise ->
Right $ tagConcurrentWriterReader put
tagOpenReadThenWrite :: Set FsPath -> EventPred
tagOpenReadThenWrite readOpen = successful $ \ev _ ->
case eventMockCmd ev of
Open (PExpPath fp) ReadMode ->
Right $ tagOpenReadThenWrite $ Set.insert fp readOpen
Open (PExpPath fp) (WriteMode _) | Set.member fp readOpen ->
Left TagOpenReadThenWrite
_otherwise -> Right $ tagOpenReadThenWrite readOpen
tagOpenReadThenRead :: Set FsPath -> EventPred
tagOpenReadThenRead readOpen = successful $ \ev _ ->
case eventMockCmd ev of
Open (PExpPath fp) ReadMode | Set.member fp readOpen ->
Left TagOpenReadThenRead
Open (PExpPath fp) ReadMode ->
Right $ tagOpenReadThenRead $ Set.insert fp readOpen
_otherwise -> Right $ tagOpenReadThenRead readOpen
tagWriteWriteRead :: Map (HandleMock, FsPath) Int -> EventPred
tagWriteWriteRead wr = successful $ \ev _ ->
case eventMockCmd ev of
Put (Handle h fp) bs | BS.length bs > 0 ->
let f Nothing = Just 0
f (Just x) = Just $ x + 1
in Right $ tagWriteWriteRead $ Map.alter f (h, fp) wr
Get (Handle hRead fp) n | n > 1 ->
if not $ Map.null $ Map.filterWithKey (\(hWrite, fp') times -> fp' == fp && times > 1 && not (hWrite == hRead)) wr
then Left TagWriteWriteRead
else Right $ tagWriteWriteRead wr
GetAt (Handle hRead fp) n _ | n > 1 ->
if not $ Map.null $ Map.filterWithKey (\(hWrite, fp') times -> fp' == fp && times > 1 && not (hWrite == hRead)) wr
then Left TagWriteWriteRead
else Right $ tagWriteWriteRead wr
_otherwise ->
Right $ tagWriteWriteRead wr
-- this never succeeds because of an fsLimitation
tagOpenDirectory :: Set FsPath -> EventPred
tagOpenDirectory created = C.predicate $ \ev ->
case (eventMockCmd ev, eventMockResp ev) of
(CreateDir fe, Resp (Right _)) ->
Right $ tagOpenDirectory (Set.insert fp created)
where
fp = evalPathExpr fe
(CreateDirIfMissing True fe, Resp (Right _)) ->
Right $ tagOpenDirectory (Set.insert fp created)
where
fp = evalPathExpr fe
(Open fe _mode, _) | Set.member (evalPathExpr fe) created ->
Left TagOpenDirectory
_otherwise ->
Right $ tagOpenDirectory created
tagWrite :: EventPred
tagWrite = successful $ \ev _ ->
case eventMockCmd ev of
Put _ bs | BS.length bs > 0 ->
Left TagWrite
_otherwise -> Right tagWrite
tagSeekFromEnd :: EventPred
tagSeekFromEnd = successful $ \ev _ ->
case eventMockCmd ev of
Seek _ SeekFromEnd n | n < 0 -> Left TagSeekFromEnd
_otherwise -> Right tagSeekFromEnd
tagCreateDirectory :: EventPred
tagCreateDirectory = successful $ \ev _ ->
case eventMockCmd ev of
CreateDirIfMissing True (PExpPath fp) | length (fsPathToList fp) > 1 ->
Left TagCreateDirectory
_otherwise ->
Right tagCreateDirectory
tagDoesFileExistOK :: EventPred
tagDoesFileExistOK = successful $ \ev suc ->
case (eventMockCmd ev, suc) of
(DoesFileExist _, Bool True) -> Left TagDoesFileExistOK
_otherwise -> Right tagDoesFileExistOK
tagDoesFileExistKO :: EventPred
tagDoesFileExistKO = successful $ \ev suc ->
case (eventMockCmd ev, suc) of
(DoesFileExist _, Bool False) -> Left TagDoesFileExistKO
_otherwise -> Right tagDoesFileExistKO
tagDoesDirectoryExistOK :: EventPred
tagDoesDirectoryExistOK = successful $ \ev suc ->
case (eventMockCmd ev, suc) of
(DoesDirectoryExist (PExpPath fp), Bool True) | not (fp == mkFsPath ["/"])
-> Left TagDoesDirectoryExistOK
_otherwise -> Right tagDoesDirectoryExistOK
tagDoesDirectoryExistKO :: EventPred
tagDoesDirectoryExistKO = successful $ \ev suc ->
case (eventMockCmd ev, suc) of
(DoesDirectoryExist _, Bool False) -> Left TagDoesDirectoryExistKO
_otherwise -> Right tagDoesDirectoryExistKO
tagRemoveDirectoryRecursive :: Set FsPath -> EventPred
tagRemoveDirectoryRecursive removed = successful $ \ev _suc ->
case eventMockCmd ev of
RemoveDirRecursive fe -> Right $ tagRemoveDirectoryRecursive $ Set.insert fp removed
where
fp = evalPathExpr fe
DoesFileExist fe -> if Set.member fp removed
then Left TagRemoveDirectoryRecursive
else Right $ tagRemoveDirectoryRecursive removed
where
fp = evalPathExpr fe
_otherwise -> Right $ tagRemoveDirectoryRecursive removed
tagRemoveFile :: Set FsPath -> EventPred
tagRemoveFile removed = successful $ \ev _suc ->
case eventMockCmd ev of
RemoveFile fe -> Right $ tagRemoveFile $ Set.insert fp removed
where
fp = evalPathExpr fe
DoesFileExist fe -> if Set.member fp removed
then Left TagRemoveFile
else Right $ tagRemoveFile removed
where
fp = evalPathExpr fe
_otherwise -> Right $ tagRemoveFile removed
tagRenameFile :: EventPred
tagRenameFile = successful $ \ev _suc ->
case eventMockCmd ev of
RenameFile {} -> Left TagRenameFile
_otherwise -> Right tagRenameFile
tagClosedTwice :: Set HandleMock -> EventPred
tagClosedTwice closed = successful $ \ev _suc ->
case eventMockCmd ev of
Close (Handle h _) | Set.member h closed -> Left TagClosedTwice
Close (Handle h _) -> Right $ tagClosedTwice $ Set.insert h closed
_otherwise -> Right $ tagClosedTwice closed
-- this never succeeds because of an fsLimitation
tagReadInvalid :: Set HandleMock -> EventPred
tagReadInvalid openAppend = C.predicate $ \ev ->
case (eventMockCmd ev, eventMockResp ev) of
(Open _ (AppendMode _), Resp (Right (WHandle _ (Handle h _)))) ->
Right $ tagReadInvalid $ Set.insert h openAppend
(Close (Handle h _), Resp (Right _)) ->
Right $ tagReadInvalid $ Set.delete h openAppend
(Get (Handle h _) _, Resp (Left _)) | Set.member h openAppend ->
Left TagReadInvalid
(GetAt (Handle h _) _ _, Resp (Left _)) | Set.member h openAppend ->
Left TagReadInvalid
_otherwise -> Right $ tagReadInvalid openAppend
tagWriteInvalid :: Set HandleMock -> EventPred
tagWriteInvalid openRead = C.predicate $ \ev ->
case (eventMockCmd ev, eventMockResp ev) of
(Open _ ReadMode, Resp (Right (RHandle (Handle h _)))) ->
Right $ tagWriteInvalid $ Set.insert h openRead
(Close (Handle h _), Resp (Right _)) ->
Right $ tagWriteInvalid $ Set.delete h openRead
(Put (Handle h _) _, _) | Set.member h openRead ->
Left TagWriteInvalid
_otherwise -> Right $ tagWriteInvalid openRead
tagPutSeekGet :: Set HandleMock -> Set HandleMock -> EventPred
tagPutSeekGet put seek = successful $ \ev _suc ->
case eventMockCmd ev of
Put (Handle h _) bs | BS.length bs > 0 ->
Right $ tagPutSeekGet (Set.insert h put) seek
Seek (Handle h _) RelativeSeek n | n > 0 && Set.member h put ->
Right $ tagPutSeekGet put (Set.insert h seek)
Get (Handle h _) n | n > 0 && Set.member h seek ->
Left TagPutSeekGet
GetAt (Handle h _) n _ | n > 0 && Set.member h seek ->
Left TagPutSeekGet
_otherwise -> Right $ tagPutSeekGet put seek
tagPutSeekNegGet :: Set HandleMock -> Set HandleMock -> EventPred
tagPutSeekNegGet put seek = successful $ \ev _suc ->
case eventMockCmd ev of
Put (Handle h _) bs | BS.length bs > 0 ->
Right $ tagPutSeekNegGet (Set.insert h put) seek
Seek (Handle h _) RelativeSeek n | n < 0 && Set.member h put ->
Right $ tagPutSeekNegGet put (Set.insert h seek)
Get (Handle h _) n | n > 0 && Set.member h seek ->
Left TagPutSeekNegGet
GetAt (Handle h _) n _ | n > 0 && Set.member h seek ->
Left TagPutSeekNegGet
_otherwise -> Right $ tagPutSeekNegGet put seek
tagExclusiveFail :: EventPred
tagExclusiveFail = C.predicate $ \ev ->
case (eventMockCmd ev, eventMockResp ev) of
(Open _ mode, Resp (Left fsError))
| MustBeNew <- allowExisting mode
, fsErrorType fsError == FsResourceAlreadyExist ->
Left TagExclusiveFail
_otherwise -> Right tagExclusiveFail
tagReadEOF :: EventPred
tagReadEOF = successful $ \ev suc ->
case (eventMockCmd ev, suc) of
(Get _ n, ByteString bl)
| n > 0, BS.null bl -> Left TagReadEOF
_otherwise -> Right tagReadEOF
tagPread :: EventPred
tagPread = successful $ \ev _ ->
case eventMockCmd ev of
GetAt{} -> Left TagPread
_otherwise -> Right tagPread
tagPutGetBufAt :: Set HandleMock -> EventPred
tagPutGetBufAt put = successful $ \ev _ ->
case eventMockCmd ev of
PutBufAt (Handle h _) bs c _ | BS.length bs > 0 && c > 0 ->
Right (tagPutGetBufAt (Set.insert h put))
GetBufAt _ c _ | c > 0 ->
Left TagPutGetBufAt
_otherwise ->
Right (tagPutGetBufAt put)
tagPutGetBuf :: Set HandleMock -> EventPred
tagPutGetBuf put = successful $ \ev _ ->
case eventMockCmd ev of
PutBuf (Handle h _) bs c | BS.length bs > 0 && c > 0 ->
Right (tagPutGetBuf (Set.insert h put))
GetBuf _ c | c > 0 ->
Left TagPutGetBuf
_otherwise ->
Right (tagPutGetBuf put)
-- | Step the model using a 'QSM.Command' (i.e., a command associated with
-- an explicit set of variables)
execCmd :: Model Symbolic -> QSM.Command (At Cmd) (At Resp) -> Event Symbolic
execCmd model (QSM.Command cmd resp _vars) = lockstep model cmd resp
-- | 'execCmds' is just the repeated form of 'execCmd'
execCmds :: QSM.Commands (At Cmd) (At Resp) -> [Event Symbolic]
execCmds = \(QSM.Commands cs) -> go initModel cs
where
go :: Model Symbolic -> [QSM.Command (At Cmd) (At Resp)] -> [Event Symbolic]
go _ [] = []
go m (c : cs) = let ev = execCmd m c in ev : go (eventAfter ev) cs
{-------------------------------------------------------------------------------
Required instances
The 'ToExpr' constraints come from "Data.TreeDiff".
-------------------------------------------------------------------------------}
constrInfo :: SOP.HasDatatypeInfo a
=> proxy a
-> SOP.NP SOP.ConstructorInfo (SOP.Code a)
constrInfo = SOP.constructorInfo . SOP.datatypeInfo
constrName :: forall a. SOP.HasDatatypeInfo a => a -> String
constrName a =
SOP.hcollapse $ SOP.hliftA2 go (constrInfo p) (SOP.unSOP (SOP.from a))
where
go :: SOP.ConstructorInfo b -> SOP.NP SOP.I b -> SOP.K String b
go nfo _ = SOP.K $ SOP.constructorName nfo
p = Proxy @a
constrNames :: SOP.HasDatatypeInfo a => proxy a -> [String]
constrNames p =
SOP.hcollapse $ SOP.hmap go (constrInfo p)
where
go :: SOP.ConstructorInfo a -> SOP.K String a
go nfo = SOP.K $ SOP.constructorName nfo
instance QSM.CommandNames (At Cmd) where
cmdName (At cmd) = constrName cmd
cmdNames _ = constrNames (Proxy @(Cmd () ()))
deriving instance ToExpr a => ToExpr (FsTree a)
deriving instance ToExpr fp => ToExpr (PathExpr fp)
deriving instance ToExpr HandleMock
deriving instance ToExpr MockFS
deriving instance ToExpr Mock.HandleState
deriving instance ToExpr Mock.OpenHandleState
deriving instance ToExpr Mock.ClosedHandleState
deriving instance ToExpr Mock.FilePtr
deriving instance ToExpr FsPath
instance ToExpr (Handle h) where
toExpr = defaultExprViaShow
deriving instance ToExpr (Model Concrete)
{-------------------------------------------------------------------------------
Top-level tests
-------------------------------------------------------------------------------}
-- | Show minimal examples for each of the generated tags
--
-- TODO: The examples listed are not always minimal. I'm not entirely sure why.
showLabelledExamples' :: Maybe Int
-- ^ Seed
-> Int
-- ^ Number of tests to run to find examples
-> (Tag -> Bool)
-- ^ Tag filter (can be @const True@)
-> IO ()
showLabelledExamples' mReplay numTests focus = do
replaySeed <- case mReplay of
Nothing -> getStdRandom (randomR (1,999999))
Just seed -> return seed
labelledExamplesWith (stdArgs { replay = Just (mkQCGen replaySeed, 0)
, maxSuccess = numTests
}) $
forAllShrinkShow (QSM.generateCommands sm' Nothing)
(QSM.shrinkCommands sm')
pp $ \cmds ->
collects (filter focus . tag . execCmds $ cmds) $
property True
putStrLn $ "Used replaySeed " ++ show replaySeed
where
sm' = sm unusedHasFS
pp = \x -> ppShow x ++ "\n" ++ condense x
collects :: Show a => [a] -> Property -> Property
collects = repeatedly collect
where
repeatedly :: (a -> b -> b) -> ([a] -> b -> b)
repeatedly = flip . foldl' . flip
showLabelledExamples :: IO ()
showLabelledExamples = showLabelledExamples' Nothing 1000 (const True)
prop_sequential :: Property
prop_sequential = withMaxSuccess 1000 $
QSM.forAllCommands (sm unusedHasFS) Nothing $ runCmds
runCmds :: QSM.Commands (At Cmd) (At Resp) -> Property
runCmds cmds = QC.monadicIO $ do
(tstTmpDir, hist, res) <- QC.run $
withSystemTempDirectory "StateMachine" $ \tstTmpDir -> do
let mount = MountPoint tstTmpDir
hfs = ioHasFS mount
sm' = sm hfs
(hist, model, res) <- QSM.runCommands' sm' cmds
-- Close all open handles
forM_ (RE.keys (knownHandles model)) $ hClose hfs . QSM.concrete
return (tstTmpDir, hist, res)
QSM.prettyCommands (sm unusedHasFS) hist
$ QSM.checkCommandNames cmds
$ tabulate "Tags" (map show $ tag (execCmds cmds))
$ counterexample ("Mount point: " ++ tstTmpDir)
$ res === QSM.Ok
tests :: TestTree
tests = testGroup "Test.System.FS.StateMachine" [
testProperty "q-s-m" $ prop_sequential
, localOption (QuickCheckTests 1)
$ testProperty "regression_removeFileOnDir" $ runCmds regression_removeFileOnDir
]
-- | Unused HasFS
--
-- 'forAllCommands' wants the entire state machine as argument, but we need the
-- HasFS only when /executing/ the commands in IO. We can therefore generate the
-- commands with a dummy HasFS, and then inside the property construct a
-- temporary directory which we can use for execution.
unusedHasFS :: HasFS m h
unusedHasFS = error "HasFS not used during command generation"
-- | The error numbers returned by Linux vs. MacOS differ when using
-- 'removeFile' on a directory. The model mainly mimicks Linux-style errors,
-- which results in an 'FsResourceInappropriateType' error, whereas on MacOS it
-- results in an 'FsInsufficientPermissions' error. The implementation of
-- 'F.sameError' was made more lenient for MacOS in fs-sim#41 to allow this
-- model-SUT discrepancy to occur without making the tests fail. We might revist
-- this /temporary/ fix in the future, see fs-sim#45.
regression_removeFileOnDir :: QSM.Commands (At Cmd) (At Resp)
regression_removeFileOnDir = QSM.Commands {unCommands = [
QSM.Command
(At {unAt =
CreateDirIfMissing
True
(PExpPath (mkFsPath ["x"]))})
(At {unAt = Resp {getResp =
Right (Path (QSM.Reference (QSM.Symbolic (QSM.Var 0))) ())}})
[QSM.Var 0]
, QSM.Command
(At {unAt =
RemoveFile
(PExpPath (mkFsPath ["x"]))})
(At {unAt = Resp {getResp =
Left (FsError {
fsErrorType = FsResourceInappropriateType
, fsErrorPath = FsErrorPath Nothing (mkFsPath ["x"])
, fsErrorString = "expected file"
, fsErrorNo = Nothing
, fsErrorStack = prettyCallStack, fsLimitation = False})}})
[]
]}
{-------------------------------------------------------------------------------
Debugging
-------------------------------------------------------------------------------}
-- | Debugging: show @n@ levels of shrink steps (with some required tags)
--
-- This can be useful when debugging the shrinker
_showTaggedShrinks :: ([Tag] -> Bool) -- ^ Required tags
-> Int -- ^ Number of shrink steps
-> QSM.Commands (At Cmd) (At Resp)
-> IO ()
_showTaggedShrinks hasRequiredTags numLevels = go 0
where
go :: Int -> QSM.Commands (At Cmd) (At Resp) -> IO ()
go n _ | n == numLevels = return ()
go n cmds = do
if hasRequiredTags tags then do
putStrLn $ replicate n '\t' ++ condense (cmds, tags)
forM_ shrinks $ go (n + 1)
else
return ()
where
tags = tag $ execCmds cmds
shrinks = QSM.shrinkCommands (sm unusedHasFS) cmds
{-------------------------------------------------------------------------------
Pretty-printing
-------------------------------------------------------------------------------}
instance Condense fp => Condense (PathExpr fp) where
condense (PExpPath fp) = condense fp
condense (PExpRef fp) = condense fp
condense (PExpParentOf fp) = condense fp ++ "/.."
instance (Condense fp, Condense h) => Condense (Cmd fp h) where
condense = L.intercalate " " . go
where
go (Open fp mode) = ["open", condense fp, condense mode]
go (Close h) = ["close", condense h]
go (IsOpen h) = ["isOpen", condense h]
go (Seek h mode o) = ["seek", condense h, condense mode, condense o]
go (Get h n) = ["get", condense h, condense n]
go (GetAt h n o) = ["getAt", condense h, condense n, condense o]
go (GetBuf h n) = ["getBuf", condense h, condense n]
go (GetBufAt h n o) = ["getBufAt", condense h, condense n, condense o]
go (Put h bs) = ["put", condense h, condense bs]
go (PutBuf h bs n) = ["putBuf", condense h, condense bs, condense n]
go (PutBufAt h bs n o) = ["putBufAt", condense h, condense bs, condense n, condense o]
go (Truncate h sz) = ["truncate", condense h, condense sz]
go (GetSize h) = ["getSize", condense h]
go (CreateDir fp) = ["createDir", condense fp]
go (CreateDirIfMissing p fp) = ["createDirIfMissing", condense p, condense fp]
go (ListDirectory fp) = ["listDirectory", condense fp]
go (DoesDirectoryExist fp) = ["doesDirectoryExist", condense fp]
go (DoesFileExist fp) = ["doesFileExist", condense fp]
go (RemoveDirRecursive fp) = ["removeDirectoryRecursive", condense fp]
go (RemoveFile fp) = ["removeFile", condense fp]
go (RenameFile fp1 fp2) = ["renameFile", condense fp1, condense fp2]
instance Condense1 r => Condense (Cmd :@ r) where
condense (At cmd) = condense cmd
instance Condense Tag where
condense = show
instance Condense AbsOffset where
condense = show
instance Condense ByteCount where
condense = show
{-------------------------------------------------------------------------------
(Orphan) condense instance for QSM types
-------------------------------------------------------------------------------}
instance Condense QSM.Var where
condense (QSM.Var i) = "x" ++ condense i
instance Condense1 Symbolic where
liftCondense _ (QSM.Symbolic a) = condense a
instance Condense (QSM.Opaque a) where
condense _ = "<>"
instance (Condense1 r, Condense a) => Condense (QSM.Reference a r) where
condense (QSM.Reference ra) = condense1 ra
instance Condense (cmd Symbolic) => Condense (QSM.Command cmd resp) where
condense = \(QSM.Command cmd _resp vars) ->
L.intercalate " " $ go cmd vars
where
go :: cmd Symbolic -> [QSM.Var] -> [String]
go cmd [] = [condense cmd]
go cmd xs = [condense xs, "<-", condense cmd]
instance Condense (cmd Symbolic) => Condense (QSM.Commands cmd resp) where
condense (QSM.Commands cmds) = unlines $ "do" : map (indent . condense) cmds
where
indent :: String -> String
indent = (" " ++)