bdcs-0.3.0: src/BDCS/Export/FSTree.hs
-- Copyright (C) 2017 Red Hat, Inc.
--
-- This library is free software; you can redistribute it and/or
-- modify it under the terms of the GNU Lesser General Public
-- License as published by the Free Software Foundation; either
-- version 2.1 of the License, or (at your option) any later version.
--
-- This library is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
-- Lesser General Public License for more details.
--
-- You should have received a copy of the GNU Lesser General Public
-- License along with this library; if not, see <http://www.gnu.org/licenses/>.
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE RecordWildCards #-}
module BDCS.Export.FSTree(FSEntry,
FSTree,
addFileToTree,
filesToTree,
fstreeSource)
where
import Control.Conditional(whenM)
import Control.Monad(foldM)
import Control.Monad.Except(MonadError, throwError)
import Control.Monad.State(StateT, evalStateT, gets, withStateT)
import Data.Conduit(Sink, Source, yield)
import qualified Data.Conduit.List as CL
import Data.List.Safe(init, last)
import qualified Data.Text as T
import Data.Tree(Tree(..))
import System.FilePath(joinPath, splitDirectories)
import System.Posix.Files(directoryMode, fileTypeModes, intersectFileModes, symbolicLinkMode)
import System.Posix.Types(FileMode)
import BDCS.DB(Files(..))
import Prelude hiding(init, last)
-- FSTree types
-- A filesystem entry consists of a path component, and maybe a Files object.
-- Nothing for the object is used to represent placeholder directories.
-- e.g., if adding "/a/b" to an empty tree, a placeholder will be created for "a"
-- to hold the "b" entry. If "/a" is later added to the tree, the placeholder will
-- be replaced with a Files object for /a.
-- | A single node within a file system tree. The pair is a single path component
-- (e.g., "c" for the node at "/a/b/c"), and maybe a Files object. Automatically
-- created parent directories will use Nothing as the snd element.
type FSEntry = (FilePath, Maybe Files)
-- | A tree of file system entries.
type FSTree = Tree FSEntry
filesToTree :: MonadError String m => Sink Files m FSTree
filesToTree =
let
-- Create an empty tree. This tree starts one level above "/"
rootTree = Node{rootLabel=("", Nothing), subForest=[]}
in
CL.foldM (addFileToTree False) rootTree
-- | Add a 'Files' object to a 'FSTree'. Will optionally replace existing paths, or throw an error.
-- This function cannot replace a directory with a regular file, or vice versa.
addFileToTree :: MonadError String m =>
Bool -- ^ Whether to replace the file if the path already exists
-> FSTree -- ^ The original tree
-> Files -- ^ The file to add
-> m FSTree
addFileToTree replace root object = do
let rootZipper = (root, [])
let pathComponents = splitDirectories $ T.unpack $ filesPath object
dirComponents <- maybe (throwError $ "Invalid path on " ++ show object) return $ init pathComponents
lastComponent <- maybe (throwError $ "Invalid path on " ++ show object) return $ last pathComponents
-- Resolve the directory name
dirZipper <- evalStateT (findDirectory rootZipper dirComponents) 0
-- Wrap the new file in a tree node and add it to the directory
let newEntry = Node (lastComponent, Just object) []
getTree <$> addEntryToTree dirZipper newEntry
where
-- Given a directory path, split into components, return a new zipper focused on this path
-- If the directory or any parent directories do not exist, create placeholders for them
findDirectory :: MonadError String m => FSZipper -> [FilePath] -> StateT Int m FSZipper
-- End of the list, we found it
findDirectory zipper [] = return zipper
-- . and .. cases
findDirectory zipper (".":xs) = findDirectory zipper xs
findDirectory zipper ("..":xs) = findDirectory (goUp zipper) xs
-- normal case: find the child entry, create if missing, recurse
findDirectory zipper (pathComponent:xs) =
let
placeholder = Node (pathComponent, Nothing) []
in
-- Find this path component in the subForest for the current tree.
-- Several things can happen:
-- 1) We find nothing. That's fine. Add a placeholder to the tree, recurse.
-- 2) We find a directory (or a placeholder). That's good. Recurse.
-- 3) We find a file. That's bad. Fail.
-- 4) We find a symlink. Oh boy!. Call findDirectory on the symlink target to
-- get the actual directory we need to be in, then recurse.
case findChild pathComponent zipper of
-- No path found, add the placeholder and recurse
Nothing -> findDirectory (addChild placeholder zipper) xs
-- Grab the FSEntry out of the child tree, figure out what we found
Just childZipper@(Node{..}, _) -> case categorize rootLabel of
-- Found a placeholder, recurse
Placeholder -> findDirectory childZipper xs
-- existing directory, recurse
Directory _ -> findDirectory childZipper xs
-- symlink. Follow it, recurse on the symlink target
-- check and increment the symlink level so we don't get stuck in a loop
Symlink link -> do
whenM (gets (>= maxSymlinks)) $
throwError $ "Too many levels of symbolic links while resolving " ++ T.unpack (filesPath object)
linkZipper <- withStateT (+1) $ resolveSymlink zipper link
findDirectory linkZipper xs
-- Anything else means there's a non-directory file in the middle of our path
Other existing -> throwError $ "Unable to resolve path " ++ T.unpack (filesPath object) ++
", non-directory object exists at " ++ T.unpack (filesPath existing)
-- Wrapper for findDirectory with a symlink target. If it's an absolute symlink target,
-- unzip the zipper to the top and call findDirectory, otherwise just call findDirectory
resolveSymlink :: MonadError String m => FSZipper -> Files -> StateT Int m FSZipper
resolveSymlink zipper Files{..} = do
symlinkTarget <- maybe (throwError $ "Error: symlink with no target at " ++ T.unpack filesPath)
(return . T.unpack) filesTarget
let pathComponents = splitDirectories symlinkTarget
let startZipper = if head pathComponents == "/" then getRoot zipper else zipper
findDirectory startZipper pathComponents
addEntryToTree :: MonadError String m => FSZipper -> FSTree -> m FSZipper
addEntryToTree zipper newEntry = do
-- At this point, we have a directory to stick the new entry into, and the first thing to do is see
-- if the directory already contains an entry with the same name. If it does:
-- - The new entry matches the old entry. That's fine, let everything be and we're done
-- - The existing entry is a placeholder:
-- * are we adding a directory? replace the placeholder with the new real directory, move the placeholder's
-- children to the real directory
-- * are we adding a symlink? replace the placeholder with the symlink, and move all of the placeholder's
-- children to the symlink target
-- * are we adding a different placeholder? Add the new placeholder's children as children of the existing
-- placeholder.
-- * otherwise? we have a conflict, throw an error
-- - The existing entry is a directory:
-- * are we adding a placeholder? Add the new placeholder's children to the existing directory.
-- * otherwise, conflict
--
-- The scenarios that involve adding new placeholders may seem a little odd, but they can arise
-- when moving things around due to replacing placeholder directories with symlinks.
-- For example, adding something like the following to a tree:
--
-- 1) /a/b/c
-- 2) /d/b/
-- 3) /a -> d
--
-- would do something like:
--
-- 1) create placeholder /a, placeholder /a/b, real entry /a/b/c
-- 2) create placeholder /d, real entry /d/b/
-- 3) replace placeholder /a with a symlink, move placeholder /a/b/ and children to /d/,
-- find that there's already a b/ entry and move /a/b/c to /d/b/
let entryName = fst $ rootLabel newEntry
let maybeExisting = findChild entryName zipper
case maybeExisting of
-- The easy case: nothing there, just add it
Nothing -> return $ addChild newEntry zipper
Just existing@(self, crumbs) -> case ((categorize . rootLabel) self, (categorize . rootLabel) newEntry) of
-- Move the new placeholder's children to the existing placeholder
(Placeholder, Placeholder) -> addChildren existing newEntry
-- Replace the existing placeholder, add its children to the new directory
(Placeholder, Directory _) -> addChildren (newEntry, crumbs) self
-- Replace the placeholder, find the symlink target, move the placeholder children to the target.
-- The context for relative links is the directory, so one level above the new symlink
(Placeholder, Symlink s) -> do
let newZipper = (newEntry, crumbs)
targetZipper <- evalStateT (resolveSymlink (goUp newZipper) s) 0
addChildren targetZipper self
-- Attempting to replace a placeholder directory with a regular file, that's an error
(Placeholder, _) -> throwError $ "Unable to add " ++ T.unpack (filesPath object) ++
", directory added at path"
-- existing directory, new placeholder. Leave the directory, but take the placeholder's children
(Directory _, Placeholder) -> addChildren existing newEntry
-- Replacing a directory with a directory, only valid if the Files objects are the same
-- or if "replace" is true.
(Directory d1, Directory d2) -> if | compareDirs d1 d2 -> addChildren existing newEntry
| replace -> addChildren (newEntry, crumbs) self
| otherwise -> throwError $ "Unable to add " ++
T.unpack (filesPath object) ++
", directory already added at this location"
(Directory _, _) -> throwError $ "Unable to to add " ++ T.unpack (filesPath object) ++
", directory already added at this location"
-- Allow a symlink to be added twice if it's the same symlink
(Symlink s1, Symlink s2) -> if compareLinks s1 s2 then return existing
else throwError $ "Unable to add " ++ T.unpack (filesPath object) ++
", symlink already added at this location"
-- Follow the symlink, and move the placeholder's children to the destination directory
(Symlink s, Placeholder) -> do
targetZipper <- evalStateT (resolveSymlink zipper s) 0
addChildren targetZipper newEntry
-- Replacing a file with a directory, no good
(Other _, Directory _) -> throwError $ "Unable to add " ++ T.unpack (filesPath object) ++
", file already added at this location"
(Other _, Placeholder) -> throwError $ "Unable to add " ++ T.unpack (filesPath object) ++
", file already added at this location"
-- Otherwise, we have two non-directory, non-placeholder files, see if they match,
-- or if we should explicitly replace.
_ -> if | self == newEntry -> return existing
| replace -> return (newEntry, crumbs)
| otherwise -> throwError $ "Unable to add " ++
T.unpack (filesPath object) ++
", file already added at this location"
addChildren :: MonadError String m => FSZipper -> FSTree -> m FSZipper
addChildren dirZipper newEntry = foldM (\z e -> goUp <$> addEntryToTree z e) dirZipper (subForest newEntry)
-- Compare files, ignoring size and mtime, because the data for these is basically made up
compareDirs :: Files -> Files -> Bool
compareDirs f1 f2 = f1{filesMtime=0, filesSize=0} == f2{filesMtime=0, filesSize=0}
-- For symlinks, the only parts that count are the path and the target
compareLinks :: Files -> Files -> Bool
compareLinks f1 f2 = (filesPath f1 == filesPath f2) && (filesTarget f1 == filesTarget f2)
-- Walk a tree and emit the Files entries in order, modifying the paths as we go
-- to match the final, symlink-resolved results
fstreeSource :: Monad m => FSTree -> Source m Files
fstreeSource root = fstreeSource' [] root
where
fstreeSource' :: Monad m => [FilePath] -> FSTree -> Source m Files
fstreeSource' prefix Node{rootLabel=(pathComponent, maybeFile), ..} =
let newPrefix = prefix ++ [pathComponent]
in yieldEntry (joinPath newPrefix) maybeFile >>
mapM_ (fstreeSource' newPrefix) subForest
-- yieldEntry :: Monad m => FilePath -> Maybe Files
yieldEntry _ Nothing = return ()
yieldEntry realPath (Just f) = yield f{filesPath=T.pack realPath}
-- Private symbols
-- The FSZipper is the currently focused node of the tree, plus a trail of breadcrumbs leading
-- back up to the top of the tree. Each crumb contains the rootLabel of the parent of the focused
-- tree, the subForest to the left of the focused tree, and the subForest to the right of the
-- focused tree.
type FSCrumb = (FSEntry, [FSTree], [FSTree])
type FSZipper = (FSTree, [FSCrumb])
-- Zipper navigation
-- Change the focus to the parent of the current tree
goUp :: FSZipper -> FSZipper
-- Pop the head off of the breadcrumb trail, use it to reconstruct the parent Tree
goUp (self, (entry, left, right):crumbs) = (Node entry (left ++ [self] ++ right), crumbs)
-- already at the top, just stay at the top
goUp zipper@(_, []) = zipper
-- Change the focus all the way to the top of a zipper
getRoot :: FSZipper -> FSZipper
getRoot zipper@(_, []) = zipper
getRoot zipper = getRoot $ goUp zipper
-- Convert a zipper back into a tree
getTree :: FSZipper -> FSTree
getTree zipper = fst $ getRoot zipper
-- Attempt to focus on a child of the current tree that matches the given path component
-- If there is no such child, return nothing
findChild :: FilePath -> FSZipper -> Maybe FSZipper
findChild pathComponent (self, crumbs) =
case break ((== pathComponent) . fst . rootLabel) $ subForest self of
-- An empty right list means no node was found
(_, []) -> Nothing
-- If we did find something, the head of the right list is the new tree to focus on
(left, node:right) -> let newCrumb = (rootLabel self, left, right)
in Just (node, newCrumb:crumbs)
-- Add a new subtree to the currently focused tree, return a zipper focused on the new subtree
addChild :: FSTree -> FSZipper -> FSZipper
addChild subTree (Node{..}, crumbs) =
let newCrumb = (rootLabel, subForest, [])
in (subTree, newCrumb:crumbs)
-- Split FSEntries into categories, to simplify the conditionals involved in adding things to a FSTree
data FSCategory = Placeholder
| Directory Files
| Symlink Files
| Other Files
categorize :: FSEntry -> FSCategory
categorize (_, Nothing) = Placeholder
categorize (_, Just f@Files{..}) =
if | isDirectory -> Directory f
| isSymlink -> Symlink f
| otherwise -> Other f
where
getFileMode :: FileMode
getFileMode = fromIntegral filesMode `intersectFileModes` fileTypeModes
isDirectory :: Bool
isDirectory = getFileMode == directoryMode
isSymlink :: Bool
isSymlink = getFileMode == symbolicLinkMode
-- Constant for detecting symlink loops, equivalent to MAXSYMLINKS in linux
maxSymlinks :: Int
maxSymlinks = 40