packages feed

hslua-module-zip (empty) → 1.0.0

raw patch · 7 files changed

+820/−0 lines, 7 filesdep +basedep +bytestringdep +filepath

Dependencies added: base, bytestring, filepath, hslua-core, hslua-list, hslua-marshalling, hslua-module-system, hslua-module-zip, hslua-packaging, tasty, tasty-hunit, tasty-lua, text, time, zip-archive

Files

+ CHANGELOG.md view
@@ -0,0 +1,11 @@+# Changelog++`hslua-module-zips` uses [PVP Versioning][].++## hslua-module-zip-1.0.0++Release pending.++-   Initially created.++[PVP Versioning]: https://pvp.haskell.org
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright © 2020-2022 Albert Krewinkel++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,159 @@+# hslua-module-zip++[![GitHub CI][CI badge]](https://github.com/hslua/hslua/actions)+[![Hackage][Hackage badge]](https://hackage.haskell.org/package/hslua-module-zip)+[![Stackage Lts][Stackage Lts badge]](http://stackage.org/lts/package/hslua-module-zip)+[![Stackage Nightly][Stackage Nightly badge]](http://stackage.org/nightly/package/hslua-module-zip)+[![MIT license][License badge]](LICENSE)++[CI badge]: https://img.shields.io/github/workflow/status/hslua/hslua/CI.svg?logo=github+[Hackage badge]: https://img.shields.io/hackage/v/hslua-module-zip.svg?logo=haskell+[Stackage Lts badge]: http://stackage.org/package/hslua-module-zip/badge/lts+[Stackage Nightly badge]: http://stackage.org/package/hslua-module-zip/badge/nightly+[License badge]: https://img.shields.io/badge/license-MIT-blue.svg++Lua module to work with file zips.++## zip++Functions to create, modify, and extract files from zip archives.++The module can be called as a function, in which case it behaves+like the `zip` function described below.++Zip options are optional; when defined, they must be a table with+any of the following keys:++  - `recursive`: recurse directories when set to `true`;+  - `verbose`: print info messages to stdout;+  - `destination`: the value specifies the directory in which to extract;+  - `location`: value is used as path name, defining where files+    are placed.+  - `preserve_symlinks`: Boolean value, controlling whether+    symbolic links are preserved as such. This option is ignored+    on Windows.++## Functions++### Archive++`Archive (bytestring_or_entries)`++Reads an *Archive* structure from a raw zip archive or a list of+Entry items; throws an error if the given string cannot be decoded+into an archive.++*Since: 1.0.0*++Parameters:++bytestring_or_entries+:    (string|{ZipEntry,...})++Returns:++ -   (ZipArchive)++### Entry++`Entry (path, contents[, modtime])`++Generates a zip Entry from a filepath, the file's uncompressed+content, and the file's modification time.++*Since: 1.0.0*++Parameters:++path+:   file path in archive (string)++contents+:   uncompressed contents (string)++modtime+:   modification time (integer)++### read_entry++`read_entry (filepath, opts)`++Generates a ZipEntry from a file or directory.++*Since: 1.0.0*++Parameters:++filepath+:    (string)++opts+:   zip options (table)++Returns:++ -  a new zip archive entry (ZipEntry)++### zip++`zip (filepaths[, options])`++Package and compress the given files into a new Archive.++*Since: 1.0.0*++Parameters:++filepaths+:    list of files from which the archive is created. ({string,...})++options+:   zip options (table)++Returns:++ -  a new archive (ZipArchive)++## Types++### Archive++A zip archive with file entries.++#### Fields++`entries`+:   files in this zip archive ({Entry,...})++#### Methods++`extract([opts])`+:   Extract all files from this archive, creating directories as+    needed. Note that the last-modified time is set correctly only+    in POSIX, not in Windows. This function fails if encrypted+    entries are present.++    Use `archive:extract{destination = 'dir'}` to extract to+    subdirectory `dir`.++`bytestring()`+:   Returns the raw binary string representation of the archive.++### Entry++File or directory entry in a zip archive.++#### Fields:++`path`+:   relative path, using `/` as separator++`modtime`+:   modification time (seconds since unix epoch)++#### Methods:++`contents([password])`+:   Get the uncompressed contents of a zip entry. If `password` is+    given, then that password is used to decrypt the contents. An+    error is throws if decrypting fails.
+ hslua-module-zip.cabal view
@@ -0,0 +1,80 @@+cabal-version:       2.2+name:                hslua-module-zip+version:             1.0.0+synopsis:            Lua module to work with file zips.+description:         Module with function for creating, modifying, and+                     extracting files from zip archives.+homepage:            https://hslua.org/+bug-reports:         https://github.com/hslua/hslua/issues+license:             MIT+license-file:        LICENSE+author:              Albert Krewinkel+maintainer:          Albert Krewinkel <albert@zeitkraut.de>+copyright:           © 2020-2022 Albert Krewinkel+category:            Foreign+build-type:          Simple+extra-doc-files:     README.md+                     CHANGELOG.md+extra-source-files:  test/test-zip.lua+tested-with:         GHC == 8.0.2+                   , GHC == 8.2.2+                   , GHC == 8.4.4+                   , GHC == 8.6.5+                   , GHC == 8.8.4+                   , GHC == 8.10.7+                   , GHC == 9.0.2+                   , GHC == 9.2.3++source-repository head+  type:                git+  location:            https://github.com/hslua/hslua+  subdir:              hslua-module-zip++common common-options+  build-depends:       base              >= 4.9.1  && < 5+                     , bytestring+                     , filepath          >= 1.4    && < 1.5+                     , hslua-core        >= 2.1    && < 2.3+                     , hslua-list        >= 1.1    && < 1.2+                     , hslua-marshalling >= 2.1    && < 2.3+                     , hslua-packaging   >= 2.1    && < 2.3+                     , text              >= 1.2    && < 2.1+                     , time              >= 1.5    && < 1.14+                     , zip-archive       >= 0.4    && < 0.5++  ghc-options:         -Wall+                       -Wcompat+                       -Widentities+                       -Wincomplete-uni-patterns+                       -Wincomplete-record-updates+  if impl(ghc >= 8.0)+    ghc-options:       -Wredundant-constraints+  if impl(ghc >= 8.2)+    ghc-options:       -fhide-source-paths+  if impl(ghc >= 8.4)+    ghc-options:       -Wmissing-export-lists+                       -Wpartial-fields+  if impl(ghc >= 8.8)+    ghc-options:       -Wmissing-deriving-strategies++  default-language:    Haskell2010++library+  import:              common-options+  hs-source-dirs:      src+  exposed-modules:     HsLua.Module.Zip++test-suite hslua-module-zip-test+  import:              common-options+  type:                exitcode-stdio-1.0+  hs-source-dirs:      test+  main-is:             test-hslua-module-zip.hs+  build-depends:       hslua-module-zip+                     , hslua-module-system+                     , tasty+                     , tasty-hunit+                     , tasty-lua         >= 1.0    && < 1.1+                     , text+  ghc-options:         -threaded+                       -rtsopts+                       -with-rtsopts=-N
+ src/HsLua/Module/Zip.hs view
@@ -0,0 +1,297 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications    #-}+{-|+Module      : HsLua.Module.Zip+Copyright   : © 2022 Albert Krewinkel+License     : MIT+Maintainer  : Albert Krewinkel <albert+hslua@zeitkraut.de>++Lua module to work with file zips.+-}+module HsLua.Module.Zip (+  -- * Module+    documentedModule++  -- * Zip archives+  , typeArchive+  , mkArchive+  , read_entry+  , zip+  -- ** archive methods+  , extract+  , bytestring+  -- * Zip entry+  , typeEntry+  , peekEntryFuzzy+  -- ** entry methods+  , contents+  -- * Zip Options+  , peekZipOptions+  )+where++import Prelude hiding (zip)+import Control.Applicative (optional)+import Control.Monad ((<$!>))+import Codec.Archive.Zip (Archive, Entry, ZipOption (..), emptyArchive)+import Data.Maybe (catMaybes, fromMaybe)+#if !MIN_VERSION_base(4,11,0)+import Data.Semigroup (Semigroup(..))  -- includes (<>)+#endif+import Data.Time.Clock.POSIX (getPOSIXTime)+import Data.Version (Version, makeVersion)+import HsLua.Core+  ( LuaError, NumArgs (..), NumResults (..), Type(..), call, failLua+  , fromStackIndex, getfield, gettop, replace, liftIO, ltype+  , nth, nthBottom, setmetatable )+import HsLua.List (newListMetatable)+import HsLua.Marshalling+  ( Peeker, Pusher, choice, failPeek, liftLua, peekBool+  , peekFieldRaw, peekIntegral, peekLazyByteString, peekList, peekString+  , pushLazyByteString, pushList, pushIntegral, pushString+  , retrieving, typeMismatchMessage )+import HsLua.Packaging++import qualified Codec.Archive.Zip as Zip+import qualified Data.Text as T++#if MIN_VERSION_base(4,11,0)+import Data.Functor ((<&>))+#else+(<&>) :: Functor f => f a -> (a -> b) -> f b+(<&>) = flip fmap+#endif++-- | The @zip@ module specification.+documentedModule :: forall e. LuaError e => Module e+documentedModule = Module+  { moduleName = "zip"+  , moduleDescription = T.unwords+    [ "Function for creating, modifying, and extracting files from zip"+    , "archives."+    ]+  , moduleFields = fields+  , moduleFunctions = functions+  , moduleOperations =+    [ operation Call $ lambda+      ### (do+              -- call function `zip`+              _ <- getfield (nthBottom 1) (functionName @e zip)+              replace (nthBottom 1)+              nargs <- NumArgs . subtract 1 . fromStackIndex <$> gettop+              call nargs 1+              pure (NumResults 1))+      =?> "new Archive"+    ]+  }++-- | First published version of this library.+initialVersion :: Version+initialVersion = makeVersion [1,0,0]++--+-- Fields+--++-- | Exported fields.+fields :: [Field e]+fields = []+++--+-- Functions+--++-- | Exported functions+functions :: LuaError e => [DocumentedFunction e]+functions =+  [ mkArchive+  , mkEntry+  , read_entry+  , zip+  ]++-- | Creates a new 'Archive' from a list of files.+zip :: LuaError e => DocumentedFunction e+zip = defun "zip"+  ### (\filepaths mopts ->+         let opts = fromMaybe [] mopts+         in liftIO $! Zip.addFilesToArchive opts emptyArchive filepaths)+  <#> parameter (peekList peekString) "{string,...}"+       "filepaths" "list of files from which the archive is created."+  <#> opt (parameter peekZipOptions "table" "opts" "zip options")+  =#> udresult typeArchive "a new archive"+  #? T.unlines+     [ "Package and compress the given files into a new Archive." ]+  `since` initialVersion++-- | Creates a new 'ZipEntry' from a file; wraps 'Zip.readEntry'.+read_entry :: LuaError e => DocumentedFunction e+read_entry = defun "read_entry"+  ### (\filepath mopts -> liftIO $! Zip.readEntry (fromMaybe [] mopts) filepath)+  <#> parameter peekString "string" "filepath" ""+  <#> opt (parameter peekZipOptions "table" "opts" "zip options")+  =#> udresult typeEntry "a new zip archive entry"+  #? T.unlines+     [ "Generates a ZipEntry from a file or directory."+     ]+  `since` initialVersion++--+-- * Options+--+peekZipOptions :: LuaError e => Peeker e [ZipOption]+peekZipOptions = retrieving "Zip options" . \idx -> catMaybes <$> sequence+  [ optional (peekFieldRaw peekBool "recursive" idx) <&> \case+      Just True -> Just OptRecursive+      _         -> Nothing+  , optional (peekFieldRaw peekBool "verbose" idx) <&> \case+      Just True -> Just OptVerbose+      _         -> Nothing+  , optional (peekFieldRaw peekString "destination" idx) <&> \case+      Just fp -> Just (OptDestination fp)+      _       -> Nothing+  , optional (peekFieldRaw peekString "location" idx) <&> \case+      Just fp -> Just (OptLocation fp True)+      _       -> Nothing+  , optional (peekFieldRaw peekBool "preserve_symlinks" idx) <&> \case+      Just True -> (Just OptPreserveSymbolicLinks)+      _         -> Nothing+  ]++--+-- * Archive+--++-- | The Lua 'Archive' type+typeArchive :: LuaError e => DocumentedType e Archive+typeArchive = deftype "ZipArchive"+  []+  [ property "entries" "files in this zip archive"+    (pushEntries, Zip.zEntries)+    (peekList peekEntryFuzzy, \ar entries -> ar { Zip.zEntries = entries })+  , method extract+  , method bytestring+  ]++-- | Wrapper for 'Zip.toArchive'; converts a string into an Archive.+mkArchive :: LuaError e => DocumentedFunction e+mkArchive = defun "Archive"+  ### (\case+          Nothing                ->+            pure Zip.emptyArchive+          Just (Left bytestring') ->+            either failLua pure $ Zip.toArchiveOrFail bytestring'+          Just (Right entries)   ->+            pure $ foldr Zip.addEntryToArchive emptyArchive entries)+  <#> opt (parameter (choice [ fmap Left  . peekLazyByteString+                             , fmap Right . peekList peekEntryFuzzy ])+           "string|{ZipEntry,...}" "contents"+           "binary archive data or list of entries")+  =#> udresult typeArchive "new Archive"+  #? T.unlines+     [ "Reads an *Archive* structure from a raw zip archive or a list of"+     , "Entry items; throws an error if the given string cannot be decoded"+     , "into an archive."+     ]+  `since` initialVersion++-- | Returns the raw binary string representation of the archive;+-- wraps 'Zip.extractFilesFromArchive'+extract :: LuaError e => DocumentedFunction e+extract = defun "extract"+  ### (\archive mopts ->+         liftIO $! Zip.extractFilesFromArchive (fromMaybe [] mopts) archive)+  <#> udparam typeArchive "self" ""+  <#> opt (parameter peekZipOptions "table" "opts" "zip options")+  =#> []+  #? T.unlines+     [ "Extract all files from this archive, creating directories as needed."+     , "Note that the last-modified time is set correctly only in POSIX, not"+     , "in Windows. This function fails if encrypted entries are present."+     ]++-- | Returns the raw binary string representation of the archive.+bytestring :: LuaError e => DocumentedFunction e+bytestring = defun "bytestring"+  ### liftPure Zip.fromArchive+  <#> udparam typeArchive "self" ""+  =#> functionResult pushLazyByteString "string" "bytes of the archive"+  #? "Returns the raw binary string representation of the archive."++--+-- * Entry+--++-- | The Lua type for 'Entry' objects.+typeEntry :: LuaError e => DocumentedType e Entry+typeEntry = deftype "ZipEntry"+  []+  [ property "path" "relative path, using `/` as separator"+    (pushString, Zip.eRelativePath)+    (peekString, \entry path -> entry { Zip.eRelativePath = path })+  , property "modtime" "modification time (seconds since unix epoch)"+    (pushIntegral, Zip.eLastModified)+    (peekIntegral, \entry modtime -> entry { Zip.eLastModified = modtime})+  , method contents+  ]++-- | Creates a new 'ZipEntry' from a file; wraps 'Zip.readEntry'.+mkEntry :: LuaError e => DocumentedFunction e+mkEntry = defun "Entry"+  ### (\filepath contents' mmodtime -> do+          modtime <- maybe (floor <$> liftIO getPOSIXTime) pure mmodtime+          pure $ Zip.toEntry filepath modtime contents')+  <#> parameter peekString "string" "path" "file path in archive"+  <#> parameter peekLazyByteString "string" "contents" "uncompressed contents"+  <#> opt (parameter peekIntegral "integer" "modtime" "modification time")+  =#> udresult typeEntry "a new zip archive entry"+  #? T.unlines+     [ "Generates a ZipEntry from a filepath, uncompressed content, and"+     , "the file's modification time."+     ]+  `since` initialVersion++-- | Returns the uncompressed contents of a zip entry.+contents :: LuaError e => DocumentedFunction e+contents = defun "contents"+  ### (\entry mpasswd -> case mpasswd of+          Nothing -> return $! Zip.fromEntry entry+          Just passwd -> case Zip.fromEncryptedEntry passwd entry of+            Just contents' -> return $! contents'+            Nothing        -> failLua "Could not decrypt entry.")+  <#> udparam typeEntry "self" ""+  <#> opt (parameter peekString "string" "password" "password for entry")+  =#> functionResult pushLazyByteString "string" "binary contents"+  #? T.unlines+     [ "Get the uncompressed contents of a zip entry. If `password` is given,"+     , "then that password is used to decrypt the contents. An error is throws"+     , "if decrypting fails."+     ]++peekEntryFuzzy :: LuaError e => Peeker e Entry+peekEntryFuzzy = retrieving "ZipEntry" . \idx ->+  liftLua (ltype idx) >>= \case+    TypeUserdata -> peekUD typeEntry idx+    TypeTable    -> peekEntryFromTable idx+    _            -> failPeek =<<+                    typeMismatchMessage "ZipEntry userdata or table" idx++peekEntryFromTable :: LuaError e => Peeker e Entry+peekEntryFromTable idx = Zip.toEntry+  <$!> peekFieldRaw peekString "path" idx+  <*>  (peekFieldRaw (optional . peekIntegral) "modtime" idx >>= \case+          Nothing -> pure 0+          Just t  -> pure t)+  <*>  peekFieldRaw peekLazyByteString "contents" idx++-- | Pushes a list of entries as an Entries object, i.e., a list with+-- additional methods.+pushEntries :: LuaError e => Pusher e [Entry]+pushEntries es = do+  pushList (pushUD typeEntry) es+  newListMetatable "ZipEntry list" (pure ())+  setmetatable (nth 2)
+ test/test-hslua-module-zip.hs view
@@ -0,0 +1,61 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications  #-}+{-|+Module      : Main+Copyright   : © 2021-2022 Albert Krewinkel+License     : MIT+Maintainer  : Albert Krewinkel <albert+hslua@zeitkraut.de>+Stability   : stable+Portability : Requires language extensions ForeignFunctionInterface,+              OverloadedStrings.++Tests for the `zip` Lua module.+-}+module Main (main) where++import Control.Monad (void)+import HsLua.Core (Lua, top)+import HsLua.Packaging ( preloadModule, preloadModuleWithName, pushModule+                       , registerModule)+import HsLua.Module.Zip (documentedModule)+import Test.Tasty (TestTree, defaultMain, testGroup)+import Test.Tasty.HUnit (assertEqual, testCase)+import Test.Tasty.Lua (translateResultsFromFile)++import qualified HsLua.Core as Lua+import qualified HsLua.Module.System as System++main :: IO ()+main = do+  luaTestResults <- Lua.run @Lua.Exception $ do+    Lua.openlibs+    registerModule documentedModule+    registerModule System.documentedModule+    Lua.pop 1+    translateResultsFromFile "test/test-zip.lua"+  defaultMain $ testGroup "hslua-module-zip" [tests, luaTestResults]++-- | HSpec tests for the Lua 'system' module+tests :: TestTree+tests = testGroup "HsLua zip module"+  [ testCase "zip module can be pushed to the stack" $+      Lua.run (void (pushModule documentedModule) :: Lua ())++  , testCase "zip module can be added to the preloader" . Lua.run $ do+      Lua.openlibs+      preloadModule documentedModule+      assertEqual' "function not added to preloader" Lua.TypeFunction =<< do+        void $ Lua.getglobal "package"+          *> Lua.getfield top "preload"+          *> Lua.getfield top "zip"+        Lua.ltype (-1)++  , testCase "zip module can be loaded as hszip" . Lua.run $ do+      Lua.openlibs+      preloadModuleWithName documentedModule "hszip"+      assertEqual' "loading the module fails " Lua.OK =<<+        Lua.dostring "require 'hszip'"+  ]++assertEqual' :: (Show a, Eq a) => String -> a -> a -> Lua ()+assertEqual' msg expected = Lua.liftIO . assertEqual msg expected
+ test/test-zip.lua view
@@ -0,0 +1,191 @@+--+-- Tests for the system module+--+local zip = require 'zip'+local tasty = require 'tasty'+local system = require 'system'++local group = tasty.test_group+local test = tasty.test_case+local assert = tasty.assert++local make_sample_archive = function (opts)+  opts = opts or {}+  local filename = opts.filename or 'greetings.txt'+  local contents = opts.contents or 'Hello Bob!\n'+  return system.with_tmpdir('archive', function (tmpdir)+    return system.with_wd(tmpdir, function ()+      local fh = io.open(filename, 'w')+      fh:write(contents)+      fh:close()+      return zip{filename}+    end)+  end)+end++local empty_archive = '\80\75\5\6\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0'+-- Check existence static fields+return {+  group 'Archive' {+    test('empty archive', function ()+      assert.are_equal(type(zip.Archive()), 'userdata')+    end),+  },++  group 'zip' {+    test('archive with file', function ()+      system.with_tmpdir('archive', function (tmpdir)+        system.with_wd(tmpdir, function ()+          local filename = 'greetings.txt'+          local fh = io.open(filename, 'w')+          fh:write('Hi Mom!\n')+          fh:close()+          assert.are_equal(+            type(zip.zip{filename}),+            'userdata'+          )+        end)+      end)+    end),++    test('recursive', function ()+      system.with_tmpdir('archive', function (tmpdir)+        system.with_wd(tmpdir, function ()+          local dirname = 'greetings'+          local filename = dirname .. '/' .. 'french.txt'+          system.mkdir(dirname)+          local fh = io.open(filename, 'w')+          fh:write('Bonjour!\n')+          fh:close()+          local archive = zip.zip({dirname}, {recursive=true})+          assert.are_equal(+            archive.entries[2].path,+            filename+          )+        end)+      end)+    end),++    test('module metamethod', function()+      local filename = 'greetings.txt'+      local fh = io.open(filename, 'w')+      fh:write('Hi Mom!\n')+      fh:close()+      assert.are_equal(+        zip.zip{filename}:bytestring(),+        zip{filename}:bytestring()+      )+    end)+  },++  group 'extract' {+    test('archive with file', function ()+      system.with_tmpdir('archive', function (tmpdir)+        system.with_wd(tmpdir, function ()+          local filename = 'greetings.txt'+          local archive = make_sample_archive{+            filename = filename,+            contents = 'Hi Mom!\n',+          }+          archive:extract{destination = 'foo'}+          assert.are_equal(system.ls()[1], 'foo')+          assert.are_equal(+            io.open('foo/' .. filename):read 'a',+            'Hi Mom!\n'+          )+        end)+      end)+    end),+    test('archive with file', function ()+      system.with_tmpdir('archive', function (tmpdir)+        system.with_wd(tmpdir, function ()+          local filename = 'greetings.txt'+          local archive = make_sample_archive{+            filename = filename,+            contents = 'Hi Mom!\n',+          }+          archive:extract()++          assert.are_equal(system.ls()[1], filename)+          assert.are_equal(+            io.open(filename):read 'a',+            'Hi Mom!\n'+          )+        end)+      end)+    end)+  },++  group 'entries' {+    test('empty archive', function ()+      assert.are_equal(#zip.Archive().entries, 0)+    end),++    test('archive with file', function ()+      local archive = make_sample_archive{filename='greetings.txt'}+      assert.are_equal(+        archive.entries[1].path,+        'greetings.txt'+      )+    end),++    test('has type "ZipEntry list"', function ()+      local archive = make_sample_archive()+      assert.are_equal(getmetatable(archive.entries).__name, 'ZipEntry list')+    end),++    test('has `insert` method', function ()+      local archive = make_sample_archive()+      assert.are_equal(type(archive.entries.insert), 'function')+    end),+  },++  group 'read_entry' {+    test('has correct file path', function ()+      system.with_tmpdir('archive', function (tmpdir)+        system.with_wd(tmpdir, function ()+          local filename = 'greetings.txt'+          local fh = io.open(filename, 'w')+          fh:write('Hi Mom!\n')+          fh:close()+          local entry = zip.read_entry(filename)+          assert.are_equal(entry.path, filename)+        end)+      end)+    end),++    test('has contents', function ()+      system.with_tmpdir('archive', function (tmpdir)+        system.with_wd(tmpdir, function ()+          local filename = 'greetings.txt'+          local fh = io.open(filename, 'w')+          fh:write('Hallo!\n')+          fh:close()+          local entry = zip.read_entry(filename)+          assert.are_equal(+            entry:contents('foo'),+            'Hallo!\n'+          )+        end)+      end)+    end)+  },++  group 'bytestring' {+    test('empty archive', function ()+      assert.are_equal(zip.Archive():bytestring(), empty_archive)+    end),+  },++  group 'Archive constructor' {+    test('empty archive', function ()+      assert.are_equal(type(zip.Archive(empty_archive)), 'userdata')+    end),+    test('misformed archive', function ()+      assert.error_matches(+        function () zip.Archive(empty_archive:sub(2)) end,+        ''+      )+    end),+  },+}