サイトアイコン Omomuki Tech

java.util.zipを使いこなす!ZIPファイル操作の完全ガイド

この記事から得られる知識

  • java.util.zipパッケージの全体像と主要なクラスの役割
  • ZipFileZipInputStreamを利用したZIPファイルの読み込みと展開方法
  • ZipOutputStreamを用いた、単一・複数ファイル、さらにはディレクトリ構造を維持したZIPファイルの作成方法
  • GZIP形式(.gz)ファイルの圧縮および展開方法
  • ファイルの文字コード問題や、深刻な脆弱性「Zip Slip」への対策など、安全なZIP操作のための注意点

第1章: `java.util.zip`パッケージとは?

java.util.zipは、Javaの標準ライブラリ(API)に含まれているパッケージで、ZIPやGZIPといった一般的な圧縮ファイル形式を扱うためのクラス群を提供します。このパッケージを利用することで、外部ライブラリを追加することなく、Javaプログラム内でファイルの圧縮や展開(解凍)を実装できます。

主な用途としては、複数のファイルを一つにまとめて管理しやすくする「アーカイブ化」や、ファイルサイズを小さくしてストレージ容量を節約したり、ネットワーク経由でのデータ転送時間を短縮したりすることが挙げられます。

主要なクラスとインターフェース

java.util.zipパッケージには多くのクラスが含まれていますが、中心となるのは以下のクラスです。それぞれの役割を理解することが、このパッケージを使いこなす第一歩となります。

クラス名 / インターフェース名 概要
ZipFile ZIPファイルを読み込むためのクラス。ファイル内の各エントリ(ファイルやディレクトリ)へのランダムアクセスが可能です。
ZipInputStream ZIPファイルをストリームとして逐次的に読み込むためのクラス。ネットワーク経由のデータなど、ファイル以外のソースからも読み込めます。
ZipOutputStream ZIPファイルをストリームとして書き出す(作成する)ためのクラス。ファイルやデータを圧縮してZIPファイルに追加します。
ZipEntry ZIPファイル内の個々のエントリ(ファイルやディレクトリ)を表すクラス。ファイル名、サイズ、更新日時などのメタデータを保持します。
GZIPInputStream / GZIPOutputStream GZIP形式(.gz)の圧縮・展開を行うためのクラス。単一のファイルやストリームを対象とします。
Checksum (インターフェース) データの誤りを検出するためのチェックサムを計算する機能のインターフェースです。
CRC32 / Adler32 Checksumインターフェースを実装したクラス。それぞれCRC-32とAdler-32というアルゴリズムでチェックサムを計算します。Adler-32はCRC-32より高速ですが、信頼性は若干劣ります。
Deflater / Inflater DEFLATE圧縮アルゴリズム(ZIPやGZIPで使われる中核技術)を直接扱うための低レベルなクラスです。

第2章: ZIPファイルの読み込みと展開

ZIPファイルを読み込むには、主にZipFileクラスを使う方法とZipInputStreamクラスを使う方法の2つがあります。

方法1: `ZipFile` を使った効率的な読み込み

ZipFileは、物理的なZIPファイルを読み込む際に推奨される方法です。ファイル全体をメモリにマッピングするため、ファイル内の任意のエントリに高速にアクセス(ランダムアクセス)できます。

ポイント

特定のファイルだけを素早く取り出したい場合や、ファイル内のエントリ一覧を先に取得したい場合に特に有効です。

以下は、ZipFileを使ってZIPファイル内の全エントリを展開するサンプルコードです。Java 7から導入されたtry-with-resources文を使用することで、リソースのクローズ処理を自動化でき、コードが簡潔かつ安全になります。

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

public class ZipFileExtractor {
    public void extract(String zipFilePath, String destDirectory) throws IOException {
        File destDir = new File(destDirectory);
        if (!destDir.exists()) {
            destDir.mkdirs();
        }

        // Java 7以降ではCharsetを指定可能。文字化け対策にUTF-8を指定。
        try (ZipFile zipFile = new ZipFile(zipFilePath, StandardCharsets.UTF_8)) {
            Enumeration<? extends ZipEntry> entries = zipFile.entries();

            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                String entryFilePath = destDirectory + File.separator + entry.getName();

                if (entry.isDirectory()) {
                    new File(entryFilePath).mkdirs();
                } else {
                    // 親ディレクトリが存在しない場合に作成
                    new File(new File(entryFilePath).getParent()).mkdirs();

                    try (InputStream is = zipFile.getInputStream(entry);
                         FileOutputStream fos = new FileOutputStream(entryFilePath)) {

                        byte[] buffer = new byte;
                        int bytesRead;
                        while ((bytesRead = is.read(buffer)) != -1) {
                            fos.write(buffer, 0, bytesRead);
                        }
                    }
                }
            }
        }
    }
}

方法2: `ZipInputStream` を使ったストリームベースの読み込み

ZipInputStreamは、入力ストリームからZIPデータを読み込むためのクラスです。ファイルだけでなく、ネットワーク経由で受信したデータや、メモリ上のバイト配列など、様々なデータソースに対応できる柔軟性があります。

ポイント

データソースがファイルではない場合や、メモリ使用量を抑えたい場合に適しています。ただし、エントリを先頭から順番にしか読み込めない(シーケンシャルアクセス)という制約があります。

以下は、ZipInputStreamを用いた展開処理のサンプルコードです。こちらもtry-with-resources文を活用しています。

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

public class ZipStreamExtractor {
    public void extract(String zipFilePath, String destDirectory) throws IOException {
        File destDir = new File(destDirectory);
        if (!destDir.exists()) {
            destDir.mkdirs();
        }

        // こちらもJava 7以降、コンストラクタでCharsetを指定可能
        try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFilePath), StandardCharsets.UTF_8)) {
            ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                String entryFilePath = destDirectory + File.separator + entry.getName();
                
                // Zip Slip脆弱性対策(第5章で詳述)
                File newFile = new File(entryFilePath);
                if (!newFile.getCanonicalPath().startsWith(new File(destDirectory).getCanonicalPath() + File.separator)) {
                    throw new IOException("Entry is outside of the target dir: " + entry.getName());
                }

                if (entry.isDirectory()) {
                    newFile.mkdirs();
                } else {
                    new File(newFile.getParent()).mkdirs();
                    try (FileOutputStream fos = new FileOutputStream(newFile)) {
                        byte[] buffer = new byte;
                        int bytesRead;
                        while ((bytesRead = zis.read(buffer)) != -1) {
                            fos.write(buffer, 0, bytesRead);
                        }
                    }
                }
                zis.closeEntry();
            }
        }
    }
}

第3章: ZIPファイルの作成と圧縮

ZIPファイルの作成にはZipOutputStreamクラスを使用します。このクラスは、出力ストリームに対してエントリ(ファイルやディレクトリ)を次々と追加していくことで、ZIPファイルを構築します。

単一・複数ファイルの圧縮

まずは基本的な、いくつかのファイルを指定して1つのZIPファイルにまとめる方法です。

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class FileZipper {
    public void zipFiles(String[] sourceFilePaths, String zipFilePath) throws IOException {
        try (FileOutputStream fos = new FileOutputStream(zipFilePath);
             ZipOutputStream zos = new ZipOutputStream(fos, StandardCharsets.UTF_8)) {

            for (String sourceFilePath : sourceFilePaths) {
                File fileToZip = new File(sourceFilePath);
                if (!fileToZip.exists() || !fileToZip.isFile()) {
                    System.err.println("Skipping: " + sourceFilePath);
                    continue;
                }

                try (FileInputStream fis = new FileInputStream(fileToZip)) {
                    // ZIPファイル内のエントリ名を設定
                    ZipEntry zipEntry = new ZipEntry(fileToZip.getName());
                    zos.putNextEntry(zipEntry);

                    byte[] buffer = new byte;
                    int bytesRead;
                    while ((bytesRead = fis.read(buffer)) != -1) {
                        zos.write(buffer, 0, bytesRead);
                    }
                    zos.closeEntry();
                }
            }
        }
    }
}

重要: 各エントリを書き込み終えたら、必ず `zos.closeEntry()` を呼び出してください。これを忘れると、次のエントリが正しく書き込めなかったり、ZIPファイルが破損したりする原因となります。

ディレクトリ構造を維持した圧縮

サブディレクトリを含むディレクトリ全体を、その構造を維持したまま圧縮するには、再帰的な処理が有効です。

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public class DirectoryZipper {
    public void zipDirectory(String sourceDirPath, String zipFilePath) throws IOException {
        File sourceDir = new File(sourceDirPath);
        try (FileOutputStream fos = new FileOutputStream(zipFilePath);
             ZipOutputStream zos = new ZipOutputStream(fos, StandardCharsets.UTF_8)) {
            
            addDirectoryToZip(sourceDir, sourceDir.getName(), zos);
        }
    }

    private void addDirectoryToZip(File fileToZip, String entryName, ZipOutputStream zos) throws IOException {
        if (fileToZip.isHidden()) {
            return;
        }

        if (fileToZip.isDirectory()) {
            // ディレクトリエントリを追加(末尾に / をつける)
            if (!entryName.endsWith("/")) {
                entryName += "/";
            }
            ZipEntry zipEntry = new ZipEntry(entryName);
            zos.putNextEntry(zipEntry);
            zos.closeEntry();

            File[] children = fileToZip.listFiles();
            if (children != null) {
                for (File childFile : children) {
                    addDirectoryToZip(childFile, entryName + childFile.getName(), zos);
                }
            }
            return;
        }

        try (FileInputStream fis = new FileInputStream(fileToZip)) {
            ZipEntry zipEntry = new ZipEntry(entryName);
            zos.putNextEntry(zipEntry);
            byte[] buffer = new byte;
            int bytesRead;
            while ((bytesRead = fis.read(buffer)) != -1) {
                zos.write(buffer, 0, bytesRead);
            }
            zos.closeEntry();
        }
    }
}

このコードでは、まず圧縮対象がディレクトリかファイルかを判断します。ディレクトリの場合は、末尾にスラッシュ(`/`)をつけたエントリを作成して追加し、その後、内部のファイルやサブディレクトリに対して再帰的に同じ処理を呼び出します。ファイルの場合は、通常通り内容を書き込みます。これにより、元のディレクトリ構造がZIPファイル内に再現されます。

第4章: 発展的なトピック

GZIP形式の操作

java.util.zipパッケージは、ZIPだけでなくGZIP形式の圧縮・展開もサポートしています。 GZIPは、複数のファイルをまとめるアーカイブ機能を持たず、単一のファイルやデータストリームを圧縮することに特化しています。Linux環境などでログファイルを圧縮する際(例:`access.log.gz`)によく利用されます。

操作は非常にシンプルで、GZIPOutputStreamGZIPInputStreamを使います。

// GZIP圧縮
try (FileOutputStream fos = new FileOutputStream("original.txt.gz");
     GZIPOutputStream gzos = new GZIPOutputStream(fos);
     FileInputStream fis = new FileInputStream("original.txt")) {
    
    byte[] buffer = new byte;
    int bytesRead;
    while ((bytesRead = fis.read(buffer)) != -1) {
        gzos.write(buffer, 0, bytesRead);
    }
}

// GZIP展開
try (FileInputStream fis = new FileInputStream("original.txt.gz");
     GZIPInputStream gzis = new GZIPInputStream(fis);
     FileOutputStream fos = new FileOutputStream("uncompressed.txt")) {

    byte[] buffer = new byte;
    int bytesRead;
    while ((bytesRead = gzis.read(buffer)) != -1) {
        fos.write(buffer, 0, bytesRead);
    }
}

チェックサムによるデータ完全性の検証

ファイル転送時や保存時にデータが破損していないかを確認するために、チェックサムが利用されます。 java.util.zipには、チェックサムを計算するためのCRC32クラスやAdler32クラスが用意されています。

CheckedOutputStreamCheckedInputStreamと組み合わせることで、データの読み書きと同時にチェックサムを計算・検証できます。

import java.io.FileOutputStream;
import java.io.IOException;
import java.util.zip.CRC32;
import java.util.zip.CheckedOutputStream;
import java.util.zip.Checksum;

public class ChecksumExample {
    public void writeWithChecksum(String filePath, byte[] data) throws IOException {
        Checksum crc32 = new CRC32();
        try (FileOutputStream fos = new FileOutputStream(filePath);
             CheckedOutputStream cos = new CheckedOutputStream(fos, crc32)) {
            
            cos.write(data);
            cos.flush();

            long checksumValue = cos.getChecksum().getValue();
            System.out.println("CRC32 Checksum: " + checksumValue);
            // このチェックサム値を別途保存・転送し、読み込み側で検証する
        }
    }
}

第5章: 注意点とベストプラクティス

java.util.zipは強力ですが、安全かつ正しく使用するためにはいくつかの重要な注意点があります。

文字コードの問題と対策

ZIPファイル内のファイル名には文字コードが関連しており、これが文字化けの主な原因となります。特に、Windowsで作成されたZIPファイル(通常はMS932/`Shift_JIS`)と、MacやLinuxで作成されたZIPファイル(通常はUTF-8)とでは、ファイル名のエンコーディングが異なります。

Java 6以前のjava.util.zipでは、ファイル名をUTF-8としてしか扱えず、Windows環境で作成された日本語ファイル名を含むZIPを扱うと文字化けが発生する問題が頻発しました。

解決策: Java 7以降、ZipFileZipInputStreamZipOutputStreamのコンストラクタでCharsetオブジェクトを渡せるようになりました。 これにより、ファイル名の文字コードを明示的に指定できます。特別な理由がない限り、新規にZIPを作成する場合は`StandardCharsets.UTF_8`を、Windows環境で作成されたZIPを読む可能性がある場合は`Charset.forName(“MS932”)`を指定することを検討してください。

セキュリティ: Zip Slip脆弱性

Zip Slipは、2018年に公表された非常に危険なディレクトリトラバーサルの脆弱性です。

攻撃者は、../../evil.shのように、ディレクトリ階層を遡るパス情報を含んだファイル名をZIPアーカイブに含めることができます。 脆弱なプログラムがこのファイル名を検証せずに展開処理を行うと、意図しないディレクトリ(例えば、アプリケーションのルートディレクトリやシステムの重要なディレクトリ)に悪意のあるファイルが書き込まれ、リモートでのコード実行など深刻な被害につながる可能性があります。

対策: 絶対にファイル名を検証すること!

ZIPエントリを展開する前に、そのエントリの展開先パスが、意図した展開先ディレクトリの配下に正しく収まっているかを必ず検証する必要があります。 具体的には、展開先のファイルのgetCanonicalPath()(正規化された絶対パス)が、展開先ディレクトリのgetCanonicalPath()で始まっていることを確認します。
// Zip Slip対策を施した安全な展開処理の例
File destDir = new File(destDirectory);
String destDirPath = destDir.getCanonicalPath();

ZipEntry entry = ... ; // zis.getNextEntry() などで取得
File newFile = new File(destDir, entry.getName());

String entryPath = newFile.getCanonicalPath();

// 展開先のパスが、指定したディレクトリの外に出ていないかを確認
if (!entryPath.startsWith(destDirPath + File.separator)) {
    throw new IOException("Entry is outside of the target dir: " + entry.getName());
}

// 検証が成功した場合のみ、ファイルへの書き込み処理を行う
// ...

リソースの解放

ZipFileや各種ストリーム(`InputStream`/`OutputStream`)は、OSのファイルハンドルなどのリソースを消費します。使用後は必ずclose()メソッドを呼び出してリソースを解放する必要があります。

ベストプラクティス: このようなリソース管理は、`try-with-resources`文を使用するのが最も安全で確実です。`try`ブロックを抜ける際に、`()`内で宣言されたリソースの`close()`メソッドが自動的に呼び出されるため、解放漏れを防ぐことができます。本記事のサンプルコードはすべてこの構文を使用しています。

まとめ

本記事では、Javaの標準ライブラリであるjava.util.zipパッケージについて、その基本的な使い方から、ディレクトリ構造の維持、GZIP形式の扱い、そして文字コードやZip Slip脆弱性といった重要な注意点まで、幅広く解説しました。

このパッケージは、外部ライブラリに依存することなく、ファイル圧縮・展開という一般的な要件を満たすことができる強力なツールです。一方で、その手軽さゆえにセキュリティ上のリスクを見過ごしがちです。特に、ユーザーがアップロードしたZIPファイルなどを扱う際には、本記事で解説したZip Slip脆弱性への対策を必ず実装し、安全なアプリケーション開発を心がけてください。

try-with-resources文や適切な文字コードの指定といったベストプラクティスを実践することで、堅牢で信頼性の高いコードを書くことができます。ぜひこの機会にjava.util.zipをマスターし、日々の開発に役立ててください。

モバイルバージョンを終了