CodeGym /課程 /JAVA 25 SELF /封存/壓縮:java.util.zip

封存/壓縮:java.util.zip

JAVA 25 SELF
等級 41 , 課堂 4
開放

1. 導言:為什麼在 Java 需要封存與壓縮

在現代,處理封存與壓縮檔案是很常見的任務:備份、檔案交換、記錄(logging)、大型資料的保存。Java 透過套件 java.util.zip 提供了對 ZIP 封存與 GZIP 壓縮檔的標準支援。

Java 能做什麼:

  • 讀取與建立 ZIP 封存(多檔案容器)。
  • 讀取與建立 GZIP 檔(單一檔案壓縮)。
  • 管理封存內容,依樣式過濾檔案。
  • 控制壓縮等級。
  • 在解壓時進行安全性檢查(對抗 zip slipzip bomb)。

2. 主要類別

ZipInputStream 與 ZipOutputStream

這是用於順序讀寫 ZIP 封存的串流類別。何時使用:當需要「即時」讀取或建立封存,而不需要對單個檔案進行隨機存取時。

範例:讀取壓縮檔

import java.io.*;
import java.util.zip.*;

try (ZipInputStream zis = new ZipInputStream(new FileInputStream("archive.zip"))) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        System.out.println("檔案:" + entry.getName());
        // 可以透過 zis.read(...) 讀取 entry 的內容
        zis.closeEntry();
    }
}

範例:建立壓縮檔

import java.io.FileOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("archive.zip"))) {
    ZipEntry entry = new ZipEntry("hello.txt");
    zos.putNextEntry(entry);
    zos.write("你好,壓縮檔!".getBytes());
    zos.closeEntry();
}

ZipFile

用於對 ZIP 內容進行隨機存取的類別:可以快速取得檔案清單、開啟任何指定檔案並讀取其內容。

範例:

import java.util.zip.*;
import java.io.*;

ZipFile zipFile = new ZipFile("archive.zip");
zipFile.stream().forEach(entry -> System.out.println(entry.getName()));

ZipEntry entry = zipFile.getEntry("hello.txt");
try (InputStream is = zipFile.getInputStream(entry)) {
    // 讀取檔案內容
}
zipFile.close();

何時使用 ZipFile

  • 需要快速取得檔案清單、中繼資料、大小、日期等資訊。
  • 需要在不順序遍歷整個封存的情況下讀取個別檔案。

ZipEntry

代表封存內單一檔案或資料夾的物件。包含名稱、大小、日期、旗標、壓縮等級等資訊。

import java.util.zip.ZipEntry;

ZipEntry entry = new ZipEntry("docs/readme.txt");
entry.setComment("檔案說明");
entry.setTime(System.currentTimeMillis());

壓縮等級(Deflater)

在建立封存時可以控制壓縮強度(從 0 — 無壓縮,到 9 — 最高壓縮):

import java.io.FileOutputStream;
import java.util.zip.Deflater;
import java.util.zip.ZipOutputStream;

try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("archive.zip"))) {
    zos.setLevel(Deflater.BEST_COMPRESSION); // 或 0..9
    // ...
}
  • Deflater.NO_COMPRESSION (0)
  • Deflater.BEST_SPEED (1)
  • Deflater.BEST_COMPRESSION (9)
  • Deflater.DEFAULT_COMPRESSION (-1)

規則:等級越高 — 速度越慢,但壓縮率越好。

3. 單一檔案壓縮

GZIP 是用於壓縮單一檔案的格式(不是封存!)。常用於日誌、暫存檔、網路傳輸。

範例:壓縮檔案

import java.util.zip.*;
import java.io.*;

try (GZIPOutputStream gos = new GZIPOutputStream(new FileOutputStream("file.txt.gz"));
     FileInputStream fis = new FileInputStream("file.txt")) {
    fis.transferTo(gos);
}

範例:解壓檔案

import java.util.zip.GZIPInputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

try (GZIPInputStream gis = new GZIPInputStream(new FileInputStream("file.txt.gz"));
     FileOutputStream fos = new FileOutputStream("file.txt")) {
    gis.transferTo(fos);
}

請記住:GZIP 僅處理單一檔案 — 不會保留資料夾結構與其他中繼資訊。相較之下,ZIP 可以一次封裝多個檔案與整個資料夾,並保留其結構與每個項目的資訊。

4. 封裝/解壓目錄,透過 PathMatcher 過濾

將目錄封裝成 ZIP

要封裝包含子資料夾與檔案的資料夾時,可遞迴走訪檔案樹,並以正確的相對路徑將每個檔案加入封存(ZIP 中的分隔符號一律為 "/")。

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

Path sourceDir = Paths.get("myfolder");
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream("archive.zip"))) {
    Files.walk(sourceDir)
        .filter(Files::isRegularFile)
        .forEach(path -> {
            String entryName = sourceDir.relativize(path).toString().replace("\\", "/");
            try (InputStream is = Files.newInputStream(path)) {
                zos.putNextEntry(new ZipEntry(entryName));
                is.transferTo(zos);
                zos.closeEntry();
            } catch (IOException e) { e.printStackTrace(); }
        });
}

將壓縮檔解壓到目錄

import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

try (ZipInputStream zis = new ZipInputStream(new FileInputStream("archive.zip"))) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        Path outPath = Paths.get("output", entry.getName());
        if (entry.isDirectory()) {
            Files.createDirectories(outPath);
        } else {
            Files.createDirectories(outPath.getParent());
            try (OutputStream os = Files.newOutputStream(outPath)) {
                zis.transferTo(os);
            }
        }
        zis.closeEntry();
    }
}

依樣式(PathMatcher)過濾檔案

可在封裝/解壓時依樣式過濾檔案,例如只處理 "*.txt"

import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.PathMatcher;

PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:**/*.txt");
Files.walk(sourceDir)
    .filter(matcher::matches)
    .forEach(/* ... */);

5. 安全性:Zip Slip、zip bomb、路徑正規化檢查

Zip Slip(路徑穿越攻擊)

問題:攻擊者可以建立名稱為 "../../../../etc/passwd" 的檔案並放入封存。若在解壓時未進行檢查,這樣的檔案可能會覆寫系統檔!

解法:在寫入檔案前對路徑做正規化,並確保它沒有超出目標目錄的範圍。

import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

Path targetDir = Paths.get("output").toAbsolutePath();
try (ZipInputStream zis = new ZipInputStream(new FileInputStream("archive.zip"))) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        Path outPath = targetDir.resolve(entry.getName()).normalize();
        if (!outPath.startsWith(targetDir)) {
            throw new IOException("Zip Slip:嘗試寫入目標資料夾之外!");
        }
        if (entry.isDirectory()) {
            Files.createDirectories(outPath);
        } else {
            Files.createDirectories(outPath.getParent());
            try (OutputStream os = Files.newOutputStream(outPath)) {
                zis.transferTo(os);
            }
        }
        zis.closeEntry();
    }
}

Zip bomb(壓縮檔炸彈)

問題:封存內可能包含一個檔案,解壓後佔用數 GB,但封存本身只有幾 KB。這可能會「拖垮」伺服器或磁碟。

解法:限制每個解壓檔案的最大大小與整體解壓總量,一旦超過上限就中止處理。

import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

long maxSize = 100 * 1024 * 1024; // 100 MB
long totalUnzipped = 0;

try (ZipInputStream zis = new ZipInputStream(new FileInputStream("archive.zip"))) {
    ZipEntry entry;
    while ((entry = zis.getNextEntry()) != null) {
        Path outPath = Paths.get("output", entry.getName()).normalize();
        Files.createDirectories(outPath.getParent());

        long written = 0;
        try (OutputStream os = Files.newOutputStream(outPath)) {
            byte[] buf = new byte[8192];
            int len;
            while ((len = zis.read(buf)) > 0) {
                os.write(buf, 0, len);
                written += len;
                totalUnzipped += len;
                if (written > maxSize || totalUnzipped > maxSize) {
                    throw new IOException("Zip bomb detected!");
                }
            }
        }
        zis.closeEntry();
    }
}

6. 實作:帶樣式的「zip/unzip」CLI 工具

我們來撰寫一個支援樣式過濾的簡單主控台工具,用於封裝與解壓檔案。

範例:封裝

// java ZipUtil zip myfolder archive.zip *.txt
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

public static void zip(String sourceDir, String zipFile, String glob) throws IOException {
    Path src = Paths.get(sourceDir);
    PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + glob);
    try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
        Files.walk(src)
            .filter(Files::isRegularFile)
            .filter(matcher::matches)
            .forEach(path -> {
                String entryName = src.relativize(path).toString().replace("\\", "/");
                try (InputStream is = Files.newInputStream(path)) {
                    zos.putNextEntry(new ZipEntry(entryName));
                    is.transferTo(zos);
                    zos.closeEntry();
                } catch (IOException e) { e.printStackTrace(); }
            });
    }
}

範例:具 Zip Slip 防護的解壓

// java ZipUtil unzip archive.zip output
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

public static void unzip(String zipFile, String outDir) throws IOException {
    Path targetDir = Paths.get(outDir).toAbsolutePath();
    try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            Path outPath = targetDir.resolve(entry.getName()).normalize();
            if (!outPath.startsWith(targetDir)) {
                throw new IOException("Zip Slip:嘗試寫入目標資料夾之外!");
            }
            if (entry.isDirectory()) {
                Files.createDirectories(outPath);
            } else {
                Files.createDirectories(outPath.getParent());
                try (OutputStream os = Files.newOutputStream(outPath)) {
                    zis.transferTo(os);
                }
            }
            zis.closeEntry();
        }
    }
}

執行範例:

java ZipUtil zip myfolder archive.zip "*.txt"
java ZipUtil unzip archive.zip output

在第一個範例中,指令 java ZipUtil zip myfolder archive.zip "*.txt" 會將資料夾 myfolder 中所有副檔名為 .txt 的檔案封裝到 archive.zip。在第二個範例中,java ZipUtil unzip archive.zip output 會將封存解壓到 output 資料夾,同時檢查確保沒有任何檔案被寫到目標目錄之外——這就是對 Zip Slip 的防護。

1
問卷/小測驗
IO 優化,等級 41,課堂 4
未開放
IO 優化
IO 優化
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION