Skip to content

Commit 7f12865

Browse files
authored
Merge pull request #22 from commercialhaskell/b/permit-missing-sdists
Permit missing sdists
2 parents 526e1b1 + 9cf4038 commit 7f12865

8 files changed

Lines changed: 131 additions & 41 deletions

File tree

all-cabal-tool.cabal

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,10 @@ test-suite all-cabal-tool-test
102102
, all-cabal-tool
103103
, bytestring
104104
, containers
105+
, hackage-security
105106
, hit
107+
, http-client
108+
, network-uri
106109
, QuickCheck
107110
, tasty
108111
, tasty-quickcheck

nix/packages/all-cabal-tool.nix

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ mkDerivation {
2929
temporary zlib
3030
];
3131
testHaskellDepends = [
32-
aeson base bytestring containers hit QuickCheck tasty
33-
tasty-quickcheck text
32+
aeson base bytestring containers hackage-security hit http-client
33+
network-uri QuickCheck tasty tasty-quickcheck text
3434
];
3535
homepage = "https://github.com/commercialhaskell/all-cabal-tool#readme";
3636
description = "Update the various all-cabal-* repos";

src/Stackage/Package/Hackage.hs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
module Stackage.Package.Hackage
77
( Hackage
88
, HasUpdates(..)
9+
, SdistUnavailable(..)
910
, withHackage
1011
, refreshIndex
1112
, withSdist
1213
, sdistLocations
1314
) where
1415

15-
import Control.Exception (bracket)
16+
import Control.Exception (Exception(..), bracket, throwIO)
1617
import Control.Monad (when)
1718
import GHC.Stack (HasCallStack)
1819
import Data.Time (UTCTime)
@@ -33,10 +34,11 @@ import Hackage.Security.Client.Formats (Format(..))
3334
import Hackage.Security.Client.Repository.Cache
3435
(Cache(..), getCachedIndex)
3536
import qualified Hackage.Security.Client.Repository.Remote as Remote
37+
import Hackage.Security.Util.Checked (tryChecked)
3638
import Hackage.Security.Util.Path (fromFilePath, makeAbsolute, toFilePath)
37-
import Hackage.Security.Util.Pretty (pretty)
39+
import Hackage.Security.Util.Pretty (Pretty(..))
3840

39-
import Stackage.Package.HttpLib (withHttpLib)
41+
import Stackage.Package.HttpLib (unexpectedResponseStatus, withHttpLib)
4042

4143
-- | A bootstrapped connection to Hackage together with the local cache backing
4244
-- it.
@@ -45,6 +47,22 @@ data Hackage = Hackage
4547
, hackageCache :: Cache
4648
}
4749

50+
-- | A package whose source tarball no mirror would serve.
51+
--
52+
-- This can happen for moderated packages. The index is append-only, so they're
53+
-- still there... just not downloadable.
54+
data SdistUnavailable = SdistUnavailable
55+
{ unavailablePackage :: PackageIdentifier
56+
, unavailableStatus :: Int -- ^ What the last mirror tried answered with
57+
} deriving (Show)
58+
59+
instance Pretty SdistUnavailable where
60+
pretty (SdistUnavailable pkgId code) =
61+
"No mirror would serve " ++ display pkgId ++ ": status " ++ show code
62+
63+
instance Exception SdistUnavailable where
64+
displayException = pretty
65+
4866
-- | Primary server first, then out-of-band mirrors.
4967
--
5068
-- Hackage's own @mirrors.json@ currently lists only defunct mirrors, so the
@@ -118,14 +136,25 @@ refreshIndex hackage now = do
118136
--
119137
-- The tarball lands under the cache root because @hackage-security@ moves it
120138
-- there from a temporary file of its own with a plain rename.
121-
withSdist :: Hackage -> PackageIdentifier -> (FilePath -> IO a) -> IO a
139+
withSdist
140+
:: Hackage
141+
-> PackageIdentifier
142+
-> (FilePath -> IO a)
143+
-> IO (Either SdistUnavailable a)
122144
withSdist hackage pkgId action = do
123145
let tmpDir = toFilePath (cacheRoot (hackageCache hackage)) </> "sdists"
124146
createDirectoryIfMissing True tmpDir
125147
bracket (newTempFile tmpDir) removeFile $ \dest -> do
126-
uncheckClientErrors $
148+
fetched <-
149+
uncheckClientErrors $
150+
tryChecked $
127151
downloadPackage' (hackageRepository hackage) pkgId dest
128-
action dest
152+
case fetched of
153+
Right () -> Right <$> action dest
154+
Left err ->
155+
case unexpectedResponseStatus err of
156+
Just code -> return (Left (SdistUnavailable pkgId code))
157+
Nothing -> throwIO err
129158
where
130159
newTempFile dir = do
131160
(path, h) <- openBinaryTempFile dir (display pkgId ++ ".tar.gz")

src/Stackage/Package/Hashes.hs

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,17 @@ createHashesIfMissing hackage hashesRepo hackageHashMap pkgName pkgVersion =
120120
hackageHashMap
121121
(packageHashes package)
122122
Nothing -> do
123-
package <- computePackage hackage pkgName pkgVersion
124-
areAllValid <-
125-
validateHackageHashes
126-
(pack $ getPackageFullName pkgName pkgVersion)
127-
hackageHashMap
128-
(packageHashes package)
129-
when areAllValid $ repoWriteFile hashesRepo jsonfp (encode package)
130-
return areAllValid
123+
mpackage <- computePackage hackage pkgName pkgVersion
124+
case mpackage of
125+
Nothing -> return False
126+
Just package -> do
127+
areAllValid <-
128+
validateHackageHashes
129+
(pack $ getPackageFullName pkgName pkgVersion)
130+
hackageHashMap
131+
(packageHashes package)
132+
when areAllValid $ repoWriteFile hashesRepo jsonfp (encode package)
133+
return areAllValid
131134

132135
-- | Kinda like sequence, except not.
133136
flatten :: Package Maybe -> Maybe (Package Identity)
@@ -151,25 +154,31 @@ instance FromJSON (Package Maybe) where
151154
Package <$> o .: "package-hashes" <*> o .: "package-locations" <*>
152155
o .:? "package-size"
153156

154-
-- | Fetch a source tarball and derive its hashes and size.
157+
-- | Fetch a source tarball and derive its hashes and size, or nothing at all if
158+
-- the tarball is no longer served.
155159
computePackage
156160
:: MonadIO m
157161
=> Hackage
158162
-> PackageName -- ^ Package name
159163
-> Version -- ^ Package version
160-
-> m (Package Identity)
164+
-> m (Maybe (Package Identity))
161165
computePackage hackage pkgName pkgVersion = liftIO $ do
162166
putStrLn $ "Computing package information for: " ++ pack pkgFullName
163-
(hashes, size) <-
167+
fetched <-
164168
withSdist hackage pkgId $ \path -> do
165169
lbs <- L.readFile path
166170
runConduit $ CL.sourceList (L.toChunks lbs) .| getZipSink pairSink
167-
return
168-
Package
169-
{ packageHashes = hashes
170-
, packageLocations = map pack (sdistLocations pkgId)
171-
, packageSize = Identity size
172-
}
171+
case fetched of
172+
Left unavailable -> do
173+
hPutStrLn stderr $ pack $ displayException unavailable
174+
return Nothing
175+
Right (hashes, size) ->
176+
return $ Just
177+
Package
178+
{ packageHashes = hashes
179+
, packageLocations = map pack (sdistLocations pkgId)
180+
, packageSize = Identity size
181+
}
173182
where
174183
pkgId = PackageIdentifier pkgName pkgVersion
175184
pkgFullName = getPackageFullName pkgName pkgVersion

src/Stackage/Package/HttpLib.hs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
module Stackage.Package.HttpLib
1616
( withHttpLib
1717
, UnexpectedResponse(..)
18+
, unexpectedResponseStatus
1819
) where
1920

2021
import Control.Exception (Exception(..), IOException)
2122
import qualified Data.ByteString.Char8 as S8
2223
import Data.List (intercalate)
24+
import Data.Typeable (cast)
2325
import Network.HTTP.Client
2426
(Manager, Request(..), Response(..), requestFromURI)
2527
import qualified Network.HTTP.Client as HTTP
@@ -118,9 +120,11 @@ wrapCustomEx act =
118120
where
119121
go ex = throwChecked (SomeRemoteError ex)
120122

121-
data UnexpectedResponse =
122-
UnexpectedResponse URI
123-
Int
123+
-- | A mirror answered with something other than the file that was asked for.
124+
data UnexpectedResponse = UnexpectedResponse
125+
{ unexpectedUri :: URI
126+
, unexpectedStatus :: Int
127+
}
124128

125129
instance Pretty UnexpectedResponse where
126130
pretty (UnexpectedResponse uri code) =
@@ -130,3 +134,8 @@ deriving instance Show UnexpectedResponse
130134

131135
instance Exception UnexpectedResponse where
132136
displayException = pretty
137+
138+
-- | The status a mirror answered with, for failures that came from the
139+
-- response rather than from the transport underneath it.
140+
unexpectedResponseStatus :: SomeRemoteError -> Maybe Int
141+
unexpectedResponseStatus (SomeRemoteError inner) = unexpectedStatus <$> cast inner

src/Stackage/Package/Metadata.hs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module Stackage.Package.Metadata
88
) where
99

1010
import qualified Codec.Archive.Tar as Tar
11+
import Control.Exception (displayException)
1112
import Control.Monad (when)
1213
import Control.Monad.IO.Class (MonadIO(liftIO))
1314
import qualified Data.ByteString.Lazy as L
@@ -156,17 +157,20 @@ updatePackageIfChanged hackage metadataRepo (cabalFile@CabalFile {..}, packageNa
156157
sink = CL.fold goEntry (cfDescription, "haddock", "", "")
157158
updatePackage = do
158159
checkCabalFile
159-
(desc, desct, cl, clt) <-
160+
fetched <-
160161
withSdist hackage (PackageIdentifier packageName pkgVersionMax) $
161162
\path -> localTarballSink path True sink
162-
putStrLn $
163-
"Updating Metadata for package: " ++
164-
pkgNameStr ++ " to version: " ++ pkgVersionStr
165-
repoWriteFile
166-
metadataRepo
167-
fp
168-
(L.fromStrict . Y.encode $
169-
makePackageInfo cabalFile versionSet desc desct cl clt)
163+
case fetched of
164+
Left unavailable -> hPutStrLn stderr $ displayException unavailable
165+
Right (desc, desct, cl, clt) -> do
166+
putStrLn $
167+
"Updating Metadata for package: " ++
168+
pkgNameStr ++ " to version: " ++ pkgVersionStr
169+
repoWriteFile
170+
metadataRepo
171+
fp
172+
(L.fromStrict . Y.encode $
173+
makePackageInfo cabalFile versionSet desc desct cl clt)
170174
fp =
171175
"packages" </> (unpack $ toLower $ pack $ take 2 $ pkgNameStr ++ "XX") </>
172176
pkgNameStr <.>

test-integration/Main.hs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-- | Exercises the @hackage-security@ client against the live Hackage
2-
-- repository: bootstrapping trust, fetching the index, and fetching a
3-
-- verified source tarball.
2+
-- repository: bootstrapping trust, fetching the index, and fetching both a
3+
-- verified source tarball and a withdrawn one.
44
--
55
-- Enable with @cabal test --flags=integration@.
66
module Main where
@@ -21,6 +21,11 @@ sampleSdist :: (PackageIdentifier, Integer)
2121
sampleSdist =
2222
(PackageIdentifier (mkPackageName "text") (mkVersion [2, 1, 2]), 449871)
2323

24+
-- | @hermes-1.3.4.3@ is still named by the index, but isn't available.
25+
withdrawnSdist :: PackageIdentifier
26+
withdrawnSdist =
27+
PackageIdentifier (mkPackageName "hermes") (mkVersion [1, 3, 4, 3])
28+
2429
main :: IO ()
2530
main = do
2631
hSetBuffering stdout LineBuffering
@@ -35,8 +40,16 @@ main = do
3540
indexSize <- getFileSize indexPath
3641
check "the index was cached" (indexSize > 0)
3742
let (pkgId, expectedSize) = sampleSdist
38-
sdistSize <- withSdist hackage pkgId getFileSize
39-
check "the source tarball has the published size" (sdistSize == expectedSize)
43+
fetched <- withSdist hackage pkgId getFileSize
44+
check "the source tarball has the published size" $
45+
case fetched of
46+
Right sdistSize -> sdistSize == expectedSize
47+
Left _ -> False
48+
withdrawn <- withSdist hackage withdrawnSdist getFileSize
49+
check "a withdrawn source tarball is reported, not thrown" $
50+
case withdrawn of
51+
Left _ -> True
52+
Right _ -> False
4053

4154
check :: String -> Bool -> IO ()
4255
check what ok = do

test/Main.hs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@ import qualified Data.Text as T
99
import Data.Aeson (fromJSON, toJSON)
1010
import qualified Data.Aeson as Aeson
1111
import Data.Word (Word8)
12+
import Network.HTTP.Client (HttpException(InvalidUrlException))
13+
import Network.URI (nullURI)
1214
import Test.QuickCheck
1315
import Test.QuickCheck.Monadic (monadicIO, run)
1416
import Test.Tasty
1517
import Test.Tasty.QuickCheck
1618

1719
import Data.Git.Ref (Ref, fromBinary, toBinary)
20+
import Hackage.Security.Client (SomeRemoteError(..))
1821

1922
import Stackage.Package.Git.Object (makeGitFile)
2023
import Stackage.Package.Git.Types (FileName(..), GitFile(..), TreePath, toShortRef, fromShortRef, overlaps)
2124
import Stackage.Package.Git.WorkTree (emptyWorkTree, insertGitFile, lookupFile, removeGitFile)
25+
import Stackage.Package.HttpLib (UnexpectedResponse(..), unexpectedResponseStatus)
2226
import Stackage.Package.Metadata.Types (Deprecation(..))
2327

2428
-- | Arbitrary instance for FileName.
@@ -295,6 +299,21 @@ prop_deprecation_json_roundtrip dep =
295299
Aeson.Success dep' -> dep' == dep
296300
Aeson.Error _ -> False
297301

302+
-- UnexpectedResponse properties
303+
304+
-- | A mirror that answered with a status is reported as that status.
305+
prop_unexpected_response_status :: Int -> Bool
306+
prop_unexpected_response_status code =
307+
unexpectedResponseStatus (SomeRemoteError (UnexpectedResponse nullURI code)) ==
308+
Just code
309+
310+
-- | A failure to reach a mirror at all is not a status.
311+
prop_transport_failure_has_no_status :: Property
312+
prop_transport_failure_has_no_status =
313+
once $
314+
unexpectedResponseStatus (SomeRemoteError (InvalidUrlException "" "")) ==
315+
Nothing
316+
298317
main :: IO ()
299318
main = defaultMain tests
300319

@@ -322,4 +341,8 @@ tests = testGroup "all-cabal-tool"
322341
, testGroup "Deprecation"
323342
[ testProperty "JSON roundtrip" prop_deprecation_json_roundtrip
324343
]
344+
, testGroup "UnexpectedResponse"
345+
[ testProperty "a response carries its status" prop_unexpected_response_status
346+
, testProperty "a transport failure has no status" prop_transport_failure_has_no_status
347+
]
325348
]

0 commit comments

Comments
 (0)