packages feed

ppad-censor-0.5.1: test-integration/Main.hs

{-# LANGUAGE BangPatterns #-}

-- End-to-end integration test for the dlopen runner path.
--
-- Compiles a small C shim exporting two @void(uint8_t *)@ targets
-- (one constant-time, one linear in @ws[0]@), loads it through
-- 'Censor.Runner.DL', and drives fix-vs-random on byte offset 0..32
-- through the sequential driver. Asserts Pass on the CT target and
-- Reject on the leaky one.

module Main where

import qualified Censor as C
import qualified Censor.FFI as F
import qualified Censor.Rng as R
import qualified Censor.Runner.DL as DL
import Data.Bits (xor)
import Data.Word (Word8, Word64)
import Foreign.Marshal.Alloc (mallocBytes)
import Foreign.Marshal.Utils (copyBytes)
import Foreign.Ptr (Ptr)
import System.Environment (lookupEnv)
import System.Info (os)
import System.Process (callProcess)
import Test.Tasty
import Test.Tasty.HUnit

-- inline shim source; the build produces a shared library from this
-- string at test startup, so the suite doesn't depend on any file
-- outside the cabal target.
shimSource :: String
shimSource = unlines
  [ "#include <stdint.h>"
  , ""
  , "void ct_countdown(uint8_t *ws) {"
  , "  volatile uint64_t sink = 0;"
  , "  uint64_t n = 25500;"
  , "  (void)ws;"
  , "  while (n--) sink += n;"
  , "}"
  , ""
  , "void leaky_countdown(uint8_t *ws) {"
  , "  volatile uint64_t sink = 0;"
  , "  uint64_t n = (uint64_t)ws[0] * 100;"
  , "  while (n--) sink += n;"
  , "}"
  ]

sharedExt :: String
sharedExt = case os of
  "darwin" -> "dylib"
  _        -> "so"

tmpDir :: IO FilePath
tmpDir = maybe "/tmp" id `fmap` lookupEnv "TMPDIR"

buildShim :: IO FilePath
buildShim = do
  dir <- tmpDir
  let src = dir ++ "/censor-integration-shim.c"
      lib = dir ++ "/censor-integration-shim." ++ sharedExt
  writeFile src shimSource
  callProcess "cc" ["-O2", "-shared", "-fPIC", "-o", lib, src]
  pure lib

main :: IO ()
main = do
  lib <- buildShim
  defaultMain (tests lib)

tests :: FilePath -> TestTree
tests lib = testGroup "censor-integration" [
    testCase "ct_countdown passes within budget" $
      runCase lib "ct_countdown" ExpectPass

  , testCase "leaky_countdown rejects within budget" $
      runCase lib "leaky_countdown" ExpectReject
  ]

data Expected = ExpectPass | ExpectReject

runCase :: FilePath -> String -> Expected -> Assertion
runCase lib sym expected = DL.withLibrary lib $ \h -> do
  target <- DL.resolveTarget h sym
  hyp    <- buildHyp target
  res    <- F.withFFIHypothesis hyp (C.runCT C.wallClock cfg)
  case (expected, res) of
    (ExpectPass, C.Pass{}) ->
      pure ()
    (ExpectReject, C.Reject{}) ->
      pure ()
    (ExpectPass, C.Reject { C.resPairs = n, C.resPeakLogW = w }) ->
      assertFailure $
        "CT target falsely rejected at n=" ++ show n
        ++ " logW=" ++ show w
    (ExpectReject, C.Pass { C.resPairs = n, C.resPeakLogW = w }) ->
      assertFailure $
        "leaky target not detected; passed at n=" ++ show n
        ++ " logW=" ++ show w
  where
    cfg = C.defaultConfig
      { C.cfgAlpha  = 1.0e-3
      , C.cfgBudget = 4000
      , C.cfgWarmup = 100
      , C.cfgBatch  = 1
      }

-- workspace layout matches the manual smoke: 64 bytes, --secret 0:32,
-- --public empty. class A pins bytes 0..32 to a shared random-seeded
-- fixed buffer; class B fills the whole workspace at random. so ws[0]
-- is constant across class-A pairs and uniform across class-B pairs.
wsBytes :: Int
wsBytes = 64

secretLen :: Int
secretLen = 32

buildHyp :: (Ptr Word8 -> IO ()) -> IO F.FFIHypothesis
buildHyp target = do
  let seed = 0xC0FFEE5EED0BEEF7 :: Word64
  fixSec <- mallocBytes wsBytes
  gFix <- R.mkRng seed
  F.fillRandom gFix fixSec wsBytes
  ga <- R.mkRng (seed `xor` 0x0A0A0A0A0A0A0A0A)
  gb <- R.mkRng (seed `xor` 0x0B0B0B0B0B0B0B0B)
  pure F.FFIHypothesis
    { F.ffiWorkspaceBytes = wsBytes
    , F.ffiPrepare = pure ()
    , F.ffiSampleA = \p -> do
        F.fillRandom ga p wsBytes
        copyBytes p fixSec secretLen
    , F.ffiSampleB = \p ->
        F.fillRandom gb p wsBytes
    , F.ffiTarget = target
    }