packages feed

seihou-core-0.16.0.0: src/Seihou/Core/Path.hs

module Seihou.Core.Path
  ( validateProjectRelativePath,
    validateCanonicalProjectPath,
    pathsHaveConflictingSpelling,

    -- * Seihou and Git control paths
    ProjectPathClass (..),
    PathOperand (..),
    classifyProjectPath,
    reservedSeihouEntries,
    isControlFileTarget,
    isControlDirectory,
    controlPathReason,
    ownStateDirectoryViolation,
  )
where

import Data.Maybe (isJust)
import Data.Text qualified as T
import Seihou.Prelude
import System.FilePath qualified as FilePath
import System.FilePath.Windows qualified as WindowsPath

-- | Validate that a path stays within a project root when later appended to it.
-- The path must be non-empty, relative on POSIX and Windows, and must not
-- contain a parent-directory segment.
validateProjectRelativePath :: Text -> Either Text FilePath
validateProjectRelativePath rawPath
  | T.null path = Left "path must not be empty"
  | FilePath.isAbsolute pathString || WindowsPath.isAbsolute pathString =
      Left ("path must be relative: " <> path)
  | any (== "..") (pathSegments path) =
      Left ("path must not contain '..' segment: " <> path)
  | otherwise = Right pathString
  where
    path = T.strip rawPath
    pathString = T.unpack path

pathSegments :: Text -> [Text]
pathSegments =
  filter (not . T.null)
    . T.split (\c -> c == '/' || c == '\\')

-- | What a validated project-relative path names, for the rules that keep
-- module output away from Seihou's and Git's own data.
--
-- A module owns exactly one directory under @.seihou/@, named after itself;
-- everything on 'reservedSeihouEntries' belongs to Seihou. See
-- docs/adr/0025-a-module-owns-only-its-own-directory-under-seihou.md.
data ProjectPathClass
  = -- | An ordinary project path, outside @.seihou@ and @.git@.
    OrdinaryProjectPath
  | -- | The @.seihou@ directory itself: creatable as a directory, never a file.
    SeihouRootDirectory
  | -- | Under @.seihou/<area>@, where @<area>@ (as written) is not Seihou's own.
    -- The 'Bool' says whether the path continues below the area directory.
    ModuleStatePath !Text !Bool
  | -- | Seihou's own state or Git's; the 'Text' is the user-facing reason.
    ControlPath !Text
  deriving stock (Eq, Show, Generic)

-- | Whether a path names a file to write or a directory to create.
data PathOperand = FileTarget | DirectoryOperand
  deriving stock (Eq, Show, Generic)

-- | The entries directly under @.seihou/@ that Seihou itself owns, case-folded.
-- Every name beginning with @.@ is reserved as well. New Seihou state under
-- @.seihou/@ must be added here and must use a name that is not a valid module
-- name (one not matching @[a-z][a-z0-9-]*@), so it never collides with a
-- module's state directory.
reservedSeihouEntries :: [Text]
reservedSeihouEntries =
  [ "manifest.json",
    "manifest.json.tmp",
    "baselines",
    "transactions",
    "transactions-quarantine",
    "modules",
    "context",
    "config.dhall",
    "trace.jsonl"
  ]

-- | Classify a path that has already passed 'validateProjectRelativePath'.
-- Segments split on either separator; empty and @.@ segments are ignored, and
-- the @.seihou@, @.git@ and reserved names compare case-insensitively because
-- the default macOS volume does.
classifyProjectPath :: FilePath -> ProjectPathClass
classifyProjectPath path = case controlSegments path of
  first' : rest
    | T.toCaseFold first' == ".git" -> ControlPath "path targets Git control data"
    | T.toCaseFold first' == ".seihou" -> case rest of
        [] -> SeihouRootDirectory
        entry : below
          | isReservedSeihouEntry entry ->
              ControlPath ("path targets Seihou's own state at " <> first' <> "/" <> entry)
          | otherwise -> ModuleStatePath entry (not (null below))
  _ -> OrdinaryProjectPath

controlSegments :: FilePath -> [Text]
controlSegments = filter (/= ".") . pathSegments . T.pack

isReservedSeihouEntry :: Text -> Bool
isReservedSeihouEntry entry =
  "." `T.isPrefixOf` entry || T.toCaseFold entry `elem` reservedSeihouEntries

-- | A path no module output may write as a file: Seihou's or Git's control
-- data, or the @.seihou@ directory itself.
isControlFileTarget :: FilePath -> Bool
isControlFileTarget = isJust . controlPathReason FileTarget

-- | A directory no module output may create or replace. The @.seihou@
-- directory itself is allowed: creating it when it exists is a no-op.
isControlDirectory :: FilePath -> Bool
isControlDirectory = isJust . controlPathReason DirectoryOperand

-- | Why a path is refused as the given kind of operand, in user words, or
-- 'Nothing' when it is not control data.
controlPathReason :: PathOperand -> FilePath -> Maybe Text
controlPathReason operand path = case classifyProjectPath path of
  ControlPath reason -> Just reason
  SeihouRootDirectory
    | operand == FileTarget -> Just "path names Seihou's state directory .seihou itself"
  _ -> Nothing

-- | 'Nothing' when the module named by the first argument may write (as a file
-- target) or create (as a directory operand) the path. A module may write under
-- @.seihou/@ only inside @.seihou/<its own name>/@, spelled exactly; any
-- other answer is a message that names the directory it may use.
ownStateDirectoryViolation :: Text -> PathOperand -> FilePath -> Maybe Text
ownStateDirectoryViolation moduleName operand path = case classifyProjectPath path of
  OrdinaryProjectPath -> Nothing
  ControlPath reason -> Just reason
  SeihouRootDirectory
    | operand == DirectoryOperand -> Nothing
    | otherwise -> Just (T.pack path <> " is Seihou's state directory and cannot be written as a file")
  ModuleStatePath area below
    | ownSpelling && area == moduleName && (below || operand == DirectoryOperand) -> Nothing
    | ownSpelling && area == moduleName ->
        Just (T.pack path <> " is this module's state directory and cannot be written as a file")
    | otherwise ->
        Just
          ( T.pack path
              <> " is outside this module's state directory; a module may write under .seihou/ only inside "
              <> ownDirectory
          )
  where
    ownSpelling = take 1 (controlSegments path) == [".seihou"]
    ownDirectory = ".seihou/" <> moduleName <> "/"

-- | Checkout declarations use a canonical, portable spelling and reserve metadata.
-- This deliberately strengthens rather than changes the legacy validator above.
validateCanonicalProjectPath :: Text -> Either Text ()
validateCanonicalProjectPath raw = do
  _ <- validateProjectRelativePath raw
  if T.null raw
    || raw /= T.strip raw
    || T.any (\c -> c == '\\' || c == '\0' || c == ':') raw
    || any (\part -> T.null part || part == "." || part == ".." || T.toCaseFold part `elem` [".git", ".seihou"]) (T.splitOn "/" raw)
    then Left ("path must use canonical project-relative components outside .git and .seihou: " <> raw)
    else Right ()

-- | Refuse case aliases in the portable output namespace, including conflicting
-- ancestor spellings, before any filesystem object is created.
pathsHaveConflictingSpelling :: FilePath -> FilePath -> Bool
pathsHaveConflictingSpelling a b = go (T.splitOn "/" (T.pack a)) (T.splitOn "/" (T.pack b))
  where
    go (x : xs) (y : ys)
      | x == y = go xs ys
      | otherwise = T.toCaseFold x == T.toCaseFold y
    go _ _ = False