packages feed

landlock-0.1.0.0: test/Main.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

{-# OPTIONS_GHC -Wno-orphans #-}

module Main (main) where

import Control.Exception.Base (handleJust)
import Control.Monad (unless)
import Data.List (nub, sort)
import Data.Proxy (Proxy(Proxy))
import Foreign.Marshal.Alloc (alloca)
import Foreign.Storable (Storable, peek, poke)
import System.Environment (lookupEnv)
import System.Exit (ExitCode(..))
import System.FilePath ((</>))
import System.IO (IOMode(..), withFile)
import System.IO.Error (isPermissionError)
import System.Posix.Types (Fd)
import System.Process (CreateProcess(..), proc, readCreateProcessWithExitCode)

import Test.QuickCheck.Monadic (assert, monadicIO, run)
import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.ExpectedFailure (expectFailBecause)
import Test.Tasty.HUnit ((@?), (@=?), (@?=), testCase, testCaseSteps)
import Test.Tasty.QuickCheck as QC

import System.Landlock (AccessFsFlag(..), RulesetAttr(..), OpenPathFlags(..), abiVersion, accessFsFlags, defaultOpenPathFlags, isSupported, landlock, version1, withOpenPath)
import System.Landlock.Rules (Rule, RuleType(..), pathBeneath)
import System.Landlock.Syscalls (LandlockRulesetAttr(..))

-- 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
        hasLandlock <- isSupported
        defaultMain (tests hasLandlock)
    Just testName -> case lookup testName functionalTestCases of
        Nothing -> fail $ "Unknown test: " ++ testName
        Just act -> act

tests :: Bool -> TestTree
tests hasLandlock = testGroup "Tests" [
      properties
    , (if hasLandlock then id else expectFailBecause "Landlock not supported") functionalTests
    ]

properties :: TestTree
properties = testGroup "Properties" [
      storable "LandlockRulesetAttr" (Proxy @LandlockRulesetAttr)
    , storable "Rule 'PathBeneath" (Proxy @(Rule 'PathBeneath))
    ]

storable :: forall proxy a. (Eq a, Show a, Arbitrary a, Storable a) => String -> proxy a -> TestTree
storable name _ = testGroup ("Storable for " ++ name) [
      QC.testProperty "peek . poke == id" $
          \a -> monadicIO $ do
              a' <- run $ alloca $ \ptr -> do
                  poke ptr (a :: a)
                  peek ptr
              assert $ a' == a
    ]

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


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
    ] ++ 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
    let Just v1Restrictions = lookup version1 accessFsFlags
    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 ()
        Just v1Restrictions = lookup version1 accessFsFlags

    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