JavaRush /Курси /Kotlin SELF /ZIP‑архіви: ZipEntry, читання/запис

ZIP‑архіви: ZipEntry, читання/запис

Kotlin SELF
Рівень 43 , Лекція 4
Відкрита

1. ZIP як контейнер на JVM

Коли ви чуєте слово «архів», легко уявити магію: «якось там воно стискається, пакується, розпаковується». На практиці корисніше мислити простіше й приземленіше (саме така «нудна» логіка — паливо для надійних програм). ZIP — це контейнер, тобто один файл, усередині якого лежить набір елементів. Кожен елемент має імʼя й набір байтів (дані файлу), а іноді — ще й ознаку «це каталог».

Щоб не плутатися, маленька ремарка для поціновувачів Kotlin: у стандартній бібліотеці Kotlin є функція zip() для колекцій (поєднує елементи попарно). Вона ніяк не повʼязана із ZIP‑архівом, окрім збігу назви.

У Java/Kotlin на JVM ZIP найчастіше обробляють потоково — через класи з java.util.zip. Нам знадобляться три «герої»:

Що це Клас Роль
«Опис елемента в архіві»
ZipEntry
Імʼя всередині архіву, прапорці, метадані
«Пишемо ZIP»
ZipOutputStream
Додаємо entry і записуємо в нього байти
«Читаємо ZIP»
ZipInputStream
Перебираємо entry і читаємо байти кожного

ZipEntry та імена всередині архіву

Якщо уявити ZIP як шафу, то ZipEntry — це не «річ», а наліпка на коробці: як називається коробка і що в ній лежить. У ZipEntry нас найбільше цікавить name. Це рядок, який задає «шлях» усередині архіву: наприклад, docs/readme.txt або images/logo.png.

І тут є важливий момент: entry.name виглядає як шлях, але це не гарантія, що його безпечно напряму перетворювати на шлях на вашому диску. Архів може містити дивні імена на кшталт ../../secret.txt. У промисловому коді це перевіряють дуже суворо. Ми сьогодні не будуємо повноцінну систему безпеки, але акуратну базову перевірку все одно покажемо. Зокрема, не дозволимо виходити з цільового каталогу під час розпаковування.

Ще один нюанс: у entry буває ознака «каталог» (папка). У Java це зазвичай виражено тим, що entry.isDirectory == true (і часто імʼя закінчується на /). Ми врахуємо це під час розпаковування: каталог потрібно створити, а не намагатися «записати в нього байти».

2. Запис ZIP

Порядок дій під час додавання записів

Запис ZIP — це місце, де новачки найчастіше повторюють одну й ту саму помилку: забувають, що архів складається з кількох записів, і кожен запис має свої «межі».

Правильний порядок для одного запису такий: створити ZipEntry, викликати putNextEntry(entry), записати байти, а потім викликати closeEntry(). Якщо ви додаєте кілька файлів, цей цикл повторюється для кожного з них.

Схематично це виглядає так:

flowchart TD
    A[ZipOutputStream відкрито] --> B["ZipEntry(name)"]
    B --> C["putNextEntry(entry)"]
    C --> D["copyByChunks(fileInput -> zip)"]
    D --> E["closeEntry()"]
    E --> F{ще файли?}
    F -->|так| B
    F -->|ні| G[ZipOutputStream закрито]

Chunk‑копіювання: функція, яку зручно перевикористовувати

Перш ніж писати ZIP, потрібен «робочий кінь»: функція, яка переносить байти з InputStream у OutputStream блоками.

Зверніть увагу: ByteArray — це масив фіксованого розміру, тож його зручно перевикористовувати як буфер.

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
    }
}

Тут важливо, що ми пишемо output.write(buffer, 0, n), а не весь буфер цілком. Останній блок майже завжди менший за розмір буфера. Якщо записати зайве, ви допишете «хвіст» зі старих байтів.

Найпростіший запис ZIP: один файл до архіву

Мінімальний сценарій: взяти файл report.bin і покласти його до backup.zip під імʼям 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()
    }
}

Зауважте: copyByChunks(input, zip) працює, тому що ZipOutputStream — це OutputStream. Для нього запис виглядає так само, як запис «у звичайний файл»: просто байти.

ZIP із кількох файлів: цикл із closeEntry()

Якщо пакуєте кілька файлів, головне правило не змінюється: для кожного файлу створюєте новий ZipEntry, викликаєте putNextEntry, копіюєте байти та викликаєте 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()
        }
    }
}

Якщо хочеться бодай мінімально проконтролювати результат (без криптографії та хешів), можна хоча б вивести розмір архіву на диску. Це не доводить коректність, але допомагає швидко помітити «порожній архів», який випадково вийшов через помилку.

import java.io.File

fun main() {
    val zip = File("backup.zip")
    println("zipSize=${zip.length()}") // zipSize=...
}

3. Читання ZIP і розпаковування

ZipInputStream, nextEntry і послідовне читання

Читання ZIP влаштоване інакше, ніж читання звичайного каталогу: ZipInputStream читає архів послідовно, entry за entry.

Ключовий API: zip.nextEntry. Він повертає ZipEntry?. Якщо повернув null, записи закінчилися. Поки entry активний, сам zip поводиться як InputStream саме для даних цього entry: ви викликаєте zip.read(buffer) і отримуєте байти файлу.

Міні‑утиліта «показати, що всередині архіву» (без розпаковування):

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() під час читання теж важливий. Навіть якщо десь «і так спрацює», краще тримати дисципліну: «закрили запис» — «перейшли до наступного».

Розпаковування: каталоги, батьківські папки та безпечний шлях

Розпаковування — це місце, де chunk‑копіювання знову в центрі сцени. Ми беремо ZipInputStream, перебираємо entry і для кожного «файлового» entry створюємо FileOutputStream у цільовому каталозі. Потім переносимо байти блоками: copyByChunks(zip, out).

Є три побутові проблеми, які варто розвʼязати навіть у навчальній версії:

  • Перша проблема — каталоги. Якщо entry — папка, ми маємо створити її (mkdirs()), а не намагатися «записати в папку байти».
  • Друга проблема — батьківські папки файлів. Якщо entry називається docs/readme.txt, то папка docs має існувати. Тому перед записом файлу потрібно викликати outFile.parentFile?.mkdirs().
  • Третя проблема — небезпечні імена. Мінімально коректний підхід: зібрати підсумковий файл, нормалізувати/канонізувати шлях і перевірити, що він лишається усередині 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
        }
    }
}

Якщо придивитися, код виглядає майже як «звичайне копіювання». Різниця лише в тому, що вхідний потік для файлу береться не з FileInputStream, а з ZipInputStream, який «зараз показує» дані поточного запису.

4. Міні‑CLI і корисні нюанси

Команди zip, unzip, list в одному main()

Під час навчання легко перетворити проєкт на колекцію розрізнених main()-ів. Для практики зручніше зробити один простий CLI‑вхід, який викликає наші функції.

Парсинг зробимо максимально примітивним: читаємо рядок, ділимо за пробілами, беремо перші слова. Без «розумного» розбору лапок і пробілів у шляхах — зараз це не мета.

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("Використання: list <zipPath>")
                else listZip(parts[1])
            }

            "zip" -> {
                if (parts.size < 3) println("Використання: zip <srcFile> <zipFile>")
                else zipSingleFile(File(parts[1]), File(parts[2]))
            }

            "unzip" -> {
                if (parts.size < 3) println("Використання: unzip <zipFile> <outDir>")
                else unzipAll(File(parts[1]), File(parts[2]))
            }

            else -> println("Невідома команда")
        }
    }
}

Нюанси: памʼять і безпека шляхів

ZIP як формат уміє багато: стиснення (різні алгоритми), паролі (у реальному світі часто криво сумісні), коментарі, атрибути файлових систем. Ми цим не займаємося. Але два нюанси рівня «не наступити на граблі» варто памʼятати.

Перший нюанс — розмір і памʼять. ZIP може бути великим, а всередині нього може лежати файл на десятки гігабайт. Саме тому ZipInputStream/ZipOutputStream — потокові: вони дають змогу не тримати все в памʼяті. Якщо ви розпаковуєте entry за принципом «прочитати цілком у ByteArray», ви самі нівелюєте сенс потоків.

Другий нюанс — імена entry та безпека шляхів. Навіть якщо ваш код «внутрішній», звичка бездумно робити File(outDir, entry.name) інколи закінчується дуже неприємними історіями. У цій лекції ми показали простий практичний підхід: канонізувати шлях і переконатися, що підсумковий файл усе ще розташований усередині outDir.

5. Типові помилки під час роботи із ZIP‑архівами

Помилка №1: забути closeEntry() під час запису або читання.
ZIP — це формат, де записи йдуть підряд, і межі запису мають бути явно позначені. Якщо ви не закрили entry під час запису, наступний запис може «злитися» з попереднім. А під час читання ви ризикуєте отримати дивну поведінку під час переходу між елементами. Тримайте в голові правило: один запис — один putNextEntry(...) і один closeEntry().

Помилка №2: розпаковувати без створення батьківських каталогів.
Entry усередині архіву часто мають «внутрішні папки», наприклад docs/readme.txt. Якщо ви одразу відкриєте FileOutputStream(outFile), а папки docs немає, ви отримаєте помилку на рівному місці. Звичка викликати outFile.parentFile?.mkdirs() перед записом економить багато нервів.

Помилка №3: писати output.write(buffer) замість output.write(buffer, 0, n).
Це класика chunk‑копіювання: read(buffer) повертає n, і тільки перші n байтів буфера є актуальними. Записати весь буфер — означає записати ще й «сміттєвий хвіст» (зазвичай це шматки даних із попередньої ітерації). В архівах такий баг особливо неприємний: ви можете отримати архів, який «створився», але файли всередині будуть пошкоджені.

Помилка №4: вважати, що entry.name — це «безпечний шлях на диску».
Імʼя всередині архіву — це просто рядок. Якщо ви безпосередньо перетворюєте його на шлях, архів потенційно може записати файли «куди завгодно» відносно вашого цільового каталогу. Навіть у навчальних прикладах варто хоча б мінімально перевіряти, що підсумковий шлях лишається всередині outDir.

Помилка №5: намагатися читати ZIP «цілком у памʼять», бо так простіше.
Так, «простіше» написати щось на кшталт zip.readBytes() (або аналог), але це часто перетворює розпаковування на лотерею з памʼяттю. Сенс потоків і буферів у тому, щоб обробляти дані поступово. Якщо файл малий, усе буде «здаватися нормальним», а потім трапиться файл більший — і програма раптово стане підручником з помилок OutOfMemoryError.

1
Опитування
Бінарні файли, рівень 43, лекція 4
Недоступний
Бінарні файли
Бінарні файли
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ