canontra-0.1.0.0: src/Canontra/Security/Path.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : Canontra.Security.Path
Description : Air-gapped zero-trust path sandboxing, symlink cycle breaking, and resource ceilings.
Provides pure Haskell, platform-invariant path containment and security boundaries:
- Canonical root containment to prevent directory traversal escapes (../../etc/passwd).
- Visited (DeviceID, FileID) pair tracking to break recursive symlink / junction loops without stack overflow.
- Resource ceiling enforcement: file size ceiling (50 MB) and directory recursion depth limit (<= 64 levels).
-}
module Canontra.Security.Path
( DeviceID
, FileID
, maxFileSizeBytes
, maxRecursionDepth
, canonicalizeSafePath
, isSymlinkLoop
, checkResourceBounds
, checkResourceBoundsWith
, isPathContained
, normalizePathUniversal
) where
import Control.Exception (IOException, try)
import Data.Char (toLower)
import Data.List (isPrefixOf)
import Data.Set (Set)
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word (Word64)
import System.Directory (canonicalizePath, doesFileExist, getFileSize)
import System.FilePath (isRelative, splitDirectories, takeDrive, (</>))
import Canontra.Cache.Common (fastPathHash64)
-- | 64-bit Device/Volume identifier.
type DeviceID = Word64
-- | 64-bit File/Inode identifier.
type FileID = Word64
-- | Maximum file size ceiling: 50 MB (52,428,800 bytes).
maxFileSizeBytes :: Integer
maxFileSizeBytes = 50 * 1024 * 1024
-- | Maximum directory recursion nesting depth ceiling: 64 levels.
maxRecursionDepth :: Int
maxRecursionDepth = 64
-- | Universal cross-platform path normalization (forward slashes + lowercasing).
normalizePathUniversal :: FilePath -> FilePath
normalizePathUniversal = map (\c -> if c == '\\' then '/' else toLower c)
-- | Remove any non-root trailing slash from a normalized path.
stripTrailingSlash :: FilePath -> FilePath
stripTrailingSlash p
| p == "/" = "/"
| length p == 3 && p !! 1 == ':' && p !! 2 == '/' = p -- e.g. "c:/"
| not (null p) && last p == '/' = init p
| otherwise = p
-- | Pure check whether a candidate canonical path is strictly contained within a root canonical path.
isPathContained :: FilePath -> FilePath -> Bool
isPathContained rootPath candidatePath =
let !normRoot = stripTrailingSlash (normalizePathUniversal rootPath)
!normCand = stripTrailingSlash (normalizePathUniversal candidatePath)
!prefix = if normRoot == "/" || (length normRoot == 3 && normRoot !! 1 == ':' && normRoot !! 2 == '/')
then normRoot
else normRoot ++ "/"
in normCand == normRoot || prefix `isPrefixOf` normCand
-- | Verifies that a resolved file path resides strictly within the specified root directory.
-- Returns 'Right canonicalPath' on success or 'Left errorMessage' on directory traversal escape.
canonicalizeSafePath :: FilePath -> FilePath -> IO (Either String FilePath)
canonicalizeSafePath rootDir candidatePath
| any (== '\0') rootDir = pure (Left "Security violation: root directory path contains null byte")
| any (== '\0') candidatePath = pure (Left "Security violation: candidate path contains null byte")
| otherwise = do
eRoot <- try (canonicalizePath rootDir) :: IO (Either IOException FilePath)
case eRoot of
Left err -> pure (Left ("Security violation: failed to resolve root directory: " ++ show err))
Right canonRoot -> do
let targetPath = if isRelative candidatePath
then rootDir </> candidatePath
else candidatePath
eCand <- try (canonicalizePath targetPath) :: IO (Either IOException FilePath)
case eCand of
Left err -> pure (Left ("Security violation: failed to resolve candidate path: " ++ show err))
Right canonCand ->
if isPathContained canonRoot canonCand
then pure (Right canonCand)
else pure (Left ("Security violation: path traverses outside root directory: "
++ candidatePath ++ " (resolved to " ++ canonCand
++ ", root directory is " ++ canonRoot ++ ")"))
-- | Detects whether a directory has already been visited in the traversal chain, breaking symlink cycles.
isSymlinkLoop :: Set (DeviceID, FileID) -> FilePath -> IO (Bool, Set (DeviceID, FileID))
isSymlinkLoop visited dir = do
eCanon <- try (canonicalizePath dir) :: IO (Either IOException FilePath)
case eCanon of
Left _ -> pure (True, visited) -- Treat unresolvable/recursive loop as loop
Right canonDir -> do
let !norm = stripTrailingSlash (normalizePathUniversal canonDir)
!drive = takeDrive norm
!devId = fastPathHash64 (TE.encodeUtf8 (T.pack drive))
!fileId = fastPathHash64 (TE.encodeUtf8 (T.pack norm))
!pair = (devId, fileId)
if Set.member pair visited
then pure (True, visited)
else pure (False, Set.insert pair visited)
-- | Verifies resource bounds: file size <= 50MB and directory nesting depth <= 64.
checkResourceBounds :: FilePath -> IO (Either String ())
checkResourceBounds = checkResourceBoundsWith maxFileSizeBytes maxRecursionDepth
-- | Parameterized resource bound verification.
checkResourceBoundsWith :: Integer -> Int -> FilePath -> IO (Either String ())
checkResourceBoundsWith maxBytes maxDepth path = do
let !normalized = map (\c -> if c == '\\' then '/' else c) path
!comps = filter (\c -> not (null c) && c /= "." && c /= "/") (splitDirectories normalized)
!depth = length comps
if depth > maxDepth
then pure (Left ("Resource limit exceeded: directory nesting depth ("
++ show depth ++ ") exceeds ceiling of " ++ show maxDepth))
else do
isFile <- doesFileExist path
if isFile
then do
sz <- getFileSize path
if sz > maxBytes
then pure (Left ("Resource limit exceeded: file size ("
++ show sz ++ " bytes) exceeds ceiling of "
++ show maxBytes ++ " bytes (50MB)"))
else pure (Right ())
else pure (Right ())