1. ZIP as a container on the JVM
When you hear the word “archive”, it is easy to imagine magic: “somehow it gets compressed, packed, unpacked”. In practice, it is more useful to think simpler and a bit more boring (boring thinking is fuel for reliable programs): ZIP is a container, meaning a single file that contains a set of elements inside. Each element has a name and a set of bytes (file data), and sometimes also a flag for “this is a folder”.
To avoid confusion, a small remark for Kotlin fans: the Kotlin standard library has the zip() function for collections (pairwise combining elements). It has nothing to do with a ZIP archive, except for the same letters.
In Java/Kotlin on the JVM, ZIP is most often processed in a streaming way via classes from java.util.zip. We will need three main heroes:
| What it is | Class | Role |
|---|---|---|
| “Description of an element in the archive” | |
Name inside the archive, flags, metadata |
| “Writing a ZIP” | |
Add an entry and write bytes into it |
| “Reading a ZIP” | |
Iterate entries and read the bytes of each |
ZipEntry and names inside the archive
If you imagine a ZIP as a cabinet, then ZipEntry is not the “thing”, but a label on the box: what the box is called and what is in it. Inside ZipEntry we are most interested in name. This is a string that defines a “path” inside the archive: for example, docs/readme.txt or images/logo.png.
And here is an important point: entry.name looks like a path, but it is not a guarantee that it is safe to turn it directly into a path on your disk. An archive may contain weird names like ../../secret.txt. In production code this is checked very strictly. Today we are not building a full security system, but we will still show a careful basic check: we will not allow escaping the target directory during extraction.
One more nuance: an entry can have a “directory” (folder) flag. In Java this is usually expressed as entry.isDirectory == true (and the name often ends with /). We will take this into account when extracting: you need to create the folder, not try to “write bytes into it”.
2. Writing a ZIP
Action order when adding entries
Writing a ZIP is the place where beginners most often make the same mistake: they forget that an archive consists of multiple entries, and each entry has “boundaries”.
The correct order for one entry is: create a ZipEntry, call putNextEntry(entry), write bytes, call closeEntry(). If you write multiple files, this cycle repeats for each file.
Schematically, it looks like this:
flowchart TD
A[ZipOutputStream opened] --> B["ZipEntry(name)"]
B --> C["putNextEntry(entry)"]
C --> D["copyByChunks(fileInput -> zip)"]
D --> E["closeEntry()"]
E --> F{more files?}
F -->|yes| B
F -->|no| G[ZipOutputStream closed]
Chunk copying: a function that is convenient to reuse
Before writing a ZIP, you need a “workhorse”: a function that moves bytes from an InputStream to an OutputStream in blocks.
Note: ByteArray is a fixed-size array, so it is convenient to reuse it as a buffer.
import java.io.InputStream
import java.io.OutputStream
fun copyByChunks(input: InputStream, output: OutputStream, bufferSize: Int = 8 * 1024): Long {
val buffer = ByteArray(bufferSize)
var total = 0L
while (true) {
val n = input.read(buffer)
if (n == -1) return total
output.write(buffer, 0, n)
total += n
}
}
What matters here is that we write output.write(buffer, 0, n), not the entire buffer. The last block is almost always smaller than the buffer size, and if you write extra — you will append a “tail” of old bytes.
The simplest ZIP writing: one file into an archive
The minimal scenario: take the file report.bin and put it into backup.zip under the name report.bin.
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
fun zipSingleFile(src: File, zipFile: File) {
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
zip.putNextEntry(ZipEntry(src.name))
FileInputStream(src).use { input -> copyByChunks(input, zip) }
zip.closeEntry()
}
}
Note that copyByChunks(input, zip) works because ZipOutputStream is an OutputStream. For it, writing looks the same as writing “to a regular file”: just bytes.
ZIP from multiple files: a loop with closeEntry()
If we package multiple files, the main rule does not change: for each file we create a new ZipEntry, call putNextEntry, copy bytes, call closeEntry().
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
fun zipFiles(files: List<File>, zipFile: File) {
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
for (f in files) {
zip.putNextEntry(ZipEntry(f.name))
FileInputStream(f).use { input -> copyByChunks(input, zip) }
zip.closeEntry()
}
}
}
If you want a mini sanity-check of the result (without cryptography and hashes), you can at least print the ZIP size on disk. It does not prove correctness, but it helps you notice an “empty archive” that accidentally happened due to a bug.
import java.io.File
fun main() {
val zip = File("backup.zip")
println("zipSize=${zip.length()}") // zipSize=...
}
3. Reading ZIP and extracting
ZipInputStream, nextEntry, and sequential reading
Reading a ZIP is structured differently than reading a normal directory: ZipInputStream reads an archive sequentially, entry by entry.
The key API is: zip.nextEntry. It returns a ZipEntry?. If it returns null, the entries are over. While the entry is active, the zip itself behaves like an InputStream specifically for the data of this entry: you call zip.read(buffer) and get the file’s bytes.
Mini utility “show what is inside the archive” (without extracting):
import java.io.FileInputStream
import java.util.zip.ZipInputStream
fun listZip(zipPath: String) {
ZipInputStream(FileInputStream(zipPath)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
println("entry=${entry.name}, dir=${entry.isDirectory}")
zip.closeEntry()
entry = zip.nextEntry
}
}
}
closeEntry() is important while reading as well. Even if somewhere it “works anyway”, it is better to keep discipline: “closed the entry” — “moved to the next”.
Extraction: directories, parent folders, and a safe path
Extraction is where chunk copying returns center stage. We take a ZipInputStream, run through entries, and for each “file” entry we create a FileOutputStream in the target directory, then move bytes in blocks: copyByChunks(zip, out).
There are three everyday problems worth solving even in a learning version:
- The first problem is directories. If an entry is a folder, we must create it (mkdirs()), not try to “write bytes into a folder”.
- The second problem is parent directories for files. If an entry is named docs/readme.txt, then the folder docs must exist. Therefore, before writing the file you need to do outFile.parentFile?.mkdirs().
- The third problem is unsafe names. A minimally correct approach: build the resulting file, normalize/canonicalize the path and check that it stays inside outDir.
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
private fun resolveSafely(outDir: File, entryName: String): File? {
val target = File(outDir, entryName)
val outDirCanonical = outDir.canonicalFile
val targetCanonical = target.canonicalFile
return if (targetCanonical.path.startsWith(outDirCanonical.path + File.separator)) {
targetCanonical
} else {
null
}
}
fun unzipAll(zipFile: File, outDir: File) {
outDir.mkdirs()
ZipInputStream(FileInputStream(zipFile)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val outFile = resolveSafely(outDir, entry.name)
if (outFile != null) {
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { out ->
copyByChunks(zip, out)
}
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
If you look closely, the code looks almost like “regular copying”, except the input stream for the file is taken not from FileInputStream, but from ZipInputStream, which “currently points to” the data of the current entry.
4. Mini CLI and useful nuances
Commands zip, unzip, list in one main()
When you are learning, it is easy to turn a project into a collection of scattered main()s. For practice, it is more convenient to make one simple CLI entry point that calls our functions.
We will keep parsing as primitive as possible: read a line, split by spaces, take the first words. No “smart” handling of quotes and spaces in paths — that is not the goal right now.
import java.io.File
fun main() {
while (true) {
print("filelab> ")
val line = readln().trim()
if (line.isEmpty()) continue
val parts = line.split(" ")
when (parts[0]) {
"exit" -> return
"list" -> {
if (parts.size < 2) println("Usage: list <zipPath>")
else listZip(parts[1])
}
"zip" -> {
if (parts.size < 3) println("Usage: zip <srcFile> <zipFile>")
else zipSingleFile(File(parts[1]), File(parts[2]))
}
"unzip" -> {
if (parts.size < 3) println("Usage: unzip <zipFile> <outDir>")
else unzipAll(File(parts[1]), File(parts[2]))
}
else -> println("Unknown command")
}
}
}
Nuances: memory and path safety
ZIP as a format can do a lot: compression (different algorithms), passwords (in the real world often poorly compatible), comments, filesystem attributes. We are not dealing with that. But two “don’t step on a rake” nuances are worth remembering.
The first nuance is size and memory. A ZIP can be large, and it can contain a file tens of gigabytes in size. That is exactly why ZipInputStream/ZipOutputStream are streaming: they allow you not to keep everything in memory. If you extract an entry by “reading it all into a ByteArray”, you cancel the whole point of streams yourself.
The second nuance is entry names and path safety. Even if your code is “internal”, the habit of mindlessly doing File(outDir, entry.name) sometimes turns into very unpleasant stories. In this lecture we showed a simple practical approach: canonicalize the path and make sure the resulting file is still inside outDir.
5. Typical mistakes when working with ZIP archives
Mistake #1: forget closeEntry() when writing or reading.
ZIP is a format where entries go one after another, and entry boundaries must be explicitly marked. If you did not close an entry while writing, the next entry may “merge” with the previous one, and while reading you risk getting strange behavior when switching between elements. Keep the rule in mind: one entry — one putNextEntry(...) and one closeEntry().
Mistake #2: extract without creating parent directories.
Entries inside an archive often have “internal folders”, for example docs/readme.txt. If you immediately open FileOutputStream(outFile) and the docs folder does not exist, you will get an error out of nowhere. The habit of calling outFile.parentFile?.mkdirs() before writing saves a lot of nerves.
Mistake #3: write output.write(buffer) instead of output.write(buffer, 0, n).
This is classic chunk copying: read(buffer) returns n, and only the first n bytes of the buffer are valid. Writing the whole buffer means also writing a “garbage tail” (usually pieces of data from the previous iteration). In archives this bug is especially nasty: you can end up with an archive that “was created”, but the files inside will be corrupted.
Mistake #4: assume that entry.name is a “safe disk path”.
The name inside the archive is just a string. If you turn it into a path directly, the archive can potentially write files “anywhere” relative to your target directory. Even in learning examples, you should at least minimally verify that the final path stays inside outDir.
Mistake #5: try to read a ZIP “fully into memory” because it is easier.
Yes, it is “easier” to write something like zip.readBytes() (or an analog) — but that often turns extraction into a memory lottery. The point of streams and buffers is to process data gradually. If the file is small, everything will “seem fine”, and then a larger file will come along, and the program will suddenly become a textbook of OutOfMemoryError mistakes.
GO TO FULL VERSION