mirror of
https://github.com/HMCL-dev/HMCL.git
synced 2026-10-16 21:21:33 +08:00
优化 ArchiveFileTree 构建性能 (#6838)
This commit is contained in:
@@ -53,7 +53,6 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
}
|
||||
|
||||
protected final R reader;
|
||||
protected final Dir<E> root = new Dir<>("", "");
|
||||
|
||||
public ArchiveFileTree(R reader) {
|
||||
this.reader = reader;
|
||||
@@ -63,11 +62,10 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
return reader;
|
||||
}
|
||||
|
||||
public Dir<E> getRoot() {
|
||||
return root;
|
||||
}
|
||||
public abstract Dir<E> getRoot();
|
||||
|
||||
public @Nullable E getEntry(@NotNull String entryPath) {
|
||||
Dir<E> root = getRoot();
|
||||
Dir<E> dir = root;
|
||||
if (entryPath.indexOf('/') < 0) {
|
||||
return dir.getFiles().get(entryPath);
|
||||
@@ -91,7 +89,7 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
}
|
||||
|
||||
public @Nullable Dir<E> getDirectory(@NotNull String dirPath) {
|
||||
Dir<E> dir = root;
|
||||
Dir<E> dir = getRoot();
|
||||
if (dirPath.isEmpty()) {
|
||||
return dir;
|
||||
}
|
||||
@@ -106,45 +104,66 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
return dir;
|
||||
}
|
||||
|
||||
protected void addEntry(E entry) throws IOException {
|
||||
String[] path = entry.getName().split("/");
|
||||
List<String> pathList = Arrays.asList(path);
|
||||
|
||||
/// Adds an entry, creating missing parent directories.
|
||||
/// Empty and `.` directory components are skipped. Entries with `..` directory
|
||||
/// components, duplicate files, and file/directory conflicts are skipped with a warning.
|
||||
/// Parent directories created before a skipped entry is detected remain in the tree.
|
||||
///
|
||||
/// @param entry the archive entry to add
|
||||
protected static <E extends ArchiveEntry> void addEntry(Dir<E> root, @NotNull E entry) {
|
||||
String name = entry.getName();
|
||||
Dir<E> dir = root;
|
||||
|
||||
for (int i = 0, end = entry.isDirectory() ? path.length : path.length - 1; i < end; i++) {
|
||||
String item = path[i];
|
||||
if (item.equals("."))
|
||||
int start = 0;
|
||||
while (start < name.length()) {
|
||||
int end = name.indexOf('/', start);
|
||||
boolean isLastPart = end < 0 || end == name.length() - 1;
|
||||
String item = end >= 0 ? name.substring(start, end) : name.substring(start);
|
||||
|
||||
// A final component may be skipped, so the cursor must still reach the end.
|
||||
start = end < 0 ? name.length() : end + 1;
|
||||
|
||||
if (isLastPart && !entry.isDirectory()) {
|
||||
if (dir.getSubDirs().containsKey(item)) {
|
||||
LOG.warning("A file and a directory have the same name: " + name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dir.getFiles().containsKey(item)) {
|
||||
LOG.warning("Duplicate entry: " + entry.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
if (dir.files.isEmpty())
|
||||
dir.files = new HashMap<>();
|
||||
dir.files.put(item, entry);
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.equals(".") || item.isEmpty())
|
||||
continue;
|
||||
if (item.equals("..") || item.isEmpty())
|
||||
throw new IOException("Invalid entry: " + entry.getName());
|
||||
|
||||
if (dir.files.containsKey(item)) {
|
||||
throw new IOException("A file and a directory have the same name: " + entry.getName());
|
||||
if (item.equals("..")) {
|
||||
LOG.warning("Invalid entry name: " + name);
|
||||
return;
|
||||
}
|
||||
|
||||
final int nameEnd = i + 1;
|
||||
dir = dir.subDirs.computeIfAbsent(item, name ->
|
||||
new Dir<>(name, String.join("/", pathList.subList(0, nameEnd))));
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (dir.entry == null)
|
||||
dir.entry = entry;
|
||||
else if (!dir.entry.isDirectory())
|
||||
throw new IOException("A file and a directory have the same name: " + entry.getName());
|
||||
} else {
|
||||
String fileName = path[path.length - 1];
|
||||
|
||||
if (dir.subDirs.containsKey(fileName)) {
|
||||
throw new IOException("A file and a directory have the same name: " + entry.getName());
|
||||
if (dir.getFiles().containsKey(item)) {
|
||||
LOG.warning("A file and a directory have the same name: " + name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dir.files.containsKey(fileName)) {
|
||||
throw new IOException("Duplicate entry: " + entry.getName());
|
||||
}
|
||||
if (dir.subDirs.isEmpty())
|
||||
dir.subDirs = new HashMap<>();
|
||||
|
||||
dir.files.put(fileName, entry);
|
||||
dir = dir.subDirs.computeIfAbsent(item, Dir::new);
|
||||
|
||||
if (isLastPart) {
|
||||
if (dir.entry == null)
|
||||
dir.entry = entry;
|
||||
else if (!dir.entry.isDirectory())
|
||||
LOG.warning("A file and a directory have the same name: " + entry.getName());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,15 +240,13 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
|
||||
public static final class Dir<E extends ArchiveEntry> {
|
||||
private final String name;
|
||||
private final String fullName;
|
||||
private E entry;
|
||||
|
||||
final Map<String, Dir<E>> subDirs = new HashMap<>();
|
||||
final Map<String, E> files = new HashMap<>();
|
||||
Map<String, Dir<E>> subDirs = Map.of();
|
||||
Map<String, E> files = Map.of();
|
||||
|
||||
public Dir(String name, String fullName) {
|
||||
public Dir(String name) {
|
||||
this.name = name;
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public boolean isRoot() {
|
||||
@@ -240,11 +257,6 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Get the normalized full path. Leading `/` and all `.` in the path will be removed.
|
||||
public @NotNull String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public @Nullable E getEntry() {
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.util.tree;
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import kala.compress.archivers.tar.TarArchiveReader;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
@@ -61,31 +62,11 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
|
||||
|
||||
private final Path tempFile;
|
||||
private final Thread shutdownHook;
|
||||
private @Nullable Dir<TarArchiveEntry> root;
|
||||
|
||||
public TarFileTree(TarArchiveReader file, Path tempFile) throws IOException {
|
||||
public TarFileTree(TarArchiveReader file, Path tempFile) {
|
||||
super(file);
|
||||
this.tempFile = tempFile;
|
||||
try {
|
||||
for (TarArchiveEntry entry : file.getEntries()) {
|
||||
addEntry(entry);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
try {
|
||||
file.close();
|
||||
} catch (Throwable e2) {
|
||||
e.addSuppressed(e2);
|
||||
}
|
||||
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (Throwable e2) {
|
||||
e.addSuppressed(e2);
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (tempFile != null) {
|
||||
this.shutdownHook = new Thread(() -> {
|
||||
@@ -99,6 +80,18 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
|
||||
this.shutdownHook = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dir<TarArchiveEntry> getRoot() {
|
||||
if (root == null) {
|
||||
root = new Dir<>("");
|
||||
for (TarArchiveEntry entry : reader.getEntries()) {
|
||||
addEntry(root, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void copyAttributes(@NotNull TarArchiveEntry source, @NotNull Path targetFile) throws IOException {
|
||||
var fileAttributeView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
|
||||
|
||||
@@ -19,8 +19,8 @@ package org.jackhuang.hmcl.util.tree;
|
||||
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -37,23 +37,26 @@ import java.util.Set;
|
||||
*/
|
||||
public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArchiveEntry> {
|
||||
private final boolean closeReader;
|
||||
private @Nullable Dir<ZipArchiveEntry> root;
|
||||
|
||||
public ZipFileTree(ZipArchiveReader file) throws IOException {
|
||||
public ZipFileTree(ZipArchiveReader file) {
|
||||
this(file, true);
|
||||
}
|
||||
|
||||
public ZipFileTree(ZipArchiveReader file, boolean closeReader) throws IOException {
|
||||
public ZipFileTree(ZipArchiveReader file, boolean closeReader) {
|
||||
super(file);
|
||||
this.closeReader = closeReader;
|
||||
try {
|
||||
for (ZipArchiveEntry zipArchiveEntry : file.getEntries()) {
|
||||
addEntry(zipArchiveEntry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dir<ZipArchiveEntry> getRoot() {
|
||||
if (root == null) {
|
||||
root = new Dir<>("");
|
||||
for (ZipArchiveEntry zipArchiveEntry : reader.getEntries()) {
|
||||
addEntry(root, zipArchiveEntry);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (closeReader)
|
||||
IOUtils.closeQuietly(file, e);
|
||||
throw e;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -103,6 +106,15 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ZipArchiveEntry getEntry(@NotNull String entryPath) {
|
||||
ZipArchiveEntry entry = reader.getEntry(entryPath);
|
||||
if (entry != null)
|
||||
return entry;
|
||||
|
||||
return super.getEntry(entryPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream(ZipArchiveEntry entry) throws IOException {
|
||||
return getReader().getInputStream(entry);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.tree;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import kala.compress.archivers.tar.TarArchiveOutputStream;
|
||||
import kala.compress.archivers.tar.TarArchiveReader;
|
||||
import kala.compress.archivers.tar.TarConstants;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// Verifies archive path traversal and handling of skipped entries.
|
||||
@NotNullByDefault
|
||||
public final class ArchiveFileTreeTest {
|
||||
|
||||
/// Creates an empty TAR tree whose entries can be added directly by each test.
|
||||
private static TarFileTree openTree(Path directory) throws IOException {
|
||||
Path archive = directory.resolve("test.tar");
|
||||
try (var output = new TarArchiveOutputStream(Files.newOutputStream(archive))) {
|
||||
output.finish();
|
||||
}
|
||||
return new TarFileTree(new TarArchiveReader(archive), null);
|
||||
}
|
||||
|
||||
/// Completes traversal when the final directory component is a dot, with or without a slash.
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {".", "./", "a/.", "a/./"})
|
||||
public void testDotDirectoryTerminates(String name, @TempDir Path directory) throws IOException {
|
||||
try (var tree = openTree(directory)) {
|
||||
// A TAR directory flag does not require its name to end in a slash.
|
||||
var entry = new TarArchiveEntry(name, TarConstants.LF_DIR);
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(2), () -> ArchiveFileTree.addEntry(tree.getRoot(), entry));
|
||||
assertTrue(tree.getRoot().getFiles().isEmpty());
|
||||
if (name.startsWith("a/")) {
|
||||
assertEquals(1, tree.getRoot().getSubDirs().size());
|
||||
assertNotNull(tree.getDirectory("a"));
|
||||
} else {
|
||||
assertTrue(tree.getRoot().getSubDirs().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserves lookup of files beneath dot and empty directory components.
|
||||
@Test
|
||||
public void testNormalizedFilePath(@TempDir Path directory) throws IOException {
|
||||
try (var tree = openTree(directory)) {
|
||||
var entry = new TarArchiveEntry("./a//./file.txt");
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), entry);
|
||||
assertSame(entry, tree.getEntry("a/file.txt"));
|
||||
assertTrue(tree.getRoot().getFiles().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips the whole entry when a parent component is a file, retaining the existing file.
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"a/", "a/file.txt"})
|
||||
public void testFileConflictSkipsEntry(String name, @TempDir Path directory) throws IOException {
|
||||
try (var tree = openTree(directory)) {
|
||||
var existing = new TarArchiveEntry("a");
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), existing);
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), new TarArchiveEntry(name));
|
||||
assertSame(existing, tree.getEntry("a"));
|
||||
assertEquals(1, tree.getRoot().getFiles().size());
|
||||
assertTrue(tree.getRoot().getSubDirs().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserves explicit and implicit directories when a file has the same name.
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"a/", "a/file.txt"})
|
||||
public void testDirectoryConflictSkipsFile(String name, @TempDir Path directory) throws IOException {
|
||||
try (var tree = openTree(directory)) {
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), new TarArchiveEntry(name));
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), new TarArchiveEntry("a"));
|
||||
assertNotNull(tree.getDirectory("a"));
|
||||
assertTrue(tree.getRoot().getFiles().isEmpty());
|
||||
if (name.endsWith("file.txt"))
|
||||
assertNotNull(tree.getEntry("a/file.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the first file entry when another file has the same path.
|
||||
@Test
|
||||
public void testDuplicateFileIsSkipped(@TempDir Path directory) throws IOException {
|
||||
try (var tree = openTree(directory)) {
|
||||
var existing = new TarArchiveEntry("file.txt");
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), existing);
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), new TarArchiveEntry("file.txt"));
|
||||
assertSame(existing, tree.getEntry("file.txt"));
|
||||
assertEquals(1, tree.getRoot().getFiles().size());
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips entries containing parent directory components without remapping their files.
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"../file.txt", "a/../file.txt"})
|
||||
public void testParentDirectorySkipsEntry(String name, @TempDir Path directory) throws IOException {
|
||||
try (var tree = openTree(directory)) {
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), new TarArchiveEntry(name));
|
||||
assertNull(tree.getEntry("file.txt"));
|
||||
assertNull(tree.getEntry("a/file.txt"));
|
||||
var valid = new TarArchiveEntry("valid.txt");
|
||||
ArchiveFileTree.addEntry(tree.getRoot(), valid);
|
||||
assertSame(valid, tree.getEntry("valid.txt"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user