Skip to content

Commit ac32ec9

Browse files
authored
Merge pull request #539 from IntersectMBO/jdral/corrupt-snapshot
Test snapshot corruption
2 parents 9f6b334 + 7e88c2f commit ac32ec9

File tree

3 files changed

+391
-45
lines changed

3 files changed

+391
-45
lines changed

test/Test/Database/LSMTree/Internal/Snapshot/FS.hs

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,36 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
13
-- | Tests for snapshots and their interaction with the file system
24
module Test.Database.LSMTree.Internal.Snapshot.FS (tests) where
35

46
import Codec.CBOR.Read (DeserialiseFailure)
5-
import Control.Exception
7+
import Control.Monad.Class.MonadThrow
8+
import Control.Monad.IOSim (runSimOrThrow)
9+
import Control.Tracer
10+
import Data.Bifunctor (Bifunctor (..))
11+
import Data.Maybe (fromJust)
12+
import qualified Data.Set as Set
13+
import qualified Data.Vector as V
14+
import Data.Word
15+
import Database.LSMTree.Extras (showPowersOf10)
16+
import Database.LSMTree.Extras.Generators ()
17+
import Database.LSMTree.Internal
18+
import Database.LSMTree.Internal.Config
619
import Database.LSMTree.Internal.CRC32C
20+
import Database.LSMTree.Internal.Entry
21+
import Database.LSMTree.Internal.Paths
22+
import Database.LSMTree.Internal.Serialise
723
import Database.LSMTree.Internal.Snapshot
824
import Database.LSMTree.Internal.Snapshot.Codec
25+
import qualified System.FS.API as FS
926
import System.FS.API
1027
import System.FS.Sim.Error hiding (genErrors)
1128
import qualified System.FS.Sim.MockFS as MockFS
1229
import Test.Database.LSMTree.Internal.Snapshot.Codec ()
30+
import Test.QuickCheck
31+
import Test.QuickCheck.Gen (genDouble)
1332
import Test.Tasty
14-
import Test.Tasty.QuickCheck as QC
33+
import Test.Tasty.QuickCheck
1534
import Test.Util.FS
1635

1736
tests :: TestTree
@@ -20,6 +39,7 @@ tests = testGroup "Test.Database.LSMTree.Internal.Snapshot.FS" [
2039
prop_fsRoundtripSnapshotMetaData
2140
, testProperty "prop_fault_fsRoundtripSnapshotMetaData"
2241
prop_fault_fsRoundtripSnapshotMetaData
42+
, testProperty "prop_flipSnapshotBit" prop_flipSnapshotBit
2343
]
2444

2545
-- | @readFileSnapshotMetaData . writeFileSnapshotMetaData = id@
@@ -56,12 +76,12 @@ prop_fault_fsRoundtripSnapshotMetaData testErrs metadata =
5676
ioProperty $
5777
withSimErrorHasFS propNoOpenHandles MockFS.empty emptyErrors $ \hfs _fsVar errsVar -> do
5878
writeResult <-
59-
try @FsError $
79+
try @_ @FsError $
6080
withErrors errsVar (writeErrors testErrs) $
6181
writeFileSnapshotMetaData hfs metadataPath checksumPath metadata
6282

6383
readResult <-
64-
try @SomeException $
84+
try @_ @SomeException $
6585
withErrors errsVar (readErrors testErrs) $
6686
readFileSnapshotMetaData hfs metadataPath checksumPath
6787

@@ -137,3 +157,106 @@ instance Arbitrary TestErrors where
137157
[ TestErrors writeErrors' readErrors'
138158
| (writeErrors', readErrors') <- shrink (writeErrors, readErrors)
139159
]
160+
161+
{-------------------------------------------------------------------------------
162+
Snapshot corruption
163+
-------------------------------------------------------------------------------}
164+
165+
-- | A 'Double' in the @[0, 1)@ range.
166+
newtype Double_0_1 = Double_0_1 Double
167+
deriving stock (Show, Eq)
168+
169+
instance Arbitrary Double_0_1 where
170+
arbitrary = Double_0_1 <$> genDouble
171+
shrink (Double_0_1 x) = [Double_0_1 x' | x' <- shrink x, 0 <= x', x' < 1]
172+
173+
-- TODO: an alternative to generating doubles a priori is to run the monadic
174+
-- code in @PropertyM (IOSim s)@, and then we can do quantification inside the
175+
-- monadic property using @pick@. This complicates matters, however, because
176+
-- functions like @withSimHasBlockIO@ and @withTable@ would have to run in
177+
-- @PropertyM (IOSim s)@ as well. It's not clear whether the refactoring is
178+
-- worth it.
179+
prop_flipSnapshotBit ::
180+
Positive (Small Int)
181+
-> V.Vector (Word64, Entry Word64 Word64)
182+
-> Double_0_1 -- ^ Used to pick which file to corrupt
183+
-> Double_0_1 -- ^ Used to pick which bit to flip in the file we picked
184+
-> Property
185+
prop_flipSnapshotBit
186+
(Positive (Small bufferSize))
187+
es
188+
(Double_0_1 pickFile)
189+
(Double_0_1 pickBit) =
190+
runSimOrThrow $
191+
withSimHasBlockIO propNoOpenHandles MockFS.empty $ \hfs hbio _fsVar ->
192+
withSession nullTracer hfs hbio root $ \s ->
193+
withTable s conf $ \t -> do
194+
-- Create a table, populate it, and create a snapshot
195+
updates resolve es' t
196+
createSnap t
197+
198+
-- Pick a random file from the named snapshot directory
199+
files <- listDirectoryRecursiveFiles hfs (getNamedSnapshotDir namedSnapDir)
200+
let i = round (fromIntegral (Set.size files - 1) * pickFile)
201+
let file = Set.elemAt i files
202+
let path = getNamedSnapshotDir namedSnapDir </> file
203+
-- Pick a random bit from the file that we want to corrupt
204+
n <- withFile hfs path ReadMode $ hGetSize hfs
205+
let j = round (fromIntegral (n * 8 - 1) * pickBit)
206+
207+
-- Some info for the test output
208+
let
209+
tabCorruptedFile = tabulate "Corrupted file" [show path]
210+
counterCorruptedFile = counterexample ("Corrupted file: " ++ show path)
211+
tabFlippedBit = tabulate "Flipped bit" [showPowersOf10 j]
212+
counterFlippedBit = counterexample ("Flipped bit: " ++ show j)
213+
214+
let isUncheckedFile =
215+
path == getNamedSnapshotDir namedSnapDir </> FS.mkFsPath ["0.keyops"]
216+
|| path == getNamedSnapshotDir namedSnapDir </> FS.mkFsPath ["0.blobs"]
217+
|| path == getNamedSnapshotDir namedSnapDir </> FS.mkFsPath ["0.checksums"]
218+
219+
-- TODO: remove once write buffer files have checksum verification
220+
if isUncheckedFile then
221+
pure discard
222+
else if n <= 0 then -- file is empty
223+
pure $ tabulate "Result" ["No corruption applied"] True
224+
else do -- file is non-empty
225+
226+
-- Flip a bit and try to open the snapshot
227+
flipFileBit hfs path j
228+
t' <- try @_ @SomeException $ bracket (openSnap s) close $ \_ -> pure ()
229+
230+
pure $
231+
tabCorruptedFile $ counterCorruptedFile $ tabFlippedBit $ counterFlippedBit $
232+
case t' of
233+
-- If we find an error, we detected corruption. Success!
234+
Left e ->
235+
tabulate
236+
"Result"
237+
["Corruption detected: " <> getConstructorName e]
238+
True
239+
-- The corruption was not detected. Failure!
240+
Right _ -> property False
241+
where
242+
root = FS.mkFsPath []
243+
namedSnapDir = namedSnapshotDir (SessionRoot root) snapName
244+
245+
conf = defaultTableConfig {
246+
confWriteBufferAlloc = AllocNumEntries (NumEntries bufferSize)
247+
}
248+
es' = fmap (bimap serialiseKey (bimap serialiseValue serialiseBlob)) es
249+
250+
resolve (SerialisedValue x) (SerialisedValue y) =
251+
SerialisedValue (x <> y)
252+
253+
snapName = fromJust $ mkSnapshotName "snap"
254+
snapLabel = SnapshotLabel "label"
255+
256+
createSnap t =
257+
createSnapshot snapName snapLabel SnapFullTable t
258+
259+
openSnap s =
260+
openSnapshot s snapLabel SnapFullTable configNoOverride snapName resolve
261+
262+
getConstructorName e = takeWhile (/= ' ') (show e)

test/Test/FS.hs

Lines changed: 68 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ module Test.FS (tests) where
66
import Control.Concurrent.Class.MonadSTM (MonadSTM (atomically))
77
import Control.Concurrent.Class.MonadSTM.Strict.TMVar
88
import Control.Monad
9+
import Control.Monad.Class.MonadThrow
910
import Control.Monad.IOSim (runSimOrThrow)
10-
import Data.Char (isAsciiLower, isAsciiUpper)
11-
import qualified Data.List as List
12-
import qualified Data.Text as Text
11+
import Control.Monad.ST (runST)
12+
import Data.Bit (cloneFromByteString, cloneToByteString, flipBit)
13+
import Data.ByteString (ByteString)
14+
import qualified Data.ByteString as BS
15+
import Data.Set (Set)
16+
import qualified Data.Set as Set
17+
import Data.Vector.Unboxed (thaw, unsafeFreeze)
1318
import GHC.Generics (Generic)
1419
import System.FS.API
20+
import System.FS.API.Lazy
21+
import System.FS.API.Strict
1522
import System.FS.Sim.Error
1623
import qualified System.FS.Sim.MockFS as MockFS
1724
import qualified System.FS.Sim.Stream as S
@@ -29,6 +36,8 @@ tests = testGroup "Test.FS" [
2936
-- * Simulated file system properties
3037
testProperty "prop_numOpenHandles" prop_numOpenHandles
3138
, testProperty "prop_numDirEntries" prop_numDirEntries
39+
-- * Corruption
40+
, testProperty "prop_flipFileBit" prop_flipFileBit
3241
-- * Equality
3342
, testClassLaws "Stream" $
3443
eqLaws (Proxy @(Stream Int))
@@ -40,43 +49,16 @@ tests = testGroup "Test.FS" [
4049
Simulated file system properties
4150
-------------------------------------------------------------------------------}
4251

43-
newtype Path = Path FsPath
44-
deriving stock (Show, Eq)
45-
46-
newtype UniqueList a = UniqueList [a]
47-
deriving stock Show
48-
49-
instance (Arbitrary a, Eq a) => Arbitrary (UniqueList a) where
50-
arbitrary = do
51-
xs <- arbitrary
52-
pure (UniqueList (List.nub xs))
53-
shrink (UniqueList []) = []
54-
shrink (UniqueList xs) = UniqueList . List.nub <$> shrink xs
55-
56-
instance Arbitrary Path where
57-
arbitrary = Path . mkFsPath . (:[]) <$> ((:) <$> genChar <*> listOf genChar)
58-
where
59-
genChar = elements (['A'..'Z'] ++ ['a'..'z'])
60-
shrink (Path p) = case fsPathToList p of
61-
[] -> []
62-
t:_ -> [
63-
Path p'
64-
| t' <- shrink t
65-
, let t'' = Text.filter (\c -> isAsciiUpper c || isAsciiLower c) t'
66-
, not (Text.null t'')
67-
, let p' = fsPathFromList [t']
68-
]
69-
7052
-- | Sanity check for 'propNoOpenHandles' and 'propNumOpenHandles'
71-
prop_numOpenHandles :: UniqueList Path -> Property
72-
prop_numOpenHandles (UniqueList paths) = runSimOrThrow $
53+
prop_numOpenHandles :: Set FsPathComponent -> Property
54+
prop_numOpenHandles (Set.toList -> paths) = runSimOrThrow $
7355
withSimHasFS propTrivial MockFS.empty $ \hfs fsVar -> do
7456
-- No open handles initially
7557
fs <- atomically $ readTMVar fsVar
7658
let prop = propNoOpenHandles fs
7759

7860
-- Open n handles
79-
hs <- forM paths $ \(Path p) -> hOpen hfs p (WriteMode MustBeNew)
61+
hs <- forM paths $ \(fsPathComponentFsPath -> p) -> hOpen hfs p (WriteMode MustBeNew)
8062

8163
-- Now there should be precisely n open handles
8264
fs' <- atomically $ readTMVar fsVar
@@ -94,8 +76,12 @@ prop_numOpenHandles (UniqueList paths) = runSimOrThrow $
9476
n = length paths
9577

9678
-- | Sanity check for 'propNoDirEntries' and 'propNumDirEntries'
97-
prop_numDirEntries :: Path -> InfiniteList Bool -> UniqueList Path -> Property
98-
prop_numDirEntries (Path dir) isFiles (UniqueList paths) = runSimOrThrow $
79+
prop_numDirEntries ::
80+
FsPathComponent
81+
-> InfiniteList Bool
82+
-> Set FsPathComponent
83+
-> Property
84+
prop_numDirEntries (fsPathComponentFsPath -> dir) isFiles (Set.toList -> paths) = runSimOrThrow $
9985
withSimHasFS propTrivial MockFS.empty $ \hfs fsVar -> do
10086
createDirectoryIfMissing hfs False dir
10187

@@ -104,17 +90,17 @@ prop_numDirEntries (Path dir) isFiles (UniqueList paths) = runSimOrThrow $
10490
let prop = propNoDirEntries dir fs
10591

10692
-- Create n entries
107-
forM_ xs $ \(isFile, Path p) ->
93+
forM_ xs $ \(isFile, fsPathComponentFsPath -> p) ->
10894
if isFile
109-
then withFile hfs (dir </> p) (WriteMode MustBeNew) $ \_ -> pure ()
95+
then createFile hfs (dir </> p)
11096
else createDirectory hfs (dir </> p)
11197

11298
-- Now there should be precisely n entries
11399
fs' <- atomically $ readTMVar fsVar
114100
let prop' = propNumDirEntries dir n fs'
115101

116102
-- Remove n entries
117-
forM_ xs $ \(isFile, Path p) ->
103+
forM_ xs $ \(isFile, fsPathComponentFsPath -> p) ->
118104
if isFile
119105
then removeFile hfs (dir </> p)
120106
else removeDirectoryRecursive hfs (dir </> p)
@@ -128,6 +114,50 @@ prop_numDirEntries (Path dir) isFiles (UniqueList paths) = runSimOrThrow $
128114
n = length paths
129115
xs = zip (getInfiniteList isFiles) paths
130116

117+
createFile :: MonadThrow m => HasFS m h -> FsPath -> m ()
118+
createFile hfs p = withFile hfs p (WriteMode MustBeNew) $ \_ -> pure ()
119+
120+
{-------------------------------------------------------------------------------
121+
Corruption
122+
-------------------------------------------------------------------------------}
123+
124+
data WithBitOffset a = WithBitOffset Int a
125+
deriving stock Show
126+
127+
instance Arbitrary (WithBitOffset ByteString) where
128+
arbitrary = do
129+
bs <- arbitrary `suchThat` (\bs -> BS.length bs > 0)
130+
bitOffset <- chooseInt (0, BS.length bs - 1)
131+
pure $ WithBitOffset bitOffset bs
132+
shrink (WithBitOffset bitOffset bs) =
133+
[ WithBitOffset bitOffset' bs'
134+
| bs' <- shrink bs
135+
, BS.length bs' > 0
136+
, let bitOffset' = max 0 $ min (BS.length bs' - 1) bitOffset
137+
] ++ [
138+
WithBitOffset bitOffset' bs
139+
| bitOffset' <- max 0 <$> shrink bitOffset
140+
, bitOffset' >= 0
141+
]
142+
143+
prop_flipFileBit :: WithBitOffset ByteString -> Property
144+
prop_flipFileBit (WithBitOffset bitOffset bs) =
145+
ioProperty $
146+
withSimHasFS propTrivial MockFS.empty $ \hfs _fsVar -> do
147+
void $ withFile hfs path (WriteMode MustBeNew) $ \h -> hPutAllStrict hfs h bs
148+
flipFileBit hfs path bitOffset
149+
bs' <- withFile hfs path ReadMode $ \h -> BS.toStrict <$> hGetAll hfs h
150+
pure (spec_flipFileBit bs bitOffset === bs')
151+
where
152+
path = mkFsPath ["file"]
153+
154+
spec_flipFileBit :: ByteString -> Int -> ByteString
155+
spec_flipFileBit bs bitOffset = runST $ do
156+
mv <- thaw $ cloneFromByteString bs
157+
flipBit mv bitOffset
158+
v <- unsafeFreeze mv
159+
pure $ cloneToByteString v
160+
131161
{-------------------------------------------------------------------------------
132162
Equality
133163
-------------------------------------------------------------------------------}

0 commit comments

Comments
 (0)