packages feed

libfuse3-0.1.0.0: example/statjson.hs

{-# LANGUAGE DisambiguateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
-- Copyright: (c) 2020 yohashi
-- License  : MIT
--
-- A read-only filesystem that exposes file stats in JSON files.
--
-- This filesystem mirrors the content of a source directory along with JSON files generated
-- next to each file. JSON files have \".stat.json\" at the end of their names.
--
-- For example:
--
-- Source directory:
--
-- > a.jpg
-- > b
-- > b/c.txt
--
-- Mounted directory:
--
-- > rootdir.stat.json  <- struct stat of the source directory
-- > a.jpg
-- > a.jpg.stat.json    <- struct stat of a.jpg
-- > b
-- > b.stat.json        <- struct stat of b
-- > b/c.txt
-- > b/c.txt.stat.json  <- struct stat of b/c.txt
--
-- If the source directory contains files which already have \".stat.json\" at the end of
-- their names, they are shadowed (but their own JSON files are still generated, so you'll
-- see \".stat.json.stat.json\" files).
--
-- This example demonstrates the expressiveness of Haskell. It uses the \"layered\"
-- approach. There are three parts in this program; the part that generates
-- \"rootdir.stat.json\", another for generating \".stat.json\" for each file, and the other
-- that provides a read-only view of the source directory. For each of the parts a set of
-- filesystem operations is defined as a layer. Operations may process and respond to
-- filesystem requests themselves or delegate to the next layer. Finally the layers are
-- combined to a @FuseOperations@.
module Main(main) where

import Data.Aeson ((.=))
import Data.Bits ((.&.), (.|.), complement)
import Data.ByteString (ByteString)
import Data.Void (Void)
import Foreign (Ptr, allocaBytes)
import Foreign.C (CInt(CInt), CSize(CSize), Errno, eIO, eNOSYS, eOPNOTSUPP)
import Numeric (showHex)
import System.Clock (TimeSpec(TimeSpec))
import System.Directory (doesPathExist, listDirectory, makeAbsolute)
import System.Environment (getArgs, withArgs)
import System.FilePath ((<.>), dropTrailingPathSeparator, stripExtension)
import System.LibFuse3
import System.IO (hPrint, stderr)
import System.Posix.IO (OpenFileFlags, OpenMode(ReadOnly), closeFd, openFd)
import System.Posix.Files (regularFileMode, stdFileMode)
import System.Posix.Types (COff(COff), CSsize(CSsize), ByteCount, Fd(Fd), FileOffset)

import qualified Data.Aeson as Aeson
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as BL
import qualified System.Clock

foreign import ccall "pread"
  c_pread :: CInt -> Ptr a -> CSize -> COff -> IO CSsize

-- | An outcome of an individual operation in a layer.
data Outcome a
  -- | The request is forwarded to the next layer.
  = Delegate
  -- | The request is handled by the operation.
  | Respond a
  -- | The request is handled by both the operation and the next layer, and the results are
  -- merged by the function.
  | Merge a (a -> a -> a)

type Action a = IO (Either Errno (Outcome a))

-- | A set of operations, or a layer.
data OpsLayer fh = OpsLayer
  { layerGetAttr  :: FilePath -> Action FileStat
  , layerReaddir  :: FilePath -> Action [(String, Maybe FileStat)]
  -- | `Merge` is treated as same as `Respond`
  , layerOpen     :: FilePath -> OpenMode -> OpenFileFlags -> Action fh
  , layerRead     :: fh -> ByteCount -> FileOffset -> IO (Either Errno ByteString)
  , layerRelease  :: fh -> IO ()
  }

removeWriteModes :: FileStat -> FileStat
removeWriteModes stat = stat { fileMode = go (fileMode stat) }
  where
  go mode = mode .&. complement 0o222

-- | Provides the read-only view of the source directory.
passthroughOps :: FilePath -> OpsLayer Fd
passthroughOps srcDir = OpsLayer
  -- note that we can't use (</>) to concatenate to srcDir because paths passed from libfuse start with '/'
  -- if srcDir has a trailing slash it makes double slashes but we ignore it
  { layerGetAttr = \path -> tryErrno . fmap Respond . fmap removeWriteModes . getFileStat . (srcDir <>) $ path
  , layerReaddir = \path -> tryErrno $ fmap Respond $ do
      entries <- listDirectory $ srcDir <> path
      pure $ map (\entry -> (entry, Nothing)) entries
  , layerOpen = \path mode flags ->
      case mode of
        ReadOnly -> tryErrno $ fmap Respond $ openFd (srcDir <> path) mode Nothing flags
        _ -> pure $ Left eOPNOTSUPP
  , layerRead = \(Fd fd) size offset -> tryErrno $
      allocaBytes (fromIntegral size) $ \buf -> do
        readBytes <- c_pread fd buf size offset
        B.packCStringLen (buf, fromIntegral readBytes)
  , layerRelease = closeFd
  }

-- | Presents the \"\/roodir.stat.json\" file whose content is the metadata of the source
-- directory.
generateRootStatJsonOps :: FilePath -> OpsLayer FileStat
generateRootStatJsonOps srcDir = OpsLayer
  { layerGetAttr = \path ->
      if path /= "/rootdir.stat.json"
        then pure $ Right Delegate
        else tryErrno $ do
          -- borrow the original file's stat for the generated JSON's one
          srcDirStat <- getFileStat $ srcDir
          pure $ Respond $ srcDirStat
            { fileMode = 0o444 .|. entryTypeToFileMode RegularFile
            , linkCount = 1
            , fileSize = fromIntegral $ BL.length $ generateFileStatJson srcDirStat
            }

  , layerReaddir = \path -> pure $ Right $ if path /= "/"
      then Delegate
      else Merge [("rootdir.stat.json", Nothing)] (<>)

  , layerOpen = \path mode _ -> if path /= "/rootdir.stat.json"
      then pure $ Right Delegate
      else case mode of
        ReadOnly -> tryErrno $ fmap Respond $ getFileStat srcDir
        _ -> pure $ Left eOPNOTSUPP

  , layerRead = \stat size off -> pure $ Right
      $ BL.toStrict
      $ BL.take (fromIntegral size)
      $ BL.drop (fromIntegral off)
      $ generateFileStatJson stat

  , layerRelease = \_ -> pure ()
  }

-- | Encodes a `FileStat` into a JSON data.
generateFileStatJson :: FileStat -> BL.ByteString
generateFileStatJson stat = Aeson.encode $ Aeson.object
  [ "st_ino"    .= toInt (fileID stat)
  , "st_mode"   .= ("0x" <> showHex (fileMode stat) "")
  , "st_nlink"  .= toInt (linkCount stat)
  , "st_uid"    .= toInt (fileOwner stat)
  , "st_gid"    .= toInt (fileGroup stat)
  , "st_rdev"   .= toInt (specialDeviceID stat)
  , "st_size"   .= toInt (fileSize stat)
  , "st_blocks" .= toInt (blockCount stat)
  , "st_atim"   .= timeSpecToJson (accessTimeHiRes stat)
  , "st_mtim"   .= timeSpecToJson (modificationTimeHiRes stat)
  , "st_ctim"   .= timeSpecToJson (statusChangeTimeHiRes stat)
  ]
  where
  toInt x = fromIntegral x :: Int
  timeSpecToJson TimeSpec{sec, nsec} = Aeson.object [ "sec" .= sec, "nsec" .= nsec ]

-- | Presents a \".stat.json\" file for each file in the source directory.
generateFileStatJsonOps :: FilePath -> OpsLayer FileStat
generateFileStatJsonOps srcDir = OpsLayer
  { layerGetAttr = \path ->
      case stripExtension ".stat.json" path of
        Nothing -> pure $ Right Delegate
        Just origFilePath -> tryErrno $ do
          origFileExists <- doesPathExist $ srcDir <> origFilePath
          if not origFileExists
            then pure Delegate
            else do
              -- borrow the original file's stat for the generated JSON's one
              origFileStat <- getFileStat $ srcDir <> origFilePath
              pure $ Respond $ removeWriteModes $ origFileStat
                { fileMode = regularFileMode .|. fileMode origFileStat .&. stdFileMode
                , fileSize = fromIntegral $ BL.length $ generateFileStatJson origFileStat
                }

  , layerReaddir = \path -> tryErrno $ do
      origEntries <- listDirectory $ srcDir <> path
      let statJsonEntries = map (\name -> (name, Nothing)) $ map (<.> ".stat.json") origEntries
      pure $ Merge statJsonEntries (<>)

  , layerOpen = \path mode _ ->
      case mode of
        ReadOnly -> case stripExtension ".stat.json" path of
          Nothing -> pure $ Right Delegate
          Just origFilePath -> tryErrno $ do
            origFileExists <- doesPathExist $ srcDir <> origFilePath
            if not origFileExists
              then pure Delegate
              else do
                origFileStat <- getFileStat $ srcDir <> origFilePath
                pure $ Respond origFileStat
        _ -> pure $ Left eOPNOTSUPP

  , layerRead = \stat size off -> pure $ Right
      $ BL.toStrict
      $ BL.take (fromIntegral size)
      $ BL.drop (fromIntegral off)
      $ generateFileStatJson stat

  , layerRelease = \_ -> pure ()
  }

-- poor man's ExceptT
andThen :: IO (Either e a) -> (a -> IO (Either e b)) -> IO (Either e b)
andThen io f = io >>= \case
  Left e -> pure $ Left e
  Right a -> f a

-- | Chains two `Action`s into one.
--
-- Runs the first `Action` and then run the second if needed.
chainActions :: Action a -> Action a -> Action a
chainActions a1 a2 = a1 `andThen` \case
  Respond a  -> pure $ Right $ Respond a
  Delegate   -> a2
  Merge a fa -> a2 >>= pure . fmap (\case
    Delegate   -> Merge a fa
    Respond b  -> Respond $ fa a b
    Merge b fb -> Merge (fa a b) fb
    )

infixl 2 `chainActions`

-- | Runs (possibly a chain of) `Action`(s).
--
-- If it returns `Merge`, it is treated as `Respond`.
runAction :: Action a -> IO (Either Errno a)
runAction = fmap $ \case
  Left e -> Left e
  Right Delegate -> Left eNOSYS
  Right (Respond a) -> Right a
  Right (Merge a _) -> Right a

data TheHandle
  = GenRootStatHandle FileStat
  | GenFileStatHandle FileStat
  | PassthroughHandle Fd

-- | Merges the three layers into one, and converts it to `FuseOperations`.
ops :: FilePath -> FuseOperations TheHandle Void
ops srcDir = defaultFuseOperations
  { -- return the response of the first layer to respond.
    fuseGetattr = Just $ \path _ -> runAction $
      layerGetAttr (generateRootStatJsonOps  srcDir) path `chainActions`
      layerGetAttr (generateFileStatJsonOps srcDir) path `chainActions`
      layerGetAttr (passthroughOps          srcDir) path
  , -- return the response of the first layer to respond.
    fuseReaddir = Just $ \path _ -> runAction $
      layerReaddir (generateRootStatJsonOps  srcDir) path `chainActions`
      layerReaddir (generateFileStatJsonOps srcDir) path `chainActions`
      layerReaddir (passthroughOps          srcDir) path
  , -- try each layers in the order and put the result into the 3-way handle.
    -- here we treat `Merge` as if `Respond`.
    fuseOpen = Just $ \path mode flags ->
      layerOpen (generateRootStatJsonOps srcDir) path mode flags `andThen` \case
        Respond x -> pure $ Right $ GenRootStatHandle x
        Merge x _ -> pure $ Right $ GenRootStatHandle x
        Delegate -> layerOpen (generateFileStatJsonOps srcDir) path mode flags `andThen` \case
          Respond x -> pure $ Right $ GenFileStatHandle x
          Merge x _ -> pure $ Right $ GenFileStatHandle x
          Delegate -> layerOpen (passthroughOps srcDir) path mode flags `andThen` \case
            Respond x -> pure $ Right $ PassthroughHandle x
            Merge x _ -> pure $ Right $ PassthroughHandle x
            Delegate -> pure $ Left eNOSYS
  , -- branch on the 3-way handle created by fuseOpen.
    fuseRead = Just $ \_ -> \case
      GenRootStatHandle stat -> layerRead (generateRootStatJsonOps srcDir) stat
      GenFileStatHandle stat -> layerRead (generateFileStatJsonOps srcDir) stat
      PassthroughHandle fd   -> layerRead (passthroughOps srcDir) fd
  , -- branch on the 3-way handle created by fuseOpen.
    fuseRelease = Just $ \_ -> \case
      GenRootStatHandle stat -> layerRelease (generateRootStatJsonOps srcDir) stat
      GenFileStatHandle stat -> layerRelease (generateFileStatJsonOps srcDir) stat
      PassthroughHandle fd   -> layerRelease (passthroughOps srcDir) fd
  }

-- |
--
-- In addition to the common commandline arguments of libfuse applications, this accepts
-- @--source PATH@ which takes the source directory.
main :: IO ()
main = do
  (srcDir', rest) <- do
    args <- getArgs
    case break (=="--source") args of
      (pre, _source:path:suf) -> pure (path, pre <> suf)
      (_, _source:[]) -> fail "`--source` option expects a path"
      (_, []) -> fail "requires an option `--source PATH`"
  srcDir <- dropTrailingPathSeparator <$> makeAbsolute srcDir'
  withArgs rest $ fuseMain (ops srcDir) (\e -> hPrint stderr (e :: IOError) >> pure eIO)