hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Run/FileRoot.hs
-- | Caller-owned Hurl body directories. Validation uses filesystem operations
-- under the effective identity; no mode-bit inference or process-global cwd.
module HurlWorkbench.Run.FileRoot
( FileRoot,
FileRootError,
validateFileRoot,
fileRootPath,
checkFileRootOutputs,
selectSuiteFileRoot,
renderFileRootError,
)
where
import Control.Exception (IOException, try)
import Data.Generics.Labels ()
import Data.List (isPrefixOf)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import HurlWorkbench.Parameter.Resolve
import HurlWorkbench.Prelude
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Types
import System.Directory (canonicalizePath, listDirectory)
import System.FilePath (isAbsolute, splitDirectories, (</>))
import System.Posix.Files (getFileStatus, isDirectory)
-- The flag records explicit selection, which requires disjoint output trees.
data FileRoot = FileRoot !FilePath !Bool
deriving stock (Generic, Eq)
instance Show FileRoot where
show _ = "FileRoot <redacted>"
data FileRootError = InvalidFileRoot | FileRootBindingFailed !BindingError | FileRootOutputOverlap | FileRootOutputUnreadable
deriving stock (Generic, Eq)
instance Show FileRootError where
show = Text.unpack . renderFileRootError
-- | Nothing selects the supplied workspace base. Explicit relative paths use
-- the supplied invocation or workspace base, according to the caller.
validateFileRoot :: FilePath -> Maybe FilePath -> IO (Either FileRootError FileRoot)
validateFileRoot base requested = do
attempted <- try @IOException $ do
let selected = fromMaybe base requested
absolute = case requested of
Nothing -> base
Just _ -> if isAbsolute selected then selected else base </> selected
when (maybe False null requested) (ioError (userError "empty file root"))
canonical <- canonicalizePath absolute
-- stat of root/. needs directory search access, and listing needs read
-- access. Both use the effective identity, including ACLs and privileges.
status <- getFileStatus (canonical </> ".")
unless (isDirectory status) (ioError (userError "file root is not a directory"))
_ <- listDirectory canonical
pure (FileRoot canonical (isJust requested))
pure (either (const (Left InvalidFileRoot)) Right attempted)
fileRootPath :: FileRoot -> FilePath
fileRootPath (FileRoot path _) = path
-- | Canonicalize existing parent components even for not-yet-created outputs.
-- This check must run before report reservation, overwrite, or chmod.
checkFileRootOutputs :: FileRoot -> [FilePath] -> IO (Either FileRootError ())
checkFileRootOutputs (FileRoot _ False) _ = pure (Right ())
checkFileRootOutputs (FileRoot root True) targets = do
inspected <- try @IOException (traverse canonicalizePath targets)
pure $ case inspected of
Left _ -> Left FileRootOutputUnreadable
Right paths
| any overlaps paths -> Left FileRootOutputOverlap
| otherwise -> Right ()
where
overlaps path = let a = splitDirectories root; b = splitDirectories path in a `isPrefixOf` b || b `isPrefixOf` a
-- | Only suite-wide bindings participate. A typed CLI selection wins without
-- resolving the shadowed parameter. Static workspace validation checks its kind.
selectSuiteFileRoot :: ValidatedWorkspace -> SuiteName -> BindingInput -> Maybe FileRoot -> IO (Either FileRootError FileRoot)
selectSuiteFileRoot workspace name input selected = case selected of
Just root -> pure (Right root)
Nothing -> case lookupSuite name workspace >>= view #fileRootParameter of
Nothing -> validateFileRoot base Nothing
Just parameter ->
resolveParameterSubset workspace (Set.singleton parameter) input >>= \case
Left err -> pure (Left (FileRootBindingFailed err))
Right bindings -> case Map.lookup parameter (bindings ^. #variables) of
Nothing -> pure (Left InvalidFileRoot)
Just value -> validateFileRoot base (Just (Text.unpack (hurlValueLiteralText value)))
where
base = unWorkspaceRoot (validatedRoot workspace)
renderFileRootError :: FileRootError -> Text
renderFileRootError = \case
InvalidFileRoot -> "--file-root must select an existing readable, searchable directory"
FileRootBindingFailed err -> "suite file-root parameter: " <> renderBindingError err
FileRootOutputOverlap -> "file-root input and output trees must be disjoint"
FileRootOutputUnreadable -> "could not inspect file-root output layout"