packages feed

baikai-kit-0.2.0.0: src/Baikai/Kit/Path.hs

module Baikai.Kit.Path
  ( safeItemName,
    safeRelativePath,
    safeSourcePath,
  )
where

import Baikai.Kit.Error (KitError (..))
import Baikai.Prelude
import Control.Exception (IOException, try)
import Data.List (isPrefixOf)
import Data.Text qualified as Text
import System.Directory (canonicalizePath, doesFileExist, doesPathExist, pathIsSymbolicLink)
import System.FilePath (isAbsolute, normalise, splitDirectories, (</>))

safeRelativePath :: Text -> Either Text FilePath
safeRelativePath input
  | Text.null input = Left "path is empty"
  | Text.any (== '\0') input = Left "path contains a NUL byte"
  | Text.any (== '\\') input = Left "path contains a backslash"
  | isAbsolute raw = Left "path is absolute"
  | ".." `elem` components = Left "path contains a parent-directory component"
  | otherwise = Right normalised
  where
    raw = Text.unpack input
    normalised = normalise raw
    components = splitDirectories normalised

safeItemName :: Text -> Either Text FilePath
safeItemName input
  | Text.any (== '/') input = Left "name must be a single path segment"
  | otherwise = do
      name <- safeRelativePath input
      validateName name
  where
    validateName name
      | name == "." = Left "name cannot be '.'"
      | name == ".." = Left "name cannot be '..'"
      | "." `Text.isPrefixOf` Text.pack name = Left "name cannot start with '.'"
      | length (splitDirectories name) /= 1 = Left "name must be a single path segment"
      | otherwise = Right name

-- | Resolve an untrusted relative source path below a trusted kit
--   checkout.
--
--   Runs 'safeRelativePath' on the relative path, then walks every prefix
--   of it below @root@: a prefix that does not exist is
--   'KitSourceMissing', a prefix that is a symbolic link
--   ('pathIsSymbolicLink') is 'KitSourceSymlink'. Finally the canonical
--   form of the full path must lie component-wise below the canonical
--   form of @root@ ('canonicalizePath' on both, compared with
--   'splitDirectories'), else 'KitSourceEscapes', and the full path must
--   be a regular file ('doesFileExist'), else 'KitSourceMissing'. Any
--   'IOException' raised while inspecting a prefix is
--   'KitSourceUnreadable'. Returns @root '</>' rel@.
--
--   Check-then-read: the caller reads the returned path afterwards; a
--   writer to the checkout could swap a file for a link in between, which
--   is accepted because the checkout is owned by the invoking user and
--   written only by git before this runs.
safeSourcePath :: FilePath -> FilePath -> IO (Either KitError FilePath)
safeSourcePath root rel =
  case safeRelativePath (Text.pack rel) of
    Left reason -> pure (Left (KitUnsafePath (Text.pack rel) reason))
    Right validated -> do
      let full = root </> validated
          components = filter (/= ".") (splitDirectories validated)
      outcome <- try @IOException (inspect root full components)
      pure (either (Left . KitSourceUnreadable full . Text.pack . show) id outcome)
  where
    inspect base full components = do
      walked <- walkPrefixes base components
      case walked of
        Just err -> pure (Left err)
        Nothing -> do
          canonRoot <- canonicalizePath base
          canonFull <- canonicalizePath full
          if not (splitDirectories canonRoot `isPrefixOf` splitDirectories canonFull)
            then pure (Left (KitSourceEscapes canonFull canonRoot))
            else do
              isFile <- doesFileExist full
              pure (if isFile then Right full else Left (KitSourceMissing full))

    walkPrefixes _ [] = pure Nothing
    walkPrefixes base (component : rest) = do
      let here = base </> component
      -- 'doesPathExist' is False for a dangling link, which refuses the
      -- path without following it; 'pathIsSymbolicLink' throws when the
      -- path is absent, so the existence check comes first.
      exists <- doesPathExist here
      if not exists
        then pure (Just (KitSourceMissing here))
        else do
          isLink <- pathIsSymbolicLink here
          if isLink
            then pure (Just (KitSourceSymlink here))
            else walkPrefixes here rest