diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/hslua-module-zip.cabal b/hslua-module-zip.cabal
new file mode 100644
--- /dev/null
+++ b/hslua-module-zip.cabal
@@ -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
diff --git a/src/HsLua/Module/Zip.hs b/src/HsLua/Module/Zip.hs
new file mode 100644
--- /dev/null
+++ b/src/HsLua/Module/Zip.hs
@@ -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)
diff --git a/test/test-hslua-module-zip.hs b/test/test-hslua-module-zip.hs
new file mode 100644
--- /dev/null
+++ b/test/test-hslua-module-zip.hs
@@ -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
diff --git a/test/test-zip.lua b/test/test-zip.lua
new file mode 100644
--- /dev/null
+++ b/test/test-zip.lua
@@ -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),
+  },
+}
