clod-0.2.3: src/Clod/FileSystem/Operations.hs
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Module : Clod.FileSystem.Operations
-- Description : File system operations for Clod
-- Copyright : (c) Fuzz Leonard, 2025
-- License : MIT
-- Maintainer : fuzz@fuzz.ink
-- Stability : experimental
--
-- This module provides basic file system operations like finding files,
-- copying files, and safely removing files.
module Clod.FileSystem.Operations
( -- * File operations
findAllFiles
, copyFile
, safeRemoveFile
, safeReadFile
, safeWriteFile
, safeCopyFile
) where
import Control.Monad (when)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import System.Directory (doesDirectoryExist, doesFileExist, getDirectoryContents, removeFile,
copyFile, canonicalizePath)
import System.FilePath ((</>))
import qualified Data.ByteString as BS
import Clod.Types (ClodM, FileReadCap(..), FileWriteCap(..), ClodError(..), isPathAllowed,
allowedReadDirs, allowedWriteDirs, (^.))
-- | Recursively find all files in a directory
--
-- This function takes a base path and a list of files/directories,
-- and recursively finds all files within those directories.
-- It returns paths relative to the base path.
--
-- @
-- -- Find all files in the "src" directory
-- files <- findAllFiles "/path/to/repo" ["src"]
--
-- -- Find all files in multiple directories
-- files <- findAllFiles "/path/to/repo" ["src", "docs", "tests"]
--
-- -- Find all files in the root directory (without "./" prefix)
-- files <- findAllFiles "/path/to/repo" [""]
-- @
findAllFiles :: FilePath -> [FilePath] -> ClodM [FilePath]
findAllFiles basePath = fmap concat . mapM findFilesRecursive
where
findFilesRecursive :: FilePath -> ClodM [FilePath]
findFilesRecursive file = do
-- Special case for empty string or "." to handle root directory
-- without adding a "./" prefix to paths
let useBasePath = null file || file == "."
fullPath = if useBasePath then basePath else basePath </> file
isDir <- liftIO $ doesDirectoryExist fullPath
case isDir of
False -> return [file] -- Just return the file path
True -> do
-- Get directory contents, excluding "." and ".."
contents <- liftIO $ getDirectoryContents fullPath
let validContents = filter (`notElem` [".", ".."]) contents
-- Recursively process subdirectories
subFiles <- findAllFiles fullPath validContents
-- Prepend current path to subdirectory files, but only if not the root dir
return $ if useBasePath
then subFiles -- For root dir, don't add any prefix
else map (file </>) subFiles -- Otherwise add directory prefix
-- | Safely remove a file, ignoring errors if it doesn't exist
safeRemoveFile :: FilePath -> ClodM ()
safeRemoveFile path = do
exists <- liftIO $ doesFileExist path
when exists $ liftIO $ removeFile path
-- | Safe file reading that checks capabilities
safeReadFile :: FileReadCap -> FilePath -> ClodM BS.ByteString
safeReadFile cap path = do
allowed <- liftIO $ isPathAllowed (cap ^. allowedReadDirs) path
if allowed
then liftIO $ BS.readFile path
else do
canonicalPath <- liftIO $ canonicalizePath path
throwError $ CapabilityError canonicalPath "Access denied: Cannot read file outside allowed directories"
-- | Safe file writing that checks capabilities
safeWriteFile :: FileWriteCap -> FilePath -> BS.ByteString -> ClodM ()
safeWriteFile cap path content = do
allowed <- liftIO $ isPathAllowed (cap ^. allowedWriteDirs) path
if allowed
then liftIO $ BS.writeFile path content
else do
canonicalPath <- liftIO $ canonicalizePath path
throwError $ CapabilityError canonicalPath "Access denied: Cannot write file outside allowed directories"
-- | Safe file copying that checks capabilities for both read and write
safeCopyFile :: FileReadCap -> FileWriteCap -> FilePath -> FilePath -> ClodM ()
safeCopyFile readCap writeCap src dest = do
srcAllowed <- liftIO $ isPathAllowed (readCap ^. allowedReadDirs) src
destAllowed <- liftIO $ isPathAllowed (writeCap ^. allowedWriteDirs) dest
if srcAllowed && destAllowed
then liftIO $ copyFile src dest
else do
canonicalSrc <- liftIO $ canonicalizePath src
canonicalDest <- liftIO $ canonicalizePath dest
let (path, errorMsg) = if not srcAllowed && not destAllowed
then (canonicalSrc, "Access denied: Both source and destination paths violate restrictions")
else if not srcAllowed
then (canonicalSrc, "Access denied: Source path violates restrictions")
else (canonicalDest, "Access denied: Destination path violates restrictions")
throwError $ CapabilityError path errorMsg