✨ Add zipTo for files and folders - #642
Open
anggrayudi wants to merge 2 commits into
Open
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes#298.
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:
java.util.zip.Deflater(nowrap = true)deflateInit2_with a negative window, onenativeMainimplementationzlib turns out to be a Kotlin/Native platform library on all four native targets, so no new dependency and no
.deffile.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
delete(),createDirectories()and the rest ofnonWebMain. Say the word if you would rather it lived incommonMainwith a web implementation.zipTois a plainsuspend funreturningUnit, consistent withcopyToandatomicMove. Adding aFlowhere 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.zip -rdoes. 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.Verification
The format is judged by implementations that know nothing about the one that wrote it:
java.util.zip.ZipInputStream(entry names and contents), and a 200 KB binary payload throughZipFile, which verifies each entry's CRC against the compressed bytes.unzip -t. This is what actually exercises the zlib deflater, since the container above it is shared code.nonWebTest, so they run on JVM, iOS simulator, macOS and Android host.I checked those guards can fail rather than trusting them. Making
Crc32.valuereturn a wrong number makes both the JVM round-trip andunzip -treject the archive. Removing the cancellation check makes the cancellation test fail. All pass again once restored../gradlew assemblepasses, and 216 tests pass acrossjvmTest,iosSimulatorArm64Test,macosArm64TestandtestAndroidHostTest.What I could not check
linuxX64andmingwX64compile (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.AGENTS.md— ktlint is not on my PATH andktlint-compose-0.4.28-all.jaris not in the repo, so style is matched by hand. Happy to fix whatever CI flags.🤖 Generated with Claude Code