blob: 08a91175c1efbde1c4c8bbeda9efb8993e8b24a0 [file]
/*
* Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code 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
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package jdk.jpackage.internal.util;
import static java.util.stream.Collectors.collectingAndThen;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.CopyOption;
import java.nio.file.FileVisitOption;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Stream;
/**
* Group of paths. Each path in the group is assigned a unique id.
*/
public final class PathGroup {
/**
* Creates path group with the initial paths.
*
* @param paths the initial paths
*/
public PathGroup(Map<Object, Path> paths) {
paths.keySet().forEach(Objects::requireNonNull);
paths.values().forEach(Objects::requireNonNull);
entries = new HashMap<>(paths);
}
/**
* Returns a path associated with the given identifier in this path group.
*
* @param id the identifier
* @return the path corresponding to the given identifier in this path group or
* <code>null</code> if there is no such path
*/
public Path getPath(Object id) {
Objects.requireNonNull(id);
return entries.get(id);
}
/**
* Assigns the specified path value to the given identifier in this path group.
* If the given identifier doesn't exist in this path group, it is added,
* otherwise, the current value associated with the identifier is replaced with
* the given path value. If the path value is <code>null</code> the given
* identifier is removed from this path group if it existed; otherwise, no
* action is taken.
*
* @param id the identifier
* @param path the path to associate with the identifier or <code>null</code>
*/
public void setPath(Object id, Path path) {
Objects.requireNonNull(id);
if (path != null) {
entries.put(id, path);
} else {
entries.remove(id);
}
}
/**
* Adds a path associated with the new unique identifier to this path group.
*
* @param path the path to associate the new unique identifier in this path
* group
*/
public void ghostPath(Path path) {
Objects.requireNonNull(path);
setPath(new Object(), path);
}
/**
* Gets all identifiers of this path group.
* <p>
* The order of identifiers in the returned list is undefined.
*
* @return all identifiers of this path group
*/
public Set<Object> keys() {
return entries.keySet();
}
/**
* Gets paths associated with all identifiers in this path group.
* <p>
* The order of paths in the returned list is undefined.
*
* @return paths associated with all identifiers in this path group
*/
public List<Path> paths() {
return entries.values().stream().toList();
}
/**
* Gets root paths in this path group.
* <p>
* If multiple identifiers are associated with the same path value in the group,
* the path value is added to the returned list only once. Paths that are
* descendants of other paths in the group are not added to the returned list.
* <p>
* The order of paths in the returned list is undefined.
*
* @return unique root paths in this path group
*/
public List<Path> roots() {
if (entries.isEmpty()) {
return List.of();
}
// Sort by the number of path components in descending order.
final var sorted = entries.entrySet().stream().map(e -> {
return Map.entry(e.getValue().normalize(), e.getValue());
}).sorted(Comparator.comparingInt(e -> e.getValue().getNameCount() * -1)).distinct().toList();
final var shortestNormalizedPath = sorted.getLast().getKey();
if (shortestNormalizedPath.getNameCount() == 1 && shortestNormalizedPath.getFileName().toString().isEmpty()) {
return List.of(sorted.getLast().getValue());
}
final List<Path> roots = new ArrayList<>();
for (int i = 0; i < sorted.size(); ++i) {
final var path = sorted.get(i).getKey();
boolean pathIsRoot = true;
for (int j = i + 1; j < sorted.size(); ++j) {
final var maybeParent = sorted.get(j).getKey();
if (path.getNameCount() > maybeParent.getNameCount() && path.startsWith(maybeParent)) {
pathIsRoot = false;
break;
}
}
if (pathIsRoot) {
roots.add(sorted.get(i).getValue());
}
}
return roots;
}
/**
* Gets the number of bytes in root paths of this path group. The method sums
* the size of all root path entries in the group. If the path entry is a
* directory it calculates the total size of the files in the directory. If the
* path entry is a file, it takes its size.
*
* @return the total number of bytes in root paths of this path group
* @throws IOException If an I/O error occurs
*/
public long sizeInBytes() throws IOException {
long reply = 0;
final var roots = roots();
try {
for (Path dir : roots.stream().filter(Files::isDirectory).toList()) {
try (Stream<Path> stream = Files.walk(dir)) {
reply += stream.mapToLong(PathGroup::sizeInBytes).sum();
}
}
reply += roots.stream().mapToLong(PathGroup::sizeInBytes).sum();
} catch (UncheckedIOException ex) {
throw ex.getCause();
}
return reply;
}
private static long sizeInBytes(Path path) throws UncheckedIOException {
if (Files.isRegularFile(path)) {
try {
return Files.size(path);
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
} else {
return 0;
}
}
/**
* Creates a copy of this path group with all paths resolved against the given
* root. Taken action is equivalent to creating a copy of this path group and
* calling <code>root.resolve()</code> on every path in the copy.
*
* @param root the root against which to resolve paths
*
* @return a new path group resolved against the given root path
*/
public PathGroup resolveAt(Path root) {
Objects.requireNonNull(root);
return new PathGroup(entries.entrySet().stream()
.collect(toMap(Map.Entry::getKey, e -> root.resolve(e.getValue()))));
}
/**
* Copies files/directories from the locations in the path group into the
* locations of the given path group. For every identifier found in this and the
* given group, copy the associated file or directory from the location
* specified by the path value associated with the identifier in this group into
* the location associated with the identifier in the given group.
*
* @param dst the destination path group
* @throws IOException If an I/O error occurs
*/
public void copy(PathGroup dst, CopyOption ...options) throws IOException {
final var handler = new Copy(false, options);
copy(this, dst, handler, handler.followSymlinks());
}
/**
* Similar to {@link #copy(PathGroup, CopyOption...)} but moves files/directories instead of
* copying.
*
* @param dst the destination path group
* @throws IOException If an I/O error occurs
*/
public void move(PathGroup dst, CopyOption ...options) throws IOException {
final var handler = new Copy(true, options);
copy(this, dst, handler, handler.followSymlinks());
deleteEntries();
}
/**
* Similar to {@link #copy(PathGroup, CopyOption...)} but uses the given handler to transform
* paths instead of coping.
*
* @param dst the destination path group
* @param handler the path transformation handler
* @throws IOException If an I/O error occurs
*/
public void transform(PathGroup dst, TransformHandler handler) throws IOException {
copy(this, dst, handler, false);
}
/**
* Handler of file copying and directory creating.
*
* @see #transform
*/
public static interface TransformHandler {
/**
* Request to copy a file from the given source location into the given
* destination location.
*
* @implNote Default implementation takes no action
*
* @param src the source file location
* @param dst the destination file location
* @throws IOException If an I/O error occurs
*/
default void copyFile(Path src, Path dst) throws IOException {
}
/**
* Request to create a directory at the given location.
*
* @implNote Default implementation takes no action
*
* @param dir the path where the directory is requested to be created
* @throws IOException
*/
default void createDirectory(Path dir) throws IOException {
}
/**
* Request to copy a symbolic link from the given source location into the given
* destination location.
*
* @implNote Default implementation calls {@link #copyFile}.
*
* @param src the source symbolic link location
* @param dst the destination symbolic link location
* @throws IOException If an I/O error occurs
*/
default void copySymbolicLink(Path src, Path dst) throws IOException {
copyFile(src, dst);
}
}
private void deleteEntries() throws IOException {
for (final var file : entries.values()) {
if (Files.isDirectory(file)) {
FileUtils.deleteRecursive(file);
} else {
Files.deleteIfExists(file);
}
}
}
private record CopySpec(Path basepath, Path from, Path to) {
CopySpec {
Objects.requireNonNull(basepath);
Objects.requireNonNull(to);
if (!from.startsWith(basepath)) {
throw new IllegalArgumentException();
}
}
@Override
public int hashCode() {
return Objects.hash(from, to);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if ((obj == null) || (getClass() != obj.getClass())) {
return false;
}
CopySpec other = (CopySpec) obj;
return Objects.equals(from, other.from) && Objects.equals(to, other.to);
}
Path fromNormalized() {
return from().normalize();
}
Path toNormalized() {
return to().normalize();
}
CopySpec(Path from, Path to) {
this(from, from, to);
}
}
private static void copy(PathGroup src, PathGroup dst, TransformHandler handler, boolean followSymlinks) throws IOException {
List<CopySpec> copySpecs = new ArrayList<>();
List<Path> excludePaths = new ArrayList<>();
for (final var e : src.entries.entrySet()) {
final var srcPath = e.getValue();
final var dstPath = dst.entries.get(e.getKey());
if (dstPath != null) {
copySpecs.add(new CopySpec(srcPath, dstPath));
} else {
excludePaths.add(srcPath.normalize());
}
}
copy(copySpecs, excludePaths, handler, followSymlinks);
}
private record Copy(boolean move, boolean followSymlinks, CopyOption ... options) implements TransformHandler {
Copy(boolean move, CopyOption ... options) {
this(move, !Set.of(options).contains(LinkOption.NOFOLLOW_LINKS), options);
}
@Override
public void copyFile(Path src, Path dst) throws IOException {
Files.createDirectories(dst.getParent());
if (move) {
Files.move(src, dst, options);
} else {
Files.copy(src, dst, options);
}
}
@Override
public void createDirectory(Path dir) throws IOException {
Files.createDirectories(dir);
}
}
private static boolean match(Path what, List<Path> paths) {
return paths.stream().anyMatch(what::startsWith);
}
private static void copy(List<CopySpec> copySpecs, List<Path> excludePaths,
TransformHandler handler, boolean followSymlinks) throws IOException {
Objects.requireNonNull(excludePaths);
Objects.requireNonNull(handler);
final var filteredCopySpecs = copySpecs.stream().<CopySpec>mapMulti((copySpec, consumer) -> {
final var src = copySpec.from();
if (!Files.exists(src) || match(src, excludePaths)) {
return;
}
if (Files.isDirectory(copySpec.from())) {
final var dst = copySpec.to;
final var walkMode = followSymlinks ? new FileVisitOption[] { FileVisitOption.FOLLOW_LINKS } : new FileVisitOption[0];
try (final var files = Files.walk(src, walkMode)) {
files.filter(file -> {
return !match(file, excludePaths);
}).map(file -> {
return new CopySpec(src, file, dst.resolve(src.relativize(file)));
}).toList().forEach(consumer::accept);
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
} else {
consumer.accept(copySpec);
}
}).collect(groupingBy(CopySpec::fromNormalized, collectingAndThen(toSet(), copySpecGroup -> {
return copySpecGroup.stream().filter(copySpec -> {
for (final var otherCopySpec : copySpecGroup) {
if (otherCopySpec != copySpec && !otherCopySpec.basepath().equals(copySpec.basepath())
&& otherCopySpec.basepath().startsWith(copySpec.basepath())) {
return false;
}
}
return true;
}).toList();
}))).values().stream().flatMap(Collection::stream).toList();
filteredCopySpecs.stream().collect(toMap(CopySpec::toNormalized, x -> x, (x, y) -> {
throw new IllegalStateException(String.format(
"Duplicate source files [%s] and [%s] for [%s] destination file", x.from(), y.from(), x.to()));
}));
try {
filteredCopySpecs.stream().forEach(copySpec -> {
try {
if (Files.isSymbolicLink(copySpec.from())) {
handler.copySymbolicLink(copySpec.from(), copySpec.to());
} else if (Files.isDirectory(copySpec.from())) {
handler.createDirectory(copySpec.to());
} else {
handler.copyFile(copySpec.from(), copySpec.to());
}
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
});
} catch (UncheckedIOException ex) {
throw ex.getCause();
}
}
private final Map<Object, Path> entries;
}