diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2026, Henning Thielemann
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Henning Thielemann nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.lhs b/Setup.lhs
new file mode 100644
--- /dev/null
+++ b/Setup.lhs
@@ -0,0 +1,3 @@
+#! /usr/bin/env runhaskell
+> import Distribution.Simple
+> main = defaultMain
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,166 @@
+module Main where
+
+import qualified Options.Applicative as OP
+import System.Posix.Types (FileMode)
+
+import qualified Codec.Archive.Zip.Unix as ZipUnix
+import qualified Codec.Archive.Zip as Zip
+import qualified Data.Conduit.Tar as CTar
+import qualified Conduit as C
+import Data.Conduit (runConduitRes, (.|))
+
+import qualified Data.Time.Clock.POSIX as PosixTime
+import qualified Data.ByteString.Char8 as BC
+import qualified Data.Map as Map
+import qualified Data.List as List
+import qualified Data.Char as Char
+import Data.Traversable (for)
+import Data.Ord (comparing)
+import Data.Bits ((.&.), (.|.))
+import Data.Word (Word32)
+
+import Control.Monad (join, void)
+
+
+
+info :: OP.Parser a -> OP.ParserInfo a
+info p =
+   OP.info
+      (OP.helper <*> p)
+      (OP.fullDesc <>
+       OP.progDesc "Convert ZIP to TAR archive")
+
+data Sort = SortPosition | SortLexicographic
+   deriving (Eq, Ord)
+
+parser :: OP.Parser (IO ())
+parser =
+   pure convertZipToTar
+   <*> parseInfoConversion
+   <*>
+      OP.flag SortPosition SortLexicographic
+         (OP.long "sort-by-name" <>
+          OP.help
+            "sort files by names (default: storage order)")
+   <*>
+      OP.strArgument
+         (OP.metavar "INPUT" <>
+          OP.help "Input ZIP archive")
+
+parseInfoConversion ::
+   OP.Parser (Zip.EntrySelector -> Zip.EntryDescription -> CTar.FileInfo)
+parseInfoConversion =
+   pure fileInfoFromZipEntry
+   <*>
+      OP.option (OP.eitherReader packName)
+         (OP.long "user-name" <>
+          OP.metavar "NAME" <>
+          OP.value BC.empty <>
+          OP.help "UNIX user name for files in TAR output")
+   <*>
+      OP.option OP.auto
+         (OP.long "user-id" <>
+          OP.metavar "UID" <>
+          OP.value 0 <>
+          OP.help "UNIX user id for files in TAR output")
+   <*>
+      OP.option (OP.eitherReader packName)
+         (OP.long "group-name" <>
+          OP.metavar "NAME" <>
+          OP.value BC.empty <>
+          OP.help "UNIX group name for files in TAR output")
+   <*>
+      OP.option OP.auto
+         (OP.long "group-id" <>
+          OP.metavar "GID" <>
+          OP.value 0 <>
+          OP.help "UNIX group id for files in TAR output")
+   <*>
+      OP.flag AttributeWindows AttributeUNIX
+         (OP.long "use-unix-permissions" <>
+          OP.help
+            ("respect UNIX file permissions of zipped files " ++
+             "(default: read Windows attributes)"))
+
+packName :: String -> Either String BC.ByteString
+packName name =
+   if all Char.isAscii name
+   then Right $ BC.pack name
+   else Left "name contains non-ASCII characters"
+
+
+main :: IO ()
+main = join $ OP.execParser $ info parser
+
+
+convertZipToTar ::
+   (Zip.EntrySelector -> Zip.EntryDescription -> CTar.FileInfo) ->
+   Sort -> FilePath -> IO ()
+convertZipToTar fileInfoFromZipEntry_ sortMode zipPath =
+   Zip.withArchive zipPath $ do
+      let sort =
+            case sortMode of
+               SortPosition -> List.sortBy (comparing (Zip.edOffset . snd))
+               SortLexicographic -> id
+      entries <- sort . Map.toList <$> Zip.getEntries
+      files <-
+         for entries $ \(selector, descr) ->
+            flip fmap (Zip.getEntrySource selector) $ \src ->
+               src
+               .|
+               (C.yield (Left $ fileInfoFromZipEntry_ selector descr)
+                >>
+                C.mapC Right)
+      C.liftIO $ runConduitRes $
+         sequence_ files
+         .|
+         void CTar.tar
+         .|
+         C.stdoutC
+
+
+data AttributeSource = AttributeWindows | AttributeUNIX
+   deriving (Eq, Ord)
+
+
+windowsReadonlyAttr :: Word32
+windowsReadonlyAttr = 1
+
+unixUserReadable :: FileMode
+unixUserReadable = 0o400
+
+unixUserWriteable :: FileMode
+unixUserWriteable = 0o200
+
+unixPermFromWindowsAttr :: Word32 -> FileMode
+unixPermFromWindowsAttr attrs =
+   unixUserReadable
+   .|.
+   if attrs .&. windowsReadonlyAttr == 0
+      then unixUserWriteable
+      else 0
+
+fileInfoFromZipEntry ::
+   BC.ByteString -> CTar.UserID ->
+   BC.ByteString -> CTar.GroupID ->
+   AttributeSource ->
+   Zip.EntrySelector -> Zip.EntryDescription -> CTar.FileInfo
+fileInfoFromZipEntry userName userId groupName groupId attrSrc sel descr =
+   CTar.FileInfo {
+      CTar.filePath      = BC.pack $ Zip.unEntrySelector sel,
+      CTar.fileUserId    = userId,
+      CTar.fileUserName  = userName,
+      CTar.fileGroupId   = groupId,
+      CTar.fileGroupName = groupName,
+      CTar.fileMode      =
+         (case attrSrc of
+            AttributeWindows -> unixPermFromWindowsAttr
+            AttributeUNIX -> ZipUnix.toFileMode) $
+         Zip.edExternalFileAttrs descr,
+      CTar.fileSize      = fromIntegral $ Zip.edUncompressedSize descr,
+      CTar.fileType      = CTar.FTNormal,
+      CTar.fileModTime   =
+         fromInteger $ round $
+         (realToFrac $ PosixTime.utcTimeToPOSIXSeconds $ Zip.edModTime descr
+            :: Rational)
+   }
diff --git a/zip2tar.cabal b/zip2tar.cabal
new file mode 100644
--- /dev/null
+++ b/zip2tar.cabal
@@ -0,0 +1,70 @@
+Name:                zip2tar
+Version:             0.0
+Synopsis:            Convert ZIP to TAR archives
+Description:
+  Convert ZIP to TAR archives with minimal memory footprint.
+  .
+  Usage:
+  .
+  > zip2tar input.zip >output.tar
+  .
+  > zip2tar input.zip | lzip >output.tar.lz
+  .
+  ZIP archives cannot be read in a purely sequential way,
+  because the central file catalogue is stored at the end of the archive file.
+  Thus we do not provide reading ZIP archives from Stdin.
+  .
+  Per default @zip2tar@ stores files in the TAR archive
+  in the same order as the ZIP archive in order to minimize seeking.
+  However this means unsorted filenames.
+  You can order with respect to filenames with the @--sort-by-name@ option.
+  .
+  Per default @zip2tar@ sets user and group
+  ids to zero and names to empty strings,
+  because we do not know what is available on the system
+  where the TAR archive gets unpacked.
+  You may set those values individually like in:
+  .
+  > zip2tar --user-name janedoe --user-id 42 --group-name users --group-id 23 input.zip
+  .
+  @zip2tar@ does not try to derive id from name or vice versa.
+  If you want certain values you must set them explicitly.
+  You may want to set the values to the settings of the current system like so:
+  .
+  > zip2tar --user-name $USER --user-id $UID --group-name $(id -gn) --group-id $(id -g) input.zip
+Homepage:            https://hub.darcs.net/thielema/zip2tar
+License:             BSD3
+License-File:        LICENSE
+Author:              Henning Thielemann
+Maintainer:          haskell@henning-thielemann.de
+Category:            Archive, Codec
+Build-Type:          Simple
+Cabal-Version:       >=1.10
+
+Source-Repository this
+  Tag:         0.0
+  Type:        darcs
+  Location:    https://hub.darcs.net/thielema/zip2tar
+
+Source-Repository head
+  Type:        darcs
+  Location:    https://hub.darcs.net/thielema/zip2tar
+
+-- Alternative: https://github.com/dylex/zip-stream 2017
+-- Alternative: https://github.com/timcherganov/zip-conduit 2022, has no way to "conduit" the whole archive
+
+Executable zip2tar
+  Build-Depends:
+    zip >=2.2 && <2.3,
+    tar-conduit >=0.4.1 && <0.5,
+    conduit >=1.3 && <1.4,
+    optparse-applicative >=0.11 && <0.20,
+    time >=1.0 && <1.16,
+    bytestring >=0.10 && <0.13,
+    containers >=0.6 && <0.9,
+    base >=4.5 && <5
+  Hs-Source-Dirs:      src
+  Main-Is:             Main.hs
+  Default-Language:    Haskell2010
+  GHC-Options:
+    -Wall -fwarn-incomplete-uni-patterns -fwarn-tabs
