Skip to content

✨ Add zipTo for files and folders - #642

Open
anggrayudi wants to merge 2 commits into
vinceglb:mainfrom
anggrayudi:feat/298-zip-folder-and-files
Open

✨ Add zipTo for files and folders#642
anggrayudi wants to merge 2 commits into
vinceglb:mainfrom
anggrayudi:feat/298-zip-folder-and-files

Conversation

@anggrayudi

@anggrayudianggrayudi commented Aug 15, 2026

Copy link
Copy Markdown

Closes#298.

file zipTo archive // one file, or a whole directory treelistOf(report, photos) zipTo archive // several items into one archive

A directory keeps its own name as the top-level entry, so unzipping recreates the folder rather than spilling its contents into the current directory. Infix, to match the existing copyTo.

How it is put together

The zip container is written in shared Kotlin in nonWebMain — local headers, data descriptors, central directory, EOCD, CRC-32 and the DOS timestamp. Every platform emits the same bytes and the format lives in one place.

Only the deflater is per-platform, and it stays small:

TargetDeflater
JVM, Androidjava.util.zip.Deflater(nowrap = true)
Apple, Linux, Windowszlib deflateInit2_ with a negative window, one nativeMain implementation

zlib turns out to be a Kotlin/Native platform library on all four native targets, so no new dependency and no .def file.

Entries stream through a 64 KB chunk rather than being buffered whole, so archiving a large file does not pull it into memory. That means the sizes and CRC are unknown when the local header goes out, which is exactly what the data descriptor flag is for.

Cancellation

The compression loop has no suspension point of its own, so it checks the job on every chunk. Without that a cancelled coroutine would keep deflating to the end of the file.

Because cancelling mid-entry makes a truncated archive a real outcome — and a truncated archive still opens like an archive — a failed or cancelled run removes the destination. That delete runs under NonCancellable, since it suspends and would otherwise throw on the already-cancelled job that got us there. That subtlety was caught by the test, not by reading the code.

Decisions worth a look

  • Web is out of scope, matching delete(), createDirectories() and the rest of nonWebMain. Say the word if you would rather it lived in commonMain with a web implementation.
  • No progress reporting.zipTo is a plain suspend fun returning Unit, consistent with copyTo and atomicMove. Adding a Flow here would introduce a pattern the rest of the API does not have, so it felt like your call rather than mine — happy to add one if you want it.
  • Symlinks are followed, as zip -r does. A link leading back into a directory already being written is skipped, tracked by absolute path, so a cycle cannot recurse forever. This deliberately does not depend on ✨ Add recursive delete for non-empty directories #641, so the two can merge in either order.
  • Duplicate top-level names are rejected rather than silently producing two entries with the same path.
  • No zip64, so this tops out at 4 GB per entry and per archive. Happy to add it if you want it now rather than later.

Verification

The format is judged by implementations that know nothing about the one that wrote it:

  • JVM: round-trips a nested directory tree through java.util.zip.ZipInputStream (entry names and contents), and a 200 KB binary payload through ZipFile, which verifies each entry's CRC against the compressed bytes.
  • Native: a macOS test hands the archive to the system's own unzip -t. This is what actually exercises the zlib deflater, since the container above it is shared code.
  • Cancellation: a 64 MB barely-compressible file is zipped, cancelled mid-entry, and the test asserts both that the job really cancelled and that no archive was left behind.
  • Plus signature, compression-ratio and error cases in nonWebTest, so they run on JVM, iOS simulator, macOS and Android host.

I checked those guards can fail rather than trusting them. Making Crc32.value return a wrong number makes both the JVM round-trip andunzip -t reject the archive. Removing the cancellation check makes the cancellation test fail. All pass again once restored.

./gradlew assemble passes, and 216 tests pass across jvmTest, iosSimulatorArm64Test, macosArm64Test and testAndroidHostTest.

What I could not check

  • linuxX64 and mingwX64compile (cross-compiled from macOS) but I could not run their tests, so the zlib path on those two is reasoned about rather than executed. Given the deflater is one shared implementation and macOS exercises it, the risk is mostly in zlib linking on Windows — worth a CI run or a look from someone with those machines.
  • The ktlint command in AGENTS.md — ktlint is not on my PATH and ktlint-compose-0.4.28-all.jar is not in the repo, so style is matched by hand. Happy to fix whatever CI flags.

🤖 Generated with Claude Code

anggrayudi.hardiannicoand others added 2 commits August 16, 2026 04:28
`file zipTo archive` writes one file or a whole directory tree, and
`listOf(a, b) zipTo archive` writes several into one archive. A directory
keeps its own name as the top-level entry, so unzipping recreates the
folder instead of spilling its contents.
The zip container is written in shared Kotlin — local headers, data
descriptors, central directory, EOCD, CRC-32 and the DOS timestamp — so
every platform emits the same bytes and the format lives in one place.
Only the deflater is per-platform: java.util.zip.Deflater on JVM and
Android, zlib's deflateInit2_ with a negative window on native, which
covers Apple, Linux and Windows from a single nativeMain implementation.
Entries stream through a 64 KB chunk rather than being buffered whole, so
archiving a large file does not need it in memory. Sizes and the CRC are
therefore unknown when the local header is written, which is what the
data descriptor flag is for.
Symlinks are followed as `zip -r` does, but a link leading back into a
directory already being written is skipped, so a cycle cannot recurse
forever. Web is out of scope, matching delete() and createDirectories().
Closesvinceglb#298
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compression loop had no suspension point of its own, so a cancelled
job kept deflating to the end of the file. It now checks the job on every
chunk.
Cancelling mid-entry makes a truncated archive a real outcome, and one
still opens like an archive, so a failed or cancelled run removes the
destination. That delete runs under NonCancellable: it suspends, and on
the already-cancelled job that got us here it would otherwise throw
before removing anything.
The regression test cancels a zip of a 64 MB barely-compressible file and
asserts both that the job actually cancelled and that nothing was left
behind. Removing either the ensureActive check or NonCancellable makes it
fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

can we create a Zip of a folder and files?

1 participant

@anggrayudi