ZipUtils 提供常用且穩健的 ZIP 壓縮與解壓功能,支援:
zip4j)InputStream 建立 ZIP 內容FileUtils(檔案輸入輸出輔助)net.lingala.zip4j:zip4j(AES 加密與高階 ZIP 操作)下列範例與說明對應 src/main/java/.../ZipUtils.java 中的方法。
ZipUtils.zip("C:/data/logs", "C:/tmp/logs.zip", progress -> {
System.out.printf("壓縮進度: %.2f%%\n", progress);
});
說明:會遞迴收集資料夾內檔案,保留資料夾結構。若 zipPath 的父目錄不存在,會自動建立。
方法簽章:
public static void zip(String sourcePath, String zipPath, ProgressCallback callback) throws IOException
ZipUtils.zip("C:/data/report", progress -> System.out.println(progress));
說明:便利 overload,會將輸出檔名設為 sourcePath + ".zip"。
方法簽章:
public static void zip(String sourcePath, ProgressCallback callback) throws IOException
ZipUtils.unzip("C:/tmp/logs.zip", "C:/data/logs_unzip", progress -> {
System.out.printf("解壓進度: %.2f%%\n", progress);
});
說明:解壓時會檢查 Zip Slip(避免路徑跳脫)。若遇到不安全的 entry 會拋出 IOException。
方法簽章:
public static void unzip(String zipPath, String destPath, ProgressCallback callback) throws IOException
InputStream zipped = ZipUtils.zipInputStream(originalInputStream, "file.txt");
說明:把單一 InputStream 轉為包含單一 entry 的 ZIP 格式 InputStream,適合記憶體或網路傳輸場景。
方法簽章:
public static InputStream zipInputStream(InputStream inputStream, String zipEntry) throws IOException
ZipUtils.zipWithPassword("C:/data/report", "C:/tmp/report.zip", "MySecretPwd123");
說明:若 password 為空則不啟用加密。此功能依賴 zip4j。
方法簽章:
public static void zipWithPassword(String sourcePath, String zipPath, String password) throws IOException
ZipUtils.unzipWithPassword("C:/tmp/report.zip", "MySecretPwd123", "C:/tmp/unzip");
方法簽章:
public static void unzipWithPassword(String zipPath, String password, String destPath) throws IOException
List<String> sources = Arrays.asList("C:/data/a", "C:/other/b.txt");
ZipUtils.zipMultiple(sources, "C:/tmp/multi.zip", progress -> System.out.println(progress));
說明:會保留來源資料夾的名稱,並以相對基底路徑建立 entry。
方法簽章:
public static void zipMultiple(List<String> sources, String zipPath, ProgressCallback callback) throws IOException
ZipUtils.zipLargeFileStreaming("C:/big/file.dat", "C:/tmp/file.dat.zip", 65536, progress -> {
System.out.printf("流式壓縮進度: %.2f%%\n", progress);
});
說明:僅支援單一檔案,避免一次將整個檔案讀入記憶體,建議 bufferSize 使用 64KB 或更大。
方法簽章:
public static void zipLargeFileStreaming(String sourcePath, String zipPath, int bufferSize, ProgressCallback callback) throws IOException
@FunctionalInterface
public interface ProgressCallback {
void onProgress(double progress); // progress: 0-100
}
Zip Slip 防護
unzip 會檢查解壓後的路徑是否仍位於目標目錄,若發現異常 entry(如 ../)會拋出 IOException。權限與檔案系統
zipPath 及 destPath。大型檔案處理
zipLargeFileStreaming 以避免記憶體耗盡。加密與密碼管理
zipWithPassword 使用 AES 加密,密碼需妥善管理,勿將密碼明文硬編在程式中。例外處理
IOException,呼叫端應當捕捉並處理(例如重試或回滾)。相依性與部署
pom.xml 中加入 zip4j 依賴,且在部署環境中確保該 JAR 可用。這些測試案例展示了以下功能的正確性:
@Test
void testPrivateConstructor() {
Exception exception = assertThrows(IllegalStateException.class, () -> {
Constructor<?> constructor = ZipUtils.class.getDeclaredConstructor();
constructor.setAccessible(true);
try {
constructor.newInstance();
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
} else {
throw new RuntimeException(cause);
}
}
});
assertEquals("This is a utility class and cannot be instantiated",
exception.getMessage());
}
@Test
void testZipSingleFile() throws IOException {
// 建立測試檔案
Path sourceFile = tempDir.resolve("test.txt");
Files.writeString(sourceFile, TEST_CONTENT);
Path zipFile = tempDir.resolve("test.zip");
// 執行壓縮
ZipUtils.zip(sourceFile.toString(), zipFile.toString(),
progress -> progressUpdates.add(progress));
// 驗證
assertTrue(Files.exists(zipFile));
assertTrue(Files.size(zipFile) > 0);
assertFalse(progressUpdates.isEmpty());
assertEquals(100.0, progressUpdates.get(progressUpdates.size() - 1), 0.1);
}
@Test
void testZipDirectory() throws IOException {
// 建立測試目錄結構
Path subDir = tempDir.resolve("subdir");
Files.createDirectory(subDir);
Path file1 = tempDir.resolve("file1.txt");
Path file2 = subDir.resolve("file2.txt");
Files.writeString(file1, "Content 1");
Files.writeString(file2, "Content 2");
Path zipFile = tempDir.resolve("directory.zip");
// 執行壓縮
ZipUtils.zip(tempDir.toString(), zipFile.toString(),
progress -> progressUpdates.add(progress));
// 驗證
assertTrue(Files.exists(zipFile));
assertTrue(Files.size(zipFile) > 0);
assertFalse(progressUpdates.isEmpty());
}
@Test
void testZipSlipProtection() throws IOException {
Path extractDir = tempDir.resolve("extract");
Path zipFile = tempDir.resolve("malicious.zip");
// 建立一個包含惡意路徑的 ZIP 檔案
try (FileOutputStream fos = new FileOutputStream(zipFile.toFile());
ZipOutputStream zos = new ZipOutputStream(fos)) {
ZipEntry entry = new ZipEntry("../../../malicious.txt");
zos.putNextEntry(entry);
zos.write("malicious content".getBytes());
zos.closeEntry();
}
// 驗證解壓縮時會拋出異常
assertThrows(IOException.class, () ->
ZipUtils.unzip(zipFile.toString(), extractDir.toString(), null)
);
}
@Test
void testZipWithPassword_and_unzipWithPassword_success() throws IOException {
Path sourceFile = tempDir.resolve("secure.txt");
Files.writeString(sourceFile, "Secret Data");
Path zipFile = tempDir.resolve("secure.zip");
Path extractDir = tempDir.resolve("secure-extract");
String password = "P@ssw0rd";
// 用密碼壓縮
ZipUtils.zipWithPassword(sourceFile.toString(), zipFile.toString(),
password);
// 用正確密碼解壓
ZipUtils.unzipWithPassword(zipFile.toString(), password,
extractDir.toString());
Path extracted = extractDir.resolve("secure.txt");
assertTrue(Files.exists(extracted));
assertEquals("Secret Data", Files.readString(extracted));
}
工具類別保護測試
單檔案壓縮測試
目錄壓縮測試
安全性測試
密碼保護測試
package tw.lewishome.webapp.base.utility.common;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.apache.commons.lang3.StringUtils;
import lombok.extern.slf4j.Slf4j;
import net.lingala.zip4j.ZipFile;
import net.lingala.zip4j.model.ZipParameters;
import net.lingala.zip4j.model.enums.EncryptionMethod;
import net.lingala.zip4j.model.enums.CompressionLevel;
/**
* ZipUtility 提供完整的檔案與資料夾壓縮/解壓縮功能。
* 支援單一檔案、多檔案、整個資料夾的壓縮與解壓縮,並保留目錄結構。
*
* 特點:
* - 支援遞迴處理子資料夾
* - 保留原始目錄結構
* - 支援進度回調
* - 自動處理檔案路徑
* - 防止 Zip Slip 攻擊
*
* @author Lewis
* @version 1.0
*/
@Slf4j
public class ZipUtils {
private static final int BUFFER_SIZE = 8192;
private ZipUtils() {
throw new IllegalStateException("This is a utility class and cannot be instantiated");
}
/**
* 壓縮檔案或資料夾
*
* @param sourcePath 來源檔案或資料夾路徑
* @param zipPath 目標 ZIP 檔案路徑
* @param callback 進度回調(可為 null)
* @throws IOException IOException 如果發生 I/O 錯誤
*/
public static void zip(String sourcePath, String zipPath, ProgressCallback callback) throws IOException {
Path sourceFile = Paths.get(sourcePath).toAbsolutePath().normalize();
Path zipFile = Paths.get(zipPath).toAbsolutePath().normalize();
if (!Files.exists(sourceFile)) {
throw new FileNotFoundException("找不到來源檔案或資料夾: " + sourcePath);
}
// 如果目標 ZIP 檔案的父目錄不存在,則建立它
Files.createDirectories(zipFile.getParent());
try (FileOutputStream fileOutputStream = new FileOutputStream(zipFile.toFile());
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
ZipOutputStream zipOutputStream = new ZipOutputStream(bufferedOutputStream)) {
Path baseDir = Files.isDirectory(sourceFile) ? sourceFile : sourceFile.getParent();
List<Path> listPFilePath = new ArrayList<>();
// 收集所有需要壓縮的檔案
if (Files.isDirectory(sourceFile)) {
Files.walk(sourceFile)
.filter(path -> !Files.isDirectory(path))
.forEach(listPFilePath::add);
} else {
listPFilePath.add(sourceFile);
}
long totalBytes = calculateTotalBytes(listPFilePath);
long processedBytes = 0;
// 處理每個檔案
for (Path file : listPFilePath) {
String entryName = baseDir.relativize(file).toString().replace("\\", "/");
ZipEntry zipEntry = new ZipEntry(entryName);
zipOutputStream.putNextEntry(zipEntry);
try (InputStream inputStream = new BufferedInputStream(Files.newInputStream(file))) {
byte[] buffer = new byte[BUFFER_SIZE];
int len;
while ((len = inputStream.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, len);
processedBytes += len;
if (callback != null) {
callback.onProgress((double) processedBytes / totalBytes * 100);
}
}
}
zipOutputStream.closeEntry();
}
}
}
/**
* 壓縮 For 檔案
*
* @param sourcePath 需要壓縮的檔案 (to sourcePath)
* @throws java.io.IOException java.io.IOException
*/
public static void zip(String sourcePath,ProgressCallback callback) throws IOException {
zip(sourcePath, sourcePath + ".zip",callback);
}
/**
* 解壓縮 ZIP 檔案
*
* @param zipPath ZIP 檔案路徑
* @param destPath 目標解壓縮路徑
* @param callback 進度回調(可為 null)
* @throws IOException IOException 如果發生 I/O 錯誤
*/
public static void unzip(String zipPath, String destPath, ProgressCallback callback) throws IOException {
Path zipFile = Paths.get(zipPath).toAbsolutePath().normalize();
Path destDir = Paths.get(destPath).toAbsolutePath().normalize();
if (!Files.exists(zipFile)) {
throw new FileNotFoundException("找不到 ZIP 檔案: " + zipPath);
}
// 建立目標目錄
Files.createDirectories(destDir);
try (ZipInputStream zipInputStream = new ZipInputStream(
new BufferedInputStream(Files.newInputStream(zipFile)))) {
long totalSize = Files.size(zipFile);
long processedBytes = 0;
ZipEntry zipEntry;
while ((zipEntry = zipInputStream.getNextEntry()) != null) {
// 檢查 Zip Slip 漏洞
Path resolvedPath = destDir.resolve(zipEntry.getName()).normalize();
if (!resolvedPath.startsWith(destDir)) {
throw new IOException("發現不安全的 ZIP 項目: " + zipEntry.getName());
}
if (zipEntry.isDirectory()) {
Files.createDirectories(resolvedPath);
} else {
// 確保父目錄存在
Files.createDirectories(resolvedPath.getParent());
// 寫入檔案內容
try (BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(
Files.newOutputStream(resolvedPath))) {
byte[] buffer = new byte[BUFFER_SIZE];
int len;
while ((len = zipInputStream.read(buffer)) > 0) {
bufferedOutputStream.write(buffer, 0, len);
processedBytes += len;
if (callback != null) {
callback.onProgress((double) processedBytes / totalSize * 100);
}
}
}
}
zipInputStream.closeEntry();
}
}
}
/**
*
* getCompressed.
*
*
* @param inputStream a {@link java.io.InputStream} object
* @param zipEntry a String object
* @return a {@link java.io.InputStream} object
* @throws java.io.IOException if any.
*/
public static InputStream zipInputStream(InputStream inputStream, String zipEntry) throws IOException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream);
if (StringUtils.isBlank(zipEntry)) {
zipOutputStream.putNextEntry(new ZipEntry("zipEntry"));
} else {
zipOutputStream.putNextEntry(new ZipEntry(zipEntry));
}
int count;
byte[] data = new byte[2048];
BufferedInputStream entryBufferedInputStream = new BufferedInputStream(inputStream, 2048);
while ((count = entryBufferedInputStream.read(data, 0, 2048)) != -1) {
zipOutputStream.write(data, 0, count);
}
entryBufferedInputStream.close();
zipOutputStream.closeEntry();
zipOutputStream.close();
return new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
}
/**
* 解壓縮 with Password
*
* @param zipFileInput 需要解壓縮的檔案
* @param password 壓縮的密碼
* @param destinationDirectory 壓縮的 檔案
* @throws IOException IOException
*/
public static void unZip(File zipFileInput, String password, String destinationDirectory) throws IOException {
try (ZipFile zipFile = new ZipFile(zipFileInput, password.toCharArray())) {
zipFile.extractAll(destinationDirectory);
}
}
/**
* 計算所有檔案的總大小
*/
private static long calculateTotalBytes(List<Path> files) throws IOException {
long total = 0;
for (Path file : files) {
total += Files.size(file);
}
return total;
}
/**
* 壓縮多個檔案或資料夾到單一 ZIP 檔案
*
* @param sources 來源檔案或資料夾的路徑列表
* @param zipPath 目標 ZIP 檔案路徑
* @param callback 進度回調(可為 null)
* @throws IOException IOException 如果發生 I/O 錯誤
*/
public static void zipMultiple(List<String> sources, String zipPath, ProgressCallback callback)
throws IOException {
Path zipFile = Paths.get(zipPath).toAbsolutePath().normalize();
// 建立父目錄(如果不存在)
Files.createDirectories(zipFile.getParent());
try (FileOutputStream fileOutputStream = new FileOutputStream(zipFile.toFile());
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
ZipOutputStream zipOutputStream = new ZipOutputStream(bufferedOutputStream)) {
List<Path> listFilePath = new ArrayList<>();
Map<Path, Path> mapFileBase = new HashMap<>();
// 收集所有檔案並記錄其基礎路徑
for (String source : sources) {
Path sourcePath = Paths.get(source).toAbsolutePath().normalize();
if (!Files.exists(sourcePath)) {
log.warn("找不到檔案或資料夾: {}", source);
continue;
}
if (Files.isDirectory(sourcePath)) {
Files.walk(sourcePath)
.filter(path -> !Files.isDirectory(path))
.forEach(path -> {
listFilePath.add(path);
// use the parent of the source folder so the folder name is preserved in the entry
mapFileBase.put(path, sourcePath.getParent());
});
} else {
listFilePath.add(sourcePath);
mapFileBase.put(sourcePath, sourcePath.getParent());
}
}
long totalBytes = calculateTotalBytes(listFilePath);
long processedBytes = 0;
// 處理每個檔案
for (Path file : listFilePath) {
Path baseDir = mapFileBase.get(file);
String entryName = baseDir.relativize(file).toString().replace("\\", "/");
ZipEntry zipEntry = new ZipEntry(entryName);
zipOutputStream.putNextEntry(zipEntry);
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
byte[] buffer = new byte[BUFFER_SIZE];
int len;
while ((len = in.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, len);
processedBytes += len;
if (callback != null) {
callback.onProgress((double) processedBytes / totalBytes * 100);
}
}
}
zipOutputStream.closeEntry();
}
}
}
/**
* 使用密碼壓縮檔案或資料夾(AES 加密)
*
* @param sourcePath 來源檔案或資料夾
* @param zipPath 目標 ZIP 檔案
* @param password 密碼(若為 null 或空字串,則為不加密)
* @throws IOException IOException 如果發生 I/O 錯誤
*/
public static void zipWithPassword(String sourcePath, String zipPath, String password) throws IOException {
Path sourceFile = Paths.get(sourcePath).toAbsolutePath().normalize();
Path zipFile = Paths.get(zipPath).toAbsolutePath().normalize();
if (!Files.exists(sourceFile)) {
throw new FileNotFoundException("找不到來源檔案或資料夾: " + sourcePath);
}
Files.createDirectories(zipFile.getParent());
ZipParameters zipParameters = new ZipParameters();
zipParameters.setCompressionLevel(CompressionLevel.HIGHER);
if (StringUtils.isNotBlank(password)) {
zipParameters.setEncryptFiles(true);
zipParameters.setEncryptionMethod(EncryptionMethod.AES);
}
try (net.lingala.zip4j.ZipFile newZipFile = new net.lingala.zip4j.ZipFile(zipFile.toFile(),
StringUtils.isBlank(password) ? null : password.toCharArray())) {
if (Files.isDirectory(sourceFile)) {
newZipFile.addFolder(sourceFile.toFile(), zipParameters);
} else {
newZipFile.addFile(sourceFile.toFile(), zipParameters);
}
}
}
/**
* 使用密碼解壓縮 ZIP 檔案(支援 zip4j AES 加密)
*
* @param zipPath ZIP 檔案路徑
* @param password 密碼
* @param destPath 目標解壓資料夾
* @throws IOException IOException 如果發生 I/O 錯誤
*/
public static void unzipWithPassword(String zipPath, String password, String destPath) throws IOException {
Path zipFile = Paths.get(zipPath).toAbsolutePath().normalize();
Path destDir = Paths.get(destPath).toAbsolutePath().normalize();
if (!Files.exists(zipFile)) {
throw new FileNotFoundException("找不到 ZIP 檔案: " + zipPath);
}
Files.createDirectories(destDir);
try (ZipFile newZipFile = new ZipFile(zipFile.toFile(),
StringUtils.isBlank(password) ? null : password.toCharArray())) {
newZipFile.extractAll(destDir.toString());
}
}
/**
* 對單一大型檔案使用流式壓縮,避免一次將整個檔案讀入記憶體。
* 僅支援單一檔案(非資料夾),如需壓縮資料夾請使用 zip() 或 zipMultiple()
*
* @param sourcePath 要壓縮的檔案完整路徑
* @param zipPath 目標 ZIP 檔案
* @param bufferSize 緩衝區大小(bytes),建議 65536(64KB)或更大
* @param callback 進度回調(可為 null)
* @throws IOException IOException 當 I/O 發生錯誤時
*/
public static void zipLargeFileStreaming(String sourcePath, String zipPath, int bufferSize, ProgressCallback callback) throws IOException {
Path sourceFile = Paths.get(sourcePath).toAbsolutePath().normalize();
Path zipFile = Paths.get(zipPath).toAbsolutePath().normalize();
if (!Files.exists(sourceFile) || Files.isDirectory(sourceFile)) {
throw new FileNotFoundException("來源檔案不存在或為資料夾: " + sourcePath);
}
Files.createDirectories(zipFile.getParent());
long totalBytes = Files.size(sourceFile);
long processedBytes = 0L;
try (FileOutputStream fileOutputStream = new FileOutputStream(zipFile.toFile());
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream, bufferSize);
ZipOutputStream zipOutputStream = new ZipOutputStream(bufferedOutputStream);
InputStream inputStream = new BufferedInputStream(Files.newInputStream(sourceFile), bufferSize)) {
ZipEntry entry = new ZipEntry(sourceFile.getFileName().toString());
zipOutputStream.putNextEntry(entry);
byte[] buffer = new byte[bufferSize];
int len;
while ((len = inputStream.read(buffer)) > 0) {
zipOutputStream.write(buffer, 0, len);
processedBytes += len;
if (callback != null) {
callback.onProgress((double) processedBytes / totalBytes * 100);
}
}
zipOutputStream.closeEntry();
}
}
/**
* 進度回調介面
*/
@FunctionalInterface
public interface ProgressCallback {
/**
* 當進度更新時調用
* @param progress 進度百分比 (0-100)
*/
void onProgress(double progress);
}
}
package tw.lewishome.webapp.base.utility.common;
import org.junit.jupiter.api.*;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZipUtility 的單元測試類別
*/
class ZipUtilsTest {
private Path tempDir;
private static final String TEST_CONTENT = "Test Content";
private List<Double> progressUpdates;
@BeforeEach
void setUp() throws IOException {
tempDir = Files.createTempDirectory("zipUtils-test");
progressUpdates = new ArrayList<>();
}
@AfterEach
void tearDown() throws IOException {
// 清理測試目錄
if (tempDir != null) {
Files.walk(tempDir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
}
}
@Test
void testPrivateConstructor() {
Exception exception = assertThrows(IllegalStateException.class, () -> {
java.lang.reflect.Constructor<?> constructor = ZipUtils.class.getDeclaredConstructor();
constructor.setAccessible(true);
try {
constructor.newInstance();
} catch (java.lang.reflect.InvocationTargetException e) {
// rethrow the underlying cause (expected IllegalStateException from the private constructor)
Throwable cause = e.getCause();
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
} else {
throw new RuntimeException(cause);
}
}
});
assertEquals("This is a utility class and cannot be instantiated", exception.getMessage());
}
@Test
void testZipSingleFile() throws IOException {
// 建立測試檔案
Path sourceFile = tempDir.resolve("test.txt");
Files.writeString(sourceFile, TEST_CONTENT);
Path zipFile = tempDir.resolve("test.zip");
// 執行壓縮
ZipUtils.zip(sourceFile.toString(), zipFile.toString(),
progress -> progressUpdates.add(progress));
// 驗證
assertTrue(Files.exists(zipFile));
assertTrue(Files.size(zipFile) > 0);
assertFalse(progressUpdates.isEmpty());
assertEquals(100.0, progressUpdates.get(progressUpdates.size() - 1), 0.1);
}
@Test
void testZipDirectory() throws IOException {
// 建立測試目錄結構
Path subDir = tempDir.resolve("subdir");
Files.createDirectory(subDir);
Path file1 = tempDir.resolve("file1.txt");
Path file2 = subDir.resolve("file2.txt");
Files.writeString(file1, "Content 1");
Files.writeString(file2, "Content 2");
Path zipFile = tempDir.resolve("directory.zip");
// 執行壓縮
ZipUtils.zip(tempDir.toString(), zipFile.toString(),
progress -> progressUpdates.add(progress));
// 驗證
assertTrue(Files.exists(zipFile));
assertTrue(Files.size(zipFile) > 0);
assertFalse(progressUpdates.isEmpty());
}
@Test
void testUnzip() throws IOException {
// 準備測試資料
Path sourceFile = tempDir.resolve("source.txt");
Files.writeString(sourceFile, TEST_CONTENT);
Path zipFile = tempDir.resolve("test.zip");
Path extractDir = tempDir.resolve("extract");
// 先壓縮
ZipUtils.zip(sourceFile.toString(), zipFile.toString(), null);
// 執行解壓縮
ZipUtils.unzip(zipFile.toString(), extractDir.toString(),
progress -> progressUpdates.add(progress));
// 驗證
Path extractedFile = extractDir.resolve("source.txt");
assertTrue(Files.exists(extractedFile));
assertEquals(TEST_CONTENT, Files.readString(extractedFile));
assertFalse(progressUpdates.isEmpty());
}
@Test
void testZipMultiple() throws IOException {
// 建立多個測試檔案
Path file1 = tempDir.resolve("file1.txt");
Path file2 = tempDir.resolve("file2.txt");
Path subDir = tempDir.resolve("subdir");
Files.createDirectory(subDir);
Path file3 = subDir.resolve("file3.txt");
Files.writeString(file1, "Content 1");
Files.writeString(file2, "Content 2");
Files.writeString(file3, "Content 3");
Path zipFile = tempDir.resolve("multiple.zip");
// 執行多檔案壓縮
List<String> sources = Arrays.asList(
file1.toString(),
file2.toString(),
subDir.toString()
);
ZipUtils.zipMultiple(sources, zipFile.toString(),
progress -> progressUpdates.add(progress));
// 驗證
assertTrue(Files.exists(zipFile));
assertTrue(Files.size(zipFile) > 0);
assertFalse(progressUpdates.isEmpty());
// 解壓縮並驗證內容
Path extractDir = tempDir.resolve("extract-multiple");
ZipUtils.unzip(zipFile.toString(), extractDir.toString(), null);
assertTrue(Files.exists(extractDir.resolve("file1.txt")));
assertTrue(Files.exists(extractDir.resolve("file2.txt")));
assertTrue(Files.exists(extractDir.resolve("subdir/file3.txt")));
}
@Test
void testZipSlipProtection() throws IOException {
Path extractDir = tempDir.resolve("extract");
Path zipFile = tempDir.resolve("malicious.zip");
// 建立一個包含惡意路徑的 ZIP 檔案
try (FileOutputStream fos = new FileOutputStream(zipFile.toFile());
ZipOutputStream zos = new ZipOutputStream(fos)) {
ZipEntry entry = new ZipEntry("../../../malicious.txt");
zos.putNextEntry(entry);
zos.write("malicious content".getBytes());
zos.closeEntry();
}
// 驗證解壓縮時會拋出異常
assertThrows(IOException.class, () ->
ZipUtils.unzip(zipFile.toString(), extractDir.toString(), null)
);
}
@Test
void testNonExistentSource() {
Path nonExistentFile = tempDir.resolve("non-existent.txt");
Path zipFile = tempDir.resolve("output.zip");
assertThrows(FileNotFoundException.class, () ->
ZipUtils.zip(nonExistentFile.toString(), zipFile.toString(), null)
);
}
@Test
void testNonExistentZipFile() {
Path nonExistentZip = tempDir.resolve("non-existent.zip");
Path extractDir = tempDir.resolve("extract");
assertThrows(FileNotFoundException.class, () ->
ZipUtils.unzip(nonExistentZip.toString(), extractDir.toString(), null)
);
}
@Test
void testZipWithPassword_and_unzipWithPassword_success() throws IOException {
Path sourceFile = tempDir.resolve("secure.txt");
Files.writeString(sourceFile, "Secret Data");
Path zipFile = tempDir.resolve("secure.zip");
Path extractDir = tempDir.resolve("secure-extract");
String password = "P@ssw0rd";
// 用密碼壓縮
ZipUtils.zipWithPassword(sourceFile.toString(), zipFile.toString(), password);
assertTrue(Files.exists(zipFile));
// 用正確密碼解壓
ZipUtils.unzipWithPassword(zipFile.toString(), password, extractDir.toString());
Path extracted = extractDir.resolve("secure.txt");
assertTrue(Files.exists(extracted));
assertEquals("Secret Data", Files.readString(extracted));
}
@Test
void testUnzipWithPassword_wrongPassword_throws() throws IOException {
Path sourceFile = tempDir.resolve("secure2.txt");
Files.writeString(sourceFile, "Secret2");
Path zipFile = tempDir.resolve("secure2.zip");
Path extractDir = tempDir.resolve("secure2-extract");
String password = "CorrectPass";
String wrongPassword = "WrongPass";
ZipUtils.zipWithPassword(sourceFile.toString(), zipFile.toString(), password);
// 嘗試用錯誤密碼解壓,zip4j 會在內部拋出 ZipException 或 RuntimeException
assertThrows(Exception.class, () ->
ZipUtils.unzipWithPassword(zipFile.toString(), wrongPassword, extractDir.toString())
);
}
@Test
void testZipWithPassword_emptyPassword_behaves_like_unencrypted() throws IOException {
Path sourceFile = tempDir.resolve("noEncrypt.txt");
Files.writeString(sourceFile, "NoEnc");
Path zipFile = tempDir.resolve("noEncrypt.zip");
Path extractDir = tempDir.resolve("noEncrypt-extract");
// 使用空密碼,應等同於不加密
ZipUtils.zipWithPassword(sourceFile.toString(), zipFile.toString(), "");
ZipUtils.unzipWithPassword(zipFile.toString(), "", extractDir.toString());
Path extracted = extractDir.resolve("noEncrypt.txt");
assertTrue(Files.exists(extracted));
assertEquals("NoEnc", Files.readString(extracted));
}
}