canontra-0.1.0.0: test/Canontra/SecuritySpec.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : Canontra.SecuritySpec
Description : Test suite for Air-Gapped Zero-Trust Security & Path Sandboxing (Phase 2).
Verifies:
1. Canonical Root Containment (canonicalizeSafePath rejects ../../etc/passwd and directory escapes).
2. Symlink Cycle Breaking (isSymlinkLoop detects cyclic directory references and avoids stack overflow).
3. Resource Ceilings (checkResourceBounds enforces <= 50MB file size and <= 64 directory recursion levels).
4. Repository Crawler Integration (discoverSourceFilesSafe skips ignored directories and enforces limits).
5. PagedCache CRC32 Page-Level Recovery (corrupted 4KB slab page discarded, valid pages preserved).
6. Gate 2 Security Boundary Exit Code 4 Verification.
-}
module Canontra.SecuritySpec (spec) where
import Data.Bits (shiftL, xor)
import qualified Data.ByteString as BS
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import System.Directory
( canonicalizePath
, createDirectoryIfMissing
, findExecutable
, getCurrentDirectory
, getTemporaryDirectory
, removeDirectoryRecursive
, removeFile
)
import System.Exit (ExitCode (..))
import System.FilePath ((</>), makeRelative)
import System.Process (readProcessWithExitCode)
import Test.Hspec
import Canontra.Cache.Common (MerkleCache (..), MerkleCacheEntry (..))
import Canontra.Cache.PagedCache
( decodeBinaryCacheV5
, decodeBinaryCacheV5Resilient
, decodeBinaryCacheV5WithRecovery
, encodeBinaryCacheV5
, readPagedCacheFile
, verifyHeaderCRC
, verifyPageCRC
, writePagedCacheFile
)
import Canontra.Repository.Repository
( discoverSourceFiles
, discoverSourceFilesSafe
)
import Canontra.Security.Path
( canonicalizeSafePath
, checkResourceBounds
, checkResourceBoundsWith
, isPathContained
, isSymlinkLoop
, maxFileSizeBytes
, maxRecursionDepth
, normalizePathUniversal
)
import Canontra.Types (Fingerprint (..), FingerprintBundle (..))
makeSampleBundle :: String -> FingerprintBundle
makeSampleBundle tag =
FingerprintBundle
(Fingerprint $ T.pack ("f0_" ++ tag))
(Fingerprint $ T.pack ("f1_" ++ tag))
(Fingerprint $ T.pack ("f2_" ++ tag))
(Fingerprint $ T.pack ("f3_" ++ tag))
(Fingerprint $ T.pack ("fcg_" ++ tag))
(Fingerprint $ T.pack ("fcf_" ++ tag))
(Fingerprint $ T.pack ("fdf_" ++ tag))
(Fingerprint $ T.pack ("ft_" ++ tag))
(Fingerprint $ T.pack ("f4_" ++ tag))
flipBitAt :: Int -> Int -> BS.ByteString -> BS.ByteString
flipBitAt byteIdx bitIdx bs
| byteIdx < 0 || byteIdx >= BS.length bs = bs
| otherwise =
let (pfx, sfx) = BS.splitAt byteIdx bs
targetByte = BS.head sfx
flippedByte = targetByte `xor` (1 `shiftL` (bitIdx `mod` 8))
rest = BS.tail sfx
in pfx <> BS.singleton flippedByte <> rest
spec :: Spec
spec = do
describe "Air-Gapped Zero-Trust Security & Path Sandboxing (Phase 2)" $ do
-- ========================================================================
-- 1. Canonical Root Containment & Directory Traversal Escapes
-- ========================================================================
describe "Canonical Root Containment (canonicalizeSafePath)" $ do
it "accepts paths strictly inside the root directory" $ do
cwd <- getCurrentDirectory
res <- canonicalizeSafePath cwd "src/Canontra/Types.hs"
case res of
Left err -> expectationFailure ("Expected safe path, got error: " ++ err)
Right safePath -> do
canonExpected <- canonicalizePath (cwd </> "src/Canontra/Types.hs")
safePath `shouldBe` canonExpected
it "accepts paths with internal . and .. references that do not escape root" $ do
cwd <- getCurrentDirectory
res <- canonicalizeSafePath cwd "src/../src/Canontra/Types.hs"
case res of
Left err -> expectationFailure ("Expected safe path, got error: " ++ err)
Right safePath -> do
canonExpected <- canonicalizePath (cwd </> "src/Canontra/Types.hs")
safePath `shouldBe` canonExpected
it "rejects directory traversal escape: ../../etc/passwd" $ do
cwd <- getCurrentDirectory
res <- canonicalizeSafePath cwd "../../etc/passwd"
case res of
Left err -> err `shouldContain` "Security violation: path traverses outside root directory"
Right path -> expectationFailure ("Expected escape rejection, but got: " ++ path)
it "rejects deep directory traversal escapes: ../../../../windows/system32" $ do
cwd <- getCurrentDirectory
res <- canonicalizeSafePath cwd "../../../../windows/system32"
case res of
Left err -> err `shouldContain` "Security violation"
Right path -> expectationFailure ("Expected escape rejection, but got: " ++ path)
it "rejects paths containing null bytes" $ do
cwd <- getCurrentDirectory
res <- canonicalizeSafePath cwd "src/foo\0bar.py"
case res of
Left err -> err `shouldContain` "null byte"
Right path -> expectationFailure ("Expected null byte rejection, but got: " ++ path)
it "rejects sibling directories with common prefix" $ do
tmpDir <- getTemporaryDirectory
let baseRoot = tmpDir </> "canontra_sec_root"
siblingDir = tmpDir </> "canontra_sec_root_other"
siblingFile = siblingDir </> "secret.py"
createDirectoryIfMissing True baseRoot
createDirectoryIfMissing True siblingDir
writeFile siblingFile "SECRET = 42\n"
res <- canonicalizeSafePath baseRoot siblingFile
case res of
Left err -> err `shouldContain` "Security violation"
Right path -> expectationFailure ("Expected sibling prefix rejection, but got: " ++ path)
removeFile siblingFile
removeDirectoryRecursive siblingDir
removeDirectoryRecursive baseRoot
it "isPathContained accurately evaluates path prefixes" $ do
isPathContained "C:/project" "C:/project/src/lib.py" `shouldBe` True
isPathContained "C:/project" "C:/project_other/lib.py" `shouldBe` False
isPathContained "/home/user/repo" "/home/user/repo/app.js" `shouldBe` True
isPathContained "/home/user/repo" "/etc/passwd" `shouldBe` False
-- ========================================================================
-- 2. Symlink Cycle Breaking
-- ========================================================================
describe "Symlink Cycle Breaker (isSymlinkLoop)" $ do
it "returns loop=False for the initial visit to a directory" $ do
cwd <- getCurrentDirectory
(isLoop, visited1) <- isSymlinkLoop Set.empty cwd
isLoop `shouldBe` False
Set.size visited1 `shouldBe` 1
it "returns loop=True when revisiting an already tracked directory" $ do
cwd <- getCurrentDirectory
(_, visited1) <- isSymlinkLoop Set.empty cwd
(isLoop2, visited2) <- isSymlinkLoop visited1 cwd
isLoop2 `shouldBe` True
Set.size visited2 `shouldBe` 1
it "tracks multiple distinct directories without false positives" $ do
cwd <- getCurrentDirectory
let sub1 = cwd </> "src"
sub2 = cwd </> "test"
(_, v1) <- isSymlinkLoop Set.empty sub1
(loop2, v2) <- isSymlinkLoop v1 sub2
loop2 `shouldBe` False
Set.size v2 `shouldBe` 2
(loop3, _) <- isSymlinkLoop v2 sub1
loop3 `shouldBe` True
-- ========================================================================
-- 3. Resource Ceilings (File Size & Recursion Depth)
-- ========================================================================
describe "Resource Ceilings (checkResourceBounds)" $ do
it "enforces constants: 50MB file size ceiling and 64-level directory depth" $ do
maxFileSizeBytes `shouldBe` 52428800
maxRecursionDepth `shouldBe` 64
it "accepts standard repository files within 50MB" $ do
res <- checkResourceBounds "canontra.cabal"
res `shouldBe` Right ()
it "rejects files exceeding configured size ceiling" $ do
tmpDir <- getTemporaryDirectory
let testFile = tmpDir </> "canontra_oversized.bin"
BS.writeFile testFile (BS.replicate 200 0x41) -- 200 bytes
-- Verify with 100-byte ceiling
res <- checkResourceBoundsWith 100 64 testFile
case res of
Left err -> err `shouldContain` "Resource limit exceeded: file size"
Right () -> expectationFailure "Expected file size ceiling rejection"
removeFile testFile
it "rejects directory paths exceeding 64 recursion levels" $ do
let deepPath = concat (replicate 68 "nested/") ++ "file.py"
res <- checkResourceBounds deepPath
case res of
Left err -> err `shouldContain` "Resource limit exceeded: directory nesting depth (69) exceeds ceiling of 64"
Right () -> expectationFailure "Expected directory depth ceiling rejection"
it "accepts directory paths within 64 recursion levels" $ do
let safePath = concat (replicate 20 "nested/") ++ "file.py"
res <- checkResourceBounds safePath
res `shouldBe` Right ()
-- ========================================================================
-- 4. Repository Crawler Integration
-- ========================================================================
describe "Repository Crawler Integration (discoverSourceFilesSafe)" $ do
it "skips standard ignored directories (.git, node_modules, .venv, .stack-work, .canontra)" $ do
tmpDir <- getTemporaryDirectory
let repoRoot = tmpDir </> "canontra_crawler_test"
gitDir = repoRoot </> ".git"
nodeDir = repoRoot </> "node_modules"
srcDir = repoRoot </> "src"
goodFile = srcDir </> "main.py"
gitFile = gitDir </> "config.py"
nodeFile = nodeDir </> "pkg.js"
createDirectoryIfMissing True srcDir
createDirectoryIfMissing True gitDir
createDirectoryIfMissing True nodeDir
writeFile goodFile "print('hello')\n"
writeFile gitFile "print('git')\n"
writeFile nodeFile "console.log('node');\n"
files <- discoverSourceFiles repoRoot
let normFiles = map (normalizePathUniversal . makeRelative repoRoot) files
normFiles `shouldContain` ["src/main.py"]
normFiles `shouldNotContain` [".git/config.py"]
normFiles `shouldNotContain` ["node_modules/pkg.js"]
removeDirectoryRecursive repoRoot
it "rejects repository path with null bytes safely" $ do
res <- discoverSourceFilesSafe "repo\0bad"
case res of
Left err -> err `shouldContain` "null byte"
Right _ -> expectationFailure "Expected null byte rejection"
-- ========================================================================
-- 5. PagedCache CRC32 Page-Level Recovery
-- ========================================================================
describe "PagedCache Page-Level CRC32 Recovery" $ do
it "discards only corrupted 4KB slab page and preserves valid pages with recovery" $ do
-- Build a 15-entry cache spanning exactly 2 slab pages (14 records on page 1, 1 record on page 2)
let entries = [ ("src/file" ++ show i ++ ".py", 100 + fromIntegral i, 1000, "f" ++ show i)
| i <- [1..15 :: Int]
]
entryMap = Map.fromList [ (p, MerkleCacheEntry sz mt (makeSampleBundle tag))
| (p, sz, mt, tag) <- entries
]
cache15 = MerkleCache entryMap
bin15 = encodeBinaryCacheV5 cache15
verifyHeaderCRC bin15 `shouldBe` True
verifyPageCRC bin15 1 `shouldBe` True
verifyPageCRC bin15 2 `shouldBe` True
-- Inject bit flip into Page 1 record data (offset 4096 + 64)
let corruptedBin = flipBitAt (4096 + 64) 2 bin15
verifyHeaderCRC corruptedBin `shouldBe` True
verifyPageCRC corruptedBin 1 `shouldBe` False -- Page 1 corrupted!
verifyPageCRC corruptedBin 2 `shouldBe` True -- Page 2 intact!
-- Strict decodeBinaryCacheV5 rejects entire cache as expected by legacy contract
decodeBinaryCacheV5 corruptedBin `shouldBe` Nothing
-- Resilient decodeBinaryCacheV5WithRecovery recovers Page 2 records and reports Page 1 corruption!
let (mRecovered, corruptedPages) = decodeBinaryCacheV5WithRecovery corruptedBin
corruptedPages `shouldBe` [1]
case mRecovered of
Nothing -> expectationFailure "Expected successful page-level recovery"
Just (MerkleCache recMap) -> do
-- Page 1 (records 1..14) was discarded; Page 2 (record 15) was preserved!
Map.size recMap `shouldBe` 1
let recoveredPath = head (Map.keys recMap)
let allPaths = [p | (p, _, _, _) <- entries]
recoveredPath `shouldSatisfy` (`elem` allPaths)
-- decodeBinaryCacheV5Resilient also succeeds and recovers without throwing
mResilient <- decodeBinaryCacheV5Resilient corruptedBin
case mResilient of
Nothing -> expectationFailure "Expected resilient recovery"
Just (MerkleCache resMap) -> do
Map.size resMap `shouldBe` 1
let resPath = head (Map.keys resMap)
let allPaths = [p | (p, _, _, _) <- entries]
resPath `shouldSatisfy` (`elem` allPaths)
it "readPagedCacheFile transparently recovers uncorrupted records on slab bit-rot" $ do
tmpDir <- getTemporaryDirectory
let cacheDir = tmpDir </> "canontra_recovery_disk_test"
cachePath = cacheDir </> "cache.bin"
createDirectoryIfMissing True cacheDir
let entries = [ ("pkg/module" ++ show i ++ ".go", 200 + fromIntegral i, 2000, "m" ++ show i)
| i <- [1..15 :: Int]
]
entryMap = Map.fromList [ (p, MerkleCacheEntry sz mt (makeSampleBundle tag))
| (p, sz, mt, tag) <- entries
]
cache15 = MerkleCache entryMap
writePagedCacheFile cachePath cache15
-- Corrupt Page 1 on disk
rawBytes <- BS.readFile cachePath
let corruptedOnDisk = flipBitAt (4096 + 70) 1 rawBytes
BS.writeFile cachePath corruptedOnDisk
-- readPagedCacheFile should recover valid Page 2 records instead of terminating
recoveredCache <- readPagedCacheFile cachePath
let (MerkleCache recMap) = recoveredCache
Map.size recMap `shouldBe` 1
let recoveredPath = head (Map.keys recMap)
let allPaths = [p | (p, _, _, _) <- entries]
recoveredPath `shouldSatisfy` (`elem` allPaths)
removeDirectoryRecursive cacheDir
-- ========================================================================
-- 6. Gate 2 Security Boundary Exit Code 4 Verification
-- ========================================================================
describe "Gate 2 Security Boundary Verification" $ do
it "exits with Exit Code 4 on directory traversal escape (../../etc/passwd)" $ do
mExe <- findExecutable "canontra"
case mExe of
Nothing -> do
-- If not installed in PATH, execute via stack exec
(exitCode, _, errOut) <- readProcessWithExitCode "stack" ["exec", "--", "canontra", "fp", "../../etc/passwd"] ""
exitCode `shouldBe` ExitFailure 4
errOut `shouldContain` "CANONTRA SECURITY BOUNDARY VIOLATION"
Just exePath -> do
(exitCode, _, errOut) <- readProcessWithExitCode exePath ["fp", "../../etc/passwd"] ""
exitCode `shouldBe` ExitFailure 4
errOut `shouldContain` "CANONTRA SECURITY BOUNDARY VIOLATION"
it "exits with Exit Code 4 when target file does not exist" $ do
mExe <- findExecutable "canontra"
case mExe of
Nothing -> do
(exitCode, _, errOut) <- readProcessWithExitCode "stack" ["exec", "--", "canontra", "fp", "non_existent_source_file_98765.py"] ""
exitCode `shouldBe` ExitFailure 4
errOut `shouldContain` "CANONTRA I/O ERROR"
Just exePath -> do
(exitCode, _, errOut) <- readProcessWithExitCode exePath ["fp", "non_existent_source_file_98765.py"] ""
exitCode `shouldBe` ExitFailure 4
errOut `shouldContain` "CANONTRA I/O ERROR"