iT邦幫忙

0

應用系統建置前準備工具 - ZipUtils 壓縮/解壓工具

  • 分享至 

  • xImage
  •  

ZipUtils 壓縮/解壓工具

概述

ZipUtils 提供常用且穩健的 ZIP 壓縮與解壓功能,支援:

  • 單檔或整個資料夾壓縮(保留目錄結構)
  • 多來源合併壓縮(多檔/多資料夾)
  • 大檔流式壓縮以降低記憶體使用
  • AES 密碼壓縮/解壓(採 zip4j
  • InputStream 建立 ZIP 內容
  • 進度回調(可用於 UI / 日誌)與 Zip Slip 防護

專案相關程式

  • FileUtils(檔案輸入輸出輔助)

第三方元件 (Dependency)

  • net.lingala.zip4j:zip4j(AES 加密與高階 ZIP 操作)
  • org.apache.commons.lang3;

主要功能(方法範例)

下列範例與說明對應 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

壓縮為預設 .zip 檔

ZipUtils.zip("C:/data/report", progress -> System.out.println(progress));

說明:便利 overload,會將輸出檔名設為 sourcePath + ".zip"

方法簽章:

public static void zip(String sourcePath, ProgressCallback callback) throws IOException

解壓 ZIP

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 產生 ZIP InputStream

InputStream zipped = ZipUtils.zipInputStream(originalInputStream, "file.txt");

說明:把單一 InputStream 轉為包含單一 entry 的 ZIP 格式 InputStream,適合記憶體或網路傳輸場景。

方法簽章:

public static InputStream zipInputStream(InputStream inputStream, String zipEntry) throws IOException

使用密碼壓縮(AES)

ZipUtils.zipWithPassword("C:/data/report", "C:/tmp/report.zip", "MySecretPwd123");

說明:若 password 為空則不啟用加密。此功能依賴 zip4j

方法簽章:

public static void zipWithPassword(String sourcePath, String zipPath, String password) throws IOException

解壓帶密碼的 ZIP

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
}

重要注意事項

  1. Zip Slip 防護

    • unzip 會檢查解壓後的路徑是否仍位於目標目錄,若發現異常 entry(如 ../)會拋出 IOException
  2. 權限與檔案系統

    • 請確保程式有權限寫入 zipPathdestPath
  3. 大型檔案處理

    • 若來源檔案非常大,請使用 zipLargeFileStreaming 以避免記憶體耗盡。
  4. 加密與密碼管理

    • zipWithPassword 使用 AES 加密,密碼需妥善管理,勿將密碼明文硬編在程式中。
  5. 例外處理

    • 所有 I/O 發生錯誤時會拋出 IOException,呼叫端應當捕捉並處理(例如重試或回滾)。
  6. 相依性與部署

    • 若使用加密功能,請在 pom.xml 中加入 zip4j 依賴,且在部署環境中確保該 JAR 可用。

    單元測試範例

    這些測試案例展示了以下功能的正確性:

    • 基本的壓縮與解壓功能
    • 多檔案及目錄壓縮
    • 安全性與邊界條件處理
    • 密碼保護功能
    • 進度回調功能

    1. 工具類別保護測試

    @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());
    }
    

    2. 單檔案壓縮測試

    @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);
    }
    

    3. 目錄壓縮測試

    @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());
    }
    

    4. 安全性測試

    @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)
       );
    }
    

    5. 密碼保護測試

    @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));
    }
    

    測試案例說明

    1. 工具類別保護測試

      • 確保工具類別無法被實例化
      • 驗證私有建構子的例外訊息
    2. 單檔案壓縮測試

      • 驗證基本壓縮功能
      • 測試進度回調機制
      • 確認輸出檔案正確性
    3. 目錄壓縮測試

      • 測試複雜目錄結構壓縮
      • 驗證目錄層級保留
      • 確認所有檔案都被包含
    4. 安全性測試

      • 防範 Zip Slip 攻擊
      • 驗證惡意路徑處理
      • 確保安全解壓機制
    5. 密碼保護測試

      • 測試 AES 加密功能
      • 驗證密碼正確性檢查
      • 確認加密檔案的完整性

程式碼 ZipUtils.java

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

單元測試程式碼 ZipUtilsTest.java

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

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言