From 686f59e21bf691a5a9a35bd215ca37198514dc1c Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 6 Jan 2026 21:19:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=20Unzipper=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=20kala-compress=20(#5148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/launch/DefaultLauncher.java | 9 +- .../hmcl/mod/ModpackInstallTask.java | 16 +- .../org/jackhuang/hmcl/util/io/Unzipper.java | 179 +++++++++++------- 3 files changed, 129 insertions(+), 75 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 3f4ba0098..777be6b75 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -397,9 +397,12 @@ public class DefaultLauncher extends Launcher { for (Library library : version.getLibraries()) if (library.isNative()) new Unzipper(repository.getLibraryFile(version, library), destination) - .setFilter((zipEntry, isDirectory, destFile, path) -> { - if (!isDirectory && Files.isRegularFile(destFile) && Files.size(destFile) == Files.size(zipEntry)) + .setFilter((zipEntry, destFile, relativePath) -> { + if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink() + && Files.isRegularFile(destFile) + && zipEntry.getSize() == Files.size(destFile)) { return false; + } String ext = FileUtils.getExtension(destFile); if (ext.equals("sha1") || ext.equals("git")) return false; @@ -411,7 +414,7 @@ public class DefaultLauncher extends Launcher { return false; } - return library.getExtract().shouldExtract(path); + return library.getExtract().shouldExtract(relativePath); }) .setReplaceExistentFile(false).unzip(); } catch (IOException e) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java index b23606db6..5e49194bc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModpackInstallTask.java @@ -73,22 +73,22 @@ public class ModpackInstallTask extends Task { .setTerminateIfSubDirectoryNotExists() .setReplaceExistentFile(true) .setEncoding(charset) - .setFilter((destPath, isDirectory, zipEntry, entryPath) -> { - if (isDirectory) return true; - if (!callback.test(entryPath)) return false; - entries.add(entryPath); + .setFilter((zipEntry, destFile, relativePath) -> { + if (zipEntry.isDirectory()) return true; + if (!callback.test(relativePath)) return false; + entries.add(relativePath); - if (!files.containsKey(entryPath)) { + if (!files.containsKey(relativePath)) { // If old modpack does not have this entry, add this entry or override the file that user added. return true; - } else if (!Files.exists(destPath)) { + } else if (!Files.exists(destFile)) { // If both old and new modpacks have this entry, but the file is deleted by user, leave it missing. return false; } else { // If both old and new modpacks have this entry, and user has modified this file, // we will not replace it since this modified file is what user expects. - String fileHash = DigestUtils.digestToString("SHA-1", destPath); - String oldHash = files.get(entryPath).getHash(); + String fileHash = DigestUtils.digestToString("SHA-1", destFile); + String oldHash = files.get(relativePath).getHash(); return Objects.equals(oldHash, fileHash); } }).unzip(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java index b78fc52b0..05542fdbd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/Unzipper.java @@ -17,57 +17,54 @@ */ package org.jackhuang.hmcl.util.io; +import kala.compress.archivers.zip.ZipArchiveEntry; +import kala.compress.archivers.zip.ZipArchiveReader; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + import java.io.IOException; +import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.*; -import java.nio.file.attribute.BasicFileAttributes; public final class Unzipper { private final Path zipFile, dest; private boolean replaceExistentFile = false; private boolean terminateIfSubDirectoryNotExists = false; private String subDirectory = "/"; - private FileFilter filter = null; + private EntryFilter filter; private Charset encoding = StandardCharsets.UTF_8; - /** - * Decompress the given zip file to a directory. - * - * @param zipFile the input zip file to be uncompressed - * @param destDir the dest directory to hold uncompressed files - */ + /// Decompress the given zip file to a directory. + /// + /// @param zipFile the input zip file to be uncompressed + /// @param destDir the dest directory to hold uncompressed files public Unzipper(Path zipFile, Path destDir) { this.zipFile = zipFile; this.dest = destDir; } - /** - * True if replace the existent files in destination directory, - * otherwise those conflict files will be ignored. - */ + /// True if replace the existent files in destination directory, + /// otherwise those conflict files will be ignored. public Unzipper setReplaceExistentFile(boolean replaceExistentFile) { this.replaceExistentFile = replaceExistentFile; return this; } - /** - * Will be called for every entry in the zip file. - * Callback returns false if you want leave the specific file uncompressed. - */ - public Unzipper setFilter(FileFilter filter) { + /// Will be called for every entry in the zip file. + /// Callback returns false if you want leave the specific file uncompressed. + public Unzipper setFilter(EntryFilter filter) { this.filter = filter; return this; } - /** - * Will only uncompress files in the "subDirectory", their path will be also affected. - * - * For example, if you set subDirectory to /META-INF, files in /META-INF/ will be - * uncompressed to the destination directory without creating META-INF folder. - * - * Default value: "/" - */ + /// Will only uncompress files in the "subDirectory", their path will be also affected. + /// + /// For example, if you set subDirectory to /META-INF, files in /META-INF/ will be + /// uncompressed to the destination directory without creating META-INF folder. + /// + /// Default value: "/" public Unzipper setSubDirectory(String subDirectory) { this.subDirectory = FileUtils.normalizePath(subDirectory); return this; @@ -83,53 +80,107 @@ public final class Unzipper { return this; } - /** - * Decompress the given zip file to a directory. - * - * @throws IOException if zip file is malformed or filesystem error. - */ + private ZipArchiveReader openReader() throws IOException { + ZipArchiveReader zipReader = new ZipArchiveReader(Files.newByteChannel(zipFile)); + + Charset suitableEncoding; + try { + if (encoding != StandardCharsets.UTF_8 && CompressingUtils.testEncoding(zipReader, encoding)) { + suitableEncoding = encoding; + } else { + suitableEncoding = CompressingUtils.findSuitableEncoding(zipReader); + if (suitableEncoding == StandardCharsets.UTF_8) + return zipReader; + } + } catch (Throwable e) { + IOUtils.closeQuietly(zipReader, e); + throw e; + } + + zipReader.close(); + return new ZipArchiveReader(Files.newByteChannel(zipFile), suitableEncoding); + } + + /// Decompress the given zip file to a directory. + /// + /// @throws IOException if zip file is malformed or filesystem error. public void unzip() throws IOException { - Files.createDirectories(dest); - try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(encoding).setAutoDetectEncoding(true).build()) { - Path root = fs.getPath(subDirectory); - if (!root.isAbsolute() || (subDirectory.length() > 1 && subDirectory.endsWith("/"))) - throw new IllegalArgumentException("Subdirectory for unzipper must be absolute"); + Path destDir = this.dest.toAbsolutePath().normalize(); + Files.createDirectories(destDir); - if (terminateIfSubDirectoryNotExists && Files.notExists(root)) - return; + CopyOption[] copyOptions = replaceExistentFile + ? new CopyOption[]{StandardCopyOption.REPLACE_EXISTING} + : new CopyOption[]{}; - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, - BasicFileAttributes attrs) throws IOException { - String relativePath = root.relativize(file).toString(); - Path destFile = dest.resolve(relativePath); - if (filter != null && !filter.accept(file, false, destFile, relativePath)) - return FileVisitResult.CONTINUE; - try { - Files.copy(file, destFile, replaceExistentFile ? new CopyOption[]{StandardCopyOption.REPLACE_EXISTING} : new CopyOption[]{}); - } catch (FileAlreadyExistsException e) { + long entryCount = 0L; + try (ZipArchiveReader reader = openReader()) { + String pathPrefix = StringUtils.addSuffix(subDirectory, "/"); + + for (ZipArchiveEntry entry : reader.getEntries()) { + String normalizedPath = FileUtils.normalizePath(entry.getName()); + if (!normalizedPath.startsWith(pathPrefix)) { + continue; + } + + String relativePath = normalizedPath.substring(pathPrefix.length()); + Path destFile = destDir.resolve(relativePath).toAbsolutePath().normalize(); + if (!destFile.startsWith(destDir)) { + throw new IOException("Zip entry is trying to write outside of the destination directory: " + entry.getName()); + } + + if (filter != null && !filter.accept(entry, destFile, relativePath)) { + continue; + } + + entryCount++; + + if (entry.isDirectory()) { + Files.createDirectories(destFile); + } else { + Files.createDirectories(destFile.getParent()); + if (entry.isUnixSymlink()) { + String linkTarget = reader.getUnixSymlink(entry); if (replaceExistentFile) - throw e; - } - return FileVisitResult.CONTINUE; - } + Files.deleteIfExists(destFile); - @Override - public FileVisitResult preVisitDirectory(Path dir, - BasicFileAttributes attrs) throws IOException { - String relativePath = root.relativize(dir).toString(); - Path dirToCreate = dest.resolve(relativePath); - if (filter != null && !filter.accept(dir, true, dirToCreate, relativePath)) - return FileVisitResult.SKIP_SUBTREE; - Files.createDirectories(dirToCreate); - return FileVisitResult.CONTINUE; + Path targetPath; + try { + targetPath = Path.of(linkTarget); + } catch (InvalidPathException e) { + throw new IOException("Zip entry has an invalid symlink target: " + entry.getName(), e); + } + + if (!destFile.getParent().resolve(targetPath).toAbsolutePath().normalize().startsWith(destDir)) { + throw new IOException("Zip entry is trying to create a symlink outside of the destination directory: " + entry.getName()); + } + + try { + Files.createSymbolicLink(destFile, targetPath); + } catch (FileAlreadyExistsException ignored) { + } + } else { + try (InputStream input = reader.getInputStream(entry)) { + Files.copy(input, destFile, copyOptions); + } catch (FileAlreadyExistsException e) { + if (replaceExistentFile) + throw e; + } + + if (entry.getUnixMode() != 0 && OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS) { + Files.setPosixFilePermissions(destFile, FileUtils.parsePosixFilePermission(entry.getUnixMode())); + } + } } - }); + } + + if (entryCount == 0 && !terminateIfSubDirectoryNotExists) { + throw new NoSuchFileException("Subdirectory " + subDirectory + " does not exist in the zip file."); + } } } - public interface FileFilter { - boolean accept(Path zipEntry, boolean isDirectory, Path destFile, String entryPath) throws IOException; + @FunctionalInterface + public interface EntryFilter { + boolean accept(ZipArchiveEntry zipArchiveEntry, Path destFile, String relativePath) throws IOException; } }