packages feed

streamly-zip-0.0.1: src/Streamly/External/Zip/Internal.hs

{-# LANGUAGE TypeApplications #-}

module Streamly.External.Zip.Internal where

import Control.Exception
import Control.Monad
import Control.Monad.IO.Class
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import Data.List
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe
import Foreign
import Foreign.C.String
import Foreign.C.Types
import Streamly.Data.Unfold (Unfold)
import Streamly.External.Zip.Internal.Error
import Streamly.External.Zip.Internal.Foreign
import Streamly.Internal.Data.IOFinalizer
import qualified Streamly.Internal.Data.Unfold as U
import Text.Printf

-- | A zip archive.
newtype Zip
  = -- A ForeignPtr works because zip_discard() returns void.
    Zip (ForeignPtr Zip_t)

-- (*) Certain libzip functionality (e.g., flags) has been commented out because it is currently not
-- applicable for this library, e.g., because this library is currently read-only.

data OpenFlag
  = -- Most libzip flags not included; see (*).

    -- | Perform additional stricter consistency checks on the archive, and error if they fail.
    O_CHECKCONS
  deriving (Eq, Ord)

-- | /Internal/.
openFlags :: Map OpenFlag CInt
openFlags =
  M.fromList
    [ (O_CHECKCONS, zip_checkcons)
    -- (O_CREATE, zip_create), -- See (*).
    -- (O_EXCL, zip_excl),
    -- (O_TRUNCATE, zip_truncate)
    -- (O_RDONLY, zip_rdonly)
    ]

-- | Opens the zip archive at the given file path.
--
-- /Warning/: To satisfy low-level libzip requirements, please use each 'Zip' from one thread
-- only—or make sure to synchronize its use. Note that it is perfectly fine to open multiple 'Zip's
-- for a single zip file on disk.
openZip :: FilePath -> [OpenFlag] -> IO Zip
openZip fp flags =
  -- This library is currently read-only; always open the archive in read-only mode; see (*).
  let flags' = zip_rdonly .|. combineFlags openFlags flags
   in withCString fp $ \fpc -> alloca $ \errp -> mask_ $ do
        zipp <- c_zip_open fpc flags' errp
        if zipp == nullPtr
          then do
            err <- libzipErrToString =<< peek errp
            throwError "openZip" err
          else Zip <$> newForeignPtr c_zip_discard_ptr zipp

-- See (*).
-- data NumEntriesFlag
--   = -- | The original number of entries is returned.
--     NE_FL_UNCHANGED
--   deriving (Eq, Ord)

-- numEntriesFlags :: Map NumEntriesFlag Zip_flags_t
-- numEntriesFlags =
--   M.fromList
--     [(NEF_FL_UNCHANGED, zip_fl_unchanged)]

-- getNumEntries :: Zip -> [NumEntriesFlag] -> IO Int

-- | Gets the number of entries in the given archive.
getNumEntries :: Zip -> IO Int
getNumEntries (Zip zipfp) =
  let flags' = 0 -- combineFlags numEntriesFlags flags; see (*).
   in do
        num <- withForeignPtr zipfp $ \zipp -> c_zip_get_num_entries zipp flags'
        if num < 0
          then -- c_zip_get_num_entries should not return -1 here, for zipp is known to be non-NULL.
            throwError "getNumEntries" "unexpected"
          else return $ fromIntegral num

data PathFlag
  = --
    --   -- | The original unchanged filename is returned. -- See (*).
    --   P_FL_UNCHANGED

    -- | Return the unmodified names as it is in the ZIP archive.
    P_FL_ENC_RAW
  | -- | (Default.) Guess the encoding of the name in the ZIP archive and convert it to UTF-8, if
    -- necessary. (Only CP-437 and UTF-8 are recognized.)
    P_FL_ENC_GUESS
  | -- | Follow the ZIP specification and expect CP-437 encoded names in the ZIP archive (except if
    -- they are explicitly marked as UTF-8). Convert it to UTF-8.
    P_FL_ENC_STRICT
  deriving (Eq, Ord)

-- | /Internal/.
pathFlags :: Map PathFlag Zip_flags_t
pathFlags =
  M.fromList
    [ -- (P_FL_UNCHANGED, zip_fl_unchanged) -- See (*).
      (P_FL_ENC_RAW, zip_fl_enc_raw),
      (P_FL_ENC_GUESS, zip_fl_enc_guess),
      (P_FL_ENC_STRICT, zip_fl_enc_strict)
    ]

-- | Gets the path (e.g., @"foo.txt"@, @"foo/"@, or @"foo/bar.txt"@) of the file at the given
-- 0-based index in the given zip archive. Please use 'getNumEntries' to find the upper bound for
-- the index.
getPathByIndex :: Zip -> Int -> [PathFlag] -> IO ByteString
getPathByIndex (Zip zipfp) idx flags =
  let flags' = combineFlags pathFlags flags
   in withForeignPtr zipfp $ \zipp -> do
        name <- c_zip_get_name zipp (fromIntegral idx) flags'
        if name == nullPtr
          then do
            err <- BC.unpack <$> (B.packCString =<< c_zip_strerror zipp)
            throwError "getPathByIndex" err
          else B.packCString name

-- | A file inside of a 'Zip' archive.
--
-- /Internal/.
data File
  = File
      !(Ptr Zip_file_t) -- ForeignPtr does not work because zip_fclose() does not return void.
      !IOFinalizer

data GetFileFlag
  = -- | Read the compressed data. Otherwise the data is uncompressed when reading.
    GF_FL_COMPRESSED
  -- -- | Read the original data from the zip archive, ignoring any changes made to the file.
  --  GF_FL_UNCHANGED
  deriving (Eq, Ord)

-- /Internal/.
getFileFlags :: Map GetFileFlag Zip_flags_t
getFileFlags =
  M.fromList
    [ (GF_FL_COMPRESSED, zip_fl_compressed)
    -- (GF_FL_UNCHANGED, zip_fl_unchanged)
    ]

-- | We don't publicly expose getting a 'File' (and then unfolding from it) because we don't want
-- users to unfold from the same 'File' more than once. (libzip’s @c_zip_fread@ isn’t designed for
-- iterating through a file more than once.)
--
-- /Internal/.
getFileByPathOrIndex :: Zip -> [GetFileFlag] -> Either String Int -> IO File
getFileByPathOrIndex (Zip zipfp) flags pathOrIdx = mask_ $ do
  let flags' = combineFlags getFileFlags flags
  filep <- case pathOrIdx of
    Left path ->
      withCString path $ \pathc -> withForeignPtr zipfp $ \zipp -> do
        filep <- c_zip_fopen zipp pathc flags'
        when (filep == nullPtr) $ do
          err <- BC.unpack <$> (B.packCString =<< c_zip_strerror zipp)
          -- Keep zip alive (at least) until we have gotten the above error. This should take care
          -- of touchForeignPtr’s “divergence” caveat. Ref (**).
          touchForeignPtr zipfp
          throwError "Error opening file at path" err
        return filep
    Right idx ->
      withForeignPtr zipfp $ \zipp -> do
        filep <- c_zip_fopen_index zipp (fromIntegral idx) flags'
        when (filep == nullPtr) $ do
          err <- BC.unpack <$> (B.packCString =<< c_zip_strerror zipp)
          touchForeignPtr zipfp -- See (**).
          throwError "Error opening file at index" err
        return filep
  ref <- newIOFinalizer $ do
    ret <- c_zip_fclose filep
    touchForeignPtr zipfp -- See (**).
    when (ret /= 0) $
      throwError
        "Error closing file"
        (printf "zip_fclose() return code: %d" (fromIntegral @_ @Int ret))
  return $ File filep ref

-- /Internal/.
{-# INLINE unfoldFile #-}
unfoldFile :: (MonadIO m) => Unfold m (Zip, [GetFileFlag], Either String Int) ByteString
unfoldFile =
  U.mkUnfoldM
    ( \(z@(Zip zipfp), file@(File filep _), bufp, ref) -> liftIO $ do
        bytesRead <- c_zip_fread filep bufp chunkSize
        if bytesRead < 0
          then
            throwError
              "Error reading file"
              (printf "zip_fread() return value: %d" (fromIntegral @_ @Int bytesRead))
          else
            if bytesRead == 0
              then do
                runIOFinalizer ref
                -- Keep zip alive for (at least) the duration of the unfold. See also (**).
                touchForeignPtr zipfp
                return U.Stop
              else do
                bs <- B.packCStringLen (bufp, fromIntegral bytesRead)
                return $ U.Yield bs (z, file, bufp, ref)
    )
    ( \(z@(Zip zipfp), flags, pathOrIndex) -> liftIO $ mask_ $ do
        file@(File _ fileFinalizer) <- getFileByPathOrIndex z flags pathOrIndex
        bufp <- mallocBytes $ fromIntegral chunkSize
        ref <- newIOFinalizer $ do
          free bufp
          runIOFinalizer fileFinalizer
          touchForeignPtr zipfp -- See also (**).
        return (z, file, bufp, ref)
    )

-- /Internal/.
{-# INLINE chunkSize #-}
chunkSize :: Zip_uint64_t
chunkSize = 64000

-- /Internal/.
combineFlags :: (Ord flagType, Bits a, Num a) => Map flagType a -> [flagType] -> a
combineFlags allFlags =
  foldl'
    (\acc chosenFlag -> acc .|. fromMaybe (error "flag expected") (M.lookup chosenFlag allFlags))
    0

-- | Creates an @Unfold@ with which one can stream data out of the entry at the given path (e.g.,
-- @"foo.txt"@, @"foo/"@, or @"foo/bar.txt"@).
unfoldFileAtPath :: (MonadIO m) => Unfold m (Zip, [GetFileFlag], String) ByteString
unfoldFileAtPath = U.lmap (\(z, fl, p) -> (z, fl, Left p)) unfoldFile

-- | Creates an @Unfold@ with which one can stream data out of the entry at the given index. Please
-- use 'getNumEntries' to find the upper bound for the index.
unfoldFileAtIndex :: (MonadIO m) => Unfold m (Zip, [GetFileFlag], Int) ByteString
unfoldFileAtIndex = U.lmap (\(z, fl, idx) -> (z, fl, Right idx)) unfoldFile