{-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE ViewPatterns #-} {-# OPTIONS_GHC -Wno-orphans #-} -- | Implementation of the @dhall to-directory-tree@ subcommand module Dhall.DirectoryTree ( -- * Filesystem toDirectoryTree , FilesystemError(..) -- * Low-level types and functions , module Dhall.DirectoryTree.Types , decodeDirectoryTree , directoryTreeType ) where import Control.Applicative (empty) import Control.Exception (Exception) import Control.Monad (unless, when) import Data.Either.Validation (Validation (..)) import Data.Functor.Identity (Identity (..)) import Data.Maybe (fromMaybe, isJust) import Data.Sequence (Seq) import Data.Text (Text) import Data.Void (Void) import Dhall.DirectoryTree.Types import Dhall.Marshal.Decode (Decoder (..), Expector) import Dhall.Src (Src) import Dhall.Syntax ( Chunks (..) , Const (..) , Expr (..) , RecordField (..) , Var (..) ) import System.FilePath (()) import System.PosixCompat.Types (FileMode, GroupID, UserID) import qualified Control.Exception as Exception import qualified Data.Foldable as Foldable import qualified Data.Text as Text import qualified Data.Text.IO as Text.IO import qualified Dhall.Core as Core import qualified Dhall.Map as Map import qualified Dhall.Marshal.Decode as Decode import qualified Dhall.Pretty import qualified Dhall.TypeCheck as TypeCheck import qualified Dhall.Util as Util import qualified Prettyprinter as Pretty import qualified Prettyprinter.Render.String as Pretty import qualified System.Directory as Directory import qualified System.FilePath as FilePath #ifdef mingw32_HOST_OS import System.IO.Error (illegalOperationErrorType, mkIOError) #else import qualified System.Posix.User as Posix #endif import qualified System.PosixCompat.Files as Posix {-| Attempt to transform a Dhall record into a directory tree where: * Records are translated into directories * @Map@s are also translated into directories * @Text@ values or fields are translated into files * @Optional@ values are omitted if @None@ * There is a more advanced way to construct directory trees using a fixpoint encoding. See the documentation below on that. For example, the following Dhall record: > { dir = { `hello.txt` = "Hello\n" } > , `goodbye.txt`= Some "Goodbye\n" > , `missing.txt` = None Text > } ... should translate to this directory tree: > $ tree result > result > ├── dir > │ └── hello.txt > └── goodbye.txt > > $ cat result/dir/hello.txt > Hello > > $ cat result/goodbye.txt > Goodbye Use this in conjunction with the Prelude's support for rendering JSON/YAML in "pure Dhall" so that you can generate files containing JSON. For example: > let JSON = > https://prelude.dhall-lang.org/v12.0.0/JSON/package.dhall sha256:843783d29e60b558c2de431ce1206ce34bdfde375fcf06de8ec5bf77092fdef7 > > in { `example.json` = > JSON.render (JSON.array [ JSON.number 1.0, JSON.bool True ]) > , `example.yaml` = > JSON.renderYAML > (JSON.object (toMap { foo = JSON.string "Hello", bar = JSON.null })) > } ... which would generate: > $ cat result/example.json > [ 1.0, true ] > > $ cat result/example.yaml > ! "bar": null > ! "foo": "Hello" /Advanced construction of directory trees/ In addition to the ways described above using "simple" Dhall values to construct the directory tree there is one based on a fixpoint encoding. It works by passing a value of the following type to the interpreter: > let User = < UserId : Natural | UserName : Text > > > let Group = < GroupId : Natural | GroupName : Text > > > let Access = > { execute : Optional Bool > , read : Optional Bool > , write : Optional Bool > } > > let Mode = > { user : Optional Access > , group : Optional Access > , other : Optional Access > } > > let Entry = > \(content : Type) -> > { name : Text > , content : content > , user : Optional User > , group : Optional Group > , mode : Optional Mode > } > > in forall (tree : Type) -> > forall ( make > : { directory : Entry (List tree) -> tree > , file : Entry Text -> tree > } > ) -> > List tree The fact that the metadata for filesystem entries is modeled after the POSIX permission model comes with the unfortunate downside that it might not apply to other systems: There, changes to the metadata (user, group, permissions) might be a no-op and __no warning will be issued__. This is a leaking abstraction of the [unix-compat](https://hackage.haskell.org/package/unix-compat) package used internally. __NOTE__: This utility does not take care of type-checking and normalizing the provided expression. This will raise a `FilesystemError` exception or a `Dhall.Marshal.Decode.DhallErrors` exception upon encountering an expression that cannot be converted as-is. -} toDirectoryTree :: Bool -- ^ Whether to allow path separators in file names or not -> FilePath -> Expr Void Void -> IO () toDirectoryTree allowSeparators path expression = case expression of RecordLit keyValues -> Map.unorderedTraverseWithKey_ process $ recordFieldValue <$> keyValues ListLit (Just (Record [ ("mapKey", recordFieldValue -> Text), ("mapValue", _) ])) [] -> return () ListLit _ records | not (null records) , Just keyValues <- extract (Foldable.toList records) -> Foldable.traverse_ (uncurry process) keyValues TextLit (Chunks [] text) -> Text.IO.writeFile path text Some value -> toDirectoryTree allowSeparators path value App (Field (Union _) _) value -> do toDirectoryTree allowSeparators path value App None _ -> return () -- If this pattern matches we assume the user wants to use the fixpoint -- approach, hence we typecheck it and output error messages like we would -- do for every other Dhall program. Lam _ _ (Lam _ _ _) -> do entries <- decodeDirectoryTree expression processFilesystemEntryList allowSeparators path entries _ -> die where extract [] = return [] extract (RecordLit [ ("mapKey", recordFieldValue -> TextLit (Chunks [] key)) , ("mapValue", recordFieldValue -> value)] : records) = fmap ((key, value) :) (extract records) extract _ = empty process key value = do when (not allowSeparators && Text.isInfixOf (Text.pack [ FilePath.pathSeparator ]) key) $ die Directory.createDirectoryIfMissing allowSeparators path toDirectoryTree allowSeparators (path Text.unpack key) value die = Exception.throwIO FilesystemError{..} where unexpectedExpression = expression -- | Decode a fixpoint directory tree from a Dhall expression. decodeDirectoryTree :: Expr s Void -> IO (Seq FilesystemEntry) decodeDirectoryTree (Core.alphaNormalize . Core.denote -> expression@(Lam _ _ (Lam _ _ body))) = do expected' <- case directoryTreeType of Success x -> return x Failure e -> Exception.throwIO e _ <- Core.throws $ TypeCheck.typeOf $ Annot expression expected' case Decode.extract decoder body of Success x -> return x Failure e -> Exception.throwIO e where decoder :: Decoder (Seq FilesystemEntry) decoder = Decode.auto decodeDirectoryTree expr = Exception.throwIO $ FilesystemError $ Core.denote expr -- | The type of a fixpoint directory tree expression. directoryTreeType :: Expector (Expr Src Void) directoryTreeType = Pi Nothing "tree" (Const Type) <$> (Pi Nothing "make" <$> makeType <*> pure (App List (Var (V "tree" 0)))) -- | The type of make part of a fixpoint directory tree expression. makeType :: Expector (Expr Src Void) makeType = Record . Map.fromList <$> sequenceA [ makeConstructor "directory" (Decode.auto :: Decoder DirectoryEntry) , makeConstructor "file" (Decode.auto :: Decoder FileEntry) ] where makeConstructor :: Text -> Decoder b -> Expector (Text, RecordField Src Void) makeConstructor name dec = (name,) . Core.makeRecordField <$> (Pi Nothing "_" <$> expected dec <*> pure (Var (V "tree" 0))) -- | Resolve a `User` to a numerical id. getUser :: User -> IO UserID getUser (UserId uid) = return uid getUser (UserName name) = #ifdef mingw32_HOST_OS ioError $ mkIOError illegalOperationErrorType x Nothing Nothing where x = "System.Posix.User.getUserEntryForName: not supported" #else Posix.userID <$> Posix.getUserEntryForName name #endif -- | Resolve a `Group` to a numerical id. getGroup :: Group -> IO GroupID getGroup (GroupId gid) = return gid getGroup (GroupName name) = #ifdef mingw32_HOST_OS ioError $ mkIOError illegalOperationErrorType x Nothing Nothing where x = "System.Posix.User.getGroupEntryForName: not supported" #else Posix.groupID <$> Posix.getGroupEntryForName name #endif -- | Process a `FilesystemEntry`. Writes the content to disk and apply the -- metadata to the newly created item. processFilesystemEntry :: Bool -> FilePath -> FilesystemEntry -> IO () processFilesystemEntry allowSeparators path (DirectoryEntry entry) = do let path' = path entryName entry when (hasMetadata entry && not isMetadataSupported) $ Exception.throwIO $ MetadataUnsupportedError path' Directory.createDirectoryIfMissing allowSeparators path' processFilesystemEntryList allowSeparators path' $ entryContent entry -- It is important that we write the metadata after we wrote the content of -- the directories/files below this directory as we might lock ourself out -- by changing ownership or permissions. applyMetadata entry path' processFilesystemEntry _ path (FileEntry entry) = do let path' = path entryName entry when (hasMetadata entry && not isMetadataSupported) $ Exception.throwIO $ MetadataUnsupportedError path' Text.IO.writeFile path' $ entryContent entry -- It is important that we write the metadata after we wrote the content of -- the file as we might lock ourself out by changing ownership or -- permissions. applyMetadata entry path' -- | Process a list of `FilesystemEntry`s. processFilesystemEntryList :: Bool -> FilePath -> Seq FilesystemEntry -> IO () processFilesystemEntryList allowSeparators path = Foldable.traverse_ (processFilesystemEntry allowSeparators path) -- | Does this entry have some metadata set? hasMetadata :: Entry a -> Bool hasMetadata entry = isJust (entryUser entry) || isJust (entryGroup entry) || maybe False hasMode (entryMode entry) where hasMode :: Mode Maybe -> Bool hasMode mode = maybe False hasAccess (modeUser mode) || maybe False hasAccess (modeGroup mode) || maybe False hasAccess (modeOther mode) hasAccess :: Access Maybe -> Bool hasAccess access = isJust (accessExecute access) || isJust (accessRead access) || isJust (accessWrite access) -- | Set the metadata of an object referenced by a path. applyMetadata :: Entry a -> FilePath -> IO () applyMetadata entry fp = do s <- Posix.getFileStatus fp let user = Posix.fileOwner s group = Posix.fileGroup s mode = fileModeToMode $ Posix.fileMode s user' <- getUser $ fromMaybe (UserId user) (entryUser entry) group' <- getGroup $ fromMaybe (GroupId group) (entryGroup entry) unless ((user', group') == (user, group)) $ Posix.setOwnerAndGroup fp user' group' let mode' = maybe mode (updateModeWith mode) (entryMode entry) unless (mode' == mode) $ setFileMode fp $ modeToFileMode mode' -- | Calculate the new `Mode` from the current mode and the changes specified by -- the user. updateModeWith :: Mode Identity -> Mode Maybe -> Mode Identity updateModeWith x y = Mode { modeUser = combine modeUser modeUser , modeGroup = combine modeGroup modeGroup , modeOther = combine modeOther modeOther } where combine f g = maybe (f x) (Identity . updateAccessWith (runIdentity $ f x)) (g y) -- | Calculate the new `Access` from the current permissions and the changes -- specified by the user. updateAccessWith :: Access Identity -> Access Maybe -> Access Identity updateAccessWith x y = Access { accessExecute = combine accessExecute accessExecute , accessRead = combine accessRead accessRead , accessWrite = combine accessWrite accessWrite } where combine f g = maybe (f x) Identity (g y) -- | Convert a filesystem mode given as a bitmask (`FileMode`) to an ADT -- (`Mode`). fileModeToMode :: FileMode -> Mode Identity fileModeToMode mode = Mode { modeUser = Identity $ Access { accessExecute = Identity $ mode `hasFileMode` Posix.ownerExecuteMode , accessRead = Identity $ mode `hasFileMode` Posix.ownerReadMode , accessWrite = Identity $ mode `hasFileMode` Posix.ownerReadMode } , modeGroup = Identity $ Access { accessExecute = Identity $ mode `hasFileMode` Posix.groupExecuteMode , accessRead = Identity $ mode `hasFileMode` Posix.groupReadMode , accessWrite = Identity $ mode `hasFileMode` Posix.groupReadMode } , modeOther = Identity $ Access { accessExecute = Identity $ mode `hasFileMode` Posix.otherExecuteMode , accessRead = Identity $ mode `hasFileMode` Posix.otherReadMode , accessWrite = Identity $ mode `hasFileMode` Posix.otherReadMode } } -- | Convert a filesystem mode given as an ADT (`Mode`) to a bitmask -- (`FileMode`). modeToFileMode :: Mode Identity -> FileMode modeToFileMode mode = foldr Posix.unionFileModes Posix.nullFileMode $ [ Posix.ownerExecuteMode | runIdentity $ accessExecute (runIdentity $ modeUser mode) ] <> [ Posix.ownerReadMode | runIdentity $ accessRead (runIdentity $ modeUser mode) ] <> [ Posix.ownerWriteMode | runIdentity $ accessWrite (runIdentity $ modeUser mode) ] <> [ Posix.groupExecuteMode | runIdentity $ accessExecute (runIdentity $ modeGroup mode) ] <> [ Posix.groupReadMode | runIdentity $ accessRead (runIdentity $ modeGroup mode) ] <> [ Posix.groupWriteMode | runIdentity $ accessWrite (runIdentity $ modeGroup mode) ] <> [ Posix.otherExecuteMode | runIdentity $ accessExecute (runIdentity $ modeOther mode) ] <> [ Posix.otherReadMode | runIdentity $ accessRead (runIdentity $ modeOther mode) ] <> [ Posix.otherWriteMode | runIdentity $ accessWrite (runIdentity $ modeOther mode) ] -- | Check whether the second `FileMode` is contained in the first one. hasFileMode :: FileMode -> FileMode -> Bool hasFileMode mode x = (mode `Posix.intersectFileModes` x) == x {- | This error indicates that you supplied an invalid Dhall expression to the `toDirectoryTree` function. The Dhall expression could not be translated to a directory tree. -} newtype FilesystemError = FilesystemError { unexpectedExpression :: Expr Void Void } instance Exception FilesystemError instance Show FilesystemError where show FilesystemError{..} = Pretty.renderString (Dhall.Pretty.layout message) where message = Util._ERROR <> ": Not a valid directory tree expression \n\\ \ \n\\ \Explanation: Only a subset of Dhall expressions can be converted to a directory \n\\ \tree. Specifically, record literals or maps can be converted to directories, \n\\ \❰Text❱ literals can be converted to files, and ❰Optional❱ values are included if \n\\ \❰Some❱ and omitted if ❰None❱. Values of union types can also be converted if \n\\ \they are an alternative which has a non-nullary constructor whose argument is of \n\\ \an otherwise convertible type. Furthermore, there is a more advanced approach to \n\\ \constructing a directory tree utilizing a fixpoint encoding. Consult the upstream \n\\ \documentation of the `toDirectoryTree` function in the Dhall.Directory module for \n\\ \further information on that. \n\\ \No other type of value can be translated to a directory tree. \n\\ \ \n\\ \For example, this is a valid expression that can be translated to a directory \n\\ \tree: \n\\ \ \n\\ \ \n\\ \ ┌──────────────────────────────────┐ \n\\ \ │ { `example.json` = \"[1, true]\" } │ \n\\ \ └──────────────────────────────────┘ \n\\ \ \n\\ \ \n\\ \In contrast, the following expression is not allowed due to containing a \n\\ \❰Natural❱ field, which cannot be translated in this way: \n\\ \ \n\\ \ \n\\ \ ┌───────────────────────┐ \n\\ \ │ { `example.txt` = 1 } │ \n\\ \ └───────────────────────┘ \n\\ \ \n\\ \ \n\\ \Note that key names cannot contain path separators: \n\\ \ \n\\ \ \n\\ \ ┌─────────────────────────────────────┐ \n\\ \ │ { `directory/example.txt` = \"ABC\" } │ Invalid: Key contains a forward slash\n\\ \ └─────────────────────────────────────┘ \n\\ \ \n\\ \ \n\\ \Instead, you need to refactor the expression to use nested records instead: \n\\ \ \n\\ \ \n\\ \ ┌───────────────────────────────────────────┐ \n\\ \ │ { directory = { `example.txt` = \"ABC\" } } │ \n\\ \ └───────────────────────────────────────────┘ \n\\ \ \n\\ \ \n\\ \You tried to translate the following expression to a directory tree: \n\\ \ \n\\ \" <> Util.insert unexpectedExpression <> "\n\\ \ \n\\ \... which is not an expression that can be translated to a directory tree. \n" {- | This error indicates that you want to set some metadata for a file or directory, but that operation is not supported on your platform. -} newtype MetadataUnsupportedError = MetadataUnsupportedError { metadataForPath :: FilePath } instance Exception MetadataUnsupportedError instance Show MetadataUnsupportedError where show MetadataUnsupportedError{..} = Pretty.renderString (Dhall.Pretty.layout message) where message = Util._ERROR <> ": Setting metadata is not supported on this platform. \n\\ \ \n\\ \Explanation: Your Dhall expression indicates that you intend to set some metadata \n\\ \like ownership or permissions for the following file or directory: \n\\ \ \n\\ \" <> Pretty.pretty metadataForPath <> "\n\\ \ \n\\ \... which is not supported on your platform. \n"