data-embed-0.1.0.0: Data/Embed/File.hs
{-# LANGUAGE RecordWildCards #-}
-- | Reading/writing bundles of embedded files and other data from/to
-- executables.
--
-- A bundle has three parts: the static header, which identifies
-- a string of bytes as a bundle using a particular version of the format
-- and gives the size of the dynamic header; the dynamic header which
-- describes all files and directories contained in the bundle; and the data
-- part, where the data for all files is located.
-- All words are stored in little endian format.
--
-- The static header comprises the last 'bundleHeaderStaticSize' bytes of the
-- file, with the dynamic header coming immediately before, and the data
-- section coming immediately before the dynamic header.
--
-- The dynamic header is stored as a tuple of the number of files in the
-- bundle (Word32), and the .
-- Each file is stored as a triple of the file's UTF-8 encoded path,
-- its offset from the start of the data section, and its size.
-- The path is prepended with a Word32 giving its length in bytes; the
-- offset is given as a Word32 and the size is given as a Word32.
--
-- The layout of the bundle format is given in the following table:
--
-- > [file data]
-- >
-- > [files] * hdrNumFiles
-- > pathLen : Word32
-- > path : pathLen * Word8
-- > offset : Word64
-- > size : Word32
-- >
-- > [static header]
-- > hdrDataOffset : Word64
-- > hdrNumFiles : Word32
-- > hdrDynSize : Word32
-- > hdrVersion : Word8
-- > "BNDLLDNB" : Word64
--
-- The included @embedtool@ program offers a command line interface
-- for manipulating and inspecting bundles.
module Data.Embed.File (
Bundle,
-- * Reading bundles
hasBundle, openBundle, withBundle, closeBundle, readBundleFile, readBundle,
listBundleFiles,
-- * Creating bundles
File (..), appendBundle, eraseBundle, replaceBundle
) where
import Control.Concurrent
import Control.Exception
import Control.Monad
import qualified Data.ByteString as BS
import Data.Hashable
import qualified Data.IntMap as M
import Data.Serialize
import Data.String
import System.Directory
import System.IO
import Data.Embed.Header
-- | A file to be included in a bundle. May be either the path to a file on
-- disk or an actual (path, data) pair.
--
-- If a file path refers to a directory, all non-dotfile files and
-- subdirectories of that directory will be included in the bundle.
-- File paths also have a strip number: the number of leading directories
-- to strip from file names when adding them to a bundle.
-- For instance, adding @File 1 "foo/bar"@ to a bundle will add the file
-- @foo/bar@, under the name @bar@ within the bundle.
--
-- If a file name would "disappear" entirely due to stripping, for instance
-- when stripping two directories from @foo/bar@, @bar@ will "disappear"
-- entirely and so will be silently ignored.
data File = FilePath Int FilePath | FileData FilePath BS.ByteString
instance IsString File where
fromString = FilePath 0
-- | A handle to a file bundle. Bundle handles are obtained using 'openBundle'
-- or 'withBundle' and start out as open. That is, files may be read from
-- them. An open bundle contains an open file handle to the bundle's backing
-- file, ensuring that the file data will not disappear from under it.
-- When a bundle becomes unreachable, its corresponding file handle is
-- closed by the bundle's associated finalizer.
--
-- However, as finalizers are not guaranteed to run promptly - or even
-- at all - bundles may also be closed before becoming unreachable using
-- 'closeBundle'. If you expect to perform other operations on a bundle's
-- backing file, you should always close the bundle manually first.
data Bundle = Bundle {
-- | The header of the bundle.
bundleHeader :: !BundleHeader,
-- | An open handle to the bundle's file, for reading file data.
bundleHandle :: !Handle,
-- | The offset from the end of the file to the start of the data section.
bundleDataOffset :: !Integer,
-- | Lock to ensure thread safety of read/close operations. Iff 'True', then
-- the bundle is still alive, i.e. the handle is still open.
bundleLock :: !(MVar Bool)
}
-- | Read a bundle static header from the end of a file.
readStaticHeader :: Handle -> IO (Either String StaticHeader)
readStaticHeader hdl = do
hSeek hdl SeekFromEnd (negate bundleHeaderStaticSize)
ehdr <- decode <$> BS.hGet hdl (fromInteger bundleHeaderStaticSize)
case ehdr of
Left e ->
pure (Left $ "unable to parse static header: " ++ e)
Right hdr
| hdrMagicNumber hdr /= bundleMagicNumber ->
pure (Left "not a bundle")
| hdrVersion hdr > bundleCurrentVersion ->
pure (Left "unsupported bundle version")
| otherwise ->
pure (Right hdr)
-- | Open a file bundle. The bundle will keep an open handle to its backing
-- file. The handle will be closed when the bundle is garbage collected.
-- Use 'closeBundle' to close the handle before the
openBundle :: FilePath -> IO (Either String Bundle)
openBundle fp = flip catch (\(SomeException e) -> pure (Left $ show e)) $ do
-- Read static header
hdl <- openBinaryFile fp ReadMode
ehdr <- readStaticHeader hdl
case ehdr of
Left err -> pure (Left err)
Right statichdr -> do
-- Read dynamic header
let dynsize = fromIntegral (hdrDynSize statichdr)
dynoffset = bundleHeaderStaticSize + dynsize
hSeek hdl SeekFromEnd (negate dynoffset)
bytes <- BS.hGet hdl (fromInteger dynsize)
-- Parse dynamic header and create bundle
case runGet (getHdrFiles (hdrNumFiles statichdr)) bytes of
Left e -> fail $ "unable to parse file list: " ++ e
Right filehdr -> do
let hdr = BundleHeader {
hdrFiles = filehdr,
hdrStatic = statichdr
}
lock <- newMVar True
let b = Bundle hdr hdl (fromIntegral (hdrDataOffset statichdr)) lock
_ <- mkWeakMVar lock (closeBundle b)
pure $! Right $! b
-- | Close a bundle before it becomes unreachable. After a bundle is closed,
-- any read operations performed on it will fail as though the requested
-- file could not be found.
-- Subsequent close operations on it will have no effect.
closeBundle :: Bundle -> IO ()
closeBundle b = do
alive <- takeMVar (bundleLock b)
when alive $ hClose (bundleHandle b)
putMVar (bundleLock b) False
-- | Perform a computation over a bundle, returning an error if either the
-- computation failed or the bundle could not be loaded.
-- The bundle is always closed before this function returns, regardless of
-- whether an error occurred.
withBundle :: FilePath -> (Bundle -> IO a) -> IO (Either String a)
withBundle fp f = do
eb <- openBundle fp
case eb of
Right b -> fmap Right (f b) `catch` handler <* closeBundle b
Left e -> return (Left e)
where
handler (SomeException e) = pure (Left $ show e)
-- | Write a bundle to a file. If the given file already has a bundle, the new
-- bundle will be written *after* the old one. The old bundle will thus still
-- be present in the file, but only the new one will be recognized by
-- 'openBundle' and friends.
appendBundle :: FilePath -> [File] -> IO ()
appendBundle fp fs = withBinaryFile fp AppendMode $ \hdl -> do
(datasize, metadata) <- foldM (packFile hdl) (0, M.empty) fs
let mdbytes = putHdrFiles metadata
mdlen = BS.length mdbytes
dataoff = datasize + fromIntegral mdlen
+ fromIntegral bundleHeaderStaticSize
hdr = mkStaticHdr (M.size metadata) mdlen dataoff
BS.hPut hdl mdbytes
BS.hPut hdl (encode hdr)
where
isDotFile ('.':_) = True
isDotFile _ = False
p </> f = p ++ "/" ++ f
stripLeading 0 f = f
stripLeading n f = stripLeading (n-1) (drop 1 (dropWhile (/= '/') f))
packFile hdl acc (FilePath n p) = do
let stripped = stripLeading n p
if null stripped
then return acc
else do
isDir <- doesDirectoryExist p
if isDir
then do
files <- filter (not . isDotFile) <$> getDirectoryContents p
foldM (packFile hdl) acc (map (FilePath n . (p </>)) files)
else BS.readFile p >>= packFile hdl acc . FileData stripped
packFile hdl (off, m) (FileData p d) = do
BS.hPut hdl d
let len = fromIntegral (BS.length d)
off' = off + fromIntegral len
off' `seq` return (off', (M.alter (ins p off len) (hash p) m))
ins p off len Nothing = Just [(p, (off, len))]
ins p off len (Just xs) = Just ((p, (off, len)) : xs)
-- | Read a file from a previously opened bundle. Will fail of the given path
-- is not found within the bundle, or if the bundle is no longer alive, i.e.
-- it has been closed using 'closeBundle'.
readBundleFile :: Bundle -> FilePath -> IO (Either String BS.ByteString)
readBundleFile (Bundle {..}) fp =
flip catch (\(SomeException e) -> pure (Left $ show e)) $ do
withMVar bundleLock $ \v -> do
when (v == False) $ fail "bundle already closed"
case M.lookup (hash fp) (hdrFiles bundleHeader) >>= lookup fp of
Nothing -> fail "no such file"
Just (off, sz) -> do
hSeek bundleHandle SeekFromEnd (fromIntegral off - bundleDataOffset)
Right <$> BS.hGet bundleHandle (fromIntegral sz)
-- | List all files in the given bundle. Will succeed even on closed bundles.
listBundleFiles :: Bundle -> [FilePath]
listBundleFiles = map fst . concat . M.elems . hdrFiles . bundleHeader
-- | Like 'readBundleFile', but attempts to decode the file's contents into an
-- appropriate Haskell value.
readBundle :: Serialize a => Bundle -> FilePath -> IO (Either String a)
readBundle b fp = do
ebytes <- readBundleFile b fp
pure (ebytes >>= decode)
-- | Does the given file contain a bundle or not?
hasBundle :: FilePath -> IO Bool
hasBundle fp = do
ehdr <- withBinaryFile fp ReadMode readStaticHeader
case ehdr of
Right _ -> pure True
_ -> pure False
-- | Remove a bundle from an existing file. Does nothing if the given file
-- does not have a bundle. The given file is *not* removed, even if it only
-- contains the bundle.
eraseBundle :: FilePath -> IO ()
eraseBundle fp = do
withBinaryFile fp ReadWriteMode $ \hdl -> do
ehdr <- readStaticHeader hdl
case ehdr of
Right hdr -> do
sz <- hFileSize hdl
hSetFileSize hdl (sz - fromIntegral (hdrDataOffset hdr))
_ -> return ()
-- | Replace the bundle currently attached to the given file. Equivalent to
-- 'appendBundle' if the given file does not already have a bundle attached.
replaceBundle :: FilePath -> [File] -> IO ()
replaceBundle fp fs = eraseBundle fp >> appendBundle fp fs