mcm-0.6.8.1: FileCorrector.hs
-- MCM - Machine Configuration Manager; manages the contents of files and directories
-- Copyright (c) 2013-2018 Anthony Doggett <mcm@interfaces.org.uk>
--
-- Licence:
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program 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 General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program. If not, see <http://www.gnu.org/licenses/>.
module FileCorrector (ChangesSummary(..), correctAll, CompFun, diffCompare, simpleCompare, Path, PathType(..), theRootDir, addPath, walk, Permissions(..), emptyPermissions, DirType(..), OwnerP(..), GroupP(..), ownerAsString, groupAsString, mergePaths, Source)
where
import Control.Exception (catchJust)
import Data.Char(isSpace)
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.Text.Lazy as T
import qualified Data.Text.Lazy.IO as TextIO
import Numeric(showOct)
import System.Directory (getDirectoryContents, removeFile, removeDirectory)
import qualified System.Directory as SD
import System.Exit(ExitCode(ExitSuccess))
import System.FilePath (joinPath, normalise, splitFileName, makeRelative, splitDirectories, dropTrailingPathSeparator, takeFileName, combine)
import System.IO.HVFS.Utils (recursiveRemove, SystemFS(..))
import System.IO.Error (isDoesNotExistErrorType, ioeGetErrorType)
import System.Posix.Directory (createDirectory)
import System.Posix.Files (fileExist, isDirectory, setFileMode, removeLink, getSymbolicLinkStatus, isSymbolicLink, readSymbolicLink, createSymbolicLink, setOwnerAndGroup, setSymbolicLinkOwnerAndGroup, fileMode, fileOwner, fileGroup, getFileStatus, accessModes, intersectFileModes)
import System.Posix.Types (FileMode, UserID, GroupID)
import System.Process (readProcessWithExitCode)
import Action
data ChangesSummary = SomeItemsChangedOrRemoved | OnlyNewItems
deriving Show
data FT = FileT | DirT | SymlinkT
deriving (Show, Eq)
data WrongFT = FileWT | SymlinkWT | EmptyDirWT | NonEmptyDirWT
deriving (Show, Eq)
data AlsoWrongPermissions = PermissionsWrongToo | PermissionsRight
deriving (Show, Eq)
-- Intermediate Status of files found (all but for permissions checked)
data IStatus = IMissing -- File/Dir is not there at all
| IWrongType WrongFT -- File when should be Dir, etc.
| IWrongContents (Maybe String) -- Files: Might as well not be there...
| IExtraFiles [(FilePath, WrongFT)] -- Full Dirs: some extra files
| ILooksOk
deriving (Show, Eq)
-- Final Status of files found
data FStatus = FMissing -- File/Dir is not there at all
| FWrongType WrongFT -- File when should be Dir, etc.
| FWrongContents (Maybe String) -- Files: Might as well not be there...
| FExtraFiles [(FilePath, WrongFT)] AlsoWrongPermissions -- Full Dirs: some extra files
| FWrongPermissions -- Content ok, permissions need correcting
| FPerfect
deriving (Show, Eq)
data DirType = Full -- Fully managed dir (delete unmanaged files/dirs)
| Partial -- Partially managed dir (do not delete unmanaged files/dirs)
| Implicit -- Implicitly created
deriving (Show, Eq)
data PathType = Dir DirType
| File T.Text
| Symlink FilePath
| Absent
deriving (Show, Eq)
kind2ft :: PathType -> FT
kind2ft (Dir _) = DirT
kind2ft (File _) = FileT
kind2ft (Symlink _) = SymlinkT
kind2ft Absent = error "Absent has no ft"
-- FileMode, UserName, GroupName
newtype OwnerP = OwnerP (T.Text, UserID)
deriving (Show, Eq)
newtype GroupP = GroupP (T.Text, GroupID)
deriving (Show, Eq)
data Permissions = Perm (Maybe FileMode) (Maybe OwnerP) (Maybe GroupP)
deriving (Show, Eq)
emptyPermissions :: Permissions
emptyPermissions = Perm Nothing Nothing Nothing
ownerAsString :: OwnerP -> String
ownerAsString (OwnerP (o, _)) = T.unpack o
groupAsString :: GroupP -> String
groupAsString (GroupP (g, _)) = T.unpack g
type Source = String
implicitSource :: Source
implicitSource = "(implicit)"
-- A tree of paths. Each FilePath contains the full path
-- ToDo: shouldn't the Map.Map be part of the Dir PathType?
data Path = Path FilePath PathType (Map.Map FilePath Path) Permissions Source
deriving (Show, Eq)
theRootDir :: Path
theRootDir = Path "" (Dir Implicit) Map.empty emptyPermissions implicitSource
type CompFun = T.Text -> FilePath -> IO IStatus
walk :: Path -> [(FilePath, PathType, Permissions)]
walk (Path "" _ ps _ _) = concatMap walk (Map.elems ps)
walk (Path path kind ps perm _) = x:xs
where
x = (path, kind, perm)
xs = concatMap walk (Map.elems ps)
addPath :: Path -> FilePath -> PathType -> Permissions -> Source -> Either String Path
addPath p f = addPath' p normalisedFilePath
where
normalisedFilePath = normalise (dropTrailingPathSeparator f)
addPath' :: Path -> FilePath -> PathType -> Permissions -> Source -> Either String Path
addPath' (Path path kind ps perm source) newPath newType newPermissions newS =
if path == newPath then
if kind == Dir Implicit then
if newType `elem` [Dir Full, Dir Partial] then
-- Replace old implicit directory
Right $ Path path newType ps newPermissions newS
else
Left $ "Path " ++ newPath ++ " is already implicitly a directory from " ++ show source
else
Left $ "Path " ++ newPath ++ " already exists from " ++ show source ++ " and the new definition from " ++ show newS ++ " conflicts."
else
let (dir, _) = splitFileName newPath
dir' = if dir == "./" then "" else dropTrailingPathSeparator dir
in
if dir' == path then
-- Add item into this path,
-- complaining if already exists and is different
case Map.lookup newPath ps of
Nothing -> let iP = Path newPath newType Map.empty newPermissions newS
in Right $ Path path kind (Map.insert newPath iP ps) perm source
Just p -> case addPath' p newPath newType newPermissions newS of
Left e -> Left e
Right pp -> Right $ Path path kind (Map.insert newPath pp ps) perm source
else
-- recurse, adding result as a new/replacement path
let remainingdirs = makeRelative path dir'
nextdir = head $ splitDirectories remainingdirs
newentryPath = joinPath [path, nextdir]
defaultNewEntry = Path newentryPath (Dir Implicit) Map.empty emptyPermissions newS
lookedupNewEntry = Map.findWithDefault defaultNewEntry newentryPath ps
in case addPath' lookedupNewEntry newPath newType newPermissions newS of
Left e -> Left e
Right p -> Right $ Path path kind (Map.insert newentryPath p ps) perm source
-- As yet only for the merging of very simple, compileTo-generated paths
mergePaths :: Path -> Path -> Path
mergePaths (Path apath akind aps aperm asource) (Path bpath bkind bps bperm _) =
if or [apath /= bpath, akind /= bkind, aperm /= bperm]
then error "Internal Error: Path construction/traversal mistake"
else let mergedps = Map.unionWith mergePaths aps bps
in Path apath akind mergedps aperm asource
calculateStatus :: CompFun -> Path -> IO FStatus
calculateStatus compfun (Path path kind paths perm _) = do
s <- case kind of
Dir t -> calcDirStatus t path paths
File s -> calcFileStatus compfun s path
Symlink fp -> calcSymlinkStatus fp path
Absent -> calcAbsentStatus path
checkPermissions kind path perm s
checkPermissions :: PathType -> FilePath -> Permissions -> IStatus -> IO FStatus
checkPermissions kind path perm (IExtraFiles fs) = do
wrong <- permissionsAreWrong kind path perm
return $ FExtraFiles fs $ if wrong then PermissionsWrongToo else PermissionsRight
checkPermissions kind path perm ILooksOk = do
wrong <- permissionsAreWrong kind path perm
return $ if wrong then FWrongPermissions else FPerfect
checkPermissions _ _ _ IMissing = return FMissing
checkPermissions _ _ _ (IWrongType wft) = return $ FWrongType wft
checkPermissions _ _ _ (IWrongContents ms) = return $ FWrongContents ms
permissionsAreWrong :: PathType -> FilePath -> Permissions -> IO Bool
permissionsAreWrong _ _ (Perm Nothing Nothing Nothing) = return False
permissionsAreWrong kind path (Perm m u g) = do
let getStatusCmd = case kind of
(Symlink _) -> getSymbolicLinkStatus
_ -> getFileStatus
fstatus <- getStatusCmd path
let wrong = or [case m of
(Just m') -> m' /= intersectFileModes accessModes (fileMode fstatus)
_ -> False
,case u of
(Just (OwnerP(_, u'))) -> u' /= fileOwner fstatus
_ -> False
,case g of
(Just (GroupP(_, g'))) -> g' /= fileGroup fstatus
_ -> False
]
return wrong
calcDirStatus :: DirType -> FilePath -> Map.Map FilePath Path -> IO IStatus
calcDirStatus _ "" _ = return ILooksOk
calcDirStatus t path paths = do
e <- fileExist path
if e
then do
fs <- getSymbolicLinkStatus path
if isSymbolicLink fs
then return $ IWrongType SymlinkWT
else if isDirectory fs
then
case t of
Full -> checkContents path paths
_ -> return ILooksOk
else return $ IWrongType FileWT
else return IMissing
listDirectory :: FilePath -> IO [FilePath]
listDirectory fp = do
fs <- getDirectoryContents fp
return $ filter (\f -> f `notElem` [".", ".."]) fs
-- Return ILooksOk if path contains no files other than path.
-- Otherwise return ExtraFiles.
checkContents :: FilePath -> Map.Map FilePath Path -> IO IStatus
checkContents path paths = do
files <- listDirectory path
let wanted = Set.map (takeFileName.dropTrailingPathSeparator) (Map.keysSet paths)
actual = Set.fromList files
extras = Set.toList $ Set.difference actual wanted
extrasWithPath = map (combine path) extras
if null extras
then return ILooksOk
else do
withFTs <- mapM appendFileType extrasWithPath
return $ IExtraFiles withFTs
-- Is the given directory empty? helper during calculating WrongFT
isDirEmpty :: FilePath -> IO WrongFT
isDirEmpty path = do
ls <- listDirectory path
return $ if [] == ls then EmptyDirWT else NonEmptyDirWT
appendFileType :: FilePath -> IO (FilePath, WrongFT)
appendFileType path = do
fs <- getSymbolicLinkStatus path
t <- if isSymbolicLink fs
then return SymlinkWT
else if isDirectory fs
then isDirEmpty path
else return FileWT
return (path, t)
calcFileStatus :: CompFun -> T.Text -> FilePath -> IO IStatus
calcFileStatus compfun contents path = do
e <- fileExist path
if e
then do
fs <- getSymbolicLinkStatus path
if isSymbolicLink fs
then return $ IWrongType SymlinkWT
else if isDirectory fs
then IWrongType <$> isDirEmpty path
else compfun contents path
else return IMissing
simpleCompare :: CompFun
simpleCompare contents path = do
let contents' = appendNewlineIfMissing contents
current <- TextIO.readFile path
return $ if current == contents' then ILooksOk else IWrongContents Nothing
diffCompare :: CompFun
diffCompare contents path = do
let contents' = appendNewlineIfMissing contents
(exitCode, stderr, stdout)
<- readProcessWithExitCode "diff" [path, "-"] $ T.unpack contents'
return $ if exitCode == ExitSuccess
then ILooksOk
else IWrongContents (Just $ stderr++stdout)
calcSymlinkStatus :: FilePath -> FilePath -> IO IStatus
calcSymlinkStatus dest source = do
e <- fileExist source -- NB. Works on the _target_ of symbolic links
if e
then do
fs <- getSymbolicLinkStatus source
if isSymbolicLink fs
then do
l <- readSymbolicLink source
return $ if l == dest
then ILooksOk
else IWrongContents (Just $ "Symlink is currently " ++ l)
else if isDirectory fs
then IWrongType <$> isDirEmpty source
else return $ IWrongType FileWT
else do
isslink <- catchJust (\ex -> if isDoesNotExistErrorType (ioeGetErrorType ex) then Just () else Nothing)
(do
fs <- getSymbolicLinkStatus source
return $ isSymbolicLink fs)
(\_ -> return False)
if isslink
then do
l <- readSymbolicLink source
return $ if l == dest
then ILooksOk
else IWrongContents (Just $ "Symlink is currently " ++ l)
else
return IMissing
calcAbsentStatus :: FilePath -> IO IStatus
calcAbsentStatus path = do
e <- fileExist path
if e
then do
fs <- getSymbolicLinkStatus path
if isSymbolicLink fs
then return $ IWrongType SymlinkWT
else if isDirectory fs
then IWrongType <$> isDirEmpty path
else return $ IWrongType FileWT
else
return ILooksOk
correctOne :: Path -> FStatus -> [Action]
correctOne _ FPerfect = []
correctOne (Path path kind _ perm _) s@FWrongPermissions = correctPermissions s kind path perm
correctOne (Path path Absent _ _ _) s = correctAbsent s path
correctOne p@(Path path _ _ _ _) s@(FWrongType _) = correctAbsent s path ++ correctOne p FMissing
correctOne (Path path kind _ perm _) s = k ++ cor
where cor = correctPermissions s kind path perm'
(perm', k) = case kind of
Dir _ -> correctDir s path perm
File c -> correctFile s c path perm
Symlink fp -> correctSymlink s fp path perm
Absent -> (emptyPermissions, correctAbsent s path)
correctPermissions :: FStatus -> PathType -> FilePath -> Permissions -> [Action]
correctPermissions FPerfect _ _ _ = []
correctPermissions _ kind path (Perm m u g) = correctM m ++ correctUG u g
where
correctM Nothing = []
correctM (Just m') = [Action ("chmod " ++ showOct m' []
++ " " ++ path)
(setFileMode path m')]
correctUG Nothing Nothing = []
correctUG _ _ =
let text = case (u, g) of
(Nothing, Nothing) -> ""
(Nothing, Just s) -> "chgrp " ++ groupAsString s ++ " " ++ path
(Just s, Nothing) -> "chown " ++ ownerAsString s ++ " " ++ path
(Just a, Just b) -> "chown " ++ ownerAsString a ++ ":" ++ groupAsString b ++ " " ++ path
ft = kind2ft kind
in
[Action text (repairOwnerAndGroup ft path u g)]
repairOwnerAndGroup :: FT -> FilePath -> Maybe OwnerP -> Maybe GroupP -> IO ()
repairOwnerAndGroup ft p u g = ogrepair p u' g'
where
u' = case u of
Nothing -> -1
Just (OwnerP(_, uid)) -> uid
g' = case g of
Nothing -> -1
Just (GroupP(_, gid)) -> gid
ogrepair = case ft of
SymlinkT -> setSymbolicLinkOwnerAndGroup
_ -> setOwnerAndGroup
joinWithSpace :: String -> String -> String
joinWithSpace "" b = b
joinWithSpace a "" = a
joinWithSpace a b = a ++ " " ++ b
toModeOwnerGroup :: FT -> FilePath -> Permissions -> String -> (String, IO ())
toModeOwnerGroup _ path (Perm Nothing Nothing Nothing) extraargs = (joinWithSpace extraargs path, return ())
toModeOwnerGroup kind path (Perm m u g) extraargs = (mt ++ ugt ++ joinWithSpace extraargs path, do {mio; ugio})
where
(mt, mio) = case m of
Nothing -> ("", return ())
Just m' -> ("--mode " ++ showOct m' [] ++ " ", setFileMode path m')
ugio = repairOwnerAndGroup kind path u g
ugt = case (u, g) of
(Nothing, Nothing) -> ""
(Nothing, Just s) -> "--group " ++ groupAsString s ++ " "
(Just s, Nothing) -> "--owner " ++ ownerAsString s ++ " "
(Just a, Just b) -> "--owner " ++ ownerAsString a ++ " --group " ++ groupAsString b ++ " "
correctAbsent :: FStatus -> FilePath -> [Action]
correctAbsent (FWrongType SymlinkWT) path = [Action ("rm "++path) (removeLink path)]
correctAbsent (FWrongType FileWT) path = [Action ("rm "++path) (removeFile path)]
correctAbsent (FWrongType EmptyDirWT) path = [Action ("rmdir "++path) (removeDirectory path)]
correctAbsent (FWrongType NonEmptyDirWT) path = [Action ("rm -r "++path) (recursiveRemove SystemFS path)]
correctAbsent FMissing _ = [] -- Occurs when parent directories do not exist
correctAbsent s path = error $ "Unexpected correctAbsent state: " ++ show s ++ " (occurred for path " ++ path ++ ")"
-- NB. For security reasons we create the directory with the correct owner
correctDir :: FStatus -> FilePath -> Permissions -> (Permissions, [Action])
correctDir FMissing p perm@(Perm m _ _) =
let (mogText, mogIO) = toModeOwnerGroup DirT p perm ""
mkdir = case m of
Nothing -> SD.createDirectory p
Just mode -> createDirectory p mode
in (emptyPermissions, [Action ("mkdir " ++ mogText) (do {mkdir; mogIO})])
correctDir (FExtraFiles fs wrongp) _ perm = (perm', concatMap (\(p, ft) -> correctAbsent (FWrongType ft) p) fs)
where
perm' = case wrongp of
PermissionsRight -> emptyPermissions
PermissionsWrongToo -> perm
correctDir _ _ _ = error "Unexpected correctDir state"
correctFile :: FStatus -> T.Text -> FilePath -> Permissions -> (Permissions, [Action])
correctFile (FWrongContents w) c p perm =
let (mogText, mogIO) = toModeOwnerGroup FileT p perm ""
c' = appendNewlineIfMissing c
in (emptyPermissions, correctAbsent (FWrongType FileWT) p
++ showWrong w
++ [Action ("install "++mogText) (do {TextIO.writeFile p c'; mogIO})])
correctFile FMissing c p perm =
let (mogText, mogIO) = toModeOwnerGroup FileT p perm ""
c' = appendNewlineIfMissing c
in (emptyPermissions, [Action ("install " ++ mogText) (do {TextIO.writeFile p c'; mogIO})])
correctFile _ _ _ _ = error "Unexpected correctFile state"
correctSymlink :: FStatus -> FilePath -> FilePath -> Permissions -> (Permissions, [Action])
correctSymlink (FWrongContents w) fp p perm =
let (perm', fixmissing) = correctSymlink FMissing fp p perm
in (perm', correctAbsent (FWrongType SymlinkWT) p ++ showWrong w ++ fixmissing)
correctSymlink FMissing fp p perm =
let (mogText, mogIO) = toModeOwnerGroup FileT p perm ("-s " ++ fp)
in (emptyPermissions, [Action ("ln " ++ mogText) (do {createSymbolicLink fp p; mogIO})])
correctSymlink _ _ _ _ = error "Unexpected correctSymlink state"
showWrong :: Maybe String -> [Action]
showWrong Nothing = []
showWrong (Just s) = [Action (trim s) (return ())]
trim :: String -> String
trim = f . f
where f = reverse . dropWhile isSpace
statusAndCorrect :: CompFun -> Path -> IO (FStatus, [Action])
statusAndCorrect compfun p = do
s <- calculateStatus compfun p
return (s, correctOne p s)
correctAll :: CompFun -> Path -> IO (ChangesSummary, [Action])
correctAll compfun p@(Path _ _ paths _ _) = do
(s, a) <- statusAndCorrect compfun p
let corrector = case s of
FPerfect -> correctAll
FWrongPermissions -> correctAll
FExtraFiles _ _ -> correctAll
_ -> \_ pp -> return (OnlyNewItems, correctTheMissingRest pp)
csummary = case s of
FMissing -> OnlyNewItems
FPerfect -> OnlyNewItems
_ -> SomeItemsChangedOrRemoved
calcCSummary :: ChangesSummary -> ChangesSummary -> ChangesSummary
calcCSummary SomeItemsChangedOrRemoved _ = SomeItemsChangedOrRemoved
calcCSummary _ SomeItemsChangedOrRemoved = SomeItemsChangedOrRemoved
calcCSummary OnlyNewItems OnlyNewItems = OnlyNewItems
children <- mapM (corrector compfun) $ Map.elems paths
return (foldr1 calcCSummary (csummary:map fst children), concat (a:map snd children))
correctTheMissingRest :: Path -> [Action]
correctTheMissingRest p@(Path _ _ paths _ _) =
let a = correctOne p FMissing
children = map correctTheMissingRest $ Map.elems paths
in
concat (a:children)
appendNewlineIfMissing :: T.Text -> T.Text
appendNewlineIfMissing x | T.null x = x
appendNewlineIfMissing x | T.last x == '\n' = x
appendNewlineIfMissing x = T.snoc x '\n'