landlock-0.2.1.0: test/landlock-test.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Main (main) where
import Control.Concurrent.Async (withAsync)
import Control.Exception.Base (handleJust)
import Control.Monad (unless)
import Data.List (nub, sort, (\\))
import Data.Proxy (Proxy (Proxy))
import System.Environment (lookupEnv)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (IOMode (..), withFile)
import System.IO.Error (isPermissionError)
import System.Landlock
( AccessFsFlag (..),
OpenPathFlags (..),
RulesetAttr (..),
abiVersion,
accessFsFlags,
defaultOpenPathFlags,
isSupported,
landlock,
version1,
version2,
version3,
withOpenPath,
)
import System.Landlock.Flags
( CreateRulesetFlag,
accessFsFlagToBit,
createRulesetFlagToBit,
)
import System.Landlock.Rules (Rule, RuleType (..), pathBeneath)
import System.Landlock.Syscalls (LandlockRulesetAttr (..))
import System.Landlock.Version (Version (..))
import System.Posix.Types (Fd)
import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)
import Test.QuickCheck ((=/=), (===))
import Test.QuickCheck.Classes.Base
( Laws (..),
boundedEnumLaws,
eqLaws,
ordLaws,
showLaws,
storableLaws,
)
import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, testCaseSteps, (@=?), (@?), (@?=))
import Test.Tasty.QuickCheck (Arbitrary (..), arbitraryBoundedEnum, testProperty)
import ThreadedScenario (scenario)
-- This test-suite is a bit "weird". We want to test various privilege-related
-- functions. Now, whenever we drop some privileges, we can't (and shouldn't be
-- able to) regain these later. Hence, all tests which drop privileges *must
-- run in a different process*. This sounds simple: just `fork`, run the test
-- in the subprocess, and wait for it to exit. However, this doesn't work that
-- well in a GHC world, where `forkProcess` should always immediately `exec`
-- something else: running Haskell code after `forkProcess` can lock up
-- indefinitely.
-- So, instead of using a simple `fork`, this test executable is used in two
-- ways: either as-is, in which case Tasty is used to run a bunch of tests,
-- or with the `LANDLOCK_TEST` environment variable set. If the latter is
-- set, the Tasty test-suite won't be executed, but instead a specific test
-- will run. This way, the Tasty test-suite can run this very same binary
-- in a different environment to run test scenarios.
landlockTestEnvironmentVariable :: String
landlockTestEnvironmentVariable = "LANDLOCK_TEST"
main :: IO ()
main =
lookupEnv "LANDLOCK_TEST" >>= \case
Nothing -> do
defaultMain tests
Just testName -> case lookup testName functionalTestCases of
Nothing -> fail $ "Unknown test: " ++ testName
Just act -> act
tests :: TestTree
tests =
testGroup
"Tests"
[ properties,
unitTests,
functionalTests,
scenario withAsync
]
properties :: TestTree
properties =
testGroup
"Properties"
[ testGroup "LandlockRulesetAttr" $
map
lawsToTestTree
[ eqLaws (Proxy @LandlockRulesetAttr),
showLaws (Proxy @LandlockRulesetAttr),
storableLaws (Proxy @LandlockRulesetAttr)
],
testGroup "RulesetAttr" $
map
lawsToTestTree
[ eqLaws (Proxy @RulesetAttr),
showLaws (Proxy @RulesetAttr)
],
testGroup "CreateRulesetFlag" $
map
lawsToTestTree
[ eqLaws (Proxy @CreateRulesetFlag),
showLaws (Proxy @CreateRulesetFlag)
-- boundedEnum laws don't work nicely with single-constructor enums
-- , boundedEnumLaws (Proxy @CreateRulesetFlag)
],
testGroup "AccessFsFlag" $
map
lawsToTestTree
[ eqLaws (Proxy @AccessFsFlag),
showLaws (Proxy @AccessFsFlag),
boundedEnumLaws (Proxy @AccessFsFlag),
ordLaws (Proxy @AccessFsFlag)
],
testGroup "OpenPathFlags" $
map
lawsToTestTree
[ eqLaws (Proxy @OpenPathFlags),
showLaws (Proxy @OpenPathFlags)
],
testGroup "Rule 'PathBeneath" $
map
lawsToTestTree
[ eqLaws (Proxy @(Rule 'PathBeneath)),
showLaws (Proxy @(Rule 'PathBeneath)),
storableLaws (Proxy @(Rule 'PathBeneath))
],
testGroup "Version" $
map
lawsToTestTree
[ eqLaws (Proxy @Version),
showLaws (Proxy @Version),
ordLaws (Proxy @Version)
],
testProperty "accessFsFlagToBit is different for different flags" $
mapEq accessFsFlagToBit,
testProperty "createRulesetFlagToBit is different for different flags" $
mapEq createRulesetFlagToBit
]
where
lawsToTestTree laws = testGroup (lawsTypeclass laws) $ flip map (lawsProperties laws) $ uncurry testProperty
mapEq fn a b =
let cmp = if a == b then (===) else (=/=)
in fn a `cmp` fn b
instance Arbitrary Version where
arbitrary = Version <$> arbitrary
instance Arbitrary OpenPathFlags where
arbitrary = OpenPathFlags <$> arbitrary <*> arbitrary <*> arbitrary
instance Arbitrary RulesetAttr where
arbitrary = RulesetAttr <$> arbitrary
instance Arbitrary CreateRulesetFlag where
arbitrary = arbitraryBoundedEnum
instance Arbitrary LandlockRulesetAttr where
arbitrary = LandlockRulesetAttr <$> arbitrary
instance Arbitrary (Rule 'PathBeneath) where
arbitrary =
pathBeneath
<$> fmap (fromIntegral :: Int -> Fd) arbitrary
<*> fmap (nub . sort) arbitrary
instance Arbitrary AccessFsFlag where
arbitrary = arbitraryBoundedEnum
unitTests :: TestTree
unitTests =
testGroup
"Unit Tests"
[ testCase "lookup version1 accessFsFlags" $
lookup version1 accessFsFlags @?= Just [AccessFsExecute .. AccessFsMakeSym],
testCase "lookup version2 accessFsFlags" $
lookup version2 accessFsFlags @?= Just [AccessFsExecute .. AccessFsRefer],
testCase "lookup version3 accessFsFlags" $
lookup version3 accessFsFlags @?= Just [AccessFsExecute .. AccessFsTruncate],
testCase "ABI v2 introduced [AccessFsRefer]" $
(\\)
<$> lookup version2 accessFsFlags
<*> lookup version1 accessFsFlags
@?= Just [AccessFsRefer],
testCase "ABI v3 introduced [AccessFsTruncate]" $
(\\)
<$> lookup version3 accessFsFlags
<*> lookup version2 accessFsFlags
@?= Just [AccessFsTruncate],
testCase "accessFsFlagToBit is unique for all AccessFsFlags" $
length (nub (sort (map accessFsFlagToBit [minBound .. maxBound])))
@?= length [minBound :: AccessFsFlag .. maxBound],
testCase "createRulesetFlagToBit is unique for all CreateRulesetFlags" $
length (nub (sort (map createRulesetFlagToBit [minBound .. maxBound])))
@?= length [minBound :: CreateRulesetFlag .. maxBound]
]
functionalTests :: TestTree
functionalTests =
testGroup "Functional Tests" $
[ testCase "abiVersion >= 1" $ do
v <- abiVersion
v >= version1 @? "Unexpected version",
testCase "abiVersion is idempotent" $ do
v1 <- abiVersion
v2 <- abiVersion
v1 @=? v2,
testCase "isSupported" $
isSupported >>= assertBool "landlock API not supported"
]
++ map (\(name, _) -> testCaseSteps name (`runFunctionalTest` name)) functionalTestCases
where
runFunctionalTest step name = do
step "Running test subprocess"
(rc, stdout, stderr) <- readCreateProcessWithExitCode (mkCreateProcess name) ""
step $ "Test subprocess exited with " ++ show rc
unless (null stdout) $
step $
"Test subprocess stdout:\n" ++ stdout
unless (null stderr) $
step $
"Test subprocess stderr:\n" ++ stderr
rc @?= ExitSuccess
mkCreateProcess name =
(proc "/proc/self/exe" [])
{ env = Just [(landlockTestEnvironmentVariable, name)],
close_fds = True
}
functionalTestCases :: [(String, IO ())]
functionalTestCases =
[ ("All v1 restrictions in sandbox", testAllV1Restrictions),
("Restrict read, except for /etc", testRestrictReadExceptEtc)
]
testAllV1Restrictions :: IO ()
testAllV1Restrictions = do
let fn = "/etc/resolv.conf"
try act = withFile fn ReadMode act
-- First, try to open as-is
try (\_ -> return ())
-- Then, sandbox and try again
v1Restrictions <- case lookup version1 accessFsFlags of
Nothing -> assertFailure $ "Unknown ABI version: " ++ show version1
Just r -> return r
landlock (RulesetAttr v1Restrictions) [] [] $ \_ -> return ()
catchPermissionDenied $ try $ \_ -> fail $ "Still able to open " ++ fn
testRestrictReadExceptEtc :: IO ()
testRestrictReadExceptEtc = do
let dir = "/etc"
file = dir </> "passwd"
act = withFile file ReadMode $ \_ -> return ()
v1Restrictions <- case lookup version1 accessFsFlags of
Nothing -> assertFailure $ "Unknown ABI version: " ++ show version1
Just r -> return r
act
landlock (RulesetAttr v1Restrictions) [] [] $ \addRule -> do
withOpenPath dir defaultOpenPathFlags {directory = True} $ \fd -> do
addRule (pathBeneath fd [AccessFsReadFile, AccessFsReadDir, AccessFsExecute]) []
act
catchPermissionDenied :: IO () -> IO ()
catchPermissionDenied = handleJust (\exc -> if isPermissionError exc then Just () else Nothing) return