なぜ「zip解凍ユーティリティ」が業務で重要になるのか
ZIP は「複数ファイルをまとめる箱」です。レポート一式、バックアップ、ユーザーへの一括ダウンロードなど、業務システムでは ZIP を受け取って中身を展開する場面が頻繁に出てきます。 そのたびにその場で ZipInputStream を直接書いていると、エントリ名の扱いミス、ディレクトリトラバーサルの危険、ストリームの閉じ忘れなど、典型的な事故のタネが増えていきます。
だからこそ、「zip解凍」をユーティリティとして一度きれいにまとめておくと、 業務コードがすっきりし、セキュリティと運用の安定性が一気に上がります。
基本形:ZIP の中身をディレクトリに展開するユーティリティ
ZipInputStream を使った最もシンプルな解凍
まずは、「xxx.zip を指定したディレクトリに展開する」一番ベーシックなユーティリティから押さえましょう。
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public class ZipExtractUtils {
public static void unzip(Path zipFile, Path targetDir) throws IOException {
try (InputStream in = Files.newInputStream(zipFile);
ZipInputStream zipIn = new ZipInputStream(in)) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (entry.isDirectory()) {
// ディレクトリエントリの場合
Path dirPath = targetDir.resolve(entry.getName());
Files.createDirectories(dirPath);
} else {
// ファイルエントリの場合
Path filePath = targetDir.resolve(entry.getName());
Files.createDirectories(filePath.getParent());
try (OutputStream out = Files.newOutputStream(filePath)) {
byte[] buffer = new byte[8192];
int len;
while ((len = zipIn.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
}
zipIn.closeEntry();
}
}
}
}
Java使い方の例です。
import java.nio.file.Path;
public class UnzipExample {
public static void main(String[] args) throws Exception {
Path zip = Path.of("data/reports.zip");
Path outDir = Path.of("out/reports");
ZipExtractUtils.unzip(zip, outDir);
}
}
Javaここで重要なのは、次の三点です。
一つ目は、「ZipInputStream から getNextEntry() で順番にエントリを取り出している」ことです。 ZIP は「エントリ(ファイル・ディレクトリ)単位」で構造を持っているので、このループが解凍の基本形になります。
二つ目は、「ディレクトリエントリとファイルエントリを分けて扱っている」ことです。 entry.isDirectory() を見て、ディレクトリなら createDirectories、ファイルなら親ディレクトリを作ってから中身を書き出します。
三つ目は、「部分読み込みと部分書き込み」を正しく扱っていることです。 zipIn.read(buffer) は、バッファサイズぴったり読めるとは限らず、「今回実際に読めたバイト数」を返します。 必ず out.write(buffer, 0, len) として、有効な範囲だけを書き出す必要があります。
パスの扱いとディレクトリトラバーサル対策
entry.getName() をそのまま信じない、という感覚
ZIP のエントリ名には、../ を含む相対パスが入っている可能性があります。 もし targetDir.resolve(entry.getName()) をそのまま使うと、 ../../etc/passwd のようなパスで、意図しない場所にファイルを書かれてしまう危険があります。
実務で安全にするためには、「展開先が必ず targetDir 配下であること」をチェックするのが重要です。
private static Path safeResolve(Path baseDir, String entryName) throws IOException {
Path target = baseDir.resolve(entryName).normalize();
if (!target.startsWith(baseDir.normalize())) {
throw new IOException("不正なパスです: " + entryName);
}
return target;
}
Javaこれを使って、先ほどのユーティリティを書き換えます。
Path filePath = safeResolve(targetDir, entry.getName());
Javaここで深掘りしたいのは、「normalize と startsWith で“ベースディレクトリからはみ出していないか”を確認する」という感覚です。 これを入れておくだけで、ZIP 解凍によるディレクトリトラバーサル攻撃をかなり防げます。
大きな ZIP を扱うときのストリーム設計
メモリに載せず、エントリごとに流しながら処理する
ZIP は複数ファイルをまとめる形式なので、「中身を全部メモリに載せる」設計は危険です。 先ほどのユーティリティのように、「エントリごとにストリームで読みながら書き出す」形が基本になります。
ここで押さえておきたいのは、「ZipInputStream 自体は一つのストリームで、エントリごとに読み進める」という構造です。 つまり、エントリごとに zipIn.read() を使い、読み終わったら closeEntry() を呼んで次のエントリに進む、という流れを守る必要があります。
この構造を理解しておくと、 「特定の拡張子だけ展開する」「特定のディレクトリ配下だけ展開する」といったフィルタリングも、自然に書けるようになります。
特定ファイルだけを取り出すユーティリティ
「ZIP の中からこのファイルだけ欲しい」という業務パターン
業務では、「ZIP の中にたくさんファイルがあるけれど、そのうち特定の CSV だけ読みたい」という場面もよくあります。
その場合のユーティリティは、次のように書けます。
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public class ZipSelectUtils {
public static byte[] extractEntry(Path zipFile, String entryName) throws IOException {
try (InputStream in = Files.newInputStream(zipFile);
ZipInputStream zipIn = new ZipInputStream(in)) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory() && entry.getName().equals(entryName)) {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int len;
while ((len = zipIn.read(buffer)) != -1) {
bout.write(buffer, 0, len);
}
return bout.toByteArray();
}
zipIn.closeEntry();
}
}
throw new IOException("エントリが見つかりません: " + entryName);
}
}
Java使い方の例です。
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
public class ZipSelectExample {
public static void main(String[] args) throws Exception {
Path zip = Path.of("data/reports.zip");
byte[] bytes = ZipSelectUtils.extractEntry(zip, "report1.csv");
String csv = new String(bytes, StandardCharsets.UTF_8);
System.out.println(csv);
}
}
Javaここで重要なのは、「エントリ名で厳密に一致を取っている」ことと、 「見つからなかった場合に例外を投げている」ことです。
業務的には、「欲しいファイルが ZIP に入っていなかった」ことはエラーなので、 それをきちんと呼び出し側に伝える設計が大事になります。
セキュリティ・運用の観点から見た ZIP解凍
どこに何を展開するかを設計で縛る
ZIP解凍は、「外部から渡された複数ファイルを、サーバー上のどこかに書き出す」操作です。 これはそのまま「外部入力をファイルシステムに反映する」ことなので、セキュリティ的にセンシティブです。
押さえておきたいポイントは次のようなものです。
展開先のベースディレクトリを固定し、その配下にしか書かない。 エントリ名を正規化し、ベースディレクトリからはみ出していないかチェックする。 展開するファイルの種類(拡張子など)を制限することも検討する。 展開したファイルの一覧をログに残し、監査可能にしておく。
これらをユーティリティ側に組み込んでおくと、 業務コードは「ZIP を渡して展開してもらう」だけで済み、 セキュリティの細かい配慮を毎回書かずに済みます。
サイズと件数の上限を意識する
ZIP の中には、非常に多くのファイルや巨大なファイルが入っている可能性があります。 解凍するとディスクが一気に埋まる、という事態もありえます。
ユーティリティ単体ではなく、運用設計とセットで次のようなことを考えておくと安全です。
展開先ディレクトリの容量を監視し、閾値を超えたら警告・停止する。 ZIP のアップロードサイズに上限を設ける。 展開したファイル数や総サイズをログに残し、異常値を検知できるようにする。
まとめ:zip解凍ユーティリティで身につけてほしい感覚
zip解凍ユーティリティは、「複数ファイルをまとめた箱を、安全に開けて中身を取り出す」ための道具です。 その中には、次のような大事な感覚が詰まっています。
ZipInputStream で getNextEntry() を回しながら、エントリごとに処理する構造を理解する。 ディレクトリエントリとファイルエントリを分けて扱い、必要なディレクトリを自動生成する。 部分読み込みと部分書き込みを正しく扱い、データ破壊を防ぐ。 エントリ名を正規化し、ベースディレクトリからはみ出さないようにしてディレクトリトラバーサルを防ぐ。 展開先・サイズ・件数などを運用設計とセットで考え、外部入力としての ZIP を安全に扱う。
もしあなたのプロジェクトで、 ZIP 解凍処理が画面ごと・機能ごとにバラバラに書かれているなら、 それを一度「業務用 zip解凍ユーティリティ」に集約できないか眺めてみてください。
そこから先は、ファイル配布・インポート・バックアップの扱いが、 セキュアで運用しやすい形に、ぐっと整っていきます。
