diff --git a/gbs-downloader.cabal b/gbs-downloader.cabal index 6bb1f36ef7a0183555d418421585f1eb1a618e09..8d377001b6a0f7437ef008f13a615b2108909641 100644 --- a/gbs-downloader.cabal +++ b/gbs-downloader.cabal @@ -99,7 +99,7 @@ library -- Base language which the package is written in. default-language: Haskell2010 -executable gbs-downloader +executable gbs-download -- Import common warning flags. import: warnings @@ -135,7 +135,7 @@ test-suite gbs-downloader-test default-language: Haskell2010 -- Modules included in this executable, other than Main. - -- other-modules: + other-modules: Generators -- The interface type and version of the test suite. type: exitcode-stdio-1.0 @@ -149,6 +149,8 @@ test-suite gbs-downloader-test -- Test dependencies. build-depends: , base ^>=4.14.3.0 + , base32 + , binary , bytestring , containers , crypto-api @@ -156,4 +158,6 @@ test-suite gbs-downloader-test , hedgehog , tahoe-chk , tasty + , tasty-hedgehog , tasty-hunit + , text diff --git a/src/Tahoe/Download.hs b/src/Tahoe/Download.hs index 24e6715a99debf1947aac0de7278edc70a39c787..91f851e9552e642e22f95ff7302117b99db3f8cb 100644 --- a/src/Tahoe/Download.hs +++ b/src/Tahoe/Download.hs @@ -29,6 +29,9 @@ data DownloadError , notEnoughSharesFound :: Int } | ShareDecodingFailed + | -- | An attempt was made to download a share but no servers were given for + -- the download. + NoServers deriving (Eq, Ord, Show) {- | An problem arose while attempting to discover the shares held on a @@ -105,7 +108,7 @@ download servers cap@Reader{readKey, verifier = Verifier{..}} openServer = -} makeShareMap :: Ord k => [(v, Set.Set k)] -> Map.Map k [v] makeShareMap locations = - foldl' (Map.unionWith (<>)) mempty ((\(k, v) -> Map.fromSet (\_ -> [k]) v) <$> locations) + foldl' (Map.unionWith (<>)) mempty ((\(k, v) -> Map.fromSet (const [k]) v) <$> locations) -- | Download the bytes of a share from one (or more!) of the given servers. downloadShare :: @@ -121,4 +124,5 @@ downloadShare :: -- | The bytes of the share or some error that was encountered during -- download. IO (ShareNum, Either DownloadError LB.ByteString) -downloadShare = undefined +downloadShare _ shareNum [] = pure (shareNum, Left NoServers) +downloadShare _ shareNum _ = pure (shareNum, Right "") diff --git a/test/Generators.hs b/test/Generators.hs new file mode 100644 index 0000000000000000000000000000000000000000..08ba46e3fcbefd45e977c8d35e240821439aab1b --- /dev/null +++ b/test/Generators.hs @@ -0,0 +1,22 @@ +module Generators where + +import Data.Int (Int64) +import Hedgehog (MonadGen) +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range +import Tahoe.CHK.Types (Parameters (..)) + +-- | The maximum value an Int64 can represent. +maxInt64 :: Integer +maxInt64 = fromIntegral (maxBound :: Int64) + +-- | Generate Parameters values for which all field invariants hold. +genParameters :: MonadGen m => m Parameters +genParameters = do + paramSegmentSize <- Gen.integral (Range.exponential 1 maxInt64) + paramTotalShares <- Gen.integral (Range.linear 2 256) + paramRequiredShares <- Gen.integral (Range.linear 1 (paramTotalShares - 1)) + -- XXX We're going to get rid of "Happy" from this type. For now it's + -- easier not to let this value vary and it doesn't hurt anything. + let paramHappyShares = 1 + pure $ Parameters{paramSegmentSize, paramTotalShares, paramHappyShares, paramRequiredShares} diff --git a/test/Spec.hs b/test/Spec.hs index 4251dece5429a402609148124e1adfd5402daef4..fac9ffe160b82ca222b37a3fe42a9a5bfa0840ab 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -1,17 +1,36 @@ module Main where +import Control.Exception (Exception, throwIO) +import Control.Monad (replicateM, zipWithM_) import Control.Monad.IO.Class (liftIO) import Crypto.Classes (buildKey) +import qualified Data.Binary as Binary import qualified Data.ByteString as B +import Data.ByteString.Base32 (encodeBase32Unpadded) +import qualified Data.ByteString.Lazy as BL import qualified Data.Map.Strict as Map +import qualified Data.Set as Set +import Data.Text.Encoding (encodeUtf8) import Data.Word (Word16) +import Generators (genParameters) +import Hedgehog (MonadGen, diff, forAll, property) +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range import System.IO (hSetEncoding, stderr, stdout, utf8) +import qualified Tahoe.CHK import Tahoe.CHK.Capability (Reader (..), Verifier (..)) -import Tahoe.CHK.Server (StorageServer (..), StorageServerAnnouncement (..)) +import qualified Tahoe.CHK.Encrypt +import Tahoe.CHK.Server (StorageServer (..), StorageServerAnnouncement (..), StorageServerID) +import Tahoe.CHK.Types (Parameters (..)) +import Tahoe.CHK.Upload (getConvergentKey) import Tahoe.Download (DownloadError (..), download) import Tahoe.Server (memoryStorageServer) import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit (assertEqual, testCase) +import Test.Tasty.Hedgehog (testProperty) + +data PlacementError = RanOutOfPlacementInfo | RanOutOfServers deriving (Eq, Show) +instance Exception PlacementError tests :: TestTree tests = @@ -128,20 +147,103 @@ tests = "download should fail with not enough shares" (Left NotEnoughShares{notEnoughSharesNeeded = 3, notEnoughSharesFound = 2}) result - , testCase "success" $ do - -- If we can recover enough distinct, decodeable shares from the - -- configured servers then we can recover the application data. - -- - -- XXX Start writing here. Probably make this a property test - -- that generates lots of configurations where it should be - -- possible to recover the data (have all the shares, have enough - -- of the shares, spread them across many servers, concentrate - -- them on one or a few, etc) - pure () + , testProperty "success" $ + property $ do + -- If we can recover enough distinct, decodeable shares from the + -- configured servers then we can recover the application data. + + -- Generates configurations where it should be possible to recover + -- the data (have all the shares, have enough of the shares, + -- spread them across many servers, concentrate them on one or a + -- few, etc) + + secret <- forAll $ Gen.bytes (Range.singleton 32) + plaintext <- forAll $ BL.fromStrict <$> Gen.bytes (Range.exponential 56 1024) + params@Parameters{paramTotalShares} <- forAll genParameters + + -- Since multiple shares can be placed on a single server, as long + -- as we have one server we have a valid case. Since some shares + -- might be placed non-optimally it is also nice to allow for some + -- empty servers so allow for that as well. + let numServers = Range.linear 1 (fromIntegral paramTotalShares + 1) + serverIDs = Gen.text (Range.singleton 2) Gen.ascii + serverIDs' <- forAll $ Gen.set numServers serverIDs + -- Constructor <$> arbitrary <*> arbitrary + + -- Choose a share distribution. Each element of the resulting + -- list tells us how many shares to place on the next server, for + -- some arbitrary (stable) server ordering. + perServerShareCount <- + forAll $ + genListWithSum (length serverIDs') (fromIntegral paramTotalShares) + + -- Make the servers. + servers <- liftIO $ replicateM (length serverIDs') memoryStorageServer + + -- Encrypt and encode the data into shares. + let key = getConvergentKey secret params plaintext + ciphertext = Tahoe.CHK.Encrypt.encrypt key plaintext + (shares, cap) <- liftIO $ Tahoe.CHK.encode key params ciphertext + + -- Distribute the shares. + liftIO $ placeShares cap (Binary.encode <$> shares) perServerShareCount servers 0 + + -- Recover the plaintext from the servers. + result <- liftIO $ download (Map.fromSet makeAnn serverIDs') cap (someServers $ Map.fromList $ zip (encodeUtf8 <$> Set.toList serverIDs') servers) + diff (Right plaintext) (==) result ] where + -- A server lookup function that always fails. noServers _ = pure Nothing + -- A server lookup function that finds servers already present in a Map. + someServers servers = pure . flip Map.lookup servers . parseURL + where + -- Exactly match the nonsense makeAnn spits out + parseURL = B.take 2 . B.drop 5 . encodeUtf8 + + --- PHILOSOFY + -- We wish that share numbers were an opaque type instead of a + -- numeric/integral type. This is not the place to argue the point + -- though. + placeShares :: Reader -> [BL.ByteString] -> [Int] -> [StorageServer] -> Int -> IO () + -- Out of shares, done. + placeShares _ [] _ _ _ = pure () + -- Out of placement info but not out of shares is a programming error. + placeShares _ _ [] _ _ = throwIO RanOutOfPlacementInfo + -- Out of servers but not out of shares is a programming error. + placeShares _ _ _ [] _ = throwIO RanOutOfServers + -- Having some of all three means we can make progress. + placeShares cap shares (n : ns) (s : ss) sharesSoFar = do + -- write the right number of shares to this server + zipWithM_ + (\shnum share -> storageServerWrite s (storageIndex . verifier $ cap) shnum 0 share) + [fromIntegral n ..] + (BL.toStrict <$> take n shares) + -- recurse to write the rest + placeShares cap (drop n shares) ns ss (sharesSoFar + n) + + -- Make up a distinct (but nonsense) announcement for a given storage + -- server identifier. + makeAnn :: StorageServerID -> StorageServerAnnouncement + makeAnn sid = + StorageServerAnnouncement + { storageServerAnnouncementFURL = Just $ "pb://" <> sid <> "/" <> sid + , storageServerAnnouncementNick = Just . encodeBase32Unpadded . encodeUtf8 $ sid + , storageServerAnnouncementPermutationSeed = Nothing + } + + -- Generate lists of ints that sum to a given total. + genListWithSum :: MonadGen m => Int -> Int -> m [Int] + -- We hit the target. + genListWithSum _ 0 = pure [] + -- We only have room for one more element. + genListWithSum 1 t = pure [t] + -- Use up some of what's left on one element and recurse. + genListWithSum maxLength t = do + v <- Gen.int (Range.linear 0 t) + (v :) <$> genListWithSum (maxLength - 1) (t - v) + trivialCap :: Word16 -> Word16 -> Reader trivialCap required total = Reader{..} where