Skip to content

Commit 815c21e

Browse files
committed
Add pre and post build hooks
Run a program (named "preBuildHook") before doing a package build and another program (named "postBuildHook") after the package is built. The exit code from the pre-build hook is passed to the post-build hook. The commit includes documentation for the hooks and the security safeguards implemented to avoid the running of malicious hook files.
1 parent 595d023 commit 815c21e

File tree

18 files changed

+313
-2
lines changed

18 files changed

+313
-2
lines changed

cabal-install/cabal-install.cabal

+1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ library
141141
Distribution.Client.GlobalFlags
142142
Distribution.Client.Haddock
143143
Distribution.Client.HashValue
144+
Distribution.Client.HookAccept
144145
Distribution.Client.HttpUtils
145146
Distribution.Client.IndexUtils
146147
Distribution.Client.IndexUtils.ActiveRepos

cabal-install/src/Distribution/Client/CmdFreeze.hs

+1
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ freezeAction flags@NixStyleFlags{..} extraArgs globalFlags = do
142142
(_, elaboratedPlan, _, totalIndexState, activeRepos) <-
143143
rebuildInstallPlan
144144
verbosity
145+
mempty
145146
distDirLayout
146147
cabalDirLayout
147148
projectConfig

cabal-install/src/Distribution/Client/CmdTarget.hs

+1
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ targetAction flags@NixStyleFlags{..} ts globalFlags = do
160160
(_, elaboratedPlan, _, _, _) <-
161161
rebuildInstallPlan
162162
verbosity
163+
mempty
163164
distDirLayout
164165
cabalDirLayout
165166
projectConfig

cabal-install/src/Distribution/Client/Errors.hs

+34
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ data CabalInstallException
186186
| MissingPackageList Repo.RemoteRepo
187187
| CmdPathAcceptsNoTargets
188188
| CmdPathCommandDoesn'tSupportDryRun
189+
| HookAcceptUnknown FilePath FilePath String
190+
| HookAcceptHashMismatch FilePath FilePath String String
189191
deriving (Show)
190192

191193
exceptionCodeCabalInstall :: CabalInstallException -> Int
@@ -338,6 +340,8 @@ exceptionCodeCabalInstall e = case e of
338340
MissingPackageList{} -> 7160
339341
CmdPathAcceptsNoTargets{} -> 7161
340342
CmdPathCommandDoesn'tSupportDryRun -> 7163
343+
HookAcceptUnknown{} -> 7164
344+
HookAcceptHashMismatch{} -> 7165
341345

342346
exceptionMessageCabalInstall :: CabalInstallException -> String
343347
exceptionMessageCabalInstall e = case e of
@@ -860,6 +864,36 @@ exceptionMessageCabalInstall e = case e of
860864
"The 'path' command accepts no target arguments."
861865
CmdPathCommandDoesn'tSupportDryRun ->
862866
"The 'path' command doesn't support the flag '--dry-run'."
867+
HookAcceptUnknown hsPath fpath hash ->
868+
concat
869+
[ "The following file does not appear in the hooks-security file.\n"
870+
, " hook file : "
871+
, fpath
872+
, "\n"
873+
, " file hash : "
874+
, hash
875+
, "\n"
876+
, "After checking the contents of that file, it should be added to the\n"
877+
, "hooks-security file with either AcceptAlways or better yet an AcceptHash.\n"
878+
, "The hooks-security file is (probably) located at: "
879+
, hsPath
880+
]
881+
HookAcceptHashMismatch hsPath fpath expected actual ->
882+
concat
883+
[ "\nHook file hash mismatch for:\n"
884+
, " hook file : "
885+
, fpath
886+
, "\n"
887+
, " expected hash: "
888+
, expected
889+
, "\n"
890+
, " actual hash : "
891+
, actual
892+
, "\n"
893+
, "The hook file should be inspected and if deemed ok, the hooks-security file updated.\n"
894+
, "The hooks-security file is (probably) located at: "
895+
, hsPath
896+
]
863897

864898
instance Exception (VerboseException CabalInstallException) where
865899
displayException :: VerboseException CabalInstallException -> [Char]

cabal-install/src/Distribution/Client/HashValue.hs

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
module Distribution.Client.HashValue
55
( HashValue
66
, hashValue
7+
, hashValueFromHex
78
, truncateHash
89
, showHashValue
910
, readFileHashValue
@@ -51,6 +52,11 @@ instance Structured HashValue
5152
hashValue :: LBS.ByteString -> HashValue
5253
hashValue = HashValue . SHA256.hashlazy
5354

55+
-- From a base16 encoded Bytestring to a HashValue with `Base16`'s
56+
-- error passing through.
57+
hashValueFromHex :: BS.ByteString -> Either String HashValue
58+
hashValueFromHex bs = HashValue <$> Base16.decode bs
59+
5460
showHashValue :: HashValue -> String
5561
showHashValue (HashValue digest) = BS.unpack (Base16.encode digest)
5662

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{-# LANGUAGE DeriveGeneric #-}
2+
{-# LANGUAGE OverloadedStrings #-}
3+
4+
module Distribution.Client.HookAccept
5+
( HookAccept (..)
6+
, assertHookHash
7+
, loadHookHasheshMap
8+
, parseHooks
9+
) where
10+
11+
import Distribution.Client.Compat.Prelude
12+
13+
import Data.ByteString.Char8 (ByteString)
14+
import qualified Data.ByteString.Char8 as BS
15+
16+
import qualified Data.Map.Strict as Map
17+
18+
import Distribution.Client.Config (getConfigFilePath)
19+
import Distribution.Client.Errors (CabalInstallException (..))
20+
import Distribution.Client.HashValue (HashValue, hashValueFromHex, readFileHashValue, showHashValue)
21+
import Distribution.Simple.Setup (Flag (..))
22+
import Distribution.Simple.Utils (dieWithException)
23+
import Distribution.Verbosity (normal)
24+
25+
import System.FilePath (takeDirectory, (</>))
26+
27+
data HookAccept
28+
= AcceptAlways
29+
| AcceptHash HashValue
30+
deriving (Eq, Show, Generic)
31+
32+
instance Monoid HookAccept where
33+
mempty = AcceptAlways -- Should never be needed.
34+
mappend = (<>)
35+
36+
instance Semigroup HookAccept where
37+
AcceptAlways <> AcceptAlways = AcceptAlways
38+
AcceptAlways <> AcceptHash h = AcceptHash h
39+
AcceptHash h <> AcceptAlways = AcceptHash h
40+
AcceptHash h <> _ = AcceptHash h
41+
42+
instance Binary HookAccept
43+
instance Structured HookAccept
44+
45+
assertHookHash :: Map FilePath HookAccept -> FilePath -> IO ()
46+
assertHookHash m fpath = do
47+
actualHash <- readFileHashValue fpath
48+
hsPath <- getHooksSecurityFilePath NoFlag
49+
case Map.lookup fpath m of
50+
Nothing ->
51+
dieWithException normal $
52+
HookAcceptUnknown hsPath fpath (showHashValue actualHash)
53+
Just AcceptAlways -> pure ()
54+
Just (AcceptHash expectedHash) ->
55+
when (actualHash /= expectedHash) $
56+
dieWithException normal $
57+
HookAcceptHashMismatch
58+
hsPath
59+
fpath
60+
(showHashValue expectedHash)
61+
(showHashValue actualHash)
62+
63+
getHooksSecurityFilePath :: Flag FilePath -> IO FilePath
64+
getHooksSecurityFilePath configFileFlag = do
65+
hfpath <- getConfigFilePath configFileFlag
66+
pure $ takeDirectory hfpath </> "hooks-security"
67+
68+
loadHookHasheshMap :: Flag FilePath -> IO (Map FilePath HookAccept)
69+
loadHookHasheshMap configFileFlag = do
70+
hookFilePath <- getHooksSecurityFilePath configFileFlag
71+
handleNotExists $ fmap parseHooks (BS.readFile hookFilePath)
72+
where
73+
handleNotExists :: IO (Map FilePath HookAccept) -> IO (Map FilePath HookAccept)
74+
handleNotExists action = catchIO action $ \_ -> return mempty
75+
76+
parseHooks :: ByteString -> Map FilePath HookAccept
77+
parseHooks = Map.fromList . map parse . cleanUp . BS.lines
78+
where
79+
cleanUp :: [ByteString] -> [ByteString]
80+
cleanUp = filter (not . BS.null) . map rmComments
81+
82+
rmComments :: ByteString -> ByteString
83+
rmComments = fst . BS.breakSubstring "--"
84+
85+
parse :: ByteString -> (FilePath, HookAccept)
86+
parse bs =
87+
case BS.words bs of
88+
[fp, "AcceptAlways"] -> (BS.unpack fp, AcceptAlways)
89+
[fp, "AcceptHash"] -> buildAcceptHash fp "00"
90+
[fp, "AcceptHash", h] -> buildAcceptHash fp h
91+
_ -> error $ "Not able to parse:" ++ show bs
92+
where
93+
buildAcceptHash :: ByteString -> ByteString -> (FilePath, HookAccept)
94+
buildAcceptHash fp h =
95+
case hashValueFromHex h of
96+
Left err -> error $ "Distribution.Client.HookAccept.parse :" ++ err
97+
Right hv -> (BS.unpack fp, AcceptHash hv)

cabal-install/src/Distribution/Client/ProjectBuilding/UnpackedPackage.hs

+37-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ module Distribution.Client.ProjectBuilding.UnpackedPackage
3030
import Distribution.Client.Compat.Prelude
3131
import Prelude ()
3232

33+
import Distribution.Client.HookAccept (assertHookHash)
3334
import Distribution.Client.PackageHash (renderPackageHashInputs)
3435
import Distribution.Client.ProjectBuilding.Types
3536
import Distribution.Client.ProjectConfig
@@ -105,7 +106,7 @@ import qualified Data.ByteString.Lazy.Char8 as LBS.Char8
105106
import qualified Data.List.NonEmpty as NE
106107

107108
import Control.Exception (ErrorCall, Handler (..), SomeAsyncException, assert, catches, onException)
108-
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, removeFile)
109+
import System.Directory (canonicalizePath, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, removeFile)
109110
import System.FilePath (dropDrive, normalise, takeDirectory, (<.>), (</>))
110111
import System.IO (Handle, IOMode (AppendMode), withFile)
111112
import System.Semaphore (SemaphoreName (..))
@@ -697,7 +698,42 @@ buildAndInstallUnpackedPackage
697698
runConfigure
698699
PBBuildPhase{runBuild} -> do
699700
noticeProgress ProgressBuilding
701+
hooksDir <- (</> "cabalHooks") <$> getCurrentDirectory
702+
-- run preBuildHook. If it returns with 0, we assume the build was
703+
-- successful. If not, run the build.
704+
preBuildHookFile <- canonicalizePath (hooksDir </> "preBuildHook")
705+
assertHookHash (pkgConfigHookHashes pkgshared) preBuildHookFile
706+
preCode <-
707+
rawSystemExitCode
708+
verbosity
709+
(Just srcdir)
710+
preBuildHookFile
711+
[ (unUnitId $ installedUnitId rpkg)
712+
, (getSymbolicPath srcdir)
713+
, (getSymbolicPath builddir)
714+
]
715+
Nothing
716+
`catchIO` (\_ -> pure (ExitFailure 10))
717+
-- Regardless of whether the preBuildHook exists or not, or whether it returned an
718+
-- error or not, we want to run the build command.
719+
-- If the preBuildHook downloads a cached version of the build products, the following
720+
-- should be a NOOP.
700721
runBuild
722+
-- not sure, if we want to care about a failed postBuildHook?
723+
postBuildHookFile <- canonicalizePath (hooksDir </> "postBuildHook")
724+
assertHookHash (pkgConfigHookHashes pkgshared) postBuildHookFile
725+
void $
726+
rawSystemExitCode
727+
verbosity
728+
(Just srcdir)
729+
postBuildHookFile
730+
[ (unUnitId $ installedUnitId rpkg)
731+
, (getSymbolicPath srcdir)
732+
, (getSymbolicPath builddir)
733+
, show preCode
734+
]
735+
Nothing
736+
`catchIO` (\_ -> pure (ExitFailure 10))
701737
PBHaddockPhase{runHaddock} -> do
702738
noticeProgress ProgressHaddock
703739
runHaddock

cabal-install/src/Distribution/Client/ProjectConfig/Legacy.hs

+2
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,8 @@ convertLegacyAllPackageFlags globalFlags configFlags configExFlags installFlags
715715

716716
projectConfigPackageDBs = (fmap . fmap) (interpretPackageDB Nothing) projectConfigPackageDBs_
717717

718+
projectConfigHookHashes = mempty -- :: Map FilePath HookAccept
719+
718720
ConfigFlags
719721
{ configCommonFlags = commonFlags
720722
, configHcFlavor = projectConfigHcFlavor

cabal-install/src/Distribution/Client/ProjectConfig/Types.hs

+2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import qualified Data.ByteString.Char8 as BS
3030
import Distribution.Client.BuildReports.Types
3131
( ReportLevel (..)
3232
)
33+
import Distribution.Client.HookAccept (HookAccept (..))
3334
import Distribution.Client.Dependency.Types
3435
( PreSolver
3536
)
@@ -227,6 +228,7 @@ data ProjectConfigShared = ProjectConfigShared
227228
, projectConfigPreferOldest :: Flag PreferOldest
228229
, projectConfigProgPathExtra :: NubList FilePath
229230
, projectConfigMultiRepl :: Flag Bool
231+
, projectConfigHookHashes :: Map FilePath HookAccept
230232
-- More things that only make sense for manual mode, not --local mode
231233
-- too much control!
232234
-- projectConfigShadowPkgs :: Flag Bool,

cabal-install/src/Distribution/Client/ProjectOrchestration.hs

+8
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ import qualified Data.List.NonEmpty as NE
176176
import qualified Data.Map as Map
177177
import qualified Data.Set as Set
178178
import Distribution.Client.Errors
179+
import Distribution.Client.HookAccept (loadHookHasheshMap)
180+
179181
import Distribution.Package
180182
import Distribution.Simple.Command (commandShowOptions)
181183
import Distribution.Simple.Compiler
@@ -363,13 +365,16 @@ withInstallPlan
363365
, installedPackages
364366
}
365367
action = do
368+
hookHashes <- loadHookHasheshMap (projectConfigConfigFile $ projectConfigShared projectConfig)
369+
366370
-- Take the project configuration and make a plan for how to build
367371
-- everything in the project. This is independent of any specific targets
368372
-- the user has asked for.
369373
--
370374
(elaboratedPlan, _, elaboratedShared, _, _) <-
371375
rebuildInstallPlan
372376
verbosity
377+
hookHashes
373378
distDirLayout
374379
cabalDirLayout
375380
projectConfig
@@ -392,13 +397,16 @@ runProjectPreBuildPhase
392397
, installedPackages
393398
}
394399
selectPlanSubset = do
400+
hookHashes <- loadHookHasheshMap (projectConfigConfigFile $ projectConfigShared projectConfig)
401+
395402
-- Take the project configuration and make a plan for how to build
396403
-- everything in the project. This is independent of any specific targets
397404
-- the user has asked for.
398405
--
399406
(elaboratedPlan, _, elaboratedShared, _, _) <-
400407
rebuildInstallPlan
401408
verbosity
409+
hookHashes
402410
distDirLayout
403411
cabalDirLayout
404412
projectConfig

0 commit comments

Comments
 (0)